From 4408c9575a054a4f6dc189727c9a38bb516820ab Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:45:41 +0200 Subject: [PATCH 1/6] model --- tapir/accounts/models.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tapir/accounts/models.py b/tapir/accounts/models.py index 6a07900f2..1d08b87f0 100644 --- a/tapir/accounts/models.py +++ b/tapir/accounts/models.py @@ -305,3 +305,21 @@ class Meta: fields=["user", "mail_id"], name="user-mail-constraint" ) ] + + +class CoPurchaser(models.Model): + user = models.ForeignKey( + "TapirUser", on_delete=models.CASCADE, related_name="copurchaser" + ) + first_name = models.CharField(blank=True, max_length=255) + last_name = models.CharField(blank=True, max_length=255) + email = models.EmailField(blank=True, max_length=254) + order = models.PositiveIntegerField(default=0) + + class Meta: + verbose_name_plural = _("Co-Purchasers") + verbose_name = _("Co-Purchaser") + ordering = ["order"] + + def get_full_name(self): + return f"{self.first_name} {self.last_name}" From aea8ce2b53f63775577725a576c14aff77317b99 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:58:23 +0200 Subject: [PATCH 2/6] CO_PURCHASER_MAX_CO_PURCHASERS --- tapir/accounts/models.py | 8 ++++++++ tapir/settings.py | 2 ++ 2 files changed, 10 insertions(+) diff --git a/tapir/accounts/models.py b/tapir/accounts/models.py index 1d08b87f0..527fec42c 100644 --- a/tapir/accounts/models.py +++ b/tapir/accounts/models.py @@ -323,3 +323,11 @@ class Meta: def get_full_name(self): return f"{self.first_name} {self.last_name}" + + def clean(self): + max_co_purchasers = getattr(settings, "CO_PURCHASER_MAX_CO_PURCHASERS", 2) + + if self.pk is None: + existing_count = CoPurchaser.objects.filter(user=self.user).count() + if existing_count >= max_co_purchasers: + raise ValidationError(_("Maximum numbers of Co-Purchasers exceeded")) diff --git a/tapir/settings.py b/tapir/settings.py index c994200cc..513446bf5 100644 --- a/tapir/settings.py +++ b/tapir/settings.py @@ -436,3 +436,5 @@ SUBDIV_FOR_HOLIDAYS_AUTO_CANCEL = env.str( "SUBDIV_FOR_HOLIDAYS_AUTO_CANCEL", default="BE" ) + +CO_PURCHASER_MAX_CO_PURCHASERS = 2 From 06c330a96077cb86aa19929088d529a3802535c1 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:58:57 +0200 Subject: [PATCH 3/6] migration and user_detail.html --- tapir/accounts/migrations/0024_copurchaser.py | 49 ++++++++++++++++++ .../migrations/0025_co_purchaser_model.py | 50 +++++++++++++++++++ .../templates/accounts/user_detail.html | 25 +++++++++- 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 tapir/accounts/migrations/0024_copurchaser.py create mode 100644 tapir/accounts/migrations/0025_co_purchaser_model.py diff --git a/tapir/accounts/migrations/0024_copurchaser.py b/tapir/accounts/migrations/0024_copurchaser.py new file mode 100644 index 000000000..da566d0f2 --- /dev/null +++ b/tapir/accounts/migrations/0024_copurchaser.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.13 on 2026-04-26 17:01 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "accounts", + "0023_updatetapiruserlogentry_accounts_up_old_val_25b95f_gin_and_more", + ), + ] + + operations = [ + migrations.CreateModel( + name="CoPurchaser", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("first_name", models.CharField(blank=True, max_length=255)), + ("last_name", models.CharField(blank=True, max_length=255)), + ("email", models.EmailField(blank=True, max_length=254)), + ("order", models.PositiveIntegerField(default=0)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="copurchaser", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Co-Purchaser", + "verbose_name_plural": "Co-Purchasers", + "ordering": ["order"], + }, + ), + ] diff --git a/tapir/accounts/migrations/0025_co_purchaser_model.py b/tapir/accounts/migrations/0025_co_purchaser_model.py new file mode 100644 index 000000000..edcb442b1 --- /dev/null +++ b/tapir/accounts/migrations/0025_co_purchaser_model.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.13 on 2026-04-26 17:02 + +from django.db import migrations + + +def split_name(full_name): + if not full_name: + return "", "" + parts = full_name.strip().split() + if len(parts) == 1: + return parts[0], "" + else: + return " ".join(parts[:-1]), parts[-1] + + +def migrate_co_purchasers(apps, schema_editor): + TapirUser = apps.get_model("accounts", "TapirUser") + CoPurchaser = apps.get_model("accounts", "CoPurchaser") + + for user in TapirUser.objects.all(): + + if user.co_purchaser: + first_name, last_name = split_name(user.co_purchaser) + CoPurchaser.objects.create( + user=user, + first_name=first_name, + last_name=last_name, + email=user.co_purchaser_mail, + order=0, + ) + if user.co_purchaser_2: + first_name, last_name = split_name(user.co_purchaser_2) + CoPurchaser.objects.create( + user=user, + first_name=first_name, + last_name=last_name, + email=user.co_purchaser_2_mail, + order=1, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0024_copurchaser"), + ] + + operations = [ + migrations.RunPython(migrate_co_purchasers), + ] diff --git a/tapir/accounts/templates/accounts/user_detail.html b/tapir/accounts/templates/accounts/user_detail.html index 27602e3fb..ea60f63b9 100644 --- a/tapir/accounts/templates/accounts/user_detail.html +++ b/tapir/accounts/templates/accounts/user_detail.html @@ -153,7 +153,30 @@
{% share_owner_ownership_list object.share_owner %} +
+
+
+ {% translate "Co-Purchasers" %} + {% if perms.accounts.manage %} + + "add" + + {% endif %} +
+
+ {% with co_purchasers=object.copurchaser.all %} + {% if co_purchasers %} +
+ {% for co_purchaser in co_purchasers %}
{{ co_purchaser.get_full_name }}
{% endfor %} +
+ {% else %} +

{% translate "No co-purchasers registered." %}

+ {% endif %} + {% endwith %} +
+
+ {% share_owner_ownership_list object.share_owner %} +
{% user_shifts_overview object %}
From 98303ebfded31d02c6244438f712c6c6e297cbc4 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:06:57 +0200 Subject: [PATCH 4/6] CreateView --- tapir/accounts/forms.py | 40 ++++------------- tapir/accounts/models.py | 8 ---- .../templates/accounts/co_purchaser_form.html | 24 ++++++++++ .../templates/accounts/user_detail.html | 5 ++- tapir/accounts/urls.py | 5 +++ tapir/accounts/views.py | 44 ++++++++++++++++++- 6 files changed, 84 insertions(+), 42 deletions(-) create mode 100644 tapir/accounts/templates/accounts/co_purchaser_form.html diff --git a/tapir/accounts/forms.py b/tapir/accounts/forms.py index 6e631916c..ab8019f02 100644 --- a/tapir/accounts/forms.py +++ b/tapir/accounts/forms.py @@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _ from tapir import settings -from tapir.accounts.models import TapirUser +from tapir.accounts.models import TapirUser, CoPurchaser from tapir.core.mail_option import MailOption from tapir.core.services.mail_classes_service import MailClassesService from tapir.core.services.optional_mail_choices_service import OptionalMailChoicesService @@ -50,10 +50,6 @@ class Meta(TapirUserSelfUpdateForm.Meta): "postcode", "city", "preferred_language", - "co_purchaser", - "co_purchaser_mail", - "co_purchaser_2", - "co_purchaser_2_mail", ] + TapirUserSelfUpdateForm.Meta.fields widgets = TapirUserSelfUpdateForm.Meta.widgets | { @@ -61,33 +57,6 @@ class Meta(TapirUserSelfUpdateForm.Meta): "username": TextInput(attrs={"readonly": True}), } - def clean(self): - cleaned_data = super().clean() - - if ( - cleaned_data.get("co_purchaser_mail", "") != "" - and cleaned_data.get("co_purchaser", "") == "" - ): - raise ValidationError( - { - "co_purchaser_mail": _( - "If there is not co-purchaser then the co-purchaser-mail field must also be empty" - ) - } - ) - - if ( - cleaned_data.get("co_purchaser_2_mail", "") != "" - and cleaned_data.get("co_purchaser_2", "") == "" - ): - raise ValidationError( - { - "co_purchaser_2_mail": _( - "If there is not co-purchaser 2 then the co-purchaser-mail 2 field must also be empty" - ) - } - ) - class PasswordResetForm(auth_forms.PasswordResetForm): def get_users(self, email): @@ -176,3 +145,10 @@ def __init__(self, *args, **kwargs): tapir_user ) ) + + +class CoPurchaserForm(forms.ModelForm): + class Meta: + model = CoPurchaser + fields = ["first_name", "last_name", "email"] + widgets = {} diff --git a/tapir/accounts/models.py b/tapir/accounts/models.py index 527fec42c..1d08b87f0 100644 --- a/tapir/accounts/models.py +++ b/tapir/accounts/models.py @@ -323,11 +323,3 @@ class Meta: def get_full_name(self): return f"{self.first_name} {self.last_name}" - - def clean(self): - max_co_purchasers = getattr(settings, "CO_PURCHASER_MAX_CO_PURCHASERS", 2) - - if self.pk is None: - existing_count = CoPurchaser.objects.filter(user=self.user).count() - if existing_count >= max_co_purchasers: - raise ValidationError(_("Maximum numbers of Co-Purchasers exceeded")) diff --git a/tapir/accounts/templates/accounts/co_purchaser_form.html b/tapir/accounts/templates/accounts/co_purchaser_form.html new file mode 100644 index 000000000..5801aff5f --- /dev/null +++ b/tapir/accounts/templates/accounts/co_purchaser_form.html @@ -0,0 +1,24 @@ +{% extends "core/base.html" %} +{% load django_bootstrap5 %} +{% load i18n %} +{% load static %} +{% load core %} +{% load utils %} +{% block head %} + {{ block.super }} + {{ form.media }} +{% endblock head %} +{% block title %} + {% form_title %} +{% endblock title %} +{% block content %} +
+
+
{{ form_title }}
+
+ {% csrf_token %} + {% bootstrap_form form %} +
+
+
+{% endblock content %} diff --git a/tapir/accounts/templates/accounts/user_detail.html b/tapir/accounts/templates/accounts/user_detail.html index ea60f63b9..81ba0d0e7 100644 --- a/tapir/accounts/templates/accounts/user_detail.html +++ b/tapir/accounts/templates/accounts/user_detail.html @@ -159,7 +159,10 @@
{% if perms.accounts.manage %} - "add" + + person_add{% translate "Add Co-Purchaser" %} + {% endif %}
diff --git a/tapir/accounts/urls.py b/tapir/accounts/urls.py index 8c6d65abb..f576ba261 100644 --- a/tapir/accounts/urls.py +++ b/tapir/accounts/urls.py @@ -55,6 +55,11 @@ views.MailSettingsView.as_view(), name="mail_settings", ), + path( + "user//co-purchasers/create/", + views.CoPurchaserCreateView.as_view(), + name="co_purchaser_create", + ), path( "open_door", views.OpenDoorView.as_view(), diff --git a/tapir/accounts/views.py b/tapir/accounts/views.py index 481a2be59..7530ccf23 100644 --- a/tapir/accounts/views.py +++ b/tapir/accounts/views.py @@ -7,12 +7,13 @@ from django.db import transaction from django.http import HttpResponse, HttpResponseForbidden from django.shortcuts import get_object_or_404, redirect -from django.urls import reverse +from django.urls import reverse, reverse_lazy from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.views import generic from django.views.decorators.csrf import csrf_protect from django.views.decorators.http import require_POST, require_GET +from django.views.generic import CreateView from tapir import settings from tapir.accounts import pdfs @@ -24,11 +25,13 @@ TapirUserSelfUpdateForm, EditUsernameForm, OptionalMailsForm, + CoPurchaserForm, ) from tapir.accounts.models import ( TapirUser, UpdateTapirUserLogEntry, OptionalMails, + CoPurchaser, ) from tapir.coop.emails.co_purchaser_updated_mail import CoPurchaserUpdatedMail from tapir.coop.emails.tapir_account_created_email import ( @@ -499,3 +502,42 @@ def get_context_data(self, **kwargs): hasattr(user, "share_owner") and user.share_owner is not None ) return context + + +class CoPurchaserCreateView( + PermissionRequiredMixin, LoginRequiredMixin, TapirFormMixin, CreateView +): + model = CoPurchaser + permission_required = PERMISSION_ACCOUNTS_MANAGE + form_class = CoPurchaserForm + + def dispatch(self, request, *args, **kwargs): + self.tapir_user = get_object_or_404(TapirUser, pk=self.kwargs["pk"]) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["card_title"] = _("Add Co-Purchaser for %(name)s") % { + "name": UserUtils.build_html_link_for_viewer( + self.tapir_user, self.request.user + ) + } + context["page_title"] = _("Add Co-Purchaser") + return context + + def form_valid(self, form): + max_co_purchasers = getattr(settings, "CO_PURCHASER_MAX_CO_PURCHASERS", 2) + existing_count = CoPurchaser.objects.filter(user=self.tapir_user).count() + + if existing_count >= max_co_purchasers: + messages.error(self.request, _("Maximum numbers of Co-Purchasers exceeded")) + return self.form_invalid(form) + + form.instance.user = self.tapir_user + form.instance.order = existing_count + + messages.success(self.request, _("Co-Purchaser successfully added")) + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("accounts:user_detail", kwargs={"pk": self.tapir_user.pk}) From a24858a9bd0b41cb22182692730d745df2ebdd31 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:23:17 +0200 Subject: [PATCH 5/6] UpdateView --- .../templates/accounts/co_purchaser_form.html | 24 ------------------- .../templates/accounts/user_detail.html | 15 +++++++++++- tapir/accounts/urls.py | 5 ++++ tapir/accounts/views.py | 21 ++++++++++++++++ 4 files changed, 40 insertions(+), 25 deletions(-) delete mode 100644 tapir/accounts/templates/accounts/co_purchaser_form.html diff --git a/tapir/accounts/templates/accounts/co_purchaser_form.html b/tapir/accounts/templates/accounts/co_purchaser_form.html deleted file mode 100644 index 5801aff5f..000000000 --- a/tapir/accounts/templates/accounts/co_purchaser_form.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "core/base.html" %} -{% load django_bootstrap5 %} -{% load i18n %} -{% load static %} -{% load core %} -{% load utils %} -{% block head %} - {{ block.super }} - {{ form.media }} -{% endblock head %} -{% block title %} - {% form_title %} -{% endblock title %} -{% block content %} -
-
-
{{ form_title }}
-
- {% csrf_token %} - {% bootstrap_form form %} -
-
-
-{% endblock content %} diff --git a/tapir/accounts/templates/accounts/user_detail.html b/tapir/accounts/templates/accounts/user_detail.html index 81ba0d0e7..c744d19f0 100644 --- a/tapir/accounts/templates/accounts/user_detail.html +++ b/tapir/accounts/templates/accounts/user_detail.html @@ -170,7 +170,20 @@
- {% for co_purchaser in co_purchasers %}
{{ co_purchaser.get_full_name }}
{% endfor %} + {% for co_purchaser in co_purchasers %} +
+
{{ co_purchaser.get_full_name }}
+ {% if perms.accounts.manage %} + + {% endif %} +
+ {% endfor %}
{% else %}

