Merge branch 'main' into services
This commit is contained in:
commit
535543f82f
|
@ -1,5 +1,5 @@
|
||||||
default_language_version:
|
default_language_version:
|
||||||
python: python3.11
|
python: python3.12
|
||||||
exclude: ^.*\b(migrations)\b.*$
|
exclude: ^.*\b(migrations)\b.*$
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
@ -16,45 +16,22 @@ repos:
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||||
rev: 'v0.1.11'
|
rev: 'v0.3.0'
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args:
|
args:
|
||||||
- --fix
|
- --fix
|
||||||
- repo: https://github.com/asottile/reorder_python_imports
|
- id: ruff-format
|
||||||
rev: v3.12.0
|
|
||||||
hooks:
|
|
||||||
- id: reorder-python-imports
|
|
||||||
args:
|
|
||||||
- --py310-plus
|
|
||||||
- --application-directories=.:src
|
|
||||||
exclude: migrations/
|
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v3.15.0
|
rev: v3.15.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args:
|
args:
|
||||||
- --py311-plus
|
- --py311-plus
|
||||||
exclude: migrations/
|
exclude: migrations/
|
||||||
- repo: https://github.com/adamchainz/django-upgrade
|
- repo: https://github.com/adamchainz/django-upgrade
|
||||||
rev: 1.15.0
|
rev: 1.16.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: django-upgrade
|
- id: django-upgrade
|
||||||
args:
|
args:
|
||||||
- --target-version=4.1
|
- --target-version=5.0
|
||||||
- repo: https://github.com/asottile/yesqa
|
|
||||||
rev: v1.5.0
|
|
||||||
hooks:
|
|
||||||
- id: yesqa
|
|
||||||
- repo: https://github.com/asottile/add-trailing-comma
|
|
||||||
rev: v3.1.0
|
|
||||||
hooks:
|
|
||||||
- id: add-trailing-comma
|
|
||||||
- repo: https://github.com/hadialqattan/pycln
|
|
||||||
rev: v2.4.0
|
|
||||||
hooks:
|
|
||||||
- id: pycln
|
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 23.12.1
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM python:3.11-slim-bullseye
|
FROM python:3.12-slim-bullseye
|
||||||
|
|
||||||
ENV PYTHONFAULTHANDLER=1 \
|
ENV PYTHONFAULTHANDLER=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
|
|
241
devenv.lock
Normal file
241
devenv.lock
Normal file
|
@ -0,0 +1,241 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"devenv": {
|
||||||
|
"locked": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"lastModified": 1707004164,
|
||||||
|
"narHash": "sha256-9Hr8onWtvLk5A8vCEkaE9kxA0D7PR62povFokM1oL5Q=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"rev": "0e68853bb27981a4ffd7a7225b59ed84f7180fc7",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"dir": "src/modules",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1696426674,
|
||||||
|
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat_2": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1696426674,
|
||||||
|
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1705309234,
|
||||||
|
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"id": "flake-utils",
|
||||||
|
"type": "indirect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils_2": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1701680307,
|
||||||
|
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"pre-commit-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1703887061,
|
||||||
|
"narHash": "sha256-gGPa9qWNc6eCXT/+Z5/zMkyYOuRZqeFZBDbopNZQkuY=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "43e1aa1308018f37118e34d3a9cb4f5e75dc11d5",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1707451808,
|
||||||
|
"narHash": "sha256-UwDBUNHNRsYKFJzyTMVMTF5qS4xeJlWoeyJf+6vvamU=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "442d407992384ed9c0e6d352de75b69079904e4e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-python": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1707114737,
|
||||||
|
"narHash": "sha256-ZXqv2epXAjDjfWbYn+yy4VOmW+C7SuUBoiZkkDoSqA4=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "nixpkgs-python",
|
||||||
|
"rev": "f34ed02276bc08fe1c91c1bf0ef3589d68028878",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "nixpkgs-python",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-stable": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1704874635,
|
||||||
|
"narHash": "sha256-YWuCrtsty5vVZvu+7BchAxmcYzTMfolSPP5io8+WYCg=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "3dc440faeee9e889fe2d1b4d25ad0f430d449356",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-23.11",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1707347730,
|
||||||
|
"narHash": "sha256-0etC/exQIaqC9vliKhc3eZE2Mm2wgLa0tj93ZF/egvM=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "6832d0d99649db3d65a0e15fa51471537b2c56a6",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-23.11",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pre-commit-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat_2",
|
||||||
|
"flake-utils": "flake-utils_2",
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"nixpkgs-stable": "nixpkgs-stable"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1707297608,
|
||||||
|
"narHash": "sha256-ADjo/5VySGlvtCW3qR+vdFF4xM9kJFlRDqcC9ZGI8EA=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "pre-commit-hooks.nix",
|
||||||
|
"rev": "0db2e67ee49910adfa13010e7f012149660af7f0",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "pre-commit-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": "devenv",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"nixpkgs-python": "nixpkgs-python",
|
||||||
|
"pre-commit-hooks": "pre-commit-hooks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
19
devenv.nix
Normal file
19
devenv.nix
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{ pkgs, ... }:
|
||||||
|
|
||||||
|
{
|
||||||
|
languages.python.enable = true;
|
||||||
|
languages.python.version = "3.12";
|
||||||
|
|
||||||
|
services.postgres = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.postgresql_15;
|
||||||
|
initialDatabases = [ {"name" = "postgres";} ];
|
||||||
|
listen_addresses = "localhost";
|
||||||
|
initialScript = "create user postgres with password 'postgres' superuser;";
|
||||||
|
};
|
||||||
|
|
||||||
|
processes = {
|
||||||
|
app.exec = "while ! pg_isready -d postgres -h localhost -U postgres 2>/dev/null; do sleep 1; done; hatch run server";
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
5
devenv.yaml
Normal file
5
devenv.yaml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
inputs:
|
||||||
|
nixpkgs:
|
||||||
|
url: github:NixOS/nixpkgs/nixpkgs-unstable
|
||||||
|
nixpkgs-python:
|
||||||
|
url: github:cachix/nixpkgs-python
|
|
@ -104,3 +104,26 @@ follow_imports = "normal"
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "tests.*"
|
module = "tests.*"
|
||||||
allow_untyped_defs = true
|
allow_untyped_defs = true
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
extend-exclude = [
|
||||||
|
".git",
|
||||||
|
"__pycache__",
|
||||||
|
]
|
||||||
|
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)
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
force-single-line = true
|
||||||
|
|
|
@ -7,7 +7,6 @@ from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
@ -99,9 +98,7 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"vat",
|
"vat",
|
||||||
djmoney.models.fields.MoneyField(
|
djmoney.models.fields.MoneyField(decimal_places=2, max_digits=16, verbose_name="VAT"),
|
||||||
decimal_places=2, max_digits=16, verbose_name="VAT"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
("is_paid", models.BooleanField(default=False, verbose_name="is paid")),
|
("is_paid", models.BooleanField(default=False, verbose_name="is paid")),
|
||||||
(
|
(
|
||||||
|
|
|
@ -17,8 +17,7 @@ class CreatedModifiedAbstract(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class Account(CreatedModifiedAbstract):
|
class Account(CreatedModifiedAbstract):
|
||||||
"""
|
"""This is the model where we can give access to several users, such that they
|
||||||
This is the model where we can give access to several users, such that they
|
|
||||||
can decide which account to use to pay for something.
|
can decide which account to use to pay for something.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -30,8 +29,7 @@ class Account(CreatedModifiedAbstract):
|
||||||
|
|
||||||
|
|
||||||
class Transaction(CreatedModifiedAbstract):
|
class Transaction(CreatedModifiedAbstract):
|
||||||
"""
|
"""Tracks in and outgoing events of an account. When an order is received, an
|
||||||
Tracks in and outgoing events of an account. When an order is received, an
|
|
||||||
amount is subtracted, when a payment is received, an amount is added.
|
amount is subtracted, when a payment is received, an amount is added.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -50,8 +48,7 @@ class Transaction(CreatedModifiedAbstract):
|
||||||
|
|
||||||
|
|
||||||
class Order(CreatedModifiedAbstract):
|
class Order(CreatedModifiedAbstract):
|
||||||
"""
|
"""Scoped out: Contents of invoices will have to be tracked either here or in
|
||||||
Scoped out: Contents of invoices will have to be tracked either here or in
|
|
||||||
a separate Invoice model. This is undecided because we are not generating
|
a separate Invoice model. This is undecided because we are not generating
|
||||||
invoices at the moment.
|
invoices at the moment.
|
||||||
"""
|
"""
|
||||||
|
@ -91,7 +88,7 @@ class Order(CreatedModifiedAbstract):
|
||||||
verbose_name = pgettext_lazy("accounting term", "Order")
|
verbose_name = pgettext_lazy("accounting term", "Order")
|
||||||
verbose_name_plural = pgettext_lazy("accounting term", "Orders")
|
verbose_name_plural = pgettext_lazy("accounting term", "Orders")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"Order ID {self.display_id}"
|
return f"Order ID {self.display_id}"
|
||||||
|
|
||||||
|
|
||||||
|
@ -116,7 +113,7 @@ class Payment(CreatedModifiedAbstract):
|
||||||
description=order.description,
|
description=order.description,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"Payment ID {self.display_id}"
|
return f"Payment ID {self.display_id}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -8,8 +8,8 @@ from . import models
|
||||||
# do stuff
|
# do stuff
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db()
|
||||||
def test_balance():
|
def test_balance() -> None:
|
||||||
user = User.objects.create_user("test", "lala@adas.com", "1234")
|
user = User.objects.create_user("test", "lala@adas.com", "1234")
|
||||||
account = models.Account.objects.create(owner=user)
|
account = models.Account.objects.create(owner=user)
|
||||||
assert account.balance == 0
|
assert account.balance == 0
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
"""
|
"""Membership application.
|
||||||
Membership application
|
|
||||||
======================
|
======================
|
||||||
|
|
||||||
This application's domain relate to organizational structures and
|
This application's domain relate to organizational structures and
|
||||||
|
|
|
@ -5,7 +5,7 @@ from django.db.models.signals import post_migrate
|
||||||
class MembershipConfig(AppConfig):
|
class MembershipConfig(AppConfig):
|
||||||
name = "membership"
|
name = "membership"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self) -> None:
|
||||||
from .permissions import persist_permissions
|
from .permissions import persist_permissions
|
||||||
|
|
||||||
post_migrate.connect(persist_permissions, sender=self)
|
post_migrate.connect(persist_permissions, sender=self)
|
||||||
|
|
|
@ -7,7 +7,6 @@ from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
import django.contrib.postgres.constraints
|
import django.contrib.postgres.constraints
|
||||||
import django.contrib.postgres.fields.ranges
|
import django.contrib.postgres.fields.ranges
|
||||||
from django.db import migrations, models
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("membership", "0001_initial"),
|
("membership", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
@ -34,9 +34,7 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"period",
|
"period",
|
||||||
django.contrib.postgres.fields.ranges.DateRangeField(
|
django.contrib.postgres.fields.ranges.DateRangeField(verbose_name="period"),
|
||||||
verbose_name="period"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
# Generated by Django 4.1 on 2023-01-02 21:05
|
# Generated by Django 4.1 on 2023-01-02 21:05
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("membership", "0002_subscriptionperiod_remove_membership_period_and_more"),
|
("membership", "0002_subscriptionperiod_remove_membership_period_and_more"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
# Generated by Django 4.1 on 2023-01-02 21:06
|
# Generated by Django 4.1 on 2023-01-02 21:06
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("membership", "0003_membership_period"),
|
("membership", "0003_membership_period"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,22 +4,20 @@ from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('auth', '0012_alter_user_first_name_max_length'),
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
('membership', '0004_alter_membership_period'),
|
("membership", "0004_alter_membership_period"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Member',
|
name="Member",
|
||||||
fields=[
|
fields=[],
|
||||||
],
|
|
||||||
options={
|
options={
|
||||||
'proxy': True,
|
"proxy": True,
|
||||||
'indexes': [],
|
"indexes": [],
|
||||||
'constraints': [],
|
"constraints": [],
|
||||||
},
|
},
|
||||||
bases=('auth.user',),
|
bases=("auth.user",),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -36,9 +36,7 @@ class Member(User):
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionPeriod(CreatedModifiedAbstract):
|
class SubscriptionPeriod(CreatedModifiedAbstract):
|
||||||
"""
|
"""Denotes a period for which members should pay their membership fee for."""
|
||||||
Denotes a period for which members should pay their membership fee for.
|
|
||||||
"""
|
|
||||||
|
|
||||||
period = DateRangeField(verbose_name=_("period"))
|
period = DateRangeField(verbose_name=_("period"))
|
||||||
|
|
||||||
|
@ -52,16 +50,12 @@ class SubscriptionPeriod(CreatedModifiedAbstract):
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return f"{self.period.lower} - {self.period.upper or _('next general assembly')}"
|
||||||
f"{self.period.lower} - {self.period.upper or _('next general assembly')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Membership(CreatedModifiedAbstract):
|
class Membership(CreatedModifiedAbstract):
|
||||||
"""
|
"""Tracks that a user has membership of a given type for a given period."""
|
||||||
Tracks that a user has membership of a given type for a given period.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class QuerySet(models.QuerySet):
|
class QuerySet(models.QuerySet):
|
||||||
def for_member(self, member: Member):
|
def for_member(self, member: Member):
|
||||||
|
@ -102,13 +96,12 @@ class Membership(CreatedModifiedAbstract):
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"{self.user} - {self.period}"
|
return f"{self.user} - {self.period}"
|
||||||
|
|
||||||
|
|
||||||
class MembershipType(CreatedModifiedAbstract):
|
class MembershipType(CreatedModifiedAbstract):
|
||||||
"""
|
"""Models membership types. Currently only a name, but will in the future
|
||||||
Models membership types. Currently only a name, but will in the future
|
|
||||||
possibly contain more information like fees.
|
possibly contain more information like fees.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -118,7 +111,7 @@ class MembershipType(CreatedModifiedAbstract):
|
||||||
|
|
||||||
name = models.CharField(verbose_name=_("name"), max_length=64)
|
name = models.CharField(verbose_name=_("name"), max_length=64)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
PERMISSIONS = []
|
PERMISSIONS = []
|
||||||
|
|
||||||
|
|
||||||
def persist_permissions(sender, **kwargs):
|
def persist_permissions(sender, **kwargs) -> None:
|
||||||
for permission in PERMISSIONS:
|
for permission in PERMISSIONS:
|
||||||
permission.persist_permission()
|
permission.persist_permission()
|
||||||
|
|
||||||
|
@ -23,10 +23,10 @@ class Permission:
|
||||||
PERMISSIONS.append(self)
|
PERMISSIONS.append(self)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path(self):
|
def path(self) -> str:
|
||||||
return f"{self.app_label}.{self.codename}"
|
return f"{self.app_label}.{self.codename}"
|
||||||
|
|
||||||
def persist_permission(self):
|
def persist_permission(self) -> None:
|
||||||
content_type, _ = ContentType.objects.get_or_create(
|
content_type, _ = ContentType.objects.get_or_create(
|
||||||
app_label=self.app_label,
|
app_label=self.app_label,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_view_decorator import namespaced_decorator_factory
|
from django_view_decorator import namespaced_decorator_factory
|
||||||
|
from utils.view_utils import RowAction
|
||||||
|
from utils.view_utils import render
|
||||||
|
from utils.view_utils import render_list
|
||||||
|
|
||||||
from .permissions import ADMINISTRATE_MEMBERS
|
from .permissions import ADMINISTRATE_MEMBERS
|
||||||
from .selectors import get_member
|
from .selectors import get_member
|
||||||
from .selectors import get_members
|
from .selectors import get_members
|
||||||
from .selectors import get_memberships
|
from .selectors import get_memberships
|
||||||
from .selectors import get_subscription_periods
|
from .selectors import get_subscription_periods
|
||||||
from utils.view_utils import render
|
|
||||||
from utils.view_utils import render_list
|
|
||||||
from utils.view_utils import RowAction
|
|
||||||
|
|
||||||
|
|
||||||
member_view = namespaced_decorator_factory(namespace="member", base_path="membership")
|
member_view = namespaced_decorator_factory(namespace="member", base_path="membership")
|
||||||
|
|
||||||
|
|
|
@ -156,6 +156,26 @@ ACCOUNT_EMAIL_REQUIRED = True
|
||||||
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
|
ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
|
||||||
ACCOUNT_USERNAME_REQUIRED = False
|
ACCOUNT_USERNAME_REQUIRED = False
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
# We want to log everything to stdout in docker
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"level": "DEBUG",
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": "DEBUG",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
INSTALLED_APPS += ["debug_toolbar", "django_browser_reload"]
|
||||||
MIDDLEWARE += [
|
MIDDLEWARE += [
|
||||||
|
|
2018
src/project/static/css/bootstrap-icons.css
vendored
2018
src/project/static/css/bootstrap-icons.css
vendored
File diff suppressed because it is too large
Load diff
7
src/project/static/css/bootstrap.min.css
vendored
7
src/project/static/css/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
56
src/project/static/css/dark-style.css
Normal file
56
src/project/static/css/dark-style.css
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
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);
|
||||||
|
}
|
|
@ -1,39 +1,65 @@
|
||||||
/* Reset */
|
/* Reset */
|
||||||
*, *::before, *::after {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
img, picture, video, canvas, svg {
|
|
||||||
|
img,
|
||||||
|
picture,
|
||||||
|
video,
|
||||||
|
canvas,
|
||||||
|
svg {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
input, button, textarea, select {
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
p, h1, h2, h3, h4, h5, h6 {
|
|
||||||
|
p,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
#root, #__next {
|
|
||||||
|
#root,
|
||||||
|
#__next {
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Variables */
|
/* Variables */
|
||||||
:root {
|
:root {
|
||||||
/* Colors */
|
/* Colors */
|
||||||
--light : #fff;
|
--light: #ffffff;
|
||||||
--light-dust : #f6f6f6;
|
--light-dust: #fefef9;
|
||||||
--dust : #f1f1f1;
|
--dust: #f4f1ef;
|
||||||
--medium-dust: #dadada;
|
--medium-dust: #dadada;
|
||||||
--dark-dust: #bfbfbf;
|
--dark-dust: #bfbfbf;
|
||||||
--fade: #878787;
|
--fade: #878787;
|
||||||
--twilight: #4a4a4a;
|
--twilight: #4a4a4a;
|
||||||
|
--dark-twilight: #2f2f2f;
|
||||||
--dark: #2a2a2a;
|
--dark: #2a2a2a;
|
||||||
|
--dark-dark: #121212;
|
||||||
|
--light-custard: #eee7d5;
|
||||||
--custard: #f0dcac;
|
--custard: #f0dcac;
|
||||||
|
--dark-custard: #d4c7a9;
|
||||||
--water: #a8f3f4;
|
--water: #a8f3f4;
|
||||||
--splash: #4b3aba;
|
--splash: #4b3aba;
|
||||||
|
|
||||||
|
@ -51,19 +77,38 @@ p, h1, h2, h3, h4, h5, h6 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html,
|
||||||
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
font-size: 1.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--twilight);
|
color: var(--twilight);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
font-weight : 500;
|
<<<<<<< HEAD font-weight: 500;
|
||||||
color: var(--splash);
|
color: var(--splash);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: var(--double-space) 0;
|
||||||
|
height: 0;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--dark-custard);
|
||||||
|
=======font-weight: 500;
|
||||||
|
color: var(--splash);
|
||||||
|
text-decoration: none;
|
||||||
|
>>>>>>>bdc2d8717cbcab1795b1b2dc4f08f83242e4a4ca
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
@ -88,21 +133,36 @@ header > h1 {
|
||||||
font-size: 1.44em;
|
font-size: 1.44em;
|
||||||
}
|
}
|
||||||
|
|
||||||
header > a.logout {
|
#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;
|
padding: 6px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--twilight);
|
background: var(--twilight);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--dust);
|
color: var(--dust);
|
||||||
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
header > a.logout:hover {
|
header>div>a#logout:hover {
|
||||||
background: var(--splash);
|
background: var(--splash);
|
||||||
color: var(--light);
|
color: var(--light);
|
||||||
}
|
}
|
||||||
|
|
||||||
aside {
|
aside {
|
||||||
padding : var(--double-space) var(--outer-space);
|
padding: 0 var(--outer-space) var(--double-space) var(--outer-space);
|
||||||
background: var(--light);
|
background: var(--light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -141,7 +201,7 @@ aside > div > dl > dt {
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
display: block;
|
display: block;
|
||||||
border-bottom : 1px solid var(--dark-dust);
|
border-bottom: 1px solid var(--dark-custard);
|
||||||
background: var(--light);
|
background: var(--light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,26 +248,31 @@ article {
|
||||||
padding: var(--double-space) var(--outer-space);
|
padding: var(--double-space) var(--outer-space);
|
||||||
}
|
}
|
||||||
|
|
||||||
article > div {
|
article>div.content-view {
|
||||||
background: var(--dust);
|
background: var(--dust);
|
||||||
padding: var(--double-space);
|
padding: var(--double-space);
|
||||||
margin-bottom : var(--double-space);
|
margin-bottom: var(--space);
|
||||||
}
|
}
|
||||||
|
|
||||||
div.content-view>h2 {
|
div.content-view>h2 {
|
||||||
margin : 0 0 var(--double-space) 0;
|
margin: 0 0 var(--space) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.services {
|
div.services {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content : start;
|
justify-content: space-between;
|
||||||
gap: var(--double-space);
|
gap: var(--double-space);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.services>div,
|
div.services>div,
|
||||||
div.infobox {
|
div.infobox {
|
||||||
background : var(--light);
|
<<<<<<< HEAD background: var(--light);
|
||||||
|
padding: var(--double-space);
|
||||||
|
border-radius: 6px;
|
||||||
|
flex: 240px;
|
||||||
|
max-width: 420px;
|
||||||
|
=======background: var(--light);
|
||||||
padding: var(--double-space);
|
padding: var(--double-space);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
flex: 240px;
|
flex: 240px;
|
||||||
|
@ -315,6 +380,166 @@ form > div > input[type="password"] {
|
||||||
color: var(--twilight);
|
color: var(--twilight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#loginbox>div.login {
|
||||||
|
background: var(--light-dust);
|
||||||
|
>>>>>>>bdc2d8717cbcab1795b1b2dc4f08f83242e4a4ca 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
#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 {
|
#loginbox>div.login {
|
||||||
background: var(--light-dust);
|
background: var(--light-dust);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -375,10 +600,48 @@ span.time_remaining {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding : 0;
|
padding: var(--half-space) 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination>li {
|
.pagination>li {
|
||||||
margin : 0 6px;
|
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;
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -2,29 +2,26 @@
|
||||||
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block head_title %}{% trans "E-mail Addresses" %}{% endblock %}
|
{% block head_title %}{% trans "Email Addresses" %}{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="content-view">
|
||||||
|
<h2>{% trans "Email Addresses" %}</h2>
|
||||||
|
<p>{% trans 'The following email addresses are associated with your account:' %}</p>
|
||||||
|
|
||||||
<div class="row">
|
<hr />
|
||||||
<div class="col-md-12">
|
|
||||||
|
|
||||||
<div class="panel panel-default">
|
|
||||||
<div class="panel-heading">
|
|
||||||
<h4>{% trans "E-mail Addresses" %}</h4>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
|
|
||||||
{% if user.emailaddress_set.all %}
|
{% if user.emailaddress_set.all %}
|
||||||
<p>{% trans 'The following e-mail addresses are associated with your account:' %}</p>
|
<form action="{% url 'account_email' %}" class="email_list" method="post">
|
||||||
<form action="{% url 'account_email' %}" class="email_list"
|
|
||||||
method="post">
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<fieldset class="blockLabels">
|
<fieldset class="blockLabels">
|
||||||
|
<div class="buttonHolder">
|
||||||
<table class="table">
|
<button class="small" name="action_add" style="float:right">Add Email …</button>
|
||||||
|
<button class="small" disabled type="submit" id="action_primary" name="action_primary">Make Primary</button>
|
||||||
|
<button class="small" type="submit" name="action_send">Re-send Verification</button>
|
||||||
|
<button class="small" type="submit" name="action_remove">Remove</button>
|
||||||
|
</div>
|
||||||
|
<table class="table" id="user_email_table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
|
@ -34,9 +31,8 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
||||||
{% for emailaddress in user.emailaddress_set.all %}
|
{% for emailaddress in user.emailaddress_set.all %}
|
||||||
<tr class="ctrlHolder">
|
<tr>
|
||||||
<label for="email_radio_{{ forloop.counter }}"
|
<label for="email_radio_{{ forloop.counter }}"
|
||||||
class="{% if emailaddress.primary %}primary_email{% endif %}">
|
class="{% if emailaddress.primary %}primary_email{% endif %}">
|
||||||
<td>
|
<td>
|
||||||
|
@ -48,6 +44,7 @@
|
||||||
{% if emailaddress.primary or user.emailaddress_set.count == 1 %}
|
{% if emailaddress.primary or user.emailaddress_set.count == 1 %}
|
||||||
checked="checked"
|
checked="checked"
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
class="{% if emailaddress.primary %}primary_email{% endif %}"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -55,14 +52,15 @@
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if emailaddress.verified %}
|
{% if emailaddress.verified %}
|
||||||
<span class="label label-success">Verified</span>
|
<span class="label label-success">{% trans "Verified" %}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="label label-danger">Unverified</span>
|
<span class="label label-danger">{% trans "Unverified" %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if emailaddress.primary %}
|
{% if emailaddress.primary %}
|
||||||
<span class="label label-primary">Primary</span>{% endif %}
|
<span class="label label-primary">{% trans "Primary" %}</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</label>
|
</label>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -70,19 +68,6 @@
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="buttonHolder">
|
|
||||||
<button class="btn btn-success" type="submit"
|
|
||||||
name="action_primary">Make Primary
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary" type="submit"
|
|
||||||
name="action_send">Re-send Verification
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-danger" type="submit"
|
|
||||||
name="action_remove">Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -91,13 +76,10 @@
|
||||||
{% trans "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}
|
{% trans "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
<!--
|
||||||
</div>
|
<hr />
|
||||||
|
|
||||||
<div class="panel panel-default">
|
<h3>{% trans "Add E-mail" %}</h3>
|
||||||
<div class="panel-heading">
|
|
||||||
<h4>{% trans "Add E-mail" %}</h4>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<form method="post" action="{% url 'account_email' %}"
|
<form method="post" action="{% url 'account_email' %}"
|
||||||
class="add_email">
|
class="add_email">
|
||||||
|
@ -108,9 +90,7 @@
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
-->
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
@ -124,6 +104,15 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let radio_actions = document.getElementsByName('email');
|
||||||
|
if (radio_actions.length) {
|
||||||
|
for (radio of radio_actions) {
|
||||||
|
radio.addEventListener("change", function (e) {
|
||||||
|
document.getElementById('action_primary').disabled = e.target.classList.contains('primary_email')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -20,13 +20,13 @@
|
||||||
<div>
|
<div>
|
||||||
<label for="id_username"
|
<label for="id_username"
|
||||||
class="visually-hidden">
|
class="visually-hidden">
|
||||||
{% trans "E-mail" %}
|
{% trans "Email" %}
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="id_username"
|
id="id_username"
|
||||||
name="login"
|
name="login"
|
||||||
class="form-control mb-lg-2"
|
class="form-control mb-lg-2"
|
||||||
placeholder="{% trans "E-mail" %}"
|
placeholder="{% trans "Email" %}"
|
||||||
required
|
required
|
||||||
autofocus>
|
autofocus>
|
||||||
</div>
|
</div>
|
||||||
|
@ -42,7 +42,7 @@
|
||||||
required>
|
required>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button type="submit">{% trans "Sign in" %}</button>
|
<button type="submit">{% trans "Login" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div>
|
<div>
|
||||||
|
@ -55,7 +55,7 @@
|
||||||
<div class="signup">
|
<div class="signup">
|
||||||
<img src="https://data.coop/static/img/logo_da.svg" alt="data.coop logo">
|
<img src="https://data.coop/static/img/logo_da.svg" alt="data.coop logo">
|
||||||
<div class="new_here">
|
<div class="new_here">
|
||||||
<h2> Are you new here? </h2>
|
<h2>{% trans "Are you new here?" %}</h2>
|
||||||
<a class="button" href="{% url "account_signup" %}">{% trans "Become a member" %}</a>
|
<a class="button" href="{% url "account_signup" %}">{% trans "Become a member" %}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,11 +10,39 @@
|
||||||
<title>{% block head_title %}{% endblock %} – {{ site.name }}</title>
|
<title>{% block head_title %}{% endblock %} – {{ site.name }}</title>
|
||||||
<link rel="stylesheet" href="{% static "fonts/inter.css" %}">
|
<link rel="stylesheet" href="{% static "fonts/inter.css" %}">
|
||||||
<link rel="stylesheet" href="{% static "css/style.css" %}">
|
<link rel="stylesheet" href="{% static "css/style.css" %}">
|
||||||
|
<link rel="stylesheet" href="{% static "css/dark-style.css" %}">
|
||||||
|
<script>
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
|
||||||
|
if (savedTheme === "dark" || (savedTheme == null && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||||
|
document.querySelector('html').classList.add('dark');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1> data.coop membersystem </h1>
|
<h1> data.coop membersystem </h1>
|
||||||
<a class="logout" href="{% url "account_logout" %}">Log out</a>
|
<div>
|
||||||
|
<a id="theme-switcher" title="Switch theme">
|
||||||
|
<svg id="switch-icon"
|
||||||
|
width="20.00033mm"
|
||||||
|
height="20.000334mm"
|
||||||
|
viewBox="0 0 20.00033 20.000334"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g id="layer1">
|
||||||
|
<path id="path1"
|
||||||
|
style="fill-opacity:1;stroke:none;stroke-width:0.697782;stroke-linecap:round"
|
||||||
|
d="M 9.999906,20.000334 A 10,10 0 0 0 20.000329,9.999911 10,10 0 0 0 9.999906,0 10,10 0 0 0 0,9.999911 10,10 0 0 0 9.999906,20.000334 Z m 0,-2.00039 V 1.99988 a 8,8 0 0 1 8.000023,8.000031 8,8 0 0 1 -8.000023,8.000033 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a id="logout" href="{% url "account_logout" %}">Log out</a>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<aside>
|
<aside>
|
||||||
|
@ -78,5 +106,14 @@
|
||||||
<footer>
|
<footer>
|
||||||
data.coop membersystem version 0.0.1
|
data.coop membersystem version 0.0.1
|
||||||
</footer>
|
</footer>
|
||||||
|
<script>
|
||||||
|
const themeSwitcher = document.getElementById('theme-switcher');
|
||||||
|
themeSwitcher.addEventListener('click', function() {
|
||||||
|
themeSwitcher.classList.toggle('active')
|
||||||
|
let isDark = document.querySelector('html').classList.toggle('dark');
|
||||||
|
|
||||||
|
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
"""URLs for the membersystem"""
|
"""URLs for the membersystem."""
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include
|
from django.urls import include
|
||||||
|
@ -15,4 +15,5 @@ if settings.DEBUG:
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("__debug__/", include("debug_toolbar.urls")),
|
path("__debug__/", include("debug_toolbar.urls")),
|
||||||
path("__reload__/", include("django_browser_reload.urls")),
|
path("__reload__/", include("django_browser_reload.urls")),
|
||||||
] + urlpatterns
|
*urlpatterns,
|
||||||
|
]
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
from django_view_decorator import view
|
from django_view_decorator import view
|
||||||
|
|
||||||
from utils.view_utils import render
|
from utils.view_utils import render
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
"""
|
"""WSGI config for membersystem project.
|
||||||
WSGI config for membersystem project.
|
|
||||||
|
|
||||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,6 @@ register = template.Library()
|
||||||
@register.simple_tag(takes_context=True)
|
@register.simple_tag(takes_context=True)
|
||||||
def active_path(context, path_name, class_name) -> str | None:
|
def active_path(context, path_name, class_name) -> str | None:
|
||||||
"""Return the given class name if the current path matches the given path name."""
|
"""Return the given class name if the current path matches the given path name."""
|
||||||
|
|
||||||
path = reverse(path_name)
|
path = reverse(path_name)
|
||||||
request_path = context.get("request").path
|
request_path = context.get("request").path
|
||||||
|
|
||||||
|
@ -19,3 +18,4 @@ def active_path(context, path_name, class_name) -> str | None:
|
||||||
|
|
||||||
if is_path or is_base_path:
|
if is_path or is_base_path:
|
||||||
return class_name
|
return class_name
|
||||||
|
return None
|
||||||
|
|
|
@ -1,23 +1,24 @@
|
||||||
import contextlib
|
import contextlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.core.exceptions import FieldError
|
from django.core.exceptions import FieldError
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Model
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from zen_queries import queries_disabled
|
from zen_queries import queries_disabled
|
||||||
from zen_queries import render as zen_queries_render
|
from zen_queries import render as zen_queries_render
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django.db.models import Model
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Row:
|
class Row:
|
||||||
"""
|
"""A row in a table."""
|
||||||
A row in a table.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data: dict[str, str]
|
data: dict[str, str]
|
||||||
actions: list[dict[str, str]]
|
actions: list[dict[str, str]]
|
||||||
|
@ -25,18 +26,14 @@ class Row:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RowAction:
|
class RowAction:
|
||||||
"""
|
"""An action that can be performed on a row in a table."""
|
||||||
An action that can be performed on a row in a table.
|
|
||||||
"""
|
|
||||||
|
|
||||||
label: str
|
label: str
|
||||||
url_name: str
|
url_name: str
|
||||||
url_kwargs: dict[str, str]
|
url_kwargs: dict[str, str]
|
||||||
|
|
||||||
def render(self, obj) -> dict[str, str]:
|
def render(self, obj) -> dict[str, str]:
|
||||||
"""
|
"""Render the action as a dictionary for the given object."""
|
||||||
Render the action as a dictionary for the given object.
|
|
||||||
"""
|
|
||||||
url = reverse(
|
url = reverse(
|
||||||
self.url_name,
|
self.url_name,
|
||||||
kwargs={key: getattr(obj, value) for key, value in self.url_kwargs.items()},
|
kwargs={key: getattr(obj, value) for key, value in self.url_kwargs.items()},
|
||||||
|
@ -50,14 +47,11 @@ def render_list(
|
||||||
entity_name_plural: str,
|
entity_name_plural: str,
|
||||||
objects: list["Model"],
|
objects: list["Model"],
|
||||||
columns: list[tuple[str, str]],
|
columns: list[tuple[str, str]],
|
||||||
row_actions: list[RowAction] = None,
|
row_actions: list[RowAction] | None = None,
|
||||||
list_actions: list[tuple[str, str]] = None,
|
list_actions: list[tuple[str, str]] | None = None,
|
||||||
paginate_by: int = None,
|
paginate_by: int | None = None,
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
"""
|
"""Render a list of objects with a table."""
|
||||||
Render a list of objects with a table.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# TODO: List actions
|
# TODO: List actions
|
||||||
|
|
||||||
total_count = len(objects)
|
total_count = len(objects)
|
||||||
|
@ -107,17 +101,18 @@ def render_list(
|
||||||
|
|
||||||
|
|
||||||
def base_context(request: HttpRequest) -> dict[str, Any]:
|
def base_context(request: HttpRequest) -> dict[str, Any]:
|
||||||
"""
|
"""Return a base context for all views."""
|
||||||
Return a base context for all views.
|
|
||||||
"""
|
|
||||||
return {"site": get_current_site(request)}
|
return {"site": get_current_site(request)}
|
||||||
|
|
||||||
|
|
||||||
def render(request, template_name, context=None):
|
def render(request, template_name, context=None):
|
||||||
"""
|
"""Render a template with a base context."""
|
||||||
Render a template with a base context.
|
|
||||||
"""
|
|
||||||
if context is None:
|
if context is None:
|
||||||
context = {}
|
context = {}
|
||||||
context = base_context(request) | context
|
context = base_context(request) | context
|
||||||
|
|
||||||
|
# Make sure to fetch all permissions before rendering the template
|
||||||
|
# otherwise django-zen-queries will complain about database queries.
|
||||||
|
request.user.get_all_permissions()
|
||||||
|
|
||||||
return zen_queries_render(request, template_name, context)
|
return zen_queries_render(request, template_name, context)
|
||||||
|
|
Loading…
Reference in a new issue