Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: GraphQL API (public parts) #888

Open
wants to merge 50 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 47 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
ec6270d
graphql: Some initial work
Kurocon Apr 17, 2023
feb7f3e
Merge branch 747-single-sign-on
Kurocon Apr 17, 2023
96eed3e
graphql: Add all public models to GraphQL for the members module
supertom01 Apr 17, 2023
6b6e103
Merge branch 'main' into 741-graphql-api
Kurocon Jun 15, 2023
36ebbae
graphql: Add schemas for videos, publications, update schema for news…
Kurocon Jun 16, 2023
2ff19c4
graphql: Re-implement members graphql to only include public models a…
Kurocon Jun 16, 2023
908ab1f
graphql: Add queries for Education pages
Kurocon Jun 18, 2023
496972a
Merge remote-tracking branch 'origin/main' into 741-graphql-api
Oct 2, 2023
6267bd9
Add a basic activities query
supertom01 Oct 16, 2023
df88044
Merge branch 'main' into 741-graphql-api
Kurocon Oct 23, 2023
88cdaed
Extend activity graphql endpoint
supertom01 Oct 30, 2023
c5cd1b0
Finalize public details for activities on GraphQL API
supertom01 Nov 13, 2023
8321e13
Merge pull request #817 from Inter-Actief/811-add-the-activities-modu…
supertom01 Nov 13, 2023
9fe5a27
Add descriptions to attributes and their translations
supertom01 Nov 13, 2023
4b24469
Merge origin/741-graph
supertom01 Nov 13, 2023
8e5262d
Merge pull request #818 from Inter-Actief/811-add-the-activities-modu…
supertom01 Nov 13, 2023
4070ecf
Add about pages to the GraphQL API
supertom01 Dec 11, 2023
5f12f07
Add translations
supertom01 Dec 11, 2023
09134c8
Add the company module to the GraphQL API
supertom01 Dec 11, 2023
9ae9d61
Fix identation
supertom01 Dec 11, 2023
5ebfc39
Add translations
supertom01 Dec 11, 2023
bbe0053
Merge pull request #826 from Inter-Actief/companies-graphql
Mihai98924 Feb 5, 2024
edd5448
Merge origin/741 into 822
supertom01 Feb 5, 2024
402b5ec
Merge pull request #824 from Inter-Actief/822-add-the-about-module-to…
Mihai98924 Feb 5, 2024
be20cc0
WIP
supertom01 Mar 18, 2024
2d2d637
Add a query for a single activity given its id
supertom01 Jun 3, 2024
dd5d945
Merge branch 'main'
Kurocon Jun 3, 2024
fc1b23e
Merge branch 'main' into 741-graphql-api
Kurocon Jun 3, 2024
782061d
Add the calendar modules to all the different Event inheriters
supertom01 Jun 10, 2024
93b7157
Add translations
supertom01 Jun 10, 2024
c941e82
Add mutation for the educational bouquet
supertom01 Jun 10, 2024
358b157
Fix merge conflicts
supertom01 Sep 2, 2024
b57476b
Merge pull request #870 from Inter-Actief/825-add-the-calendar-module…
supertom01 Sep 2, 2024
9cebceb
Merge branch '741-graphql-api' into 871-mutation-graphql-educational-…
supertom01 Sep 2, 2024
9e5cff0
Merge pull request #872 from Inter-Actief/871-mutation-graphql-educat…
supertom01 Sep 2, 2024
c25739f
Merge branch 'main' into 741-graphql-api
Kurocon Sep 2, 2024
104ead3
graphql: Fix invalid fields showing up for Event Types, remove some p…
Kurocon Sep 7, 2024
6bb9700
graphql: Fix non-public attachments and photos, inactive banners, and…
Kurocon Sep 16, 2024
686bc58
graphql: Initial framework for testing private models and fields
Kurocon Sep 30, 2024
c618510
Merge branch 'main'
Kurocon Oct 14, 2024
d90e2aa
graphql: Add more tests for activities, add tests for companyEvents
Kurocon Oct 14, 2024
3494332
graphql: Add tests for education and files modules
Kurocon Oct 28, 2024
70bf329
graphql: Add tests for members module, update tests for activities, e…
Kurocon Nov 1, 2024
966174d
Merge branch 'main' into 741-graphql-api
Kurocon Dec 9, 2024
908852a
graphql: Add tests for news, publications and videos modules.
Kurocon Dec 9, 2024
df3ed6c
Implement authentication on GraphQL types, on a field level using dec…
supertom01 Dec 9, 2024
a75777a
graphql: Small changes after review
Kurocon Dec 23, 2024
eb56e45
Merge branch 'main' into 741-graphql-api
Kurocon Dec 23, 2024
f683427
Extend documentation and add a list of exempt fields
supertom01 Dec 23, 2024
f971d7b
Merge pull request #916 from Inter-Actief/846-work-on-graphql-authent…
Kurocon Dec 24, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions amelie/about/graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
import graphene
from graphene_django import DjangoObjectType

