The ruffening.
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Víðir Valberg Guðmundsson 2024-07-15 00:19:37 +02:00
parent 480eecca12
commit f18469833a
22 changed files with 312 additions and 121 deletions

View file

@ -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

View file

@ -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
]

View file

@ -0,0 +1 @@
"""Accounting app."""

View file

@ -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

View file

@ -1,5 +1,9 @@
"""Accounting app configuration."""
from django.apps import AppConfig
class AccountingConfig(AppConfig):
"""Accounting app config."""
name = "accounting"

View file

@ -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,
),
]

View file

@ -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")

View file

@ -1,7 +1,5 @@
"""Membership application.
======================
This application's domain relate to organizational structures and
implementation of statutes, policies etc.
"""

View file

@ -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."""

View file

@ -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)

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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="<int:member_id>/",
@ -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)

View file

@ -0,0 +1 @@
"""data.coop member system."""

View file

@ -1,3 +1,5 @@
"""Settings for the project."""
from pathlib import Path
from django.utils.translation import gettext_lazy as _

View file

@ -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")

View file

@ -0,0 +1 @@
"""Utility functions for the project."""

View file

@ -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"))

View file

@ -0,0 +1 @@
"""Utility template tags for the project."""

View file

@ -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

View file

@ -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 = {}