Refactor members package to use class-based views

parent 30c47a28
......@@ -24,6 +24,14 @@ members.admin module
:undoc-members:
:show-inheritance:
members.admin\_views module
---------------------------
.. automodule:: members.admin_views
:members:
:undoc-members:
:show-inheritance:
members.apps module
-------------------
......
......@@ -25,3 +25,11 @@ utils.templatetags.thumbnail module
:undoc-members:
:show-inheritance:
utils.templatetags.urlparams module
-----------------------------------
.. automodule:: utils.templatetags.urlparams
:members:
:undoc-members:
:show-inheritance:
......@@ -8,10 +8,11 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User
from django.db.models import Q, Count
from django.http import HttpResponse
from django.urls import path
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from members import services
from members import services, admin_views
from members.models import EmailChange, Member
from . import forms, models
......@@ -206,6 +207,16 @@ class UserAdmin(BaseUserAdmin):
)
minimise_data.short_description = _('Minimise data for the selected users')
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('iban-export/',
self.admin_site.admin_view(
admin_views.IbanExportView.as_view()),
name='members_member_ibanexport'),
]
return custom_urls + urls
@admin.register(models.Member)
class MemberAdmin(UserAdmin):
......
"""Admin views provided by the members package"""
import csv
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import permission_required
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
from members.models import Member
@method_decorator(staff_member_required, 'dispatch')
@method_decorator(permission_required('auth.change_user'), 'dispatch')
class IbanExportView(View):
"""
Exports IBANs of users that have set auto renew to true in their accounts
"""
def get(self, request, **kwargs) -> HttpResponse:
header_fields = ['name', 'username', 'iban', 'bic']
rows = []
members = Member.current_members.filter(
profile__auto_renew=True)
for member in members:
if (member.current_membership.type != 'honorary' and
member.bank_accounts.exists()):
bank_account = member.bank_accounts.last()
rows.append({
'name': bank_account.name,
'username': member.username,
'iban': bank_account.iban,
'bic': bank_account.bic
})
response = HttpResponse(content_type='text/csv')
writer = csv.DictWriter(response, header_fields)
writer.writeheader()
for row in rows:
writer.writerow(row)
response['Content-Disposition'] = (
'attachment; filename="iban-export.csv"')
return response
"""DRF serializers defined by the members package"""
from django.templatetags.static import static
from django.urls import reverse
from rest_framework import serializers
......@@ -9,6 +10,7 @@ from thaliawebsite.api.services import create_image_thumbnail_dict
class MemberBirthdaySerializer(CalenderJSSerializer):
"""Serializer that renders the member birthdays to the CalendarJS format"""
class Meta(CalenderJSSerializer.Meta):
model = Member
......@@ -47,6 +49,7 @@ class MemberBirthdaySerializer(CalenderJSSerializer):
class ProfileRetrieveSerializer(serializers.ModelSerializer):
"""Serializer that renders a member profile"""
class Meta:
model = Profile
fields = ('pk', 'display_name', 'avatar', 'profile_description',
......@@ -92,6 +95,7 @@ class ProfileRetrieveSerializer(serializers.ModelSerializer):
class MemberListSerializer(serializers.ModelSerializer):
"""Serializer that renders a list of members"""
class Meta:
model = Member
fields = ('pk', 'display_name', 'avatar')
......@@ -114,6 +118,7 @@ class MemberListSerializer(serializers.ModelSerializer):
class ProfileEditSerializer(serializers.ModelSerializer):
"""Serializer that renders a profile to be edited"""
class Meta:
model = Profile
fields = ('pk', 'email', 'first_name', 'last_name', 'address_street',
......
"""DRF routes defined by the members package"""
from rest_framework import routers
from members.api import viewsets
......
"""DRF viewsets defined by the members package"""
import copy
from rest_framework import permissions
from rest_framework import viewsets, filters
from rest_framework import viewsets, filters, mixins
from rest_framework.decorators import action
from rest_framework.response import Response
......@@ -14,7 +15,8 @@ from utils.snippets import extract_date_range
class MemberViewset(viewsets.ReadOnlyModelViewSet,
viewsets.mixins.UpdateModelMixin):
mixins.UpdateModelMixin):
"""Viewset that renders or edits a member"""
queryset = Member.objects.all()
filter_backends = (filters.OrderingFilter, filters.SearchFilter,)
ordering_fields = ('profile__starting_year', 'first_name', 'last_name')
......
"""Configuration for the members package"""
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
......
"""Decorators provided by the members package"""
from django.core.exceptions import PermissionDenied
......@@ -6,6 +7,9 @@ def membership_required(view_function):
class ActiveMembershipRequired(object):
"""
Decorator that checks if the user has an active membership
"""
def __init__(self, view_function):
self.view_function = view_function
......
"""The emails defined by the members package"""
from datetime import timedelta
import logging
......
"""Forms defined by the members package"""
from django import forms
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
from django.contrib.auth.forms import UserCreationForm as BaseUserCreationForm
from django.contrib.auth.models import User
from django.utils.translation import ugettext_lazy as _
from members import emails, models
from members import emails
from .models import Profile
class ProfileForm(forms.ModelForm):
"""Form with all the user editable fields of a Profile model"""
class Meta:
fields = ['address_street', 'address_street2',
'address_postal_code', 'address_city', 'address_country',
......@@ -22,6 +24,10 @@ class ProfileForm(forms.ModelForm):
class UserCreationForm(BaseUserCreationForm):
"""
Custom Form that removes the password fields from user creation
and sends a welcome message when a user is created
"""
# Don't forget to edit the formset in admin.py!
# This is a stupid quirk of the user admin.
......@@ -71,6 +77,10 @@ class UserCreationForm(BaseUserCreationForm):
class UserChangeForm(BaseUserChangeForm):
"""
Custom user edit form that adds fields for first/last name and email
It also force-lowercases the username on save
"""
first_name = forms.CharField(
label=_('First name'),
required=True,
......@@ -104,9 +114,3 @@ class UserChangeForm(BaseUserChangeForm):
self.cleaned_data['username'] = (self.cleaned_data['username']
.lower())
super().clean()
class EmailChangeForm(forms.ModelForm):
class Meta:
model = models.EmailChange
fields = ['email', 'member']
"""Middleware provided by the members package"""
from django.utils.functional import SimpleLazyObject
from members.models import Member
......@@ -11,6 +12,9 @@ def get_member(request):
class MemberMiddleware:
"""
Adds the member attribute to requests
"""
def __init__(self, get_response):
self.get_response = get_response
......
"""Models defined in the members package"""
import logging
import operator
import os
......
"""Services defined in the members package"""
from datetime import date
from typing import Callable, List, Dict, Union, Any
from django.db.models import Q, Count
from django.utils import timezone
......@@ -9,7 +11,13 @@ from members.models import Membership, Member
from utils.snippets import datetime_to_lectureyear
def _member_group_memberships(member, skip_condition):
def _member_group_memberships(
member: Member, skip_condition: Callable[[Membership], bool]
) -> Dict[str, Any]:
"""
Determines the group membership of a user based on a condition
:return: Object with group memberships
"""
memberships = member.membergroupmembership_set.all()
data = {}
......@@ -45,9 +53,13 @@ def _member_group_memberships(member, skip_condition):
return data
def member_achievements(member):
def member_achievements(member) -> List:
"""
Derives a list of achievements of a member
Committee and board memberships + mentorships
"""
achievements = _member_group_memberships(
member, lambda membership: hasattr(membership.group, 'society'))
member, lambda membership: hasattr(membership, 'society'))
mentor_years = member.mentorship_set.all()
for mentor_year in mentor_years:
......@@ -63,14 +75,21 @@ def member_achievements(member):
return sorted(achievements.values(), key=lambda x: x['earliest'])
def member_societies(member):
def member_societies(member) -> List:
"""
Derives a list of societies a member was part of
"""
societies = _member_group_memberships(member, lambda membership: (
hasattr(membership.group, 'board') or
hasattr(membership.group, 'committee')))
return sorted(societies.values(), key=lambda x: x['earliest'])
def gen_stats_member_type(member_types):
def gen_stats_member_type(member_types) -> Dict[str, int]:
"""
Generate a dictionary where every key is a member type with
the value being the number of current members of that type
"""
total = dict()
for member_type in member_types:
total[member_type] = (Membership
......@@ -83,7 +102,8 @@ def gen_stats_member_type(member_types):
return total
def gen_stats_year(member_types):
def gen_stats_year(
member_types) -> List[Dict[Union[str, Any], Union[int, Any]]]:
"""
Generate list with 6 entries, where each entry represents the total amount
of Thalia members in a year. The sixth element contains all the multi-year
......@@ -123,7 +143,7 @@ def gen_stats_year(member_types):
return stats_year
def verify_email_change(change_request):
def verify_email_change(change_request) -> None:
"""
Mark the email change request as verified
......@@ -135,7 +155,7 @@ def verify_email_change(change_request):
process_email_change(change_request)
def confirm_email_change(change_request):
def confirm_email_change(change_request) -> None:
"""
Mark the email change request as verified
......@@ -147,7 +167,7 @@ def confirm_email_change(change_request):
process_email_change(change_request)
def process_email_change(change_request):
def process_email_change(change_request) -> None:
"""
Change the user's email address if the request was completed and
send the completion email
......@@ -164,7 +184,7 @@ def process_email_change(change_request):
emails.send_email_change_completion_message(change_request)
def execute_data_minimisation(dry_run=False, members=None):
def execute_data_minimisation(dry_run=False, members=None) -> List[Member]:
"""
Clean the profiles of members/users of whom the last membership ended
at least 31 days ago
......
"""Sitemaps defined by the members package"""
from django.contrib import sitemaps
from django.urls import reverse
class StaticViewSitemap(sitemaps.Sitemap):
"""Static sitemap with members page"""
priority = 0.5
changefreq = 'daily'
......
......@@ -3,7 +3,7 @@
{% block object-tools-items %}
<li>
<a href="{% url 'members:iban-export' %}">{% trans "Export IBANs for Direct Debit" %}</a>
<a href="{% url 'admin:members_member_ibanexport' %}">{% trans "Export IBANs for Direct Debit" %}</a>
</li>
{{ block.super }}
{% endblock %}
{% extends "base.html" %}
{% load static i18n thumbnail bootstrap4 member_card alert %}
{% load static i18n thumbnail bootstrap4 member_card alert urlparams %}
{% block title %}{% trans "members"|capfirst %} — {{ block.super }}{% endblock %}
{% block opengraph_title %}{% trans "members"|capfirst %} — {{ block.super }}{% endblock %}
......@@ -19,50 +19,49 @@
</p>
<form class="search-form form-inline col-12 col-lg-6 offset-lg-3" method="get"
action="{% url 'members:index' %}#members-directory">
<input type="hidden" name="filter" value="{{ filter }}"/>
<input class="form-control col-12 col-md-9" name="keywords" type="text" value="{{ keys }}"
action="#members-directory">
<input class="form-control col-12 col-md-9" name="keywords" type="text" value="{{ keys|default_if_none:'' }}"
placeholder="{% trans "Who are you looking for?" %}"/>
<input class="btn btn-lg btn-primary col-12 mt-2 mt-md-0 col-md-3" name="submit" type="submit"
<input class="btn btn-lg btn-primary col-12 mt-2 mt-md-0 col-md-3" type="submit"
value="{% trans "Search" %}"/>
</form>
<ul class="nav nav-tabs justify-content-center mt-4">
<li class="nav-item">
<a class="nav-link{% if not filter or filter == "all" %} active{% endif %}"
href="{% url 'members:index' %}?filter=all{% if keywords %}&keywords={{ keys }}{% endif %}{% if page %}&page={{ page }}{% endif %}#members-directory">
href="{% url 'members:index' %}{% urlparams keywords=keys %}#members-directory">
{% trans "All members" %}
</a>
</li>
{% for year in year_range %}
<li class="nav-item">
<a class="nav-link{% if filter == year|stringformat:"i" %} active{% endif %}"
href="{% url 'members:index' %}?filter={{ year }}{% if keywords %}&keywords={{ keys }}{% endif %}{% if page %}&page={{ page }}{% endif %}#members-directory">
href="{% url 'members:index' year %}{% urlparams keywords=keys %}#members-directory">
{{ year }}
</a>
</li>
{% endfor %}
<li class="nav-item">
<a class="nav-link{% if filter == "older" %} active{% endif %}"
href="{% url 'members:index' %}?filter=older{% if keywords %}&keywords={{ keys }}{% endif %}{% if page %}&page={{ page }}{% endif %}#members-directory">
href="{% url 'members:index' 'older' %}{% urlparams keywords=keys %}#members-directory">
{% trans "Older" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if filter == "benefactors" %} active{% endif %}"
href="{% url 'members:index' %}?filter=benefactors{% if keywords %}&keywords={{ keys }}{% endif %}{% if page %}&page={{ page }}{% endif %}#members-directory">
href="{% url 'members:index' 'benefactors' %}{% urlparams keywords=keys %}#members-directory">
{% trans "Benefactors" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if filter == "honorary" %} active{% endif %}"
href="{% url 'members:index' %}?filter=honorary{% if keywords %}&keywords={{ keys }}{% endif %}{% if page %}&page={{ page }}{% endif %}#members-directory">
href="{% url 'members:index' 'honorary' %}{% urlparams keywords=keys %}#members-directory">
{% trans "Honorary Members" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link{% if filter == "former" %} active{% endif %}"
href="{% url 'members:index' %}?filter=former{% if keywords %}&keywords={{ keys }}{% endif %}{% if page %}&page={{ page }}{% endif %}#members-directory">
href="{% url 'members:index' 'former' %}{% urlparams keywords=keys %}#members-directory">
{% trans "Former Members" %}
</a>
</li>
......@@ -84,27 +83,27 @@
<nav>
<ul class="pagination justify-content-center mt-4">
{% if members.has_previous %}
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link"
href="{% url 'members:index' %}?page={{ members.previous_page_number }}{% if filter %}&filter={{ filter }}{% endif %}{% if keywords %}&keywords={{ keys }}{% endif %}#members-directory">
href="?page={{ page_obj.previous_page_number }}{% if keywords %}&keywords={{ keys }}{% endif %}#members-directory">
<span aria-hidden="true">&laquo;</span>
<span class="sr-only">Previous</span>
</a>
</li>
{% endif %}
{% for page in page_range %}
<li class="page-item{% if page == members.number %} active{% endif %}">
<li class="page-item{% if page == page_obj.number %} active{% endif %}">
<a class="page-link"
href="{% url 'members:index' %}?page={{ page }}{% if filter %}&filter={{ filter }}{% endif %}{% if keywords %}&keywords={{ keys }}{% endif %}#members-director">
href="?page={{ page }}{% if keywords %}&keywords={{ keys }}{% endif %}#members-director">
{{ page }}
</a>
</li>
{% endfor %}
{% if members.has_next %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link"
href="{% url 'members:index' %}?page={{ members.next_page_number }}{% if filter %}&filter={{ filter }}{% endif %}{% if keywords %}&keywords={{ keys }}{% endif %}">
href="?page={{ page_obj.next_page_number }}{% if keywords %}&keywords={{ keys }}{% endif %}">
<span aria-hidden="true">&raquo;</span>
<span class="sr-only">Next</span>
</a>
......
......@@ -10,9 +10,10 @@
<div class="container">
<h1 class="text-center section-title">{% trans "edit profile"|capfirst %}</h1>
{% if saved %}
{% trans "Your profile has been updated successfully." as success_text %}
{% alert 'success' success_text dismissable=True %}
{% if messages %}
{% for message in messages %}
{% alert message.tags message %}
{% endfor %}
{% endif %}
{% if form.errors %}
......
from datetime import date, datetime, timedelta
from datetime import datetime
from django.test import TestCase
from django.utils import timezone
from members.models import (Profile, Member, Membership)
from members.views import filter_users
from members.models import (Profile, Member)
class MemberBirthdayTest(TestCase):
......@@ -144,51 +144,3 @@ class MemberDisplayNameTest(TestCase):
self.profile.nickname = 'John'
self.assertEqual('\'John\' Test', self.profile.display_name())
self.assertEqual('John', self.profile.short_display_name())
class MembershipFilterTest(TestCase):
@classmethod
def setUpTestData(cls):
# Add 10 members with default membership
members = [Member(id=i, username=i) for i in range(7)]
Member.objects.bulk_create(members)
profiles = [Profile(user_id=i) for i in range(7)]
Profile.objects.bulk_create(profiles)
Membership(user_id=0, type=Membership.HONORARY,
until=date.today() + timedelta(days=1)).save()
Membership(user_id=1, type=Membership.BENEFACTOR,
until=date.today() + timedelta(days=1)).save()
Membership(user_id=2, type=Membership.MEMBER,
until=date.today() + timedelta(days=1)).save()
Membership(user_id=3, type=Membership.MEMBER,
until=date.today() + timedelta(days=1)).save()
Membership(user_id=3, type=Membership.MEMBER,
until=date.today() - timedelta(days=365*10)).save()
Membership(user_id=4, type=Membership.BENEFACTOR,
until=date.today() + timedelta(days=1)).save()
Membership(user_id=4, type=Membership.MEMBER,
until=date.today() - timedelta(days=365*10)).save()
Membership(user_id=5, type=Membership.MEMBER,
until=date.today() - timedelta(days=365*10)).save()
# user_id=6 has no memberships at all
def test_honorary(self):
members = filter_users('honorary', '', [date.today().year])
self.assertEqual(len(members), 1)
self.assertEqual(members[0].id, 0)
def test_former(self):
members = filter_users('former', '', [date.today().year])
self.assertEqual(len(members), 3)
for member in members:
self.assertIn(member.id, {4, 5, 6})
# TODO more tests for other cases and move to services
from datetime import date, timedelta
from django.test import TestCase
from members.models import Member, Profile, Membership
from members.views import MembersIndex
class MembersIndexText(TestCase):
@classmethod
def setUpTestData(cls):
# Add 10 members with default membership
members = [Member(id=i, username=i) for i in range(7)]
Member.objects.bulk_create(members)
profiles = [Profile(user_id=i) for i in range(7)]
Profile.objects.bulk_create(profiles)
Membership(user_id=0, type=Membership.HONORARY,
until=date.today() + timedelta(days=1)).save()
Membership(user_id=1, type=Membership.BENEFACTOR,
until=date.today() + timedelta(days=1)).save()
Membership(user_id=2, type=Membership.MEMBER,
until=date.today() + timedelta(days=1)).save()
Membership(user_id=3, type=Membership.MEMBER,
until=date.today() + timedelta(days=1)).save()
Membership(user_id=3, type=Membership.MEMBER,
until=date.today() - timedelta(days=365*10)).save()
Membership(user_id=4, type=Membership.BENEFACTOR,
until=date.today() + timedelta(days=1)).save()
Membership(user_id=4, type=Membership.MEMBER,
until=date.today() - timedelta(days=365*10)).save()
Membership(user_id=5, type=Membership.MEMBER,
until=date.today() - timedelta(days=365*10)).save()
# user_id=6 has no memberships at all
def test_honorary_query_filter(self):
view = MembersIndex()
view.query_filter = 'honorary'
view.year_range = [date.today().year]
members = view.get_queryset()
self.assertEqual(len(members), 1)
self.assertEqual(members[0].id, 0)
def test_former_query_filter(self):
view = MembersIndex()
view.query_filter = 'former'
view.year_range = [date.today().year]
members = view.get_queryset()
self.assertEqual(len(members), 3)
for member in members:
self.assertIn(member.id, {4, 5, 6})
"""The routes defined by the members package"""
from django.urls import path, include
from . import views
from members.views import (
MembersIndex, StatisticsView, ProfileDetailView,
UserAccountView, UserProfileUpdateView,
EmailChangeFormView,
EmailChangeVerifyView, EmailChangeConfirmView
)
app_name = "members"
urlpatterns = [
path('iban-export/', views.iban_export,
name='iban-export'),
path('members/', include([
path('', views.index,
name='index'),
path('statistics/', views.statistics,
path('', MembersIndex.as_view(), name='index'),
path('<slug:filter>/', MembersIndex.as_view(), name='index'),
path('statistics/', StatisticsView.as_view(),
name='statistics'),
path('profile/<int:pk>', views.profile,
path('profile/<int:pk>', ProfileDetailView.as_view(),
name='profile'),
])),
path('user/', include([
path('', views.user,
path('', UserAccountView.as_view(),
name='user'),
path('edit-profile/', views.edit_profile,
path('edit-profile/', UserProfileUpdateView.as_view(),
name='edit-profile'),
path('change-email/', views.EmailChangeFormView.as_view(),
path('change-email/', EmailChangeFormView.as_view(),
name='email-change'),
path('change-email/verify/<uuid:key>/',
views.EmailChangeVerifyView.as_view(),
EmailChangeVerifyView.as_view(),
name='email-change-verify'),
path('change-email/confirm/<uuid:key>/',
views.EmailChangeConfirmView.as_view(),
EmailChangeConfirmView.as_view(),
name='email-change-confirm'),
])),
]
This diff is collapsed.
......@@ -89,10 +89,9 @@ urlpatterns = [ # pylint: disable=invalid-name
path('sibling-associations/', SiblingAssociationsView.as_view(), name='sibling-associations'),
url(r'^thabloid/', include('thabloid.urls')),
])),
url(r'^', include([ # 'for members' menu
url(r'^members/', include([ # 'for members' menu
path('become-active/', BecomeActiveView.as_view(), name='become-active'),
url(r'^photos/', include('photos.urls')),
path('statistics/', members.views.statistics, name='statistics'),
path('styleguide/', StyleGuideView.as_view(), name='styleguide'),
])),
url(r'^career/', include('partners.urls')),
......
from django import template
from urllib.parse import urlencode
register = template.Library()
@register.simple_tag