diff --git a/.dockerignore b/.dockerignore index ed0835d..ad9d537 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ */.* !src/ +!requirements.txt !requirements/ !entrypoint.sh !pyproject.toml diff --git a/.drone.yml b/.drone.yml index d598350..97a1517 100644 --- a/.drone.yml +++ b/.drone.yml @@ -7,7 +7,6 @@ steps: - name: docker image: plugins/docker environment: - DJANGO_ENV: production BUILD: "${DRONE_COMMIT_SHA}" settings: repo: docker.data.coop/membersystem @@ -17,7 +16,6 @@ steps: password: from_secret: DOCKER_PASSWORD build_args_from_env: - - DJANGO_ENV - BUILD tags: - "${DRONE_BUILD_NUMBER}" diff --git a/.env.example b/.env.example index 777e8f3..9da2eda 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,3 @@ DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres # Use something along the the following if you are not using docker # DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem DEBUG=True -DJANGO_ENV=development diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8e7157d..7388e5d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,22 +15,22 @@ repos: - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: 'v0.4.4' + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.5.2' hooks: - id: ruff args: - --fix - id: ruff-format - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 + rev: v3.16.0 hooks: - id: pyupgrade args: - --py311-plus exclude: migrations/ - repo: https://github.com/adamchainz/django-upgrade - rev: 1.17.0 + rev: 1.19.0 hooks: - id: django-upgrade args: diff --git a/Dockerfile b/Dockerfile index cc600b1..fe342b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,18 +7,18 @@ ENV PYTHONFAULTHANDLER=1 \ PIP_NO_CACHE_DIR=off \ PIP_DISABLE_PIP_VERSION_CHECK=on \ PIP_DEFAULT_TIMEOUT=100 -ARG DJANGO_ENV ARG BUILD ENV BUILD ${BUILD} +ARG REQUIREMENTS_FILE=requirements.txt WORKDIR /app RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www COPY --chown=www:www . . -RUN mkdir /app/src/static \ - && chown www:www /app/src/static \ - && apt-get update \ - && apt-get install -y \ +RUN mkdir /app/src/static && \ + chown www:www /app/src/static && \ + apt-get update && \ + apt-get install -y \ binutils \ libpq-dev \ build-essential \ @@ -29,9 +29,9 @@ RUN mkdir /app/src/static \ libgdk-pixbuf2.0-0 \ libffi-dev \ shared-mime-info \ - gettext \ - && pip install . \ - && django-admin compilemessages + gettext && \ + pip install --no-cache-dir -r $REQUIREMENTS_FILE && \ + django-admin compilemessages ENTRYPOINT ["./entrypoint.sh"] diff --git a/Makefile b/Makefile index 7fc23eb..a577606 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,12 @@ DOCKER_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u` DOCKER_BUILD = DOCKER_BUILDKIT=1 docker build -DOCKER_CONTAINER_NAME = backend MANAGE_EXEC = python /app/src/manage.py -MANAGE_COMMAND = ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} ${MANAGE_EXEC} - -init: setup_venv pre_commit_install migrate +MANAGE_COMMAND = ${DOCKER_RUN} app ${MANAGE_EXEC} run: ${DOCKER_COMPOSE} up -setup_venv: - rm -rf venv - python3.11 -m venv venv; - venv/bin/python -m pip install wheel setuptools; - venv/bin/python -m pip install pre-commit boto3 pip-tools; - -pre_commit_install: - venv/bin/pre-commit install - -pre_commit_run_all: - venv/bin/pre-commit run --all-files - makemigrations: ${MANAGE_COMMAND} makemigrations ${ARGS} @@ -36,23 +21,3 @@ shell: manage_command: ${MANAGE_COMMAND} ${ARGS} - -add_dependency: - ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry add --lock ${DEPENDENCY} - -add_dev_dependency: - ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry add -D --lock ${DEPENDENCY} - -poetry_lock: - ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry lock --no-update - -poetry_command: - ${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry ${COMMAND} - -build_dev_docker_image: compile_requirements - ${DOCKER_COMPOSE} build ${DOCKER_CONTAINER_NAME} - -compile_requirements: - ./venv/bin/pip-compile --output-file requirements/base.txt requirements/base.in - ./venv/bin/pip-compile --output-file requirements/test.txt requirements/test.in - ./venv/bin/pip-compile --output-file requirements/dev.txt requirements/dev.in diff --git a/README.md b/README.md index 9aed72b..2c8d5d3 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,74 @@ -# 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 -### Docker +### Using Docker Compose + +Working with the Docker Compose setup is made easy with the `Makefile` provided in the repository. #### Requirements - Docker -- Docker compose -- pre-commit (preferred for contributions) +- docker compose plugin #### 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 +### Using hatch -Make messages: +#### Requirements - $ make makemessages +- Python 3.12 or higher +- [hatch](https://hatch.pypa.io/) (Recommended way to install is using `pipx install hatch`) +- A running PostgreSQL server -Running tests: +#### Setup - $ make test +1. Setup .env file -### Non-docker +An example .env file is provided in the repository. You can copy it to .env file using the following command: -Create a venv +```bash +cp .env.example .env +``` - $ python3 -m venv venv +Edit the .env file and set the values for the environment variables, especially the database variables. -Activate the venv +2. Run migrate - $ source venv/bin/activate +```bash +hatch run dev:migrate +``` -Install requirements +3. Run the development server - $ pip install -r requirements/dev.txt - -Run migrations - - $ ./src/manage.py migrate - -Create a superuser - - $ ./src/manage.py createsuperuser - -Run the server - - $ ./src/manage.py runserver +```bash +hatch run dev:server +``` diff --git a/devenv.lock b/devenv.lock deleted file mode 100644 index 0b577e6..0000000 --- a/devenv.lock +++ /dev/null @@ -1,241 +0,0 @@ -{ - "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 -} diff --git a/devenv.nix b/devenv.nix deleted file mode 100644 index be720b2..0000000 --- a/devenv.nix +++ /dev/null @@ -1,19 +0,0 @@ -{ 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"; - }; - -} diff --git a/devenv.yaml b/devenv.yaml deleted file mode 100644 index a32e623..0000000 --- a/devenv.yaml +++ /dev/null @@ -1,5 +0,0 @@ -inputs: - nixpkgs: - url: github:NixOS/nixpkgs/nixpkgs-unstable - nixpkgs-python: - url: github:cachix/nixpkgs-python diff --git a/docker-compose.yml b/docker-compose.yml index 6872ac9..9bd0957 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,10 @@ -version: '3.7' - +--- services: - - backend: - image: data_coop_membersystem:dev + app: build: context: . + args: + - REQUIREMENTS_FILE=requirements/requirements-dev.txt command: python /app/src/manage.py runserver 0.0.0.0:8000 tty: true ports: @@ -28,3 +27,4 @@ services: volumes: postgres_data: +... diff --git a/pyproject.toml b/pyproject.toml index 5346347..643b1cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,13 +12,13 @@ authors = [ { name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" }, ] dependencies = [ - "Django==5.0.6", - "django-money==3.4.1", - "django-allauth==0.63.3", - "psycopg[binary]==3.1.19", + "Django==5.0.7", + "django-money==3.5.2", + "django-allauth==0.63.6", + "psycopg[binary]==3.2.1", "environs[django]==11.0.0", - "uvicorn==0.30.0", - "whitenoise==6.6.0", + "uvicorn==0.30.1", + "whitenoise==6.7.0", "django-zen-queries==2.1.0", "django-registries==0.0.3", "django-view-decorator==0.0.4", @@ -29,7 +29,14 @@ version = "0.0.1" [tool.hatch.build.targets.wheel] packages = ["src"] +[tool.hatch.env] +requires = ["hatch-pip-compile"] + [tool.hatch.envs.default] +type = "pip-compile" + +[tool.hatch.envs.dev] +type = "pip-compile" dependencies = [ "coverage[toml]==7.3.0", "pytest==7.2.2", @@ -45,7 +52,7 @@ dependencies = [ [[tool.hatch.envs.tests.matrix]] python = ["3.12"] -django = ["4.2", "5.0"] +django = ["5.0"] [tool.hatch.envs.tests.overrides] matrix.django.dependencies = [ @@ -55,7 +62,7 @@ matrix.python.dependencies = [ { value = "typing_extensions==4.5.0", if = ["3.10"]}, ] -[tool.hatch.envs.default.scripts] +[tool.hatch.envs.dev.scripts] cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=src --cov=tests --cov=append {args}" no-cov = "cov --no-cov {args}" typecheck = "mypy --config-file=pyproject.toml ." @@ -111,6 +118,9 @@ target-version = "py312" extend-exclude = [ ".git", "__pycache__", + "manage.py", + "asgi.py", + "wsgi.py", ] line-length = 120 @@ -124,7 +134,21 @@ ignore = [ "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) + "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] force-single-line = true + +[tool.ruff.lint.per-file-ignores] +"tests.py" = [ + "S101", # Use of assert + "SLF001", # Private member access + "D100", # Docstrings + "D103", # Docstrings +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5642b3a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,103 @@ +# +# This file is autogenerated by hatch-pip-compile with Python 3.12 +# +# - django-allauth==0.63.6 +# - django-money==3.5.2 +# - django-oauth-toolkit==2.4.0 +# - django-registries==0.0.3 +# - django-view-decorator==0.0.4 +# - django-zen-queries==2.1.0 +# - django==5.0.7 +# - environs[django]==11.0.0 +# - psycopg[binary]==3.2.1 +# - uvicorn==0.30.1 +# - whitenoise==6.7.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.7 + # 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.6 + # via hatch.envs.default +django-cache-url==3.4.5 + # via environs +django-money==3.5.2 + # 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.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 +sqlparse==0.5.1 + # via django +typing-extensions==4.12.2 + # via + # dj-database-url + # jwcrypto + # psycopg + # py-moneyed +urllib3==2.2.2 + # via requests +uvicorn==0.30.1 + # via hatch.envs.default +whitenoise==6.7.0 + # via hatch.envs.default + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/base.in b/requirements/base.in deleted file mode 100644 index 93b7645..0000000 --- a/requirements/base.in +++ /dev/null @@ -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 diff --git a/requirements/base.txt b/requirements/base.txt deleted file mode 100644 index 4d4f8f1..0000000 --- a/requirements/base.txt +++ /dev/null @@ -1,101 +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-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 diff --git a/requirements/dev.in b/requirements/dev.in deleted file mode 100644 index 74eb1bb..0000000 --- a/requirements/dev.in +++ /dev/null @@ -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 diff --git a/requirements/dev.txt b/requirements/dev.txt deleted file mode 100644 index 8899b9f..0000000 --- a/requirements/dev.txt +++ /dev/null @@ -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 diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt new file mode 100644 index 0000000..f044e8c --- /dev/null +++ b/requirements/requirements-dev.txt @@ -0,0 +1,183 @@ +# +# 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.6 +# - django-money==3.5.2 +# - django-oauth-toolkit==2.4.0 +# - django-registries==0.0.3 +# - django-view-decorator==0.0.4 +# - django-zen-queries==2.1.0 +# - django==5.0.7 +# - environs[django]==11.0.0 +# - psycopg[binary]==3.2.1 +# - uvicorn==0.30.1 +# - whitenoise==6.7.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.7 + # 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.2 + # 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.2.1 + # via + # hatch.envs.dev + # psycopg +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 +sqlparse==0.5.1 + # 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.1 + # via hatch.envs.dev +wheel==0.43.0 + # via pip-tools +whitenoise==6.7.0 + # via hatch.envs.dev + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements/test.in b/requirements/test.in deleted file mode 100644 index 2151178..0000000 --- a/requirements/test.in +++ /dev/null @@ -1,5 +0,0 @@ --r base.txt - -coverage==7.4.0 -tblib==3.0.0 -unittest-xml-reporting==3.2.0 diff --git a/requirements/test.txt b/requirements/test.txt deleted file mode 100644 index 63ad70a..0000000 --- a/requirements/test.txt +++ /dev/null @@ -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 diff --git a/src/accounting/__init__.py b/src/accounting/__init__.py index e69de29..8d5ad61 100644 --- a/src/accounting/__init__.py +++ b/src/accounting/__init__.py @@ -0,0 +1 @@ +"""Accounting app.""" diff --git a/src/accounting/admin.py b/src/accounting/admin.py index 53691b7..38664f7 100644 --- a/src/accounting/admin.py +++ b/src/accounting/admin.py @@ -1,26 +1,36 @@ +"""Admin for the accounting app.""" + from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from . import models +from .models import Order +from .models import Payment -@admin.register(models.Order) +@admin.register(Order) class OrderAdmin(admin.ModelAdmin): + """Admin for the Order model.""" + list_display = ("who", "description", "created", "is_paid") @admin.display(description=_("Customer")) - def who(self, instance): + def who(self, instance: Order) -> str: + """Return the full name of the user who made the order.""" return instance.user.get_full_name() -@admin.register(models.Payment) +@admin.register(Payment) class PaymentAdmin(admin.ModelAdmin): + """Admin for the Payment model.""" + list_display = ("who", "description", "order_id", "created") @admin.display(description=_("Customer")) - def who(self, instance): + def who(self, instance: Payment) -> str: + """Return the full name of the user who made the payment.""" return instance.order.user.get_full_name() @admin.display(description=_("Order ID")) - def order_id(self, instance): + def order_id(self, instance: Payment) -> int: + """Return the ID of the order.""" return instance.order.id diff --git a/src/accounting/apps.py b/src/accounting/apps.py index e142288..296dae8 100644 --- a/src/accounting/apps.py +++ b/src/accounting/apps.py @@ -1,5 +1,9 @@ +"""Accounting app configuration.""" + from django.apps import AppConfig class AccountingConfig(AppConfig): + """Accounting app config.""" + name = "accounting" diff --git a/src/accounting/migrations/0003_alter_payment_stripe_charge_id.py b/src/accounting/migrations/0003_alter_payment_stripe_charge_id.py new file mode 100644 index 0000000..e86a7da --- /dev/null +++ b/src/accounting/migrations/0003_alter_payment_stripe_charge_id.py @@ -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, + ), + ] diff --git a/src/accounting/models.py b/src/accounting/models.py index f529a0c..c5355f2 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -1,4 +1,7 @@ +"""Models for the accounting app.""" + from hashlib import md5 +from typing import Self from django.conf import settings from django.db import models @@ -6,9 +9,12 @@ from django.db.models.aggregates import Sum from django.utils.translation import gettext as _ from django.utils.translation import pgettext_lazy from djmoney.models.fields import MoneyField +from djmoney.money import Money class CreatedModifiedAbstract(models.Model): + """Abstract model to track creation and modification of objects.""" + modified = models.DateTimeField(auto_now=True, verbose_name=_("modified")) created = models.DateTimeField(auto_now_add=True, verbose_name=_("created")) @@ -17,19 +23,27 @@ class CreatedModifiedAbstract(models.Model): class Account(CreatedModifiedAbstract): - """This is the model where we can give access to several users, such that they + """An account for a user. + + 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. """ owner = models.ForeignKey("auth.User", on_delete=models.PROTECT) + def __str__(self) -> str: + return f"Account of {self.owner.get_full_name()}" + @property - def balance(self): + def balance(self) -> Money: + """Return the balance of the account.""" return self.transactions.all().aggregate(Sum("amount")).get("amount", 0) class Transaction(CreatedModifiedAbstract): - """Tracks in and outgoing events of an account. When an order is received, an + """A transaction. + + 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. """ @@ -46,9 +60,14 @@ class Transaction(CreatedModifiedAbstract): ) 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): - """Scoped out: Contents of invoices will have to be tracked either here or in + """An order. + + Scoped out: Contents of invoices will have to be tracked either here or in a separate Invoice model. This is undecided because we are not generating invoices at the moment. """ @@ -67,23 +86,6 @@ class Order(CreatedModifiedAbstract): 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: verbose_name = pgettext_lazy("accounting term", "Order") verbose_name_plural = pgettext_lazy("accounting term", "Orders") @@ -91,24 +93,55 @@ class Order(CreatedModifiedAbstract): def __str__(self) -> str: 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): + """A payment is a transaction that is made to pay for an order.""" + amount = MoneyField(max_digits=16, decimal_places=2) order = models.ForeignKey(Order, on_delete=models.PROTECT) description = models.CharField(max_length=1024, verbose_name=_("description")) payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT) - external_transaction_id = models.CharField(max_length=255, null=True, blank=True) + external_transaction_id = models.CharField(max_length=255, default="", blank=True) - # stripe_charge_id = models.CharField(max_length=255, null=True, blank=True) + # stripe_charge_id = models.CharField(max_length=255, null=True, blank=True) # noqa: ERA001 + + class Meta: + verbose_name = _("payment") + verbose_name_plural = _("payments") + + def __str__(self) -> str: + return f"Payment ID {self.display_id}" @property - def display_id(self): + def display_id(self) -> str: + """Return an id for the payment.""" return str(self.id).zfill(6) @classmethod - def from_order(cls, order, payment_type): + 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, @@ -117,22 +150,16 @@ class Payment(CreatedModifiedAbstract): payment_type=payment_type, ) - def __str__(self) -> str: - return f"Payment ID {self.display_id}" - - class Meta: - verbose_name = _("payment") - verbose_name_plural = _("payments") - class PaymentType(CreatedModifiedAbstract): - """Types of payments available in the system: + """Types of payments available in the system. + - bank transfer - card payment (specific provider) """ name = models.CharField(max_length=1024, verbose_name=_("description")) - description = models.TextField(max_length=2048, null=True, blank=True) + description = models.TextField(max_length=2048, blank=True) enabled = models.BooleanField(default=True) diff --git a/src/membership/__init__.py b/src/membership/__init__.py index f5e828a..3a317c3 100644 --- a/src/membership/__init__.py +++ b/src/membership/__init__.py @@ -1,7 +1,5 @@ """Membership application. -====================== This application's domain relate to organizational structures and implementation of statutes, policies etc. - """ diff --git a/src/membership/admin.py b/src/membership/admin.py index 465764f..2cf7030 100644 --- a/src/membership/admin.py +++ b/src/membership/admin.py @@ -1,3 +1,5 @@ +"""Admin configuration for membership app.""" + from django.contrib import admin from .models import Membership @@ -7,14 +9,14 @@ from .models import SubscriptionPeriod @admin.register(Membership) class MembershipAdmin(admin.ModelAdmin): - pass + """Admin for Membership model.""" @admin.register(MembershipType) class MembershipTypeAdmin(admin.ModelAdmin): - pass + """Admin for MembershipType model.""" @admin.register(SubscriptionPeriod) class SubscriptionPeriodAdmin(admin.ModelAdmin): - pass + """Admin for SubscriptionPeriod model.""" diff --git a/src/membership/apps.py b/src/membership/apps.py index b8459a2..e306005 100644 --- a/src/membership/apps.py +++ b/src/membership/apps.py @@ -1,11 +1,16 @@ +"""Membership app configuration.""" + from django.apps import AppConfig from django.db.models.signals import post_migrate class MembershipConfig(AppConfig): + """Membership app config.""" + name = "membership" def ready(self) -> None: + """Ready method.""" from .permissions import persist_permissions post_migrate.connect(persist_permissions, sender=self) diff --git a/src/membership/models.py b/src/membership/models.py index 730133f..502aa66 100644 --- a/src/membership/models.py +++ b/src/membership/models.py @@ -1,3 +1,8 @@ +"""Models for the membership app.""" + +from typing import ClassVar +from typing import Self + from django.contrib.auth.models import User from django.contrib.postgres.constraints import ExclusionConstraint from django.contrib.postgres.fields import DateRangeField @@ -8,15 +13,24 @@ from django.utils.translation import gettext as _ from utils.mixins import CreatedModifiedAbstract +class NoSubscriptionPeriodFoundError(Exception): + """Raised when no subscription period is found.""" + + class Member(User): + """Proxy model for the User model to add some convenience methods.""" + 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 current_subscription_period = get_current_subscription_period() if not current_subscription_period: - raise ValueError("No current subscription period found") + raise NoSubscriptionPeriodFoundError return self.annotate( active_membership=models.Exists( @@ -34,12 +48,15 @@ class Member(User): class SubscriptionPeriod(CreatedModifiedAbstract): - """Denotes a period for which members should pay their membership fee for.""" + """A subscription period. + + Denotes a period for which members should pay their membership fee for. + """ period = DateRangeField(verbose_name=_("period")) class Meta: - constraints = [ + constraints: ClassVar = [ ExclusionConstraint( name="exclude_overlapping_periods", expressions=[ @@ -53,22 +70,31 @@ class SubscriptionPeriod(CreatedModifiedAbstract): class Membership(CreatedModifiedAbstract): - """Tracks that a user has membership of a given type for a given period.""" + """A membership. + + Tracks that a user has membership of a given type for a given period. + """ 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) - def _current(self): + def _current(self) -> Self: + """Filter memberships for the current period.""" return self.filter(period__period__contains=timezone.now()) def current(self) -> "Membership | None": + """Get the current membership.""" try: return self._current().get() except self.model.DoesNotExist: return None def previous(self) -> list["Membership"]: + """Get previous memberships.""" # A naïve way to get previous by just excluding the current. This # means that there must be some protection against "future" # memberships. @@ -76,10 +102,6 @@ class Membership(CreatedModifiedAbstract): objects = QuerySet.as_manager() - class Meta: - verbose_name = _("membership") - verbose_name_plural = _("memberships") - user = models.ForeignKey("auth.User", on_delete=models.PROTECT) membership_type = models.ForeignKey( @@ -94,20 +116,26 @@ class Membership(CreatedModifiedAbstract): on_delete=models.PROTECT, ) + class Meta: + verbose_name = _("membership") + verbose_name_plural = _("memberships") + def __str__(self) -> str: return f"{self.user} - {self.period}" class MembershipType(CreatedModifiedAbstract): - """Models membership types. Currently only a name, but will in the future + """A membership type. + + Models membership types. Currently only a name, but will in the future possibly contain more information like fees. """ + name = models.CharField(verbose_name=_("name"), max_length=64) + class Meta: verbose_name = _("membership type") verbose_name_plural = _("membership types") - name = models.CharField(verbose_name=_("name"), max_length=64) - def __str__(self) -> str: return self.name diff --git a/src/membership/permissions.py b/src/membership/permissions.py index 657adcb..8b79c49 100644 --- a/src/membership/permissions.py +++ b/src/membership/permissions.py @@ -1,3 +1,5 @@ +"""Permissions for the membership app.""" + from dataclasses import dataclass from django.contrib.auth.models import Permission as DjangoPermission @@ -7,26 +9,32 @@ from django.utils.translation import gettext_lazy as _ PERMISSIONS = [] -def persist_permissions(sender, **kwargs) -> None: +def persist_permissions(*args, **kwargs) -> None: # type: ignore[no-untyped-def] # noqa: ARG001 + """Persist all permissions.""" for permission in PERMISSIONS: permission.persist_permission() @dataclass class Permission: + """Dataclass to define a permission.""" + name: str codename: str app_label: str model: str - def __post_init__(self, *args, **kwargs): + def __post_init__(self, *args, **kwargs) -> None: + """Post init method.""" PERMISSIONS.append(self) @property def path(self) -> str: + """Return the path of the permission.""" return f"{self.app_label}.{self.codename}" def persist_permission(self) -> None: + """Persist the permission.""" content_type, _ = ContentType.objects.get_or_create( app_label=self.app_label, model=self.model, diff --git a/src/membership/selectors.py b/src/membership/selectors.py index 83458b3..fcf4ee3 100644 --- a/src/membership/selectors.py +++ b/src/membership/selectors.py @@ -1,4 +1,9 @@ +"""Selectors for the membership app.""" + +from __future__ import annotations + import contextlib +from typing import TYPE_CHECKING from django.db.models import Exists from django.db.models import OuterRef @@ -8,8 +13,12 @@ from membership.models import Member from membership.models import Membership 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]: + """Get all subscription periods.""" subscription_periods = SubscriptionPeriod.objects.prefetch_related( "membership_set", "membership_set__user", @@ -29,6 +38,7 @@ def get_subscription_periods(member: Member | None = None) -> list[SubscriptionP def get_current_subscription_period() -> SubscriptionPeriod | None: + """Get the current subscription period.""" with contextlib.suppress(SubscriptionPeriod.DoesNotExist): return SubscriptionPeriod.objects.prefetch_related( "membership_set", @@ -41,6 +51,7 @@ def get_memberships( member: Member | None = None, period: SubscriptionPeriod | None = None, ) -> Membership.QuerySet: + """Get memberships.""" memberships = Membership.objects.select_related("membership_type").all() if member: @@ -52,9 +63,11 @@ def get_memberships( return memberships -def get_members(): +def get_members() -> QuerySet[Member]: + """Get all members.""" return Member.objects.all().annotate_membership().order_by("username") def get_member(*, member_id: int) -> Member: + """Get a member by id.""" return get_members().get(id=member_id) diff --git a/src/membership/views.py b/src/membership/views.py index 15c5a68..eeb2e58 100644 --- a/src/membership/views.py +++ b/src/membership/views.py @@ -1,8 +1,14 @@ +"""Views for the membership app.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.utils.translation import gettext_lazy as _ 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 utils.view_utils import render_list from .permissions import ADMINISTRATE_MEMBERS from .selectors import get_member @@ -10,6 +16,10 @@ from .selectors import get_members from .selectors import get_memberships 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") @@ -18,7 +28,8 @@ member_view = namespaced_decorator_factory(namespace="member", base_path="member name="membership-overview", login_required=True, ) -def membership_overview(request): +def membership_overview(request: HttpRequest) -> HttpResponse: + """View to show the membership overview.""" memberships = get_memberships(member=request.user) current_membership = memberships.current() previous_memberships = memberships.previous() @@ -50,13 +61,13 @@ admin_members_view = namespaced_decorator_factory( login_required=True, permissions=[ADMINISTRATE_MEMBERS.path], ) -def members_admin(request): +def members_admin(request: HttpRequest) -> HttpResponse: + """View to list all members.""" users = get_members() - return render_list( + render_config = RenderConfig( entity_name="member", entity_name_plural="members", - request=request, paginate_by=20, objects=users, columns=[ @@ -75,6 +86,10 @@ def members_admin(request): ], ) + return render_config.render_list( + request=request, + ) + @admin_members_view( paths="/", @@ -82,7 +97,8 @@ def members_admin(request): login_required=True, permissions=[ADMINISTRATE_MEMBERS.path], ) -def members_admin_detail(request, member_id): +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) subscription_periods = get_subscription_periods(member=member) diff --git a/src/project/__init__.py b/src/project/__init__.py index e69de29..64f16b6 100644 --- a/src/project/__init__.py +++ b/src/project/__init__.py @@ -0,0 +1 @@ +"""data.coop member system.""" diff --git a/src/project/settings.py b/src/project/settings.py index a07226f..9cf846d 100644 --- a/src/project/settings.py +++ b/src/project/settings.py @@ -1,3 +1,5 @@ +"""Settings for the project.""" + from pathlib import Path from django.utils.translation import gettext_lazy as _ diff --git a/src/project/static/css/dark-style.css b/src/project/static/css/dark-style.css index 7f48771..ebafa9b 100644 --- a/src/project/static/css/dark-style.css +++ b/src/project/static/css/dark-style.css @@ -64,4 +64,4 @@ html.dark input[type="email"] { background: var(--dark-dark); width: 100%; color: var(--light-dust); -} \ No newline at end of file +} diff --git a/src/project/static/css/style.css b/src/project/static/css/style.css index 17f12e2..d71a430 100644 --- a/src/project/static/css/style.css +++ b/src/project/static/css/style.css @@ -556,4 +556,4 @@ span.time_remaining { .pagination .page-item.disabled .page-link { cursor: default; -} \ No newline at end of file +} diff --git a/src/project/templates/account/email.html b/src/project/templates/account/email.html index ec996ca..7900a34 100644 --- a/src/project/templates/account/email.html +++ b/src/project/templates/account/email.html @@ -110,7 +110,7 @@ addEmail.addEventListener('click', function(e) { e.preventDefault(); - let overlay = document.getElementById('email-add-overlay') + let overlay = document.getElementById('email-add-overlay') overlay.style.display = 'flex' window.addEventListener('keydown', function(e) { diff --git a/src/project/templates/base.html b/src/project/templates/base.html index 5f31cd9..485fbdf 100644 --- a/src/project/templates/base.html +++ b/src/project/templates/base.html @@ -113,7 +113,7 @@ themeSwitcher.addEventListener('click', function() { themeSwitcher.classList.toggle('active') let isDark = document.querySelector('html').classList.toggle('dark'); - + localStorage.setItem('theme', isDark ? 'dark' : 'light'); }); diff --git a/src/project/urls.py b/src/project/urls.py index 8de19c6..c68d1f5 100644 --- a/src/project/urls.py +++ b/src/project/urls.py @@ -1,4 +1,5 @@ """URLs for the membersystem.""" + from django.conf import settings from django.contrib import admin from django.urls import include diff --git a/src/project/views.py b/src/project/views.py index 1416f8d..7fbde77 100644 --- a/src/project/views.py +++ b/src/project/views.py @@ -1,13 +1,24 @@ +"""Project views.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + 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( paths="", name="index", login_required=True, ) -def index(request): +def index(request: HttpRequest) -> HttpResponse: + """View to show the index page.""" return render(request, "index.html") @@ -16,5 +27,6 @@ def index(request): name="services", login_required=True, ) -def services_overview(request): +def services_overview(request: HttpRequest) -> HttpResponse: + """View to show the services overview.""" return render(request, "services_overview.html") diff --git a/src/project/wsgi.py b/src/project/wsgi.py index e4c5320..7d7695f 100644 --- a/src/project/wsgi.py +++ b/src/project/wsgi.py @@ -5,6 +5,7 @@ It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ """ + import os from django.core.wsgi import get_wsgi_application diff --git a/src/utils/__init__.py b/src/utils/__init__.py index e69de29..1728a1c 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions for the project.""" diff --git a/src/utils/mixins.py b/src/utils/mixins.py index 26e2fe7..94e33e4 100644 --- a/src/utils/mixins.py +++ b/src/utils/mixins.py @@ -1,8 +1,12 @@ +"""Mixins for models.""" + from django.db import models from django.utils.translation import gettext_lazy as _ class CreatedModifiedAbstract(models.Model): + """Abstract model to track creation and modification of objects.""" + modified = models.DateTimeField(auto_now=True, verbose_name=_("modified")) created = models.DateTimeField(auto_now_add=True, verbose_name=_("created")) diff --git a/src/utils/templatetags/__init__.py b/src/utils/templatetags/__init__.py index e69de29..b0546b6 100644 --- a/src/utils/templatetags/__init__.py +++ b/src/utils/templatetags/__init__.py @@ -0,0 +1 @@ +"""Utility template tags for the project.""" diff --git a/src/utils/templatetags/utils.py b/src/utils/templatetags/utils.py index 90ab6ef..89f2712 100644 --- a/src/utils/templatetags/utils.py +++ b/src/utils/templatetags/utils.py @@ -1,3 +1,7 @@ +"""Custom template tags for the project.""" + +from typing import Any + from django import template from django.urls import reverse @@ -5,7 +9,7 @@ register = template.Library() @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.""" path = reverse(path_name) request_path = context.get("request").path diff --git a/src/utils/view_utils.py b/src/utils/view_utils.py index 3de84c3..bb04192 100644 --- a/src/utils/view_utils.py +++ b/src/utils/view_utils.py @@ -1,3 +1,7 @@ +"""Utility views for rendering lists of objects.""" + +from __future__ import annotations + import contextlib from dataclasses import dataclass from typing import TYPE_CHECKING @@ -6,14 +10,15 @@ from typing import Any from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import FieldError from django.core.paginator import Paginator -from django.http import HttpRequest -from django.http import HttpResponse from django.urls import reverse from zen_queries import queries_disabled 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 @@ -32,7 +37,7 @@ class RowAction: url_name: 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.""" url = reverse( self.url_name, @@ -41,63 +46,77 @@ class RowAction: return {"label": self.label, "url": url} -def render_list( - request: HttpRequest, - 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 +@dataclass(kw_only=True) +class RenderConfig: + """Configuration for rendering a list of objects.""" - total_count = len(objects) + 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 - order_by = request.GET.get("order_by") + def render_list( + self, + request: HttpRequest, + ) -> HttpResponse: + """Render a list of objects with a table.""" + # TODO: List actions - if order_by: - with contextlib.suppress(FieldError): - objects = objects.order_by(order_by) + 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 - if paginate_by: - paginator = Paginator(object_list=objects, per_page=paginate_by) - page = paginator.get_page(request.GET.get("page")) - objects = page.object_list + total_count = len(objects) - 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) + order_by = request.GET.get("order_by") - 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 order_by: + with contextlib.suppress(FieldError): + objects = objects.order_by(order_by) - if paginate_by: - context |= { - "page": page, - "is_paginated": True, + if paginate_by: + paginator = Paginator(object_list=objects, per_page=paginate_by) + page = paginator.get_page(request.GET.get("page")) + objects = page.object_list + + 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, } - return render( - request=request, - template_name="utils/list.html", - context=context, - ) + if paginate_by: + context |= { + "page": page, + "is_paginated": True, + } + + return render( + request=request, + template_name="utils/list.html", + context=context, + ) def base_context(request: HttpRequest) -> dict[str, Any]: @@ -105,7 +124,7 @@ def base_context(request: HttpRequest) -> dict[str, Any]: 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.""" if context is None: context = {}