Compare commits

..

No commits in common. "fix-migration" and "master" have entirely different histories.

194 changed files with 1352 additions and 6047 deletions

View file

@ -1,10 +0,0 @@
*
.*
*/.*
!src/
!requirements.txt
!requirements/
!entrypoint.sh
!pyproject.toml
!README.md

View file

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

View file

@ -1,10 +0,0 @@
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
View file

@ -2,13 +2,5 @@ __pycache__/
*.pyc
*.sw*
db.sqlite3
project/settings/local.py
.pytest_cache
.idea/
*.mo
.env
venv/
.venv/
# collectstatic
src/static/

View file

@ -1,37 +1,14 @@
default_language_version:
python: python3
exclude: ^.*\b(migrations)\b.*$
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
- repo: git://github.com/pre-commit/pre-commit-hooks
rev: v1.3.0
hooks:
- id: check-ast
- id: check-merge-conflict
- id: check-case-conflict
- id: detect-private-key
- id: check-added-large-files
- id: check-json
- id: check-symlinks
- id: check-toml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.5.2'
- id: trailing-whitespace
- id: flake8
- id: check-yaml
- id: check-added-large-files
- id: debug-statements
- id: end-of-file-fixer
- repo: https://github.com/asottile/reorder_python_imports
rev: v1.0.1
hooks:
- 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
- id: reorder-python-imports

View file

@ -1,53 +0,0 @@
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"]

View file

@ -1,29 +1,15 @@
.PHONY: run makemigrations migrate createsuperuser shell manage_command build requirements
DOCKER_COMPOSE = COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose
DOCKER_RUN = ${DOCKER_COMPOSE} run -u `id -u`
MANAGE_EXEC = python /app/src/manage.py
MANAGE_COMMAND = ${DOCKER_RUN} app ${MANAGE_EXEC}
# These are just some make targets, expressing how things
# are supposed to be run, but feel free to change them!
run:
${DOCKER_COMPOSE} up
dev-setup:
pip install -r requirements_dev.txt --upgrade
pip install -r requirements.txt --upgrade
pre-commit install
python manage.py migrate
python manage.py createsuperuser
makemigrations:
${MANAGE_COMMAND} makemigrations ${ARGS}
lint:
pre-commit run --all
migrate:
${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
test:
pytest

105
README.md
View file

@ -1,105 +0,0 @@
# 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 Normal file
View file

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

28
accounting/admin.py Normal file
View file

@ -0,0 +1,28 @@
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")

5
accounting/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AccountingConfig(AppConfig):
name = 'accounting'

View file

@ -0,0 +1,84 @@
# 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,
},
),
]

157
accounting/models.py Normal file
View file

@ -0,0 +1,157 @@
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")

View file

@ -7,9 +7,10 @@ from . import models
# def test():
# do stuff
@pytest.mark.django_db()
def test_balance() -> None:
@pytest.mark.django_db
def test_balance():
user = User.objects.create_user("test", "lala@adas.com", "1234")
account = models.Account.objects.create(owner=user)
account = models.Account.objects.create(
owner=user
)
assert account.balance == 0

View file

@ -1,30 +0,0 @@
---
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:
...

View file

@ -1,21 +0,0 @@
#!/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 "$@"

View file

@ -6,10 +6,10 @@ if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
try:
from django.core.management import execute_from_command_line
except ImportError:
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"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)

View file

@ -1,5 +1,8 @@
"""Membership application.
"""
Membership application
======================
This application's domain relate to organizational structures and
implementation of statutes, policies etc.
"""

8
membership/admin.py Normal file
View file

@ -0,0 +1,8 @@
from django.contrib import admin
from . import models
@admin.register(models.Membership)
class MembershipAdmin(admin.ModelAdmin):
pass

5
membership/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class MembershipConfig(AppConfig):
name = 'membership'

View file

@ -0,0 +1,101 @@
# 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),
),
]

View file

144
membership/models.py Normal file
View file

@ -0,0 +1,144 @@
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")

0
project/__init__.py Normal file
View file

View file

@ -0,0 +1,9 @@
"""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)
}

View file

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

137
project/settings/base.py Normal file
View file

@ -0,0 +1,137 @@
"""
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')]

View file

@ -0,0 +1,79 @@
/* 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;
}

View file

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

12
project/urls.py Normal file
View file

@ -0,0 +1,12 @@
"""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),
]

5
project/views.py Normal file
View file

@ -0,0 +1,5 @@
from django.shortcuts import render_to_response
def index(request):
return render_to_response("index.html")

