From 7cc22aa0a1addf5c49be1804283121a7a21faf0a Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sun, 14 Jul 2024 18:12:14 +0200 Subject: [PATCH 01/28] WIP: Changes to payment models --- .pre-commit-config.yaml | 2 +- src/accounting/models.py | 23 +++++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc9fe36..8e7157d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ default_language_version: - python: python3.12 + python: python3 exclude: ^.*\b(migrations)\b.*$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/src/accounting/models.py b/src/accounting/models.py index 9db46eb..f529a0c 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -98,19 +98,23 @@ class Payment(CreatedModifiedAbstract): description = models.CharField(max_length=1024, verbose_name=_("description")) - stripe_charge_id = models.CharField(max_length=255, null=True, blank=True) + payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT) + external_transaction_id = models.CharField(max_length=255, null=True, blank=True) + + # stripe_charge_id = models.CharField(max_length=255, null=True, blank=True) @property def display_id(self): return str(self.id).zfill(6) @classmethod - def from_order(cls, order): + def from_order(cls, order, payment_type): return cls.objects.create( order=order, user=order.user, amount=order.total, description=order.description, + payment_type=payment_type, ) def __str__(self) -> str: @@ -119,3 +123,18 @@ class Payment(CreatedModifiedAbstract): class Meta: verbose_name = _("payment") verbose_name_plural = _("payments") + + +class PaymentType(CreatedModifiedAbstract): + """Types of payments available in the system: + - bank transfer + - card payment (specific provider) + """ + + name = models.CharField(max_length=1024, verbose_name=_("description")) + description = models.TextField(max_length=2048, null=True, blank=True) + + enabled = models.BooleanField(default=True) + + def __str__(self) -> str: + return f"{self.name}" -- 2.43.4 From 1405a9509449ad7bb1b82769c713766efe59f4b8 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sun, 14 Jul 2024 19:18:33 +0200 Subject: [PATCH 02/28] Waiting list function: WaitingListEntry model for people that can be invited in the future --- .gitignore | 4 +++ .pre-commit-config.yaml | 6 ++-- pyproject.toml | 2 +- src/membership/admin.py | 15 +++++---- ...itinglistentry_alter_membership_options.py | 32 +++++++++++++++++++ src/membership/models.py | 15 +++++++++ 6 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 src/membership/migrations/0006_waitinglistentry_alter_membership_options.py diff --git a/.gitignore b/.gitignore index 86b4cf9..6f40a53 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ db.sqlite3 .env venv/ .venv/ + + +# collectstatic +src/static/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc9fe36..c7f2a83 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ default_language_version: - python: python3.12 + python: python3 exclude: ^.*\b(migrations)\b.*$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks @@ -15,8 +15,8 @@ repos: - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: 'v0.4.4' + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.5.2' hooks: - id: ruff args: diff --git a/pyproject.toml b/pyproject.toml index 5346347..5434ff2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,7 +115,7 @@ extend-exclude = [ line-length = 120 [tool.ruff.lint] -select = ["ALL"] +# select = ["ALL"] ignore = [ "G004", # Logging statement uses f-string "ANN101", # Missing type annotation for `self` in method diff --git a/src/membership/admin.py b/src/membership/admin.py index 465764f..325167f 100644 --- a/src/membership/admin.py +++ b/src/membership/admin.py @@ -1,20 +1,23 @@ from django.contrib import admin -from .models import Membership -from .models import MembershipType -from .models import SubscriptionPeriod +from . import models -@admin.register(Membership) +@admin.register(models.Membership) class MembershipAdmin(admin.ModelAdmin): pass -@admin.register(MembershipType) +@admin.register(models.MembershipType) class MembershipTypeAdmin(admin.ModelAdmin): pass -@admin.register(SubscriptionPeriod) +@admin.register(models.SubscriptionPeriod) class SubscriptionPeriodAdmin(admin.ModelAdmin): pass + + +@admin.register(models.WaitingListEntry) +class WaitingListEntryAdmin(admin.ModelAdmin): + pass diff --git a/src/membership/migrations/0006_waitinglistentry_alter_membership_options.py b/src/membership/migrations/0006_waitinglistentry_alter_membership_options.py new file mode 100644 index 0000000..a993b63 --- /dev/null +++ b/src/membership/migrations/0006_waitinglistentry_alter_membership_options.py @@ -0,0 +1,32 @@ +# Generated by Django 5.0.6 on 2024-07-14 16:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('membership', '0005_member'), + ] + + operations = [ + migrations.CreateModel( + name='WaitingListEntry', + 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='created')), + ('email', models.EmailField(max_length=254)), + ('geography', models.CharField(blank=True, null=True, verbose_name='geography')), + ('comment', models.TextField(blank=True)), + ], + options={ + 'verbose_name': 'waiting list entry', + 'verbose_name_plural': 'waiting list entries', + }, + ), + migrations.AlterModelOptions( + name='membership', + options={'verbose_name': 'medlemskab', 'verbose_name_plural': 'medlemskaber'}, + ), + ] diff --git a/src/membership/models.py b/src/membership/models.py index 730133f..09146a1 100644 --- a/src/membership/models.py +++ b/src/membership/models.py @@ -111,3 +111,18 @@ class MembershipType(CreatedModifiedAbstract): def __str__(self) -> str: return self.name + + +class WaitingListEntry(CreatedModifiedAbstract): + """People who for some reason could want to be added to a waiting list and invited to join later.""" + + email = models.EmailField() + geography = models.CharField(verbose_name=_("geography"), blank=True, null=True) + comment = models.TextField(blank=True) + + def __str__(self) -> str: + return self.email + + class Meta: + verbose_name = _("waiting list entry") + verbose_name_plural = _("waiting list entries") -- 2.43.4 From b366bc2499fe7a829b85032dae6dc7c87ab77f85 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sun, 14 Jul 2024 21:19:32 +0200 Subject: [PATCH 03/28] WIP: cleanup setup --- .drone.yml | 2 -- .env.example | 1 - Dockerfile | 16 +++++++++------- Makefile | 26 +------------------------- README.md | 4 ++-- docker-compose.yml | 12 +++++++++--- entrypoint.sh | 11 ----------- 7 files changed, 21 insertions(+), 51 deletions(-) diff --git a/.drone.yml b/.drone.yml index d598350..97a1517 100644 --- a/.drone.yml +++ b/.drone.yml @@ -7,7 +7,6 @@ steps: - name: docker image: plugins/docker environment: - DJANGO_ENV: production BUILD: "${DRONE_COMMIT_SHA}" settings: repo: docker.data.coop/membersystem @@ -17,7 +16,6 @@ steps: password: from_secret: DOCKER_PASSWORD build_args_from_env: - - DJANGO_ENV - BUILD tags: - "${DRONE_BUILD_NUMBER}" diff --git a/.env.example b/.env.example index 777e8f3..9da2eda 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,3 @@ DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres # Use something along the the following if you are not using docker # DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem DEBUG=True -DJANGO_ENV=development diff --git a/Dockerfile b/Dockerfile index cc600b1..31ac4f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,16 +7,12 @@ ENV PYTHONFAULTHANDLER=1 \ PIP_NO_CACHE_DIR=off \ PIP_DISABLE_PIP_VERSION_CHECK=on \ PIP_DEFAULT_TIMEOUT=100 -ARG DJANGO_ENV ARG BUILD ENV BUILD ${BUILD} WORKDIR /app -RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www -COPY --chown=www:www . . -RUN mkdir /app/src/static \ - && chown www:www /app/src/static \ +RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www \ && apt-get update \ && apt-get install -y \ binutils \ @@ -29,8 +25,14 @@ RUN mkdir /app/src/static \ libgdk-pixbuf2.0-0 \ libffi-dev \ shared-mime-info \ - gettext \ - && pip install . \ + gettext + +COPY --chown=www:www . . + +RUN mkdir /app/src/static \ + && chown www:www /app/src/static + +RUN pip install . \ && django-admin compilemessages ENTRYPOINT ["./entrypoint.sh"] diff --git a/Makefile b/Makefile index 7fc23eb..5a6bad4 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,5 @@ DOCKER_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u` -DOCKER_BUILD = DOCKER_BUILDKIT=1 docker build DOCKER_CONTAINER_NAME = backend MANAGE_EXEC = python /app/src/manage.py MANAGE_COMMAND = ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} ${MANAGE_EXEC} @@ -10,12 +9,6 @@ init: setup_venv pre_commit_install migrate run: ${DOCKER_COMPOSE} up -setup_venv: - rm -rf venv - python3.11 -m venv venv; - venv/bin/python -m pip install wheel setuptools; - venv/bin/python -m pip install pre-commit boto3 pip-tools; - pre_commit_install: venv/bin/pre-commit install @@ -37,22 +30,5 @@ shell: manage_command: ${MANAGE_COMMAND} ${ARGS} -add_dependency: - ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry add --lock ${DEPENDENCY} - -add_dev_dependency: - ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry add -D --lock ${DEPENDENCY} - -poetry_lock: - ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry lock --no-update - -poetry_command: - ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry ${COMMAND} - -build_dev_docker_image: compile_requirements +build_dev_docker_image: ${DOCKER_COMPOSE} build ${DOCKER_CONTAINER_NAME} - -compile_requirements: - ./venv/bin/pip-compile --output-file requirements/base.txt requirements/base.in - ./venv/bin/pip-compile --output-file requirements/test.txt requirements/test.in - ./venv/bin/pip-compile --output-file requirements/dev.txt requirements/dev.in diff --git a/README.md b/README.md index 9aed72b..78e7ba1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Copy over the .env.example file to .env and adjust DATABASE_URL accordingly - $ cp .env.example .env + $ cp .env.example .env ### Docker @@ -56,7 +56,7 @@ Activate the venv Install requirements - $ pip install -r requirements/dev.txt + $ pip install . Run migrations diff --git a/docker-compose.yml b/docker-compose.yml index 6872ac9..60be6b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.7' - services: backend: @@ -13,7 +11,8 @@ services: volumes: - ./:/app/ depends_on: - - postgres + postgres: + condition: service_healthy env_file: - .env @@ -25,6 +24,13 @@ services: - 5432:5432 env_file: - .env + # This healthcheck has a large number of retries, this is currently based on the number of + # retries necessary to get the database running in GitHub Actions. + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres -d postgres" ] + interval: 5s + timeout: 5s + retries: 30 volumes: postgres_data: diff --git a/entrypoint.sh b/entrypoint.sh index 9df57b6..008f7be 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,16 +1,5 @@ #!/bin/sh -echo "Waiting for postgres..." - -POSTGRES_PORT=${POSTGRES_PORT:-5432} -POSTGRES_HOST=${POSTGRES_HOST:-localhost} - -while ! nc -z "$POSTGRES_HOST" "$POSTGRES_PORT"; do - sleep 0.1 -done - -echo "PostgreSQL started" - # Only migrate, collectstatic and compilemessages if we are NOT in development if [ -z "$DEBUG" ]; then python src/manage.py migrate; -- 2.43.4 From d769481848d43114ac1e1a55ae000a5eb30a91b1 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sat, 20 Jul 2024 22:33:30 +0200 Subject: [PATCH 04/28] Add a few more README things on Docker --- Makefile | 5 ++--- README.md | 13 +++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index de5cae6..651ed32 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,5 @@ DOCKER_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u` -DOCKER_CONTAINER_NAME = backend MANAGE_EXEC = python /app/src/manage.py MANAGE_COMMAND = ${DOCKER_RUN} app ${MANAGE_EXEC} @@ -28,5 +27,5 @@ shell: manage_command: ${MANAGE_COMMAND} ${ARGS} -build_dev_docker_image: - ${DOCKER_COMPOSE} build ${DOCKER_CONTAINER_NAME} +build: + ${DOCKER_COMPOSE} build diff --git a/README.md b/README.md index 65b41b1..41ef329 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,19 @@ Working with the Docker Compose setup is made easy with the `Makefile` provided make run ``` +#### Building and running other things + +```bash +# Build the containers +make build + +# Create a superuser +make createsuperuser + +# Create Django migrations (after this, maybe you need to change file permissions in volume) +make makemigrations +``` + ### Using hatch #### Requirements -- 2.43.4 From 250f203900b0421da809738694d58d541a52cfd1 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sat, 20 Jul 2024 22:45:44 +0200 Subject: [PATCH 05/28] Fix some issues after merge conflixts --- Dockerfile | 12 +++++------- pyproject.toml | 2 +- src/membership/admin.py | 4 ---- ...0006_waitinglistentry_alter_membership_options.py | 4 ++-- src/membership/models.py | 2 +- src/pytest.ini | 5 ----- 6 files changed, 9 insertions(+), 20 deletions(-) delete mode 100644 src/pytest.ini diff --git a/Dockerfile b/Dockerfile index 16219b3..2e9448d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,7 @@ WORKDIR /app RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www COPY --chown=www:www . . -RUN mkdir /app/src/static && \ - chown www:www /app/src/static && \ - apt-get update && \ +RUN apt-get update && \ apt-get install -y \ binutils \ libpq-dev \ @@ -33,11 +31,11 @@ RUN mkdir /app/src/static && \ COPY --chown=www:www . . -RUN mkdir /app/src/static \ - && chown www:www /app/src/static +RUN mkdir /app/src/static && \ + chown www:www /app/src/static -RUN pip install --no-cache-dir -r $REQUIREMENTS_FILE && \ - && django-admin compilemessages +RUN pip install --no-cache-dir -r $REQUIREMENTS_FILE +RUN django-admin compilemessages ENTRYPOINT ["./entrypoint.sh"] diff --git a/pyproject.toml b/pyproject.toml index 5874616..643b1cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,7 +125,7 @@ extend-exclude = [ line-length = 120 [tool.ruff.lint] -# select = ["ALL"] +select = ["ALL"] ignore = [ "G004", # Logging statement uses f-string "ANN101", # Missing type annotation for `self` in method diff --git a/src/membership/admin.py b/src/membership/admin.py index 0f84066..b16e2a1 100644 --- a/src/membership/admin.py +++ b/src/membership/admin.py @@ -19,11 +19,7 @@ class MembershipTypeAdmin(admin.ModelAdmin): class SubscriptionPeriodAdmin(admin.ModelAdmin): """Admin for SubscriptionPeriod model.""" - pass - @admin.register(models.WaitingListEntry) class WaitingListEntryAdmin(admin.ModelAdmin): """Admin for WaitingList model.""" - - pass diff --git a/src/membership/migrations/0006_waitinglistentry_alter_membership_options.py b/src/membership/migrations/0006_waitinglistentry_alter_membership_options.py index a993b63..9c2d923 100644 --- a/src/membership/migrations/0006_waitinglistentry_alter_membership_options.py +++ b/src/membership/migrations/0006_waitinglistentry_alter_membership_options.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.6 on 2024-07-14 16:22 +# Generated by Django 5.0.7 on 2024-07-20 20:45 from django.db import migrations, models @@ -17,7 +17,7 @@ class Migration(migrations.Migration): ('modified', models.DateTimeField(auto_now=True, verbose_name='modified')), ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), ('email', models.EmailField(max_length=254)), - ('geography', models.CharField(blank=True, null=True, verbose_name='geography')), + ('geography', models.CharField(blank=True, default='', verbose_name='geography')), ('comment', models.TextField(blank=True)), ], options={ diff --git a/src/membership/models.py b/src/membership/models.py index 69bdd4e..4fec013 100644 --- a/src/membership/models.py +++ b/src/membership/models.py @@ -145,7 +145,7 @@ class WaitingListEntry(CreatedModifiedAbstract): """People who for some reason could want to be added to a waiting list and invited to join later.""" email = models.EmailField() - geography = models.CharField(verbose_name=_("geography"), blank=True, null=True) + geography = models.CharField(verbose_name=_("geography"), blank=True, default="") comment = models.TextField(blank=True) def __str__(self) -> str: diff --git a/src/pytest.ini b/src/pytest.ini deleted file mode 100644 index 9929fa7..0000000 --- a/src/pytest.ini +++ /dev/null @@ -1,5 +0,0 @@ -[pytest] -testpaths = . -python_files = tests.py test_*.py *_tests.py -DJANGO_SETTINGS_MODULE = project.settings -#norecursedirs = dist tmp* .svn .* -- 2.43.4 From 1f28ffc9c51c4c680bb2d5e0af331a51c80dd8a7 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sun, 21 Jul 2024 13:41:50 +0200 Subject: [PATCH 06/28] WIP: Missing migrations --- src/accounting/models.py | 31 ++++++++++++++++++++++++++++--- src/membership/models.py | 14 ++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/accounting/models.py b/src/accounting/models.py index c5355f2..3d0fb2d 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -67,9 +67,8 @@ class Transaction(CreatedModifiedAbstract): class Order(CreatedModifiedAbstract): """An order. - Scoped out: Contents of invoices will have to be tracked either here or in - a separate Invoice model. This is undecided because we are not generating - invoices at the moment. + We assemble the order from a number of products. Once an order is paid, the contents should be + considered locked. """ user = models.ForeignKey("auth.User", on_delete=models.PROTECT) @@ -114,6 +113,32 @@ class Order(CreatedModifiedAbstract): return x.hexdigest() +class Product(CreatedModifiedAbstract): + """A generic product, for instance a membership or a service fee.""" + + name = models.CharField(max_length=512) + price = MoneyField(max_digits=16, decimal_places=2) + vat = MoneyField(max_digits=16, decimal_places=2) + + def __str__(self) -> str: + return self.name + + +class OrderProduct(CreatedModifiedAbstract): + """When a product is ordered, we store the product on the order. + + 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) + price = MoneyField(max_digits=16, decimal_places=2) + vat = MoneyField(max_digits=16, decimal_places=2) + + def __str__(self) -> str: + return f"{self.product.name}" + + class Payment(CreatedModifiedAbstract): """A payment is a transaction that is made to pay for an order.""" diff --git a/src/membership/models.py b/src/membership/models.py index 502aa66..7e71393 100644 --- a/src/membership/models.py +++ b/src/membership/models.py @@ -133,9 +133,23 @@ class MembershipType(CreatedModifiedAbstract): name = models.CharField(verbose_name=_("name"), max_length=64) + product = models.ForeignKey("accounting.Product", on_delete=models.PROTECT) + + current = models.BooleanField(default=False) + class Meta: verbose_name = _("membership type") verbose_name_plural = _("membership types") def __str__(self) -> str: return self.name + + def create_membership(self, user: User) -> Membership: + """Create a current membership for this type.""" + from .selectors import get_current_subscription_period + + return Membership.objects.create( + membership_type=self, + user=user, + period=get_current_subscription_period(), + ) -- 2.43.4 From 5055095c6b33fa214c2f3598a01219ca1fdc34c2 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sun, 21 Jul 2024 16:10:20 +0200 Subject: [PATCH 07/28] Admin for Member model, not for User --- src/accounting/models.py | 2 +- src/membership/admin.py | 28 ++++++++++++++++++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/accounting/models.py b/src/accounting/models.py index 3d0fb2d..4699076 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -150,7 +150,7 @@ class Payment(CreatedModifiedAbstract): payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT) external_transaction_id = models.CharField(max_length=255, default="", blank=True) - # stripe_charge_id = models.CharField(max_length=255, null=True, blank=True) # noqa: ERA001 + stripe_charge_id = models.CharField(max_length=255, default="", blank=True) class Meta: verbose_name = _("payment") diff --git a/src/membership/admin.py b/src/membership/admin.py index 2cf7030..6fdb423 100644 --- a/src/membership/admin.py +++ b/src/membership/admin.py @@ -1,22 +1,38 @@ """Admin configuration for membership app.""" from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import User -from .models import Membership -from .models import MembershipType -from .models import SubscriptionPeriod +from . import models + +# Do not use existing user admin +admin.site.unregister(User) -@admin.register(Membership) +@admin.register(models.Membership) class MembershipAdmin(admin.ModelAdmin): """Admin for Membership model.""" -@admin.register(MembershipType) +@admin.register(models.MembershipType) class MembershipTypeAdmin(admin.ModelAdmin): """Admin for MembershipType model.""" -@admin.register(SubscriptionPeriod) +@admin.register(models.SubscriptionPeriod) class SubscriptionPeriodAdmin(admin.ModelAdmin): """Admin for SubscriptionPeriod model.""" + + +class MembershipInlineAdmin(admin.TabularInline): + """Inline admin.""" + + model = models.Membership + + +@admin.register(models.Member) +class MemberAdmin(UserAdmin): + """Member admin is actually an admin for User objects.""" + + inlines = (MembershipInlineAdmin,) -- 2.43.4 From ee2ef48b96ad04ad615f1d487d97274364ef5fe2 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sun, 21 Jul 2024 19:07:59 +0200 Subject: [PATCH 08/28] Reorganize Order fields, add admin, Upgrade to Django 5.1b1 --- Dockerfile | 1 - README.md | 14 ++++ pyproject.toml | 6 +- requirements.txt | 4 +- requirements/requirements-dev.txt | 13 +--- src/accounting/admin.py | 53 +++++++++---- ...ayment_external_transaction_id_and_more.py | 78 +++++++++++++++++++ ...ce_remove_order_price_currency_and_more.py | 40 ++++++++++ ...06_alter_account_owner_alter_order_user.py | 25 ++++++ .../0007_rename_user_order_member.py | 18 +++++ src/accounting/models.py | 26 +++---- ...shiptype_current_membershiptype_product.py | 26 +++++++ 12 files changed, 262 insertions(+), 42 deletions(-) create mode 100644 src/accounting/migrations/0004_paymenttype_product_payment_external_transaction_id_and_more.py create mode 100644 src/accounting/migrations/0005_remove_order_price_remove_order_price_currency_and_more.py create mode 100644 src/accounting/migrations/0006_alter_account_owner_alter_order_user.py create mode 100644 src/accounting/migrations/0007_rename_user_order_member.py create mode 100644 src/membership/migrations/0007_membershiptype_current_membershiptype_product.py 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, + ), + ] -- 2.43.4 From d9de265de9acff49c21bdec0685acedad24aae12 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Mon, 22 Jul 2024 16:49:24 +0200 Subject: [PATCH 09/28] Fix some admin columms --- src/accounting/admin.py | 7 +------ src/accounting/models.py | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/accounting/admin.py b/src/accounting/admin.py index a970e9a..efdfb38 100644 --- a/src/accounting/admin.py +++ b/src/accounting/admin.py @@ -43,12 +43,7 @@ class OrderAdmin(admin.ModelAdmin): inlines = (OrderProductInline,) form = OrderAdminForm - list_display = ("who", "description", "created", "is_paid") - - @admin.display(description=_("Customer")) - def who(self, instance: models.Order) -> str: - """Return the full name of the user who made the order.""" - return instance.member.get_full_name() + list_display = ("member", "description", "created", "is_paid") @admin.register(models.Payment) diff --git a/src/accounting/models.py b/src/accounting/models.py index f92e4fc..d4cd2b0 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -32,7 +32,7 @@ class Account(CreatedModifiedAbstract): owner = models.ForeignKey("membership.Member", on_delete=models.PROTECT) def __str__(self) -> str: - return f"Account of {self.owner.get_full_name()}" + return f"Account of {self.owner}" @property def balance(self) -> Money: -- 2.43.4 From fa6a5cdb8684d47bf50b33b506846449ff51669c Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Wed, 24 Jul 2024 22:29:07 +0200 Subject: [PATCH 10/28] Revert healtcheck on Postgres, do it in the entrypoint --- docker-compose.yml | 11 ++--------- entrypoint.sh | 11 +++++++++++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c957cd0..9bd0957 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,4 @@ +--- services: app: build: @@ -11,8 +12,7 @@ services: volumes: - ./:/app/ depends_on: - postgres: - condition: service_healthy + - postgres env_file: - .env @@ -24,13 +24,6 @@ services: - 5432:5432 env_file: - .env - # This healthcheck has a large number of retries, this is currently based on the number of - # retries necessary to get the database running in GitHub Actions. - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U postgres -d postgres" ] - interval: 5s - timeout: 5s - retries: 30 volumes: postgres_data: diff --git a/entrypoint.sh b/entrypoint.sh index 008f7be..9df57b6 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,5 +1,16 @@ #!/bin/sh +echo "Waiting for postgres..." + +POSTGRES_PORT=${POSTGRES_PORT:-5432} +POSTGRES_HOST=${POSTGRES_HOST:-localhost} + +while ! nc -z "$POSTGRES_HOST" "$POSTGRES_PORT"; do + sleep 0.1 +done + +echo "PostgreSQL started" + # Only migrate, collectstatic and compilemessages if we are NOT in development if [ -z "$DEBUG" ]; then python src/manage.py migrate; -- 2.43.4 From 768ef5a7d200ba2bf5fcf6a1c583b3ca4a1afc5d Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Fri, 26 Jul 2024 00:57:22 +0200 Subject: [PATCH 11/28] Add new admins, rework relation between orders and memberships, MembershipType can contain many Products, Memberships can be activated and revoked --- src/accounting/admin.py | 15 +++++ src/accounting/apps.py | 4 ++ ...06_alter_account_owner_alter_order_user.py | 2 +- src/accounting/models.py | 5 ++ src/accounting/signals.py | 21 +++++++ ...ivated_membership_activated_on_and_more.py | 55 +++++++++++++++++++ ...shiptype_current_membershiptype_product.py | 26 --------- src/membership/models.py | 31 ++++++++++- 8 files changed, 129 insertions(+), 30 deletions(-) create mode 100644 src/accounting/signals.py create mode 100644 src/membership/migrations/0007_membership_activated_membership_activated_on_and_more.py delete mode 100644 src/membership/migrations/0007_membershiptype_current_membershiptype_product.py diff --git a/src/accounting/admin.py b/src/accounting/admin.py index efdfb38..b8b311c 100644 --- a/src/accounting/admin.py +++ b/src/accounting/admin.py @@ -56,3 +56,18 @@ class PaymentAdmin(admin.ModelAdmin): def order_id(self, instance: models.Payment) -> int: """Return the ID of the order.""" return instance.order.id + + +@admin.register(models.Product) +class ProductAdmin(admin.ModelAdmin): # noqa: D101 + list_display = ("name", "price", "vat") + + +class TransactionInline(admin.TabularInline): # noqa: D101 + model = models.Transaction + + +@admin.register(models.Account) +class AccountAdmin(admin.ModelAdmin): # noqa: D101 + list_display = ("owner", "balance") + inlines = (TransactionInline,) diff --git a/src/accounting/apps.py b/src/accounting/apps.py index 296dae8..9193122 100644 --- a/src/accounting/apps.py +++ b/src/accounting/apps.py @@ -7,3 +7,7 @@ class AccountingConfig(AppConfig): """Accounting app config.""" name = "accounting" + + def ready(self) -> None: + """Implicitly connect a signal handlers decorated with @receiver.""" + from . import signals # noqa: F401 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 index 9bda0e6..cdaeaff 100644 --- a/src/accounting/migrations/0006_alter_account_owner_alter_order_user.py +++ b/src/accounting/migrations/0006_alter_account_owner_alter_order_user.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('accounting', '0005_remove_order_price_remove_order_price_currency_and_more'), - ('membership', '0007_membershiptype_current_membershiptype_product'), + ('membership', '0006_waitinglistentry_alter_membership_options'), ] operations = [ diff --git a/src/accounting/models.py b/src/accounting/models.py index d4cd2b0..6629a2b 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -95,6 +95,11 @@ class Order(CreatedModifiedAbstract): """Return the total VAT of the order.""" return sum(order_product.vat for order_product in self.order_products) + @property + def total_with_vat(self) -> Money: + """Return the TOTAL amount WITH VAT.""" + return self.total + self.total_vat + @property def display_id(self) -> str: """Return an id for the order.""" diff --git a/src/accounting/signals.py b/src/accounting/signals.py new file mode 100644 index 0000000..c3bd259 --- /dev/null +++ b/src/accounting/signals.py @@ -0,0 +1,21 @@ +"""Loaded with the AppConfig.ready() method.""" + +from django.conf import settings +from django.core.mail import send_mail +from django.db.models.signals import post_save +from django.dispatch import receiver + +from . import models + + +# method for updating +@receiver(post_save, sender=models.Payment) +def check_total_amount(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None: # noqa: ARG001 + """Check that we receive Payments with the correct amount.""" + if instance.amount != instance.order.total_with_vat: + send_mail( + "Payment received: wrong amount", + f"Please check payment ID {instance.pk}", + settings.DEFAULT_FROM_EMAIL, + settings.ADMINS, + ) diff --git a/src/membership/migrations/0007_membership_activated_membership_activated_on_and_more.py b/src/membership/migrations/0007_membership_activated_membership_activated_on_and_more.py new file mode 100644 index 0000000..0c7c57d --- /dev/null +++ b/src/membership/migrations/0007_membership_activated_membership_activated_on_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 5.1b1 on 2024-07-25 22:47 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0007_rename_user_order_member'), + ('membership', '0006_waitinglistentry_alter_membership_options'), + ] + + operations = [ + migrations.AddField( + model_name='membership', + name='activated', + field=models.BooleanField(default=False, help_text='Membership was activated.', verbose_name='activated'), + ), + migrations.AddField( + model_name='membership', + name='activated_on', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='membership', + 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( + model_name='membership', + 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( + model_name='membership', + name='revoked_on', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='membership', + name='revoked_reason', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='membershiptype', + name='active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='membershiptype', + name='products', + field=models.ManyToManyField(to='accounting.product'), + ), + ] diff --git a/src/membership/migrations/0007_membershiptype_current_membershiptype_product.py b/src/membership/migrations/0007_membershiptype_current_membershiptype_product.py deleted file mode 100644 index f7accaa..0000000 --- a/src/membership/migrations/0007_membershiptype_current_membershiptype_product.py +++ /dev/null @@ -1,26 +0,0 @@ -# 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, - ), - ] diff --git a/src/membership/models.py b/src/membership/models.py index 2d5e69d..e8cce22 100644 --- a/src/membership/models.py +++ b/src/membership/models.py @@ -84,7 +84,7 @@ class Membership(CreatedModifiedAbstract): def _current(self) -> Self: """Filter memberships for the current period.""" - return self.filter(period__period__contains=timezone.now()) + return self.filter(activated=True, revoked=False, period__period__contains=timezone.now()) def current(self) -> "Membership | None": """Get the current membership.""" @@ -116,6 +116,31 @@ class Membership(CreatedModifiedAbstract): on_delete=models.PROTECT, ) + order = models.ForeignKey( + "accounting.Order", + null=True, + blank=True, + verbose_name=_("order"), + help_text=_("The order filled in for paying this membership."), + on_delete=models.PROTECT, + ) + + activated = models.BooleanField( + default=False, verbose_name=_("activated"), help_text=_("Membership was activated.") + ) + activated_on = models.DateTimeField(null=True, blank=True) + + revoked = models.BooleanField( + default=False, + verbose_name=_("revoked"), + help_text=_( + "Membership has explicitly been revoked. Revoking a membership is not associated with regular expiration " + "of the membership period." + ), + ) + revoked_reason = models.TextField(blank=True) + revoked_on = models.DateTimeField(null=True, blank=True) + class Meta: verbose_name = _("membership") verbose_name_plural = _("memberships") @@ -133,9 +158,9 @@ class MembershipType(CreatedModifiedAbstract): name = models.CharField(verbose_name=_("name"), max_length=64) - product = models.ForeignKey("accounting.Product", on_delete=models.PROTECT) + products = models.ManyToManyField("accounting.Product") - current = models.BooleanField(default=False) + active = models.BooleanField(default=True) class Meta: verbose_name = _("membership type") -- 2.43.4 From 3193cafe4b5d11e17f2cbe81582fe61e02f0654a Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sun, 28 Jul 2024 10:55:15 +0200 Subject: [PATCH 12/28] Add dynamic admin actions for bulk creating memberships --- pyproject.toml | 5 +++-- src/membership/admin.py | 34 ++++++++++++++++++++++++++++++++++ src/membership/models.py | 22 ++++++++++++++++++++++ src/project/settings.py | 3 +++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6a52861..a6dc9c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "django-registries==0.0.3", "django-view-decorator==0.0.4", "django-oauth-toolkit==2.4.0", + "django_stubs_ext", ] version = "0.0.1" @@ -104,10 +105,10 @@ show_error_codes = true strict = true warn_unreachable = true follow_imports = "normal" -#plugins = ["mypy_django_plugin.main"] +plugins = ["mypy_django_plugin.main"] [tool.django-stubs] -#django_settings_module = "tests.settings" +django_settings_module = "project.settings" [[tool.mypy.overrides]] module = "tests.*" diff --git a/src/membership/admin.py b/src/membership/admin.py index 8224ff6..2d391a5 100644 --- a/src/membership/admin.py +++ b/src/membership/admin.py @@ -1,8 +1,14 @@ """Admin configuration for membership app.""" +from collections.abc import Callable + from django.contrib import admin +from django.contrib.admin import ModelAdmin from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User +from django.db.models import QuerySet +from django.http import HttpRequest +from django.http import HttpResponse from . import models @@ -31,11 +37,39 @@ class MembershipInlineAdmin(admin.TabularInline): model = models.Membership +def decorate_ensure_membership_type_exists(membership_type: models.MembershipType, label: str) -> Callable: + """Generate an admin action for given membership type and label.""" + + @admin.action(description=label) + def admin_action(modeladmin: ModelAdmin, request: HttpRequest, queryset: QuerySet) -> HttpResponse: # noqa: ARG001 + return ensure_membership_type_exists(request, queryset, membership_type) + + return admin_action + + +def ensure_membership_type_exists( + request: HttpRequest, # noqa: ARG001 + queryset: QuerySet, # noqa: ARG001 + membership_type: models.MembershipType, # noqa: ARG001 +) -> HttpResponse: + """Inner function that ensures that a membership exists for a given queryset of Member objects.""" + + @admin.register(models.Member) class MemberAdmin(UserAdmin): """Member admin is actually an admin for User objects.""" inlines = (MembershipInlineAdmin,) + actions: list[Callable] = [] # noqa: RUF012 + + def get_actions(self, request: HttpRequest) -> dict: + """Populate actions with dynamic data (MembershipType).""" + current_period = models.SubscriptionPeriod.objects.current() + if current_period: + for mtype in models.MembershipType.objects.filter(active=True): + action_label = f"Ensure membership {mtype.name}, {current_period.period}, {mtype.total_including_vat}" + self.actions.append(decorate_ensure_membership_type_exists(mtype, action_label)) + return super().get_actions(request) @admin.register(models.WaitingListEntry) diff --git a/src/membership/models.py b/src/membership/models.py index e8cce22..23ac4fc 100644 --- a/src/membership/models.py +++ b/src/membership/models.py @@ -10,6 +10,7 @@ from django.contrib.postgres.fields import RangeOperators from django.db import models from django.utils import timezone from django.utils.translation import gettext as _ +from djmoney.money import Money from utils.mixins import CreatedModifiedAbstract @@ -53,6 +54,22 @@ class SubscriptionPeriod(CreatedModifiedAbstract): Denotes a period for which members should pay their membership fee for. """ + class QuerySet(models.QuerySet): + """QuerySet for the Membership model.""" + + def _current(self) -> Self: + """Filter memberships for the current period.""" + return self.filter(period__contains=timezone.now()) + + def current(self) -> "Membership | None": + """Get the current membership.""" + try: + return self._current().get() + except self.model.DoesNotExist: + return None + + objects = QuerySet.as_manager() + period = DateRangeField(verbose_name=_("period")) class Meta: @@ -179,6 +196,11 @@ class MembershipType(CreatedModifiedAbstract): period=get_current_subscription_period(), ) + @property + def total_including_vat(self) -> Money: + """Calculate the total price of this membership (including VAT).""" + return sum(product.price + product.vat for product in self.products.all()) + class WaitingListEntry(CreatedModifiedAbstract): """People who for some reason could want to be added to a waiting list and invited to join later.""" diff --git a/src/project/settings.py b/src/project/settings.py index 9cf846d..21018c0 100644 --- a/src/project/settings.py +++ b/src/project/settings.py @@ -2,9 +2,12 @@ from pathlib import Path +import django_stubs_ext from django.utils.translation import gettext_lazy as _ from environs import Env +django_stubs_ext.monkeypatch() + env = Env() env.read_env() -- 2.43.4 From 2499c3227cdc7e0f5dc944509a1b74c8f4ec934d Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sun, 28 Jul 2024 18:38:00 +0200 Subject: [PATCH 13/28] Implements bulk-creation action for memberships and orders --- src/membership/admin.py | 46 ++++++++++++++++++++++++++++++++++------ src/membership/models.py | 13 ++++++------ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/membership/admin.py b/src/membership/admin.py index 2d391a5..823d911 100644 --- a/src/membership/admin.py +++ b/src/membership/admin.py @@ -2,10 +2,15 @@ from collections.abc import Callable +from accounting.models import Account +from accounting.models import Order +from accounting.models import OrderProduct from django.contrib import admin +from django.contrib import messages from django.contrib.admin import ModelAdmin from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User +from django.db import transaction from django.db.models import QuerySet from django.http import HttpRequest from django.http import HttpResponse @@ -47,12 +52,34 @@ def decorate_ensure_membership_type_exists(membership_type: models.MembershipTyp return admin_action +@transaction.atomic def ensure_membership_type_exists( - request: HttpRequest, # noqa: ARG001 - queryset: QuerySet, # noqa: ARG001 - membership_type: models.MembershipType, # noqa: ARG001 + request: HttpRequest, + queryset: QuerySet, + membership_type: models.MembershipType, ) -> HttpResponse: """Inner function that ensures that a membership exists for a given queryset of Member objects.""" + for member in queryset: + if member.memberships.filter(membership_type=membership_type).current(): + messages.info(request, f"{member} already has a membership {membership_type}") + else: + # Get the default account of the member. We don't really know what to do if a person owns multiple accounts. + account, __ = Account.objects.get_or_create(owner=member) + # Create an Order for the products in the membership + order = Order.objects.create(member=member, account=account) + # Add stuff to the order + for product in membership_type.products.all(): + OrderProduct.objects.create(order=order, product=product, price=product.price, vat=product.vat) + # Create the Membership + models.Membership.objects.create( + membership_type=membership_type, + user=member, + period=models.SubscriptionPeriod.objects.current(), + order=order, + ) + + # Associate the order with that membership + messages.success(request, f"{member} has ordered a '{membership_type}' (unpaid)") @admin.register(models.Member) @@ -65,11 +92,18 @@ class MemberAdmin(UserAdmin): def get_actions(self, request: HttpRequest) -> dict: """Populate actions with dynamic data (MembershipType).""" current_period = models.SubscriptionPeriod.objects.current() + + super_dict = super().get_actions(request) + if current_period: - for mtype in models.MembershipType.objects.filter(active=True): + for i, mtype in enumerate(models.MembershipType.objects.filter(active=True)): action_label = f"Ensure membership {mtype.name}, {current_period.period}, {mtype.total_including_vat}" - self.actions.append(decorate_ensure_membership_type_exists(mtype, action_label)) - return super().get_actions(request) + action_func = decorate_ensure_membership_type_exists(mtype, action_label) + # Django ModelAdmin uses the non-unique __name__ property, so we need to suffix it to make it unique + action_func.__name__ += f"_{i}" + self.actions.append(action_func) + + return super_dict @admin.register(models.WaitingListEntry) diff --git a/src/membership/models.py b/src/membership/models.py index 23ac4fc..f00c458 100644 --- a/src/membership/models.py +++ b/src/membership/models.py @@ -99,16 +99,17 @@ class Membership(CreatedModifiedAbstract): """Filter memberships for a given member.""" return self.filter(user=member) + def active(self) -> Self: + """Get only activated, non-revoked memberships (may have expired so use also current()).""" + return self.filter(activated=True, revoked=False) + def _current(self) -> Self: """Filter memberships for the current period.""" - return self.filter(activated=True, revoked=False, period__period__contains=timezone.now()) + return self.filter(period__period__contains=timezone.now()) def current(self) -> "Membership | None": """Get the current membership.""" - try: - return self._current().get() - except self.model.DoesNotExist: - return None + return self._current().first() def previous(self) -> list["Membership"]: """Get previous memberships.""" @@ -119,7 +120,7 @@ class Membership(CreatedModifiedAbstract): objects = QuerySet.as_manager() - user = models.ForeignKey("auth.User", on_delete=models.PROTECT) + user = models.ForeignKey("auth.User", on_delete=models.PROTECT, related_name="memberships") membership_type = models.ForeignKey( "membership.MembershipType", -- 2.43.4 From 5c5153adb6f381d9521afcd96805719f8f4808fa Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sun, 28 Jul 2024 18:55:17 +0200 Subject: [PATCH 14/28] Add signals to update Order and Membership --- src/accounting/signals.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/accounting/signals.py b/src/accounting/signals.py index c3bd259..4e90656 100644 --- a/src/accounting/signals.py +++ b/src/accounting/signals.py @@ -4,6 +4,8 @@ from django.conf import settings from django.core.mail import send_mail from django.db.models.signals import post_save from django.dispatch import receiver +from django.utils import timezone +from membership.models import Membership from . import models @@ -19,3 +21,19 @@ def check_total_amount(sender: models.Payment, instance: models.Payment, **kwarg settings.DEFAULT_FROM_EMAIL, settings.ADMINS, ) + + +@receiver(post_save, sender=models.Payment) +def mark_order_paid(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None: # noqa: ARG001 + """Mark an order as paid when payment is received.""" + instance.order.is_paid = True + instance.order.save() + + +@receiver(post_save, sender=models.Payment) +def activate_membership(sender: models.Order, instance: models.Order, **kwargs: dict) -> None: # noqa: ARG001 + """Mark a membership as activated when its order is marked as paid.""" + if instance.is_paid: + Membership.objects.filter(order=instance, activated=False, activated_on=None).update( + activated=True, activated_on=timezone.now() + ) -- 2.43.4 From 6bf42ecba393a6e9482c50f52965ddd1bc8d54f8 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sun, 28 Jul 2024 22:39:21 +0200 Subject: [PATCH 15/28] Bootstrap an Order detail page --- .../templates/accounting/order/detail.html | 13 +++++++ src/accounting/views.py | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/accounting/templates/accounting/order/detail.html create mode 100644 src/accounting/views.py diff --git a/src/accounting/templates/accounting/order/detail.html b/src/accounting/templates/accounting/order/detail.html new file mode 100644 index 0000000..00c5989 --- /dev/null +++ b/src/accounting/templates/accounting/order/detail.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block head_title %} + {% trans "Order" %} +{% endblock %} + +{% block content %} + +
+

