Configure ruff and fix issues. (#64)

Reviewed-on: https://git.data.coop/data.coop/membersystem/pulls/64
Co-authored-by: Víðir Valberg Guðmundsson <valberg@orn.li>
Co-committed-by: Víðir Valberg Guðmundsson <valberg@orn.li>
This commit is contained in:
Víðir Valberg Guðmundsson 2025-01-26 12:44:10 +00:00 committed by valberg
parent 21b59467ea
commit ee537adc05
43 changed files with 535 additions and 450 deletions

View file

@ -1,6 +1,5 @@
default_language_version: default_language_version:
python: python3 python: python3
exclude: ^.*\b(migrations)\b.*$
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0 rev: v4.6.0

View file

@ -88,57 +88,52 @@ target-version = "py312"
extend-exclude = [ extend-exclude = [
".git", ".git",
"__pycache__", "__pycache__",
"manage.py",
"asgi.py",
"wsgi.py",
] ]
line-length = 120 lint.select = ["ALL"]
lint.ignore = [
[tool.ruff.lint] "G004", # https://docs.astral.sh/ruff/rules/logging-f-string/
select = ["ALL"] "EM101", # https://docs.astral.sh/ruff/rules/raw-string-in-exception/
ignore = [ "EM102", # https://docs.astral.sh/ruff/rules/f-string-in-exception/
"G004", # Logging statement uses f-string
"ANN101", # Missing type annotation for `self` in method
"ANN102", # Missing type annotation for `cls` in classmethod
"EM101", # Exception must not use a 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)
"D100", # Missing docstring in public module "ARG001", # https://docs.astral.sh/ruff/rules/unused-function-argument/
"D101", # Missing docstring in public class "ARG002", # https://docs.astral.sh/ruff/rules/unused-method-argument/
"D102", # Missing docstring in public method "ARG004", # https://docs.astral.sh/ruff/rules/unused-static-method-argument/
"D105", # Missing docstring in magic method "S101", # https://docs.astral.sh/ruff/rules/assert/
"D106", # Missing docstring in public nested class "FIX002", # https://docs.astral.sh/ruff/rules/line-contains-todo/ - we rely on TD*
"D107", # Missing docstring in `__init__` "D104", # https://docs.astral.sh/ruff/rules/undocumented-public-package/
"D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible. Ignoring `one-blank-line-before-class` "D105", # https://docs.astral.sh/ruff/rules/undocumented-magic-method/
"D213", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible. Ignoring `multi-line-summary-second-line`. "D106", # https://docs.astral.sh/ruff/rules/undocumented-public-nested-class/
"FIX", # TODO, FIXME, XXX "D107", # https://docs.astral.sh/ruff/rules/undocumented-public-init/
"TD", # TODO, FIXME, XXX
"ANN002", # Missing type annotation for `*args`
"ANN003", # Missing type annotation for `**kwargs`
"FBT001", # Misbehaves: Boolean-typed positional argument in function definition
"FBT002", # Misbehaves: Boolean-typed positional argument in function definition
"TRY003", # Avoid specifying long messages outside the exception class
] ]
line-length = 120
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
force-single-line = true force-single-line = true
[tool.ruff.lint.pydocstyle]
convention = "pep257"
[tool.ruff.lint.pylint]
max-args = 10
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests.py" = [ "test*.py" = [
"S101", # Use of assert "S101", # https://docs.astral.sh/ruff/rules/assert/
"SLF001", # Private member access "PLR2004", # https://docs.astral.sh/ruff/rules/magic-value-comparison/
"D100", # Docstrings "PT009", # https://docs.astral.sh/ruff/rules/pytest-unittest-assertion/
"D103", # Docstrings "S106", # https://docs.astral.sh/ruff/rules/hardcoded-password-func-arg/
"PLR0912", # https://docs.astral.sh/ruff/rules/too-many-branches/
"C901", # https://docs.astral.sh/ruff/rules/complex-structure/
"SLF001", # https://docs.astral.sh/ruff/rules/private-member-access/
"ANN001", # https://docs.astral.sh/ruff/rules/missing-type-function-argument/
"ANN201", # https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/
] ]
"tests/*" = [ "factories.py" = [
"ANN001", "PLR0913" # https://docs.astral.sh/ruff/rules/too-many-arguments/
"ANN201", ]
"ARG001", # TODO: Unused function argument. These are mostly pytest fixtures. Find a way to allow these in tests. Remove this after. "*/migrations/*" = [
"D103", "RUF001",
"D104", "RUF012",
"S101", # Use of `assert` detected "D" # https://docs.astral.sh/ruff/rules/#pydocstyle-d
"PGH004",
"PT004",
"RET504",
] ]

View file

@ -1,134 +0,0 @@
#
# This file is autogenerated by hatch-pip-compile with Python 3.12
#
# - django-allauth~=0.63
# - django-money~=3.5
# - django-oauth-toolkit~=2.4
# - django-ratelimit~=4.1
# - django-registries==0.0.3
# - django-stubs-ext~=5.0
# - django-view-decorator==0.0.4
# - django-zen-queries~=2.1
# - django~=5.1
# - environs[django]<12,>=11
# - httpx
# - psycopg[binary]~=3.2
# - stripe~=10.5
# - uvicorn~=0.30
# - whitenoise~=6.7
#
anyio==4.7.0
# via httpx
asgiref==3.8.1
# via django
babel==2.15.0
# via py-moneyed
certifi==2024.7.4
# via
# httpcore
# httpx
# requests
cffi==1.16.0
# via cryptography
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via uvicorn
cryptography==43.0.0
# via jwcrypto
dj-database-url==2.2.0
# via environs
dj-email-url==1.0.6
# via environs
django==5.1.4
# via
# hatch.envs.default
# dj-database-url
# django-allauth
# django-money
# django-oauth-toolkit
# django-registries
# django-stubs-ext
# django-view-decorator
# django-zen-queries
django-allauth==0.63.6
# via hatch.envs.default
django-cache-url==3.4.5
# via environs
django-money==3.5.3
# via hatch.envs.default
django-oauth-toolkit==2.4.0
# via hatch.envs.default
django-ratelimit==4.1.0
# via hatch.envs.default
django-registries==0.0.3
# via hatch.envs.default
django-stubs-ext==5.0.4
# via hatch.envs.default
django-view-decorator==0.0.4
# via hatch.envs.default
django-zen-queries==2.1.0
# via hatch.envs.default
environs==11.0.0
# via hatch.envs.default
h11==0.14.0
# via
# httpcore
# uvicorn
httpcore==1.0.7
# via httpx
httpx==0.28.1
# via hatch.envs.default
idna==3.7
# via
# anyio
# httpx
# requests
jwcrypto==1.5.6
# via django-oauth-toolkit
marshmallow==3.21.3
# via environs
oauthlib==3.2.2
# via django-oauth-toolkit
packaging==24.1
# via marshmallow
psycopg==3.2.1
# via hatch.envs.default
psycopg-binary==3.2.1
# via psycopg
py-moneyed==3.0
# via django-money
pycparser==2.22
# via cffi
python-dotenv==1.0.1
# via environs
pytz==2024.1
# via django-oauth-toolkit
requests==2.32.3
# via
# django-oauth-toolkit
# stripe
setuptools==72.1.0
# via django-money
sniffio==1.3.1
# via anyio
sqlparse==0.5.1
# via django
stripe==10.6.0
# via hatch.envs.default
typing-extensions==4.12.2
# via
# anyio
# dj-database-url
# django-stubs-ext
# jwcrypto
# psycopg
# py-moneyed
# stripe
urllib3==2.2.2
# via requests
uvicorn==0.30.5
# via hatch.envs.default
whitenoise==6.7.0
# via hatch.envs.default

