Compare commits

..

11 commits

Author SHA1 Message Date
Víðir Valberg Guðmundsson ad6564b510 WIP. 2024-04-20 10:32:56 +02:00
Víðir Valberg Guðmundsson f351e8735c Merge branch 'main' into services 2024-03-03 18:02:55 +01:00
Víðir Valberg Guðmundsson 9a884c782c WIP. 2024-03-03 11:04:41 +01:00
Víðir Valberg Guðmundsson 535543f82f Merge branch 'main' into services
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2024-02-29 21:52:34 +01:00
Víðir Valberg Guðmundsson c48cc3b347 Merge branch 'main' into services
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2024-01-14 12:52:56 +01:00
Víðir Valberg Guðmundsson 69cfc38771 WIP.
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2024-01-14 12:51:17 +01:00
Víðir Valberg Guðmundsson 922fd0254f Implement django-view-decorator 2024-01-14 12:50:31 +01:00
Víðir Valberg Guðmundsson b756e00a98 Add missing migration 2024-01-14 12:49:53 +01:00
Halfdan Mouritzen 6e9c5204cf Minimal CSS for tables (#26)
Reviewed-on: #26
Co-authored-by: Halfdan Mouritzen <halfdan@robothangarskib.dk>
Co-committed-by: Halfdan Mouritzen <halfdan@robothangarskib.dk>
2024-01-14 12:49:53 +01:00
Víðir Valberg Guðmundsson aed28a8a53 Display the services.
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2024-01-13 21:37:45 +01:00
Víðir Valberg Guðmundsson 2d4562944f Initial stuff for services.
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2024-01-13 21:19:15 +01:00
45 changed files with 986 additions and 682 deletions

View file

@ -3,7 +3,6 @@
*/.* */.*
!src/ !src/
!requirements.txt
!requirements/ !requirements/
!entrypoint.sh !entrypoint.sh
!pyproject.toml !pyproject.toml

View file

@ -7,6 +7,7 @@ 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
@ -16,6 +17,7 @@ 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,3 +6,4 @@ 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

View file

@ -3,7 +3,7 @@ default_language_version:
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.6.0 rev: v4.5.0
hooks: hooks:
- id: check-ast - id: check-ast
- id: check-merge-conflict - id: check-merge-conflict
@ -15,22 +15,22 @@ 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/astral-sh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 'v0.5.2' rev: 'v0.3.0'
hooks: hooks:
- id: ruff - id: ruff
args: args:
- --fix - --fix
- id: ruff-format - id: ruff-format
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.16.0 rev: v3.15.1
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.19.0 rev: 1.16.0
hooks: hooks:
- id: django-upgrade - id: django-upgrade
args: args:

View file

@ -7,18 +7,18 @@ ENV PYTHONFAULTHANDLER=1 \
PIP_NO_CACHE_DIR=off \ 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
WORKDIR /app WORKDIR /app
RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www
COPY --chown=www:www . . COPY --chown=www:www . .
RUN mkdir /app/src/static && \ RUN mkdir /app/src/static \
chown www:www /app/src/static && \ && chown www:www /app/src/static \
apt-get update && \ && apt-get update \
apt-get install -y \ && apt-get install -y \
binutils \ binutils \
libpq-dev \ libpq-dev \
build-essential \ build-essential \
@ -29,9 +29,9 @@ RUN mkdir /app/src/static && \
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 && \ && pip install . \
django-admin compilemessages && django-admin compilemessages
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]

View file

