From b7be8641adee66f6473f988874bdd8da5ec59eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Mon, 27 Nov 2023 00:45:42 +0000 Subject: [PATCH] feat: add Club/Association management tools --- club/__init__.py | 0 club/admin.py | 29 +++++++ club/apps.py | 6 ++ club/forms.py | 21 +++++ club/migrations/0001_initial.py | 58 +++++++++++++ club/migrations/__init__.py | 0 club/models.py | 96 +++++++++++++++++++++ club/templates/club/member_detail.html | 114 +++++++++++++++++++++++++ club/templates/club/member_form.html | 16 ++++ club/templates/club/member_list.html | 37 ++++++++ club/templates/club/member_table.html | 72 ++++++++++++++++ club/tests.py | 1 + club/urls.py | 10 +++ club/views.py | 26 ++++++ kcc3/settings/base.py | 2 + kcc3/urls.py | 8 +- poetry.lock | 18 +++- pyproject.toml | 1 + templates/common/base.html | 21 +++-- 19 files changed, 522 insertions(+), 14 deletions(-) create mode 100644 club/__init__.py create mode 100644 club/admin.py create mode 100644 club/apps.py create mode 100644 club/forms.py create mode 100644 club/migrations/0001_initial.py create mode 100644 club/migrations/__init__.py create mode 100644 club/models.py create mode 100644 club/templates/club/member_detail.html create mode 100644 club/templates/club/member_form.html create mode 100644 club/templates/club/member_list.html create mode 100644 club/templates/club/member_table.html create mode 100644 club/tests.py create mode 100644 club/urls.py create mode 100644 club/views.py diff --git a/club/__init__.py b/club/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/club/admin.py b/club/admin.py new file mode 100644 index 0000000..51633d5 --- /dev/null +++ b/club/admin.py @@ -0,0 +1,29 @@ +from collections.abc import Iterable +from typing import ClassVar + +from django.contrib import admin + +from club.models import Member, MemberRole, MembershipFeePeriod + + +class MemberRoleInline(admin.TabularInline): + model = MemberRole + + +class MemberAdmin(admin.ModelAdmin): + prepopulated_fields: ClassVar[dict[str, Iterable[str]]] = {"id": ("first_name", "last_name")} + + search_fields = ("id", "first_name", "last_name", "nickname") + list_display = ("id", "first_name", "last_name", "nickname", "active_since", "active_until") + list_display_links = ("id", "first_name", "last_name", "nickname") + ordering = ("last_name", "first_name") + + inlines = (MemberRoleInline,) + + +class MembershipFeePeriodAdmin(admin.ModelAdmin): + pass + + +admin.site.register(Member, MemberAdmin) +admin.site.register(MembershipFeePeriod, MembershipFeePeriodAdmin) diff --git a/club/apps.py b/club/apps.py new file mode 100644 index 0000000..fffa95b --- /dev/null +++ b/club/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ClubConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "club" diff --git a/club/forms.py b/club/forms.py new file mode 100644 index 0000000..09c63ba --- /dev/null +++ b/club/forms.py @@ -0,0 +1,21 @@ +from django.forms import ModelForm + +from club.models import Member + + +class MemberForm(ModelForm): + class Meta: + model = Member + fields = ( + "first_name", + "last_name", + "nickname", + "workspace_email", + "personal_email", + "email_communication_consent", + "address", + "discord_nickname", + "discord_id", + "active_since", + "active_until", + ) diff --git a/club/migrations/0001_initial.py b/club/migrations/0001_initial.py new file mode 100644 index 0000000..7fb8229 --- /dev/null +++ b/club/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# Generated by Django 4.2.7 on 2023-11-26 22:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('players', '0004_auto_20190922_1346'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Member', + fields=[ + ('id', models.SlugField(primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=30)), + ('last_name', models.CharField(max_length=150)), + ('nickname', models.CharField(blank=True, max_length=50)), + ('workspace_email', models.EmailField(max_length=254)), + ('email_communication_consent', models.BooleanField()), + ('personal_email', models.EmailField(blank=True, max_length=254)), + ('address', models.TextField()), + ('discord_nickname', models.CharField(blank=True, max_length=30, verbose_name='Discord nickname')), + ('discord_id', models.CharField(blank=True, max_length=30, verbose_name='Discord ID')), + ('active_since', models.DateField()), + ('active_until', models.DateField(blank=True, null=True)), + ('player', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='players.player')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='MembershipFeePeriod', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('active_since', models.DateField()), + ('active_until', models.DateField()), + ('fee', models.DecimalField(decimal_places=6, max_digits=15)), + ('members', models.ManyToManyField(blank=True, to='club.member')), + ], + ), + migrations.CreateModel( + name='MemberRole', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('BOARD', 'Board'), ('AUDIT_COMMITTEE', 'Audit committee')], max_length=20)), + ('active_since', models.DateField()), + ('active_until', models.DateField(blank=True, null=True)), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='club.member')), + ], + ), + ] diff --git a/club/migrations/__init__.py b/club/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/club/models.py b/club/models.py new file mode 100644 index 0000000..f291c14 --- /dev/null +++ b/club/models.py @@ -0,0 +1,96 @@ +from django.contrib.auth.models import User +from django.db import models +from django.utils import timezone +from django_hosts import reverse + +from players.models import Player + + +class Member(models.Model): + id = models.SlugField(primary_key=True, verbose_name="ID") + + user = models.OneToOneField(User, on_delete=models.SET_NULL, null=True, blank=True) + player = models.OneToOneField(Player, on_delete=models.SET_NULL, null=True, blank=True) + + first_name = models.CharField(max_length=30) + last_name = models.CharField(max_length=150) + nickname = models.CharField(max_length=50, blank=True) + + workspace_email = models.EmailField() + email_communication_consent = models.BooleanField() + personal_email = models.EmailField(blank=True) + address = models.TextField() + discord_nickname = models.CharField(max_length=30, blank=True, verbose_name="Discord nickname") + discord_id = models.CharField(max_length=30, blank=True, verbose_name="Discord ID") + + active_since = models.DateField() + active_until = models.DateField(null=True, blank=True) + + def get_absolute_url(self): + return reverse("member-detail", kwargs={"pk": self.pk}, host="root") + + def relevant_fees(self): + return MembershipFeePeriod.objects.filter(active_until__gte=self.active_since) + + def paid_fees(self): + return self.membershipfeeperiod_set.all() + + def current_fee_paid(self): + last_period = MembershipFeePeriod.objects.last_period() + if last_period is None: + return True + return self in last_period.members.all() + + def current_role(self): + today = timezone.now().date() + last_role = self.memberrole_set.order_by("-active_until").first() + if last_role is not None and (last_role.active_until is None or last_role.active_until >= today): + return last_role.name() + else: + return "Member" + + def __str__(self): + s = "" + + if self.first_name and self.last_name: + s = f"{self.first_name} {self.last_name}" + + if self.nickname: + if s: + s += f" ({self.nickname})" + else: + s = self.nickname + + return s + + +class MemberRole(models.Model): + class MemberRoleChoice(models.TextChoices): + BOARD = ("BOARD", "Board") + AUDIT_COMMITTEE = ("AUDIT_COMMITTEE", "Audit committee") + + member = models.ForeignKey(Member, on_delete=models.CASCADE) + role = models.CharField( + max_length=20, + choices=MemberRoleChoice.choices, + ) + active_since = models.DateField() + active_until = models.DateField(null=True, blank=True) + + def name(self): + return self.get_role_display() + + +class MembershipFeePeriodManager(models.Manager): + def last_period(self): + return self.order_by("-active_until").first() + + +class MembershipFeePeriod(models.Model): + name = models.CharField(max_length=30) + active_since = models.DateField() + active_until = models.DateField() + fee = models.DecimalField(max_digits=15, decimal_places=6) + members = models.ManyToManyField(Member, blank=True) + + objects = MembershipFeePeriodManager() diff --git a/club/templates/club/member_detail.html b/club/templates/club/member_detail.html new file mode 100644 index 0000000..fbf07ae --- /dev/null +++ b/club/templates/club/member_detail.html @@ -0,0 +1,114 @@ +{% extends 'common/base.html' %} +{% block title %}{{ object }}{% endblock %} + +{% block body %} +
+

