forked from data.coop/membersystem
Compare commits
35 commits
feature/cs
...
main
Author | SHA1 | Date | |
---|---|---|---|
Benjamin Bach | 43d5dcbd52 | ||
Benjamin Bach | f5feda3414 | ||
Benjamin Bach | 3659cf40df | ||
Benjamin Bach | 8f3e8f06f0 | ||
Benjamin Bach | b3795977ed | ||
Benjamin Bach | c81481747f | ||
Víðir Valberg Guðmundsson | 52b38abf2a | ||
Benjamin Bach | 00c615f318 | ||
Benjamin Bach | 1070e93885 | ||
Víðir Valberg Guðmundsson | ca8987ba3b | ||
Benjamin Bach | 4254baf09d | ||
Benjamin Bach | 59620aa309 | ||
Benjamin Bach | 865bc6c7bd | ||
Víðir Valberg Guðmundsson | 0cf579c5f6 | ||
Víðir Valberg Guðmundsson | 7a3a629d6f | ||
Benjamin Bach | f6d8f82065 | ||
Benjamin Bach | 2c99799d4d | ||
Víðir Valberg Guðmundsson | 5d516b7851 | ||
Víðir Valberg Guðmundsson | f18469833a | ||
Víðir Valberg Guðmundsson | 480eecca12 | ||
Víðir Valberg Guðmundsson | b39b114e30 | ||
Víðir Valberg Guðmundsson | d31f62ebb4 | ||
Víðir Valberg Guðmundsson | 712c50fac7 | ||
Halfdan Mouritzen | fedfca25a5 | ||
Halfdan Mouritzen | 89d7c9c9d5 | ||
Halfdan Mouritzen | f5acfbedd4 | ||
Víðir Valberg Guðmundsson | cf3c84b8d9 | ||
Víðir Valberg Guðmundsson | 1b65558608 | ||
Halfdan Mouritzen | 4112069cac | ||
Víðir Valberg Guðmundsson | bdc2d8717c | ||
Víðir Valberg Guðmundsson | f99c7ee698 | ||
Víðir Valberg Guðmundsson | a098a0b032 | ||
Víðir Valberg Guðmundsson | f31cd62351 | ||
Víðir Valberg Guðmundsson | b8a970d5fe | ||
Halfdan Mouritzen | 9dda7670df |
|
@ -3,5 +3,8 @@
|
|||
*/.*
|
||||
|
||||
!src/
|
||||
!requirements.txt
|
||||
!requirements/
|
||||
!entrypoint.sh
|
||||
!pyproject.toml
|
||||
!README.md
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -6,4 +6,5 @@ DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres
|
|||
# Use something along the the following if you are not using docker
|
||||
# DATABASE_URL=postgres://postgres:postgres@localhost:5432/datacoop_membersystem
|
||||
DEBUG=True
|
||||
DJANGO_ENV=development
|
||||
STRIPE_API_KEY=sk_test_
|
||||
STRIPE_ENDPOINT_SECRET=whsec_
|
||||
|
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -8,3 +8,7 @@ db.sqlite3
|
|||
.env
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
|
||||
# collectstatic
|
||||
src/static/
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
default_language_version:
|
||||
python: python3.11
|
||||
python: python3
|
||||
exclude: ^.*\b(migrations)\b.*$
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: check-ast
|
||||
- id: check-merge-conflict
|
||||
|
@ -15,46 +15,23 @@ repos:
|
|||
- id: check-toml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: 'v0.1.11'
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: 'v0.5.2'
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
- --fix
|
||||
- repo: https://github.com/asottile/reorder_python_imports
|
||||
rev: v3.12.0
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
args:
|
||||
- --py310-plus
|
||||
- --application-directories=.:src
|
||||
exclude: migrations/
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.15.0
|
||||
rev: v3.16.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args:
|
||||
- --py311-plus
|
||||
exclude: migrations/
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: 1.15.0
|
||||
rev: 1.19.0
|
||||
hooks:
|
||||
- id: django-upgrade
|
||||
args:
|
||||
- --target-version=4.1
|
||||
- repo: https://github.com/asottile/yesqa
|
||||
rev: v1.5.0
|
||||
hooks:
|
||||
- id: yesqa
|
||||
- repo: https://github.com/asottile/add-trailing-comma
|
||||
rev: v3.1.0
|
||||
hooks:
|
||||
- id: add-trailing-comma
|
||||
- repo: https://github.com/hadialqattan/pycln
|
||||
rev: v2.4.0
|
||||
hooks:
|
||||
- id: pycln
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.12.1
|
||||
hooks:
|
||||
- id: black
|
||||
- --target-version=5.0
|
||||
|
|
41
Dockerfile
41
Dockerfile
|
@ -1,18 +1,33 @@
|
|||
FROM python:3.11-slim-bullseye
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# PYTHONFAULTHANDLER: Propagate tracebacks from all threads.
|
||||
# PYTHONUNBUFFERED: Write terminal output straight to docker (to not confuse Docker Compose).
|
||||
# PYTHONDONTWRITEBYTECODE: Dont write *pyc files at all, making it possible for a 100% read-only container.
|
||||
# PIP_NO_CACHE_DIR: Disable PIP cache, we don't need pip's cache after building the image.
|
||||
# PIP_DISABLE_PIP_VERSION_CHECK: Build the image with the available pip, do not check for updates (faster!)
|
||||
# PIP_DEFAULT_TIMEOUT: Allow for longer timeouts.
|
||||
ENV PYTHONFAULTHANDLER=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONHASHSEED=random \
|
||||
PIP_NO_CACHE_DIR=off \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=on \
|
||||
PIP_DEFAULT_TIMEOUT=100
|
||||
ARG DJANGO_ENV
|
||||
ARG BUILD
|
||||
ENV BUILD ${BUILD}
|
||||
ENV BUILD=${BUILD}
|
||||
ARG REQUIREMENTS_FILE=requirements.txt
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
WORKDIR /app
|
||||
|
||||
RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www
|
||||
|
||||
# Only copy the requirements file first to leverage Docker cache
|
||||
RUN mkdir requirements/
|
||||
COPY $REQUIREMENTS_FILE $REQUIREMENTS_FILE
|
||||
|
||||
RUN mkdir -p /app/src/static && \
|
||||
chown www:www /app/src/static && \
|
||||
apt-get update && \
|
||||
apt-get install -y \
|
||||
binutils \
|
||||
libpq-dev \
|
||||
build-essential \
|
||||
|
@ -23,15 +38,13 @@ RUN apt-get update \
|
|||
libgdk-pixbuf2.0-0 \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
gettext
|
||||
gettext && \
|
||||
pip install --no-cache-dir -r $REQUIREMENTS_FILE
|
||||
|
||||
WORKDIR /app
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www
|
||||
COPY --chown=www:www . /app/
|
||||
RUN mkdir /app/src/static && chown www:www /app/src/static
|
||||
RUN pip install -r requirements/$([ "$DJANGO_ENV" = "production" ] && echo "base.txt" || echo "dev.txt") &&\
|
||||
django-admin compilemessages
|
||||
RUN django-admin compilemessages
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
|
|
41
Makefile
41
Makefile
|
@ -1,27 +1,12 @@
|
|||
.PHONY: run makemigrations migrate createsuperuser shell manage_command build requirements
|
||||
DOCKER_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose
|
||||
DOCKER_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}
|
||||
|
||||
|
@ -37,22 +22,8 @@ shell:
|
|||
manage_command:
|
||||
${MANAGE_COMMAND} ${ARGS}
|
||||
|
||||
add_dependency:
|
||||
${DOCKER_RUN} ${DOCKER_CONTAINER_NAME} poetry add --lock ${DEPENDENCY}
|
||||
build:
|
||||
${DOCKER_COMPOSE} build
|
||||
|
||||
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
|
||||
requirements:
|
||||
hatch run requirements
|
||||
|
|
102
README.md
102
README.md
|
@ -1,71 +1,105 @@
|
|||
# member.data.coop
|
||||
# data.coop member system
|
||||
|
||||
## Development
|
||||
## Development setup
|
||||
|
||||
### Setup environment
|
||||
There are two ways to setup the development environment.
|
||||
|
||||
Copy over the .env.example file to .env and adjust DATABASE_URL accordingly
|
||||
- Using the Docker Compose setup provided in this repository.
|
||||
- Using [hatch](https://hatch.pypa.io/) in your host OS.
|
||||
|
||||
$ cp .env.example .env
|
||||
### Using Docker Compose
|
||||
|
||||
### Docker
|
||||
Working with the Docker Compose setup is made easy with the `Makefile` provided in the repository.
|
||||
|
||||
#### Requirements
|
||||
|
||||
- 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
|
||||
#### Building and running other things
|
||||
|
||||
Make messages:
|
||||
```bash
|
||||
# Build the containers
|
||||
make build
|
||||
|
||||
$ make makemessages
|
||||
# Create a superuser
|
||||
make createsuperuser
|
||||
|
||||
Running tests:
|
||||
# Create Django migrations (after this, maybe you need to change file permissions in volume)
|
||||
make makemigrations
|
||||
```
|
||||
|
||||
$ make test
|
||||
### Using hatch
|
||||
|
||||
### Non-docker
|
||||
#### Requirements
|
||||
|
||||
Create a venv
|
||||
- Python 3.12 or higher
|
||||
- [hatch](https://hatch.pypa.io/) (Recommended way to install is using `pipx install hatch`)
|
||||
- A running PostgreSQL server
|
||||
|
||||
$ python3 -m venv venv
|
||||
#### Setup
|
||||
|
||||
Activate the venv
|
||||
1. Setup .env file
|
||||
|
||||
$ source venv/bin/activate
|
||||
An example .env file is provided in the repository. You can copy it to .env file using the following command:
|
||||
|
||||
Install requirements
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
$ pip install -r requirements/dev.txt
|
||||
Edit the .env file and set the values for the environment variables, especially the database variables.
|
||||
|
||||
Run migrations
|
||||
2. Run migrate
|
||||
|
||||
$ ./src/manage.py migrate
|
||||
```bash
|
||||
hatch run dev:migrate
|
||||
```
|
||||
|
||||
Create a superuser
|
||||
3. Run the development server
|
||||
|
||||
$ ./src/manage.py createsuperuser
|
||||
```bash
|
||||
hatch run dev:server
|
||||
```
|
||||
|
||||
Run the server
|
||||
### Updating requirements
|
||||
|
||||
$ ./src/manage.py runserver
|
||||
We use hatch-pip-compile. That means we have a set of loosely defined `dependencies` in `pyproject.toml` and then we can keep the exactly pinned version in our `requirements.txt` (auto-generated).
|
||||
|
||||
To generate `requirements.txt` and `requirements/requirements-dev.txt`, run the following command:
|
||||
|
||||
```bash
|
||||
# Build requirements.txt etc
|
||||
make requirements
|
||||
|
||||
# Build Docker image with new Python requirements
|
||||
make build
|
||||
```
|
||||
|
||||
## Important notes
|
||||
|
||||
* This project uses [django-zen-queries](https://github.com/dabapps/django-zen-queries), which will sometimes raise a `QueriesDisabledError` in your templates. You can find a difference of opinion about that, but you can find a difference of opinion about many things, right?
|
||||
* If a linting error annoys you, please feel free to strike back by adding a `noqa` to the line that has displeased the linter and move on with life.
|
||||
|
|
|
@ -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:
|
||||
...
|
||||
|
|
101
pyproject.toml
101
pyproject.toml
|
@ -1,5 +1,5 @@
|
|||
[build-system]
|
||||
requires = ["hatchling", "hatch-vcs"]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
|
@ -12,22 +12,37 @@ authors = [
|
|||
{ name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" },
|
||||
]
|
||||
dependencies = [
|
||||
"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",
|
||||
"Django~=5.1",
|
||||
"django-allauth~=0.63",
|
||||
"django-money~=3.5",
|
||||
"django-oauth-toolkit~=2.4",
|
||||
"django-registries==0.0.3",
|
||||
"django-view-decorator==0.0.4",
|
||||
"django-oauth-toolkit~=2.4",
|
||||
"django-ratelimit~=4.1",
|
||||
"django-zen-queries~=2.1",
|
||||
"django_stubs_ext~=5.0",
|
||||
"environs[django]>=11,<12",
|
||||
"psycopg[binary]~=3.2",
|
||||
"stripe~=10.5",
|
||||
"uvicorn~=0.30",
|
||||
"whitenoise~=6.7",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
version = "0.0.1"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src"]
|
||||
|
||||
[tool.hatch.env]
|
||||
requires = ["hatch-pip-compile"]
|
||||
|
||||
[tool.hatch.envs.default]
|
||||
type = "pip-compile"
|
||||
pip-compile-resolver = "uv"
|
||||
|
||||
[tool.hatch.envs.dev]
|
||||
type = "pip-compile"
|
||||
pip-compile-resolver = "uv"
|
||||
dependencies = [
|
||||
"coverage[toml]==7.3.0",
|
||||
"pytest==7.2.2",
|
||||
|
@ -43,26 +58,25 @@ dependencies = [
|
|||
|
||||
[[tool.hatch.envs.tests.matrix]]
|
||||
python = ["3.12"]
|
||||
django = ["4.2", "5.0"]
|
||||
django = ["5.1"]
|
||||
|
||||
[tool.hatch.envs.tests.overrides]
|
||||
matrix.django.dependencies = [
|
||||
{ value = "django~={matrix:django}" },
|
||||
]
|
||||
matrix.python.dependencies = [
|
||||
{ value = "typing_extensions==4.5.0", if = ["3.10"]},
|
||||
]
|
||||
|
||||
[tool.hatch.envs.default.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 ."
|
||||
requirements = "pip-compile --output-file requirements/base.txt pyproject.toml"
|
||||
server = "./src/manage.py runserver"
|
||||
requirements = "hatch env run --env default -- python --version; hatch env run --env dev -- python --version"
|
||||
server = "./src/manage.py runserver 0.0.0.0:8000"
|
||||
migrate = "./src/manage.py migrate"
|
||||
makemigrations = "./src/manage.py makemigrations"
|
||||
createsuperuser = "./src/manage.py createsuperuser"
|
||||
shell = "./src/manage.py shell"
|
||||
# You need to install Stripe CLI from here to run this: https://github.com/stripe/stripe-cli/releases
|
||||
stripe_cli = "stripe listen --forward-to 0.0.0.0:8000/order/stripe/webhook/"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE="tests.settings"
|
||||
|
@ -95,11 +109,58 @@ show_error_codes = true
|
|||
strict = true
|
||||
warn_unreachable = true
|
||||
follow_imports = "normal"
|
||||
#plugins = ["mypy_django_plugin.main"]
|
||||
plugins = ["mypy_django_plugin.main"]
|
||||
|
||||
[tool.django-stubs]
|
||||
#django_settings_module = "tests.settings"
|
||||
django_settings_module = "project.settings"
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tests.*"
|
||||
allow_untyped_defs = true
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
extend-exclude = [
|
||||
".git",
|
||||
"__pycache__",
|
||||
"manage.py",
|
||||
"asgi.py",
|
||||
"wsgi.py",
|
||||
]
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"G004", # Logging statement uses f-string
|
||||
"ANN101", # Missing type annotation for `self` in method
|
||||
"ANN102", # Missing type annotation for `cls` in classmethod
|
||||
"EM101", # Exception must not use a string literal, assign to variable first
|
||||
"EM102", # Exception must not use a f-string literal, assign to variable first
|
||||
"COM812", # missing-trailing-comma (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
|
||||
"ISC001", # single-line-implicit-string-concatenation (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
|
||||
"D100", # Missing docstring in public module
|
||||
"D101", # Missing docstring in public class
|
||||
"D102", # Missing docstring in public method
|
||||
"D105", # Missing docstring in magic method
|
||||
"D106", # Missing docstring in public nested class
|
||||
"D107", # Missing docstring in `__init__`
|
||||
"FIX", # TODO, FIXME, XXX
|
||||
"TD", # TODO, FIXME, XXX
|
||||
"ANN002", # Missing type annotation for `*args`
|
||||
"ANN003", # Missing type annotation for `**kwargs`
|
||||
"FBT001", # Misbehaves: Boolean-typed positional argument in function definition
|
||||
"FBT002", # Misbehaves: Boolean-typed positional argument in function definition
|
||||
"TRY003", # Avoid specifying long messages outside the exception class
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
force-single-line = true
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests.py" = [
|
||||
"S101", # Use of assert
|
||||
"SLF001", # Private member access
|
||||
"D100", # Docstrings
|
||||
"D103", # Docstrings
|
||||
]
|
||||
|
|
116
requirements.txt
Normal file
116
requirements.txt
Normal file
|
@ -0,0 +1,116 @@
|
|||
#
|
||||
# This file is autogenerated by hatch-pip-compile with Python 3.12
|
||||
#
|
||||
# - django-allauth~=0.63
|
||||
# - django-money~=3.5
|
||||
# - django-oauth-toolkit~=2.4
|
||||
# - django-ratelimit~=4.1
|
||||
# - django-registries==0.0.3
|
||||
# - django-stubs-ext~=5.0
|
||||
# - django-view-decorator==0.0.4
|
||||
# - django-zen-queries~=2.1
|
||||
# - django<5.2,>=5.1b1
|
||||
# - environs[django]<12,>=11
|
||||
# - psycopg[binary]~=3.2
|
||||
# - stripe~=10.5
|
||||
# - uvicorn~=0.30
|
||||
# - whitenoise~=6.7
|
||||
#
|
||||
|
||||
asgiref==3.8.1
|
||||
# via django
|
||||
babel==2.15.0
|
||||
# via py-moneyed
|
||||
certifi==2024.7.4
|
||||
# via requests
|
||||
cffi==1.16.0
|
||||
# via cryptography
|
||||
charset-normalizer==3.3.2
|
||||
# via requests
|
||||
click==8.1.7
|
||||
# via uvicorn
|
||||
cryptography==43.0.0
|
||||
# via jwcrypto
|
||||
dj-database-url==2.2.0
|
||||
# via environs
|
||||
dj-email-url==1.0.6
|
||||
# via environs
|
||||
django==5.1rc1
|
||||
# via
|
||||
# hatch.envs.default
|
||||
# dj-database-url
|
||||
# django-allauth
|
||||
# django-money
|
||||
# django-oauth-toolkit
|
||||
# django-registries
|
||||
# django-stubs-ext
|
||||
# django-view-decorator
|
||||
# django-zen-queries
|
||||
django-allauth==0.63.6
|
||||
# via hatch.envs.default
|
||||
django-cache-url==3.4.5
|
||||
# via environs
|
||||
django-money==3.5.3
|
||||
# via hatch.envs.default
|
||||
django-oauth-toolkit==2.4.0
|
||||
# via hatch.envs.default
|
||||
django-ratelimit==4.1.0
|
||||
# via hatch.envs.default
|
||||
django-registries==0.0.3
|
||||
# via hatch.envs.default
|
||||
django-stubs-ext==5.0.4
|
||||
# via hatch.envs.default
|
||||
django-view-decorator==0.0.4
|
||||
# via hatch.envs.default
|
||||
django-zen-queries==2.1.0
|
||||
# via hatch.envs.default
|
||||
environs==11.0.0
|
||||
# via hatch.envs.default
|
||||
h11==0.14.0
|
||||
# via uvicorn
|
||||
idna==3.7
|
||||
# via requests
|
||||
jwcrypto==1.5.6
|
||||
# via django-oauth-toolkit
|
||||
marshmallow==3.21.3
|
||||
# via environs
|
||||
oauthlib==3.2.2
|
||||
# via django-oauth-toolkit
|
||||
packaging==24.1
|
||||
# via marshmallow
|
||||
psycopg==3.2.1
|
||||
# via hatch.envs.default
|
||||
psycopg-binary==3.2.1
|
||||
# via psycopg
|
||||
py-moneyed==3.0
|
||||
# via django-money
|
||||
pycparser==2.22
|
||||
# via cffi
|
||||
python-dotenv==1.0.1
|
||||
# via environs
|
||||
pytz==2024.1
|
||||
# via django-oauth-toolkit
|
||||
requests==2.32.3
|
||||
# via
|
||||
# django-oauth-toolkit
|
||||
# stripe
|
||||
setuptools==72.1.0
|
||||
# via django-money
|
||||
sqlparse==0.5.1
|
||||
# via django
|
||||
stripe==10.6.0
|
||||
# via hatch.envs.default
|
||||
typing-extensions==4.12.2
|
||||
# via
|
||||
# dj-database-url
|
||||
# django-stubs-ext
|
||||
# jwcrypto
|
||||
# psycopg
|
||||
# py-moneyed
|
||||
# stripe
|
||||
urllib3==2.2.2
|
||||
# via requests
|
||||
uvicorn==0.30.5
|
||||
# via hatch.envs.default
|
||||
whitenoise==6.7.0
|
||||
# via hatch.envs.default
|
|
@ -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
|
|
@ -1,98 +0,0 @@
|
|||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.11
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --output-file=requirements/base.txt pyproject.toml
|
||||
#
|
||||
asgiref==3.7.2
|
||||
# via django
|
||||
babel==2.14.0
|
||||
# via py-moneyed
|
||||
certifi==2023.11.17
|
||||
# via requests
|
||||
cffi==1.16.0
|
||||
# via cryptography
|
||||
charset-normalizer==3.3.2
|
||||
# via requests
|
||||
click==8.1.7
|
||||
# via uvicorn
|
||||
cryptography==41.0.7
|
||||
# via pyjwt
|
||||
defusedxml==0.7.1
|
||||
# via python3-openid
|
||||
dj-database-url==2.1.0
|
||||
# via environs
|
||||
dj-email-url==1.0.6
|
||||
# via environs
|
||||
django==5.0.1
|
||||
# via
|
||||
# dj-database-url
|
||||
# django-allauth
|
||||
# django-money
|
||||
# django-registries
|
||||
# django-zen-queries
|
||||
# membersystem (pyproject.toml)
|
||||
django-allauth==0.60.0
|
||||
# via membersystem (pyproject.toml)
|
||||
django-cache-url==3.4.5
|
||||
# via environs
|
||||
django-money==3.4.1
|
||||
# via membersystem (pyproject.toml)
|
||||
django-registries==0.0.3
|
||||
# via membersystem (pyproject.toml)
|
||||
django-zen-queries==2.1.0
|
||||
# via membersystem (pyproject.toml)
|
||||
environs[django]==10.0.0
|
||||
# via
|
||||
# environs
|
||||
# membersystem (pyproject.toml)
|
||||
h11==0.14.0
|
||||
# via uvicorn
|
||||
idna==3.6
|
||||
# via requests
|
||||
marshmallow==3.20.1
|
||||
# via environs
|
||||
oauthlib==3.2.2
|
||||
# via requests-oauthlib
|
||||
packaging==23.2
|
||||
# via marshmallow
|
||||
psycopg[binary]==3.1.16
|
||||
# via
|
||||
# membersystem (pyproject.toml)
|
||||
# psycopg
|
||||
psycopg-binary==3.1.16
|
||||
# via psycopg
|
||||
py-moneyed==3.0
|
||||
# via django-money
|
||||
pycparser==2.21
|
||||
# via cffi
|
||||
pyjwt[crypto]==2.8.0
|
||||
# via
|
||||
# django-allauth
|
||||
# pyjwt
|
||||
python-dotenv==1.0.0
|
||||
# via environs
|
||||
python3-openid==3.2.0
|
||||
# via django-allauth
|
||||
requests==2.31.0
|
||||
# via
|
||||
# django-allauth
|
||||
# requests-oauthlib
|
||||
requests-oauthlib==1.3.1
|
||||
# via django-allauth
|
||||
sqlparse==0.4.4
|
||||
# via django
|
||||
typing-extensions==4.9.0
|
||||
# via
|
||||
# dj-database-url
|
||||
# psycopg
|
||||
# py-moneyed
|
||||
urllib3==2.1.0
|
||||
# via requests
|
||||
uvicorn==0.25.0
|
||||
# via membersystem (pyproject.toml)
|
||||
whitenoise==6.6.0
|
||||
# via membersystem (pyproject.toml)
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# setuptools
|
|
@ -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
|
|
@ -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
|
192
requirements/requirements-dev.txt
Normal file
192
requirements/requirements-dev.txt
Normal file
|
@ -0,0 +1,192 @@
|
|||
#
|
||||
# This file is autogenerated by hatch-pip-compile with Python 3.12
|
||||
#
|
||||
# - coverage[toml]==7.3.0
|
||||
# - pytest==7.2.2
|
||||
# - pytest-cov
|
||||
# - pytest-django==4.5.2
|
||||
# - mypy==1.1.1
|
||||
# - django-stubs==1.16.0
|
||||
# - pip-tools==7.3.0
|
||||
# - django-debug-toolbar==4.2.0
|
||||
# - django-browser-reload==1.7.0
|
||||
# - model-bakery==1.17.0
|
||||
# - django-allauth~=0.63
|
||||
# - django-money~=3.5
|
||||
# - django-oauth-toolkit~=2.4
|
||||
# - django-ratelimit~=4.1
|
||||
# - django-registries==0.0.3
|
||||
# - django-stubs-ext~=5.0
|
||||
# - django-view-decorator==0.0.4
|
||||
# - django-zen-queries~=2.1
|
||||
# - django<5.2,>=5.1b1
|
||||
# - environs[django]<12,>=11
|
||||
# - psycopg[binary]~=3.2
|
||||
# - stripe~=10.5
|
||||
# - uvicorn~=0.30
|
||||
# - whitenoise~=6.7
|
||||
#
|
||||
|
||||
asgiref==3.8.1
|
||||
# via django
|
||||
attrs==23.2.0
|
||||
# via pytest
|
||||
babel==2.15.0
|
||||
# via py-moneyed
|
||||
build==1.2.1
|
||||
# via pip-tools
|
||||
certifi==2024.7.4
|
||||
# via requests
|
||||
cffi==1.16.0
|
||||
# via cryptography
|
||||
charset-normalizer==3.3.2
|
||||
# via requests
|
||||
click==8.1.7
|
||||
# via
|
||||
# pip-tools
|
||||
# uvicorn
|
||||
coverage==7.3.0
|
||||
# via
|
||||
# hatch.envs.dev
|
||||
# pytest-cov
|
||||
cryptography==43.0.0
|
||||
# via jwcrypto
|
||||
dj-database-url==2.2.0
|
||||
# via environs
|
||||
dj-email-url==1.0.6
|
||||
# via environs
|
||||
django==5.1rc1
|
||||
# via
|
||||
# hatch.envs.dev
|
||||
# dj-database-url
|
||||
# django-allauth
|
||||
# django-browser-reload
|
||||
# django-debug-toolbar
|
||||
# django-money
|
||||
# django-oauth-toolkit
|
||||
# django-registries
|
||||
# django-stubs
|
||||
# django-stubs-ext
|
||||
# django-view-decorator
|
||||
# django-zen-queries
|
||||
# model-bakery
|
||||
django-allauth==0.63.6
|
||||
# via hatch.envs.dev
|
||||
django-browser-reload==1.7.0
|
||||
# via hatch.envs.dev
|
||||
django-cache-url==3.4.5
|
||||
# via environs
|
||||
django-debug-toolbar==4.2.0
|
||||
# via hatch.envs.dev
|
||||
django-money==3.5.3
|
||||
# via hatch.envs.dev
|
||||
django-oauth-toolkit==2.4.0
|
||||
# via hatch.envs.dev
|
||||
django-ratelimit==4.1.0
|
||||
# via hatch.envs.dev
|
||||
django-registries==0.0.3
|
||||
# via hatch.envs.dev
|
||||
django-stubs==1.16.0
|
||||
# via hatch.envs.dev
|
||||
django-stubs-ext==5.0.4
|
||||
# via
|
||||
# hatch.envs.dev
|
||||
# django-stubs
|
||||
django-view-decorator==0.0.4
|
||||
# via hatch.envs.dev
|
||||
django-zen-queries==2.1.0
|
||||
# via hatch.envs.dev
|
||||
environs==11.0.0
|
||||
# via hatch.envs.dev
|
||||
h11==0.14.0
|
||||
# via uvicorn
|
||||
idna==3.7
|
||||
# via requests
|
||||
iniconfig==2.0.0
|
||||
# via pytest
|
||||
jwcrypto==1.5.6
|
||||
# via django-oauth-toolkit
|
||||
marshmallow==3.21.3
|
||||
# via environs
|
||||
model-bakery==1.17.0
|
||||
# via hatch.envs.dev
|
||||
mypy==1.1.1
|
||||
# via
|
||||
# hatch.envs.dev
|
||||
# django-stubs
|
||||
mypy-extensions==1.0.0
|
||||
# via mypy
|
||||
oauthlib==3.2.2
|
||||
# via django-oauth-toolkit
|
||||
packaging==24.1
|
||||
# via
|
||||
# build
|
||||
# marshmallow
|
||||
# pytest
|
||||
pip==24.2
|
||||
# via pip-tools
|
||||
pip-tools==7.3.0
|
||||
# via hatch.envs.dev
|
||||
pluggy==1.5.0
|
||||
# via pytest
|
||||
psycopg==3.2.1
|
||||
# via hatch.envs.dev
|
||||
psycopg-binary==3.2.1
|
||||
# via psycopg
|
||||
py-moneyed==3.0
|
||||
# via django-money
|
||||
pycparser==2.22
|
||||
# via cffi
|
||||
pyproject-hooks==1.1.0
|
||||
# via build
|
||||
pytest==7.2.2
|
||||
# via
|
||||
# hatch.envs.dev
|
||||
# pytest-cov
|
||||
# pytest-django
|
||||
pytest-cov==5.0.0
|
||||
# via hatch.envs.dev
|
||||
pytest-django==4.5.2
|
||||
# via hatch.envs.dev
|
||||
python-dotenv==1.0.1
|
||||
# via environs
|
||||
pytz==2024.1
|
||||
# via django-oauth-toolkit
|
||||
requests==2.32.3
|
||||
# via
|
||||
# django-oauth-toolkit
|
||||
# stripe
|
||||
setuptools==72.1.0
|
||||
# via
|
||||
# django-money
|
||||
# pip-tools
|
||||
sqlparse==0.5.1
|
||||
# via
|
||||
# django
|
||||
# django-debug-toolbar
|
||||
stripe==10.6.0
|
||||
# via hatch.envs.dev
|
||||
tomli==2.0.1
|
||||
# via django-stubs
|
||||
types-pytz==2024.1.0.20240417
|
||||
# via django-stubs
|
||||
types-pyyaml==6.0.12.20240724
|
||||
# via django-stubs
|
||||
typing-extensions==4.12.2
|
||||
# via
|
||||
# dj-database-url
|
||||
# django-stubs
|
||||
# django-stubs-ext
|
||||
# jwcrypto
|
||||
# mypy
|
||||
# psycopg
|
||||
# py-moneyed
|
||||
# stripe
|
||||
urllib3==2.2.2
|
||||
# via requests
|
||||
uvicorn==0.30.5
|
||||
# via hatch.envs.dev
|
||||
wheel==0.43.0
|
||||
# via pip-tools
|
||||
whitenoise==6.7.0
|
||||
# via hatch.envs.dev
|
|
@ -1,5 +0,0 @@
|
|||
-r base.txt
|
||||
|
||||
coverage==7.4.0
|
||||
tblib==3.0.0
|
||||
unittest-xml-reporting==3.2.0
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
"""Accounting app."""
|
|
@ -1,26 +1,94 @@
|
|||
"""Admin for the accounting app."""
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.contrib import messages
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from membership.emails import OrderEmail
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class OrderProductInline(admin.TabularInline):
|
||||
"""Administer contents of an order inline."""
|
||||
|
||||
model = models.OrderProduct
|
||||
|
||||
|
||||
class OrderAdminForm(forms.ModelForm):
|
||||
"""Special Form for the OrderAdmin so we don't need to require the account field."""
|
||||
|
||||
account = forms.ModelChoiceField(
|
||||
required=False,
|
||||
queryset=models.Account.objects.all(),
|
||||
help_text=_("Leave empty to auto-choose the member's own account or to create one."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Order
|
||||
exclude = () # noqa: DJ006
|
||||
|
||||
def clean(self): # noqa: ANN201
|
||||
cd = super().clean()
|
||||
if not cd["account"] and cd["member"]:
|
||||
try:
|
||||
cd["account"] = models.Account.objects.get_or_create(owner=cd["member"])[0]
|
||||
except models.Account.MultipleObjectsReturned:
|
||||
cd["account"] = models.Account.objects.filter(owner=cd["member"]).first()
|
||||
return cd
|
||||
|
||||
|
||||
@admin.register(models.Order)
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
list_display = ("who", "description", "created", "is_paid")
|
||||
"""Admin for the Order model."""
|
||||
|
||||
@admin.display(description=_("Customer"))
|
||||
def who(self, instance):
|
||||
return instance.user.get_full_name()
|
||||
inlines = (OrderProductInline,)
|
||||
form = OrderAdminForm
|
||||
|
||||
actions = ("send_order",)
|
||||
|
||||
list_display = ("member", "description", "created", "is_paid", "total_with_vat")
|
||||
search_fields = ("member__email", "membership__membership_type__name", "description")
|
||||
list_filter = ("is_paid", "membership__membership_type")
|
||||
|
||||
@admin.action(description="Send order link to selected unpaid orders")
|
||||
def send_order(self, request: HttpRequest, queryset: QuerySet[models.Order]) -> None:
|
||||
for order in queryset:
|
||||
if order.is_paid:
|
||||
messages.error(
|
||||
request,
|
||||
f"Order pk={order.id} is already marked paid, not sending email to: {order.member.email}",
|
||||
)
|
||||
continue
|
||||
email = OrderEmail(order, request)
|
||||
email.send()
|
||||
messages.success(request, f"Sent an order for order pk={order.id} link to: {order.member.email}")
|
||||
|
||||
|
||||
@admin.register(models.Payment)
|
||||
class PaymentAdmin(admin.ModelAdmin):
|
||||
list_display = ("who", "description", "order_id", "created")
|
||||
"""Admin for the Payment model."""
|
||||
|
||||
@admin.display(description=_("Customer"))
|
||||
def who(self, instance):
|
||||
return instance.order.user.get_full_name()
|
||||
list_display = ("order__member", "description", "order_id", "created")
|
||||
|
||||
@admin.display(description=_("Order ID"))
|
||||
def order_id(self, instance):
|
||||
def order_id(self, instance: models.Payment) -> int:
|
||||
"""Return the ID of the order."""
|
||||
return instance.order.id
|
||||
|
||||
|
||||
@admin.register(models.Product)
|
||||
class ProductAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "price", "vat")
|
||||
|
||||
|
||||
class TransactionInline(admin.TabularInline):
|
||||
model = models.Transaction
|
||||
|
||||
|
||||
@admin.register(models.Account)
|
||||
class AccountAdmin(admin.ModelAdmin):
|
||||
list_display = ("owner", "balance")
|
||||
inlines = (TransactionInline,)
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
"""Accounting app configuration."""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountingConfig(AppConfig):
|
||||
"""Accounting app config."""
|
||||
|
||||
name = "accounting"
|
||||
|
||||
def ready(self) -> None:
|
||||
"""Implicitly connect a signal handlers decorated with @receiver."""
|
||||
from . import signals # noqa: F401
|
||||
|
|
|
@ -7,7 +7,6 @@ from django.db import models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
@ -99,9 +98,7 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
(
|
||||
"vat",
|
||||
djmoney.models.fields.MoneyField(
|
||||
decimal_places=2, max_digits=16, verbose_name="VAT"
|
||||
),
|
||||
djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16, verbose_name="VAT"),
|
||||
),
|
||||
("is_paid", models.BooleanField(default=False, verbose_name="is paid")),
|
||||
(
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 5.0.1 on 2024-01-14 11:14
|
||||
|
||||
import djmoney.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounting", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="order",
|
||||
name="price_currency",
|
||||
field=djmoney.models.fields.CurrencyField(
|
||||
choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="order",
|
||||
name="vat_currency",
|
||||
field=djmoney.models.fields.CurrencyField(
|
||||
choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="payment",
|
||||
name="amount_currency",
|
||||
field=djmoney.models.fields.CurrencyField(
|
||||
choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="transaction",
|
||||
name="amount_currency",
|
||||
field=djmoney.models.fields.CurrencyField(
|
||||
choices=[("DKK", "DKK")], default=None, editable=False, max_length=3
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
]
|
|
@ -0,0 +1,78 @@
|
|||
# Generated by Django 5.0.7 on 2024-07-21 14:12
|
||||
|
||||
import django.db.models.deletion
|
||||
import djmoney.models.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounting', '0003_alter_payment_stripe_charge_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PaymentType',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')),
|
||||
('name', models.CharField(max_length=1024, verbose_name='description')),
|
||||
('description', models.TextField(blank=True, max_length=2048)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Product',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')),
|
||||
('name', models.CharField(max_length=512)),
|
||||
('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)),
|
||||
('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
|
||||
('vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)),
|
||||
('vat', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='external_transaction_id',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='stripe_charge_id',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='payment_type',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='accounting.paymenttype'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderProduct',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')),
|
||||
('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)),
|
||||
('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
|
||||
('vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)),
|
||||
('vat', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ordered_products', to='accounting.order')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ordered_products', to='accounting.product')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,40 @@
|
|||
# Generated by Django 5.0.7 on 2024-07-21 14:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounting', '0004_paymenttype_product_payment_external_transaction_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='order',
|
||||
name='price',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='order',
|
||||
name='price_currency',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='order',
|
||||
name='vat',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='order',
|
||||
name='vat_currency',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderproduct',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='order_products', to='accounting.order'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderproduct',
|
||||
name='product',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_products', to='accounting.product'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 5.0.7 on 2024-07-21 15:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounting', '0005_remove_order_price_remove_order_price_currency_and_more'),
|
||||
('membership', '0006_waitinglistentry_alter_membership_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='account',
|
||||
name='owner',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,42 @@
|
|||
# Generated by Django 5.1b1 on 2024-08-01 10:50
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounting', '0006_alter_account_owner_alter_order_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='orderproduct',
|
||||
options={'verbose_name': 'ordered product', 'verbose_name_plural': 'ordered products'},
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='order',
|
||||
old_name='user',
|
||||
new_name='member',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='payment',
|
||||
name='stripe_charge_id',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderproduct',
|
||||
name='quantity',
|
||||
field=models.PositiveSmallIntegerField(default=1),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderproduct',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='accounting.order'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderproduct',
|
||||
name='product',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='accounting.product'),
|
||||
),
|
||||
]
|
|
@ -1,14 +1,21 @@
|
|||
"""Models for the accounting app."""
|
||||
|
||||
from hashlib import md5
|
||||
from typing import Self
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.db import models
|
||||
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,20 +24,26 @@ class CreatedModifiedAbstract(models.Model):
|
|||
|
||||
|
||||
class Account(CreatedModifiedAbstract):
|
||||
"""
|
||||
"""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)
|
||||
owner = models.ForeignKey("membership.Member", on_delete=models.PROTECT)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Account of {self.owner}"
|
||||
|
||||
@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):
|
||||
"""
|
||||
"""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.
|
||||
"""
|
||||
|
@ -48,77 +61,149 @@ 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
|
||||
a separate Invoice model. This is undecided because we are not generating
|
||||
invoices at the moment.
|
||||
"""An order.
|
||||
|
||||
We assemble the order from a number of products. Once an order is paid, the contents should be
|
||||
considered locked.
|
||||
"""
|
||||
|
||||
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
||||
member = models.ForeignKey("membership.Member", on_delete=models.PROTECT)
|
||||
account = models.ForeignKey(Account, on_delete=models.PROTECT)
|
||||
|
||||
description = models.CharField(max_length=1024, verbose_name=_("description"))
|
||||
|
||||
price = MoneyField(
|
||||
verbose_name=_("price (excl. VAT)"),
|
||||
max_digits=16,
|
||||
decimal_places=2,
|
||||
)
|
||||
vat = MoneyField(verbose_name=_("VAT"), max_digits=16, decimal_places=2)
|
||||
|
||||
is_paid = models.BooleanField(default=False, verbose_name=_("is paid"))
|
||||
|
||||
@property
|
||||
def total(self):
|
||||
return self.price + self.vat
|
||||
class Meta:
|
||||
verbose_name = pgettext_lazy("accounting", "Order")
|
||||
verbose_name_plural = pgettext_lazy("accounting", "Orders")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Order ID {self.display_id}"
|
||||
|
||||
@property
|
||||
def display_id(self):
|
||||
def total(self) -> Money:
|
||||
"""Return the total price of the order (excl VAT)."""
|
||||
return sum(item.price * item.quantity for item in self.items.all())
|
||||
|
||||
@property
|
||||
def total_vat(self) -> Money:
|
||||
"""Return the total VAT of the order."""
|
||||
return sum(item.vat * item.quantity for item in self.items.all())
|
||||
|
||||
@property
|
||||
@admin.display(
|
||||
ordering=None,
|
||||
description="Total (incl. VAT)",
|
||||
boolean=False,
|
||||
)
|
||||
def total_with_vat(self) -> Money:
|
||||
"""Return the TOTAL amount WITH VAT."""
|
||||
return self.total + self.total_vat
|
||||
|
||||
@property
|
||||
def display_id(self) -> str:
|
||||
"""Return an id for the order."""
|
||||
return str(self.id).zfill(6)
|
||||
|
||||
@property
|
||||
def payment_token(self):
|
||||
def payment_token(self) -> str:
|
||||
"""Return a token for the payment."""
|
||||
pk = str(self.pk).encode("utf-8")
|
||||
x = md5()
|
||||
x = md5() # noqa: S324
|
||||
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")
|
||||
|
||||
def __str__(self):
|
||||
return f"Order ID {self.display_id}"
|
||||
class Product(CreatedModifiedAbstract):
|
||||
"""A generic product, for instance a membership or a service fee."""
|
||||
|
||||
name = models.CharField(max_length=512)
|
||||
price = MoneyField(max_digits=16, decimal_places=2)
|
||||
vat = MoneyField(max_digits=16, decimal_places=2)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class OrderProduct(CreatedModifiedAbstract):
|
||||
"""When a product is ordered, we store the product on the order.
|
||||
|
||||
This includes pricing information.
|
||||
"""
|
||||
|
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
|
||||
product = models.ForeignKey(Product, on_delete=models.PROTECT)
|
||||
price = MoneyField(max_digits=16, decimal_places=2)
|
||||
vat = MoneyField(max_digits=16, decimal_places=2)
|
||||
quantity = models.PositiveSmallIntegerField(default=1)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("ordered product")
|
||||
verbose_name_plural = _("ordered products")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.product.name}"
|
||||
|
||||
@property
|
||||
def total_with_vat(self) -> Money:
|
||||
"""Total price of this item."""
|
||||
return (self.price + self.vat) * self.quantity
|
||||
|
||||
|
||||
class Payment(CreatedModifiedAbstract):
|
||||
"""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"))
|
||||
|
||||
stripe_charge_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
@property
|
||||
def display_id(self):
|
||||
return str(self.id).zfill(6)
|
||||
|
||||
@classmethod
|
||||
def from_order(cls, order):
|
||||
return cls.objects.create(
|
||||
order=order,
|
||||
user=order.user,
|
||||
amount=order.total,
|
||||
description=order.description,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"Payment ID {self.display_id}"
|
||||
payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT)
|
||||
external_transaction_id = models.CharField(max_length=255, default="", blank=True)
|
||||
|
||||
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) -> str:
|
||||
"""Return an id for the payment."""
|
||||
return str(self.id).zfill(6)
|
||||
|
||||
@classmethod
|
||||
def from_order(cls, order: Order, payment_type: "PaymentType") -> Self:
|
||||
"""Create a payment from an order."""
|
||||
return cls.objects.create(
|
||||
order=order,
|
||||
user=order.user,
|
||||
amount=order.total + order.total_vat,
|
||||
description=order.description,
|
||||
payment_type=payment_type,
|
||||
)
|
||||
|
||||
|
||||
class PaymentType(CreatedModifiedAbstract):
|
||||
"""Types of payments available in the system.
|
||||
|
||||
- bank transfer
|
||||
- card payment (specific provider)
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=1024, verbose_name=_("description"))
|
||||
description = models.TextField(max_length=2048, blank=True)
|
||||
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name}"
|
||||
|
|
36
src/accounting/signals.py
Normal file
36
src/accounting/signals.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
"""Loaded with the AppConfig.ready() method."""
|
||||
|
||||
from django.core.mail import mail_admins
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from membership.models import Membership
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
# method for updating
|
||||
@receiver(post_save, sender=models.Payment)
|
||||
def check_total_amount(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None: # noqa: ARG001
|
||||
"""Check that we receive Payments with the correct amount."""
|
||||
if instance.amount != instance.order.total_with_vat:
|
||||
mail_admins(
|
||||
"Payment received: wrong amount",
|
||||
f"Please check payment ID {instance.pk}",
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=models.Payment)
|
||||
def mark_order_paid(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None: # noqa: ARG001
|
||||
"""Mark an order as paid when payment is received."""
|
||||
instance.order.is_paid = True
|
||||
instance.order.save()
|
||||
|
||||
|
||||
@receiver(post_save, sender=models.Order)
|
||||
def activate_membership(sender: models.Order, instance: models.Order, **kwargs: dict) -> None: # noqa: ARG001
|
||||
"""Mark a membership as activated when its order is marked as paid."""
|
||||
if instance.is_paid:
|
||||
Membership.objects.filter(order=instance, activated=False, activated_on=None).update(
|
||||
activated=True, activated_on=timezone.now()
|
||||
)
|
18
src/accounting/templates/accounting/order/cancel.html
Normal file
18
src/accounting/templates/accounting/order/cancel.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_title %}
|
||||
{% trans "Payment cancelled" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="content-view">
|
||||
<h2>{% trans "Payment canceled" %}</h2>
|
||||
|
||||
<p>
|
||||
<a href="{% order:detail order_id=order.id %}">{% trans "Return to order page" %}</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
49
src/accounting/templates/accounting/order/detail.html
Normal file
49
src/accounting/templates/accounting/order/detail.html
Normal file
|
@ -0,0 +1,49 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_title %}
|
||||
{% trans "Order" context "accounting" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="content-view">
|
||||
<h2>Order: {{ order.id }}</h2>
|
||||
|
||||
<p>
|
||||
{% trans "Ordered" context "accounting" %}: {{ order.created }}<br>
|
||||
{% trans "Status" context "accounting" %}: {{ order.is_paid|yesno:_("paid,unpaid") }}
|
||||
</p>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Item" context "accounting" %}</th>
|
||||
<th>{% trans "Quantity" %}</th>
|
||||
<th>{% trans "Price" %}</th>
|
||||
<th>{% trans "VAT" %}</th>
|
||||
<th>{% trans "Total" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td>{{ item.product.name }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>{{ item.price }}</td>
|
||||
<td>{{ item.vat }}</td>
|
||||
<td>{{ item.total_with_vat }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>{% trans "Total price" %}: {{ order.total_with_vat }}</h2>
|
||||
|
||||
{% if not order.is_paid %}
|
||||
<p>
|
||||
<a href="{% url "order:pay" order_id=order.pk %}" class="button">{% trans "Pay now" %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
20
src/accounting/templates/accounting/order/success.html
Normal file
20
src/accounting/templates/accounting/order/success.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_title %}
|
||||
{% trans "Payment received" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="content-view">
|
||||
<h2>{% trans "Payment received" %}</h2>
|
||||
|
||||
<p>
|
||||
{% blocktrans trimmed with order.id as order_id %}
|
||||
Thanks fellow member! We received your payment for Order {{ order_id }}. We're adding more features to the site, so expect to see a confirmation email (receipt) for the order soon.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -8,8 +8,8 @@ from . import models
|
|||
# do stuff
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_balance():
|
||||
@pytest.mark.django_db()
|
||||
def test_balance() -> None:
|
||||
user = User.objects.create_user("test", "lala@adas.com", "1234")
|
||||
account = models.Account.objects.create(owner=user)
|
||||
assert account.balance == 0
|
||||
|
|
177
src/accounting/views.py
Normal file
177
src/accounting/views.py
Normal file
|
@ -0,0 +1,177 @@
|
|||
"""Views for the membership app."""
|
||||
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.mail import mail_admins
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import redirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django_view_decorator import namespaced_decorator_factory
|
||||
from djmoney.money import Money
|
||||
|
||||
from . import models
|
||||
|
||||
order_view = namespaced_decorator_factory(namespace="order", base_path="order")
|
||||
|
||||
stripe.api_key = settings.STRIPE_API_KEY
|
||||
|
||||
|
||||
@order_view(
|
||||
paths="<int:order_id>/",
|
||||
name="detail",
|
||||
login_required=True,
|
||||
)
|
||||
def order_detail(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||
"""View to show the details of a member."""
|
||||
user = request.user # People just need to login to pay something, not necessarily be a member
|
||||
order = models.Order.objects.get(pk=order_id, member=user)
|
||||
|
||||
context = {
|
||||
"order": order,
|
||||
}
|
||||
|
||||
return render(
|
||||
request=request,
|
||||
template_name="accounting/order/detail.html",
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@order_view(
|
||||
paths="<int:order_id>/pay/",
|
||||
name="pay",
|
||||
login_required=True,
|
||||
)
|
||||
def order_pay(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||
"""Create a Stripe session and redirects to Stripe Checkout."""
|
||||
user = request.user # People just need to login to pay something, not necessarily be a member
|
||||
order = models.Order.objects.get(pk=order_id, member=user)
|
||||
current_site = Site.objects.get_current(request)
|
||||
base_domain = f"https://{current_site.domain}"
|
||||
if settings.DEBUG:
|
||||
f"http://{current_site.domain}"
|
||||
|
||||
try:
|
||||
line_items = []
|
||||
for item in order.items.all():
|
||||
line_items.append( # noqa: PERF401
|
||||
{
|
||||
"price_data": {
|
||||
"currency": item.total_with_vat.currency,
|
||||
"unit_amount": int((item.price + item.vat).amount * 100),
|
||||
"product_data": {
|
||||
"name": item.product.name,
|
||||
},
|
||||
},
|
||||
"quantity": item.quantity,
|
||||
}
|
||||
)
|
||||
checkout_session = stripe.checkout.Session.create(
|
||||
line_items=line_items,
|
||||
metadata={"order_id": order.id},
|
||||
mode="payment",
|
||||
success_url=base_domain + reverse("order:success", kwargs={"order_id": order.id}),
|
||||
cancel_url=base_domain + "/cancel",
|
||||
)
|
||||
except Exception as e:
|
||||
mail_admins("Error in checkout", str(e))
|
||||
raise
|
||||
|
||||
# TODO: Redirect with status=303
|
||||
return redirect(checkout_session.url)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@order_view(
|
||||
paths="<int:order_id>/pay/success/",
|
||||
name="success",
|
||||
login_required=True,
|
||||
)
|
||||
def success(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||
"""Create a Stripe session and redirects to Stripe Checkout.
|
||||
|
||||
From Stripe docs: When you have a webhook endpoint set up to listen for checkout.session.completed events and
|
||||
you set a success_url, Checkout waits for your server to respond to the webhook event delivery before redirecting
|
||||
your customer. If you use this approach, make sure your server responds to checkout.session.completed events as
|
||||
quickly as possible.
|
||||
"""
|
||||
user = request.user # People just need to login to pay something, not necessarily be a member
|
||||
order = get_object_or_404(models.Order, pk=order_id, member=user)
|
||||
|
||||
context = {
|
||||
"order": order,
|
||||
}
|
||||
|
||||
return render(
|
||||
request=request,
|
||||
template_name="accounting/order/success.html",
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@order_view(
|
||||
paths="<int:order_id>/pay/cancel/",
|
||||
name="cancel",
|
||||
login_required=True,
|
||||
)
|
||||
def cancel(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||
"""Page to display when a payment is canceled."""
|
||||
user = request.user # People just need to login to pay something, not necessarily be a member
|
||||
order = models.Order.objects.get(pk=order_id, member=user)
|
||||
|
||||
context = {
|
||||
"order": order,
|
||||
}
|
||||
|
||||
return render(
|
||||
request=request,
|
||||
template_name="accounting/order/cancel.html",
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@order_view(
|
||||
paths="stripe/webhook/",
|
||||
name="webhook",
|
||||
)
|
||||
@csrf_exempt
|
||||
def stripe_webhook(request: HttpRequest) -> HttpResponse:
|
||||
"""Handle Stripe webhook.
|
||||
|
||||
https://docs.stripe.com/metadata/use-cases
|
||||
"""
|
||||
payload = request.body
|
||||
sig_header = request.headers["stripe-signature"]
|
||||
event = None
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_ENDPOINT_SECRET)
|
||||
except ValueError:
|
||||
# Invalid payload
|
||||
return HttpResponse(status=400)
|
||||
except stripe.error.SignatureVerificationError:
|
||||
# Invalid signature
|
||||
return HttpResponse(status=400)
|
||||
|
||||
if event["type"] == "checkout.session.completed" or event["type"] == "checkout.session.async_payment_succeeded":
|
||||
# Order is marked paid via signals, Membership is activated via signals.
|
||||
order_id = event["data"]["object"]["metadata"]["order_id"]
|
||||
order = get_object_or_404(models.Order, pk=order_id)
|
||||
if not models.Payment.objects.filter(order=order).exists():
|
||||
models.Payment.objects.create(
|
||||
order=order,
|
||||
amount=Money(event["data"]["object"]["amount_total"] / 100.0, event["data"]["object"]["currency"]),
|
||||
description="Paid via Stripe",
|
||||
payment_type=models.PaymentType.objects.get_or_create(name="Stripe")[0],
|
||||
external_transaction_id=event["id"],
|
||||
)
|
||||
|
||||
return HttpResponse(status=200)
|
|
@ -1,8 +1,5 @@
|
|||
"""
|
||||
Membership application
|
||||
======================
|
||||
"""Membership application.
|
||||
|
||||
This application's domain relate to organizational structures and
|
||||
implementation of statutes, policies etc.
|
||||
|
||||
"""
|
||||
|
|
|
@ -1,20 +1,168 @@
|
|||
from django.contrib import admin
|
||||
"""Admin configuration for membership app."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from accounting.models import Account
|
||||
from accounting.models import Order
|
||||
from accounting.models import OrderProduct
|
||||
from django.contrib import admin
|
||||
from django.contrib import messages
|
||||
from django.contrib.admin import ModelAdmin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import transaction
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.http import HttpResponse
|
||||
from django.utils.text import slugify
|
||||
|
||||
from .emails import InviteEmail
|
||||
from .models import Member
|
||||
from .models import Membership
|
||||
from .models import MembershipType
|
||||
from .models import SubscriptionPeriod
|
||||
from .models import WaitingListEntry
|
||||
|
||||
# Do not use existing user admin
|
||||
admin.site.unregister(User)
|
||||
|
||||
|
||||
@admin.register(Membership)
|
||||
class MembershipAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
"""Admin for Membership model."""
|
||||
|
||||
list_display = ("user", "period", "membership_type", "activated", "revoked")
|
||||
list_filter = ("period", "membership_type", "activated", "revoked")
|
||||
search_fields = ("membership_type__name", "user__email", "user__first_name", "user__last_name")
|
||||
|
||||
|
||||
@admin.register(MembershipType)
|
||||
class MembershipTypeAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
"""Admin for MembershipType model."""
|
||||
|
||||
|
||||
@admin.register(SubscriptionPeriod)
|
||||
class SubscriptionPeriodAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
"""Admin for SubscriptionPeriod model."""
|
||||
|
||||
|
||||
class MembershipInlineAdmin(admin.TabularInline):
|
||||
"""Inline admin."""
|
||||
|
||||
model = Membership
|
||||
|
||||
|
||||
def decorate_ensure_membership_type_exists(membership_type: MembershipType, label: str) -> Callable:
|
||||
"""Generate an admin action for given membership type and label."""
|
||||
|
||||
@admin.action(description=label)
|
||||
def admin_action(modeladmin: ModelAdmin, request: HttpRequest, queryset: QuerySet) -> HttpResponse: # noqa: ARG001
|
||||
return ensure_membership_type_exists(request, queryset, membership_type)
|
||||
|
||||
return admin_action
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def ensure_membership_type_exists(
|
||||
request: HttpRequest,
|
||||
queryset: QuerySet[Member],
|
||||
membership_type: MembershipType,
|
||||
) -> HttpResponse:
|
||||
"""Inner function that ensures that a membership exists for a given queryset of Member objects."""
|
||||
for member in queryset:
|
||||
if member.memberships.filter(membership_type=membership_type).current():
|
||||
messages.info(request, f"{member} already has a membership {membership_type}")
|
||||
else:
|
||||
# Get the default account of the member. We don't really know what to do if a person owns multiple accounts.
|
||||
account, __ = Account.objects.get_or_create(owner=member)
|
||||
# Create an Order for the products in the membership
|
||||
order = Order.objects.create(member=member, account=account, description=membership_type.name)
|
||||
# Add stuff to the order
|
||||
for product in membership_type.products.all():
|
||||
OrderProduct.objects.create(order=order, product=product, price=product.price, vat=product.vat)
|
||||
# Create the Membership
|
||||
Membership.objects.create(
|
||||
membership_type=membership_type,
|
||||
user=member,
|
||||
period=SubscriptionPeriod.objects.current(),
|
||||
order=order,
|
||||
)
|
||||
|
||||
# Associate the order with that membership
|
||||
messages.success(request, f"{member} has ordered a '{membership_type}' (unpaid)")
|
||||
|
||||
|
||||
@admin.register(Member)
|
||||
class MemberAdmin(UserAdmin):
|
||||
"""Member admin is actually an admin for User objects."""
|
||||
|
||||
inlines = (MembershipInlineAdmin,)
|
||||
actions: list[str | Callable] = ["send_invite"] # noqa: RUF012
|
||||
list_display = ("email", "current_membership", "username", "is_staff", "is_active", "date_joined")
|
||||
|
||||
@admin.display(description="membership")
|
||||
def current_membership(self, instance: Member) -> Membership | None:
|
||||
return instance.memberships.current()
|
||||
|
||||
def get_actions(self, request: HttpRequest) -> dict:
|
||||
"""Populate actions with dynamic data (MembershipType)."""
|
||||
current_period = SubscriptionPeriod.objects.current()
|
||||
|
||||
super_dict = super().get_actions(request)
|
||||
|
||||
if current_period:
|
||||
for i, mtype in enumerate(MembershipType.objects.filter(active=True)):
|
||||
action_label = f"Ensure membership {mtype.name}, {current_period.period}, {mtype.total_including_vat}"
|
||||
action_func = decorate_ensure_membership_type_exists(mtype, action_label)
|
||||
# Django ModelAdmin uses the non-unique __name__ property, so we need to suffix it to make it unique
|
||||
action_func.__name__ += f"_{i}"
|
||||
self.actions.append(action_func)
|
||||
|
||||
return super_dict
|
||||
|
||||
@admin.action(description="Send invite email to selected inactive accounts")
|
||||
def send_invite(self, request: HttpRequest, queryset: QuerySet[Member]) -> None:
|
||||
for member in queryset:
|
||||
if member.is_active:
|
||||
messages.error(
|
||||
request,
|
||||
f"Computer says no! This member will not receive an invite because the account is marked "
|
||||
f"as active: {member.email}. That means the member has probably created a password and a username "
|
||||
f"already, please tell them to use the password reminder function.",
|
||||
)
|
||||
continue
|
||||
if not member.memberships.current():
|
||||
messages.error(
|
||||
request,
|
||||
f"Computer says no! This member will not receive an invite because it has no current "
|
||||
f"membership: {member.email}. You need to create a current membership before sending the invite.",
|
||||
)
|
||||
continue
|
||||
membership = member.memberships.current()
|
||||
email = InviteEmail(membership, request)
|
||||
email.send()
|
||||
messages.success(request, f"Sent an invitation to: {member.email}")
|
||||
|
||||
|
||||
@admin.register(WaitingListEntry)
|
||||
class WaitingListEntryAdmin(admin.ModelAdmin):
|
||||
"""Admin for WaitingList model."""
|
||||
|
||||
list_display = ("email", "member")
|
||||
actions = ("create_member",)
|
||||
|
||||
@admin.action(description="Create member account for entries")
|
||||
def create_member(self, request: HttpRequest, queryset: QuerySet[WaitingListEntry]) -> None:
|
||||
"""Create a user account for this entry.
|
||||
|
||||
Note that actions can soon be made available from the edit page, too:
|
||||
https://github.com/django/django/pull/16012
|
||||
"""
|
||||
for entry in queryset:
|
||||
member = Member.objects.create_user(email=entry.email, username=slugify(entry.email), is_active=False)
|
||||
entry.member = member
|
||||
entry.save()
|
||||
messages.info(
|
||||
request,
|
||||
f"Added user for {entry.email} - ensure they have a membership and send an invite email.",
|
||||
)
|
||||
|
|
|
@ -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):
|
||||
def ready(self) -> None:
|
||||
"""Ready method."""
|
||||
from .permissions import persist_permissions
|
||||
|
||||
post_migrate.connect(persist_permissions, sender=self)
|
||||
|
|
128
src/membership/emails.py
Normal file
128
src/membership/emails.py
Normal file
|
@ -0,0 +1,128 @@
|
|||
"""Send email to members, using templates and contexts for the emails.
|
||||
|
||||
* We keep everything as plain text for now.
|
||||
* Notice that emails can be multilingual
|
||||
* Generally, an email consists of templates (for body and subject) and a get_context() method.
|
||||
"""
|
||||
|
||||
from accounting.models import Order
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.core.mail.message import EmailMessage
|
||||
from django.http import HttpRequest
|
||||
from django.template import loader
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Membership
|
||||
|
||||
|
||||
class BaseEmail(EmailMessage):
|
||||
"""Send emails via templated body and subjects.
|
||||
|
||||
This base class is extended for all email functionality.
|
||||
Because all emails are sent to the Member object, we can keep them gathered here, even when they are generated by
|
||||
other apps (like the accounting app).
|
||||
"""
|
||||
|
||||
template = "membership/email/base.txt"
|
||||
# Optional: Set to a template path for subject
|
||||
template_subject = None
|
||||
default_subject = "SET SUBJECT HERE"
|
||||
|
||||
def __init__(self, request: HttpRequest, *args, **kwargs) -> None:
|
||||
self.context = kwargs.pop("context", {})
|
||||
self.user = kwargs.pop("user", None)
|
||||
if self.user:
|
||||
kwargs["to"] = [self.user.email]
|
||||
self.context["user"] = self.user
|
||||
self.context["recipient_name"] = self.user.get_display_name()
|
||||
|
||||
# Necessary to set request before instantiating body and subject
|
||||
self.request = request
|
||||
kwargs.setdefault("subject", self.get_subject())
|
||||
kwargs.setdefault("body", self.get_body())
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_context_data(self) -> dict:
|
||||
"""Resolve common context for sending emails.
|
||||
|
||||
When overwriting, remember to call this via super().
|
||||
"""
|
||||
c = self.context
|
||||
site = get_current_site(self.request)
|
||||
c["request"] = self.request
|
||||
c["domain"] = site.domain
|
||||
c["site_name"] = site.name
|
||||
c["protocol"] = "https" # if self.request and not self.request.is_secure() else "https"
|
||||
return c
|
||||
|
||||
def get_body(self) -> str:
|
||||
"""Build the email body from template and context."""
|
||||
if self.user and self.user.language_code:
|
||||
with translation.override(self.user.language_code):
|
||||
body = loader.render_to_string(self.template, self.get_context_data())
|
||||
else:
|
||||
body = loader.render_to_string(self.template, self.get_context_data())
|
||||
return body
|
||||
|
||||
def get_subject(self) -> str:
|
||||
"""Build the email subject from template or self.default_subject."""
|
||||
if self.user and self.user.language_code:
|
||||
with translation.override(self.user.language_code):
|
||||
if self.template_subject:
|
||||
subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip()
|
||||
else:
|
||||
subject = str(self.default_subject)
|
||||
elif self.template_subject:
|
||||
subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip()
|
||||
else:
|
||||
subject = str(self.default_subject)
|
||||
return subject
|
||||
|
||||
def send_with_feedback(self, *, success_msg: str | None = None, no_message: bool = False) -> None:
|
||||
"""Send email, possibly adding feedback via django.contrib.messages."""
|
||||
if not success_msg:
|
||||
success_msg = _("Email successfully sent to {}").format(", ".join(self.to))
|
||||
try:
|
||||
self.send(fail_silently=False)
|
||||
if not no_message:
|
||||
messages.success(self.request, success_msg)
|
||||
except RuntimeError:
|
||||
messages.error(self.request, _("Not sent, something wrong with the mail server."))
|
||||
|
||||
|
||||
class InviteEmail(BaseEmail):
|
||||
template = "membership/emails/invite.txt"
|
||||
default_subject = _("Invite to data.coop membership")
|
||||
|
||||
def __init__(self, membership: Membership, request: HttpRequest, *args, **kwargs) -> None:
|
||||
self.membership = membership
|
||||
kwargs["user"] = membership.user
|
||||
kwargs["from_email"] = "kasserer@data.coop"
|
||||
super().__init__(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self) -> dict:
|
||||
c = super().get_context_data()
|
||||
c["membership"] = self.membership
|
||||
c["token"] = default_token_generator.make_token(self.membership.user)
|
||||
c["referral_code"] = self.membership.referral_code
|
||||
return c
|
||||
|
||||
|
||||
class OrderEmail(BaseEmail):
|
||||
template = "membership/emails/order.txt"
|
||||
default_subject = _("Your data.coop order and payment")
|
||||
|
||||
def __init__(self, order: Order, request: HttpRequest, *args, **kwargs) -> None:
|
||||
self.order = order
|
||||
kwargs["user"] = order.member
|
||||
kwargs["from_email"] = "kasserer@data.coop"
|
||||
super().__init__(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self) -> dict:
|
||||
c = super().get_context_data()
|
||||
c["order"] = self.order
|
||||
return c
|
39
src/membership/forms.py
Normal file
39
src/membership/forms.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
from allauth.account.adapter import get_adapter as get_allauth_adapter
|
||||
from allauth.account.forms import SetPasswordForm
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class InviteForm(SetPasswordForm):
|
||||
"""Create a new password for a user account that is created through an invite."""
|
||||
|
||||
username = forms.CharField(
|
||||
label=_("Username"),
|
||||
widget=forms.TextInput(attrs={"placeholder": _("Username"), "autocomplete": "username"}),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.membership = kwargs.pop("membership")
|
||||
kwargs["user"] = self.membership.user
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_username(self) -> str:
|
||||
"""Clean the username value.
|
||||
|
||||
Taken from the allauth Signup form - we should consider that data can be leaked here.
|
||||
"""
|
||||
value = self.cleaned_data["username"]
|
||||
# The allauth adapter ensures the username is unique.
|
||||
return get_allauth_adapter().clean_username(value)
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save instance to db.
|
||||
|
||||
Note: You can hack a re-activation of a deactivated account
|
||||
by getting a valid token before deactivation (from the reset password form).
|
||||
We can block this by also setting Membership.revoked=False when deactivating someone's account.
|
||||
"""
|
||||
self.user.username = self.cleaned_data["username"]
|
||||
self.user.is_active = True
|
||||
self.user.save()
|
||||
super().save()
|
|
@ -7,7 +7,6 @@ from django.db import models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
import django.contrib.postgres.constraints
|
||||
import django.contrib.postgres.fields.ranges
|
||||
from django.db import migrations, models
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("membership", "0001_initial"),
|
||||
]
|
||||
|
@ -34,9 +34,7 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
(
|
||||
"period",
|
||||
django.contrib.postgres.fields.ranges.DateRangeField(
|
||||
verbose_name="period"
|
||||
),
|
||||
django.contrib.postgres.fields.ranges.DateRangeField(verbose_name="period"),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
# Generated by Django 4.1 on 2023-01-02 21:05
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("membership", "0002_subscriptionperiod_remove_membership_period_and_more"),
|
||||
]
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
# Generated by Django 4.1 on 2023-01-02 21:06
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("membership", "0003_membership_period"),
|
||||
]
|
||||
|
|
|
@ -4,22 +4,20 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('membership', '0004_alter_membership_period'),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("membership", "0004_alter_membership_period"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Member',
|
||||
fields=[
|
||||
],
|
||||
name="Member",
|
||||
fields=[],
|
||||
options={
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
"proxy": True,
|
||||
"indexes": [],
|
||||
"constraints": [],
|
||||
},
|
||||
bases=('auth.user',),
|
||||
bases=("auth.user",),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 5.0.7 on 2024-07-20 20:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('membership', '0005_member'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WaitingListEntry',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
('geography', models.CharField(blank=True, default='', verbose_name='geography')),
|
||||
('comment', models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'waiting list entry',
|
||||
'verbose_name_plural': 'waiting list entries',
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='membership',
|
||||
options={'verbose_name': 'medlemskab', 'verbose_name_plural': 'medlemskaber'},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,62 @@
|
|||
# Generated by Django 5.1b1 on 2024-08-01 10:50
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounting', '0007_alter_orderproduct_options_rename_user_order_member_and_more'),
|
||||
('membership', '0006_waitinglistentry_alter_membership_options'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='membership',
|
||||
name='activated',
|
||||
field=models.BooleanField(default=False, help_text='Membership was activated.', verbose_name='activated'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='membership',
|
||||
name='activated_on',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='membership',
|
||||
name='order',
|
||||
field=models.ForeignKey(blank=True, help_text='The order filled in for paying this membership.', null=True, on_delete=django.db.models.deletion.PROTECT, to='accounting.order', verbose_name='order'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='membership',
|
||||
name='revoked',
|
||||
field=models.BooleanField(default=False, help_text='Membership has explicitly been revoked. Revoking a membership is not associated with regular expiration of the membership period.', verbose_name='revoked'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='membership',
|
||||
name='revoked_on',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='membership',
|
||||
name='revoked_reason',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='membershiptype',
|
||||
name='active',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='membershiptype',
|
||||
name='products',
|
||||
field=models.ManyToManyField(to='accounting.product'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='membership',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 5.1b1 on 2024-08-04 10:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('membership', '0007_membership_activated_membership_activated_on_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='membership',
|
||||
name='membership_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='membership.membershiptype', verbose_name='membership type'),
|
||||
),
|
||||
]
|
32
src/membership/migrations/0009_membership_referral_code.py
Normal file
32
src/membership/migrations/0009_membership_referral_code.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 5.1rc1 on 2024-08-07 22:32
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def create_uuid(apps, schema_editor):
|
||||
Membership = apps.get_model('membership', 'Membership')
|
||||
for membership in Membership.objects.all():
|
||||
membership.referral_code = uuid.uuid4()
|
||||
membership.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('membership', '0008_alter_membership_membership_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='membership',
|
||||
name='referral_code',
|
||||
field=models.UUIDField(blank=True, null=True, default=uuid.uuid4, editable=False),
|
||||
),
|
||||
migrations.RunPython(create_uuid),
|
||||
migrations.AlterField(
|
||||
model_name='membership',
|
||||
name='referral_code',
|
||||
field=models.UUIDField(unique=True, default=uuid.uuid4, editable=False),
|
||||
),
|
||||
]
|
19
src/membership/migrations/0010_waitinglistentry_member.py
Normal file
19
src/membership/migrations/0010_waitinglistentry_member.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 5.1rc1 on 2024-08-14 08:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('membership', '0009_membership_referral_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='waitinglistentry',
|
||||
name='member',
|
||||
field=models.ForeignKey(help_text='Once a member account is generated (use the admin action), this field will be marked.', null=True, blank=True, on_delete=django.db.models.deletion.CASCADE, to='membership.member', verbose_name='has member'),
|
||||
),
|
||||
]
|
|
@ -1,23 +1,39 @@
|
|||
"""Models for the membership app."""
|
||||
|
||||
import uuid
|
||||
from typing import ClassVar
|
||||
from typing import Self
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.models import UserManager
|
||||
from django.contrib.postgres.constraints import ExclusionConstraint
|
||||
from django.contrib.postgres.fields import DateRangeField
|
||||
from django.contrib.postgres.fields import RangeOperators
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from djmoney.money import Money
|
||||
from utils.mixins import CreatedModifiedAbstract
|
||||
|
||||
|
||||
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(
|
||||
|
@ -28,21 +44,54 @@ class Member(User):
|
|||
),
|
||||
)
|
||||
|
||||
objects = QuerySet.as_manager()
|
||||
objects = UserManager.from_queryset(QuerySet)()
|
||||
|
||||
def get_display_name(self) -> str:
|
||||
"""Choose how to display the user in emails and UI and ultimately to other users.
|
||||
|
||||
It's crucial that we currently don't have a good solution for this.
|
||||
We should allow the user to define their own nick.
|
||||
"""
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def language_code(self) -> str:
|
||||
"""Returns the user's preferred language code.
|
||||
|
||||
We don't have an actual setting for this... because this is a proxy table.
|
||||
"""
|
||||
return "da-dk"
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
|
||||
class SubscriptionPeriod(CreatedModifiedAbstract):
|
||||
"""
|
||||
"""A subscription period.
|
||||
|
||||
Denotes a period for which members should pay their membership fee for.
|
||||
"""
|
||||
|
||||
class QuerySet(models.QuerySet):
|
||||
"""QuerySet for the Membership model."""
|
||||
|
||||
def _current(self) -> Self:
|
||||
"""Filter memberships for the current period."""
|
||||
return self.filter(period__contains=timezone.now())
|
||||
|
||||
def current(self) -> "Membership | None":
|
||||
"""Get the current membership."""
|
||||
try:
|
||||
return self._current().get()
|
||||
except self.model.DoesNotExist:
|
||||
return None
|
||||
|
||||
objects = QuerySet.as_manager()
|
||||
|
||||
period = DateRangeField(verbose_name=_("period"))
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
constraints: ClassVar = [
|
||||
ExclusionConstraint(
|
||||
name="exclude_overlapping_periods",
|
||||
expressions=[
|
||||
|
@ -51,31 +100,37 @@ class SubscriptionPeriod(CreatedModifiedAbstract):
|
|||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.period.lower} - {self.period.upper or _('next general assembly')}"
|
||||
)
|
||||
def __str__(self) -> str:
|
||||
return f"{self.period.lower} - {self.period.upper or _('next general assembly')}"
|
||||
|
||||
|
||||
class Membership(CreatedModifiedAbstract):
|
||||
"""
|
||||
"""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 active(self) -> Self:
|
||||
"""Get only activated, non-revoked memberships (may have expired so use also current())."""
|
||||
return self.filter(activated=True, revoked=False)
|
||||
|
||||
def _current(self) -> Self:
|
||||
"""Filter memberships for the current period."""
|
||||
return self.filter(period__period__contains=timezone.now())
|
||||
|
||||
def current(self) -> "Membership | None":
|
||||
try:
|
||||
return self._current().get()
|
||||
except self.model.DoesNotExist:
|
||||
return None
|
||||
"""Get the current membership."""
|
||||
return self._current().first()
|
||||
|
||||
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.
|
||||
|
@ -83,16 +138,15 @@ 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, related_name="memberships")
|
||||
|
||||
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
||||
# This code is used for inviting a user to create an account for this membership.
|
||||
referral_code = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
membership_type = models.ForeignKey(
|
||||
"membership.MembershipType",
|
||||
related_name="memberships",
|
||||
verbose_name=_("subscription type"),
|
||||
verbose_name=_("membership type"),
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
|
||||
|
@ -101,21 +155,93 @@ class Membership(CreatedModifiedAbstract):
|
|||
on_delete=models.PROTECT,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
order = models.ForeignKey(
|
||||
"accounting.Order",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("order"),
|
||||
help_text=_("The order filled in for paying this membership."),
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
|
||||
activated = models.BooleanField(
|
||||
default=False, verbose_name=_("activated"), help_text=_("Membership was activated.")
|
||||
)
|
||||
activated_on = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
revoked = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("revoked"),
|
||||
help_text=_(
|
||||
"Membership has explicitly been revoked. Revoking a membership is not associated with regular expiration "
|
||||
"of the membership period."
|
||||
),
|
||||
)
|
||||
revoked_reason = models.TextField(blank=True)
|
||||
revoked_on = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("membership")
|
||||
verbose_name_plural = _("memberships")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} - {self.period}"
|
||||
|
||||
|
||||
class MembershipType(CreatedModifiedAbstract):
|
||||
"""
|
||||
"""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)
|
||||
|
||||
products = models.ManyToManyField("accounting.Product")
|
||||
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("membership type")
|
||||
verbose_name_plural = _("membership types")
|
||||
|
||||
name = models.CharField(verbose_name=_("name"), max_length=64)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def create_membership(self, user: User) -> Membership:
|
||||
"""Create a current membership for this type."""
|
||||
from .selectors import get_current_subscription_period
|
||||
|
||||
return Membership.objects.create(
|
||||
membership_type=self,
|
||||
user=user,
|
||||
period=get_current_subscription_period(),
|
||||
)
|
||||
|
||||
@property
|
||||
def total_including_vat(self) -> Money:
|
||||
"""Calculate the total price of this membership (including VAT)."""
|
||||
return sum(product.price + product.vat for product in self.products.all())
|
||||
|
||||
|
||||
class WaitingListEntry(CreatedModifiedAbstract):
|
||||
"""People who for some reason could want to be added to a waiting list and invited to join later."""
|
||||
|
||||
email = models.EmailField()
|
||||
geography = models.CharField(verbose_name=_("geography"), blank=True, default="")
|
||||
comment = models.TextField(blank=True)
|
||||
member = models.ForeignKey(
|
||||
Member,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("has member"),
|
||||
help_text=_("Once a member account is generated (use the admin action), this field will be marked."),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.email
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("waiting list entry")
|
||||
verbose_name_plural = _("waiting list entries")
|
||||
|
|
|
@ -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):
|
||||
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):
|
||||
def path(self) -> str:
|
||||
"""Return the path of the permission."""
|
||||
return f"{self.app_label}.{self.codename}"
|
||||
|
||||
def persist_permission(self):
|
||||
def persist_permission(self) -> None:
|
||||
"""Persist the permission."""
|
||||
content_type, _ = ContentType.objects.get_or_create(
|
||||
app_label=self.app_label,
|
||||
model=self.model,
|
||||
|
|
|
@ -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)
|
||||
|
|
9
src/membership/templates/membership/emails/base.txt
Normal file
9
src/membership/templates/membership/emails/base.txt
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% load i18n %}{% block greeting %}{% blocktrans %}Dear {{ recipient_name }},{% endblocktrans %}{% endblock %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
|
||||
{% trans "Cooperatively yours," %}
|
||||
{{ site_name }}
|
||||
|
||||
{{ protocol }}://{{ domain }}
|
7
src/membership/templates/membership/emails/invite.txt
Normal file
7
src/membership/templates/membership/emails/invite.txt
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% extends "membership/emails/base.txt" %}{% load i18n %}
|
||||
|
||||
{% block content %}{% url 'member:membership-invite' token=token referral_code=referral_code as invite_url %}{% blocktrans %}Here is your secret URL for creating an account with us:
|
||||
|
||||
{{ protocol }}://{{ domain }}{{ invite_url }}
|
||||
|
||||
If you did not request this account, get in touch with us.{% endblocktrans %}{% endblock %}
|
17
src/membership/templates/membership/emails/order.txt
Normal file
17
src/membership/templates/membership/emails/order.txt
Normal file
|
@ -0,0 +1,17 @@
|
|||
{% extends "membership/emails/base.txt" %}{% load i18n %}
|
||||
|
||||
{% block content %}{% url 'order:detail' order_id=order.id as order_url %}{% blocktrans %}You have an order in our system, which you can pay here:
|
||||
|
||||
{{ protocol }}://{{ domain }}{{ order_url }}
|
||||
|
||||
We used to handle membership stuff in a spreadsheet and via bank transfers. This is now all handled with our custom-made membership system. We hope you like it.
|
||||
|
||||
If you received this email and no longer want a membership, you can ignore it. But please let us know by writing board@data.coop, so we can erase any personal data we have about your previous membership.
|
||||
|
||||
Dansk:
|
||||
|
||||
Hej! Så kører medlemsystemet endeligt! Det er mega-fedt, fordi vi længe har haft besvær med manuelle procedurer. Nu har vi flyttet medlemsdata over på member.data.coop, og betalingen fungerer. Vi kan dermed fremover arbejde stille og roligt på at integrere systemet, så man kan styre sine services via medlemssystemet.
|
||||
|
||||
Hvis du ikke længere vil være medlem, kan du ignorere mailen her; men du må meget gerne informere os via board@data.coop, så vi kan slette evt. personlige data og services, du har kørende på dit tidligere medlemskab.
|
||||
|
||||
{% endblocktrans %}{% endblock %}
|
21
src/membership/templates/membership/invite.html
Normal file
21
src/membership/templates/membership/invite.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_title %}
|
||||
{% trans "Membership" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="content-view">
|
||||
<h2>{% trans "Create account" %}</h2>
|
||||
<p>{% trans "Congratulations! You've been invited to create an account with us:" %}</p>
|
||||
<p>Email: <strong>{{ membership.user.email }}</strong></p>
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" class="btn">{% trans "Create account" %}</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,19 +1,43 @@
|
|||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
"""Views for the membership app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_ratelimit.decorators import ratelimit
|
||||
from django_view_decorator import namespaced_decorator_factory
|
||||
from utils.view_utils import RenderConfig
|
||||
from utils.view_utils import RowAction
|
||||
from utils.view_utils import render
|
||||
|
||||
from .forms import InviteForm
|
||||
from .models import Membership
|
||||
from .permissions import ADMINISTRATE_MEMBERS
|
||||
from .selectors import get_member
|
||||
from .selectors import get_members
|
||||
from .selectors import get_memberships
|
||||
from .selectors import get_subscription_periods
|
||||
from utils.view_utils import render
|
||||
from utils.view_utils import render_list
|
||||
from utils.view_utils import RowAction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.http import HttpRequest
|
||||
from django.http import HttpResponse
|
||||
|
||||
member_view = namespaced_decorator_factory(namespace="member", base_path="membership")
|
||||
|
||||
|
||||
@login_required
|
||||
def membership_overview(request):
|
||||
@member_view(
|
||||
paths="",
|
||||
name="membership-overview",
|
||||
login_required=True,
|
||||
)
|
||||
def membership_overview(request: HttpRequest) -> HttpResponse:
|
||||
"""View to show the membership overview."""
|
||||
memberships = get_memberships(member=request.user)
|
||||
current_membership = memberships.current()
|
||||
previous_memberships = memberships.previous()
|
||||
|
@ -33,15 +57,25 @@ def membership_overview(request):
|
|||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required(ADMINISTRATE_MEMBERS.path)
|
||||
def members_admin(request):
|
||||
admin_members_view = namespaced_decorator_factory(
|
||||
namespace="admin-members",
|
||||
base_path="admin",
|
||||
)
|
||||
|
||||
|
||||
@admin_members_view(
|
||||
paths="members/",
|
||||
name="list",
|
||||
login_required=True,
|
||||
permissions=[ADMINISTRATE_MEMBERS.path],
|
||||
)
|
||||
def members_admin(request: HttpRequest) -> HttpResponse:
|
||||
"""View to list all members."""
|
||||
users = get_members()
|
||||
|
||||
return render_list(
|
||||
render_config = RenderConfig(
|
||||
entity_name="member",
|
||||
entity_name_plural="members",
|
||||
request=request,
|
||||
paginate_by=20,
|
||||
objects=users,
|
||||
columns=[
|
||||
|
@ -54,23 +88,32 @@ def members_admin(request):
|
|||
row_actions=[
|
||||
RowAction(
|
||||
label=_("View"),
|
||||
url_name="admin-members-detail",
|
||||
url_name="admin-members:detail",
|
||||
url_kwargs={"member_id": "id"},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return render_config.render_list(
|
||||
request=request,
|
||||
)
|
||||
|
||||
@login_required
|
||||
@permission_required(ADMINISTRATE_MEMBERS.path)
|
||||
def members_admin_detail(request, member_id):
|
||||
|
||||
@admin_members_view(
|
||||
paths="<int:member_id>/",
|
||||
name="detail",
|
||||
login_required=True,
|
||||
permissions=[ADMINISTRATE_MEMBERS.path],
|
||||
)
|
||||
def members_admin_detail(request: HttpRequest, member_id: int) -> HttpResponse:
|
||||
"""View to show the details of a member."""
|
||||
member = get_member(member_id=member_id)
|
||||
subscription_periods = get_subscription_periods(member=member)
|
||||
|
||||
context = {
|
||||
"member": member,
|
||||
"subscription_periods": subscription_periods,
|
||||
"base_path": "admin-members",
|
||||
"base_path": "admin-members:list",
|
||||
}
|
||||
|
||||
return render(
|
||||
|
@ -78,3 +121,49 @@ def members_admin_detail(request, member_id):
|
|||
template_name="membership/members_admin_detail.html",
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@ratelimit(group="membership", key="ip", rate="10/d", method="ALL", block=True)
|
||||
@member_view(
|
||||
paths="invite/<str:referral_code>/<str:token>/",
|
||||
name="membership-invite",
|
||||
login_required=False,
|
||||
)
|
||||
def invite(request: HttpRequest, referral_code: str, token: str) -> HttpResponse:
|
||||
"""View to invite a member to create a membership.
|
||||
|
||||
The token belongs to a non-active Member object. If the token is valid,
|
||||
the caller is allowed to create a membership.
|
||||
|
||||
We ratelimit this view so it's not possible to brute-force tokens.
|
||||
"""
|
||||
if request.user.is_authenticated:
|
||||
return HttpResponseForbidden("You're already logged in. So you cannot receive an invite.")
|
||||
|
||||
# Firstly, we get the membership by the referral code.
|
||||
membership = get_object_or_404(Membership, referral_code=referral_code, user__is_active=False, revoked=False)
|
||||
|
||||
token_valid = default_token_generator.check_token(membership.user, token)
|
||||
|
||||
if not token_valid:
|
||||
raise HttpResponseForbidden("Token not valid - maybe it expired?")
|
||||
|
||||
if request.method == "POST":
|
||||
form = InviteForm(membership=membership, data=request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.info(request, _("Password is set for your account and you can now login."))
|
||||
return redirect("account_login")
|
||||
else:
|
||||
form = InviteForm(membership=membership)
|
||||
|
||||
context = {
|
||||
"token": token,
|
||||
"membership": membership,
|
||||
"form": form,
|
||||
}
|
||||
return render(
|
||||
request=request,
|
||||
template_name="membership/invite.html",
|
||||
context=context,
|
||||
)
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""data.coop member system."""
|
|
@ -1,8 +1,13 @@
|
|||
"""Settings for the project."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import django_stubs_ext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from environs import Env
|
||||
|
||||
django_stubs_ext.monkeypatch()
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
|
@ -40,6 +45,7 @@ DJANGO_APPS = [
|
|||
THIRD_PARTY_APPS = [
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"django_view_decorator",
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
|
@ -121,6 +127,7 @@ EMAIL_BACKEND = env.str(
|
|||
default="django.core.mail.backends.console.EmailBackend",
|
||||
)
|
||||
DEFAULT_FROM_EMAIL = env.str("DEFAULT_FROM_EMAIL", default="")
|
||||
SERVER_EMAIL = env.str("SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
|
||||
# Parse email URLs, e.g. "smtp://"
|
||||
email = env.dj_email_url("EMAIL_URL", default="smtp://")
|
||||
EMAIL_HOST = email["EMAIL_HOST"]
|
||||
|
@ -153,6 +160,32 @@ ACCOUNT_EMAIL_REQUIRED = True
|
|||
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
|
||||
ACCOUNT_USERNAME_REQUIRED = False
|
||||
|
||||
# Logging
|
||||
# We want to log everything to stdout in docker
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"": {
|
||||
"handlers": ["console"],
|
||||
"level": "DEBUG",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
STRIPE_API_KEY = env.str("STRIPE_API_KEY")
|
||||
STRIPE_ENDPOINT_SECRET = env.str("STRIPE_ENDPOINT_SECRET")
|
||||
|
||||
# The number of seconds a password reset link is valid for (default: 3 days).
|
||||
# We've extended this to 7 days because invites then last for 1 week.
|
||||
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 * 7
|
||||
|
||||
if DEBUG:
|
||||
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
||||
MIDDLEWARE += [
|
||||
|
|
2018
src/project/static/css/bootstrap-icons.css
vendored
2018
src/project/static/css/bootstrap-icons.css
vendored
File diff suppressed because it is too large
Load diff
7
src/project/static/css/bootstrap.min.css
vendored
7
src/project/static/css/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
67
src/project/static/css/dark-style.css
Normal file
67
src/project/static/css/dark-style.css
Normal file
|
@ -0,0 +1,67 @@
|
|||
html.dark body {
|
||||
--splash: #5b47e0;
|
||||
background: var(--dark-dark);
|
||||
color: var(--medium-dust)
|
||||
}
|
||||
|
||||
html.dark h1,
|
||||
html.dark h2,
|
||||
html.dark h3,
|
||||
html.dark h4,
|
||||
html.dark h5,
|
||||
html.dark h6,
|
||||
html.dark footer,
|
||||
html.dark nav ol li a {
|
||||
color: var(--medium-dust);
|
||||
}
|
||||
|
||||
html.dark nav ol li a:not(.current):hover {
|
||||
border-color: var(--medium-dust);
|
||||
}
|
||||
|
||||
html.dark header,
|
||||
html.dark main aside,
|
||||
html.dark nav {
|
||||
background: #1d1d1d;
|
||||
}
|
||||
|
||||
html.dark nav {
|
||||
border: none;
|
||||
}
|
||||
|
||||
html.dark hr {
|
||||
border-color: var(--twilight);
|
||||
}
|
||||
|
||||
html.dark main aside div,
|
||||
html.dark article div.content-view {
|
||||
background: var(--dark-twilight);
|
||||
}
|
||||
|
||||
html.dark article table tbody {
|
||||
background: var(--dark-twilight);
|
||||
}
|
||||
|
||||
html.dark article table tbody tr:nth-child(2n+1) {
|
||||
background: var(--dark);
|
||||
}
|
||||
|
||||
html.dark article table tbody tr:nth-child(2n+1) td {
|
||||
border-top: 1px solid var(--dark-dark);
|
||||
border-bottom: 1px solid var(--dark-dark);
|
||||
}
|
||||
|
||||
html.dark article table tbody tr:last-child td {
|
||||
border-bottom: var(--half-space) solid var(--twilight);
|
||||
}
|
||||
|
||||
html.dark form>div>input[type="text"],
|
||||
html.dark form>div>input[type="password"],
|
||||
html.dark input[type="email"] {
|
||||
border: 2px solid var(--twilight);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
background: var(--dark-dark);
|
||||
width: 100%;
|
||||
color: var(--light-dust);
|
||||
}
|
|
@ -1,414 +1,485 @@
|
|||
/* Reset */
|
||||
*, *::before, *::after {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
}
|
||||
img, picture, video, canvas, svg {
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
input, button, textarea, select {
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
p, h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
#root, #__next {
|
||||
|
||||
#root,
|
||||
#__next {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Variables */
|
||||
:root {
|
||||
/* Colors */
|
||||
--light : #ffffff;
|
||||
--light-dust : #fefef9;
|
||||
--dust : #f4f1ef;
|
||||
--medium-dust : #dadada;
|
||||
--dark-dust : #bfbfbf;
|
||||
--fade : #878787;
|
||||
--twilight : #4a4a4a;
|
||||
--dark : #2a2a2a;
|
||||
--light-custard : #eee7d5;
|
||||
--custard : #f0dcac;
|
||||
--dark-custard : #d4c7a9;
|
||||
--water : #a8f3f4;
|
||||
--splash : #4b3aba;
|
||||
--light: #ffffff;
|
||||
--light-dust: #fefef9;
|
||||
--dust: #f4f1ef;
|
||||
--medium-dust: #dadada;
|
||||
--dark-dust: #bfbfbf;
|
||||
--fade: #878787;
|
||||
--twilight: #4a4a4a;
|
||||
--dark-twilight: #2f2f2f;
|
||||
--dark: #2a2a2a;
|
||||
--dark-dark: #121212;
|
||||
--light-custard: #eee7d5;
|
||||
--custard: #f0dcac;
|
||||
--dark-custard: #d4c7a9;
|
||||
--water: #a8f3f4;
|
||||
--splash: #4b3aba;
|
||||
|
||||
/* Sizes */
|
||||
--space : 12px;
|
||||
--double-space : calc(var(--space) * 2);
|
||||
--half-space : calc(var(--space) / 2);
|
||||
--quarter-space : calc(var(--space) / 4);
|
||||
--outer-space : var(--double-space);
|
||||
--space: 12px;
|
||||
--double-space: calc(var(--space) * 2);
|
||||
--half-space: calc(var(--space) / 2);
|
||||
--quarter-space: calc(var(--space) / 4);
|
||||
--outer-space: var(--double-space);
|
||||
}
|
||||
|
||||
@media (min-width: 1380px) {
|
||||
:root {
|
||||
--outer-space : 15%;
|
||||
--outer-space: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
height : 100%;
|
||||
font-size : 1.05em;
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight : 600;
|
||||
color : var(--twilight);
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 600;
|
||||
color: var(--twilight);
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight : 500;
|
||||
color : var(--splash);
|
||||
text-decoration : none;
|
||||
cursor : pointer;
|
||||
font-weight: 500;
|
||||
color: var(--splash);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin : var(--double-space) 0;
|
||||
height : 0;
|
||||
border : 0;
|
||||
border-bottom : 1px solid var(--dark-custard);
|
||||
margin: var(--double-space) 0;
|
||||
height: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--dark-custard);
|
||||
}
|
||||
|
||||
body {
|
||||
margin : 0;
|
||||
padding : 0;
|
||||
background : var(--custard);
|
||||
font-family : Inter;
|
||||
font-weight : 400;
|
||||
line-height : 1.6;
|
||||
color : var(--dark);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--custard);
|
||||
font-family: Inter;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
header {
|
||||
display : flex;
|
||||
padding : var(--double-space) var(--outer-space);
|
||||
background : var(--light);
|
||||
justify-content : space-between;
|
||||
align-items : center;
|
||||
display: flex;
|
||||
padding: var(--double-space) var(--outer-space);
|
||||
background: var(--light);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header > h1 {
|
||||
font-size : 1.44em;
|
||||
header>h1 {
|
||||
font-size: 1.44em;
|
||||
}
|
||||
|
||||
header > a.logout {
|
||||
padding : 6px 12px;
|
||||
border-radius : 6px;
|
||||
background : var(--twilight);
|
||||
text-decoration : none;
|
||||
color : var(--dust);
|
||||
#switch-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 0 var(--space);
|
||||
top: -2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
header > a.logout:hover {
|
||||
background : var(--splash);
|
||||
color : var(--light);
|
||||
#switch-icon #layer1 path {
|
||||
fill: var(--twilight);
|
||||
}
|
||||
|
||||
header>div>a#logout {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--twilight);
|
||||
text-decoration: none;
|
||||
color: var(--dust);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
header>div>a#logout:hover {
|
||||
background: var(--splash);
|
||||
color: var(--light);
|
||||
}
|
||||
|
||||
aside {
|
||||
padding : 0 var(--outer-space) var(--double-space) var(--outer-space);
|
||||
background : var(--light);
|
||||
padding: 0 var(--outer-space) var(--double-space) var(--outer-space);
|
||||
background: var(--light);
|
||||
}
|
||||
|
||||
aside > div {
|
||||
background : var(--dust);
|
||||
padding : var(--double-space);
|
||||
border-radius : var(--quarter-space);
|
||||
overflow : hidden;
|
||||
aside>div {
|
||||
background: var(--dust);
|
||||
padding: var(--double-space);
|
||||
border-radius: var(--quarter-space);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
aside > div > h2 {
|
||||
font-size : 1.22em;
|
||||
margin : 0 0 6px 0;
|
||||
aside>div>h2 {
|
||||
font-size: 1.22em;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
aside > div > figure {
|
||||
width : 100px;
|
||||
height : 100px;
|
||||
border : 1px solid var(--dark-dust);
|
||||
float : left;
|
||||
margin-right : var(--double-space);
|
||||
aside>div>figure {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 1px solid var(--dark-dust);
|
||||
float: left;
|
||||
margin-right: var(--double-space);
|
||||
}
|
||||
|
||||
aside > div > dl {
|
||||
overflow : hidden;
|
||||
aside>div>dl {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
aside > div > dl > dt {
|
||||
float : left;
|
||||
clear : left;
|
||||
margin : 0 var(--double-space) 0 0;
|
||||
width : 180px;
|
||||
font-weight : 600;
|
||||
color : var(--twilight);
|
||||
aside>div>dl>dt {
|
||||
float: left;
|
||||
clear: left;
|
||||
margin: 0 var(--double-space) 0 0;
|
||||
width: 180px;
|
||||
font-weight: 600;
|
||||
color: var(--twilight);
|
||||
}
|
||||
|
||||
nav {
|
||||
display : block;
|
||||
border-bottom : 1px solid var(--dark-custard);
|
||||
background : var(--light);
|
||||
display: block;
|
||||
border-bottom: 1px solid var(--dark-custard);
|
||||
background: var(--light);
|
||||
}
|
||||
|
||||
nav ol {
|
||||
margin: 0 calc(var(--outer-space));
|
||||
padding : 0;
|
||||
list-style-type : none;
|
||||
overflow : hidden;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
nav ol li {
|
||||
margin : 0;
|
||||
padding : 0;
|
||||
float : left;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
nav > ol > li > a {
|
||||
display : block;
|
||||
padding : var(--half-space) var(--half-space) var(--quarter-space);
|
||||
margin : 0 var(--space);
|
||||
border-bottom : var(--half-space) solid transparent;
|
||||
text-decoration : none;
|
||||
color : var(--dark);
|
||||
cursor : pointer;
|
||||
font-weight : 500;
|
||||
letter-spacing : 0.04em;
|
||||
nav>ol>li>a {
|
||||
display: block;
|
||||
padding: var(--half-space) var(--half-space) var(--quarter-space);
|
||||
margin: 0 var(--space);
|
||||
border-bottom: var(--half-space) solid transparent;
|
||||
text-decoration: none;
|
||||
color: var(--dark);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
nav > ol > li:first-child > a {
|
||||
margin-left : 0;
|
||||
nav>ol>li:first-child>a {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
nav ol li a:hover {
|
||||
border-color : rgba(0,0,0,0.6);
|
||||
border-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
nav ol li a.current {
|
||||
font-weight : bold;
|
||||
border-color : var(--splash);
|
||||
color : var(--splash);
|
||||
font-weight: bold;
|
||||
border-color: var(--splash);
|
||||
color: var(--splash);
|
||||
}
|
||||
|
||||
article {
|
||||
padding : var(--double-space) var(--outer-space);
|
||||
padding: var(--double-space) var(--outer-space);
|
||||
}
|
||||
|
||||
article > div.content-view {
|
||||
background : var(--dust);
|
||||
padding : var(--double-space);
|
||||
margin-bottom : var(--space);
|
||||
article div.content-view {
|
||||
background: var(--dust);
|
||||
padding: var(--double-space);
|
||||
margin-bottom: var(--space);
|
||||
}
|
||||
|
||||
div.content-view > h2 {
|
||||
margin : 0 0 var(--space) 0;
|
||||
div.content-view>h2 {
|
||||
margin: 0 0 var(--space) 0;
|
||||
}
|
||||
|
||||
div.services {
|
||||
display : flex;
|
||||
justify-content : space-between;
|
||||
gap : var(--double-space);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--double-space);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
div.services > div,
|
||||
div.services>div,
|
||||
div.infobox {
|
||||
background : var(--light);
|
||||
padding : var(--double-space);
|
||||
border-radius : 6px;
|
||||
flex : 240px;
|
||||
max-width : 420px;
|
||||
display : flex;
|
||||
flex-flow : column;
|
||||
justify-content : space-between;
|
||||
}
|
||||
|
||||
div.infobox button {
|
||||
margin-top : var(--double-space);
|
||||
}
|
||||
|
||||
div.services > div > div.description {
|
||||
margin-bottom : var(--double-space);
|
||||
}
|
||||
|
||||
div.services > div > div.description > p {
|
||||
margin-top : var(--half-space);
|
||||
}
|
||||
|
||||
div.services > div > a,
|
||||
a.button,
|
||||
button {
|
||||
display : block;
|
||||
color : var(--light);
|
||||
background : var(--splash);
|
||||
padding : var(--space) var(--double-space);
|
||||
border-radius : var(--quarter-space);
|
||||
opacity : 0.85;
|
||||
cursor : pointer;
|
||||
text-align : center;
|
||||
border : 0;
|
||||
font-weight : 600;
|
||||
text-decoration : none;
|
||||
}
|
||||
|
||||
button.small {
|
||||
font-size : 0.78em;
|
||||
padding : var(--half-space) var(--space);
|
||||
}
|
||||
|
||||
div.services > div > a:hover,
|
||||
a.button:hover,
|
||||
button:hover {
|
||||
opacity : 1.0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity : 0.6;
|
||||
background : var(--twilight);
|
||||
cursor : default;
|
||||
}
|
||||
|
||||
article table {
|
||||
width : 100%;
|
||||
border-spacing : 0;
|
||||
margin : var(--space) 0;
|
||||
}
|
||||
|
||||
article table thead th {
|
||||
background : var(--twilight);
|
||||
color : var(--medium-dust);
|
||||
}
|
||||
|
||||
article table thead th a {
|
||||
color : var(--light);
|
||||
}
|
||||
|
||||
article table thead th:first-child {
|
||||
border-radius : var(--half-space) 0 0 0;
|
||||
}
|
||||
|
||||
article table thead th:last-child {
|
||||
border-radius : 0 var(--half-space) 0 0;
|
||||
}
|
||||
|
||||
article table tbody {
|
||||
background : var(--light-dust);
|
||||
}
|
||||
|
||||
article table tbody tr:nth-child(odd) {
|
||||
background : var(--light-custard);
|
||||
}
|
||||
|
||||
article table tbody tr:nth-child(odd) td {
|
||||
border-top : 1px solid var(--dark-custard);
|
||||
border-bottom : 1px solid var(--dark-custard);
|
||||
}
|
||||
|
||||
article table tbody tr:last-child td {
|
||||
border-bottom : var(--half-space) solid var(--twilight);
|
||||
}
|
||||
|
||||
article table thead th,
|
||||
article table tbody td {
|
||||
padding : var(--space);
|
||||
text-align : left;
|
||||
}
|
||||
|
||||
article table#user_email_table tbody tr td:first-child {
|
||||
text-align : center;
|
||||
}
|
||||
|
||||
form > div {
|
||||
margin : 0 0 var(--double-space);
|
||||
}
|
||||
|
||||
form > div >label {
|
||||
display : block;
|
||||
margin : 0 0 6px;
|
||||
}
|
||||
|
||||
form > div > input[type="text"],
|
||||
form > div > input[type="password"],
|
||||
input[type="email"] {
|
||||
border : 2px solid var(--twilight);
|
||||
border-radius : 6px;
|
||||
padding : 8px;
|
||||
background : var(--light-dust);
|
||||
width : 100%;
|
||||
}
|
||||
|
||||
form fieldset {
|
||||
border : 0;
|
||||
padding : 0;
|
||||
margin : 0;
|
||||
}
|
||||
|
||||
form div.buttonHolder {
|
||||
}
|
||||
|
||||
form div.buttonHolder button {
|
||||
display : inline-block;
|
||||
}
|
||||
|
||||
#login {
|
||||
height : 100%;
|
||||
display : flex;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
}
|
||||
|
||||
#loginbox {
|
||||
border-radius : var(--space);
|
||||
border : 6px solid white;
|
||||
width : 800px;
|
||||
height : 500px;
|
||||
|
||||
display : flex;
|
||||
flex-flow : row;
|
||||
}
|
||||
|
||||
#loginbox > div {
|
||||
padding : var(--double-space);
|
||||
flex : 1;
|
||||
}
|
||||
|
||||
#loginbox label {
|
||||
color : var(--twilight);
|
||||
}
|
||||
|
||||
#loginbox > div.login {
|
||||
background : var(--light-dust);
|
||||
background: var(--light);
|
||||
padding: var(--double-space);
|
||||
border-radius: 6px;
|
||||
flex: 240px;
|
||||
max-width: 420px;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#loginbox > div.signup {
|
||||
background : var(--water);
|
||||
div.infobox button {
|
||||
margin-top: var(--double-space);
|
||||
}
|
||||
|
||||
div.services>div>div.description {
|
||||
margin-bottom: var(--double-space);
|
||||
}
|
||||
|
||||
div.services>div>div.description>p {
|
||||
margin-top: var(--half-space);
|
||||
}
|
||||
|
||||
div.services>div>a,
|
||||
a.button,
|
||||
button {
|
||||
display: block;
|
||||
color: var(--light);
|
||||
background: var(--splash);
|
||||
padding: var(--space) var(--double-space);
|
||||
border-radius: var(--quarter-space);
|
||||
opacity: 0.85;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
border: 0;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
button.small {
|
||||
font-size: 0.78em;
|
||||
padding: var(--half-space) var(--space);
|
||||
}
|
||||
|
||||
div.services>div>a:hover,
|
||||
a.button:hover,
|
||||
button:hover {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
background: var(--twilight);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: var(--twilight);
|
||||
}
|
||||
|
||||
article table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
margin: var(--space) 0;
|
||||
}
|
||||
|
||||
article table thead th {
|
||||
background: var(--twilight);
|
||||
color: var(--medium-dust);
|
||||
}
|
||||
|
||||
article table thead th a {
|
||||
color: var(--light);
|
||||
}
|
||||
|
||||
article table thead th:first-child {
|
||||
border-radius: var(--half-space) 0 0 0;
|
||||
}
|
||||
|
||||
article table thead th:last-child {
|
||||
border-radius: 0 var(--half-space) 0 0;
|
||||
}
|
||||
|
||||
article table tbody {
|
||||
background: var(--light-dust);
|
||||
}
|
||||
|
||||
article table tbody tr:nth-child(odd) {
|
||||
background: var(--light-custard);
|
||||
}
|
||||
|
||||
article table tbody tr:nth-child(odd) td {
|
||||
border-top: 1px solid var(--dark-custard);
|
||||
border-bottom: 1px solid var(--dark-custard);
|
||||
}
|
||||
|
||||
article table tbody tr:last-child td {
|
||||
border-bottom: var(--half-space) solid var(--twilight);
|
||||
}
|
||||
|
||||
article table thead th,
|
||||
article table tbody td {
|
||||
padding: var(--space);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
article table#user_email_table tbody tr td:first-child {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
form>div {
|
||||
margin: 0 0 var(--double-space);
|
||||
}
|
||||
|
||||
form>div>label {
|
||||
display: block;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
form>div>input[type="text"],
|
||||
form>div>input[type="password"],
|
||||
input[type="email"] {
|
||||
border: 2px solid var(--twilight);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
background: var(--light-dust);
|
||||
width: 100%;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
form fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
form div.buttonHolder button {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#email-add-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#email-add-overlay .content-view {
|
||||
width: 600px;
|
||||
padding: var(--double-space);
|
||||
}
|
||||
|
||||
#email-add-overlay .content-view p {
|
||||
margin: var(--double-space) 0;
|
||||
}
|
||||
|
||||
#login {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#loginbox {
|
||||
border-radius: var(--space);
|
||||
border: 6px solid white;
|
||||
width: 800px;
|
||||
height: 500px;
|
||||
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
}
|
||||
|
||||
#loginbox>div {
|
||||
padding: var(--double-space);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#loginbox label {
|
||||
color: var(--twilight);
|
||||
}
|
||||
|
||||
#loginbox>div.login {
|
||||
background: var(--light-dust);
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#loginbox>div.signup {
|
||||
background: var(--water);
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#loginbox > div:first-child {
|
||||
border-radius : var(--half-space) 0 0 var(--half-space);
|
||||
#loginbox>div:first-child {
|
||||
border-radius: var(--half-space) 0 0 var(--half-space);
|
||||
}
|
||||
|
||||
#loginbox > div:last-child {
|
||||
border-radius : 0 var(--half-space) var(--half-space) 0;
|
||||
#loginbox>div:last-child {
|
||||
border-radius: 0 var(--half-space) var(--half-space) 0;
|
||||
}
|
||||
|
||||
#loginbox > div:last-child > * {
|
||||
flex : 1;
|
||||
#loginbox>div:last-child>* {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#loginbox div.new_here {
|
||||
margin-top : var(--double-space);
|
||||
margin-top: var(--double-space);
|
||||
}
|
||||
|
||||
#loginbox div.new_here h2 {
|
||||
|
@ -416,73 +487,78 @@ form div.buttonHolder button {
|
|||
}
|
||||
|
||||
#loginbox button {
|
||||
width : 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#loginbox img {
|
||||
padding : 0 var(--double-space);
|
||||
padding: 0 var(--double-space);
|
||||
}
|
||||
|
||||
footer {
|
||||
margin : var(--space) var(--outer-space);
|
||||
padding : var(--space);
|
||||
border-radius : var(--quarter-space);
|
||||
background : var(--dark);
|
||||
color : var(--dust);
|
||||
font-size : 0.78em;
|
||||
opacity : 0.8;
|
||||
margin: var(--space) var(--outer-space);
|
||||
padding: var(--space);
|
||||
border-radius: var(--quarter-space);
|
||||
background: var(--dark);
|
||||
color: var(--dust);
|
||||
font-size: 0.78em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
footer a, footer a:visited, footer a:active {
|
||||
color: var(--dust);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
span.time_remaining {
|
||||
color : var(--fade);
|
||||
color: var(--fade);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display : flex;
|
||||
justify-content : center;
|
||||
list-style : none;
|
||||
padding : var(--half-space) 0;
|
||||
margin : 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
list-style: none;
|
||||
padding: var(--half-space) 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pagination > li {
|
||||
margin : 0 var(--half-space);
|
||||
.pagination>li {
|
||||
margin: 0 var(--half-space);
|
||||
}
|
||||
|
||||
.pagination > li:first-child {
|
||||
margin-right : var(--double-space);
|
||||
.pagination>li:first-child {
|
||||
margin-right: var(--double-space);
|
||||
}
|
||||
|
||||
.pagination > li:last-child {
|
||||
margin-left : var(--double-space);
|
||||
.pagination>li:last-child {
|
||||
margin-left: var(--double-space);
|
||||
}
|
||||
|
||||
.pagination .page-item {
|
||||
border : 1px solid var(--fade);
|
||||
padding : var(--quarter-space) var(--half-space);
|
||||
border-radius : var(--half-space);
|
||||
background : var(--light-dust);
|
||||
font-size : 0.78em;
|
||||
border: 1px solid var(--fade);
|
||||
padding: var(--quarter-space) var(--half-space);
|
||||
border-radius: var(--half-space);
|
||||
background: var(--light-dust);
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.pagination .page-link {
|
||||
padding : var(--half-space);
|
||||
color : var(--twilight);
|
||||
padding: var(--half-space);
|
||||
color: var(--twilight);
|
||||
}
|
||||
|
||||
.pagination .page-item.active {
|
||||
background : var(--twilight);
|
||||
background: var(--twilight);
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
color : var(--light-dust);
|
||||
font-weight : bold;
|
||||
color: var(--light-dust);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pagination .page-item.disabled {
|
||||
opacity : 0.6;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pagination .page-item.disabled .page-link {
|
||||
cursor : default;
|
||||
cursor: default;
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -7,7 +7,6 @@
|
|||
{% block content %}
|
||||
<div class="content-view">
|
||||
<h2>{% trans "Email Addresses" %}</h2>
|
||||
<p>Her kan du tilføje og ændre emailadresser tilknyttet din konto.</p>
|
||||
<p>{% trans 'The following email addresses are associated with your account:' %}</p>
|
||||
|
||||
<hr />
|
||||
|
@ -17,7 +16,7 @@
|
|||
{% csrf_token %}
|
||||
<fieldset class="blockLabels">
|
||||
<div class="buttonHolder">
|
||||
<button class="small" type="submit" name="action_primary" style="float:right">+ Add Email</button>
|
||||
<button class="small" name="action_add_open" style="float:right">Add Email …</button>
|
||||
<button class="small" disabled type="submit" id="action_primary" name="action_primary">Make Primary</button>
|
||||
<button class="small" type="submit" name="action_send">Re-send Verification</button>
|
||||
<button class="small" type="submit" name="action_remove">Remove</button>
|
||||
|
@ -77,21 +76,22 @@
|
|||
{% trans "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<!--
|
||||
<hr />
|
||||
|
||||
</div>
|
||||
<div id="email-add-overlay">
|
||||
<div class="content-view">
|
||||
<h3>{% trans "Add E-mail" %}</h3>
|
||||
<div class="panel-body">
|
||||
<form method="post" action="{% url 'account_email' %}"
|
||||
class="add_email">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button name="action_add" class="btn btn-success" type="submit">
|
||||
<button name="action_add" style="float:right" type="submit">
|
||||
{% trans "Add E-mail" %}
|
||||
</button>
|
||||
<button id="overlay-close-button" class="secondary">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
@ -106,6 +106,24 @@
|
|||
});
|
||||
}
|
||||
|
||||
let addEmail = document.getElementsByName('action_add_open')[0];
|
||||
|
||||
addEmail.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
let overlay = document.getElementById('email-add-overlay')
|
||||
overlay.style.display = 'flex'
|
||||
|
||||
window.addEventListener('keydown', function(e) {
|
||||
if (e.key == 'Escape') {
|
||||
overlay.style.display = 'none'
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('overlay-close-button').addEventListener('click', function() {
|
||||
overlay.style.display = 'none'
|
||||
})
|
||||
})
|
||||
|
||||
let radio_actions = document.getElementsByName('email');
|
||||
if (radio_actions.length) {
|
||||
for (radio of radio_actions) {
|
||||
|
|
|
@ -10,11 +10,39 @@
|
|||
<title>{% block head_title %}{% endblock %} – {{ site.name }}</title>
|
||||
<link rel="stylesheet" href="{% static "fonts/inter.css" %}">
|
||||
<link rel="stylesheet" href="{% static "css/style.css" %}">
|
||||
<link rel="stylesheet" href="{% static "css/dark-style.css" %}">
|
||||
<script>
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
|
||||
if (savedTheme === "dark" || (savedTheme == null && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.querySelector('html').classList.add('dark');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1> data.coop membersystem </h1>
|
||||
<a class="logout" href="{% url "account_logout" %}">Log out</a>
|
||||
<div>
|
||||
<a id="theme-switcher" title="Switch theme">
|
||||
<svg id="switch-icon"
|
||||
width="20.00033mm"
|
||||
height="20.000334mm"
|
||||
viewBox="0 0 20.00033 20.000334"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g id="layer1">
|
||||
<path id="path1"
|
||||
style="fill-opacity:1;stroke:none;stroke-width:0.697782;stroke-linecap:round"
|
||||
d="M 9.999906,20.000334 A 10,10 0 0 0 20.000329,9.999911 10,10 0 0 0 9.999906,0 10,10 0 0 0 0,9.999911 10,10 0 0 0 9.999906,20.000334 Z m 0,-2.00039 V 1.99988 a 8,8 0 0 1 8.000023,8.000031 8,8 0 0 1 -8.000023,8.000033 z" />
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
<a id="logout" href="{% url "account_logout" %}">Log out</a>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<aside>
|
||||
|
@ -66,7 +94,7 @@
|
|||
|
||||
{% if perms.membership.administrate_memberships %}
|
||||
<li>
|
||||
<a href="{% url "admin-members" %}" class="{% active_path "admin-members" "current" %}">
|
||||
<a href="{% url "admin-members:list" %}" class="{% active_path "admin-members:list" "current" %}">
|
||||
Admin
|
||||
</a>
|
||||
</li>
|
||||
|
@ -74,11 +102,30 @@
|
|||
</ol>
|
||||
</nav>
|
||||
<article>
|
||||
{% if messages %}
|
||||
<div class="content-view">
|
||||
{% for message in messages %}
|
||||
<p>📨</p>
|
||||
<p><strong>{{ message }}</strong></p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</article>
|
||||
</main>
|
||||
<footer>
|
||||
data.coop membersystem version 0.0.1
|
||||
data.coop membersystem alpha - report issues on <a href="https://git.data.coop/data.coop/membersystem/">git</a>
|
||||
</footer>
|
||||
<script>
|
||||
const themeSwitcher = document.getElementById('theme-switcher');
|
||||
themeSwitcher.addEventListener('click', function() {
|
||||
themeSwitcher.classList.toggle('active')
|
||||
let isDark = document.querySelector('html').classList.toggle('dark');
|
||||
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -14,6 +14,10 @@
|
|||
It is very much under construction.
|
||||
</p>
|
||||
|
||||
{% for order in unpaid_orders %}
|
||||
<p>You have an unpaid order: <a href="{% url "order:detail" order_id=order.id %}">View Order ID {{ order.id }}</a></p>
|
||||
{% endfor %}
|
||||
|
||||
{% comment %}
|
||||
<hr>
|
||||
<br>
|
||||
|
|
|
@ -1,26 +1,13 @@
|
|||
"""URLs for the membersystem"""
|
||||
"""URLs for the membersystem."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import include
|
||||
from django.urls import path
|
||||
|
||||
from .views import index
|
||||
from .views import services_overview
|
||||
from membership.views import members_admin
|
||||
from membership.views import members_admin_detail
|
||||
from membership.views import membership_overview
|
||||
from django_view_decorator import include_view_urls
|
||||
|
||||
urlpatterns = [
|
||||
path("", login_required(index), name="index"),
|
||||
path("services/", login_required(services_overview), name="services"),
|
||||
path("membership/", membership_overview, name="membership-overview"),
|
||||
path("admin/members/", members_admin, name="admin-members"),
|
||||
path(
|
||||
"admin/members/<int:member_id>/",
|
||||
members_admin_detail,
|
||||
name="admin-members-detail",
|
||||
),
|
||||
path("", include_view_urls(extra_modules=["project.views"])),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
path("_admin/", admin.site.urls),
|
||||
]
|
||||
|
@ -29,4 +16,5 @@ if settings.DEBUG:
|
|||
urlpatterns = [
|
||||
path("__debug__/", include("debug_toolbar.urls")),
|
||||
path("__reload__/", include("django_browser_reload.urls")),
|
||||
] + urlpatterns
|
||||
*urlpatterns,
|
||||
]
|
||||
|
|
|
@ -1,9 +1,37 @@
|
|||
"""Project views."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from accounting.models import Order
|
||||
from django_view_decorator import view
|
||||
from utils.view_utils import render
|
||||
|
||||
|
||||
def index(request):
|
||||
return render(request, "index.html")
|
||||
if TYPE_CHECKING:
|
||||
from django.http import HttpRequest
|
||||
from django.http import HttpResponse
|
||||
|
||||
|
||||
def services_overview(request):
|
||||
@view(
|
||||
paths="",
|
||||
name="index",
|
||||
login_required=True,
|
||||
)
|
||||
def index(request: HttpRequest) -> HttpResponse:
|
||||
"""View to show the index page."""
|
||||
unpaid_orders = Order.objects.filter(member=request.user, is_paid=False)
|
||||
|
||||
context = {"unpaid_orders": list(unpaid_orders)}
|
||||
|
||||
return render(request, "index.html", context=context)
|
||||
|
||||
|
||||
@view(
|
||||
paths="services/",
|
||||
name="services",
|
||||
login_required=True,
|
||||
)
|
||||
def services_overview(request: HttpRequest) -> HttpResponse:
|
||||
"""View to show the services overview."""
|
||||
return render(request, "services_overview.html")
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
"""
|
||||
WSGI config for membersystem project.
|
||||
"""WSGI config for membersystem project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
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
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
[pytest]
|
||||
testpaths = .
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
DJANGO_SETTINGS_MODULE = project.settings
|
||||
#norecursedirs = dist tmp* .svn .*
|
|
@ -0,0 +1 @@
|
|||
"""Utility functions for the project."""
|
|
@ -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"))
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Utility template tags for the project."""
|
|
@ -1,3 +1,7 @@
|
|||
"""Custom template tags for the project."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django import template
|
||||
from django.urls import reverse
|
||||
|
||||
|
@ -5,9 +9,8 @@ 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
|
||||
|
||||
|
@ -19,3 +22,4 @@ def active_path(context, path_name, class_name) -> str | None:
|
|||
|
||||
if is_path or is_base_path:
|
||||
return class_name
|
||||
return None
|
||||
|
|
|
@ -1,23 +1,29 @@
|
|||
"""Utility views for rendering lists of objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
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.db.models import Model
|
||||
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
|
||||
class Row:
|
||||
"""
|
||||
A row in a table.
|
||||
"""
|
||||
"""A row in a table."""
|
||||
|
||||
data: dict[str, str]
|
||||
actions: list[dict[str, str]]
|
||||
|
@ -25,18 +31,14 @@ class Row:
|
|||
|
||||
@dataclass
|
||||
class RowAction:
|
||||
"""
|
||||
An action that can be performed on a row in a table.
|
||||
"""
|
||||
"""An action that can be performed on a row in a table."""
|
||||
|
||||
label: str
|
||||
url_name: str
|
||||
url_kwargs: dict[str, str]
|
||||
|
||||
def render(self, obj) -> dict[str, str]:
|
||||
"""
|
||||
Render the action as a dictionary for the given object.
|
||||
"""
|
||||
def render(self, obj: Model) -> dict[str, str]:
|
||||
"""Render the action as a dictionary for the given object."""
|
||||
url = reverse(
|
||||
self.url_name,
|
||||
kwargs={key: getattr(obj, value) for key, value in self.url_kwargs.items()},
|
||||
|
@ -44,22 +46,33 @@ class RowAction:
|
|||
return {"label": self.label, "url": url}
|
||||
|
||||
|
||||
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,
|
||||
list_actions: list[tuple[str, str]] = None,
|
||||
paginate_by: int = None,
|
||||
) -> HttpResponse:
|
||||
"""
|
||||
Render a list of objects with a table.
|
||||
"""
|
||||
@dataclass(kw_only=True)
|
||||
class RenderConfig:
|
||||
"""Configuration for rendering a list of 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
|
||||
|
||||
def render_list(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
) -> HttpResponse:
|
||||
"""Render a list of objects with a table."""
|
||||
# TODO: List actions
|
||||
|
||||
entity_name = self.entity_name
|
||||
entity_name_plural = self.entity_name_plural
|
||||
objects = self.objects
|
||||
columns = self.columns
|
||||
row_actions = self.row_actions or []
|
||||
list_actions = self.list_actions or []
|
||||
paginate_by = self.paginate_by
|
||||
|
||||
total_count = len(objects)
|
||||
|
||||
order_by = request.GET.get("order_by")
|
||||
|
@ -107,17 +120,18 @@ def render_list(
|
|||
|
||||
|
||||
def base_context(request: HttpRequest) -> dict[str, Any]:
|
||||
"""
|
||||
Return a base context for all views.
|
||||
"""
|
||||
"""Return a base context for all views."""
|
||||
return {"site": get_current_site(request)}
|
||||
|
||||
|
||||
def render(request, template_name, context=None):
|
||||
"""
|
||||
Render a template with a base context.
|
||||
"""
|
||||
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 = {}
|
||||
context = base_context(request) | context
|
||||
|
||||
# Make sure to fetch all permissions before rendering the template
|
||||
# otherwise django-zen-queries will complain about database queries.
|
||||
request.user.get_all_permissions()
|
||||
|
||||
return zen_queries_render(request, template_name, context)
|
||||
|
|
Loading…
Reference in a new issue