@ -1,12 +1,27 @@
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_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} app ${MANAGE_EXEC} MANAGE_COMMAND = ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} ${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}
@ -21,3 +36,23 @@ shell:
manage_command: manage_command:
${MANAGE_COMMAND} ${ARGS} ${MANAGE_COMMAND} ${ARGS}
add_dependency:
${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry add --lock ${DEPENDENCY}
add_dev_dependency:
${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry add -D --lock ${DEPENDENCY}
poetry_lock:
${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry lock --no-update
poetry_command:
${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry ${COMMAND}
build_dev_docker_image: compile_requirements
${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

View file

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

241
devenv.lock Normal file
View file

@ -0,0 +1,241 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1707004164,
"narHash": "sha256-9Hr8onWtvLk5A8vCEkaE9kxA0D7PR62povFokM1oL5Q=",
"owner": "cachix",
"repo": "devenv",
"rev": "0e68853bb27981a4ffd7a7225b59ed84f7180fc7",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-compat_2": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1705309234,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"type": "github"
},
"original": {
"id": "flake-utils",
"type": "indirect"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1703887061,
"narHash": "sha256-gGPa9qWNc6eCXT/+Z5/zMkyYOuRZqeFZBDbopNZQkuY=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "43e1aa1308018f37118e34d3a9cb4f5e75dc11d5",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1707451808,
"narHash": "sha256-UwDBUNHNRsYKFJzyTMVMTF5qS4xeJlWoeyJf+6vvamU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "442d407992384ed9c0e6d352de75b69079904e4e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-python": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1707114737,
"narHash": "sha256-ZXqv2epXAjDjfWbYn+yy4VOmW+C7SuUBoiZkkDoSqA4=",
"owner": "cachix",
"repo": "nixpkgs-python",
"rev": "f34ed02276bc08fe1c91c1bf0ef3589d68028878",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "nixpkgs-python",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1704874635,
"narHash": "sha256-YWuCrtsty5vVZvu+7BchAxmcYzTMfolSPP5io8+WYCg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3dc440faeee9e889fe2d1b4d25ad0f430d449356",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1707347730,
"narHash": "sha256-0etC/exQIaqC9vliKhc3eZE2Mm2wgLa0tj93ZF/egvM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6832d0d99649db3d65a0e15fa51471537b2c56a6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.11",
"repo": "nixpkgs",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": "flake-compat_2",
"flake-utils": "flake-utils_2",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1707297608,
"narHash": "sha256-ADjo/5VySGlvtCW3qR+vdFF4xM9kJFlRDqcC9ZGI8EA=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "0db2e67ee49910adfa13010e7f012149660af7f0",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs",
"nixpkgs-python": "nixpkgs-python",
"pre-commit-hooks": "pre-commit-hooks"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

19
devenv.nix Normal file
View file

@ -0,0 +1,19 @@
{ pkgs, ... }:
{
languages.python.enable = true;
languages.python.version = "3.12";
services.postgres = {
enable = true;
package = pkgs.postgresql_15;
initialDatabases = [ {"name" = "postgres";} ];
listen_addresses = "localhost";
initialScript = "create user postgres with password 'postgres' superuser;";
};
processes = {
app.exec = "while ! pg_isready -d postgres -h localhost -U postgres 2>/dev/null; do sleep 1; done; hatch run server";
};
}

5
devenv.yaml Normal file
View file

@ -0,0 +1,5 @@
inputs:
nixpkgs:
url: github:NixOS/nixpkgs/nixpkgs-unstable
nixpkgs-python:
url: github:cachix/nixpkgs-python

View file

@ -1,10 +1,11 @@
--- 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:
@ -27,4 +28,3 @@ services:
volumes: volumes:
postgres_data: postgres_data:
...

View file

@ -12,31 +12,24 @@ 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.6", "Django==5.0.1",
"django-money==3.4.1", "django-money==3.4.1",
"django-allauth==0.63.3", "django-allauth==0.60.0",
"psycopg[binary]==3.1.19", "psycopg[binary]==3.1.16",
"environs[django]==11.0.0", "environs[django]==10.0.0",
"uvicorn==0.30.0", "uvicorn==0.25.0",
"whitenoise==6.6.0", "whitenoise==6.6.0",
"django-zen-queries==2.1.0", "django-zen-queries==2.1.0",
"django-registries==0.0.3", "django-registries==0.0.3",
"django-view-decorator==0.0.4", "django-view-decorator==0.0.4",
"django-oauth-toolkit==2.4.0", "django-oauth-toolkit==2.3.0",
] ]
version = "0.0.1" version = "0.0.1"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src"] packages = ["src"]
[tool.hatch.env]
requires = ["hatch-pip-compile"]
[tool.hatch.envs.default] [tool.hatch.envs.default]
type = "pip-compile"
[tool.hatch.envs.dev]
type = "pip-compile"
dependencies = [ dependencies = [
"coverage[toml]==7.3.0", "coverage[toml]==7.3.0",
"pytest==7.2.2", "pytest==7.2.2",
@ -52,7 +45,7 @@ dependencies = [
[[tool.hatch.envs.tests.matrix]] [[tool.hatch.envs.tests.matrix]]
python = ["3.12"] python = ["3.12"]
django = ["5.0"] django = ["4.2", "5.0"]
[tool.hatch.envs.tests.overrides] [tool.hatch.envs.tests.overrides]
matrix.django.dependencies = [ matrix.django.dependencies = [
@ -62,7 +55,7 @@ matrix.python.dependencies = [
{ value = "typing_extensions==4.5.0", if = ["3.10"]}, { value = "typing_extensions==4.5.0", if = ["3.10"]},
] ]
[tool.hatch.envs.dev.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 ."
@ -118,9 +111,6 @@ target-version = "py312"
extend-exclude = [ extend-exclude = [
".git", ".git",
"__pycache__", "__pycache__",
"manage.py",
"asgi.py",
"wsgi.py",
] ]
line-length = 120 line-length = 120
@ -134,21 +124,7 @@ ignore = [
"EM102", # Exception must not use a f-string literal, assign to variable first "EM102", # Exception must not use a f-string literal, assign to variable first
"COM812", # missing-trailing-comma (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) "COM812", # missing-trailing-comma (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
"ISC001", # single-line-implicit-string-concatenation (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) "ISC001", # single-line-implicit-string-concatenation (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
"D105", # Missing docstring in magic method
"D106", # Missing docstring in public nested class
"FIX", # TODO, FIXME, XXX
"TD", # TODO, FIXME, XXX
"ANN002", # Missing type annotation for `*args`
"ANN003", # Missing type annotation for `**kwargs`
] ]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
force-single-line = true force-single-line = true
[tool.ruff.lint.per-file-ignores]
"tests.py" = [
"S101", # Use of assert
"SLF001", # Private member access
"D100", # Docstrings
"D103", # Docstrings
]

View file

@ -1,103 +0,0 @@
#
# This file is autogenerated by hatch-pip-compile with Python 3.12
#
# - django-allauth==0.63.3
# - django-money==3.4.1
# - django-oauth-toolkit==2.4.0
# - django-registries==0.0.3
# - django-view-decorator==0.0.4
# - django-zen-queries==2.1.0
# - django==5.0.6
# - environs[django]==11.0.0
# - psycopg[binary]==3.1.19
# - uvicorn==0.30.0
# - whitenoise==6.6.0
#
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==42.0.8
# via jwcrypto
dj-database-url==2.2.0
# via environs
dj-email-url==1.0.6
# via environs
django==5.0.6
# via
# hatch.envs.default
# dj-database-url
# django-allauth
# django-money
# django-oauth-toolkit
# django-registries
# django-view-decorator
# django-zen-queries
django-allauth==0.63.3
# via hatch.envs.default
django-cache-url==3.4.5
# via environs
django-money==3.4.1
# via hatch.envs.default
django-oauth-toolkit==2.4.0
# via hatch.envs.default
django-registries==0.0.3
# 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.1.19
# via hatch.envs.default
psycopg-binary==3.1.19
# 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
sqlparse==0.5.0
# via django
typing-extensions==4.12.2
# via
# dj-database-url
# jwcrypto
# psycopg
# py-moneyed
urllib3==2.2.2
# via requests
uvicorn==0.30.0
# via hatch.envs.default
whitenoise==6.6.0
# via hatch.envs.default
# The following packages are considered to be unsafe in a requirements file:
# setuptools

8
requirements/base.in Normal file
View file

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

@ -0,0 +1,101 @@
#
# 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-view-decorator
# 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-view-decorator==0.0.4
# 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

8
requirements/dev.in Normal file
View file

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

213
requirements/dev.txt Normal file
View file

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

@ -1,183 +0,0 @@
#
# 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.3
# - django-money==3.4.1
# - django-oauth-toolkit==2.4.0
# - django-registries==0.0.3
# - django-view-decorator==0.0.4
# - django-zen-queries==2.1.0
# - django==5.0.6
# - environs[django]==11.0.0
# - psycopg[binary]==3.1.19
# - uvicorn==0.30.0
# - whitenoise==6.6.0
#
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
# coverage
# pytest-cov
cryptography==42.0.8
# via jwcrypto
dj-database-url==2.2.0
# via environs
dj-email-url==1.0.6
# via environs
django==5.0.6
# 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.3
# 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.4.1
# via hatch.envs.dev
django-oauth-toolkit==2.4.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.2
# via 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
# environs
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-tools==7.3.0
# via hatch.envs.dev
pluggy==1.5.0
# via pytest
psycopg==3.1.19
# via
# hatch.envs.dev
# psycopg
psycopg-binary==3.1.19
# 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
sqlparse==0.5.0
# via
# django
# django-debug-toolbar
tomli==2.0.1
# via django-stubs
types-pytz==2024.1.0.20240417
# via django-stubs
types-pyyaml==6.0.12.20240311
# via django-stubs
typing-extensions==4.12.2
# via
# dj-database-url
# django-stubs
# django-stubs-ext
# jwcrypto
# mypy
# psycopg
# py-moneyed
urllib3==2.2.2
# via requests
uvicorn==0.30.0
# via hatch.envs.dev
wheel==0.43.0
# via pip-tools
whitenoise==6.6.0
# via hatch.envs.dev
# The following packages are considered to be unsafe in a requirements file:
# pip
# setuptools

5
requirements/test.in Normal file
View file

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

149
requirements/test.txt Normal file
View file

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

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

View file

@ -1,36 +1,26 @@
"""Admin for the accounting app."""
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Order from . import models
from .models import Payment
@admin.register(Order) @admin.register(models.Order)
class OrderAdmin(admin.ModelAdmin): class OrderAdmin(admin.ModelAdmin):
"""Admin for the Order model."""
list_display = ("who", "description", "created", "is_paid") list_display = ("who", "description", "created", "is_paid")
@admin.display(description=_("Customer")) @admin.display(description=_("Customer"))
def who(self, instance: Order) -> str: def who(self, instance):
"""Return the full name of the user who made the order."""
return instance.user.get_full_name() return instance.user.get_full_name()
@admin.register(Payment) @admin.register(models.Payment)
class PaymentAdmin(admin.ModelAdmin): class PaymentAdmin(admin.ModelAdmin):
"""Admin for the Payment model."""
list_display = ("who", "description", "order_id", "created") list_display = ("who", "description", "order_id", "created")
@admin.display(description=_("Customer")) @admin.display(description=_("Customer"))
def who(self, instance: Payment) -> str: def who(self, instance):
"""Return the full name of the user who made the payment."""
return instance.order.user.get_full_name() return instance.order.user.get_full_name()
@admin.display(description=_("Order ID")) @admin.display(description=_("Order ID"))
def order_id(self, instance: Payment) -> int: def order_id(self, instance):
"""Return the ID of the order."""
return instance.order.id return instance.order.id

View file

@ -1,9 +1,5 @@
"""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"

View file

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

@ -1,7 +1,4 @@
"""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.db import models from django.db import models
@ -9,12 +6,9 @@ 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"))
@ -23,27 +17,19 @@ 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("auth.User", on_delete=models.PROTECT)
def __str__(self) -> str:
return f"Account of {self.owner.get_full_name()}"
@property @property
def balance(self) -> Money: def balance(self):
"""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.
""" """
@ -60,14 +46,9 @@ 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
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 a separate Invoice model. This is undecided because we are not generating
invoices at the moment. invoices at the moment.
""" """
@ -86,6 +67,23 @@ class Order(CreatedModifiedAbstract):
is_paid = models.BooleanField(default=False, verbose_name=_("is paid")) is_paid = models.BooleanField(default=False, verbose_name=_("is paid"))
@property
def total(self):
return self.price + self.vat
@property
def display_id(self):
return str(self.id).zfill(6)
@property
def payment_token(self):
pk = str(self.pk).encode("utf-8")
x = md5()
x.update(pk)
extra_hash = (settings.SECRET_KEY + "blah").encode("utf-8")
x.update(extra_hash)
return x.hexdigest()
class Meta: class Meta:
verbose_name = pgettext_lazy("accounting term", "Order") verbose_name = pgettext_lazy("accounting term", "Order")
verbose_name_plural = pgettext_lazy("accounting term", "Orders") verbose_name_plural = pgettext_lazy("accounting term", "Orders")
@ -93,55 +91,31 @@ class Order(CreatedModifiedAbstract):
def __str__(self) -> str: def __str__(self) -> str:
return f"Order ID {self.display_id}" return f"Order ID {self.display_id}"
@property
def total(self) -> Money:
"""Return the total price of the order."""
return self.price + self.vat
@property
def display_id(self) -> str:
"""Return an id for the order."""
return str(self.id).zfill(6)
@property
def payment_token(self) -> str:
"""Return a token for the payment."""
pk = str(self.pk).encode("utf-8")
x = md5() # noqa: S324
x.update(pk)
extra_hash = (settings.SECRET_KEY + "blah").encode("utf-8")
x.update(extra_hash)
return x.hexdigest()
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, blank=True) stripe_charge_id = models.CharField(max_length=255, null=True, blank=True)
class Meta:
verbose_name = _("payment")
verbose_name_plural = _("payments")
def __str__(self) -> str:
return f"Payment ID {self.display_id}"
@property @property
def display_id(self) -> str: def display_id(self):
"""Return an id for the payment."""
return str(self.id).zfill(6) return str(self.id).zfill(6)
@classmethod @classmethod
def from_order(cls, order: Order) -> Self: def from_order(cls, order):
"""Create a payment from an order."""
return cls.objects.create( return cls.objects.create(
order=order, order=order,
user=order.user, user=order.user,
amount=order.total, amount=order.total,
description=order.description, description=order.description,
) )
def __str__(self) -> str:
return f"Payment ID {self.display_id}"
class Meta:
verbose_name = _("payment")
verbose_name_plural = _("payments")

View file

@ -1,5 +1,7 @@
"""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,5 +1,3 @@
"""Admin configuration for membership app."""
from django.contrib import admin from django.contrib import admin
from .models import Membership from .models import Membership
@ -10,20 +8,19 @@ from .models import SubscriptionPeriod
@admin.register(Membership) @admin.register(Membership)
class MembershipAdmin(admin.ModelAdmin): class MembershipAdmin(admin.ModelAdmin):
"""Admin for Membership model.""" pass
@admin.register(MembershipType) @admin.register(MembershipType)
class MembershipTypeAdmin(admin.ModelAdmin): class MembershipTypeAdmin(admin.ModelAdmin):
"""Admin for MembershipType model.""" pass
@admin.register(SubscriptionPeriod) @admin.register(SubscriptionPeriod)
class SubscriptionPeriodAdmin(admin.ModelAdmin): class SubscriptionPeriodAdmin(admin.ModelAdmin):
"""Admin for SubscriptionPeriod model.""" pass
@admin.register(ServiceAccess) @admin.register(ServiceAccess)
class ServiceAccessAdmin(admin.ModelAdmin): class ServiceAccessAdmin(admin.ModelAdmin):
"""Admin for ServiceAccess model."""
pass pass

View file

@ -1,16 +1,11 @@
"""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) -> None: 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)

View file

@ -1,8 +1,3 @@
"""Models for the membership app."""
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.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
@ -15,24 +10,15 @@ from services.registry import ServiceRegistry
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):
"""QuerySet for the Member model.""" def annotate_membership(self):
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 NoSubscriptionPeriodFoundError raise ValueError("No current subscription period found")
return self.annotate( return self.annotate(
active_membership=models.Exists( active_membership=models.Exists(
@ -50,15 +36,12 @@ class Member(User):
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.
"""
period = DateRangeField(verbose_name=_("period")) period = DateRangeField(verbose_name=_("period"))
class Meta: class Meta:
constraints: ClassVar = [ constraints = [
ExclusionConstraint( ExclusionConstraint(
name="exclude_overlapping_periods", name="exclude_overlapping_periods",
expressions=[ expressions=[
@ -72,31 +55,22 @@ class SubscriptionPeriod(CreatedModifiedAbstract):
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):
"""QuerySet for the Membership model.""" def for_member(self, member: Member):
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) -> Self: def _current(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":
"""Get the current membership."""
try: try:
return self._current().get() return self._current().get()
except self.model.DoesNotExist: except self.model.DoesNotExist:
return None 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.
@ -104,6 +78,10 @@ class Membership(CreatedModifiedAbstract):
objects = QuerySet.as_manager() objects = QuerySet.as_manager()
class Meta:
verbose_name = _("membership")
verbose_name_plural = _("memberships")
user = models.ForeignKey("auth.User", on_delete=models.PROTECT) user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
membership_type = models.ForeignKey( membership_type = models.ForeignKey(
@ -118,27 +96,21 @@ class Membership(CreatedModifiedAbstract):
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
class Meta:
verbose_name = _("membership")
verbose_name_plural = _("memberships")
def __str__(self) -> str: 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)
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) -> str:
return self.name return self.name

View file

@ -1,5 +1,3 @@
"""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
@ -9,32 +7,26 @@ from django.utils.translation import gettext_lazy as _
PERMISSIONS = [] PERMISSIONS = []
def persist_permissions(*args, **kwargs) -> None: # type: ignore[no-untyped-def] # noqa: ARG001 def persist_permissions(sender, **kwargs) -> None:
"""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) -> None: def __post_init__(self, *args, **kwargs):
"""Post init method."""
PERMISSIONS.append(self) PERMISSIONS.append(self)
@property @property
def path(self) -> str: 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) -> None: 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,9 +1,4 @@
"""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
@ -13,12 +8,8 @@ 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",
@ -38,7 +29,6 @@ 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",
@ -51,7 +41,6 @@ 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:
@ -63,11 +52,9 @@ def get_memberships(
return memberships return memberships
def get_members() -> QuerySet[Member]: def get_members():
"""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

@ -1,14 +1,8 @@
"""Views for the membership app."""
from __future__ import annotations
from typing import TYPE_CHECKING
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_view_decorator import namespaced_decorator_factory from django_view_decorator import namespaced_decorator_factory
from utils.view_utils import RenderConfig
from utils.view_utils import RowAction from utils.view_utils import RowAction
from utils.view_utils import render from utils.view_utils import render
from utils.view_utils import render_list
from .permissions import ADMINISTRATE_MEMBERS from .permissions import ADMINISTRATE_MEMBERS
from .selectors import get_member from .selectors import get_member
@ -16,10 +10,6 @@ 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
if TYPE_CHECKING:
from django.http import HttpRequest
from django.http import HttpResponse
member_view = namespaced_decorator_factory(namespace="member", base_path="membership") member_view = namespaced_decorator_factory(namespace="member", base_path="membership")
@ -28,8 +18,7 @@ member_view = namespaced_decorator_factory(namespace="member", base_path="member
name="membership-overview", name="membership-overview",
login_required=True, login_required=True,
) )
def membership_overview(request: HttpRequest) -> HttpResponse: def membership_overview(request):
"""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()
@ -61,13 +50,13 @@ admin_members_view = namespaced_decorator_factory(
login_required=True, login_required=True,
permissions=[ADMINISTRATE_MEMBERS.path], permissions=[ADMINISTRATE_MEMBERS.path],
) )
def members_admin(request: HttpRequest) -> HttpResponse: def members_admin(request):
"""View to list all members."""
users = get_members() users = get_members()
render_config = RenderConfig( return render_list(
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=[
@ -86,10 +75,6 @@ def members_admin(request: HttpRequest) -> HttpResponse:
], ],
) )
return render_config.render_list(
request=request,
)
@admin_members_view( @admin_members_view(
paths="<int:member_id>/", paths="<int:member_id>/",
@ -97,8 +82,7 @@ def members_admin(request: HttpRequest) -> HttpResponse:
login_required=True, login_required=True,
permissions=[ADMINISTRATE_MEMBERS.path], permissions=[ADMINISTRATE_MEMBERS.path],
) )
def members_admin_detail(request: HttpRequest, member_id: int) -> HttpResponse: def members_admin_detail(request, member_id):
"""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)

View file

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

View file

@ -1,5 +1,3 @@
"""Settings for the project."""
from pathlib import Path from pathlib import Path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _

View file

@ -51,7 +51,6 @@ h6 {
--light-dust: #fefef9; --light-dust: #fefef9;
--dust: #f4f1ef; --dust: #f4f1ef;
--medium-dust: #dadada; --medium-dust: #dadada;
--medium-dust : #dadada;
--dark-dust: #bfbfbf; --dark-dust: #bfbfbf;
--fade: #878787; --fade: #878787;
--twilight: #4a4a4a; --twilight: #4a4a4a;
@ -257,7 +256,7 @@ div.content-view>h2 {
div.services { div.services {
display: flex; display: flex;
justify-content: start; justify-content: space-between;
gap: var(--double-space); gap: var(--double-space);
flex-wrap: wrap; flex-wrap: wrap;
} }

View file

@ -1,5 +1,4 @@
"""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.urls import include from django.urls import include

View file

@ -1,16 +1,5 @@
"""Project views."""
from __future__ import annotations
from membership.models import ServiceAccess
from services.registry import ServiceRegistry
from utils.view_utils import render
from typing import TYPE_CHECKING
from django_view_decorator import view from django_view_decorator import view
from utils.view_utils import render
if TYPE_CHECKING:
from django.http import HttpRequest
from django.http import HttpResponse
@view( @view(
@ -18,6 +7,14 @@ if TYPE_CHECKING:
name="index", name="index",
login_required=True, login_required=True,
) )
def index(request: HttpRequest) -> HttpResponse: def index(request):
"""View to show the index page."""
return render(request, "index.html") return render(request, "index.html")
@view(
paths="services/",
name="services",
login_required=True,
)
def services_overview(request):
return render(request, "services_overview.html")

View file

@ -5,7 +5,6 @@ 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 +0,0 @@
"""Utility functions for the project."""

View file

@ -1,12 +1,8 @@
"""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

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

View file

@ -1,7 +1,3 @@
"""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
@ -9,7 +5,7 @@ register = template.Library()
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def active_path(context: dict[str, Any], path_name: str, class_name: str) -> str | None: def active_path(context, path_name, class_name) -> 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

View file

@ -1,7 +1,3 @@
"""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 TYPE_CHECKING
@ -10,15 +6,14 @@ 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.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: if TYPE_CHECKING:
from django.db.models import Model from django.db.models import Model
from django.db.models import QuerySet
from django.http import HttpRequest
from django.http import HttpResponse
@dataclass @dataclass
@ -37,7 +32,7 @@ class RowAction:
url_name: str url_name: str
url_kwargs: dict[str, str] url_kwargs: dict[str, str]
def render(self, obj: Model) -> dict[str, str]: def render(self, obj) -> 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,
@ -46,77 +41,63 @@ class RowAction:
return {"label": self.label, "url": url} return {"label": self.label, "url": url}
@dataclass(kw_only=True) def render_list(
class RenderConfig: request: HttpRequest,
"""Configuration for rendering a list of objects.""" entity_name: str,
entity_name_plural: str,
objects: list["Model"],
columns: list[tuple[str, str]],
row_actions: list[RowAction] | None = None,
list_actions: list[tuple[str, str]] | None = None,
paginate_by: int | None = None,
) -> HttpResponse:
"""Render a list of objects with a table."""
# TODO: List actions
entity_name: str total_count = len(objects)
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( order_by = request.GET.get("order_by")
self,
request: HttpRequest,
) -> HttpResponse:
"""Render a list of objects with a table."""
# TODO: List actions
entity_name = self.entity_name if order_by:
entity_name_plural = self.entity_name_plural with contextlib.suppress(FieldError):
objects = self.objects objects = objects.order_by(order_by)
columns = self.columns
row_actions = self.row_actions or []
list_actions = self.list_actions or []
paginate_by = self.paginate_by
total_count = len(objects) if paginate_by:
paginator = Paginator(object_list=objects, per_page=paginate_by)
page = paginator.get_page(request.GET.get("page"))
objects = page.object_list
order_by = request.GET.get("order_by") rows = []
for obj in objects:
with queries_disabled():
row = Row(
data={column: getattr(obj, column[0]) for column in columns},
actions=[action.render(obj) for action in row_actions],
)
rows.append(row)
if order_by: context = {
with contextlib.suppress(FieldError): "rows": rows,
objects = objects.order_by(order_by) "columns": columns,
"row_actions": row_actions,
"list_actions": list_actions,
"total_count": total_count,
"order_by": order_by,
"entity_name": entity_name,
"entity_name_plural": entity_name_plural,
}
if paginate_by: if paginate_by:
paginator = Paginator(object_list=objects, per_page=paginate_by) context |= {
page = paginator.get_page(request.GET.get("page")) "page": page,
objects = page.object_list "is_paginated": True,
rows = []
for obj in objects:
with queries_disabled():
row = Row(
data={column: getattr(obj, column[0]) for column in columns},
actions=[action.render(obj) for action in row_actions],
)
rows.append(row)
context = {
"rows": rows,
"columns": columns,
"row_actions": row_actions,
"list_actions": list_actions,
"total_count": total_count,
"order_by": order_by,
"entity_name": entity_name,
"entity_name_plural": entity_name_plural,
} }
if paginate_by: return render(
context |= { request=request,
"page": page, template_name="utils/list.html",
"is_paginated": True, context=context,
} )
return render(
request=request,
template_name="utils/list.html",
context=context,
)
def base_context(request: HttpRequest) -> dict[str, Any]: def base_context(request: HttpRequest) -> dict[str, Any]:
@ -124,7 +105,7 @@ def base_context(request: HttpRequest) -> dict[str, Any]:
return {"site": get_current_site(request)} return {"site": get_current_site(request)}
def render(request: HttpRequest, template_name: str, context: dict[str, Any] | None = None) -> HttpResponse: def render(request, template_name, context=None):
"""Render a template with a base context.""" """Render a template with a base context."""
if context is None: if context is None:
context = {} context = {}