View file

@ -30,7 +30,8 @@ class OrderAdminForm(forms.ModelForm):
model = models.Order model = models.Order
exclude = () # noqa: DJ006 exclude = () # noqa: DJ006
def clean(self): # noqa: ANN201 def clean(self) -> None:
"""Clean the order."""
cd = super().clean() cd = super().clean()
if not cd["account"] and cd["member"]: if not cd["account"] and cd["member"]:
try: try:
@ -55,6 +56,7 @@ class OrderAdmin(admin.ModelAdmin):
@admin.action(description="Send order link to selected unpaid orders") @admin.action(description="Send order link to selected unpaid orders")
def send_order(self, request: HttpRequest, queryset: QuerySet[models.Order]) -> None: def send_order(self, request: HttpRequest, queryset: QuerySet[models.Order]) -> None:
"""Send the order to the member."""
for order in queryset: for order in queryset:
if order.is_paid: if order.is_paid:
messages.error( messages.error(
@ -81,14 +83,20 @@ class PaymentAdmin(admin.ModelAdmin):
@admin.register(models.Product) @admin.register(models.Product)
class ProductAdmin(admin.ModelAdmin): class ProductAdmin(admin.ModelAdmin):
"""Admin for the Product model."""
list_display = ("name", "price", "vat") list_display = ("name", "price", "vat")
class TransactionInline(admin.TabularInline): class TransactionInline(admin.TabularInline):
"""Inline admin for the Transaction model."""
model = models.Transaction model = models.Transaction
@admin.register(models.Account) @admin.register(models.Account)
class AccountAdmin(admin.ModelAdmin): class AccountAdmin(admin.ModelAdmin):
"""Admin for the Account model."""
list_display = ("owner", "balance") list_display = ("owner", "balance")
inlines = (TransactionInline,) inlines = (TransactionInline,)

View file

@ -1,19 +1,19 @@
# Generated by Django 5.0.6 on 2024-07-14 22:16 # Generated by Django 5.0.6 on 2024-07-14 22:16
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('accounting', '0002_alter_order_price_currency_alter_order_vat_currency_and_more'), ("accounting", "0002_alter_order_price_currency_alter_order_vat_currency_and_more"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='payment', model_name="payment",
name='stripe_charge_id', name="stripe_charge_id",
field=models.CharField(blank=True, default='', max_length=255), field=models.CharField(blank=True, default="", max_length=255),
preserve_default=False, preserve_default=False,
), ),
] ]

View file

@ -2,77 +2,113 @@
import django.db.models.deletion import django.db.models.deletion
import djmoney.models.fields import djmoney.models.fields
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('accounting', '0003_alter_payment_stripe_charge_id'), ("accounting", "0003_alter_payment_stripe_charge_id"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='PaymentType', name="PaymentType",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
('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")),
('name', models.CharField(max_length=1024, verbose_name='description')), ("name", models.CharField(max_length=1024, verbose_name="description")),
('description', models.TextField(blank=True, max_length=2048)), ("description", models.TextField(blank=True, max_length=2048)),
('enabled', models.BooleanField(default=True)), ("enabled", models.BooleanField(default=True)),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Product', name="Product",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
('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")),
('name', models.CharField(max_length=512)), ("name", models.CharField(max_length=512)),
('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)), (
('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)), "price_currency",
('vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)), djmoney.models.fields.CurrencyField(
('vat', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)), choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
),
),
("price", djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
(
"vat_currency",
djmoney.models.fields.CurrencyField(
choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
),
),
("vat", djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
migrations.AddField( migrations.AddField(
model_name='payment', model_name="payment",
name='external_transaction_id', name="external_transaction_id",
field=models.CharField(blank=True, default='', max_length=255), field=models.CharField(blank=True, default="", max_length=255),
), ),
migrations.AlterField( migrations.AlterField(
model_name='payment', model_name="payment",
name='stripe_charge_id', name="stripe_charge_id",
field=models.CharField(blank=True, default='', max_length=255), field=models.CharField(blank=True, default="", max_length=255),
), ),
migrations.AddField( migrations.AddField(
model_name='payment', model_name="payment",
name='payment_type', name="payment_type",
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='accounting.paymenttype'), field=models.ForeignKey(
default=1, on_delete=django.db.models.deletion.PROTECT, to="accounting.paymenttype"
),
preserve_default=False, preserve_default=False,
), ),
migrations.CreateModel( migrations.CreateModel(
name='OrderProduct', name="OrderProduct",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
('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")),
('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)), (
('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)), "price_currency",
('vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)), djmoney.models.fields.CurrencyField(
('vat', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)), choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ordered_products', to='accounting.order')), ),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ordered_products', to='accounting.product')), ),
("price", djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
(
"vat_currency",
djmoney.models.fields.CurrencyField(
choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
),
),
("vat", djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
(
"order",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ordered_products",
to="accounting.order",
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="ordered_products",
to="accounting.product",
),
),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
] ]

View file

@ -1,40 +1,44 @@
# Generated by Django 5.0.7 on 2024-07-21 14:53 # Generated by Django 5.0.7 on 2024-07-21 14:53
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('accounting', '0004_paymenttype_product_payment_external_transaction_id_and_more'), ("accounting", "0004_paymenttype_product_payment_external_transaction_id_and_more"),
] ]
operations = [ operations = [
migrations.RemoveField( migrations.RemoveField(
model_name='order', model_name="order",
name='price', name="price",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='order', model_name="order",
name='price_currency', name="price_currency",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='order', model_name="order",
name='vat', name="vat",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='order', model_name="order",
name='vat_currency', name="vat_currency",
), ),
migrations.AlterField( migrations.AlterField(
model_name='orderproduct', model_name="orderproduct",
name='order', name="order",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='order_products', to='accounting.order'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="order_products", to="accounting.order"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='orderproduct', model_name="orderproduct",
name='product', name="product",
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_products', to='accounting.product'), field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, related_name="order_products", to="accounting.product"
),
), ),
] ]

View file

@ -1,25 +1,25 @@
# Generated by Django 5.0.7 on 2024-07-21 15:17 # Generated by Django 5.0.7 on 2024-07-21 15:17
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('accounting', '0005_remove_order_price_remove_order_price_currency_and_more'), ("accounting", "0005_remove_order_price_remove_order_price_currency_and_more"),
('membership', '0006_waitinglistentry_alter_membership_options'), ("membership", "0006_waitinglistentry_alter_membership_options"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='account', model_name="account",
name='owner', name="owner",
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member'), field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="membership.member"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='order', model_name="order",
name='user', name="user",
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member'), field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="membership.member"),
), ),
] ]