Order: {{ order.id }}

+
+{% endblock %} diff --git a/src/accounting/views.py b/src/accounting/views.py new file mode 100644 index 0000000..c31c5d9 --- /dev/null +++ b/src/accounting/views.py @@ -0,0 +1,38 @@ +"""Views for the membership app.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.shortcuts import render +from django_view_decorator import namespaced_decorator_factory + +from . import models + +if TYPE_CHECKING: + from django.http import HttpRequest + from django.http import HttpResponse + + +order_view = namespaced_decorator_factory(namespace="order", base_path="order") + + +@order_view( + paths="/", + name="detail", + login_required=True, +) +def order_detail(request: HttpRequest, order_id: int) -> HttpResponse: + """View to show the details of a member.""" + user = request.user # People just need to login to pay something, not necessarily be a member + order = models.Order.objects.get(pk=order_id, member=user) + + context = { + "order": order, + } + + return render( + request=request, + template_name="accounting/order/detail.html", + context=context, + ) -- 2.43.4 From e2f4a66645daeeb31f61766949ec603841df26fd Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Sun, 28 Jul 2024 23:27:53 +0200 Subject: [PATCH 16/28] Add details to order page --- ...erproduct_options_orderproduct_quantity.py | 22 ++++++++++++ ...roduct_order_alter_orderproduct_product.py | 24 +++++++++++++ src/accounting/models.py | 18 +++++++--- .../templates/accounting/order/detail.html | 35 +++++++++++++++++++ .../migrations/0008_alter_membership_user.py | 21 +++++++++++ 5 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 src/accounting/migrations/0008_alter_orderproduct_options_orderproduct_quantity.py create mode 100644 src/accounting/migrations/0009_alter_orderproduct_order_alter_orderproduct_product.py create mode 100644 src/membership/migrations/0008_alter_membership_user.py diff --git a/src/accounting/migrations/0008_alter_orderproduct_options_orderproduct_quantity.py b/src/accounting/migrations/0008_alter_orderproduct_options_orderproduct_quantity.py new file mode 100644 index 0000000..51a56e0 --- /dev/null +++ b/src/accounting/migrations/0008_alter_orderproduct_options_orderproduct_quantity.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1b1 on 2024-07-28 21:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0007_rename_user_order_member'), + ] + + operations = [ + migrations.AlterModelOptions( + name='orderproduct', + options={'verbose_name': 'ordered products'}, + ), + migrations.AddField( + model_name='orderproduct', + name='quantity', + field=models.PositiveSmallIntegerField(default=1), + ), + ] diff --git a/src/accounting/migrations/0009_alter_orderproduct_order_alter_orderproduct_product.py b/src/accounting/migrations/0009_alter_orderproduct_order_alter_orderproduct_product.py new file mode 100644 index 0000000..ac36b45 --- /dev/null +++ b/src/accounting/migrations/0009_alter_orderproduct_order_alter_orderproduct_product.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1b1 on 2024-07-28 21:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0008_alter_orderproduct_options_orderproduct_quantity'), + ] + + operations = [ + migrations.AlterField( + model_name='orderproduct', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='accounting.order'), + ), + migrations.AlterField( + model_name='orderproduct', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='accounting.product'), + ), + ] diff --git a/src/accounting/models.py b/src/accounting/models.py index 6629a2b..0aa75cc 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -88,12 +88,12 @@ class Order(CreatedModifiedAbstract): @property def total(self) -> Money: """Return the total price of the order (excl VAT).""" - return sum(order_product.price for order_product in self.order_products) + return sum(item.price * item.quantity for item in self.items.all()) @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) + return sum(item.vat * item.quantity for item in self.items.all()) @property def total_with_vat(self) -> Money: @@ -133,14 +133,24 @@ class OrderProduct(CreatedModifiedAbstract): This includes pricing information. """ - order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="order_products") - product = models.ForeignKey(Product, related_name="order_products", on_delete=models.PROTECT) + order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items") + product = models.ForeignKey(Product, on_delete=models.PROTECT) price = MoneyField(max_digits=16, decimal_places=2) vat = MoneyField(max_digits=16, decimal_places=2) + quantity = models.PositiveSmallIntegerField(default=1) + + class Meta: + verbose_name = _("ordered product") + verbose_name_plural = _("ordered products") def __str__(self) -> str: return f"{self.product.name}" + @property + def total_with_vat(self) -> Money: + """Total price of this item.""" + return (self.price + self.vat) * self.quantity + class Payment(CreatedModifiedAbstract): """A payment is a transaction that is made to pay for an order.""" diff --git a/src/accounting/templates/accounting/order/detail.html b/src/accounting/templates/accounting/order/detail.html index 00c5989..cae290b 100644 --- a/src/accounting/templates/accounting/order/detail.html +++ b/src/accounting/templates/accounting/order/detail.html @@ -9,5 +9,40 @@

Order: {{ order.id }}

+ +

+ {% trans "Ordered" %}: {{ order.created }}
+ {% trans "Status" %}: {{ order.is_paid|yesno:_("paid,unpaid") }} +

+ + + + + + + + + + + + + {% for item in order.items.all %} + + + + + + + + {% endfor %} + +
{% trans "Item" %}{% trans "Quantity" %}{% trans "Price" %}{% trans "VAT" %}{% trans "Total" %}
{{ item.product.name }}{{ item.quantity }}{{ item.price }}{{ item.vat }}{{ item.total_with_vat }}
+ +

{% trans "Total price" %}: {{ order.total_with_vat }}

+ +

+ {% trans "Pay now" %} +

+
{% endblock %} diff --git a/src/membership/migrations/0008_alter_membership_user.py b/src/membership/migrations/0008_alter_membership_user.py new file mode 100644 index 0000000..d7f13ac --- /dev/null +++ b/src/membership/migrations/0008_alter_membership_user.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1b1 on 2024-07-28 21:20 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('membership', '0007_membership_activated_membership_activated_on_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='membership', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to=settings.AUTH_USER_MODEL), + ), + ] -- 2.43.4 From 78b3264bb6dd15846c32fb5d3dc77a259582ff99 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Tue, 30 Jul 2024 00:24:16 +0200 Subject: [PATCH 17/28] Add Stripe to requirements --- Makefile | 4 +++ pyproject.toml | 5 +-- requirements.txt | 57 ++++++++++++++++--------------- requirements/requirements-dev.txt | 22 +++++++++--- 4 files changed, 55 insertions(+), 33 deletions(-) diff --git a/Makefile b/Makefile index 651ed32..49faf48 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,4 @@ +.PHONY: run pre_commit_install pre_commit_run_all makemigrations migrate createsuperuser shell manage_command build requirements DOCKER_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u` MANAGE_EXEC = python /app/src/manage.py @@ -29,3 +30,6 @@ manage_command: build: ${DOCKER_COMPOSE} build + +requirements: + hatch run dev:requirements diff --git a/pyproject.toml b/pyproject.toml index a6dc9c6..cd0f7fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,8 @@ dependencies = [ "django-registries==0.0.3", "django-view-decorator==0.0.4", "django-oauth-toolkit==2.4.0", - "django_stubs_ext", + "django_stubs_ext~=5.0", + "stripe~=10.5", ] version = "0.0.1" @@ -67,7 +68,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 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 0e7d0ea..5a684f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,9 @@ # -# This file is autogenerated by hatch-pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: # -# - django-allauth==0.63.6 -# - django-money==3.5.2 -# - django-oauth-toolkit==2.4.0 -# - django-registries==0.0.3 -# - django-view-decorator==0.0.4 -# - django-zen-queries==2.1.0 -# - django<5.2,>=5.1b1 -# - environs[django]==11.0.0 -# - psycopg[binary]==3.2.1 -# - uvicorn==0.30.1 -# - whitenoise==6.7.0 +# pip-compile pyproject.toml # - asgiref==3.8.1 # via django babel==2.15.0 @@ -34,30 +24,35 @@ dj-email-url==1.0.6 # via environs django==5.1b1 # 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 + # membersystem (pyproject.toml) django-allauth==0.63.6 - # via hatch.envs.default + # via membersystem (pyproject.toml) django-cache-url==3.4.5 # via environs django-money==3.5.2 - # via hatch.envs.default + # via membersystem (pyproject.toml) django-oauth-toolkit==2.4.0 - # via hatch.envs.default + # via membersystem (pyproject.toml) django-registries==0.0.3 - # via hatch.envs.default + # via membersystem (pyproject.toml) +django-stubs-ext==5.0.4 + # via membersystem (pyproject.toml) django-view-decorator==0.0.4 - # via hatch.envs.default + # via membersystem (pyproject.toml) django-zen-queries==2.1.0 - # via hatch.envs.default -environs==11.0.0 - # via hatch.envs.default + # via membersystem (pyproject.toml) +environs[django]==11.0.0 + # via + # environs + # membersystem (pyproject.toml) h11==0.14.0 # via uvicorn idna==3.7 @@ -70,8 +65,10 @@ 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 + # membersystem (pyproject.toml) + # psycopg psycopg-binary==3.2.1 # via psycopg py-moneyed==3.0 @@ -83,21 +80,27 @@ python-dotenv==1.0.1 pytz==2024.1 # via django-oauth-toolkit requests==2.32.3 - # via django-oauth-toolkit + # via + # django-oauth-toolkit + # stripe sqlparse==0.5.1 # via django +stripe==10.5.0 + # via membersystem (pyproject.toml) typing-extensions==4.12.2 # via # dj-database-url + # django-stubs-ext # jwcrypto # psycopg # py-moneyed + # stripe urllib3==2.2.2 # via requests uvicorn==0.30.1 - # via hatch.envs.default + # via membersystem (pyproject.toml) whitenoise==6.7.0 - # via hatch.envs.default + # via membersystem (pyproject.toml) # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index 3d132d5..2acde9b 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -15,11 +15,13 @@ # - django-money==3.5.2 # - django-oauth-toolkit==2.4.0 # - django-registries==0.0.3 +# - django-stubs-ext~=5.0 # - django-view-decorator==0.0.4 # - django-zen-queries==2.1.0 # - django<5.2,>=5.1b1 # - environs[django]==11.0.0 # - psycopg[binary]==3.2.1 +# - stripe~=10.5 # - uvicorn==0.30.1 # - whitenoise==6.7.0 # @@ -45,6 +47,7 @@ click==8.1.7 coverage==7.3.0 # via # hatch.envs.dev + # coverage # pytest-cov cryptography==42.0.8 # via jwcrypto @@ -84,13 +87,17 @@ django-registries==0.0.3 django-stubs==1.16.0 # via hatch.envs.dev django-stubs-ext==5.0.2 - # via django-stubs + # via + # hatch.envs.dev + # django-stubs django-view-decorator==0.0.4 # via hatch.envs.dev django-zen-queries==2.1.0 # via hatch.envs.dev environs==11.0.0 - # via hatch.envs.dev + # via + # hatch.envs.dev + # environs h11==0.14.0 # via uvicorn idna==3.7 @@ -121,7 +128,9 @@ pip-tools==7.3.0 pluggy==1.5.0 # via pytest psycopg==3.2.1 - # via hatch.envs.dev + # via + # hatch.envs.dev + # psycopg psycopg-binary==3.2.1 # via psycopg py-moneyed==3.0 @@ -144,11 +153,15 @@ python-dotenv==1.0.1 pytz==2024.1 # via django-oauth-toolkit requests==2.32.3 - # via django-oauth-toolkit + # via + # django-oauth-toolkit + # stripe sqlparse==0.5.1 # via # django # django-debug-toolbar +stripe==10.5.0 + # via hatch.envs.dev tomli==2.0.1 # via django-stubs types-pytz==2024.1.0.20240417 @@ -164,6 +177,7 @@ typing-extensions==4.12.2 # mypy # psycopg # py-moneyed + # stripe urllib3==2.2.2 # via requests uvicorn==0.30.1 -- 2.43.4 From 1029162a62e2c257fb2e311da8a2c459cde8f6e9 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Tue, 30 Jul 2024 15:21:47 +0200 Subject: [PATCH 18/28] View for Stripe checkout --- .env.example | 1 + src/accounting/views.py | 59 ++++++++++++++++++++++++++++++++++------- src/project/settings.py | 2 ++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 9da2eda..3b6b211 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,4 @@ DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres # Use something along the the following if you are not using docker # DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem DEBUG=True +STRIPE_API_KEY=sk_test_51Pi1452K58NJXwrP3ayQi0LmGCjiVPUdVFnPOT4didTslRQpmGLSxiuYsmwoTJSLDny5I4uIPcpL2fwpG7GvlTbH00mewgemdz diff --git a/src/accounting/views.py b/src/accounting/views.py index c31c5d9..99ab1c7 100644 --- a/src/accounting/views.py +++ b/src/accounting/views.py @@ -1,21 +1,21 @@ """Views for the membership app.""" -from __future__ import annotations - -from typing import TYPE_CHECKING - +import stripe +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.mail import send_mail +from django.http import HttpRequest +from django.http import HttpResponse +from django.shortcuts import redirect from django.shortcuts import render from django_view_decorator import namespaced_decorator_factory from . import models -if TYPE_CHECKING: - from django.http import HttpRequest - from django.http import HttpResponse - - order_view = namespaced_decorator_factory(namespace="order", base_path="order") +stripe.api_key = settings.STRIPE_API_KEY + @order_view( paths="/", @@ -36,3 +36,44 @@ def order_detail(request: HttpRequest, order_id: int) -> HttpResponse: template_name="accounting/order/detail.html", context=context, ) + + +@order_view( + paths="/pay/", + name="pay", + login_required=True, +) +def order_pay(request: HttpRequest, order_id: int) -> HttpResponse: + """Create a Stripe session and redirects to Stripe Checkout.""" + user = request.user # People just need to login to pay something, not necessarily be a member + order = models.Order.objects.get(pk=order_id, member=user) + current_site = Site.objects.get_current(request) + base_domain = f"https://{current_site.domain}" + + try: + line_items = [] + for item in order.items.all(): + line_items.append( # noqa: PERF401 + { + "price_data": { + "currency": item.total_with_vat.currency, + "unit_amount": (item.price, +item.vat).amount, + "product_data": { + "name": item.product.name, + }, + }, + "quantity": item.quantity, + } + ) + checkout_session = stripe.checkout.Session.create( + line_items=line_items, + mode="payment", + success_url=base_domain + "/success", + cancel_url=base_domain + "/cancel", + ) + except Exception as e: + send_mail("Error in checkout", str(e), settings.DEFAULT_FROM_EMAIL, settings.ADMINS) + raise + + # TODO: Redirect with status=303 + return redirect(checkout_session.url) diff --git a/src/project/settings.py b/src/project/settings.py index 21018c0..25e74bf 100644 --- a/src/project/settings.py +++ b/src/project/settings.py @@ -28,6 +28,8 @@ CSRF_TRUSTED_ORIGINS = env.list( ADMINS = [tuple(x.split(":")) for x in env.list("DJANGO_ADMINS", default=[])] +DEFAULT_FROM_EMAIL = "server@data.coop" + DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Application definition -- 2.43.4 From 9a61c237c77e9394083aea8b5cff7cb6deda4d9c Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Wed, 31 Jul 2024 01:02:38 +0200 Subject: [PATCH 19/28] WIP: Handle Stripe webhook and add success/cancel pages --- .env.example | 1 + .../templates/accounting/order/cancel.html | 18 ++++ .../templates/accounting/order/detail.html | 2 +- .../templates/accounting/order/success.html | 20 ++++ src/accounting/views.py | 91 ++++++++++++++++++- src/project/settings.py | 2 + 6 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 src/accounting/templates/accounting/order/cancel.html create mode 100644 src/accounting/templates/accounting/order/success.html diff --git a/.env.example b/.env.example index 3b6b211..f0d9ade 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,4 @@ DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres # DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem DEBUG=True STRIPE_API_KEY=sk_test_51Pi1452K58NJXwrP3ayQi0LmGCjiVPUdVFnPOT4didTslRQpmGLSxiuYsmwoTJSLDny5I4uIPcpL2fwpG7GvlTbH00mewgemdz +STRIPE_ENDPOINT_SECRET=whsec_ diff --git a/src/accounting/templates/accounting/order/cancel.html b/src/accounting/templates/accounting/order/cancel.html new file mode 100644 index 0000000..bc73b69 --- /dev/null +++ b/src/accounting/templates/accounting/order/cancel.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block head_title %} + {% trans "Payment cancelled" %} +{% endblock %} + +{% block content %} + +
+

{% trans "Payment canceled" %}

+ +

+ {% trans "Return to order page" %} +

+ +
+{% endblock %} diff --git a/src/accounting/templates/accounting/order/detail.html b/src/accounting/templates/accounting/order/detail.html index cae290b..4fec0ed 100644 --- a/src/accounting/templates/accounting/order/detail.html +++ b/src/accounting/templates/accounting/order/detail.html @@ -41,7 +41,7 @@

{% trans "Total price" %}: {{ order.total_with_vat }}

- {% trans "Pay now" %} + {% trans "Pay now" %}

diff --git a/src/accounting/templates/accounting/order/success.html b/src/accounting/templates/accounting/order/success.html new file mode 100644 index 0000000..c6c04d4 --- /dev/null +++ b/src/accounting/templates/accounting/order/success.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block head_title %} + {% trans "Payment received" %} +{% endblock %} + +{% block content %} + +
+

{% trans "Payment received" %}

+ +

+ {% blocktrans trimmed with order.id as order_id %} + We received your payment for Order {{ order_id }}. + {% endblocktrans %} +

+ +
+{% endblock %} diff --git a/src/accounting/views.py b/src/accounting/views.py index 99ab1c7..bf02c65 100644 --- a/src/accounting/views.py +++ b/src/accounting/views.py @@ -4,10 +4,13 @@ import stripe from django.conf import settings from django.contrib.sites.models import Site from django.core.mail import send_mail +from django.db import transaction from django.http import HttpRequest from django.http import HttpResponse from django.shortcuts import redirect from django.shortcuts import render +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt from django_view_decorator import namespaced_decorator_factory from . import models @@ -57,7 +60,7 @@ def order_pay(request: HttpRequest, order_id: int) -> HttpResponse: { "price_data": { "currency": item.total_with_vat.currency, - "unit_amount": (item.price, +item.vat).amount, + "unit_amount": int((item.price + item.vat).amount * 100), "product_data": { "name": item.product.name, }, @@ -68,7 +71,8 @@ def order_pay(request: HttpRequest, order_id: int) -> HttpResponse: checkout_session = stripe.checkout.Session.create( line_items=line_items, mode="payment", - success_url=base_domain + "/success", + success_url=base_domain + + reverse("order:success", kwargs={"order_id": order.id, "stripe_session_id": "{CHECKOUT_SESSION_ID}"}), cancel_url=base_domain + "/cancel", ) except Exception as e: @@ -77,3 +81,86 @@ def order_pay(request: HttpRequest, order_id: int) -> HttpResponse: # TODO: Redirect with status=303 return redirect(checkout_session.url) + + +@transaction.atomic +@order_view( + paths="/pay/success//", + name="success", + login_required=True, +) +def success(request: HttpRequest, order_id: int, stripe_session_id: str) -> HttpResponse: + """Create a Stripe session and redirects to Stripe Checkout. + + From Stripe docs: When you have a webhook endpoint set up to listen for checkout.session.completed events and + you set a success_url, Checkout waits for your server to respond to the webhook event delivery before redirecting + your customer. If you use this approach, make sure your server responds to checkout.session.completed events as + quickly as possible. + """ + user = request.user # People just need to login to pay something, not necessarily be a member + order = models.Order.objects.get(pk=order_id, member=user) + + bool(stripe_session_id) + + context = { + "order": order, + } + + return render( + request=request, + template_name="accounting/order/success.html", + context=context, + ) + + +@transaction.atomic +@order_view( + paths="/pay/cancel/", + name="cancel", + login_required=True, +) +def cancel(request: HttpRequest, order_id: int) -> HttpResponse: + """Page to display when a payment is canceled.""" + user = request.user # People just need to login to pay something, not necessarily be a member + order = models.Order.objects.get(pk=order_id, member=user) + + context = { + "order": order, + } + + return render( + request=request, + template_name="accounting/order/cancel.html", + context=context, + ) + + +@csrf_exempt +@order_view( + paths="stripe/webhook/", + name="webhook", + login_required=True, +) +def stripe_webhook(request: HttpRequest) -> HttpResponse: + """Handle Stripe webhook. + + https://docs.stripe.com/metadata/use-cases + """ + payload = request.body + sig_header = request.headers["stripe-signature"] + event = None + + try: + event = stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_ENDPOINT_SECRET) + except ValueError: + # Invalid payload + return HttpResponse(status=400) + except stripe.error.SignatureVerificationError: + # Invalid signature + return HttpResponse(status=400) + + if event["type"] == "checkout.session.completed" or event["type"] == "checkout.session.async_payment_succeeded": + # TODO: Implement the payment + pass + + return HttpResponse(status=200) diff --git a/src/project/settings.py b/src/project/settings.py index 25e74bf..f88fb56 100644 --- a/src/project/settings.py +++ b/src/project/settings.py @@ -180,6 +180,8 @@ LOGGING = { }, } +STRIPE_API_KEY = env.str("STRIPE_API_KEY") +STRIPE_ENDPOINT_SECRET = env.str("STRIPE_ENDPOINT_SECRET") if DEBUG: INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"] -- 2.43.4 From 298a453e192f03d90935a7733a57458bc91955b2 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Wed, 31 Jul 2024 21:35:04 +0200 Subject: [PATCH 20/28] Remove stripe session from success page (doesnt somehow work according to their docs hmm) --- src/accounting/views.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/accounting/views.py b/src/accounting/views.py index bf02c65..05b745f 100644 --- a/src/accounting/views.py +++ b/src/accounting/views.py @@ -71,8 +71,7 @@ def order_pay(request: HttpRequest, order_id: int) -> HttpResponse: checkout_session = stripe.checkout.Session.create( line_items=line_items, mode="payment", - success_url=base_domain - + reverse("order:success", kwargs={"order_id": order.id, "stripe_session_id": "{CHECKOUT_SESSION_ID}"}), + success_url=base_domain + reverse("order:success", kwargs={"order_id": order.id}), cancel_url=base_domain + "/cancel", ) except Exception as e: @@ -85,11 +84,11 @@ def order_pay(request: HttpRequest, order_id: int) -> HttpResponse: @transaction.atomic @order_view( - paths="/pay/success//", + paths="/pay/success/", name="success", login_required=True, ) -def success(request: HttpRequest, order_id: int, stripe_session_id: str) -> HttpResponse: +def success(request: HttpRequest, order_id: int) -> HttpResponse: """Create a Stripe session and redirects to Stripe Checkout. From Stripe docs: When you have a webhook endpoint set up to listen for checkout.session.completed events and @@ -100,8 +99,6 @@ def success(request: HttpRequest, order_id: int, stripe_session_id: str) -> Http user = request.user # People just need to login to pay something, not necessarily be a member order = models.Order.objects.get(pk=order_id, member=user) - bool(stripe_session_id) - context = { "order": order, } -- 2.43.4 From 44c0156890a9f2db984a3a58b2e160b9766da036 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Thu, 1 Aug 2024 00:21:42 +0200 Subject: [PATCH 21/28] Functional webhook, clean up some model stuff, need to update migrations --- pyproject.toml | 2 ++ ...010_alter_orderproduct_options_and_more.py | 21 ++++++++++++++++++ src/accounting/models.py | 2 -- src/accounting/signals.py | 2 +- .../templates/accounting/order/detail.html | 3 ++- src/accounting/views.py | 22 +++++++++++++++---- src/membership/admin.py | 2 +- 7 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 src/accounting/migrations/0010_alter_orderproduct_options_and_more.py diff --git a/pyproject.toml b/pyproject.toml index cd0f7fd..575383b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,8 @@ migrate = "./src/manage.py migrate" makemigrations = "./src/manage.py makemigrations" createsuperuser = "./src/manage.py createsuperuser" shell = "./src/manage.py shell" +# You need to install Stripe CLI from here to run this: https://github.com/stripe/stripe-cli/releases +stripe_cli = "stripe listen --forward-to 0.0.0.0:8000/order/stripe/webhook/" [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE="tests.settings" diff --git a/src/accounting/migrations/0010_alter_orderproduct_options_and_more.py b/src/accounting/migrations/0010_alter_orderproduct_options_and_more.py new file mode 100644 index 0000000..4a710b3 --- /dev/null +++ b/src/accounting/migrations/0010_alter_orderproduct_options_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1b1 on 2024-07-31 22:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0009_alter_orderproduct_order_alter_orderproduct_product'), + ] + + operations = [ + migrations.AlterModelOptions( + name='orderproduct', + options={'verbose_name': 'ordered product', 'verbose_name_plural': 'ordered products'}, + ), + migrations.RemoveField( + model_name='payment', + name='stripe_charge_id', + ), + ] diff --git a/src/accounting/models.py b/src/accounting/models.py index 0aa75cc..d7f7315 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -163,8 +163,6 @@ class Payment(CreatedModifiedAbstract): payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT) external_transaction_id = models.CharField(max_length=255, default="", blank=True) - stripe_charge_id = models.CharField(max_length=255, default="", blank=True) - class Meta: verbose_name = _("payment") verbose_name_plural = _("payments") diff --git a/src/accounting/signals.py b/src/accounting/signals.py index 4e90656..f33263d 100644 --- a/src/accounting/signals.py +++ b/src/accounting/signals.py @@ -30,7 +30,7 @@ def mark_order_paid(sender: models.Payment, instance: models.Payment, **kwargs: instance.order.save() -@receiver(post_save, sender=models.Payment) +@receiver(post_save, sender=models.Order) def activate_membership(sender: models.Order, instance: models.Order, **kwargs: dict) -> None: # noqa: ARG001 """Mark a membership as activated when its order is marked as paid.""" if instance.is_paid: diff --git a/src/accounting/templates/accounting/order/detail.html b/src/accounting/templates/accounting/order/detail.html index 4fec0ed..a3a0b0f 100644 --- a/src/accounting/templates/accounting/order/detail.html +++ b/src/accounting/templates/accounting/order/detail.html @@ -40,9 +40,10 @@