from amelie.about.models import Page


class PageType(DjangoObjectType):
class Meta:
model = Page
description = "Type definition for a single Page"
fields = ["name_nl", "name_en", "slug_nl", "slug_en", "educational", "content_nl", "content_en", "last_modified"]

name = graphene.String(description=_("Page name"))
slug = graphene.String(description=_("Page slug"))
content = graphene.String(description=_("Page content"))

def resolve_name(obj: Page, info):
return obj.name

def resolve_slug(obj: Page, info):
return obj.slug

def resolve_content(obj: Page, info):
return obj.content


class AboutQuery(graphene.ObjectType):
page = graphene.Field(PageType, id=graphene.ID(), slug=graphene.String())

def resolve_page(self, info, id=None, slug=None):
if id is not None:
return Page.objects.get(pk=id)
if slug is not None:
return Page.objects.get(Q(slug_en=slug) | Q(slug_nl=slug))
return None


# Exports
GRAPHQL_QUERIES = [AboutQuery]
GRAPHQL_MUTATIONS = []
129 changes: 129 additions & 0 deletions amelie/activities/graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import graphene
from django_filters import FilterSet
from django.utils.translation import gettext_lazy as _
from graphene_django import DjangoObjectType

from amelie.activities.models import Activity, ActivityLabel
from amelie.calendar.graphql import EventType, EVENT_TYPE_BASE_FIELDS
from amelie.graphql.pagination.connection_field import DjangoPaginationConnectionField


class ActivityFilterSet(FilterSet):
class Meta:
model = Activity
fields = {
'summary_nl': ("icontains", "iexact"),
'summary_en': ("icontains", "iexact"),
'begin': ("gt", "lt", "exact"),
'end': ("gt", "lt", "exact"),
'dutch_activity': ("exact", ),
}


class ActivityType(EventType):

class Meta:
model = Activity

# Other fields are inherited from the EventType class
fields = [
"enrollment",
"enrollment_begin",
"enrollment_end",
"maximum",
"waiting_list_locked",
"photos",
"components",
"price",
"can_unenroll",
"image_icon",
"activity_label"
] + EVENT_TYPE_BASE_FIELDS
filterset_class = ActivityFilterSet

absolute_url = graphene.String(description=_('The absolute URL to an activity.'))
random_photo_url = graphene.String(description=_('A URL to a random picture that was made at this activity.'))
photo_url = graphene.String(description=_('A URL that points to the picture gallery for this activity.'))
calendar_url = graphene.String(description=_('A link to the ICS file for this activity.'))
enrollment_open = graphene.Boolean(description=_('Whether people can still enroll for this activity.'))
enrollment_closed = graphene.Boolean(description=_('Whether people can no longer enroll for this activity.'))
can_edit = graphene.Boolean(description=_('Whether the person that is currently signed-in can edit this activity.'))
enrollment_full = graphene.Boolean(description=_('Whether this activity is full.'))
enrollment_almost_full = graphene.Boolean(description=_('Whether this activity is almost full (<= 10 places left).'))
has_enrollment_options = graphene.Boolean(description=_('If there are any options for enrollments.'))
has_costs = graphene.Boolean(description=_('If there are any costs associated with this activity.'))

def resolve_photos(self: Activity, info):
# `info.context` is the Django Request object in Graphene
return self.photos.filter_public(info.context)

def resolve_absolute_url(self: Activity, info):
return self.get_absolute_url()

def resolve_random_photo_url(self: Activity, info):
return self.get_photo_url_random()

def resolve_photo_url(self: Activity, info):
return self.get_photo_url()

def resolve_calendar_url(self: Activity, info):
return self.get_calendar_url()

def resolve_enrollment_open(self: Activity, info):
return self.enrollment_open()

def resolve_enrollment_closed(self: Activity, info):
return self.enrollment_closed()

def resolve_can_edit(self: Activity, info):
if hasattr(info.context.user, 'person'):
return self.can_edit(info.context.user.person)
return False

def resolve_enrollment_full(self: Activity, info):
return self.enrollment_full()

def resolve_enrollment_almost_full(self: Activity, info):
return self.enrollment_almost_full()