View file

@ -1,42 +1,44 @@
# Generated by Django 5.1b1 on 2024-08-01 10:50 # Generated by Django 5.1b1 on 2024-08-01 10:50
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('accounting', '0006_alter_account_owner_alter_order_user'), ("accounting", "0006_alter_account_owner_alter_order_user"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='orderproduct', name="orderproduct",
options={'verbose_name': 'ordered product', 'verbose_name_plural': 'ordered products'}, options={"verbose_name": "ordered product", "verbose_name_plural": "ordered products"},
), ),
migrations.RenameField( migrations.RenameField(
model_name='order', model_name="order",
old_name='user', old_name="user",
new_name='member', new_name="member",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='payment', model_name="payment",
name='stripe_charge_id', name="stripe_charge_id",
), ),
migrations.AddField( migrations.AddField(
model_name='orderproduct', model_name="orderproduct",
name='quantity', name="quantity",
field=models.PositiveSmallIntegerField(default=1), field=models.PositiveSmallIntegerField(default=1),
), ),
migrations.AlterField( migrations.AlterField(
model_name='orderproduct', model_name="orderproduct",
name='order', name="order",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='accounting.order'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="items", to="accounting.order"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='orderproduct', model_name="orderproduct",
name='product', name="product",
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='accounting.product'), field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="accounting.product"),
), ),
] ]

View file