{% trans "Total price" %}: {{ order.total_with_vat }}

+ {% if not order.is_paid %}

{% trans "Pay now" %}

- + {% endif %} {% endblock %} diff --git a/src/accounting/views.py b/src/accounting/views.py index 05b745f..acfc077 100644 --- a/src/accounting/views.py +++ b/src/accounting/views.py @@ -7,11 +7,13 @@ from django.core.mail import send_mail from django.db import transaction from django.http import HttpRequest from django.http import HttpResponse +from django.shortcuts import get_object_or_404 from django.shortcuts import redirect from django.shortcuts import render from django.urls import reverse from django.views.decorators.csrf import csrf_exempt from django_view_decorator import namespaced_decorator_factory +from djmoney.money import Money from . import models @@ -52,6 +54,8 @@ def order_pay(request: HttpRequest, order_id: int) -> HttpResponse: order = models.Order.objects.get(pk=order_id, member=user) current_site = Site.objects.get_current(request) base_domain = f"https://{current_site.domain}" + if settings.DEBUG: + f"http://{current_site.domain}" try: line_items = [] @@ -70,6 +74,7 @@ def order_pay(request: HttpRequest, order_id: int) -> HttpResponse: ) checkout_session = stripe.checkout.Session.create( line_items=line_items, + metadata={"order_id": order.id}, mode="payment", success_url=base_domain + reverse("order:success", kwargs={"order_id": order.id}), cancel_url=base_domain + "/cancel", @@ -132,12 +137,12 @@ def cancel(request: HttpRequest, order_id: int) -> HttpResponse: ) -@csrf_exempt +@transaction.atomic @order_view( paths="stripe/webhook/", name="webhook", - login_required=True, ) +@csrf_exempt def stripe_webhook(request: HttpRequest) -> HttpResponse: """Handle Stripe webhook. @@ -157,7 +162,16 @@ def stripe_webhook(request: HttpRequest) -> HttpResponse: return HttpResponse(status=400) if event["type"] == "checkout.session.completed" or event["type"] == "checkout.session.async_payment_succeeded": - # TODO: Implement the payment - pass + # Order is marked paid via signals, Membership is activated via signals. + order_id = event["data"]["object"]["metadata"]["order_id"] + order = get_object_or_404(models.Order, pk=order_id) + if not models.Payment.objects.filter(order=order).exists(): + models.Payment.objects.create( + order=order, + amount=Money(event["data"]["object"]["amount_total"], event["data"]["object"]["currency"]), + description="Paid via Stripe", + payment_type=models.PaymentType.objects.get_or_create(name="Stripe")[0], + external_transaction_id=event["id"], + ) return HttpResponse(status=200) diff --git a/src/membership/admin.py b/src/membership/admin.py index 823d911..33c5d9d 100644 --- a/src/membership/admin.py +++ b/src/membership/admin.py @@ -66,7 +66,7 @@ def ensure_membership_type_exists( # Get the default account of the member. We don't really know what to do if a person owns multiple accounts. account, __ = Account.objects.get_or_create(owner=member) # Create an Order for the products in the membership - order = Order.objects.create(member=member, account=account) + order = Order.objects.create(member=member, account=account, description=membership_type.name) # Add stuff to the order for product in membership_type.products.all(): OrderProduct.objects.create(order=order, product=product, price=product.price, vat=product.vat) -- 2.43.4 From 0a74f626274f9dd11e157ca8a30a4d353062f065 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Thu, 1 Aug 2024 12:51:21 +0200 Subject: [PATCH 22/28] Reduce amount of migrations --- ...tions_rename_user_order_member_and_more.py | 42 +++++++++++++++++++ .../0007_rename_user_order_member.py | 18 -------- ...erproduct_options_orderproduct_quantity.py | 22 ---------- ...roduct_order_alter_orderproduct_product.py | 24 ----------- ...010_alter_orderproduct_options_and_more.py | 21 ---------- ...ivated_membership_activated_on_and_more.py | 11 ++++- .../migrations/0008_alter_membership_user.py | 21 ---------- 7 files changed, 51 insertions(+), 108 deletions(-) create mode 100644 src/accounting/migrations/0007_alter_orderproduct_options_rename_user_order_member_and_more.py delete mode 100644 src/accounting/migrations/0007_rename_user_order_member.py delete mode 100644 src/accounting/migrations/0008_alter_orderproduct_options_orderproduct_quantity.py delete mode 100644 src/accounting/migrations/0009_alter_orderproduct_order_alter_orderproduct_product.py delete mode 100644 src/accounting/migrations/0010_alter_orderproduct_options_and_more.py delete mode 100644 src/membership/migrations/0008_alter_membership_user.py diff --git a/src/accounting/migrations/0007_alter_orderproduct_options_rename_user_order_member_and_more.py b/src/accounting/migrations/0007_alter_orderproduct_options_rename_user_order_member_and_more.py new file mode 100644 index 0000000..c60aba3 --- /dev/null +++ b/src/accounting/migrations/0007_alter_orderproduct_options_rename_user_order_member_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1b1 on 2024-08-01 10:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0006_alter_account_owner_alter_order_user'), + ] + + operations = [ + migrations.AlterModelOptions( + name='orderproduct', + options={'verbose_name': 'ordered product', 'verbose_name_plural': 'ordered products'}, + ), + migrations.RenameField( + model_name='order', + old_name='user', + new_name='member', + ), + migrations.RemoveField( + model_name='payment', + name='stripe_charge_id', + ), + migrations.AddField( + model_name='orderproduct', + name='quantity', + field=models.PositiveSmallIntegerField(default=1), + ), + migrations.AlterField( + model_name='orderproduct', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='accounting.order'), + ), + migrations.AlterField( + model_name='orderproduct', + name='product', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='accounting.product'), + ), + ] diff --git a/src/accounting/migrations/0007_rename_user_order_member.py b/src/accounting/migrations/0007_rename_user_order_member.py deleted file mode 100644 index 9108b34..0000000 --- a/src/accounting/migrations/0007_rename_user_order_member.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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/migrations/0008_alter_orderproduct_options_orderproduct_quantity.py b/src/accounting/migrations/0008_alter_orderproduct_options_orderproduct_quantity.py deleted file mode 100644 index 51a56e0..0000000 --- a/src/accounting/migrations/0008_alter_orderproduct_options_orderproduct_quantity.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.1b1 on 2024-07-28 21:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounting', '0007_rename_user_order_member'), - ] - - operations = [ - migrations.AlterModelOptions( - name='orderproduct', - options={'verbose_name': 'ordered products'}, - ), - migrations.AddField( - model_name='orderproduct', - name='quantity', - field=models.PositiveSmallIntegerField(default=1), - ), - ] diff --git a/src/accounting/migrations/0009_alter_orderproduct_order_alter_orderproduct_product.py b/src/accounting/migrations/0009_alter_orderproduct_order_alter_orderproduct_product.py deleted file mode 100644 index ac36b45..0000000 --- a/src/accounting/migrations/0009_alter_orderproduct_order_alter_orderproduct_product.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1b1 on 2024-07-28 21:27 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounting', '0008_alter_orderproduct_options_orderproduct_quantity'), - ] - - operations = [ - migrations.AlterField( - model_name='orderproduct', - name='order', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='accounting.order'), - ), - migrations.AlterField( - model_name='orderproduct', - name='product', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='accounting.product'), - ), - ] diff --git a/src/accounting/migrations/0010_alter_orderproduct_options_and_more.py b/src/accounting/migrations/0010_alter_orderproduct_options_and_more.py deleted file mode 100644 index 4a710b3..0000000 --- a/src/accounting/migrations/0010_alter_orderproduct_options_and_more.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1b1 on 2024-07-31 22:02 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounting', '0009_alter_orderproduct_order_alter_orderproduct_product'), - ] - - operations = [ - migrations.AlterModelOptions( - name='orderproduct', - options={'verbose_name': 'ordered product', 'verbose_name_plural': 'ordered products'}, - ), - migrations.RemoveField( - model_name='payment', - name='stripe_charge_id', - ), - ] diff --git a/src/membership/migrations/0007_membership_activated_membership_activated_on_and_more.py b/src/membership/migrations/0007_membership_activated_membership_activated_on_and_more.py index 0c7c57d..cedb798 100644 --- a/src/membership/migrations/0007_membership_activated_membership_activated_on_and_more.py +++ b/src/membership/migrations/0007_membership_activated_membership_activated_on_and_more.py @@ -1,14 +1,16 @@ -# Generated by Django 5.1b1 on 2024-07-25 22:47 +# Generated by Django 5.1b1 on 2024-08-01 10:50 import django.db.models.deletion +from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('accounting', '0007_rename_user_order_member'), + ('accounting', '0007_alter_orderproduct_options_rename_user_order_member_and_more'), ('membership', '0006_waitinglistentry_alter_membership_options'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -52,4 +54,9 @@ class Migration(migrations.Migration): name='products', field=models.ManyToManyField(to='accounting.product'), ), + migrations.AlterField( + model_name='membership', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to=settings.AUTH_USER_MODEL), + ), ] diff --git a/src/membership/migrations/0008_alter_membership_user.py b/src/membership/migrations/0008_alter_membership_user.py deleted file mode 100644 index d7f13ac..0000000 --- a/src/membership/migrations/0008_alter_membership_user.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1b1 on 2024-07-28 21:20 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('membership', '0007_membership_activated_membership_activated_on_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='membership', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to=settings.AUTH_USER_MODEL), - ), - ] -- 2.43.4 From b9deb3f54ef3112f8482510d330ffd222e7f2e8f Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Thu, 1 Aug 2024 12:52:14 +0200 Subject: [PATCH 23/28] Example API key doesn't need to look like a secret --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index f0d9ade..404c3b3 100644 --- a/.env.example +++ b/.env.example @@ -6,5 +6,5 @@ DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres # Use something along the the following if you are not using docker # DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem DEBUG=True -STRIPE_API_KEY=sk_test_51Pi1452K58NJXwrP3ayQi0LmGCjiVPUdVFnPOT4didTslRQpmGLSxiuYsmwoTJSLDny5I4uIPcpL2fwpG7GvlTbH00mewgemdz +STRIPE_API_KEY=sk_test_ STRIPE_ENDPOINT_SECRET=whsec_ -- 2.43.4 From 37719191b70de2b76beaa0cc87c61ec85eefddde Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Thu, 1 Aug 2024 12:57:28 +0200 Subject: [PATCH 24/28] Clean up README --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index be00381..7a0256d 100644 --- a/README.md +++ b/README.md @@ -85,16 +85,16 @@ make makemigrations hatch run dev:server ``` -#### Upgrade requirements +### Updating requirements -We use hatch-pip-compile. +We use hatch-pip-compile. That means we have a set of loosely defined `dependencies` in `pyproject.toml` and then we can keep the exactly pinned version in our `requirements.txt` (auto-generated). + +To generate `requirements.txt` and `requirements/requirements-dev.txt`, run the following command: ```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 +# Build requirements.txt etc +make requirements + +# Build Docker image with new Python requirements +make build ``` -- 2.43.4 From 037c20441111e40ec9b954ba9b1a9485ed9903b3 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Thu, 1 Aug 2024 13:04:31 +0200 Subject: [PATCH 25/28] Re-instate updated pip-compile command, regenerate requirements*.txt --- pyproject.toml | 2 +- requirements.txt | 54 ++++++++++++++++++------------- requirements/requirements-dev.txt | 4 +-- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 58bd69e..49f2fb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,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 pyproject.toml" +requirements = "hatch env run --env default -- python --version; hatch env run --env dev -- python --version" 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 63b460a..64b1651 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,21 @@ # -# This file is autogenerated by pip-compile with Python 3.12 -# by the following command: +# This file is autogenerated by hatch-pip-compile with Python 3.12 # -# pip-compile pyproject.toml +# - django-allauth~=0.63 +# - django-money~=3.5 +# - django-oauth-toolkit~=2.4 +# - django-registries==0.0.3 +# - django-stubs-ext~=5.0 +# - django-view-decorator==0.0.4 +# - django-zen-queries~=2.1 +# - django<5.2,>=5.1b1 +# - environs[django]<12,>=11 +# - psycopg[binary]~=3.2 +# - stripe~=10.5 +# - uvicorn~=0.30 +# - whitenoise~=6.7 # + asgiref==3.8.1 # via django babel==2.15.0 @@ -22,8 +34,9 @@ dj-database-url==2.2.0 # via environs dj-email-url==1.0.6 # via environs -django==5.1b1 +django==5.1rc1 # via + # hatch.envs.default # dj-database-url # django-allauth # django-money @@ -32,27 +45,24 @@ django==5.1b1 # django-stubs-ext # django-view-decorator # django-zen-queries - # membersystem (pyproject.toml) django-allauth==0.63.6 - # via membersystem (pyproject.toml) + # via hatch.envs.default django-cache-url==3.4.5 # via environs django-money==3.5.2 - # via membersystem (pyproject.toml) + # via hatch.envs.default django-oauth-toolkit==2.4.0 - # via membersystem (pyproject.toml) + # via hatch.envs.default django-registries==0.0.3 - # via membersystem (pyproject.toml) + # via hatch.envs.default django-stubs-ext==5.0.4 - # via membersystem (pyproject.toml) + # via hatch.envs.default django-view-decorator==0.0.4 - # via membersystem (pyproject.toml) + # via hatch.envs.default django-zen-queries==2.1.0 - # via membersystem (pyproject.toml) -environs[django]==11.0.0 - # via - # environs - # membersystem (pyproject.toml) + # via hatch.envs.default +environs==11.0.0 + # via hatch.envs.default h11==0.14.0 # via uvicorn idna==3.7 @@ -65,10 +75,8 @@ oauthlib==3.2.2 # via django-oauth-toolkit packaging==24.1 # via marshmallow -psycopg[binary]==3.2.1 - # via - # membersystem (pyproject.toml) - # psycopg +psycopg==3.2.1 + # via hatch.envs.default psycopg-binary==3.2.1 # via psycopg py-moneyed==3.0 @@ -86,7 +94,7 @@ requests==2.32.3 sqlparse==0.5.1 # via django stripe==10.5.0 - # via membersystem (pyproject.toml) + # via hatch.envs.default typing-extensions==4.12.2 # via # dj-database-url @@ -98,9 +106,9 @@ typing-extensions==4.12.2 urllib3==2.2.2 # via requests uvicorn==0.30.4 - # via membersystem (pyproject.toml) + # via hatch.envs.default whitenoise==6.7.0 - # via membersystem (pyproject.toml) + # via hatch.envs.default # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index 7d83b17..55b03b8 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -55,7 +55,7 @@ dj-database-url==2.2.0 # via environs dj-email-url==1.0.6 # via environs -django==5.1b1 +django==5.1rc1 # via # hatch.envs.dev # dj-database-url @@ -180,7 +180,7 @@ typing-extensions==4.12.2 # stripe urllib3==2.2.2 # via requests -uvicorn==0.30.3 +uvicorn==0.30.4 # via hatch.envs.dev wheel==0.43.0 # via pip-tools -- 2.43.4 From ba55d18e5be29b541f0913ab4e57fb689560f037 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Thu, 1 Aug 2024 16:19:51 +0200 Subject: [PATCH 26/28] Remove duped definition of DEFAULT_FROM_EMAIL --- src/project/settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/project/settings.py b/src/project/settings.py index f88fb56..2f78775 100644 --- a/src/project/settings.py +++ b/src/project/settings.py @@ -28,8 +28,6 @@ CSRF_TRUSTED_ORIGINS = env.list( ADMINS = [tuple(x.split(":")) for x in env.list("DJANGO_ADMINS", default=[])] -DEFAULT_FROM_EMAIL = "server@data.coop" - DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Application definition -- 2.43.4 From 5a8e036dd1cbc641dc86c097b161b2fd091ace71 Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Thu, 1 Aug 2024 17:06:04 +0200 Subject: [PATCH 27/28] Update success message --- src/accounting/templates/accounting/order/success.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/accounting/templates/accounting/order/success.html b/src/accounting/templates/accounting/order/success.html index c6c04d4..8ebce6d 100644 --- a/src/accounting/templates/accounting/order/success.html +++ b/src/accounting/templates/accounting/order/success.html @@ -12,7 +12,7 @@

{% blocktrans trimmed with order.id as order_id %} - We received your payment for Order {{ order_id }}. + Thanks fellow member! We received your payment for Order {{ order_id }}. We're adding more features to the site, so expect to see a confirmation email (receipt) for the order soon. {% endblocktrans %}

-- 2.43.4 From c809e5652cbcb6a50ad315b9af68f1aae05fb37d Mon Sep 17 00:00:00 2001 From: valberg Date: Fri, 2 Aug 2024 22:59:51 +0000 Subject: [PATCH 28/28] Update Makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index bd4283a..bcf05e5 100644 --- a/Makefile +++ b/Makefile @@ -26,4 +26,4 @@ build: ${DOCKER_COMPOSE} build requirements: - hatch run dev:requirements + hatch run requirements -- 2.43.4