View file

@ -1,11 +1,11 @@
"""WSGI config for membersystem project.
"""
WSGI config for membersystem project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application

View file

@ -1,166 +0,0 @@
[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
]

5
pytest.ini Normal file
View file

@ -0,0 +1,5 @@
[pytest]
testpaths = .
python_files = tests.py test_*.py *_tests.py
DJANGO_SETTINGS_MODULE = project.settings
#norecursedirs = dist tmp* .svn .*

View file

@ -1,116 +1,2 @@
#
# 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
Django>=2.2,<2.3
django-money==0.15

View file

@ -1,192 +0,0 @@
#
# This file is autogenerated by hatch-pip-compile with Python 3.12
#
# - coverage[toml]==7.3.0
# - pytest==7.2.2
# - pytest-cov
# - pytest-django==4.5.2
# - mypy==1.1.1
# - django-stubs==1.16.0
# - pip-tools==7.3.0
# - django-debug-toolbar==4.2.0
# - django-browser-reload==1.7.0
# - model-bakery==1.17.0
# - django-allauth~=0.63
# - 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

3
requirements_dev.txt Normal file
View file

@ -0,0 +1,3 @@
pytest
pytest-django
pre-commit

13
setup.cfg Normal file
View file

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

View file

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

View file

@ -1,94 +0,0 @@
"""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,)

View file

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

View file

@ -1,233 +0,0 @@
# 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",
},
),
]

View file

@ -1,41 +0,0 @@
# 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
),
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 5.0.6 on 2024-07-14 22:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounting', '0002_alter_order_price_currency_alter_order_vat_currency_and_more'),
]
operations = [
migrations.AlterField(
model_name='payment',
name='stripe_charge_id',
field=models.CharField(blank=True, default='', max_length=255),
preserve_default=False,
),
]

View file

@ -1,78 +0,0 @@
# 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,
},
),
]

View file

@ -1,40 +0,0 @@
# 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'),
),
]

View file

@ -1,25 +0,0 @@
# 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'),
),
]

View file

@ -1,42 +0,0 @@
# 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'),
),
]

View file

@ -1,209 +0,0 @@
"""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}"

View file

@ -1,36 +0,0 @@
"""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()
)

View file

@ -1,18 +0,0 @@
{% 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 %}

View file

@ -1,49 +0,0 @@
{% 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 %}

View file

@ -1,20 +0,0 @@
{% 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 %}

View file

@ -1,177 +0,0 @@
"""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)

View file

@ -1,168 +0,0 @@
"""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.",
)

View file

@ -1,16 +0,0 @@
"""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)

View file

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

View file

@ -1,39 +0,0 @@
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()

View file

@ -1,92 +0,0 @@
# 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",
},
),
]

View file

@ -1,61 +0,0 @@
# 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"
),
),
]

View file

@ -1,23 +0,0 @@
# 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",
),
),
]

View file

@ -1,22 +0,0 @@
# 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",
),
),
]

View file

@ -1,23 +0,0 @@
# 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",),
),
]

View file

@ -1,32 +0,0 @@
# 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'},
),
]

View file

@ -1,62 +0,0 @@
# 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),
),
]

View file

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

View file

@ -1,32 +0,0 @@
# 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),
),
]

View file

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

View file

@ -1,247 +0,0 @@
"""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")

View file

@ -1,54 +0,0 @@
"""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",
)

View file

@ -1,73 +0,0 @@
"""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)

View file

@ -1,9 +0,0 @@
{% load i18n %}{% block greeting %}{% blocktrans %}Dear {{ recipient_name }},{% endblocktrans %}{% endblock %}
{% block content %}{% endblock %}
{% trans "Cooperatively yours," %}
{{ site_name }}
{{ protocol }}://{{ domain }}

View file

@ -1,7 +0,0 @@
{% 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 %}

View file

@ -1,6 +0,0 @@
{% 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 %}

View file

@ -1,21 +0,0 @@
{% 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 %}

View file

@ -1,45 +0,0 @@
{% 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 %}

View file

@ -1,63 +0,0 @@
{% 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 %}

View file

@ -1,169 +0,0 @@
"""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,
)

View file

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

View file

@ -1,7 +0,0 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
application = get_asgi_application()

View file

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

View file

@ -1,200 +0,0 @@
"""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,
}

View file

@ -1,67 +0,0 @@
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);
}

View file

@ -1,559 +0,0 @@
/* 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;
}

Some files were not shown because too many files have changed in this diff Show more