@ -11,7 +11,7 @@ from . import models
# method for updating # method for updating
@receiver(post_save, sender=models.Payment) @receiver(post_save, sender=models.Payment)
def check_total_amount(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None: # noqa: ARG001 def check_total_amount(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None:
"""Check that we receive Payments with the correct amount.""" """Check that we receive Payments with the correct amount."""
if instance.amount != instance.order.total_with_vat: if instance.amount != instance.order.total_with_vat:
mail_admins( mail_admins(
@ -21,14 +21,14 @@ def check_total_amount(sender: models.Payment, instance: models.Payment, **kwarg
@receiver(post_save, sender=models.Payment) @receiver(post_save, sender=models.Payment)
def mark_order_paid(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None: # noqa: ARG001 def mark_order_paid(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None:
"""Mark an order as paid when payment is received.""" """Mark an order as paid when payment is received."""
instance.order.is_paid = True instance.order.is_paid = True
instance.order.save() instance.order.save()
@receiver(post_save, sender=models.Order) @receiver(post_save, sender=models.Order)
def activate_membership(sender: models.Order, instance: models.Order, **kwargs: dict) -> None: # noqa: ARG001 def activate_membership(sender: models.Order, instance: models.Order, **kwargs: dict) -> None:
"""Mark a membership as activated when its order is marked as paid.""" """Mark a membership as activated when its order is marked as paid."""
if instance.is_paid: if instance.is_paid:
Membership.objects.filter(order=instance, activated=False, activated_on=None).update( Membership.objects.filter(order=instance, activated=False, activated_on=None).update(

View file

@ -83,7 +83,8 @@ def order_pay(request: HttpRequest, order_id: int) -> HttpResponse:
mail_admins("Error in checkout", str(e)) mail_admins("Error in checkout", str(e))
raise raise
# TODO: Redirect with status=303 # TODO(benjaoming): Redirect with status=303
# https://git.data.coop/data.coop/membersystem/issues/63
return redirect(checkout_session.url) return redirect(checkout_session.url)

View file

@ -1,15 +1,23 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os import os
import sys import sys
if __name__ == "__main__":
def main() -> None:
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError: except ImportError as exc:
raise ImportError( raise ImportError( # noqa: TRY003
"Couldn't import Django. Are you sure it's installed and " "Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you " "available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?", "forget to activate a virtual environment?",
) ) from exc
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View file

@ -56,7 +56,7 @@ def decorate_ensure_membership_type_exists(membership_type: MembershipType, labe
"""Generate an admin action for given membership type and label.""" """Generate an admin action for given membership type and label."""
@admin.action(description=label) @admin.action(description=label)
def admin_action(modeladmin: ModelAdmin, request: HttpRequest, queryset: QuerySet) -> HttpResponse: # noqa: ARG001 def admin_action(modeladmin: ModelAdmin, request: HttpRequest, queryset: QuerySet) -> HttpResponse:
return ensure_membership_type_exists(request, queryset, membership_type) return ensure_membership_type_exists(request, queryset, membership_type)
return admin_action return admin_action
@ -102,6 +102,7 @@ class MemberAdmin(UserAdmin):
@admin.display(description="membership") @admin.display(description="membership")
def current_membership(self, instance: Member) -> Membership | None: def current_membership(self, instance: Member) -> Membership | None:
"""Get the current membership for the member."""
return instance.memberships.current() return instance.memberships.current()
def get_actions(self, request: HttpRequest) -> dict: def get_actions(self, request: HttpRequest) -> dict:
@ -122,6 +123,7 @@ class MemberAdmin(UserAdmin):
@admin.action(description="Send invite email to selected inactive accounts") @admin.action(description="Send invite email to selected inactive accounts")
def send_invite(self, request: HttpRequest, queryset: QuerySet[Member]) -> None: def send_invite(self, request: HttpRequest, queryset: QuerySet[Member]) -> None:
"""Send invite email to the selected inactive accounts."""
for member in queryset: for member in queryset:
if member.is_active: if member.is_active:
messages.error( messages.error(

View file

@ -5,16 +5,22 @@
* Generally, an email consists of templates (for body and subject) and a get_context() method. * Generally, an email consists of templates (for body and subject) and a get_context() method.
""" """
from accounting.models import Order from __future__ import annotations
from typing import TYPE_CHECKING
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
from django.core.mail.message import EmailMessage from django.core.mail.message import EmailMessage
from django.http import HttpRequest
from django.template import loader from django.template import loader
from django.utils import translation from django.utils import translation
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
if TYPE_CHECKING:
from accounting.models import Order
from django.http import HttpRequest
from .models import Membership from .models import Membership
@ -31,7 +37,7 @@ class BaseEmail(EmailMessage):
template_subject = None template_subject = None
default_subject = "SET SUBJECT HERE" default_subject = "SET SUBJECT HERE"
def __init__(self, request: HttpRequest, *args, **kwargs) -> None: def __init__(self, request: HttpRequest, *args, **kwargs) -> None: # noqa: ANN002, ANN003
self.context = kwargs.pop("context", {}) self.context = kwargs.pop("context", {})
self.user = kwargs.pop("user", None) self.user = kwargs.pop("user", None)
if self.user: if self.user:
@ -95,16 +101,19 @@ class BaseEmail(EmailMessage):
class InviteEmail(BaseEmail): class InviteEmail(BaseEmail):
"""Email for invitations."""
template = "membership/emails/invite.txt" template = "membership/emails/invite.txt"
default_subject = _("Invite to data.coop membership") default_subject = _("Invite to data.coop membership")
def __init__(self, membership: Membership, request: HttpRequest, *args, **kwargs) -> None: def __init__(self, membership: Membership, request: HttpRequest, *args, **kwargs) -> None: # noqa: ANN002, ANN003
self.membership = membership self.membership = membership
kwargs["user"] = membership.user kwargs["user"] = membership.user
kwargs["from_email"] = "kasserer@data.coop" kwargs["from_email"] = "kasserer@data.coop"
super().__init__(request, *args, **kwargs) super().__init__(request, *args, **kwargs)
def get_context_data(self) -> dict: def get_context_data(self) -> dict:
"""Resolve context for invitation emails."""
c = super().get_context_data() c = super().get_context_data()
c["membership"] = self.membership c["membership"] = self.membership
c["token"] = default_token_generator.make_token(self.membership.user) c["token"] = default_token_generator.make_token(self.membership.user)
@ -113,16 +122,19 @@ class InviteEmail(BaseEmail):
class OrderEmail(BaseEmail): class OrderEmail(BaseEmail):
"""Email for orders."""
template = "membership/emails/order.txt" template = "membership/emails/order.txt"
default_subject = _("Your data.coop order and payment") default_subject = _("Your data.coop order and payment")
def __init__(self, order: Order, request: HttpRequest, *args, **kwargs) -> None: def __init__(self, order: Order, request: HttpRequest, *args, **kwargs) -> None: # noqa: ANN002, ANN003
self.order = order self.order = order
kwargs["user"] = order.member kwargs["user"] = order.member
kwargs["from_email"] = "kasserer@data.coop" kwargs["from_email"] = "kasserer@data.coop"
super().__init__(request, *args, **kwargs) super().__init__(request, *args, **kwargs)
def get_context_data(self) -> dict: def get_context_data(self) -> dict:
"""Resolve context for order emails."""
c = super().get_context_data() c = super().get_context_data()
c["order"] = self.order c["order"] = self.order
return c return c

View file

@ -1,3 +1,5 @@
"""Form for the membership app."""
from allauth.account.adapter import get_adapter as get_allauth_adapter from allauth.account.adapter import get_adapter as get_allauth_adapter
from allauth.account.forms import SetPasswordForm from allauth.account.forms import SetPasswordForm
from django import forms from django import forms
@ -12,7 +14,7 @@ class InviteForm(SetPasswordForm):
widget=forms.TextInput(attrs={"placeholder": _("Username"), "autocomplete": "username"}), widget=forms.TextInput(attrs={"placeholder": _("Username"), "autocomplete": "username"}),
) )
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
self.membership = kwargs.pop("membership") self.membership = kwargs.pop("membership")
kwargs["user"] = self.membership.user kwargs["user"] = self.membership.user
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View file

@ -1,32 +1,32 @@
# Generated by Django 5.0.7 on 2024-07-20 20:45 # Generated by Django 5.0.7 on 2024-07-20 20:45
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('membership', '0005_member'), ("membership", "0005_member"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='WaitingListEntry', name="WaitingListEntry",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
('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")),
('email', models.EmailField(max_length=254)), ("email", models.EmailField(max_length=254)),
('geography', models.CharField(blank=True, default='', verbose_name='geography')), ("geography", models.CharField(blank=True, default="", verbose_name="geography")),
('comment', models.TextField(blank=True)), ("comment", models.TextField(blank=True)),
], ],
options={ options={
'verbose_name': 'waiting list entry', "verbose_name": "waiting list entry",
'verbose_name_plural': 'waiting list entries', "verbose_name_plural": "waiting list entries",
}, },
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='membership', name="membership",
options={'verbose_name': 'medlemskab', 'verbose_name_plural': 'medlemskaber'}, options={"verbose_name": "medlemskab", "verbose_name_plural": "medlemskaber"},
), ),
] ]

View file

@ -2,61 +2,77 @@
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('accounting', '0007_alter_orderproduct_options_rename_user_order_member_and_more'), ("accounting", "0007_alter_orderproduct_options_rename_user_order_member_and_more"),
('membership', '0006_waitinglistentry_alter_membership_options'), ("membership", "0006_waitinglistentry_alter_membership_options"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='membership', model_name="membership",
name='activated', name="activated",
field=models.BooleanField(default=False, help_text='Membership was activated.', verbose_name='activated'), field=models.BooleanField(default=False, help_text="Membership was activated.", verbose_name="activated"),
), ),
migrations.AddField( migrations.AddField(
model_name='membership', model_name="membership",
name='activated_on', name="activated_on",
field=models.DateTimeField(blank=True, null=True), field=models.DateTimeField(blank=True, null=True),
), ),
migrations.AddField( migrations.AddField(
model_name='membership', model_name="membership",
name='order', name="order",
field=models.ForeignKey(blank=True, help_text='The order filled in for paying this membership.', null=True, on_delete=django.db.models.deletion.PROTECT, to='accounting.order', verbose_name='order'), field=models.ForeignKey(
blank=True,
help_text="The order filled in for paying this membership.",
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="accounting.order",
verbose_name="order",
),
), ),
migrations.AddField( migrations.AddField(
model_name='membership', model_name="membership",
name='revoked', name="revoked",
field=models.BooleanField(default=False, help_text='Membership has explicitly been revoked. Revoking a membership is not associated with regular expiration of the membership period.', verbose_name='revoked'), field=models.BooleanField(
default=False,
help_text=(
"Membership has explicitly been revoked. Revoking a membership is not associated with regular "
"expiration of the membership period."
),
verbose_name="revoked",
),
), ),
migrations.AddField( migrations.AddField(
model_name='membership', model_name="membership",
name='revoked_on', name="revoked_on",
field=models.DateTimeField(blank=True, null=True), field=models.DateTimeField(blank=True, null=True),
), ),
migrations.AddField( migrations.AddField(
model_name='membership', model_name="membership",
name='revoked_reason', name="revoked_reason",
field=models.TextField(blank=True), field=models.TextField(blank=True),
), ),
migrations.AddField( migrations.AddField(
model_name='membershiptype', model_name="membershiptype",
name='active', name="active",
field=models.BooleanField(default=True), field=models.BooleanField(default=True),
), ),
migrations.AddField( migrations.AddField(
model_name='membershiptype', model_name="membershiptype",
name='products', name="products",
field=models.ManyToManyField(to='accounting.product'), field=models.ManyToManyField(to="accounting.product"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='membership', model_name="membership",
name='user', name="user",
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to=settings.AUTH_USER_MODEL), field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, related_name="memberships", to=settings.AUTH_USER_MODEL
),
), ),
] ]

