Coverage for website/payments/admin.py: 100.00%
339 statements
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
1import csv
2from collections import OrderedDict
4from django.contrib import admin, messages
5from django.contrib.admin import ModelAdmin
6from django.contrib.admin.options import IncorrectLookupParameters
7from django.contrib.admin.utils import model_ngettext
8from django.db.models import QuerySet
9from django.db.models.query_utils import Q
10from django.http import HttpRequest, HttpResponse
11from django.urls import path, reverse
12from django.utils import timezone
13from django.utils.html import format_html
14from django.utils.text import capfirst
15from django.utils.translation import gettext_lazy as _
17from payments import admin_views, payables, services
18from payments.forms import BankAccountAdminForm, BatchPaymentInlineAdminForm
20from .models import BankAccount, Batch, Payment, PaymentUser
23def _show_message(
24 model_admin: ModelAdmin, request: HttpRequest, n: int, message: str, error: str
25) -> None:
26 if n == 0:
27 model_admin.message_user(request, error, messages.ERROR)
28 else:
29 model_admin.message_user(
30 request,
31 message % {"count": n, "items": model_ngettext(model_admin.opts, n)},
32 messages.SUCCESS,
33 )
36class PayableModelListFilter(admin.SimpleListFilter):
37 title = _("payable model")
38 parameter_name = "payable_model"
40 def lookups(self, request, model_admin):
41 return [
42 (
43 model._meta.get_field("payment").related_query_name(),
44 f"{model._meta.app_label} | {model._meta.verbose_name}",
45 )
46 for model in payables.payables.get_payable_models()
47 ] + [("none", _("None"))]
49 def queryset(self, request, queryset):
50 value = self.value()
51 if not value:
52 return queryset
53 if value == "none":
54 return queryset.filter(
55 **{
56 model._meta.get_field("payment").related_query_name(): None
57 for model in payables.payables.get_payable_models()
58 }
59 )
60 if value not in (
61 model._meta.get_field("payment").related_query_name()
62 for model in payables.payables.get_payable_models()
63 ):
64 raise IncorrectLookupParameters(_("Invalid payable model"))
65 return queryset.filter(**{value + "__isnull": False})
68@admin.register(Payment)
69class PaymentAdmin(admin.ModelAdmin):
70 """Manage the payments."""
72 list_display = (
73 "created_at",
74 "amount",
75 "type",
76 "paid_by_link",
77 "processed_by_link",
78 "batch_link",
79 "topic",
80 )
81 list_filter = ("type", "batch", PayableModelListFilter)
82 list_select_related = ("paid_by", "processed_by", "batch")
83 date_hierarchy = "created_at"
84 fields = (
85 "created_at",
86 "amount",
87 "type",
88 "paid_by",
89 "processed_by",
90 "topic",
91 "notes",
92 "batch",
93 "payable_object",
94 )
96 search_fields = (
97 "topic",
98 "notes",
99 "paid_by__username",
100 "paid_by__first_name",
101 "paid_by__last_name",
102 "processed_by__username",
103 "processed_by__first_name",
104 "processed_by__last_name",
105 "amount",
106 )
107 ordering = ("-created_at",)
108 autocomplete_fields = ("paid_by", "processed_by")
109 actions = [
110 "add_to_new_batch",
111 "add_to_last_batch",
112 "export_csv",
113 ]
115 @admin.display(description=_("payable object"))
116 def payable_object(self, obj: Payment) -> str:
117 payable_object = obj.payable_object
118 if payable_object:
119 return format_html(
120 "<a href='{}'>{}</a>",
121 reverse(
122 f"admin:{payable_object._meta.app_label}_{payable_object._meta.model_name}_change",
123 args=[payable_object.pk],
124 ),
125 payable_object,
126 )
127 return "None"
129 @staticmethod
130 def _member_link(member: PaymentUser) -> str:
131 return (
132 format_html(
133 "<a href='{}'>{}</a>", member.get_absolute_url(), member.get_full_name()
134 )
135 if member
136 else None
137 )
139 def paid_by_link(self, obj: Payment) -> str:
140 return self._member_link(obj.paid_by)
142 paid_by_link.admin_order_field = "paid_by"
143 paid_by_link.short_description = _("paid by")
145 @staticmethod
146 def _batch_link(payment: Payment, batch: Batch) -> str:
147 if batch:
148 return format_html(
149 "<a href='{}'>{}</a>", batch.get_absolute_url(), str(batch)
150 )
151 if payment.type == Payment.TPAY:
152 return _("No batch attached")
153 return ""
155 def batch_link(self, obj: Payment) -> str:
156 return self._batch_link(obj, obj.batch)
158 batch_link.admin_order_field = "batch"
159 batch_link.short_description = _("in batch")
161 def processed_by_link(self, obj: Payment) -> str:
162 return self._member_link(obj.processed_by)
164 processed_by_link.admin_order_field = "processed_by"
165 processed_by_link.short_description = _("processed by")
167 def has_delete_permission(self, request, obj=None):
168 if isinstance(obj, Payment):
169 if obj.batch and obj.batch.processed:
170 return False
171 if (
172 "payment/" in request.path
173 and request.POST
174 and request.POST.get("action") == "delete_selected"
175 ):
176 for payment_id in request.POST.getlist("_selected_action"):
177 payment = Payment.objects.get(id=payment_id)
178 if payment.batch and payment.batch.processed:
179 return False
181 return super().has_delete_permission(request, obj)
183 def get_field_queryset(self, db, db_field, request):
184 if str(db_field) == "payments.Payment.batch":
185 return Batch.objects.filter(processed=False)
186 return super().get_field_queryset(db, db_field, request)
188 def get_readonly_fields(self, request: HttpRequest, obj: Payment = None):
189 readonly_fields = "created_at", "processed_by", "payable_object"
190 if not obj:
191 return readonly_fields + ("batch",)
192 if obj.type == Payment.TPAY and not (obj.batch and obj.batch.processed):
193 return readonly_fields + (
194 "amount",
195 "paid_by",
196 "type",
197 "topic",
198 "notes",
199 )
200 return readonly_fields + (
201 "amount",
202 "paid_by",
203 "type",
204 "topic",
205 "notes",
206 "batch",
207 )
209 def get_actions(self, request: HttpRequest) -> OrderedDict:
210 """Get the actions for the payments.
212 Hide the processing actions if the right permissions are missing
213 """
214 actions = super().get_actions(request)
215 if not request.user.has_perm("payments.process_batches"):
216 del actions["add_to_new_batch"]
217 del actions["add_to_last_batch"]
219 return actions
221 def add_to_new_batch(self, request: HttpRequest, queryset: QuerySet) -> None:
222 """Add selected TPAY payments to a new batch."""
223 tpays = queryset.filter(type=Payment.TPAY)
224 if len(tpays) > 0:
225 batch = Batch.objects.create()
226 tpays.update(batch=batch)
227 _show_message(
228 self,
229 request,
230 len(tpays),
231 _("Successfully added {} payments to new batch").format(len(tpays)),
232 _("No payments using Thalia Pay are selected, no batch is created"),
233 )
235 add_to_new_batch.short_description = _(
236 "Add selected Thalia Pay payments to a new batch"
237 )
239 def add_to_last_batch(self, request: HttpRequest, queryset: QuerySet) -> None:
240 """Add selected TPAY payments to the last batch."""
241 tpays = queryset.filter(type=Payment.TPAY)
242 if len(tpays) > 0:
243 batch = Batch.objects.last()
244 if batch is None:
245 self.message_user(request, _("No batches available."), messages.ERROR)
246 elif not batch.processed:
247 batch.save()
248 tpays.update(batch=batch)
249 self.message_user(
250 request,
251 _("Successfully added {} payments to {}").format(len(tpays), batch),
252 messages.SUCCESS,
253 )
254 else:
255 self.message_user(
256 request,
257 _("The last batch {} is already processed").format(batch),
258 messages.ERROR,
259 )
260 else:
261 self.message_user(
262 request,
263 _("No payments using Thalia Pay are selected, no batch is created"),
264 messages.ERROR,
265 )
267 add_to_last_batch.short_description = _(
268 "Add selected Thalia Pay payments to the last batch"
269 )
271 def get_urls(self) -> list:
272 urls = super().get_urls()
273 custom_urls = [
274 path(
275 "<str:app_label>/<str:model_name>/<payable>/create/",
276 self.admin_site.admin_view(admin_views.PaymentAdminView.as_view()),
277 name="payments_payment_create",
278 ),
279 ]
280 return custom_urls + urls
282 def export_csv(self, request: HttpRequest, queryset: QuerySet) -> HttpResponse:
283 """Export a CSV of payments.
285 :param request: Request
286 :param queryset: Items to be exported
287 """
288 response = HttpResponse(content_type="text/csv")
289 response["Content-Disposition"] = 'attachment;filename="payments.csv"'
290 writer = csv.writer(response)
291 headers = [
292 _("created"),
293 _("amount"),
294 _("type"),
295 _("processor"),
296 _("payer id"),
297 _("payer name"),
298 _("notes"),
299 ]
300 writer.writerow([capfirst(x) for x in headers])
301 for payment in queryset:
302 writer.writerow(
303 [
304 payment.created_at,
305 payment.amount,
306 payment.get_type_display(),
307 payment.processed_by.get_full_name()
308 if payment.processed_by
309 else "-",
310 payment.paid_by.pk if payment.paid_by else "-",
311 payment.paid_by.get_full_name() if payment.paid_by else "-",
312 payment.notes,
313 ]
314 )
315 return response
317 export_csv.short_description = _("Export")
320class ValidAccountFilter(admin.SimpleListFilter):
321 """Filter the bank accounts by whether they are active or not."""
323 title = _("mandates")
324 parameter_name = "active"
326 def lookups(self, request, model_admin) -> tuple:
327 return (
328 ("valid", _("Valid")),
329 ("invalid", _("Invalid")),
330 ("none", _("None")),
331 )
333 def queryset(self, request, queryset) -> QuerySet:
334 today = timezone.now().date()
336 if self.value() == "valid":
337 return queryset.filter(
338 Q(valid_from__lte=today)
339 & (Q(valid_until=None) | Q(valid_until__gt=today))
340 )
342 if self.value() == "invalid":
343 return queryset.filter(
344 Q(valid_from__isnull=False)
345 & (Q(valid_from__gt=today) | Q(valid_until__lte=today))
346 )
348 if self.value() == "none":
349 return queryset.filter(valid_from=None)
351 return queryset
354class PaymentsInline(admin.TabularInline):
355 """The inline for payments in the Batch admin."""
357 model = Payment
358 readonly_fields = (
359 "topic",
360 "paid_by",
361 "amount",
362 "created_at",
363 "notes",
364 )
365 form = BatchPaymentInlineAdminForm
366 extra = 0
367 max_num = 0
368 can_delete = False
370 def get_fields(self, request, obj=None):
371 fields = super().get_fields(request, obj)
372 if obj and obj.processed:
373 fields.remove("remove_batch")
374 return fields
377@admin.register(Batch)
378class BatchAdmin(admin.ModelAdmin):
379 """Manage payment batches."""
381 inlines = (PaymentsInline,)
382 list_display = (
383 "id",
384 "description",
385 "withdrawal_date",
386 "start_date",
387 "end_date",
388 "total_amount",
389 "payments_count",
390 "processing_date",
391 "processed",
392 )
393 fields = (
394 "id",
395 "description",
396 "withdrawal_date",
397 "processed",
398 "processing_date",
399 "total_amount",
400 )
401 search_fields = (
402 "id",
403 "description",
404 "withdrawal_date",
405 )
407 def get_readonly_fields(self, request: HttpRequest, obj: Batch = None):
408 default_fields = (
409 "id",
410 "processed",
411 "processing_date",
412 "total_amount",
413 )
414 if obj and obj.processed:
415 return (
416 "description",
417 "withdrawal_date",
418 ) + default_fields
419 return default_fields
421 def has_delete_permission(self, request, obj=None):
422 if isinstance(obj, Batch):
423 if obj.processed:
424 return False
425 if (
426 "batch/" in request.path
427 and request.POST
428 and request.POST.get("action") == "delete_selected"
429 ):
430 for payment_id in request.POST.getlist("_selected_action"):
431 if Batch.objects.get(id=payment_id).processed:
432 return False
434 return super().has_delete_permission(request, obj)
436 def get_urls(self) -> list:
437 urls = super().get_urls()
438 custom_urls = [
439 path(
440 "<int:pk>/process/",
441 self.admin_site.admin_view(admin_views.BatchProcessAdminView.as_view()),
442 name="payments_batch_process",
443 ),
444 path(
445 "<int:pk>/export/",
446 self.admin_site.admin_view(admin_views.BatchExportAdminView.as_view()),
447 name="payments_batch_export",
448 ),
449 path(
450 "<int:pk>/export-topic/",
451 self.admin_site.admin_view(
452 admin_views.BatchTopicExportAdminView.as_view()
453 ),
454 name="payments_batch_export_topic",
455 ),
456 path(
457 "<int:pk>/topic-description/",
458 self.admin_site.admin_view(
459 admin_views.BatchTopicDescriptionAdminView.as_view()
460 ),
461 name="payments_batch_topic_description",
462 ),
463 path(
464 "new_filled/",
465 self.admin_site.admin_view(
466 admin_views.BatchNewFilledAdminView.as_view()
467 ),
468 name="payments_batch_new_batch_filled",
469 ),
470 ]
471 return custom_urls + urls
473 def save_formset(self, request, form, formset, change):
474 instances = formset.save(commit=False)
476 for instance in instances:
477 if instance.batch and not instance.batch.processed:
478 instance.batch = None
479 instance.save()
480 formset.save_m2m()
482 def changeform_view(
483 self,
484 request: HttpRequest,
485 object_id: str | None = None,
486 form_url: str = "",
487 extra_context: dict | None = None,
488 ) -> HttpResponse:
489 """Render the change formview.
491 Only allow when the batch has not been processed yet.
492 """
493 extra_context = extra_context or {}
494 obj = None
495 if object_id is not None and request.user.has_perm("payments.process_batches"):
496 obj = Batch.objects.get(id=object_id)
498 extra_context["batch"] = obj
499 return super().changeform_view(request, object_id, form_url, extra_context)
502@admin.register(BankAccount)
503class BankAccountAdmin(admin.ModelAdmin):
504 """Manage bank accounts."""
506 list_display = ("iban", "owner_link", "last_used", "valid_from", "valid_until")
507 fields = (
508 "created_at",
509 "last_used",
510 "owner",
511 "iban",
512 "bic",
513 "initials",
514 "last_name",
515 "mandate_no",
516 "valid_from",
517 "valid_until",
518 "signature",
519 "can_be_revoked",
520 )
521 readonly_fields = (
522 "created_at",
523 "can_be_revoked",
524 )
525 search_fields = ("owner__username", "owner__first_name", "owner__last_name", "iban")
526 autocomplete_fields = ("owner",)
527 actions = ["set_last_used"]
528 form = BankAccountAdminForm
530 def owner_link(self, obj: BankAccount) -> str:
531 if obj.owner:
532 return format_html(
533 "<a href='{}'>{}</a>",
534 reverse("admin:auth_user_change", args=[obj.owner.pk]),
535 obj.owner.get_full_name(),
536 )
537 return ""
539 owner_link.admin_order_field = "owner"
540 owner_link.short_description = _("owner")
542 def can_be_revoked(self, obj: BankAccount):
543 return obj.can_be_revoked
545 can_be_revoked.boolean = True
547 def set_last_used(self, request: HttpRequest, queryset: QuerySet) -> None:
548 """Set the last used date of selected accounts."""
549 if request.user.has_perm("payments.change_bankaccount"):
550 updated = services.update_last_used(queryset)
551 _show_message(
552 self,
553 request,
554 updated,
555 message=_("Successfully updated %(count)d %(items)s."),
556 error=_("The selected account(s) could not be updated."),
557 )
559 set_last_used.short_description = _("Update the last used date")
561 def export_csv(self, request: HttpRequest, queryset: QuerySet) -> HttpResponse:
562 response = HttpResponse(content_type="text/csv")
563 response["Content-Disposition"] = 'attachment;filename="accounts.csv"'
564 writer = csv.writer(response)
565 headers = [
566 _("created"),
567 _("name"),
568 _("reference"),
569 _("IBAN"),
570 _("BIC"),
571 _("valid from"),
572 _("valid until"),
573 _("signature"),
574 ]
575 writer.writerow([capfirst(x) for x in headers])
576 for account in queryset:
577 writer.writerow(
578 [
579 account.created_at,
580 account.name,
581 account.mandate_no,
582 account.iban,
583 account.bic or "",
584 account.valid_from or "",
585 account.valid_until or "",
586 account.signature or "",
587 ]
588 )
589 return response
591 export_csv.short_description = _("Export")
594class BankAccountInline(admin.TabularInline):
595 model = BankAccount
596 fields = (
597 "iban",
598 "bic",
599 "mandate_no",
600 "valid_from",
601 "valid_until",
602 "last_used",
603 )
604 show_change_link = True
606 can_delete = False
608 def has_add_permission(self, request, obj=None):
609 return False
611 def has_change_permission(self, request, obj=None):
612 return False
614 def has_delete_permission(self, request, obj=None):
615 return False
618class PaymentInline(admin.TabularInline):
619 model = Payment
620 fk_name = "paid_by"
621 fields = (
622 "created_at",
623 "type",
624 "amount",
625 "topic",
626 "notes",
627 "batch",
628 )
629 ordering = ("-created_at",)
631 show_change_link = True
633 can_delete = False
635 def has_add_permission(self, request, obj=None):
636 return False
638 def has_change_permission(self, request, obj=None):
639 return False
641 def has_delete_permission(self, request, obj=None):
642 return False
645class ThaliaPayAllowedFilter(admin.SimpleListFilter):
646 title = _("Thalia Pay allowed")
647 parameter_name = "tpay_allowed"
649 def lookups(self, request, model_admin):
650 return ("1", _("Yes")), ("0", _("No"))
652 def queryset(self, request, queryset):
653 if self.value() == "1":
654 return queryset.filter(tpay_allowed=True)
655 if self.value() == "0":
656 return queryset.exclude(tpay_allowed=True)
657 return queryset
660class ThaliaPayEnabledFilter(admin.SimpleListFilter):
661 title = _("Thalia Pay enabled")
662 parameter_name = "tpay_enabled"
664 def lookups(self, request, model_admin):
665 return ("1", _("Yes")), ("0", _("No"))
667 def queryset(self, request, queryset):
668 if self.value() == "1":
669 return queryset.filter(tpay_enabled=True)
670 if self.value() == "0":
671 return queryset.exclude(tpay_enabled=True)
672 return queryset
675class ThaliaPayBalanceFilter(admin.SimpleListFilter):
676 title = _("Thalia Pay balance")
677 parameter_name = "tpay_balance"
679 def lookups(self, request, model_admin):
680 return (
681 ("0", "€0,00"),
682 ("1", ">€0.00"),
683 )
685 def queryset(self, request, queryset):
686 if self.value() == "0":
687 return queryset.filter(tpay_balance=0)
688 if self.value() == "1":
689 return queryset.exclude(tpay_balance=0)
690 return queryset
693@admin.register(PaymentUser)
694class PaymentUserAdmin(admin.ModelAdmin):
695 list_display = (
696 "__str__",
697 "email",
698 "get_tpay_allowed",
699 "get_tpay_enabled",
700 "get_tpay_balance",
701 )
702 list_filter = [
703 ThaliaPayAllowedFilter,
704 ThaliaPayEnabledFilter,
705 ThaliaPayBalanceFilter,
706 ]
708 inlines = [BankAccountInline, PaymentInline]
710 fields = (
711 "user_link",
712 "get_tpay_allowed",
713 "get_tpay_enabled",
714 "get_tpay_balance",
715 )
717 readonly_fields = (
718 "user_link",
719 "get_tpay_allowed",
720 "get_tpay_enabled",
721 "get_tpay_balance",
722 )
724 search_fields = (
725 "first_name",
726 "last_name",
727 "username",
728 "email",
729 )
731 # Facet counts would crash for this admin.
732 show_facets = admin.ShowFacets.NEVER
734 def get_queryset(self, request):
735 queryset = super().get_queryset(request)
736 queryset = queryset.prefetch_related("bank_accounts", "paid_payment_set")
737 queryset = queryset.select_properties(
738 "tpay_balance",
739 "tpay_enabled",
740 "tpay_allowed",
741 )
742 return queryset
744 def get_tpay_balance(self, obj):
745 return f"€ {obj.tpay_balance:.2f}" if obj.tpay_enabled else "-"
747 get_tpay_balance.short_description = _("balance")
749 def get_tpay_enabled(self, obj):
750 return obj.tpay_enabled
752 get_tpay_enabled.short_description = _("Thalia Pay enabled")
753 get_tpay_enabled.boolean = True
755 def get_tpay_allowed(self, obj):
756 return obj.tpay_allowed
758 get_tpay_allowed.short_description = _("Thalia Pay allowed")
759 get_tpay_allowed.boolean = True
761 def user_link(self, obj):
762 return (
763 format_html(
764 "<a href='{}'>{}</a>",
765 reverse("admin:auth_user_change", args=[obj.pk]),
766 obj.get_full_name(),
767 )
768 if obj
769 else ""
770 )
772 user_link.admin_order_field = "user"
773 user_link.short_description = _("user")
775 actions = ["disallow_thalia_pay", "allow_thalia_pay"]
777 def disallow_thalia_pay(self, request, queryset):
778 count = 0
779 for x in queryset:
780 changed = x.disallow_tpay()
781 count += 1 if changed else 0
782 messages.success(
783 request, f"Succesfully disallowed Thalia Pay for {count} users."
784 )
786 disallow_thalia_pay.short_description = _("Disallow Thalia Pay for selected users")
788 def allow_thalia_pay(self, request, queryset):
789 count = 0
790 for x in queryset:
791 changed = x.allow_tpay()
792 count += 1 if changed else 0
793 messages.success(request, f"Succesfully allowed Thalia Pay for {count} users.")
795 allow_thalia_pay.short_description = _("Allow Thalia Pay for selected users")
797 def has_add_permission(self, request, obj=None):
798 return False
800 def has_change_permission(self, request, obj=None):
801 return False
803 def has_delete_permission(self, request, obj=None):
804 return False