{{ object }}

+ +
+
First name
+
{{ object.first_name }}
+ +
Last name
+
{{ object.last_name }}
+ +
Nickname
+
{{ object.nickname|default:"–" }}
+ +
Workspace email
+
+ {% if member.workspace_email %} + + {{ member.workspace_email|default:"–" }} + + {% else %} + - + {% endif %} +
+ +
Personal email
+
+ {% if member.workspace_email %} + + {{ member.personal_email|default:"–" }} + + {% else %} + - + {% endif %} +
+ +
Address
+
{{ object.address|linebreaksbr }}
+ +
Discord nickname
+
{{ object.discord_nickname|default:"–" }}
+ +
Discord ID
+
{{ object.discord_id|default:"–" }}
+ +
Active since
+
{{ object.active_since|default:"–" }}
+ +
Active until
+
{{ object.active_until|default:"–" }}
+ +
Current fee paid
+
+ {% if object.current_fee_paid %} + ✅ + {% else %} + ❌ + {% endif %} +
+ +
Current role
+
{{ member.current_role }}
+
+ +

Fees

+ + + + {% for fee in member.relevant_fees %} + + {% endfor %} + + + + + {% for fee in member.relevant_fees %} + + {% endfor %} + + +
{{ fee.name }}
+ {% if fee in member.paid_fees %} + ✅ + {% else %} + ❌ + {% endif %} +
+ +

