forked from data.coop/membersystem
Compare commits
103 commits
master
...
dockerfile
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
*
|
||||||
|
.*
|
||||||
|
*/.*
|
||||||
|
|
||||||
|
!src/
|
||||||
|
!requirements.txt
|
||||||
|
!requirements/
|
||||||
|
!entrypoint.sh
|
||||||
|
!pyproject.toml
|
||||||
|
!README.md
|
25
.drone.yml
Normal file
25
.drone.yml
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: default
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: docker
|
||||||
|
image: plugins/docker
|
||||||
|
environment:
|
||||||
|
BUILD: "${DRONE_COMMIT_SHA}"
|
||||||
|
settings:
|
||||||
|
repo: docker.data.coop/membersystem
|
||||||
|
registry: docker.data.coop
|
||||||
|
username:
|
||||||
|
from_secret: DOCKER_USERNAME
|
||||||
|
password:
|
||||||
|
from_secret: DOCKER_PASSWORD
|
||||||
|
build_args_from_env:
|
||||||
|
- BUILD
|
||||||
|
tags:
|
||||||
|
- "${DRONE_BUILD_NUMBER}"
|
||||||
|
- "latest"
|
||||||
|
when:
|
||||||
|
branch:
|
||||||
|
- main
|
8
.env.example
Normal file
8
.env.example
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
SECRET_KEY=something-very-random
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
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
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -2,5 +2,9 @@ __pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.sw*
|
*.sw*
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
project/settings/local.py
|
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
|
.idea/
|
||||||
|
*.mo
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
|
@ -1,14 +1,37 @@
|
||||||
|
default_language_version:
|
||||||
|
python: python3
|
||||||
|
exclude: ^.*\b(migrations)\b.*$
|
||||||
repos:
|
repos:
|
||||||
- repo: git://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v1.3.0
|
rev: v4.6.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: check-ast
|
||||||
- id: flake8
|
- id: check-merge-conflict
|
||||||
- id: check-yaml
|
- id: check-case-conflict
|
||||||
- id: check-added-large-files
|
- id: detect-private-key
|
||||||
- id: debug-statements
|
- id: check-added-large-files
|
||||||
- id: end-of-file-fixer
|
- id: check-json
|
||||||
- repo: https://github.com/asottile/reorder_python_imports
|
- id: check-symlinks
|
||||||
rev: v1.0.1
|
- id: check-toml
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: 'v0.5.2'
|
||||||
hooks:
|
hooks:
|
||||||
- id: reorder-python-imports
|
- id: ruff
|
||||||
|
args:
|
||||||
|
- --fix
|
||||||
|
- id: ruff-format
|
||||||
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
|
rev: v3.16.0
|
||||||
|
hooks:
|
||||||
|
- id: pyupgrade
|
||||||
|
args:
|
||||||
|
- --py311-plus
|
||||||
|
exclude: migrations/
|
||||||
|
- repo: https://github.com/adamchainz/django-upgrade
|
||||||
|
rev: 1.19.0
|
||||||
|
hooks:
|
||||||
|
- id: django-upgrade
|
||||||
|
args:
|
||||||
|
- --target-version=5.0
|
||||||
|
|
45
Dockerfile
Normal file
45
Dockerfile
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
FROM python:3.12-slim-bullseye
|
||||||
|
|
||||||
|
# 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 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=on \
|
||||||
|
PIP_DEFAULT_TIMEOUT=100
|
||||||
|
ARG BUILD
|
||||||
|
ENV BUILD ${BUILD}
|
||||||
|
ARG REQUIREMENTS_FILE=requirements.txt
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN groupadd -g 1000 www && useradd -u 1000 -ms /bin/bash -g www www
|
||||||
|
COPY --chown=www:www . .
|
||||||
|
RUN mkdir /app/src/static && \
|
||||||
|
chown www:www /app/src/static && \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get install -y \
|
||||||
|
binutils \
|
||||||
|
libpq-dev \
|
||||||
|
build-essential \
|
||||||
|
netcat-openbsd \
|
||||||
|
libcairo2 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libgdk-pixbuf2.0-0 \
|
||||||
|
libffi-dev \
|
||||||
|
shared-mime-info \
|
||||||
|
gettext && \
|
||||||
|
pip install --no-cache-dir -r $REQUIREMENTS_FILE && \
|
||||||
|
django-admin compilemessages
|
||||||
|
|
||||||
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "project.asgi:application", "--host", "0.0.0.0", "--port", "8000", "--workers", "3", "--lifespan", "off", "--app-dir", "/app/src"]
|
32
Makefile
32
Makefile
|
@ -1,15 +1,23 @@
|
||||||
# These are just some make targets, expressing how things
|
DOCKER_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose
|
||||||
# are supposed to be run, but feel free to change them!
|
DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u`
|
||||||
|
DOCKER_BUILD = DOCKER_BUILDKIT=1 docker build
|
||||||
|
MANAGE_EXEC = python /app/src/manage.py
|
||||||
|
MANAGE_COMMAND = ${DOCKER_RUN} app ${MANAGE_EXEC}
|
||||||
|
|
||||||
dev-setup:
|
run:
|
||||||
pip install -r requirements_dev.txt --upgrade
|
${DOCKER_COMPOSE} up
|
||||||
pip install -r requirements.txt --upgrade
|
|
||||||
pre-commit install
|
|
||||||
python manage.py migrate
|
|
||||||
python manage.py createsuperuser
|
|
||||||
|
|
||||||
lint:
|
makemigrations:
|
||||||
pre-commit run --all
|
${MANAGE_COMMAND} makemigrations ${ARGS}
|
||||||
|
|
||||||
test:
|
migrate:
|
||||||
pytest
|
${MANAGE_COMMAND} migrate ${ARGS}
|
||||||
|
|
||||||
|
createsuperuser:
|
||||||
|
${MANAGE_COMMAND} createsuperuser
|
||||||
|
|
||||||
|
shell:
|
||||||
|
${MANAGE_COMMAND} shell
|
||||||
|
|
||||||
|
manage_command:
|
||||||
|
${MANAGE_COMMAND} ${ARGS}
|
||||||
|
|
74
README.md
Normal file
74
README.md
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
# data.coop member system
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
There are two ways to setup the development environment.
|
||||||
|
|
||||||
|
- Using the Docker Compose setup provided in this repository.
|
||||||
|
- Using [hatch](https://hatch.pypa.io/) in your host OS.
|
||||||
|
|
||||||
|
|
||||||
|
### Using Docker Compose
|
||||||
|
|
||||||
|
Working with the Docker Compose setup is made easy with the `Makefile` provided in the repository.
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
|
||||||
|
- Docker
|
||||||
|
- docker compose plugin
|
||||||
|
|
||||||
|
#### Setup
|
||||||
|
|
||||||
|
1. Setup .env file
|
||||||
|
|
||||||
|
An example .env file is provided in the repository. You can copy it to .env file using the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
The default values in the .env file are suitable for the docker-compose setup.
|
||||||
|
|
||||||
|
2. Migrate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the development server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using hatch
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
|
||||||
|
- Python 3.12 or higher
|
||||||
|
- [hatch](https://hatch.pypa.io/) (Recommended way to install is using `pipx install hatch`)
|
||||||
|
- A running PostgreSQL server
|
||||||
|
|
||||||
|
#### Setup
|
||||||
|
|
||||||
|
1. Setup .env file
|
||||||
|
|
||||||
|
An example .env file is provided in the repository. You can copy it to .env file using the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the .env file and set the values for the environment variables, especially the database variables.
|
||||||
|
|
||||||
|
2. Run migrate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hatch run dev:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the development server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hatch run dev:server
|
||||||
|
```
|
19
README.rst
19
README.rst
|
@ -1,19 +0,0 @@
|
||||||
member.data.coop
|
|
||||||
================
|
|
||||||
|
|
||||||
To start developing:
|
|
||||||
|
|
||||||
# Create a virtualenv with python 3
|
|
||||||
$ mkvirtualenv -p python3 datacoop
|
|
||||||
|
|
||||||
# Run this make target, which installs all the requirements and sets up a
|
|
||||||
# development database.
|
|
||||||
$ make dev-setup
|
|
||||||
|
|
||||||
To run the Django development server:
|
|
||||||
|
|
||||||
$ python manage.py runserver
|
|
||||||
|
|
||||||
Before you push your stuff, run tests:
|
|
||||||
|
|
||||||
$ make test
|
|
|
@ -1,28 +0,0 @@
|
||||||
from django.contrib import admin
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from . import models
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Order)
|
|
||||||
class OrderAdmin(admin.ModelAdmin):
|
|
||||||
|
|
||||||
list_display = ('who', 'description', 'created', 'is_paid',)
|
|
||||||
|
|
||||||
def who(self, instance):
|
|
||||||
return instance.user.get_full_name()
|
|
||||||
who.short_description = _("Customer")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Payment)
|
|
||||||
class PaymentAdmin(admin.ModelAdmin):
|
|
||||||
|
|
||||||
list_display = ('who', 'description', 'order_id', 'created',)
|
|
||||||
|
|
||||||
def who(self, instance):
|
|
||||||
return instance.order.user.get_full_name()
|
|
||||||
who.short_description = _("Customer")
|
|
||||||
|
|
||||||
def order_id(self, instance):
|
|
||||||
return instance.order.id
|
|
||||||
order_id.short_description = _("Order ID")
|
|
|
@ -1,5 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AccountingConfig(AppConfig):
|
|
||||||
name = 'accounting'
|
|
|
@ -1,84 +0,0 @@
|
||||||
# Generated by Django 2.0.6 on 2018-06-23 19:51
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import djmoney.models.fields
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Account',
|
|
||||||
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')),
|
|
||||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'abstract': False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Order',
|
|
||||||
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')),
|
|
||||||
('description', models.CharField(max_length=1024, verbose_name='description')),
|
|
||||||
('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
|
|
||||||
('price', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), max_digits=16, verbose_name='price (excl. VAT)')),
|
|
||||||
('vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
|
|
||||||
('vat', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), max_digits=16, verbose_name='VAT')),
|
|
||||||
('is_paid', models.BooleanField(default=False, verbose_name='is paid')),
|
|
||||||
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='accounting.Account')),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Order',
|
|
||||||
'verbose_name_plural': 'Orders',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Payment',
|
|
||||||
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')),
|
|
||||||
('amount_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
|
|
||||||
('amount', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), max_digits=16)),
|
|
||||||
('description', models.CharField(max_length=1024, verbose_name='description')),
|
|
||||||
('stripe_charge_id', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='accounting.Order')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'payment',
|
|
||||||
'verbose_name_plural': 'payments',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Transaction',
|
|
||||||
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')),
|
|
||||||
('amount_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
|
|
||||||
('amount', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), help_text='This will include VAT', max_digits=16, verbose_name='amount')),
|
|
||||||
('description', models.CharField(max_length=1024, verbose_name='description')),
|
|
||||||
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='accounting.Account')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'abstract': False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
services:
|
||||||
|
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:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./:/app/
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:13-alpine
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data/
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
...
|
21
entrypoint.sh
Executable file
21
entrypoint.sh
Executable file
|
@ -0,0 +1,21 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Waiting for postgres..."
|
||||||
|
|
||||||
|
POSTGRES_PORT=${POSTGRES_PORT:-5432}
|
||||||
|
POSTGRES_HOST=${POSTGRES_HOST:-localhost}
|
||||||
|
|
||||||
|
while ! nc -z "$POSTGRES_HOST" "$POSTGRES_PORT"; do
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "PostgreSQL started"
|
||||||
|
|
||||||
|
# Only migrate, collectstatic and compilemessages if we are NOT in development
|
||||||
|
if [ -z "$DEBUG" ]; then
|
||||||
|
python src/manage.py migrate;
|
||||||
|
python src/manage.py collectstatic --no-input;
|
||||||
|
python src/manage.py compilemessages;
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
|
@ -1,8 +0,0 @@
|
||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from . import models
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Membership)
|
|
||||||
class MembershipAdmin(admin.ModelAdmin):
|
|
||||||
pass
|
|
|
@ -1,5 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class MembershipConfig(AppConfig):
|
|
||||||
name = 'membership'
|
|
|
@ -1,101 +0,0 @@
|
||||||
# Generated by Django 2.0.6 on 2018-06-23 19:07
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import djmoney.models.fields
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Membership',
|
|
||||||
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')),
|
|
||||||
('can_vote', models.BooleanField(default=False, help_text='Indicates that the user has a democratic membership of the organization.', verbose_name='can vote')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'membership',
|
|
||||||
'verbose_name_plural': 'memberships',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Organization',
|
|
||||||
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')),
|
|
||||||
('name', models.CharField(max_length=64, verbose_name='name')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'organization',
|
|
||||||
'verbose_name_plural': 'organizations',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Subscription',
|
|
||||||
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')),
|
|
||||||
('active', models.BooleanField(default=False, help_text='Automatically set by payment system.', verbose_name='active')),
|
|
||||||
('starts', models.DateField()),
|
|
||||||
('ends', models.DateField()),
|
|
||||||
('renewed_subscription', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='membership.Subscription', verbose_name='renewed subscription')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'subscription',
|
|
||||||
'verbose_name_plural': 'subscriptions',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='SubscriptionType',
|
|
||||||
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')),
|
|
||||||
('name', models.CharField(max_length=64, verbose_name='name')),
|
|
||||||
('fee_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
|
|
||||||
('fee', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0.0'), max_digits=16)),
|
|
||||||
('fee_vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default='XYZ', editable=False, max_length=3)),
|
|
||||||
('fee_vat', djmoney.models.fields.MoneyField(decimal_places=2, default=Decimal('0'), max_digits=16)),
|
|
||||||
('duration', models.PositiveSmallIntegerField(choices=[(1, 'annual')], default=1, verbose_name='duration')),
|
|
||||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.Organization')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'subscription type',
|
|
||||||
'verbose_name_plural': 'subscription types',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='subscription_type',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='membership.SubscriptionType', verbose_name='subscription type'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='subscription',
|
|
||||||
name='user',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='membership',
|
|
||||||
name='organization',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.Organization'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='membership',
|
|
||||||
name='user',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,144 +0,0 @@
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.db import models
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from djmoney.models.fields import MoneyField
|
|
||||||
|
|
||||||
|
|
||||||
class CreatedModifiedAbstract(models.Model):
|
|
||||||
|
|
||||||
modified = models.DateTimeField(
|
|
||||||
auto_now=True,
|
|
||||||
verbose_name=_("modified"),
|
|
||||||
)
|
|
||||||
created = models.DateTimeField(
|
|
||||||
auto_now_add=True,
|
|
||||||
verbose_name=_("created"),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
|
|
||||||
class Organization(CreatedModifiedAbstract):
|
|
||||||
"""
|
|
||||||
This holds the data of the organization that someone is a member of. It is
|
|
||||||
possible that we'll create more advanced features here.
|
|
||||||
"""
|
|
||||||
name = models.CharField(
|
|
||||||
verbose_name=_("name"),
|
|
||||||
max_length=64,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("organization")
|
|
||||||
verbose_name_plural = _("organizations")
|
|
||||||
|
|
||||||
|
|
||||||
class Membership(CreatedModifiedAbstract):
|
|
||||||
"""
|
|
||||||
A user remains a member of an organization even though the subscription is
|
|
||||||
unpaid or renewed. This just changes the status/permissions etc. of the
|
|
||||||
membership, thus we need to track subscription creation, expiry, renewals
|
|
||||||
etc. and ensure that the membership is modified accordingly.
|
|
||||||
|
|
||||||
This expresses some
|
|
||||||
"""
|
|
||||||
|
|
||||||
organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
|
|
||||||
user = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
|
|
||||||
|
|
||||||
can_vote = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name=_("can vote"),
|
|
||||||
help_text=_(
|
|
||||||
"Indicates that the user has a democratic membership of the "
|
|
||||||
"organization."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
|
|
||||||
return _("{} is a member of {}").format(
|
|
||||||
self.user.get_full_name(),
|
|
||||||
self.organization.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("membership")
|
|
||||||
verbose_name_plural = _("memberships")
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionType(CreatedModifiedAbstract):
|
|
||||||
"""
|
|
||||||
Properties of subscriptions are stored here. Should of course not be edited
|
|
||||||
after subscriptions are created.
|
|
||||||
"""
|
|
||||||
|
|
||||||
organization = models.ForeignKey(Organization, on_delete=models.PROTECT)
|
|
||||||
|
|
||||||
name = models.CharField(
|
|
||||||
verbose_name=_("name"),
|
|
||||||
max_length=64,
|
|
||||||
)
|
|
||||||
|
|
||||||
fee = MoneyField(
|
|
||||||
max_digits=16,
|
|
||||||
decimal_places=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
fee_vat = MoneyField(
|
|
||||||
max_digits=16,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
duration = models.PositiveSmallIntegerField(
|
|
||||||
default=1,
|
|
||||||
choices=[(1, _("annual"))],
|
|
||||||
verbose_name=_("duration"),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("subscription type")
|
|
||||||
verbose_name_plural = _("subscription types")
|
|
||||||
|
|
||||||
|
|
||||||
class Subscription(CreatedModifiedAbstract):
|
|
||||||
"""
|
|
||||||
To not confuse other types of subscriptions, one can be a *subscribed*
|
|
||||||
member of an organization, meaning that they are paying etc.
|
|
||||||
|
|
||||||
A subscription does not track payment, this is done in the accounting app.
|
|
||||||
"""
|
|
||||||
|
|
||||||
subscription_type = models.ForeignKey(
|
|
||||||
SubscriptionType,
|
|
||||||
related_name='memberships',
|
|
||||||
verbose_name=_("subscription type"),
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
)
|
|
||||||
user = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
|
|
||||||
|
|
||||||
active = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name=_("active"),
|
|
||||||
help_text=_("Automatically set by payment system.")
|
|
||||||
)
|
|
||||||
|
|
||||||
starts = models.DateField()
|
|
||||||
ends = models.DateField()
|
|
||||||
|
|
||||||
renewed_subscription = models.ForeignKey(
|
|
||||||
'self',
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name=_("renewed subscription"),
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("subscription")
|
|
||||||
verbose_name_plural = _("subscriptions")
|
|
|
@ -1,9 +0,0 @@
|
||||||
"""Context processors for the membersystem app."""
|
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
|
||||||
|
|
||||||
|
|
||||||
def current_site(request):
|
|
||||||
"""Include the current site in the context."""
|
|
||||||
return {
|
|
||||||
'site': get_current_site(request)
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
import warnings
|
|
||||||
|
|
||||||
from .base import * # noqa
|
|
||||||
|
|
||||||
try:
|
|
||||||
from .local import * # noqa
|
|
||||||
except ImportError:
|
|
||||||
warnings.warn(
|
|
||||||
"No settings.local, using a default SECRET_KEY 'hest'. You should "
|
|
||||||
"write a custom local.py with this setting."
|
|
||||||
)
|
|
||||||
SECRET_KEY = "hest"
|
|
||||||
DEBUG = True
|
|
||||||
pass
|
|
|
@ -1,137 +0,0 @@
|
||||||
"""
|
|
||||||
Django settings for membersystem project.
|
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 2.0.4.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/2.0/topics/settings/
|
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
|
||||||
https://docs.djangoproject.com/en/2.0/ref/settings/
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
|
||||||
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
|
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
|
||||||
DEBUG = False
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = []
|
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
'django.contrib.admin',
|
|
||||||
'django.contrib.auth',
|
|
||||||
'django.contrib.contenttypes',
|
|
||||||
'django.contrib.sessions',
|
|
||||||
'django.contrib.messages',
|
|
||||||
'django.contrib.staticfiles',
|
|
||||||
'django.contrib.sites',
|
|
||||||
|
|
||||||
'users',
|
|
||||||
'accounting',
|
|
||||||
'membership',
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
'django.middleware.security.SecurityMiddleware',
|
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
||||||
'django.middleware.common.CommonMiddleware',
|
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = 'project.urls'
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
||||||
'DIRS': [os.path.join("project", "templates")],
|
|
||||||
'APP_DIRS': True,
|
|
||||||
'OPTIONS': {
|
|
||||||
'context_processors': [
|
|
||||||
'django.template.context_processors.debug',
|
|
||||||
'django.template.context_processors.request',
|
|
||||||
'django.contrib.auth.context_processors.auth',
|
|
||||||
'django.contrib.messages.context_processors.messages',
|
|
||||||
'project.context_processors.current_site',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = (
|
|
||||||
'django.contrib.auth.backends.ModelBackend',
|
|
||||||
'allauth.account.auth_backends.AuthenticationBackend',
|
|
||||||
)
|
|
||||||
|
|
||||||
WSGI_APPLICATION = 'project.wsgi.application'
|
|
||||||
|
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/2.0/ref/settings/#databases
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
|
||||||
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'users.User'
|
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
# https://docs.djangoproject.com/en/2.0/topics/i18n/
|
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
|
||||||
|
|
||||||
USE_I18N = True
|
|
||||||
|
|
||||||
USE_L10N = True
|
|
||||||
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/2.0/howto/static-files/
|
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
|
||||||
|
|
||||||
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')]
|
|
||||||
|
|
||||||
SITE_ID = 1
|
|
||||||
|
|
||||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
|
||||||
|
|
||||||
CURRENCIES = ('DKK',)
|
|
||||||
CURRENCY_CHOICES = [('DKK', 'DKK')]
|
|
|
@ -1,79 +0,0 @@
|
||||||
/* General styles */
|
|
||||||
html
|
|
||||||
{
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family: sans-serif;
|
|
||||||
font-size: 2.5vmin;
|
|
||||||
background: #f8f8f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
body
|
|
||||||
{
|
|
||||||
background: #fff;
|
|
||||||
color: #000;
|
|
||||||
margin: 1em auto;
|
|
||||||
max-width: 50em;
|
|
||||||
padding: 0 1em;
|
|
||||||
box-shadow: 0 0 2.5em rgba(0, 0, 0, 20%);
|
|
||||||
}
|
|
||||||
|
|
||||||
header,
|
|
||||||
footer
|
|
||||||
{
|
|
||||||
background: #eee;
|
|
||||||
padding: .5em;
|
|
||||||
margin: 0 -1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer
|
|
||||||
{
|
|
||||||
margin-top: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
header h1
|
|
||||||
{
|
|
||||||
font-size: 1em;
|
|
||||||
float: left;
|
|
||||||
padding: .5em .5em;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header ul,
|
|
||||||
footer ul
|
|
||||||
{
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
header ul li,
|
|
||||||
footer ul li
|
|
||||||
{
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
header ul li a,
|
|
||||||
footer ul li a
|
|
||||||
{
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0;
|
|
||||||
padding: .5em .5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Forms */
|
|
||||||
label
|
|
||||||
{
|
|
||||||
display: block;
|
|
||||||
padding: .5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
textarea
|
|
||||||
{
|
|
||||||
font-size: inherit;
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
{% load static %}
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>{% block head_title %}{% endblock %} – {{ site.name }}</title>
|
|
||||||
{% block extra_head %}{% endblock %}
|
|
||||||
<link rel="stylesheet" href="{% static '/css/membersystem.css' %}" type="text/css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>
|
|
||||||
<a href="/">{{ site.name }}</a>
|
|
||||||
</h1>
|
|
||||||
<ul>
|
|
||||||
{% if user.is_authenticated %}
|
|
||||||
<li><a href="{% url 'users:email' %}">Change e-mail</a></li>
|
|
||||||
<li><a href="{% url 'users:logout' %}">Sign out</a></li>
|
|
||||||
{% else %}
|
|
||||||
<li><a href="{% url 'users:login' %}">Sign in</a></li>
|
|
||||||
<li><a href="{% url 'users:signup' %}">Sign up</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</header>
|
|
||||||
{% block body %}
|
|
||||||
{% if messages %}
|
|
||||||
<ul id="messages">
|
|
||||||
{% for message in messages %}
|
|
||||||
<li>{{message}}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% endblock %}
|
|
||||||
{% endblock %}
|
|
||||||
{% block extra_body %}
|
|
||||||
{% endblock %}
|
|
||||||
<footer>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://data.coop">data.coop</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://git.data.coop/data.coop/membersystem">source code</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,12 +0,0 @@
|
||||||
"""URLs for the membersystem"""
|
|
||||||
from django.contrib import admin
|
|
||||||
from django.urls import include
|
|
||||||
from django.urls import path
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('', views.index),
|
|
||||||
path("users/", include("users.urls")),
|
|
||||||
path('admin/', admin.site.urls),
|
|
||||||
]
|
|
|
@ -1,5 +0,0 @@
|
||||||
from django.shortcuts import render_to_response
|
|
||||||
|
|
||||||
|
|
||||||
def index(request):
|
|
||||||
return render_to_response("index.html")
|
|
154
pyproject.toml
Normal file
154
pyproject.toml
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "membersystem"
|
||||||
|
description = ''
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
keywords = []
|
||||||
|
authors = [
|
||||||
|
{ name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" },
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"Django~=5.0",
|
||||||
|
"django-money~=3.5",
|
||||||
|
"django-allauth~=0.63",
|
||||||
|
"psycopg[binary]~=3.2",
|
||||||
|
"environs[django]>=11,<12",
|
||||||
|
"uvicorn~=0.30",
|
||||||
|
"whitenoise~=6.7",
|
||||||
|
"django-zen-queries~=2.1",
|
||||||
|
"django-registries==0.0.3",
|
||||||
|
"django-view-decorator==0.0.4",
|
||||||
|
"django-oauth-toolkit~=2.4",
|
||||||
|
]
|
||||||
|
version = "0.0.1"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src"]
|
||||||
|
|
||||||
|
[tool.hatch.env]
|
||||||
|
requires = ["hatch-pip-compile"]
|
||||||
|
|
||||||
|
[tool.hatch.envs.default]
|
||||||
|
type = "pip-compile"
|
||||||
|
|
||||||
|
[tool.hatch.envs.dev]
|
||||||
|
type = "pip-compile"
|
||||||
|
dependencies = [
|
||||||
|
"coverage[toml]==7.3.0",
|
||||||
|
"pytest==7.2.2",
|
||||||
|
"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",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[tool.hatch.envs.tests.matrix]]
|
||||||
|
python = ["3.12"]
|
||||||
|
django = ["5.0"]
|
||||||
|
|
||||||
|
[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.dev.scripts]
|
||||||
|
cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=src --cov=tests --cov=append {args}"
|
||||||
|
no-cov = "cov --no-cov {args}"
|
||||||
|
typecheck = "mypy --config-file=pyproject.toml ."
|
||||||
|
requirements = "pip-compile --output-file requirements/base.txt pyproject.toml"
|
||||||
|
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"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
DJANGO_SETTINGS_MODULE="tests.settings"
|
||||||
|
addopts = "--reuse-db"
|
||||||
|
norecursedirs = "build dist docs .eggs/* *.egg-info htmlcov .git"
|
||||||
|
python_files = "test*.py"
|
||||||
|
testpaths = "tests"
|
||||||
|
pythonpath = ". tests"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
branch = true
|
||||||
|
parallel = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"no cov",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
mypy_path = "src/"
|
||||||
|
exclude = [
|
||||||
|
"venv/",
|
||||||
|
"dist/",
|
||||||
|
"docs/",
|
||||||
|
]
|
||||||
|
namespace_packages = false
|
||||||
|
show_error_codes = true
|
||||||
|
strict = true
|
||||||
|
warn_unreachable = true
|
||||||
|
follow_imports = "normal"
|
||||||
|
#plugins = ["mypy_django_plugin.main"]
|
||||||
|
|
||||||
|
[tool.django-stubs]
|
||||||
|
#django_settings_module = "tests.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)
|
||||||
|
"D105", # Missing docstring in magic method
|
||||||
|
"D106", # Missing docstring in public nested class
|
||||||
|
"FIX", # TODO, FIXME, XXX
|
||||||
|
"TD", # TODO, FIXME, XXX
|
||||||
|
"ANN002", # Missing type annotation for `*args`
|
||||||
|
"ANN003", # Missing type annotation for `**kwargs`
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
force-single-line = true
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests.py" = [
|
||||||
|
"S101", # Use of assert
|
||||||
|
"SLF001", # Private member access
|
||||||
|
"D100", # Docstrings
|
||||||
|
"D103", # Docstrings
|
||||||
|
]
|
105
requirements.txt
105
requirements.txt
|
@ -1,2 +1,103 @@
|
||||||
Django>=2.2,<2.3
|
#
|
||||||
django-money==0.15
|
# This file is autogenerated by hatch-pip-compile with Python 3.12
|
||||||
|
#
|
||||||
|
# - django-allauth==0.63.6
|
||||||
|
# - django-money==3.5.2
|
||||||
|
# - django-oauth-toolkit==2.4.0
|
||||||
|
# - django-registries==0.0.3
|
||||||
|
# - django-view-decorator==0.0.4
|
||||||
|
# - django-zen-queries==2.1.0
|
||||||
|
# - django==5.0.7
|
||||||
|
# - environs[django]==11.0.0
|
||||||
|
# - psycopg[binary]==3.2.1
|
||||||
|
# - uvicorn==0.30.1
|
||||||
|
# - whitenoise==6.7.0
|
||||||
|
#
|
||||||
|
|
||||||
|
asgiref==3.8.1
|
||||||
|
# via django
|
||||||
|
babel==2.15.0
|
||||||
|
# via py-moneyed
|
||||||
|
certifi==2024.7.4
|
||||||
|
# via requests
|
||||||
|
cffi==1.16.0
|
||||||
|
# via cryptography
|
||||||
|
charset-normalizer==3.3.2
|
||||||
|
# via requests
|
||||||
|
click==8.1.7
|
||||||
|
# via uvicorn
|
||||||
|
cryptography==42.0.8
|
||||||
|
# via jwcrypto
|
||||||
|
dj-database-url==2.2.0
|
||||||
|
# via environs
|
||||||
|
dj-email-url==1.0.6
|
||||||
|
# via environs
|
||||||
|
django==5.0.7
|
||||||
|
# via
|
||||||
|
# hatch.envs.default
|
||||||
|
# dj-database-url
|
||||||
|
# django-allauth
|
||||||
|
# django-money
|
||||||
|
# django-oauth-toolkit
|
||||||
|
# django-registries
|
||||||
|
# django-view-decorator
|
||||||
|
# django-zen-queries
|
||||||
|
django-allauth==0.63.6
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-cache-url==3.4.5
|
||||||
|
# via environs
|
||||||
|
django-money==3.5.2
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-oauth-toolkit==2.4.0
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-registries==0.0.3
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-view-decorator==0.0.4
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-zen-queries==2.1.0
|
||||||
|
# via hatch.envs.default
|
||||||
|
environs==11.0.0
|
||||||
|
# via hatch.envs.default
|
||||||
|
h11==0.14.0
|
||||||
|
# via uvicorn
|
||||||
|
idna==3.7
|
||||||
|
# via requests
|
||||||
|
jwcrypto==1.5.6
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
marshmallow==3.21.3
|
||||||
|
# via environs
|
||||||
|
oauthlib==3.2.2
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
packaging==24.1
|
||||||
|
# via marshmallow
|
||||||
|
psycopg==3.2.1
|
||||||
|
# via hatch.envs.default
|
||||||
|
psycopg-binary==3.2.1
|
||||||
|
# via psycopg
|
||||||
|
py-moneyed==3.0
|
||||||
|
# via django-money
|
||||||
|
pycparser==2.22
|
||||||
|
# via cffi
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
# via environs
|
||||||
|
pytz==2024.1
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
requests==2.32.3
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
sqlparse==0.5.1
|
||||||
|
# via django
|
||||||
|
typing-extensions==4.12.2
|
||||||
|
# via
|
||||||
|
# dj-database-url
|
||||||
|
# jwcrypto
|
||||||
|
# psycopg
|
||||||
|
# py-moneyed
|
||||||
|
urllib3==2.2.2
|
||||||
|
# via requests
|
||||||
|
uvicorn==0.30.1
|
||||||
|
# via hatch.envs.default
|
||||||
|
whitenoise==6.7.0
|
||||||
|
# via hatch.envs.default
|
||||||
|
|
||||||
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
|
# setuptools
|
||||||
|
|
97
requirements/base.txt
Normal file
97
requirements/base.txt
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.12
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --output-file=requirements/base.txt pyproject.toml
|
||||||
|
#
|
||||||
|
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.0.7
|
||||||
|
# via
|
||||||
|
# dj-database-url
|
||||||
|
# django-allauth
|
||||||
|
# django-money
|
||||||
|
# django-oauth-toolkit
|
||||||
|
# django-registries
|
||||||
|
# django-view-decorator
|
||||||
|
# django-zen-queries
|
||||||
|
# membersystem (pyproject.toml)
|
||||||
|
django-allauth==0.63.6
|
||||||
|
# via membersystem (pyproject.toml)
|
||||||
|
django-cache-url==3.4.5
|
||||||
|
# via environs
|
||||||
|
django-money==3.5.2
|
||||||
|
# via membersystem (pyproject.toml)
|
||||||
|
django-oauth-toolkit==2.4.0
|
||||||
|
# via membersystem (pyproject.toml)
|
||||||
|
django-registries==0.0.3
|
||||||
|
# via membersystem (pyproject.toml)
|
||||||
|
django-view-decorator==0.0.4
|
||||||
|
# via membersystem (pyproject.toml)
|
||||||
|
django-zen-queries==2.1.0
|
||||||
|
# via membersystem (pyproject.toml)
|
||||||
|
environs[django]==11.0.0
|
||||||
|
# via
|
||||||
|
# environs
|
||||||
|
# membersystem (pyproject.toml)
|
||||||
|
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[binary]==3.2.1
|
||||||
|
# via
|
||||||
|
# membersystem (pyproject.toml)
|
||||||
|
# psycopg
|
||||||
|
psycopg-binary==3.2.1
|
||||||
|
# via psycopg
|
||||||
|
py-moneyed==3.0
|
||||||
|
# via django-money
|
||||||
|
pycparser==2.22
|
||||||
|
# via cffi
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
# via environs
|
||||||
|
pytz==2024.1
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
requests==2.32.3
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
sqlparse==0.5.1
|
||||||
|
# via django
|
||||||
|
typing-extensions==4.12.2
|
||||||
|
# via
|
||||||
|
# dj-database-url
|
||||||
|
# jwcrypto
|
||||||
|
# psycopg
|
||||||
|
# py-moneyed
|
||||||
|
urllib3==2.2.2
|
||||||
|
# via requests
|
||||||
|
uvicorn==0.30.3
|
||||||
|
# via membersystem (pyproject.toml)
|
||||||
|
whitenoise==6.7.0
|
||||||
|
# via membersystem (pyproject.toml)
|
||||||
|
|
||||||
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
|
# setuptools
|
183
requirements/requirements-dev.txt
Normal file
183
requirements/requirements-dev.txt
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
#
|
||||||
|
# This file is autogenerated by hatch-pip-compile with Python 3.12
|
||||||
|
#
|
||||||
|
# - coverage[toml]==7.3.0
|
||||||
|
# - pytest==7.2.2
|
||||||
|
# - pytest-cov
|
||||||
|
# - pytest-django==4.5.2
|
||||||
|
# - mypy==1.1.1
|
||||||
|
# - django-stubs==1.16.0
|
||||||
|
# - pip-tools==7.3.0
|
||||||
|
# - django-debug-toolbar==4.2.0
|
||||||
|
# - django-browser-reload==1.7.0
|
||||||
|
# - model-bakery==1.17.0
|
||||||
|
# - django-allauth~=0.63
|
||||||
|
# - django-money~=3.5
|
||||||
|
# - django-oauth-toolkit~=2.4
|
||||||
|
# - django-registries==0.0.3
|
||||||
|
# - django-view-decorator==0.0.4
|
||||||
|
# - django-zen-queries~=2.1
|
||||||
|
# - django~=5.0
|
||||||
|
# - environs[django]<12,>=11
|
||||||
|
# - psycopg[binary]~=3.2
|
||||||
|
# - 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
|
||||||
|
# coverage
|
||||||
|
# pytest-cov
|
||||||
|
cryptography==42.0.8
|
||||||
|
# via jwcrypto
|
||||||
|
dj-database-url==2.2.0
|
||||||
|
# via environs
|
||||||
|
dj-email-url==1.0.6
|
||||||
|
# via environs
|
||||||
|
django==5.0.7
|
||||||
|
# via
|
||||||
|
# hatch.envs.dev
|
||||||
|
# dj-database-url
|
||||||
|
# django-allauth
|
||||||
|
# django-browser-reload
|
||||||
|
# django-debug-toolbar
|
||||||
|
# django-money
|
||||||
|
# django-oauth-toolkit
|
||||||
|
# django-registries
|
||||||
|
# django-stubs
|
||||||
|
# django-stubs-ext
|
||||||
|
# django-view-decorator
|
||||||
|
# django-zen-queries
|
||||||
|
# model-bakery
|
||||||
|
django-allauth==0.63.6
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-browser-reload==1.7.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-cache-url==3.4.5
|
||||||
|
# via environs
|
||||||
|
django-debug-toolbar==4.2.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-money==3.5.2
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-oauth-toolkit==2.4.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-registries==0.0.3
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-stubs==1.16.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-stubs-ext==5.0.2
|
||||||
|
# via django-stubs
|
||||||
|
django-view-decorator==0.0.4
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-zen-queries==2.1.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
environs==11.0.0
|
||||||
|
# via
|
||||||
|
# hatch.envs.dev
|
||||||
|
# environs
|
||||||
|
h11==0.14.0
|
||||||
|
# via uvicorn
|
||||||
|
idna==3.7
|
||||||
|
# via requests
|
||||||
|
iniconfig==2.0.0
|
||||||
|
# via pytest
|
||||||
|
jwcrypto==1.5.6
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
marshmallow==3.21.3
|
||||||
|
# via environs
|
||||||
|
model-bakery==1.17.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
mypy==1.1.1
|
||||||
|
# via
|
||||||
|
# hatch.envs.dev
|
||||||
|
# django-stubs
|
||||||
|
mypy-extensions==1.0.0
|
||||||
|
# via mypy
|
||||||
|
oauthlib==3.2.2
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
packaging==24.1
|
||||||
|
# via
|
||||||
|
# build
|
||||||
|
# marshmallow
|
||||||
|
# pytest
|
||||||
|
pip-tools==7.3.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
pluggy==1.5.0
|
||||||
|
# via pytest
|
||||||
|
psycopg==3.2.1
|
||||||
|
# via
|
||||||
|
# hatch.envs.dev
|
||||||
|
# psycopg
|
||||||
|
psycopg-binary==3.2.1
|
||||||
|
# via psycopg
|
||||||
|
py-moneyed==3.0
|
||||||
|
# via django-money
|
||||||
|
pycparser==2.22
|
||||||
|
# via cffi
|
||||||
|
pyproject-hooks==1.1.0
|
||||||
|
# via build
|
||||||
|
pytest==7.2.2
|
||||||
|
# via
|
||||||
|
# hatch.envs.dev
|
||||||
|
# pytest-cov
|
||||||
|
# pytest-django
|
||||||
|
pytest-cov==5.0.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
pytest-django==4.5.2
|
||||||
|
# via hatch.envs.dev
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
# via environs
|
||||||
|
pytz==2024.1
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
requests==2.32.3
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
sqlparse==0.5.1
|
||||||
|
# via
|
||||||
|
# django
|
||||||
|
# django-debug-toolbar
|
||||||
|
tomli==2.0.1
|
||||||
|
# via django-stubs
|
||||||
|
types-pytz==2024.1.0.20240417
|
||||||
|
# via django-stubs
|
||||||
|
types-pyyaml==6.0.12.20240311
|
||||||
|
# via django-stubs
|
||||||
|
typing-extensions==4.12.2
|
||||||
|
# via
|
||||||
|
# dj-database-url
|
||||||
|
# django-stubs
|
||||||
|
# django-stubs-ext
|
||||||
|
# jwcrypto
|
||||||
|
# mypy
|
||||||
|
# psycopg
|
||||||
|
# py-moneyed
|
||||||
|
urllib3==2.2.2
|
||||||
|
# via requests
|
||||||
|
uvicorn==0.30.1
|
||||||
|
# via hatch.envs.dev
|
||||||
|
wheel==0.43.0
|
||||||
|
# via pip-tools
|
||||||
|
whitenoise==6.7.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
|
||||||
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
|
# pip
|
||||||
|
# setuptools
|
|
@ -1,3 +0,0 @@
|
||||||
pytest
|
|
||||||
pytest-django
|
|
||||||
pre-commit
|
|
13
setup.cfg
13
setup.cfg
|
@ -1,13 +0,0 @@
|
||||||
[flake8]
|
|
||||||
ignore = E226,E302,E41
|
|
||||||
max-line-length = 160
|
|
||||||
max-complexity = 10
|
|
||||||
exclude = */migrations/*
|
|
||||||
|
|
||||||
[isort]
|
|
||||||
atomic = true
|
|
||||||
multi_line_output = 5
|
|
||||||
line_length = 160
|
|
||||||
indent = ' '
|
|
||||||
combine_as_imports = true
|
|
||||||
skip = wsgi.py,.eggs,setup.py
|
|
1
src/accounting/__init__.py
Normal file
1
src/accounting/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Accounting app."""
|
36
src/accounting/admin.py
Normal file
36
src/accounting/admin.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
"""Admin for the accounting app."""
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from .models import Order
|
||||||
|
from .models import Payment
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Order)
|
||||||
|
class OrderAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for the Order model."""
|
||||||
|
|
||||||
|
list_display = ("who", "description", "created", "is_paid")
|
||||||
|
|
||||||
|
@admin.display(description=_("Customer"))
|
||||||
|
def who(self, instance: Order) -> str:
|
||||||
|
"""Return the full name of the user who made the order."""
|
||||||
|
return instance.user.get_full_name()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Payment)
|
||||||
|
class PaymentAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for the Payment model."""
|
||||||
|
|
||||||
|
list_display = ("who", "description", "order_id", "created")
|
||||||
|
|
||||||
|
@admin.display(description=_("Customer"))
|
||||||
|
def who(self, instance: Payment) -> str:
|
||||||
|
"""Return the full name of the user who made the payment."""
|
||||||
|
return instance.order.user.get_full_name()
|
||||||
|
|
||||||
|
@admin.display(description=_("Order ID"))
|
||||||
|
def order_id(self, instance: Payment) -> int:
|
||||||
|
"""Return the ID of the order."""
|
||||||
|
return instance.order.id
|
9
src/accounting/apps.py
Normal file
9
src/accounting/apps.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
"""Accounting app configuration."""
|
||||||
|
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingConfig(AppConfig):
|
||||||
|
"""Accounting app config."""
|
||||||
|
|
||||||
|
name = "accounting"
|
233
src/accounting/migrations/0001_initial.py
Normal file
233
src/accounting/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,233 @@
|
||||||
|
# Generated by Django 3.1.7 on 2021-02-27 20:06
|
||||||
|
import django.db.models.deletion
|
||||||
|
import djmoney.models.fields
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Account",
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"owner",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Order",
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.CharField(max_length=1024, verbose_name="description"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"price_currency",
|
||||||
|
djmoney.models.fields.CurrencyField(
|
||||||
|
choices=[("DKK", "DKK")],
|
||||||
|
default="XYZ",
|
||||||
|
editable=False,
|
||||||
|
max_length=3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"price",
|
||||||
|
djmoney.models.fields.MoneyField(
|
||||||
|
decimal_places=2,
|
||||||
|
max_digits=16,
|
||||||
|
verbose_name="price (excl. VAT)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"vat_currency",
|
||||||
|
djmoney.models.fields.CurrencyField(
|
||||||
|
choices=[("DKK", "DKK")],
|
||||||
|
default="XYZ",
|
||||||
|
editable=False,
|
||||||
|
max_length=3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"vat",
|
||||||
|
djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16, verbose_name="VAT"),
|
||||||
|
),
|
||||||
|
("is_paid", models.BooleanField(default=False, verbose_name="is paid")),
|
||||||
|
(
|
||||||
|
"account",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="accounting.account",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Order",
|
||||||
|
"verbose_name_plural": "Orders",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Transaction",
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"amount_currency",
|
||||||
|
djmoney.models.fields.CurrencyField(
|
||||||
|
choices=[("DKK", "DKK")],
|
||||||
|
default="XYZ",
|
||||||
|
editable=False,
|
||||||
|
max_length=3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"amount",
|
||||||
|
djmoney.models.fields.MoneyField(
|
||||||
|
decimal_places=2,
|
||||||
|
help_text="This will include VAT",
|
||||||
|
max_digits=16,
|
||||||
|
verbose_name="amount",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.CharField(max_length=1024, verbose_name="description"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"account",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="transactions",
|
||||||
|
to="accounting.account",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Payment",
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"amount_currency",
|
||||||
|
djmoney.models.fields.CurrencyField(
|
||||||
|
choices=[("DKK", "DKK")],
|
||||||
|
default="XYZ",
|
||||||
|
editable=False,
|
||||||
|
max_length=3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"amount",
|
||||||
|
djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.CharField(max_length=1024, verbose_name="description"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"stripe_charge_id",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"order",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="accounting.order",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "payment",
|
||||||
|
"verbose_name_plural": "payments",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -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,
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,44 +1,48 @@
|
||||||
|
"""Models for the accounting app."""
|
||||||
|
|
||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.aggregates import Sum
|
from django.db.models.aggregates import Sum
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.utils.translation import pgettext_lazy
|
from django.utils.translation import pgettext_lazy
|
||||||
from djmoney.models.fields import MoneyField
|
from djmoney.models.fields import MoneyField
|
||||||
|
from djmoney.money import Money
|
||||||
|
|
||||||
|
|
||||||
class CreatedModifiedAbstract(models.Model):
|
class CreatedModifiedAbstract(models.Model):
|
||||||
|
"""Abstract model to track creation and modification of objects."""
|
||||||
|
|
||||||
modified = models.DateTimeField(
|
modified = models.DateTimeField(auto_now=True, verbose_name=_("modified"))
|
||||||
auto_now=True,
|
created = models.DateTimeField(auto_now_add=True, verbose_name=_("created"))
|
||||||
verbose_name=_("modified"),
|
|
||||||
)
|
|
||||||
created = models.DateTimeField(
|
|
||||||
auto_now_add=True,
|
|
||||||
verbose_name=_("created"),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
class Account(CreatedModifiedAbstract):
|
class Account(CreatedModifiedAbstract):
|
||||||
"""
|
"""An account for a user.
|
||||||
|
|
||||||
This is the model where we can give access to several users, such that they
|
This is the model where we can give access to several users, such that they
|
||||||
can decide which account to use to pay for something.
|
can decide which account to use to pay for something.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
owner = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
|
owner = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Account of {self.owner.get_full_name()}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def balance(self):
|
def balance(self) -> Money:
|
||||||
return self.transactions.all().aggregate(Sum('amount')).get('amount', 0)
|
"""Return the balance of the account."""
|
||||||
|
return self.transactions.all().aggregate(Sum("amount")).get("amount", 0)
|
||||||
|
|
||||||
|
|
||||||
class Transaction(CreatedModifiedAbstract):
|
class Transaction(CreatedModifiedAbstract):
|
||||||
"""
|
"""A transaction.
|
||||||
|
|
||||||
Tracks in and outgoing events of an account. When an order is received, an
|
Tracks in and outgoing events of an account. When an order is received, an
|
||||||
amount is subtracted, when a payment is received, an amount is added.
|
amount is subtracted, when a payment is received, an amount is added.
|
||||||
"""
|
"""
|
||||||
|
@ -52,106 +56,92 @@ class Transaction(CreatedModifiedAbstract):
|
||||||
verbose_name=_("amount"),
|
verbose_name=_("amount"),
|
||||||
max_digits=16,
|
max_digits=16,
|
||||||
decimal_places=2,
|
decimal_places=2,
|
||||||
help_text=_("This will include VAT")
|
help_text=_("This will include VAT"),
|
||||||
)
|
|
||||||
description = models.CharField(
|
|
||||||
max_length=1024,
|
|
||||||
verbose_name=_("description")
|
|
||||||
)
|
)
|
||||||
|
description = models.CharField(max_length=1024, verbose_name=_("description"))
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Transaction of {self.amount} for {self.account}"
|
||||||
|
|
||||||
|
|
||||||
class Order(CreatedModifiedAbstract):
|
class Order(CreatedModifiedAbstract):
|
||||||
"""
|
"""An order.
|
||||||
|
|
||||||
Scoped out: Contents of invoices will have to be tracked either here or in
|
Scoped out: Contents of invoices will have to be tracked either here or in
|
||||||
a separate Invoice model. This is undecided because we are not generating
|
a separate Invoice model. This is undecided because we are not generating
|
||||||
invoices at the moment.
|
invoices at the moment.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
|
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
||||||
account = models.ForeignKey(Account, on_delete=models.PROTECT)
|
account = models.ForeignKey(Account, on_delete=models.PROTECT)
|
||||||
is_paid = models.BooleanField(default=False)
|
|
||||||
|
|
||||||
description = models.CharField(
|
description = models.CharField(max_length=1024, verbose_name=_("description"))
|
||||||
max_length=1024,
|
|
||||||
verbose_name=_("description")
|
|
||||||
)
|
|
||||||
|
|
||||||
price = MoneyField(
|
price = MoneyField(
|
||||||
verbose_name=_("price (excl. VAT)"),
|
verbose_name=_("price (excl. VAT)"),
|
||||||
max_digits=16,
|
max_digits=16,
|
||||||
decimal_places=2,
|
decimal_places=2,
|
||||||
)
|
)
|
||||||
vat = MoneyField(
|
vat = MoneyField(verbose_name=_("VAT"), max_digits=16, decimal_places=2)
|
||||||
verbose_name=_("VAT"),
|
|
||||||
max_digits=16,
|
|
||||||
decimal_places=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
is_paid = models.BooleanField(
|
is_paid = models.BooleanField(default=False, verbose_name=_("is paid"))
|
||||||
default=False,
|
|
||||||
verbose_name=_("is paid"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def total(self):
|
|
||||||
return self.price + self.vat
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_id(self):
|
|
||||||
return str(self.id).zfill(6)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def payment_token(self):
|
|
||||||
pk = str(self.pk).encode("utf-8")
|
|
||||||
x = md5()
|
|
||||||
x.update(pk)
|
|
||||||
extra_hash = (settings.SECRET_KEY + 'blah').encode('utf-8')
|
|
||||||
x.update(extra_hash)
|
|
||||||
return x.hexdigest()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = pgettext_lazy("accounting term", "Order")
|
verbose_name = pgettext_lazy("accounting term", "Order")
|
||||||
verbose_name_plural = pgettext_lazy("accounting term", "Orders")
|
verbose_name_plural = pgettext_lazy("accounting term", "Orders")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return "Order ID {id}".format(id=self.display_id)
|
return f"Order ID {self.display_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> Money:
|
||||||
|
"""Return the total price of the order."""
|
||||||
|
return self.price + self.vat
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_id(self) -> str:
|
||||||
|
"""Return an id for the order."""
|
||||||
|
return str(self.id).zfill(6)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payment_token(self) -> str:
|
||||||
|
"""Return a token for the payment."""
|
||||||
|
pk = str(self.pk).encode("utf-8")
|
||||||
|
x = md5() # noqa: S324
|
||||||
|
x.update(pk)
|
||||||
|
extra_hash = (settings.SECRET_KEY + "blah").encode("utf-8")
|
||||||
|
x.update(extra_hash)
|
||||||
|
return x.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
class Payment(CreatedModifiedAbstract):
|
class Payment(CreatedModifiedAbstract):
|
||||||
|
"""A payment is a transaction that is made to pay for an order."""
|
||||||
|
|
||||||
amount = MoneyField(
|
amount = MoneyField(max_digits=16, decimal_places=2)
|
||||||
max_digits=16,
|
|
||||||
decimal_places=2,
|
|
||||||
)
|
|
||||||
order = models.ForeignKey(Order, on_delete=models.PROTECT)
|
order = models.ForeignKey(Order, on_delete=models.PROTECT)
|
||||||
|
|
||||||
description = models.CharField(
|
description = models.CharField(max_length=1024, verbose_name=_("description"))
|
||||||
max_length=1024,
|
|
||||||
verbose_name=_("description")
|
|
||||||
)
|
|
||||||
|
|
||||||
stripe_charge_id = models.CharField(
|
stripe_charge_id = models.CharField(max_length=255, blank=True)
|
||||||
max_length=255,
|
|
||||||
null=True,
|
class Meta:
|
||||||
blank=True,
|
verbose_name = _("payment")
|
||||||
)
|
verbose_name_plural = _("payments")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Payment ID {self.display_id}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_id(self):
|
def display_id(self) -> str:
|
||||||
|
"""Return an id for the payment."""
|
||||||
return str(self.id).zfill(6)
|
return str(self.id).zfill(6)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_order(cls, order):
|
def from_order(cls, order: Order) -> Self:
|
||||||
|
"""Create a payment from an order."""
|
||||||
return cls.objects.create(
|
return cls.objects.create(
|
||||||
order=order,
|
order=order,
|
||||||
user=order.user,
|
user=order.user,
|
||||||
amount=order.total,
|
amount=order.total,
|
||||||
description=order.description,
|
description=order.description,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "Payment ID {id}".format(id=self.display_id)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("payment")
|
|
||||||
verbose_name_plural = _("payments")
|
|
|
@ -7,10 +7,9 @@ from . import models
|
||||||
# def test():
|
# def test():
|
||||||
# do stuff
|
# 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")
|
user = User.objects.create_user("test", "lala@adas.com", "1234")
|
||||||
account = models.Account.objects.create(
|
account = models.Account.objects.create(owner=user)
|
||||||
owner=user
|
|
||||||
)
|
|
||||||
assert account.balance == 0
|
assert account.balance == 0
|
|
@ -6,10 +6,10 @@ if __name__ == "__main__":
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"Couldn't import Django. Are you sure it's installed and "
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
"available on your PYTHONPATH environment variable? Did you "
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
"forget to activate a virtual environment?"
|
"forget to activate a virtual environment?",
|
||||||
)
|
)
|
||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
|
@ -1,8 +1,5 @@
|
||||||
"""
|
"""Membership application.
|
||||||
Membership application
|
|
||||||
======================
|
|
||||||
|
|
||||||
This application's domain relate to organizational structures and
|
This application's domain relate to organizational structures and
|
||||||
implementation of statutes, policies etc.
|
implementation of statutes, policies etc.
|
||||||
|
|
||||||
"""
|
"""
|
22
src/membership/admin.py
Normal file
22
src/membership/admin.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
"""Admin configuration for membership app."""
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Membership
|
||||||
|
from .models import MembershipType
|
||||||
|
from .models import SubscriptionPeriod
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Membership)
|
||||||
|
class MembershipAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for Membership model."""
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MembershipType)
|
||||||
|
class MembershipTypeAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for MembershipType model."""
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SubscriptionPeriod)
|
||||||
|
class SubscriptionPeriodAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for SubscriptionPeriod model."""
|
16
src/membership/apps.py
Normal file
16
src/membership/apps.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
"""Membership app configuration."""
|
||||||
|
|
||||||
|
from django.apps import AppConfig
|
||||||
|
from django.db.models.signals import post_migrate
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipConfig(AppConfig):
|
||||||
|
"""Membership app config."""
|
||||||
|
|
||||||
|
name = "membership"
|
||||||
|
|
||||||
|
def ready(self) -> None:
|
||||||
|
"""Ready method."""
|
||||||
|
from .permissions import persist_permissions
|
||||||
|
|
||||||
|
post_migrate.connect(persist_permissions, sender=self)
|
92
src/membership/migrations/0001_initial.py
Normal file
92
src/membership/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
# Generated by Django 3.1.7 on 2021-02-28 21:09
|
||||||
|
import django.contrib.postgres.fields.ranges
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MembershipType",
|
||||||
|
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=64, verbose_name="navn")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "membership type",
|
||||||
|
"verbose_name_plural": "membership types",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Membership",
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"period",
|
||||||
|
django.contrib.postgres.fields.ranges.DateTimeRangeField(
|
||||||
|
help_text="The duration this subscription is for. "
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"membership_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="memberships",
|
||||||
|
to="membership.membershiptype",
|
||||||
|
verbose_name="subscription type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "membership",
|
||||||
|
"verbose_name_plural": "memberships",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Generated by Django 4.1 on 2023-01-02 21:05
|
||||||
|
|
||||||
|
import django.contrib.postgres.constraints
|
||||||
|
import django.contrib.postgres.fields.ranges
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("membership", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SubscriptionPeriod",
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"period",
|
||||||
|
django.contrib.postgres.fields.ranges.DateRangeField(verbose_name="period"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="membership",
|
||||||
|
name="period",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="membership",
|
||||||
|
name="created",
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name="created"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="membershiptype",
|
||||||
|
name="created",
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name="created"),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="subscriptionperiod",
|
||||||
|
constraint=django.contrib.postgres.constraints.ExclusionConstraint(
|
||||||
|
expressions=[("period", "&&")], name="exclude_overlapping_periods"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
23
src/membership/migrations/0003_membership_period.py
Normal file
23
src/membership/migrations/0003_membership_period.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 4.1 on 2023-01-02 21:05
|
||||||
|
|
||||||
|
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"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="membership",
|
||||||
|
name="period",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="membership.subscriptionperiod",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
22
src/membership/migrations/0004_alter_membership_period.py
Normal file
22
src/membership/migrations/0004_alter_membership_period.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 4.1 on 2023-01-02 21:06
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("membership", "0003_membership_period"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="membership",
|
||||||
|
name="period",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to="membership.subscriptionperiod",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
23
src/membership/migrations/0005_member.py
Normal file
23
src/membership/migrations/0005_member.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 4.1.5 on 2023-09-16 14:09
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
("membership", "0004_alter_membership_period"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Member",
|
||||||
|
fields=[],
|
||||||
|
options={
|
||||||
|
"proxy": True,
|
||||||
|
"indexes": [],
|
||||||
|
"constraints": [],
|
||||||
|
},
|
||||||
|
bases=("auth.user",),
|
||||||
|
),
|
||||||
|
]
|
141
src/membership/models.py
Normal file
141
src/membership/models.py
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
"""Models for the membership app."""
|
||||||
|
|
||||||
|
from typing import ClassVar
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.contrib.postgres.constraints import ExclusionConstraint
|
||||||
|
from django.contrib.postgres.fields import DateRangeField
|
||||||
|
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 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):
|
||||||
|
"""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 NoSubscriptionPeriodFoundError
|
||||||
|
|
||||||
|
return self.annotate(
|
||||||
|
active_membership=models.Exists(
|
||||||
|
Membership.objects.filter(
|
||||||
|
user=models.OuterRef("pk"),
|
||||||
|
period=current_subscription_period.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = QuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
proxy = True
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionPeriod(CreatedModifiedAbstract):
|
||||||
|
"""A subscription period.
|
||||||
|
|
||||||
|
Denotes a period for which members should pay their membership fee for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
period = DateRangeField(verbose_name=_("period"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints: ClassVar = [
|
||||||
|
ExclusionConstraint(
|
||||||
|
name="exclude_overlapping_periods",
|
||||||
|
expressions=[
|
||||||
|
("period", RangeOperators.OVERLAPS),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""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) -> Self:
|
||||||
|
"""Filter memberships for the current period."""
|
||||||
|
return self.filter(period__period__contains=timezone.now())
|
||||||
|
|
||||||
|
def current(self) -> "Membership | None":
|
||||||
|
"""Get the current membership."""
|
||||||
|
try:
|
||||||
|
return self._current().get()
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def previous(self) -> list["Membership"]:
|
||||||
|
"""Get previous memberships."""
|
||||||
|
# A naïve way to get previous by just excluding the current. This
|
||||||
|
# means that there must be some protection against "future"
|
||||||
|
# memberships.
|
||||||
|
return list(self.all().difference(self._current()))
|
||||||
|
|
||||||
|
objects = QuerySet.as_manager()
|
||||||
|
|
||||||
|
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
membership_type = models.ForeignKey(
|
||||||
|
"membership.MembershipType",
|
||||||
|
related_name="memberships",
|
||||||
|
verbose_name=_("subscription type"),
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
period = models.ForeignKey(
|
||||||
|
"membership.SubscriptionPeriod",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("membership")
|
||||||
|
verbose_name_plural = _("memberships")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.user} - {self.period}"
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipType(CreatedModifiedAbstract):
|
||||||
|
"""A membership type.
|
||||||
|
|
||||||
|
Models membership types. Currently only a name, but will in the future
|
||||||
|
possibly contain more information like fees.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(verbose_name=_("name"), max_length=64)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("membership type")
|
||||||
|
verbose_name_plural = _("membership types")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
54
src/membership/permissions.py
Normal file
54
src/membership/permissions.py
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
"""Permissions for the membership app."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Permission as DjangoPermission
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
PERMISSIONS = []
|
||||||
|
|
||||||
|
|
||||||
|
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) -> None:
|
||||||
|
"""Post init method."""
|
||||||
|
PERMISSIONS.append(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> str:
|
||||||
|
"""Return the path of the permission."""
|
||||||
|
return f"{self.app_label}.{self.codename}"
|
||||||
|
|
||||||
|
def persist_permission(self) -> None:
|
||||||
|
"""Persist the permission."""
|
||||||
|
content_type, _ = ContentType.objects.get_or_create(
|
||||||
|
app_label=self.app_label,
|
||||||
|
model=self.model,
|
||||||
|
)
|
||||||
|
DjangoPermission.objects.get_or_create(
|
||||||
|
content_type=content_type,
|
||||||
|
codename=self.codename,
|
||||||
|
defaults={"name": self.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ADMINISTRATE_MEMBERS = Permission(
|
||||||
|
name=_("Can administrate members"),
|
||||||
|
codename="administrate_members",
|
||||||
|
app_label="membership",
|
||||||
|
model="membership",
|
||||||
|
)
|
73
src/membership/selectors.py
Normal file
73
src/membership/selectors.py
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
"""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
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
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",
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if member:
|
||||||
|
subscription_periods = subscription_periods.annotate(
|
||||||
|
membership_exists=Exists(
|
||||||
|
Membership.objects.filter(
|
||||||
|
user=member,
|
||||||
|
period=OuterRef("pk"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).filter(membership_exists=True)
|
||||||
|
|
||||||
|
return list(subscription_periods)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_subscription_period() -> SubscriptionPeriod | None:
|
||||||
|
"""Get the current subscription period."""
|
||||||
|
with contextlib.suppress(SubscriptionPeriod.DoesNotExist):
|
||||||
|
return SubscriptionPeriod.objects.prefetch_related(
|
||||||
|
"membership_set",
|
||||||
|
"membership_set__user",
|
||||||
|
).get(period__contains=timezone.now())
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
memberships = memberships.for_member(member=member)
|
||||||
|
|
||||||
|
if period:
|
||||||
|
memberships = memberships.filter(period=period)
|
||||||
|
|
||||||
|
return memberships
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
|
@ -0,0 +1,45 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% trans "Member detail" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
<h1>
|
||||||
|
{{ member.username }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h3>{% trans "Membership" %}</h3>
|
||||||
|
|
||||||
|
{% if subscription_periods %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Start" %}</th>
|
||||||
|
<th>{% trans "End" %}</th>
|
||||||
|
<th>{% trans "Has membership" %}</th>
|
||||||
|
<th>{% trans "Actions" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for period in subscription_periods %}
|
||||||
|
<tr {% if not period.period.upper %}class="table-active"{% endif %}>
|
||||||
|
<td>{{ period.period.lower }}</td>
|
||||||
|
<td>{{ period.period.upper }}</td>
|
||||||
|
<td>{{ period.membership_exists }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
{% trans "No memberships" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
63
src/membership/templates/membership/membership_overview.html
Normal file
63
src/membership/templates/membership/membership_overview.html
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% trans "Membership" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
<h2>Membership settings</h2>
|
||||||
|
{% if not current_membership %}
|
||||||
|
<p>{% trans "You do not have an active membership!" %}</p>
|
||||||
|
|
||||||
|
<p>{% trans "You can become a member by depositing the membership fee to our bank account." %}</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Reg. 8401 (Merkur)</li>
|
||||||
|
<li>Kontonr. 1016866</li>
|
||||||
|
<li>Tekst på overførslen: Your email</li>
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>{% trans "You are a member!" %}</p>
|
||||||
|
|
||||||
|
{% trans "next general assembly" as next_general_assembly %}
|
||||||
|
<p>{% trans "Period" %}: {{ current_period.lower|date:"SHORT_DATE_FORMAT" }} to {{ current_period.upper|date:"SHORT_DATE_FORMAT"|default:next_general_assembly }}</p>
|
||||||
|
<p>{% trans "Type" %}: {{ current_membership.membership_type }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
<h2>Profile settings</h2>
|
||||||
|
<form>
|
||||||
|
<div>
|
||||||
|
<label for="username">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input id="username" type="text" value="{{user}}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="first_name">
|
||||||
|
First name
|
||||||
|
</label>
|
||||||
|
<input id="first_name" type="text" value="{{user.first_name}}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="last_name">
|
||||||
|
Last name
|
||||||
|
</label>
|
||||||
|
<input id="last_name" type="text" value="{{user.last_name}}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button>Update Profile</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-list">
|
||||||
|
<h2>Email settings</h2>
|
||||||
|
<button>Update Email</button>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
115
src/membership/views.py
Normal file
115
src/membership/views.py
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
"""Views for the membership app."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_view_decorator import namespaced_decorator_factory
|
||||||
|
from utils.view_utils import RenderConfig
|
||||||
|
from utils.view_utils import RowAction
|
||||||
|
from utils.view_utils import render
|
||||||
|
|
||||||
|
from .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
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
|
||||||
|
member_view = namespaced_decorator_factory(namespace="member", base_path="membership")
|
||||||
|
|
||||||
|
|
||||||
|
@member_view(
|
||||||
|
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()
|
||||||
|
|
||||||
|
current_period = current_membership.period.period if current_membership else None
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"current_membership": current_membership,
|
||||||
|
"current_period": current_period,
|
||||||
|
"previous_memberships": previous_memberships,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request=request,
|
||||||
|
template_name="membership/membership_overview.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
render_config = RenderConfig(
|
||||||
|
entity_name="member",
|
||||||
|
entity_name_plural="members",
|
||||||
|
paginate_by=20,
|
||||||
|
objects=users,
|
||||||
|
columns=[
|
||||||
|
("username", _("Username")),
|
||||||
|
("first_name", _("First name")),
|
||||||
|
("last_name", _("Last name")),
|
||||||
|
("email", _("Email")),
|
||||||
|
("active_membership", _("Active membership")),
|
||||||
|
],
|
||||||
|
row_actions=[
|
||||||
|
RowAction(
|
||||||
|
label=_("View"),
|
||||||
|
url_name="admin-members:detail",
|
||||||
|
url_kwargs={"member_id": "id"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_config.render_list(
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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:list",
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request=request,
|
||||||
|
template_name="membership/members_admin_detail.html",
|
||||||
|
context=context,
|
||||||
|
)
|
1
src/project/__init__.py
Normal file
1
src/project/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""data.coop member system."""
|
7
src/project/asgi.py
Normal file
7
src/project/asgi.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
519
src/project/locale/da/LC_MESSAGES/django.po
Normal file
519
src/project/locale/da/LC_MESSAGES/django.po
Normal file
|
@ -0,0 +1,519 @@
|
||||||
|
# SOME DESCRIPTIVE TITLE.
|
||||||
|
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||||
|
# This file is distributed under the same license as the PACKAGE package.
|
||||||
|
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: \n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2021-03-04 09:06+0100\n"
|
||||||
|
"PO-Revision-Date: 2021-03-04 09:06+0100\n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"Language: da\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
"X-Generator: Poedit 2.4.1\n"
|
||||||
|
|
||||||
|
#: src/accounting/admin.py:15 src/accounting/admin.py:26
|
||||||
|
msgid "Customer"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/admin.py:31
|
||||||
|
msgid "Order ID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:13 src/membership/models.py:11
|
||||||
|
msgid "modified"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:14 src/membership/models.py:12
|
||||||
|
msgid "created"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:43
|
||||||
|
msgid "amount"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:46
|
||||||
|
msgid "This will include VAT"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:48 src/accounting/models.py:61
|
||||||
|
#: src/accounting/models.py:100
|
||||||
|
msgid "description"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:64
|
||||||
|
msgid "price (excl. VAT)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:66
|
||||||
|
msgid "VAT"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:68
|
||||||
|
msgid "is paid"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:88
|
||||||
|
msgctxt "accounting term"
|
||||||
|
msgid "Order"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:89
|
||||||
|
msgctxt "accounting term"
|
||||||
|
msgid "Orders"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:121
|
||||||
|
msgid "payment"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/accounting/models.py:122
|
||||||
|
msgid "payments"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/membership/models.py:45
|
||||||
|
msgid "membership"
|
||||||
|
msgstr "medlemskab"
|
||||||
|
|
||||||
|
#: src/membership/models.py:46
|
||||||
|
msgid "memberships"
|
||||||
|
msgstr "medlemskaber"
|
||||||
|
|
||||||
|
#: src/membership/models.py:53
|
||||||
|
msgid "subscription type"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/membership/models.py:57
|
||||||
|
msgid "The duration this subscription is for. "
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/membership/models.py:70
|
||||||
|
msgid "membership type"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/membership/models.py:71
|
||||||
|
msgid "membership types"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/membership/models.py:73
|
||||||
|
msgid "name"
|
||||||
|
msgstr "navn"
|
||||||
|
|
||||||
|
#: src/membership/templates/membership_overview.html:7
|
||||||
|
msgid "You do not have an active membership!"
|
||||||
|
msgstr "Du har ikke et aktivt medlemskab!"
|
||||||
|
|
||||||
|
#: src/membership/templates/membership_overview.html:9
|
||||||
|
msgid ""
|
||||||
|
"You can become a member by depositing the membership fee to our bank account."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/membership/templates/membership_overview.html:17
|
||||||
|
msgid "You are a member!"
|
||||||
|
msgstr "Du er medlem!"
|
||||||
|
|
||||||
|
#: src/membership/templates/membership_overview.html:19
|
||||||
|
msgid "Period"
|
||||||
|
msgstr "Periode"
|
||||||
|
|
||||||
|
#: src/membership/templates/membership_overview.html:20
|
||||||
|
msgid "Type"
|
||||||
|
msgstr "Type"
|
||||||
|
|
||||||
|
#: src/project/settings.py:131
|
||||||
|
msgid "Danish"
|
||||||
|
msgstr "Dansk"
|
||||||
|
|
||||||
|
#: src/project/settings.py:132
|
||||||
|
msgid "English"
|
||||||
|
msgstr "Engelsk"
|
||||||
|
|
||||||
|
#: src/project/templates/account/account_inactive.html:5
|
||||||
|
#: src/project/templates/account/account_inactive.html:8
|
||||||
|
msgid "Account Inactive"
|
||||||
|
msgstr "Inaktiv konto"
|
||||||
|
|
||||||
|
#: src/project/templates/account/account_inactive.html:10
|
||||||
|
msgid "This account is inactive."
|
||||||
|
msgstr "Denne konto er inaktiv."
|
||||||
|
|
||||||
|
#: src/project/templates/account/email.html:5
|
||||||
|
#: src/project/templates/account/email.html:16
|
||||||
|
msgid "E-mail Addresses"
|
||||||
|
msgstr "E-mail adresser"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email.html:21
|
||||||
|
msgid "The following e-mail addresses are associated with your account:"
|
||||||
|
msgstr "De følgende e-mail adresser er tilknyttet din konto:"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email.html:31
|
||||||
|
msgid "Address"
|
||||||
|
msgstr "Adresse"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email.html:32
|
||||||
|
msgid "Status"
|
||||||
|
msgstr "Status"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email.html:33
|
||||||
|
msgid "Primary"
|
||||||
|
msgstr "Primær"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email.html:90
|
||||||
|
msgid "Warning:"
|
||||||
|
msgstr "Advarsel:"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email.html:91
|
||||||
|
msgid ""
|
||||||
|
"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."
|
||||||
|
msgstr ""
|
||||||
|
"Du har lige nu ingen e-mail adresse tilknyttet. Du burde virkelig tilføje en "
|
||||||
|
"e-mail adresse så du kan modtage notifikationer, nulstille dit kodeord, osv."
|
||||||
|
|
||||||
|
#: src/project/templates/account/email.html:99
|
||||||
|
#: src/project/templates/account/email.html:107
|
||||||
|
msgid "Add E-mail"
|
||||||
|
msgstr "Tilføj e-mail"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email.html:118
|
||||||
|
msgid "Do you really want to remove the selected e-mail address?"
|
||||||
|
msgstr "Vil du virkelig fjerne den valgte e-mail?"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email/base_message.txt:1
|
||||||
|
#, python-format
|
||||||
|
msgid "Hello from %(site_name)s!"
|
||||||
|
msgstr "Hej fra %(site_name)s!"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email/base_message.txt:5
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"Thank you for using %(site_name)s!\n"
|
||||||
|
"%(site_domain)s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/email/email_confirmation_message.txt:5
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"You're receiving this e-mail because user %(user_display)s has given your e-"
|
||||||
|
"mail address to register an account on %(site_domain)s.\n"
|
||||||
|
"\n"
|
||||||
|
"To confirm this is correct, go to %(activate_url)s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/email/email_confirmation_subject.txt:3
|
||||||
|
msgid "Please Confirm Your E-mail Address"
|
||||||
|
msgstr "Venligst bekræft din e-mail adresse"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email/password_reset_key_message.txt:4
|
||||||
|
msgid ""
|
||||||
|
"You're receiving this e-mail because you or someone else has requested a "
|
||||||
|
"password for your user account.\n"
|
||||||
|
"It can be safely ignored if you did not request a password reset. Click the "
|
||||||
|
"link below to reset your password."
|
||||||
|
msgstr ""
|
||||||
|
"Du modtager denne e-mail fordi du, eller nogen anden, har anmodet om "
|
||||||
|
"nulstilling af dit kodeord.\n"
|
||||||
|
"Du kan trygt ignorere dette hvis det ikke var dig der anmodede om "
|
||||||
|
"nulstillingen. Klik på linket herunder for at nulstille dit kodeord."
|
||||||
|
|
||||||
|
#: src/project/templates/account/email/password_reset_key_message.txt:9
|
||||||
|
#, python-format
|
||||||
|
msgid "In case you forgot, your username is %(username)s."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/email/password_reset_key_subject.txt:3
|
||||||
|
msgid "Password Reset E-mail"
|
||||||
|
msgstr "Nulstilling af password"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email_confirm.html:6
|
||||||
|
#: src/project/templates/account/email_confirm.html:10
|
||||||
|
msgid "Confirm E-mail Address"
|
||||||
|
msgstr "Bekræft e-mail adresse"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email_confirm.html:16
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"Please confirm that <a href=\"mailto:%(email)s\">%(email)s</a> is an e-mail "
|
||||||
|
"address for user %(user_display)s."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/email_confirm.html:20
|
||||||
|
msgid "Confirm"
|
||||||
|
msgstr "Bekræft"
|
||||||
|
|
||||||
|
#: src/project/templates/account/email_confirm.html:27
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"This e-mail confirmation link expired or is invalid. Please <a href="
|
||||||
|
"\"%(email_url)s\">issue a new e-mail confirmation request</a>."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/login.html:20
|
||||||
|
#: src/project/templates/account/login.html:26
|
||||||
|
#: src/project/templates/account/signup.html:26
|
||||||
|
#: src/project/templates/account/signup.html:32
|
||||||
|
msgid "E-mail"
|
||||||
|
msgstr "E-mail"
|
||||||
|
|
||||||
|
#: src/project/templates/account/login.html:31
|
||||||
|
#: src/project/templates/account/login.html:37
|
||||||
|
#: src/project/templates/account/signup.html:44
|
||||||
|
#: src/project/templates/account/signup.html:50
|
||||||
|
msgid "Password"
|
||||||
|
msgstr "Kodeord"
|
||||||
|
|
||||||
|
#: src/project/templates/account/login.html:41
|
||||||
|
msgid "Sign in"
|
||||||
|
msgstr "Log ind"
|
||||||
|
|
||||||
|
#: src/project/templates/account/login.html:45
|
||||||
|
msgid "Forgot password?"
|
||||||
|
msgstr "Glemt kodeord?"
|
||||||
|
|
||||||
|
#: src/project/templates/account/login.html:48
|
||||||
|
msgid "Or"
|
||||||
|
msgstr "Eller"
|
||||||
|
|
||||||
|
#: src/project/templates/account/login.html:53
|
||||||
|
#: src/project/templates/account/signup.html:6
|
||||||
|
msgid "Become a member"
|
||||||
|
msgstr "Bliv medlem"
|
||||||
|
|
||||||
|
#: src/project/templates/account/logout.html:5
|
||||||
|
#: src/project/templates/account/logout.html:8
|
||||||
|
#: src/project/templates/account/logout.html:17
|
||||||
|
msgid "Sign Out"
|
||||||
|
msgstr "Log ud"
|
||||||
|
|
||||||
|
#: src/project/templates/account/logout.html:10
|
||||||
|
msgid "Are you sure you want to sign out?"
|
||||||
|
msgstr "Er du sikker på at du vil logge ind?"
|
||||||
|
|
||||||
|
#: src/project/templates/account/messages/cannot_delete_primary_email.txt:2
|
||||||
|
#, python-format
|
||||||
|
msgid "You cannot remove your primary e-mail address (%(email)s)."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/messages/email_confirmation_sent.txt:2
|
||||||
|
#, python-format
|
||||||
|
msgid "Confirmation e-mail sent to %(email)s."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/messages/email_confirmed.txt:2
|
||||||
|
#, python-format
|
||||||
|
msgid "You have confirmed %(email)s."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/messages/email_deleted.txt:2
|
||||||
|
#, python-format
|
||||||
|
msgid "Removed e-mail address %(email)s."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/messages/logged_in.txt:4
|
||||||
|
#, python-format
|
||||||
|
msgid "Successfully signed in as %(name)s."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/messages/logged_out.txt:2
|
||||||
|
msgid "You have signed out."
|
||||||
|
msgstr "Du er nu logget ud."
|
||||||
|
|
||||||
|
#: src/project/templates/account/messages/password_changed.txt:2
|
||||||
|
msgid "Password successfully changed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/messages/password_set.txt:2
|
||||||
|
msgid "Password successfully set."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/messages/primary_email_set.txt:2
|
||||||
|
msgid "Primary e-mail address set."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/messages/unverified_primary_email.txt:2
|
||||||
|
msgid "Your primary e-mail address must be verified."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_change.html:5
|
||||||
|
#: src/project/templates/account/password_change.html:8
|
||||||
|
#: src/project/templates/account/password_change.html:13
|
||||||
|
#: src/project/templates/account/password_reset_from_key.html:4
|
||||||
|
#: src/project/templates/account/password_reset_from_key.html:7
|
||||||
|
#: src/project/templates/account/password_reset_from_key_done.html:4
|
||||||
|
#: src/project/templates/account/password_reset_from_key_done.html:7
|
||||||
|
msgid "Change Password"
|
||||||
|
msgstr "Skift kodeord"
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_change.html:14
|
||||||
|
msgid "Forgot Password?"
|
||||||
|
msgstr "Glemt kodeord?"
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_reset.html:6
|
||||||
|
#: src/project/templates/account/password_reset.html:10
|
||||||
|
#: src/project/templates/account/password_reset_done.html:6
|
||||||
|
#: src/project/templates/account/password_reset_done.html:9
|
||||||
|
msgid "Password Reset"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_reset.html:16
|
||||||
|
msgid ""
|
||||||
|
"Forgotten your password? Enter your e-mail address below, and we'll send you "
|
||||||
|
"an e-mail allowing you to reset it."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_reset.html:21
|
||||||
|
#: src/project/templates/account/password_reset.html:27
|
||||||
|
msgid "E-mail address"
|
||||||
|
msgstr "E-mail adresser"
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_reset.html:38
|
||||||
|
msgid "Reset My Password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_reset.html:41
|
||||||
|
msgid "Please contact us if you have any trouble resetting your password."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_reset.html:47
|
||||||
|
#: src/project/templates/account/signup.html:72
|
||||||
|
msgid "To login"
|
||||||
|
msgstr "Til login"
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_reset_done.html:15
|
||||||
|
msgid ""
|
||||||
|
"We have sent you an e-mail. Please contact us if you do not receive it "
|
||||||
|
"within a few minutes."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_reset_from_key.html:7
|
||||||
|
msgid "Bad Token"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_reset_from_key.html:11
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"The password reset link was invalid, possibly because it has already been "
|
||||||
|
"used. Please request a <a href=\"%(passwd_reset_url)s\">new password reset</"
|
||||||
|
"a>."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_reset_from_key.html:17
|
||||||
|
msgid "change password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_reset_from_key.html:20
|
||||||
|
#: src/project/templates/account/password_reset_from_key_done.html:8
|
||||||
|
msgid "Your password is now changed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/password_set.html:5
|
||||||
|
#: src/project/templates/account/password_set.html:8
|
||||||
|
#: src/project/templates/account/password_set.html:13
|
||||||
|
msgid "Set Password"
|
||||||
|
msgstr "Sæt kodeord"
|
||||||
|
|
||||||
|
#: src/project/templates/account/signup.html:19
|
||||||
|
msgid "To become a member, you need to have an account. Create one here."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/signup.html:66
|
||||||
|
msgid "Sign up"
|
||||||
|
msgstr "Bliv medlem"
|
||||||
|
|
||||||
|
#: src/project/templates/account/signup_closed.html:5
|
||||||
|
#: src/project/templates/account/signup_closed.html:8
|
||||||
|
msgid "Sign Up Closed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/signup_closed.html:10
|
||||||
|
msgid "We are sorry, but the sign up is currently closed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/snippets/already_logged_in.html:5
|
||||||
|
msgid "Note"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/snippets/already_logged_in.html:5
|
||||||
|
#, python-format
|
||||||
|
msgid "you are already logged in as %(user_display)s."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/verification_sent.html:5
|
||||||
|
#: src/project/templates/account/verification_sent.html:8
|
||||||
|
#: src/project/templates/account/verified_email_required.html:5
|
||||||
|
#: src/project/templates/account/verified_email_required.html:8
|
||||||
|
msgid "Verify Your E-mail Address"
|
||||||
|
msgstr "Verificér din e-mail adresse"
|
||||||
|
|
||||||
|
#: src/project/templates/account/verification_sent.html:10
|
||||||
|
msgid ""
|
||||||
|
"We have sent an e-mail to you for verification. Follow the link provided to "
|
||||||
|
"finalize the signup process. Please contact us if you do not receive it "
|
||||||
|
"within a few minutes."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/verified_email_required.html:12
|
||||||
|
msgid ""
|
||||||
|
"This part of the site requires us to verify that\n"
|
||||||
|
"you are who you claim to be. For this purpose, we require that you\n"
|
||||||
|
"verify ownership of your e-mail address. "
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/verified_email_required.html:16
|
||||||
|
msgid ""
|
||||||
|
"We have sent an e-mail to you for\n"
|
||||||
|
"verification. Please click on the link inside this e-mail. Please\n"
|
||||||
|
"contact us if you do not receive it within a few minutes."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/account/verified_email_required.html:20
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"<strong>Note:</strong> you can still <a href=\"%(email_url)s\">change your e-"
|
||||||
|
"mail address</a>."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/base.html:140
|
||||||
|
msgid "Dashboard"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/project/templates/base.html:146
|
||||||
|
msgid "Profile"
|
||||||
|
msgstr "Profil"
|
||||||
|
|
||||||
|
#: src/project/templates/base.html:152
|
||||||
|
msgid "Details"
|
||||||
|
msgstr "Detaljer"
|
||||||
|
|
||||||
|
#: src/project/templates/base.html:158
|
||||||
|
msgid "Emails"
|
||||||
|
msgstr "Emails"
|
||||||
|
|
||||||
|
#: src/project/templates/base.html:164
|
||||||
|
msgid "Membership"
|
||||||
|
msgstr "Medlemskab"
|
||||||
|
|
||||||
|
#: src/project/templates/base.html:171 src/project/templates/base.html:184
|
||||||
|
msgid "Overview"
|
||||||
|
msgstr "Oversigt"
|
||||||
|
|
||||||
|
#: src/project/templates/base.html:177
|
||||||
|
msgid "Services"
|
||||||
|
msgstr "Tjenester"
|
||||||
|
|
||||||
|
#: src/project/templates/base.html:191
|
||||||
|
msgid "Admin"
|
||||||
|
msgstr "Admin"
|
||||||
|
|
||||||
|
#: src/project/templates/base.html:197
|
||||||
|
msgid "Members"
|
||||||
|
msgstr "Medlemmer"
|
||||||
|
|
||||||
|
#~ msgid "OR"
|
||||||
|
#~ msgstr "Eller"
|
190
src/project/settings.py
Normal file
190
src/project/settings.py
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
"""Settings for the project."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from environs import Env
|
||||||
|
|
||||||
|
env = Env()
|
||||||
|
env.read_env()
|
||||||
|
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
PROJECT_DIR = BASE_DIR / "project"
|
||||||
|
|
||||||
|
SECRET_KEY = env.str("SECRET_KEY", default="something-very-secret")
|
||||||
|
|
||||||
|
DEBUG = env.bool("DEBUG", default=False)
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["*"])
|
||||||
|
CSRF_TRUSTED_ORIGINS = env.list(
|
||||||
|
"CSRF_TRUSTED_ORIGINS",
|
||||||
|
default=["http://localhost:8000"],
|
||||||
|
)
|
||||||
|
|
||||||
|
ADMINS = [tuple(x.split(":")) for x in env.list("DJANGO_ADMINS", default=[])]
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
DJANGO_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"django.contrib.sites",
|
||||||
|
]
|
||||||
|
|
||||||
|
THIRD_PARTY_APPS = [
|
||||||
|
"allauth",
|
||||||
|
"allauth.account",
|
||||||
|
"django_view_decorator",
|
||||||
|
]
|
||||||
|
|
||||||
|
LOCAL_APPS = [
|
||||||
|
"utils",
|
||||||
|
"accounting",
|
||||||
|
"membership",
|
||||||
|
]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
*DJANGO_APPS,
|
||||||
|
*THIRD_PARTY_APPS,
|
||||||
|
*LOCAL_APPS,
|
||||||
|
]
|
||||||
|
|
||||||
|
DATABASES = {"default": env.dj_db_url("DATABASE_URL")}
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"allauth.account.middleware.AccountMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "project.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [PROJECT_DIR / "templates"],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
"builtins": [
|
||||||
|
"django.templatetags.i18n",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
AUTHENTICATION_BACKENDS = (
|
||||||
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
"allauth.account.auth_backends.AuthenticationBackend",
|
||||||
|
)
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "project.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = []
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "da-dk"
|
||||||
|
|
||||||
|
TIME_ZONE = "Europe/Copenhagen"
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "/static/"
|
||||||
|
STATICFILES_DIRS = [PROJECT_DIR / "static"]
|
||||||
|
STATIC_ROOT = BASE_DIR / "static"
|
||||||
|
|
||||||
|
SITE_ID = 1
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_URL = "/"
|
||||||
|
|
||||||
|
EMAIL_BACKEND = env.str(
|
||||||
|
"EMAIL_BACKEND",
|
||||||
|
default="django.core.mail.backends.console.EmailBackend",
|
||||||
|
)
|
||||||
|
DEFAULT_FROM_EMAIL = env.str("DEFAULT_FROM_EMAIL", default="")
|
||||||
|
# Parse email URLs, e.g. "smtp://"
|
||||||
|
email = env.dj_email_url("EMAIL_URL", default="smtp://")
|
||||||
|
EMAIL_HOST = email["EMAIL_HOST"]
|
||||||
|
EMAIL_PORT = email["EMAIL_PORT"]
|
||||||
|
EMAIL_HOST_PASSWORD = email["EMAIL_HOST_PASSWORD"]
|
||||||
|
EMAIL_HOST_USER = email["EMAIL_HOST_USER"]
|
||||||
|
EMAIL_USE_TLS = email["EMAIL_USE_TLS"]
|
||||||
|
|
||||||
|
# Always show DDT in development for any IP, not just 127.0.0.1 or
|
||||||
|
# settings.INTERNAL_IPS. This is useful in a docker setup where the
|
||||||
|
# requesting IP isn't static.
|
||||||
|
DEBUG_TOOLBAR_CONFIG = {
|
||||||
|
"SHOW_TOOLBAR_CALLBACK": lambda _x: DEBUG,
|
||||||
|
}
|
||||||
|
|
||||||
|
CURRENCIES = ("DKK",)
|
||||||
|
CURRENCY_CHOICES = [("DKK", "DKK")]
|
||||||
|
|
||||||
|
LANGUAGES = [
|
||||||
|
("da", _("Danish")),
|
||||||
|
("en", _("English")),
|
||||||
|
]
|
||||||
|
|
||||||
|
# We store all translations in one location
|
||||||
|
LOCALE_PATHS = [PROJECT_DIR / "locale"]
|
||||||
|
|
||||||
|
# Allauth configuration
|
||||||
|
ACCOUNT_AUTHENTICATION_METHOD = "email"
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
||||||
|
MIDDLEWARE += [
|
||||||
|
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||||
|
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||||
|
]
|
||||||
|
# Always show DDT in development for any IP, not just 127.0.0.1 or
|
||||||
|
# settings.INTERNAL_IPS. This is useful in a docker setup where the
|
||||||
|
# requesting IP isn't static.
|
||||||
|
DEBUG_TOOLBAR_CONFIG = {
|
||||||
|
"SHOW_TOOLBAR_CALLBACK": lambda _x: DEBUG,
|
||||||
|
}
|
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);
|
||||||
|
}
|
BIN
src/project/static/css/fonts/bootstrap-icons.woff
Normal file
BIN
src/project/static/css/fonts/bootstrap-icons.woff
Normal file
Binary file not shown.
BIN
src/project/static/css/fonts/bootstrap-icons.woff2
Normal file
BIN
src/project/static/css/fonts/bootstrap-icons.woff2
Normal file
Binary file not shown.
559
src/project/static/css/style.css
Normal file
559
src/project/static/css/style.css
Normal file
|
@ -0,0 +1,559 @@
|
||||||
|
/* Reset */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
picture,
|
||||||
|
video,
|
||||||
|
canvas,
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1380px) {
|
||||||
|
:root {
|
||||||
|
--outer-space: 15%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
font-size: 1.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--twilight);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
header>h1 {
|
||||||
|
font-size: 1.44em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#switch-icon {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 0 var(--space);
|
||||||
|
top: -2px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#switch-icon #layer1 path {
|
||||||
|
fill: var(--twilight);
|
||||||
|
}
|
||||||
|
|
||||||
|
header>div>a#logout {
|
||||||
|
padding: 6px 12px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>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>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);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ol {
|
||||||
|
margin: 0 calc(var(--outer-space));
|
||||||
|
padding: 0;
|
||||||
|
list-style-type: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ol li {
|
||||||
|
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:first-child>a {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ol li a:hover {
|
||||||
|
border-color: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ol li a.current {
|
||||||
|
font-weight: bold;
|
||||||
|
border-color: var(--splash);
|
||||||
|
color: var(--splash);
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
padding: var(--double-space) var(--outer-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.services {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--double-space);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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:last-child {
|
||||||
|
border-radius: 0 var(--half-space) var(--half-space) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginbox>div:last-child>* {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginbox div.new_here {
|
||||||
|
margin-top: var(--double-space);
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginbox div.new_here h2 {
|
||||||
|
margin: var(--double-space) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginbox button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginbox img {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.time_remaining {
|
||||||
|
color: var(--fade);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
list-style: none;
|
||||||
|
padding: var(--half-space) 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination>li {
|
||||||
|
margin: 0 var(--half-space);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination>li:first-child {
|
||||||
|
margin-right: var(--double-space);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination>li:last-child {
|
||||||
|
margin-left: var(--double-space);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item {
|
||||||
|
border: 1px solid var(--fade);
|
||||||
|
padding: var(--quarter-space) var(--half-space);
|
||||||
|
border-radius: var(--half-space);
|
||||||
|
background: var(--light-dust);
|
||||||
|
font-size: 0.78em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-link {
|
||||||
|
padding: var(--half-space);
|
||||||
|
color: var(--twilight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item.active {
|
||||||
|
background: var(--twilight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item.active .page-link {
|
||||||
|
color: var(--light-dust);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-item.disabled .page-link {
|
||||||
|
cursor: default;
|
||||||
|
}
|
BIN
src/project/static/fonts/Inter-Black.woff
Normal file
BIN
src/project/static/fonts/Inter-Black.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Black.woff2
Normal file
BIN
src/project/static/fonts/Inter-Black.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-BlackItalic.woff
Normal file
BIN
src/project/static/fonts/Inter-BlackItalic.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-BlackItalic.woff2
Normal file
BIN
src/project/static/fonts/Inter-BlackItalic.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Bold.woff
Normal file
BIN
src/project/static/fonts/Inter-Bold.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Bold.woff2
Normal file
BIN
src/project/static/fonts/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-BoldItalic.woff
Normal file
BIN
src/project/static/fonts/Inter-BoldItalic.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-BoldItalic.woff2
Normal file
BIN
src/project/static/fonts/Inter-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-ExtraBold.woff
Normal file
BIN
src/project/static/fonts/Inter-ExtraBold.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-ExtraBold.woff2
Normal file
BIN
src/project/static/fonts/Inter-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-ExtraBoldItalic.woff
Normal file
BIN
src/project/static/fonts/Inter-ExtraBoldItalic.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-ExtraBoldItalic.woff2
Normal file
BIN
src/project/static/fonts/Inter-ExtraBoldItalic.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-ExtraLight.woff
Normal file
BIN
src/project/static/fonts/Inter-ExtraLight.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-ExtraLight.woff2
Normal file
BIN
src/project/static/fonts/Inter-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-ExtraLightItalic.woff
Normal file
BIN
src/project/static/fonts/Inter-ExtraLightItalic.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-ExtraLightItalic.woff2
Normal file
BIN
src/project/static/fonts/Inter-ExtraLightItalic.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Italic.woff
Normal file
BIN
src/project/static/fonts/Inter-Italic.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Italic.woff2
Normal file
BIN
src/project/static/fonts/Inter-Italic.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Light.woff
Normal file
BIN
src/project/static/fonts/Inter-Light.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Light.woff2
Normal file
BIN
src/project/static/fonts/Inter-Light.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-LightItalic.woff
Normal file
BIN
src/project/static/fonts/Inter-LightItalic.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-LightItalic.woff2
Normal file
BIN
src/project/static/fonts/Inter-LightItalic.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Medium.woff
Normal file
BIN
src/project/static/fonts/Inter-Medium.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Medium.woff2
Normal file
BIN
src/project/static/fonts/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-MediumItalic.woff
Normal file
BIN
src/project/static/fonts/Inter-MediumItalic.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-MediumItalic.woff2
Normal file
BIN
src/project/static/fonts/Inter-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Regular.woff
Normal file
BIN
src/project/static/fonts/Inter-Regular.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Regular.woff2
Normal file
BIN
src/project/static/fonts/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-SemiBold.woff
Normal file
BIN
src/project/static/fonts/Inter-SemiBold.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-SemiBold.woff2
Normal file
BIN
src/project/static/fonts/Inter-SemiBold.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-SemiBoldItalic.woff
Normal file
BIN
src/project/static/fonts/Inter-SemiBoldItalic.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-SemiBoldItalic.woff2
Normal file
BIN
src/project/static/fonts/Inter-SemiBoldItalic.woff2
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Thin.woff
Normal file
BIN
src/project/static/fonts/Inter-Thin.woff
Normal file
Binary file not shown.
BIN
src/project/static/fonts/Inter-Thin.woff2
Normal file
BIN
src/project/static/fonts/Inter-Thin.woff2
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue