diff --git a/amelie/about/graphql.py b/amelie/about/graphql.py new file mode 100644 index 0000000..4539feb --- /dev/null +++ b/amelie/about/graphql.py @@ -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 = [] diff --git a/amelie/activities/graphql.py b/amelie/activities/graphql.py new file mode 100644 index 0000000..8c10f95 --- /dev/null +++ b/amelie/activities/graphql.py @@ -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 = [] diff --git a/amelie/api/test_activitystream.py b/amelie/api/test_activitystream.py index 2ca26b7..14329e2 100644 --- a/amelie/api/test_activitystream.py +++ b/amelie/api/test_activitystream.py @@ -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): @@ -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: @@ -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: @@ -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: @@ -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) @@ -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) @@ -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)) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/amelie/calendar/graphql.py b/amelie/calendar/graphql.py new file mode 100644 index 0000000..a5125e2 --- /dev/null +++ b/amelie/calendar/graphql.py @@ -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 = [] diff --git a/amelie/companies/graphql.py b/amelie/companies/graphql.py new file mode 100644 index 0000000..5241b5f --- /dev/null +++ b/amelie/companies/graphql.py @@ -0,0 +1,183 @@ +import graphene +from django_filters import FilterSet +from graphene_django import DjangoObjectType + +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from amelie.activities.graphql import ActivityLabelType +from amelie.calendar.graphql import EventType, EVENT_TYPE_BASE_FIELDS +from amelie.companies.models import Company, WebsiteBanner, TelevisionBanner, VivatBanner, CompanyEvent +from amelie.graphql.pagination.connection_field import DjangoPaginationConnectionField + + +class CompanyType(DjangoObjectType): + class Meta: + model = Company + description = "Type definition of a single Company" + filter_fields = { + "name_nl": ("icontains", "iexact"), + "name_en": ("icontains", "iexact"), + } + fields = ["name_nl", "name_en", "slug", "url", "logo", "logo_width", "logo_height", "profile_nl", "profile_en", + "short_description_nl", "short_description_en", "start_date", "end_date", "show_in_app", "app_logo", + "app_logo_height", "app_logo_width"] + + name = graphene.String(description=_("Name of the company")) + profile = graphene.String(description=_("Profile of the company")) + short_description = graphene.String(description=_("Short description of the company")) + + +class CompanyEventFilterSet(FilterSet): + class Meta: + model = CompanyEvent + fields = { + 'summary_nl': ("icontains", "iexact"), + 'summary_en': ("icontains", "iexact"), + 'begin': ("gt", "lt", "exact"), + 'end': ("gt", "lt", "exact"), + 'dutch_activity': ("exact", ), + } + + +class CompanyEventType(EventType): + + class Meta: + model = CompanyEvent + fields = [ + "company", + "company_text", + "company_url" + ] + EVENT_TYPE_BASE_FIELDS + filterset_class = CompanyEventFilterSet + + activity_label = graphene.Field(ActivityLabelType, description=_("The label that belongs to this activity")) + activity_type = graphene.String(description=_("The type of activity")) + calender_url = graphene.String(description=_("The url to the ics for this activity")) + absolute_url = graphene.String(description=_("The absolute URL to this event")) + is_visible = graphene.Boolean(description=_("Whether this event is visible")) + + def resolve_activity_label(self: CompanyEvent, info): + return self.activity_label + + def resolve_activity_type(self: CompanyEvent, info): + return self.activity_type + + def resolve_calendar_url(self: CompanyEvent, info): + return self.get_calendar_url() + + def resolve_absolute_url(self: CompanyEvent, info): + return self.get_absolute_url() + + def resolve_is_visible(self: CompanyEvent, info): + return self.is_visible() + +class WebsiteBannerType(DjangoObjectType): + class Meta: + model = WebsiteBanner + description = "Type definition of a single Website Banner" + filter_fields = { + "name": ("icontains", "iexact"), + } + fields = ["picture", "name", "slug", "active", "url"] + + +class TelevisionBannerType(DjangoObjectType): + class Meta: + model = TelevisionBanner + description = "Type definition of a single Television Banner" + filter_fields = { + "name": ("icontains", "iexact"), + } + fields = ["picture", "name", "slug", "active"] + + +class VivatBannerType(DjangoObjectType): + class Meta: + model = VivatBanner + description = "Type definition of a single Vivat Banner" + filter_fields = { + "name": ("icontains", "iexact"), + } + fields = ["picture", "name", "slug", "active", "url"] + + +class CompaniesQuery(graphene.ObjectType): + company = graphene.Field(CompanyType, id=graphene.ID(), slug=graphene.String()) + companies = DjangoPaginationConnectionField(CompanyType) + + company_event = graphene.Field(CompanyEventType, id=graphene.ID()) + company_events = DjangoPaginationConnectionField(CompanyEventType, id=graphene.ID()) + + website_banner = graphene.Field(WebsiteBannerType, id=graphene.ID(), slug=graphene.String()) + website_banners = DjangoPaginationConnectionField(WebsiteBannerType) + + television_banner = graphene.Field(TelevisionBannerType, id=graphene.ID(), slug=graphene.String()) + television_banners = DjangoPaginationConnectionField(TelevisionBannerType) + + vivat_banner = graphene.Field(VivatBannerType, id=graphene.ID(), slug=graphene.String()) + vivat_banners = DjangoPaginationConnectionField(VivatBannerType) + + def resolve_company(self, info, id=None, slug=None): + if id is not None: + return Company.objects.get(pk=id) + if slug is not None: + return Company.objects.get(slug=slug) + return None + + def resolve_company_event(self, info, id=None): + now = timezone.now() + qs = CompanyEvent.objects.filter_public(info.context) + # If the user is not board, filter only visible activities + if not (hasattr(info.context, 'user') and info.context.user.is_authenticated and info.context.is_board): + qs = qs.filter(visible_from__lt=now, visible_till__gt=now) + + if id is not None: + return qs.get(pk=id) + return None + + def resolve_company_events(self, info, id=None, *args, **kwargs): + now = timezone.now() + qs = CompanyEvent.objects.filter_public(info.context) + # If the user is not board, filter only visible activities + if not (hasattr(info.context, 'user') and info.context.user.is_authenticated and info.context.is_board): + qs = qs.filter(visible_from__lt=now, visible_till__gt=now) + + if id is not None: + qs = qs.filter(pk=id) + return qs + + def resolve_website_banner(self, info, id=None, slug=None): + if id is not None: + return WebsiteBanner.objects.get(pk=id) + if slug is not None: + return WebsiteBanner.objects.get(slug=slug) + return None + + def resolve_website_banners(self, info, *args, **kwargs): + return WebsiteBanner.objects.filter(active=True) + + def resolve_television_banner(self, info, id=None, slug=None): + if id is not None: + return TelevisionBanner.objects.get(pk=id) + if slug is not None: + return TelevisionBanner.objects.get(slug=slug) + return None + + def resolve_television_banners(self, info, *args, **kwargs): + return TelevisionBanner.objects.filter(active=True) + + def resolve_vivat_banner(self, info, id=None, slug=None): + if id is not None: + return VivatBanner.objects.get(pk=id) + if slug is not None: + return VivatBanner.objects.get(slug=slug) + return None + + def resolve_vivat_banners(self, info, *args, **kwargs): + return VivatBanner.objects.filter(active=True) + + +# Exports +GRAPHQL_QUERIES = [CompaniesQuery] +GRAPHQL_MUTATIONS = [] diff --git a/amelie/education/graphql.py b/amelie/education/graphql.py new file mode 100644 index 0000000..faf60de --- /dev/null +++ b/amelie/education/graphql.py @@ -0,0 +1,146 @@ +import graphene +from django_filters import FilterSet + +from graphene_django import DjangoObjectType +from django.utils.translation import gettext_lazy as _ +from graphene_django.forms.mutation import DjangoFormMutation + +from amelie.education.forms import EducationalBouquetForm + +from amelie.activities.graphql import ActivityLabelType +from amelie.calendar.graphql import EventType, EVENT_TYPE_BASE_FIELDS +from amelie.graphql.pagination.connection_field import DjangoPaginationConnectionField + +from amelie.education.models import Category, Page, EducationEvent + + +class EducationPageType(DjangoObjectType): + class Meta: + model = Page + description = "Type definition for a single Education Page" + filter_fields = { + 'id': ("exact",), + 'name_nl': ("icontains", "iexact"), + 'name_en': ("icontains", "iexact"), + } + fields = [ + "id", "name_nl", "name_en", "slug", "category", "content_nl", "content_en", "last_changed", "position" + ] + + # Translated fields in user's preferred language + name = graphene.String(description=_("Page name (localized for user)")) + content = graphene.String(description=_("Page content (localized for user)")) + + def resolve_name(obj: Page, info): + return obj.name + + def resolve_content(obj: Page, info): + return obj.content + + +class EducationPageCategoryType(DjangoObjectType): + class Meta: + model = Category + description = "Type definition for a single education page Category" + filter_fields = { + 'id': ("exact",), + 'name_nl': ("icontains", "iexact"), + 'name_en': ("icontains", "iexact"), + } + fields = ["id", "name_nl", "name_en", "page_set"] + + # Translated fields in user's preferred language + name = graphene.String(description=_("Category name (localized for user)")) + + def resolve_name(obj: Category, info): + return obj.name + + +class EducationEventFilterSet(FilterSet): + class Meta: + model = EducationEvent + fields = { + 'summary_nl': ("icontains", "iexact"), + 'summary_en': ("icontains", "iexact"), + 'begin': ("gt", "lt", "exact"), + 'end': ("gt", "lt", "exact"), + 'dutch_activity': ("exact",), + } + + +class EducationEventType(EventType): + + class Meta: + model = EducationEvent + + fields = [ + "education_organizer" + ] + EVENT_TYPE_BASE_FIELDS + + filterset_class = EducationEventFilterSet + + activity_label = graphene.Field(ActivityLabelType) + activity_type = graphene.String(description="The type of event") + absolute_url = graphene.String(description="The absolute URL to this event") + + def resolve_activity_label(self: EducationEvent, info): + return self.activity_label + + def resolve_activity_type(self: EducationEvent, info): + return self.activity_type + + def resolve_absolute_url(self: EducationEvent, info): + return self.get_absolute_url() + + +class EducationQuery(graphene.ObjectType): + educationpage_category = graphene.Field(EducationPageCategoryType, id=graphene.ID()) + educationpage_categories = DjangoPaginationConnectionField(EducationPageCategoryType) + + educationpage = graphene.Field(EducationPageType, id=graphene.ID(), slug=graphene.String()) + educationpages = DjangoPaginationConnectionField(EducationPageType) + + education_event = graphene.Field(EducationEventType, id=graphene.ID()) + education_events = DjangoPaginationConnectionField(EducationEventType, id=graphene.ID()) + + def resolve_educationpage_category(root, info, id=None): + """Find education page category by ID""" + if id is not None: + return Category.objects.get(pk=id) + return None + + def resolve_educationpage(root, info, id=None, slug=None): + """Find education page by ID or slug""" + if id is not None: + return Page.objects.get(pk=id) + if slug is not None: + return Page.objects.get(slug=slug) + return None + + def resolve_education_event(self, info, id=None): + """Find education event by ID""" + qs = EducationEvent.objects.filter_public(info.context) + if id is not None: + return qs.get(pk=id) + return None + + def resolve_education_events(self, info, id=None, *args, **kwargs): + """Find education event by ID""" + qs = EducationEvent.objects.filter_public(info.context) + if id is not None: + return qs.filter(pk=id) + return qs + + +class EducationalBouquetMutation(DjangoFormMutation): + + class Meta: + form_class = EducationalBouquetForm + +class EducationMutation: + educational_bouquet = EducationalBouquetMutation.Field() + + +# Exports +GRAPHQL_QUERIES = [EducationQuery] +GRAPHQL_MUTATIONS = [EducationMutation] diff --git a/amelie/files/graphql.py b/amelie/files/graphql.py new file mode 100644 index 0000000..de58d4c --- /dev/null +++ b/amelie/files/graphql.py @@ -0,0 +1,39 @@ +import graphene +from graphene_django import DjangoObjectType + +from amelie.files.models import Attachment + + +class AttachmentType(DjangoObjectType): + class Meta: + model = Attachment + fields = [ + "file", + "caption", + "thumb_small", + "thumb_medium", + "thumb_large", + "mimetype", + "owner", + "created", + "modified", + "thumb_small_height", + "thumb_small_width", + "thumb_medium_height", + "thumb_medium_width", + "thumb_large_height", + "thumb_large_width", + "public" + ] + + +class FilesQuery(graphene.ObjectType): + attachment = graphene.Field(AttachmentType, id=graphene.ID()) + + def resolve_attachment(root, info, id): + # `info.context` is the Django Request object in Graphene + return Attachment.objects.filter_public(info.context).get(pk=id) + + +GRAPHQL_QUERIES = [FilesQuery] +GRAPHQL_MUTATIONS = [] diff --git a/amelie/graphql/__init__.py b/amelie/graphql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/amelie/graphql/admin.py b/amelie/graphql/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/amelie/graphql/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/amelie/graphql/apps.py b/amelie/graphql/apps.py new file mode 100644 index 0000000..a68e545 --- /dev/null +++ b/amelie/graphql/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GraphqlConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'amelie.graphql' diff --git a/amelie/graphql/auth.py b/amelie/graphql/auth.py new file mode 100644 index 0000000..2752451 --- /dev/null +++ b/amelie/graphql/auth.py @@ -0,0 +1,31 @@ +import graphql_jwt +from django.contrib.auth import get_user_model + +import graphene +from graphene_django import DjangoObjectType + + +class UserType(DjangoObjectType): + class Meta: + model = get_user_model() + description = "Type definition for a single User" + exclude = ("password", ) + + +class AuthenticationQuery(graphene.ObjectType): + me = graphene.Field( + UserType, + description="Information about the currently logged in user" + ) + + def resolve_me(self, info): + user = info.context.user + if user.is_anonymous: + return None + return user + + +class AuthenticationMutation(graphene.ObjectType): + verify_token = graphql_jwt.Verify.Field( + description="Check if an authentication token is valid" + ) diff --git a/amelie/graphql/decorators.py b/amelie/graphql/decorators.py new file mode 100644 index 0000000..2d192b1 --- /dev/null +++ b/amelie/graphql/decorators.py @@ -0,0 +1,125 @@ +from graphql import GraphQLError + +from graphql_jwt.decorators import user_passes_test, login_required +from graphql_jwt.exceptions import PermissionDenied + + +def _get_attribute(obj, dotted_path): + value = obj + for key in dotted_path.split('.'): + if isinstance(value, list): + value = value[int(key)] + elif isinstance(value, dict): + value = value[key] + else: + value = getattr(value, key) + return value + + +def allow_only_self_or_board(identity): + def wrapper_allow_only_self_or_board(func): + def wrapper_args_allow_only_self_or_board(self, info, *args, **kwargs): + user = info.context.user + is_board = hasattr(user, 'person') and hasattr(user.person, 'is_board') and user.person.is_board + is_superuser = hasattr(user, 'is_superuser') and user.is_superuser + if not is_board and not is_superuser and _get_attribute(info, identity) != self.id: + raise GraphQLError("Access denied.") + return func(self, info, *args, **kwargs) + return wrapper_args_allow_only_self_or_board + return wrapper_allow_only_self_or_board + + +AUTHORIZATION_FIELD_TYPES = ["public_fields", "login_fields", "committee_fields", "board_fields", "private_fields"] + +def is_board_or_www(user): + is_board = hasattr(user, 'person') and hasattr(user.person, 'is_board') and user.person.is_board + is_superuser = hasattr(user, 'is_superuser') and user.is_superuser + return is_board or is_superuser + +def committee_required(committees: list): + return user_passes_test(lambda u:is_board_or_www(u) or (hasattr(u, 'person') and hasattr(u.person, 'is_in_committee') and any(u.person.is_in_committee(committee) for committee in committees))) + +def board_required(): + return user_passes_test(lambda u: is_board_or_www(u)) + +def no_access(): + return user_passes_test(lambda u: False) + +def check_authorization(cls): + """ + Enforces authorization checks when this model is queried. + + There are multiple types of fields, each can be defined on the DjangoObjectType: + + * public_fields: Fields that are accessible for people within being signed in. + * login_fields: Fields that are only accessible after being signed in. + * committee_fields: Fields that are only accessible by members of a committee, WWW superusers, and board members. + * allowed_committees: When committee fields are defined, acronyms of visible committees should be passed. + * board_fields: Fields that are only accessible by WWW superusers and board members. + * private_fields: Fields that cannot be queried through the GraphQL API. + * exempt_fields: Fields that are exempt from these checks, + their resolvers should have their own authorization checking. + + An example class would be: + ```python + class FooType(DjangoObjectType): + public_fields = ['id'] + login_fields = ['login'] + committee_fields = ['committee'] + allowed_committees = ['some-committee'] + board_fields = ['board'] + private_fields = ['private'] + exempt_fields = ['exempt'] + + class Meta: + model = Foo + fields = ['id', 'login', 'committee', 'board', 'private', 'exempt'] + + def resolve_exempt(obj: Foo, info): + # Custom authorization checks + return obj + ``` + """ + # Make sure that at least one of the authorization fields is present. + if not any(hasattr(cls, authorization_field) for authorization_field in AUTHORIZATION_FIELD_TYPES): + raise ValueError(f"At least one authorization field type should be defined for a GraphQL type, choose from: {', '.join(AUTHORIZATION_FIELD_TYPES)}") + + public_fields = getattr(cls, "public_fields", []) + login_fields = getattr(cls, "login_fields", []) + committee_fields = getattr(cls, "committee_fields", []) + board_fields = getattr(cls, "board_fields", []) + private_fields = getattr(cls, "private_fields", []) + exempt_fields = getattr(cls, "exempt_fields", []) + + allowed_committees = getattr(cls, "allowed_committees", []) + + # If there are committee fields defined, then the allowed committee list cannot be non-empty + if len(committee_fields) > 0 and len(allowed_committees) == 0: + raise ValueError(f"The following fields are only visible by a committee: \"{','.join(committee_fields)}\", but there are no committees defined that can view this field. Make sure that \"allowed_committees\" has at least a single entry.") + + # Make sure that all the fields in the authorization fields are mutually exclusive. + authorization_fields = [*public_fields, *login_fields, *committee_fields, *board_fields, *private_fields, *exempt_fields] + if len(authorization_fields) != len(set(authorization_fields)): + raise ValueError("Some of the authorization fields have overlapping Django fields. Make sure that they are all mutually exclusive!") + + # Make sure that all the fields that are defined in the fields list are in the authorization fields. + if not all((missing_field := field) in authorization_fields for field in cls._meta.fields): + raise ValueError(f"The field \"{missing_field}\" is defined in the Django fields list, but not in an authorization field list. All the django fields must be present in the authorization fields.") + + # Require a user to be signed in. + for login_field in login_fields: + setattr(cls, f"resolve_{login_field}", login_required(lambda self, info, field=login_field: getattr(self, login_field))) + + # Require a user to be in a committee + for committee_field in committee_fields: + setattr(cls, f"resolve_{committee_field}", committee_required(allowed_committees)(lambda self, info, field=committee_field: getattr(self, committee_field))) + + # Require a user to be in the board + for board_field in board_fields: + setattr(cls, f"resolve_{board_field}", board_required()(lambda self, info, field=board_field: getattr(self, board_field))) + + # No-one can access these fields + for private_field in private_fields: + setattr(cls, f"resolve_{private_field}", no_access()(lambda self, info, field: False)) + + return cls diff --git a/amelie/graphql/filters.py b/amelie/graphql/filters.py new file mode 100644 index 0000000..943087c --- /dev/null +++ b/amelie/graphql/filters.py @@ -0,0 +1,5 @@ +import django_filters + + +class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): + pass diff --git a/amelie/graphql/jwt_handlers.py b/amelie/graphql/jwt_handlers.py new file mode 100644 index 0000000..64fdc6b --- /dev/null +++ b/amelie/graphql/jwt_handlers.py @@ -0,0 +1,17 @@ +from amelie.tools.user import get_user_by_username + + +def allow_none(info, **kwargs): + return False + + +def get_username_from_jwt_payload(payload): + username = payload.get('preferred_username', None) + if username is None: + from graphql_jwt.exceptions import JSONWebTokenError + raise JSONWebTokenError("Invalid payload") + return username + + +def get_user_from_jwt_username(username): + return get_user_by_username(username) diff --git a/amelie/graphql/migrations/__init__.py b/amelie/graphql/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/amelie/graphql/models.py b/amelie/graphql/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/amelie/graphql/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/amelie/graphql/pagination/__init__.py b/amelie/graphql/pagination/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/amelie/graphql/pagination/connection.py b/amelie/graphql/pagination/connection.py new file mode 100644 index 0000000..203e5b4 --- /dev/null +++ b/amelie/graphql/pagination/connection.py @@ -0,0 +1,49 @@ +import re + +from collections import OrderedDict + +from graphene import Connection, List, NonNull, Field +from graphene.relay.connection import ConnectionOptions + +from amelie.graphql.pagination.objects_type import PageInfoExtra + + +# Based on: https://github.com/instruct-br/graphene-django-pagination +class PaginationConnection(Connection): + class Meta: + abstract = True + + @classmethod + def __init_subclass_with_meta__(cls, node=None, name=None, **options): + _meta = ConnectionOptions(cls) + base_name = re.sub("Connection$", "", name or cls.__name__) or node._meta.name # noqa + + if not name: + name = "{}Connection".format(base_name) + + options["name"] = name + _meta.node = node + _meta.fields = OrderedDict( + [ + ( + "page_info", + Field( + PageInfoExtra, + name="pageInfo", + required=True, + description="Pagination data for this connection.", + ), + ), + ( + "results", + Field( + NonNull(List(node)), + description="Contains the nodes in this connection.", + ), + ), + ] + ) + + return super(Connection, cls).__init_subclass_with_meta__( + _meta=_meta, **options + ) diff --git a/amelie/graphql/pagination/connection_field.py b/amelie/graphql/pagination/connection_field.py new file mode 100644 index 0000000..f2dbdc4 --- /dev/null +++ b/amelie/graphql/pagination/connection_field.py @@ -0,0 +1,135 @@ +import re +import math + +from django.core.paginator import Paginator +from django.conf import settings + +from graphene import Int, String +from graphene_django.filter import DjangoFilterConnectionField +from graphene_django.utils import maybe_queryset + +from amelie.graphql.pagination.connection import PaginationConnection +from amelie.graphql.pagination.objects_type import PageInfoExtra + + +# Based on: https://github.com/instruct-br/graphene-django-pagination +class DjangoPaginationConnectionField(DjangoFilterConnectionField): + def __init__( + self, + type, + fields=None, + order_by=None, + extra_filter_meta=None, + filterset_class=None, + *args, + **kwargs + ): + self._type = type + self._fields = fields + self._provided_filterset_class = filterset_class + self._filterset_class = None + self._extra_filter_meta = extra_filter_meta + self._base_args = None + + kwargs.setdefault("limit", Int(settings.GRAPHENE_DEFAULT_LIMIT, description="Query limit")) + kwargs.setdefault("offset", Int(description="Query offset")) + kwargs.setdefault("ordering", String(description="Query order")) + + super(DjangoPaginationConnectionField, self).__init__( + type, + *args, + **kwargs + ) + + @property + def type(self): + + class NodeConnection(PaginationConnection): + total_count = Int() + + class Meta: + node = self._type + name = '{}NodeConnection'.format(self._type._meta.name) + + def resolve_total_count(self, info, **kwargs): + return self.iterable.count() + + return NodeConnection + + @classmethod + def resolve_connection(cls, connection, arguments, iterable, max_limit=None): + iterable = maybe_queryset(iterable) + + _len = len(iterable) + + ordering = arguments.get("ordering") + + if ordering: + iterable = connection_from_list_ordering(iterable, ordering) + + connection = connection_from_list_slice( + iterable, + arguments, + connection_type=connection, + pageinfo_type=PageInfoExtra, + ) + connection.iterable = iterable + connection.length = _len + + return connection + + +def connection_from_list_slice( + list_slice, args=None, connection_type=None, pageinfo_type=None +): + args = args or {} + limit = args.get("limit", None) + offset = args.get("offset", 0) + + if limit is None: + return connection_type( + results=list_slice, + page_info=pageinfo_type( + has_previous_page=False, + has_next_page=False, + page=1, + num_pages=1, + limit=limit, + offset=offset + ) + ) + else: + assert isinstance(limit, int), "Limit must be of type int" + assert limit > 0, "Limit must be positive integer greater than 0" + + paginator = Paginator(list_slice, limit) + _slice = list_slice[offset:(offset+limit)] + + page_num = math.ceil(offset/limit) + 1 + page_num = ( + paginator.num_pages + if page_num > paginator.num_pages + else page_num + ) + page = paginator.page(page_num) + + return connection_type( + results=_slice, + page_info=pageinfo_type( + has_previous_page=page.has_previous(), + has_next_page=page.has_next(), + page=page_num, + num_pages=paginator.num_pages, + limit=limit, + offset=offset + ) + ) + + +def connection_from_list_ordering(items_list, ordering): + field, order = ordering.split(',') + + order = '-' if order == 'desc' else '' + field = re.sub(r'(? }" for list view + field_spec = f"results {{ {field_spec} }}" + self._test_public_model_and_private_field( + query_name="activity", field_name=field_name, field_spec=field_spec, + variables={"id": (self.public_activity.id, "ID")} + ) + + def test_activity_private_attachment(self): + # Test if private activity attachments are hidden in get view + query = "query ($id: ID) { activity(id: $id) { attachments { public }}}" + response = self.query(query, variables={"id": self.public_activity.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'activity', public field 'attachments' returned an error!" + ) + + # Check that all attachments are public, and that the correct amount of attachments are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['activity']['attachments']), + f"Query for 'activity', public field 'attachments' returned a private attachment!") + num_attachments = len(content['data']['activity']['attachments']) + self.assertEqual( + num_attachments, 1, + f"Query for 'activity', public field 'attachments' did not return 1 expected attachment (returned {num_attachments})!" + ) + + def test_activities_private_attachment(self): + # Test if private activity attachments are hidden in list view + query = "query ($id: ID) { activities(id: $id) { results { attachments { public }}}}" + response = self.query(query, variables={"id": self.public_activity.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'activities', public field 'attachments' returned an error!" + ) + + # Check that all attachments are public, and that the correct amount of attachments are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['activities']['results'][0]['attachments']), + f"Query for 'activities', public field 'attachments' returned a private attachment!") + num_attachments = len(content['data']['activities']['results'][0]['attachments']) + self.assertEqual( + num_attachments, 1, + f"Query for 'activities', public field 'attachments' did not return 1 expected attachment (returned {num_attachments})!" + ) + + def test_activity_private_photo(self): + # Test if private activity photos are hidden in get view + query = "query ($id: ID) { activity(id: $id) { photos { public }}}" + response = self.query(query, variables={"id": self.public_activity.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'activity', public field 'photos' returned an error!" + ) + + # Check that all photos are public, and that the correct amount of photos are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['activity']['photos']), + f"Query for 'activity', public field 'photos' returned a private photo!") + num_photos = len(content['data']['activity']['photos']) + self.assertEqual( + num_photos, 1, + f"Query for 'activity', public field 'photos' did not return 1 expected photo (returned {num_photos})!" + ) + + def test_activities_private_photo(self): + # Test if private activity photos are hidden in list view + query = "query ($id: ID) { activities(id: $id) { results { photos { public }}}}" + response = self.query(query, variables={"id": self.public_activity.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'activities', public field 'photos' returned an error!" + ) + + # Check that all photos are public, and that the correct amount of photos are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['activities']['results'][0]['photos']), + f"Query for 'activity', public field 'photos' returned a private photo!") + num_photos = len(content['data']['activities']['results'][0]['photos']) + self.assertEqual( + num_photos, 1, + f"Query for 'activities', public field 'photos' did not return 1 expected photo (returned {num_photos})!" + ) diff --git a/amelie/graphql/tests/test_companies.py b/amelie/graphql/tests/test_companies.py new file mode 100644 index 0000000..911a9b8 --- /dev/null +++ b/amelie/graphql/tests/test_companies.py @@ -0,0 +1,202 @@ +import json +import datetime +import random +from typing import Dict + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone + +from amelie.companies.models import Company, CompanyEvent, WebsiteBanner, VivatBanner, TelevisionBanner +from amelie.files.models import Attachment +from amelie.members.models import Committee +from amelie.graphql.tests import BaseGraphQLPrivateFieldTests + + +def generate_company_events(): + """ + Generate Company Events for testing. + + It will generate 4 events: + - A public event that is visible + - A public event that is not visible + - A private event that is visible + - A private event that is not visible + """ + + now = timezone.now() + committee = Committee.objects.all()[0] + start = now - datetime.timedelta(days=10) + end = start + datetime.timedelta(days=20) + company = Company.objects.create( + name_nl="Bedrijf", name_en="Company", url="https://inter-actief.net", + profile_nl="Een bedrijf", profile_en="A company", start_date=start, end_date=end + ) + + i = 0 + for public in [True, False]: + for visible in [True, False]: + i += 1 + start = now + datetime.timedelta(days=i, seconds=random.uniform(0, 5*3600)) + end = start + datetime.timedelta(seconds=random.uniform(3600, 10*3600)) + + if visible: + visible_from = now - datetime.timedelta(days=5) + visible_till = now + datetime.timedelta(days=5) + else: + visible_from = now - datetime.timedelta(days=15) + visible_till = now - datetime.timedelta(days=5) + + event = CompanyEvent( + begin=start, end=end, summary_nl='Test Event %i' % i, + summary_en='Test event %i' % i, + organizer=committee, public=public, company=company, + visible_from=visible_from, visible_till=visible_till + ) + event.save() + + +class CompaniesGraphQLPrivateFieldTests(BaseGraphQLPrivateFieldTests): + """ + Tests for private fields of models of the Companies app + """ + + def setUp(self): + super(CompaniesGraphQLPrivateFieldTests, self).setUp() + + # Generate company events + generate_company_events() + + # Retrieve those events + now = timezone.now() + self.public_visible_event = CompanyEvent.objects.filter(public=True, visible_from__lt=now, visible_till__gt=now).order_by('-id').first() + self.public_invisible_event = CompanyEvent.objects.filter(public=True, visible_till__lt=now).order_by('-id').first() + self.private_visible_event = CompanyEvent.objects.filter(public=False, visible_from__lt=now, visible_till__gt=now).order_by('-id').first() + self.private_invisible_event = CompanyEvent.objects.filter(public=False, visible_till__lt=now).order_by('-id').first() + + # Add a private and public attachment to the public visible event + self.public_attachment = Attachment( + public=True, file=SimpleUploadedFile("public.txt", b"File Contents") + ) + self.public_attachment.save(create_thumbnails=False) + self.private_attachment = Attachment( + public=False, file=SimpleUploadedFile("private.txt", b"Secret Contents") + ) + self.private_attachment.save(create_thumbnails=False) + + self.public_visible_event.attachments.add(self.public_attachment) + self.public_visible_event.attachments.add(self.private_attachment) + + + COMPANY_EVENT_PRIVATE_FIELDS: Dict[str, str] = { + "visible_from": "visibleFrom", + "visible_till": "visibleTill", + "callback_url": "callbackUrl", + "callback_secret_key": "callbackSecretKey", + "update_count": "updateCount", + + # Reverse foreign keys (Event) + "participation": "participation", + + # organizer private subfields + "organizer.abbreviation": "organizer { abbreviation }", + "organizer.private_email": "organizer { privateEmail }", + "organizer.superuser": "organizer { superuser }", + "organizer.gitlab": "organizer { gitlab }", + "organizer.ledger_account_number": "organizer { ledgerAccountNumber }", + + # organizer.function_set private subfields + "organizer.function_set.note": "organizer { functionSet { note }}", + } + + def test_company_event_private_model(self): + # Test if public, but invisible, and private events cannot be retrieved + self._test_private_model( + query_name="companyEvent", + variables={"id": (self.public_invisible_event.id, "ID")} + ) + self._test_private_model( + query_name="companyEvent", + variables={"id": (self.private_visible_event.id, "ID")} + ) + self._test_private_model( + query_name="companyEvent", + variables={"id": (self.private_invisible_event.id, "ID")} + ) + + def test_company_events_private_model(self): + # Test if public, but invisible, and private events cannot be retrieved via list view + self._test_private_model_list( + query_name="companyEvents", + public_field_spec="results { id }", + variables={"id": (self.public_invisible_event.id, "ID")} + ) + self._test_private_model_list( + query_name="companyEvents", + public_field_spec="results { id }", + variables={"id": (self.private_visible_event.id, "ID")} + ) + self._test_private_model_list( + query_name="companyEvents", + public_field_spec="results { id }", + variables={"id": (self.private_invisible_event.id, "ID")} + ) + + def test_company_event_private_fields(self): + # Test if private fields on public events cannot be retrieved + for field_name, field_spec in self.COMPANY_EVENT_PRIVATE_FIELDS.items(): + self._test_public_model_and_private_field( + query_name="companyEvent", field_name=field_name, field_spec=field_spec, + variables={"id": (self.public_visible_event.id, "ID")}, + ) + + def test_company_events_private_fields(self): + # Test if private fields on public events cannot be retrieved via list view + for field_name, field_spec in self.COMPANY_EVENT_PRIVATE_FIELDS.items(): + # Wrap the field spec in "results { }" for list view + field_spec = f"results {{ {field_spec} }}" + self._test_public_model_and_private_field( + query_name="companyEvents", field_name=field_name, field_spec=field_spec, + variables={"id": (self.public_visible_event.id, "ID")} + ) + + def test_company_event_private_attachment(self): + # Test if private event attachments are hidden in get view + query = "query ($id: ID) { companyEvent(id: $id) { attachments { public }}}" + response = self.query(query, variables={"id": self.public_visible_event.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'companyEvent', public field 'attachments' returned an error!" + ) + + # Check that all attachments are public, and that the correct amount of attachments are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['companyEvent']['attachments']), + f"Query for 'companyEvent', public field 'attachments' returned a private attachment!") + num_attachments = len(content['data']['companyEvent']['attachments']) + self.assertEqual( + num_attachments, 1, + f"Query for 'companyEvent', public field 'attachments' did not return 1 expected attachment (returned {num_attachments})!" + ) + + def test_company_events_private_attachment(self): + # Test if private event attachments are hidden in list view + query = "query ($id: ID) { companyEvents(id: $id) { results { attachments { public }}}}" + response = self.query(query, variables={"id": self.public_visible_event.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'companyEvents', public field 'attachments' returned an error!" + ) + + # Check that all attachments are public, and that the correct amount of attachments are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['companyEvents']['results'][0]['attachments']), + f"Query for 'companyEvents', public field 'attachments' returned a private attachment!") + num_attachments = len(content['data']['companyEvents']['results'][0]['attachments']) + self.assertEqual( + num_attachments, 1, + f"Query for 'companyEvents', public field 'attachments' did not return 1 expected attachment (returned {num_attachments})!" + ) diff --git a/amelie/graphql/tests/test_education.py b/amelie/graphql/tests/test_education.py new file mode 100644 index 0000000..84eb96b --- /dev/null +++ b/amelie/graphql/tests/test_education.py @@ -0,0 +1,161 @@ +import json +import datetime +import random +from typing import Dict + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone + +from amelie.education.models import EducationEvent +from amelie.files.models import Attachment +from amelie.members.models import Committee +from amelie.graphql.tests import BaseGraphQLPrivateFieldTests + + +def generate_education_events(): + """ + Generate Education Events for testing. + + It will generate a public and a private event. + """ + + now = timezone.now() + committee = Committee.objects.all()[0] + + i = 0 + for public in [True, False]: + i += 1 + start = now + datetime.timedelta(days=i, seconds=random.uniform(0, 5*3600)) + end = start + datetime.timedelta(seconds=random.uniform(3600, 10*3600)) + + event = EducationEvent( + begin=start, end=end, summary_nl='Test Event %i' % i, + summary_en='Test event %i' % i, + organizer=committee, public=public, + education_organizer="Education organizer" + ) + event.save() + + +class EducationGraphQLPrivateFieldTests(BaseGraphQLPrivateFieldTests): + """ + Tests for private fields of models of the Education app + """ + + def setUp(self): + super(EducationGraphQLPrivateFieldTests, self).setUp() + + # Generate education events + generate_education_events() + + # Retrieve those events + self.public_event = EducationEvent.objects.filter(public=True).order_by('-id').first() + self.private_event = EducationEvent.objects.filter(public=False).order_by('-id').first() + + # Add a private and public attachment to the public visible event + self.public_attachment = Attachment( + public=True, file=SimpleUploadedFile("public.txt", b"File Contents") + ) + self.public_attachment.save(create_thumbnails=False) + self.private_attachment = Attachment( + public=False, file=SimpleUploadedFile("private.txt", b"Secret Contents") + ) + self.private_attachment.save(create_thumbnails=False) + + self.public_event.attachments.add(self.public_attachment) + self.public_event.attachments.add(self.private_attachment) + + + EDUCATION_EVENT_PRIVATE_FIELDS: Dict[str, str] = { + "callback_url": "callbackUrl", + "callback_secret_key": "callbackSecretKey", + "update_count": "updateCount", + + # Reverse foreign keys (Event) + "participation": "participation", + + # organizer private subfields + "organizer.abbreviation": "organizer { abbreviation }", + "organizer.private_email": "organizer { privateEmail }", + "organizer.superuser": "organizer { superuser }", + "organizer.gitlab": "organizer { gitlab }", + "organizer.ledger_account_number": "organizer { ledgerAccountNumber }", + + # organizer.function_set private subfields + "organizer.function_set.note": "organizer { functionSet { note }}", + } + + def test_education_event_private_model(self): + # Test if private events cannot be retrieved + self._test_private_model( + query_name="educationEvent", + variables={"id": (self.private_event.id, "ID")} + ) + + def test_education_events_private_model(self): + # Test if private events cannot be retrieved via list view + self._test_private_model_list( + query_name="educationEvents", + public_field_spec="results { id }", + variables={"id": (self.private_event.id, "ID")} + ) + + def test_education_event_private_fields(self): + # Test if private fields on public events cannot be retrieved + for field_name, field_spec in self.EDUCATION_EVENT_PRIVATE_FIELDS.items(): + self._test_public_model_and_private_field( + query_name="educationEvent", field_name=field_name, field_spec=field_spec, + variables={"id": (self.public_event.id, "ID")}, + ) + + def test_education_events_private_fields(self): + # Test if private fields on public events cannot be retrieved via list view + for field_name, field_spec in self.EDUCATION_EVENT_PRIVATE_FIELDS.items(): + # Wrap the field spec in "results { }" for list view + field_spec = f"results {{ {field_spec} }}" + self._test_public_model_and_private_field( + query_name="educationEvents", field_name=field_name, field_spec=field_spec, + variables={"id": (self.public_event.id, "ID")} + ) + + def test_education_event_private_attachment(self): + # Test if private event attachments are hidden in get view + query = "query ($id: ID) { educationEvent(id: $id) { attachments { public }}}" + response = self.query(query, variables={"id": self.public_event.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'educationEvent', public field 'attachments' returned an error!" + ) + + # Check that all attachments are public, and that the correct amount of attachments are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['educationEvent']['attachments']), + "Query for 'educationEvent', public field 'attachments' returned a private attachment!") + num_attachments = len(content['data']['educationEvent']['attachments']) + self.assertEqual( + num_attachments, 1, + f"Query for 'educationEvent', public field 'attachments' did not return 1 expected attachment (returned {num_attachments})!" + ) + + def test_education_events_private_attachment(self): + # Test if private event attachments are hidden in list view + query = "query ($id: ID) { educationEvents(id: $id) { results { attachments { public }}}}" + response = self.query(query, variables={"id": self.public_event.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'educationEvents', public field 'attachments' returned an error!" + ) + + # Check that all attachments are public, and that the correct amount of attachments are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['educationEvents']['results'][0]['attachments']), + f"Query for 'educationEvents', public field 'attachments' returned a private attachment!") + num_attachments = len(content['data']['educationEvents']['results'][0]['attachments']) + self.assertEqual( + num_attachments, 1, + f"Query for 'educationEvents', public field 'attachments' did not return 1 expected attachment (returned {num_attachments})!" + ) diff --git a/amelie/graphql/tests/test_files.py b/amelie/graphql/tests/test_files.py new file mode 100644 index 0000000..c2a451b --- /dev/null +++ b/amelie/graphql/tests/test_files.py @@ -0,0 +1,28 @@ +from django.core.files.uploadedfile import SimpleUploadedFile + +from amelie.files.models import Attachment +from amelie.graphql.tests import BaseGraphQLPrivateFieldTests + + +class FilesGraphQLPrivateFieldTests(BaseGraphQLPrivateFieldTests): + """ + Tests for private fields of models of the Files app + """ + + def setUp(self): + super(FilesGraphQLPrivateFieldTests, self).setUp() + + # Create a private attachment + self.private_attachment = Attachment( + public=False, file=SimpleUploadedFile("private.txt", b"Secret Contents") + ) + self.private_attachment.save(create_thumbnails=False) + + + def test_attachment_private_model(self): + # Test if private attachment cannot be retrieved + self._test_private_model( + query_name="attachment", + public_field_spec="public", + variables={"id": (self.private_attachment.id, "ID")} + ) diff --git a/amelie/graphql/tests/test_members.py b/amelie/graphql/tests/test_members.py new file mode 100644 index 0000000..b817b57 --- /dev/null +++ b/amelie/graphql/tests/test_members.py @@ -0,0 +1,201 @@ +import json +import datetime +import random +from typing import Dict + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone + +from amelie.members.models import Committee, CommitteeCategory +from amelie.graphql.tests import BaseGraphQLPrivateFieldTests + + +def generate_committees(): + """ + Generate Committees for testing. + + It will generate a committee category and 4 committees: + - A regular committee without parent + - A regular committee with a parent + - An abolished committee without a parent + - An abolished committee with a parent + """ + + cc = CommitteeCategory.objects.create(name="Committee Category", slug="committee-category") + + now = datetime.date.today() + last_week = now - datetime.timedelta(days=7) + + for abolished in [True, False]: + committee = Committee.objects.create( + name=f"Committee {'Abolished' if abolished else 'Regular'}", + abbreviation=f"c-{'abolished' if abolished else 'regular'}", + category=cc, + slug=f"committee-{'abolished' if abolished else 'regular'}", + email=f"committee-{'abolished' if abolished else 'regular'}@inter-actief.net", + founded=last_week, + abolished=now if abolished else None, + website="https://inter-actief.net", + information_nl="Informatie NL", + information_en="Information En", + private_email=abolished, + superuser=False, + gitlab=False, + logo=SimpleUploadedFile("logo.jpg", b"File Contents"), + group_picture=SimpleUploadedFile("group.jpg", b"File Contents"), + ledger_account_number="1111" + ) + child_committee = Committee.objects.create( + name=f"Child Committee {'Abolished' if abolished else 'Regular'}", + abbreviation=f"cc-{'abolished' if abolished else 'regular'}", + category=cc, + slug=f"child-committee-{'abolished' if abolished else 'regular'}", + email=f"childcommittee-{'abolished' if abolished else 'regular'}@inter-actief.net", + founded=last_week, + abolished=now if abolished else None, + website="https://inter-actief.net", + information_nl="Informatie NL", + information_en="Information En", + private_email=abolished, + superuser=False, + gitlab=False, + logo=SimpleUploadedFile("logo.jpg", b"File Contents"), + group_picture=SimpleUploadedFile("group.jpg", b"File Contents"), + ledger_account_number="1111" + ) + child_committee.parent_committees.add(committee) + + + +class MembersGraphQLPrivateFieldTests(BaseGraphQLPrivateFieldTests): + """ + Tests for private fields of models of the Members app + + Queries: + - committeeCategory + - committeeCategories + - committee + - committees + """ + + def setUp(self): + super(MembersGraphQLPrivateFieldTests, self).setUp() + + # Generate committees + generate_committees() + + # Retrieve those committees + self.category = CommitteeCategory.objects.get(slug="committee-category") + self.regular_committee = Committee.objects.get(abbreviation="c-regular") + self.abolished_committee = Committee.objects.get(abbreviation="c-abolished") + self.regular_child_committee = Committee.objects.get(abbreviation="cc-regular") + self.abolished_child_committee = Committee.objects.get(abbreviation="cc-abolished") + + + MEMBERS_COMITTEE_PRIVATE_FIELDS: Dict[str, str] = { + "abbreviation": "abbreviation", + "private_email": "privateEmail", + "superuser": "superuser", + "gitlab": "gitlab", + "ledger_account_number": "ledgerAccountNumber", + + # function_set private subfields + "function_set.note": "functionSet { note }", + + # parent_committees private subfields + "parent_committees.abbreviation": "parentCommittees { abbreviation }", + "parent_committees.private_email": "parentCommittees { privateEmail }", + "parent_committees.superuser": "parentCommittees { superuser }", + "parent_committees.gitlab": "parentCommittees { gitlab }", + "parent_committees.ledger_account_number": "parentCommittees { ledgerAccountNumber }", + "parent_committees.function_set.note": "parentCommittees { functionSet { note } }", + } + # TODO: email field if private_email is set for committee + + MEMBERS_COMITTEECATEGORY_PRIVATE_FIELDS: Dict[str, str] = { + "committee_set.": "callbackUrl", + "committee_set.abbreviation": "committeeSet { abbreviation }", + "committee_set.private_email": "committeeSet { privateEmail }", + "committee_set.superuser": "committeeSet { superuser }", + "committee_set.gitlab": "committeeSet { gitlab }", + "committee_set.ledger_account_number": "committeeSet { ledgerAccountNumber }", + + # function_set private subfields + "committee_set.function_set.note": "committeeSet { functionSet { note } }", + + # parent_committees private subfields + "committee_set.parent_committees.abbreviation": "committeeSet { parentCommittees { abbreviation } }", + "committee_set.parent_committees.private_email": "committeeSet { parentCommittees { privateEmail } }", + "committee_set.parent_committees.superuser": "committeeSet { parentCommittees { superuser } }", + "committee_set.parent_committees.gitlab": "committeeSet { parentCommittees { gitlab } }", + "committee_set.parent_committees.ledger_account_number": "committeeSet { parentCommittees { ledgerAccountNumber } }", + "committee_set.parent_committees.function_set.note": "committeeSet { parentCommittees { functionSet { note } } }", + } + + def test_committee_private_model(self): + # Test if abolished committees cannot be retrieved + self._test_private_model( + query_name="committee", + variables={"id": (self.abolished_committee.id, "ID")} + ) + self._test_private_model( + query_name="committee", + variables={"id": (self.abolished_child_committee.id, "ID")} + ) + + def test_committees_private_model(self): + # Test if abolished committees cannot be retrieved via list view + self._test_private_model_list( + query_name="committees", + public_field_spec="results { id }", + variables={"id": (self.abolished_committee.id, "ID"), "includeAbolished": (True, "Boolean!")} + ) + self._test_private_model_list( + query_name="committees", + public_field_spec="results { id }", + variables={"id": (self.abolished_child_committee.id, "ID"), "includeAbolished": (True, "Boolean!")} + ) + + def test_committee_private_fields(self): + # Test if private fields on regular committees cannot be retrieved + for field_name, field_spec in self.MEMBERS_COMITTEE_PRIVATE_FIELDS.items(): + self._test_public_model_and_private_field( + query_name="committee", field_name=field_name, field_spec=field_spec, + variables={"id": (self.regular_committee.id, "ID")}, + ) + self._test_public_model_and_private_field( + query_name="committee", field_name=field_name, field_spec=field_spec, + variables={"id": (self.regular_child_committee.id, "ID")}, + ) + + def test_committees_private_fields(self): + # Test if private fields on regular committees cannot be retrieved via list view + for field_name, field_spec in self.MEMBERS_COMITTEE_PRIVATE_FIELDS.items(): + # Wrap the field spec in "results { }" for list view + field_spec = f"results {{ {field_spec} }}" + self._test_public_model_and_private_field( + query_name="committees", field_name=field_name, field_spec=field_spec, + variables={"id": (self.regular_committee.id, "ID"), "includeAbolished": (True, "Boolean!")} + ) + self._test_public_model_and_private_field( + query_name="committees", field_name=field_name, field_spec=field_spec, + variables={"id": (self.regular_child_committee.id, "ID"), "includeAbolished": (True, "Boolean!")} + ) + + def test_committee_category_private_fields(self): + # Test if private fields on committee categories cannot be retrieved + for field_name, field_spec in self.MEMBERS_COMITTEECATEGORY_PRIVATE_FIELDS.items(): + self._test_public_model_and_private_field( + query_name="committeeCategory", field_name=field_name, field_spec=field_spec, + variables={"id": (self.category.id, "ID")}, + ) + + def test_committee_categories_private_fields(self): + # Test if private fields on committee categories cannot be retrieved via list view + for field_name, field_spec in self.MEMBERS_COMITTEECATEGORY_PRIVATE_FIELDS.items(): + # Wrap the field spec in "results { }" for list view + field_spec = f"results {{ {field_spec} }}" + self._test_public_model_and_private_field( + query_name="committeeCategories", field_name=field_name, field_spec=field_spec, + variables={"id": (self.category.id, "ID")} + ) diff --git a/amelie/graphql/tests/test_news.py b/amelie/graphql/tests/test_news.py new file mode 100644 index 0000000..f0fc81c --- /dev/null +++ b/amelie/graphql/tests/test_news.py @@ -0,0 +1,203 @@ +import json + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone + +from amelie.activities.models import Activity +from amelie.news.models import NewsItem +from amelie.files.models import Attachment +from amelie.members.models import Committee, Person +from amelie.graphql.tests import BaseGraphQLPrivateFieldTests +from amelie.tools.tests import generate_activities + + +def generate_news_article() -> NewsItem: + """ + Generate News article for testing. + + It will generate 1 article with: + - a public attachment + - a private attachment + - a linked public activity + - a linked private activity + """ + + now = timezone.now() + committee = Committee.objects.first() + author = Person.objects.first() + + # Generate two activities, one public and one private + generate_activities(2) + + item = NewsItem( + publication_date=now, + title_nl=f"Nieuwsartikel", + title_en=f"News Article", + introduction_nl="Dit is een nieuwsartikel.", + introduction_en="This is a news article.", + content_nl="Dit is de inhoud.", + content_en="This is the content.", + publisher=committee, + author=author, + ) + item.save() + + # Add public attachment + public_attachment = Attachment( + public=True, file=SimpleUploadedFile("public.txt", b"File Contents") + ) + public_attachment.save(create_thumbnails=False) + item.attachments.add(public_attachment) + + # Add private attachment + private_attachment = Attachment( + public=False, file=SimpleUploadedFile("public.txt", b"File Contents") + ) + private_attachment.save(create_thumbnails=False) + item.attachments.add(private_attachment) + + # Add linked public activity + public_activity = Activity.objects.filter(public=True).order_by('-id').first() + item.activities.add(public_activity) + + # Add linked private activity + private_activity = Activity.objects.filter(public=False).order_by('-id').first() + item.activities.add(private_activity) + + return item + +class NewsGraphQLPrivateFieldTests(BaseGraphQLPrivateFieldTests): + """ + Tests for private fields of models of the News app + """ + + def setUp(self): + super(NewsGraphQLPrivateFieldTests, self).setUp() + + # A committee with the abbreviation of the education committee is required for the news module + # functions to work properly. So we get_or_create it here to make sure it exists in the test DB. + _ = Committee.objects.get_or_create(name="EduCom", abbreviation=settings.EDUCATION_COMMITTEE_ABBR) + + # Generate news article + self.article = generate_news_article() + + def test_news_item_private_attachment(self): + # Test if private attachments are hidden in get view + query = "query ($id: ID) { newsItem(id: $id) { attachments { public }}}" + response = self.query(query, variables={"id": self.article.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'newsItem', public field 'attachments' returned an error!" + ) + + # Check that all attachments are public, and that the correct amount of attachments are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['newsItem']['attachments']), + f"Query for 'newsItem', public field 'attachments' returned a private attachment!") + num_attachments = len(content['data']['newsItem']['attachments']) + self.assertEqual( + num_attachments, 1, + f"Query for 'newsItem', public field 'attachments' did not return 1 expected attachment (returned {num_attachments})!" + ) + + def test_news_items_private_attachment(self): + # Test if private attachments are hidden in list view + query = "query ($id: ID) { newsItems(id: $id) { results { attachments { public }}}}" + response = self.query(query, variables={"id": self.article.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'newsItems', public field 'attachments' returned an error!" + ) + + # Check that all attachments are public, and that the correct amount of attachments are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['newsItems']['results'][0]['attachments']), + f"Query for 'newsItems', public field 'attachments' returned a private attachment!") + num_attachments = len(content['data']['newsItems']['results'][0]['attachments']) + self.assertEqual( + num_attachments, 1, + f"Query for 'newsItems', public field 'attachments' did not return 1 expected attachment (returned {num_attachments})!" + ) + + def test_news_item_private_activity(self): + # Test if private activities are hidden in get view + query = "query ($id: ID) { newsItem(id: $id) { activities { public }}}" + response = self.query(query, variables={"id": self.article.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'newsItem', public field 'activities' returned an error!" + ) + + # Check that all activities are public, and that the correct amount of activities are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['newsItem']['activities']), + f"Query for 'newsItem', public field 'activities' returned a private attachment!") + num_activities = len(content['data']['newsItem']['activities']) + self.assertEqual( + num_activities, 1, + f"Query for 'newsItem', public field 'activities' did not return 1 expected attachment (returned {num_activities})!" + ) + + def test_news_items_private_activity(self): + # Test if private activities are hidden in list view + query = "query ($id: ID) { newsItems(id: $id) { results { activities { public }}}}" + response = self.query(query, variables={"id": self.article.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'newsItems', public field 'activities' returned an error!" + ) + + # Check that all activities are public, and that the correct amount of activities are received (1) + self.assertTrue(all(a['public'] == True for a in content['data']['newsItems']['results'][0]['activities']), + f"Query for 'newsItems', public field 'activities' returned a private attachment!") + num_activities = len(content['data']['newsItems']['results'][0]['activities']) + self.assertEqual( + num_activities, 1, + f"Query for 'newsItems', public field 'activities' did not return 1 expected activity (returned {num_activities})!" + ) + + def test_news_item_author_publisher_string(self): + # Test if the author and publisher of a news item are a strings in get view + query = "query ($id: ID) { newsItem(id: $id) { author, publisher }}" + response = self.query(query, variables={"id": self.article.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'newsItem', public fields 'author, publisher' returned an error!" + ) + + # Check that both author and publisher fields are strings + self.assertTrue(isinstance(content['data']['newsItem']['author'], str), + f"Query for 'newsItem', public field 'author' returned something else than a string!") + self.assertTrue(isinstance(content['data']['newsItem']['publisher'], str), + f"Query for 'newsItem', public field 'author' returned something else than a string!") + + def test_news_items_author_publisher_string(self): + # Test if the author and publisher of a news item are a strings in list view + query = "query ($id: ID) { newsItems(id: $id) { results { author, publisher }}}" + response = self.query(query, variables={"id": self.article.id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'newsItems', public fields 'author, publisher' returned an error!" + ) + + # Check that both author and publisher fields are strings + self.assertTrue(isinstance(content['data']['newsItems']['results'][0]['author'], str), + f"Query for 'newsItem', public field 'author' returned something else than a string!") + self.assertTrue(isinstance(content['data']['newsItems']['results'][0]['publisher'], str), + f"Query for 'newsItem', public field 'author' returned something else than a string!") diff --git a/amelie/graphql/tests/test_publications.py b/amelie/graphql/tests/test_publications.py new file mode 100644 index 0000000..80b9c59 --- /dev/null +++ b/amelie/graphql/tests/test_publications.py @@ -0,0 +1,61 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone + +from amelie.graphql.tests import BaseGraphQLPrivateFieldTests +from amelie.publications.models import Publication, PublicationType + + +def generate_publication() -> Publication: + """ + Generate Publication for testing. + + It will generate 1 private publication + """ + + now = timezone.now() + + # Create PublicationType + publication_type = PublicationType( + type_name="Publication Type", + description="This is a publication type.", + default_thumbnail=SimpleUploadedFile("thumb.png", b"Some PNG") + ) + publication_type.save() + # Create publication + item = Publication( + name=f"Publication", + description="This is a publication", + date_published=now, + publication_type=publication_type, + file=SimpleUploadedFile("publication.txt", b"This is a very nice publication"), + public=False + ) + item.save() + return item + + +class PublicationsGraphQLPrivateFieldTests(BaseGraphQLPrivateFieldTests): + """ + Tests for private fields of models of the Publications app + """ + + def setUp(self): + super(PublicationsGraphQLPrivateFieldTests, self).setUp() + + # Generate two publications, one public and one private + self.private_publication = generate_publication() + + def test_publication_private_model(self): + # Test if private publication cannot be retrieved + self._test_private_model( + query_name="publication", + variables={"id": (self.private_publication.id, "ID")} + ) + + def test_publications_private_model(self): + # Test if private publication cannot be retrieved via list view + self._test_private_model_list( + query_name="publications", + public_field_spec="results { id }", + variables={"id": (self.private_publication.id, "ID")} + ) diff --git a/amelie/graphql/tests/test_videos.py b/amelie/graphql/tests/test_videos.py new file mode 100644 index 0000000..f4b72d6 --- /dev/null +++ b/amelie/graphql/tests/test_videos.py @@ -0,0 +1,127 @@ +import json +from typing import Tuple + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone + +from amelie.activities.models import Activity +from amelie.news.models import NewsItem +from amelie.files.models import Attachment +from amelie.members.models import Committee, Person +from amelie.graphql.tests import BaseGraphQLPrivateFieldTests +from amelie.videos.models import BaseVideo, YouTubeVideo, StreamingIAVideo +from amelie.tools.tests import generate_activities + + +def generate_videos(): + """ + Generate Video for testing. + + It will generate 4 videos + - A public YouTube video + - A public Streaming.IA video + - A private YouTube video + - A private Streaming.IA video + """ + + now = timezone.now() + committee = Committee.objects.first() + + for i in range(2): + # Create video + item = YouTubeVideo( + video_id=i, + title=f"YouTube Video {i + 1}", + description="This is a Youtube video.", + date_published=now, + publisher=committee, + public=bool(i) + ) + item.save() + item = StreamingIAVideo( + video_id=i, + title=f"Streaming.IA Video {i + 1}", + description="This is a Streaming.IA video.", + date_published=now, + publisher=committee, + public=bool(i) + ) + item.save() + + +class VideosGraphQLPrivateFieldTests(BaseGraphQLPrivateFieldTests): + """ + Tests for private fields of models of the Videos app + """ + + def setUp(self): + super(VideosGraphQLPrivateFieldTests, self).setUp() + + # Generate four videos, public and private, YouTube and Streaming.IA videos. + generate_videos() + + # Retrieve and store those videos + self.public_yt_video = YouTubeVideo.objects.filter(public=True).order_by('-video_id').first() + self.private_yt_video = YouTubeVideo.objects.filter(public=False).order_by('-video_id').first() + self.public_ia_video = StreamingIAVideo.objects.filter(public=True).order_by('-video_id').first() + self.private_ia_video = StreamingIAVideo.objects.filter(public=False).order_by('-video_id').first() + + def test_video_private_model(self): + # Test if private videos cannot be retrieved + self._test_private_model( + query_name="video", + public_field_spec="videoId", + variables={"videoId": (self.private_yt_video.video_id, "ID")} + ) + self._test_private_model( + query_name="video", + public_field_spec="videoId", + variables={"videoId": (self.private_ia_video.video_id, "ID")} + ) + + def test_videos_private_model(self): + # Test if private videos cannot be retrieved via list view + self._test_private_model_list( + query_name="videos", + public_field_spec="results { videoId }", + variables={"videoId": (self.private_yt_video.video_id, "ID")} + ) + self._test_private_model_list( + query_name="videos", + public_field_spec="results { videoId }", + variables={"videoId": (self.private_ia_video.video_id, "ID")} + ) + + def test_video_publisher_string(self): + # Test if the publisher of a video is a string in get view + query = "query ($videoId: ID) { video(videoId: $videoId) { publisher }}" + response = self.query(query, variables={"videoId": self.public_yt_video.video_id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'video', public field 'publisher' returned an error!" + ) + + # Check that both author and publisher fields are strings + self.assertTrue(isinstance(content['data']['video']['publisher'], str), + f"Query for 'video', public field 'publisher' returned something else than a string!") + + def test_videos_publisher_string(self): + # Test if the publisher of a video is a string in list view + query = "query ($videoId: ID) { videos(videoId: $videoId) { results { publisher }}}" + response = self.query(query, variables={"videoId": self.public_yt_video.video_id}) + content = response.json() + + # The request should succeed + self.assertResponseNoErrors( + response, + f"Query for 'videos', public field 'publisher' returned an error!" + ) + + # Check that both author and publisher fields are strings + self.assertTrue(isinstance(content['data']['videos']['results'][0]['publisher'], str), + f"Query for 'newsItem', public field 'publisher' returned something else than a string!") + diff --git a/amelie/graphql/urls.py b/amelie/graphql/urls.py new file mode 100644 index 0000000..5066cfc --- /dev/null +++ b/amelie/graphql/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from django.views.decorators.csrf import csrf_exempt + +from amelie.graphql.views import IAGraphQLView + +app_name = 'graphql' + + +urlpatterns = [ + path('', csrf_exempt(IAGraphQLView.as_view(graphiql=True))) +] diff --git a/amelie/graphql/views.py b/amelie/graphql/views.py new file mode 100644 index 0000000..4570e59 --- /dev/null +++ b/amelie/graphql/views.py @@ -0,0 +1,8 @@ +from graphene_django.views import GraphQLView + + +class IAGraphQLView(GraphQLView): + def execute_graphql_request( + self, request, data, query, variables, operation_name, show_graphiql=False + ): + return super().execute_graphql_request(request, data, query, variables, operation_name, show_graphiql=show_graphiql) diff --git a/amelie/members/graphql.py b/amelie/members/graphql.py new file mode 100644 index 0000000..de0a08c --- /dev/null +++ b/amelie/members/graphql.py @@ -0,0 +1,243 @@ +import graphene +import django_filters + +from graphql import GraphQLError +from graphene_django import DjangoObjectType +from django_filters import FilterSet +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + +from amelie.graphql.decorators import check_authorization +from amelie.graphql.pagination.connection_field import DjangoPaginationConnectionField +from amelie.members.models import Committee, Function, CommitteeCategory + + +class FunctionType(DjangoObjectType): + class Meta: + model = Function + description = "Type definition for a single Function" + fields = ["person", "committee", "function", "begin", "end"] + + person = graphene.String(description=_("Person name")) + is_current_member = graphene.Boolean(description=_("This person is currently a member of this committee")) + + def resolve_person(obj: Function, info): + return obj.person.incomplete_name() + + def resolve_is_current_member(obj: Function, info): + return obj.end is None and obj.committee.abolished is None + + def resolve_begin(obj: Function, info): + # Begin date is only accessible for board members + if hasattr(info.context, 'user') and info.context.user.is_authenticated and info.context.is_board: + return obj.begin + raise GraphQLError("Object access denied.") + + def resolve_end(obj: Function, info): + # End date is only accessible for board members + if hasattr(info.context, 'user') and info.context.user.is_authenticated and info.context.is_board: + return obj.end + raise GraphQLError("Object access denied.") + + +class CommitteeFilterSet(FilterSet): + class Meta: + model = Committee + fields = { + 'name': ("icontains", "iexact"), + 'founded': ("exact", "gt", "lt"), + 'abolished': ("exact", "gt", "lt"), + } + + include_abolished = django_filters.BooleanFilter(method="include_abolished_filter", required=True) + + def include_abolished_filter(self, qs, filter_field, value): + """ + Only active committees should be returned, + unless specifically asked for by a board member or by someone who was a member of that committee + """ + # If abolished committees are requested, we need to check if the user is logged in and allowed to see them + if value and hasattr(self.request, 'user') and self.request.user.is_authenticated: + # If user is a board member, include all committees + if self.request.is_board: + return qs + # If user was a member of the committee, include active committees and committees in which they were active + if self.request.user.person is not None: + return qs.filter( + Q(abolished__isnull=True) | Q(function__person=self.request.user.person) + ).distinct() + # Else only return active committees + return qs.filter(abolished__isnull=True) + + +@check_authorization +class CommitteeType(DjangoObjectType): + public_fields = [ + "id", + "name", + "category", + "parent_committees", + "slug", + "email", + "abolished", + "website", + "information_nl", + "information_en", + "group_picture", + "function_set" + ] + committee_fields = [ + "founded" + ] + allowed_committees = ["WWW"] + private_fields = ["logo", "information"] + + class Meta: + model = Committee + description = "Type definition for a single Committee" + filterset_class = CommitteeFilterSet + fields = [ + "id", + "name", + "category", + "parent_committees", + "slug", + "email", + "founded", + "abolished", + "website", + "information_nl", + "information_en", + "logo", + "group_picture", + "function_set", + ] + + function_set = graphene.Field(graphene.List(FunctionType, description=_("Members of this committee")), + include_past_members=graphene.Boolean()) + + # Translated fields in user's preferred language + information = graphene.String(description=_("Committee information (localized for user)")) + + def resolve_email(obj: Committee, info): + """Resolves committee e-mail. Returns None if the e-mail is private and the user is not a board member.""" + if obj.private_email and not info.context.is_board: + return None + return obj.email + + def resolve_function_set(obj: Committee, info, include_past_members=False): + """ + Only current members should be returned as part of a category, + unless specifically asked for by a board member + """ + # If past members are requested, only include them if the user is a board member + if include_past_members and hasattr(info.context, 'user') and info.context.user.is_authenticated and info.context.is_board: + return obj.function_set.all() + # Else only return current members + return obj.function_set.filter(end__isnull=True) + + def resolve_information(obj: Committee, info): + return obj.information + + +class CommitteeCategoryType(DjangoObjectType): + class Meta: + model = CommitteeCategory + description = "Type definition for a single CommitteeCategory" + filter_fields = { + 'name': ("icontains", "iexact"), + 'id': ("exact",), + } + fields = ["id", "name", "slug", "committee_set"] + + committee_set = graphene.Field(graphene.List(CommitteeType, description=_("Committees in this category")), + include_abolished=graphene.Boolean()) + + def resolve_committee_set(obj: CommitteeCategory, info, include_abolished=False): + """ + Only active committees should be returned as part of a category, + unless specifically asked for by a board member or by someone who was a member of that committee + """ + # If abolished committees are requested, we need to check if the user is allowed to see them + if include_abolished and hasattr(info.context, 'user') and info.context.user.is_authenticated: + # If user is a board member, include all committees + if info.context.is_board: + return obj.committee_set.all() + # If user was a member of the committee, include active committees and committees in which they were active + if info.context.user.person is not None: + return obj.committee_set.filter( + Q(abolished__isnull=True) | Q(function__person=info.context.person) + ).distinct() + # Else only return active committees + return obj.committee_set.filter(abolished__isnull=True) + + +class MembersQuery(graphene.ObjectType): + committee_category = graphene.Field(CommitteeCategoryType, id=graphene.ID(), slug=graphene.String()) + committee_categories = DjangoPaginationConnectionField(CommitteeCategoryType) + + committee = graphene.Field(CommitteeType, id=graphene.ID(), slug=graphene.String()) + committees = DjangoPaginationConnectionField(CommitteeType, id=graphene.ID(), slug=graphene.String()) + + def resolve_committee_category(root, info, id=None, slug=None): + """Find committee category by ID or slug""" + if id is not None: + return CommitteeCategory.objects.get(pk=id) + if slug is not None: + return CommitteeCategory.objects.get(slug=slug) + return None + + def resolve_committee(root, info, id=None, slug=None): + """Find committee by ID or slug, if the user is allowed to see it""" + # Logged-in users can see more committees than non-logged-in users. + if hasattr(info.context, 'user') and info.context.user.is_authenticated: + # Board members can see all committees, including abolished ones + if info.context.is_board: + qs = Committee.objects + + # Logged-in users can see abolished committees that they were a part of + else: + qs = Committee.objects.filter( + Q(abolished__isnull=True) | Q(function__person=info.context.person) + ).distinct() + + # Non-logged in users are only allowed to see active committees + else: + qs = Committee.objects.filter(abolished__isnull=True) + + # Find the committee by its ID or slug + if id is not None: + return qs.get(pk=id) + if slug is not None: + return qs.get(slug=slug) + return None + + def resolve_committees(root, info, id=None, slug=None, *args, **kwargs): + """Find committees by ID or slug, if the user is allowed to see it""" + # Logged-in users can see more committees than non-logged-in users. + if hasattr(info.context, 'user') and info.context.user.is_authenticated: + # Board members can see all committees, including abolished ones + if info.context.is_board: + qs = Committee.objects + + # Logged-in users can see abolished committees that they were a part of + else: + qs = Committee.objects.filter( + Q(abolished__isnull=True) | Q(function__person=info.context.person) + ).distinct() + + # Non-logged in users are only allowed to see active committees + else: + qs = Committee.objects.filter(abolished__isnull=True) + + # Find the committee by its ID or slug + if id is not None: + return qs.filter(pk=id) + if slug is not None: + return qs.filter(slug=slug) + return qs + + +# Exports +GRAPHQL_QUERIES = [MembersQuery] +GRAPHQL_MUTATIONS = [] diff --git a/amelie/news/graphql.py b/amelie/news/graphql.py new file mode 100644 index 0000000..15becd6 --- /dev/null +++ b/amelie/news/graphql.py @@ -0,0 +1,87 @@ +import graphene + +from graphene_django import DjangoObjectType +from django.utils.translation import gettext_lazy as _ + +from amelie.graphql.pagination.connection_field import DjangoPaginationConnectionField +from amelie.news.models import NewsItem + + +class NewsItemType(DjangoObjectType): + class Meta: + model = NewsItem + description = "Type definition for a single News Item" + filter_fields = { + 'title_nl': ("icontains", "iexact"), + 'title_en': ("icontains", "iexact"), + 'publication_date': ("exact", "gt", "lt"), + 'publisher': ("exact", ), + } + fields = [ + "id", + "publication_date", + "title_nl", + "title_en", + "slug", + "introduction_nl", + "introduction_en", + "content_nl", + "content_en", + "publisher", + "author", + "attachments", + "activities", + "pinned", + ] + + author = graphene.String(description=_("Message author")) + publisher = graphene.String(description=_("Publishing committee")) + + # Translated fields in user's preferred language + title = graphene.String(description=_("Message title (localized for user)")) + introduction = graphene.String(description=_("Message introduction (localized for user)")) + content = graphene.String(description=_("Message content (localized for user)")) + + def resolve_attachments(self: NewsItem, info): + # `info.context` is the Django Request object in Graphene + return self.attachments.filter_public(info.context).all() + + def resolve_activities(self: NewsItem, info): + # `info.context` is the Django Request object in Graphene + return self.activities.filter_public(info.context).all() + + def resolve_author(obj: NewsItem, info): + return obj.author.incomplete_name() + + def resolve_publisher(obj: NewsItem, info): + return obj.publisher.name + + def resolve_title(obj: NewsItem, info): + return obj.title + + def resolve_introduction(obj: NewsItem, info): + return obj.introduction + + def resolve_content(obj: NewsItem, info): + return obj.content + + +class NewsQuery(graphene.ObjectType): + news_item = graphene.Field(NewsItemType, id=graphene.ID()) + news_items = DjangoPaginationConnectionField(NewsItemType, id=graphene.ID()) + + def resolve_news_item(root, info, id): + return NewsItem.objects.get(pk=id) + + def resolve_news_items(root, info, id=None, *args, **kwargs): + """Find news items by ID""" + qs = NewsItem.objects + # Find the news item by its ID + if id is not None: + return qs.filter(pk=id) + return qs + + +# Exports +GRAPHQL_QUERIES = [NewsQuery] +GRAPHQL_MUTATIONS = [] diff --git a/amelie/personal_tab/jsonapi.py b/amelie/personal_tab/jsonapi.py deleted file mode 100644 index cdda30b..0000000 --- a/amelie/personal_tab/jsonapi.py +++ /dev/null @@ -1,24 +0,0 @@ -from jsonrpc import jsonrpc_method - -from amelie.personal_tab.models import Category, Article - - -@jsonrpc_method('getItems() -> Array', authenticated=False) -def getItemStream(request): - result = [] - categories = Category.objects.all() - for category in categories: - categorydict = ({ - "category": category.name, - "category-image": category.image, - "items": [] - }) - articles = Article.objects.filter(category=category) - for article in articles: - categorydict["items"].append({ - "name": article.name, - "price": article.price, - "article-image": article.image - }) - result.append(categorydict) - return result diff --git a/amelie/publications/graphql.py b/amelie/publications/graphql.py new file mode 100644 index 0000000..91af8b6 --- /dev/null +++ b/amelie/publications/graphql.py @@ -0,0 +1,65 @@ +from datetime import time + +import graphene + +from graphene_django import DjangoObjectType + +from amelie.graphql.pagination.connection_field import DjangoPaginationConnectionField +from amelie.publications.models import Publication, PublicationType + + +class PublicationTypeType(DjangoObjectType): + class Meta: + model = PublicationType + description = "Type definition for a type of Publication" + fields = [ + "type_name", + "description", + ] + + +class PublicationItemType(DjangoObjectType): + class Meta: + model = Publication + description = "Type definition for a single Publication" + filter_fields = { + 'name': ("icontains", "iexact"), + 'date_published': ("exact", "gt", "lt"), + 'publication_type__type_name': ("icontains", "iexact"), + 'is_featured': ("exact",), + } + fields = [ + "id", + "name", + "description", + "date_published", + "publication_type", + "thumbnail", + "file", + "is_featured", + "public", + ] + + def resolve_thumbnail(obj: Publication, info): + return obj.get_thumbnail() + + +class PublicationQuery(graphene.ObjectType): + publication = graphene.Field(PublicationItemType, id=graphene.ID()) + publications = DjangoPaginationConnectionField(PublicationItemType, id=graphene.ID()) + + def resolve_publication(root, info, id): + return Publication.objects.filter_public(info.context).get(pk=id) + + def resolve_publications(root, info, id=None, *args, **kwargs): + """Find publications by ID""" + qs = Publication.objects.filter_public(info.context) + # Find the publication by its ID + if id is not None: + return qs.filter(pk=id) + return qs + + +# Exports +GRAPHQL_QUERIES = [PublicationQuery] +GRAPHQL_MUTATIONS = [] diff --git a/amelie/settings/generic.py b/amelie/settings/generic.py index 0708541..76a40fe 100644 --- a/amelie/settings/generic.py +++ b/amelie/settings/generic.py @@ -14,6 +14,8 @@ from saml2.saml import NAMEID_FORMAT_EMAILADDRESS, NAMEID_FORMAT_UNSPECIFIED from saml2.sigver import get_xmlsec_binary +from amelie.graphql.jwt_handlers import allow_none, get_user_from_jwt_username, get_username_from_jwt_payload + # In the passing of years a lot of settings have been added that contain confidential data. # These settings need to be unreadable at all times, also in error reports, logs, etc. # Unfortunately, Django's own regex is not extensive enough for this, so we add to it here. @@ -213,6 +215,7 @@ # Authentication backends used by the application AUTHENTICATION_BACKENDS = [ 'amelie.tools.auth.IAOIDCAuthenticationBackend', # Logins via OIDC / auth.ia + 'graphql_jwt.backends.JSONWebTokenBackend', # API key logins via GraphQL ] # URL to the login page @@ -263,6 +266,7 @@ 'amelie.style', 'amelie.narrowcasting', 'amelie.api', + 'amelie.graphql', 'amelie.claudia', 'amelie.iamailer', 'amelie.weekmail', @@ -278,6 +282,9 @@ # JSONRPC API 'modernrpc', + # GraphQL API + 'graphene_django', + # FCM (Firebase Cloud Messaging) 'fcm_django', @@ -377,9 +384,52 @@ LANGUAGE_COOKIE_NAME = 'amelie_django_language' SESSION_COOKIE_NAME = 'amelie_sessionid' -# Allow Cross Origin requests, but only on the API. +# Allow Cross Origin requests, but only on the JSONRPC or GraphQL APIs. CORS_ORIGIN_ALLOW_ALL = True -CORS_URLS_REGEX = r'^/api/.*$' +CORS_URLS_REGEX = r'^/(api|graphql)/.*$' + +# GraphQL API settings +GRAPHENE = { + 'SCHEMA': "amelie.graphql.schema.schema", + 'MIDDLEWARE': [ + 'graphql_jwt.middleware.JSONWebTokenMiddleware', + 'graphene_django_extras.ExtraGraphQLDirectiveMiddleware', + ], + 'RELAY_CONNECTION_MAX_LIMIT': 100, + 'TESTING_ENDPOINT': '/graphql/' +} +GRAPHENE_DEFAULT_LIMIT = 10 + +GRAPHQL_JWT = { + 'JWT_ALLOW_ANY_HANDLER': allow_none, + 'JWT_ALGORITHM': "RS256", + 'JWT_PUBLIC_KEY': "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtAj6EjQ4jYb7n2ipgHHX3EMegOyFMovsTAKOuPKslh5eeckU2aq0Qp/YAGRzXu6HxBXMJ5hFuDE8HgxIS5ZRBxR5AbEpO7YnvpH8CY9jUyFc7caR0L+QmugG649jy8NmkhiFvanKy1AY3DPXwfQS75D4QTrDis4viYF2xn1QAnOzTdtfe3srz2uYk/dAguj2lffAeZ0OoQ20sejO+TGHQeOpXTR7Vk16CPu89JhjWcpnhLWSkBgvwuLrg+3XoMlPum9cSHaIhc9BX1hbqt351XVMZbk8Ui4Kv6elJyMQEklPMDQhPiCCDMTXa51nqyAPkJUceA1IXkP3t1x0HBFkOwIDAQAB\n-----END PUBLIC KEY-----".encode('ascii'), + 'JWT_VERIFY': True, + 'JWT_VERIFY_EXPIRATION': True, + 'JWT_ALLOW_REFRESH': False, + 'JWT_PAYLOAD_GET_USERNAME_HANDLER': get_username_from_jwt_payload, + 'JWT_GET_USER_BY_NATURAL_KEY_HANDLER': get_user_from_jwt_username, +} + +GRAPHQL_SCHEMAS = [ + "amelie.about.graphql", + "amelie.activities.graphql", + "amelie.companies.graphql", + "amelie.education.graphql", + "amelie.files.graphql", + "amelie.members.graphql", + "amelie.news.graphql", + "amelie.publications.graphql", + "amelie.videos.graphql", +] + +GRAPHENE_DJANGO_EXTRAS = { + 'DEFAULT_PAGINATION_CLASS': 'graphene_django_extras.paginations.LimitOffsetGraphqlPagination', + 'DEFAULT_PAGE_SIZE': 20, + 'MAX_PAGE_SIZE': 100, + 'CACHE_ACTIVE': True, + 'CACHE_TIMEOUT': 300 # seconds +} # Increase the maximum file upload count to 1000, to allow large batches of pictures to be uploaded DATA_UPLOAD_MAX_NUMBER_FILES = 1000 @@ -681,9 +731,14 @@ # SAML2 Identity Provider configuration SAML_BASE_URL = "https://www.inter-actief.utwente.nl/saml2idp" +try: + xmlsec_binary = get_xmlsec_binary(['/opt/local/bin', '/usr/bin/xmlsec1']) +except saml2.sigver.SigverError: + print("Could not find xmlsec1 binary for SAML. Continuing with no xmlsec configured, SAML2 IDP will not work.") + xmlsec_binary = None SAML_IDP_CONFIG = { 'debug': 0, - 'xmlsec_binary': get_xmlsec_binary(['/opt/local/bin', '/usr/bin/xmlsec1']), + 'xmlsec_binary': xmlsec_binary, 'entityid': '%s/metadata' % SAML_BASE_URL, 'description': 'Inter-Actief SAML IdP', diff --git a/amelie/settings/local.py.default b/amelie/settings/local.py.default index 72198fa..62c9d65 100644 --- a/amelie/settings/local.py.default +++ b/amelie/settings/local.py.default @@ -4,6 +4,7 @@ # Keep these imports here! import warnings import os +import sys from amelie.settings.generic import * @@ -33,6 +34,9 @@ DEBUG = True DEBUG_TOOLBAR = True ALLOWED_HOSTS = ["*"] +# Debug toolbar does not work during tests, so to make tests work, we exclude the toolbar if we're testing +DEBUG_TOOLBAR = DEBUG_TOOLBAR and "test" not in sys.argv + # Default setting for enabling profiling (the rest of the config is done in local.py(.default) # If this is turned on a lot of data will be generated and stored in the database. So only turn it on if you feel bold. ENABLE_REQUEST_PROFILING = False diff --git a/amelie/settings/local.py.localdev b/amelie/settings/local.py.localdev index 85c3638..267200c 100644 --- a/amelie/settings/local.py.localdev +++ b/amelie/settings/local.py.localdev @@ -4,6 +4,7 @@ # Keep these imports here! import warnings import os +import sys from amelie.settings.generic import * @@ -25,6 +26,9 @@ DEBUG = True DEBUG_TOOLBAR = True ALLOWED_HOSTS = ["*"] +# Debug toolbar does not work during tests, so to make tests work, we exclude the toolbar if we're testing +DEBUG_TOOLBAR = DEBUG_TOOLBAR and "test" not in sys.argv + # Disable secure redirects to allow local development without SSL SECURE_SSL_REDIRECT = False diff --git a/amelie/tools/tests.py b/amelie/tools/tests.py index 4bd95a3..5b01ea1 100644 --- a/amelie/tools/tests.py +++ b/amelie/tools/tests.py @@ -2,8 +2,10 @@ import datetime import json +import random from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.core.serializers.json import DjangoJSONEncoder from django.urls import reverse from django.test import Client, testcases @@ -11,11 +13,63 @@ from django.utils import timezone from oauth2_provider.models import Application, AccessToken +from amelie.activities.models import Restaurant, ActivityLabel, Activity, EnrollmentoptionQuestion, \ + EnrollmentoptionCheckbox, EnrollmentoptionFood from amelie.members.models import Person, Committee, Preference, PreferenceCategory, Membership, MembershipType, Function from amelie.tools.logic import current_association_year from amelie.tools.models import Profile + +def generate_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() + + class SimpleTestCase(testcases.SimpleTestCase): # Use long messages on failure longMessage = True diff --git a/amelie/tools/user.py b/amelie/tools/user.py new file mode 100644 index 0000000..9016094 --- /dev/null +++ b/amelie/tools/user.py @@ -0,0 +1,33 @@ +from django.contrib.auth import get_user_model +from django.conf import settings + + +def get_user_by_username(username): + """ + Retrieve a Django user model by username + + This can be an Inter-Actief username, or a UT student- or employee number. + """ + UserModel = get_user_model() + + if not username: + return UserModel.objects.none() + + if not settings.DEBUG and username.lower() in settings.LOGIN_NOT_ALLOWED_USERNAMES: + return UserModel.objects.none() + + # Find Person by IA account name + from amelie.members.models import Person + try: + person = Person.objects.get(account_name=username) + except Person.DoesNotExist: + # Login was successful, but person was not found (should not happen) + person = None + + if person: + # Get or create the user object for this person + user, created = person.get_or_create_user(username) + else: + # Get or create the user object for this unknown user (not linked to a Person) + user, created = UserModel.objects.get_or_create(username=username) + return user diff --git a/amelie/urls.py b/amelie/urls.py index 9f5eedf..b78c53d 100644 --- a/amelie/urls.py +++ b/amelie/urls.py @@ -58,6 +58,9 @@ # API path('api/', include('amelie.api.urls')), + # GraphQL API + path('graphql/', include('amelie.graphql.urls')), + # Feeds path('feeds/', include( ([ diff --git a/amelie/videos/graphql.py b/amelie/videos/graphql.py new file mode 100644 index 0000000..f786b75 --- /dev/null +++ b/amelie/videos/graphql.py @@ -0,0 +1,85 @@ +import graphene +import django_filters + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from graphene_django import DjangoObjectType + +from amelie.graphql.pagination.connection_field import DjangoPaginationConnectionField +from amelie.videos.models import BaseVideo + + +class VideoFilterSet(django_filters.FilterSet): + class Meta: + model = BaseVideo + fields = { + 'title': ("icontains", "iexact"), + 'date_published': ("exact", "gt", "lt"), + 'publisher__name': ("icontains", "iexact"), + 'is_featured': ("exact",), + } + + class VideoTypes(models.TextChoices): + YOUTUBE = 'youtube', _('YouTube') + STREAMING_IA = 'streamingia', _('Streaming.IA') + + video_type = django_filters.ChoiceFilter(method='video_type_filter', choices=VideoTypes.choices) + + def video_type_filter(self, qs, filter_field, value): + if value == "youtube": + return qs.filter(youtubevideo__isnull=False) + elif value == "streamingia": + return qs.filter(streamingiavideo__isnull=False) + else: + return qs + + +class VideoType(DjangoObjectType): + class Meta: + model = BaseVideo + description = "Type definition for a single Video" + filterset_class = VideoFilterSet + fields = [ + "video_id", + "title", + "description", + "date_published", + "thumbnail_url", + "publisher", + "is_featured", + "public", + ] + + publisher = graphene.String(description=_("Publishing committee")) + video_type = graphene.String(description=_("Video type (Youtube or IA)")) + video_url = graphene.String(description=_("URL to the video")) + + def resolve_publisher(obj: BaseVideo, info): + return obj.publisher.name + + def resolve_video_type(obj: BaseVideo, info): + return obj.get_video_type() + + def resolve_video_url(obj: BaseVideo, info): + return obj.get_absolute_url() + + +class VideoQuery(graphene.ObjectType): + video = graphene.Field(VideoType, video_id=graphene.ID()) + videos = DjangoPaginationConnectionField(VideoType, video_id=graphene.ID()) + + def resolve_video(root, info, video_id): + return BaseVideo.objects.filter_public(info.context).get(video_id=video_id) + + def resolve_videos(root, info, video_id=None, *args, **kwargs): + """Find videos by ID""" + qs = BaseVideo.objects.filter_public(info.context) + # Find the video by its Video ID + if video_id is not None: + return qs.filter(video_id=video_id) + return qs + + +# Exports +GRAPHQL_QUERIES = [VideoQuery] +GRAPHQL_MUTATIONS = [] diff --git a/requirements.txt b/requirements.txt index 3d31e9b..470a8d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,6 +24,12 @@ django-ckeditor>=6.3.2 # FCM (Firebase Cloud Messaging) fcm-django>=0.3.11,<1 # 1.x has breaking changes (old firebase api removed) # TODO: Breaking +# GraphQL API +graphene-django +django-graphql-jwt +django-filter +graphene-django-extras + # Used for stripping Markdown notations and indexing the IA Summary website. beautifulsoup4>=4.11.1,<4.12