Roles

+ + + + + + + + + + {% for role in member.memberrole_set.all %} + + + + + + {% endfor %} + + + +
Role nameActive sinceActive until
{{ role.name }}{{ role.active_since }}{{ role.active_until|default:"–" }}
+
+{% endblock %} diff --git a/club/templates/club/member_form.html b/club/templates/club/member_form.html new file mode 100644 index 0000000..e3ca500 --- /dev/null +++ b/club/templates/club/member_form.html @@ -0,0 +1,16 @@ +{% extends 'common/base.html' %} +{% load django_bootstrap5 %} +{% block title %}Members - Add{% endblock %} + +{% block body %} +
+

Create member

+ +
+ {% csrf_token %} + {% bootstrap_form form %} + + {% bootstrap_button "Create" button_type="submit" button_class="btn-primary" %} +
+
+{% endblock %} diff --git a/club/templates/club/member_list.html b/club/templates/club/member_list.html new file mode 100644 index 0000000..9d67b0a --- /dev/null +++ b/club/templates/club/member_list.html @@ -0,0 +1,37 @@ +{% extends 'common/base.html' %} +{% block title %}Members{% endblock %} + +{% block body %} +
+

Members

+ + + + + + {% include 'club/member_table.html' with members=object_list %} +
+{% endblock %} diff --git a/club/templates/club/member_table.html b/club/templates/club/member_table.html new file mode 100644 index 0000000..045a032 --- /dev/null +++ b/club/templates/club/member_table.html @@ -0,0 +1,72 @@ +
+ + + + + + + + + + + + + + + + {% for member in members %} + + + + + + + + + + + + {% endfor %} + +
First nameLast nameNicknameWorkspace E-mailPersonal E-mailFee paidRoleMember sinceMember until
+ + {{ member.first_name|default:"–" }} + + + + {{ member.last_name|default:"–" }} + + + + {{ member.nickname|default:"–" }} + + + {% if member.workspace_email %} + + {{ member.workspace_email|default:"–" }} + + {% else %} + - + {% endif %} + + {% if member.workspace_email %} + + {{ member.personal_email|default:"–" }} + + {% else %} + - + {% endif %} + + {% if member.current_fee_paid %} + ✅ + {% else %} + ❌ + {% endif %} + + {{ member.current_role }} + + {{ member.active_since|default:"–" }} + + {{ member.active_until|default:"–" }} +
+
diff --git a/club/tests.py b/club/tests.py new file mode 100644 index 0000000..a39b155 --- /dev/null +++ b/club/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/club/urls.py b/club/urls.py new file mode 100644 index 0000000..d9269fa --- /dev/null +++ b/club/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import MemberCreateView, MemberDetailView, MemberListView + +urlpatterns = [ + path("", MemberListView.as_view(), name="member-list"), + path("members/create/", MemberCreateView.as_view(), name="member-create"), + path("members/get//", MemberDetailView.as_view(), name="member-detail"), + # path("members//edit/", MemberDetailView.as_view(), name="member-edit"), +] diff --git a/club/views.py b/club/views.py new file mode 100644 index 0000000..d5d0892 --- /dev/null +++ b/club/views.py @@ -0,0 +1,26 @@ +from django.views.generic import CreateView, ListView +from django.views.generic.detail import DetailView + +from club.forms import MemberForm +from club.models import Member + + +class MemberListView(ListView): + model = Member + + +class MemberDetailView(DetailView): + model = Member + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + print(context["object"].membershipfeeperiod_set.all()) + + return context + + +class MemberCreateView(CreateView): + model = Member + form_class = MemberForm + # fields = ["name"] diff --git a/kcc3/settings/base.py b/kcc3/settings/base.py index 7a06834..3880d99 100644 --- a/kcc3/settings/base.py +++ b/kcc3/settings/base.py @@ -31,6 +31,7 @@ "django_hosts", "rest_framework", "rest_framework.authtoken", + "django_bootstrap5", ] PROJECT_APPS = [ @@ -39,6 +40,7 @@ "players", "chombos", "yakumans", + "club", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + PROJECT_APPS diff --git a/kcc3/urls.py b/kcc3/urls.py index 6d543ab..4432729 100644 --- a/kcc3/urls.py +++ b/kcc3/urls.py @@ -21,6 +21,7 @@ import badges.urls import chombos.urls +import club.urls import players.urls from badges.viewsets import BadgeViewSet from chombos.viewsets import ChomboViewSet @@ -42,12 +43,13 @@ ] urlpatterns = [ - path("api/", include(router.urls)), - path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), + path("", include(badges.urls.global_urlpatterns)), path("admin/", include(admin_urlpatterns)), + path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), + path("api/", include(router.urls)), path("badge-clients/", include(badgeclients_urlpatterns)), - path("", include(badges.urls.global_urlpatterns)), path("badges/", include(badges.urls.urlpatterns)), + path("club/", include(club.urls.urlpatterns)), path("players/", include(players.urls.urlpatterns)), *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), ] diff --git a/poetry.lock b/poetry.lock index 9e088c7..cd81613 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand. [[package]] name = "amqp" @@ -311,6 +311,20 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] +[[package]] +name = "django-bootstrap5" +version = "23.3" +description = "Bootstrap 5 for Django" +optional = false +python-versions = ">=3.7" +files = [ + {file = "django_bootstrap5-23.3-py3-none-any.whl", hash = "sha256:ca1bb2f40175ed1c1725f52f249a574bf629b33b5a0af3bec0eab1de6d72836c"}, + {file = "django_bootstrap5-23.3.tar.gz", hash = "sha256:21e1956a8a819370decc5d365ce4f4207761cb065afec5a7df8c4c8c49507f2e"}, +] + +[package.dependencies] +django = ">=3.2" + [[package]] name = "django-celery-beat" version = "2.5.0" @@ -749,4 +763,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "5ec0887b8e7534afc56038141ad90291504ea68a903243bde149c1b9cd5e21fd" +content-hash = "a3cd9dd0ea2ca417875414492918b4055d159c69399868ebceab4713cea53741" diff --git a/pyproject.toml b/pyproject.toml index af47094..4b082be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ psycopg2-binary = "2.9.9" pytz = "2023.3.post1" requests = "2.31.0" sqlparse = "0.4.4" +django-bootstrap5 = "^23.3" [tool.ruff] line-length = 120 diff --git a/templates/common/base.html b/templates/common/base.html index d491b8a..24c1c63 100644 --- a/templates/common/base.html +++ b/templates/common/base.html @@ -1,4 +1,5 @@ {% load static %} +{% load django_bootstrap5 %} @@ -7,7 +8,9 @@ {% block title %}{% endblock %} — Kraków Chombo Club - + {% bootstrap_css %} + + {% block head %}{% endblock %} @@ -19,11 +22,8 @@ class="d-inline-block align-top" alt=""> Chombo! - +{% bootstrap_messages %} + {% block body %}{% endblock %} - - - +{% bootstrap_javascript %}