diff --git a/requirements.txt b/requirements.txt index 47bdfbf5..3e917c19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,10 @@ dj-database-url~=0.5.0 -Django~=3.1.2 +Django~=3.2.20 django-ajax-selects~=2.0.0 django-auth-ldap~=2.2.0 -django-countries~=6.1.3 +django-countries~=7.5.1 django-dotenv~=1.4.2 +django-filter~=23.2 django-getenv~=1.3.2 django-bootstrap4~=2.3.1 djangorestframework~=3.12.1 diff --git a/teknologr/.env.example b/teknologr/.env.example index 299ad44f..3f3d16d7 100644 --- a/teknologr/.env.example +++ b/teknologr/.env.example @@ -7,6 +7,12 @@ SECRET_KEY=SomeRandomSecretKey # Should debug be enabled? True/False DEBUG=False +# Log file path +LOG_FILE=/var/log/teknologr/info.log + +# Directory where to collect all static files +STATIC_ROOT="/var/www/teknologr/static" + # Static collection for ajax-select #AJAX_SELECT_INLINES = 'inline' AJAX_SELECT_INLINES = 'staticfiles' diff --git a/teknologr/api/admin.py b/teknologr/api/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/teknologr/api/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/teknologr/api/filters.py b/teknologr/api/filters.py new file mode 100644 index 00000000..1eb9a84a --- /dev/null +++ b/teknologr/api/filters.py @@ -0,0 +1,328 @@ +import django_filters +from django.db.models import Count +from members.models import * +from functools import reduce +from operator import and_ + +class BooleanFilter(django_filters.BooleanFilter): + ''' + The defualt null label is for some reason 'unknown', but I want it to be '' + ''' + def __init__(self, *args, **kwargs): + kwargs['widget'] = django_filters.widgets.BooleanWidget + super().__init__(*args, **kwargs) + +class BaseFilter(django_filters.rest_framework.FilterSet): + ''' + Base filter class that takes care of normal users from using staff-only filters. + ''' + STAFF_ONLY = [] + created = django_filters.DateFromToRangeFilter(label='Skapad mellan') + modified = django_filters.DateFromToRangeFilter(label='Modifierad mellan') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + always_last = [] + # Inherited classes can remove these fields, so need to check for that + if not hasattr(self, 'created') or self.created: + always_last.append('created') + if not hasattr(self, 'modified') or self.modified: + always_last.append('modified') + + # Handle thie special case STAFF_ONLY = '__all__' + if self.STAFF_ONLY == '__all__': + self.STAFF_ONLY = [k for k in list(self.filters.keys()) if k not in always_last] + self.STAFF_ONLY = self.STAFF_ONLY + always_last + + # If normal user, remove all staff-only filters, otherwise move them last and add a prefix to their labels + is_staff = self.is_staff + for key in self.STAFF_ONLY: + f = self.filters.pop(key) + if is_staff: + f.label = f'[Staff only] {f.label}' + self.filters[key] = f + + @property + def is_staff(self): + ''' Helper method for checking if the user making the request is staff ''' + user = self.request.user + return user and user.is_staff + + def filter_count(self, queryset, value, field_name): + name = f'n_{field_name}' + queryset = queryset.annotate(**{name: Count(field_name, distinct=True)}) + min = value.start + max = value.stop + if min is not None: + queryset = queryset.exclude(**{f'{name}__lt': min}) + if max is not None: + queryset = queryset.exclude(**{f'{name}__gt': max}) + return queryset + + +class MemberFilter(BaseFilter): + ''' + A custom FilterSet class that set up filters for Members. Some filters are ignored if the requesting user is not staff. + ''' + + # Public filters + name = django_filters.CharFilter( + # NOTE: Custom field name + # NOTE: given_names is semi-public, handling of hidden Members is done in the method + method='filter_name', + label='Namnet innehåller', + ) + n_functionaries = django_filters.RangeFilter( + method='filter_n_functionaries', + label='Antalet poster är mellan', + ) + n_groups = django_filters.RangeFilter( + method='filter_n_groups', + label='Antalet grupper är mellan', + ) + n_decorations = django_filters.RangeFilter( + method='filter_n_decorations', + label='Antalet betygelser är mellan', + ) + + # Public but hidable fields (hidden Members are not included) + HIDABLE = Member.HIDABLE_FIELDS + ['address'] + address = django_filters.CharFilter( + # NOTE: Custom field name, but added manually to HIDABLE + method='filter_address', + label='Adressen innehåller', + ) + email = django_filters.CharFilter( + lookup_expr='icontains', + label='E-postadressen innehåller', + ) + degree_programme = django_filters.CharFilter( + lookup_expr='icontains', + label='Studieprogrammet innehåller', + ) + enrolment_year = django_filters.RangeFilter( + label='Inskrivingsåret är mellan', + ) + graduated = BooleanFilter( + method='filter_graduated', + label='Utexaminerad?', + ) + graduated_year = django_filters.RangeFilter( + label='Utexamineringsåret är mellan', + ) + + # Staff only filters + STAFF_ONLY = Member.STAFF_ONLY_FIELDS + birth_date = django_filters.DateFromToRangeFilter( + label='Född mellan', + ) + student_id = django_filters.CharFilter( + label='Studienummer', + ) + dead = BooleanFilter( + label='Avliden?', + ) + subscribed_to_modulen = BooleanFilter( + label='Prenumererar på Modulen?', + ) + allow_studentbladet = BooleanFilter( + label='Prenumererar på Studentbladet?', + ) + allow_publish_info = BooleanFilter( + label='Tillåter publicering av kontaktinformation?', + ) + comment = django_filters.CharFilter( + lookup_expr='icontains', + label='Kommentaren innehåller', + ) + username = django_filters.CharFilter( + label='Användarnamn', + ) + bill_code = django_filters.CharFilter( + label='BILL-konto', + ) + + def includes_hidable_field(self): + ''' Check if the current query includes filtering on any hidable Member fields ''' + for name, value in self.data.items(): + # XXX: This wrongly identifies invalid filter values as being set. For example, providing an invalid string to a BooleanFilter field will be caught here, even though the field will not be filtered... Maybe this is good enough? + if name in self.HIDABLE and any(value): + return True + return False + + def filter_queryset(self, queryset): + ''' Remove hidden Members for normal users if filters on any hidden field is used. ''' + if not self.is_staff and self.includes_hidable_field(): + queryset = queryset.filter(Member.get_show_info_Q()) + return super().filter_queryset(queryset) + + def filter_name(self, queryset, name, value): + ''' + The given_names field is semi-public so can not filter on that for hidden Members. + ''' + is_staff = self.is_staff + + # Split the filter value and compare all individual values against all name columns + queries = [] + for v in value.split(): + q = Q(preferred_name__icontains=v) | Q(surname__icontains=v) + # Non-staff users only get to filter on 'given_names' if the Member has allowed publishing of info + if is_staff: + q |= Q(given_names__icontains=v) + else: + q |= (Q(given_names__icontains=v) & Member.get_show_info_Q()) + queries.append(q) + return queryset.filter(reduce(and_, queries)) + + def filter_address(self, queryset, name, value): + # Split the filter value and compare all individual values against all address columns + queries = [] + for v in value.split(): + queries.append( + Q(street_address__icontains=v) | + Q(postal_code__icontains=v) | + Q(city__icontains=v) | + Q(country__icontains=v) + ) + return queryset.filter(reduce(and_, queries)) + + def filter_graduated(self, queryset, name, value): + ''' + Modifying the graduated flag whan adding a graduated year can sometimes be forgotten, so taking that into consideration for this filter query. + ''' + assert value in [True, False, None] + + if value: + # Graduated or graduated year is set + return queryset.filter(Q(graduated=True) | Q(graduated_year__isnull=False)) + # Not graduated and graduated year is not set + return queryset.filter(Q(graduated=False) & Q(graduated_year__isnull=True)) + + def filter_n_functionaries(self, queryset, name, value): + return self.filter_count(queryset, value, 'functionaries') + + def filter_n_groups(self, queryset, name, value): + return self.filter_count(queryset, value, 'group_memberships') + + def filter_n_decorations(self, queryset, name, value): + return self.filter_count(queryset, value, 'decoration_ownerships') + +class DecorationFilter(BaseFilter): + name = django_filters.CharFilter( + lookup_expr='icontains', + label='Betygelsens namn innehåller', + ) + n_ownerships = django_filters.RangeFilter( + method='filter_n_ownerships', + label='Antalet innehavare är mellan', + ) + + def filter_n_ownerships(self, queryset, name, value): + return self.filter_count(queryset, value, 'ownerships') + +class DecorationOwnershipFilter(BaseFilter): + decoration__id = django_filters.NumberFilter(label='Betygelsens id') + decoration__name = django_filters.CharFilter( + lookup_expr='icontains', + label='Betygelsens namn innehåller', + ) + member__id = django_filters.NumberFilter(label='Medlemmens id') + acquired = django_filters.DateFromToRangeFilter(label='Tilldelat mellan') + + +class FunctionaryTypeFilter(BaseFilter): + name = django_filters.CharFilter( + lookup_expr='icontains', + label='Postens namn innehåller', + ) + n_functionaries_total = django_filters.RangeFilter( + method='filter_n_functionaries_total', + label='Totala antalet postinnehavare är mellan', + ) + # XXX: What about filtering on unique functionaries? + + def filter_n_functionaries_total(self, queryset, name, value): + return self.filter_count(queryset, value, 'functionaries') + +class FunctionaryFilter(BaseFilter): + functionarytype__id = django_filters.NumberFilter(label='Postens id') + functionarytype__name = django_filters.CharFilter( + lookup_expr='icontains', + label='Postens namn innehåller', + ) + member__id = django_filters.NumberFilter(label='Medlemmens id') + begin_date = django_filters.DateFromToRangeFilter(label='Startdatumet är mellan') + end_date = django_filters.DateFromToRangeFilter(label='Slutdatumet är mellan') + + +class GroupTypeFilter(BaseFilter): + name = django_filters.CharFilter( + lookup_expr='icontains', + label='Gruppens namn innehåller', + ) + n_groups = django_filters.RangeFilter( + method='filter_n_groups', + label='Antalet undergrupper är mellan', + ) + # XXX: What about filtering on amount of members (total and unique)? + + def filter_n_groups(self, queryset, name, value): + return self.filter_count(queryset, value, 'groups') + +class GroupFilter(BaseFilter): + begin_date = django_filters.DateFromToRangeFilter(label='Startdatumet är mellan') + end_date = django_filters.DateFromToRangeFilter(label='Slutdatumet är mellan') + grouptype__id = django_filters.NumberFilter(label='Gruppens id') + grouptype__name = django_filters.CharFilter( + lookup_expr='icontains', + label='Gruppens namn innehåller', + ) + n_members = django_filters.RangeFilter( + method='filter_n_members', + label='Antalet gruppmedlemmar är mellan', + ) + + def filter_n_members(self, queryset, name, value): + return self.filter_count(queryset, value, 'memberships') + +class GroupMembershipFilter(BaseFilter): + group__id = django_filters.NumberFilter(label='Undergruppens id') + group__begin_date = django_filters.DateFromToRangeFilter(label='Undergruppens startdatum är mellan') + group__end_date = django_filters.DateFromToRangeFilter(label='Undergruppens slutdatum är mellan') + group__grouptype__id = django_filters.NumberFilter(label='Gruppens id') + group__grouptype__name = django_filters.CharFilter( + lookup_expr='icontains', + label='Gruppens namn innehåller', + ) + member__id = django_filters.NumberFilter(label='Medlemmens id') + + +class MemberTypeFilter(BaseFilter): + STAFF_ONLY = '__all__' + begin_date = django_filters.DateFromToRangeFilter(label='Startdatumet är mellan') + end_date = django_filters.DateFromToRangeFilter(label='Slutdatumet är mellan') + type = django_filters.ChoiceFilter(choices=MemberType.TYPES, label='Medlemstyp') + member__id = django_filters.NumberFilter(label='Medlemmens id') + + +class ApplicantFilter(MemberFilter): + STAFF_ONLY = '__all__' + + # Need to remove all Member fields that do not exist on Applicant + graduated = None + graduated_year = None + comment = None + dead = None + bill_code = None + created = None + + # Add all extra fields + motivation = django_filters.CharFilter( + lookup_expr='icontains', + label='Motiveringen innehåller', + ) + mother_tongue = django_filters.CharFilter( + lookup_expr='icontains', + label='Modersmålet innehåller', + ) + created_at = django_filters.DateFromToRangeFilter(label='Ansökningsdatum är mellan') diff --git a/teknologr/api/models.py b/teknologr/api/models.py deleted file mode 100644 index 71a83623..00000000 --- a/teknologr/api/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/teknologr/api/serializers.py b/teknologr/api/serializers.py index 3d915a3f..86a5aa29 100644 --- a/teknologr/api/serializers.py +++ b/teknologr/api/serializers.py @@ -10,79 +10,234 @@ def to_representation(self, value): return '' # instead of `value` as Country(u'') is not serializable return super(SerializableCountryField, self).to_representation(value) -# Serializers define the API representation. +class BaseSerializer(serializers.ModelSerializer): + ''' + Base class for all our serializers that automatically removes staff-only fields for normal users. It also captures the 'detail' keyword that signifies that the serializer is used for a detail API view instead, for which inherited classes add more details to the representation. + ''' + + STAFF_ONLY = [] + + def __init__(self, *args, detail=False, is_staff=False, **kwargs): + super().__init__(*args, **kwargs) + self.detail = detail + self.is_staff = is_staff + + # Remove staff only fields for normal users + # NOTE: This assumes that this serializer instanace is only used for one request + if not self.is_staff: + for field in self.STAFF_ONLY + ['created', 'modified']: + if field in self.fields: + self.fields.pop(field) + + def get_minimal_id_name(self, instance): + return {'id': instance.id, 'name': instance.name} + + def get_minimal_member(self, member): + return {'id': member.id, 'name': member.name if self.is_staff else member.public_full_name} + # Members +class MemberSerializer(BaseSerializer): + STAFF_ONLY = Member.STAFF_ONLY_FIELDS -class MemberSerializer(serializers.ModelSerializer): country = SerializableCountryField(allow_blank=True, choices=Countries(), required=False) - nationality = SerializableCountryField(allow_blank=True, choices=Countries(), required=False) + n_decorations = serializers.IntegerField(read_only=True) + n_functionaries = serializers.IntegerField(read_only=True) + n_groups = serializers.IntegerField(read_only=True) class Meta: model = Member fields = '__all__' + def to_representation(self, instance): + data = super().to_representation(instance) + + hide = not self.is_staff and not instance.showContactInformation() + if hide: + for field in Member.HIDABLE_FIELDS: + data.pop(field) + + # Add the actual related objects if detail view + # XXX: Do we need to prefetch all related objects here? It's now done earlier, by the caller... + if self.detail: + data['decorations'] = [{ + 'decoration': {'id': do.decoration.id, 'name': do.decoration.name}, + 'acquired': do.acquired, + } for do in instance.decoration_ownerships.all()] + data['functionaries'] = [{ + 'functionarytype': {'id': f.functionarytype.id, 'name': f.functionarytype.name}, + 'begin_date': f.begin_date, + 'end_date': f.end_date, + } for f in instance.functionaries.all()] + data['groups'] = [{ + 'grouptype': {'id': gm.group.grouptype.id, 'name': gm.group.grouptype.name}, + 'begin_date': gm.group.begin_date, + 'end_date': gm.group.end_date, + } for gm in instance.group_memberships.all()] + + # Modify certain fields if necessary + if hide: + data['given_names'] = instance.get_given_names_with_initials() + + return data + + +# GroupTypes, Groups and GroupMemberships + +class GroupTypeSerializer(BaseSerializer): + n_groups = serializers.IntegerField(read_only=True) + n_members_total = serializers.IntegerField(read_only=True) + n_members_unique = serializers.IntegerField(read_only=True) -# Groups - -class GroupSerializer(serializers.ModelSerializer): class Meta: - model = Group + model = GroupType fields = '__all__' + def to_representation(self, instance): + data = super().to_representation(instance) + + # Add the actual related objects if detail view + if self.detail: + data['groups'] = [{ + 'id': g.id, + 'begin_date': g.begin_date, + 'end_date': g.end_date, + 'n_members': g.n_members + } for g in instance.groups.all()] + + return data + +class GroupSerializer(BaseSerializer): + n_members = serializers.IntegerField(read_only=True) -class GroupTypeSerializer(serializers.ModelSerializer): class Meta: - model = GroupType + model = Group fields = '__all__' + def to_representation(self, instance): + data = super().to_representation(instance) + + # XXX: Should it be n_members/members or n_memberships/memberships (or some other combination)? It feels silly having a list of memberships that looks like [{id, member}, ..], so skipping over the actual GroupMembership objects here. Could of course add a field called membership_id, but I don't see that being needed... + + # Add the actual related objects if detail view + if self.detail: + data['members'] = [{ + 'id': gm.member.id, + 'name': gm.member.public_full_name, + } for gm in instance.memberships.select_related('member')] + + # Always show the grouptype as a subobject + data['grouptype'] = self.get_minimal_id_name(instance.grouptype) -class GroupMembershipSerializer(serializers.ModelSerializer): + return data + +class GroupMembershipSerializer(BaseSerializer): class Meta: model = GroupMembership fields = '__all__' + def to_representation(self, instance): + data = super().to_representation(instance) + group = instance.group + + data['member'] = self.get_minimal_member(instance.member) + data['group'] = { + 'id': group.id, + 'begin_date': group.begin_date, + 'end_date': group.end_date, + 'grouptype': self.get_minimal_id_name(group.grouptype) + } + + return data + + +# FunctionaryTypes and Functionaries -# Functionaries +class FunctionaryTypeSerializer(BaseSerializer): + n_functionaries_total = serializers.IntegerField(read_only=True) + n_functionaries_unique = serializers.IntegerField(read_only=True) -class FunctionarySerializer(serializers.ModelSerializer): class Meta: - model = Functionary + model = FunctionaryType fields = '__all__' + def to_representation(self, instance): + data = super().to_representation(instance) + + # Add the actual related objects if detail view + if self.detail: + data['functionaries'] = [{ + 'id': f.id, + 'begin_date': f.begin_date, + 'end_date': f.end_date, + 'member': self.get_minimal_member(f.member), + } for f in instance.functionaries.select_related('member')] + + return data -class FunctionaryTypeSerializer(serializers.ModelSerializer): +class FunctionarySerializer(BaseSerializer): class Meta: - model = FunctionaryType + model = Functionary fields = '__all__' + def to_representation(self, instance): + data = super().to_representation(instance) + data['functionarytype'] = self.get_minimal_id_name(instance.functionarytype) + data['member'] = self.get_minimal_member(instance.member) + return data + + +# Decorations and DecorationOwnerships -# Decorations +class DecorationSerializer(BaseSerializer): + n_ownerships = serializers.IntegerField(read_only=True) -class DecorationSerializer(serializers.ModelSerializer): class Meta: model = Decoration fields = '__all__' + def to_representation(self, instance): + data = super().to_representation(instance) -class DecorationOwnershipSerializer(serializers.ModelSerializer): + # Add the actual related objects if detail view + if self.detail: + data['ownerships'] = [{ + 'id': do.id, + 'acquired': do.acquired, + 'member': self.get_minimal_member(do.member), + } for do in instance.ownerships.select_related('member')] + + return data + +class DecorationOwnershipSerializer(BaseSerializer): class Meta: model = DecorationOwnership fields = '__all__' + def to_representation(self, instance): + data = super().to_representation(instance) + data['decoration'] = self.get_minimal_id_name(instance.decoration) + data['member'] = self.get_minimal_member(instance.member) + return data + # MemberTypes -class MemberTypeSerializer(serializers.ModelSerializer): +class MemberTypeSerializer(BaseSerializer): class Meta: model = MemberType fields = '__all__' + def to_representation(self, instance): + data = super().to_representation(instance) + data['member'] = self.get_minimal_member(instance.member) + return data + # Applicant -class ApplicantSerializer(serializers.ModelSerializer): +class ApplicantSerializer(BaseSerializer): class Meta: model = Applicant fields = '__all__' diff --git a/teknologr/api/tests.py b/teknologr/api/tests.py index 7ce503c2..149022d8 100644 --- a/teknologr/api/tests.py +++ b/teknologr/api/tests.py @@ -1,3 +1,702 @@ -from django.test import TestCase +from django.contrib.auth.models import User, Group +from members.models import * +from registration.models import * +from rest_framework import status +from rest_framework.test import APITestCase -# Create your tests here. +class BaseAPITest(APITestCase): + def setUp(self): + self.user = User.objects.create_user(username='svakar', password='teknolog') + self.superuser = User.objects.create_superuser(username='superuser', password='teknolog') + + self.m1 = Member.objects.create( + country='FI', + given_names='Sverker Svakar', + preferred_name='Svakar', + surname='von Teknolog', + street_address='OK01', + postal_code='11111', + city='Stad1', + phone='040-1', + email='1@teknolog.com', + birth_date='1999-01-01', + student_id='111111', + degree_programme='Energi- och miljöteknik', + enrolment_year=2019, + graduated=True, + graduated_year=2022, + dead=False, + subscribed_to_modulen=True, + allow_publish_info=False, + allow_studentbladet=True, + comment='Kommentar', + ) + self.m2 = Member.objects.create( + country='FI', + given_names='Sigrid Svatta', + preferred_name='Svatta', + surname='von Teknolog', + street_address='OK02', + postal_code='22222', + city='Stad2', + phone='040-2', + email='2@teknolog.com', + birth_date='2999-02-02', + student_id='222222', + degree_programme='Energi- och miljöteknik', + enrolment_year=2019, + graduated=True, + graduated_year=2022, + dead=True, + subscribed_to_modulen=True, + allow_publish_info=True, + allow_studentbladet=True, + comment='Kommentar', + ) + self.m3 = Member.objects.create( + country='FI', + given_names='Test Holger Björn-Anders', + preferred_name='Test', + surname='von Teknolog', + street_address='OK03', + postal_code='33333', + city='Stad3', + phone='040-3', + email='3@teknolog.com', + birth_date='3999-03-03', + student_id='333333', + degree_programme='Energi- och miljöteknik', + enrolment_year=2019, + graduated=True, + graduated_year=2022, + dead=False, + subscribed_to_modulen=True, + allow_publish_info=True, + allow_studentbladet=True, + comment='Kommentar', + ) + + d1 = '1999-01-01' + d2 = '1999-12-31' + + self.d = Decoration.objects.create(name='My decoration') + self.do = DecorationOwnership.objects.create(decoration=self.d, member=self.m1, acquired=d1) + DecorationOwnership.objects.create(decoration=self.d, member=self.m2, acquired=d1) + DecorationOwnership.objects.create(decoration=self.d, member=self.m3, acquired=d1) + + self.ft = FunctionaryType.objects.create(name='My functionarytype') + self.f = Functionary.objects.create(functionarytype=self.ft, member=self.m1, begin_date=d1, end_date=d2) + Functionary.objects.create(functionarytype=self.ft, member=self.m2, begin_date=d1, end_date=d2) + Functionary.objects.create(functionarytype=self.ft, member=self.m3, begin_date=d1, end_date=d2) + + self.gt = GroupType.objects.create(name='My grouptype') + self.g = Group.objects.create(grouptype=self.gt, begin_date=d1, end_date=d1) + self.gm = GroupMembership.objects.create(group=self.g, member=self.m1) + g2 = Group.objects.create(grouptype=self.gt, begin_date=d2, end_date=d2) + GroupMembership.objects.create(group=g2, member=self.m1) + GroupMembership.objects.create(group=g2, member=self.m2) + GroupMembership.objects.create(group=g2, member=self.m3) + + self.mt = MemberType.objects.create(member=self.m1, type='OM', begin_date=d1) + + self.a = Applicant.objects.create( + given_names='Märta', + preferred_name='Märta', + surname='von Teknolog', + birth_date='1993-03-03', + ) + + self.ms = [self.m1, self.m2, self.m3] + + def login_user(self): + self.client.login(username='svakar', password='teknolog') + + def login_superuser(self): + self.client.login(username='superuser', password='teknolog') + + def get_all(self): + return self.client.get(self.api_path) + + def get_one(self, obj): + return self.client.get(f'{self.api_path}{obj.id}/') + + def post(self): + return self.client.post(self.api_path, self.post_data) + + def check_status_code(self, response, status_code): + self.assertEqual(response.status_code, status_code, response.data) + + +''' +Test implementation classes that implements all the tests. The test case classes then sets up their own test data and extends from these classes to get the sutiable tests. +''' + +class CheckJSON(): + def __check_type(self, value, t): + return (t is None and value is None) or type(value) == t + + def __check_types(self, value, t): + if t in [dict, list]: + raise Exception('Be more specific please') + + # Dict works as sub-objects + if type(t) is dict: + return self.__check_type(value, dict) and self.check_response(value, t) + + # List works as sub-arrays + if type(t) is list: + if len(t) != 1: + raise Exception('Can only check the list elements against one type') + + # The required structure is a list + if not self.__check_type(value, list): + return False + + # Getting a list with length 0 is pretty useless, so make a better test + if len(value) == 0: + return False + + # Check the structure for each element + for element in value: + if not self.__check_types(element, t[0]): + return False + return True + + # Tuple works as a union of types + if type(t) is tuple: + ok = False + for tt in t: + ok |= self.__check_type(value, tt) + return ok + + return self.__check_type(value, t) + + def check_response(self, data, structure): + ''' Check that the data from a JSON response has a specific structure ''' + self.assertEqual(len(data), len(structure), f'Length not {len(structure)}: {data}') + for key, types in structure.items(): + self.assertTrue(key in data, f"Key '{key}' not in {data}") + value = data[key] + ok = self.__check_types(value, types) + self.assertTrue(ok, f"Value of key '{key}' is '{value}', which is not of type {types}") + return True + +class GetAllMethodTests(CheckJSON): + def test_get_all_for_anonymous_users(self): + response = self.get_all() + self.check_status_code(response, status.HTTP_403_FORBIDDEN) + + def test_get_all_for_user(self): + self.login_user() + response = self.get_all() + if self.columns_public is None: + return self.check_status_code(response, status.HTTP_403_FORBIDDEN) + self.check_status_code(response, status.HTTP_200_OK) + self.assertEqual(response.json()['count'], self.n_all) + self.check_response(response.json()['results'][0], self.columns_public) + + def test_get_all_for_superuser(self): + self.login_superuser() + response = self.get_all() + self.check_status_code(response, status.HTTP_200_OK) + self.assertEqual(response.json()['count'], self.n_all) + self.check_response(response.json()['results'][0], self.columns_admin) + +class GetOneMethodTests(CheckJSON): + def test_get_one_for_anonymous_users(self): + response = self.get_one(self.item) + self.check_status_code(response, status.HTTP_403_FORBIDDEN) + + def test_get_one_for_user(self): + self.login_user() + response = self.get_one(self.item) + columns = self.columns_public_detail if hasattr(self, 'columns_public_detail') else self.columns_public + if columns is None: + return self.check_status_code(response, status.HTTP_403_FORBIDDEN) + self.check_status_code(response, status.HTTP_200_OK) + self.check_response(response.json(), columns) + + def test_get_one_for_superuser(self): + self.login_superuser() + response = self.get_one(self.item) + self.check_status_code(response, status.HTTP_200_OK) + self.check_response(response.json(), self.columns_admin_detail if hasattr(self, 'columns_admin_detail') else self.columns_admin) + +class PostMethodTests(): + def test_post_for_anonymous_users(self): + response = self.post() + self.check_status_code(response, status.HTTP_403_FORBIDDEN) + + def test_post_for_user(self): + self.login_user() + response = self.post() + self.check_status_code(response, status.HTTP_403_FORBIDDEN) + + def test_post_for_superuser(self): + self.login_superuser() + response = self.post() + self.check_status_code(response, status.HTTP_201_CREATED) + + +''' +Test case classes that extends one or many test implementation classes. Each test case class sets up their own custom test data. +''' + +# MEMBERS + +MEMBER_PUBLIC = { + 'id': int, + 'given_names': str, + 'preferred_name': str, + 'surname': str, + 'n_functionaries': int, + 'n_groups': int, + 'n_decorations': int, +} +MEMBER_PERSONAL = { + **MEMBER_PUBLIC, + 'street_address': str, + 'postal_code': str, + 'city': str, + 'country': str, + 'phone': str, + 'email': str, + 'degree_programme': str, + 'enrolment_year': int, + 'graduated': bool, + 'graduated_year': int, +} +MEMBER_ADMIN = { + **MEMBER_PERSONAL, + 'created': str, + 'modified': str, + 'birth_date': str, + 'student_id': str, + 'dead': bool, + 'subscribed_to_modulen': bool, + 'allow_publish_info': bool, + 'allow_studentbladet': bool, + 'comment': str, + 'username': (str, None), + 'bill_code': (str, None), +} +MEMBER_DETAIL = { + 'functionaries': [{ + 'functionarytype': { + 'id': int, + 'name': str, + }, + 'begin_date': str, + 'end_date': str, + }], + 'groups': [{ + 'grouptype': { + 'id': int, + 'name': str, + }, + 'begin_date': str, + 'end_date': str, + }], + 'decorations': [{ + 'decoration': { + 'id': int, + 'name': str, + }, + 'acquired': str, + }], +} +MEMBER_PUBLIC_DETAIL = { + **MEMBER_PUBLIC, + **MEMBER_DETAIL, +} +MEMBER_PERSONAL_DETAIL = { + **MEMBER_PERSONAL, + **MEMBER_DETAIL, +} +MEMBER_ADMIN_DETAIL = { + **MEMBER_ADMIN, + **MEMBER_DETAIL, +} + +class MembersAPITest(BaseAPITest, GetAllMethodTests, PostMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/members/' + self.columns_public = MEMBER_PUBLIC + self.columns_admin = MEMBER_ADMIN + self.n_all = len(self.ms) + self.post_data = {} + + def test_get_all_structure_for_user(self): + self.login_user() + data = self.get_all().json() + self.assertEqual(3, data['count']) + results = data['results'] + self.check_response(results[0], MEMBER_PUBLIC) + self.check_response(results[1], MEMBER_PUBLIC) + self.check_response(results[2], MEMBER_PERSONAL) + +class MemberHiddenAPITest(BaseAPITest, GetOneMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/members/' + self.item = self.m1 + self.columns_public_detail = MEMBER_PUBLIC_DETAIL + self.columns_admin_detail = MEMBER_ADMIN_DETAIL + + def test_member_given_names_for_user(self): + self.login_user() + data = self.get_one(self.item).json() + self.assertEqual(data.get('given_names'), 'S Svakar') + +class MemberDeadAPITest(BaseAPITest, GetOneMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/members/' + self.item = self.m2 + self.columns_public_detail = MEMBER_PUBLIC_DETAIL + self.columns_admin_detail = MEMBER_ADMIN_DETAIL + + def test_member_given_names_for_user(self): + self.login_user() + data = self.get_one(self.item).json() + self.assertEqual(data.get('given_names'), 'S Svatta') + +class MemberNormalAPITest(BaseAPITest, GetOneMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/members/' + self.item = self.m3 + self.columns_public_detail = MEMBER_PERSONAL_DETAIL + self.columns_admin_detail = MEMBER_ADMIN_DETAIL + + def test_member_given_names_for_user(self): + self.login_user() + data = self.get_one(self.item).json() + self.assertEqual(data.get('given_names'), 'Test Holger Björn-Anders') + +class MemberSearchTest(BaseAPITest): + def setUp(self): + super().setUp() + + # These Members should only be found by staff + Member.objects.create( + given_names='Sverker Svakar', + surname='von Teknolog', + ) + Member.objects.create( + given_names='Test', + surname='von Teknolog', + email='test@svakar.fi', + ) + Member.objects.create( + given_names='Test', + surname='von Teknolog', + comment='Svakar', + ) + + def search(self): + return self.client.get('/api/members/?search=Svakar') + + def test_search_for_anonymous_users(self): + response = self.search() + self.check_status_code(response, status.HTTP_403_FORBIDDEN) + + def test_search_for_user(self): + self.login_user() + response = self.search() + self.check_status_code(response, status.HTTP_200_OK) + self.assertEqual(response.json()['count'], 1) + + def test_search_for_superuser(self): + self.login_superuser() + response = self.search() + self.check_status_code(response, status.HTTP_200_OK) + self.assertEqual(response.json()['count'], 4) + + +# DECORATIONS + +class DecorationsAPITest(BaseAPITest, GetOneMethodTests, GetAllMethodTests, PostMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/decorations/' + self.item = self.d + self.columns_public = {'id': int, 'name': str, 'comment': str, 'n_ownerships': int} + self.columns_public_detail = { + **self.columns_public, + 'ownerships': [{ + 'id': int, + 'acquired': str, + 'member': {'id': int, 'name': str}, + }], + } + self.columns_admin = {**self.columns_public, 'created': str, 'modified': str} + self.columns_admin_detail = {**self.columns_public_detail, 'created': str, 'modified': str} + self.n_all = 1 + self.post_data = {'name': 'test'} + +class DecorationOwnershipsAPITest(BaseAPITest, GetOneMethodTests, GetAllMethodTests, PostMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/decorationownerships/' + self.item = self.do + self.columns_public = { + 'id': int, + 'decoration': { + 'id': int, + 'name': str, + }, + 'member': { + 'id': int, + 'name': str, + }, + 'acquired': str, + } + self.columns_admin = {**self.columns_public, 'created': str, 'modified': str} + self.n_all = 3 + self.post_data = { + 'decoration': self.d.id, + 'member': self.m2.id, + 'acquired': date.today().isoformat(), + } + + +# FUNCTIONARIES + +class FunctionaryTypesAPITest(BaseAPITest, GetOneMethodTests, GetAllMethodTests, PostMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/functionarytypes/' + self.item = self.ft + self.columns_public = { + 'id': int, + 'name': str, + 'comment': str, + 'n_functionaries_total': int, + 'n_functionaries_unique': int, + } + self.columns_public_detail = { + **self.columns_public, + 'functionaries': [{ + 'id': int, + 'begin_date': str, + 'end_date': str, + 'member': {'id': int, 'name': str}, + }], + } + self.columns_admin = {**self.columns_public, 'created': str, 'modified': str} + self.columns_admin_detail = {**self.columns_public_detail, 'created': str, 'modified': str} + self.n_all = 1 + self.post_data = {'name': 'test'} + +class FunctionariesAPITest(BaseAPITest, GetOneMethodTests, GetAllMethodTests, PostMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/functionaries/' + self.item = self.f + self.columns_public = { + 'id': int, + 'functionarytype': { + 'id': int, + 'name': str, + }, + 'member': { + 'id': int, + 'name': str, + }, + 'begin_date': str, + 'end_date': str, + } + self.columns_admin = {**self.columns_public, 'created': str, 'modified': str} + self.n_all = 3 + self.post_data = { + 'functionarytype': self.ft.id, + 'member': self.m2.id, + 'begin_date': date.today().isoformat(), + 'end_date': date.today().isoformat(), + } + + +# GROUPS + +class GroupTypesAPITest(BaseAPITest, GetOneMethodTests, GetAllMethodTests, PostMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/grouptypes/' + self.item = self.gt + self.columns_public = { + 'id': int, + 'name': str, + 'comment': str, + 'n_groups': int, + 'n_members_total': int, + 'n_members_unique': int, + } + self.columns_public_detail = { + **self.columns_public, + 'groups': [{ + 'id': int, + 'begin_date': str, + 'end_date': str, + 'n_members': int, + }] + } + self.columns_admin = {**self.columns_public, 'created': str, 'modified': str} + self.columns_admin_detail = {**self.columns_public_detail, 'created': str, 'modified': str} + self.n_all = 1 + self.post_data = {'name': 'test'} + +class GroupsAPITest(BaseAPITest, GetOneMethodTests, GetAllMethodTests, PostMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/groups/' + self.item = self.g + self.columns_public = { + 'id': int, + 'grouptype': { + 'id': int, + 'name': str, + }, + 'begin_date': str, + 'end_date': str, + 'n_members': int, + } + self.columns_public_detail = { + **self.columns_public, + 'members': [{ + 'id': int, + 'name': str, + }], + } + self.columns_admin = {**self.columns_public, 'created': str, 'modified': str} + self.columns_admin_detail = {**self.columns_public_detail, 'created': str, 'modified': str} + self.n_all = 2 + self.post_data = { + 'grouptype': self.gt.id, + 'begin_date': date.today().isoformat(), + 'end_date': date.today().isoformat(), + } + +class GroupMembershipsAPITest(BaseAPITest, GetOneMethodTests, GetAllMethodTests, PostMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/groupmemberships/' + self.item = self.gm + self.columns_public = { + 'id': int, + 'group': { + 'id': int, + 'grouptype': { + 'id': int, + 'name': str, + }, + 'begin_date': str, + 'end_date': str, + }, + 'member': { + 'id': int, + 'name': str, + }, + } + self.columns_admin = {**self.columns_public, 'created': str, 'modified': str} + self.n_all = 4 + self.post_data = { + 'group': self.g.id, + 'member': self.m2.id, + } + + +# MEMBERTYPES + +class MemberTypesAPITest(BaseAPITest, GetOneMethodTests, GetAllMethodTests, PostMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/membertypes/' + self.item = self.mt + self.columns_public = None + self.columns_admin = { + 'id': int, + 'begin_date': str, + 'end_date': (str, None), + 'type': str, + 'member': { + 'id': int, + 'name': str, + }, + 'created': str, + 'modified': str, + } + self.n_all = 1 + self.post_data = { + 'member': self.m2.id, + 'type': 'OM', + 'begin_date': date.today().isoformat(), + } + + +# APPLICANTS + +class ApplicantsAPITest(BaseAPITest, GetOneMethodTests, GetAllMethodTests, PostMethodTests): + def setUp(self): + super().setUp() + self.api_path = '/api/applicants/' + self.item = self.mt + self.columns_public = None + self.columns_admin = { + 'id': int, + 'surname': str, + 'given_names': str, + 'preferred_name': str, + 'street_address': str, + 'postal_code': str, + 'city': str, + 'country': str, + 'phone': str, + 'email': str, + 'birth_date': str, + 'student_id': str, + 'degree_programme': str, + 'enrolment_year': int, + 'username': (str, None), + 'motivation': str, + 'subscribed_to_modulen': bool, + 'allow_publish_info': bool, + 'allow_studentbladet': bool, + 'mother_tongue': str, + 'created_at': str, + } + self.n_all = 1 + self.post_data = { + 'surname': 'Test', + 'given_names': 'Test', + 'street_address': 'Test', + 'postal_code': 'Test', + 'city': 'Test', + 'phone': 'Test', + 'email': 'test@test.com', + 'birth_date': '1999-01-01', + 'student_id': "123456", + 'degree_programme': 'Test', + } + +class RootAPIPageTests(BaseAPITest): + def setUp(self): + super().setUp() + self.api_path = '/api/' + + def test_get_for_anonymous_users(self): + response = self.get_all() + self.check_status_code(response, status.HTTP_403_FORBIDDEN) + + def test_get_for_user(self): + self.login_user() + response = self.get_all() + self.check_status_code(response, status.HTTP_200_OK) + self.assertEqual(10, len(response.json())) + + def test_get_for_superuser(self): + self.login_superuser() + response = self.get_all() + self.check_status_code(response, status.HTTP_200_OK) + self.assertEqual(10, len(response.json())) diff --git a/teknologr/api/tests_bill.py b/teknologr/api/tests_bill.py new file mode 100644 index 00000000..0e80939f --- /dev/null +++ b/teknologr/api/tests_bill.py @@ -0,0 +1,103 @@ +from django.contrib.auth.models import User +from members.models import * +from rest_framework import status +from rest_framework.test import APITestCase + +class BaseClass(APITestCase): + def setUp(self): + self.user = User.objects.create_user(username='svakar', password='teknolog') + self.superuser = User.objects.create_superuser(username='superuser', password='teknolog') + + self.member = Member.objects.create( + given_names='Sverker Svakar', + preferred_name='Svakar', + surname='von Teknolog', + student_id='123456', + username='vonteks1', + ) + + self.d1 = '2000-01-01' + self.d2 = '2000-07-07' + + MemberType.objects.create(member=self.member, type='PH', begin_date=self.d1, end_date=self.d2) + MemberType.objects.create(member=self.member, type='OM', begin_date=self.d2) + + def login_user(self): + self.client.login(username='svakar', password='teknolog') + + def login_superuser(self): + self.client.login(username='superuser', password='teknolog') + + +class BILLTestCases: + def test_get_for_anonymous_users(self): + response = self.get() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_for_user(self): + self.login_user() + response = self.get() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_for_superuser(self): + self.login_superuser() + response = self.get() + self.assertEqual(response.status_code, status.HTTP_200_OK) + if self.response is None: + self.assertEqual(self.response, response.data) + else: + self.assertEqual(self.response, response.json()) + +class BILLByUsernameTestCases(BILLTestCases): + def get(self): + return self.client.get(f'/api/memberTypesForMember/username/{self.username}/') + +class BILLByStudynumberTestCases(BILLTestCases): + def get(self): + return self.client.get(f'/api/memberTypesForMember/studynumber/{self.studynumber}/') + + +RESPONSE = { + 'given_names': [ + 'Sverker', + 'Svakar', + ], + 'surname': 'von Teknolog', + 'nickname': '', + 'preferred_name': 'Svakar', + 'membertypes': { + 'OM': [ + '2000-07-07', + 'None' + ], + 'PH': [ + '2000-01-01', + '2000-07-07' + ] + } +} + +class BILLByInvalidUsernameTests(BaseClass, BILLByUsernameTestCases): + def setUp(self): + super().setUp() + self.username = 'invalid' + self.response = None + +class BILLByValidUsernameTests(BaseClass, BILLByUsernameTestCases): + def setUp(self): + super().setUp() + self.username = self.member.username + self.response = RESPONSE + + +class BILLByInvalidStudynumberTests(BaseClass, BILLByStudynumberTestCases): + def setUp(self): + super().setUp() + self.studynumber = '123321' + self.response = None + +class BILLByValidStudynumberTests(BaseClass, BILLByStudynumberTestCases): + def setUp(self): + super().setUp() + self.studynumber = self.member.student_id + self.response = RESPONSE diff --git a/teknologr/api/tests_dumps.py b/teknologr/api/tests_dumps.py new file mode 100644 index 00000000..441a04de --- /dev/null +++ b/teknologr/api/tests_dumps.py @@ -0,0 +1,130 @@ +from django.contrib.auth.models import User +from members.models import * +from registration.models import * +from rest_framework import status +from rest_framework.test import APITestCase +from datetime import datetime + +today = datetime.today().strftime('%Y-%m-%d') + +class BaseClass(APITestCase): + def setUp(self): + self.user = User.objects.create_user(username='svakar', password='teknolog') + self.superuser = User.objects.create_superuser(username='superuser', password='teknolog') + + m = Member.objects.create( + given_names='Sverker Svakar', + preferred_name='Svakar', + surname='von Teknolog', + street_address='OK22', + postal_code='12345', + city='Kouvola', + country='FI', + student_id='123456', + username='vonteks1', + subscribed_to_modulen=True, + allow_studentbladet=True, + ) + + ft = FunctionaryType.objects.create(name='Funkkis') + Functionary.objects.create(member=m, functionarytype=ft, begin_date=today, end_date='2999-01-01') + d = Decoration.objects.create(name='Hedersmedlem', pk=3) + DecorationOwnership.objects.create(member=m, decoration=d, acquired='1999-01-01') + MemberType.objects.create(member=m, type='OM', begin_date='1999-01-01') + + Applicant.objects.create( + given_names='Siri Svatta', + preferred_name='Svatta', + surname='von Teknolog', + email='svatta@svatta.fi', + mother_tongue='Svenska', + birth_date='1999-01-01', + ) + + def login_user(self): + self.client.login(username='svakar', password='teknolog') + + def login_superuser(self): + self.client.login(username='superuser', password='teknolog') + + +class DumpsTestCases(): + def get(self): + return self.client.get(self.path) + + def test_get_for_anonymous_users(self): + response = self.get() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_for_user(self): + self.login_user() + response = self.get() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_for_superuser(self): + self.login_superuser() + response = self.get() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.response, response.json()) + +class HTK(BaseClass, DumpsTestCases): + path = f'/api/dump-htk/' + response = [{ + 'id': 1, + 'name': 'Sverker Svakar von Teknolog', + 'functionaries': [f'Funkkis: {today} > 2999-01-01'], + 'groups': [], + 'membertypes': ['Ordinarie Medlem: 1999-01-01 > None'], + 'decorations': ['Hedersmedlem: 1999-01-01'], + }] + +class Modulen(BaseClass, DumpsTestCases): + path = f'/api/dump-modulen/' + response = [{ + 'given_names': 'Sverker Svakar', + 'preferred_name': 'Svakar', + 'surname': 'von Teknolog', + 'street_address': 'OK22', + 'postal_code': '12345', + 'city': 'Kouvola', + 'country': 'Finland', + }] + +class Active(BaseClass, DumpsTestCases): + path = f'/api/dump-active/' + response = [{ + 'position': 'Funkkis', + 'member': 'Sverker Svakar von Teknolog', + }] + +class Arsk(BaseClass, DumpsTestCases): + path = f'/api/dump-arsk/' + response = [{ + 'name': 'Sverker Svakar', + 'surname': 'von Teknolog', + 'street_address': 'OK22', + 'postal_code': '12345', + 'city': 'Kouvola', + 'country': 'Finland', + 'associations': 'Hedersmedlem,Funkkis', + }] + +class RegEmails(BaseClass, DumpsTestCases): + path = f'/api/dump-regemails/' + response = [{ + 'name': 'Siri Svatta', + 'surname': 'von Teknolog', + 'preferred_name': 'Svatta', + 'email': 'svatta@svatta.fi', + 'language': 'Svenska', + }] + +class Studentbladet(BaseClass, DumpsTestCases): + path = f'/api/dump-studentbladet/' + response = [{ + 'name': 'Sverker Svakar von Teknolog', + 'street_address': 'OK22', + 'postal_code': '12345', + 'city': 'Kouvola', + 'country': 'FI', + }] diff --git a/teknologr/api/tests_filter.py b/teknologr/api/tests_filter.py new file mode 100644 index 00000000..77feab7e --- /dev/null +++ b/teknologr/api/tests_filter.py @@ -0,0 +1,838 @@ +from django.contrib.auth.models import User, Group +from members.models import * +from rest_framework import status +from rest_framework.test import APITestCase +from datetime import date + +''' +This file tests the filtering on the Members API. +''' + +class BaseAPITest(APITestCase): + def setUp(self): + self.user = User.objects.create_user(username='svakar', password='teknolog') + self.superuser = User.objects.create_superuser(username='superuser', password='teknolog') + + # This Member should almost never be found + Member.objects.create( + given_names='Dummy', + surname='von Dummy', + street_address='Dummyvägen 42', + city='Dummystaden', + country='SE', + email='dummy@dummy.net', + degree_programme='Dummylinjen', + enrolment_year=1999, + graduated=False, + graduated_year=None, + birth_date=date(1999, 1, 1), + student_id='12345L', + dead=False, + subscribed_to_modulen=False, + allow_studentbladet=False, + allow_publish_info=True, + comment='Dummy', + username='dummyd1', + bill_code=42, + ) + + def login_user(self): + self.client.login(username='svakar', password='teknolog') + + def login_superuser(self): + self.client.login(username='superuser', password='teknolog') + +class TestCases(): + def filter(self): + return self.client.get(f'/api/members/?{self.query}') + + def test_filter_for_anonymous_users(self): + response = self.filter() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN, response.data) + + def test_filter_for_user(self): + self.login_user() + response = self.filter() + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + self.assertEqual(response.json()['count'], self.n_normal) + + def test_filter_for_superuser(self): + self.login_superuser() + response = self.filter() + self.assertEqual(response.status_code, status.HTTP_200_OK, response.data) + self.assertEqual(response.json()['count'], self.n_staff) + + +class MemberFilterSingleNameTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'name=Svakar' + self.n_normal = 3 + self.n_staff = 5 + + # Should be found by all + Member.objects.create( + # In preferred name, even if hidden and dead + given_names='Sverker Svakar', + preferred_name='Svakar', + surname='von Teknolog', + allow_publish_info=False, + dead=True, + ) + Member.objects.create( + # In surname, even if hidden and dead + given_names='Sverker', + surname='von Svakar', + allow_publish_info=False, + dead=True, + ) + Member.objects.create( + # In given names for public Member + given_names='Sverker Svakar', + surname='von Teknolog', + allow_publish_info=True, + dead=False, + ) + + # Should only be found by staff + Member.objects.create( + # No preferred name on hidden Member + given_names='Sverker Svakar', + surname='von Teknolog', + allow_publish_info=False, + dead=False, + ) + Member.objects.create( + # In given names but dead + given_names='Sverker Svakar', + surname='von Teknolog', + allow_publish_info=True, + dead=True, + ) + + +class MemberFilterMultipleNamesTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'name=Svakar+von+Teknolog' + self.n_normal = 3 + self.n_staff = 4 + + # Should be found by all + Member.objects.create( + # Part of preferred and surname name, even if hidden and dead + given_names='Sverker Svakar', + preferred_name='Svakar', + surname='von Teknolog', + allow_publish_info=False, + dead=True, + ) + Member.objects.create( + # Part of given names and surname for public Member + given_names='Sverker Svakar', + surname='von Teknolog', + allow_publish_info=True, + dead=False, + ) + + # Should only be found by staff + Member.objects.create( + # Part of given names and surname for hidden Member + given_names='Sverker Svakar', + surname='von Teknolog', + allow_publish_info=False, + dead=False, + ) + + # Should not be found + Member.objects.create( + given_names='Sverker Svakar', + preferred_name='Sverker', + surname='von Teknolog', + allow_publish_info=True, + ) + Member.objects.create( + given_names='Sverker Svakar', + preferred_name='Svakar', + surname='Teknolog', + allow_publish_info=True, + ) + Member.objects.create( + given_names='Sverker Svakar', + preferred_name='Svakar', + surname='von Svakar', + allow_publish_info=True, + ) + + +class MemberFilterSingleAddressTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'address=fi' + self.n_normal = 3 + self.n_staff = 5 + + # Should be found by all + Member.objects.create( + # In street name on public Member + given_names='Test', + surname='von Test', + street_address='Filipgatan 42', + allow_publish_info=True, + ) + Member.objects.create( + # In city on public Member + given_names='Test', + surname='von Test', + city='Filipstad', + allow_publish_info=True, + ) + Member.objects.create( + # In country on public Member + given_names='Test', + surname='von Test', + country='FI', + allow_publish_info=True, + ) + + # Should only be found by staff + Member.objects.create( + # Hidden member + given_names='Test', + surname='von Test', + street_address='Filipgatan 42', + city='Filipstad', + country='FI', + allow_publish_info=False, + dead=False, + ) + Member.objects.create( + # Dead member + given_names='Test', + surname='von Test', + street_address='Filipgatan 42', + city='Filipstad', + country='FI', + allow_publish_info=True, + dead=True, + ) + + +class MemberFilterMultipleAddressesTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'address=filip+42' + self.n_normal = 2 + self.n_staff = 4 + + # Should be found by all + Member.objects.create( + # In city and street name on public Member + given_names='Test', + surname='von Test', + street_address='Vägen 42', + city='Filipstad', + allow_publish_info=True, + ) + Member.objects.create( + # In city and postal code public Member + given_names='Test', + surname='von Test', + city='Filipstad', + postal_code=1423, + allow_publish_info=True, + ) + + # Should only be found by staff + Member.objects.create( + # Hidden member + given_names='Test', + surname='von Test', + street_address='Filipgatan 42', + allow_publish_info=False, + dead=False, + ) + Member.objects.create( + # Dead member + given_names='Test', + surname='von Test', + street_address='Filipgatan 42', + allow_publish_info=True, + dead=True, + ) + + +class MemberFilterEmailTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'email=Svakar' + self.n_normal = 1 + self.n_staff = 3 + + # Should be found by all + Member.objects.create( + given_names='Test', + surname='von Test', + email='test@svakar.fi', + allow_publish_info=True, + ) + + # Should only be found by staff + Member.objects.create( + # Hidden Member + given_names='Test', + surname='von Test', + email='test@svakar.fi', + allow_publish_info=False, + dead=False, + ) + Member.objects.create( + # Dead Member + given_names='Test', + surname='von Test', + email='test@svakar.fi', + allow_publish_info=True, + dead=True, + ) + + +class MemberFilterDegreeProgrammeTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'degree_programme=Svakar' + self.n_normal = 1 + self.n_staff = 2 + + # Should be found by all + Member.objects.create( + given_names='Test', + surname='von Test', + degree_programme='Svakars linje', + allow_publish_info=True, + dead=False, + ) + + # Should only be found by staff + Member.objects.create( + given_names='Test', + surname='von Test', + degree_programme='Svakars linje', + allow_publish_info=False, + dead=True, + ) + + +class MemberFilterEnrolmentYearTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'enrolment_year_min=2000&enrolment_year_max=2010' + self.n_normal = 3 + self.n_staff = 3 + + # Should be found by all + Member.objects.create( + # Inside range + given_names='Test', + surname='von Test', + enrolment_year=2000, + allow_publish_info=False, + dead=True, + ) + Member.objects.create( + # Inside range + given_names='Test', + surname='von Test', + enrolment_year=2005, + allow_publish_info=False, + dead=True, + ) + Member.objects.create( + # Inside range + given_names='Test', + surname='von Test', + enrolment_year=2010, + allow_publish_info=False, + dead=True, + ) + + # Should not be found + Member.objects.create( + # Outside range + given_names='Test', + surname='von Test', + enrolment_year=1999, + allow_publish_info=True, + dead=False, + ) + Member.objects.create( + # Outside range + given_names='Test', + surname='von Test', + enrolment_year=2011, + allow_publish_info=True, + dead=False, + ) + + +class MemberFilterGraduatedTrueTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'graduated=true' + self.n_normal = 2 + self.n_staff = 3 + + # Should be found by all + Member.objects.create( + # Graduated + given_names='Test', + surname='von Test', + graduated=True, + allow_publish_info=True, + ) + Member.objects.create( + # Has graduated year (XXX: graduated can be false) + given_names='Test', + surname='von Test', + graduated=False, + graduated_year=2000, + allow_publish_info=True, + ) + + # Should only be found by staff + Member.objects.create( + # Graduated, but hidden + given_names='Test', + surname='von Test', + graduated=True, + allow_publish_info=False, + ) + + # Should not be found + Member.objects.create( + # Not graduated + given_names='Test', + surname='von Test', + graduated=False, + allow_publish_info=True, + ) + +class MemberFilterGraduatedFalseTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + # Dummy member included + self.query = 'graduated=false' + self.n_normal = 2 + self.n_staff = 3 + + # Should be found by all + Member.objects.create( + given_names='Test', + surname='von Test', + graduated=False, + allow_publish_info=True, + ) + + # Should only be found by staff + Member.objects.create( + # Graduated and has graduated year + given_names='Test', + surname='von Test', + graduated=False, + allow_publish_info=False, + ) + + # Should not be found + Member.objects.create( + # Not graduated + given_names='Test', + surname='von Test', + graduated=True, + allow_publish_info=True, + ) + Member.objects.create( + # Has graduated year (XXX: graduated can be false) + given_names='Test', + surname='von Test', + graduated=False, + graduated_year=2000, + allow_publish_info=True, + ) + +class MemberFilterGraduatedNullTest(MemberFilterGraduatedTrueTest): + def setUp(self): + super().setUp() + + # graduated is the only BooleanFilter on a hidable field, so trying out the null label for it + # Dummy member included + self.query = 'graduated=' + self.n_normal = len(Member.objects.all()) + self.n_staff = self.n_normal + +class MemberFilterGraduatedInvalidTest(MemberFilterGraduatedTrueTest): + def setUp(self): + super().setUp() + + # graduated is the only BooleanFilter on a hidable field, so trying out an invalid label for it + # Dummy member included + # XXX: This should probably return all Members, but now the filter is being recognized as "set", so all hidden Members will be filtered out for normal users + self.query = 'graduated=unknown' + self.n_normal = 4 + self.n_staff = 5 + +class MemberFilterGraduatedYearTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'graduated_year_min=2000&graduated_year_max=2010' + self.n_normal = 3 + self.n_staff = 3 + + # Should be found by all + Member.objects.create( + # Inside range (XXX: Graduated can be false) + given_names='Test', + surname='von Test', + graduated=False, + graduated_year=2000, + allow_publish_info=False, + dead=True, + ) + Member.objects.create( + # Inside range + given_names='Test', + surname='von Test', + graduated=False, + graduated_year=2005, + allow_publish_info=False, + dead=True, + ) + Member.objects.create( + # Inside range + given_names='Test', + surname='von Test', + graduated=False, + graduated_year=2010, + allow_publish_info=False, + dead=True, + ) + + # Should not be found + Member.objects.create( + # Outside range + given_names='Test', + surname='von Test', + graduated=True, + graduated_year=1999, + allow_publish_info=True, + dead=False, + ) + Member.objects.create( + # Outside range + given_names='Test', + surname='von Test', + graduated=True, + graduated_year=2011, + allow_publish_info=True, + dead=False, + ) + + +class MemberFilterBirthDateTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'birth_date_after=2000-1-1&birth_date_before=2010-12-31' + self.n_normal = 6 # Filter does not work for normal users + self.n_staff = 3 + + # Should only be found by staff + Member.objects.create( + # Inside range + given_names='Test', + surname='von Test', + birth_date='2000-01-01', + allow_publish_info=True, + dead=False, + ) + Member.objects.create( + # Inside range + given_names='Test', + surname='von Test', + birth_date='2005-07-07', + allow_publish_info=True, + dead=False, + ) + Member.objects.create( + # Inside range + given_names='Test', + surname='von Test', + birth_date='2010-12-31', + allow_publish_info=True, + dead=False, + ) + + # Should not be found + Member.objects.create( + # Inside range + given_names='Test', + surname='von Test', + birth_date='1999-12-31', + allow_publish_info=True, + dead=False, + ) + Member.objects.create( + # Inside range + given_names='Test', + surname='von Test', + birth_date='2011-01-01', + allow_publish_info=True, + dead=False, + ) + + +class MemberFilterStudentIdTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'student_id=12345' + self.n_normal = 3 # Filter does not work for normal users + self.n_staff = 1 + + # Should only be found by staff + Member.objects.create( + given_names='Test', + surname='von Test', + student_id='12345', + allow_publish_info=True, + dead=False, + ) + + # Should not be found + Member.objects.create( + # Not correct student id + given_names='Test', + surname='von Test', + student_id='123456', + allow_publish_info=True, + dead=False, + ) + + +class MemberFilterDeadTrueTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'dead=true' + self.n_normal = 3 # Filter does not work for normal users + self.n_staff = 1 + + # Should only be found by staff + Member.objects.create( + given_names='Test', + surname='von Test', + allow_publish_info=True, + dead=True, + ) + + # Should not be found + Member.objects.create( + # Not dead + given_names='Test', + surname='von Test', + allow_publish_info=True, + dead=False, + ) + +class MemberFilterDeadFalseTest(MemberFilterDeadTrueTest): + def setUp(self): + super().setUp() + + self.query = self.query.replace('true', 'false') + self.n_staff = self.n_normal - self.n_staff + + +class MemberFilterModulenTrueTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'subscribed_to_modulen=true' + self.n_normal = 3 # Filter does not work for normal users + self.n_staff = 1 + + # Should only be found by staff + Member.objects.create( + given_names='Test', + surname='von Test', + subscribed_to_modulen=True, + dead=True, + ) + + # Should not be found + Member.objects.create( + # Not subscribed + given_names='Test', + surname='von Test', + allow_publish_info=True, + dead=False, + ) + +class MemberFilterModulenFalseTest(MemberFilterModulenTrueTest): + def setUp(self): + super().setUp() + + self.query = self.query.replace('true', 'false') + self.n_staff = self.n_normal - self.n_staff + + +class MemberFilterStudentbladetTrueTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'allow_studentbladet=true' + self.n_normal = 3 # Filter does not work for normal users + self.n_staff = 1 + + # Should only be found by staff + Member.objects.create( + given_names='Test', + surname='von Test', + allow_studentbladet=True, + dead=True, + ) + + # Should not be found + Member.objects.create( + # Not subscribed + given_names='Test', + surname='von Test', + allow_publish_info=True, + dead=False, + ) + +class MemberFilterStudentbladetFalseTest(MemberFilterStudentbladetTrueTest): + def setUp(self): + super().setUp() + + self.query = self.query.replace('true', 'false') + self.n_staff = self.n_normal - self.n_staff + + +class MemberFilterPublishInfoTrueTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'allow_publish_info=true' + self.n_normal = 3 # Filter does not work for normal users + self.n_staff = 2 + + # Should only be found by staff + Member.objects.create( + given_names='Test', + surname='von Test', + allow_publish_info=True, + dead=True, + ) + + # Should not be found + Member.objects.create( + # Not subscribed + given_names='Test', + surname='von Test', + dead=False, + ) + +class MemberFilterPublishInfoFalseTest(MemberFilterPublishInfoTrueTest): + def setUp(self): + super().setUp() + + self.query = self.query.replace('true', 'false') + self.n_staff = self.n_normal - self.n_staff + + +class MemberFilterCommentTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'comment=svakar' + self.n_normal = 3 # Filter does not work for normal users + self.n_staff = 1 + + # Should only be found by staff + Member.objects.create( + given_names='Test', + surname='von Test', + comment='f. von Svakar', + allow_publish_info=True, + dead=False, + ) + + # Should not be found + Member.objects.create( + given_names='Test', + surname='von Test', + allow_publish_info=True, + dead=False, + ) + + +class MemberFilterUsernameTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'username=svakar' + self.n_normal = 3 # Filter does not work for normal users + self.n_staff = 1 + + # Should only be found by staff + Member.objects.create( + given_names='Test', + surname='von Test', + username='svakar', + allow_publish_info=True, + dead=False, + ) + + # Should not be found + Member.objects.create( + given_names='Test', + surname='von Test', + username='svakar2', + allow_publish_info=True, + dead=False, + ) + + +class MemberFilterBillCodeTest(BaseAPITest, TestCases): + def setUp(self): + super().setUp() + + self.query = 'bill_code=1234' + self.n_normal = 3 # Filter does not work for normal users + self.n_staff = 1 + + # Should only be found by staff + Member.objects.create( + given_names='Test', + surname='von Test', + bill_code=1234, + allow_publish_info=True, + dead=False, + ) + + # Should not be found + Member.objects.create( + given_names='Test', + surname='von Test', + bill_code='12345', + allow_publish_info=True, + dead=False, + ) diff --git a/teknologr/api/tests_generikey.py b/teknologr/api/tests_generikey.py new file mode 100644 index 00000000..f674f988 --- /dev/null +++ b/teknologr/api/tests_generikey.py @@ -0,0 +1,109 @@ +from django.contrib.auth.models import User +from members.models import * +from rest_framework import status +from rest_framework.test import APITestCase + +class BaseClass(APITestCase): + def setUp(self): + self.user = User.objects.create_user(username='svakar', password='teknolog') + self.superuser = User.objects.create_superuser(username='superuser', password='teknolog') + + self.m1 = Member.objects.create( + given_names='Sverker Svakar', + preferred_name='Svakar', + surname='von Teknolog', + student_id='123456', + username='vonteks1', + ) + self.m2 = Member.objects.create( + given_names='Svatta', + surname='von Teknolog', + student_id='654321', + username='vonteks2', + ) + self.m3 = Member.objects.create( + given_names='Simon', + surname='von Teknolog', + ) + + d1 = '2000-01-01' + d2 = '2010-01-01' + + MemberType.objects.create(member=self.m1, type='PH', begin_date=d1, end_date=d2) + MemberType.objects.create(member=self.m1, type='OM', begin_date=d2) + MemberType.objects.create(member=self.m1, type='JS', begin_date=d1) + + MemberType.objects.create(member=self.m2, type='PH', begin_date=d1, end_date=d2) + MemberType.objects.create(member=self.m2, type='OM', begin_date=d2) + MemberType.objects.create(member=self.m2, type='ST', begin_date=d1) + MemberType.objects.create(member=self.m2, type='ST', begin_date=d2) + + MemberType.objects.create(member=self.m3, type='JS', begin_date=d1) + MemberType.objects.create(member=self.m3, type='ST', begin_date=d2) + MemberType.objects.create(member=self.m3, type='ST', begin_date=d2) + + def login_superuser(self): + self.client.login(username='superuser', password='teknolog') + +class GenerikeyTestCases(): + def test_get_for_anonymous_users(self): + response = self.get('PH') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_for_user(self): + self.client.login(username='svakar', password='teknolog') + response = self.get('PH') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_not_found(self): + self.login_superuser() + response = self.get('XXX') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_get_invalid(self): + self.login_superuser() + response = self.get('XX') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), []) + + def test_get_all_ended(self): + self.login_superuser() + response = self.get('PH') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), []) + + def test_get_normal(self): + self.login_superuser() + response = self.get('OM') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), self.normal) + + def test_get_null(self): + self.login_superuser() + response = self.get('JS') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), self.null) + + def test_get_double(self): + self.login_superuser() + response = self.get('ST') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), self.double) + + +class GenerikeyStudynumbersTestCases(BaseClass, GenerikeyTestCases): + normal = ['123456', '654321'] + null = ['123456', None] + double = ['654321', None] + + def get(self, type): + return self.client.get(f'/api/membersByMemberType/{type}/') + + +class GenerikeyUsernamesTestCases(BaseClass, GenerikeyTestCases): + normal = ['vonteks1', 'vonteks2'] + null = ['vonteks1', None] + double = ['vonteks2', None] + + def get(self, type): + return self.client.get(f'/api/membersByMemberType/{type}/usernames') diff --git a/teknologr/api/urls.py b/teknologr/api/urls.py index 7670da15..414b0005 100644 --- a/teknologr/api/urls.py +++ b/teknologr/api/urls.py @@ -1,9 +1,16 @@ from django.conf.urls import url, include -from rest_framework import routers +from rest_framework import routers, permissions from api.views import * +class RootView(routers.APIRootView): + permission_classes = (permissions.IsAuthenticated, ) + name = 'Katalogen root API' + description = '' + # Routers provide an easy way of automatically determining the URL conf. +# NOTE: Use for example {% url 'api:member-list' %} to access these urls router = routers.DefaultRouter() +router.APIRootView = RootView router.register(r'members', MemberViewSet) router.register(r'grouptypes', GroupTypeViewSet) router.register(r'groups', GroupViewSet) @@ -30,11 +37,9 @@ url(r'^applicants/make-member/(\d+)/$', ApplicantMembershipView.as_view()), url(r'^dump-htk/(\d+)?$', dump_htk, name='dump_htk'), url(r'^dump-modulen/$', dump_modulen, name='dump_modulen'), - url(r'^dump-full/$', dump_full, name='dump_full'), url(r'^dump-active/$', dump_active, name='dump_active'), url(r'^dump-arsk/$', dump_arsk, name='dump_arsk'), url(r'^dump-regemails/$', dump_reg_emails, name='dump_reg_emails'), - url(r'^dump-applicantlanguages/$', dump_applicant_languages, name='dump_applicant_languages'), url(r'^dump-studentbladet/$', dump_studentbladet, name='dump_studentbladet'), # Used by BILL url(r'^memberTypesForMember/(?Pusername|studynumber)/(?P[A-Za-z0-9]+)/$', member_types_for_member), diff --git a/teknologr/api/utils.py b/teknologr/api/utils.py index dc4fc713..65c0fcf2 100644 --- a/teknologr/api/utils.py +++ b/teknologr/api/utils.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- from django.db.models import Q +from rest_framework.renderers import BrowsableAPIRenderer +from rest_framework.pagination import LimitOffsetPagination from members.models import Member from registration.models import Applicant -from datetime import date +from datetime import datetime +from rest_framework.response import Response def findMembers(query, count=50): @@ -29,3 +32,37 @@ def findApplicants(query, count=50): return [] return Applicant.objects.filter(*args).order_by('surname', 'given_names')[:count] + + +def create_dump_response(content, name, filetype): + dumpname = f'filename="{name}_{datetime.today().strftime("%Y-%m-%d_%H-%M-%S")}.{filetype}' + return Response( + content, + status=200, + headers={'Content-Disposition': f'attachment; {dumpname}'} + ) + +def assert_public_member_fields(fields): + assert len(set(fields).intersection(set(Member.STAFF_ONLY_FIELDS + Member.HIDABLE_FIELDS))) == 0, 'Only 100% public Member fields allowed' + + +class BrowsableAPIRendererWithoutForms(BrowsableAPIRenderer): + """ Custom renderer for the browsable API that hides the form. """ + + def get_context(self, *args, **kwargs): + ctx = super().get_context(*args, **kwargs) + ctx['display_edit_forms'] = False + return ctx + + def show_form_for_method(self, view, method, request, obj): + return False + + def get_rendered_html_form(self, data, view, method, request): + return '' + + +class Pagination(LimitOffsetPagination): + default_limit = 100 + limit_query_param = 'limit' + offset_query_param = 'offset' + max_limit = 1000 diff --git a/teknologr/api/views.py b/teknologr/api/views.py index c260d408..3fba193f 100644 --- a/teknologr/api/views.py +++ b/teknologr/api/views.py @@ -2,49 +2,120 @@ from django.db import connection from django.db.models import Q from django.db.utils import IntegrityError -from rest_framework import viewsets -from api.serializers import * +from django_filters import rest_framework as filters +from rest_framework import viewsets, permissions from rest_framework.views import APIView -from rest_framework.decorators import api_view, renderer_classes +from rest_framework.filters import SearchFilter, OrderingFilter from rest_framework.response import Response -from members.models import GroupMembership, Member, Group -from members.programmes import DEGREE_PROGRAMME_CHOICES -from registration.models import Applicant -from api.ldap import LDAPAccountManager +from rest_framework.decorators import api_view from ldap import LDAPError -from api.bill import BILLAccountManager, BILLException -from rest_framework_csv import renderers as csv_renderer -from api.mailutils import mailNewPassword, mailNewAccount from collections import defaultdict from datetime import datetime +from api.serializers import * +from api.filters import * +from api.ldap import LDAPAccountManager +from api.bill import BILLAccountManager, BILLException +from api.utils import assert_public_member_fields +from api.mailutils import mailNewPassword, mailNewAccount +from members.models import GroupMembership, Member, Group +from members.programmes import DEGREE_PROGRAMME_CHOICES +from registration.models import Applicant -# Create your views here. # ViewSets define the view behavior. -# Members +class APIPermissions(permissions.BasePermission): + def has_permission(self, request, view): + # Do not allow anything for un-authenticated users + if not request.user.is_authenticated: + return False + # Allow everything for superusers and staff + if request.user.is_staff: + return True -class MemberViewSet(viewsets.ModelViewSet): - queryset = Member.objects.all() - serializer_class = MemberSerializer + # Allow safe methods for the rest + return request.method in permissions.SAFE_METHODS +class BaseModelViewSet(viewsets.ModelViewSet): + # Use custom permissions + permission_classes = (APIPermissions, ) -# Groups + def get_serializer(self, *args, **kwargs): + serializer_class = self.get_serializer_class() + kwargs['detail'] = self.action == 'retrieve' + kwargs['is_staff'] = self.request.user.is_staff + return serializer_class(*args, **kwargs) -class GroupViewSet(viewsets.ModelViewSet): - queryset = Group.objects.all() - serializer_class = GroupSerializer +class MemberSearchFilter(SearchFilter): + ''' + A custom SearchFilter class for Members that restricts the searchable columns to non-staff. -class GroupTypeViewSet(viewsets.ModelViewSet): + XXX: It would be nice to have for example 'email' be searchable for everyone, but how to restrict the search to only those Members that allow their info to be published? + ''' + + def get_search_fields(self, view, request): + # By default only allow searching in a few 100% public fields + fields = ['preferred_name', 'surname'] + assert_public_member_fields(fields) + + # Staff get to search in a few more fields + if request.user.is_staff: + fields += ['given_names', 'email', 'comment', 'username'] + + return fields + +class MemberViewSet(BaseModelViewSet): + queryset = Member.objects.all_with_related() + serializer_class = MemberSerializer + filter_backends = (MemberSearchFilter, filters.DjangoFilterBackend, OrderingFilter, ) + filterset_class = MemberFilter + search_fields = ('dummy', ) # The search box does not appear if this is removed + # XXX: Is there a way to dynamically change which fields can be ordered (depending on the requesting user)? + # XXX: Ordering in alphabethical order does not take into account the locale, and can not do our manual sort either because OrderingFilter.filter() is expected to return a queryset + ordering_fields = ('id', 'preferred_name', 'surname', ) + + assert_public_member_fields(search_fields) + assert_public_member_fields(ordering_fields) + + +# GroupTypes, Groups and GroupMemberships + +class GroupTypeViewSet(BaseModelViewSet): queryset = GroupType.objects.all() serializer_class = GroupTypeSerializer + filter_backends = (SearchFilter, filters.DjangoFilterBackend, OrderingFilter, ) + search_fields = ('name', 'comment', ) + filterset_class = GroupTypeFilter + ordering_fields = ('id', 'name', ) +class GroupViewSet(BaseModelViewSet): + queryset = Group.objects.select_related('grouptype') + serializer_class = GroupSerializer + filter_backends = (filters.DjangoFilterBackend, OrderingFilter, ) + filterset_class = GroupFilter + ordering_fields = ( + 'id', + 'begin_date', + 'end_date', + ('grouptype__id', 'grouptype.id'), + ('grouptype__name', 'grouptype.name'), + ) -class GroupMembershipViewSet(viewsets.ModelViewSet): - queryset = GroupMembership.objects.all() +class GroupMembershipViewSet(BaseModelViewSet): + queryset = GroupMembership.objects.all_with_related() serializer_class = GroupMembershipSerializer + filter_backends = (filters.DjangoFilterBackend, OrderingFilter, ) + filterset_class = GroupMembershipFilter + ordering_fields = ( + 'id', + ('group__begin_date', 'group.begin_date'), + ('group__end_date', 'group.end_date'), + ('group__grouptype__id', 'group.grouptype.id'), + ('group__grouptype__name', 'group.grouptype.name'), + ('member__id', 'member.id'), + ) def getMultiSelectValues(request, key): @@ -116,35 +187,71 @@ def multi_decoration_ownerships_save(request): return Response(status=200) -# Functionaries - -class FunctionaryViewSet(viewsets.ModelViewSet): - queryset = Functionary.objects.all() - serializer_class = FunctionarySerializer - +# FunctionaryTypes and Functionaries -class FunctionaryTypeViewSet(viewsets.ModelViewSet): +class FunctionaryTypeViewSet(BaseModelViewSet): queryset = FunctionaryType.objects.all() serializer_class = FunctionaryTypeSerializer + filter_backends = (SearchFilter, filters.DjangoFilterBackend, OrderingFilter, ) + search_fields = ('name', 'comment', ) + filterset_class = FunctionaryTypeFilter + ordering_fields = ('id', 'name', ) + +class FunctionaryViewSet(BaseModelViewSet): + queryset = Functionary.objects.all_with_related() + serializer_class = FunctionarySerializer + filter_backends = (filters.DjangoFilterBackend, OrderingFilter, ) + filterset_class = FunctionaryFilter + ordering_fields = ( + 'id', + ('functionarytype__id', 'functionarytype.id'), + ('functionarytype__name', 'functionarytype.name'), + ('member__id', 'member.id'), + ) -# Decorations +# Decorations and DecorationOwnerships -class DecorationViewSet(viewsets.ModelViewSet): +class DecorationViewSet(BaseModelViewSet): queryset = Decoration.objects.all() serializer_class = DecorationSerializer + filter_backends = (SearchFilter, filters.DjangoFilterBackend, OrderingFilter, ) + search_fields = ('name', 'comment', ) + filterset_class = DecorationFilter + ordering_fields = ('id', 'name', ) - -class DecorationOwnershipViewSet(viewsets.ModelViewSet): - queryset = DecorationOwnership.objects.all() +class DecorationOwnershipViewSet(BaseModelViewSet): + queryset = DecorationOwnership.objects.all_with_related() serializer_class = DecorationOwnershipSerializer + filter_backends = (filters.DjangoFilterBackend, OrderingFilter, ) + filterset_class = DecorationOwnershipFilter + ordering_fields = ( + 'id', + 'acquired', + ('decoration__id', 'decoration.id'), + ('decoration__name', 'decoration.name'), + ('member__id', 'member.id'), + ) # MemberTypes -class MemberTypeViewSet(viewsets.ModelViewSet): - queryset = MemberType.objects.all() - serializer_class = MemberTypeSerializer +class MemberTypeViewSet(BaseModelViewSet): + # NOTE: Default permissions (staff-only) + permission_classes = (permissions.IsAdminUser, ) + queryset = MemberType.objects.all_with_related() + filter_backends = (filters.DjangoFilterBackend, OrderingFilter, ) + filterset_class = MemberTypeFilter + ordering_fields = ( + 'id', + 'type', + 'begin_date', + 'end_date', + ('member__id', 'member.id'), + ) + + def get_serializer(self, *args, **kwargs): + return MemberTypeSerializer(is_staff=True, *args, **kwargs) # User accounts @@ -287,9 +394,34 @@ def delete(self, request, member_id): # Registration/Applicant -class ApplicantViewSet(viewsets.ModelViewSet): +class ApplicantViewSet(BaseModelViewSet): + # NOTE: Default permissions (staff-only) + permission_classes = (permissions.IsAdminUser, ) queryset = Applicant.objects.all() - serializer_class = ApplicantSerializer + filter_backends = (SearchFilter, filters.DjangoFilterBackend, OrderingFilter, ) + search_fields = ( + 'surname', + 'given_names', + 'preferred_name', + 'email', + 'username', + 'motivation', + 'mother_tongue', + ) + filterset_class = ApplicantFilter + ordering_fields = ( + 'id', + 'surname', + 'preferred_name', + 'birth_date', + 'degree_programme', + 'enrolment_year', + 'mother_tongue', + 'created_at', + ) + + def get_serializer(self, *args, **kwargs): + return ApplicantSerializer(is_staff=True, *args, **kwargs) class ApplicantMembershipView(APIView): @@ -431,7 +563,7 @@ def members_by_member_type(request, membertype, field=None): def dump_htk(request, member_id=None): def dumpMember(member): # Functionaries - funcs = Functionary.objects.filter(member=member) + funcs = member.functionaries.all() func_list = [] for func in funcs: func_str = "{}: {} > {}".format( @@ -441,17 +573,17 @@ def dumpMember(member): ) func_list.append(func_str) # Groups - groups = GroupMembership.objects.filter(member=member) + group_memberships = member.group_memberships.all() group_list = [] - for group in groups: + for gm in group_memberships: group_str = "{}: {} > {}".format( - group.group.grouptype.name, - group.group.begin_date, - group.group.end_date + gm.group.grouptype.name, + gm.group.begin_date, + gm.group.end_date ) group_list.append(group_str) # Membertypes - types = MemberType.objects.filter(member=member) + types = member.member_types.all() type_list = [] for type in types: type_str = "{}: {} > {}".format( @@ -461,12 +593,12 @@ def dumpMember(member): ) type_list.append(type_str) # Decorations - decorations = DecorationOwnership.objects.filter(member=member) + decoration_ownerships = member.decoration_ownerships.all() decoration_list = [] - for decoration in decorations: + for do in decoration_ownerships: decoration_str = "{}: {}".format( - decoration.decoration.name, - decoration.acquired + do.decoration.name, + do.acquired ) decoration_list.append(decoration_str) @@ -479,28 +611,18 @@ def dumpMember(member): "decorations": decoration_list } + # Remember to prefetch all needed data to avoid hitting the db with n_members*5 extra fetches if member_id: - member = get_object_or_404(Member, id=member_id) + member = Member.objects.get_prefetched_or_404(member_id) data = dumpMember(member) else: - data = [dumpMember(member) for member in Member.objects.all()] - - dumpname = 'filename="HTKdump_{}.json'.format(datetime.today().date()) - return Response( - data, - status=200, - headers={'Content-Disposition': 'attachment; {}'.format(dumpname)} - ) + data = [dumpMember(member) for member in Member.objects.all_with_related()] - -# CSV-render class -class ModulenRenderer(csv_renderer.CSVRenderer): - header = ['given_names', 'preferred_name', 'surname', 'street_address', 'postal_code', 'city', 'country'] + return Response(data, status=200) # List of addresses whom to post modulen to @api_view(['GET']) -@renderer_classes((ModulenRenderer,)) def dump_modulen(request): recipients = Member.objects.exclude( postal_code='02150' @@ -524,47 +646,33 @@ def dump_modulen(request): 'street_address': recipient.street_address, 'postal_code': recipient.postal_code, 'city': recipient.city, - 'country': recipient.country + 'country': recipient.country.name } for recipient in recipients] - dumpname = 'filename="modulendump_{}.csv"'.format(datetime.today().date()) - return Response( - content, - status=200, - headers={'Content-Disposition': 'attachment; {}'.format(dumpname)} - ) - - -class ActiveRenderer(csv_renderer.CSVRenderer): - header = ['position', 'member'] + return Response(content, status=200) # Lists all members that are active at the moment. These are members # that are either functionaries right now or in a group that has an # active mandate @api_view(['GET']) -@renderer_classes((ActiveRenderer,)) def dump_active(request): - now = datetime.now().date() + now = datetime.today().date() content = [] # Functionaries - all_functionaries = Functionary.objects.filter( - begin_date__lt=now, - end_date__gt=now + all_functionaries = Functionary.objects.all_with_related().filter( + begin_date__lte=now, + end_date__gte=now ) for func in all_functionaries: content.append({ - 'position': str(func.functionarytype), - 'member': '' - }) - content.append({ - 'position': '', - 'member': func.member.common_name + 'position': func.functionarytype.name, + 'member': func.member.full_name, }) # Groups - groupmemberships = GroupMembership.objects.all() + groupmemberships = GroupMembership.objects.all_with_related() grouped_by_group = defaultdict(list) for membership in groupmemberships: if membership.group.begin_date < now and membership.group.end_date > now: @@ -579,85 +687,23 @@ def dump_active(request): 'member': m.common_name } for m in members]) - dumpname = 'filename="activedump_{}.csv"'.format(datetime.today().date()) - return Response( - content, - status=200, - headers={'Content-Disposition': 'attachment; {}'.format(dumpname)} - ) - - -class FullRenderer(csv_renderer.CSVRenderer): - header = [ - 'id', 'given_names', 'preferred_name', 'surname', 'membertype', - 'street_address', 'postal_code', 'city', 'country', 'birth_date', - 'student_id', 'enrolment_year', 'graduated', 'graduated_year', - 'degree_programme', 'dead', 'phone', 'email', 'subscribed_to_modulen', - 'allow_publish_info', 'username', 'bill_code', 'comment', 'should_be_stalmed' - ] - - -# "Fulldump". If you need some arbitrary bit of info this with some excel magic might do the trick. -# Preferably tough for all common needs implement a specific endpoint for it (like modulen or HTK) -# to save time in the long run. -@api_view(['GET']) -@renderer_classes((FullRenderer,)) -def dump_full(request): - members = Member.objects.exclude(dead=True) - - content = [{ - 'id': member.id, - 'given_names': member.given_names, - 'preferred_name': member.preferred_name, - 'surname': member.surname, - 'membertype': str(member.getMostRecentMemberType()), - 'street_address': member.street_address, - 'postal_code': member.postal_code, - 'city': member.city, - 'country': member.country, - 'birth_date': member.birth_date, - 'student_id': member.student_id, - 'enrolment_year': member.enrolment_year, - 'graduated': member.graduated, - 'graduated_year': member.graduated_year, - 'degree_programme': member.degree_programme, - 'dead': member.dead, - 'phone': member.phone, - 'email': member.email, - 'subscribed_to_modulen': member.subscribed_to_modulen, - 'allow_publish_info': member.allow_publish_info, - 'username': member.username, - 'bill_code': member.bill_code, - 'comment': member.comment, - 'should_be_stalmed': member.shouldBeStalm()} - for member in members] - - dumpname = 'filename="fulldump_{}.csv"'.format(datetime.today().date()) - return Response( - content, - status=200, - headers={'Content-Disposition': 'attachment; {}'.format(dumpname)} - ) - - -class ArskRenderer(csv_renderer.CSVRenderer): - header = ['name', 'surname', 'street_address', 'postal_code', 'city', 'country', 'associations'] + return Response(content, status=200) # Dump for Årsfestkommittén, includes all members that should be posted invitations. # These include: honor-members, all TFS 5 years back + exactly 10 years back, all counsels, all current functionaries @api_view(['GET']) -@renderer_classes((ArskRenderer,)) def dump_arsk(request): tfs_low_range = 5 - current_year = datetime.now().year + current_year = datetime.today().year + # XXX: Very scary hardcoded impementation... Still correct as of 4.8.2023 //FS counsel_ids = [ 10, # Affärsrådet (AR) 9, # Finansrådet (FR) 12, # De Äldres Råd (DÄR) - 44, # Fastighets Rådet (FaR) - 19, # Kontinuitets Rådet (KonRad) + 44, # Fastighetsrådet (FaR) + 19, # Kontinuitetsrådet (KonRad) ] styrelse_id = 2 # Styrelsen honor_id = 3 # Hedersmedlemmar @@ -678,17 +724,17 @@ def dump_arsk(request): styrelse_members_query = Q(is_styrelse_q & Q(is_recent_q | is_ten_years_back_q)) # Apply membership queries to database and append answer - memberships = GroupMembership.objects.filter(Q(styrelse_members_query | counsel_members_query) & is_alive) + memberships = GroupMembership.objects.all_with_related().filter(Q(styrelse_members_query | counsel_members_query) & is_alive) for membership in memberships: by_association[membership.member].append(str(membership.group)) # Apply honor member queries and append answer - honor_decoration = DecorationOwnership.objects.filter(Q(decoration__id=honor_id) & is_alive) + honor_decoration = DecorationOwnership.objects.all_with_related().filter(Q(decoration__id=honor_id) & is_alive) for decoration in honor_decoration: by_association[decoration.member].append(decoration.decoration.name) # Apply functionary queries and append answer - functionaries = Functionary.objects.filter(Q(begin_date__year=current_year) & is_alive) + functionaries = Functionary.objects.all_with_related().filter(Q(begin_date__year=current_year) & is_alive) for functionary in functionaries: by_association[functionary.member].append(functionary.functionarytype.name) @@ -700,70 +746,30 @@ def dump_arsk(request): 'postal_code': member.postal_code, 'city': member.city, 'country': member.country.name, - 'associations': ','.join(association)} - for member, association in by_association.items()] - - dumpname = 'filename="arskdump_{}.csv"'.format(datetime.today().date()) - return Response( - content, - status=200, - headers={'Content-Disposition': 'attachment; {}'.format(dumpname)} - ) - + 'associations': ','.join(association), + } for member, association in by_association.items()] -class RegEmailRenderer(csv_renderer.CSVRenderer): - header = ['name', 'surname', 'preferred_name', 'email'] + return Response(content, status=200) # Dump for receiving all emails from member applicants # Used by e.g. the PhuxMästare to send out information @api_view(['GET']) -@renderer_classes((RegEmailRenderer,)) def dump_reg_emails(request): applicants = Applicant.objects.all() content = [{ 'name': applicant.given_names, 'surname': applicant.surname, 'preferred_name': applicant.preferred_name, - 'email': applicant.email} - for applicant in applicants] - - dumpname = 'filename="regEmailDump_{}.csv"'.format(datetime.today().date()) - return Response( - content, - status=200, - headers={'Content-Disposition': 'attachment; {}'.format(dumpname)} - ) - - -class ApplicantLanguagesRenderer(csv_renderer.CSVRenderer): - header = ['language'] - - -@api_view(['GET']) -@renderer_classes((ApplicantLanguagesRenderer,)) -def dump_applicant_languages(request): - applicants = Applicant.objects.exclude(Q(mother_tongue__isnull=True) | Q(mother_tongue__exact='')) - content = [{ - 'language': applicant.mother_tongue} - for applicant in applicants] - - dumpname = 'filename="applicantLanguages_{}.csv"'.format(datetime.today().date()) - return Response( - content, - status=200, - headers={'Content-Disposition': 'attachment; {}'.format(dumpname)} - ) + 'email': applicant.email, + 'language': applicant.mother_tongue, + } for applicant in applicants] - -# CSV-render class -class StudentbladetRenderer(csv_renderer.CSVRenderer): - header = ['name', 'street_address', 'postal_code', 'city', 'country'] + return Response(content, status=200) # List of addresses whom to post Studentbladet to @api_view(['GET']) -@renderer_classes((StudentbladetRenderer,)) def dump_studentbladet(request): recipients = Member.objects.exclude(dead=True).filter(allow_studentbladet=True) recipients = [m for m in recipients if m.isValidMember()] @@ -773,12 +779,7 @@ def dump_studentbladet(request): 'street_address': recipient.street_address, 'postal_code': recipient.postal_code, 'city': recipient.city, - 'country': recipient.country + 'country': str(recipient.country), } for recipient in recipients] - dumpname = 'filename="studentbladetdump_{}.csv"'.format(datetime.today().date()) - return Response( - content, - status=200, - headers={'Content-Disposition': 'attachment; {}'.format(dumpname)} - ) + return Response(content, status=200) diff --git a/teknologr/katalogen/admin.py b/teknologr/katalogen/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/teknologr/katalogen/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/teknologr/katalogen/models.py b/teknologr/katalogen/models.py deleted file mode 100644 index 71a83623..00000000 --- a/teknologr/katalogen/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/teknologr/katalogen/utils.py b/teknologr/katalogen/utils.py index 2eca4c59..71dcd598 100644 --- a/teknologr/katalogen/utils.py +++ b/teknologr/katalogen/utils.py @@ -1,4 +1,5 @@ -import datetime +from datetime import timedelta +from collections import defaultdict from operator import attrgetter from functools import total_ordering from django.utils.formats import date_format @@ -29,13 +30,11 @@ def to_sort_string(self): class DurationsHelper: def __init__(self, items): - self.__dict = {} + self.__dict = defaultdict(list) for key, duration in items: self.add(key, duration) def add(self, key, new_duration): - if key not in self.__dict: - self.__dict[key] = [] self.__dict[key].append(new_duration) def simplify(self): @@ -84,7 +83,7 @@ def simplify_durations(durations): simplified = [durations[0]] for next in durations[1:]: last = simplified[-1] - if next.begin_date > last.end_date + datetime.timedelta(days=1): + if next.begin_date > last.end_date + timedelta(days=1): simplified.append(next) elif next.end_date > last.end_date: last.end_date = next.end_date diff --git a/teknologr/katalogen/views.py b/teknologr/katalogen/views.py index e09a45eb..27eefdc9 100644 --- a/teknologr/katalogen/views.py +++ b/teknologr/katalogen/views.py @@ -4,9 +4,10 @@ from members.models import * from members.utils import * from katalogen.utils import * -from django.db.models import Q, Count, Prefetch +from django.db.models import Q, Count from functools import reduce from operator import and_ +from collections import defaultdict def _get_base_context(request): @@ -70,6 +71,7 @@ def profile(request, member_id): return render(request, 'profile.html', { **_get_base_context(request), + # XXX: Could use MemberSerializerPartial to remove any unwanted fields for real instead of just not showing them 'show_all': person.username == request.user.username or person.showContactInformation(), 'person': person, 'combined': combine, @@ -194,7 +196,7 @@ def years(request): 3. SELECT DecortaionOwnership => COUNT 4. SELECT MemberType WHERE type IN ["OM", "ST"] => COUNT ''' - years = {} + years = defaultdict(dict) def add(obj, key, count_key=None): date = obj['year'] @@ -202,8 +204,6 @@ def add(obj, key, count_key=None): return y = date.year - if y not in years: - years[y] = {} years[y][key] = obj[count_key or key] # Get all functionaries and group them by their start year, and for each year return a count for the total amount of functionaries and the amount of unique members holding the posts @@ -242,10 +242,10 @@ def year(request, year): This could be enhanced, but curretnly it is done with 10 queries: 1. SELECT Functionary WHERE correct_year => COUNT 2. SELECT Functionary WHERE correct_year - 3. SELECT GroupMemebership WHERE correct_year => COUNT + 3. SELECT GroupMembership WHERE correct_year => COUNT 4. COUNT DISTINCT over ^ 5. group_ids, group_type_ids = SELECT Group WHERE correct_year - 6. SELECT GroupMemebership WHERE group__id IN group_ids + 6. SELECT GroupMembership WHERE group__id IN group_ids 7. SELECT GroupType WHERE id IN group_type_ids 8. SELECT DecortaionOwnership WHERE correct_year 9. SELECT MemberType WHERE correct_year AND type="OM" diff --git a/teknologr/members/admin.py b/teknologr/members/admin.py deleted file mode 100644 index db99fe5f..00000000 --- a/teknologr/members/admin.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.contrib import admin -from members.models import * - -admin.site.register(Member) -admin.site.register(Group) -admin.site.register(GroupType) -admin.site.register(Functionary) -admin.site.register(FunctionaryType) -admin.site.register(GroupMembership) -admin.site.register(DecorationOwnership) diff --git a/teknologr/members/models.py b/teknologr/members/models.py index 58e09530..b1482a92 100644 --- a/teknologr/members/models.py +++ b/teknologr/members/models.py @@ -20,7 +20,7 @@ class Meta: class MemberManager(models.Manager): - def get_prefetched_or_404(self, member_id): + def all_with_related(self): ''' This is done in 5 queries: 1. SELECT Member WHERE id=member_id @@ -29,17 +29,26 @@ def get_prefetched_or_404(self, member_id): 4. SELECT GroupMembership WHERE member__id=member_id 5. SELECT MemberType WHERE member__id=member_id ''' - queryset = Member.objects.prefetch_related( + return Member.objects.prefetch_related( Prefetch('decoration_ownerships', queryset=DecorationOwnership.objects.select_related('decoration')), Prefetch('functionaries', queryset=Functionary.objects.select_related('functionarytype')), Prefetch('group_memberships', queryset=GroupMembership.objects.select_related('group', 'group__grouptype')), 'member_types', + ).annotate( + count_decoration_ownerships=Count('decoration_ownerships'), + count_functionaries=Count('functionaries'), + count_group_memberships=Count('group_memberships'), ) - return get_object_or_404(queryset, id=member_id) + + def get_prefetched_or_404(self, member_id): + return get_object_or_404(self.all_with_related(), id=member_id) class Member(SuperClass): objects = MemberManager() + STAFF_ONLY_FIELDS = ['birth_date', 'student_id', 'dead', 'subscribed_to_modulen', 'allow_publish_info', 'allow_studentbladet', 'comment', 'username', 'bill_code'] + HIDABLE_FIELDS = ['street_address', 'postal_code', 'city', 'country', 'phone', 'email', 'degree_programme', 'enrolment_year', 'graduated', 'graduated_year'] + # NOTE: given_names is semi-hidable # NAMES given_names = models.CharField(max_length=64, blank=False, null=False, default="UNKNOWN") @@ -114,8 +123,17 @@ def get_preferred_name(self): return self.preferred_name or self.given_names.split()[0] def get_given_names_with_initials(self): + ''' + All given names except the preferred one is converted to initials: + - _Foo_ Bar Baz -> Foo B B + - _Foo-Bar_ Baz -> Foo-Bar B + - Foo-_Bar_ Baz -> Foo-Bar B # XXX: Or is 'F-Bar B' better? + - _Foo_-Bar Baz -> Foo-Bar B # XXX: Or is 'Foo-B B' better? + - Foo Bar _Baz_ -> F B Baz + - Foo-Bar _Baz_ -> F-B Baz + ''' preferred_name = self.get_preferred_name() - names = [n if n == preferred_name else n[0] for n in self.given_names.split()] + names = [n if preferred_name in n else '-'.join([nn[0] for nn in n.split('-')]) for n in self.given_names.split()] return " ".join(names) def get_surname_without_prefixes(self): @@ -172,6 +190,24 @@ def full_address(self): address_parts = [self.street_address, city, country] return ", ".join([s for s in address_parts if s]) + @property + def n_decorations(self): + if hasattr(self, 'count_decoration_ownerships'): + return self.count_decoration_ownerships + return self.decoration_ownerships.count() + + @property + def n_functionaries(self): + if hasattr(self, 'count_functionaries'): + return self.count_functionaries + return self.functionaries.count() + + @property + def n_groups(self): + if hasattr(self, 'count_group_memberships'): + return self.count_group_memberships + return self.group_memberships.count() + def save(self, *args, **kwargs): if not self.username: self.username = None @@ -228,7 +264,11 @@ def phux_year(self): return member_type_phux.begin_date.year if member_type_phux else None def showContactInformation(self): - return self.allow_publish_info and self.isValidMember() and not self.dead + return self.allow_publish_info and not self.dead + + @classmethod + def get_show_info_Q(cls): + return Q(allow_publish_info=True, dead=False) @property def decoration_ownerships_by_date(self): @@ -261,8 +301,11 @@ def order_by(cls, members_list, by, reverse=False): class DecorationOwnershipManager(models.Manager): + def all_with_related(self): + return self.get_queryset().select_related('decoration', 'member') + def year(self, year): - return self.get_queryset().select_related('member', 'decoration').filter(acquired__year=year) + return self.all_with_related().filter(acquired__year=year) def year_ordered(self, year): l = list(self.year(year)) @@ -319,6 +362,12 @@ class Decoration(SuperClass): def __str__(self): return self.name + @property + def n_ownerships(self): + if hasattr(self, 'count'): + return self.count + return self.ownerships.count() + @property def ownerships_by_date(self): l = list(self.ownerships.all()) @@ -336,6 +385,11 @@ def order_by(cls, decorations_list, by, reverse=False): class GroupMembership(SuperClass): + class GMManager(models.Manager): + def all_with_related(self): + return self.get_queryset().select_related('group', 'group__grouptype', 'member') + + objects = GMManager() member = models.ForeignKey("Member", on_delete=models.CASCADE, related_name="group_memberships") group = models.ForeignKey("Group", on_delete=models.CASCADE, related_name="memberships") @@ -384,13 +438,19 @@ class Group(SuperClass): def __str__(self): return f'{self.grouptype}: {self.begin_date} - {self.end_date}' + @property + def n_members(self): + if hasattr(self, 'num_members'): + return self.num_members + return self.memberships.count() + @property def duration(self): return Duration(self.begin_date, self.end_date) @property def memberships_by_member(self): - l = list(self.memberships.all()) + l = list(self.memberships.select_related('member')) GroupMembership.order_by(l, 'member') return l @@ -439,6 +499,24 @@ class GroupType(SuperClass): def __str__(self): return self.name + @property + def n_groups(self): + if hasattr(self, 'count'): + return self.count + return self.groups.count() + + @property + def n_members_total(self): + if hasattr(self, 'count_members_total'): + return self.count_members_total + return self.groups.aggregate(count=Count('memberships'))['count'] + + @property + def n_members_unique(self): + if hasattr(self, 'count_members_unique'): + return self.count_members_unique + return self.groups.aggregate(count=Count('memberships__member', distinct=True))['count'] + @property def groups_by_date(self): l = list(self.groups.all()) @@ -455,8 +533,11 @@ def order_by(cls, grouptypes_list, by, reverse=False): class FunctionaryManager(models.Manager): + def all_with_related(self): + return self.get_queryset().select_related('functionarytype', 'member') + def year(self, year): - return self.get_queryset().select_related('member', 'functionarytype').filter(begin_date__lte=datetime.date(int(year), 12, 31), end_date__gte=datetime.date(int(year), 1, 1)) + return self.all_with_related().filter(begin_date__lte=datetime.date(int(year), 12, 31), end_date__gte=datetime.date(int(year), 1, 1)) def year_ordered_and_unique(self, year): queryset = self.year(year) @@ -527,6 +608,18 @@ class FunctionaryType(SuperClass): def __str__(self): return self.name + @property + def n_functionaries_total(self): + if hasattr(self, 'count'): + return self.count + return self.functionaries.count() + + @property + def n_functionaries_unique(self): + if hasattr(self, 'count_unique'): + return self.count_unique + return self.functionaries.aggregate(count=Count('member', distinct=True))['count'] + @property def functionaries_by_date(self): l = list(self.functionaries.all()) @@ -544,8 +637,11 @@ def order_by(cls, functionarytypes_list, by, reverse=False): class MemberTypeManager(models.Manager): + def all_with_related(self): + return self.get_queryset().select_related('member') + def begin_year(self, year): - return self.get_queryset().select_related('member').filter(begin_date__year=year) + return self.all_with_related().filter(begin_date__year=year) def ordinary_members_begin_year(self, year): return self.begin_year(year).filter(type='OM') diff --git a/teknologr/members/templates/base.html b/teknologr/members/templates/base.html index adfa5576..cea84925 100644 --- a/teknologr/members/templates/base.html +++ b/teknologr/members/templates/base.html @@ -39,23 +39,22 @@ - + {% if info_url %}
  • - diff --git a/teknologr/members/tests.py b/teknologr/members/tests.py index 12799e7c..67dde22b 100644 --- a/teknologr/members/tests.py +++ b/teknologr/members/tests.py @@ -1,8 +1,7 @@ -import datetime - from django.test import TestCase from members.models import * from ldap import LDAPError +from datetime import date, timedelta import random def shuffle(l): @@ -21,22 +20,22 @@ class BaseTest(TestCase): 'Öbc', ] test_dates = [ - datetime.date(1999, 7, 1), - datetime.date(1999, 7, 30), - datetime.date(2023, 6, 1), - datetime.date(2023, 6, 13), - datetime.date(2023, 6, 30), - datetime.date(2030, 5, 1), - datetime.date(2030, 5, 30), + date(1999, 7, 1), + date(1999, 7, 30), + date(2023, 6, 1), + date(2023, 6, 13), + date(2023, 6, 30), + date(2030, 5, 1), + date(2030, 5, 30), ] test_date_pairs = [ - [datetime.date(2022, 1, 1), datetime.date(2023, 1, 1)], - [datetime.date(2023, 1, 1), datetime.date(2023, 7, 7)], - [datetime.date(2023, 2, 2), datetime.date(2023, 11, 11)], - [datetime.date(2023, 1, 1), datetime.date(2023, 12, 31)], - [datetime.date(2023, 7, 7), datetime.date(2023, 12, 31)], - [datetime.date(2022, 1, 1), datetime.date(2024, 1, 1)], - [datetime.date(2023, 1, 1), datetime.date(2024, 1, 1)], + [date(2022, 1, 1), date(2023, 1, 1)], + [date(2023, 1, 1), date(2023, 7, 7)], + [date(2023, 2, 2), date(2023, 11, 11)], + [date(2023, 1, 1), date(2023, 12, 31)], + [date(2023, 7, 7), date(2023, 12, 31)], + [date(2022, 1, 1), date(2024, 1, 1)], + [date(2023, 1, 1), date(2024, 1, 1)], ] @@ -67,7 +66,7 @@ def setUp(self): # Surname with prefixes # Address is incomplete self.member3 = Member.objects.create( - given_names='Foo Bar Baz', + given_names='Foo-Bar Biz-Baz', preferred_name='Baz', surname='von der Tester', street_address='Otsvängen 22', @@ -76,12 +75,12 @@ def setUp(self): ) MemberType.objects.create( - begin_date=datetime.date(2020, 1, 1), + begin_date=date(2020, 1, 1), type='OM', member=self.member2, ) MemberType.objects.create( - begin_date=datetime.date(2020, 1, 1), + begin_date=date(2020, 1, 1), type='OM', member=self.member3, ) @@ -94,7 +93,7 @@ def test_get_preferred_name(self): def test_get_given_names_with_initials(self): self.assertEqual(self.member1.get_given_names_with_initials(), 'Foo B B') self.assertEqual(self.member2.get_given_names_with_initials(), 'Foo B B') - self.assertEqual(self.member3.get_given_names_with_initials(), 'F B Baz') + self.assertEqual(self.member3.get_given_names_with_initials(), 'F-B Biz-Baz') def test_get_surname_without_prefixes(self): self.assertEqual(self.member1.get_surname_without_prefixes(), 'Tester') @@ -104,27 +103,27 @@ def test_get_surname_without_prefixes(self): def test_full_name(self): self.assertEqual(self.member1.full_name, 'Foo Bar Baz Tester') self.assertEqual(self.member2.full_name, 'Foo Bar Baz Tester') - self.assertEqual(self.member3.full_name, 'Foo Bar Baz von der Tester') + self.assertEqual(self.member3.full_name, 'Foo-Bar Biz-Baz von der Tester') def test_full_name_for_sorting(self): self.assertEqual(self.member1.full_name_for_sorting, 'Tester, Foo Bar Baz') self.assertEqual(self.member2.full_name_for_sorting, 'Tester, Foo Bar Baz') - self.assertEqual(self.member3.full_name_for_sorting, 'Tester, Foo Bar Baz') + self.assertEqual(self.member3.full_name_for_sorting, 'Tester, Foo-Bar Biz-Baz') def test_public_full_name(self): self.assertEqual(self.member1.public_full_name, 'Foo B B Tester') self.assertEqual(self.member2.public_full_name, 'Foo Bar Baz Tester') - self.assertEqual(self.member3.public_full_name, 'F B Baz von der Tester') + self.assertEqual(self.member3.public_full_name, 'F-B Biz-Baz von der Tester') def test_public_full_name_for_sorting(self): self.assertEqual(self.member1.public_full_name_for_sorting, 'Tester, Foo B B') self.assertEqual(self.member2.public_full_name_for_sorting, 'Tester, Foo Bar Baz') - self.assertEqual(self.member3.public_full_name_for_sorting, 'Tester, F B Baz') + self.assertEqual(self.member3.public_full_name_for_sorting, 'Tester, F-B Biz-Baz') def test_name(self): self.assertEqual(self.member1.name, 'Foo Bar Baz Tester') self.assertEqual(self.member2.name, 'Foo Bar Baz Tester') - self.assertEqual(self.member3.name, 'Foo Bar Baz von der Tester') + self.assertEqual(self.member3.name, 'Foo-Bar Biz-Baz von der Tester') def test_address(self): self.assertEquals('Otsvängen 22, 02150 Esbo, Finland', self.member1.full_address) @@ -138,7 +137,7 @@ def test_address_incomplete(self): def test_str(self): self.assertEqual('Foo B B Tester', str(self.member1)) self.assertEqual('Foo Bar Baz Tester', str(self.member2)) - self.assertEqual('F B Baz von der Tester', str(self.member3)) + self.assertEqual('F-B Biz-Baz von der Tester', str(self.member3)) def test_member_type(self): member = Member(given_names='Svatta', surname='Teknolog') @@ -148,14 +147,14 @@ def test_member_type(self): self.assertEquals('', member.current_member_type) # add some member types. First default member type phux - ph = MemberType(member=member, begin_date=datetime.date(2012, 9, 1)) + ph = MemberType(member=member, begin_date=date(2012, 9, 1)) ph.save() # A 'Phux' is not a real member type and should return '' self.assertEquals('', member.current_member_type) self.assertFalse(member.isValidMember()) # Make our person a 'real member' - om = MemberType(member=member, type='OM', begin_date=datetime.date(2012, 9, 27)) + om = MemberType(member=member, type='OM', begin_date=date(2012, 9, 27)) om.save() self.assertEquals('Ordinarie Medlem', member.current_member_type) self.assertTrue(member.isValidMember()) @@ -163,18 +162,18 @@ def test_member_type(self): self.assertEquals('Ordinarie Medlem: 2012-09-27 ->', str(om)) # Wappen kom, phuxen är inte mera phux! - ph.end_date = datetime.date(2012, 4, 30) + ph.end_date = date(2012, 4, 30) ph.save() self.assertEquals('Phux: 2012-09-01 - 2012-04-30', str(ph)) self.assertFalse(member.shouldBeStalm()) # Our person became JuniorStÄlM :O - js = MemberType(member=member, type='JS', begin_date=datetime.date(2017, 10, 15)) + js = MemberType(member=member, type='JS', begin_date=date(2017, 10, 15)) js.save() self.assertFalse(member.shouldBeStalm()) # Our person is now finished with his/her studies - om.end_date = datetime.date(2019, 4, 30) + om.end_date = date(2019, 4, 30) om.save() self.assertEquals('Ordinarie Medlem: 2012-09-27 - 2019-04-30', str(om)) @@ -183,10 +182,10 @@ def test_member_type(self): # and person should become StÄlM now self.assertTrue(member.shouldBeStalm()) - fg = MemberType(member=member, type='FG', begin_date=datetime.date(2019, 5, 1)) + fg = MemberType(member=member, type='FG', begin_date=date(2019, 5, 1)) fg.save() - st = MemberType(member=member, type='ST', begin_date=datetime.date(2019, 5, 16)) + st = MemberType(member=member, type='ST', begin_date=date(2019, 5, 16)) st.save() self.assertEquals('StÄlM', member.current_member_type) self.assertFalse(member.shouldBeStalm()) @@ -233,7 +232,7 @@ def setUp(self): self.ownership = DecorationOwnership.objects.create( member=member, decoration=decoration, - acquired=datetime.date.today(), + acquired=date.today(), ) def test_str(self): @@ -303,23 +302,23 @@ def setUp(self): group_type = GroupType.objects.create(name='Group Type') self.group1 = Group.objects.create( grouptype=group_type, - begin_date=datetime.date(2023, 1, 1), - end_date=datetime.date(2023, 6, 14), + begin_date=date(2023, 1, 1), + end_date=date(2023, 6, 14), ) self.group2 = Group.objects.create( grouptype=group_type, - begin_date=datetime.date(2023, 6, 14), - end_date=datetime.date(2023, 12, 31), + begin_date=date(2023, 6, 14), + end_date=date(2023, 12, 31), ) self.group3 = Group.objects.create( grouptype=group_type, - begin_date=datetime.date(2023, 1, 1), - end_date=datetime.date(2023, 12, 31), + begin_date=date(2023, 1, 1), + end_date=date(2023, 12, 31), ) self.group4 = Group.objects.create( grouptype=group_type, - begin_date=datetime.date(2022, 1, 1), - end_date=datetime.date(2024, 12, 31), + begin_date=date(2022, 1, 1), + end_date=date(2024, 12, 31), ) def test_str(self): @@ -342,7 +341,7 @@ def setUp(self): Group.objects.create( grouptype=group_type, begin_date=date, - end_date=date + datetime.timedelta(days=1), + end_date=date + timedelta(days=1), ) self.groups = list(Group.objects.all()) @@ -404,26 +403,26 @@ def setUp(self): self.functionary1 = Functionary.objects.create( functionarytype=functionary_type, member=member, - begin_date=datetime.date(2023, 1, 1), - end_date=datetime.date(2023, 6, 14), + begin_date=date(2023, 1, 1), + end_date=date(2023, 6, 14), ) self.functionary2 = Functionary.objects.create( functionarytype=functionary_type, member=member, - begin_date=datetime.date(2023, 6, 14), - end_date=datetime.date(2023, 12, 31), + begin_date=date(2023, 6, 14), + end_date=date(2023, 12, 31), ) self.functionary3 = Functionary.objects.create( functionarytype=functionary_type, member=member, - begin_date=datetime.date(2023, 1, 1), - end_date=datetime.date(2023, 12, 31), + begin_date=date(2023, 1, 1), + end_date=date(2023, 12, 31), ) self.functionary4 = Functionary.objects.create( functionarytype=functionary_type, member=member, - begin_date=datetime.date(2022, 1, 1), - end_date=datetime.date(2024, 12, 31), + begin_date=date(2022, 1, 1), + end_date=date(2024, 12, 31), ) def test_str(self): @@ -448,7 +447,7 @@ def setUp(self): member=member, functionarytype=functionary_type, begin_date=date, - end_date=date + datetime.timedelta(days=1), + end_date=date + timedelta(days=1), ) self.functionaries = list(Functionary.objects.all()) @@ -518,13 +517,13 @@ def setUp(self): self.member_type1 = MemberType.objects.create( type='OM', member=member, - begin_date=datetime.date(2023, 1, 1), - end_date=datetime.date(2023, 6, 14), + begin_date=date(2023, 1, 1), + end_date=date(2023, 6, 14), ) self.member_type2 = MemberType.objects.create( type='OM', member=member, - begin_date=datetime.date(2023, 6, 14), + begin_date=date(2023, 6, 14), ) def test_str(self): @@ -540,7 +539,7 @@ def setUp(self): MemberType.objects.create( member=member, begin_date=date, - end_date=date + datetime.timedelta(days=1), + end_date=date + timedelta(days=1), ) self.member_types = list(MemberType.objects.all()) diff --git a/teknologr/members/utils.py b/teknologr/members/utils.py index e3660bdb..74174328 100644 --- a/teknologr/members/utils.py +++ b/teknologr/members/utils.py @@ -1,8 +1,8 @@ -import datetime +from datetime import date, datetime from .models import * def getCurrentDate(): - return datetime.datetime.now() + return datetime.now() def getCurrentYear(): @@ -10,8 +10,8 @@ def getCurrentYear(): def getFirstDayOfCurrentYear(): - return datetime.date(getCurrentYear(), 1, 1) + return date(getCurrentYear(), 1, 1) def getLastDayOfCurrentYear(): - return datetime.date(getCurrentYear(), 12, 31) + return date(getCurrentYear(), 12, 31) diff --git a/teknologr/registration/admin.py b/teknologr/registration/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/teknologr/registration/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/teknologr/teknologr/settings.py b/teknologr/teknologr/settings.py index 58043502..d86eb634 100644 --- a/teknologr/teknologr/settings.py +++ b/teknologr/teknologr/settings.py @@ -30,6 +30,7 @@ TEST_PEP8_EXCLUDE = ['migrations', ] # Exclude this paths from tests TEST_PEP8_IGNORE = [ 'E226', # Whitespace around arithmetic operators + 'E266', # Leading amount of '#' in comment 'E261', # Spaces before inline comments 'E302', # Blank lines 'E501', # Line lengths @@ -61,6 +62,7 @@ 'django.contrib.staticfiles', 'test_pep8', 'rest_framework', + 'django_filters', 'ajax_select', 'members', 'katalogen', @@ -107,7 +109,7 @@ 'file': { 'level': 'INFO', 'class': 'logging.FileHandler', - 'filename': '/var/log/teknologr/info.log', + 'filename': env('LOG_FILE', '/var/log/teknologr/info.log'), }, }, 'loggers': { @@ -145,6 +147,7 @@ 'default': dj_database_url.parse(env('DATABASE', 'sqlite:///' + os.path.join(BASE_DIR, 'db.sqlite3'))) } +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' # Password validation # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators @@ -206,6 +209,12 @@ 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAdminUser', ), + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', + 'api.utils.BrowsableAPIRendererWithoutForms', + 'rest_framework_csv.renderers.CSVRenderer', + ), + 'DEFAULT_PAGINATION_CLASS': 'api.utils.Pagination', } # LDAP stuff