diff --git a/docs/members.rst b/docs/members.rst index 688f1f18a49ad1efb708f297abcf4cfb6c396b0c..82954818ad5c99b0e880cc893a6e43bc1e739d27 100644 --- a/docs/members.rst +++ b/docs/members.rst @@ -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 ------------------- diff --git a/docs/utils.templatetags.rst b/docs/utils.templatetags.rst index 445de90a3e1d817bf515339de93bf9887972d8c4..e9454c40759f7eea69483683f7555a0d29a3fdb4 100644 --- a/docs/utils.templatetags.rst +++ b/docs/utils.templatetags.rst @@ -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: + diff --git a/website/members/admin.py b/website/members/admin.py index 5abef9a376742d281f8e0ebacabb4090f02bf986..5ad277ba1cf0d741a917a52dcbbefbc759f9656c 100644 --- a/website/members/admin.py +++ b/website/members/admin.py @@ -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): diff --git a/website/members/admin_views.py b/website/members/admin_views.py new file mode 100644 index 0000000000000000000000000000000000000000..de6d62cb24e9bbe5b24417885c3a148f177a7e03 --- /dev/null +++ b/website/members/admin_views.py @@ -0,0 +1,46 @@ +"""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 diff --git a/website/members/api/serializers.py b/website/members/api/serializers.py index bf5c13d0a5d9568d9937061fcd3fbf432c1a42bb..be71e23f69b352e3c8074e9a202fbd68ce96d1e7 100644 --- a/website/members/api/serializers.py +++ b/website/members/api/serializers.py @@ -1,3 +1,4 @@ +"""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', diff --git a/website/members/api/urls.py b/website/members/api/urls.py index 35d75933dfa9da04899d9b286de5cdb4b2f983ff..e3601152228addf8bc4c3505ddd83bb2e702ab10 100644 --- a/website/members/api/urls.py +++ b/website/members/api/urls.py @@ -1,3 +1,4 @@ +"""DRF routes defined by the members package""" from rest_framework import routers from members.api import viewsets diff --git a/website/members/api/viewsets.py b/website/members/api/viewsets.py index 80eb74d6b6889a6b5af29cba2cd9526989037bcc..451279fb944ae58ec5f98aef165d1419ed71c838 100644 --- a/website/members/api/viewsets.py +++ b/website/members/api/viewsets.py @@ -1,7 +1,8 @@ +"""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') diff --git a/website/members/apps.py b/website/members/apps.py index cf3d2508d51851f40b47ec292dc272e4f2c5233e..ade1e03a9b37923e06bcced7ca308648dba7b31d 100644 --- a/website/members/apps.py +++ b/website/members/apps.py @@ -1,3 +1,4 @@ +"""Configuration for the members package""" from django.apps import AppConfig from django.utils.translation import gettext_lazy as _ diff --git a/website/members/decorators.py b/website/members/decorators.py index f1f36b409bd4c3dcfb656eda3adf769536d0e756..dcf8252c415fc9a68b6d11082930b1a5efe64c87 100644 --- a/website/members/decorators.py +++ b/website/members/decorators.py @@ -1,3 +1,4 @@ +"""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 diff --git a/website/members/emails.py b/website/members/emails.py index 8386087d915dbf0aafa126e135bcbd1fb603af80..acf0581b898050548d728b5703a6fc15d644e643 100644 --- a/website/members/emails.py +++ b/website/members/emails.py @@ -1,3 +1,4 @@ +"""The emails defined by the members package""" from datetime import timedelta import logging diff --git a/website/members/forms.py b/website/members/forms.py index ff30b79e37464d844717f232fd41babb9afcb9fe..0e4a13ef78992db8890ea7d91f59680e75ee13d3 100644 --- a/website/members/forms.py +++ b/website/members/forms.py @@ -1,14 +1,16 @@ +"""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'] diff --git a/website/members/middleware.py b/website/members/middleware.py index b4c2a999ed4e0a51f114a9ba8a67fee8a72cf2b5..15d69b79b4bf07918b8fd51dec4e98376b121ed8 100644 --- a/website/members/middleware.py +++ b/website/members/middleware.py @@ -1,3 +1,4 @@ +"""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 diff --git a/website/members/models.py b/website/members/models.py index 43ca253659712c8bb21df9f274e523c1fc198ca8..b7bff7f45b0c5095b5543b06a8c479a5f37ccbcc 100644 --- a/website/members/models.py +++ b/website/members/models.py @@ -1,3 +1,4 @@ +"""Models defined in the members package""" import logging import operator import os diff --git a/website/members/services.py b/website/members/services.py index 651207b0fc8510a14fb775e3e3025bd43350388b..2df3e47088846626506fd667b3262517f03146d3 100644 --- a/website/members/services.py +++ b/website/members/services.py @@ -1,4 +1,6 @@ +"""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 diff --git a/website/members/sitemaps.py b/website/members/sitemaps.py index b39bbc3388bf1b635e257562614efa7a741f721a..2f43d9148ecd80df8b94f852893b0ee10886f3b2 100644 --- a/website/members/sitemaps.py +++ b/website/members/sitemaps.py @@ -1,8 +1,10 @@ +"""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' diff --git a/website/members/templates/admin/members/change_list.html b/website/members/templates/admin/members/change_list.html index 196f15f3a1fefb85ace52d2aa05aec7f8da61905..f263a2b3aa84c01c90ea30a0baca955a1c7f22de 100644 --- a/website/members/templates/admin/members/change_list.html +++ b/website/members/templates/admin/members/change_list.html @@ -3,7 +3,7 @@ {% block object-tools-items %}
  • - {% trans "Export IBANs for Direct Debit" %} + {% trans "Export IBANs for Direct Debit" %}
  • {{ block.super }} {% endblock %} diff --git a/website/members/templates/members/index.html b/website/members/templates/members/index.html index b8c5bb440ecc5fea5ce5a57167c38944cddfe2e1..b9ac889e396975cf0f643df8fe268396a9fc2310 100644 --- a/website/members/templates/members/index.html +++ b/website/members/templates/members/index.html @@ -1,5 +1,5 @@ {% 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 @@

    - - + -