Compare commits

...

35 commits

Author SHA1 Message Date
Benjamin Bach 43d5dcbd52 Set FROM field on emails (#55)
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #54

Reviewed-on: data.coop/membersystem#55
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-08-14 20:44:11 +00:00
Benjamin Bach f5feda3414 Fix invite text and add accounting context to translation messages (#53)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: data.coop/membersystem#53
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-08-14 19:52:09 +00:00
Benjamin Bach 3659cf40df Add more precision to the order email (#51)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: data.coop/membersystem#51
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-08-14 10:08:01 +00:00
Benjamin Bach 8f3e8f06f0 Remove unique constraint from initial field (#50)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: data.coop/membersystem#50
Reviewed-by: valberg <valberg@orn.li>
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-08-14 09:37:13 +00:00
Benjamin Bach b3795977ed Membership invitations and order emails (#47)
All checks were successful
continuous-integration/drone/push Build is passing
* [x] Create invite emails from admin
* [x] Sign up on special invite form (create password and username)
* [x] Create email with unpaid orders and payment links
* [x] Lodge unpaid orders somewhere in UI for visibility

Reviewed-on: data.coop/membersystem#47
Reviewed-by: valberg <valberg@orn.li>
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-08-14 09:17:29 +00:00
Benjamin Bach c81481747f Add a footer link to the git repo (#49)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: data.coop/membersystem#49
Reviewed-by: valberg <valberg@orn.li>
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-08-14 05:08:02 +00:00
Víðir Valberg Guðmundsson 52b38abf2a chore: cleanup and fix Dockerfile for development
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-09 08:33:14 +02:00
Benjamin Bach 00c615f318 More admin controls + Fix pay/success error 500 (#45)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: data.coop/membersystem#45
Reviewed-by: valberg <valberg@orn.li>
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-08-04 17:12:02 +00:00
Benjamin Bach 1070e93885 Divide the amount received from Stripe by 100.0 (#40)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: data.coop/membersystem#40
Reviewed-by: valberg <valberg@orn.li>
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-08-04 07:34:50 +00:00
Víðir Valberg Guðmundsson ca8987ba3b Use mail_admins instead of send_mail.
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-03 20:53:18 +02:00
Benjamin Bach 4254baf09d Changes to payment models (#32)
All checks were successful
continuous-integration/drone/push Build is passing
Flagging incoming changes, no actions required.

This is stuff I consider "MVP", as in what we need urgently to send out payment links to members and receive payments via Stripe.

- [x] Allow products, orders etc
- [x] Define several products per membership type
- [x] Possibility to create a membership BEFORE it's paid
- [x] Mark memberships active when payments are received
- [x] Create membership history for each member (via Django admin)
- [x] Efficiently mark members in a list and choose "create <membership type> for current year with an unpaid order" (Django Admin actions)
- [x] Order payment page w/ Stripe integration
- [ ] Send email with order payment link
- [ ] Send payment confirmation emails
- [x] Re-generate migrations

Co-authored-by: valberg <valberg@orn.li>
Reviewed-on: data.coop/membersystem#32
Reviewed-by: valberg <valberg@orn.li>
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-08-03 17:55:32 +00:00
Benjamin Bach 59620aa309 Switch to using uv instead of pip-tools (#39)
All checks were successful
continuous-integration/drone/push Build is passing
Seems to work!

Reviewed-on: data.coop/membersystem#39
Reviewed-by: valberg <valberg@orn.li>
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-08-02 22:58:19 +00:00
Benjamin Bach 865bc6c7bd New WaitingListEntry (#33)
All checks were successful
continuous-integration/drone/push Build is passing
Sorted the pre-commit things... some were because of `src/static` being included, and some have been fixed in another PR 🎉

Reviewed-on: data.coop/membersystem#33
Reviewed-by: valberg <valberg@orn.li>
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-07-31 22:49:46 +00:00
Víðir Valberg Guðmundsson 0cf579c5f6 Update dockerfile to using bookworm, and to avoid invalidating cache unless updating requirements.
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-31 23:48:39 +02:00
Víðir Valberg Guðmundsson 7a3a629d6f Update requirements compilation to use hatch-pip-compile.
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-31 23:26:40 +02:00
Benjamin Bach f6d8f82065 Requirements pinning + some cleanup (#36)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: data.coop/membersystem#36
Reviewed-by: valberg <valberg@orn.li>
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-07-31 21:17:00 +00:00
Benjamin Bach 2c99799d4d Shorten and document Docker tweaks for Python and pip (#35)
All checks were successful
continuous-integration/drone/push Build is passing
Wanted to use some of the setup for bootstrapping another project, so I had a close look over these couple of items.

Reviewed-on: data.coop/membersystem#35
Reviewed-by: valberg <valberg@orn.li>
Co-authored-by: Benjamin Bach <benjamin@overtag.dk>
Co-committed-by: Benjamin Bach <benjamin@overtag.dk>
2024-07-23 22:48:24 +00:00
Víðir Valberg Guðmundsson 5d516b7851 Update packages.
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-17 08:44:07 +02:00
Víðir Valberg Guðmundsson f18469833a The ruffening.
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-15 00:19:37 +02:00
Víðir Valberg Guðmundsson 480eecca12 Cleanup. 2024-07-14 23:14:07 +02:00
Víðir Valberg Guðmundsson b39b114e30 pre-commit autoupdate 2024-07-14 20:55:22 +02:00
Víðir Valberg Guðmundsson d31f62ebb4 Upd.
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-31 21:26:20 +02:00
Víðir Valberg Guðmundsson 712c50fac7 Use hatch for installing.
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-03 11:05:21 +01:00
Halfdan Mouritzen fedfca25a5 Adding flow to add new emails to an account
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-02 17:43:16 +01:00
Halfdan Mouritzen 89d7c9c9d5 CSS should be merged correctly now
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-02 14:49:50 +01:00
Halfdan Mouritzen f5acfbedd4 Cleaning 2024-03-02 12:15:33 +01:00
Víðir Valberg Guðmundsson cf3c84b8d9 More linting.
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-29 21:30:36 +01:00
Víðir Valberg Guðmundsson 1b65558608 Lint galore. Also use python 3.12. 2024-02-29 21:28:17 +01:00
Halfdan Mouritzen 4112069cac feature/css (#29)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: data.coop/membersystem#29
Co-authored-by: Halfdan Mouritzen <halfdan@robothangarskib.dk>
Co-committed-by: Halfdan Mouritzen <halfdan@robothangarskib.dk>
2024-02-29 20:02:39 +00:00
Víðir Valberg Guðmundsson bdc2d8717c Add devenv files.
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-10 10:55:38 +01:00
Víðir Valberg Guðmundsson f99c7ee698 Fetch permissions before rendering templates.
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-10 10:50:23 +01:00
Víðir Valberg Guðmundsson a098a0b032 Add logging.
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-09 22:04:30 +01:00
Víðir Valberg Guðmundsson f31cd62351 Implement django-view-decorator
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-14 12:27:36 +01:00
Víðir Valberg Guðmundsson b8a970d5fe Add missing migration 2024-01-14 12:14:51 +01:00
Halfdan Mouritzen 9dda7670df Minimal CSS for tables (#26)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: data.coop/membersystem#26
Co-authored-by: Halfdan Mouritzen <halfdan@robothangarskib.dk>
Co-committed-by: Halfdan Mouritzen <halfdan@robothangarskib.dk>
2024-01-14 11:10:55 +00:00
80 changed files with 2956 additions and 3256 deletions

View file

@ -3,5 +3,8 @@
*/.* */.*
!src/ !src/
!requirements.txt
!requirements/ !requirements/
!entrypoint.sh !entrypoint.sh
!pyproject.toml
!README.md

View file

@ -7,7 +7,6 @@ steps:
- name: docker - name: docker
image: plugins/docker image: plugins/docker
environment: environment:
DJANGO_ENV: production
BUILD: "${DRONE_COMMIT_SHA}" BUILD: "${DRONE_COMMIT_SHA}"
settings: settings:
repo: docker.data.coop/membersystem repo: docker.data.coop/membersystem
@ -17,7 +16,6 @@ steps:
password: password:
from_secret: DOCKER_PASSWORD from_secret: DOCKER_PASSWORD
build_args_from_env: build_args_from_env:
- DJANGO_ENV
- BUILD - BUILD
tags: tags:
- "${DRONE_BUILD_NUMBER}" - "${DRONE_BUILD_NUMBER}"

View file

@ -6,4 +6,5 @@ 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
DJANGO_ENV=development STRIPE_API_KEY=sk_test_
STRIPE_ENDPOINT_SECRET=whsec_

4
.gitignore vendored
View file

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

View file

@ -1,9 +1,9 @@
default_language_version: default_language_version:
python: python3.11 python: python3
exclude: ^.*\b(migrations)\b.*$ exclude: ^.*\b(migrations)\b.*$
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0 rev: v4.6.0
hooks: hooks:
- id: check-ast - id: check-ast
- id: check-merge-conflict - id: check-merge-conflict
@ -15,46 +15,23 @@ repos:
- id: check-toml - id: check-toml
- id: end-of-file-fixer - id: end-of-file-fixer
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.1.11' rev: 'v0.5.2'
hooks: hooks:
- id: ruff - id: ruff
args: args:
- --fix - --fix
- repo: https://github.com/asottile/reorder_python_imports - id: ruff-format
rev: v3.12.0
hooks:
- id: reorder-python-imports
args:
- --py310-plus
- --application-directories=.:src
exclude: migrations/
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.15.0 rev: v3.16.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: args:
- --py311-plus - --py311-plus
exclude: migrations/ exclude: migrations/
- repo: https://github.com/adamchainz/django-upgrade - repo: https://github.com/adamchainz/django-upgrade
rev: 1.15.0 rev: 1.19.0
hooks: hooks:
- id: django-upgrade - id: django-upgrade
args: args:
- --target-version=4.1 - --target-version=5.0
- repo: https://github.com/asottile/yesqa
rev: v1.5.0
hooks:
- id: yesqa
- repo: https://github.com/asottile/add-trailing-comma
rev: v3.1.0
hooks:
- id: add-trailing-comma
- repo: https://github.com/hadialqattan/pycln
rev: v2.4.0
hooks:
- id: pycln
- repo: https://github.com/psf/black
rev: 23.12.1
hooks:
- id: black

View file

@ -1,18 +1,33 @@
FROM python:3.11-slim-bullseye FROM python:3.12-slim-bookworm
# PYTHONFAULTHANDLER: Propagate tracebacks from all threads.
# PYTHONUNBUFFERED: Write terminal output straight to docker (to not confuse Docker Compose).
# PYTHONDONTWRITEBYTECODE: Dont write *pyc files at all, making it possible for a 100% read-only container.
# PIP_NO_CACHE_DIR: Disable PIP cache, we don't need pip's cache after building the image.
# PIP_DISABLE_PIP_VERSION_CHECK: Build the image with the available pip, do not check for updates (faster!)
# PIP_DEFAULT_TIMEOUT: Allow for longer timeouts.
ENV PYTHONFAULTHANDLER=1 \ ENV PYTHONFAULTHANDLER=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
PYTHONHASHSEED=random \ PIP_NO_CACHE_DIR=1 \
PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=on \ PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 PIP_DEFAULT_TIMEOUT=100
ARG DJANGO_ENV
ARG BUILD ARG BUILD
ENV BUILD ${BUILD} ENV BUILD=${BUILD}
ARG REQUIREMENTS_FILE=requirements.txt
RUN apt-get update \ WORKDIR /app
&& apt-get install -y \
RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www
# Only copy the requirements file first to leverage Docker cache
RUN mkdir requirements/
COPY $REQUIREMENTS_FILE $REQUIREMENTS_FILE
RUN mkdir -p /app/src/static && \
chown www:www /app/src/static && \
apt-get update && \
apt-get install -y \
binutils \ binutils \
libpq-dev \ libpq-dev \
build-essential \ build-essential \
@ -23,15 +38,13 @@ RUN apt-get update \
libgdk-pixbuf2.0-0 \ libgdk-pixbuf2.0-0 \
libffi-dev \ libffi-dev \
shared-mime-info \ shared-mime-info \
gettext gettext && \
pip install --no-cache-dir -r $REQUIREMENTS_FILE
WORKDIR /app # Copy the rest of the application
COPY . .
RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www RUN django-admin compilemessages
COPY --chown=www:www . /app/
RUN mkdir /app/src/static && chown www:www /app/src/static
RUN pip install -r requirements/$([ "$DJANGO_ENV" = "production" ] && echo "base.txt" || echo "dev.txt") &&\
django-admin compilemessages
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]

View file

@ -1,27 +1,12 @@
.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
DOCKER_CONTAINER_NAME = backend
MANAGE_EXEC = python /app/src/manage.py MANAGE_EXEC = python /app/src/manage.py
MANAGE_COMMAND = ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} ${MANAGE_EXEC} MANAGE_COMMAND = ${DOCKER_RUN} app ${MANAGE_EXEC}
init: setup_venv pre_commit_install migrate
run: run:
${DOCKER_COMPOSE} up ${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
pre_commit_run_all:
venv/bin/pre-commit run --all-files
makemigrations: makemigrations:
${MANAGE_COMMAND} makemigrations ${ARGS} ${MANAGE_COMMAND} makemigrations ${ARGS}
@ -37,22 +22,8 @@ shell:
manage_command: manage_command:
${MANAGE_COMMAND} ${ARGS} ${MANAGE_COMMAND} ${ARGS}
add_dependency: build:
${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry add --lock ${DEPENDENCY} ${DOCKER_COMPOSE} build
add_dev_dependency: requirements:
${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry add -D --lock ${DEPENDENCY} hatch run requirements
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
${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

102
README.md
View file

@ -1,71 +1,105 @@
# member.data.coop # data.coop member system
## Development ## Development setup
### Setup environment There are two ways to setup the development environment.
Copy over the .env.example file to .env and adjust DATABASE_URL accordingly - Using the Docker Compose setup provided in this repository.
- Using [hatch](https://hatch.pypa.io/) in your host OS.
$ cp .env.example .env ### Using Docker Compose
### Docker Working with the Docker Compose setup is made easy with the `Makefile` provided in the repository.
#### Requirements #### Requirements
- Docker - Docker
- Docker compose - docker compose plugin
- pre-commit (preferred for contributions)
#### Setup #### Setup
Given that the requirements above are installed, it should be as easy as: 1. Setup .env file
$ make migrate An example .env file is provided in the repository. You can copy it to .env file using the following command:
This will setup the database. Next run: ```bash
cp .env.example .env
```
$ make run The default values in the .env file are suitable for the docker-compose setup.
This will build the docker image and start the member system on http://localhost:8000. 2. Migrate
You can create a superuser by running: ```bash
make migrate
```
$ make createsuperuser 3. Run the development server
Make migrations: ```bash
make run
```
$ make makemigrations #### Building and running other things
Make messages: ```bash
# Build the containers
make build
$ make makemessages # Create a superuser
make createsuperuser
Running tests: # Create Django migrations (after this, maybe you need to change file permissions in volume)
make makemigrations
```
$ make test ### Using hatch
### Non-docker #### Requirements
Create a venv - Python 3.12 or higher
- [hatch](https://hatch.pypa.io/) (Recommended way to install is using `pipx install hatch`)
- A running PostgreSQL server
$ python3 -m venv venv #### Setup
Activate the venv 1. Setup .env file
$ source venv/bin/activate An example .env file is provided in the repository. You can copy it to .env file using the following command:
Install requirements ```bash
cp .env.example .env
```
$ pip install -r requirements/dev.txt Edit the .env file and set the values for the environment variables, especially the database variables.
Run migrations 2. Run migrate
$ ./src/manage.py migrate ```bash
hatch run dev:migrate
```
Create a superuser 3. Run the development server
$ ./src/manage.py createsuperuser ```bash
hatch run dev:server
```
Run the server ### Updating requirements
$ ./src/manage.py runserver 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
# Build requirements.txt etc
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

@ -1,11 +1,10 @@
version: '3.7' ---
services: services:
app:
backend:
image: data_coop_membersystem:dev
build: build:
context: . context: .
args:
- REQUIREMENTS_FILE=requirements/requirements-dev.txt
command: python /app/src/manage.py runserver 0.0.0.0:8000 command: python /app/src/manage.py runserver 0.0.0.0:8000
tty: true tty: true
ports: ports:
@ -28,3 +27,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
...

View file

@ -1,5 +1,5 @@
[build-system] [build-system]
requires = ["hatchling", "hatch-vcs"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[project] [project]
@ -12,22 +12,37 @@ 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.0.1", "Django~=5.1",
"django-money==3.4.1", "django-allauth~=0.63",
"django-allauth==0.60.0", "django-money~=3.5",
"psycopg[binary]==3.1.16", "django-oauth-toolkit~=2.4",
"environs[django]==10.0.0",
"uvicorn==0.25.0",
"whitenoise==6.6.0",
"django-zen-queries==2.1.0",
"django-registries==0.0.3", "django-registries==0.0.3",
"django-view-decorator==0.0.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",
] ]
dynamic = ["version"] version = "0.0.1"
[tool.hatch.version] [tool.hatch.build.targets.wheel]
source = "vcs" packages = ["src"]
[tool.hatch.env]
requires = ["hatch-pip-compile"]
[tool.hatch.envs.default] [tool.hatch.envs.default]
type = "pip-compile"
pip-compile-resolver = "uv"
[tool.hatch.envs.dev]
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",
@ -43,26 +58,25 @@ dependencies = [
[[tool.hatch.envs.tests.matrix]] [[tool.hatch.envs.tests.matrix]]
python = ["3.12"] python = ["3.12"]
django = ["4.2", "5.0"] django = ["5.1"]
[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.default.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 = "pip-compile --output-file requirements/base.txt pyproject.toml" requirements = "hatch env run --env default -- python --version; hatch env run --env dev -- python --version"
server = "./src/manage.py runserver" 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"
@ -95,11 +109,58 @@ 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 = "tests.settings" django_settings_module = "project.settings"
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "tests.*" module = "tests.*"
allow_untyped_defs = true allow_untyped_defs = true
[tool.ruff]
target-version = "py312"
extend-exclude = [
".git",
"__pycache__",
"manage.py",
"asgi.py",
"wsgi.py",
]
line-length = 120
[tool.ruff.lint]
select = ["ALL"]
ignore = [
"G004", # Logging statement uses f-string
"ANN101", # Missing type annotation for `self` in method
"ANN102", # Missing type annotation for `cls` in classmethod
"EM101", # Exception must not use a string literal, assign to variable first
"EM102", # Exception must not use a f-string literal, assign to variable first
"COM812", # missing-trailing-comma (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
"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
"D106", # Missing docstring in public nested class
"D107", # Missing docstring in `__init__`
"FIX", # TODO, FIXME, XXX
"TD", # TODO, FIXME, XXX
"ANN002", # Missing type annotation for `*args`
"ANN003", # Missing type annotation for `**kwargs`
"FBT001", # Misbehaves: Boolean-typed positional argument in function definition
"FBT002", # Misbehaves: Boolean-typed positional argument in function definition
"TRY003", # Avoid specifying long messages outside the exception class
]
[tool.ruff.lint.isort]
force-single-line = true
[tool.ruff.lint.per-file-ignores]
"tests.py" = [
"S101", # Use of assert
"SLF001", # Private member access
"D100", # Docstrings
"D103", # Docstrings
]

116
requirements.txt Normal file
View file

@ -0,0 +1,116 @@
#
# This file is autogenerated by hatch-pip-compile with Python 3.12
#
# - django-allauth~=0.63
# - django-money~=3.5
# - django-oauth-toolkit~=2.4
# - django-ratelimit~=4.1
# - django-registries==0.0.3
# - django-stubs-ext~=5.0
# - django-view-decorator==0.0.4
# - django-zen-queries~=2.1
# - django<5.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
# via py-moneyed
certifi==2024.7.4
# via requests
cffi==1.16.0
# via cryptography
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via uvicorn
cryptography==43.0.0
# via jwcrypto
dj-database-url==2.2.0
# via environs
dj-email-url==1.0.6
# via environs
django==5.1rc1
# via
# hatch.envs.default
# dj-database-url
# django-allauth
# django-money
# django-oauth-toolkit
# django-registries
# django-stubs-ext
# django-view-decorator
# django-zen-queries
django-allauth==0.63.6
# via hatch.envs.default
django-cache-url==3.4.5
# via environs
django-money==3.5.3
# via hatch.envs.default
django-oauth-toolkit==2.4.0
# via hatch.envs.default
django-ratelimit==4.1.0
# via hatch.envs.default
django-registries==0.0.3
# via hatch.envs.default
django-stubs-ext==5.0.4
# via hatch.envs.default
django-view-decorator==0.0.4
# via hatch.envs.default
django-zen-queries==2.1.0
# via hatch.envs.default
environs==11.0.0
# via hatch.envs.default
h11==0.14.0
# via uvicorn
idna==3.7
# via requests
jwcrypto==1.5.6
# via django-oauth-toolkit
marshmallow==3.21.3
# via environs
oauthlib==3.2.2
# via django-oauth-toolkit
packaging==24.1
# via marshmallow
psycopg==3.2.1
# via hatch.envs.default
psycopg-binary==3.2.1
# via psycopg
py-moneyed==3.0
# via django-money
pycparser==2.22
# via cffi
python-dotenv==1.0.1
# via environs
pytz==2024.1
# via django-oauth-toolkit
requests==2.32.3
# via
# django-oauth-toolkit
# stripe
setuptools==72.1.0
# via django-money
sqlparse==0.5.1
# via django
stripe==10.6.0
# via hatch.envs.default
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.5
# via hatch.envs.default
whitenoise==6.7.0
# via hatch.envs.default

View file

@ -1,8 +0,0 @@
Django==5.0.1
django-money==3.4.1
django-allauth==0.60.0
psycopg[binary]==3.1.16
environs[django]==10.0.0
uvicorn==0.25.0
whitenoise==6.6.0
django-zen-queries==2.1.0

View file

@ -1,98 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --output-file=requirements/base.txt pyproject.toml
#
asgiref==3.7.2
# via django
babel==2.14.0
# via py-moneyed
certifi==2023.11.17
# via requests
cffi==1.16.0
# via cryptography
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via uvicorn
cryptography==41.0.7
# via pyjwt
defusedxml==0.7.1
# via python3-openid
dj-database-url==2.1.0
# via environs
dj-email-url==1.0.6
# via environs
django==5.0.1
# via
# dj-database-url
# django-allauth
# django-money
# django-registries
# django-zen-queries
# membersystem (pyproject.toml)
django-allauth==0.60.0
# via membersystem (pyproject.toml)
django-cache-url==3.4.5
# via environs
django-money==3.4.1
# via membersystem (pyproject.toml)
django-registries==0.0.3
# via membersystem (pyproject.toml)
django-zen-queries==2.1.0
# via membersystem (pyproject.toml)
environs[django]==10.0.0
# via
# environs
# membersystem (pyproject.toml)
h11==0.14.0
# via uvicorn
idna==3.6
# via requests
marshmallow==3.20.1
# via environs
oauthlib==3.2.2
# via requests-oauthlib
packaging==23.2
# via marshmallow
psycopg[binary]==3.1.16
# via
# membersystem (pyproject.toml)
# psycopg
psycopg-binary==3.1.16
# via psycopg
py-moneyed==3.0
# via django-money
pycparser==2.21
# via cffi
pyjwt[crypto]==2.8.0
# via
# django-allauth
# pyjwt
python-dotenv==1.0.0
# via environs
python3-openid==3.2.0
# via django-allauth
requests==2.31.0
# via
# django-allauth
# requests-oauthlib
requests-oauthlib==1.3.1
# via django-allauth
sqlparse==0.4.4
# via django
typing-extensions==4.9.0
# via
# dj-database-url
# psycopg
# py-moneyed
urllib3==2.1.0
# via requests
uvicorn==0.25.0
# via membersystem (pyproject.toml)
whitenoise==6.6.0
# via membersystem (pyproject.toml)
# The following packages are considered to be unsafe in a requirements file:
# setuptools

View file

@ -1,8 +0,0 @@
-r test.txt
django-browser-reload==1.12.1
django-debug-toolbar==4.2.0
django-extensions==3.2.3
django-stubs==4.2.7
ipython==8.19.0
mypy==1.8.0

View file

@ -1,213 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --output-file=requirements/dev.txt requirements/dev.in
#
asgiref==3.7.2
# via
# -r requirements/test.txt
# django
# django-browser-reload
asttokens==2.4.1
# via stack-data
babel==2.14.0
# via
# -r requirements/test.txt
# py-moneyed
certifi==2023.11.17
# via
# -r requirements/test.txt
# requests
cffi==1.16.0
# via
# -r requirements/test.txt
# cryptography
charset-normalizer==3.3.2
# via
# -r requirements/test.txt
# requests
click==8.1.7
# via
# -r requirements/test.txt
# uvicorn
coverage==7.4.0
# via -r requirements/test.txt
cryptography==41.0.7
# via
# -r requirements/test.txt
# pyjwt
decorator==5.1.1
# via ipython
defusedxml==0.7.1
# via
# -r requirements/test.txt
# python3-openid
dj-database-url==2.1.0
# via
# -r requirements/test.txt
# environs
dj-email-url==1.0.6
# via
# -r requirements/test.txt
# environs
django==5.0.1
# via
# -r requirements/test.txt
# dj-database-url
# django-allauth
# django-browser-reload
# django-debug-toolbar
# django-extensions
# django-money
# django-stubs
# django-stubs-ext
# django-zen-queries
django-allauth==0.60.0
# via -r requirements/test.txt
django-browser-reload==1.12.1
# via -r requirements/dev.in
django-cache-url==3.4.5
# via
# -r requirements/test.txt
# environs
django-debug-toolbar==4.2.0
# via -r requirements/dev.in
django-extensions==3.2.3
# via -r requirements/dev.in
django-money==3.4.1
# via -r requirements/test.txt
django-stubs==4.2.7
# via -r requirements/dev.in
django-stubs-ext==4.2.7
# via django-stubs
django-zen-queries==2.1.0
# via -r requirements/test.txt
environs[django]==10.0.0
# via -r requirements/test.txt
executing==2.0.1
# via stack-data
h11==0.14.0
# via
# -r requirements/test.txt
# uvicorn
idna==3.6
# via
# -r requirements/test.txt
# requests
ipython==8.19.0
# via -r requirements/dev.in
jedi==0.19.1
# via ipython
lxml==5.0.1
# via
# -r requirements/test.txt
# unittest-xml-reporting
marshmallow==3.20.1
# via
# -r requirements/test.txt
# environs
matplotlib-inline==0.1.6
# via ipython
mypy==1.8.0
# via -r requirements/dev.in
mypy-extensions==1.0.0
# via mypy
oauthlib==3.2.2
# via
# -r requirements/test.txt
# requests-oauthlib
packaging==23.2
# via
# -r requirements/test.txt
# marshmallow
parso==0.8.3
# via jedi
pexpect==4.9.0
# via ipython
prompt-toolkit==3.0.43
# via ipython
psycopg[binary]==3.1.16
# via -r requirements/test.txt
psycopg-binary==3.1.16
# via
# -r requirements/test.txt
# psycopg
ptyprocess==0.7.0
# via pexpect
pure-eval==0.2.2
# via stack-data
py-moneyed==3.0
# via
# -r requirements/test.txt
# django-money
pycparser==2.21
# via
# -r requirements/test.txt
# cffi
pygments==2.17.2
# via ipython
pyjwt[crypto]==2.8.0
# via
# -r requirements/test.txt
# django-allauth
python-dotenv==1.0.0
# via
# -r requirements/test.txt
# environs
python3-openid==3.2.0
# via
# -r requirements/test.txt
# django-allauth
requests==2.31.0
# via
# -r requirements/test.txt
# django-allauth
# requests-oauthlib
requests-oauthlib==1.3.1
# via
# -r requirements/test.txt
# django-allauth
six==1.16.0
# via asttokens
sqlparse==0.4.4
# via
# -r requirements/test.txt
# django
# django-debug-toolbar
stack-data==0.6.3
# via ipython
tblib==3.0.0
# via -r requirements/test.txt
traitlets==5.14.1
# via
# ipython
# matplotlib-inline
types-pytz==2023.3.1.1
# via django-stubs
types-pyyaml==6.0.12.12
# via django-stubs
typing-extensions==4.9.0
# via
# -r requirements/test.txt
# dj-database-url
# django-stubs
# django-stubs-ext
# mypy
# psycopg
# py-moneyed
unittest-xml-reporting==3.2.0
# via -r requirements/test.txt
urllib3==2.1.0
# via
# -r requirements/test.txt
# requests
uvicorn==0.25.0
# via -r requirements/test.txt
wcwidth==0.2.13
# via prompt-toolkit
whitenoise==6.6.0
# via -r requirements/test.txt
# The following packages are considered to be unsafe in a requirements file:
# setuptools

View file

@ -0,0 +1,192 @@
#
# This file is autogenerated by hatch-pip-compile with Python 3.12
#
# - coverage[toml]==7.3.0
# - pytest==7.2.2
# - pytest-cov
# - pytest-django==4.5.2
# - mypy==1.1.1
# - django-stubs==1.16.0
# - pip-tools==7.3.0
# - django-debug-toolbar==4.2.0
# - django-browser-reload==1.7.0
# - model-bakery==1.17.0
# - django-allauth~=0.63
# - django-money~=3.5
# - django-oauth-toolkit~=2.4
# - django-ratelimit~=4.1
# - django-registries==0.0.3
# - django-stubs-ext~=5.0
# - django-view-decorator==0.0.4
# - django-zen-queries~=2.1
# - django<5.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
attrs==23.2.0
# via pytest
babel==2.15.0
# via py-moneyed
build==1.2.1
# via pip-tools
certifi==2024.7.4
# via requests
cffi==1.16.0
# via cryptography
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via
# pip-tools
# uvicorn
coverage==7.3.0
# via
# hatch.envs.dev
# pytest-cov
cryptography==43.0.0
# via jwcrypto
dj-database-url==2.2.0
# via environs
dj-email-url==1.0.6
# via environs
django==5.1rc1
# via
# hatch.envs.dev
# dj-database-url
# django-allauth
# django-browser-reload
# django-debug-toolbar
# django-money
# django-oauth-toolkit
# django-registries
# django-stubs
# django-stubs-ext
# django-view-decorator
# django-zen-queries
# model-bakery
django-allauth==0.63.6
# via hatch.envs.dev
django-browser-reload==1.7.0
# via hatch.envs.dev
django-cache-url==3.4.5
# via environs
django-debug-toolbar==4.2.0
# via hatch.envs.dev
django-money==3.5.3
# via hatch.envs.dev
django-oauth-toolkit==2.4.0
# via hatch.envs.dev
django-ratelimit==4.1.0
# via hatch.envs.dev
django-registries==0.0.3
# via hatch.envs.dev
django-stubs==1.16.0
# via hatch.envs.dev
django-stubs-ext==5.0.4
# 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
h11==0.14.0
# via uvicorn
idna==3.7
# via requests
iniconfig==2.0.0
# via pytest
jwcrypto==1.5.6
# via django-oauth-toolkit
marshmallow==3.21.3
# via environs
model-bakery==1.17.0
# via hatch.envs.dev
mypy==1.1.1
# via
# hatch.envs.dev
# django-stubs
mypy-extensions==1.0.0
# via mypy
oauthlib==3.2.2
# via django-oauth-toolkit
packaging==24.1
# via
# build
# marshmallow
# pytest
pip==24.2
# via pip-tools
pip-tools==7.3.0
# via hatch.envs.dev
pluggy==1.5.0
# via pytest
psycopg==3.2.1
# via hatch.envs.dev
psycopg-binary==3.2.1
# via psycopg
py-moneyed==3.0
# via django-money
pycparser==2.22
# via cffi
pyproject-hooks==1.1.0
# via build
pytest==7.2.2
# via
# hatch.envs.dev
# pytest-cov
# pytest-django
pytest-cov==5.0.0
# via hatch.envs.dev
pytest-django==4.5.2
# via hatch.envs.dev
python-dotenv==1.0.1
# via environs
pytz==2024.1
# via django-oauth-toolkit
requests==2.32.3
# via
# django-oauth-toolkit
# stripe
setuptools==72.1.0
# via
# django-money
# pip-tools
sqlparse==0.5.1
# via
# django
# django-debug-toolbar
stripe==10.6.0
# via hatch.envs.dev
tomli==2.0.1
# via django-stubs
types-pytz==2024.1.0.20240417
# via django-stubs
types-pyyaml==6.0.12.20240724
# via django-stubs
typing-extensions==4.12.2
# via
# dj-database-url
# django-stubs
# django-stubs-ext
# jwcrypto
# mypy
# psycopg
# py-moneyed
# stripe
urllib3==2.2.2
# via requests
uvicorn==0.30.5
# via hatch.envs.dev
wheel==0.43.0
# via pip-tools
whitenoise==6.7.0
# via hatch.envs.dev

View file

@ -1,5 +0,0 @@
-r base.txt
coverage==7.4.0
tblib==3.0.0
unittest-xml-reporting==3.2.0

View file

@ -1,149 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --output-file=requirements/test.txt requirements/test.in
#
asgiref==3.7.2
# via
# -r requirements/base.txt
# django
babel==2.14.0
# via
# -r requirements/base.txt
# py-moneyed
certifi==2023.11.17
# via
# -r requirements/base.txt
# requests
cffi==1.16.0
# via
# -r requirements/base.txt
# cryptography
charset-normalizer==3.3.2
# via
# -r requirements/base.txt
# requests
click==8.1.7
# via
# -r requirements/base.txt
# uvicorn
coverage==7.4.0
# via -r requirements/test.in
cryptography==41.0.7
# via
# -r requirements/base.txt
# pyjwt
defusedxml==0.7.1
# via
# -r requirements/base.txt
# python3-openid
dj-database-url==2.1.0
# via
# -r requirements/base.txt
# environs
dj-email-url==1.0.6
# via
# -r requirements/base.txt
# environs
django==5.0.1
# via
# -r requirements/base.txt
# dj-database-url
# django-allauth
# django-money
# django-zen-queries
django-allauth==0.60.0
# via -r requirements/base.txt
django-cache-url==3.4.5
# via
# -r requirements/base.txt
# environs
django-money==3.4.1
# via -r requirements/base.txt
django-zen-queries==2.1.0
# via -r requirements/base.txt
environs[django]==10.0.0
# via -r requirements/base.txt
h11==0.14.0
# via
# -r requirements/base.txt
# uvicorn
idna==3.6
# via
# -r requirements/base.txt
# requests
lxml==5.0.1
# via unittest-xml-reporting
marshmallow==3.20.1
# via
# -r requirements/base.txt
# environs
oauthlib==3.2.2
# via
# -r requirements/base.txt
# requests-oauthlib
packaging==23.2
# via
# -r requirements/base.txt
# marshmallow
psycopg[binary]==3.1.16
# via -r requirements/base.txt
psycopg-binary==3.1.16
# via
# -r requirements/base.txt
# psycopg
py-moneyed==3.0
# via
# -r requirements/base.txt
# django-money
pycparser==2.21
# via
# -r requirements/base.txt
# cffi
pyjwt[crypto]==2.8.0
# via
# -r requirements/base.txt
# django-allauth
python-dotenv==1.0.0
# via
# -r requirements/base.txt
# environs
python3-openid==3.2.0
# via
# -r requirements/base.txt
# django-allauth
requests==2.31.0
# via
# -r requirements/base.txt
# django-allauth
# requests-oauthlib
requests-oauthlib==1.3.1
# via
# -r requirements/base.txt
# django-allauth
sqlparse==0.4.4
# via
# -r requirements/base.txt
# django
tblib==3.0.0
# via -r requirements/test.in
typing-extensions==4.9.0
# via
# -r requirements/base.txt
# dj-database-url
# psycopg
# py-moneyed
unittest-xml-reporting==3.2.0
# via -r requirements/test.in
urllib3==2.1.0
# via
# -r requirements/base.txt
# requests
uvicorn==0.25.0
# via -r requirements/base.txt
whitenoise==6.6.0
# via -r requirements/base.txt
# The following packages are considered to be unsafe in a requirements file:
# setuptools

View file

@ -0,0 +1 @@
"""Accounting app."""

View file

@ -1,26 +1,94 @@
"""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 . import models
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: 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) @admin.register(models.Order)
class OrderAdmin(admin.ModelAdmin): class OrderAdmin(admin.ModelAdmin):
list_display = ("who", "description", "created", "is_paid") """Admin for the Order model."""
@admin.display(description=_("Customer")) inlines = (OrderProductInline,)
def who(self, instance): form = OrderAdminForm
return instance.user.get_full_name()
actions = ("send_order",)
list_display = ("member", "description", "created", "is_paid", "total_with_vat")
search_fields = ("member__email", "membership__membership_type__name", "description")
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(models.Payment)
class PaymentAdmin(admin.ModelAdmin): class PaymentAdmin(admin.ModelAdmin):
list_display = ("who", "description", "order_id", "created") """Admin for the Payment model."""
@admin.display(description=_("Customer")) list_display = ("order__member", "description", "order_id", "created")
def who(self, instance):
return instance.order.user.get_full_name()
@admin.display(description=_("Order ID")) @admin.display(description=_("Order ID"))
def order_id(self, instance): def order_id(self, instance: models.Payment) -> int:
"""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

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

View file

@ -7,7 +7,6 @@ from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
@ -99,9 +98,7 @@ class Migration(migrations.Migration):
), ),
( (
"vat", "vat",
djmoney.models.fields.MoneyField( djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16, verbose_name="VAT"),
decimal_places=2, max_digits=16, verbose_name="VAT"
),
), ),
("is_paid", models.BooleanField(default=False, verbose_name="is paid")), ("is_paid", models.BooleanField(default=False, verbose_name="is paid")),
( (

View file

@ -0,0 +1,41 @@
# Generated by Django 5.0.1 on 2024-01-14 11:14
import djmoney.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounting", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="order",
name="price_currency",
field=djmoney.models.fields.CurrencyField(
choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
),
),
migrations.AlterField(
model_name="order",
name="vat_currency",
field=djmoney.models.fields.CurrencyField(
choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
),
),
migrations.AlterField(
model_name="payment",
name="amount_currency",
field=djmoney.models.fields.CurrencyField(
choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
),
),
migrations.AlterField(
model_name="transaction",
name="amount_currency",
field=djmoney.models.fields.CurrencyField(
choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
),
),
]

View file

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

View file

@ -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,
},
),
]

View file

@ -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'),
),
]

View file

@ -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', '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

@ -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'),
),
]

View file

@ -1,14 +1,21 @@
"""Models for the accounting app."""
from hashlib import md5 from hashlib import md5
from typing import Self
from django.conf import settings from django.conf import settings
from django.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 _
from django.utils.translation import pgettext_lazy from django.utils.translation import pgettext_lazy
from djmoney.models.fields import MoneyField from djmoney.models.fields import MoneyField
from djmoney.money import Money
class CreatedModifiedAbstract(models.Model): class CreatedModifiedAbstract(models.Model):
"""Abstract model to track creation and modification of objects."""
modified = models.DateTimeField(auto_now=True, verbose_name=_("modified")) modified = models.DateTimeField(auto_now=True, verbose_name=_("modified"))
created = models.DateTimeField(auto_now_add=True, verbose_name=_("created")) created = models.DateTimeField(auto_now_add=True, verbose_name=_("created"))
@ -17,20 +24,26 @@ class CreatedModifiedAbstract(models.Model):
class Account(CreatedModifiedAbstract): class Account(CreatedModifiedAbstract):
""" """An account for a user.
This is the model where we can give access to several users, such that they This is the model where we can give access to several users, such that they
can decide which account to use to pay for something. can decide which account to use to pay for something.
""" """
owner = models.ForeignKey("auth.User", on_delete=models.PROTECT) owner = models.ForeignKey("membership.Member", on_delete=models.PROTECT)
def __str__(self) -> str:
return f"Account of {self.owner}"
@property @property
def balance(self): def balance(self) -> Money:
"""Return the balance of the account."""
return self.transactions.all().aggregate(Sum("amount")).get("amount", 0) return self.transactions.all().aggregate(Sum("amount")).get("amount", 0)
class Transaction(CreatedModifiedAbstract): class Transaction(CreatedModifiedAbstract):
""" """A transaction.
Tracks in and outgoing events of an account. When an order is received, an Tracks in and outgoing events of an account. When an order is received, an
amount is subtracted, when a payment is received, an amount is added. amount is subtracted, when a payment is received, an amount is added.
""" """
@ -48,77 +61,149 @@ class Transaction(CreatedModifiedAbstract):
) )
description = models.CharField(max_length=1024, verbose_name=_("description")) description = models.CharField(max_length=1024, verbose_name=_("description"))
def __str__(self) -> str:
return f"Transaction of {self.amount} for {self.account}"
class Order(CreatedModifiedAbstract): class Order(CreatedModifiedAbstract):
""" """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 We assemble the order from a number of products. Once an order is paid, the contents should be
invoices at the moment. 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) 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"))
@property class Meta:
def total(self): verbose_name = pgettext_lazy("accounting", "Order")
return self.price + self.vat verbose_name_plural = pgettext_lazy("accounting", "Orders")
def __str__(self) -> str:
return f"Order ID {self.display_id}"
@property @property
def display_id(self): def total(self) -> Money:
"""Return the total price of the order (excl VAT)."""
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(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
def display_id(self) -> str:
"""Return an id for the order."""
return str(self.id).zfill(6) return str(self.id).zfill(6)
@property @property
def payment_token(self): def payment_token(self) -> str:
"""Return a token for the payment."""
pk = str(self.pk).encode("utf-8") pk = str(self.pk).encode("utf-8")
x = md5() x = md5() # noqa: S324
x.update(pk) x.update(pk)
extra_hash = (settings.SECRET_KEY + "blah").encode("utf-8") extra_hash = (settings.SECRET_KEY + "blah").encode("utf-8")
x.update(extra_hash) x.update(extra_hash)
return x.hexdigest() return x.hexdigest()
class Meta:
verbose_name = pgettext_lazy("accounting term", "Order")
verbose_name_plural = pgettext_lazy("accounting term", "Orders")
def __str__(self): class Product(CreatedModifiedAbstract):
return f"Order ID {self.display_id}" """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."""
amount = MoneyField(max_digits=16, decimal_places=2) amount = MoneyField(max_digits=16, decimal_places=2)
order = models.ForeignKey(Order, on_delete=models.PROTECT) order = models.ForeignKey(Order, on_delete=models.PROTECT)
description = models.CharField(max_length=1024, verbose_name=_("description")) description = models.CharField(max_length=1024, verbose_name=_("description"))
stripe_charge_id = models.CharField(max_length=255, null=True, blank=True) payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT)
external_transaction_id = models.CharField(max_length=255, default="", blank=True)
@property
def display_id(self):
return str(self.id).zfill(6)
@classmethod
def from_order(cls, order):
return cls.objects.create(
order=order,
user=order.user,
amount=order.total,
description=order.description,
)
def __str__(self):
return f"Payment ID {self.display_id}"
class Meta: class Meta:
verbose_name = _("payment") verbose_name = _("payment")
verbose_name_plural = _("payments") verbose_name_plural = _("payments")
def __str__(self) -> str:
return f"Payment ID {self.display_id}"
@property
def display_id(self) -> str:
"""Return an id for the payment."""
return str(self.id).zfill(6)
@classmethod
def from_order(cls, order: Order, payment_type: "PaymentType") -> Self:
"""Create a payment from an order."""
return cls.objects.create(
order=order,
user=order.user,
amount=order.total + order.total_vat,
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}"

36
src/accounting/signals.py Normal file
View file

@ -0,0 +1,36 @@
"""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

@ -0,0 +1,18 @@
{% 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

@ -0,0 +1,49 @@
{% 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

@ -0,0 +1,20 @@
{% 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

@ -8,8 +8,8 @@ from . import models
# do stuff # do stuff
@pytest.mark.django_db @pytest.mark.django_db()
def test_balance(): def test_balance() -> None:
user = User.objects.create_user("test", "lala@adas.com", "1234") user = User.objects.create_user("test", "lala@adas.com", "1234")
account = models.Account.objects.create(owner=user) account = models.Account.objects.create(owner=user)
assert account.balance == 0 assert account.balance == 0

177
src/accounting/views.py Normal file
View file

@ -0,0 +1,177 @@
"""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,8 +1,5 @@
""" """Membership application.
Membership application
======================
This application's domain relate to organizational structures and This application's domain relate to organizational structures and
implementation of statutes, policies etc. implementation of statutes, policies etc.
""" """

View file

@ -1,20 +1,168 @@
from django.contrib import admin """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 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 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):
pass """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):
pass """Admin for MembershipType model."""
@admin.register(SubscriptionPeriod) @admin.register(SubscriptionPeriod)
class SubscriptionPeriodAdmin(admin.ModelAdmin): class SubscriptionPeriodAdmin(admin.ModelAdmin):
pass """Admin for SubscriptionPeriod model."""
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,11 +1,16 @@
"""Membership app configuration."""
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_migrate from django.db.models.signals import post_migrate
class MembershipConfig(AppConfig): class MembershipConfig(AppConfig):
"""Membership app config."""
name = "membership" name = "membership"
def ready(self): def ready(self) -> None:
"""Ready method."""
from .permissions import persist_permissions from .permissions import persist_permissions
post_migrate.connect(persist_permissions, sender=self) post_migrate.connect(persist_permissions, sender=self)

128
src/membership/emails.py Normal file
View file

@ -0,0 +1,128 @@
"""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

39
src/membership/forms.py Normal file
View file

@ -0,0 +1,39 @@
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

@ -7,7 +7,6 @@ from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [

View file

@ -2,11 +2,11 @@
import django.contrib.postgres.constraints import django.contrib.postgres.constraints
import django.contrib.postgres.fields.ranges import django.contrib.postgres.fields.ranges
from django.db import migrations, models from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("membership", "0001_initial"), ("membership", "0001_initial"),
] ]
@ -34,9 +34,7 @@ class Migration(migrations.Migration):
), ),
( (
"period", "period",
django.contrib.postgres.fields.ranges.DateRangeField( django.contrib.postgres.fields.ranges.DateRangeField(verbose_name="period"),
verbose_name="period"
),
), ),
], ],
), ),

View file

@ -1,11 +1,11 @@
# Generated by Django 4.1 on 2023-01-02 21:05 # Generated by Django 4.1 on 2023-01-02 21:05
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("membership", "0002_subscriptionperiod_remove_membership_period_and_more"), ("membership", "0002_subscriptionperiod_remove_membership_period_and_more"),
] ]

View file

@ -1,11 +1,11 @@
# Generated by Django 4.1 on 2023-01-02 21:06 # Generated by Django 4.1 on 2023-01-02 21:06
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("membership", "0003_membership_period"), ("membership", "0003_membership_period"),
] ]

View file

@ -4,22 +4,20 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('auth', '0012_alter_user_first_name_max_length'), ("auth", "0012_alter_user_first_name_max_length"),
('membership', '0004_alter_membership_period'), ("membership", "0004_alter_membership_period"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Member', name="Member",
fields=[ fields=[],
],
options={ options={
'proxy': True, "proxy": True,
'indexes': [], "indexes": [],
'constraints': [], "constraints": [],
}, },
bases=('auth.user',), bases=("auth.user",),
), ),
] ]

View file

@ -0,0 +1,32 @@
# 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

@ -0,0 +1,62 @@
# 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

@ -0,0 +1,19 @@
# 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

@ -0,0 +1,32 @@
# 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

@ -0,0 +1,19 @@
# 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,23 +1,39 @@
"""Models for the membership app."""
import uuid
from typing import ClassVar
from typing import Self
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.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 djmoney.money import Money
from utils.mixins import CreatedModifiedAbstract from utils.mixins import CreatedModifiedAbstract
class NoSubscriptionPeriodFoundError(Exception):
"""Raised when no subscription period is found."""
class Member(User): class Member(User):
"""Proxy model for the User model to add some convenience methods."""
class QuerySet(models.QuerySet): class QuerySet(models.QuerySet):
def annotate_membership(self): """QuerySet for the Member model."""
def annotate_membership(self) -> Self:
"""Annotate whether the user has an active membership."""
from .selectors import get_current_subscription_period from .selectors import get_current_subscription_period
current_subscription_period = get_current_subscription_period() current_subscription_period = get_current_subscription_period()
if not current_subscription_period: if not current_subscription_period:
raise ValueError("No current subscription period found") raise NoSubscriptionPeriodFoundError
return self.annotate( return self.annotate(
active_membership=models.Exists( active_membership=models.Exists(
@ -28,21 +44,54 @@ class Member(User):
), ),
) )
objects = QuerySet.as_manager() objects = UserManager.from_queryset(QuerySet)()
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
class SubscriptionPeriod(CreatedModifiedAbstract): class SubscriptionPeriod(CreatedModifiedAbstract):
""" """A subscription period.
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=[
@ -51,31 +100,37 @@ class SubscriptionPeriod(CreatedModifiedAbstract):
), ),
] ]
def __str__(self): def __str__(self) -> str:
return ( return f"{self.period.lower} - {self.period.upper or _('next general assembly')}"
f"{self.period.lower} - {self.period.upper or _('next general assembly')}"
)
class Membership(CreatedModifiedAbstract): class Membership(CreatedModifiedAbstract):
""" """A membership.
Tracks that a user has membership of a given type for a given period. Tracks that a user has membership of a given type for a given period.
""" """
class QuerySet(models.QuerySet): class QuerySet(models.QuerySet):
def for_member(self, member: Member): """QuerySet for the Membership model."""
def for_member(self, member: Member) -> Self:
"""Filter memberships for a given member."""
return self.filter(user=member) return self.filter(user=member)
def _current(self): def 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(period__period__contains=timezone.now()) return self.filter(period__period__contains=timezone.now())
def current(self) -> "Membership | None": def current(self) -> "Membership | None":
try: """Get the current membership."""
return self._current().get() return self._current().first()
except self.model.DoesNotExist:
return None
def previous(self) -> list["Membership"]: def previous(self) -> list["Membership"]:
"""Get previous memberships."""
# A naïve way to get previous by just excluding the current. This # A naïve way to get previous by just excluding the current. This
# means that there must be some protection against "future" # means that there must be some protection against "future"
# memberships. # memberships.
@ -83,16 +138,15 @@ class Membership(CreatedModifiedAbstract):
objects = QuerySet.as_manager() objects = QuerySet.as_manager()
class Meta: user = models.ForeignKey("auth.User", on_delete=models.PROTECT, related_name="memberships")
verbose_name = _("membership")
verbose_name_plural = _("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=_("subscription type"), verbose_name=_("membership type"),
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
@ -101,21 +155,93 @@ class Membership(CreatedModifiedAbstract):
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
def __str__(self): 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")
def __str__(self) -> str:
return f"{self.user} - {self.period}" return f"{self.user} - {self.period}"
class MembershipType(CreatedModifiedAbstract): class MembershipType(CreatedModifiedAbstract):
""" """A membership type.
Models membership types. Currently only a name, but will in the future Models membership types. Currently only a name, but will in the future
possibly contain more information like fees. possibly contain more information like fees.
""" """
name = models.CharField(verbose_name=_("name"), max_length=64)
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")
name = models.CharField(verbose_name=_("name"), max_length=64) def __str__(self) -> str:
def __str__(self):
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")

View file

@ -1,3 +1,5 @@
"""Permissions for the membership app."""
from dataclasses import dataclass from dataclasses import dataclass
from django.contrib.auth.models import Permission as DjangoPermission from django.contrib.auth.models import Permission as DjangoPermission
@ -7,26 +9,32 @@ from django.utils.translation import gettext_lazy as _
PERMISSIONS = [] PERMISSIONS = []
def persist_permissions(sender, **kwargs): def persist_permissions(*args, **kwargs) -> None: # type: ignore[no-untyped-def] # noqa: ARG001
"""Persist all permissions."""
for permission in PERMISSIONS: for permission in PERMISSIONS:
permission.persist_permission() permission.persist_permission()
@dataclass @dataclass
class Permission: class Permission:
"""Dataclass to define a permission."""
name: str name: str
codename: str codename: str
app_label: str app_label: str
model: str model: str
def __post_init__(self, *args, **kwargs): def __post_init__(self, *args, **kwargs) -> None:
"""Post init method."""
PERMISSIONS.append(self) PERMISSIONS.append(self)
@property @property
def path(self): def path(self) -> str:
"""Return the path of the permission."""
return f"{self.app_label}.{self.codename}" return f"{self.app_label}.{self.codename}"
def persist_permission(self): def persist_permission(self) -> None:
"""Persist the permission."""
content_type, _ = ContentType.objects.get_or_create( content_type, _ = ContentType.objects.get_or_create(
app_label=self.app_label, app_label=self.app_label,
model=self.model, model=self.model,

View file

@ -1,4 +1,9 @@
"""Selectors for the membership app."""
from __future__ import annotations
import contextlib import contextlib
from typing import TYPE_CHECKING
from django.db.models import Exists from django.db.models import Exists
from django.db.models import OuterRef from django.db.models import OuterRef
@ -8,8 +13,12 @@ from membership.models import Member
from membership.models import Membership from membership.models import Membership
from membership.models import SubscriptionPeriod from membership.models import SubscriptionPeriod
if TYPE_CHECKING:
from django.db.models.query import QuerySet
def get_subscription_periods(member: Member | None = None) -> list[SubscriptionPeriod]: def get_subscription_periods(member: Member | None = None) -> list[SubscriptionPeriod]:
"""Get all subscription periods."""
subscription_periods = SubscriptionPeriod.objects.prefetch_related( subscription_periods = SubscriptionPeriod.objects.prefetch_related(
"membership_set", "membership_set",
"membership_set__user", "membership_set__user",
@ -29,6 +38,7 @@ def get_subscription_periods(member: Member | None = None) -> list[SubscriptionP
def get_current_subscription_period() -> SubscriptionPeriod | None: def get_current_subscription_period() -> SubscriptionPeriod | None:
"""Get the current subscription period."""
with contextlib.suppress(SubscriptionPeriod.DoesNotExist): with contextlib.suppress(SubscriptionPeriod.DoesNotExist):
return SubscriptionPeriod.objects.prefetch_related( return SubscriptionPeriod.objects.prefetch_related(
"membership_set", "membership_set",
@ -41,6 +51,7 @@ def get_memberships(
member: Member | None = None, member: Member | None = None,
period: SubscriptionPeriod | None = None, period: SubscriptionPeriod | None = None,
) -> Membership.QuerySet: ) -> Membership.QuerySet:
"""Get memberships."""
memberships = Membership.objects.select_related("membership_type").all() memberships = Membership.objects.select_related("membership_type").all()
if member: if member:
@ -52,9 +63,11 @@ def get_memberships(
return memberships return memberships
def get_members(): def get_members() -> QuerySet[Member]:
"""Get all members."""
return Member.objects.all().annotate_membership().order_by("username") return Member.objects.all().annotate_membership().order_by("username")
def get_member(*, member_id: int) -> Member: def get_member(*, member_id: int) -> Member:
"""Get a member by id."""
return get_members().get(id=member_id) return get_members().get(id=member_id)

View file

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

View file

@ -0,0 +1,7 @@
{% 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

@ -0,0 +1,17 @@
{% 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

@ -0,0 +1,21 @@
{% 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

@ -1,19 +1,43 @@
from django.contrib.auth.decorators import login_required """Views for the membership app."""
from django.contrib.auth.decorators import permission_required
from django.utils.translation import gettext_lazy as _
from __future__ import annotations
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_ratelimit.decorators import ratelimit
from django_view_decorator import namespaced_decorator_factory
from utils.view_utils import RenderConfig
from utils.view_utils import RowAction
from utils.view_utils import 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
from .selectors import get_memberships from .selectors import get_memberships
from .selectors import get_subscription_periods from .selectors import get_subscription_periods
from utils.view_utils import render
from utils.view_utils import render_list if TYPE_CHECKING:
from utils.view_utils import RowAction from django.http import HttpRequest
from django.http import HttpResponse
member_view = namespaced_decorator_factory(namespace="member", base_path="membership")
@login_required @member_view(
def membership_overview(request): paths="",
name="membership-overview",
login_required=True,
)
def membership_overview(request: HttpRequest) -> HttpResponse:
"""View to show the membership overview."""
memberships = get_memberships(member=request.user) memberships = get_memberships(member=request.user)
current_membership = memberships.current() current_membership = memberships.current()
previous_memberships = memberships.previous() previous_memberships = memberships.previous()
@ -33,15 +57,25 @@ def membership_overview(request):
) )
@login_required admin_members_view = namespaced_decorator_factory(
@permission_required(ADMINISTRATE_MEMBERS.path) namespace="admin-members",
def members_admin(request): base_path="admin",
)
@admin_members_view(
paths="members/",
name="list",
login_required=True,
permissions=[ADMINISTRATE_MEMBERS.path],
)
def members_admin(request: HttpRequest) -> HttpResponse:
"""View to list all members."""
users = get_members() users = get_members()
return render_list( render_config = RenderConfig(
entity_name="member", entity_name="member",
entity_name_plural="members", entity_name_plural="members",
request=request,
paginate_by=20, paginate_by=20,
objects=users, objects=users,
columns=[ columns=[
@ -54,23 +88,32 @@ def members_admin(request):
row_actions=[ row_actions=[
RowAction( RowAction(
label=_("View"), label=_("View"),
url_name="admin-members-detail", url_name="admin-members:detail",
url_kwargs={"member_id": "id"}, url_kwargs={"member_id": "id"},
), ),
], ],
) )
return render_config.render_list(
request=request,
)
@login_required
@permission_required(ADMINISTRATE_MEMBERS.path) @admin_members_view(
def members_admin_detail(request, member_id): paths="<int:member_id>/",
name="detail",
login_required=True,
permissions=[ADMINISTRATE_MEMBERS.path],
)
def members_admin_detail(request: HttpRequest, member_id: int) -> HttpResponse:
"""View to show the details of a member."""
member = get_member(member_id=member_id) member = get_member(member_id=member_id)
subscription_periods = get_subscription_periods(member=member) subscription_periods = get_subscription_periods(member=member)
context = { context = {
"member": member, "member": member,
"subscription_periods": subscription_periods, "subscription_periods": subscription_periods,
"base_path": "admin-members", "base_path": "admin-members:list",
} }
return render( return render(
@ -78,3 +121,49 @@ def members_admin_detail(request, member_id):
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

@ -0,0 +1 @@
"""data.coop member system."""

View file

@ -1,8 +1,13 @@
"""Settings for the project."""
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()
@ -40,6 +45,7 @@ DJANGO_APPS = [
THIRD_PARTY_APPS = [ THIRD_PARTY_APPS = [
"allauth", "allauth",
"allauth.account", "allauth.account",
"django_view_decorator",
] ]
LOCAL_APPS = [ LOCAL_APPS = [
@ -121,6 +127,7 @@ 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"]
@ -153,6 +160,32 @@ ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
ACCOUNT_USERNAME_REQUIRED = False ACCOUNT_USERNAME_REQUIRED = False
# Logging
# We want to log everything to stdout in docker
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
},
},
"loggers": {
"": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
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"]
MIDDLEWARE += [ MIDDLEWARE += [

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,67 @@
html.dark body {
--splash: #5b47e0;
background: var(--dark-dark);
color: var(--medium-dust)
}
html.dark h1,
html.dark h2,
html.dark h3,
html.dark h4,
html.dark h5,
html.dark h6,
html.dark footer,
html.dark nav ol li a {
color: var(--medium-dust);
}
html.dark nav ol li a:not(.current):hover {
border-color: var(--medium-dust);
}
html.dark header,
html.dark main aside,
html.dark nav {
background: #1d1d1d;
}
html.dark nav {
border: none;
}
html.dark hr {
border-color: var(--twilight);
}
html.dark main aside div,
html.dark article div.content-view {
background: var(--dark-twilight);
}
html.dark article table tbody {
background: var(--dark-twilight);
}
html.dark article table tbody tr:nth-child(2n+1) {
background: var(--dark);
}
html.dark article table tbody tr:nth-child(2n+1) td {
border-top: 1px solid var(--dark-dark);
border-bottom: 1px solid var(--dark-dark);
}
html.dark article table tbody tr:last-child td {
border-bottom: var(--half-space) solid var(--twilight);
}
html.dark form>div>input[type="text"],
html.dark form>div>input[type="password"],
html.dark input[type="email"] {
border: 2px solid var(--twilight);
border-radius: 6px;
padding: 8px;
background: var(--dark-dark);
width: 100%;
color: var(--light-dust);
}

View file

@ -1,39 +1,65 @@
/* Reset */ /* Reset */
*, *::before, *::after { *,
*::before,
*::after {
box-sizing: border-box; box-sizing: border-box;
} }
* { * {
margin: 0; margin: 0;
} }
body { body {
line-height: 1.5; line-height: 1.5;
} }
img, picture, video, canvas, svg {
img,
picture,
video,
canvas,
svg {
display: block; display: block;
max-width: 100%; max-width: 100%;
} }
input, button, textarea, select {
input,
button,
textarea,
select {
font: inherit; font: inherit;
} }
p, h1, h2, h3, h4, h5, h6 {
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
#root, #__next {
#root,
#__next {
isolation: isolate; isolation: isolate;
} }
/* Variables */ /* Variables */
:root { :root {
/* Colors */ /* Colors */
--light : #fff; --light: #ffffff;
--light-dust : #f6f6f6; --light-dust: #fefef9;
--dust : #f1f1f1; --dust: #f4f1ef;
--medium-dust: #dadada; --medium-dust: #dadada;
--dark-dust: #bfbfbf; --dark-dust: #bfbfbf;
--fade: #878787; --fade: #878787;
--twilight: #4a4a4a; --twilight: #4a4a4a;
--dark-twilight: #2f2f2f;
--dark: #2a2a2a; --dark: #2a2a2a;
--dark-dark: #121212;
--light-custard: #eee7d5;
--custard: #f0dcac; --custard: #f0dcac;
--dark-custard: #d4c7a9;
--water: #a8f3f4; --water: #a8f3f4;
--splash: #4b3aba; --splash: #4b3aba;
@ -51,11 +77,18 @@ p, h1, h2, h3, h4, h5, h6 {
} }
} }
html, body { html,
body {
height: 100%; height: 100%;
font-size: 1.05em;
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600; font-weight: 600;
color: var(--twilight); color: var(--twilight);
} }
@ -64,6 +97,14 @@ a {
font-weight: 500; font-weight: 500;
color: var(--splash); color: var(--splash);
text-decoration: none; text-decoration: none;
cursor: pointer;
}
hr {
margin: var(--double-space) 0;
height: 0;
border: 0;
border-bottom: 1px solid var(--dark-custard);
} }
body { body {
@ -88,21 +129,36 @@ header > h1 {
font-size: 1.44em; font-size: 1.44em;
} }
header > a.logout { #switch-icon {
width: 30px;
height: 30px;
display: inline-block;
vertical-align: middle;
margin: 0 var(--space);
top: -2px;
position: relative;
}
#switch-icon #layer1 path {
fill: var(--twilight);
}
header>div>a#logout {
padding: 6px 12px; padding: 6px 12px;
border-radius: 6px; border-radius: 6px;
background: var(--twilight); background: var(--twilight);
text-decoration: none; text-decoration: none;
color: var(--dust); color: var(--dust);
transition: background 0.2s;
} }
header > a.logout:hover { header>div>a#logout:hover {
background: var(--splash); background: var(--splash);
color: var(--light); color: var(--light);
} }
aside { aside {
padding : var(--double-space) var(--outer-space); padding: 0 var(--outer-space) var(--double-space) var(--outer-space);
background: var(--light); background: var(--light);
} }
@ -141,7 +197,7 @@ aside > div > dl > dt {
nav { nav {
display: block; display: block;
border-bottom : 1px solid var(--dark-dust); border-bottom: 1px solid var(--dark-custard);
background: var(--light); background: var(--light);
} }
@ -188,14 +244,14 @@ article {
padding: var(--double-space) var(--outer-space); padding: var(--double-space) var(--outer-space);
} }
article > div { article div.content-view {
background: var(--dust); background: var(--dust);
padding: var(--double-space); padding: var(--double-space);
margin-bottom : var(--double-space); margin-bottom: var(--space);
} }
div.content-view>h2 { div.content-view>h2 {
margin : 0 0 var(--double-space) 0; margin: 0 0 var(--space) 0;
} }
div.services { div.services {
@ -236,14 +292,20 @@ button {
color: var(--light); color: var(--light);
background: var(--splash); background: var(--splash);
padding: var(--space) var(--double-space); padding: var(--space) var(--double-space);
border-radius : 3px; border-radius: var(--quarter-space);
opacity : 0.9; opacity: 0.85;
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;
border: 0; border: 0;
font-weight: 600; font-weight: 600;
letter-spacing : 0.03em;
text-decoration: none; text-decoration: none;
transition: opacity 0.15s;
}
button.small {
font-size: 0.78em;
padding: var(--half-space) var(--space);
} }
div.services>div>a:hover, div.services>div>a:hover,
@ -252,6 +314,16 @@ button:hover {
opacity: 1.0; opacity: 1.0;
} }
button:disabled {
opacity: 0.6;
background: var(--twilight);
cursor: default;
}
button.secondary {
background: var(--twilight);
}
article table { article table {
width: 100%; width: 100%;
border-spacing: 0; border-spacing: 0;
@ -259,16 +331,47 @@ article table {
} }
article table thead th { article table thead th {
text-align : left; background: var(--twilight);
color: var(--medium-dust);
}
article table thead th a {
color: var(--light);
}
article table thead th:first-child {
border-radius: var(--half-space) 0 0 0;
}
article table thead th:last-child {
border-radius: 0 var(--half-space) 0 0;
}
article table tbody {
background: var(--light-dust);
} }
article table tbody tr:nth-child(odd) { article table tbody tr:nth-child(odd) {
background : var(--medium-dust); background: var(--light-custard);
}
article table tbody tr:nth-child(odd) td {
border-top: 1px solid var(--dark-custard);
border-bottom: 1px solid var(--dark-custard);
}
article table tbody tr:last-child td {
border-bottom: var(--half-space) solid var(--twilight);
} }
article table thead th, article table thead th,
article table tbody td { article table tbody td {
padding : var(--half-space); padding: var(--space);
text-align: left;
}
article table#user_email_table tbody tr td:first-child {
text-align: center;
} }
form>div { form>div {
@ -281,12 +384,46 @@ form > div >label {
} }
form>div>input[type="text"], form>div>input[type="text"],
form > div > input[type="password"] { form>div>input[type="password"],
input[type="email"] {
border: 2px solid var(--twilight); border: 2px solid var(--twilight);
border-radius: 6px; border-radius: 6px;
padding: 8px; padding: 8px;
background: var(--light-dust); background: var(--light-dust);
width: 100%; width: 100%;
color: var(--dark);
}
form fieldset {
border: 0;
padding: 0;
margin: 0;
}
form div.buttonHolder button {
display: inline-block;
}
#email-add-overlay {
display: none;
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
align-items: center;
justify-content: center;
z-index: 1000;
}
#email-add-overlay .content-view {
width: 600px;
padding: var(--double-space);
}
#email-add-overlay .content-view p {
margin: var(--double-space) 0;
} }
#login { #login {
@ -367,6 +504,11 @@ 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);
} }
@ -375,10 +517,48 @@ span.time_remaining {
display: flex; display: flex;
justify-content: center; justify-content: center;
list-style: none; list-style: none;
padding : 0; padding: var(--half-space) 0;
margin: 0; margin: 0;
} }
.pagination>li { .pagination>li {
margin : 0 6px; margin: 0 var(--half-space);
}
.pagination>li:first-child {
margin-right: var(--double-space);
}
.pagination>li:last-child {
margin-left: var(--double-space);
}
.pagination .page-item {
border: 1px solid var(--fade);
padding: var(--quarter-space) var(--half-space);
border-radius: var(--half-space);
background: var(--light-dust);
font-size: 0.78em;
}
.pagination .page-link {
padding: var(--half-space);
color: var(--twilight);
}
.pagination .page-item.active {
background: var(--twilight);
}
.pagination .page-item.active .page-link {
color: var(--light-dust);
font-weight: bold;
}
.pagination .page-item.disabled {
opacity: 0.6;
}
.pagination .page-item.disabled .page-link {
cursor: default;
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -2,29 +2,26 @@
{% load i18n %} {% load i18n %}
{% block head_title %}{% trans "E-mail Addresses" %}{% endblock %} {% block head_title %}{% trans "Email Addresses" %}{% endblock %}
{% block content %} {% block content %}
<div class="content-view">
<h2>{% trans "Email Addresses" %}</h2>
<p>{% trans 'The following email addresses are associated with your account:' %}</p>
<div class="row"> <hr />
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<h4>{% trans "E-mail Addresses" %}</h4>
</div>
<div class="panel-body">
{% if user.emailaddress_set.all %} {% if user.emailaddress_set.all %}
<p>{% trans 'The following e-mail addresses are associated with your account:' %}</p> <form action="{% url 'account_email' %}" class="email_list" method="post">
<form action="{% url 'account_email' %}" class="email_list"
method="post">
{% csrf_token %} {% csrf_token %}
<fieldset class="blockLabels"> <fieldset class="blockLabels">
<div class="buttonHolder">
<table class="table"> <button class="small" name="action_add_open" style="float:right">Add Email …</button>
<button class="small" disabled type="submit" id="action_primary" name="action_primary">Make Primary</button>
<button class="small" type="submit" name="action_send">Re-send Verification</button>
<button class="small" type="submit" name="action_remove">Remove</button>
</div>
<table class="table" id="user_email_table">
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
@ -34,9 +31,8 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for emailaddress in user.emailaddress_set.all %} {% for emailaddress in user.emailaddress_set.all %}
<tr class="ctrlHolder"> <tr>
<label for="email_radio_{{ forloop.counter }}" <label for="email_radio_{{ forloop.counter }}"
class="{% if emailaddress.primary %}primary_email{% endif %}"> class="{% if emailaddress.primary %}primary_email{% endif %}">
<td> <td>
@ -48,6 +44,7 @@
{% if emailaddress.primary or user.emailaddress_set.count == 1 %} {% if emailaddress.primary or user.emailaddress_set.count == 1 %}
checked="checked" checked="checked"
{% endif %} {% endif %}
class="{% if emailaddress.primary %}primary_email{% endif %}"
/> />
</td> </td>
<td> <td>
@ -55,14 +52,15 @@
</td> </td>
<td> <td>
{% if emailaddress.verified %} {% if emailaddress.verified %}
<span class="label label-success">Verified</span> <span class="label label-success">{% trans "Verified" %}</span>
{% else %} {% else %}
<span class="label label-danger">Unverified</span> <span class="label label-danger">{% trans "Unverified" %}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if emailaddress.primary %} {% if emailaddress.primary %}
<span class="label label-primary">Primary</span>{% endif %} <span class="label label-primary">{% trans "Primary" %}</span>
{% endif %}
</td> </td>
</label> </label>
</tr> </tr>
@ -70,19 +68,6 @@
</tbody> </tbody>
</table> </table>
<div class="buttonHolder">
<button class="btn btn-success" type="submit"
name="action_primary">Make Primary
</button>
<button class="btn btn-primary" type="submit"
name="action_send">Re-send Verification
</button>
<button class="btn btn-danger" type="submit"
name="action_remove">Remove
</button>
</div>
</fieldset> </fieldset>
</form> </form>
{% else %} {% else %}
@ -92,25 +77,21 @@
</p> </p>
{% endif %} {% endif %}
</div> </div>
</div> <div id="email-add-overlay">
<div class="content-view">
<div class="panel panel-default"> <h3>{% trans "Add E-mail" %}</h3>
<div class="panel-heading">
<h4>{% trans "Add E-mail" %}</h4>
</div>
<div class="panel-body"> <div class="panel-body">
<form method="post" action="{% url 'account_email' %}" <form method="post" action="{% url 'account_email' %}"
class="add_email"> class="add_email">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button name="action_add" class="btn btn-success" type="submit"> <button name="action_add" style="float:right" type="submit">
{% trans "Add E-mail" %} {% trans "Add E-mail" %}
</button> </button>
<button id="overlay-close-button" class="secondary">Cancel</button>
</form> </form>
</div> </div>
</div> </div>
</div>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
@ -124,6 +105,33 @@
} }
}); });
} }
let addEmail = document.getElementsByName('action_add_open')[0];
addEmail.addEventListener('click', function(e) {
e.preventDefault();
let overlay = document.getElementById('email-add-overlay')
overlay.style.display = 'flex'
window.addEventListener('keydown', function(e) {
if (e.key == 'Escape') {
overlay.style.display = 'none'
}
})
document.getElementById('overlay-close-button').addEventListener('click', function() {
overlay.style.display = 'none'
})
})
let radio_actions = document.getElementsByName('email');
if (radio_actions.length) {
for (radio of radio_actions) {
radio.addEventListener("change", function (e) {
document.getElementById('action_primary').disabled = e.target.classList.contains('primary_email')
});
}
}
})(); })();
</script> </script>

View file

@ -20,13 +20,13 @@
<div> <div>
<label for="id_username" <label for="id_username"
class="visually-hidden"> class="visually-hidden">
{% trans "E-mail" %} {% trans "Email" %}
</label> </label>
<input type="text" <input type="text"
id="id_username" id="id_username"
name="login" name="login"
class="form-control mb-lg-2" class="form-control mb-lg-2"
placeholder="{% trans "E-mail" %}" placeholder="{% trans "Email" %}"
required required
autofocus> autofocus>
</div> </div>
@ -42,7 +42,7 @@
required> required>
</div> </div>
<div> <div>
<button type="submit">{% trans "Sign in" %}</button> <button type="submit">{% trans "Login" %}</button>
</div> </div>
</form> </form>
<div> <div>
@ -55,7 +55,7 @@
<div class="signup"> <div class="signup">
<img src="https://data.coop/static/img/logo_da.svg" alt="data.coop logo"> <img src="https://data.coop/static/img/logo_da.svg" alt="data.coop logo">
<div class="new_here"> <div class="new_here">
<h2> Are you new here? </h2> <h2>{% trans "Are you new here?" %}</h2>
<a class="button" href="{% url "account_signup" %}">{% trans "Become a member" %}</a> <a class="button" href="{% url "account_signup" %}">{% trans "Become a member" %}</a>
</div> </div>
</div> </div>

View file

@ -10,11 +10,39 @@
<title>{% block head_title %}{% endblock %} {{ site.name }}</title> <title>{% block head_title %}{% endblock %} {{ site.name }}</title>
<link rel="stylesheet" href="{% static "fonts/inter.css" %}"> <link rel="stylesheet" href="{% static "fonts/inter.css" %}">
<link rel="stylesheet" href="{% static "css/style.css" %}"> <link rel="stylesheet" href="{% static "css/style.css" %}">
<link rel="stylesheet" href="{% static "css/dark-style.css" %}">
<script>
const savedTheme = localStorage.getItem('theme');
if (savedTheme === "dark" || (savedTheme == null && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.querySelector('html').classList.add('dark');
}
</script>
</head> </head>
<body> <body>
<header> <header>
<h1> data.coop membersystem </h1> <h1> data.coop membersystem </h1>
<a class="logout" href="{% url "account_logout" %}">Log out</a> <div>
<a id="theme-switcher" title="Switch theme">
<svg id="switch-icon"
width="20.00033mm"
height="20.000334mm"
viewBox="0 0 20.00033 20.000334"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g id="layer1">
<path id="path1"
style="fill-opacity:1;stroke:none;stroke-width:0.697782;stroke-linecap:round"
d="M 9.999906,20.000334 A 10,10 0 0 0 20.000329,9.999911 10,10 0 0 0 9.999906,0 10,10 0 0 0 0,9.999911 10,10 0 0 0 9.999906,20.000334 Z m 0,-2.00039 V 1.99988 a 8,8 0 0 1 8.000023,8.000031 8,8 0 0 1 -8.000023,8.000033 z" />
</g>
</svg>
</a>
<a id="logout" href="{% url "account_logout" %}">Log out</a>
</div>
</header> </header>
<main> <main>
<aside> <aside>
@ -66,7 +94,7 @@
{% if perms.membership.administrate_memberships %} {% if perms.membership.administrate_memberships %}
<li> <li>
<a href="{% url "admin-members" %}" class="{% active_path "admin-members" "current" %}"> <a href="{% url "admin-members:list" %}" class="{% active_path "admin-members:list" "current" %}">
Admin Admin
</a> </a>
</li> </li>
@ -74,11 +102,30 @@
</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 version 0.0.1 data.coop membersystem alpha - report issues on <a href="https://git.data.coop/data.coop/membersystem/">git</a>
</footer> </footer>
<script>
const themeSwitcher = document.getElementById('theme-switcher');
themeSwitcher.addEventListener('click', function() {
themeSwitcher.classList.toggle('active')
let isDark = document.querySelector('html').classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
</script>
</body> </body>
</html> </html>

View file

@ -14,6 +14,10 @@
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

@ -1,26 +1,13 @@
"""URLs for the membersystem""" """URLs for the membersystem."""
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.decorators import login_required
from django.urls import include from django.urls import include
from django.urls import path from django.urls import path
from django_view_decorator import include_view_urls
from .views import index
from .views import services_overview
from membership.views import members_admin
from membership.views import members_admin_detail
from membership.views import membership_overview
urlpatterns = [ urlpatterns = [
path("", login_required(index), name="index"), path("", include_view_urls(extra_modules=["project.views"])),
path("services/", login_required(services_overview), name="services"),
path("membership/", membership_overview, name="membership-overview"),
path("admin/members/", members_admin, name="admin-members"),
path(
"admin/members/<int:member_id>/",
members_admin_detail,
name="admin-members-detail",
),
path("accounts/", include("allauth.urls")), path("accounts/", include("allauth.urls")),
path("_admin/", admin.site.urls), path("_admin/", admin.site.urls),
] ]
@ -29,4 +16,5 @@ if settings.DEBUG:
urlpatterns = [ urlpatterns = [
path("__debug__/", include("debug_toolbar.urls")), path("__debug__/", include("debug_toolbar.urls")),
path("__reload__/", include("django_browser_reload.urls")), path("__reload__/", include("django_browser_reload.urls")),
] + urlpatterns *urlpatterns,
]

View file

@ -1,9 +1,37 @@
"""Project views."""
from __future__ import annotations
from typing import TYPE_CHECKING
from accounting.models import Order
from django_view_decorator import view
from utils.view_utils import render from utils.view_utils import render
if TYPE_CHECKING:
def index(request): from django.http import HttpRequest
return render(request, "index.html") from django.http import HttpResponse
def services_overview(request): @view(
paths="",
name="index",
login_required=True,
)
def index(request: HttpRequest) -> HttpResponse:
"""View to show the index page."""
unpaid_orders = Order.objects.filter(member=request.user, is_paid=False)
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") return render(request, "services_overview.html")

View file

@ -1,11 +1,11 @@
""" """WSGI config for membersystem project.
WSGI config for membersystem project.
It exposes the WSGI callable as a module-level variable named ``application``. It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see For more information on this file, see
https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/
""" """
import os import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application

View file

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

View file

@ -0,0 +1 @@
"""Utility functions for the project."""

View file

@ -1,8 +1,12 @@
"""Mixins for models."""
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
class CreatedModifiedAbstract(models.Model): class CreatedModifiedAbstract(models.Model):
"""Abstract model to track creation and modification of objects."""
modified = models.DateTimeField(auto_now=True, verbose_name=_("modified")) modified = models.DateTimeField(auto_now=True, verbose_name=_("modified"))
created = models.DateTimeField(auto_now_add=True, verbose_name=_("created")) created = models.DateTimeField(auto_now_add=True, verbose_name=_("created"))

View file

@ -0,0 +1 @@
"""Utility template tags for the project."""

View file

@ -1,3 +1,7 @@
"""Custom template tags for the project."""
from typing import Any
from django import template from django import template
from django.urls import reverse from django.urls import reverse
@ -5,9 +9,8 @@ register = template.Library()
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def active_path(context, path_name, class_name) -> str | None: def active_path(context: dict[str, Any], path_name: str, class_name: str) -> str | None:
"""Return the given class name if the current path matches the given path name.""" """Return the given class name if the current path matches the given path name."""
path = reverse(path_name) path = reverse(path_name)
request_path = context.get("request").path request_path = context.get("request").path
@ -19,3 +22,4 @@ def active_path(context, path_name, class_name) -> str | None:
if is_path or is_base_path: if is_path or is_base_path:
return class_name return class_name
return None

View file

@ -1,23 +1,29 @@
"""Utility views for rendering lists of objects."""
from __future__ import annotations
import contextlib import contextlib
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Any from typing import Any
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Model
from django.http import HttpRequest
from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
from zen_queries import queries_disabled from zen_queries import queries_disabled
from zen_queries import render as zen_queries_render from zen_queries import render as zen_queries_render
if TYPE_CHECKING:
from django.db.models import Model
from django.db.models import QuerySet
from django.http import HttpRequest
from django.http import HttpResponse
@dataclass @dataclass
class Row: class Row:
""" """A row in a table."""
A row in a table.
"""
data: dict[str, str] data: dict[str, str]
actions: list[dict[str, str]] actions: list[dict[str, str]]
@ -25,18 +31,14 @@ class Row:
@dataclass @dataclass
class RowAction: class RowAction:
""" """An action that can be performed on a row in a table."""
An action that can be performed on a row in a table.
"""
label: str label: str
url_name: str url_name: str
url_kwargs: dict[str, str] url_kwargs: dict[str, str]
def render(self, obj) -> dict[str, str]: def render(self, obj: Model) -> dict[str, str]:
""" """Render the action as a dictionary for the given object."""
Render the action as a dictionary for the given object.
"""
url = reverse( url = reverse(
self.url_name, self.url_name,
kwargs={key: getattr(obj, value) for key, value in self.url_kwargs.items()}, kwargs={key: getattr(obj, value) for key, value in self.url_kwargs.items()},
@ -44,22 +46,33 @@ class RowAction:
return {"label": self.label, "url": url} return {"label": self.label, "url": url}
def render_list( @dataclass(kw_only=True)
request: HttpRequest, class RenderConfig:
entity_name: str, """Configuration for rendering a list of objects."""
entity_name_plural: str,
objects: list["Model"],
columns: list[tuple[str, str]],
row_actions: list[RowAction] = None,
list_actions: list[tuple[str, str]] = None,
paginate_by: int = None,
) -> HttpResponse:
"""
Render a list of objects with a table.
"""
entity_name: str
entity_name_plural: str
objects: QuerySet
columns: list[tuple[str, str]]
row_actions: list[RowAction] | None = None
list_actions: list[tuple[str, str]] | None = None
paginate_by: int | None = None
def render_list(
self,
request: HttpRequest,
) -> HttpResponse:
"""Render a list of objects with a table."""
# TODO: List actions # TODO: List actions
entity_name = self.entity_name
entity_name_plural = self.entity_name_plural
objects = self.objects
columns = self.columns
row_actions = self.row_actions or []
list_actions = self.list_actions or []
paginate_by = self.paginate_by
total_count = len(objects) total_count = len(objects)
order_by = request.GET.get("order_by") order_by = request.GET.get("order_by")
@ -107,17 +120,18 @@ def render_list(
def base_context(request: HttpRequest) -> dict[str, Any]: def base_context(request: HttpRequest) -> dict[str, Any]:
""" """Return a base context for all views."""
Return a base context for all views.
"""
return {"site": get_current_site(request)} return {"site": get_current_site(request)}
def render(request, template_name, context=None): def render(request: HttpRequest, template_name: str, context: dict[str, Any] | None = None) -> HttpResponse:
""" """Render a template with a base context."""
Render a template with a base context.
"""
if context is None: if context is None:
context = {} context = {}
context = base_context(request) | context context = base_context(request) | context
# Make sure to fetch all permissions before rendering the template
# otherwise django-zen-queries will complain about database queries.
request.user.get_all_permissions()
return zen_queries_render(request, template_name, context) return zen_queries_render(request, template_name, context)