Compare commits

..

4 commits

56 changed files with 217 additions and 1975 deletions

View file

@ -6,5 +6,3 @@ DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres
# Use something along the the following if you are not using docker # Use something along the the following if you are not using docker
# DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem # DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem
DEBUG=True DEBUG=True
STRIPE_API_KEY=sk_test_
STRIPE_ENDPOINT_SECRET=whsec_

4
.gitignore vendored
View file

@ -8,7 +8,3 @@ db.sqlite3
.env .env
venv/ venv/
.venv/ .venv/
# collectstatic
src/static/

View file

@ -1,4 +1,4 @@
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bullseye
# PYTHONFAULTHANDLER: Propagate tracebacks from all threads. # PYTHONFAULTHANDLER: Propagate tracebacks from all threads.
# PYTHONUNBUFFERED: Write terminal output straight to docker (to not confuse Docker Compose). # PYTHONUNBUFFERED: Write terminal output straight to docker (to not confuse Docker Compose).
@ -13,18 +13,14 @@ ENV PYTHONFAULTHANDLER=1 \
PIP_DISABLE_PIP_VERSION_CHECK=on \ PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 PIP_DEFAULT_TIMEOUT=100
ARG BUILD ARG BUILD
ENV BUILD=${BUILD} ENV BUILD ${BUILD}
ARG REQUIREMENTS_FILE=requirements.txt ARG REQUIREMENTS_FILE=requirements.txt
WORKDIR /app WORKDIR /app
RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www
COPY --chown=www:www . .
# Only copy the requirements file first to leverage Docker cache RUN mkdir /app/src/static && \
RUN mkdir requirements/
COPY $REQUIREMENTS_FILE $REQUIREMENTS_FILE
RUN mkdir -p /app/src/static && \
chown www:www /app/src/static && \ chown www:www /app/src/static && \
apt-get update && \ apt-get update && \
apt-get install -y \ apt-get install -y \
@ -39,12 +35,8 @@ RUN mkdir -p /app/src/static && \
libffi-dev \ libffi-dev \
shared-mime-info \ shared-mime-info \
gettext && \ gettext && \
pip install --no-cache-dir -r $REQUIREMENTS_FILE pip install --no-cache-dir -r $REQUIREMENTS_FILE && \
django-admin compilemessages
# Copy the rest of the application
COPY . .
RUN django-admin compilemessages
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]

View file

@ -1,6 +1,6 @@
.PHONY: run makemigrations migrate createsuperuser shell manage_command build requirements
DOCKER_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose DOCKER_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose
DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u` DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u`
DOCKER_BUILD = DOCKER_BUILDKIT=1 docker build
MANAGE_EXEC = python /app/src/manage.py MANAGE_EXEC = python /app/src/manage.py
MANAGE_COMMAND = ${DOCKER_RUN} app ${MANAGE_EXEC} MANAGE_COMMAND = ${DOCKER_RUN} app ${MANAGE_EXEC}
@ -21,9 +21,3 @@ shell:
manage_command: manage_command:
${MANAGE_COMMAND} ${ARGS} ${MANAGE_COMMAND} ${ARGS}
build:
${DOCKER_COMPOSE} build
requirements:
hatch run requirements

View file