{% translate "No co-purchasers registered." %}

diff --git a/tapir/accounts/urls.py b/tapir/accounts/urls.py index f576ba261..97ec3b287 100644 --- a/tapir/accounts/urls.py +++ b/tapir/accounts/urls.py @@ -60,6 +60,11 @@ views.CoPurchaserCreateView.as_view(), name="co_purchaser_create", ), + path( + "user//co-purchasers/update/", + views.CoPurchaserUpdateView.as_view(), + name="co_purchaser_update", + ), path( "open_door", views.OpenDoorView.as_view(), diff --git a/tapir/accounts/views.py b/tapir/accounts/views.py index 7530ccf23..d6bc4d60f 100644 --- a/tapir/accounts/views.py +++ b/tapir/accounts/views.py @@ -541,3 +541,24 @@ def form_valid(self, form): def get_success_url(self): return reverse_lazy("accounts:user_detail", kwargs={"pk": self.tapir_user.pk}) + + +class CoPurchaserUpdateView( + LoginRequiredMixin, PermissionRequiredMixin, TapirFormMixin, generic.UpdateView +): + permission_required = PERMISSION_ACCOUNTS_MANAGE + model = CoPurchaser + form_class = CoPurchaserForm + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["card_title"] = _("Update Co-Purchaser for %(name)s") % { + "name": UserUtils.build_html_link_for_viewer( + self.object.user, self.request.user + ) + } + context["page_title"] = _("Add Co-Purchaser") + return context + + def get_success_url(self): + return reverse_lazy("accounts:user_detail", kwargs={"pk": self.object.user.pk}) From 53902c7f83eec05be402026404c68e2ddfdbe805 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:31:04 +0200 Subject: [PATCH 6/6] DeleteView --- .../templates/accounts/user_detail.html | 3 +++ tapir/accounts/urls.py | 5 ++++ tapir/accounts/views.py | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/tapir/accounts/templates/accounts/user_detail.html b/tapir/accounts/templates/accounts/user_detail.html index c744d19f0..222104f94 100644 --- a/tapir/accounts/templates/accounts/user_detail.html +++ b/tapir/accounts/templates/accounts/user_detail.html @@ -180,6 +180,9 @@
edit + delete {% endif %} diff --git a/tapir/accounts/urls.py b/tapir/accounts/urls.py index 97ec3b287..83506a0d2 100644 --- a/tapir/accounts/urls.py +++ b/tapir/accounts/urls.py @@ -65,6 +65,11 @@ views.CoPurchaserUpdateView.as_view(), name="co_purchaser_update", ), + path( + "user//co-purchasers/delete/", + views.CoPurchaserDeleteView.as_view(), + name="co_purchaser_delete", + ), path( "open_door", views.OpenDoorView.as_view(), diff --git a/tapir/accounts/views.py b/tapir/accounts/views.py index d6bc4d60f..1c72069be 100644 --- a/tapir/accounts/views.py +++ b/tapir/accounts/views.py @@ -562,3 +562,26 @@ def get_context_data(self, **kwargs): def get_success_url(self): return reverse_lazy("accounts:user_detail", kwargs={"pk": self.object.user.pk}) + + +class CoPurchaserDeleteView( + LoginRequiredMixin, PermissionRequiredMixin, TapirFormMixin, generic.DeleteView +): + permission_required = PERMISSION_ACCOUNTS_MANAGE + model = CoPurchaser + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["card_title"] = _( + "Delete Co-Purchaser %(copurchaser)s for %(name)s" + ) % { + "copurchaser": self.object.get_full_name(), + "name": UserUtils.build_html_link_for_viewer( + self.object.user, self.request.user + ), + } + context["page_title"] = _("Add Co-Purchaser") + return context + + def get_success_url(self): + return reverse_lazy("accounts:user_detail", kwargs={"pk": self.object.user.pk})