def resolve_has_enrollment_option(self: Activity, info):
return self.has_enrollmentoptions()

def resolve_has_costs(self: Activity, info):
return self.has_costs()


class ActivityLabelType(DjangoObjectType):
class Meta:
model = ActivityLabel
fields = [
"name_en",
"name_nl",
"color",
"icon",
"explanation_en",
"explanation_nl",
"active"
]


class ActivitiesQuery(graphene.ObjectType):
activities = DjangoPaginationConnectionField(ActivityType, id=graphene.ID(), organizer=graphene.ID())
activity = graphene.Field(ActivityType, id=graphene.ID())

def resolve_activities(self, info, id=None, organizer=None, *args, **kwargs):
qs = Activity.objects.filter_public(info.context)
if organizer is not None:
qs = qs.filter(organizer__pk=organizer)
if id is not None:
qs = qs.filter(id=id)
return qs

def resolve_activity(self, info, id, *args, **kwargs):
if id is not None:
return Activity.objects.filter_public(info.context).get(pk=id)
return None

# Exports
GRAPHQL_QUERIES = [ActivitiesQuery]
GRAPHQL_MUTATIONS = []
75 changes: 12 additions & 63 deletions amelie/api/test_activitystream.py
Original file line number Diff line number Diff line change
@@ -1,66 +1,15 @@
from __future__ import division, absolute_import, print_function, unicode_literals

import datetime
import random
from decimal import Decimal

from django.contrib.contenttypes.models import ContentType
from django.utils import timezone

from amelie.activities.models import Activity, EnrollmentoptionQuestion, EnrollmentoptionCheckbox, EnrollmentoptionFood, \
Restaurant, ActivityLabel
from amelie.activities.models import Activity, EnrollmentoptionQuestion, EnrollmentoptionCheckbox, EnrollmentoptionFood
from amelie.api.common import strip_markdown
from amelie.members.models import Committee
from amelie.personal_tab.models import Authorization, AuthorizationType
from amelie.tools.templatetags import md
from amelie.tools.tests import APITestCase


def _gen_activities(count):
"""
Generate activities.

Half of the activities is private.

:param int count: Number of activities to generate.
"""

now = timezone.now()
committee = Committee.objects.all()[0]

restaurant = Restaurant(name='Test Restaurant')
restaurant.save()
restaurant.dish_set.create(name='Dish 1', price=33.42)
restaurant.dish_set.create(name='Dish 2', price=13.37)
label = ActivityLabel.objects.create(name_en="Test EN", name_nl="Test NL", color="000000", icon="-", explanation_en="-",
explanation_nl="-")

for i in range(0, count):
public = bool(i % 2)

start = now + datetime.timedelta(days=i, seconds=random.uniform(0, 5*3600))
end = start + datetime.timedelta(seconds=random.uniform(3600, 10*3600))

activity = Activity(begin=start, end=end, summary_nl='Test Activity %i' % i,
summary_en='Test event %i' % i,
organizer=committee, public=public, activity_label=label)
activity.save()

ct_question = ContentType.objects.get_for_model(EnrollmentoptionQuestion)
ct_checkbox = ContentType.objects.get_for_model(EnrollmentoptionCheckbox)
ct_food = ContentType.objects.get_for_model(EnrollmentoptionFood)

EnrollmentoptionQuestion(activity=activity, title='Optional question %i' % i, content_type=ct_question,
required=False).save()
EnrollmentoptionQuestion(activity=activity, title='Mandatory question %i' % i, content_type=ct_question,
required=True).save()
EnrollmentoptionCheckbox(activity=activity, title='Free checkbox %i' % i, content_type=ct_checkbox).save()
EnrollmentoptionCheckbox(activity=activity, title='Paid checkbox %i' % i, content_type=ct_checkbox,
price_extra=42.33).save()
EnrollmentoptionFood(activity=activity, title='Voluntary food %i' % i, content_type=ct_food,
restaurant=restaurant, required=False).save()
EnrollmentoptionFood(activity=activity, title='Mandatory food %i' % i, content_type=ct_food,
restaurant=restaurant, required=False).save()
from amelie.tools.tests import APITestCase, generate_activities


def _activity_data(activity, signedup=False):
Expand Down Expand Up @@ -176,7 +125,7 @@ def test_public(self):
"""
Test the getActivityDetailed() call with public events.
"""
_gen_activities(10)
generate_activities(10)

activities = Activity.objects.filter_public(True)
for activity in activities:
Expand All @@ -191,7 +140,7 @@ def test_private(self):
"""
Test the getActivityDetailed() call with private events.
"""
_gen_activities(10)
generate_activities(10)