View file

@ -1,19 +1,24 @@
# Generated by Django 5.1b1 on 2024-08-04 10:26 # Generated by Django 5.1b1 on 2024-08-04 10:26
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('membership', '0007_membership_activated_membership_activated_on_and_more'), ("membership", "0007_membership_activated_membership_activated_on_and_more"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='membership', model_name="membership",
name='membership_type', name="membership_type",
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='membership.membershiptype', verbose_name='membership type'), field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="memberships",
to="membership.membershiptype",
verbose_name="membership type",
),
), ),
] ]

View file

@ -1,32 +1,33 @@
# Generated by Django 5.1rc1 on 2024-08-07 22:32 # Generated by Django 5.1rc1 on 2024-08-07 22:32
import uuid import uuid
from django.db import migrations, models
from django.db import migrations
from django.db import models
def create_uuid(apps, schema_editor): def create_uuid(apps, schema_editor) -> None: # noqa: ANN001
Membership = apps.get_model('membership', 'Membership') Membership = apps.get_model("membership", "Membership")
for membership in Membership.objects.all(): for membership in Membership.objects.all():
membership.referral_code = uuid.uuid4() membership.referral_code = uuid.uuid4()
membership.save() membership.save()
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('membership', '0008_alter_membership_membership_type'), ("membership", "0008_alter_membership_membership_type"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='membership', model_name="membership",
name='referral_code', name="referral_code",
field=models.UUIDField(blank=True, null=True, default=uuid.uuid4, editable=False), field=models.UUIDField(blank=True, null=True, default=uuid.uuid4, editable=False),
), ),
migrations.RunPython(create_uuid), migrations.RunPython(create_uuid),
migrations.AlterField( migrations.AlterField(
model_name='membership', model_name="membership",
name='referral_code', name="referral_code",
field=models.UUIDField(unique=True, default=uuid.uuid4, editable=False), field=models.UUIDField(unique=True, default=uuid.uuid4, editable=False),
), ),
] ]

View file

@ -1,19 +1,26 @@
# Generated by Django 5.1rc1 on 2024-08-14 08:05 # Generated by Django 5.1rc1 on 2024-08-14 08:05
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('membership', '0009_membership_referral_code'), ("membership", "0009_membership_referral_code"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='waitinglistentry', model_name="waitinglistentry",
name='member', name="member",
field=models.ForeignKey(help_text='Once a member account is generated (use the admin action), this field will be marked.', null=True, blank=True, on_delete=django.db.models.deletion.CASCADE, to='membership.member', verbose_name='has member'), field=models.ForeignKey(
help_text="Once a member account is generated (use the admin action), this field will be marked.",
null=True,
blank=True,
on_delete=django.db.models.deletion.CASCADE,
to="membership.member",
verbose_name="has member",
),
), ),
] ]

View file

@ -4,46 +4,60 @@ import django.db.models.deletion
import django_registries.registry import django_registries.registry
import services.registry import services.registry
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('membership', '0010_waitinglistentry_member'), ("membership", "0010_waitinglistentry_member"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='ServiceAccess', name="ServiceAccess",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
('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")),
('service', django_registries.registry.ChoicesField(choices=[('hedgedoc', 'hedgedoc'), ('mail', 'mail'), ('mastodon', 'mastodon'), ('matrix', 'matrix'), ('nextcloud', 'nextcloud'), ('rallly', 'rallly')], registry=services.registry.ServiceRegistry, verbose_name='service')), (
('subscription_data', models.JSONField(blank=True, null=True, verbose_name='subscription data')), "service",
django_registries.registry.ChoicesField(
choices=[
("hedgedoc", "hedgedoc"),
("mail", "mail"),
("mastodon", "mastodon"),
("matrix", "matrix"),
("nextcloud", "nextcloud"),
("rallly", "rallly"),
],
registry=services.registry.ServiceRegistry,
verbose_name="service",
),
),
("subscription_data", models.JSONField(blank=True, null=True, verbose_name="subscription data")),
], ],
options={ options={
'verbose_name': 'service access', "verbose_name": "service access",
'verbose_name_plural': 'service accesses', "verbose_name_plural": "service accesses",
}, },
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='membership', name="membership",
options={'verbose_name': 'membership', 'verbose_name_plural': 'memberships'}, options={"verbose_name": "membership", "verbose_name_plural": "memberships"},
), ),
migrations.RemoveConstraint( migrations.RemoveConstraint(
model_name='subscriptionperiod', model_name="subscriptionperiod",
name='exclude_overlapping_periods', name="exclude_overlapping_periods",
), ),
migrations.AddField( migrations.AddField(
model_name='serviceaccess', model_name="serviceaccess",
name='user', name="user",
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='serviceaccess', model_name="serviceaccess",
constraint=models.UniqueConstraint(fields=('user', 'service'), name='unique_user_service'), constraint=models.UniqueConstraint(fields=("user", "service"), name="unique_user_service"),
), ),
] ]

