forked from data.coop/membersystem
Compare commits
121 commits
master
...
fix-migrat
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
|
10
.env.example
Normal file
10
.env.example
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
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
|
||||||
|
STRIPE_API_KEY=sk_test_
|
||||||
|
STRIPE_ENDPOINT_SECRET=whsec_
|
10
.gitignore
vendored
10
.gitignore
vendored
|
@ -2,5 +2,13 @@ __pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.sw*
|
*.sw*
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
project/settings/local.py
|
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
|
.idea/
|
||||||
|
*.mo
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
|
||||||
|
# collectstatic
|
||||||
|
src/static/
|
||||||
|
|
|
@ -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
|
||||||
|
|
53
Dockerfile
Normal file
53
Dockerfile
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
# PYTHONFAULTHANDLER: Propagate tracebacks from all threads.
|
||||||
|
# PYTHONUNBUFFERED: Write terminal output straight to docker (to not confuse Docker Compose).
|
||||||
|
# PYTHONDONTWRITEBYTECODE: Dont write *pyc files at all, making it possible for a 100% read-only container.
|
||||||
|
# PIP_NO_CACHE_DIR: Disable PIP cache, we don't need pip's cache after building the image.
|
||||||
|
# PIP_DISABLE_PIP_VERSION_CHECK: Build the image with the available pip, do not check for updates (faster!)
|
||||||
|
# PIP_DEFAULT_TIMEOUT: Allow for longer timeouts.
|
||||||
|
ENV PYTHONFAULTHANDLER=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
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
|
||||||
|
|
||||||
|
# Only copy the requirements file first to leverage Docker cache
|
||||||
|
RUN mkdir requirements/
|
||||||
|
COPY $REQUIREMENTS_FILE $REQUIREMENTS_FILE
|
||||||
|
|
||||||
|
RUN mkdir -p /app/src/static && \
|
||||||
|
chown www:www /app/src/static && \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get install -y \
|
||||||
|
binutils \
|
||||||
|
libpq-dev \
|
||||||
|
build-essential \
|
||||||
|
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
|
||||||
|
|
||||||
|
# Copy the rest of the application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN 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"]
|
38
Makefile
38
Makefile
|
@ -1,15 +1,29 @@
|
||||||
# These are just some make targets, expressing how things
|
.PHONY: run makemigrations migrate createsuperuser shell manage_command build requirements
|
||||||
# are supposed to be run, but feel free to change them!
|
DOCKER_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose
|
||||||
|
DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u`
|
||||||
|
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}
|
||||||
|
|
||||||
|
build:
|
||||||
|
${DOCKER_COMPOSE} build
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
hatch run requirements
|
||||||
|
|
105
README.md
Normal file
105
README.md
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Building and running other things
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the containers
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Create a superuser
|
||||||
|
make createsuperuser
|
||||||
|
|
||||||
|
# Create Django migrations (after this, maybe you need to change file permissions in volume)
|
||||||
|
make makemigrations
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating requirements
|
||||||
|
|
||||||
|
We use hatch-pip-compile. That means we have a set of loosely defined `dependencies` in `pyproject.toml` and then we can keep the exactly pinned version in our `requirements.txt` (auto-generated).
|
||||||
|
|
||||||
|
To generate `requirements.txt` and `requirements/requirements-dev.txt`, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build requirements.txt etc
|
||||||
|
make requirements
|
||||||
|
|
||||||
|
# Build Docker image with new Python requirements
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important notes
|
||||||
|
|
||||||
|
* This project uses [django-zen-queries](https://github.com/dabapps/django-zen-queries), which will sometimes raise a `QueriesDisabledError` in your templates. You can find a difference of opinion about that, but you can find a difference of opinion about many things, right?
|
||||||
|
* If a linting error annoys you, please feel free to strike back by adding a `noqa` to the line that has displeased the linter and move on with life.
|
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,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,157 +0,0 @@
|
||||||
from hashlib import md5
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.db import models
|
|
||||||
from django.db.models.aggregates import Sum
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from django.utils.translation import pgettext_lazy
|
|
||||||
from djmoney.models.fields import MoneyField
|
|
||||||
|
|
||||||
|
|
||||||
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 Account(CreatedModifiedAbstract):
|
|
||||||
"""
|
|
||||||
This is the model where we can give access to several users, such that they
|
|
||||||
can decide which account to use to pay for something.
|
|
||||||
"""
|
|
||||||
|
|
||||||
owner = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def balance(self):
|
|
||||||
return self.transactions.all().aggregate(Sum('amount')).get('amount', 0)
|
|
||||||
|
|
||||||
|
|
||||||
class Transaction(CreatedModifiedAbstract):
|
|
||||||
"""
|
|
||||||
Tracks in and outgoing events of an account. When an order is received, an
|
|
||||||
amount is subtracted, when a payment is received, an amount is added.
|
|
||||||
"""
|
|
||||||
|
|
||||||
account = models.ForeignKey(
|
|
||||||
Account,
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
related_name="transactions",
|
|
||||||
)
|
|
||||||
amount = MoneyField(
|
|
||||||
verbose_name=_("amount"),
|
|
||||||
max_digits=16,
|
|
||||||
decimal_places=2,
|
|
||||||
help_text=_("This will include VAT")
|
|
||||||
)
|
|
||||||
description = models.CharField(
|
|
||||||
max_length=1024,
|
|
||||||
verbose_name=_("description")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Order(CreatedModifiedAbstract):
|
|
||||||
"""
|
|
||||||
Scoped out: Contents of invoices will have to be tracked either here or in
|
|
||||||
a separate Invoice model. This is undecided because we are not generating
|
|
||||||
invoices at the moment.
|
|
||||||
"""
|
|
||||||
|
|
||||||
user = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
|
|
||||||
account = models.ForeignKey(Account, on_delete=models.PROTECT)
|
|
||||||
is_paid = models.BooleanField(default=False)
|
|
||||||
|
|
||||||
description = models.CharField(
|
|
||||||
max_length=1024,
|
|
||||||
verbose_name=_("description")
|
|
||||||
)
|
|
||||||
|
|
||||||
price = MoneyField(
|
|
||||||
verbose_name=_("price (excl. VAT)"),
|
|
||||||
max_digits=16,
|
|
||||||
decimal_places=2,
|
|
||||||
)
|
|
||||||
vat = MoneyField(
|
|
||||||
verbose_name=_("VAT"),
|
|
||||||
max_digits=16,
|
|
||||||
decimal_places=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
is_paid = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name=_("is paid"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def total(self):
|
|
||||||
return self.price + self.vat
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_id(self):
|
|
||||||
return str(self.id).zfill(6)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def payment_token(self):
|
|
||||||
pk = str(self.pk).encode("utf-8")
|
|
||||||
x = md5()
|
|
||||||
x.update(pk)
|
|
||||||
extra_hash = (settings.SECRET_KEY + 'blah').encode('utf-8')
|
|
||||||
x.update(extra_hash)
|
|
||||||
return x.hexdigest()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = pgettext_lazy("accounting term", "Order")
|
|
||||||
verbose_name_plural = pgettext_lazy("accounting term", "Orders")
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "Order ID {id}".format(id=self.display_id)
|
|
||||||
|
|
||||||
|
|
||||||
class Payment(CreatedModifiedAbstract):
|
|
||||||
|
|
||||||
amount = MoneyField(
|
|
||||||
max_digits=16,
|
|
||||||
decimal_places=2,
|
|
||||||
)
|
|
||||||
order = models.ForeignKey(Order, on_delete=models.PROTECT)
|
|
||||||
|
|
||||||
description = models.CharField(
|
|
||||||
max_length=1024,
|
|
||||||
verbose_name=_("description")
|
|
||||||
)
|
|
||||||
|
|
||||||
stripe_charge_id = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_id(self):
|
|
||||||
return str(self.id).zfill(6)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_order(cls, order):
|
|
||||||
return cls.objects.create(
|
|
||||||
order=order,
|
|
||||||
user=order.user,
|
|
||||||
amount=order.total,
|
|
||||||
description=order.description,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "Payment ID {id}".format(id=self.display_id)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("payment")
|
|
||||||
verbose_name_plural = _("payments")
|
|
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")
|
|
166
pyproject.toml
Normal file
166
pyproject.toml
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
[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.1",
|
||||||
|
"django-allauth~=0.63",
|
||||||
|
"django-money~=3.5",
|
||||||
|
"django-oauth-toolkit~=2.4",
|
||||||
|
"django-registries==0.0.3",
|
||||||
|
"django-view-decorator==0.0.4",
|
||||||
|
"django-oauth-toolkit~=2.4",
|
||||||
|
"django-ratelimit~=4.1",
|
||||||
|
"django-zen-queries~=2.1",
|
||||||
|
"django_stubs_ext~=5.0",
|
||||||
|
"environs[django]>=11,<12",
|
||||||
|
"psycopg[binary]~=3.2",
|
||||||
|
"stripe~=10.5",
|
||||||
|
"uvicorn~=0.30",
|
||||||
|
"whitenoise~=6.7",
|
||||||
|
]
|
||||||
|
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"
|
||||||
|
pip-compile-resolver = "uv"
|
||||||
|
|
||||||
|
[tool.hatch.envs.dev]
|
||||||
|
type = "pip-compile"
|
||||||
|
pip-compile-resolver = "uv"
|
||||||
|
dependencies = [
|
||||||
|
"coverage[toml]==7.3.0",
|
||||||
|
"pytest==7.2.2",
|
||||||
|
"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.1"]
|
||||||
|
|
||||||
|
[tool.hatch.envs.tests.overrides]
|
||||||
|
matrix.django.dependencies = [
|
||||||
|
{ value = "django~={matrix:django}" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.envs.default.scripts]
|
||||||
|
cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=src --cov=tests --cov=append {args}"
|
||||||
|
no-cov = "cov --no-cov {args}"
|
||||||
|
typecheck = "mypy --config-file=pyproject.toml ."
|
||||||
|
requirements = "hatch env run --env default -- python --version; hatch env run --env dev -- python --version"
|
||||||
|
server = "./src/manage.py runserver 0.0.0.0:8000"
|
||||||
|
migrate = "./src/manage.py migrate"
|
||||||
|
makemigrations = "./src/manage.py makemigrations"
|
||||||
|
createsuperuser = "./src/manage.py createsuperuser"
|
||||||
|
shell = "./src/manage.py shell"
|
||||||
|
# You need to install Stripe CLI from here to run this: https://github.com/stripe/stripe-cli/releases
|
||||||
|
stripe_cli = "stripe listen --forward-to 0.0.0.0:8000/order/stripe/webhook/"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
DJANGO_SETTINGS_MODULE="tests.settings"
|
||||||
|
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 = "project.settings"
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "tests.*"
|
||||||
|
allow_untyped_defs = true
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
extend-exclude = [
|
||||||
|
".git",
|
||||||
|
"__pycache__",
|
||||||
|
"manage.py",
|
||||||
|
"asgi.py",
|
||||||
|
"wsgi.py",
|
||||||
|
]
|
||||||
|
line-length = 120
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["ALL"]
|
||||||
|
ignore = [
|
||||||
|
"G004", # Logging statement uses f-string
|
||||||
|
"ANN101", # Missing type annotation for `self` in method
|
||||||
|
"ANN102", # Missing type annotation for `cls` in classmethod
|
||||||
|
"EM101", # Exception must not use a string literal, assign to variable first
|
||||||
|
"EM102", # Exception must not use a f-string literal, assign to variable first
|
||||||
|
"COM812", # missing-trailing-comma (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
|
||||||
|
"ISC001", # single-line-implicit-string-concatenation (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
|
||||||
|
"D100", # Missing docstring in public module
|
||||||
|
"D101", # Missing docstring in public class
|
||||||
|
"D102", # Missing docstring in public method
|
||||||
|
"D105", # Missing docstring in magic method
|
||||||
|
"D106", # Missing docstring in public nested class
|
||||||
|
"D107", # Missing docstring in `__init__`
|
||||||
|
"FIX", # TODO, FIXME, XXX
|
||||||
|
"TD", # TODO, FIXME, XXX
|
||||||
|
"ANN002", # Missing type annotation for `*args`
|
||||||
|
"ANN003", # Missing type annotation for `**kwargs`
|
||||||
|
"FBT001", # Misbehaves: Boolean-typed positional argument in function definition
|
||||||
|
"FBT002", # Misbehaves: Boolean-typed positional argument in function definition
|
||||||
|
"TRY003", # Avoid specifying long messages outside the exception class
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
force-single-line = true
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests.py" = [
|
||||||
|
"S101", # Use of assert
|
||||||
|
"SLF001", # Private member access
|
||||||
|
"D100", # Docstrings
|
||||||
|
"D103", # Docstrings
|
||||||
|
]
|
|
@ -1,5 +0,0 @@
|
||||||
[pytest]
|
|
||||||
testpaths = .
|
|
||||||
python_files = tests.py test_*.py *_tests.py
|
|
||||||
DJANGO_SETTINGS_MODULE = project.settings
|
|
||||||
#norecursedirs = dist tmp* .svn .*
|
|
118
requirements.txt
118
requirements.txt
|
@ -1,2 +1,116 @@
|
||||||
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
|
||||||
|
# - django-money~=3.5
|
||||||
|
# - django-oauth-toolkit~=2.4
|
||||||
|
# - django-ratelimit~=4.1
|
||||||
|
# - django-registries==0.0.3
|
||||||
|
# - django-stubs-ext~=5.0
|
||||||
|
# - django-view-decorator==0.0.4
|
||||||
|
# - django-zen-queries~=2.1
|
||||||
|
# - django<5.2,>=5.1b1
|
||||||
|
# - environs[django]<12,>=11
|
||||||
|
# - psycopg[binary]~=3.2
|
||||||
|
# - stripe~=10.5
|
||||||
|
# - uvicorn~=0.30
|
||||||
|
# - whitenoise~=6.7
|
||||||
|
#
|
||||||
|
|
||||||
|
asgiref==3.8.1
|
||||||
|
# via django
|
||||||
|
babel==2.15.0
|
||||||
|
# via py-moneyed
|
||||||
|
certifi==2024.7.4
|
||||||
|
# via requests
|
||||||
|
cffi==1.16.0
|
||||||
|
# via cryptography
|
||||||
|
charset-normalizer==3.3.2
|
||||||
|
# via requests
|
||||||
|
click==8.1.7
|
||||||
|
# via uvicorn
|
||||||
|
cryptography==43.0.0
|
||||||
|
# via jwcrypto
|
||||||
|
dj-database-url==2.2.0
|
||||||
|
# via environs
|
||||||
|
dj-email-url==1.0.6
|
||||||
|
# via environs
|
||||||
|
django==5.1rc1
|
||||||
|
# via
|
||||||
|
# hatch.envs.default
|
||||||
|
# dj-database-url
|
||||||
|
# django-allauth
|
||||||
|
# django-money
|
||||||
|
# django-oauth-toolkit
|
||||||
|
# django-registries
|
||||||
|
# django-stubs-ext
|
||||||
|
# django-view-decorator
|
||||||
|
# django-zen-queries
|
||||||
|
django-allauth==0.63.6
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-cache-url==3.4.5
|
||||||
|
# via environs
|
||||||
|
django-money==3.5.3
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-oauth-toolkit==2.4.0
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-ratelimit==4.1.0
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-registries==0.0.3
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-stubs-ext==5.0.4
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-view-decorator==0.0.4
|
||||||
|
# via hatch.envs.default
|
||||||
|
django-zen-queries==2.1.0
|
||||||
|
# via hatch.envs.default
|
||||||
|
environs==11.0.0
|
||||||
|
# via hatch.envs.default
|
||||||
|
h11==0.14.0
|
||||||
|
# via uvicorn
|
||||||
|
idna==3.7
|
||||||
|
# via requests
|
||||||
|
jwcrypto==1.5.6
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
marshmallow==3.21.3
|
||||||
|
# via environs
|
||||||
|
oauthlib==3.2.2
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
packaging==24.1
|
||||||
|
# via marshmallow
|
||||||
|
psycopg==3.2.1
|
||||||
|
# via hatch.envs.default
|
||||||
|
psycopg-binary==3.2.1
|
||||||
|
# via psycopg
|
||||||
|
py-moneyed==3.0
|
||||||
|
# via django-money
|
||||||
|
pycparser==2.22
|
||||||
|
# via cffi
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
# via environs
|
||||||
|
pytz==2024.1
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
requests==2.32.3
|
||||||
|
# via
|
||||||
|
# django-oauth-toolkit
|
||||||
|
# stripe
|
||||||
|
setuptools==72.1.0
|
||||||
|
# via django-money
|
||||||
|
sqlparse==0.5.1
|
||||||
|
# via django
|
||||||
|
stripe==10.6.0
|
||||||
|
# via hatch.envs.default
|
||||||
|
typing-extensions==4.12.2
|
||||||
|
# via
|
||||||
|
# dj-database-url
|
||||||
|
# django-stubs-ext
|
||||||
|
# jwcrypto
|
||||||
|
# psycopg
|
||||||
|
# py-moneyed
|
||||||
|
# stripe
|
||||||
|
urllib3==2.2.2
|
||||||
|
# via requests
|
||||||
|
uvicorn==0.30.5
|
||||||
|
# via hatch.envs.default
|
||||||
|
whitenoise==6.7.0
|
||||||
|
# via hatch.envs.default
|
||||||
|
|
192
requirements/requirements-dev.txt
Normal file
192
requirements/requirements-dev.txt
Normal file
|
@ -0,0 +1,192 @@
|
||||||
|
#
|
||||||
|
# This file is autogenerated by hatch-pip-compile with Python 3.12
|
||||||
|
#
|
||||||
|
# - coverage[toml]==7.3.0
|
||||||
|
# - pytest==7.2.2
|
||||||
|
# - pytest-cov
|
||||||
|
# - pytest-django==4.5.2
|
||||||
|
# - mypy==1.1.1
|
||||||
|
# - django-stubs==1.16.0
|
||||||
|
# - pip-tools==7.3.0
|
||||||
|
# - django-debug-toolbar==4.2.0
|
||||||
|
# - django-browser-reload==1.7.0
|
||||||
|
# - model-bakery==1.17.0
|
||||||
|
# - django-allauth~=0.63
|
||||||
|
# - django-money~=3.5
|
||||||
|
# - django-oauth-toolkit~=2.4
|
||||||
|
# - django-ratelimit~=4.1
|
||||||
|
# - django-registries==0.0.3
|
||||||
|
# - django-stubs-ext~=5.0
|
||||||
|
# - django-view-decorator==0.0.4
|
||||||
|
# - django-zen-queries~=2.1
|
||||||
|
# - django<5.2,>=5.1b1
|
||||||
|
# - environs[django]<12,>=11
|
||||||
|
# - psycopg[binary]~=3.2
|
||||||
|
# - stripe~=10.5
|
||||||
|
# - uvicorn~=0.30
|
||||||
|
# - whitenoise~=6.7
|
||||||
|
#
|
||||||
|
|
||||||
|
asgiref==3.8.1
|
||||||
|
# via django
|
||||||
|
attrs==23.2.0
|
||||||
|
# via pytest
|
||||||
|
babel==2.15.0
|
||||||
|
# via py-moneyed
|
||||||
|
build==1.2.1
|
||||||
|
# via pip-tools
|
||||||
|
certifi==2024.7.4
|
||||||
|
# via requests
|
||||||
|
cffi==1.16.0
|
||||||
|
# via cryptography
|
||||||
|
charset-normalizer==3.3.2
|
||||||
|
# via requests
|
||||||
|
click==8.1.7
|
||||||
|
# via
|
||||||
|
# pip-tools
|
||||||
|
# uvicorn
|
||||||
|
coverage==7.3.0
|
||||||
|
# via
|
||||||
|
# hatch.envs.dev
|
||||||
|
# pytest-cov
|
||||||
|
cryptography==43.0.0
|
||||||
|
# via jwcrypto
|
||||||
|
dj-database-url==2.2.0
|
||||||
|
# via environs
|
||||||
|
dj-email-url==1.0.6
|
||||||
|
# via environs
|
||||||
|
django==5.1rc1
|
||||||
|
# via
|
||||||
|
# hatch.envs.dev
|
||||||
|
# dj-database-url
|
||||||
|
# django-allauth
|
||||||
|
# django-browser-reload
|
||||||
|
# django-debug-toolbar
|
||||||
|
# django-money
|
||||||
|
# django-oauth-toolkit
|
||||||
|
# django-registries
|
||||||
|
# django-stubs
|
||||||
|
# django-stubs-ext
|
||||||
|
# django-view-decorator
|
||||||
|
# django-zen-queries
|
||||||
|
# model-bakery
|
||||||
|
django-allauth==0.63.6
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-browser-reload==1.7.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-cache-url==3.4.5
|
||||||
|
# via environs
|
||||||
|
django-debug-toolbar==4.2.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-money==3.5.3
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-oauth-toolkit==2.4.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-ratelimit==4.1.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-registries==0.0.3
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-stubs==1.16.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-stubs-ext==5.0.4
|
||||||
|
# via
|
||||||
|
# hatch.envs.dev
|
||||||
|
# django-stubs
|
||||||
|
django-view-decorator==0.0.4
|
||||||
|
# via hatch.envs.dev
|
||||||
|
django-zen-queries==2.1.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
environs==11.0.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
h11==0.14.0
|
||||||
|
# via uvicorn
|
||||||
|
idna==3.7
|
||||||
|
# via requests
|
||||||
|
iniconfig==2.0.0
|
||||||
|
# via pytest
|
||||||
|
jwcrypto==1.5.6
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
marshmallow==3.21.3
|
||||||
|
# via environs
|
||||||
|
model-bakery==1.17.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
mypy==1.1.1
|
||||||
|
# via
|
||||||
|
# hatch.envs.dev
|
||||||
|
# django-stubs
|
||||||
|
mypy-extensions==1.0.0
|
||||||
|
# via mypy
|
||||||
|
oauthlib==3.2.2
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
packaging==24.1
|
||||||
|
# via
|
||||||
|
# build
|
||||||
|
# marshmallow
|
||||||
|
# pytest
|
||||||
|
pip==24.2
|
||||||
|
# via pip-tools
|
||||||
|
pip-tools==7.3.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
pluggy==1.5.0
|
||||||
|
# via pytest
|
||||||
|
psycopg==3.2.1
|
||||||
|
# via hatch.envs.dev
|
||||||
|
psycopg-binary==3.2.1
|
||||||
|
# via psycopg
|
||||||
|
py-moneyed==3.0
|
||||||
|
# via django-money
|
||||||
|
pycparser==2.22
|
||||||
|
# via cffi
|
||||||
|
pyproject-hooks==1.1.0
|
||||||
|
# via build
|
||||||
|
pytest==7.2.2
|
||||||
|
# via
|
||||||
|
# hatch.envs.dev
|
||||||
|
# pytest-cov
|
||||||
|
# pytest-django
|
||||||
|
pytest-cov==5.0.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
pytest-django==4.5.2
|
||||||
|
# via hatch.envs.dev
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
# via environs
|
||||||
|
pytz==2024.1
|
||||||
|
# via django-oauth-toolkit
|
||||||
|
requests==2.32.3
|
||||||
|
# via
|
||||||
|
# django-oauth-toolkit
|
||||||
|
# stripe
|
||||||
|
setuptools==72.1.0
|
||||||
|
# via
|
||||||
|
# django-money
|
||||||
|
# pip-tools
|
||||||
|
sqlparse==0.5.1
|
||||||
|
# via
|
||||||
|
# django
|
||||||
|
# django-debug-toolbar
|
||||||
|
stripe==10.6.0
|
||||||
|
# via hatch.envs.dev
|
||||||
|
tomli==2.0.1
|
||||||
|
# via django-stubs
|
||||||
|
types-pytz==2024.1.0.20240417
|
||||||
|
# via django-stubs
|
||||||
|
types-pyyaml==6.0.12.20240724
|
||||||
|
# via django-stubs
|
||||||
|
typing-extensions==4.12.2
|
||||||
|
# via
|
||||||
|
# dj-database-url
|
||||||
|
# django-stubs
|
||||||
|
# django-stubs-ext
|
||||||
|
# jwcrypto
|
||||||
|
# mypy
|
||||||
|
# psycopg
|
||||||
|
# py-moneyed
|
||||||
|
# stripe
|
||||||
|
urllib3==2.2.2
|
||||||
|
# via requests
|
||||||
|
uvicorn==0.30.5
|
||||||
|
# via hatch.envs.dev
|
||||||
|
wheel==0.43.0
|
||||||
|
# via pip-tools
|
||||||
|
whitenoise==6.7.0
|
||||||
|
# via hatch.envs.dev
|
|
@ -1,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."""
|
94
src/accounting/admin.py
Normal file
94
src/accounting/admin.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
"""Admin for the accounting app."""
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from membership.emails import OrderEmail
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
class OrderProductInline(admin.TabularInline):
|
||||||
|
"""Administer contents of an order inline."""
|
||||||
|
|
||||||
|
model = models.OrderProduct
|
||||||
|
|
||||||
|
|
||||||
|
class OrderAdminForm(forms.ModelForm):
|
||||||
|
"""Special Form for the OrderAdmin so we don't need to require the account field."""
|
||||||
|
|
||||||
|
account = forms.ModelChoiceField(
|
||||||
|
required=False,
|
||||||
|
queryset=models.Account.objects.all(),
|
||||||
|
help_text=_("Leave empty to auto-choose the member's own account or to create one."),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Order
|
||||||
|
exclude = () # noqa: DJ006
|
||||||
|
|
||||||
|
def clean(self): # noqa: ANN201
|
||||||
|
cd = super().clean()
|
||||||
|
if not cd["account"] and cd["member"]:
|
||||||
|
try:
|
||||||
|
cd["account"] = models.Account.objects.get_or_create(owner=cd["member"])[0]
|
||||||
|
except models.Account.MultipleObjectsReturned:
|
||||||
|
cd["account"] = models.Account.objects.filter(owner=cd["member"]).first()
|
||||||
|
return cd
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.Order)
|
||||||
|
class OrderAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for the Order model."""
|
||||||
|
|
||||||
|
inlines = (OrderProductInline,)
|
||||||
|
form = OrderAdminForm
|
||||||
|
|
||||||
|
actions = ("send_order",)
|
||||||
|
|
||||||
|
list_display = ("member", "description", "created", "is_paid", "total_with_vat")
|
||||||
|
search_fields = ("member__email", "membership__membership_type__name", "description")
|
||||||
|
list_filter = ("is_paid", "membership__membership_type")
|
||||||
|
|
||||||
|
@admin.action(description="Send order link to selected unpaid orders")
|
||||||
|
def send_order(self, request: HttpRequest, queryset: QuerySet[models.Order]) -> None:
|
||||||
|
for order in queryset:
|
||||||
|
if order.is_paid:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Order pk={order.id} is already marked paid, not sending email to: {order.member.email}",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
email = OrderEmail(order, request)
|
||||||
|
email.send()
|
||||||
|
messages.success(request, f"Sent an order for order pk={order.id} link to: {order.member.email}")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.Payment)
|
||||||
|
class PaymentAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for the Payment model."""
|
||||||
|
|
||||||
|
list_display = ("order__member", "description", "order_id", "created")
|
||||||
|
|
||||||
|
@admin.display(description=_("Order ID"))
|
||||||
|
def order_id(self, instance: models.Payment) -> int:
|
||||||
|
"""Return the ID of the order."""
|
||||||
|
return instance.order.id
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.Product)
|
||||||
|
class ProductAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "price", "vat")
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionInline(admin.TabularInline):
|
||||||
|
model = models.Transaction
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.Account)
|
||||||
|
class AccountAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("owner", "balance")
|
||||||
|
inlines = (TransactionInline,)
|
13
src/accounting/apps.py
Normal file
13
src/accounting/apps.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
"""Accounting app configuration."""
|
||||||
|
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingConfig(AppConfig):
|
||||||
|
"""Accounting app config."""
|
||||||
|
|
||||||
|
name = "accounting"
|
||||||
|
|
||||||
|
def ready(self) -> None:
|
||||||
|
"""Implicitly connect a signal handlers decorated with @receiver."""
|
||||||
|
from . import signals # noqa: F401
|
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,
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Generated by Django 5.0.7 on 2024-07-21 14:12
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import djmoney.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounting', '0003_alter_payment_stripe_charge_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PaymentType',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')),
|
||||||
|
('name', models.CharField(max_length=1024, verbose_name='description')),
|
||||||
|
('description', models.TextField(blank=True, max_length=2048)),
|
||||||
|
('enabled', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Product',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')),
|
||||||
|
('name', models.CharField(max_length=512)),
|
||||||
|
('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)),
|
||||||
|
('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
|
||||||
|
('vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)),
|
||||||
|
('vat', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='external_transaction_id',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='stripe_charge_id',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='payment_type',
|
||||||
|
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='accounting.paymenttype'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OrderProduct',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='oprettet')),
|
||||||
|
('price_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)),
|
||||||
|
('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
|
||||||
|
('vat_currency', djmoney.models.fields.CurrencyField(choices=[('DKK', 'DKK')], default=None, editable=False, max_length=3)),
|
||||||
|
('vat', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16)),
|
||||||
|
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ordered_products', to='accounting.order')),
|
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ordered_products', to='accounting.product')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Generated by Django 5.0.7 on 2024-07-21 14:53
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounting', '0004_paymenttype_product_payment_external_transaction_id_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='price',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='price_currency',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='vat',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='vat_currency',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderproduct',
|
||||||
|
name='order',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='order_products', to='accounting.order'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderproduct',
|
||||||
|
name='product',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='order_products', to='accounting.product'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 5.0.7 on 2024-07-21 15:17
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounting', '0005_remove_order_price_remove_order_price_currency_and_more'),
|
||||||
|
('membership', '0006_waitinglistentry_alter_membership_options'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='order',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='membership.member'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Generated by Django 5.1b1 on 2024-08-01 10:50
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounting', '0006_alter_account_owner_alter_order_user'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='orderproduct',
|
||||||
|
options={'verbose_name': 'ordered product', 'verbose_name_plural': 'ordered products'},
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='order',
|
||||||
|
old_name='user',
|
||||||
|
new_name='member',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='payment',
|
||||||
|
name='stripe_charge_id',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='orderproduct',
|
||||||
|
name='quantity',
|
||||||
|
field=models.PositiveSmallIntegerField(default=1),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderproduct',
|
||||||
|
name='order',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='accounting.order'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderproduct',
|
||||||
|
name='product',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='accounting.product'),
|
||||||
|
),
|
||||||
|
]
|
209
src/accounting/models.py
Normal file
209
src/accounting/models.py
Normal file
|
@ -0,0 +1,209 @@
|
||||||
|
"""Models for the accounting app."""
|
||||||
|
|
||||||
|
from hashlib import md5
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models.aggregates import Sum
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from django.utils.translation import pgettext_lazy
|
||||||
|
from djmoney.models.fields import MoneyField
|
||||||
|
from djmoney.money import Money
|
||||||
|
|
||||||
|
|
||||||
|
class CreatedModifiedAbstract(models.Model):
|
||||||
|
"""Abstract model to track creation and modification of objects."""
|
||||||
|
|
||||||
|
modified = models.DateTimeField(auto_now=True, verbose_name=_("modified"))
|
||||||
|
created = models.DateTimeField(auto_now_add=True, verbose_name=_("created"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class Account(CreatedModifiedAbstract):
|
||||||
|
"""An account for a user.
|
||||||
|
|
||||||
|
This is the model where we can give access to several users, such that they
|
||||||
|
can decide which account to use to pay for something.
|
||||||
|
"""
|
||||||
|
|
||||||
|
owner = models.ForeignKey("membership.Member", on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Account of {self.owner}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def balance(self) -> Money:
|
||||||
|
"""Return the balance of the account."""
|
||||||
|
return self.transactions.all().aggregate(Sum("amount")).get("amount", 0)
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(CreatedModifiedAbstract):
|
||||||
|
"""A transaction.
|
||||||
|
|
||||||
|
Tracks in and outgoing events of an account. When an order is received, an
|
||||||
|
amount is subtracted, when a payment is received, an amount is added.
|
||||||
|
"""
|
||||||
|
|
||||||
|
account = models.ForeignKey(
|
||||||
|
Account,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="transactions",
|
||||||
|
)
|
||||||
|
amount = MoneyField(
|
||||||
|
verbose_name=_("amount"),
|
||||||
|
max_digits=16,
|
||||||
|
decimal_places=2,
|
||||||
|
help_text=_("This will include VAT"),
|
||||||
|
)
|
||||||
|
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):
|
||||||
|
"""An order.
|
||||||
|
|
||||||
|
We assemble the order from a number of products. Once an order is paid, the contents should be
|
||||||
|
considered locked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
member = models.ForeignKey("membership.Member", on_delete=models.PROTECT)
|
||||||
|
account = models.ForeignKey(Account, on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
description = models.CharField(max_length=1024, verbose_name=_("description"))
|
||||||
|
|
||||||
|
is_paid = models.BooleanField(default=False, verbose_name=_("is paid"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = pgettext_lazy("accounting term", "Order")
|
||||||
|
verbose_name_plural = pgettext_lazy("accounting term", "Orders")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Order ID {self.display_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> Money:
|
||||||
|
"""Return the total price of the order (excl VAT)."""
|
||||||
|
return sum(item.price * item.quantity for item in self.items.all())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_vat(self) -> Money:
|
||||||
|
"""Return the total VAT of the order."""
|
||||||
|
return sum(item.vat * item.quantity for item in self.items.all())
|
||||||
|
|
||||||
|
@property
|
||||||
|
@admin.display(
|
||||||
|
ordering=None,
|
||||||
|
description="Total (incl. VAT)",
|
||||||
|
boolean=False,
|
||||||
|
)
|
||||||
|
def total_with_vat(self) -> Money:
|
||||||
|
"""Return the TOTAL amount WITH VAT."""
|
||||||
|
return self.total + self.total_vat
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_id(self) -> str:
|
||||||
|
"""Return an id for the order."""
|
||||||
|
return str(self.id).zfill(6)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payment_token(self) -> 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 Product(CreatedModifiedAbstract):
|
||||||
|
"""A generic product, for instance a membership or a service fee."""
|
||||||
|
|
||||||
|
name = models.CharField(max_length=512)
|
||||||
|
price = MoneyField(max_digits=16, decimal_places=2)
|
||||||
|
vat = MoneyField(max_digits=16, decimal_places=2)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class OrderProduct(CreatedModifiedAbstract):
|
||||||
|
"""When a product is ordered, we store the product on the order.
|
||||||
|
|
||||||
|
This includes pricing information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
|
||||||
|
product = models.ForeignKey(Product, on_delete=models.PROTECT)
|
||||||
|
price = MoneyField(max_digits=16, decimal_places=2)
|
||||||
|
vat = MoneyField(max_digits=16, decimal_places=2)
|
||||||
|
quantity = models.PositiveSmallIntegerField(default=1)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("ordered product")
|
||||||
|
verbose_name_plural = _("ordered products")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.product.name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_with_vat(self) -> Money:
|
||||||
|
"""Total price of this item."""
|
||||||
|
return (self.price + self.vat) * self.quantity
|
||||||
|
|
||||||
|
|
||||||
|
class Payment(CreatedModifiedAbstract):
|
||||||
|
"""A payment is a transaction that is made to pay for an order."""
|
||||||
|
|
||||||
|
amount = MoneyField(max_digits=16, decimal_places=2)
|
||||||
|
order = models.ForeignKey(Order, on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
description = models.CharField(max_length=1024, verbose_name=_("description"))
|
||||||
|
|
||||||
|
payment_type = models.ForeignKey("PaymentType", on_delete=models.PROTECT)
|
||||||
|
external_transaction_id = models.CharField(max_length=255, default="", blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("payment")
|
||||||
|
verbose_name_plural = _("payments")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Payment ID {self.display_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_id(self) -> str:
|
||||||
|
"""Return an id for the payment."""
|
||||||
|
return str(self.id).zfill(6)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_order(cls, order: Order, payment_type: "PaymentType") -> Self:
|
||||||
|
"""Create a payment from an order."""
|
||||||
|
return cls.objects.create(
|
||||||
|
order=order,
|
||||||
|
user=order.user,
|
||||||
|
amount=order.total + order.total_vat,
|
||||||
|
description=order.description,
|
||||||
|
payment_type=payment_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentType(CreatedModifiedAbstract):
|
||||||
|
"""Types of payments available in the system.
|
||||||
|
|
||||||
|
- bank transfer
|
||||||
|
- card payment (specific provider)
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(max_length=1024, verbose_name=_("description"))
|
||||||
|
description = models.TextField(max_length=2048, blank=True)
|
||||||
|
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.name}"
|
36
src/accounting/signals.py
Normal file
36
src/accounting/signals.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
"""Loaded with the AppConfig.ready() method."""
|
||||||
|
|
||||||
|
from django.core.mail import mail_admins
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.utils import timezone
|
||||||
|
from membership.models import Membership
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
# method for updating
|
||||||
|
@receiver(post_save, sender=models.Payment)
|
||||||
|
def check_total_amount(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None: # noqa: ARG001
|
||||||
|
"""Check that we receive Payments with the correct amount."""
|
||||||
|
if instance.amount != instance.order.total_with_vat:
|
||||||
|
mail_admins(
|
||||||
|
"Payment received: wrong amount",
|
||||||
|
f"Please check payment ID {instance.pk}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=models.Payment)
|
||||||
|
def mark_order_paid(sender: models.Payment, instance: models.Payment, **kwargs: dict) -> None: # noqa: ARG001
|
||||||
|
"""Mark an order as paid when payment is received."""
|
||||||
|
instance.order.is_paid = True
|
||||||
|
instance.order.save()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=models.Order)
|
||||||
|
def activate_membership(sender: models.Order, instance: models.Order, **kwargs: dict) -> None: # noqa: ARG001
|
||||||
|
"""Mark a membership as activated when its order is marked as paid."""
|
||||||
|
if instance.is_paid:
|
||||||
|
Membership.objects.filter(order=instance, activated=False, activated_on=None).update(
|
||||||
|
activated=True, activated_on=timezone.now()
|
||||||
|
)
|
18
src/accounting/templates/accounting/order/cancel.html
Normal file
18
src/accounting/templates/accounting/order/cancel.html
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% trans "Payment cancelled" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
<h2>{% trans "Payment canceled" %}</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="{% order:detail order_id=order.id %}">{% trans "Return to order page" %}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
49
src/accounting/templates/accounting/order/detail.html
Normal file
49
src/accounting/templates/accounting/order/detail.html
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% trans "Order" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
<h2>Order: {{ order.id }}</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% trans "Ordered" %}: {{ order.created }}<br>
|
||||||
|
{% trans "Status" %}: {{ order.is_paid|yesno:_("paid,unpaid") }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Item" %}</th>
|
||||||
|
<th>{% trans "Quantity" %}</th>
|
||||||
|
<th>{% trans "Price" %}</th>
|
||||||
|
<th>{% trans "VAT" %}</th>
|
||||||
|
<th>{% trans "Total" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in order.items.all %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.product.name }}</td>
|
||||||
|
<td>{{ item.quantity }}</td>
|
||||||
|
<td>{{ item.price }}</td>
|
||||||
|
<td>{{ item.vat }}</td>
|
||||||
|
<td>{{ item.total_with_vat }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>{% trans "Total price" %}: {{ order.total_with_vat }}</h2>
|
||||||
|
|
||||||
|
{% if not order.is_paid %}
|
||||||
|
<p>
|
||||||
|
<a href="{% url "order:pay" order_id=order.pk %}" class="button">{% trans "Pay now" %}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
20
src/accounting/templates/accounting/order/success.html
Normal file
20
src/accounting/templates/accounting/order/success.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% trans "Payment received" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
<h2>{% trans "Payment received" %}</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed with order.id as order_id %}
|
||||||
|
Thanks fellow member! We received your payment for Order {{ order_id }}. We're adding more features to the site, so expect to see a confirmation email (receipt) for the order soon.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -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
|
177
src/accounting/views.py
Normal file
177
src/accounting/views.py
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
"""Views for the membership app."""
|
||||||
|
|
||||||
|
import stripe
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.mail import mail_admins
|
||||||
|
from django.db import transaction
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django_view_decorator import namespaced_decorator_factory
|
||||||
|
from djmoney.money import Money
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
order_view = namespaced_decorator_factory(namespace="order", base_path="order")
|
||||||
|
|
||||||
|
stripe.api_key = settings.STRIPE_API_KEY
|
||||||
|
|
||||||
|
|
||||||
|
@order_view(
|
||||||
|
paths="<int:order_id>/",
|
||||||
|
name="detail",
|
||||||
|
login_required=True,
|
||||||
|
)
|
||||||
|
def order_detail(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||||
|
"""View to show the details of a member."""
|
||||||
|
user = request.user # People just need to login to pay something, not necessarily be a member
|
||||||
|
order = models.Order.objects.get(pk=order_id, member=user)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"order": order,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request=request,
|
||||||
|
template_name="accounting/order/detail.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@order_view(
|
||||||
|
paths="<int:order_id>/pay/",
|
||||||
|
name="pay",
|
||||||
|
login_required=True,
|
||||||
|
)
|
||||||
|
def order_pay(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||||
|
"""Create a Stripe session and redirects to Stripe Checkout."""
|
||||||
|
user = request.user # People just need to login to pay something, not necessarily be a member
|
||||||
|
order = models.Order.objects.get(pk=order_id, member=user)
|
||||||
|
current_site = Site.objects.get_current(request)
|
||||||
|
base_domain = f"https://{current_site.domain}"
|
||||||
|
if settings.DEBUG:
|
||||||
|
f"http://{current_site.domain}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
line_items = []
|
||||||
|
for item in order.items.all():
|
||||||
|
line_items.append( # noqa: PERF401
|
||||||
|
{
|
||||||
|
"price_data": {
|
||||||
|
"currency": item.total_with_vat.currency,
|
||||||
|
"unit_amount": int((item.price + item.vat).amount * 100),
|
||||||
|
"product_data": {
|
||||||
|
"name": item.product.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"quantity": item.quantity,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
checkout_session = stripe.checkout.Session.create(
|
||||||
|
line_items=line_items,
|
||||||
|
metadata={"order_id": order.id},
|
||||||
|
mode="payment",
|
||||||
|
success_url=base_domain + reverse("order:success", kwargs={"order_id": order.id}),
|
||||||
|
cancel_url=base_domain + "/cancel",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
mail_admins("Error in checkout", str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
# TODO: Redirect with status=303
|
||||||
|
return redirect(checkout_session.url)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
@order_view(
|
||||||
|
paths="<int:order_id>/pay/success/",
|
||||||
|
name="success",
|
||||||
|
login_required=True,
|
||||||
|
)
|
||||||
|
def success(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||||
|
"""Create a Stripe session and redirects to Stripe Checkout.
|
||||||
|
|
||||||
|
From Stripe docs: When you have a webhook endpoint set up to listen for checkout.session.completed events and
|
||||||
|
you set a success_url, Checkout waits for your server to respond to the webhook event delivery before redirecting
|
||||||
|
your customer. If you use this approach, make sure your server responds to checkout.session.completed events as
|
||||||
|
quickly as possible.
|
||||||
|
"""
|
||||||
|
user = request.user # People just need to login to pay something, not necessarily be a member
|
||||||
|
order = get_object_or_404(models.Order, pk=order_id, member=user)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"order": order,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request=request,
|
||||||
|
template_name="accounting/order/success.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
@order_view(
|
||||||
|
paths="<int:order_id>/pay/cancel/",
|
||||||
|
name="cancel",
|
||||||
|
login_required=True,
|
||||||
|
)
|
||||||
|
def cancel(request: HttpRequest, order_id: int) -> HttpResponse:
|
||||||
|
"""Page to display when a payment is canceled."""
|
||||||
|
user = request.user # People just need to login to pay something, not necessarily be a member
|
||||||
|
order = models.Order.objects.get(pk=order_id, member=user)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"order": order,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request=request,
|
||||||
|
template_name="accounting/order/cancel.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
@order_view(
|
||||||
|
paths="stripe/webhook/",
|
||||||
|
name="webhook",
|
||||||
|
)
|
||||||
|
@csrf_exempt
|
||||||
|
def stripe_webhook(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Handle Stripe webhook.
|
||||||
|
|
||||||
|
https://docs.stripe.com/metadata/use-cases
|
||||||
|
"""
|
||||||
|
payload = request.body
|
||||||
|
sig_header = request.headers["stripe-signature"]
|
||||||
|
event = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_ENDPOINT_SECRET)
|
||||||
|
except ValueError:
|
||||||
|
# Invalid payload
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
except stripe.error.SignatureVerificationError:
|
||||||
|
# Invalid signature
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
if event["type"] == "checkout.session.completed" or event["type"] == "checkout.session.async_payment_succeeded":
|
||||||
|
# Order is marked paid via signals, Membership is activated via signals.
|
||||||
|
order_id = event["data"]["object"]["metadata"]["order_id"]
|
||||||
|
order = get_object_or_404(models.Order, pk=order_id)
|
||||||
|
if not models.Payment.objects.filter(order=order).exists():
|
||||||
|
models.Payment.objects.create(
|
||||||
|
order=order,
|
||||||
|
amount=Money(event["data"]["object"]["amount_total"] / 100.0, event["data"]["object"]["currency"]),
|
||||||
|
description="Paid via Stripe",
|
||||||
|
payment_type=models.PaymentType.objects.get_or_create(name="Stripe")[0],
|
||||||
|
external_transaction_id=event["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return HttpResponse(status=200)
|
|
@ -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.
|
||||||
|
|
||||||
"""
|
"""
|
168
src/membership/admin.py
Normal file
168
src/membership/admin.py
Normal file
|
@ -0,0 +1,168 @@
|
||||||
|
"""Admin configuration for membership app."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from accounting.models import Account
|
||||||
|
from accounting.models import Order
|
||||||
|
from accounting.models import OrderProduct
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.admin import ModelAdmin
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
from .emails import InviteEmail
|
||||||
|
from .models import Member
|
||||||
|
from .models import Membership
|
||||||
|
from .models import MembershipType
|
||||||
|
from .models import SubscriptionPeriod
|
||||||
|
from .models import WaitingListEntry
|
||||||
|
|
||||||
|
# Do not use existing user admin
|
||||||
|
admin.site.unregister(User)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Membership)
|
||||||
|
class MembershipAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for Membership model."""
|
||||||
|
|
||||||
|
list_display = ("user", "period", "membership_type", "activated", "revoked")
|
||||||
|
list_filter = ("period", "membership_type", "activated", "revoked")
|
||||||
|
search_fields = ("membership_type__name", "user__email", "user__first_name", "user__last_name")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MembershipType)
|
||||||
|
class MembershipTypeAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for MembershipType model."""
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SubscriptionPeriod)
|
||||||
|
class SubscriptionPeriodAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for SubscriptionPeriod model."""
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipInlineAdmin(admin.TabularInline):
|
||||||
|
"""Inline admin."""
|
||||||
|
|
||||||
|
model = Membership
|
||||||
|
|
||||||
|
|
||||||
|
def decorate_ensure_membership_type_exists(membership_type: MembershipType, label: str) -> Callable:
|
||||||
|
"""Generate an admin action for given membership type and label."""
|
||||||
|
|
||||||
|
@admin.action(description=label)
|
||||||
|
def admin_action(modeladmin: ModelAdmin, request: HttpRequest, queryset: QuerySet) -> HttpResponse: # noqa: ARG001
|
||||||
|
return ensure_membership_type_exists(request, queryset, membership_type)
|
||||||
|
|
||||||
|
return admin_action
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def ensure_membership_type_exists(
|
||||||
|
request: HttpRequest,
|
||||||
|
queryset: QuerySet[Member],
|
||||||
|
membership_type: MembershipType,
|
||||||
|
) -> HttpResponse:
|
||||||
|
"""Inner function that ensures that a membership exists for a given queryset of Member objects."""
|
||||||
|
for member in queryset:
|
||||||
|
if member.memberships.filter(membership_type=membership_type).current():
|
||||||
|
messages.info(request, f"{member} already has a membership {membership_type}")
|
||||||
|
else:
|
||||||
|
# Get the default account of the member. We don't really know what to do if a person owns multiple accounts.
|
||||||
|
account, __ = Account.objects.get_or_create(owner=member)
|
||||||
|
# Create an Order for the products in the membership
|
||||||
|
order = Order.objects.create(member=member, account=account, description=membership_type.name)
|
||||||
|
# Add stuff to the order
|
||||||
|
for product in membership_type.products.all():
|
||||||
|
OrderProduct.objects.create(order=order, product=product, price=product.price, vat=product.vat)
|
||||||
|
# Create the Membership
|
||||||
|
Membership.objects.create(
|
||||||
|
membership_type=membership_type,
|
||||||
|
user=member,
|
||||||
|
period=SubscriptionPeriod.objects.current(),
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Associate the order with that membership
|
||||||
|
messages.success(request, f"{member} has ordered a '{membership_type}' (unpaid)")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Member)
|
||||||
|
class MemberAdmin(UserAdmin):
|
||||||
|
"""Member admin is actually an admin for User objects."""
|
||||||
|
|
||||||
|
inlines = (MembershipInlineAdmin,)
|
||||||
|
actions: list[str | Callable] = ["send_invite"] # noqa: RUF012
|
||||||
|
list_display = ("email", "current_membership", "username", "is_staff", "is_active", "date_joined")
|
||||||
|
|
||||||
|
@admin.display(description="membership")
|
||||||
|
def current_membership(self, instance: Member) -> Membership | None:
|
||||||
|
return instance.memberships.current()
|
||||||
|
|
||||||
|
def get_actions(self, request: HttpRequest) -> dict:
|
||||||
|
"""Populate actions with dynamic data (MembershipType)."""
|
||||||
|
current_period = SubscriptionPeriod.objects.current()
|
||||||
|
|
||||||
|
super_dict = super().get_actions(request)
|
||||||
|
|
||||||
|
if current_period:
|
||||||
|
for i, mtype in enumerate(MembershipType.objects.filter(active=True)):
|
||||||
|
action_label = f"Ensure membership {mtype.name}, {current_period.period}, {mtype.total_including_vat}"
|
||||||
|
action_func = decorate_ensure_membership_type_exists(mtype, action_label)
|
||||||
|
# Django ModelAdmin uses the non-unique __name__ property, so we need to suffix it to make it unique
|
||||||
|
action_func.__name__ += f"_{i}"
|
||||||
|
self.actions.append(action_func)
|
||||||
|
|
||||||
|
return super_dict
|
||||||
|
|
||||||
|
@admin.action(description="Send invite email to selected inactive accounts")
|
||||||
|
def send_invite(self, request: HttpRequest, queryset: QuerySet[Member]) -> None:
|
||||||
|
for member in queryset:
|
||||||
|
if member.is_active:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Computer says no! This member will not receive an invite because the account is marked "
|
||||||
|
f"as active: {member.email}. That means the member has probably created a password and a username "
|
||||||
|
f"already, please tell them to use the password reminder function.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not member.memberships.current():
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Computer says no! This member will not receive an invite because it has no current "
|
||||||
|
f"membership: {member.email}. You need to create a current membership before sending the invite.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
membership = member.memberships.current()
|
||||||
|
email = InviteEmail(membership, request)
|
||||||
|
email.send()
|
||||||
|
messages.success(request, f"Sent an invitation to: {member.email}")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(WaitingListEntry)
|
||||||
|
class WaitingListEntryAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for WaitingList model."""
|
||||||
|
|
||||||
|
list_display = ("email", "member")
|
||||||
|
actions = ("create_member",)
|
||||||
|
|
||||||
|
@admin.action(description="Create member account for entries")
|
||||||
|
def create_member(self, request: HttpRequest, queryset: QuerySet[WaitingListEntry]) -> None:
|
||||||
|
"""Create a user account for this entry.
|
||||||
|
|
||||||
|
Note that actions can soon be made available from the edit page, too:
|
||||||
|
https://github.com/django/django/pull/16012
|
||||||
|
"""
|
||||||
|
for entry in queryset:
|
||||||
|
member = Member.objects.create_user(email=entry.email, username=slugify(entry.email), is_active=False)
|
||||||
|
entry.member = member
|
||||||
|
entry.save()
|
||||||
|
messages.info(
|
||||||
|
request,
|
||||||
|
f"Added user for {entry.email} - ensure they have a membership and send an invite email.",
|
||||||
|
)
|
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)
|
126
src/membership/emails.py
Normal file
126
src/membership/emails.py
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
"""Send email to members, using templates and contexts for the emails.
|
||||||
|
|
||||||
|
* We keep everything as plain text for now.
|
||||||
|
* Notice that emails can be multilingual
|
||||||
|
* Generally, an email consists of templates (for body and subject) and a get_context() method.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from accounting.models import Order
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.tokens import default_token_generator
|
||||||
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
from django.core.mail.message import EmailMessage
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.template import loader
|
||||||
|
from django.utils import translation
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from .models import Membership
|
||||||
|
|
||||||
|
|
||||||
|
class BaseEmail(EmailMessage):
|
||||||
|
"""Send emails via templated body and subjects.
|
||||||
|
|
||||||
|
This base class is extended for all email functionality.
|
||||||
|
Because all emails are sent to the Member object, we can keep them gathered here, even when they are generated by
|
||||||
|
other apps (like the accounting app).
|
||||||
|
"""
|
||||||
|
|
||||||
|
template = "membership/email/base.txt"
|
||||||
|
# Optional: Set to a template path for subject
|
||||||
|
template_subject = None
|
||||||
|
default_subject = "SET SUBJECT HERE"
|
||||||
|
|
||||||
|
def __init__(self, request: HttpRequest, *args, **kwargs) -> None:
|
||||||
|
self.context = kwargs.pop("context", {})
|
||||||
|
self.user = kwargs.pop("user", None)
|
||||||
|
if self.user:
|
||||||
|
kwargs["to"] = [self.user.email]
|
||||||
|
self.context["user"] = self.user
|
||||||
|
self.context["recipient_name"] = self.user.get_display_name()
|
||||||
|
|
||||||
|
# Necessary to set request before instantiating body and subject
|
||||||
|
self.request = request
|
||||||
|
kwargs.setdefault("subject", self.get_subject())
|
||||||
|
kwargs.setdefault("body", self.get_body())
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self) -> dict:
|
||||||
|
"""Resolve common context for sending emails.
|
||||||
|
|
||||||
|
When overwriting, remember to call this via super().
|
||||||
|
"""
|
||||||
|
c = self.context
|
||||||
|
site = get_current_site(self.request)
|
||||||
|
c["request"] = self.request
|
||||||
|
c["domain"] = site.domain
|
||||||
|
c["site_name"] = site.name
|
||||||
|
c["protocol"] = "http" if self.request and not self.request.is_secure() else "https"
|
||||||
|
return c
|
||||||
|
|
||||||
|
def get_body(self) -> str:
|
||||||
|
"""Build the email body from template and context."""
|
||||||
|
if self.user and self.user.language_code:
|
||||||
|
with translation.override(self.user.language_code):
|
||||||
|
body = loader.render_to_string(self.template, self.get_context_data())
|
||||||
|
else:
|
||||||
|
body = loader.render_to_string(self.template, self.get_context_data())
|
||||||
|
return body
|
||||||
|
|
||||||
|
def get_subject(self) -> str:
|
||||||
|
"""Build the email subject from template or self.default_subject."""
|
||||||
|
if self.user and self.user.language_code:
|
||||||
|
with translation.override(self.user.language_code):
|
||||||
|
if self.template_subject:
|
||||||
|
subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip()
|
||||||
|
else:
|
||||||
|
subject = str(self.default_subject)
|
||||||
|
elif self.template_subject:
|
||||||
|
subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip()
|
||||||
|
else:
|
||||||
|
subject = str(self.default_subject)
|
||||||
|
return subject
|
||||||
|
|
||||||
|
def send_with_feedback(self, *, success_msg: str | None = None, no_message: bool = False) -> None:
|
||||||
|
"""Send email, possibly adding feedback via django.contrib.messages."""
|
||||||
|
if not success_msg:
|
||||||
|
success_msg = _("Email successfully sent to {}").format(", ".join(self.to))
|
||||||
|
try:
|
||||||
|
self.send(fail_silently=False)
|
||||||
|
if not no_message:
|
||||||
|
messages.success(self.request, success_msg)
|
||||||
|
except RuntimeError:
|
||||||
|
messages.error(self.request, _("Not sent, something wrong with the mail server."))
|
||||||
|
|
||||||
|
|
||||||
|
class InviteEmail(BaseEmail):
|
||||||
|
template = "membership/emails/invite.txt"
|
||||||
|
default_subject = _("Invite to data.coop membership")
|
||||||
|
|
||||||
|
def __init__(self, membership: Membership, request: HttpRequest, *args, **kwargs) -> None:
|
||||||
|
self.membership = membership
|
||||||
|
kwargs["user"] = membership.user
|
||||||
|
super().__init__(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self) -> dict:
|
||||||
|
c = super().get_context_data()
|
||||||
|
c["membership"] = self.membership
|
||||||
|
c["token"] = default_token_generator.make_token(self.membership.user)
|
||||||
|
c["referral_code"] = self.membership.referral_code
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
class OrderEmail(BaseEmail):
|
||||||
|
template = "membership/emails/order.txt"
|
||||||
|
default_subject = _("Your data.coop order and payment")
|
||||||
|
|
||||||
|
def __init__(self, order: Order, request: HttpRequest, *args, **kwargs) -> None:
|
||||||
|
self.order = order
|
||||||
|
kwargs["user"] = order.member
|
||||||
|
super().__init__(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self) -> dict:
|
||||||
|
c = super().get_context_data()
|
||||||
|
c["order"] = self.order
|
||||||
|
return c
|
39
src/membership/forms.py
Normal file
39
src/membership/forms.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
from allauth.account.adapter import get_adapter as get_allauth_adapter
|
||||||
|
from allauth.account.forms import SetPasswordForm
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class InviteForm(SetPasswordForm):
|
||||||
|
"""Create a new password for a user account that is created through an invite."""
|
||||||
|
|
||||||
|
username = forms.CharField(
|
||||||
|
label=_("Username"),
|
||||||
|
widget=forms.TextInput(attrs={"placeholder": _("Username"), "autocomplete": "username"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
self.membership = kwargs.pop("membership")
|
||||||
|
kwargs["user"] = self.membership.user
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean_username(self) -> str:
|
||||||
|
"""Clean the username value.
|
||||||
|
|
||||||
|
Taken from the allauth Signup form - we should consider that data can be leaked here.
|
||||||
|
"""
|
||||||
|
value = self.cleaned_data["username"]
|
||||||
|
# The allauth adapter ensures the username is unique.
|
||||||
|
return get_allauth_adapter().clean_username(value)
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Save instance to db.
|
||||||
|
|
||||||
|
Note: You can hack a re-activation of a deactivated account
|
||||||
|
by getting a valid token before deactivation (from the reset password form).
|
||||||
|
We can block this by also setting Membership.revoked=False when deactivating someone's account.
|
||||||
|
"""
|
||||||
|
self.user.username = self.cleaned_data["username"]
|
||||||
|
self.user.is_active = True
|
||||||
|
self.user.save()
|
||||||
|
super().save()
|
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",),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 5.0.7 on 2024-07-20 20:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('membership', '0005_member'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WaitingListEntry',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('geography', models.CharField(blank=True, default='', verbose_name='geography')),
|
||||||
|
('comment', models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'waiting list entry',
|
||||||
|
'verbose_name_plural': 'waiting list entries',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='membership',
|
||||||
|
options={'verbose_name': 'medlemskab', 'verbose_name_plural': 'medlemskaber'},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Generated by Django 5.1b1 on 2024-08-01 10:50
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounting', '0007_alter_orderproduct_options_rename_user_order_member_and_more'),
|
||||||
|
('membership', '0006_waitinglistentry_alter_membership_options'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='membership',
|
||||||
|
name='activated',
|
||||||
|
field=models.BooleanField(default=False, help_text='Membership was activated.', verbose_name='activated'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='membership',
|
||||||
|
name='activated_on',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='membership',
|
||||||
|
name='order',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='The order filled in for paying this membership.', null=True, on_delete=django.db.models.deletion.PROTECT, to='accounting.order', verbose_name='order'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='membership',
|
||||||
|
name='revoked',
|
||||||
|
field=models.BooleanField(default=False, help_text='Membership has explicitly been revoked. Revoking a membership is not associated with regular expiration of the membership period.', verbose_name='revoked'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='membership',
|
||||||
|
name='revoked_on',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='membership',
|
||||||
|
name='revoked_reason',
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='membershiptype',
|
||||||
|
name='active',
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='membershiptype',
|
||||||
|
name='products',
|
||||||
|
field=models.ManyToManyField(to='accounting.product'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='membership',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 5.1b1 on 2024-08-04 10:26
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('membership', '0007_membership_activated_membership_activated_on_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='membership',
|
||||||
|
name='membership_type',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='membership.membershiptype', verbose_name='membership type'),
|
||||||
|
),
|
||||||
|
]
|
32
src/membership/migrations/0009_membership_referral_code.py
Normal file
32
src/membership/migrations/0009_membership_referral_code.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 5.1rc1 on 2024-08-07 22:32
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def create_uuid(apps, schema_editor):
|
||||||
|
Membership = apps.get_model('membership', 'Membership')
|
||||||
|
for membership in Membership.objects.all():
|
||||||
|
membership.referral_code = uuid.uuid4()
|
||||||
|
membership.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('membership', '0008_alter_membership_membership_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='membership',
|
||||||
|
name='referral_code',
|
||||||
|
field=models.UUIDField(blank=True, null=True, default=uuid.uuid4, editable=False),
|
||||||
|
),
|
||||||
|
migrations.RunPython(create_uuid),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='membership',
|
||||||
|
name='referral_code',
|
||||||
|
field=models.UUIDField(unique=True, default=uuid.uuid4, editable=False),
|
||||||
|
),
|
||||||
|
]
|
19
src/membership/migrations/0010_waitinglistentry_member.py
Normal file
19
src/membership/migrations/0010_waitinglistentry_member.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 5.1rc1 on 2024-08-14 08:05
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('membership', '0009_membership_referral_code'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='waitinglistentry',
|
||||||
|
name='member',
|
||||||
|
field=models.ForeignKey(help_text='Once a member account is generated (use the admin action), this field will be marked.', null=True, blank=True, on_delete=django.db.models.deletion.CASCADE, to='membership.member', verbose_name='has member'),
|
||||||
|
),
|
||||||
|
]
|
247
src/membership/models.py
Normal file
247
src/membership/models.py
Normal file
|
@ -0,0 +1,247 @@
|
||||||
|
"""Models for the membership app."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import ClassVar
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.contrib.auth.models import UserManager
|
||||||
|
from django.contrib.postgres.constraints import ExclusionConstraint
|
||||||
|
from django.contrib.postgres.fields import DateRangeField
|
||||||
|
from django.contrib.postgres.fields import RangeOperators
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from djmoney.money import Money
|
||||||
|
from utils.mixins import CreatedModifiedAbstract
|
||||||
|
|
||||||
|
|
||||||
|
class NoSubscriptionPeriodFoundError(Exception):
|
||||||
|
"""Raised when no subscription period is found."""
|
||||||
|
|
||||||
|
|
||||||
|
class Member(User):
|
||||||
|
"""Proxy model for the User model to add some convenience methods."""
|
||||||
|
|
||||||
|
class QuerySet(models.QuerySet):
|
||||||
|
"""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 = UserManager.from_queryset(QuerySet)()
|
||||||
|
|
||||||
|
def get_display_name(self) -> str:
|
||||||
|
"""Choose how to display the user in emails and UI and ultimately to other users.
|
||||||
|
|
||||||
|
It's crucial that we currently don't have a good solution for this.
|
||||||
|
We should allow the user to define their own nick.
|
||||||
|
"""
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
@property
|
||||||
|
def language_code(self) -> str:
|
||||||
|
"""Returns the user's preferred language code.
|
||||||
|
|
||||||
|
We don't have an actual setting for this... because this is a proxy table.
|
||||||
|
"""
|
||||||
|
return "da-dk"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
proxy = True
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionPeriod(CreatedModifiedAbstract):
|
||||||
|
"""A subscription period.
|
||||||
|
|
||||||
|
Denotes a period for which members should pay their membership fee for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class QuerySet(models.QuerySet):
|
||||||
|
"""QuerySet for the Membership model."""
|
||||||
|
|
||||||
|
def _current(self) -> Self:
|
||||||
|
"""Filter memberships for the current period."""
|
||||||
|
return self.filter(period__contains=timezone.now())
|
||||||
|
|
||||||
|
def current(self) -> "Membership | None":
|
||||||
|
"""Get the current membership."""
|
||||||
|
try:
|
||||||
|
return self._current().get()
|
||||||
|
except self.model.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
objects = QuerySet.as_manager()
|
||||||
|
|
||||||
|
period = DateRangeField(verbose_name=_("period"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints: 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 active(self) -> Self:
|
||||||
|
"""Get only activated, non-revoked memberships (may have expired so use also current())."""
|
||||||
|
return self.filter(activated=True, revoked=False)
|
||||||
|
|
||||||
|
def _current(self) -> Self:
|
||||||
|
"""Filter memberships for the current period."""
|
||||||
|
return self.filter(period__period__contains=timezone.now())
|
||||||
|
|
||||||
|
def current(self) -> "Membership | None":
|
||||||
|
"""Get the current membership."""
|
||||||
|
return self._current().first()
|
||||||
|
|
||||||
|
def previous(self) -> list["Membership"]:
|
||||||
|
"""Get previous memberships."""
|
||||||
|
# A naïve way to get previous by just excluding the current. This
|
||||||
|
# means that there must be some protection against "future"
|
||||||
|
# memberships.
|
||||||
|
return list(self.all().difference(self._current()))
|
||||||
|
|
||||||
|
objects = QuerySet.as_manager()
|
||||||
|
|
||||||
|
user = models.ForeignKey("auth.User", on_delete=models.PROTECT, related_name="memberships")
|
||||||
|
|
||||||
|
# This code is used for inviting a user to create an account for this membership.
|
||||||
|
referral_code = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
|
||||||
|
|
||||||
|
membership_type = models.ForeignKey(
|
||||||
|
"membership.MembershipType",
|
||||||
|
related_name="memberships",
|
||||||
|
verbose_name=_("membership type"),
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
period = models.ForeignKey(
|
||||||
|
"membership.SubscriptionPeriod",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
order = models.ForeignKey(
|
||||||
|
"accounting.Order",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("order"),
|
||||||
|
help_text=_("The order filled in for paying this membership."),
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
activated = models.BooleanField(
|
||||||
|
default=False, verbose_name=_("activated"), help_text=_("Membership was activated.")
|
||||||
|
)
|
||||||
|
activated_on = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
revoked = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("revoked"),
|
||||||
|
help_text=_(
|
||||||
|
"Membership has explicitly been revoked. Revoking a membership is not associated with regular expiration "
|
||||||
|
"of the membership period."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
revoked_reason = models.TextField(blank=True)
|
||||||
|
revoked_on = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("membership")
|
||||||
|
verbose_name_plural = _("memberships")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.user} - {self.period}"
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipType(CreatedModifiedAbstract):
|
||||||
|
"""A membership type.
|
||||||
|
|
||||||
|
Models membership types. Currently only a name, but will in the future
|
||||||
|
possibly contain more information like fees.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(verbose_name=_("name"), max_length=64)
|
||||||
|
|
||||||
|
products = models.ManyToManyField("accounting.Product")
|
||||||
|
|
||||||
|
active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("membership type")
|
||||||
|
verbose_name_plural = _("membership types")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def create_membership(self, user: User) -> Membership:
|
||||||
|
"""Create a current membership for this type."""
|
||||||
|
from .selectors import get_current_subscription_period
|
||||||
|
|
||||||
|
return Membership.objects.create(
|
||||||
|
membership_type=self,
|
||||||
|
user=user,
|
||||||
|
period=get_current_subscription_period(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_including_vat(self) -> Money:
|
||||||
|
"""Calculate the total price of this membership (including VAT)."""
|
||||||
|
return sum(product.price + product.vat for product in self.products.all())
|
||||||
|
|
||||||
|
|
||||||
|
class WaitingListEntry(CreatedModifiedAbstract):
|
||||||
|
"""People who for some reason could want to be added to a waiting list and invited to join later."""
|
||||||
|
|
||||||
|
email = models.EmailField()
|
||||||
|
geography = models.CharField(verbose_name=_("geography"), blank=True, default="")
|
||||||
|
comment = models.TextField(blank=True)
|
||||||
|
member = models.ForeignKey(
|
||||||
|
Member,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("has member"),
|
||||||
|
help_text=_("Once a member account is generated (use the admin action), this field will be marked."),
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.email
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("waiting list entry")
|
||||||
|
verbose_name_plural = _("waiting list entries")
|
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)
|
9
src/membership/templates/membership/emails/base.txt
Normal file
9
src/membership/templates/membership/emails/base.txt
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{% load i18n %}{% block greeting %}{% blocktrans %}Dear {{ recipient_name }},{% endblocktrans %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% trans "Cooperatively yours," %}
|
||||||
|
{{ site_name }}
|
||||||
|
|
||||||
|
{{ protocol }}://{{ domain }}
|
7
src/membership/templates/membership/emails/invite.txt
Normal file
7
src/membership/templates/membership/emails/invite.txt
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{% extends "membership/emails/base.txt" %}{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}{% url 'member:membership-invite' token=token referral_code=referral_code as invite_url %}{% blocktrans %}Here is your secret URL for creating an account with us:
|
||||||
|
|
||||||
|
{{ protocol }}://{{ domain }}{{ invite_url }}
|
||||||
|
|
||||||
|
If you did not request this account, get in touch with us.{% endblocktrans %}{% endblock %}
|
6
src/membership/templates/membership/emails/order.txt
Normal file
6
src/membership/templates/membership/emails/order.txt
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "membership/emails/base.txt" %}{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}{% url 'order:detail' order_id=order.id as order_url %}{% blocktrans %}You have an order in our system, you can pay it here:
|
||||||
|
|
||||||
|
{{ protocol }}://{{ domain }}{{ order_url }}
|
||||||
|
{% endblocktrans %}{% endblock %}
|
21
src/membership/templates/membership/invite.html
Normal file
21
src/membership/templates/membership/invite.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% trans "Membership" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
<h2>{% trans "Create account" %}</h2>
|
||||||
|
<p>{% trans "Congratulations! You've been invited to create an account with us:" %}</p>
|
||||||
|
<p>Email: <strong>{{ membership.user.email }}</strong></p>
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<button type="submit" class="btn">{% trans "Create account" %}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -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 %}
|
169
src/membership/views.py
Normal file
169
src/membership/views.py
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
"""Views for the membership app."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.tokens import default_token_generator
|
||||||
|
from django.http import HttpResponseForbidden
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_ratelimit.decorators import ratelimit
|
||||||
|
from django_view_decorator import namespaced_decorator_factory
|
||||||
|
from utils.view_utils import RenderConfig
|
||||||
|
from utils.view_utils import RowAction
|
||||||
|
from utils.view_utils import render
|
||||||
|
|
||||||
|
from .forms import InviteForm
|
||||||
|
from .models import Membership
|
||||||
|
from .permissions import ADMINISTRATE_MEMBERS
|
||||||
|
from .selectors import get_member
|
||||||
|
from .selectors import get_members
|
||||||
|
from .selectors import get_memberships
|
||||||
|
from .selectors import get_subscription_periods
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ratelimit(group="membership", key="ip", rate="10/d", method="ALL", block=True)
|
||||||
|
@member_view(
|
||||||
|
paths="invite/<str:referral_code>/<str:token>/",
|
||||||
|
name="membership-invite",
|
||||||
|
login_required=False,
|
||||||
|
)
|
||||||
|
def invite(request: HttpRequest, referral_code: str, token: str) -> HttpResponse:
|
||||||
|
"""View to invite a member to create a membership.
|
||||||
|
|
||||||
|
The token belongs to a non-active Member object. If the token is valid,
|
||||||
|
the caller is allowed to create a membership.
|
||||||
|
|
||||||
|
We ratelimit this view so it's not possible to brute-force tokens.
|
||||||
|
"""
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return HttpResponseForbidden("You're already logged in. So you cannot receive an invite.")
|
||||||
|
|
||||||
|
# Firstly, we get the membership by the referral code.
|
||||||
|
membership = get_object_or_404(Membership, referral_code=referral_code, user__is_active=False, revoked=False)
|
||||||
|
|
||||||
|
token_valid = default_token_generator.check_token(membership.user, token)
|
||||||
|
|
||||||
|
if not token_valid:
|
||||||
|
raise HttpResponseForbidden("Token not valid - maybe it expired?")
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = InviteForm(membership=membership, data=request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.info(request, _("Password is set for your account and you can now login."))
|
||||||
|
return redirect("account_login")
|
||||||
|
else:
|
||||||
|
form = InviteForm(membership=membership)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"token": token,
|
||||||
|
"membership": membership,
|
||||||
|
"form": form,
|
||||||
|
}
|
||||||
|
return render(
|
||||||
|
request=request,
|
||||||
|
template_name="membership/invite.html",
|
||||||
|
context=context,
|
||||||
|
)
|
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"
|
200
src/project/settings.py
Normal file
200
src/project/settings.py
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
"""Settings for the project."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import django_stubs_ext
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from environs import Env
|
||||||
|
|
||||||
|
django_stubs_ext.monkeypatch()
|
||||||
|
|
||||||
|
env = Env()
|
||||||
|
env.read_env()
|
||||||
|
|
||||||
|
|
||||||
|
# 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="")
|
||||||
|
SERVER_EMAIL = env.str("SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
|
||||||
|
# Parse email URLs, e.g. "smtp://"
|
||||||
|
email = env.dj_email_url("EMAIL_URL", default="smtp://")
|
||||||
|
EMAIL_HOST = email["EMAIL_HOST"]
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
STRIPE_API_KEY = env.str("STRIPE_API_KEY")
|
||||||
|
STRIPE_ENDPOINT_SECRET = env.str("STRIPE_ENDPOINT_SECRET")
|
||||||
|
|
||||||
|
# The number of seconds a password reset link is valid for (default: 3 days).
|
||||||
|
# We've extended this to 7 days because invites then last for 1 week.
|
||||||
|
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 * 7
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
||||||
|
MIDDLEWARE += [
|
||||||
|
"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.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue