Skip to content

Commit

Permalink
Merge pull request #888 from Inter-Actief/741-graphql-api
Browse files Browse the repository at this point in the history
GraphQL API (public parts)
  • Loading branch information
supertom01 authored Mar 3, 2025
2 parents 10de156 + ba2d893 commit 38b9c3b
Show file tree
Hide file tree
Showing 52 changed files with 3,443 additions and 92 deletions.
50 changes: 50 additions & 0 deletions amelie/about/graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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
from amelie.graphql.decorators import check_authorization


@check_authorization
class PageType(DjangoObjectType):
public_fields = [
"name_nl", "name_en", "name",
"slug_nl", "slug_en", "slug",
"content_nl", "content_en", "content",
"educational", "last_modified"
]
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 = []
166 changes: 166 additions & 0 deletions amelie/activities/graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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, EVENT_TYPE_BASE_PUBLIC_FIELDS
from amelie.graphql.decorators import check_authorization
from amelie.graphql.helpers import is_logged_in
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", ),
}


@check_authorization
class ActivityType(EventType):
public_fields = [
"enrollment",
"enrollment_begin",
"enrollment_end",
"maximum",
"waiting_list_locked",
"photos",
"components",
"price",
"can_unenroll",
"image_icon",
"activity_label",
"absolute_url",
"random_photo_url",
"photo_url",
"calendar_url",
"enrollment_open",
"enrollment_closed",
"can_edit",
"enrollment_full",
"enrollment_almost_full",
"has_enrollment_options",
"has_costs"
] + EVENT_TYPE_BASE_PUBLIC_FIELDS

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 is_logged_in(info):
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()


@check_authorization
class ActivityLabelType(DjangoObjectType):
public_fields = [
"name_en",
"name_nl",
"color",
"icon",
"explanation_en",
"explanation_nl",
"active"
]
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 = []
11 changes: 11 additions & 0 deletions amelie/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
from amelie.api.decorators import authentication_optional, authentication_required
from amelie.api.exceptions import NotLoggedInError

from django.http import JsonResponse
from django.views.decorators.csrf import ensure_csrf_cookie
from django.middleware.csrf import get_token
from django.views.decorators.http import require_GET

from modernrpc.core import rpc_method, REQUEST_KEY


Expand Down Expand Up @@ -129,3 +134,9 @@ def get_authenticated_apps(**kwargs) -> Union[List[Dict], None]:
else:
return None

@require_GET
@ensure_csrf_cookie # Ensures the CSRF cookie is set
def get_csrf_token(request):
response = JsonResponse({"message": "CSRF cookie set"})
response["X-CSRFToken"] = get_token(request) # Send CSRF token in headers
return response
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
Loading

0 comments on commit 38b9c3b

Please sign in to comment.