diff --git a/Dockerfile b/Dockerfile index 2e9448d..ddce830 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,6 @@ ARG REQUIREMENTS_FILE=requirements.txt WORKDIR /app RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www -COPY --chown=www:www . . RUN apt-get update && \ apt-get install -y \ binutils \ diff --git a/README.md b/README.md index 41ef329..be00381 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,17 @@ make makemigrations ```bash hatch run dev:server ``` + +#### Upgrade requirements + +We use hatch-pip-compile. + +```bash +# Install hatch-pip-compile in some environment +pipx install hatch-pip-compile +# Change requirements in pyproject.toml (...) +# Update requirements.txt: +hatch-pip-compile +# Update requirements/requirements-dev.txt: +hatch-pip-compile dev +``` diff --git a/pyproject.toml b/pyproject.toml index 643b1cd..6a52861 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [ { name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" }, ] dependencies = [ - "Django==5.0.7", + "Django>=5.1b1,<5.2", "django-money==3.5.2", "django-allauth==0.63.6", "psycopg[binary]==3.2.1", @@ -52,7 +52,7 @@ dependencies = [ [[tool.hatch.envs.tests.matrix]] python = ["3.12"] -django = ["5.0"] +django = ["5.1b1"] [tool.hatch.envs.tests.overrides] matrix.django.dependencies = [ @@ -66,7 +66,7 @@ matrix.python.dependencies = [ cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=src --cov=tests --cov=append {args}" no-cov = "cov --no-cov {args}" typecheck = "mypy --config-file=pyproject.toml ." -requirements = "pip-compile --output-file requirements/base.txt pyproject.toml" +# requirements = "pip-compile --output-file requirements/base.txt pyproject.toml" server = "./src/manage.py runserver 0.0.0.0:8000" migrate = "./src/manage.py migrate" makemigrations = "./src/manage.py makemigrations" diff --git a/requirements.txt b/requirements.txt index 5642b3a..0e7d0ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ # - django-registries==0.0.3 # - django-view-decorator==0.0.4 # - django-zen-queries==2.1.0 -# - django==5.0.7 +# - django<5.2,>=5.1b1 # - environs[django]==11.0.0 # - psycopg[binary]==3.2.1 # - uvicorn==0.30.1 @@ -32,7 +32,7 @@ dj-database-url==2.2.0 # via environs dj-email-url==1.0.6 # via environs -django==5.0.7 +django==5.1b1 # via # hatch.envs.default # dj-database-url diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index f044e8c..3d132d5 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -17,7 +17,7 @@ # - django-registries==0.0.3 # - django-view-decorator==0.0.4 # - django-zen-queries==2.1.0 -# - django==5.0.7 +# - django<5.2,>=5.1b1 # - environs[django]==11.0.0 # - psycopg[binary]==3.2.1 # - uvicorn==0.30.1 @@ -45,7 +45,6 @@ click==8.1.7 coverage==7.3.0 # via # hatch.envs.dev - # coverage # pytest-cov cryptography==42.0.8 # via jwcrypto @@ -53,7 +52,7 @@ dj-database-url==2.2.0 # via environs dj-email-url==1.0.6 # via environs -django==5.0.7 +django==5.1b1 # via # hatch.envs.dev # dj-database-url @@ -91,9 +90,7 @@ django-view-decorator==0.0.4 django-zen-queries==2.1.0 # via hatch.envs.dev environs==11.0.0 - # via - # hatch.envs.dev - # environs + # via hatch.envs.dev h11==0.14.0 # via uvicorn idna==3.7 @@ -124,9 +121,7 @@ pip-tools==7.3.0 pluggy==1.5.0 # via pytest psycopg==3.2.1 - # via - # hatch.envs.dev - # psycopg + # via hatch.envs.dev psycopg-binary==3.2.1 # via psycopg py-moneyed==3.0 diff --git a/src/accounting/admin.py b/src/accounting/admin.py index 38664f7..a970e9a 100644 --- a/src/accounting/admin.py +++ b/src/accounting/admin.py @@ -1,36 +1,63 @@ """Admin for the accounting app.""" +from django import forms from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from .models import Order -from .models import Payment +from . import models -@admin.register(Order) +class OrderProductInline(admin.TabularInline): + """Administer contents of an order inline.""" + + model = models.OrderProduct + + +class OrderAdminForm(forms.ModelForm): + """Special Form for the OrderAdmin so we don't need to require the account field.""" + + account = forms.ModelChoiceField( + required=False, + queryset=models.Account.objects.all(), + help_text=_("Leave empty to auto-choose the member's own account or to create one."), + ) + + class Meta: + model = models.Order + exclude = () # noqa: DJ006 + + def clean(self): # noqa: D102, ANN201 + cd = super().clean() + if not cd["account"] and cd["member"]: + try: + cd["account"] = models.Account.objects.get_or_create(owner=cd["member"])[0] + except models.Account.MultipleObjectsReturned: + cd["account"] = models.Account.objects.filter(owner=cd["member"]).first() + return cd + + +@admin.register(models.Order) class OrderAdmin(admin.ModelAdmin): """Admin for the Order model.""" + inlines = (OrderProductInline,) + form = OrderAdminForm + list_display = ("who", "description", "created", "is_paid") @admin.display(description=_("Customer")) - def who(self, instance: Order) -> str: + def who(self, instance: models.Order) -> str: """Return the full name of the user who made the order.""" - return instance.user.get_full_name() + return instance.member.get_full_name() -@admin.register(Payment) +@admin.register(models.Payment) class PaymentAdmin(admin.ModelAdmin): """Admin for the Payment model.""" - list_display = ("who", "description", "order_id", "created") - - @admin.display(description=_("Customer")) - def who(self, instance: Payment) -> str: - """Return the full name of the user who made the payment.""" - return instance.order.user.get_full_name() + list_display = ("order__member", "description", "order_id", "created") @admin.display(description=_("Order ID")) - def order_id(self, instance: Payment) -> int: + def order_id(self, instance: models.Payment) -> int: """Return the ID of the order.""" return instance.order.id diff --git a/src/accounting/migrations/0004_paymenttype_product_payment_external_transaction_id_and_more.py b/src/accounting/migrations/0004_paymenttype_product_payment_external_transaction_id_and_more.py new file mode 100644 index 0000000..240546f --- /dev/null +++ b/src/accounting/migrations/0004_paymenttype_product_payment_external_transaction_id_and_more.py @@ -0,0 +1,78 @@ +# Generated by Django 5.0.7 on 2024-07-21 14:12 + +import django.db.models.deletion +import djmoney.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0003_alter_payment_stripe_charge_id'), + ] + + operations = [ + migrations.CreateModel( + name='PaymentType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')), + ('name', models.CharField(max_length=1024, verbose_name='description')), + ('description', models.TextField(blank=True, max_length=2048)), + ('enabled', models.BooleanField(default=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')), + ('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)), + ('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={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='payment', + name='external_transaction_id', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AlterField( + model_name='payment', + name='stripe_charge_id', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='payment', + name='payment_type', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='accounting.paymenttype'), + preserve_default=False, + ), + migrations.CreateModel( + name='OrderProduct', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')), + ('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)), + ('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={ + 'abstract': False, + }, + ), + ] diff --git a/src/accounting/migrations/0005_remove_order_price_remove_order_price_currency_and_more.py b/src/accounting/migrations/0005_remove_order_price_remove_order_price_currency_and_more.py new file mode 100644 index 0000000..6252335 --- /dev/null +++ b/src/accounting/migrations/0005_remove_order_price_remove_order_price_currency_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.0.7 on 2024-07-21 14:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0004_paymenttype_product_payment_external_transaction_id_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='order', + name='price', + ), + migrations.RemoveField( + model_name='order', + name='price_currency', + ), + migrations.RemoveField( + model_name='order', + name='vat', + ), + migrations.RemoveField( + model_name='order', + name='vat_currency', + ), + migrations.AlterField( + model_name='orderproduct', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='order_products', to='accounting.order'), + ), + migrations.AlterField( + model_name='orderproduct', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_products', to='accounting.product'), + ), + ] diff --git a/src/accounting/migrations/0006_alter_account_owner_alter_order_user.py b/src/accounting/migrations/0006_alter_account_owner_alter_order_user.py new file mode 100644 index 0000000..9bda0e6 --- /dev/null +++ b/src/accounting/migrations/0006_alter_account_owner_alter_order_user.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.7 on 2024-07-21 15:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0005_remove_order_price_remove_order_price_currency_and_more'), + ('membership', '0007_membershiptype_current_membershiptype_product'), + ] + + operations = [ + migrations.AlterField( + model_name='account', + name='owner', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member'), + ), + migrations.AlterField( + model_name='order', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member'), + ), + ] diff --git a/src/accounting/migrations/0007_rename_user_order_member.py b/src/accounting/migrations/0007_rename_user_order_member.py new file mode 100644 index 0000000..9108b34 --- /dev/null +++ b/src/accounting/migrations/0007_rename_user_order_member.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.7 on 2024-07-21 15:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0006_alter_account_owner_alter_order_user'), + ] + + operations = [ + migrations.RenameField( + model_name='order', + old_name='user', + new_name='member', + ), + ] diff --git a/src/accounting/models.py b/src/accounting/models.py index 4699076..f92e4fc 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -29,7 +29,7 @@ class Account(CreatedModifiedAbstract): can decide which account to use to pay for something. """ - owner = models.ForeignKey("auth.User", on_delete=models.PROTECT) + owner = models.ForeignKey("membership.Member", on_delete=models.PROTECT) def __str__(self) -> str: return f"Account of {self.owner.get_full_name()}" @@ -71,18 +71,11 @@ class Order(CreatedModifiedAbstract): considered locked. """ - user = models.ForeignKey("auth.User", on_delete=models.PROTECT) + member = models.ForeignKey("membership.Member", on_delete=models.PROTECT) account = models.ForeignKey(Account, on_delete=models.PROTECT) description = models.CharField(max_length=1024, verbose_name=_("description")) - price = MoneyField( - verbose_name=_("price (excl. VAT)"), - max_digits=16, - decimal_places=2, - ) - vat = MoneyField(verbose_name=_("VAT"), max_digits=16, decimal_places=2) - is_paid = models.BooleanField(default=False, verbose_name=_("is paid")) class Meta: @@ -94,8 +87,13 @@ class Order(CreatedModifiedAbstract): @property def total(self) -> Money: - """Return the total price of the order.""" - return self.price + self.vat + """Return the total price of the order (excl VAT).""" + return sum(order_product.price for order_product in self.order_products) + + @property + def total_vat(self) -> Money: + """Return the total VAT of the order.""" + return sum(order_product.vat for order_product in self.order_products) @property def display_id(self) -> str: @@ -130,8 +128,8 @@ class OrderProduct(CreatedModifiedAbstract): This includes pricing information. """ - order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="ordered_products") - product = models.ForeignKey(Product, related_name="ordered_products", on_delete=models.PROTECT) + order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="order_products") + product = models.ForeignKey(Product, related_name="order_products", on_delete=models.PROTECT) price = MoneyField(max_digits=16, decimal_places=2) vat = MoneyField(max_digits=16, decimal_places=2) @@ -170,7 +168,7 @@ class Payment(CreatedModifiedAbstract): return cls.objects.create( order=order, user=order.user, - amount=order.total, + amount=order.total + order.total_vat, description=order.description, payment_type=payment_type, ) diff --git a/src/membership/migrations/0007_membershiptype_current_membershiptype_product.py b/src/membership/migrations/0007_membershiptype_current_membershiptype_product.py new file mode 100644 index 0000000..f7accaa --- /dev/null +++ b/src/membership/migrations/0007_membershiptype_current_membershiptype_product.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.7 on 2024-07-21 14:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0004_paymenttype_product_payment_external_transaction_id_and_more'), + ('membership', '0006_waitinglistentry_alter_membership_options'), + ] + + operations = [ + migrations.AddField( + model_name='membershiptype', + name='current', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='membershiptype', + name='product', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='accounting.product'), + preserve_default=False, + ), + ]