View file

@ -6,19 +6,30 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('membership', '0011_serviceaccess_alter_membership_options_and_more'), ("membership", "0011_serviceaccess_alter_membership_options_and_more"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='membership', name="membership",
options={'verbose_name': 'medlemskab', 'verbose_name_plural': 'medlemskaber'}, options={"verbose_name": "medlemskab", "verbose_name_plural": "medlemskaber"},
), ),
migrations.AlterField( migrations.AlterField(
model_name='serviceaccess', model_name="serviceaccess",
name='service', name="service",
field=django_registries.registry.ChoicesField(choices=[('forgejo', 'forgejo'), ('hedgedoc', 'hedgedoc'), ('mail', 'mail'), ('mastodon', 'mastodon'), ('matrix', 'matrix'), ('nextcloud', 'nextcloud'), ('rallly', 'rallly')], registry=services.registry.ServiceRegistry, verbose_name='service'), field=django_registries.registry.ChoicesField(
choices=[
("forgejo", "forgejo"),
("hedgedoc", "hedgedoc"),
("mail", "mail"),
("mastodon", "mastodon"),
("matrix", "matrix"),
("nextcloud", "nextcloud"),
("rallly", "rallly"),
],
registry=services.registry.ServiceRegistry,
verbose_name="service",
),
), ),
] ]

View file

@ -4,13 +4,12 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('membership', '0012_alter_membership_options_alter_serviceaccess_service'), ("membership", "0012_alter_membership_options_alter_serviceaccess_service"),
] ]
operations = [ operations = [
migrations.DeleteModel( migrations.DeleteModel(
name='ServiceAccess', name="ServiceAccess",
), ),
] ]

View file

@ -4,14 +4,13 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('membership', '0013_delete_serviceaccess'), ("membership", "0013_delete_serviceaccess"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='membership', name="membership",
options={'verbose_name': 'membership', 'verbose_name_plural': 'memberships'}, options={"verbose_name": "membership", "verbose_name_plural": "memberships"},
), ),
] ]

View file

@ -188,7 +188,8 @@ class Membership(DirtyFieldsMixin, CreatedModifiedAbstract):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.user} - {self.period}" return f"{self.user} - {self.period}"
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
"""Override the standard save method."""
is_new = not self.pk is_new = not self.pk
# A Membership is considered recently activated when: # A Membership is considered recently activated when:
# It was created w/ activated=True OR # It was created w/ activated=True OR

View file

@ -1,5 +1,7 @@
"""Permissions for the membership app.""" """Permissions for the membership app."""
from __future__ import annotations
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
@ -9,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
PERMISSIONS = [] PERMISSIONS = []
def persist_permissions(*args, **kwargs) -> None: # type: ignore[no-untyped-def] # noqa: ARG001 def persist_permissions(*args, **kwargs) -> None: # type: ignore[no-untyped-def] # noqa: ANN002, ANN003
"""Persist all permissions.""" """Persist all permissions."""
for permission in PERMISSIONS: for permission in PERMISSIONS:
permission.persist_permission() permission.persist_permission()
@ -24,7 +26,7 @@ class Permission:
app_label: str app_label: str
model: str model: str
def __post_init__(self, *args, **kwargs) -> None: def __post_init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
"""Post init method.""" """Post init method."""
PERMISSIONS.append(self) PERMISSIONS.append(self)

View file

@ -123,6 +123,12 @@ def members_admin_detail(request: HttpRequest, member_id: int) -> HttpResponse:
) )
class InvalidTokenError(HttpResponseForbidden):
"""Exception raised when an invalid token is encountered."""
content = "Token not valid - maybe it expired?"
@ratelimit(group="membership", key="ip", rate="10/d", method="ALL", block=True) @ratelimit(group="membership", key="ip", rate="10/d", method="ALL", block=True)
@member_view( @member_view(
paths="invite/<str:referral_code>/<str:token>/", paths="invite/<str:referral_code>/<str:token>/",
@ -146,7 +152,7 @@ def invite(request: HttpRequest, referral_code: str, token: str) -> HttpResponse
token_valid = default_token_generator.check_token(membership.user, token) token_valid = default_token_generator.check_token(membership.user, token)
if not token_valid: if not token_valid:
raise HttpResponseForbidden("Token not valid - maybe it expired?") raise InvalidTokenError
if request.method == "POST": if request.method == "POST":
form = InviteForm(membership=membership, data=request.POST) form = InviteForm(membership=membership, data=request.POST)

View file

@ -1,3 +1,11 @@
"""ASGI config.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
"""
import os import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application

View file

@ -1,3 +1,5 @@
"""Admin for the services app."""
from django.contrib import admin from django.contrib import admin
from services.models import ServiceAccess from services.models import ServiceAccess
@ -6,6 +8,8 @@ from services.models import ServiceRequest
@admin.register(ServiceRequest) @admin.register(ServiceRequest)
class ServiceRequestAdmin(admin.ModelAdmin): class ServiceRequestAdmin(admin.ModelAdmin):
"""Admin for the ServiceRequest model."""
list_display = ("member", "service", "request", "status") list_display = ("member", "service", "request", "status")
list_filter = ("request", "status") list_filter = ("request", "status")

View file

@ -1,6 +1,10 @@
"""Config for the services app."""
from django.apps import AppConfig from django.apps import AppConfig
class ServicesConfig(AppConfig): class ServicesConfig(AppConfig):
"""Config for the services app."""
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "services" name = "services"

View file

@ -1,36 +1,64 @@
# Generated by Django 5.1rc1 on 2024-12-24 17:32 # Generated by Django 5.1rc1 on 2024-12-24 17:32
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('membership', '0012_alter_membership_options_alter_serviceaccess_service'), ("membership", "0012_alter_membership_options_alter_serviceaccess_service"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='ServiceRequest', name="ServiceRequest",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
('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")),
('service', models.CharField(choices=[])), ("service", models.CharField(choices=[])),
('request', models.CharField(choices=[('CREATION', 'Creation'), ('PASSWORD_RESET', 'Password reset'), ('DELETION', 'Deletion')])), (
('is_auto_created', models.BooleanField(default=False)), "request",
('status', models.CharField(choices=[('NEW', 'New'), ('RESOLVED', 'Resolved')], default='NEW', max_length=24)), models.CharField(
('member_notes', models.TextField(blank=True, help_text='Notes from the member, intended to guide the resolution of the request.')), choices=[
('admin_notes', models.TextField(blank=True, help_text='Readable by member: Notes from the admin / status updates, resolutions etc.')), ("CREATION", "Creation"),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='membership.member')), ("PASSWORD_RESET", "Password reset"),
("DELETION", "Deletion"),
]
),
),
("is_auto_created", models.BooleanField(default=False)),
(
"status",
models.CharField(choices=[("NEW", "New"), ("RESOLVED", "Resolved")], default="NEW", max_length=24),
),
(
"member_notes",
models.TextField(
blank=True, help_text="Notes from the member, intended to guide the resolution of the request."
),
),
(
"admin_notes",
models.TextField(
blank=True,
help_text="Readable by member: Notes from the admin / status updates, resolutions etc.",
),
),
("member", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="membership.member")),
], ],
options={ options={
'verbose_name': 'Service Request', "verbose_name": "Service Request",
'verbose_name_plural': 'Service Requests', "verbose_name_plural": "Service Requests",
'constraints': [models.CheckConstraint(condition=models.Q(('status__in', ['NEW', 'RESOLVED'])), name='services_servicerequest_status_valid')], "constraints": [
models.CheckConstraint(
condition=models.Q(("status__in", ["NEW", "RESOLVED"])),
name="services_servicerequest_status_valid",
)
],
}, },
), ),
] ]

