Skip to content

Commit

Permalink
Merge pull request #162 from Teknologforeningen/feature/enhanced-apis
Browse files Browse the repository at this point in the history
Enhanced API
  • Loading branch information
filiptypjeu authored Aug 12, 2023
2 parents cc0c597 + f944d07 commit dda8f36
Show file tree
Hide file tree
Showing 27 changed files with 2,850 additions and 353 deletions.
5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions teknologr/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 0 additions & 3 deletions teknologr/api/admin.py

This file was deleted.

328 changes: 328 additions & 0 deletions teknologr/api/filters.py
Original file line number Diff line number Diff line change
@@ -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')
3 changes: 0 additions & 3 deletions teknologr/api/models.py

This file was deleted.

Loading

0 comments on commit dda8f36

Please sign in to comment.