forked from data.coop/membersystem
The ruffening.
This commit is contained in:
parent
480eecca12
commit
f18469833a
|
@ -15,7 +15,7 @@ repos:
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: 'v0.5.2'
|
rev: 'v0.5.2'
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
|
|
|
@ -118,6 +118,9 @@ target-version = "py312"
|
||||||
extend-exclude = [
|
extend-exclude = [
|
||||||
".git",
|
".git",
|
||||||
"__pycache__",
|
"__pycache__",
|
||||||
|
"manage.py",
|
||||||
|
"asgi.py",
|
||||||
|
"wsgi.py",
|
||||||
]
|
]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
|
|
||||||
|
@ -131,7 +134,21 @@ ignore = [
|
||||||
"EM102", # Exception must not use a f-string literal, assign to variable first
|
"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)
|
"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)
|
"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]
|
[tool.ruff.lint.isort]
|
||||||
force-single-line = true
|
force-single-line = true
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests.py" = [
|
||||||
|
"S101", # Use of assert
|
||||||
|
"SLF001", # Private member access
|
||||||
|
"D100", # Docstrings
|
||||||
|
"D103", # Docstrings
|
||||||
|
]
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
"""Accounting app."""
|
|
@ -1,26 +1,36 @@
|
||||||
|
"""Admin for the accounting app."""
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.translation import gettext_lazy as _
|
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):
|
class OrderAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for the Order model."""
|
||||||
|
|
||||||
list_display = ("who", "description", "created", "is_paid")
|
list_display = ("who", "description", "created", "is_paid")
|
||||||
|
|
||||||
@admin.display(description=_("Customer"))
|
@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()
|
return instance.user.get_full_name()
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Payment)
|
@admin.register(Payment)
|
||||||
class PaymentAdmin(admin.ModelAdmin):
|
class PaymentAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for the Payment model."""
|
||||||
|
|
||||||
list_display = ("who", "description", "order_id", "created")
|
list_display = ("who", "description", "order_id", "created")
|
||||||
|
|
||||||
@admin.display(description=_("Customer"))
|
@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()
|
return instance.order.user.get_full_name()
|
||||||
|
|
||||||
@admin.display(description=_("Order ID"))
|
@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
|
return instance.order.id
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
|
"""Accounting app configuration."""
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class AccountingConfig(AppConfig):
|
class AccountingConfig(AppConfig):
|
||||||
|
"""Accounting app config."""
|
||||||
|
|
||||||
name = "accounting"
|
name = "accounting"
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,4 +1,7 @@
|
||||||
|
"""Models for the accounting app."""
|
||||||
|
|
||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
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 gettext as _
|
||||||
from django.utils.translation import pgettext_lazy
|
from django.utils.translation import pgettext_lazy
|
||||||
from djmoney.models.fields import MoneyField
|
from djmoney.models.fields import MoneyField
|
||||||
|
from djmoney.money import Money
|
||||||
|
|
||||||
|
|
||||||
class CreatedModifiedAbstract(models.Model):
|
class CreatedModifiedAbstract(models.Model):
|
||||||
|
"""Abstract model to track creation and modification of objects."""
|
||||||
|
|
||||||
modified = models.DateTimeField(auto_now=True, verbose_name=_("modified"))
|
modified = models.DateTimeField(auto_now=True, verbose_name=_("modified"))
|
||||||
created = models.DateTimeField(auto_now_add=True, verbose_name=_("created"))
|
created = models.DateTimeField(auto_now_add=True, verbose_name=_("created"))
|
||||||
|
|
||||||
|
@ -17,19 +23,27 @@ class CreatedModifiedAbstract(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class Account(CreatedModifiedAbstract):
|
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.
|
can decide which account to use to pay for something.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
owner = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
owner = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Account of {self.owner.get_full_name()}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def balance(self):
|
def balance(self) -> Money:
|
||||||
|
"""Return the balance of the account."""
|
||||||
return self.transactions.all().aggregate(Sum("amount")).get("amount", 0)
|
return self.transactions.all().aggregate(Sum("amount")).get("amount", 0)
|
||||||
|
|
||||||
|
|
||||||
class Transaction(CreatedModifiedAbstract):
|
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.
|
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"))
|
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):
|
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
|
a separate Invoice model. This is undecided because we are not generating
|
||||||
invoices at the moment.
|
invoices at the moment.
|
||||||
"""
|
"""
|
||||||
|
@ -67,23 +86,6 @@ class Order(CreatedModifiedAbstract):
|
||||||
|
|
||||||
is_paid = models.BooleanField(default=False, verbose_name=_("is paid"))
|
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:
|
class Meta:
|
||||||
verbose_name = pgettext_lazy("accounting term", "Order")
|
verbose_name = pgettext_lazy("accounting term", "Order")
|
||||||
verbose_name_plural = pgettext_lazy("accounting term", "Orders")
|
verbose_name_plural = pgettext_lazy("accounting term", "Orders")
|
||||||
|
@ -91,31 +93,55 @@ class Order(CreatedModifiedAbstract):
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Order ID {self.display_id}"
|
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):
|
class Payment(CreatedModifiedAbstract):
|
||||||
|
"""A payment is a transaction that is made to pay for an order."""
|
||||||
|
|
||||||
amount = MoneyField(max_digits=16, decimal_places=2)
|
amount = MoneyField(max_digits=16, decimal_places=2)
|
||||||
order = models.ForeignKey(Order, on_delete=models.PROTECT)
|
order = models.ForeignKey(Order, on_delete=models.PROTECT)
|
||||||
|
|
||||||
description = models.CharField(max_length=1024, verbose_name=_("description"))
|
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
|
@property
|
||||||
def display_id(self):
|
def display_id(self) -> str:
|
||||||
|
"""Return an id for the payment."""
|
||||||
return str(self.id).zfill(6)
|
return str(self.id).zfill(6)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_order(cls, order):
|
def from_order(cls, order: Order) -> Self:
|
||||||
|
"""Create a payment from an order."""
|
||||||
return cls.objects.create(
|
return cls.objects.create(
|
||||||
order=order,
|
order=order,
|
||||||
user=order.user,
|
user=order.user,
|
||||||
amount=order.total,
|
amount=order.total,
|
||||||
description=order.description,
|
description=order.description,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"Payment ID {self.display_id}"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("payment")
|
|
||||||
verbose_name_plural = _("payments")
|
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
"""Membership application.
|
"""Membership application.
|
||||||
======================
|
|
||||||
|
|
||||||
This application's domain relate to organizational structures and
|
This application's domain relate to organizational structures and
|
||||||
implementation of statutes, policies etc.
|
implementation of statutes, policies etc.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
"""Admin configuration for membership app."""
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Membership
|
from .models import Membership
|
||||||
|
@ -7,14 +9,14 @@ from .models import SubscriptionPeriod
|
||||||
|
|
||||||
@admin.register(Membership)
|
@admin.register(Membership)
|
||||||
class MembershipAdmin(admin.ModelAdmin):
|
class MembershipAdmin(admin.ModelAdmin):
|
||||||
pass
|
"""Admin for Membership model."""
|
||||||
|
|
||||||
|
|
||||||
@admin.register(MembershipType)
|
@admin.register(MembershipType)
|
||||||
class MembershipTypeAdmin(admin.ModelAdmin):
|
class MembershipTypeAdmin(admin.ModelAdmin):
|
||||||
pass
|
"""Admin for MembershipType model."""
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SubscriptionPeriod)
|
@admin.register(SubscriptionPeriod)
|
||||||
class SubscriptionPeriodAdmin(admin.ModelAdmin):
|
class SubscriptionPeriodAdmin(admin.ModelAdmin):
|
||||||
pass
|
"""Admin for SubscriptionPeriod model."""
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
|
"""Membership app configuration."""
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.db.models.signals import post_migrate
|
from django.db.models.signals import post_migrate
|
||||||
|
|
||||||
|
|
||||||
class MembershipConfig(AppConfig):
|
class MembershipConfig(AppConfig):
|
||||||
|
"""Membership app config."""
|
||||||
|
|
||||||
name = "membership"
|
name = "membership"
|
||||||
|
|
||||||
def ready(self) -> None:
|
def ready(self) -> None:
|
||||||
|
"""Ready method."""
|
||||||
from .permissions import persist_permissions
|
from .permissions import persist_permissions
|
||||||
|
|
||||||
post_migrate.connect(persist_permissions, sender=self)
|
post_migrate.connect(persist_permissions, sender=self)
|
||||||
|
|
|
@ -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.auth.models import User
|
||||||
from django.contrib.postgres.constraints import ExclusionConstraint
|
from django.contrib.postgres.constraints import ExclusionConstraint
|
||||||
from django.contrib.postgres.fields import DateRangeField
|
from django.contrib.postgres.fields import DateRangeField
|
||||||
|
@ -8,15 +13,24 @@ from django.utils.translation import gettext as _
|
||||||
from utils.mixins import CreatedModifiedAbstract
|
from utils.mixins import CreatedModifiedAbstract
|
||||||
|
|
||||||
|
|
||||||
|
class NoSubscriptionPeriodFoundError(Exception):
|
||||||
|
"""Raised when no subscription period is found."""
|
||||||
|
|
||||||
|
|
||||||
class Member(User):
|
class Member(User):
|
||||||
|
"""Proxy model for the User model to add some convenience methods."""
|
||||||
|
|
||||||
class QuerySet(models.QuerySet):
|
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
|
from .selectors import get_current_subscription_period
|
||||||
|
|
||||||
current_subscription_period = get_current_subscription_period()
|
current_subscription_period = get_current_subscription_period()
|
||||||
|
|
||||||
if not current_subscription_period:
|
if not current_subscription_period:
|
||||||
raise ValueError("No current subscription period found")
|
raise NoSubscriptionPeriodFoundError
|
||||||
|
|
||||||
return self.annotate(
|
return self.annotate(
|
||||||
active_membership=models.Exists(
|
active_membership=models.Exists(
|
||||||
|
@ -34,12 +48,15 @@ class Member(User):
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionPeriod(CreatedModifiedAbstract):
|
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"))
|
period = DateRangeField(verbose_name=_("period"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
constraints = [
|
constraints: ClassVar = [
|
||||||
ExclusionConstraint(
|
ExclusionConstraint(
|
||||||
name="exclude_overlapping_periods",
|
name="exclude_overlapping_periods",
|
||||||
expressions=[
|
expressions=[
|
||||||
|
@ -53,22 +70,31 @@ class SubscriptionPeriod(CreatedModifiedAbstract):
|
||||||
|
|
||||||
|
|
||||||
class Membership(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):
|
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)
|
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())
|
return self.filter(period__period__contains=timezone.now())
|
||||||
|
|
||||||
def current(self) -> "Membership | None":
|
def current(self) -> "Membership | None":
|
||||||
|
"""Get the current membership."""
|
||||||
try:
|
try:
|
||||||
return self._current().get()
|
return self._current().get()
|
||||||
except self.model.DoesNotExist:
|
except self.model.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def previous(self) -> list["Membership"]:
|
def previous(self) -> list["Membership"]:
|
||||||
|
"""Get previous memberships."""
|
||||||
# A naïve way to get previous by just excluding the current. This
|
# A naïve way to get previous by just excluding the current. This
|
||||||
# means that there must be some protection against "future"
|
# means that there must be some protection against "future"
|
||||||
# memberships.
|
# memberships.
|
||||||
|
@ -76,10 +102,6 @@ class Membership(CreatedModifiedAbstract):
|
||||||
|
|
||||||
objects = QuerySet.as_manager()
|
objects = QuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("membership")
|
|
||||||
verbose_name_plural = _("memberships")
|
|
||||||
|
|
||||||
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
||||||
|
|
||||||
membership_type = models.ForeignKey(
|
membership_type = models.ForeignKey(
|
||||||
|
@ -94,20 +116,26 @@ class Membership(CreatedModifiedAbstract):
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("membership")
|
||||||
|
verbose_name_plural = _("memberships")
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.user} - {self.period}"
|
return f"{self.user} - {self.period}"
|
||||||
|
|
||||||
|
|
||||||
class MembershipType(CreatedModifiedAbstract):
|
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.
|
possibly contain more information like fees.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(verbose_name=_("name"), max_length=64)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("membership type")
|
verbose_name = _("membership type")
|
||||||
verbose_name_plural = _("membership types")
|
verbose_name_plural = _("membership types")
|
||||||
|
|
||||||
name = models.CharField(verbose_name=_("name"), max_length=64)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.name
|
return self.name
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
"""Permissions for the membership app."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission as DjangoPermission
|
from django.contrib.auth.models import Permission as DjangoPermission
|
||||||
|
@ -7,26 +9,32 @@ from django.utils.translation import gettext_lazy as _
|
||||||
PERMISSIONS = []
|
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:
|
for permission in PERMISSIONS:
|
||||||
permission.persist_permission()
|
permission.persist_permission()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Permission:
|
class Permission:
|
||||||
|
"""Dataclass to define a permission."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
codename: str
|
codename: str
|
||||||
app_label: str
|
app_label: str
|
||||||
model: str
|
model: str
|
||||||
|
|
||||||
def __post_init__(self, *args, **kwargs):
|
def __post_init__(self, *args, **kwargs) -> None:
|
||||||
|
"""Post init method."""
|
||||||
PERMISSIONS.append(self)
|
PERMISSIONS.append(self)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path(self) -> str:
|
def path(self) -> str:
|
||||||
|
"""Return the path of the permission."""
|
||||||
return f"{self.app_label}.{self.codename}"
|
return f"{self.app_label}.{self.codename}"
|
||||||
|
|
||||||
def persist_permission(self) -> None:
|
def persist_permission(self) -> None:
|
||||||
|
"""Persist the permission."""
|
||||||
content_type, _ = ContentType.objects.get_or_create(
|
content_type, _ = ContentType.objects.get_or_create(
|
||||||
app_label=self.app_label,
|
app_label=self.app_label,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
|
"""Selectors for the membership app."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from django.db.models import Exists
|
from django.db.models import Exists
|
||||||
from django.db.models import OuterRef
|
from django.db.models import OuterRef
|
||||||
|
@ -8,8 +13,12 @@ from membership.models import Member
|
||||||
from membership.models import Membership
|
from membership.models import Membership
|
||||||
from membership.models import SubscriptionPeriod
|
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]:
|
def get_subscription_periods(member: Member | None = None) -> list[SubscriptionPeriod]:
|
||||||
|
"""Get all subscription periods."""
|
||||||
subscription_periods = SubscriptionPeriod.objects.prefetch_related(
|
subscription_periods = SubscriptionPeriod.objects.prefetch_related(
|
||||||
"membership_set",
|
"membership_set",
|
||||||
"membership_set__user",
|
"membership_set__user",
|
||||||
|
@ -29,6 +38,7 @@ def get_subscription_periods(member: Member | None = None) -> list[SubscriptionP
|
||||||
|
|
||||||
|
|
||||||
def get_current_subscription_period() -> SubscriptionPeriod | None:
|
def get_current_subscription_period() -> SubscriptionPeriod | None:
|
||||||
|
"""Get the current subscription period."""
|
||||||
with contextlib.suppress(SubscriptionPeriod.DoesNotExist):
|
with contextlib.suppress(SubscriptionPeriod.DoesNotExist):
|
||||||
return SubscriptionPeriod.objects.prefetch_related(
|
return SubscriptionPeriod.objects.prefetch_related(
|
||||||
"membership_set",
|
"membership_set",
|
||||||
|
@ -41,6 +51,7 @@ def get_memberships(
|
||||||
member: Member | None = None,
|
member: Member | None = None,
|
||||||
period: SubscriptionPeriod | None = None,
|
period: SubscriptionPeriod | None = None,
|
||||||
) -> Membership.QuerySet:
|
) -> Membership.QuerySet:
|
||||||
|
"""Get memberships."""
|
||||||
memberships = Membership.objects.select_related("membership_type").all()
|
memberships = Membership.objects.select_related("membership_type").all()
|
||||||
|
|
||||||
if member:
|
if member:
|
||||||
|
@ -52,9 +63,11 @@ def get_memberships(
|
||||||
return memberships
|
return memberships
|
||||||
|
|
||||||
|
|
||||||
def get_members():
|
def get_members() -> QuerySet[Member]:
|
||||||
|
"""Get all members."""
|
||||||
return Member.objects.all().annotate_membership().order_by("username")
|
return Member.objects.all().annotate_membership().order_by("username")
|
||||||
|
|
||||||
|
|
||||||
def get_member(*, member_id: int) -> Member:
|
def get_member(*, member_id: int) -> Member:
|
||||||
|
"""Get a member by id."""
|
||||||
return get_members().get(id=member_id)
|
return get_members().get(id=member_id)
|
||||||
|
|
|
@ -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.utils.translation import gettext_lazy as _
|
||||||
from django_view_decorator import namespaced_decorator_factory
|
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 RowAction
|
||||||
from utils.view_utils import render
|
from utils.view_utils import render
|
||||||
from utils.view_utils import render_list
|
|
||||||
|
|
||||||
from .permissions import ADMINISTRATE_MEMBERS
|
from .permissions import ADMINISTRATE_MEMBERS
|
||||||
from .selectors import get_member
|
from .selectors import get_member
|
||||||
|
@ -10,6 +16,10 @@ from .selectors import get_members
|
||||||
from .selectors import get_memberships
|
from .selectors import get_memberships
|
||||||
from .selectors import get_subscription_periods
|
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")
|
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",
|
name="membership-overview",
|
||||||
login_required=True,
|
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)
|
memberships = get_memberships(member=request.user)
|
||||||
current_membership = memberships.current()
|
current_membership = memberships.current()
|
||||||
previous_memberships = memberships.previous()
|
previous_memberships = memberships.previous()
|
||||||
|
@ -50,13 +61,13 @@ admin_members_view = namespaced_decorator_factory(
|
||||||
login_required=True,
|
login_required=True,
|
||||||
permissions=[ADMINISTRATE_MEMBERS.path],
|
permissions=[ADMINISTRATE_MEMBERS.path],
|
||||||
)
|
)
|
||||||
def members_admin(request):
|
def members_admin(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""View to list all members."""
|
||||||
users = get_members()
|
users = get_members()
|
||||||
|
|
||||||
return render_list(
|
render_config = RenderConfig(
|
||||||
entity_name="member",
|
entity_name="member",
|
||||||
entity_name_plural="members",
|
entity_name_plural="members",
|
||||||
request=request,
|
|
||||||
paginate_by=20,
|
paginate_by=20,
|
||||||
objects=users,
|
objects=users,
|
||||||
columns=[
|
columns=[
|
||||||
|
@ -75,6 +86,10 @@ def members_admin(request):
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return render_config.render_list(
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin_members_view(
|
@admin_members_view(
|
||||||
paths="<int:member_id>/",
|
paths="<int:member_id>/",
|
||||||
|
@ -82,7 +97,8 @@ def members_admin(request):
|
||||||
login_required=True,
|
login_required=True,
|
||||||
permissions=[ADMINISTRATE_MEMBERS.path],
|
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)
|
member = get_member(member_id=member_id)
|
||||||
subscription_periods = get_subscription_periods(member=member)
|
subscription_periods = get_subscription_periods(member=member)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
"""data.coop member system."""
|
|
@ -1,3 +1,5 @@
|
||||||
|
"""Settings for the project."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
|
@ -1,13 +1,24 @@
|
||||||
|
"""Project views."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from django_view_decorator import view
|
from django_view_decorator import view
|
||||||
from utils.view_utils import render
|
from utils.view_utils import render
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
|
||||||
@view(
|
@view(
|
||||||
paths="",
|
paths="",
|
||||||
name="index",
|
name="index",
|
||||||
login_required=True,
|
login_required=True,
|
||||||
)
|
)
|
||||||
def index(request):
|
def index(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""View to show the index page."""
|
||||||
return render(request, "index.html")
|
return render(request, "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@ -16,5 +27,6 @@ def index(request):
|
||||||
name="services",
|
name="services",
|
||||||
login_required=True,
|
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")
|
return render(request, "services_overview.html")
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
"""Utility functions for the project."""
|
|
@ -1,8 +1,12 @@
|
||||||
|
"""Mixins for models."""
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
class CreatedModifiedAbstract(models.Model):
|
class CreatedModifiedAbstract(models.Model):
|
||||||
|
"""Abstract model to track creation and modification of objects."""
|
||||||
|
|
||||||
modified = models.DateTimeField(auto_now=True, verbose_name=_("modified"))
|
modified = models.DateTimeField(auto_now=True, verbose_name=_("modified"))
|
||||||
created = models.DateTimeField(auto_now_add=True, verbose_name=_("created"))
|
created = models.DateTimeField(auto_now_add=True, verbose_name=_("created"))
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
"""Utility template tags for the project."""
|
|
@ -1,3 +1,7 @@
|
||||||
|
"""Custom template tags for the project."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
@ -5,7 +9,7 @@ register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag(takes_context=True)
|
@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."""
|
"""Return the given class name if the current path matches the given path name."""
|
||||||
path = reverse(path_name)
|
path = reverse(path_name)
|
||||||
request_path = context.get("request").path
|
request_path = context.get("request").path
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
"""Utility views for rendering lists of objects."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
@ -6,14 +10,15 @@ from typing import Any
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.core.exceptions import FieldError
|
from django.core.exceptions import FieldError
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.http import HttpResponse
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from zen_queries import queries_disabled
|
from zen_queries import queries_disabled
|
||||||
from zen_queries import render as zen_queries_render
|
from zen_queries import render as zen_queries_render
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -32,7 +37,7 @@ class RowAction:
|
||||||
url_name: str
|
url_name: str
|
||||||
url_kwargs: dict[str, 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."""
|
"""Render the action as a dictionary for the given object."""
|
||||||
url = reverse(
|
url = reverse(
|
||||||
self.url_name,
|
self.url_name,
|
||||||
|
@ -41,63 +46,77 @@ class RowAction:
|
||||||
return {"label": self.label, "url": url}
|
return {"label": self.label, "url": url}
|
||||||
|
|
||||||
|
|
||||||
def render_list(
|
@dataclass(kw_only=True)
|
||||||
request: HttpRequest,
|
class RenderConfig:
|
||||||
entity_name: str,
|
"""Configuration for rendering a list of objects."""
|
||||||
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
|
|
||||||
|
|
||||||
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:
|
entity_name = self.entity_name
|
||||||
with contextlib.suppress(FieldError):
|
entity_name_plural = self.entity_name_plural
|
||||||
objects = objects.order_by(order_by)
|
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:
|
total_count = len(objects)
|
||||||
paginator = Paginator(object_list=objects, per_page=paginate_by)
|
|
||||||
page = paginator.get_page(request.GET.get("page"))
|
|
||||||
objects = page.object_list
|
|
||||||
|
|
||||||
rows = []
|
order_by = request.GET.get("order_by")
|
||||||
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 = {
|
if order_by:
|
||||||
"rows": rows,
|
with contextlib.suppress(FieldError):
|
||||||
"columns": columns,
|
objects = objects.order_by(order_by)
|
||||||
"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 paginate_by:
|
if paginate_by:
|
||||||
context |= {
|
paginator = Paginator(object_list=objects, per_page=paginate_by)
|
||||||
"page": page,
|
page = paginator.get_page(request.GET.get("page"))
|
||||||
"is_paginated": True,
|
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(
|
if paginate_by:
|
||||||
request=request,
|
context |= {
|
||||||
template_name="utils/list.html",
|
"page": page,
|
||||||
context=context,
|
"is_paginated": True,
|
||||||
)
|
}
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request=request,
|
||||||
|
template_name="utils/list.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def base_context(request: HttpRequest) -> dict[str, Any]:
|
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)}
|
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."""
|
"""Render a template with a base context."""
|
||||||
if context is None:
|
if context is None:
|
||||||
context = {}
|
context = {}
|
||||||
|
|
Loading…
Reference in a new issue