View file

@ -2,51 +2,83 @@
import django.db.models.deletion import django.db.models.deletion
import django_registries.registry import django_registries.registry
from django.db import migrations
from django.db import models
import services.registry import services.registry
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('membership', '0013_delete_serviceaccess'), ("membership", "0013_delete_serviceaccess"),
('services', '0001_initial'), ("services", "0001_initial"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='servicerequest', name="servicerequest",
options={'verbose_name': 'service request', 'verbose_name_plural': 'service requests'}, options={"verbose_name": "service request", "verbose_name_plural": "service requests"},
), ),
migrations.AlterField( migrations.AlterField(
model_name='servicerequest', model_name="servicerequest",
name='member', name="member",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_requests', to='membership.member'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="service_requests", to="membership.member"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='servicerequest', model_name="servicerequest",
name='request', name="request",
field=models.CharField(choices=[('CREATION', 'Creation'), ('PASSWORD_RESET', 'Password reset'), ('DELETION', 'Deletion')], max_length=24), field=models.CharField(
choices=[("CREATION", "Creation"), ("PASSWORD_RESET", "Password reset"), ("DELETION", "Deletion")],
max_length=24,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='servicerequest', model_name="servicerequest",
name='service', name="service",
field=django_registries.registry.ChoicesField(choices=[('forgejo', 'forgejo'), ('hedgedoc', 'hedgedoc'), ('mail', 'mail'), ('mastodon', 'mastodon'), ('matrix', 'matrix'), ('nextcloud', 'nextcloud'), ('rallly', 'rallly')], registry=services.registry.ServiceRegistry), field=django_registries.registry.ChoicesField(
choices=[
("forgejo", "forgejo"),
("hedgedoc", "hedgedoc"),
("mail", "mail"),
("mastodon", "mastodon"),
("matrix", "matrix"),
("nextcloud", "nextcloud"),
("rallly", "rallly"),
],
registry=services.registry.ServiceRegistry,
),
), ),
migrations.CreateModel( migrations.CreateModel(
name='ServiceAccess', name="ServiceAccess",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
('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")),
('service', django_registries.registry.ChoicesField(choices=[('forgejo', 'forgejo'), ('hedgedoc', 'hedgedoc'), ('mail', 'mail'), ('mastodon', 'mastodon'), ('matrix', 'matrix'), ('nextcloud', 'nextcloud'), ('rallly', 'rallly')], registry=services.registry.ServiceRegistry, verbose_name='service')), (
('subscription_data', models.JSONField(blank=True, null=True, verbose_name='subscription data')), "service",
('member', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member')), django_registries.registry.ChoicesField(
choices=[
("forgejo", "forgejo"),
("hedgedoc", "hedgedoc"),
("mail", "mail"),
("mastodon", "mastodon"),
("matrix", "matrix"),
("nextcloud", "nextcloud"),
("rallly", "rallly"),
],
registry=services.registry.ServiceRegistry,
verbose_name="service",
),
),
("subscription_data", models.JSONField(blank=True, null=True, verbose_name="subscription data")),
("member", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="membership.member")),
], ],
options={ options={
'verbose_name': 'service access', "verbose_name": "service access",
'verbose_name_plural': 'service accesses', "verbose_name_plural": "service accesses",
'unique_together': {('member', 'service')}, "unique_together": {("member", "service")},
}, },
), ),
] ]

View file

@ -1,22 +1,22 @@
# Generated by Django 5.1.4 on 2025-01-16 07:20 # Generated by Django 5.1.4 on 2025-01-16 07:20
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('membership', '0014_alter_membership_options'), ("membership", "0014_alter_membership_options"),
('services', '0002_alter_servicerequest_options_and_more'), ("services", "0002_alter_servicerequest_options_and_more"),
] ]
operations = [ operations = [
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='serviceaccess', name="serviceaccess",
unique_together=set(), unique_together=set(),
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='serviceaccess', model_name="serviceaccess",
constraint=models.UniqueConstraint(fields=('member', 'service'), name='unique_user_service'), constraint=models.UniqueConstraint(fields=("member", "service"), name="unique_user_service"),
), ),
] ]

View file

