diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 84cb500..49e1ce6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/charliermarsh/ruff-pre-commit + - repo: https://github.com/astral-sh/ruff-pre-commit rev: 'v0.5.2' hooks: - id: ruff diff --git a/pyproject.toml b/pyproject.toml index 86868a7..156bfa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,9 @@ target-version = "py312" extend-exclude = [ ".git", "__pycache__", + "manage.py", + "asgi.py", + "wsgi.py", ] line-length = 120 @@ -131,7 +134,21 @@ ignore = [ "EM102", # Exception must not use a f-string literal, assign to variable first "COM812", # missing-trailing-comma (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) "ISC001", # single-line-implicit-string-concatenation (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) + "D105", # Missing docstring in magic method + "D106", # Missing docstring in public nested class + "FIX", # TODO, FIXME, XXX + "TD", # TODO, FIXME, XXX + "ANN002", # Missing type annotation for `*args` + "ANN003", # Missing type annotation for `**kwargs` ] [tool.ruff.lint.isort] force-single-line = true + +[tool.ruff.lint.per-file-ignores] +"tests.py" = [ + "S101", # Use of assert + "SLF001", # Private member access + "D100", # Docstrings + "D103", # Docstrings +] diff --git a/src/accounting/__init__.py b/src/accounting/__init__.py index e69de29..8d5ad61 100644 --- a/src/accounting/__init__.py +++ b/src/accounting/__init__.py @@ -0,0 +1 @@ +"""Accounting app.""" diff --git a/src/accounting/admin.py b/src/accounting/admin.py index 53691b7..38664f7 100644 --- a/src/accounting/admin.py +++ b/src/accounting/admin.py @@ -1,26 +1,36 @@ +"""Admin for the accounting app.""" + from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from . import models +from .models import Order +from .models import Payment -@admin.register(models.Order) +@admin.register(Order) class OrderAdmin(admin.ModelAdmin): + """Admin for the Order model.""" + list_display = ("who", "description", "created", "is_paid") @admin.display(description=_("Customer")) - def who(self, instance): + def who(self, instance: Order) -> str: + """Return the full name of the user who made the order.""" return instance.user.get_full_name() -@admin.register(models.Payment) +@admin.register(Payment) class PaymentAdmin(admin.ModelAdmin): + """Admin for the Payment model.""" + list_display = ("who", "description", "order_id", "created") @admin.display(description=_("Customer")) - def who(self, instance): + def who(self, instance: Payment) -> str: + """Return the full name of the user who made the payment.""" return instance.order.user.get_full_name() @admin.display(description=_("Order ID")) - def order_id(self, instance): + def order_id(self, instance: Payment) -> int: + """Return the ID of the order.""" return instance.order.id diff --git a/src/accounting/apps.py b/src/accounting/apps.py index e142288..296dae8 100644 --- a/src/accounting/apps.py +++ b/src/accounting/apps.py @@ -1,5 +1,9 @@ +"""Accounting app configuration.""" + from django.apps import AppConfig class AccountingConfig(AppConfig): + """Accounting app config.""" + name = "accounting" diff --git a/src/accounting/migrations/0003_alter_payment_stripe_charge_id.py b/src/accounting/migrations/0003_alter_payment_stripe_charge_id.py new file mode 100644 index 0000000..e86a7da --- /dev/null +++ b/src/accounting/migrations/0003_alter_payment_stripe_charge_id.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.6 on 2024-07-14 22:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0002_alter_order_price_currency_alter_order_vat_currency_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='payment', + name='stripe_charge_id', + field=models.CharField(blank=True, default='', max_length=255), + preserve_default=False, + ), + ] diff --git a/src/accounting/models.py b/src/accounting/models.py index 9db46eb..5243b04 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -1,4 +1,7 @@ +"""Models for the accounting app.""" + from hashlib import md5 +from typing import Self from django.conf import settings from django.db import models @@ -6,9 +9,12 @@ from django.db.models.aggregates import Sum from django.utils.translation import gettext as _ from django.utils.translation import pgettext_lazy from djmoney.models.fields import MoneyField +from djmoney.money import Money class CreatedModifiedAbstract(models.Model): + """Abstract model to track creation and modification of objects.""" + modified = models.DateTimeField(auto_now=True, verbose_name=_("modified")) created = models.DateTimeField(auto_now_add=True, verbose_name=_("created")) @@ -17,19 +23,27 @@ class CreatedModifiedAbstract(models.Model): class Account(CreatedModifiedAbstract): - """This is the model where we can give access to several users, such that they + """An account for a user. + + This is the model where we can give access to several users, such that they can decide which account to use to pay for something. """ owner = models.ForeignKey("auth.User", on_delete=models.PROTECT) + def __str__(self) -> str: + return f"Account of {self.owner.get_full_name()}" + @property - def balance(self): + def balance(self) -> Money: + """Return the balance of the account.""" return self.transactions.all().aggregate(Sum("amount")).get("amount", 0) class Transaction(CreatedModifiedAbstract): - """Tracks in and outgoing events of an account. When an order is received, an + """A transaction. + + Tracks in and outgoing events of an account. When an order is received, an amount is subtracted, when a payment is received, an amount is added. """ @@ -46,9 +60,14 @@ class Transaction(CreatedModifiedAbstract): ) description = models.CharField(max_length=1024, verbose_name=_("description")) + def __str__(self) -> str: + return f"Transaction of {self.amount} for {self.account}" + class Order(CreatedModifiedAbstract): - """Scoped out: Contents of invoices will have to be tracked either here or in + """An order. + + Scoped out: Contents of invoices will have to be tracked either here or in a separate Invoice model. This is undecided because we are not generating invoices at the moment. """ @@ -67,23 +86,6 @@ class Order(CreatedModifiedAbstract): is_paid = models.BooleanField(default=False, verbose_name=_("is paid")) - @property - def total(self): - return self.price + self.vat - - @property - def display_id(self): - return str(self.id).zfill(6) - - @property - def payment_token(self): - pk = str(self.pk).encode("utf-8") - x = md5() - x.update(pk) - extra_hash = (settings.SECRET_KEY + "blah").encode("utf-8") - x.update(extra_hash) - return x.hexdigest() - class Meta: verbose_name = pgettext_lazy("accounting term", "Order") verbose_name_plural = pgettext_lazy("accounting term", "Orders") @@ -91,31 +93,55 @@ class Order(CreatedModifiedAbstract): def __str__(self) -> str: return f"Order ID {self.display_id}" + @property + def total(self) -> Money: + """Return the total price of the order.""" + return self.price + self.vat + + @property + def display_id(self) -> str: + """Return an id for the order.""" + return str(self.id).zfill(6) + + @property + def payment_token(self) -> str: + """Return a token for the payment.""" + pk = str(self.pk).encode("utf-8") + x = md5() # noqa: S324 + x.update(pk) + extra_hash = (settings.SECRET_KEY + "blah").encode("utf-8") + x.update(extra_hash) + return x.hexdigest() + class Payment(CreatedModifiedAbstract): + """A payment is a transaction that is made to pay for an order.""" + amount = MoneyField(max_digits=16, decimal_places=2) order = models.ForeignKey(Order, on_delete=models.PROTECT) description = models.CharField(max_length=1024, verbose_name=_("description")) - stripe_charge_id = models.CharField(max_length=255, null=True, blank=True) + stripe_charge_id = models.CharField(max_length=255, blank=True) + + class Meta: + verbose_name = _("payment") + verbose_name_plural = _("payments") + + def __str__(self) -> str: + return f"Payment ID {self.display_id}" @property - def display_id(self): + def display_id(self) -> str: + """Return an id for the payment.""" return str(self.id).zfill(6) @classmethod - def from_order(cls, order): + def from_order(cls, order: Order) -> Self: + """Create a payment from an order.""" return cls.objects.create( order=order, user=order.user, amount=order.total, description=order.description, ) - - def __str__(self) -> str: - return f"Payment ID {self.display_id}" - - class Meta: - verbose_name = _("payment") - verbose_name_plural = _("payments") diff --git a/src/membership/__init__.py b/src/membership/__init__.py index f5e828a..3a317c3 100644 --- a/src/membership/__init__.py +++ b/src/membership/__init__.py @@ -1,7 +1,5 @@ """Membership application. -====================== This application's domain relate to organizational structures and implementation of statutes, policies etc. - """ diff --git a/src/membership/admin.py b/src/membership/admin.py index 465764f..2cf7030 100644 --- a/src/membership/admin.py +++ b/src/membership/admin.py @@ -1,3 +1,5 @@ +"""Admin configuration for membership app.""" + from django.contrib import admin from .models import Membership @@ -7,14 +9,14 @@ from .models import SubscriptionPeriod @admin.register(Membership) class MembershipAdmin(admin.ModelAdmin): - pass + """Admin for Membership model.""" @admin.register(MembershipType) class MembershipTypeAdmin(admin.ModelAdmin): - pass + """Admin for MembershipType model.""" @admin.register(SubscriptionPeriod) class SubscriptionPeriodAdmin(admin.ModelAdmin): - pass + """Admin for SubscriptionPeriod model.""" diff --git a/src/membership/apps.py b/src/membership/apps.py index b8459a2..e306005 100644 --- a/src/membership/apps.py +++ b/src/membership/apps.py @@ -1,11 +1,16 @@ +"""Membership app configuration.""" + from django.apps import AppConfig from django.db.models.signals import post_migrate class MembershipConfig(AppConfig): + """Membership app config.""" + name = "membership" def ready(self) -> None: + """Ready method.""" from .permissions import persist_permissions post_migrate.connect(persist_permissions, sender=self) diff --git a/src/membership/models.py b/src/membership/models.py index 730133f..502aa66 100644 --- a/src/membership/models.py +++ b/src/membership/models.py @@ -1,3 +1,8 @@ +"""Models for the membership app.""" + +from typing import ClassVar +from typing import Self + from django.contrib.auth.models import User from django.contrib.postgres.constraints import ExclusionConstraint from django.contrib.postgres.fields import DateRangeField @@ -8,15 +13,24 @@ from django.utils.translation import gettext as _ from utils.mixins import CreatedModifiedAbstract +class NoSubscriptionPeriodFoundError(Exception): + """Raised when no subscription period is found.""" + + class Member(User): + """Proxy model for the User model to add some convenience methods.""" + class QuerySet(models.QuerySet): - def annotate_membership(self): + """QuerySet for the Member model.""" + + def annotate_membership(self) -> Self: + """Annotate whether the user has an active membership.""" from .selectors import get_current_subscription_period current_subscription_period = get_current_subscription_period() if not current_subscription_period: - raise ValueError("No current subscription period found") + raise NoSubscriptionPeriodFoundError return self.annotate( active_membership=models.Exists( @@ -34,12 +48,15 @@ class Member(User): class SubscriptionPeriod(CreatedModifiedAbstract): - """Denotes a period for which members should pay their membership fee for.""" + """A subscription period. + + Denotes a period for which members should pay their membership fee for. + """ period = DateRangeField(verbose_name=_("period")) class Meta: - constraints = [ + constraints: ClassVar = [ ExclusionConstraint( name="exclude_overlapping_periods", expressions=[ @@ -53,22 +70,31 @@ class SubscriptionPeriod(CreatedModifiedAbstract): class Membership(CreatedModifiedAbstract): - """Tracks that a user has membership of a given type for a given period.""" + """A membership. + + Tracks that a user has membership of a given type for a given period. + """ class QuerySet(models.QuerySet): - def for_member(self, member: Member): + """QuerySet for the Membership model.""" + + def for_member(self, member: Member) -> Self: + """Filter memberships for a given member.""" return self.filter(user=member) - def _current(self): + def _current(self) -> Self: + """Filter memberships for the current period.""" return self.filter(period__period__contains=timezone.now()) def current(self) -> "Membership | None": + """Get the current membership.""" try: return self._current().get() except self.model.DoesNotExist: return None def previous(self) -> list["Membership"]: + """Get previous memberships.""" # A naïve way to get previous by just excluding the current. This # means that there must be some protection against "future" # memberships. @@ -76,10 +102,6 @@ class Membership(CreatedModifiedAbstract): objects = QuerySet.as_manager() - class Meta: - verbose_name = _("membership") - verbose_name_plural = _("memberships") - user = models.ForeignKey("auth.User", on_delete=models.PROTECT) membership_type = models.ForeignKey( @@ -94,20 +116,26 @@ class Membership(CreatedModifiedAbstract): on_delete=models.PROTECT, ) + class Meta: + verbose_name = _("membership") + verbose_name_plural = _("memberships") + def __str__(self) -> str: return f"{self.user} - {self.period}" class MembershipType(CreatedModifiedAbstract): - """Models membership types. Currently only a name, but will in the future + """A membership type. + + Models membership types. Currently only a name, but will in the future possibly contain more information like fees. """ + name = models.CharField(verbose_name=_("name"), max_length=64) + class Meta: verbose_name = _("membership type") verbose_name_plural = _("membership types") - name = models.CharField(verbose_name=_("name"), max_length=64) - def __str__(self) -> str: return self.name diff --git a/src/membership/permissions.py b/src/membership/permissions.py index 657adcb..8b79c49 100644 --- a/src/membership/permissions.py +++ b/src/membership/permissions.py @@ -1,3 +1,5 @@ +"""Permissions for the membership app.""" + from dataclasses import dataclass from django.contrib.auth.models import Permission as DjangoPermission @@ -7,26 +9,32 @@ from django.utils.translation import gettext_lazy as _ PERMISSIONS = [] -def persist_permissions(sender, **kwargs) -> None: +def persist_permissions(*args, **kwargs) -> None: # type: ignore[no-untyped-def] # noqa: ARG001 + """Persist all permissions.""" for permission in PERMISSIONS: permission.persist_permission() @dataclass class Permission: + """Dataclass to define a permission.""" + name: str codename: str app_label: str model: str - def __post_init__(self, *args, **kwargs): + def __post_init__(self, *args, **kwargs) -> None: + """Post init method.""" PERMISSIONS.append(self) @property def path(self) -> str: + """Return the path of the permission.""" return f"{self.app_label}.{self.codename}" def persist_permission(self) -> None: + """Persist the permission.""" content_type, _ = ContentType.objects.get_or_create( app_label=self.app_label, model=self.model, diff --git a/src/membership/selectors.py b/src/membership/selectors.py index 83458b3..fcf4ee3 100644 --- a/src/membership/selectors.py +++ b/src/membership/selectors.py @@ -1,4 +1,9 @@ +"""Selectors for the membership app.""" + +from __future__ import annotations + import contextlib +from typing import TYPE_CHECKING from django.db.models import Exists from django.db.models import OuterRef @@ -8,8 +13,12 @@ from membership.models import Member from membership.models import Membership from membership.models import SubscriptionPeriod +if TYPE_CHECKING: + from django.db.models.query import QuerySet + def get_subscription_periods(member: Member | None = None) -> list[SubscriptionPeriod]: + """Get all subscription periods.""" subscription_periods = SubscriptionPeriod.objects.prefetch_related( "membership_set", "membership_set__user", @@ -29,6 +38,7 @@ def get_subscription_periods(member: Member | None = None) -> list[SubscriptionP def get_current_subscription_period() -> SubscriptionPeriod | None: + """Get the current subscription period.""" with contextlib.suppress(SubscriptionPeriod.DoesNotExist): return SubscriptionPeriod.objects.prefetch_related( "membership_set", @@ -41,6 +51,7 @@ def get_memberships( member: Member | None = None, period: SubscriptionPeriod | None = None, ) -> Membership.QuerySet: + """Get memberships.""" memberships = Membership.objects.select_related("membership_type").all() if member: @@ -52,9 +63,11 @@ def get_memberships( return memberships -def get_members(): +def get_members() -> QuerySet[Member]: + """Get all members.""" return Member.objects.all().annotate_membership().order_by("username") def get_member(*, member_id: int) -> Member: + """Get a member by id.""" return get_members().get(id=member_id) diff --git a/src/membership/views.py b/src/membership/views.py index 15c5a68..eeb2e58 100644 --- a/src/membership/views.py +++ b/src/membership/views.py @@ -1,8 +1,14 @@ +"""Views for the membership app.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.utils.translation import gettext_lazy as _ from django_view_decorator import namespaced_decorator_factory +from utils.view_utils import RenderConfig from utils.view_utils import RowAction from utils.view_utils import render -from utils.view_utils import render_list from .permissions import ADMINISTRATE_MEMBERS from .selectors import get_member @@ -10,6 +16,10 @@ from .selectors import get_members from .selectors import get_memberships from .selectors import get_subscription_periods +if TYPE_CHECKING: + from django.http import HttpRequest + from django.http import HttpResponse + member_view = namespaced_decorator_factory(namespace="member", base_path="membership") @@ -18,7 +28,8 @@ member_view = namespaced_decorator_factory(namespace="member", base_path="member name="membership-overview", login_required=True, ) -def membership_overview(request): +def membership_overview(request: HttpRequest) -> HttpResponse: + """View to show the membership overview.""" memberships = get_memberships(member=request.user) current_membership = memberships.current() previous_memberships = memberships.previous() @@ -50,13 +61,13 @@ admin_members_view = namespaced_decorator_factory( login_required=True, permissions=[ADMINISTRATE_MEMBERS.path], ) -def members_admin(request): +def members_admin(request: HttpRequest) -> HttpResponse: + """View to list all members.""" users = get_members() - return render_list( + render_config = RenderConfig( entity_name="member", entity_name_plural="members", - request=request, paginate_by=20, objects=users, columns=[ @@ -75,6 +86,10 @@ def members_admin(request): ], ) + return render_config.render_list( + request=request, + ) + @admin_members_view( paths="/", @@ -82,7 +97,8 @@ def members_admin(request): login_required=True, permissions=[ADMINISTRATE_MEMBERS.path], ) -def members_admin_detail(request, member_id): +def members_admin_detail(request: HttpRequest, member_id: int) -> HttpResponse: + """View to show the details of a member.""" member = get_member(member_id=member_id) subscription_periods = get_subscription_periods(member=member) diff --git a/src/project/__init__.py b/src/project/__init__.py index e69de29..64f16b6 100644 --- a/src/project/__init__.py +++ b/src/project/__init__.py @@ -0,0 +1 @@ +"""data.coop member system.""" diff --git a/src/project/settings.py b/src/project/settings.py index a07226f..9cf846d 100644 --- a/src/project/settings.py +++ b/src/project/settings.py @@ -1,3 +1,5 @@ +"""Settings for the project.""" + from pathlib import Path from django.utils.translation import gettext_lazy as _ diff --git a/src/project/views.py b/src/project/views.py index 1416f8d..7fbde77 100644 --- a/src/project/views.py +++ b/src/project/views.py @@ -1,13 +1,24 @@ +"""Project views.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from django_view_decorator import view from utils.view_utils import render +if TYPE_CHECKING: + from django.http import HttpRequest + from django.http import HttpResponse + @view( paths="", name="index", login_required=True, ) -def index(request): +def index(request: HttpRequest) -> HttpResponse: + """View to show the index page.""" return render(request, "index.html") @@ -16,5 +27,6 @@ def index(request): name="services", login_required=True, ) -def services_overview(request): +def services_overview(request: HttpRequest) -> HttpResponse: + """View to show the services overview.""" return render(request, "services_overview.html") diff --git a/src/utils/__init__.py b/src/utils/__init__.py index e69de29..1728a1c 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions for the project.""" diff --git a/src/utils/mixins.py b/src/utils/mixins.py index 26e2fe7..94e33e4 100644 --- a/src/utils/mixins.py +++ b/src/utils/mixins.py @@ -1,8 +1,12 @@ +"""Mixins for models.""" + from django.db import models from django.utils.translation import gettext_lazy as _ class CreatedModifiedAbstract(models.Model): + """Abstract model to track creation and modification of objects.""" + modified = models.DateTimeField(auto_now=True, verbose_name=_("modified")) created = models.DateTimeField(auto_now_add=True, verbose_name=_("created")) diff --git a/src/utils/templatetags/__init__.py b/src/utils/templatetags/__init__.py index e69de29..b0546b6 100644 --- a/src/utils/templatetags/__init__.py +++ b/src/utils/templatetags/__init__.py @@ -0,0 +1 @@ +"""Utility template tags for the project.""" diff --git a/src/utils/templatetags/utils.py b/src/utils/templatetags/utils.py index 90ab6ef..89f2712 100644 --- a/src/utils/templatetags/utils.py +++ b/src/utils/templatetags/utils.py @@ -1,3 +1,7 @@ +"""Custom template tags for the project.""" + +from typing import Any + from django import template from django.urls import reverse @@ -5,7 +9,7 @@ register = template.Library() @register.simple_tag(takes_context=True) -def active_path(context, path_name, class_name) -> str | None: +def active_path(context: dict[str, Any], path_name: str, class_name: str) -> str | None: """Return the given class name if the current path matches the given path name.""" path = reverse(path_name) request_path = context.get("request").path diff --git a/src/utils/view_utils.py b/src/utils/view_utils.py index 3de84c3..bb04192 100644 --- a/src/utils/view_utils.py +++ b/src/utils/view_utils.py @@ -1,3 +1,7 @@ +"""Utility views for rendering lists of objects.""" + +from __future__ import annotations + import contextlib from dataclasses import dataclass from typing import TYPE_CHECKING @@ -6,14 +10,15 @@ from typing import Any from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import FieldError from django.core.paginator import Paginator -from django.http import HttpRequest -from django.http import HttpResponse from django.urls import reverse from zen_queries import queries_disabled from zen_queries import render as zen_queries_render if TYPE_CHECKING: from django.db.models import Model + from django.db.models import QuerySet + from django.http import HttpRequest + from django.http import HttpResponse @dataclass @@ -32,7 +37,7 @@ class RowAction: url_name: str url_kwargs: dict[str, str] - def render(self, obj) -> dict[str, str]: + def render(self, obj: Model) -> dict[str, str]: """Render the action as a dictionary for the given object.""" url = reverse( self.url_name, @@ -41,63 +46,77 @@ class RowAction: return {"label": self.label, "url": url} -def render_list( - request: HttpRequest, - entity_name: str, - entity_name_plural: str, - objects: list["Model"], - columns: list[tuple[str, str]], - row_actions: list[RowAction] | None = None, - list_actions: list[tuple[str, str]] | None = None, - paginate_by: int | None = None, -) -> HttpResponse: - """Render a list of objects with a table.""" - # TODO: List actions +@dataclass(kw_only=True) +class RenderConfig: + """Configuration for rendering a list of objects.""" - total_count = len(objects) + entity_name: str + entity_name_plural: str + objects: QuerySet + columns: list[tuple[str, str]] + row_actions: list[RowAction] | None = None + list_actions: list[tuple[str, str]] | None = None + paginate_by: int | None = None - order_by = request.GET.get("order_by") + def render_list( + self, + request: HttpRequest, + ) -> HttpResponse: + """Render a list of objects with a table.""" + # TODO: List actions - if order_by: - with contextlib.suppress(FieldError): - objects = objects.order_by(order_by) + entity_name = self.entity_name + entity_name_plural = self.entity_name_plural + objects = self.objects + columns = self.columns + row_actions = self.row_actions or [] + list_actions = self.list_actions or [] + paginate_by = self.paginate_by - if paginate_by: - paginator = Paginator(object_list=objects, per_page=paginate_by) - page = paginator.get_page(request.GET.get("page")) - objects = page.object_list + total_count = len(objects) - rows = [] - for obj in objects: - with queries_disabled(): - row = Row( - data={column: getattr(obj, column[0]) for column in columns}, - actions=[action.render(obj) for action in row_actions], - ) - rows.append(row) + order_by = request.GET.get("order_by") - context = { - "rows": rows, - "columns": columns, - "row_actions": row_actions, - "list_actions": list_actions, - "total_count": total_count, - "order_by": order_by, - "entity_name": entity_name, - "entity_name_plural": entity_name_plural, - } + if order_by: + with contextlib.suppress(FieldError): + objects = objects.order_by(order_by) - if paginate_by: - context |= { - "page": page, - "is_paginated": True, + if paginate_by: + paginator = Paginator(object_list=objects, per_page=paginate_by) + page = paginator.get_page(request.GET.get("page")) + objects = page.object_list + + rows = [] + for obj in objects: + with queries_disabled(): + row = Row( + data={column: getattr(obj, column[0]) for column in columns}, + actions=[action.render(obj) for action in row_actions], + ) + rows.append(row) + + context = { + "rows": rows, + "columns": columns, + "row_actions": row_actions, + "list_actions": list_actions, + "total_count": total_count, + "order_by": order_by, + "entity_name": entity_name, + "entity_name_plural": entity_name_plural, } - return render( - request=request, - template_name="utils/list.html", - context=context, - ) + if paginate_by: + context |= { + "page": page, + "is_paginated": True, + } + + return render( + request=request, + template_name="utils/list.html", + context=context, + ) def base_context(request: HttpRequest) -> dict[str, Any]: @@ -105,7 +124,7 @@ def base_context(request: HttpRequest) -> dict[str, Any]: return {"site": get_current_site(request)} -def render(request, template_name, context=None): +def render(request: HttpRequest, template_name: str, context: dict[str, Any] | None = None) -> HttpResponse: """Render a template with a base context.""" if context is None: context = {}