activities = Activity.objects.filter_public(False)
for activity in activities:
Expand All @@ -202,7 +151,7 @@ def test_invalid_token(self):
"""
Test the getActivityDetailed() call with private events and an invalid token.
"""
_gen_activities(10)
generate_activities(10)

activities = Activity.objects.filter(public=False)
for activity in activities:
Expand All @@ -225,7 +174,7 @@ def test_public(self):
"""
Test the getActivityStream() call with public events.
"""
_gen_activities(10)
generate_activities(10)

activities = Activity.objects.filter_public(True)[2:4]
start = self.isodate_param(activities[0].begin)
Expand All @@ -243,7 +192,7 @@ def test_private(self):
"""
Test the getActivityStream() call with private events.
"""
_gen_activities(10)
generate_activities(10)

activities = Activity.objects.filter_public(True)[4:8]
start = self.isodate_param(activities[0].begin)
Expand All @@ -261,7 +210,7 @@ def test_invalid_token(self):
"""
Test the getActivityStream() call with an invalid token.
"""
_gen_activities(10)
generate_activities(10)

start = self.isodate_param(timezone.now())
end = self.isodate_param(timezone.now() + datetime.timedelta(days=31))
Expand All @@ -282,7 +231,7 @@ def test_public(self):
"""
Test the getUpcomingActivities() call with public events.
"""
_gen_activities(10)
generate_activities(10)

expected_result = [_activity_data(a) for a in Activity.objects.filter_public(True)[:1]]
self.send_and_compare_request('getUpcomingActivities', [1], None, expected_result)
Expand All @@ -297,7 +246,7 @@ def test_private(self):
"""
Test the getUpcomingActivities() call with private events.
"""
_gen_activities(10)
generate_activities(10)

expected_result = [_activity_data(a) for a in Activity.objects.filter_public(False)[:1]]
self.send_and_compare_request('getUpcomingActivities', [1], self.data['token1'], expected_result)
Expand All @@ -312,7 +261,7 @@ def test_invalid_token(self):
"""
Test the getUpcomingActivities() call with an invalid token.
"""
_gen_activities(10)
generate_activities(10)

expected_result = [_activity_data(a) for a in Activity.objects.filter_public(True)]
self.send_and_compare_request('getUpcomingActivities', [10], 'qNPiKNn3McZIC6fWKE1X', expected_result)
Expand All @@ -323,7 +272,7 @@ class ActivitySignupTest(APITestCase):
def setUp(self):
super(ActivitySignupTest, self).setUp()

_gen_activities(1)
generate_activities(1)
self.activity = Activity.objects.get()
self.activity.enrollment = True
self.activity.enrollment_begin = timezone.now() - datetime.timedelta(hours=1)
Expand Down
67 changes: 67 additions & 0 deletions amelie/calendar/graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import graphene
from graphene_django import DjangoObjectType

from amelie.calendar.models import Event
from django.utils.translation import gettext_lazy as _

from amelie.files.graphql import AttachmentType


# Specified separately from EventType.Meta to be able to use it in the Meta class of subclasses.
EVENT_TYPE_BASE_FIELDS = [
"id",
"begin",
"end",
"entire_day",
"summary_nl",
"summary_en",
"promo_nl",
"promo_en",
"description_nl",
"description_en",
"organizer",
"location",
"public",
"dutch_activity",
]


class EventType(DjangoObjectType):
"""
The event type used for GraphQL operations
"""

class Meta:
# Make sure that this type is not actually being registered. But it can be used by other types as a base class.
skip_registry = True

model = Event
fields = EVENT_TYPE_BASE_FIELDS

attachments = graphene.List(AttachmentType, description="Attachment ids")
summary = graphene.String(description=_('A summary of this activity in the preferred language of this user.'))
description = graphene.String(
description=_('A description of this activity in the preferred language of this user.'))
promo = graphene.String(
description=_('Promotional text for this activity in the preferred language of this user.'))
description_short = graphene.String(description=_('A brief description of this activity (always in english).'))

def resolve_attachments(self: Event, info):
# `info.context` is the Django Request object in Graphene
return self.attachments.filter_public(info.context)

def resolve_summary(self: Event, info):
return self.summary

def resolve_description(self: Event, info):
return self.description

def resolve_promo(self: Event, info):
return self.promo

def resolve_description_short(self: Event, info):
return self.description_short()


GRAPHQL_QUERIES = []
GRAPHQL_MUTATIONS = []
Loading
Loading