Skip to content

Commit

Permalink
Payment.payable_object property to get payable (#3737)
Browse files Browse the repository at this point in the history
* Split Entry.payment into separate fields on Renewal and Registration

This is needed to be able to easily use the reverse relation from Payment to distinguish between registrations and renewals.

* Make Payable an ABC

This should make it a bit more readable, and improves linting.

* Add prefetch-based Payment.payable_object property

* Fix typo in migration

* Add field and filter to admin

* Add hyperlink to MoneybirdExternalInvoice admin's payable_object

* Fix tests

* Add tests
  • Loading branch information
DeD1rk authored Aug 1, 2024
1 parent e599de4 commit 6a3627e
Show file tree
Hide file tree
Showing 18 changed files with 439 additions and 102 deletions.
2 changes: 1 addition & 1 deletion website/events/payables.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from payments.payables import Payable, payables


class EventRegistrationPayable(Payable):
class EventRegistrationPayable(Payable[EventRegistration]):
@property
def payment_amount(self):
return self.model.payment_amount
Expand Down
15 changes: 15 additions & 0 deletions website/moneybirdsynchronization/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from django.contrib import admin
from django.contrib.admin import RelatedOnlyFieldListFilter
from django.urls import reverse
from django.utils.html import format_html

from .models import (
MoneybirdContact,
Expand Down Expand Up @@ -81,6 +83,19 @@ class MoneybirdExternalInvoiceAdmin(admin.ModelAdmin):
("payable_model", RelatedOnlyFieldListFilter),
)

def payable_object(self, obj: MoneybirdExternalInvoice) -> str:
payable_object = obj.payable_object
if payable_object:
return format_html(
"<a href='{}'>{}</a>",
reverse(
f"admin:{payable_object._meta.app_label}_{payable_object._meta.model_name}_change",
args=[payable_object.pk],
),
payable_object,
)
return "None"


@admin.register(MoneybirdPayment)
class MoneybirdPaymentAdmin(admin.ModelAdmin):
Expand Down
83 changes: 64 additions & 19 deletions website/payments/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from django.contrib import admin, messages
from django.contrib.admin import ModelAdmin
from django.contrib.admin.options import IncorrectLookupParameters
from django.contrib.admin.utils import model_ngettext
from django.db.models import QuerySet
from django.db.models.query_utils import Q
Expand All @@ -14,7 +15,7 @@
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _

from payments import admin_views, services
from payments import admin_views, payables, services
from payments.forms import BankAccountAdminForm, BatchPaymentInlineAdminForm

from .models import BankAccount, Batch, Payment, PaymentUser
Expand All @@ -33,6 +34,38 @@ def _show_message(
)


class PayableModelListFilter(admin.SimpleListFilter):
title = _("payable model")
parameter_name = "payable_model"

def lookups(self, request, model_admin):
return [
(
model._meta.get_field("payment").related_query_name(),
f"{model._meta.app_label} | {model._meta.verbose_name}",
)
for model in payables.payables.get_payable_models()
] + [("none", _("None"))]

def queryset(self, request, queryset):
value = self.value()
if not value:
return queryset
if value == "none":
return queryset.filter(
**{
model._meta.get_field("payment").related_query_name(): None
for model in payables.payables.get_payable_models()
}
)
if value not in (
model._meta.get_field("payment").related_query_name()
for model in payables.payables.get_payable_models()
):
raise IncorrectLookupParameters(_("Invalid payable model"))
return queryset.filter(**{value + "__isnull": False})


@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
"""Manage the payments."""
Expand All @@ -46,7 +79,7 @@ class PaymentAdmin(admin.ModelAdmin):
"batch_link",
"topic",
)
list_filter = ("type", "batch")
list_filter = ("type", "batch", PayableModelListFilter)
list_select_related = ("paid_by", "processed_by", "batch")
date_hierarchy = "created_at"
fields = (
Expand All @@ -58,17 +91,9 @@ class PaymentAdmin(admin.ModelAdmin):
"topic",
"notes",
"batch",
"payable_object",
)
readonly_fields = (
"created_at",
"amount",
"paid_by",
"processed_by",
"type",
"topic",
"notes",
"batch",
)

search_fields = (
"topic",
"notes",
Expand All @@ -88,6 +113,20 @@ class PaymentAdmin(admin.ModelAdmin):
"export_csv",
]

@admin.display(description=_("payable object"))
def payable_object(self, obj: Payment) -> str:
payable_object = obj.payable_object
if payable_object:
return format_html(
"<a href='{}'>{}</a>",
reverse(
f"admin:{payable_object._meta.app_label}_{payable_object._meta.model_name}_change",
args=[payable_object.pk],
),
payable_object,
)
return "None"

@staticmethod
def _member_link(member: PaymentUser) -> str:
return (
Expand Down Expand Up @@ -148,19 +187,25 @@ def get_field_queryset(self, db, db_field, request):
return super().get_field_queryset(db, db_field, request)

def get_readonly_fields(self, request: HttpRequest, obj: Payment = None):
readonly_fields = "created_at", "processed_by", "payable_object"
if not obj:
return "created_at", "processed_by", "batch"
return readonly_fields + ("batch",)
if obj.type == Payment.TPAY and not (obj.batch and obj.batch.processed):
return (
"created_at",
return readonly_fields + (
"amount",
"type",
"paid_by",
"processed_by",
"notes",
"type",
"topic",
"notes",
)
return super().get_readonly_fields(request, obj)
return readonly_fields + (
"amount",
"paid_by",
"type",
"topic",
"notes",
"batch",
)

def get_actions(self, request: HttpRequest) -> OrderedDict:
"""Get the actions for the payments.
Expand Down
36 changes: 36 additions & 0 deletions website/payments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

from members.models import Member

from . import payables


def validate_not_zero(value):
if value == 0:
Expand Down Expand Up @@ -180,6 +182,40 @@ class Payment(models.Model):
notes = models.TextField(verbose_name=_("notes"), blank=True, null=True)
topic = models.CharField(verbose_name=_("topic"), max_length=255, default="Unknown")

@classmethod
def get_payable_prefetches(cls):
"""Return all (OneToOneField reverse relation) fields from payables to `Payment`.
This can be used to prefetch all payable models related to a payment, which makes
the `Payment.payable_object` property much faster when called on multiple payments.
Usage:
>>> Payment.objects.prefetch_related(*Payment.get_payable_prefetches())
"""
return [
model._meta.get_field("payment").related_query_name()
for model in payables.payables.get_payable_models()
]

@property
def payable_object(self) -> models.Model | None:
"""Return the payable model instance associated with this payment.
This is based on the reverse relations of the OneToOneFields from all
payable models to `Payment`.
This property will perform many queries if the related payable models
have not been prefetched. Prefetch them with:
>>> Payment.objects.prefetch_related(*Payment.get_payable_prefetches())
"""
for model in payables.payables.get_payable_models():
if hasattr(self, model._meta.get_field("payment").related_query_name()):
return getattr(
self, model._meta.get_field("payment").related_query_name()
)
return None

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

Expand Down
Loading

0 comments on commit 6a3627e

Please sign in to comment.