@ -1,3 +1,5 @@
"""Models for the services app."""
import typing import typing
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
@ -16,6 +18,8 @@ if typing.TYPE_CHECKING:
class ServiceRequestStatus(TextChoices): class ServiceRequestStatus(TextChoices):
"""Status options for service requests."""
NEW = "NEW", _("New") NEW = "NEW", _("New")
RESOLVED = "RESOLVED", _("Resolved") RESOLVED = "RESOLVED", _("Resolved")
@ -46,7 +50,7 @@ class ServiceAccess(CreatedModifiedAbstract):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.member} - {self.service}" return f"{self.member} - {self.service}"
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
"""Ensure that existing ServiceRequest objects are automatically resolved.""" """Ensure that existing ServiceRequest objects are automatically resolved."""
is_new = not self.pk is_new = not self.pk
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -59,6 +63,8 @@ class ServiceAccess(CreatedModifiedAbstract):
class ServiceRequest(CreatedModifiedAbstract): class ServiceRequest(CreatedModifiedAbstract):
"""A service request for a member."""
member = models.ForeignKey("membership.Member", on_delete=models.CASCADE, related_name="service_requests") member = models.ForeignKey("membership.Member", on_delete=models.CASCADE, related_name="service_requests")
service = ServiceRegistry.choices_field() service = ServiceRegistry.choices_field()
request = models.CharField(max_length=24, choices=ServiceRequests.choices) request = models.CharField(max_length=24, choices=ServiceRequests.choices)
@ -97,7 +103,7 @@ class ServiceRequest(CreatedModifiedAbstract):
member=membership.user, service=service.slug, request=ServiceRequests.CREATION member=membership.user, service=service.slug, request=ServiceRequests.CREATION
) )
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
"""Create notifications when new service requests are added.""" """Create notifications when new service requests are added."""
is_new = not self.pk is_new = not self.pk

View file

@ -14,6 +14,8 @@ class ServiceRegistry(Registry):
class ServiceRequests(TextChoices): class ServiceRequests(TextChoices):
"""Service request choices."""
CREATION = "CREATION", _("Creation") CREATION = "CREATION", _("Creation")
PASSWORD_RESET = "PASSWORD_RESET", _("Password reset") PASSWORD_RESET = "PASSWORD_RESET", _("Password reset")
DELETION = "DELETION", _("Deletion") DELETION = "DELETION", _("Deletion")
@ -39,11 +41,6 @@ class ServiceInterface(Interface):
request_types: list[str, str] = DEFAULT_SERVICE_REQUEST_TYPES request_types: list[str, str] = DEFAULT_SERVICE_REQUEST_TYPES
# TODO: add a way to add a something which defines the required fields for a service
# - maybe a list of tuples with the field name and the type of the field
# this could be used to generate a form for the service, and also to validate
# the data saved in a JSONField on the ServiceAccess model
subscribe_fields: tuple[tuple[str, forms.Field]] = [] subscribe_fields: tuple[tuple[str, forms.Field]] = []
def get_form_class(self) -> type: def get_form_class(self) -> type:

View file

@ -1,3 +1,5 @@
"""Utils for communicating with matrix."""
from urllib.parse import urljoin from urllib.parse import urljoin
import httpx import httpx

View file

@ -63,8 +63,6 @@ class RenderConfig:
request: HttpRequest, request: HttpRequest,
) -> HttpResponse: ) -> HttpResponse:
"""Render a list of objects with a table.""" """Render a list of objects with a table."""
# TODO: List actions
entity_name = self.entity_name entity_name = self.entity_name
entity_name_plural = self.entity_name_plural entity_name_plural = self.entity_name_plural
objects = self.objects objects = self.objects

View file

@ -1,3 +1,5 @@
"""Pytest configuration."""
from datetime import timedelta from datetime import timedelta
from unittest import mock from unittest import mock
@ -12,11 +14,13 @@ from membership.models import SubscriptionPeriod
@pytest.fixture() @pytest.fixture()
def membership_type(): def membership_type():
"""Provide a membership type."""
return MembershipType.objects.create(name="Test Membership Type") return MembershipType.objects.create(name="Test Membership Type")
@pytest.fixture() @pytest.fixture()
def current_period(): def current_period():
"""Provide a current subscription period."""
SubscriptionPeriod.objects.create( SubscriptionPeriod.objects.create(
period=DateRange(timezone.now().date() - timedelta(days=182), timezone.now().date() + timedelta(days=183)) period=DateRange(timezone.now().date() - timedelta(days=182), timezone.now().date() + timedelta(days=183))
) )
@ -25,17 +29,18 @@ def current_period():
@pytest.fixture() @pytest.fixture()
def active_membership(membership_type, current_period): def active_membership(membership_type, current_period):
"""Provide an active membership."""
member = Member.objects.create_user("test", "lala@adas.com", "1234") member = Member.objects.create_user("test", "lala@adas.com", "1234")
membership = Membership.objects.create( return Membership.objects.create(
user=member, user=member,
membership_type=membership_type, membership_type=membership_type,
period=current_period, period=current_period,
activated=True, activated=True,
) )
return membership
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def mock_matrix_notify(): def _mock_matrix_notify() -> None:
"""Mock the matrix notify post."""
with mock.patch("utils.matrix.httpx.post"): with mock.patch("utils.matrix.httpx.post"):
yield yield

View file

@ -1 +0,0 @@
from project.settings import * # noqa

View file

@ -1,3 +1,5 @@
"""Tests for accounting."""
import pytest import pytest
from accounting import models from accounting import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -5,6 +7,7 @@ from django.contrib.auth.models import User
@pytest.mark.django_db() @pytest.mark.django_db()
def test_balance() -> None: def test_balance() -> None:
"""Test balance."""
user = User.objects.create_user("test", "lala@adas.com", "1234") user = User.objects.create_user("test", "lala@adas.com", "1234")
account = models.Account.objects.create(owner=user) account = models.Account.objects.create(owner=user)
assert account.balance == 0 assert account.balance == 0

View file

@ -1,3 +1,5 @@
"""Tests for services."""
import pytest import pytest
from membership.models import Membership from membership.models import Membership
from services.models import ServiceRequest from services.models import ServiceRequest
@ -6,6 +8,7 @@ from services.models import ServiceRequestStatus
@pytest.mark.django_db() @pytest.mark.django_db()
def test_membership_activation(active_membership: Membership): def test_membership_activation(active_membership: Membership):
"""Test membership activation."""
assert ServiceRequest.objects.filter( assert ServiceRequest.objects.filter(
member=active_membership.user, member=active_membership.user,
status=ServiceRequestStatus.NEW, status=ServiceRequestStatus.NEW,