Compare commits
14 Commits
fd544fcebc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 01e926b140 | |||
| ab372f5d0c | |||
| de2d7c12cd | |||
| dd996a78ca | |||
| cb95d6053d | |||
| 977dd30e5c | |||
| 45bd37e26c | |||
| a451d2e532 | |||
| a6e97c6170 | |||
| 927b4b0b4e | |||
| bafca3c291 | |||
| a5ac52a387 | |||
| d1fc874c35 | |||
| 5d2dfe37c1 |
81
Ideensammlung.md
Normal file
81
Ideensammlung.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Allmende Bezahlsystem
|
||||||
|
|
||||||
|
## Grundlegende Ideen
|
||||||
|
|
||||||
|
Das Allmende Bezahlsystem ermöglicht es Bewohner*innen der Allmende Waren und Leistungen in der Allmende bequem und einfach zu bezahlen.
|
||||||
|
Dazu gehören zum Beispiel:
|
||||||
|
|
||||||
|
* Das gemeinsame Essen
|
||||||
|
* Waren aus der Food-Coop
|
||||||
|
* Waschen und Trocknen
|
||||||
|
* Kaffee und Getränke im großen Gemeinschaftsraum
|
||||||
|
|
||||||
|
Das System ist leicht um weitere Waren und Leitungen zu erweitern.
|
||||||
|
|
||||||
|
#### Schnittstellen
|
||||||
|
|
||||||
|
Das System ist so gebaut, dass Schnittstellen zu anderen Systemen leicht gebaut werden können, anstatt deren Funktionalität zu integrieren. Beispiele:
|
||||||
|
|
||||||
|
* Essensanmeldung: Angemeldete Essen können automatisch eingelesen und bezahlt werden.
|
||||||
|
* Food-Coop-Waage: Das noch zu erstellende Waagensystem kann die Produkte für die Food-Coop auslesen, dem User zur Auswahl stellen und dann eine Transaktion mit der gekauften Menge ablegen.
|
||||||
|
* Warenwirtschaftssystem: Gekaufte Mengen in der Food-Coop können automatisch in ein Warenwirtschaftssystem der Food-Coop ausgeleitet werden.
|
||||||
|
* Buchhaltung: Alle für die Buchhaltung relevanten Daten können manuell oder automatisch exportiert werden.
|
||||||
|
* ...
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
Das System ist über LDAP an die bestehende Cloud der Allmende angebunden, so können alle einfach ihren gewohnten Zugang verwenden.
|
||||||
|
|
||||||
|
#### Nutzer*innen-gruppen
|
||||||
|
|
||||||
|
Es wird folgende Nutzer*innen-gruppen geben:
|
||||||
|
|
||||||
|
* Normale Nutzer*innen: Können Transaktionen für ihre eigenen Konten eintragen und sehen
|
||||||
|
* Bereichsadmins: Können Produkte für ihnen zugeordnete Bereiche bearbeiten.
|
||||||
|
* Admins: Können alle Transaktionen sehen und exportieren (Buchhaltung)
|
||||||
|
|
||||||
|
### Konten
|
||||||
|
|
||||||
|
Guthaben im System werden auf **Konten** verwaltet. Durch Überweisung auf ein Konto des Hausvereins Allmende e.V. kann Guthaben aufgeladen werden. Da viele Haushalte ein gemeinsames Konto führen werden, aber einzelne User-Zugänge haben, können mehrere User zu einem Konto hinzugefügt werden.
|
||||||
|
|
||||||
|
Ein User kann auch auf mehrere Konten Zugriff haben, da es auch Gruppenkonten (z.B. AG Kochen) geben wird.
|
||||||
|
|
||||||
|
Konten werden von Admins angelegt und verwaltet.
|
||||||
|
|
||||||
|
Für jedes Konto kann jederzeit der **Kontoauszug** angesehen werden, eine vollständige Auflistung aller Transaktionen filterbar nach Zeitraum.
|
||||||
|
|
||||||
|
### Produkte
|
||||||
|
|
||||||
|
Alles was man im Bezahlsystem bezahlen kann ist ein **Produkt**. Produkte haben folgende Eigenschaften:
|
||||||
|
|
||||||
|
* Name
|
||||||
|
* Netto-Preis
|
||||||
|
* MwSt-Satz
|
||||||
|
* Abrechnungsgröße (Stück, kg, etc)
|
||||||
|
* Beschreibung
|
||||||
|
* ...
|
||||||
|
|
||||||
|
### Bereiche
|
||||||
|
|
||||||
|
Produkte können in Bereiche sortiert werden (Food-Coop, Waschküche, etc), so dass sie leichter gefunden werden können.
|
||||||
|
|
||||||
|
### Bezahlvorgang
|
||||||
|
|
||||||
|
Der standard Bezahlvorgang läuft wie folgt ab:
|
||||||
|
|
||||||
|
* User loggt sich ein
|
||||||
|
* Wählt Produkt aus, stellt Menge ein
|
||||||
|
* Drückt auf Bezahlen
|
||||||
|
|
||||||
|
Transaktionen bleiben für 24 Stunden für den User bearbeit- und löschbar, um Fehler selbst zu korrigieren. Danach ist dies nur noch durch Admins möglich.
|
||||||
|
|
||||||
|
#### PoS (Point of Sales) - Setup
|
||||||
|
|
||||||
|
Für maximalen Komfort sollte es am Ort des Einkaufs (Waschküche, Food-Coop, Kaffeemaschine) möglich sein, direkt die Bezahlung durchzuführen. Dafür ist es denkbar dort Tablets aufzuhängen über man schnell sein Konto auswählt und den Einkauf aus einem vorausgewählten Sortiment (etc. in der Waschküche nur Waschen und Trocknen).
|
||||||
|
|
||||||
|
Fraglich ist hier, welche Art der Authentifizierung man hier wählt. Jedes Mal Nutzer und Passwort eingeben zu müssen kann eine zu hohe Hürde sein. Denkbar wäre es, diese Transaktionen durch eine kürzere PIN zu autorisieren oder sogar ganz auf das Vertrauenssystem (keine Authentifizierung am PoS) zu setzen.
|
||||||
|
|
||||||
|
Es ist natürlich auch möglich, die Papierlisten erstmal beizubehalten und die Striche dann von Admins ins System übertragen zu lassen. So kann man das System schrittweise einführen.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
7
Makefile
Normal file
7
Makefile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
lint:
|
||||||
|
uv run isort test src
|
||||||
|
uv run black test src
|
||||||
|
|
||||||
|
reset_db:
|
||||||
|
rm -f aps_db.db
|
||||||
|
uv run alembic upgrade heads
|
||||||
81
README.md
81
README.md
@@ -1,81 +0,0 @@
|
|||||||
# Allmende Bezahlsystem
|
|
||||||
|
|
||||||
## Grundlegende Ideen
|
|
||||||
|
|
||||||
Das Allmende Bezahlsystem ermöglicht es Bewohner*innen der Allmende Waren und Leistungen in der Allmende bequem und einfach zu bezahlen.
|
|
||||||
Dazu gehören zum Beispiel:
|
|
||||||
|
|
||||||
* Das gemeinsame Essen
|
|
||||||
* Waren aus der Food-Coop
|
|
||||||
* Waschen und Trocknen
|
|
||||||
* Kaffee und Getränke im großen Gemeinschaftsraum
|
|
||||||
|
|
||||||
Das System ist leicht um weitere Waren und Leitungen zu erweitern.
|
|
||||||
|
|
||||||
#### Schnittstellen
|
|
||||||
|
|
||||||
Das System ist so gebaut, dass Schnittstellen zu anderen Systemen leicht gebaut werden können, anstatt deren Funktionalität zu integrieren. Beispiele:
|
|
||||||
|
|
||||||
* Essensanmeldung: Angemeldete Essen können automatisch eingelesen und bezahlt werden.
|
|
||||||
* Food-Coop-Waage: Das noch zu erstellende Waagensystem kann die Produkte für die Food-Coop auslesen, dem User zur Auswahl stellen und dann eine Transaktion mit der gekauften Menge ablegen.
|
|
||||||
* Warenwirtschaftssystem: Gekaufte Mengen in der Food-Coop können automatisch in ein Warenwirtschaftssystem der Food-Coop ausgeleitet werden.
|
|
||||||
* Buchhaltung: Alle für die Buchhaltung relevanten Daten können manuell oder automatisch exportiert werden.
|
|
||||||
* ...
|
|
||||||
|
|
||||||
### User
|
|
||||||
|
|
||||||
Das System ist über LDAP an die bestehende Cloud der Allmende angebunden, so können alle einfach ihren gewohnten Zugang verwenden.
|
|
||||||
|
|
||||||
#### Nutzer*innen-gruppen
|
|
||||||
|
|
||||||
Es wird folgende Nutzer*innen-gruppen geben:
|
|
||||||
|
|
||||||
* Normale Nutzer*innen: Können Transaktionen für ihre eigenen Konten eintragen und sehen
|
|
||||||
* Bereichsadmins: Können Produkte für ihnen zugeordnete Bereiche bearbeiten.
|
|
||||||
* Admins: Können alle Transaktionen sehen und exportieren (Buchhaltung)
|
|
||||||
|
|
||||||
### Konten
|
|
||||||
|
|
||||||
Guthaben im System werden auf **Konten** verwaltet. Durch Überweisung auf ein Konto des Hausvereins Allmende e.V. kann Guthaben aufgeladen werden. Da viele Haushalte ein gemeinsames Konto führen werden, aber einzelne User-Zugänge haben, können mehrere User zu einem Konto hinzugefügt werden.
|
|
||||||
|
|
||||||
Ein User kann auch auf mehrere Konten Zugriff haben, da es auch Gruppenkonten (z.B. AG Kochen) geben wird.
|
|
||||||
|
|
||||||
Konten werden von Admins angelegt und verwaltet.
|
|
||||||
|
|
||||||
Für jedes Konto kann jederzeit der **Kontoauszug** angesehen werden, eine vollständige Auflistung aller Transaktionen filterbar nach Zeitraum.
|
|
||||||
|
|
||||||
### Produkte
|
|
||||||
|
|
||||||
Alles was man im Bezahlsystem bezahlen kann ist ein **Produkt**. Produkte haben folgende Eigenschaften:
|
|
||||||
|
|
||||||
* Name
|
|
||||||
* Netto-Preis
|
|
||||||
* MwSt-Satz
|
|
||||||
* Abrechnungsgröße (Stück, kg, etc)
|
|
||||||
* Beschreibung
|
|
||||||
* ...
|
|
||||||
|
|
||||||
### Bereiche
|
|
||||||
|
|
||||||
Produkte können in Bereiche sortiert werden (Food-Coop, Waschküche, etc), so dass sie leichter gefunden werden können.
|
|
||||||
|
|
||||||
### Bezahlvorgang
|
|
||||||
|
|
||||||
Der standard Bezahlvorgang läuft wie folgt ab:
|
|
||||||
|
|
||||||
* User loggt sich ein
|
|
||||||
* Wählt Produkt aus, stellt Menge ein
|
|
||||||
* Drückt auf Bezahlen
|
|
||||||
|
|
||||||
Transaktionen bleiben für 24 Stunden für den User bearbeit- und löschbar, um Fehler selbst zu korrigieren. Danach ist dies nur noch durch Admins möglich.
|
|
||||||
|
|
||||||
#### PoS (Point of Sales) - Setup
|
|
||||||
|
|
||||||
Für maximalen Komfort sollte es am Ort des Einkaufs (Waschküche, Food-Coop, Kaffeemaschine) möglich sein, direkt die Bezahlung durchzuführen. Dafür ist es denkbar dort Tablets aufzuhängen über man schnell sein Konto auswählt und den Einkauf aus einem vorausgewählten Sortiment (etc. in der Waschküche nur Waschen und Trocknen).
|
|
||||||
|
|
||||||
Fraglich ist hier, welche Art der Authentifizierung man hier wählt. Jedes Mal Nutzer und Passwort eingeben zu müssen kann eine zu hohe Hürde sein. Denkbar wäre es, diese Transaktionen durch eine kürzere PIN zu autorisieren oder sogar ganz auf das Vertrauenssystem (keine Authentifizierung am PoS) zu setzen.
|
|
||||||
|
|
||||||
Es ist natürlich auch möglich, die Papierlisten erstmal beizubehalten und die Striche dann von Admins ins System übertragen zu lassen. So kann man das System schrittweise einführen.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
147
alembic.ini
Normal file
147
alembic.ini
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the tzdata library which can be installed by adding
|
||||||
|
# `alembic[tz]` to the pip requirements.
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
sqlalchemy.url = sqlite:///aps_db.db
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
alembic/README
Normal file
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
80
alembic/env.py
Normal file
80
alembic/env.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
from allmende_payment_system.models import Base
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
28
alembic/script.py.mako
Normal file
28
alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
132
alembic/versions/958d7aee2b21_initial_model.py
Normal file
132
alembic/versions/958d7aee2b21_initial_model.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""Initial model
|
||||||
|
|
||||||
|
Revision ID: 958d7aee2b21
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-01-23 10:10:36.825333
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '958d7aee2b21'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('aps_account',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('aps_area',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('description', sa.String(), nullable=True),
|
||||||
|
sa.Column('image_path', sa.String(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('aps_user',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('username', sa.String(), nullable=False),
|
||||||
|
sa.Column('display_name', sa.String(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('username')
|
||||||
|
)
|
||||||
|
op.create_table('aps_user_group',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('description', sa.String(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('aps_order',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['aps_user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('aps_permission',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('scope', sa.String(), nullable=False),
|
||||||
|
sa.Column('action', sa.String(), nullable=False),
|
||||||
|
sa.Column('user_group_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_group_id'], ['aps_user_group.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('aps_product',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||||
|
sa.Column('unit_of_measure', sa.Enum('g', 'kg', 'l', 'piece', native_enum=False), nullable=False),
|
||||||
|
sa.Column('allow_fractional', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('vat_rate', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||||
|
sa.Column('area_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('image_path', sa.String(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['area_id'], ['aps_area.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('aps_user_account_association',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('account_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['account_id'], ['aps_account.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['aps_user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', 'account_id')
|
||||||
|
)
|
||||||
|
op.create_table('aps_user_user_group_association',
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_group_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_group_id'], ['aps_user_group.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['aps_user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', 'user_group_id')
|
||||||
|
)
|
||||||
|
op.create_table('aps_order_item',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('order_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('quantity', sa.Numeric(), nullable=False),
|
||||||
|
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['order_id'], ['aps_order.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['product_id'], ['aps_product.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('aps_transaction',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('type', sa.Enum('order', 'deposit', 'withdrawal', 'expense', native_enum=False), nullable=False),
|
||||||
|
sa.Column('quantity', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||||
|
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||||
|
sa.Column('order_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('account_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['account_id'], ['aps_account.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['order_id'], ['aps_order.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('aps_transaction')
|
||||||
|
op.drop_table('aps_order_item')
|
||||||
|
op.drop_table('aps_user_user_group_association')
|
||||||
|
op.drop_table('aps_user_account_association')
|
||||||
|
op.drop_table('aps_product')
|
||||||
|
op.drop_table('aps_permission')
|
||||||
|
op.drop_table('aps_order')
|
||||||
|
op.drop_table('aps_user_group')
|
||||||
|
op.drop_table('aps_user')
|
||||||
|
op.drop_table('aps_area')
|
||||||
|
op.drop_table('aps_account')
|
||||||
|
# ### end Alembic commands ###
|
||||||
2
justfile
2
justfile
@@ -4,5 +4,5 @@ lint:
|
|||||||
|
|
||||||
reset_db:
|
reset_db:
|
||||||
rm -f aps_db.db
|
rm -f aps_db.db
|
||||||
uv run python -c "from allmende_payment_system.database import create_tables; create_tables()"
|
uv run alembic upgrade heads
|
||||||
for file in db_fixtures/*.sql; do sqlite3 aps_db.db < "$file"; done
|
for file in db_fixtures/*.sql; do sqlite3 aps_db.db < "$file"; done
|
||||||
@@ -8,6 +8,7 @@ authors = [
|
|||||||
]
|
]
|
||||||
requires-python = ">=3.13,<3.14"
|
requires-python = ">=3.13,<3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"alembic>=1.18.1",
|
||||||
"fastapi[standard]>=0.119.0",
|
"fastapi[standard]>=0.119.0",
|
||||||
"sqlalchemy>=2.0.44",
|
"sqlalchemy>=2.0.44",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
def main() -> None:
|
|
||||||
print("Hello from allmende-payment-system!")
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
from allmende_payment_system.api.dependencies import SessionDep, UserDep
|
from allmende_payment_system.api.dependencies import SessionDep, UserDep
|
||||||
from allmende_payment_system.database import ensure_user
|
|
||||||
from allmende_payment_system.tools import get_jinja_renderer
|
from allmende_payment_system.tools import get_jinja_renderer
|
||||||
|
|
||||||
root_router = APIRouter()
|
root_router = APIRouter()
|
||||||
|
|||||||
278
src/allmende_payment_system/api/admin.py
Normal file
278
src/allmende_payment_system/api/admin.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, HTTPException, Request
|
||||||
|
from sqlalchemy import select
|
||||||
|
from starlette import status
|
||||||
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
|
from allmende_payment_system import types
|
||||||
|
from allmende_payment_system.api.dependencies import SessionDep, UserDep
|
||||||
|
from allmende_payment_system.models import Area, Permission, Product, User, UserGroup
|
||||||
|
from allmende_payment_system.tools import get_jinja_renderer
|
||||||
|
|
||||||
|
admin_router = APIRouter(prefix="/admin")
|
||||||
|
|
||||||
|
# USERS
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/users")
|
||||||
|
async def user_list(request: Request, session: SessionDep, user: UserDep):
|
||||||
|
if not user.has_permission("user", "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
users = session.scalars(select(User)).all()
|
||||||
|
groups = session.scalars(select(UserGroup)).all()
|
||||||
|
templates = get_jinja_renderer()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"users.html.jinja",
|
||||||
|
context={"request": request, "users": users, "all_groups": groups},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post("/users/{user_id}/add_group")
|
||||||
|
async def user_add_group(
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
loggend_in_user: UserDep,
|
||||||
|
user_id: int,
|
||||||
|
group_id: Annotated[int, Form()],
|
||||||
|
):
|
||||||
|
if not loggend_in_user.has_permission("user", "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
group = session.execute(
|
||||||
|
select(UserGroup).where(UserGroup.id == group_id)
|
||||||
|
).scalar_one()
|
||||||
|
user = session.execute(select(User).where(User.id == user_id)).scalar_one()
|
||||||
|
user.user_groups.append(group)
|
||||||
|
|
||||||
|
return RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/users/{user_id}/remove_group/{group_id}")
|
||||||
|
async def user_remove_group(
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
loggend_in_user: UserDep,
|
||||||
|
user_id: int,
|
||||||
|
group_id: int,
|
||||||
|
):
|
||||||
|
if not loggend_in_user.has_permission("user", "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
group = session.execute(
|
||||||
|
select(UserGroup).where(UserGroup.id == group_id)
|
||||||
|
).scalar_one()
|
||||||
|
user = session.execute(select(User).where(User.id == user_id)).scalar_one()
|
||||||
|
print(user)
|
||||||
|
user.user_groups.remove(group)
|
||||||
|
return RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
# GROUPS
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/groups")
|
||||||
|
async def group_list(request: Request, session: SessionDep, user: UserDep):
|
||||||
|
if not user.has_permission("user", "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
groups = session.scalars(select(UserGroup)).all()
|
||||||
|
templates = get_jinja_renderer()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"groups.html.jinja",
|
||||||
|
context={"request": request, "groups": groups},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post("/groups/{group_id}/add_permission")
|
||||||
|
async def group_add_permission(
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
user: UserDep,
|
||||||
|
group_id: int,
|
||||||
|
permission: Annotated[str, Form()],
|
||||||
|
):
|
||||||
|
if not user.has_permission("user", "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
scope_action = permission.split(":")
|
||||||
|
if len(scope_action) != 2:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Permission must be in the format 'scope:action'"
|
||||||
|
)
|
||||||
|
|
||||||
|
permission = Permission(scope=scope_action[0], action=scope_action[1])
|
||||||
|
group = session.execute(
|
||||||
|
select(UserGroup).where(UserGroup.id == group_id)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
session.add(permission)
|
||||||
|
group.permissions.append(permission)
|
||||||
|
|
||||||
|
return RedirectResponse(url="/admin/groups", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/groups/{group_id}/remove_permission/{permission_id}")
|
||||||
|
async def group_remove_permission(
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
user: UserDep,
|
||||||
|
group_id: int,
|
||||||
|
permission_id: int,
|
||||||
|
):
|
||||||
|
if not user.has_permission("user", "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
permission = session.execute(
|
||||||
|
select(Permission).where(Permission.id == permission_id)
|
||||||
|
).scalar_one()
|
||||||
|
group = session.execute(
|
||||||
|
select(UserGroup).where(UserGroup.id == group_id)
|
||||||
|
).scalar_one()
|
||||||
|
group.permissions.remove(permission)
|
||||||
|
session.delete(permission)
|
||||||
|
return RedirectResponse(url="/admin/groups", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post("/groups/create")
|
||||||
|
async def create_group(
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
user: UserDep,
|
||||||
|
group_data: Annotated[types.UserGroup, Form()],
|
||||||
|
):
|
||||||
|
if not user.has_permission("user", "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
group = UserGroup(name=group_data.name, description=group_data.description)
|
||||||
|
session.add(group)
|
||||||
|
|
||||||
|
return RedirectResponse(url="/admin/groups", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/groups/{group_id}/delete")
|
||||||
|
async def delete_group(
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
user: UserDep,
|
||||||
|
group_id: int,
|
||||||
|
):
|
||||||
|
if not user.has_permission("user", "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
group = session.execute(
|
||||||
|
select(UserGroup).where(UserGroup.id == group_id)
|
||||||
|
).scalar_one()
|
||||||
|
session.delete(group)
|
||||||
|
return RedirectResponse(url="/admin/groups", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
# PRODUCTS
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/products")
|
||||||
|
async def get_products(
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
user: UserDep,
|
||||||
|
):
|
||||||
|
products = session.scalars(
|
||||||
|
select(Product).order_by(Product.area_id, Product.name)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
templates = get_jinja_renderer()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"products.html.jinja",
|
||||||
|
context={"request": request, "products": products},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/products/edit/{product_id}")
|
||||||
|
async def edit_product_get(
|
||||||
|
request: Request, session: SessionDep, user: UserDep, product_id: int
|
||||||
|
):
|
||||||
|
product = session.execute(select(Product).where(Product.id == product_id)).scalar()
|
||||||
|
|
||||||
|
areas = session.scalars(select(Area)).all()
|
||||||
|
|
||||||
|
templates = get_jinja_renderer()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"product_edit.html.jinja",
|
||||||
|
context={
|
||||||
|
"request": request,
|
||||||
|
"product": product,
|
||||||
|
"edit_mode": True,
|
||||||
|
"areas": areas,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post("/products/edit/{product_id}")
|
||||||
|
async def edit_product_post(
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
user: UserDep,
|
||||||
|
product_id: int,
|
||||||
|
product_data: Annotated[types.Product, Form()],
|
||||||
|
):
|
||||||
|
if not user.has_permission("product", "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
product = session.execute(
|
||||||
|
select(Product).where(Product.id == product_id)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
for field_name, data in product_data.model_dump().items():
|
||||||
|
setattr(product, field_name, data)
|
||||||
|
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/products", status_code=status.HTTP_303_SEE_OTHER
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/products/new")
|
||||||
|
async def new_product_get(
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
user: UserDep,
|
||||||
|
):
|
||||||
|
if not user.has_permission("product", "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
areas = session.scalars(select(Area)).all()
|
||||||
|
|
||||||
|
templates = get_jinja_renderer()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"product_edit.html.jinja",
|
||||||
|
context={"request": request, "edit_mode": False, "areas": areas},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post("/products/new")
|
||||||
|
async def new_product_post(
|
||||||
|
request: Request,
|
||||||
|
session: SessionDep,
|
||||||
|
user: UserDep,
|
||||||
|
product_data: Annotated[types.Product, Form()],
|
||||||
|
# product_image: Annotated[bytes, File()]
|
||||||
|
):
|
||||||
|
if not user.has_permission("product", "edit"):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
# print(len(product_image))
|
||||||
|
product = Product()
|
||||||
|
|
||||||
|
for field_name, data in product_data.model_dump().items():
|
||||||
|
setattr(product, field_name, data)
|
||||||
|
|
||||||
|
session.add(product)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/products", status_code=status.HTTP_303_SEE_OTHER
|
||||||
|
)
|
||||||
@@ -30,7 +30,7 @@ async def get_user(request: Request) -> dict:
|
|||||||
if "ynh_user" not in request.headers:
|
if "ynh_user" not in request.headers:
|
||||||
raise HTTPException(status_code=401, detail="Missing ynh_user header")
|
raise HTTPException(status_code=401, detail="Missing ynh_user header")
|
||||||
|
|
||||||
return {"username": request.headers["ynh_user"]}
|
return {"username": request.headers["ynh_user"], "display_name": request.headers["ynh_user_fullname"]}
|
||||||
|
|
||||||
|
|
||||||
async def get_user_object(request: Request, session: SessionDep) -> User:
|
async def get_user_object(request: Request, session: SessionDep) -> User:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from starlette import status
|
from starlette import status
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
from allmende_payment_system.api.dependencies import SessionDep, UserDep
|
from allmende_payment_system.api.dependencies import SessionDep, UserDep
|
||||||
from allmende_payment_system.models import Area, OrderItem, Product
|
from allmende_payment_system.models import Area, Order, OrderItem, Product
|
||||||
from allmende_payment_system.tools import get_jinja_renderer
|
from allmende_payment_system.tools import get_jinja_renderer
|
||||||
|
|
||||||
shop_router = APIRouter()
|
shop_router = APIRouter()
|
||||||
@@ -28,8 +28,8 @@ async def get_shop(request: Request, session: SessionDep):
|
|||||||
async def get_cart(request: Request, session: SessionDep, user: UserDep):
|
async def get_cart(request: Request, session: SessionDep, user: UserDep):
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"cart.html.jinja",
|
"order.html.jinja",
|
||||||
context={"request": request, "user": user},
|
context={"request": request, "order": user.shopping_cart, "is_cart": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,3 +76,57 @@ async def add_to_cart(request: Request, session: SessionDep, user: UserDep):
|
|||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"/shop/area/{form_data['area_id']}", status_code=status.HTTP_302_FOUND
|
url=f"/shop/area/{form_data['area_id']}", status_code=status.HTTP_302_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shop_router.get("/shop/cart/remove/{item_id}")
|
||||||
|
async def remove_from_cart(
|
||||||
|
request: Request, session: SessionDep, user: UserDep, item_id: int
|
||||||
|
):
|
||||||
|
|
||||||
|
cart = user.shopping_cart
|
||||||
|
for item in cart.items:
|
||||||
|
if item.id == item_id:
|
||||||
|
item.order = None
|
||||||
|
session.delete(item)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found in cart.")
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/shop/cart", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
@shop_router.post("/shop/cart/update/{item_id}")
|
||||||
|
async def update_cart_item(
|
||||||
|
request: Request, session: SessionDep, user: UserDep, item_id: int
|
||||||
|
):
|
||||||
|
|
||||||
|
form_data = await request.form()
|
||||||
|
|
||||||
|
cart = user.shopping_cart
|
||||||
|
for item in cart.items:
|
||||||
|
if item.id == item_id:
|
||||||
|
item.update_quantity(Decimal(form_data["quantity"]))
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found in cart.")
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/shop/cart", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
@shop_router.get("/shop/order/{order_id}")
|
||||||
|
async def get_order_details(
|
||||||
|
request: Request, session: SessionDep, user: UserDep, order_id: int
|
||||||
|
):
|
||||||
|
|
||||||
|
query = select(Order).where(Order.id == order_id)
|
||||||
|
order = session.scalars(query).one()
|
||||||
|
|
||||||
|
if user.id != order.user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail=f"User not authorized to view this order."
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"order.html.jinja",
|
||||||
|
context={"request": request, "order": order, "is_cart": False},
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi import Depends, FastAPI
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from allmende_payment_system.api import root_router
|
from allmende_payment_system.api import root_router
|
||||||
|
from allmende_payment_system.api.admin import admin_router
|
||||||
from allmende_payment_system.api.dependencies import get_user_object
|
from allmende_payment_system.api.dependencies import get_user_object
|
||||||
from allmende_payment_system.api.shop import shop_router
|
from allmende_payment_system.api.shop import shop_router
|
||||||
|
|
||||||
@@ -20,3 +21,4 @@ app.mount(
|
|||||||
|
|
||||||
app.include_router(root_router)
|
app.include_router(root_router)
|
||||||
app.include_router(shop_router)
|
app.include_router(shop_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from sqlalchemy.orm import (
|
|||||||
relationship,
|
relationship,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from allmende_payment_system.types import UnitsOfMeasure
|
||||||
|
|
||||||
TABLE_PREFIX = "aps_"
|
TABLE_PREFIX = "aps_"
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +57,12 @@ class User(Base):
|
|||||||
)
|
)
|
||||||
orders: Mapped[list["Order"]] = relationship("Order", back_populates="user")
|
orders: Mapped[list["Order"]] = relationship("Order", back_populates="user")
|
||||||
|
|
||||||
|
user_groups: Mapped[list["UserGroup"]] = relationship(
|
||||||
|
"UserGroup",
|
||||||
|
secondary=TABLE_PREFIX + "user_user_group_association",
|
||||||
|
back_populates="users",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def shopping_cart(self):
|
def shopping_cart(self):
|
||||||
for order in self.orders:
|
for order in self.orders:
|
||||||
@@ -68,6 +76,52 @@ class User(Base):
|
|||||||
|
|
||||||
return cart
|
return cart
|
||||||
|
|
||||||
|
def has_permission(self, scope: str, action: str) -> bool:
|
||||||
|
for group in self.user_groups:
|
||||||
|
for permission in group.permissions:
|
||||||
|
if permission.scope == scope and permission.action == action:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class UserGroup(Base):
|
||||||
|
__tablename__ = TABLE_PREFIX + "user_group"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(nullable=False, unique=True)
|
||||||
|
description: Mapped[str] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
permissions = relationship("Permission", back_populates="user_group")
|
||||||
|
|
||||||
|
users: Mapped[list["User"]] = relationship(
|
||||||
|
"User",
|
||||||
|
secondary=TABLE_PREFIX + "user_user_group_association",
|
||||||
|
back_populates="user_groups",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
user_group_association = Table(
|
||||||
|
TABLE_PREFIX + "user_user_group_association",
|
||||||
|
Base.metadata,
|
||||||
|
Column("user_id", ForeignKey(TABLE_PREFIX + "user.id"), primary_key=True),
|
||||||
|
Column(
|
||||||
|
"user_group_id", ForeignKey(TABLE_PREFIX + "user_group.id"), primary_key=True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(Base):
|
||||||
|
__tablename__ = TABLE_PREFIX + "permission"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
scope: Mapped[str] = mapped_column(nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(nullable=False)
|
||||||
|
|
||||||
|
user_group_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey(TABLE_PREFIX + "user_group.id")
|
||||||
|
)
|
||||||
|
user_group: Mapped["UserGroup"] = relationship(
|
||||||
|
"UserGroup", back_populates="permissions"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Area(Base):
|
class Area(Base):
|
||||||
__tablename__ = TABLE_PREFIX + "area"
|
__tablename__ = TABLE_PREFIX + "area"
|
||||||
@@ -79,14 +133,6 @@ class Area(Base):
|
|||||||
products: Mapped[list["Product"]] = relationship("Product")
|
products: Mapped[list["Product"]] = relationship("Product")
|
||||||
|
|
||||||
|
|
||||||
UnitsOfMeasure = typing.Literal[
|
|
||||||
"g",
|
|
||||||
"kg",
|
|
||||||
"l",
|
|
||||||
"piece",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Product(Base):
|
class Product(Base):
|
||||||
__tablename__ = TABLE_PREFIX + "product"
|
__tablename__ = TABLE_PREFIX + "product"
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
@@ -94,6 +140,8 @@ class Product(Base):
|
|||||||
|
|
||||||
price: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
|
price: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
|
||||||
unit_of_measure: Mapped[UnitsOfMeasure] = mapped_column(nullable=False)
|
unit_of_measure: Mapped[UnitsOfMeasure] = mapped_column(nullable=False)
|
||||||
|
allow_fractional: Mapped[bool] = mapped_column(nullable=False, default=True)
|
||||||
|
|
||||||
# TODO: limit this to actually used vat rates?
|
# TODO: limit this to actually used vat rates?
|
||||||
vat_rate: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
|
vat_rate: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
|
||||||
|
|
||||||
@@ -162,6 +210,13 @@ class OrderItem(Base):
|
|||||||
Numeric(10, 2), nullable=False
|
Numeric(10, 2), nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def update_quantity(self, new_quantity: decimal.Decimal):
|
||||||
|
if new_quantity <= 0:
|
||||||
|
raise ValueError("Quantity must be positive.")
|
||||||
|
|
||||||
|
self.quantity = new_quantity
|
||||||
|
self.total_amount = self.product.price * new_quantity
|
||||||
|
|
||||||
|
|
||||||
TransactionTypes = typing.Literal[
|
TransactionTypes = typing.Literal[
|
||||||
"order",
|
"order",
|
||||||
|
|||||||
@@ -67,14 +67,15 @@
|
|||||||
<input type="hidden" name="product_id" value="{{ product.id }}">
|
<input type="hidden" name="product_id" value="{{ product.id }}">
|
||||||
<input type="hidden" name="area_id" value="{{ area.id }}">
|
<input type="hidden" name="area_id" value="{{ area.id }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="quantity{{ product.id }}" class="form-label">Menge</label>
|
<label for="quantity{{ product.id }}" class="form-label">Menge{% if product.unit_of_measure != 'piece' %} (in {{product.unit_of_measure}}){% endif %}</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
id="quantity{{ product.id }}"
|
id="quantity{{ product.id }}"
|
||||||
name="quantity"
|
name="quantity"
|
||||||
value="1"
|
value="1"
|
||||||
min="1"
|
{% if product.allow_fractional %}min="0.01"{% else %}min="1"{% endif %}
|
||||||
max="999"
|
max="999"
|
||||||
|
{% if product.allow_fractional %}step="0.01"{% else %}step="1"{% endif %}
|
||||||
oninput="updateTotal{{ product.id }}(this.value)"
|
oninput="updateTotal{{ product.id }}(this.value)"
|
||||||
style="max-width: 150px;">
|
style="max-width: 150px;">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,25 +20,34 @@
|
|||||||
</a>
|
</a>
|
||||||
<ul class="nav nav-pills flex-column mb-auto">
|
<ul class="nav nav-pills flex-column mb-auto">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="#" class="nav-link active">
|
<a href="/" class="nav-link{% if request.url.path == "/"%} active{% endif %}">
|
||||||
Übersicht
|
Übersicht
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="/shop" class="nav-link">
|
<a href="/shop" class="nav-link{% if request.url.path.startswith("/shop")%} active{% endif %}">
|
||||||
Einkaufen
|
Einkaufen
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{# <li class="nav-item">#}
|
{% if request.state.user.has_permission("product", "edit") %}
|
||||||
{# <a href="#" class="nav-link">#}
|
<li class="nav-item">
|
||||||
{# Lorem#}
|
<a href="/admin/products" class="nav-link{% if request.url.path.startswith("/admin/products")%} active{% endif %}">
|
||||||
{# </a>#}
|
Produktverwaltung
|
||||||
{# </li>#}
|
</a>
|
||||||
{# <li class="nav-item">#}
|
</li>
|
||||||
{# <a href="#" class="nav-link">#}
|
{% endif %}
|
||||||
{# Ipsum#}
|
{% if request.state.user.has_permission("user", "edit") %}
|
||||||
{# </a>#}
|
<li class="nav-item">
|
||||||
{# </li>#}
|
<a href="/admin/users" class="nav-link{% if request.url.path.startswith("/admin/users")%} active{% endif %}">
|
||||||
|
Nutzerverwaltung
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="/admin/groups" class="nav-link{% if request.url.path.startswith("/admin/groups")%} active{% endif %}">
|
||||||
|
Gruppenverwaltung
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Shopping Cart at Bottom -->
|
<!-- Shopping Cart at Bottom -->
|
||||||
@@ -61,7 +70,7 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="col-md-9 ms-sm-auto col-lg-10 p-md-4 main-content">
|
<main class="col-md-9 ms-sm-auto col-lg-10 p-md-4 main-content">
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
94
src/allmende_payment_system/templates/groups.html.jinja
Normal file
94
src/allmende_payment_system/templates/groups.html.jinja
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="h4 mb-3">Gruppen verwalten</h2>
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createGroupModal">Neue Gruppe erstellen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for creating new group -->
|
||||||
|
<div class="modal fade" id="createGroupModal" tabindex="-1" aria-labelledby="createGroupModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="createGroupModalLabel">Neue Gruppe erstellen</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/admin/groups/create">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="groupName" class="form-label">Gruppenname</label>
|
||||||
|
<input type="text" class="form-control" id="groupName" name="name" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="groupDescription" class="form-label">Beschreibung</label>
|
||||||
|
<input type="text" class="form-control" id="groupDescription" name="description">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Erstellen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if groups|length == 0 %}
|
||||||
|
<div class="alert alert-info">Keine Gruppen vorhanden.</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Beschreibung</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for group in groups %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ group.id }}</td>
|
||||||
|
<td>{{ group.name }}</td>
|
||||||
|
<td>{{ group.description }}</td>
|
||||||
|
<td>
|
||||||
|
{% for permission in group.permissions %}
|
||||||
|
<span class="badge bg-secondary me-1">{{ permission.scope }}:{{ permission.action }} <a class="btn btn-close btn-close-white ms-1" aria-label="Remove" href="/admin/groups/{{ group.id }}/remove_permission/{{ permission.id }}"></a></span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary me-1" data-bs-toggle="modal" data-bs-target="#addPermissionModal{{ group.id }}">Berechtigung hinzufügen</button>
|
||||||
|
<a class="btn btn-danger tn-sm me-1" href="/admin/groups/{{ group.id }}/delete">Gruppe löschen</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Modal for adding permission -->
|
||||||
|
<div class="modal fade" id="addPermissionModal{{ group.id }}" tabindex="-1" aria-labelledby="addPermissionModalLabel{{ group.id }}" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="addPermissionModalLabel{{ group.id }}">Berechtigung zu Gruppe hinzufügen</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/admin/groups/{{ group.id }}/add_permission">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="permissionInput{{ group.id }}" class="form-label">Berechtigung eingeben</label>
|
||||||
|
<input type="text" class="form-control" id="permissionInput{{ group.id }}" name="permission" placeholder="scope:action" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
<!-- Latest Transactions Section -->
|
<!-- Latest Transactions Section -->
|
||||||
<div class="transactions-section">
|
<div class="transactions-section">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h2 class="h3 mb-0">Latest Transactions</h2>
|
<h2 class="h3 mb-0">Letzte Buchungen</h2>
|
||||||
<a href="#" class="btn btn-outline-primary btn-sm">View All</a>
|
<a href="#" class="btn btn-outline-primary btn-sm">Alle ansehen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -44,26 +44,31 @@
|
|||||||
{% if transactions and transactions|length > 0 %}
|
{% if transactions and transactions|length > 0 %}
|
||||||
{% for transaction in transactions[:10] %}
|
{% for transaction in transactions[:10] %}
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-start py-3">
|
<div class="list-group-item d-flex justify-content-between align-items-start py-3">
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="fw-semibold">{{ transaction.type|transaction_type_de }}</div>
|
<div class="fw-semibold d-inline">{{ transaction.type|transaction_type_de }}</div>
|
||||||
<small class="text-muted">
|
<small class="text-muted d-inline ms-2">
|
||||||
{{ transaction.timestamp }}
|
{{ transaction.timestamp | timestamp_de }}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
{% if transaction.type == "order" %}
|
||||||
<div class="text-end ms-3">
|
<div class="mt-2">
|
||||||
<span class="fs-5 fw-bold {% if transaction.total_amount < 0 %}text-danger{% else %}text-success{% endif %}">
|
<a href="/shop/order/{{ transaction.order_id }}" class="btn btn-sm btn-outline-primary">Einkauf ansehen</a>
|
||||||
{{ '%+.2f' | format(transaction.total_amount | default(0)) }} €
|
</div>
|
||||||
</span>
|
{% endif %}
|
||||||
{% if transaction.quantity %}
|
</div>
|
||||||
<div class="small text-muted">{{ transaction.quantity }} €</div>
|
<div class="text-end ms-3">
|
||||||
{% endif %}
|
<span class="fs-5 fw-bold {% if transaction.total_amount < 0 %}text-danger{% else %}text-success{% endif %}">
|
||||||
</div>
|
{{ '%+.2f' | format(transaction.total_amount | default(0)) }} €
|
||||||
</div>
|
</span>
|
||||||
|
{% if transaction.quantity %}
|
||||||
|
<div class="small text-muted">{{ transaction.quantity }} €</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="list-group-item text-center py-5 text-muted">
|
<div class="list-group-item text-center py-5 text-muted">
|
||||||
<p class="mb-0">No transactions yet</p>
|
<p class="mb-0">Noch keine Buchungen</p>
|
||||||
<small>Your transactions will appear here</small>
|
<small>Deine Buchungen werden hier erscheinen</small>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{% extends "base.html.jinja" %}
|
{% extends "base.html.jinja" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h2 class="h4 mb-3">Warenkorb</h2>
|
<h2 class="h4 mb-3">{% if is_cart %}Warenkorb{% else %}Einkauf #{{ order.id }}{% endif %}</h2>
|
||||||
<p class="text-muted">Überprüfe deine Artikel und fahre zur Kasse fort.</p>
|
{% if not is_cart %}<p class="text-muted">Einkauf abgeschickt: {{ order.transaction.timestamp | timestamp_de }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% set items = user.shopping_cart.items %}
|
{% set items = order.items %}
|
||||||
|
|
||||||
{% if items|length == 0 %}
|
{% if items|length == 0 %}
|
||||||
<div class="alert alert-info">Dein Warenkorb ist leer. <a href="/shop" class="alert-link">Weiter einkaufen</a>.</div>
|
<div class="alert alert-info">Dein Warenkorb ist leer. <a href="/shop" class="alert-link">Weiter einkaufen</a>.</div>
|
||||||
@@ -25,8 +25,7 @@
|
|||||||
{% set total = namespace(value=0) %}
|
{% set total = namespace(value=0) %}
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
{% set price = item.price if item.price is defined else (item.unit_price if item.unit_price is defined else 0) %}
|
{% set price = item.price if item.price is defined else (item.unit_price if item.unit_price is defined else 0) %}
|
||||||
{% set qty = item.quantity if item.quantity is defined else (item.qty if item.qty is defined else 1) %}
|
{% set subtotal = price * item.quantity %}
|
||||||
{% set subtotal = price * qty %}
|
|
||||||
{% set total.value = total.value + subtotal %}
|
{% set total.value = total.value + subtotal %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -34,15 +33,27 @@
|
|||||||
<div class="text-muted small">{{ item.description or '' }}</div>
|
<div class="text-muted small">{{ item.description or '' }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center" style="width:180px;">
|
<td class="text-center" style="width:180px;">
|
||||||
<form method="post" action="/cart/update/{{ item.id }}" class="d-flex align-items-center justify-content-center">
|
{% if is_cart %}
|
||||||
<input type="number" name="quantity" value="{{ qty }}" min="1" step="0.01" class="form-control form-control-sm me-2" style="width:80px;">
|
<form method="post" action="/shop/cart/update/{{ item.id }}" class="d-flex align-items-center justify-content-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="quantity"
|
||||||
|
value="{% if item.product.allow_fractional %}{{ item.quantity | format_number }}{% else %}{{ item.quantity | int }}{% endif %}"
|
||||||
|
{% if item.product.allow_fractional %}min="0.01" step="0.01"{% else %}min="1" step="1"{% endif %}
|
||||||
|
class="form-control form-control-sm me-2"
|
||||||
|
style="width:80px;"
|
||||||
|
required>
|
||||||
|
{% if item.product.unit_of_measure != 'piece' %}<span class="text-muted small ms-1 me-2">{{ item.product.unit_of_measure }}</span>{% endif %}
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="submit">Aktualisieren</button>
|
<button class="btn btn-sm btn-outline-secondary" type="submit">Aktualisieren</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{{ item.quantity | format_number }}{% if item.product.unit_of_measure != 'piece' %} {{ item.product.unit_of_measure }}{% endif %}
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">{{ item.product.price | format_number }} €</td>
|
<td class="text-end">{{ item.product.price | format_number }} €</td>
|
||||||
<td class="text-end">{{ item.total_amount | format_number }} €</td>
|
<td class="text-end">{{ item.total_amount | format_number }} €</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<a href="/cart/remove/{{ item.id }}" class="btn btn-sm btn-outline-danger">Entfernen</a>
|
{% if is_cart %}<a href="/shop/cart/remove/{{ item.id }}" class="btn btn-sm btn-outline-danger">Entfernen</a>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -50,19 +61,20 @@
|
|||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="text-end fw-semibold">Gesamtsumme</td>
|
<td colspan="3" class="text-end fw-semibold">Gesamtsumme</td>
|
||||||
<td class="text-end fw-bold">{{ user.shopping_cart.total_amount | format_number }} €</td>
|
<td class="text-end fw-bold">{{ order.total_amount | format_number }} €</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if is_cart %}
|
||||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||||
<div>
|
<div>
|
||||||
<a href="/cart/clear" class="btn btn-outline-danger me-2">Warenkorb leeren</a>
|
|
||||||
<a href="/shop/finalize_order" class="btn btn-primary">Jetzt Buchen</a>
|
<a href="/shop/finalize_order" class="btn btn-primary">Jetzt Buchen</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="h4 mb-3">Produkt {% if not edit_mode %}erstellen{% else %}bearbeiten{% endif %}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" {% if edit_mode %}value="{{ product.name }}"{% endif %} required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="price" class="form-label">Preis</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" id="price" name="price" {% if edit_mode %}value="{{ product.price }}"{% endif %} required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="unit_of_measure" class="form-label">Einheit</label>
|
||||||
|
<select class="form-select" id="unit_of_measure" name="unit_of_measure" required>
|
||||||
|
<option value="g" {% if edit_mode and product.unit_of_measure == 'g' %}selected{% endif %}>g</option>
|
||||||
|
<option value="kg" {% if edit_mode and product.unit_of_measure == 'kg' %}selected{% endif %}>kg</option>
|
||||||
|
<option value="l" {% if edit_mode and product.unit_of_measure == 'l' %}selected{% endif %}>l</option>
|
||||||
|
<option value="piece" {% if edit_mode and product.unit_of_measure == 'piece' %}selected{% endif %}>Stück</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="allow_fractional" name="allow_fractional" {% if edit_mode and product.allow_fractional %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="allow_fractional">Bruchteile erlauben</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="vat_rate" class="form-label">MwSt-Satz</label>
|
||||||
|
<select class="form-select" id="vat_rate" name="vat_rate" required>
|
||||||
|
<option value="7" {% if edit_mode and product.vat_rate == '7' %}selected{% endif %}>7 %</option>
|
||||||
|
<option value="19" {% if edit_mode and product.vat_rate == '19' %}selected{% endif %}>19 %</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="area" class="form-label">Bereich</label>
|
||||||
|
<select class="form-select" id="area" name="area_id" required>
|
||||||
|
{% for area in areas %}
|
||||||
|
<option value="{{ area.id }}" {% if edit_mode and area.id == product.area_id %}selected{% endif %}>{{ area.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <div class="mb-3">
|
||||||
|
<label for="image_path" class="form-label">Bild-Pfad</label>
|
||||||
|
<input class="form-control" type="file" id="image_path" accept="image/*">
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
43
src/allmende_payment_system/templates/products.html.jinja
Normal file
43
src/allmende_payment_system/templates/products.html.jinja
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="h4 mb-3">Produkte verwalten</h2>
|
||||||
|
<a class="btn btn-primary" href="/admin/products/new">Neues Produkt erstellen</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if products|length == 0 %}
|
||||||
|
<div class="alert alert-info">Keine Produkte vorhanden.</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Bereich</th>
|
||||||
|
<th>Preis</th>
|
||||||
|
<th>Einheit</th>
|
||||||
|
<th>Steuersatz</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for product in products %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ product.id }}</td>
|
||||||
|
<td>{{ product.name }}</td>
|
||||||
|
<td>{{ product.area.name }}</td>
|
||||||
|
<td>{{ product.price | format_number}} €</td>
|
||||||
|
<td>{{ product.unit_of_measure | units_of_measure_de}}</td>
|
||||||
|
<td>{{ product.vat_rate | int }} %</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a class="btn btn-sm btn-primary" href="/admin/products/edit/{{ product.id }}">Bearbeiten</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
71
src/allmende_payment_system/templates/users.html.jinja
Normal file
71
src/allmende_payment_system/templates/users.html.jinja
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "base.html.jinja" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="h4 mb-3">Benutzer verwalten</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if users|length == 0 %}
|
||||||
|
<div class="alert alert-info">Keine Benutzer vorhanden.</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Azeigename</th>
|
||||||
|
<th>Gruppen</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.id }}</td>
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>{{ user.display_name }}</td>
|
||||||
|
<td>
|
||||||
|
{% for group in user.user_groups %}
|
||||||
|
<span class="badge bg-secondary me-1">{{ group.name }} <a class="btn btn-close btn-close-white ms-1" aria-label="Remove" href="/admin/users/{{ user.id }}/remove_group/{{ group.id }}"></a></span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary me-1" data-bs-toggle="modal" data-bs-target="#addGroupModal{{ user.id }}">Gruppe hinzufügen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Modal for adding group -->
|
||||||
|
<div class="modal fade" id="addGroupModal{{ user.id }}" tabindex="-1" aria-labelledby="addGroupModalLabel{{ user.id }}" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="addGroupModalLabel{{ user.id }}">{{ user.display_name }} zu Gruppe hinzufügen</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/admin/users/{{ user.id }}/add_group">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="groupSelect{{ user.id }}" class="form-label">Gruppe auswählen</label>
|
||||||
|
<select class="form-select" id="groupSelect{{ user.id }}" name="group_id" required>
|
||||||
|
{% for group in all_groups %}
|
||||||
|
{% if group not in user.user_groups %}
|
||||||
|
<option value="{{ group.id }}">{{ group.name }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -15,7 +15,7 @@ UNITS_OF_MEASURE = {"piece": "Stück"}
|
|||||||
|
|
||||||
def format_number(value: float):
|
def format_number(value: float):
|
||||||
try:
|
try:
|
||||||
return f"{value:n}"
|
return f"{value:.2f}".replace(".", ",")
|
||||||
except TypeError:
|
except TypeError:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@@ -25,4 +25,5 @@ def get_jinja_renderer() -> Jinja2Templates:
|
|||||||
renderer.env.filters["transaction_type_de"] = lambda x: TRANSACTION_TYPE_DE[x]
|
renderer.env.filters["transaction_type_de"] = lambda x: TRANSACTION_TYPE_DE[x]
|
||||||
renderer.env.filters["units_of_measure_de"] = lambda x: UNITS_OF_MEASURE.get(x, x)
|
renderer.env.filters["units_of_measure_de"] = lambda x: UNITS_OF_MEASURE.get(x, x)
|
||||||
renderer.env.filters["format_number"] = format_number
|
renderer.env.filters["format_number"] = format_number
|
||||||
|
renderer.env.filters["timestamp_de"] = lambda x: x.strftime("%d.%m.%Y %H:%M")
|
||||||
return renderer
|
return renderer
|
||||||
|
|||||||
25
src/allmende_payment_system/types.py
Normal file
25
src/allmende_payment_system/types.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import typing
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
UnitsOfMeasure = typing.Literal[
|
||||||
|
"g",
|
||||||
|
"kg",
|
||||||
|
"l",
|
||||||
|
"piece",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Product(BaseModel):
|
||||||
|
name: str
|
||||||
|
price: Decimal
|
||||||
|
area_id: int
|
||||||
|
vat_rate: Decimal
|
||||||
|
allow_fractional: bool = False
|
||||||
|
unit_of_measure: UnitsOfMeasure
|
||||||
|
|
||||||
|
|
||||||
|
class UserGroup(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: typing.Optional[str] = None
|
||||||
@@ -41,10 +41,23 @@ def get_test_session():
|
|||||||
app.dependency_overrides[get_session] = get_test_session
|
app.dependency_overrides[get_session] = get_test_session
|
||||||
|
|
||||||
|
|
||||||
|
class APSTestClient(TestClient):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def post(self, *args, user: str = "test", **kwargs):
|
||||||
|
with mock.patch.dict(os.environ, {"APS_username": user}, clear=False):
|
||||||
|
return super().post(*args, **kwargs)
|
||||||
|
|
||||||
|
def get(self, *args, user: str = "test", **kwargs):
|
||||||
|
with mock.patch.dict(os.environ, {"APS_username": user}, clear=False):
|
||||||
|
return super().get(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def client():
|
def client():
|
||||||
os.environ["APS_username"] = "test"
|
os.environ["APS_username"] = "test"
|
||||||
return TestClient(app)
|
return APSTestClient(app)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
|
|||||||
130
test/test_admin.py
Normal file
130
test/test_admin.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from allmende_payment_system.app import app
|
||||||
|
from allmende_payment_system.database import ensure_user
|
||||||
|
from allmende_payment_system.models import Permission, User, UserGroup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_user(test_db):
|
||||||
|
user_info = {"username": "admin", "display_name": "The Administrator"}
|
||||||
|
user = ensure_user(user_info, test_db)
|
||||||
|
|
||||||
|
group = UserGroup(id=1, name="Admins")
|
||||||
|
group.permissions.append(Permission(scope="user", action="edit"))
|
||||||
|
user.user_groups.append(group)
|
||||||
|
test_db.add(group)
|
||||||
|
test_db.flush()
|
||||||
|
return "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_add_group(test_db, client, admin_user):
|
||||||
|
user_info = {"username": "test", "display_name": "Display Test"}
|
||||||
|
user = ensure_user(user_info, test_db)
|
||||||
|
|
||||||
|
group = UserGroup(name="Bosses")
|
||||||
|
test_db.add(group)
|
||||||
|
test_db.flush()
|
||||||
|
|
||||||
|
assert 0 == len(user.user_groups)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/admin/users/{user.id}/add_group",
|
||||||
|
data={"group_id": group.id},
|
||||||
|
user=admin_user,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
|
||||||
|
user = test_db.execute(select(User).where(User.username == "test")).scalar()
|
||||||
|
assert 1 == len(user.user_groups)
|
||||||
|
assert "Bosses" == user.user_groups[0].name
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_remove_group(test_db, client, admin_user):
|
||||||
|
user_info = {"username": "test", "display_name": "Display Test"}
|
||||||
|
user = ensure_user(user_info, test_db)
|
||||||
|
|
||||||
|
group = UserGroup(name="Bosses")
|
||||||
|
test_db.add(group)
|
||||||
|
user.user_groups.append(group)
|
||||||
|
test_db.flush()
|
||||||
|
|
||||||
|
assert 1 == len(user.user_groups)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/admin/users/{user.id}/remove_group/{group.id}",
|
||||||
|
user=admin_user,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
|
||||||
|
user = test_db.execute(select(User).where(User.username == "test")).scalar()
|
||||||
|
assert 0 == len(user.user_groups)
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_add_permission(test_db, client, admin_user):
|
||||||
|
group = test_db.query(UserGroup).scalar()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/admin/groups/{group.id}/add_permission",
|
||||||
|
data={"permission": "foo:bar"},
|
||||||
|
user=admin_user,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
|
||||||
|
group = test_db.execute(select(UserGroup).where(UserGroup.id == group.id)).scalar()
|
||||||
|
assert any(
|
||||||
|
perm.scope == "foo" and perm.action == "bar" for perm in group.permissions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_add_permission_illegal_format(test_db, client, admin_user):
|
||||||
|
group = test_db.query(UserGroup).scalar()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/admin/groups/{group.id}/add_permission",
|
||||||
|
data={"permission": "foobar"},
|
||||||
|
user=admin_user,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_remove_permission(test_db, client, admin_user):
|
||||||
|
group = test_db.query(UserGroup).scalar()
|
||||||
|
response = client.get(
|
||||||
|
f"/admin/groups/{group.id}/remove_permission/1",
|
||||||
|
user=admin_user,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
group = test_db.execute(select(UserGroup).where(UserGroup.id == group.id)).scalar()
|
||||||
|
assert 0 == len(group.permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_group(test_db, client, admin_user):
|
||||||
|
response = client.post(
|
||||||
|
"/admin/groups/create",
|
||||||
|
data={"name": "New Group", "description": "A newly created group"},
|
||||||
|
user=admin_user,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert test_db.query(UserGroup).filter_by(name="New Group").scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_group(test_db, client, admin_user):
|
||||||
|
group = UserGroup(name="To Be Deleted")
|
||||||
|
test_db.add(group)
|
||||||
|
test_db.flush()
|
||||||
|
response = client.get(f"/admin/groups/{group.id}/delete", user=admin_user)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (
|
||||||
|
test_db.execute(select(UserGroup).where(UserGroup.id == group.id)).scalar()
|
||||||
|
is None
|
||||||
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from allmende_payment_system.models import Account, User
|
from allmende_payment_system.models import Account, Permission, User, UserGroup
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
@@ -26,3 +26,21 @@ def test_user_shopping_cart_new(test_db, test_user):
|
|||||||
cart = test_user.shopping_cart
|
cart = test_user.shopping_cart
|
||||||
|
|
||||||
assert len(cart.items) == 0
|
assert len(cart.items) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_permissions(test_db):
|
||||||
|
user = User(username="normie", display_name="Normal User")
|
||||||
|
admin = User(username="admin", display_name="Admin User")
|
||||||
|
test_db.add(user)
|
||||||
|
|
||||||
|
group = UserGroup(name="Admins", description="A group for admins")
|
||||||
|
group.permissions.append(Permission(scope="area", action="edit"))
|
||||||
|
test_db.add(group)
|
||||||
|
|
||||||
|
admin.user_groups.append(group)
|
||||||
|
test_db.flush()
|
||||||
|
|
||||||
|
assert len(admin.user_groups) == 1
|
||||||
|
|
||||||
|
assert not user.has_permission("area", "edit")
|
||||||
|
assert admin.has_permission("area", "edit")
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import APSTestClient
|
||||||
from fake_data import fake
|
from fake_data import fake
|
||||||
from starlette.testclient import TestClient
|
from sqlalchemy import select
|
||||||
|
|
||||||
from allmende_payment_system.database import ensure_user
|
from allmende_payment_system.database import ensure_user
|
||||||
from allmende_payment_system.models import (
|
from allmende_payment_system.models import (
|
||||||
@@ -29,14 +32,33 @@ def create_user_with_account(test_db, username: str, balance: float | None = Non
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def test_add_item_to_cart(client: TestClient, test_db):
|
def add_finalized_order_to_user(test_db, user, product) -> Order:
|
||||||
|
order = Order(user=user)
|
||||||
|
total_amount = product.price
|
||||||
|
order.items.append(
|
||||||
|
OrderItem(product=product, quantity=1, total_amount=total_amount)
|
||||||
|
)
|
||||||
|
order.transaction = Transaction(total_amount=total_amount, type="order")
|
||||||
|
order.transaction.account = user.accounts[0]
|
||||||
|
test_db.add(order)
|
||||||
|
test_db.flush()
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def product(test_db):
|
||||||
area = Area(**fake.area())
|
area = Area(**fake.area())
|
||||||
test_db.add(area)
|
test_db.add(area)
|
||||||
product = Product(**fake.product())
|
product = Product(**fake.product())
|
||||||
product.area = area
|
product.area = area
|
||||||
test_db.add(product)
|
test_db.add(product)
|
||||||
test_db.flush()
|
test_db.flush()
|
||||||
form_data = {"product_id": product.id, "quantity": 2, "area_id": area.id}
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_item_to_cart(client: APSTestClient, test_db, product):
|
||||||
|
|
||||||
|
form_data = {"product_id": product.id, "quantity": 2, "area_id": product.area.id}
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/shop/cart/add",
|
"/shop/cart/add",
|
||||||
@@ -46,15 +68,84 @@ def test_add_item_to_cart(client: TestClient, test_db):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
cart = test_db.scalar(select(Order))
|
||||||
|
assert len(cart.items) == 1
|
||||||
|
|
||||||
def test_finalize_order(client: TestClient, test_db):
|
|
||||||
area = Area(**fake.area())
|
def test_edit_item_in_cart(client: APSTestClient, test_db, product):
|
||||||
test_db.add(area)
|
form_data = {"product_id": product.id, "quantity": 2, "area_id": product.area.id}
|
||||||
product = Product(**fake.product())
|
|
||||||
product.area = area
|
response = client.post(
|
||||||
test_db.add(product)
|
"/shop/cart/add",
|
||||||
|
data=form_data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
form_data = {"quantity": 3}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/shop/cart/update/{test_db.scalar(select(OrderItem)).id}",
|
||||||
|
data=form_data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
cart = test_db.scalar(select(Order))
|
||||||
|
assert len(cart.items) == 1
|
||||||
|
assert cart.items[0].quantity == 3
|
||||||
|
assert cart.items[0].total_amount == product.price * 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_item_from_cart(client: APSTestClient, test_db, product):
|
||||||
|
|
||||||
|
user = create_user_with_account(test_db, "test")
|
||||||
|
|
||||||
|
user.shopping_cart.items.append(
|
||||||
|
OrderItem(product=product, quantity=2, total_amount=product.price * 2)
|
||||||
|
)
|
||||||
test_db.flush()
|
test_db.flush()
|
||||||
|
|
||||||
|
cart = test_db.scalar(select(Order))
|
||||||
|
assert len(cart.items) == 1
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/shop/cart/remove/{user.shopping_cart.items[0].id}", follow_redirects=False
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
cart = test_db.scalar(select(Order))
|
||||||
|
assert len(cart.items) == 0
|
||||||
|
|
||||||
|
assert len(test_db.scalars(select(OrderItem)).all()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_item_from_cart_wrong_user(client: APSTestClient, test_db, product):
|
||||||
|
user = create_user_with_account(test_db, "test")
|
||||||
|
|
||||||
|
user.shopping_cart.items.append(
|
||||||
|
OrderItem(product=product, quantity=2, total_amount=product.price * 2)
|
||||||
|
)
|
||||||
|
test_db.flush()
|
||||||
|
|
||||||
|
cart = test_db.scalar(select(Order))
|
||||||
|
assert len(cart.items) == 1
|
||||||
|
|
||||||
|
id_ = user.shopping_cart.items[0].id
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/shop/cart/remove/{id_}", follow_redirects=False, user="other_user"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
cart = test_db.scalar(select(Order))
|
||||||
|
assert len(cart.items) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_order(client: APSTestClient, test_db, product):
|
||||||
|
|
||||||
user = create_user_with_account(test_db, "test", balance=100.0)
|
user = create_user_with_account(test_db, "test", balance=100.0)
|
||||||
|
|
||||||
user.shopping_cart.items.append(
|
user.shopping_cart.items.append(
|
||||||
@@ -69,3 +160,25 @@ def test_finalize_order(client: TestClient, test_db):
|
|||||||
assert len(user.orders) == 2 # shopping cart + finalized order
|
assert len(user.orders) == 2 # shopping cart + finalized order
|
||||||
|
|
||||||
assert user.accounts[0].balance == Decimal(100.0) - (product.price * 2)
|
assert user.accounts[0].balance == Decimal(100.0) - (product.price * 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_order(client: APSTestClient, test_db, product):
|
||||||
|
user = create_user_with_account(test_db, "test")
|
||||||
|
|
||||||
|
order = add_finalized_order_to_user(test_db, user, product)
|
||||||
|
|
||||||
|
response = client.get(f"/shop/order/{order.id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert f"Einkauf #{order.id}" in response.text
|
||||||
|
assert product.name in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_order_wrong_user(client: APSTestClient, test_db, product):
|
||||||
|
user = create_user_with_account(test_db, "test")
|
||||||
|
|
||||||
|
order = add_finalized_order_to_user(test_db, user, product)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/shop/order/{order.id}", follow_redirects=False, user="other_user"
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|||||||
28
uv.lock
generated
28
uv.lock
generated
@@ -2,11 +2,26 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = "==3.13.*"
|
requires-python = "==3.13.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.18.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mako" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allmende-payment-system"
|
name = "allmende-payment-system"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "alembic" },
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
]
|
]
|
||||||
@@ -22,6 +37,7 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "alembic", specifier = ">=1.18.1" },
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.119.0" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.119.0" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.44" },
|
{ name = "sqlalchemy", specifier = ">=2.0.44" },
|
||||||
]
|
]
|
||||||
@@ -314,6 +330,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mako"
|
||||||
|
version = "1.3.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user