@ -7,6 +7,7 @@ There are two ways to setup the development environment.
- Using the Docker Compose setup provided in this repository. - Using the Docker Compose setup provided in this repository.
- Using [hatch](https://hatch.pypa.io/) in your host OS. - Using [hatch](https://hatch.pypa.io/) in your host OS.
### Using Docker Compose ### Using Docker Compose
Working with the Docker Compose setup is made easy with the `Makefile` provided in the repository. Working with the Docker Compose setup is made easy with the `Makefile` provided in the repository.
@ -20,37 +21,24 @@ Working with the Docker Compose setup is made easy with the `Makefile` provided
1. Setup .env file 1. Setup .env file
An example .env file is provided in the repository. You can copy it to .env file using the following command: An example .env file is provided in the repository. You can copy it to .env file using the following command:
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
The default values in the .env file are suitable for the docker-compose setup. The default values in the .env file are suitable for the docker-compose setup.
2. Migrate 2. Migrate
```bash ```bash
make migrate make migrate
``` ```
3. Run the development server 3. Run the development server
```bash
make run
```
#### Building and running other things
```bash ```bash
# Build the containers make run
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 ### Using hatch
@ -65,41 +53,22 @@ make makemigrations
1. Setup .env file 1. Setup .env file
An example .env file is provided in the repository. You can copy it to .env file using the following command: An example .env file is provided in the repository. You can copy it to .env file using the following command:
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
Edit the .env file and set the values for the environment variables, especially the database variables. Edit the .env file and set the values for the environment variables, especially the database variables.
2. Run migrate 2. Run migrate
```bash ```bash
hatch run dev:migrate hatch run dev:migrate
``` ```
3. Run the development server 3. Run the development server
```bash
hatch run dev:server
```
### Updating requirements
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 ```bash
# Build requirements.txt etc hatch run dev:server
make requirements
# Build Docker image with new Python requirements
make build
``` ```
## Important notes
* This project uses [django-zen-queries](https://github.com/dabapps/django-zen-queries), which will sometimes raise a `QueriesDisabledError` in your templates. You can find a difference of opinion about that, but you can find a difference of opinion about many things, right?
* If a linting error annoys you, please feel free to strike back by adding a `noqa` to the line that has displeased the linter and move on with life.

View file

@ -12,21 +12,17 @@ authors = [
{ name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" }, { name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" },
] ]
dependencies = [ dependencies = [
"Django~=5.1", "Django~=5.0",
"django-allauth~=0.63",
"django-money~=3.5", "django-money~=3.5",
"django-oauth-toolkit~=2.4", "django-allauth~=0.63",
"psycopg[binary]~=3.2",
"environs[django]>=11,<12",
"uvicorn~=0.30",
"whitenoise~=6.7",
"django-zen-queries~=2.1",
"django-registries==0.0.3", "django-registries==0.0.3",
"django-view-decorator==0.0.4", "django-view-decorator==0.0.4",
"django-oauth-toolkit~=2.4", "django-oauth-toolkit~=2.4",
"django-ratelimit~=4.1",
"django-zen-queries~=2.1",
"django_stubs_ext~=5.0",
"environs[django]>=11,<12",
"psycopg[binary]~=3.2",
"stripe~=10.5",
"uvicorn~=0.30",
"whitenoise~=6.7",
] ]
version = "0.0.1" version = "0.0.1"
@ -38,11 +34,9 @@ requires = ["hatch-pip-compile"]
[tool.hatch.envs.default] [tool.hatch.envs.default]
type = "pip-compile" type = "pip-compile"
pip-compile-resolver = "uv"
[tool.hatch.envs.dev] [tool.hatch.envs.dev]
type = "pip-compile" type = "pip-compile"
pip-compile-resolver = "uv"
dependencies = [ dependencies = [
"coverage[toml]==7.3.0", "coverage[toml]==7.3.0",
"pytest==7.2.2", "pytest==7.2.2",
@ -58,25 +52,26 @@ dependencies = [
[[tool.hatch.envs.tests.matrix]] [[tool.hatch.envs.tests.matrix]]
python = ["3.12"] python = ["3.12"]
django = ["5.1"] django = ["5.0"]
[tool.hatch.envs.tests.overrides] [tool.hatch.envs.tests.overrides]
matrix.django.dependencies = [ matrix.django.dependencies = [
{ value = "django~={matrix:django}" }, { value = "django~={matrix:django}" },
] ]
matrix.python.dependencies = [
{ value = "typing_extensions==4.5.0", if = ["3.10"]},
]
[tool.hatch.envs.default.scripts] [tool.hatch.envs.dev.scripts]
cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=src --cov=tests --cov=append {args}" cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=src --cov=tests --cov=append {args}"
no-cov = "cov --no-cov {args}" no-cov = "cov --no-cov {args}"
typecheck = "mypy --config-file=pyproject.toml ." typecheck = "mypy --config-file=pyproject.toml ."
requirements = "hatch env run --env default -- python --version; hatch env run --env dev -- python --version" requirements = "pip-compile pyproject.toml"
server = "./src/manage.py runserver 0.0.0.0:8000" server = "./src/manage.py runserver 0.0.0.0:8000"
migrate = "./src/manage.py migrate" migrate = "./src/manage.py migrate"
makemigrations = "./src/manage.py makemigrations" makemigrations = "./src/manage.py makemigrations"
createsuperuser = "./src/manage.py createsuperuser" createsuperuser = "./src/manage.py createsuperuser"
shell = "./src/manage.py shell" 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] [tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE="tests.settings" DJANGO_SETTINGS_MODULE="tests.settings"
@ -109,10 +104,10 @@ show_error_codes = true
strict = true strict = true
warn_unreachable = true warn_unreachable = true
follow_imports = "normal" follow_imports = "normal"
plugins = ["mypy_django_plugin.main"] #plugins = ["mypy_django_plugin.main"]
[tool.django-stubs] [tool.django-stubs]
django_settings_module = "project.settings" #django_settings_module = "tests.settings"
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "tests.*" module = "tests.*"
@ -139,19 +134,12 @@ ignore = [
"EM102", # Exception must not use a f-string literal, assign to variable first "EM102", # Exception must not use a f-string literal, assign to variable first
"COM812", # missing-trailing-comma (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) "COM812", # missing-trailing-comma (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
"ISC001", # single-line-implicit-string-concatenation (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) "ISC001", # single-line-implicit-string-concatenation (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
"D100", # Missing docstring in public module
"D101", # Missing docstring in public class
"D102", # Missing docstring in public method
"D105", # Missing docstring in magic method "D105", # Missing docstring in magic method
"D106", # Missing docstring in public nested class "D106", # Missing docstring in public nested class
"D107", # Missing docstring in `__init__`
"FIX", # TODO, FIXME, XXX "FIX", # TODO, FIXME, XXX
"TD", # TODO, FIXME, XXX "TD", # TODO, FIXME, XXX
"ANN002", # Missing type annotation for `*args` "ANN002", # Missing type annotation for `*args`
"ANN003", # Missing type annotation for `**kwargs` "ANN003", # Missing type annotation for `**kwargs`
"FBT001", # Misbehaves: Boolean-typed positional argument in function definition
"FBT002", # Misbehaves: Boolean-typed positional argument in function definition
"TRY003", # Avoid specifying long messages outside the exception class
] ]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]

View file

@ -1,22 +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 # pip-compile pyproject.toml
# - django-money~=3.5
# - django-oauth-toolkit~=2.4
# - django-ratelimit~=4.1
# - django-registries==0.0.3
# - django-stubs-ext~=5.0
# - django-view-decorator==0.0.4
# - django-zen-queries~=2.1
# - django<5.2,>=5.1b1
# - environs[django]<12,>=11
# - psycopg[binary]~=3.2
# - stripe~=10.5
# - uvicorn~=0.30
# - whitenoise~=6.7
# #
asgiref==3.8.1 asgiref==3.8.1
# via django # via django
babel==2.15.0 babel==2.15.0
@ -29,43 +16,40 @@ charset-normalizer==3.3.2
# via requests # via requests
click==8.1.7 click==8.1.7
# via uvicorn # via uvicorn
cryptography==43.0.0 cryptography==42.0.8
# via jwcrypto # via jwcrypto
dj-database-url==2.2.0 dj-database-url==2.2.0
# via environs # via environs
dj-email-url==1.0.6 dj-email-url==1.0.6
# via environs # via environs
django==5.1rc1 django==5.0.7
# via # via
# hatch.envs.default
# dj-database-url # dj-database-url
# django-allauth # django-allauth
# django-money # django-money
# django-oauth-toolkit # django-oauth-toolkit
# django-registries # django-registries
# django-stubs-ext
# django-view-decorator # django-view-decorator
# django-zen-queries # django-zen-queries
# membersystem (pyproject.toml)
django-allauth==0.63.6 django-allauth==0.63.6
# via hatch.envs.default # via membersystem (pyproject.toml)
django-cache-url==3.4.5 django-cache-url==3.4.5
# via environs # via environs
django-money==3.5.3 django-money==3.5.2
# via hatch.envs.default # via membersystem (pyproject.toml)
django-oauth-toolkit==2.4.0 django-oauth-toolkit==2.4.0
# via hatch.envs.default # via membersystem (pyproject.toml)
django-ratelimit==4.1.0
# via hatch.envs.default
django-registries==0.0.3 django-registries==0.0.3
# via hatch.envs.default # via membersystem (pyproject.toml)
django-stubs-ext==5.0.4
# via hatch.envs.default
django-view-decorator==0.0.4 django-view-decorator==0.0.4
# via hatch.envs.default # via membersystem (pyproject.toml)
django-zen-queries==2.1.0 django-zen-queries==2.1.0
# via hatch.envs.default # via membersystem (pyproject.toml)
environs==11.0.0 environs[django]==11.0.0
# via hatch.envs.default # via
# environs
# membersystem (pyproject.toml)
h11==0.14.0 h11==0.14.0
# via uvicorn # via uvicorn
idna==3.7 idna==3.7
@ -78,8 +62,10 @@ oauthlib==3.2.2
# via django-oauth-toolkit # via django-oauth-toolkit
packaging==24.1 packaging==24.1
# via marshmallow # via marshmallow
psycopg==3.2.1 psycopg[binary]==3.2.1
# via hatch.envs.default # via
# membersystem (pyproject.toml)
# psycopg
psycopg-binary==3.2.1 psycopg-binary==3.2.1
# via psycopg # via psycopg
py-moneyed==3.0 py-moneyed==3.0
@ -91,26 +77,21 @@ python-dotenv==1.0.1
pytz==2024.1 pytz==2024.1
# via django-oauth-toolkit # via django-oauth-toolkit
requests==2.32.3 requests==2.32.3
# via # via django-oauth-toolkit
# django-oauth-toolkit
# stripe
setuptools==72.1.0
# via django-money
sqlparse==0.5.1 sqlparse==0.5.1
# via django # via django
stripe==10.6.0
# via hatch.envs.default
typing-extensions==4.12.2 typing-extensions==4.12.2
# via # via
# dj-database-url # dj-database-url
# django-stubs-ext
# jwcrypto # jwcrypto
# psycopg # psycopg
# py-moneyed # py-moneyed
# stripe
urllib3==2.2.2 urllib3==2.2.2
# via requests # via requests
uvicorn==0.30.5 uvicorn==0.30.1
# via hatch.envs.default # via membersystem (pyproject.toml)
whitenoise==6.7.0 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

View file

View file

@ -14,15 +14,12 @@
# - django-allauth~=0.63 # - django-allauth~=0.63
# - django-money~=3.5 # - django-money~=3.5
# - django-oauth-toolkit~=2.4 # - django-oauth-toolkit~=2.4
# - django-ratelimit~=4.1
# - django-registries==0.0.3 # - django-registries==0.0.3
# - django-stubs-ext~=5.0
# - django-view-decorator==0.0.4 # - django-view-decorator==0.0.4
# - django-zen-queries~=2.1 # - django-zen-queries~=2.1
# - django<5.2,>=5.1b1 # - django~=5.0
# - environs[django]<12,>=11 # - environs[django]<12,>=11
# - psycopg[binary]~=3.2 # - psycopg[binary]~=3.2
# - stripe~=10.5
# - uvicorn~=0.30 # - uvicorn~=0.30
# - whitenoise~=6.7 # - whitenoise~=6.7
# #
@ -48,14 +45,15 @@ click==8.1.7
coverage==7.3.0 coverage==7.3.0
# via # via
# hatch.envs.dev # hatch.envs.dev
# coverage
# pytest-cov # pytest-cov
cryptography==43.0.0 cryptography==42.0.8
# via jwcrypto # via jwcrypto
dj-database-url==2.2.0 dj-database-url==2.2.0
# via environs # via environs
dj-email-url==1.0.6 dj-email-url==1.0.6
# via environs # via environs
django==5.1rc1 django==5.0.7
# via # via
# hatch.envs.dev # hatch.envs.dev
# dj-database-url # dj-database-url
@ -78,26 +76,24 @@ django-cache-url==3.4.5
# via environs # via environs
django-debug-toolbar==4.2.0 django-debug-toolbar==4.2.0
# via hatch.envs.dev # via hatch.envs.dev
django-money==3.5.3 django-money==3.5.2
# via hatch.envs.dev # via hatch.envs.dev
django-oauth-toolkit==2.4.0 django-oauth-toolkit==2.4.0
# via hatch.envs.dev # via hatch.envs.dev
django-ratelimit==4.1.0
# via hatch.envs.dev
django-registries==0.0.3 django-registries==0.0.3
# via hatch.envs.dev # via hatch.envs.dev
django-stubs==1.16.0 django-stubs==1.16.0
# via hatch.envs.dev # via hatch.envs.dev
django-stubs-ext==5.0.4 django-stubs-ext==5.0.2
# via # via django-stubs
# hatch.envs.dev
# django-stubs
django-view-decorator==0.0.4 django-view-decorator==0.0.4
# via hatch.envs.dev # via hatch.envs.dev
django-zen-queries==2.1.0 django-zen-queries==2.1.0
# via hatch.envs.dev # via hatch.envs.dev
environs==11.0.0 environs==11.0.0
# via hatch.envs.dev # via
# hatch.envs.dev
# environs
h11==0.14.0 h11==0.14.0
# via uvicorn # via uvicorn
idna==3.7 idna==3.7
@ -123,14 +119,14 @@ packaging==24.1
# build # build
# marshmallow # marshmallow
# pytest # pytest
pip==24.2
# via pip-tools
pip-tools==7.3.0 pip-tools==7.3.0
# via hatch.envs.dev # via hatch.envs.dev
pluggy==1.5.0 pluggy==1.5.0
# via pytest # via pytest
psycopg==3.2.1 psycopg==3.2.1
# via hatch.envs.dev # via
# hatch.envs.dev
# psycopg
psycopg-binary==3.2.1 psycopg-binary==3.2.1
# via psycopg # via psycopg
py-moneyed==3.0 py-moneyed==3.0
@ -153,24 +149,16 @@ python-dotenv==1.0.1
pytz==2024.1 pytz==2024.1
# via django-oauth-toolkit # via django-oauth-toolkit
requests==2.32.3 requests==2.32.3
# via # via django-oauth-toolkit
# django-oauth-toolkit
# stripe
setuptools==72.1.0
# via
# django-money
# pip-tools
sqlparse==0.5.1 sqlparse==0.5.1
# via # via
# django # django
# django-debug-toolbar # django-debug-toolbar
stripe==10.6.0
# via hatch.envs.dev
tomli==2.0.1 tomli==2.0.1
# via django-stubs # via django-stubs
types-pytz==2024.1.0.20240417 types-pytz==2024.1.0.20240417
# via django-stubs # via django-stubs
types-pyyaml==6.0.12.20240724 types-pyyaml==6.0.12.20240311
# via django-stubs # via django-stubs
typing-extensions==4.12.2 typing-extensions==4.12.2
# via # via
@ -181,12 +169,15 @@ typing-extensions==4.12.2
# mypy # mypy
# psycopg # psycopg
# py-moneyed # py-moneyed
# stripe
urllib3==2.2.2 urllib3==2.2.2
# via requests # via requests
uvicorn==0.30.5 uvicorn==0.30.1
# via hatch.envs.dev # via hatch.envs.dev
wheel==0.43.0 wheel==0.43.0
# via pip-tools # via pip-tools
whitenoise==6.7.0 whitenoise==6.7.0
# via hatch.envs.dev # via hatch.envs.dev
# The following packages are considered to be unsafe in a requirements file:
# pip
# setuptools

View file

@ -1,94 +1,36 @@
"""Admin for the accounting app.""" """Admin for the accounting app."""
from django import forms
from django.contrib import admin from django.contrib import admin
from django.contrib import messages
from django.db.models import QuerySet
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from membership.emails import OrderEmail
from . import models from .models import Order
from .models import Payment
class OrderProductInline(admin.TabularInline): @admin.register(Order)
"""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: 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): class OrderAdmin(admin.ModelAdmin):
"""Admin for the Order model.""" """Admin for the Order model."""
inlines = (OrderProductInline,) list_display = ("who", "description", "created", "is_paid")
form = OrderAdminForm
actions = ("send_order",) @admin.display(description=_("Customer"))
def who(self, instance: Order) -> str:
list_display = ("member", "description", "created", "is_paid", "total_with_vat") """Return the full name of the user who made the order."""
search_fields = ("member__email", "membership__membership_type__name", "description") return instance.user.get_full_name()
list_filter = ("is_paid", "membership__membership_type")
@admin.action(description="Send order link to selected unpaid orders")
def send_order(self, request: HttpRequest, queryset: QuerySet[models.Order]) -> None:
for order in queryset:
if order.is_paid:
messages.error(
request,
f"Order pk={order.id} is already marked paid, not sending email to: {order.member.email}",
)
continue
email = OrderEmail(order, request)
email.send()
messages.success(request, f"Sent an order for order pk={order.id} link to: {order.member.email}")
@admin.register(models.Payment) @admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin): class PaymentAdmin(admin.ModelAdmin):
"""Admin for the Payment model.""" """Admin for the Payment model."""
list_display = ("order__member", "description", "order_id", "created") 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()
@admin.display(description=_("Order ID")) @admin.display(description=_("Order ID"))
def order_id(self, instance: models.Payment) -> int: def order_id(self, instance: Payment) -> int:
"""Return the ID of the order.""" """Return the ID of the order."""
return instance.order.id return instance.order.id
@admin.register(models.Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "price", "vat")
class TransactionInline(admin.TabularInline):
model = models.Transaction
@admin.register(models.Account)
class AccountAdmin(admin.ModelAdmin):
list_display = ("owner", "balance")
inlines = (TransactionInline,)

View file

@ -7,7 +7,3 @@ class AccountingConfig(AppConfig):
"""Accounting app config.""" """Accounting app config."""
name = "accounting" name = "accounting"
def ready(self) -> None:
"""Implicitly connect a signal handlers decorated with @receiver."""
from . import signals # noqa: F401

View file

@ -1,78 +0,0 @@
# 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,
},
),
]

View file

@ -1,40 +0,0 @@
# 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'),
),
]

View file

@ -1,25 +0,0 @@
# 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', '0006_waitinglistentry_alter_membership_options'),
]
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'),
),
]

View file

@ -1,42 +0,0 @@
# 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'),
),
]

View file

@ -4,7 +4,6 @@ from hashlib import md5
from typing import Self from typing import Self
from django.conf import settings from django.conf import settings
from django.contrib import admin
from django.db import models from django.db import models
from django.db.models.aggregates import Sum from django.db.models.aggregates import Sum
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -30,10 +29,10 @@ class Account(CreatedModifiedAbstract):
can decide which account to use to pay for something. can decide which account to use to pay for something.
""" """
owner = models.ForeignKey("membership.Member", on_delete=models.PROTECT) owner = models.ForeignKey("auth.User", on_delete=models.PROTECT)
def __str__(self) -> str: def __str__(self) -> str:
return f"Account of {self.owner}" return f"Account of {self.owner.get_full_name()}"
@property @property
def balance(self) -> Money: def balance(self) -> Money:
@ -68,43 +67,36 @@ class Transaction(CreatedModifiedAbstract):
class Order(CreatedModifiedAbstract): class Order(CreatedModifiedAbstract):
"""An order. """An order.
We assemble the order from a number of products. Once an order is paid, the contents should be Scoped out: Contents of invoices will have to be tracked either here or in
considered locked. a separate Invoice model. This is undecided because we are not generating
invoices at the moment.
""" """
member = models.ForeignKey("membership.Member", on_delete=models.PROTECT) user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
account = models.ForeignKey(Account, on_delete=models.PROTECT) account = models.ForeignKey(Account, on_delete=models.PROTECT)
description = models.CharField(max_length=1024, verbose_name=_("description")) 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")) is_paid = models.BooleanField(default=False, verbose_name=_("is paid"))
class Meta: class Meta:
verbose_name = pgettext_lazy("accounting", "Order") verbose_name = pgettext_lazy("accounting term", "Order")
verbose_name_plural = pgettext_lazy("accounting", "Orders") verbose_name_plural = pgettext_lazy("accounting term", "Orders")
def __str__(self) -> str: def __str__(self) -> str:
return f"Order ID {self.display_id}" return f"Order ID {self.display_id}"
@property @property
def total(self) -> Money: def total(self) -> Money:
"""Return the total price of the order (excl VAT).""" """Return the total price of the order."""
return sum(item.price * item.quantity for item in self.items.all()) return self.price + self.vat
@property
def total_vat(self) -> Money:
"""Return the total VAT of the order."""
return sum(item.vat * item.quantity for item in self.items.all())
@property
@admin.display(
ordering=None,
description="Total (incl. VAT)",
boolean=False,
)
def total_with_vat(self) -> Money:
"""Return the TOTAL amount WITH VAT."""
return self.total + self.total_vat
@property @property
def display_id(self) -> str: def display_id(self) -> str:
@ -122,42 +114,6 @@ class Order(CreatedModifiedAbstract):
return x.hexdigest() 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="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): class Payment(CreatedModifiedAbstract):
"""A payment is a transaction that is made to pay for an order.""" """A payment is a transaction that is made to pay for an order."""
@ -166,8 +122,7 @@ class Payment(CreatedModifiedAbstract):
description = models.CharField(max_length=1024, verbose_name=_("description")) description = models.CharField(max_length=1024, verbose_name=_("description"))
payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT) stripe_charge_id = models.CharField(max_length=255, blank=True)
external_transaction_id = models.CharField(max_length=255, default="", blank=True)
class Meta: class Meta:
verbose_name = _("payment") verbose_name = _("payment")
@ -182,28 +137,11 @@ class Payment(CreatedModifiedAbstract):
return str(self.id).zfill(6) return str(self.id).zfill(6)
@classmethod @classmethod
def from_order(cls, order: Order, payment_type: "PaymentType") -> Self: def from_order(cls, order: Order) -> Self:
"""Create a payment from an order.""" """Create a payment from an order."""
return cls.objects.create( return cls.objects.create(
order=order, order=order,
user=order.user, user=order.user,
amount=order.total + order.total_vat, amount=order.total,
description=order.description, description=order.description,
payment_type=payment_type,
) )
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, blank=True)
enabled = models.BooleanField(default=True)
def __str__(self) -> str:
return f"{self.name}"

View file

@ -1,36 +0,0 @@
"""Loaded with the AppConfig.ready() method."""
from django.core.mail import mail_admins
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
# 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:
mail_admins(
"Payment received: wrong amount",
f"Please check payment ID {instance.pk}",
)
@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.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:
Membership.objects.filter(order=instance, activated=False, activated_on=None).update(
activated=True, activated_on=timezone.now()
)

View file

@ -1,18 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block head_title %}
{% trans "Payment cancelled" %}
{% endblock %}
{% block content %}
<div class="content-view">
<h2>{% trans "Payment canceled" %}</h2>
<p>
<a href="{% order:detail order_id=order.id %}">{% trans "Return to order page" %}</a>
</p>
</div>
{% endblock %}

View file

@ -1,49 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block head_title %}
{% trans "Order" context "accounting" %}
{% endblock %}
{% block content %}
<div class="content-view">
<h2>Order: {{ order.id }}</h2>
<p>
{% trans "Ordered" context "accounting" %}: {{ order.created }}<br>
{% trans "Status" context "accounting" %}: {{ order.is_paid|yesno:_("paid,unpaid") }}
</p>
<table class="table">
<thead>
<tr>
<th>{% trans "Item" context "accounting" %}</th>
<th>{% trans "Quantity" %}</th>
<th>{% trans "Price" %}</th>
<th>{% trans "VAT" %}</th>
<th>{% trans "Total" %}</th>
</tr>
</thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td>{{ item.product.name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.price }}</td>
<td>{{ item.vat }}</td>
<td>{{ item.total_with_vat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2>{% trans "Total price" %}: {{ order.total_with_vat }}</h2>
{% if not order.is_paid %}
<p>
<a href="{% url "order:pay" order_id=order.pk %}" class="button">{% trans "Pay now" %}</a>
</p>
{% endif %}
</div>
{% endblock %}

View file

@ -1,20 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block head_title %}
{% trans "Payment received" %}
{% endblock %}
{% block content %}
<div class="content-view">
<h2>{% trans "Payment received" %}</h2>
<p>
{% blocktrans trimmed with order.id as 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 %}
</p>
</div>
{% endblock %}

View file

@ -1,177 +0,0 @@
"""Views for the membership app."""
import stripe
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.mail import mail_admins
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
order_view = namespaced_decorator_factory(namespace="order", base_path="order")
stripe.api_key = settings.STRIPE_API_KEY
@order_view(
paths="<int:order_id>/",
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,
)
@order_view(
paths="<int:order_id>/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}"
if settings.DEBUG:
f"http://{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": int((item.price + item.vat).amount * 100),
"product_data": {
"name": item.product.name,
},
},
"quantity": item.quantity,
}
)
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",
)
except Exception as e:
mail_admins("Error in checkout", str(e))
raise
# TODO: Redirect with status=303
return redirect(checkout_session.url)
@transaction.atomic
@order_view(
paths="<int:order_id>/pay/success/",
name="success",
login_required=True,
)
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
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 = get_object_or_404(models.Order, pk=order_id, member=user)
context = {
"order": order,
}
return render(
request=request,
template_name="accounting/order/success.html",
context=context,
)
@transaction.atomic
@order_view(
paths="<int:order_id>/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,
)
@transaction.atomic
@order_view(
paths="stripe/webhook/",
name="webhook",
)
@csrf_exempt
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":
# 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"] / 100.0, 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)

View file

@ -1,41 +1,16 @@
"""Admin configuration for membership app.""" """Admin configuration for membership app."""
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 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
from django.utils.text import slugify
from .emails import InviteEmail
from .models import Member
from .models import Membership from .models import Membership
from .models import MembershipType from .models import MembershipType
from .models import ServiceAccess
from .models import SubscriptionPeriod from .models import SubscriptionPeriod
from .models import WaitingListEntry
# Do not use existing user admin
admin.site.unregister(User)
@admin.register(Membership) @admin.register(Membership)
class MembershipAdmin(admin.ModelAdmin): class MembershipAdmin(admin.ModelAdmin):
"""Admin for Membership model.""" """Admin for Membership model."""
list_display = ("user", "period", "membership_type", "activated", "revoked")
list_filter = ("period", "membership_type", "activated", "revoked")
search_fields = ("membership_type__name", "user__email", "user__first_name", "user__last_name")
@admin.register(MembershipType) @admin.register(MembershipType)
class MembershipTypeAdmin(admin.ModelAdmin): class MembershipTypeAdmin(admin.ModelAdmin):
@ -45,131 +20,3 @@ class MembershipTypeAdmin(admin.ModelAdmin):
@admin.register(SubscriptionPeriod) @admin.register(SubscriptionPeriod)
class SubscriptionPeriodAdmin(admin.ModelAdmin): class SubscriptionPeriodAdmin(admin.ModelAdmin):
"""Admin for SubscriptionPeriod model.""" """Admin for SubscriptionPeriod model."""
@admin.register(ServiceAccess)
class ServiceAccessAdmin(admin.ModelAdmin):
"""Admin for ServiceAccess model."""
pass
class MembershipInlineAdmin(admin.TabularInline):
"""Inline admin."""
model = Membership
def decorate_ensure_membership_type_exists(membership_type: 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
@transaction.atomic
def ensure_membership_type_exists(
request: HttpRequest,
queryset: QuerySet[Member],
membership_type: 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, 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)
# Create the Membership
Membership.objects.create(
membership_type=membership_type,
user=member,
period=SubscriptionPeriod.objects.current(),
order=order,
)
# Associate the order with that membership
messages.success(request, f"{member} has ordered a '{membership_type}' (unpaid)")
@admin.register(Member)
class MemberAdmin(UserAdmin):
"""Member admin is actually an admin for User objects."""
inlines = (MembershipInlineAdmin,)
actions: list[str | Callable] = ["send_invite"] # noqa: RUF012
list_display = ("email", "current_membership", "username", "is_staff", "is_active", "date_joined")
@admin.display(description="membership")
def current_membership(self, instance: Member) -> Membership | None:
return instance.memberships.current()
def get_actions(self, request: HttpRequest) -> dict:
"""Populate actions with dynamic data (MembershipType)."""
current_period = SubscriptionPeriod.objects.current()
super_dict = super().get_actions(request)
if current_period:
for i, mtype in enumerate(MembershipType.objects.filter(active=True)):
action_label = f"Ensure membership {mtype.name}, {current_period.period}, {mtype.total_including_vat}"
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.action(description="Send invite email to selected inactive accounts")
def send_invite(self, request: HttpRequest, queryset: QuerySet[Member]) -> None:
for member in queryset:
if member.is_active:
messages.error(
request,
f"Computer says no! This member will not receive an invite because the account is marked "
f"as active: {member.email}. That means the member has probably created a password and a username "
f"already, please tell them to use the password reminder function.",
)
continue
if not member.memberships.current():
messages.error(
request,
f"Computer says no! This member will not receive an invite because it has no current "
f"membership: {member.email}. You need to create a current membership before sending the invite.",
)
continue
membership = member.memberships.current()
email = InviteEmail(membership, request)
email.send()
messages.success(request, f"Sent an invitation to: {member.email}")
@admin.register(WaitingListEntry)
class WaitingListEntryAdmin(admin.ModelAdmin):
"""Admin for WaitingList model."""
list_display = ("email", "member")
actions = ("create_member",)
@admin.action(description="Create member account for entries")
def create_member(self, request: HttpRequest, queryset: QuerySet[WaitingListEntry]) -> None:
"""Create a user account for this entry.
Note that actions can soon be made available from the edit page, too:
https://github.com/django/django/pull/16012
"""
for entry in queryset:
member = Member.objects.create_user(email=entry.email, username=slugify(entry.email), is_active=False)
entry.member = member
entry.save()
messages.info(
request,
f"Added user for {entry.email} - ensure they have a membership and send an invite email.",
)

View file

@ -1,128 +0,0 @@
"""Send email to members, using templates and contexts for the emails.
* We keep everything as plain text for now.
* Notice that emails can be multilingual
* Generally, an email consists of templates (for body and subject) and a get_context() method.
"""
from accounting.models import Order
from django.contrib import messages
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.shortcuts import get_current_site
from django.core.mail.message import EmailMessage
from django.http import HttpRequest
from django.template import loader
from django.utils import translation
from django.utils.translation import gettext_lazy as _
from .models import Membership
class BaseEmail(EmailMessage):
"""Send emails via templated body and subjects.
This base class is extended for all email functionality.
Because all emails are sent to the Member object, we can keep them gathered here, even when they are generated by
other apps (like the accounting app).
"""
template = "membership/email/base.txt"
# Optional: Set to a template path for subject
template_subject = None
default_subject = "SET SUBJECT HERE"
def __init__(self, request: HttpRequest, *args, **kwargs) -> None:
self.context = kwargs.pop("context", {})
self.user = kwargs.pop("user", None)
if self.user:
kwargs["to"] = [self.user.email]
self.context["user"] = self.user
self.context["recipient_name"] = self.user.get_display_name()
# Necessary to set request before instantiating body and subject
self.request = request
kwargs.setdefault("subject", self.get_subject())
kwargs.setdefault("body", self.get_body())
super().__init__(*args, **kwargs)
def get_context_data(self) -> dict:
"""Resolve common context for sending emails.
When overwriting, remember to call this via super().
"""
c = self.context
site = get_current_site(self.request)
c["request"] = self.request
c["domain"] = site.domain
c["site_name"] = site.name
c["protocol"] = "https" # if self.request and not self.request.is_secure() else "https"
return c
def get_body(self) -> str:
"""Build the email body from template and context."""
if self.user and self.user.language_code:
with translation.override(self.user.language_code):
body = loader.render_to_string(self.template, self.get_context_data())
else:
body = loader.render_to_string(self.template, self.get_context_data())
return body
def get_subject(self) -> str:
"""Build the email subject from template or self.default_subject."""
if self.user and self.user.language_code:
with translation.override(self.user.language_code):
if self.template_subject:
subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip()
else:
subject = str(self.default_subject)
elif self.template_subject:
subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip()
else:
subject = str(self.default_subject)
return subject
def send_with_feedback(self, *, success_msg: str | None = None, no_message: bool = False) -> None:
"""Send email, possibly adding feedback via django.contrib.messages."""
if not success_msg:
success_msg = _("Email successfully sent to {}").format(", ".join(self.to))
try:
self.send(fail_silently=False)
if not no_message:
messages.success(self.request, success_msg)
except RuntimeError:
messages.error(self.request, _("Not sent, something wrong with the mail server."))
class InviteEmail(BaseEmail):
template = "membership/emails/invite.txt"
default_subject = _("Invite to data.coop membership")
def __init__(self, membership: Membership, request: HttpRequest, *args, **kwargs) -> None:
self.membership = membership
kwargs["user"] = membership.user
kwargs["from_email"] = "kasserer@data.coop"
super().__init__(request, *args, **kwargs)
def get_context_data(self) -> dict:
c = super().get_context_data()
c["membership"] = self.membership
c["token"] = default_token_generator.make_token(self.membership.user)
c["referral_code"] = self.membership.referral_code
return c
class OrderEmail(BaseEmail):
template = "membership/emails/order.txt"
default_subject = _("Your data.coop order and payment")
def __init__(self, order: Order, request: HttpRequest, *args, **kwargs) -> None:
self.order = order
kwargs["user"] = order.member
kwargs["from_email"] = "kasserer@data.coop"
super().__init__(request, *args, **kwargs)
def get_context_data(self) -> dict:
c = super().get_context_data()
c["order"] = self.order
return c

View file

@ -1,39 +0,0 @@
from allauth.account.adapter import get_adapter as get_allauth_adapter
from allauth.account.forms import SetPasswordForm
from django import forms
from django.utils.translation import gettext_lazy as _
class InviteForm(SetPasswordForm):
"""Create a new password for a user account that is created through an invite."""
username = forms.CharField(
label=_("Username"),
widget=forms.TextInput(attrs={"placeholder": _("Username"), "autocomplete": "username"}),
)
def __init__(self, *args, **kwargs) -> None:
self.membership = kwargs.pop("membership")
kwargs["user"] = self.membership.user
super().__init__(*args, **kwargs)
def clean_username(self) -> str:
"""Clean the username value.
Taken from the allauth Signup form - we should consider that data can be leaked here.
"""
value = self.cleaned_data["username"]
# The allauth adapter ensures the username is unique.
return get_allauth_adapter().clean_username(value)
def save(self) -> None:
"""Save instance to db.
Note: You can hack a re-activation of a deactivated account
by getting a valid token before deactivation (from the reset password form).
We can block this by also setting Membership.revoked=False when deactivating someone's account.
"""
self.user.username = self.cleaned_data["username"]
self.user.is_active = True
self.user.save()
super().save()

View file

@ -1,32 +0,0 @@
# Generated by Django 5.0.7 on 2024-07-20 20:45
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, default='', 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'},
),
]

View file

@ -1,62 +0,0 @@
# 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_alter_orderproduct_options_rename_user_order_member_and_more'),
('membership', '0006_waitinglistentry_alter_membership_options'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
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'),
),
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),
),
]

View file

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

View file

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

View file

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

View file

@ -1,49 +0,0 @@
# Generated by Django 5.1rc1 on 2024-12-22 23:20
import django.db.models.deletion
import django_registries.registry
import services.registry
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('membership', '0010_waitinglistentry_member'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ServiceAccess',
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')),
('service', django_registries.registry.ChoicesField(choices=[('hedgedoc', 'hedgedoc'), ('mail', 'mail'), ('mastodon', 'mastodon'), ('matrix', 'matrix'), ('nextcloud', 'nextcloud'), ('rallly', 'rallly')], registry=services.registry.ServiceRegistry, verbose_name='service')),
('subscription_data', models.JSONField(blank=True, null=True, verbose_name='subscription data')),
],
options={
'verbose_name': 'service access',
'verbose_name_plural': 'service accesses',
},
),
migrations.AlterModelOptions(
name='membership',
options={'verbose_name': 'membership', 'verbose_name_plural': 'memberships'},
),
migrations.RemoveConstraint(
model_name='subscriptionperiod',
name='exclude_overlapping_periods',
),
migrations.AddField(
model_name='serviceaccess',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='serviceaccess',
constraint=models.UniqueConstraint(fields=('user', 'service'), name='unique_user_service'),
),
]

View file

@ -1,19 +1,15 @@
"""Models for the membership app.""" """Models for the membership app."""
import uuid
from typing import ClassVar from typing import ClassVar
from typing import Self from typing import Self
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.models import UserManager
from django.contrib.postgres.constraints import ExclusionConstraint from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import DateRangeField from django.contrib.postgres.fields import DateRangeField
from django.contrib.postgres.fields import RangeOperators from django.contrib.postgres.fields import RangeOperators
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from services.registry import ServiceRegistry
from djmoney.money import Money
from utils.mixins import CreatedModifiedAbstract from utils.mixins import CreatedModifiedAbstract
@ -45,23 +41,7 @@ class Member(User):
), ),
) )
objects = UserManager.from_queryset(QuerySet)() objects = QuerySet.as_manager()
def get_display_name(self) -> str:
"""Choose how to display the user in emails and UI and ultimately to other users.
It's crucial that we currently don't have a good solution for this.
We should allow the user to define their own nick.
"""
return self.username
@property
def language_code(self) -> str:
"""Returns the user's preferred language code.
We don't have an actual setting for this... because this is a proxy table.
"""
return "da-dk"
class Meta: class Meta:
proxy = True proxy = True
@ -73,33 +53,17 @@ class SubscriptionPeriod(CreatedModifiedAbstract):
Denotes a period for which members should pay their membership fee for. 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")) period = DateRangeField(verbose_name=_("period"))
class Meta: class Meta:
constraints: ( constraints: ClassVar = [
ExclusionConstraint( ExclusionConstraint(
name="exclude_overlapping_periods", name="exclude_overlapping_periods",
expressions=[ expressions=[
("period", RangeOperators.OVERLAPS), ("period", RangeOperators.OVERLAPS),
], ],
), ),
) ]
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.period.lower} - {self.period.upper or _('next general assembly')}" return f"{self.period.lower} - {self.period.upper or _('next general assembly')}"
@ -118,17 +82,16 @@ class Membership(CreatedModifiedAbstract):
"""Filter memberships for a given member.""" """Filter memberships for a given member."""
return self.filter(user=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: def _current(self) -> Self:
"""Filter memberships for the current period.""" """Filter memberships for the current period."""
return self.filter(period__period__contains=timezone.now()) return self.filter(period__period__contains=timezone.now())
def current(self) -> "Membership | None": def current(self) -> "Membership | None":
"""Get the current membership.""" """Get the current membership."""
return self._current().first() try:
return self._current().get()
except self.model.DoesNotExist:
return None
def previous(self) -> list["Membership"]: def previous(self) -> list["Membership"]:
"""Get previous memberships.""" """Get previous memberships."""
@ -139,15 +102,12 @@ class Membership(CreatedModifiedAbstract):
objects = QuerySet.as_manager() objects = QuerySet.as_manager()
user = models.ForeignKey("auth.User", on_delete=models.PROTECT, related_name="memberships") user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
# This code is used for inviting a user to create an account for this membership.
referral_code = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
membership_type = models.ForeignKey( membership_type = models.ForeignKey(
"membership.MembershipType", "membership.MembershipType",
related_name="memberships", related_name="memberships",
verbose_name=_("membership type"), verbose_name=_("subscription type"),
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
@ -156,31 +116,6 @@ class Membership(CreatedModifiedAbstract):
on_delete=models.PROTECT, 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: class Meta:
verbose_name = _("membership") verbose_name = _("membership")
verbose_name_plural = _("memberships") verbose_name_plural = _("memberships")
@ -198,78 +133,9 @@ class MembershipType(CreatedModifiedAbstract):
name = models.CharField(verbose_name=_("name"), max_length=64) name = models.CharField(verbose_name=_("name"), max_length=64)
products = models.ManyToManyField("accounting.Product")
active = models.BooleanField(default=True)
class Meta: class Meta:
verbose_name = _("membership type") verbose_name = _("membership type")
verbose_name_plural = _("membership types") verbose_name_plural = _("membership types")
def __str__(self) -> str: def __str__(self) -> str:
return self.name 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(),
)
@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."""
email = models.EmailField()
geography = models.CharField(verbose_name=_("geography"), blank=True, default="")
comment = models.TextField(blank=True)
member = models.ForeignKey(
Member,
null=True,
blank=True,
verbose_name=_("has member"),
help_text=_("Once a member account is generated (use the admin action), this field will be marked."),
on_delete=models.CASCADE,
)
def __str__(self) -> str:
return self.email
class Meta:
verbose_name = _("waiting list entry")
verbose_name_plural = _("waiting list entries")
class ServiceAccess(CreatedModifiedAbstract):
"""Access to a service for a user."""
class Meta:
verbose_name = _("service access")
verbose_name_plural = _("service accesses")
constraints = (
models.UniqueConstraint(
fields=["user", "service"],
name="unique_user_service",
),
)
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
service = ServiceRegistry.choices_field(verbose_name=_("service"))
subscription_data = models.JSONField(
verbose_name=_("subscription data"),
null=True,
blank=True,
)
def __str__(self) -> str:
return f"{self.user} - {self.service}"

View file

@ -1,9 +0,0 @@
{% load i18n %}{% block greeting %}{% blocktrans %}Dear {{ recipient_name }},{% endblocktrans %}{% endblock %}
{% block content %}{% endblock %}
{% trans "Cooperatively yours," %}
{{ site_name }}
{{ protocol }}://{{ domain }}

View file

@ -1,7 +0,0 @@
{% extends "membership/emails/base.txt" %}{% load i18n %}
{% block content %}{% url 'member:membership-invite' token=token referral_code=referral_code as invite_url %}{% blocktrans %}Here is your secret URL for creating an account with us:
{{ protocol }}://{{ domain }}{{ invite_url }}
If you did not request this account, get in touch with us.{% endblocktrans %}{% endblock %}

View file

@ -1,17 +0,0 @@
{% extends "membership/emails/base.txt" %}{% load i18n %}
{% block content %}{% url 'order:detail' order_id=order.id as order_url %}{% blocktrans %}You have an order in our system, which you can pay here:
{{ protocol }}://{{ domain }}{{ order_url }}
We used to handle membership stuff in a spreadsheet and via bank transfers. This is now all handled with our custom-made membership system. We hope you like it.
If you received this email and no longer want a membership, you can ignore it. But please let us know by writing board@data.coop, so we can erase any personal data we have about your previous membership.
Dansk:
Hej! Så kører medlemsystemet endeligt! Det er mega-fedt, fordi vi længe har haft besvær med manuelle procedurer. Nu har vi flyttet medlemsdata over på member.data.coop, og betalingen fungerer. Vi kan dermed fremover arbejde stille og roligt på at integrere systemet, så man kan styre sine services via medlemssystemet.
Hvis du ikke længere vil være medlem, kan du ignorere mailen her; men du må meget gerne informere os via board@data.coop, så vi kan slette evt. personlige data og services, du har kørende på dit tidligere medlemskab.
{% endblocktrans %}{% endblock %}

View file

@ -1,21 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block head_title %}
{% trans "Membership" %}
{% endblock %}
{% block content %}
<div class="content-view">
<h2>{% trans "Create account" %}</h2>
<p>{% trans "Congratulations! You've been invited to create an account with us:" %}</p>
<p>Email: <strong>{{ membership.user.email }}</strong></p>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn">{% trans "Create account" %}</button>
</form>
</div>
{% endblock %}

View file

@ -4,20 +4,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.contrib import messages
from django.contrib.auth.tokens import default_token_generator
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_ratelimit.decorators import ratelimit
from django_view_decorator import namespaced_decorator_factory from django_view_decorator import namespaced_decorator_factory
from utils.view_utils import RenderConfig from utils.view_utils import RenderConfig
from utils.view_utils import RowAction from utils.view_utils import RowAction
from utils.view_utils import render from utils.view_utils import render
from .forms import InviteForm
from .models import Membership
from .permissions import ADMINISTRATE_MEMBERS from .permissions import ADMINISTRATE_MEMBERS
from .selectors import get_member from .selectors import get_member
from .selectors import get_members from .selectors import get_members
@ -121,49 +113,3 @@ def members_admin_detail(request: HttpRequest, member_id: int) -> HttpResponse:
template_name="membership/members_admin_detail.html", template_name="membership/members_admin_detail.html",
context=context, context=context,
) )
@ratelimit(group="membership", key="ip", rate="10/d", method="ALL", block=True)
@member_view(
paths="invite/<str:referral_code>/<str:token>/",
name="membership-invite",
login_required=False,
)
def invite(request: HttpRequest, referral_code: str, token: str) -> HttpResponse:
"""View to invite a member to create a membership.
The token belongs to a non-active Member object. If the token is valid,
the caller is allowed to create a membership.
We ratelimit this view so it's not possible to brute-force tokens.
"""
if request.user.is_authenticated:
return HttpResponseForbidden("You're already logged in. So you cannot receive an invite.")
# Firstly, we get the membership by the referral code.
membership = get_object_or_404(Membership, referral_code=referral_code, user__is_active=False, revoked=False)
token_valid = default_token_generator.check_token(membership.user, token)
if not token_valid:
raise HttpResponseForbidden("Token not valid - maybe it expired?")
if request.method == "POST":
form = InviteForm(membership=membership, data=request.POST)
if form.is_valid():
form.save()
messages.info(request, _("Password is set for your account and you can now login."))
return redirect("account_login")
else:
form = InviteForm(membership=membership)
context = {
"token": token,
"membership": membership,
"form": form,
}
return render(
request=request,
template_name="membership/invite.html",
context=context,
)

View file

@ -2,12 +2,9 @@
from pathlib import Path from pathlib import Path
import django_stubs_ext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from environs import Env from environs import Env
django_stubs_ext.monkeypatch()
env = Env() env = Env()
env.read_env() env.read_env()
@ -46,15 +43,12 @@ THIRD_PARTY_APPS = [
"allauth", "allauth",
"allauth.account", "allauth.account",
"django_view_decorator", "django_view_decorator",
"django_registries",
"oauth2_provider",
] ]
LOCAL_APPS = [ LOCAL_APPS = [
"utils", "utils",
"accounting", "accounting",
"membership", "membership",
"services",
] ]
INSTALLED_APPS = [ INSTALLED_APPS = [
@ -130,7 +124,6 @@ EMAIL_BACKEND = env.str(
default="django.core.mail.backends.console.EmailBackend", default="django.core.mail.backends.console.EmailBackend",
) )
DEFAULT_FROM_EMAIL = env.str("DEFAULT_FROM_EMAIL", default="") DEFAULT_FROM_EMAIL = env.str("DEFAULT_FROM_EMAIL", default="")
SERVER_EMAIL = env.str("SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
# Parse email URLs, e.g. "smtp://" # Parse email URLs, e.g. "smtp://"
email = env.dj_email_url("EMAIL_URL", default="smtp://") email = env.dj_email_url("EMAIL_URL", default="smtp://")
EMAIL_HOST = email["EMAIL_HOST"] EMAIL_HOST = email["EMAIL_HOST"]
@ -163,16 +156,6 @@ ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
ACCOUNT_USERNAME_REQUIRED = False ACCOUNT_USERNAME_REQUIRED = False
# OAuth2 configuration
OAUTH2_PROVIDER = {
"OIDC_ENABLED": True,
"SCOPES": {
"openid": "OpenID Connect scope",
"profile": "Profile Information",
},
"PKCE_REQUIRED": False, # this can be a callable - https://github.com/jazzband/django-oauth-toolkit/issues/711#issuecomment-497073038
}
# Logging # Logging
# We want to log everything to stdout in docker # We want to log everything to stdout in docker
LOGGING = { LOGGING = {
@ -192,12 +175,6 @@ LOGGING = {
}, },
} }
STRIPE_API_KEY = env.str("STRIPE_API_KEY")
STRIPE_ENDPOINT_SECRET = env.str("STRIPE_ENDPOINT_SECRET")
# The number of seconds a password reset link is valid for (default: 3 days).
# We've extended this to 7 days because invites then last for 1 week.
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 * 7
if DEBUG: if DEBUG:
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"] INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]

View file

@ -51,7 +51,6 @@ h6 {
--light-dust: #fefef9; --light-dust: #fefef9;
--dust: #f4f1ef; --dust: #f4f1ef;
--medium-dust: #dadada; --medium-dust: #dadada;
--medium-dust : #dadada;
--dark-dust: #bfbfbf; --dark-dust: #bfbfbf;
--fade: #878787; --fade: #878787;
--twilight: #4a4a4a; --twilight: #4a4a4a;
@ -257,7 +256,7 @@ div.content-view>h2 {
div.services { div.services {
display: flex; display: flex;
justify-content: start; justify-content: space-between;
gap: var(--double-space); gap: var(--double-space);
flex-wrap: wrap; flex-wrap: wrap;
} }
@ -505,11 +504,6 @@ footer {
opacity: 0.8; opacity: 0.8;
} }
footer a, footer a:visited, footer a:active {
color: var(--dust);
text-decoration: underline;
}
span.time_remaining { span.time_remaining {
color: var(--fade); color: var(--fade);
} }

View file

@ -78,13 +78,13 @@
</a> </a>
</li> </li>
{% if user.is_superuser %} {% comment %}
<li> <li>
<a href="{% url "services:list" %}" class="{% active_path "services:list" "current" %}"> <a href="/services" class="{% active_path "services" "current" %}">
Services Services
</a> </a>
</li> </li>
{% endif %} {% endcomment %}
<li> <li>
<a href="{% url "account_email" %}" class="{% active_path "account_email" "current" %}"> <a href="{% url "account_email" %}" class="{% active_path "account_email" "current" %}">
@ -102,21 +102,11 @@
</ol> </ol>
</nav> </nav>
<article> <article>
{% if messages %}
<div class="content-view">
{% for message in messages %}
<p>📨</p>
<p><strong>{{ message }}</strong></p>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</article> </article>
</main> </main>
<footer> <footer>
data.coop membersystem alpha - report issues on <a href="https://git.data.coop/data.coop/membersystem/">git</a> data.coop membersystem version 0.0.1
</footer> </footer>
<script> <script>
const themeSwitcher = document.getElementById('theme-switcher'); const themeSwitcher = document.getElementById('theme-switcher');

View file

@ -14,10 +14,6 @@
It is very much under construction. It is very much under construction.
</p> </p>
{% for order in unpaid_orders %}
<p>You have an unpaid order: <a href="{% url "order:detail" order_id=order.id %}">View Order ID {{ order.id }}</a></p>
{% endfor %}
{% comment %} {% comment %}
<hr> <hr>
<br> <br>

View file

@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block content %}
<div class="content-view">
Coming soon!
</div>
{% comment %}
<div class="content-view">
<h2>Services you subscribe to</h2>
<div class="services">
<div>
<div class="description">
<h3>Passit</h3>
<p>Passit is a service that blabla</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Unsubscribe</a>
</div>
</div>
</div>
<div class="content-view">
<h2>Available services</h2>
<div class="services">
<div>
<div class="description">
<h3>Forgejo</h3>
<p>Forgejo is a service that blabla</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Subscribe</a>
</div>
<div>
<div class="description">
<h3>Mastodon</h3>
<p>Mastodon is a service where you can write things to people around the world.</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Subscribe</a>
</div>
<div>
<div class="description">
<h3>Matrix</h3>
<p>Matrix is a service that blabla</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Subscribe</a>
</div>
<div>
<div class="description">
<h3>NextCloud</h3>
<p>NextCloud is a service that blabla</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Subscribe</a>
</div>
</div>
</div>
{% endcomment %}
{% endblock %}

View file

@ -8,7 +8,6 @@ from django_view_decorator import include_view_urls
urlpatterns = [ urlpatterns = [
path("", include_view_urls(extra_modules=["project.views"])), path("", include_view_urls(extra_modules=["project.views"])),
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
path("_admin/", admin.site.urls), path("_admin/", admin.site.urls),
] ]

View file

@ -1,13 +1,11 @@
"""Project views.""" """Project views."""
from __future__ import annotations from __future__ import annotations
from membership.models import ServiceAccess
from services.registry import ServiceRegistry
from utils.view_utils import render
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from accounting.models import Order
from django_view_decorator import view from django_view_decorator import view
from utils.view_utils import render
if TYPE_CHECKING: if TYPE_CHECKING:
from django.http import HttpRequest from django.http import HttpRequest
@ -21,8 +19,14 @@ if TYPE_CHECKING:
) )
def index(request: HttpRequest) -> HttpResponse: def index(request: HttpRequest) -> HttpResponse:
"""View to show the index page.""" """View to show the index page."""
unpaid_orders = Order.objects.filter(member=request.user, is_paid=False) return render(request, "index.html")
context = {"unpaid_orders": list(unpaid_orders)}
return render(request, "index.html", context=context) @view(
paths="services/",
name="services",
login_required=True,
)
def services_overview(request: HttpRequest) -> HttpResponse:
"""View to show the services overview."""
return render(request, "services_overview.html")

5
src/pytest.ini Normal file
View file

@ -0,0 +1,5 @@
[pytest]
testpaths = .
python_files = tests.py test_*.py *_tests.py
DJANGO_SETTINGS_MODULE = project.settings
#norecursedirs = dist tmp* .svn .*

View file

@ -1 +0,0 @@
# Register your models here.

View file

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

View file

@ -1 +0,0 @@
# Create your models here.

View file

@ -1,38 +0,0 @@
"""Registry for services."""
from django import forms
from django_registries.registry import Interface
from django_registries.registry import Registry
class ServiceRegistry(Registry):
"""Registry for services."""
implementations_module = "services"
class ServiceInterface(Interface):
"""Interface for services."""
registry = ServiceRegistry
name: str
description: str
url: str
public: bool = False
# TODO: add a way to add a something which defines the required fields for a service
# - maybe a list of tuples with the field name and the type of the field
# this could be used to generate a form for the service, and also to validate
# the data saved in a JSONField on the ServiceAccess model
subscribe_fields: tuple[tuple[str, forms.Field]] = []
def get_form_class(self) -> type:
"""Get the form class for the service."""
return type(
"ServiceForm",
(forms.Form,),
dict(self.subscribe_fields),
)

View file

@ -1,76 +0,0 @@
"""Service classes for data.coop."""
from django import forms
from .registry import ServiceInterface
class MailService(ServiceInterface):
"""Mail service."""
slug = "mail"
name = "Mail"
url = "https://mail.data.coop"
description = "Mail service for data.coop"
subscribe_fields = (("username", forms.CharField()),)
class MatrixService(ServiceInterface):
"""Matrix service."""
slug = "matrix"
name = "Matrix"
url = "https://matrix.data.coop"
description = "Matrix service for data.coop"
subscribe_fields = (("username", forms.CharField()),)
class MastodonService(ServiceInterface):
"""Mastodon service."""
slug = "mastodon"
name = "Mastodon"
url = "https://social.data.coop"
description = "Mastodon service for data.coop"
subscribe_fields = (("username", forms.CharField()),)
class NextcloudService(ServiceInterface):
"""Nextcloud service."""
slug = "nextcloud"
name = "Nextcloud"
url = "https://cloud.data.coop"
description = "Nextcloud service for data.coop"
class HedgeDocService(ServiceInterface):
"""HedgeDoc service."""
slug = "hedgedoc"
name = "HedgeDoc"
url = "https://pad.data.coop"
public = True
description = "HedgeDoc service for data.coop"
class ForgejoService(ServiceInterface):
"""Forgejo service."""
slug = "forgejo"
name = "Forgejo"
url = "https://git.data.coop"
description = "Git service for data.coop"
class RalllyService(ServiceInterface):
"""Rallly service."""
slug = "rallly"
name = "Rallly"
url = "https://when.data.coop"
public = True
description = "Rallly service for data.coop"

View file

@ -1,9 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="content-view">
<h2>{{ service.name }}</h2>
</div>
{% endblock %}

View file

@ -1,19 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="content-view">
<h2>Subscribe to {{ service.name }}</h2>
<form method="POST">
{% csrf_token %}
{{ form }}
<button type="submit">
Subscribe
</button>
</form>
</div>
{% endblock %}

View file

@ -1,45 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="content-view">
<h2>Services you subscribe to</h2>
<div class="services">
{% for service in active_services %}
<div>
<div class="description">
<h3>{{ service.name }}</h3>
<p>...</p>
<a href="#">Read more &hellip;</a>
</div>
<a>Unsubscribe</a>
</div>
{% empty %}
<p>You are not subscribed to any service.</p>
{% endfor %}
</div>
</div>
<div class="content-view">
<h2>Available services</h2>
<div class="services">
{% for service in non_active_services %}
<div>
<div class="description">
<h3>{{ service.name }}</h3>
<p>{{ service.description }}</p>
<a href="{% url "services:detail" service_slug=service.slug %}">
Read more
</a>
|
<a href="{{ service.url }}" target="_blank">
Visit
</a>
</div>
<a href="{% url "services:subscribe" service_slug=service.slug %}">Subscribe</a>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -1 +0,0 @@
# Create your tests here.

View file

@ -1,114 +0,0 @@
"""Views for the services app."""
from __future__ import annotations
from typing import TYPE_CHECKING
from django.shortcuts import redirect
from django_view_decorator import namespaced_decorator_factory
from membership.models import ServiceAccess
from utils.view_utils import render
from services.registry import ServiceInterface
from services.registry import ServiceRegistry
if TYPE_CHECKING:
from django.contrib.auth.models import User
from django.http import HttpRequest
from django.http import HttpResponse
services_view = namespaced_decorator_factory(
namespace="services",
base_path="services",
)
@services_view(
paths="",
name="list",
login_required=True,
)
def services_overview(request: HttpRequest) -> HttpResponse:
"""View all services."""
active_services = get_services(user=request.user)
active_service_classes = [service.__class__ for service in active_services]
services = [service for _, service in ServiceRegistry.get_items() if service not in active_service_classes]
context = {
"non_active_services": services,
"active_services": active_services,
}
return render(
request=request,
template_name="services/services_overview.html",
context=context,
)
@services_view(
paths="<str:service_slug>/",
name="detail",
login_required=True,
)
def service_detail(request: HttpRequest, service_slug: str) -> HttpResponse:
"""View a service."""
service = ServiceRegistry.get(slug=service_slug)
context = {
"service": service,
}
return render(
request=request,
template_name="services/service_detail.html",
context=context,
)
@services_view(
paths="<str:service_slug>/subscribe/",
name="subscribe",
login_required=True,
)
def service_subscribe(request: HttpRequest, service_slug: str) -> HttpResponse:
"""Subscribe to a service."""
service = ServiceRegistry.get(slug=service_slug)
form_class = service.get_form_class()
if request.method == "POST":
form = form_class(request.POST)
if form.is_valid():
data = form.cleaned_data
service_access = ServiceAccess(
user=request.user,
service=service.slug,
subscription_data=data,
)
service_access.save()
return redirect("services:list")
context = {
"service": service,
"base_path": "services:list",
"form": form_class(),
}
return render(
request=request,
template_name="services/service_subscribe.html",
context=context,
)
def get_services(*, user: User) -> list[ServiceInterface]:
"""Get services for the user."""
return [
access.service_implementation
for access in ServiceAccess.objects.filter(
user=user,
)
]