Compare commits

...

43 Commits

Author SHA1 Message Date
c4ac44271a Add martin.k as admin 2025-12-12 13:10:54 +01:00
4b0057859e feat(events): Add billing info and organizer name 2025-12-12 12:55:40 +01:00
78066b77ae fix(grist): Don't send request to grist of nothing is written 2025-12-02 21:54:59 +01:00
491a7154e2 fix apply subscription 2025-11-04 11:25:24 +01:00
a7d6d45a78 Add db backup task
Close #2
2025-10-28 10:58:58 +01:00
994624af46 Add ignore_subscription functionality for events 2025-10-27 21:30:03 +01:00
b687a260c5 Improve apply_subscriptions
Events now have a subscriptions_applied flag. Using apply_subscriptions without passing an event now processes all events in the next week, which have not yet been processed
2025-10-27 15:32:00 +01:00
a2722d5f9f Add doc to grist sync 2025-10-27 11:35:52 +01:00
9efccccc21 Add sync to grist functionality 2025-10-26 21:09:22 +01:00
a11f1a6c38 Fix: Team registrations are now checked for duplicates only for the current event 2025-10-21 10:40:19 +02:00
ef82152b95 Add info message about deletions 2025-10-20 09:23:56 +02:00
0e663b95af Remove obsolete melly-to-grist folder 2025-10-17 11:37:57 +02:00
a1bb50087b Feat: Add script to apply subscriptions 2025-10-16 12:11:27 +02:00
bfe40a4837 Auth: Require logged in user to delete registrations, team_registrations and subscriptions 2025-10-16 10:54:20 +02:00
1df2ecbebf Add robots.txt disallowing all scrapers 2025-10-16 10:35:48 +02:00
4aa6ac97fd Feat: Add ability to edit events for logged in users 2025-10-15 12:02:18 +02:00
4248da98fc fix: Don't display None comments 2025-10-15 11:20:04 +02:00
fb5336ef9e PDF: adjust padding 2025-10-15 11:16:48 +02:00
67f24c2a8a fix: Make PDF generator work with None comments 2025-10-15 11:14:50 +02:00
8fe744afe1 Add support for PDF view 2025-10-15 11:12:43 +02:00
70fa1168ea Fix team registration table name 2025-10-14 22:11:21 +02:00
726c095af5 feat: Add ability to delete events for admins 2025-10-14 21:42:21 +02:00
1f0a27f3af Fix subscription bug and add auth for event creation 2025-10-14 21:13:31 +02:00
7980a112a3 Simple user auth using ssowat. Meal creation only for logged in users 2025-10-14 12:32:54 +02:00
d9330ec8ac Add readme 2025-10-12 21:49:56 +02:00
c8500a4337 Move meal manager into it's own package 2025-10-12 21:42:28 +02:00
03d823c713 Rename new-registration-app to meal-manager 2025-10-12 21:25:23 +02:00
773f8ad2b6 remove old signup page 2025-10-12 21:19:37 +02:00
3291fbf6a0 Add comments to registrations 2025-10-12 21:12:45 +02:00
84f128806c Set up migration environment with alembic 2025-10-12 20:51:10 +02:00
494170e2ab Drop Sqlmodel and use plain Sqlalchemy 2025-10-11 22:14:04 +02:00
a190471b44 fix: Display issue on subscription page 2025-10-11 15:02:32 +02:00
02ecfa2209 Add info text to subscription 2025-10-11 14:37:34 +02:00
112459964a Minor tweaks
* display date on event page
  * Filter existing subscriptions for new subscriptions dropdown
  * Add Typeahead for Team registration
2025-10-09 12:15:46 +02:00
457418c271 Fix python version to 3.13 2025-10-08 14:07:03 +02:00
1926382021 Add uvicorn and remove Preise link 2025-10-08 14:02:19 +02:00
65b2abdad6 Add subscription functionality 2025-10-08 13:53:03 +02:00
81daf2aa0c Add rudimentary green theme 2025-10-08 10:19:37 +02:00
3812dd5d47 Add past events page 2025-10-08 10:18:56 +02:00
e1130fa493 Prevent registrations after deadline 2025-10-07 21:38:12 +02:00
847cac4bba UI improvements
* Replace registration table with cards on smaller devices
  * Use number inputs for registrations
2025-10-07 21:26:17 +02:00
102e03b546 Make page responsive and improve event listing 2025-10-07 21:08:41 +02:00
1d29e954b8 Work that has already been maxed will not show up in the dropdown 2025-10-07 20:42:41 +02:00
94 changed files with 2306 additions and 1384 deletions

9
.gitignore vendored
View File

@@ -1,5 +1,8 @@
config.php
test.php
melly-to-grist/.env
*/__pycache__
new-registration-app/database.db
.env
**/__pycache__
database.db*
.idea
db_fixtures
backups

4
README.md Normal file
View File

@@ -0,0 +1,4 @@
# Allmende Essen
Management App für das gemeinsame Kochen und Essen in der Allmende

43
alembic.ini Normal file
View File

@@ -0,0 +1,43 @@
# A generic, single database configuration.
[alembic]
# 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:///database.db
# Logging configuration
[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

9
justfile Normal file
View File

@@ -0,0 +1,9 @@
lint:
uv run isort src
uv run black src
reset_db:
rm -f database.db
touch database.db
alembic upgrade heads
bash -c 'shopt -s nullglob; for file in db_fixtures/*.sql; do sqlite3 database.db < "$file"; done'

View File

@@ -1 +0,0 @@
3.13

View File

@@ -1,75 +0,0 @@
from collections import defaultdict
import sys
import openpyxl
from pygrister.api import GristApi
from dotenv import load_dotenv
import os
import datetime
load_dotenv()
config = {
"GRIST_API_SERVER": "allmende-gufi.de",
"GRIST_TEAM_SITE": "grist",
"GRIST_API_KEY": os.getenv("GRIST_API_KEY"),
"GRIST_DOC_ID": "xmEcaq5pvxUB3mBfEY8BLe"
}
# grist = GristApi(config=config)
#
# result = grist.add_records("Transactions", [{"Datum": "01.01.1900", "Typ": "Essen", "Partei_Konto": "Heinz", "Betrag": 10}])
# print(result)
def read_excel_file(file_path):
# Load the workbook
workbook = openpyxl.load_workbook(file_path)
sheet = workbook["Liste"]
date = sheet["A1"].value.split()[-1]
result = {
"date": date,
"adult": defaultdict(lambda: 0),
"children": defaultdict(lambda: 0),
}
# Loop through each row in the sheet
for row in sheet.iter_rows(values_only=True):
if row[0] == "Essensanmeldung":
if row[2] == "Erwachsene Portionen":
result["adult"][row[3]] += row[5]
if row[2] == "Kinderportionen (ab 7 Jahren)":
result["children"][row[3]] += row[5]
# Close the workbook
workbook.close()
return result
if __name__ == "__main__":
filename = sys.argv[1]
entries = read_excel_file(filename)
grist = GristApi(config=config)
records = []
today = datetime.date.today().isoformat()
for name, number in entries["adult"].items():
records.append({
"Datum": entries["date"],
"Typ": "Essen",
"Partei_Konto": name,
"Betrag": -3.5 * number,
"Date_Added": today
})
for name, number in entries["children"].items():
records.append({
"Datum": entries["date"],
"Typ": "Essen Kind",
"Partei_Konto": name,
"Betrag": -2 * number,
"Date_Added": today
})
grist.add_records("Transactions", records)

View File

@@ -1,16 +0,0 @@
[project]
name = "melly-to-grist"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"openpyxl>=3.1.5",
"pygrister>=0.7.0",
"python-dotenv>=1.1.0",
]
[dependency-groups]
dev = [
"black>=25.1.0",
]

381
melly-to-grist/uv.lock generated
View File

@@ -1,381 +0,0 @@
version = 1
revision = 1
requires-python = ">=3.10"
[[package]]
name = "black"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419 },
{ url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080 },
{ url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886 },
{ url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404 },
{ url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372 },
{ url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865 },
{ url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699 },
{ url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028 },
{ url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988 },
{ url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985 },
{ url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816 },
{ url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860 },
{ url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673 },
{ url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190 },
{ url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926 },
{ url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613 },
{ url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 },
]
[[package]]
name = "certifi"
version = "2025.4.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818 },
{ url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649 },
{ url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045 },
{ url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356 },
{ url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471 },
{ url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317 },
{ url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368 },
{ url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491 },
{ url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695 },
{ url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849 },
{ url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091 },
{ url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445 },
{ url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782 },
{ url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794 },
{ url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846 },
{ url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350 },
{ url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657 },
{ url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260 },
{ url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164 },
{ url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571 },
{ url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952 },
{ url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959 },
{ url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030 },
{ url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015 },
{ url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106 },
{ url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402 },
{ url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 },
{ url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 },
{ url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 },
{ url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 },
{ url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 },
{ url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 },
{ url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 },
{ url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 },
{ url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 },
{ url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 },
{ url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 },
{ url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 },
{ url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 },
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 },
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 },
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 },
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 },
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 },
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 },
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 },
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 },
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 },
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 },
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 },
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 },
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 },
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 },
]
[[package]]
name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "melly-to-grist"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "openpyxl" },
{ name = "pygrister" },
{ name = "python-dotenv" },
]
[package.dev-dependencies]
dev = [
{ name = "black" },
]
[package.metadata]
requires-dist = [
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pygrister", specifier = ">=0.7.0" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
]
[package.metadata.requires-dev]
dev = [{ name = "black", specifier = ">=25.1.0" }]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 },
]
[[package]]
name = "platformdirs"
version = "4.3.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 },
]
[[package]]
name = "pygments"
version = "2.19.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
]
[[package]]
name = "pygrister"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/5f/b99618eff4ae44ccdf40f0d2d6a298dd6c80bed95f76c13907021f1b2295/pygrister-0.7.0.tar.gz", hash = "sha256:fac4b982857febb7ed995e72ba8cbf7aa3168e8847e443d3f85e7dff9247add4", size = 25584 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/67/b20cbae81094e5f2553d2b1e2d93d644562b0010d999bf0408d47c8e42ee/pygrister-0.7.0-py3-none-any.whl", hash = "sha256:324bfb283a5103ea7a813465bf0f18636088cc3c2da2dc85534e6fe8ee0f5867", size = 18452 },
]
[[package]]
name = "python-dotenv"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 },
]
[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "rich"
version = "14.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
]
[[package]]
name = "tomli"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 },
{ url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 },
{ url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 },
{ url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 },
{ url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 },
{ url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 },
{ url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 },
{ url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 },
{ url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 },
{ url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 },
{ url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 },
{ url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 },
{ url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 },
{ url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 },
{ url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 },
{ url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 },
{ url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 },
{ url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 },
{ url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 },
{ url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 },
{ url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 },
{ url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 },
{ url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 },
{ url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 },
{ url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 },
{ url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 },
{ url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 },
{ url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 },
{ url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 },
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 },
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
]
[[package]]
name = "typer"
version = "0.15.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/1a/5f36851f439884bcfe8539f6a20ff7516e7b60f319bbaf69a90dc35cc2eb/typer-0.15.3.tar.gz", hash = "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c", size = 101641 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/20/9d953de6f4367163d23ec823200eb3ecb0050a2609691e512c8b95827a9b/typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd", size = 45253 },
]
[[package]]
name = "typing-extensions"
version = "4.13.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 },
]
[[package]]
name = "urllib3"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 },
]

View File

@@ -1,189 +0,0 @@
import locale
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Annotated, Union
import starlette.status as status
from fastapi import Depends, FastAPI, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, SQLModel, create_engine, select
from models import Event, Household, Registration, TeamRegistration
sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)
locale.setlocale(locale.LC_ALL, "de_DE.UTF-8")
def get_session():
with Session(engine) as session:
yield session
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
@asynccontextmanager
async def on_startup(app_: FastAPI):
create_db_and_tables()
yield
app = FastAPI(lifespan=on_startup)
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
SessionDep = Annotated[Session, Depends(get_session)]
@app.get("/")
async def read_root(request: Request, session: SessionDep):
statement = select(Event).order_by(Event.event_time)
events = session.exec(statement).all()
return templates.TemplateResponse(
request=request,
name="index.html",
context={"events": events, "current_page": "home", "now": datetime.now()},
)
@app.get("/event/add")
async def add_event_form(request: Request, session: SessionDep):
return templates.TemplateResponse(request=request, name="add_event.html")
@app.post("/event/add")
async def add_event(request: Request, session: SessionDep):
form_data = await request.form()
event_time = datetime.fromisoformat(form_data["eventTime"])
registration_deadline = form_data.get("registrationDeadline")
if not registration_deadline:
# Find the last Sunday before event_time
deadline = event_time
while deadline.weekday() != 6: # 6 represents Sunday
deadline = deadline.replace(day=deadline.day - 1)
registration_deadline = deadline.replace(
hour=19, minute=30, second=0, microsecond=0
)
else:
registration_deadline = datetime.fromisoformat(registration_deadline)
event = Event(
title=form_data["eventName"],
event_time=event_time,
registration_deadline=registration_deadline,
description=form_data.get("eventDescription"),
recipe_link=form_data.get("recipeLink"),
)
session.add(event)
session.commit()
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
@app.get("/event/{event_id}")
async def read_event(request: Request, event_id: int, session: SessionDep):
statement = select(Event).where(Event.id == event_id)
event = session.exec(statement).one()
statement = select(Household)
households = session.exec(statement).all()
# filter out households with existing registrations
households = [
h
for h in households
if h.id not in [reg.household_id for reg in event.registrations]
]
return templates.TemplateResponse(
request=request,
name="event.html",
context={"event": event, "households": households},
)
@app.post("/event/{event_id}/register")
async def add_registration(request: Request, event_id: int, session: SessionDep):
form_data = await request.form()
# TODO: Make this return a nicer error message
try:
num_adult_meals = int(form_data["numAdults"]) if form_data["numAdults"] else 0
num_children_meals = int(form_data["numKids"]) if form_data["numKids"] else 0
num_small_children_meals = (
int(form_data["numSmallKids"]) if form_data["numSmallKids"] else 0
)
except ValueError:
raise ValueError("All number fields must be integers")
registration = Registration(
household_id=form_data["household"],
event_id=event_id,
num_adult_meals=num_adult_meals,
num_children_meals=num_children_meals,
num_small_children_meals=num_small_children_meals,
)
session.add(registration)
session.commit()
return RedirectResponse(url=f"/event/{event_id}", status_code=status.HTTP_302_FOUND)
@app.get("/event/{event_id}/registration/{household_id}/delete")
async def delete_registration(
request: Request, event_id: int, household_id: int, session: SessionDep
):
"""
Deletes a registration record for a specific household at a given event. This endpoint
handles the removal of the registration, commits the change to the database, and
redirects the user to the event page.
"""
statement = select(Registration).where(
Registration.household_id == household_id, Registration.event_id == event_id
)
session.delete(session.exec(statement).one())
session.commit()
return RedirectResponse(url=f"/event/{event_id}", status_code=status.HTTP_302_FOUND)
@app.post("/event/{event_id}/register_team")
async def add_team_registration(request: Request, event_id: int, session: SessionDep):
form_data = await request.form()
person = form_data["personName"].strip()
work_type = form_data["workType"]
statement = select(TeamRegistration).where(
TeamRegistration.person_name == person, TeamRegistration.work_type == work_type
)
if session.exec(statement).one_or_none() is None:
registration = TeamRegistration(
person_name=person,
event_id=event_id,
work_type=form_data["workType"],
)
TeamRegistration.model_validate(registration)
session.add(registration)
session.commit()
return RedirectResponse(url=f"/event/{event_id}", status_code=status.HTTP_302_FOUND)
@app.get("/event/{event_id}/register_team/{entry_id}/delete")
async def delete_team_registration(
request: Request,
event_id: int,
entry_id: int,
session: SessionDep,
):
statement = select(TeamRegistration).where(TeamRegistration.id == entry_id)
session.delete(session.exec(statement).one())
session.commit()
return RedirectResponse(url=f"/event/{event_id}", status_code=status.HTTP_302_FOUND)

View File

@@ -1,54 +0,0 @@
from datetime import datetime
from typing import Literal
from sqlmodel import Field, Relationship, SQLModel, String
class Event(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
title: str = Field(nullable=False)
event_time: datetime = Field(nullable=False)
registration_deadline: datetime = Field(nullable=False)
description: str
recipe_link: str
# Min and max number of people needed for cooking, doing the dishes and preparing the tables
team_cooking_min: int = 3
team_cooking_max: int = 5
team_dishes_min: int = 3
team_dishes_max: int = 5
team_prep_min: int = 1
team_prep_max: int = 1
registrations: list["Registration"] = Relationship()
team: list["TeamRegistration"] = Relationship()
class TeamRegistration(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
event_id: int | None = Field(default=None, foreign_key="event.id")
person_name: str = Field(nullable=False)
work_type: Literal["cooking", "dishes", "tables"] = Field(
nullable=False, sa_type=String
)
comment: str | None
class Household(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(nullable=False)
class Registration(SQLModel, table=True):
event_id: int | None = Field(default=None, foreign_key="event.id", primary_key=True)
household_id: int | None = Field(
default=None, foreign_key="household.id", primary_key=True
)
num_adult_meals: int
num_children_meals: int
num_small_children_meals: int
comment: str | None
household: Household = Relationship()

View File

@@ -1,16 +0,0 @@
[project]
name = "new-registration-app"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard]>=0.116.0",
"sqlmodel>=0.0.24",
]
[dependency-groups]
dev = [
"black>=25.1.0",
"isort>=6.0.1",
]

View File

@@ -1,39 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center mt-4">
<div class="col-md-8">
<h2>Neues Event erstellen</h2>
<form method="post" action="/event/add">
<div class="mb-3">
<label for="eventName" class="form-label">Event Name</label>
<input type="text" class="form-control" id="eventName" name="eventName" required>
</div>
<div class="mb-3">
<label for="eventTime" class="form-label">Datum und Uhrzeit</label>
<input type="datetime-local" class="form-control" id="eventTime" name="eventTime" required>
</div>
<div class="mb-3">
<label for="registrationDeadline" class="form-label">Anmeldungs-Deadline</label>
<input type="datetime-local" class="form-control" id="registrationDeadline" name="registrationDeadline">
<small class="form-text text-muted">Leer lassen für Sonntag Abend vor dem Event</small>
</div>
<div class="mb-3">
<label for="recipeLink" class="form-label">Rezept-Link</label>
<input type="text" class="form-control" id="recipeLink" name="recipeLink">
</div>
<div class="mb-3">
<label for="eventDescription" class="form-label">Beschreibung</label>
<textarea class="form-control" id="eventDescription" name="eventDescription" rows="3"></textarea>
</div>
<button type="submit" class="btn btn-primary">Event erstellen</button>
<a href="/new-registration-app/static" class="btn btn-secondary">Abbrechen</a>
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,207 +0,0 @@
{% macro teamEntries(event, work_type) -%}
{% for entry in event.team | selectattr("work_type", "equalto", work_type)%}
<div class="d-inline-flex align-items-center bg-light border rounded-pill px-3 py-1 m-1">
<span class="me-2">{{ entry.person_name }}</span>
<button type="button" class="btn btn-sm p-0 border-0 bg-transparent text-muted">
<a href="/event/{{event.id}}/register_team/{{entry.id}}/delete">
<i class="bi bi-trash"></i>
</a>
</button>
</div>
{% endfor %}
{%- endmacro %}
{% extends "base.html" %}
{% block content %}
<p class="h1">{{ event.title }}</p>
<p>{{ event.description }}</p>
<hr class="hr"/>
<p class="h3">Anmeldungen</p>
<div class="container">
<div class="row">
<div class="col-md-4 d-flex justify-content-center align-items-center flex-column">
<!-- Button trigger modal -->
<button type="button" class="btn btn-primary mb-2" data-bs-toggle="modal" data-bs-target="#registration">
Anmeldung hinzufügen
</button>
<button type="button" class="btn btn-primary mb-2" data-bs-toggle="modal" data-bs-target="#teamRegistration">
Dienst übernehmen
</button>
{% if event.recipe_link %}
<div class="mb-3">
<a href="{{ event.recipe_link }}" class="btn btn-outline-primary" target="_blank">
<i class="bi bi-book"></i> Original Rezept ansehen
</a>
</div>
{% endif %}
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Dienste</h5>
<div class="row">
<div class="col-md-4">
Kochen: {% if event.team | selectattr("work_type", "equalto", "cooking") | list | count >= 3 %}✅{% endif %}
</div>
<div class="col-md-8">
{{ teamEntries(event, "cooking") }}
</div>
</div>
<hr/>
<div class="row">
<div class="col-md-4">
Spülen: {% if event.team | selectattr("work_type", "equalto", "dishes") | list | count >= 3 %}✅{% endif %}
</div>
<div class="col-md-8">
{{ teamEntries(event, "dishes") }}
</div>
</div>
<hr/>
<div class="row">
<div class="col-md-4">
Tische vorbereiten: {% if event.team | selectattr("work_type", "equalto", "tables") | list | count >= 1 %}✅{% endif %}
</div>
<div class="col-md-8">
{{ teamEntries(event, "tables") }}
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Zusammenfassung</h5>
<table class="table table-sm">
<tr>
<td>Erwachsene:</td>
<td>{{ event.registrations | sum(attribute="num_adult_meals") }}</td>
</tr>
<tr>
<td>Kinder:</td>
<td>{{ event.registrations | sum(attribute="num_children_meals") }}</td>
</tr>
<tr>
<td>Kleinkinder:</td>
<td>{{ event.registrations | sum(attribute="num_small_children_meals") }}</td>
</tr>
<tr>
<th>Gesamt:</th>
<th>{{ event.registrations | sum(attribute="num_adult_meals") + event.registrations | sum(attribute="num_children_meals") +event.registrations | sum(attribute="num_small_children_meals")}} </th>
</tr>
</table>
</div>
</div>
</div>
</div>
<table class="table">
<thead>
<tr>
<th scope="col">Haushalt</th>
<th scope="col">Erwachsene</th>
<th scope="col">Kinder</th>
<th scope="col">Kleinkinder</th>
<th scope="col">Löschen</th>
</tr>
</thead>
<tbody>
{% for reg in event.registrations %}
<tr>
<td>{{ reg.household.name }}</td>
<td>{{ reg.num_adult_meals }}</td>
<td>{{ reg.num_children_meals }}</td>
<td>{{ reg.num_small_children_meals }}</td>
<td><a href="/event/{{event.id}}/registration/{{reg.household_id}}/delete"><i class="bi bi-trash"></i></a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Modal -->
<div class="modal fade" id="registration" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1"
aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">Anmeldung hinzufügen</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="/event/{{event.id}}/register" method="POST">
<div class="modal-body">
<p>Wenn dein Haushalt nicht auswählbar ist, existiert schon eine Anmeldung. Wenn du die Anmeldung ändern willst, lösche die bestehende Anmeldung und lege eine neue an.</p>
<div class="mb-3">
<select name="household" class="form-select" aria-label="Multiple select example">
<option selected>Wer?</option>
{% for household in households %}
<option value="{{household.id}}">{{household.name}}</option>
{% endfor %}
</select>
</div>
<div class="row">
<div class="col">
<label for="InputAdults" class="form-label">Anzahl Erwachsene</label>
<input name="numAdults" id="InputAdults" type="text" class="form-control"
aria-label="Anzahl Erwachsene">
</div>
<div class="col">
<label for="InputKids" class="form-label">Anzahl Kinder >7 </label>
<input name="numKids" id="InputKids" type="text" class="form-control"
aria-label="Anzahl Kinder >7">
</div>
<div class="col">
<label for="InputSmallKids" class="form-label">Anzahl Kinder <7 </label>
<input name="numSmallKids" id="InputSmallKids" type="text" class="form-control"
aria-label="Anzahl Kinder <7">
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Anmelden</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
</div>
</form>
</div>
</div>
</div>
<!-- Team Modal -->
<div class="modal fade" id="teamRegistration" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1"
aria-labelledby="teamRegistrationLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="teamRegistrationLabel">Dienst übernehmen</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="/event/{{event.id}}/register_team" method="POST">
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<label for="personName" class="form-label">Name</label>
<input name="personName" id="personName" type="text" class="form-control"
aria-label="Name">
</div>
<div class="col-md-6">
<label for="workType" class="form-label">Dienst-Art</label>
<select id="workType" name="workType" class="form-select" aria-label="Multiple select example">
<option value="cooking">Kochen</option>
<option value="dishes">Spülen</option>
<option value="tables">Tische vorbereiten</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Anmelden</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,29 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="row mt-4 mb-3">
<div class="col d-flex justify-content-between align-items-center">
<h2>Kommende Events</h2>
<a href="/event/add" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Neues Event erstellen
</a>
</div>
</div>
{% for event in events %}
<div class="row">
<div class="col">
<div class="card p-3 m-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title">{{ event.title }}</h5>
<h5 class="text-muted"><i class="bi bi-calendar"></i> {{ event.event_time.strftime('%A, %d.%m.%Y')
}}
</h5>
</div>
<p class="card-text">{{ event.description }}</p>
<a href="event/{{ event.id }}" class="btn {% if event.registration_deadline > now %}btn-primary{% else %}btn-secondary{% endif %}">{% if event.registration_deadline > now %}Zur Anmeldung{% else %}Details ansehen{% endif %}</a>
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}

51
pyproject.toml Normal file
View File

@@ -0,0 +1,51 @@
[project]
name = "meal-manager"
version = "0.2.0"
description = "Add your description here"
readme = "README.md"
requires-python = "~=3.13.0"
dependencies = [
"alembic>=1.17.0",
"fastapi[standard]>=0.116.0",
"pygrister>=0.8.0",
"python-dotenv>=1.1.1",
"reportlab>=4.4.4",
"sqlalchemy>=2.0.44",
"uvicorn[standard]>=0.35.0",
]
[build-system]
requires = ["uv_build>=0.9.0,<0.10.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"black>=25.1.0",
"isort>=6.0.1",
"pytest>=8.4.2",
]
[project.scripts]
apply-subscriptions = "meal_manager.scripts:apply_subscriptions_cli"
meal-manager-nightly = "meal_manager.scripts:run_nightly_tasks"
[tool.isort]
profile = "black"
[tool.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/src/meal_manager/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"
# additional paths to be prepended to sys.path. defaults to the current working directory.
prepend_sys_path = [
"."
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,98 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Termin hinzufügen</title>
<link rel="stylesheet" href="style.css"> <!-- Link to the CSS file -->
</head>
<body>
<header>
<div class="container">
<div id="branding">
<img src="Logo.png" alt="Logo">
<h1>Termin hinzufügen</h1>
</div>
</div>
</header>
<div class="container">
<h2>Neuen Termin hinzufügen</h2>
<form action="add.php" method="POST">
<label for="title">Title:</label>
<input type="text" id="title" name="title" required>
<label for="melly">Melly Link:</label>
<input type="text" id="melly" name="melly" required>
<label for="date">Event Date:</label>
<input type="date" id="date" name="date" required>
<label for="signup_deadline">Anmeldung bis:</label>
<input type="datetime-local" id="signup_deadline" name="signup_deadline" required>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
<button type="submit" class="btn">Hinzfügen</button>
</form>
<?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$config = require 'config.php';
// Database connection parameters
$host = $config['db']['host'];
$dbname = $config['db']['dbname'];
$username = $config['db']['username'];
$password = $config['db']['password'];
if ($_SERVER["REQUEST_METHOD"] == "POST") {
// Retrieve form data
$title = $_POST['title'];
$melly = $_POST['melly'];
$date = $_POST['date'];
$deadline = $_POST['signup_deadline'];
$webform_password = $_POST['password'];
if($webform_password != $config['webform_password']) {
echo 'Invalid Password!';
} else {
try {
// Create connection
$dsn = "pgsql:host=$host;dbname=$dbname";
$pdo = new PDO($dsn, $username, $password);
// Set error mode to exception for easier debugging
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// SQL query to insert a new dinner option
$sql = "INSERT INTO meals (title, link, event_date, registration_closes) VALUES (:title, :melly, :date, :deadline)";
$stmt = $pdo->prepare($sql);
// Bind parameters
$stmt->bindParam(':title', $title);
$stmt->bindParam(':melly', $melly);
$stmt->bindParam(':date', $date);
$stmt->bindParam(':deadline', $deadline);
// Execute the statement
$stmt->execute();
echo '<p class="success">Dinner option added successfully!</p>';
} catch (PDOException $e) {
// Handle connection or query error
echo "Error: " . $e->getMessage();
}
}
}
?>
</div>
</body>
</html>

View File

@@ -1,13 +0,0 @@
CREATE TABLE meals (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
link TEXT NOT NULL,
event_date DATE NOT NULL,
registration_closes TIMESTAMP NOT NULL
);
INSERT INTO meals (title, link, event_date, registration_closes)
VALUES ('Kidneybohnen Burger mit veganem Coleslaw','https://melly.de/plan/2ZSNYWR37VB8','2025-03-05', '2025-03-02T17:30:30'),
('Gemüselasagne mit Salat','hhttps://melly.de/plan/M4XU9XMVM2HP','2025-02-28', '2025-02-23T17:30:30'),
RETURNING *;

View File

@@ -1,108 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Allmende-Essen</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="container">
<div id="branding">
<img src="Logo.png" alt="Logo"/>
<h1>Gemeinsames Essen in der Allmende</h1>
</div>
</div>
</header>
<?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$config = require 'config.php';
// Database connection parameters
$host = $config['db']['host'];
$dbname = $config['db']['dbname'];
$username = $config['db']['username'];
$password = $config['db']['password'];
// Create connection
$dsn = "pgsql:host=$host;dbname=$dbname";
$pdo = new PDO($dsn, $username, $password);
// Query to fetch future dinner options
$sql = "SELECT id, title, link, event_date, registration_closes FROM meals WHERE event_date >= now()::date order by registration_closes < now(), event_date";
$stmt = $pdo->prepare($sql);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
$days = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
$today = strtotime(date('Y-m-d'));
$now = strtotime(date("Y-m-d H:i:s"));
?>
<div class="container">
<?php // Display dinner options
for($i = 0; $i < sizeof($result); $i++) {
$row = $result[$i];
$event_date = strtotime($row["event_date"]);
$weekday = date("w", $event_date);
$date = new DateTimeImmutable($row["event_date"]);
$end_of_registration = strtotime($row["registration_closes"]);
echo '<div class="dinner-option">';
echo '<h2>' . $days[$weekday] . " " . $date->format('d.m.Y') .'</h2>';
echo '<p>' . htmlspecialchars($row["title"]) . '</p>';
if ($end_of_registration > $now) {
echo '<a href="' . $row["link"] . '" class="btn">Zur Anmeldung</a>';
} else {
echo '<a href="' . $row["link"] . '" class="btn btn-grey">Anmeldungen ansehen</a>';
}
echo '</div>';
}
// close container
echo '</div>';
// Query to fetch past dinner options
$sql = "SELECT id, title, link, event_date FROM meals WHERE event_date < now()::date order by event_date";
$stmt = $pdo->prepare($sql);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
if(sizeof($result) > 0) {
?>
<div class="container">
<h2>Vergangene Essen</h2>
<?php
for($i = 0; $i < sizeof($result); $i++) {
$row = $result[$i];
$event_date = strtotime($row["event_date"]);
$weekday = date("w", $event_date);
$date = new DateTimeImmutable($row["event_date"]);
echo '<div class="dinner-option">';
echo '<h2>' . $days[$weekday] . " " . $date->format('d.m.Y') .'</h2>';
echo '<p>' . htmlspecialchars($row["title"]) . '</p>';
echo '<a href="' . $row["link"] . '" class="btn btn-grey">Anmeldungen ansehen</a>';
echo '</div>';
}
// close container
echo '</div>';
}
?>
</body>
</html>

View File

@@ -1,75 +0,0 @@
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f4f4f4;
color: #333;
}
.container {
width: 80%;
margin: auto;
overflow: hidden;
}
header {
background: #333;
color: #fff;
padding-top: 30px;
min-height: 70px;
border-bottom: #77aaff 3px solid;
}
header a {
color: #fff;
text-decoration: none;
text-transform: uppercase;
font-size: 16px;
}
header ul {
padding: 0;
list-style: none;
}
header li {
float: left;
display: inline;
padding: 0 20px 0 20px;
}
header #branding {
float: left;
}
header #branding img {
height: 50px;
width: 40px;
margin-right: 10px;
}
header #branding h1 {
margin: 0;
}
header nav {
float: right;
margin-top: 10px;
}
.dinner-option {
background: #fff;
padding: 20px;
margin: 20px 0;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.dinner-option h2 {
margin-top: 0;
}
.btn {
background: #77aaff;
color: #fff;
padding: 10px 20px;
text-decoration: none;
border-radius: 5px;
display: inline-block;
margin-top: 10px;
}
.btn-grey {
background: #ccc;
color: #333;
}
.btn:hover {
background: #5a99d0;
}

View File

@@ -0,0 +1 @@
pyproject configuration, based on the generic configuration.

View File

@@ -0,0 +1,76 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# 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
from meal_manager.models import Base
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()

View 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"}

View File

@@ -0,0 +1,110 @@
"""inital revision
Revision ID: 299a83240036
Revises:
Create Date: 2025-10-12 20:46:13.452705
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "299a83240036"
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(
"event",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("event_time", sa.DateTime(), nullable=False),
sa.Column("registration_deadline", sa.DateTime(), nullable=False),
sa.Column("description", sa.String(), nullable=False),
sa.Column("recipe_link", sa.String(), nullable=False),
sa.Column("team_cooking_min", sa.Integer(), nullable=False),
sa.Column("team_cooking_max", sa.Integer(), nullable=False),
sa.Column("team_dishes_min", sa.Integer(), nullable=False),
sa.Column("team_dishes_max", sa.Integer(), nullable=False),
sa.Column("team_prep_min", sa.Integer(), nullable=False),
sa.Column("team_prep_max", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"household",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"registration",
sa.Column("event_id", sa.Integer(), nullable=False),
sa.Column("household_id", sa.Integer(), nullable=False),
sa.Column("num_adult_meals", sa.Integer(), nullable=False),
sa.Column("num_children_meals", sa.Integer(), nullable=False),
sa.Column("num_small_children_meals", sa.Integer(), nullable=False),
sa.Column("comment", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["event_id"],
["event.id"],
),
sa.ForeignKeyConstraint(
["household_id"],
["household.id"],
),
sa.PrimaryKeyConstraint("event_id", "household_id"),
)
op.create_table(
"subscription",
sa.Column("household_id", sa.Integer(), nullable=False),
sa.Column("num_adult_meals", sa.Integer(), nullable=False),
sa.Column("num_children_meals", sa.Integer(), nullable=False),
sa.Column("num_small_children_meals", sa.Integer(), nullable=False),
sa.Column("comment", sa.String(), nullable=True),
sa.Column("last_modified", sa.DateTime(), nullable=False),
sa.Column("monday", sa.Boolean(), nullable=False),
sa.Column("tuesday", sa.Boolean(), nullable=False),
sa.Column("wednesday", sa.Boolean(), nullable=False),
sa.Column("thursday", sa.Boolean(), nullable=False),
sa.Column("friday", sa.Boolean(), nullable=False),
sa.Column("saturday", sa.Boolean(), nullable=False),
sa.Column("sunday", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["household_id"],
["household.id"],
),
sa.PrimaryKeyConstraint("household_id"),
)
op.create_table(
"teamregistration",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("event_id", sa.Integer(), nullable=False),
sa.Column("person_name", sa.String(), nullable=False),
sa.Column("work_type", sa.Text(), nullable=False),
sa.Column("comment", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["event_id"],
["event.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("team_registration")
op.drop_table("subscription")
op.drop_table("registration")
op.drop_table("household")
op.drop_table("event")
# ### end Alembic commands ###

View File

@@ -0,0 +1,47 @@
"""Add subscriptions_applied column to Event
Revision ID: 13084c5c1f68
Revises: 299a83240036
Create Date: 2025-10-27 12:25:14.633641
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "13084c5c1f68"
down_revision: Union[str, Sequence[str], None] = "299a83240036"
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.add_column(
"event",
sa.Column(
"subscriptions_applied",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
op.add_column(
"event",
sa.Column(
"ignore_subscriptions", sa.Boolean(), nullable=False, server_default="true"
),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("event", "subscriptions_applied")
op.drop_column("event", "ignore_subscriptions")
# ### end Alembic commands ###

View File

@@ -0,0 +1,49 @@
"""add billing info and organizer name to event
Revision ID: 914ebe23f071
Revises: 13084c5c1f68
Create Date: 2025-12-12 12:26:13.314293
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "914ebe23f071"
down_revision: Union[str, Sequence[str], None] = "13084c5c1f68"
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.add_column(
"event",
sa.Column(
"billed", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
)
op.add_column(
"event",
sa.Column(
"exclude_from_billing",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
op.add_column("event", sa.Column("organizer_name", sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("event", "organizer_name")
op.drop_column("event", "exclude_from_billing")
op.drop_column("event", "billed")
# ### end Alembic commands ###

64
src/meal_manager/grist.py Normal file
View File

@@ -0,0 +1,64 @@
import datetime
import os
from dotenv import load_dotenv
load_dotenv()
from pygrister.api import GristApi
config = {
"GRIST_API_SERVER": "allmende-gufi.de",
"GRIST_TEAM_SITE": "grist",
"GRIST_API_KEY": os.getenv("GRIST_API_KEY"),
"GRIST_DOC_ID": "xmEcaq5pvxUB3mBfEY8BLe",
}
def sync_with_grist(event) -> int:
"""Writes the event's registrations to Grist.
If a registration already exists for a given household, it is not overwritten.
Returns the number of new records.
"""
grist = GristApi(config=config)
status_code, grist_response = grist.list_records(
"Transactions", filter={"meal_manager_event_id": [event.id]}
)
existing_records = set()
for entry in grist_response:
existing_records.add(entry["Partei_Konto"])
new_records = []
today = datetime.date.today().isoformat()
for registration in event.registrations:
if registration.household.name in existing_records:
continue
if registration.num_adult_meals > 0:
new_records.append(
{
"Datum": event.event_time.date().isoformat(),
"Typ": "Essen",
"Partei_Konto": registration.household.name,
"Betrag": -3.5 * registration.num_adult_meals,
"Date_Added": today,
"meal_manager_event_id": event.id,
}
)
if registration.num_children_meals > 0:
new_records.append(
{
"Datum": event.event_time.date().isoformat(),
"Typ": "Essen Kind",
"Partei_Konto": registration.household.name,
"Betrag": -2 * registration.num_children_meals,
"Date_Added": today,
"meal_manager_event_id": event.id,
}
)
if new_records:
grist.add_records("Transactions", new_records)
return len(new_records)
else:
return 0

464
src/meal_manager/main.py Normal file
View File

@@ -0,0 +1,464 @@
import locale
import os
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from functools import partial
from typing import Annotated
from urllib.parse import quote_plus
import starlette.status as status
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from meal_manager.grist import sync_with_grist
from meal_manager.models import (
Base,
Event,
Household,
Registration,
Subscription,
TeamRegistration,
)
from meal_manager.pdf import build_dinner_overview_pdf
sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)
locale.setlocale(locale.LC_ALL, "de_DE.UTF-8")
def get_session():
with Session(engine) as session:
yield session
def get_user(request: Request, allow_none: bool = True) -> dict | None:
"""
Retrieve user information from the incoming request.
This function attempts to extract user information from the request headers set by ssowat.
If allow_none is set to `True`, then a `None` value will be returned if no user information is found, else
an exception will be raised, resulting in a 401 Unauthorized response.
Used in UserDep and StrictUserDep.
:param request: The incoming HTTP request containing headers and other context
information.
:param allow_none: Flag indicating whether returning `None` is permitted when
user information is not available.
:return: A dictionary containing the username if found, or `None` if no user
information is available and `allow_none` is `True`.
:raises HTTPException: If user information is not found and `allow_none` is
`False`.
"""
if fake_user := os.environ.get("MEAL_MANAGER_FAKE_USER", False):
return {"username": "fake_user", "admin": fake_user == "admin"}
if "ynh_user" in request.headers:
return {
"username": request.headers["ynh_user"],
# TODO: This should obviously be replaced with a role based check
"admin": request.headers["ynh_user"] in ["niklas.m", "martin.k"]
}
if allow_none:
return None
else:
raise HTTPException(status_code=401, detail="Not logged in")
def create_db_and_tables():
Base.metadata.create_all(engine)
@asynccontextmanager
async def on_startup(app_: FastAPI):
create_db_and_tables()
yield
app = FastAPI(lifespan=on_startup)
app.mount("/static", StaticFiles(directory="src/meal_manager/static"), name="static")
templates = Jinja2Templates(directory="src/meal_manager/templates")
SessionDep = Annotated[Session, Depends(get_session)]
UserDep = Annotated[dict, Depends(get_user)]
StrictUserDep = Annotated[dict, Depends(partial(get_user, allow_none=False))]
@app.get("/")
async def index(request: Request, session: SessionDep, user: UserDep):
"""Displays coming events and a button to register new ones"""
now = datetime.now()
# TODO: Once we refactored to use SQLAlchemy directly, we can probably do a nicer filtering on the date alone
statement = (
select(Event)
.order_by(Event.event_time)
.where(Event.event_time >= now - timedelta(days=1))
)
events = session.scalars(statement)
return templates.TemplateResponse(
request=request,
name="index.html",
context={"events": events, "current_page": "home", "now": now, "user": user},
)
@app.get("/robots.txt")
async def robots_txt():
return FileResponse("src/meal_manager/static/robots.txt", media_type="text/plain")
@app.get("/past_events")
async def past_events(request: Request, session: SessionDep):
now = datetime.now()
# TODO: Once we refactored to use SQLAlchemy directly, we can probably do a nicer filtering on the date alone
statement = (
select(Event)
.order_by(Event.event_time)
.where(Event.event_time < now - timedelta(days=1))
)
events = session.scalars(statement)
return templates.TemplateResponse(
request=request,
name="index.html",
context={"events": events, "current_page": "past", "now": now},
)
@app.get("/subscribe")
async def subscribe(request: Request, session: SessionDep, user: UserDep):
statement = select(Household)
households = session.scalars(statement)
subscriptions = session.scalars(select(Subscription)).all()
# filter out households with existing subscriptions
households = [
h for h in households if h.id not in [sub.household_id for sub in subscriptions]
]
return templates.TemplateResponse(
request=request,
name="subscribe.html",
context={
"households": households,
"subscriptions": subscriptions,
"user": user,
},
)
@app.post("/subscribe")
async def add_subscribe(request: Request, session: SessionDep):
form_data = await request.form()
# TODO: Make this return a nicer error message
try:
num_adult_meals = int(form_data["numAdults"]) if form_data["numAdults"] else 0
num_children_meals = int(form_data["numKids"]) if form_data["numKids"] else 0
num_small_children_meals = (
int(form_data["numSmallKids"]) if form_data["numSmallKids"] else 0
)
except ValueError:
raise ValueError("All number fields must be integers")
subscription = Subscription(
household_id=form_data["household"],
num_adult_meals=num_adult_meals,
num_children_meals=num_children_meals,
num_small_children_meals=num_small_children_meals,
)
selected_days = form_data.getlist("days")
if selected_days:
subscription.monday = "1" in selected_days
subscription.tuesday = "2" in selected_days
subscription.wednesday = "3" in selected_days
subscription.thursday = "4" in selected_days
subscription.friday = "5" in selected_days
subscription.saturday = "6" in selected_days
subscription.sunday = "7" in selected_days
session.add(subscription)
session.commit()
return RedirectResponse(url="/subscribe", status_code=status.HTTP_302_FOUND)
@app.get("/subscribe/{household_id}/delete")
async def delete_subscription(
request: Request, session: SessionDep, household_id: int, user: StrictUserDep
):
statement = select(Subscription).where(Subscription.household_id == household_id)
sub = session.scalars(statement).one()
session.delete(sub)
session.commit()
return RedirectResponse(url="/subscribe", status_code=status.HTTP_302_FOUND)
@app.get("/event/add")
async def add_event_form(request: Request, user: StrictUserDep):
return templates.TemplateResponse(request=request, name="add_event.html")
@app.get("/event/{event_id}/edit")
async def edit_event_form(
request: Request, event_id: int, session: SessionDep, user: StrictUserDep
):
statement = select(Event).where(Event.id == event_id)
event = session.scalars(statement).one()
return templates.TemplateResponse(
request=request,
context={"event": event, "edit_mode": True},
name="add_event.html",
)
@app.post("/event/{event_id}/edit")
async def edit_event(
request: Request, event_id: int, session: SessionDep, user: StrictUserDep
):
statement = select(Event).where(Event.id == event_id)
event = session.scalars(statement).one()
form_data = await request.form()
event_time, registration_deadline = await parse_event_times(form_data)
event.title = form_data["eventName"]
event.event_time = event_time
event.registration_deadline = registration_deadline
event.description = form_data.get("eventDescription")
event.recipe_link = form_data.get("recipeLink")
event.ignore_subscriptions = form_data.get("ignoreSubscriptions") == "on"
event.organizer_name = form_data.get("organizerName")
event.exclude_from_billing = form_data.get("excludeFromBilling") == "on"
session.commit()
return RedirectResponse(url=f"/event/{event.id}", status_code=status.HTTP_302_FOUND)
@app.post("/event/add")
async def add_event(request: Request, session: SessionDep, user: StrictUserDep):
form_data = await request.form()
event_time, registration_deadline = await parse_event_times(form_data)
event = Event(
title=form_data["eventName"],
event_time=event_time,
registration_deadline=registration_deadline,
description=form_data.get("eventDescription"),
recipe_link=form_data.get("recipeLink"),
ignore_subscriptions=form_data.get("ignoreSubscriptions") == "on",
organizer_name=form_data.get("organizerName"),
exclude_from_billing=form_data.get("excludeFromBilling") == "on",
)
session.add(event)
session.commit()
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
async def parse_event_times(form_data):
event_time = datetime.fromisoformat(form_data["eventTime"])
registration_deadline = form_data.get("registrationDeadline")
if not registration_deadline:
# Find the last Sunday before event_time
deadline = event_time
while deadline.weekday() != 6: # 6 represents Sunday
deadline = deadline.replace(day=deadline.day - 1)
registration_deadline = deadline.replace(
hour=19, minute=30, second=0, microsecond=0
)
else:
registration_deadline = datetime.fromisoformat(registration_deadline)
return event_time, registration_deadline
@app.get("/event/{event_id}/delete")
async def delete_event(
request: Request, session: SessionDep, event_id: int, user: StrictUserDep
):
if not user["admin"]:
raise HTTPException(status_code=403, detail="Not authorized")
statement = select(Event).where(Event.id == event_id)
event = session.scalars(statement).one()
session.delete(event)
session.commit()
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
@app.get("/event/{event_id}")
async def read_event(
request: Request,
event_id: int,
session: SessionDep,
user: UserDep,
message: str | None = None,
):
statement = select(Event).where(Event.id == event_id)
event = session.scalars(statement).one()
statement = select(Household)
households = session.scalars(statement)
# filter out households with existing registrations
households = [
h
for h in households
if h.id not in [reg.household_id for reg in event.registrations]
]
return templates.TemplateResponse(
request=request,
name="event.html",
context={
"event": event,
"households": households,
"now": datetime.now(),
"user": user,
"message": message,
},
)
@app.post("/event/{event_id}/register")
async def add_registration(request: Request, event_id: int, session: SessionDep):
form_data = await request.form()
# TODO: Make this return a nicer error message
try:
num_adult_meals = int(form_data["numAdults"]) if form_data["numAdults"] else 0
num_children_meals = int(form_data["numKids"]) if form_data["numKids"] else 0
num_small_children_meals = (
int(form_data["numSmallKids"]) if form_data["numSmallKids"] else 0
)
except ValueError:
raise ValueError("All number fields must be integers")
registration = Registration(
household_id=form_data["household"],
event_id=event_id,
num_adult_meals=num_adult_meals,
num_children_meals=num_children_meals,
num_small_children_meals=num_small_children_meals,
comment=form_data["comment"],
)
session.add(registration)
session.commit()
return RedirectResponse(url=f"/event/{event_id}", status_code=status.HTTP_302_FOUND)
@app.get("/event/{event_id}/registration/{household_id}/delete")
async def delete_registration(
request: Request,
event_id: int,
household_id: int,
session: SessionDep,
user: StrictUserDep,
):
"""
Deletes a registration record for a specific household at a given event. This endpoint
handles the removal of the registration, commits the change to the database, and
redirects the user to the event page.
"""
statement = select(Registration).where(
Registration.household_id == household_id, Registration.event_id == event_id
)
session.delete(session.scalars(statement).one())
session.commit()
return RedirectResponse(url=f"/event/{event_id}", status_code=status.HTTP_302_FOUND)
@app.post("/event/{event_id}/register_team")
async def add_team_registration(request: Request, event_id: int, session: SessionDep):
form_data = await request.form()
person = form_data["personName"].strip()
work_type = form_data["workType"]
statement = select(TeamRegistration).where(
TeamRegistration.person_name == person,
TeamRegistration.work_type == work_type,
TeamRegistration.event_id == event_id,
)
# if the person has already registered for the same work type, just ignore
if session.scalars(statement).one_or_none() is None:
registration = TeamRegistration(
person_name=person,
event_id=event_id,
work_type=form_data["workType"],
)
session.add(registration)
session.commit()
return RedirectResponse(url=f"/event/{event_id}", status_code=status.HTTP_302_FOUND)
@app.get("/event/{event_id}/register_team/{entry_id}/delete")
async def delete_team_registration(
request: Request,
event_id: int,
entry_id: int,
session: SessionDep,
user: StrictUserDep,
):
statement = select(TeamRegistration).where(TeamRegistration.id == entry_id)
session.delete(session.scalars(statement).one())
session.commit()
return RedirectResponse(url=f"/event/{event_id}", status_code=status.HTTP_302_FOUND)
@app.get("/event/{event_id}/pdf")
def get_event_attendance_pdf(event_id: int, session: SessionDep):
statement = select(Event).where(Event.id == event_id)
event = session.scalars(statement).one()
pdf_buffer = build_dinner_overview_pdf(event)
headers = {
"Content-Disposition": f"inline; filename=attendance_event_{event_id}.pdf"
}
return Response(
content=pdf_buffer.getvalue(), media_type="application/pdf", headers=headers
)
@app.get("/event/{event_id}/sync_with_grist")
def sync_with_grist_route(event_id: int, session: SessionDep, user: StrictUserDep):
"""
Synchronizes the specified event with Grist and redirects the user.
This function retrieves the event by its identifier, synchronizes it with Grist,
and then redirects the user to the event page with a success message.
TODO: Error handling
"""
statement = select(Event).where(Event.id == event_id)
event = session.scalars(statement).one()
entries_written = sync_with_grist(event)
message = "Es wurden keine Einträge geschrieben."
if entries_written > 0:
event.billed = True
session.commit()
message = f"Erfolgreich {entries_written} Einträge geschrieben."
return RedirectResponse(
url=f"/event/{event_id}?message={quote_plus(message)}",
status_code=status.HTTP_302_FOUND,
)

175
src/meal_manager/models.py Normal file
View File

@@ -0,0 +1,175 @@
import typing
from datetime import date, datetime
from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy.types import Text
class Base(DeclarativeBase):
pass
WorkTypes = typing.Literal["cooking", "dishes", "tables"]
class Event(Base):
__tablename__ = "event"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(nullable=False)
event_time: Mapped[datetime] = mapped_column(nullable=False)
registration_deadline: Mapped[datetime] = mapped_column(nullable=False)
description: Mapped[str] = mapped_column(nullable=True)
recipe_link: Mapped[str] = mapped_column(nullable=True)
# Min and max number of people needed for cooking, doing the dishes and preparing the tables
team_cooking_min: Mapped[int] = mapped_column(default=3, nullable=False)
team_cooking_max: Mapped[int] = mapped_column(default=5, nullable=False)
team_dishes_min: Mapped[int] = mapped_column(default=3, nullable=False)
team_dishes_max: Mapped[int] = mapped_column(default=5, nullable=False)
# Todo: Rename to "table"
team_prep_min: Mapped[int] = mapped_column(default=1, nullable=False)
team_prep_max: Mapped[int] = mapped_column(default=1, nullable=False)
subscriptions_applied: Mapped[bool] = mapped_column(default=False, nullable=False)
ignore_subscriptions: Mapped[bool] = mapped_column(default=False, nullable=False)
billed: Mapped[bool] = mapped_column(default=False, nullable=False)
exclude_from_billing: Mapped[bool] = mapped_column(default=False, nullable=False)
organizer_name: Mapped[str] = mapped_column(nullable=True)
registrations: Mapped[list["Registration"]] = relationship(
"Registration", cascade="all, delete"
)
team: Mapped[list["TeamRegistration"]] = relationship(
"TeamRegistration", cascade="all, delete"
)
def team_min_reached(self, work_type: WorkTypes):
threshold = {
"cooking": self.team_cooking_min,
"dishes": self.team_dishes_min,
"tables": self.team_prep_min,
}[work_type]
return sum(1 for t in self.team if t.work_type == work_type) >= threshold
def team_max_reached(self, work_type: WorkTypes):
threshold = {
"cooking": self.team_cooking_max,
"dishes": self.team_dishes_max,
"tables": self.team_prep_max,
}[work_type]
return sum(1 for t in self.team if t.work_type == work_type) >= threshold
def all_teams_min(self):
return all(
self.team_min_reached(work_type) for work_type in typing.get_args(WorkTypes)
)
def all_teams_max(self):
return all(
self.team_max_reached(work_type) for work_type in typing.get_args(WorkTypes)
)
@property
def registration_open(self):
return datetime.now() < self.registration_deadline
@property
def in_the_past(self):
return datetime.now() > self.event_time
class TeamRegistration(Base):
__tablename__ = "teamregistration"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
event_id: Mapped[int] = mapped_column(ForeignKey("event.id"))
person_name: Mapped[str] = mapped_column(nullable=False)
work_type: Mapped[WorkTypes] = mapped_column(Text, nullable=False)
comment: Mapped[str | None] = mapped_column()
class Household(Base):
__tablename__ = "household"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False)
class Registration(Base):
__tablename__ = "registration"
event_id: Mapped[int] = mapped_column(ForeignKey("event.id"), primary_key=True)
household_id: Mapped[int] = mapped_column(
ForeignKey("household.id"), primary_key=True
)
num_adult_meals: Mapped[int] = mapped_column(nullable=False)
num_children_meals: Mapped[int] = mapped_column(nullable=False)
num_small_children_meals: Mapped[int] = mapped_column(nullable=False)
comment: Mapped[str | None] = mapped_column()
household: Mapped["Household"] = relationship()
class Subscription(Base):
__tablename__ = "subscription"
household_id: Mapped[int] = mapped_column(
ForeignKey("household.id"), primary_key=True
)
num_adult_meals: Mapped[int] = mapped_column(nullable=False)
num_children_meals: Mapped[int] = mapped_column(nullable=False)
num_small_children_meals: Mapped[int] = mapped_column(nullable=False)
comment: Mapped[str | None] = mapped_column()
last_modified: Mapped[datetime] = mapped_column(
default=datetime.now, nullable=False
)
monday: Mapped[bool] = mapped_column(default=True, nullable=False)
tuesday: Mapped[bool] = mapped_column(default=True, nullable=False)
wednesday: Mapped[bool] = mapped_column(default=True, nullable=False)
thursday: Mapped[bool] = mapped_column(default=True, nullable=False)
friday: Mapped[bool] = mapped_column(default=True, nullable=False)
saturday: Mapped[bool] = mapped_column(default=True, nullable=False)
sunday: Mapped[bool] = mapped_column(default=True, nullable=False)
household: Mapped["Household"] = relationship()
def day_string_de(self) -> str:
"""
Generates a string representation of selected days in German short form.
"""
result = []
if self.monday:
result.append("Mo")
if self.tuesday:
result.append("Di")
if self.wednesday:
result.append("Mi")
if self.thursday:
result.append("Do")
if self.friday:
result.append("Fr")
if self.saturday:
result.append("Sa")
if self.sunday:
result.append("So")
if len(result) < 7:
return ", ".join(result)
else:
return "Alle"
def valid_on(self, date: date) -> bool:
weekday = date.weekday()
return {
0: self.monday,
1: self.tuesday,
2: self.wednesday,
3: self.thursday,
4: self.friday,
5: self.saturday,
6: self.sunday,
}[weekday]

128
src/meal_manager/pdf.py Normal file
View File

@@ -0,0 +1,128 @@
from io import BytesIO
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, portrait
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from meal_manager.models import Event
def build_dinner_overview_pdf(event: Event) -> BytesIO:
"""Build a PDF with an overview of the event's attendance."""
# Create an in-memory PDF
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=portrait(A4),
topMargin=30,
bottomMargin=30,
leftMargin=40,
rightMargin=40,
)
styles = getSampleStyleSheet()
elements = []
# Title
title_style = styles["Title"]
title_style.fontSize = 16
title_style.spaceAfter = 20
elements.append(
Paragraph(
f"Anwesenheitsliste {event.title} ({event.event_time.date().strftime('%d.%m.%y')})",
title_style,
)
)
elements.append(Spacer(1, 20))
# Team overview section
elements.append(Paragraph("Dienste", styles["Heading2"]))
elements.append(Spacer(1, 12))
team_data = [["Team", "Personen"]]
team_types = {
"Kochen": [r for r in event.team if r.work_type == "cooking"],
"Abwaschen": [r for r in event.team if r.work_type == "dishes"],
"Tische decken": [r for r in event.team if r.work_type == "tables"],
}
for team_name, registrations in team_types.items():
members = ", ".join(r.person_name for r in registrations)
team_data.append([team_name, members])
team_table = Table(team_data, repeatRows=1, colWidths=[100, "*"])
team_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E8E8")),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#A0A0A0")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("TOPPADDING", (0, 0), (-1, -1), 8),
]
)
)
elements.append(team_table)
elements.append(Spacer(1, 25))
sum_adults = 0
sum_children = 0
sum_small_children = 0
for r in event.registrations:
sum_adults += r.num_adult_meals
sum_children += r.num_children_meals
sum_small_children += r.num_small_children_meals
# Attendance section
elements.append(Paragraph("Teilnehmende", styles["Heading2"]))
elements.append(
Paragraph(
f"Gesamt: {sum_adults} Erwachsene, {sum_children} Kinder, {sum_small_children} Kleinkinder"
)
)
elements.append(Spacer(1, 12))
# Table header
data = [
["Haushalt", "Erwachsene", "Kinder >7", "Kinder <7", "Kommentar", "Anwesend?"]
]
# Table rows
for r in event.registrations:
data.append(
[
r.household.name,
r.num_adult_meals,
r.num_children_meals,
r.num_small_children_meals,
Paragraph(r.comment or ""),
"",
]
)
for _ in range(5):
data.append([""] * 6)
# Create table
table = Table(data, repeatRows=1, colWidths=[120, 70, 60, 60, "*", 65])
table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E8E8")),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#A0A0A0")),
("ALIGN", (1, 1), (-2, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("TOPPADDING", (0, 0), (-1, -1), 3),
]
)
)
elements.append(table)
doc.build(elements)
buffer.seek(0)
return buffer

116
src/meal_manager/scripts.py Normal file
View File

@@ -0,0 +1,116 @@
import argparse
import datetime
import os
import shutil
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from meal_manager.main import engine, sqlite_file_name
from meal_manager.models import Event, Registration, Subscription
def backup_db():
backup_dir = Path(os.environ.get("MEAL_MANAGER_BACKUP_DIR", None))
shutil.copy2(
sqlite_file_name,
backup_dir / f"{sqlite_file_name}.{datetime.datetime.now().isoformat()}",
)
# TODO: Delete old backups
def run_nightly_tasks():
print("Applying Subscriptions")
with Session(engine) as session:
apply_subscriptions(session)
print("Running db backup")
backup_db()
print("Done running nightly tasks.")
def apply_subscriptions(session: Session, event: Event = None, dry_run: bool = False):
subscriptions = session.scalars(select(Subscription)).all()
if event is not None:
events = [event]
else:
today = datetime.date.today()
query = select(Event).where(
~Event.subscriptions_applied,
~Event.ignore_subscriptions,
func.strftime("%Y-%m-%d", Event.event_time) >= today.strftime("%Y-%m-%d"),
func.strftime("%Y-%m-%d", Event.event_time)
<= (today + datetime.timedelta(days=7)).strftime("%Y-%m-%d"),
)
events = session.scalars(query).all()
if len(events) == 0:
print("No events to process")
return
for event in events:
if dry_run:
print(f"DRY RUN: Would process event {event.title} ({event.id})")
else:
print(f"Processing event {event.title} ({event.id})")
print(f"There are {len(subscriptions)} subscriptions to process")
relevant_subscriptions = [
s for s in subscriptions if s.valid_on(event.event_time.date())
]
print(f"{len(relevant_subscriptions)} of them are relevant to this event")
for subscription in relevant_subscriptions:
existing_registration = session.scalars(
select(Registration).where(
Registration.event_id == event.id,
Registration.household_id == subscription.household_id,
)
).one_or_none()
if existing_registration:
print(
f"There is already a registration for {subscription.household.name}. Skipping subscription."
)
continue
reg = Registration(
event_id=event.id,
household_id=subscription.household_id,
num_adult_meals=subscription.num_adult_meals,
num_children_meals=subscription.num_children_meals,
num_small_children_meals=subscription.num_small_children_meals,
comment="Dauerhafte Anmeldung",
)
if dry_run:
print(f"DRY RUN: Would register {subscription.household.name}")
else:
session.add(reg)
print(f"Registered {subscription.household.name}")
event.subscriptions_applied = True
if not dry_run:
session.commit()
def apply_subscriptions_cli():
parser = argparse.ArgumentParser(description="Apply subscriptions for an event")
parser.add_argument("--event_id", type=int, help="Event ID (required)")
parser.add_argument(
"--dry-run", action="store_true", help="Run without making changes"
)
args = parser.parse_args()
# Access the arguments
event_id = args.event_id
dry_run = args.dry_run
with Session(engine) as session:
if event_id is not None:
event = session.scalars(select(Event).where(Event.id == event_id)).one()
apply_subscriptions(session, event, dry_run)
else:
apply_subscriptions(session, dry_run=dry_run)

View File

@@ -0,0 +1,29 @@
/* theme.css */
/* Green Bootstrap Theme */
:root {
--bs-primary: #198754;
--bs-primary-rgb: 25, 135, 84;
--bs-success: #198754;
--bs-success-rgb: 25, 135, 84;
--bs-link-color: var(--bs-primary);
--bs-link-hover-color: #146c43;
}
/* Explicit fallback overrides for older versions (<=5.2) */
.btn-primary {
color: #fff;
background-color: #198754;
border-color: #198754;
}
.btn-primary:hover {
color: #fff;
background-color: #157347;
border-color: #146c43;
}
.btn-primary:focus,
.btn-primary:active {
color: #fff;
background-color: #146c43;
border-color: #125c39;
}

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center mt-4">
<div class="col-md-8">
{% if edit_mode %}<h2>Event bearbeiten</h2>{% else %}<h2>Neues Event erstellen</h2>{% endif %}
{% if edit_mode %}<form method="post" action="/event/{{ event.id }}/edit">{% else %}<form method="post" action="/event/add">{% endif %}
<div class="mb-3">
<label for="eventName" class="form-label">Event Name</label>
<input type="text" class="form-control" id="eventName" name="eventName" required {% if edit_mode %}value="{{ event.title }}"{% endif %}>
</div>
<div class="mb-3">
<label for="eventTime" class="form-label">Datum und Uhrzeit</label>
<input type="datetime-local" class="form-control" id="eventTime" name="eventTime" required {% if edit_mode %}value="{{ event.event_time }}"{% endif %}>
</div>
<div class="mb-3">
<label for="registrationDeadline" class="form-label">Anmeldungs-Deadline</label>
<input type="datetime-local" class="form-control" id="registrationDeadline" name="registrationDeadline" {% if edit_mode %}value="{{ event.registration_deadline }}"{% endif %}>
<small class="form-text text-muted">Leer lassen für Sonntag Abend vor dem Event</small>
</div>
<div class="mb-3">
<label for="organizerName" class="form-label">Organisator*in</label>
<input type="text" class="form-control" id="organizerName" name="organizerName" {% if edit_mode %}value="{{ event.organizer_name }}"{% endif %}>
<small class="form-text text-muted">Name der Person, die das Event organisiert.</small>
</div>
<div class="mb-3">
<label for="recipeLink" class="form-label">Rezept-Link</label>
<input type="text" class="form-control" id="recipeLink" name="recipeLink" {% if edit_mode %}value="{{ event.recipe_link }}"{% endif %}>
</div>
<div class="mb-3">
<label for="eventDescription" class="form-label">Beschreibung</label>
<textarea class="form-control" id="eventDescription" name="eventDescription" rows="3" {% if edit_mode %}value="{{ event.description }}"{% endif %}></textarea>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="ignoreSubscriptions" name="ignoreSubscriptions" {% if (edit_mode and event.ignore_subscriptions) or not edit_mode %}checked{% endif %}>
<label class="form-check-label" for="ignoreSubscriptions">Dauerhafte Anmeldung ignorieren</label>
<small class="form-text text-muted">
Aktivieren, um dauerhafte Anmeldungen für dieses Event zu ignorieren. Das sollte für alle Events getan werden, die keine offiziellen AG Kochen Kochabende sind.
</small>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="excludeFromBilling" name="excludeFromBilling" {% if edit_mode and event.exclude_from_billing %}checked{% endif %}>
<label class="form-check-label" for="excludeFromBilling">Keine Abrechnung</label>
<small class="form-text text-muted">
Aktivieren, um dieses Event von der Abrechnung auszuschließen.
</small>
</div>
<button type="submit" class="btn btn-primary">Event {% if edit_mode %}bearbeiten{% else %}erstellen{% endif %}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -2,13 +2,13 @@
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Allmende Essen</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB">
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/icons/bootstrap-icons.min.css" rel="stylesheet">
<link href="/static/css/allmende.css" rel="stylesheet">
</head>
<body class="p-3 m-0 border-0 bd-example m-0 border-0">
<body class="p-3 m-0 border-0 bd-example">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/">
@@ -22,14 +22,14 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link {% if current_page == 'home' %}active{% endif %}" {% if current_page == 'home' %}aria-current="page"{% endif %} href="/">Home</a>
<a class="nav-link {% if current_page == 'home' %}active{% endif %}" {% if current_page == 'home' %}aria-current="page"{% endif %} href="/">Kommende</a>
</li>
<li class="nav-item">
<a class="nav-link {% if current_page == 'vergangene' %}active{% endif %}" {% if current_page == 'vergangene' %}aria-current="page"{% endif %} href="/vergangene">Vergangene</a>
</li>
<li class="nav-item">
<a class="nav-link {% if current_page == 'preise' %}active{% endif %}" {% if current_page == 'preise' %}aria-current="page"{% endif %} href="/preise">Preise</a>
<a class="nav-link {% if current_page == 'past' %}active{% endif %}" {% if current_page == 'past' %}aria-current="page"{% endif %} href="/past_events">Vergangene</a>
</li>
<!-- <li class="nav-item">-->
<!-- <a class="nav-link {% if current_page == 'preise' %}active{% endif %}" {% if current_page == 'preise' %}aria-current="page"{% endif %} href="/preise">Preise</a>-->
<!-- </li>-->
</ul>
</div>
</div>
@@ -38,7 +38,6 @@
{% block content %}{% endblock %}
</div>
<script src="/static/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"></script>
<script src="/static/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,336 @@
{% import "macros.j2" as macros %}
{% macro teamEntries(event, work_type) -%}
{% for entry in event.team | selectattr("work_type", "equalto", work_type)%}
<div class="d-inline-flex align-items-center bg-light border rounded-pill px-3 py-1 m-1">
<span class="me-2">{{ entry.person_name }}</span>
{% if user and event.registration_open -%}
<button type="button" class="btn btn-sm p-0 border-0 bg-transparent text-muted">
<a href="/event/{{event.id}}/register_team/{{entry.id}}/delete">
<i class="bi bi-trash"></i>
</a>
</button>
{% endif -%}
</div>
{% endfor %}
{%- endmacro %}
{% extends "base.html" %}
{% block content %}
<p class="h1">{{ event.title }}</p>
<p class="text-muted">{{ event.event_time.strftime('%A, %d.%m.%Y') }}</p>
{% if event.organizer_name %}<p>Organisiert von {{ event.organizer_name }}</p>{% endif %}
<p>{{ event.description }}</p>
<hr class="hr"/>
{% if message %}
<div class="alert alert-info alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle me-2"></i>
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="container">
<p class="h3">Anmeldungen</p>
{% if not user %}
<div class="alert alert-warning m-2">
<i class="bi bi-info-circle me-2"></i>
Das Löschen von Anmeldungen und Dienstanmeldungen ist nur möglich, wenn du in der Allmende-Cloud eingeloggt bist. <br/> Geht dazu auf <a href="https://cloud.allmende-gufi.de" target="_blank">allmende.cloud</a> und melde dich an.
</div>
{% endif %}
<div class="row m-2">
<div class="col-md-4 d-flex justify-content-center align-items-center flex-column">
<p class="text-muted w-100 mb-2">Anmeldung schließt {{ event.registration_deadline.strftime('%A, %d.%m.%Y, %H:%M Uhr') }}</p>
<!-- Button trigger modal -->
<button type="button" class="btn btn-primary mb-2 w-100" {% if not (event.registration_open or (user and user.admin)) %}disabled{% endif%} data-bs-toggle="modal" data-bs-target="#registration">
Anmeldung hinzufügen
</button>
<button type="button" class="btn btn-primary mb-2 w-100" {% if event.all_teams_max() or event.in_the_past %}disabled{% endif %} data-bs-toggle="modal" data-bs-target="#teamRegistration">
Dienst übernehmen
</button>
{% if event.recipe_link %}
<a href="{{ event.recipe_link }}" class="btn btn-outline-primary mb-2 w-100" target="_blank">
<i class="bi bi-book"></i> Original Rezept ansehen
</a>
{% endif %}
<div class="row w-100">
<div class="col-6 p-1">
<a href="/event/{{event.id}}/pdf" class="btn btn-secondary w-100" target="_blank">
<i class="bi bi-printer m-2"></i> Druckansicht
</a>
</div>
<div class="col-6 p-1">
{% if user -%}
<a href="/event/{{event.id}}/edit" class="btn btn-secondary w-100">
<i class="bi bi-pen m-2"></i> Event bearbeiten
</a>
{% else -%}
<button type="button" class="btn btn-secondary w-100" data-bs-toggle="modal" data-bs-target="#loginInfo">
<i class="bi bi-pen m-2"></i> Event bearbeiten
</button>
{% endif -%}
</div>
</div>
{% if user and user.admin %}
<div class="row w-100">
<div class="col-6 p-1">
<button type="button" class="btn btn-danger w-100" data-bs-toggle="modal" data-bs-target="#deleteEvent">
Event Löschen
</button>
</div>
<div class="col-6 p-1">
<a href="/event/{{event.id}}/sync_with_grist" class="btn btn-secondary w-100 {% if event.exclude_from_billing %}disabled{% endif %}">
<i class="bi bi-cash-coin m-2"></i> Abrechnen
</a>
</div>
</div>
{% endif %}
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Dienste</h5>
<div class="row">
<div class="col-md-4">
Kochen: {% if event.team_min_reached("cooking") %}✅{% endif %}
</div>
<div class="col-md-8">
{{ teamEntries(event, "cooking") }}
</div>
</div>
<hr/>
<div class="row">
<div class="col-md-4">
Spülen: {% if event.team_min_reached("dishes") %}✅{% endif %}
</div>
<div class="col-md-8">
{{ teamEntries(event, "dishes") }}
</div>
</div>
<hr/>
<div class="row">
<div class="col-md-4">
Tische vorbereiten: {% if event.team_min_reached("tables") %}✅{% endif %}
</div>
<div class="col-md-8">
{{ teamEntries(event, "tables") }}
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Zusammenfassung</h5>
<table class="table table-sm">
<tr>
<td>Erwachsene:</td>
<td>{{ event.registrations | sum(attribute="num_adult_meals") }}</td>
</tr>
<tr>
<td>Kinder:</td>
<td>{{ event.registrations | sum(attribute="num_children_meals") }}</td>
</tr>
<tr>
<td>Kleinkinder:</td>
<td>{{ event.registrations | sum(attribute="num_small_children_meals") }}</td>
</tr>
<tr>
<th>Gesamt:</th>
<th>{{ event.registrations | sum(attribute="num_adult_meals") + event.registrations | sum(attribute="num_children_meals") +event.registrations | sum(attribute="num_small_children_meals")}} </th>
</tr>
</table>
</div>
</div>
</div>
</div>
<!-- Desktop table view -->
<div class="d-none d-md-block">
<table class="table">
<thead>
<tr>
<th scope="col">Haushalt</th>
<th scope="col">Erwachsene</th>
<th scope="col">Kinder</th>
<th scope="col">Kleinkinder</th>
<th scope="col">Kommentar</th>
{% if user %}<th scope="col">Löschen</th>{% endif %}
</tr>
</thead>
<tbody>
{% for reg in event.registrations %}
<tr>
<td>{{ reg.household.name }}</td>
<td>{{ reg.num_adult_meals }}</td>
<td>{{ reg.num_children_meals }}</td>
<td>{{ reg.num_small_children_meals }}</td>
<td>{% if reg.comment %}{{ reg.comment }}{% endif %}</td>
{% if user %}<td><a href="/event/{{event.id}}/registration/{{reg.household_id}}/delete"><i class="bi bi-trash"></i></a></td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Mobile card view -->
<div class="d-md-none">
{% for reg in event.registrations %}
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">{{ reg.household.name }}</h5>
{% if user %}<a href="/event/{{event.id}}/registration/{{reg.household_id}}/delete" class="text-danger">
<i class="bi bi-trash"></i>
</a>{% endif -%}
</div>
<div class="row mt-3">
<div class="col-3 text-center">
<div class="text-muted small">Erwachsene</div>
<div class="fw-bold">{{ reg.num_adult_meals }}</div>
</div>
<div class="col-3 text-center">
<div class="text-muted small">Kinder</div>
<div class="fw-bold">{{ reg.num_children_meals }}</div>
</div>
<div class="col-3 text-center">
<div class="text-muted small">Kleinkinder</div>
<div class="fw-bold">{{ reg.num_small_children_meals }}</div>
</div>
<div class="col-3 text-center">
<div class="text-muted small">Kommentar</div>
<div class="small">{{ reg.comment }}</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="registration" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1"
aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="staticBackdropLabel">Anmeldung hinzufügen</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="/event/{{event.id}}/register" method="POST">
<div class="modal-body">
<p>Wenn dein Haushalt nicht auswählbar ist, existiert schon eine Anmeldung. Wenn du die Anmeldung ändern willst, lösche die bestehende Anmeldung und lege eine neue an.</p>
<div class="mb-3">
<select name="household" class="form-select" aria-label="Multiple select example" required>
<option value="" disabled selected hidden>Wer?</option>
{% for household in households %}
<option value="{{household.id}}">{{household.name}}</option>
{% endfor %}
</select>
</div>
<div class="row">
<div class="col">
<label for="InputAdults" class="form-label">Anzahl Erwachsene</label>
<input name="numAdults" id="InputAdults" type="number" class="form-control"
aria-label="Anzahl Erwachsene" min="0" step="1" inputmode="numeric">
</div>
<div class="col">
<label for="InputKids" class="form-label">Anzahl Kinder >7 </label>
<input name="numKids" id="InputKids" type="number" class="form-control"
aria-label="Anzahl Kinder >7" min="0" step="1" inputmode="numeric">
</div>
<div class="col">
<label for="InputSmallKids" class="form-label">Anzahl Kinder <7 </label>
<input name="numSmallKids" id="InputSmallKids" type="number" class="form-control"
aria-label="Anzahl Kinder <7" min="0" step="1" inputmode="numeric">
</div>
<div class="mb-3 mt-3">
<label for="InputComment" class="form-label">Kommentar</label>
<input name="comment" id="InputComment" class="form-control"
aria-label="Kommentar">
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Anmelden</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
</div>
</form>
</div>
</div>
</div>
<!-- Team Modal -->
<div class="modal fade" id="teamRegistration" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1"
aria-labelledby="teamRegistrationLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="teamRegistrationLabel">Dienst übernehmen</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="/event/{{event.id}}/register_team" method="POST">
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<label for="personName" class="form-label">Name</label>
<input name="personName" id="personName" type="text" class="form-control"
aria-label="Name" list="people">
</div>
<datalist id="people">
{% for household in households %}
{% for person in household.name.split(",") %}
<option value="{{ person.strip() }}">
{% endfor %}
{% endfor %}
</datalist>
<div class="col-md-6">
<label for="workType" class="form-label">Dienst-Art</label>
<select id="workType" name="workType" class="form-select" aria-label="Multiple select example">
{% if not event.team_max_reached("cooking") %}<option value="cooking">Kochen</option>{% endif %}
{% if not event.team_max_reached("dishes") %}<option value="dishes">Spülen</option>{% endif %}
{% if not event.team_max_reached("tables") %}<option value="tables">Tische vorbereiten</option>{% endif %}
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Anmelden</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Event Modal -->
<div class="modal fade" id="deleteEvent" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1"
aria-labelledby="deleteEventLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h1 class="modal-title fs-5" id="deleteEventLabel">Event endgültig löschen?</h1>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Diese Aktion kann nicht rückgängig gemacht werden! Alle Anmeldungen und Dienstanmeldungen werden
unwiderruflich gelöscht.
</div>
</div>
<div class="modal-footer">
<a href="/event/{{event.id}}/delete" class="btn btn-danger">Unwiderruflich Löschen</a>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
</div>
</div>
</div>
</div>
{{ macros.login_info_modal() }}
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% import "macros.j2" as macros %}
{% block content %}
<div class="row mt-4 mb-3">
<div class="col d-flex justify-content-between align-items-center">
<h2>{% if current_page == "home" %}Kommende{% else %}Vergangene{% endif %} Kochabende</h2>
{% if user %}
<a href="/event/add" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Neues Event erstellen
</a>
{% else %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#loginInfo">
<i class="bi bi-plus-circle"></i> Neues Event erstellen
</button>
{% endif %}
</div>
</div>
<div class="mb-4">
<div class="card bg-success-subtle text-success-emphasis border-0 shadow-sm text-center">
<div class="card-body py-4">
<i class="bi bi-calendar-heart fs-3 mb-2"></i>
<h5 class="card-title mb-2">Nie wieder die Anmeldung vergessen</h5>
<p class="card-text small mb-3">
Die Dauerhafte Anmeldung gilt für alle kommenden Kochabende.
</p>
<a href="/subscribe" class="btn btn-light btn-sm fw-semibold px-3">
Jetzt dauerhaft Anmelden
</a>
</div>
</div>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for event in events %}
<div class="col">
<div class="card h-100 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-0">
{{ event.title }}
{% if event.billed %}<i class="bi bi-cash-coin" title="Abgerechnet"></i>{% endif %}
{% if event.exclude_from_billing %}<i class="bi bi-ban" title="Keine Abrechung"></i>{% endif %}
</h5>
</div>
{% if event.organizer_name %}<p class="text-muted small mb-0"><i class="bi bi-person"></i> {{ event.organizer_name }}</p>{% endif %}
</div>
<p class="text-muted mb-3"><i class="bi bi-calendar"></i> {{ event.event_time.strftime('%A, %d.%m.%Y') }}</p>
<p class="card-text">{{ event.description }}</p>
<a href="event/{{ event.id }}" class="btn btn-sm {% if event.registration_deadline > now %}btn-primary{% else %}btn-secondary{% endif %}">{% if event.registration_deadline > now %}Zur Anmeldung{% else %}Details ansehen{% endif %}</a>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Modal -->
{{ macros.login_info_modal() }}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% macro login_info_modal() -%}
<div class="modal fade" id="loginInfo" tabindex="-1" aria-labelledby="loginInfoLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="loginInfoLabel">Bitte einloggen</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Einige Funktionen, wie das Erstellen und Bearbeiten von Koch-Events, stehen nur zur Verfügung, wenn du in die Allmende-Cloud
eingeloggt bist. Gehe dazu auf <a href="https://cloud.allmende-gufi.de">cloud.allmende-gufi.de</a> und logge dich ein.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Verstanden</button>
</div>
</div>
</div>
</div>
{%- endmacro %}

View File

@@ -0,0 +1,146 @@
{% extends "base.html" %}
{% block content %}
<div class="row mt-4">
<!-- Left column: subscription form -->
<div class="col-12 col-lg-6">
{% if not user %}
<div class="alert alert-warning m-2">
<i class="bi bi-info-circle me-2"></i>
Das Löschen von dauerhaften Anmeldungen ist nur möglich, wenn du in der Allmende-Cloud eingeloggt bist. <br/> Geht dazu auf <a href="https://cloud.allmende-gufi.de" target="_blank">allmende.cloud</a> und melde dich an.
</div>
{% endif %}
<div class="row justify-content-center">
<div class="col">
<div class="card shadow-sm mt-4">
<div class="card-header bg-primary text-white">
<h1 class="fs-5 mb-0">Dauerhafte Anmeldung zu allen Kochabenden</h1>
</div>
<form action="/subscribe" method="POST">
<div class="card-body">
<p>
Mit einer dauerhaften Anmeldung kannst du dich/euch für alle zukünftigen Kochabende
anmelden. Es ist möglich
diese Anmeldung auf bestimmte Wochentage zu beschränken.
</p>
<p>
Dauerhafte Anmeldungen werden eine Woche vor einem Kochabend als Anmeldungen für diesen
Abend eingetragen. Danach
können sie auch noch gelöscht bzw. bearbeitet werden.
</p>
<!-- Info box about 7-day limitation -->
<div class="alert alert-info" role="alert">
<i class="bi bi-info-circle"></i>
<strong>Hinweis:</strong> Neu angelegte dauerhafte Anmeldungen werden erst nach einer Woche aktiv. Für Kochabende, die in weniger als 7 Tagen stattfinden, musst du dich noch separat anmelden.
</div>
<!-- Household selection -->
<div class="mb-3">
<select name="household" class="form-select" required>
<option value="" disabled selected hidden>Wer?</option>
{% for household in households %}
<option value="{{household.id}}">{{household.name}}</option>
{% endfor %}
</select>
</div>
<p class="text-muted">
Wenn dein Haushalt hier nicht auswählbar ist, besteht bereits eine dauerhafte Anmeldung.
Um Änderungen vorzunehmen, lösche die bestehende Anmeldung und lege eine neue an.
</p>
<!-- Person counts -->
<div class="row g-3 mb-3">
<div class="col">
<label for="InputAdults" class="form-label">Anzahl Erwachsene</label>
<input name="numAdults" id="InputAdults" type="number" class="form-control"
aria-label="Anzahl Erwachsene" min="0" step="1" inputmode="numeric">
</div>
<div class="col">
<label for="InputKids" class="form-label">Anzahl Kinder &gt;7</label>
<input name="numKids" id="InputKids" type="number" class="form-control"
aria-label="Anzahl Kinder &gt;7" min="0" step="1" inputmode="numeric">
</div>
<div class="col">
<label for="InputSmallKids" class="form-label">Anzahl Kinder &lt;7</label>
<input name="numSmallKids" id="InputSmallKids" type="number" class="form-control"
aria-label="Anzahl Kinder &lt;7" min="0" step="1" inputmode="numeric">
</div>
</div>
<!-- Days of the week -->
<div class="mb-3">
<label class="form-label">Wochentage auswählen (optional)</label>
<p class="text-muted small mb-2">
Wenn du nur an bestimmten Tagen teilnehmen möchtest, wähle sie hier aus.
</p>
<div class="d-flex flex-wrap gap-3">
{% set days = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag",
"Sonntag"] %}
{% for day in days %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="days" value="{{loop.index}}"
id="day-{{loop.index}}">
<label class="form-check-label" for="day-{{loop.index}}">
{{day}}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Footer -->
<div class="card-footer d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-primary">Dauerhaft anmelden</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Right column: existing registrations -->
<div class="col-12 col-lg-6 mt-4 mt-lg-0">
<p class="h4 m-2">Bestehende dauerhafte Anmeldungen</p>
{% if subscriptions | length == 0 %}
<p class="m-2">Es gibt noch keine dauerhaften Anmeldungen</p>
{% else %}
{% for sub in subscriptions %}
<div class="card mb-2">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<h6 class="mb-0">{{ sub.household.name }}</h6>
{% if user %}<a href="/subscribe/{{sub.household.id}}/delete" class="text-danger">
<i class="bi bi-trash"></i>
</a>{% endif %}
</div>
<div class="row g-2">
<div class="col-3 text-center">
<div class="text-muted" style="font-size: 0.7rem;">Erwachsene</div>
<div class="fw-bold small">{{ sub.num_adult_meals }}</div>
</div>
<div class="col-3 text-center">
<div class="text-muted" style="font-size: 0.7rem;">Kinder</div>
<div class="fw-bold small">{{ sub.num_children_meals }}</div>
</div>
<div class="col-3 text-center">
<div class="text-muted" style="font-size: 0.7rem;">Kleinkinder</div>
<div class="fw-bold small">{{ sub.num_small_children_meals }}</div>
</div>
<div class="col-3 text-center">
<div class="text-muted" style="font-size: 0.7rem;">Tage</div>
<div class="fw-bold small">{{ sub.day_string_de() }}</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

19
tests/conftest.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from meal_manager.models import Base
import pytest
@pytest.fixture
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine) # Create tables
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Provide a session and the engine
db = TestingSessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,56 @@
import datetime
from meal_manager.models import Event, Household, Subscription
from meal_manager.scripts import apply_subscriptions
def test_subscriptions(db_session):
now = datetime.datetime.now()
event1 = Event(
title="far future",
event_time=now + datetime.timedelta(days=8, hours=2),
registration_deadline=now + datetime.timedelta(days=3),
description="This event should not be proceesed.",
)
event2 = Event(
title="soon",
event_time=now + datetime.timedelta(days=7, hours=2),
registration_deadline=now + datetime.timedelta(days=2),
description="This event should be proceesed.",
)
ignore = Event(
title="ignore me",
event_time=now + datetime.timedelta(days=7, hours=2),
description=(
"This event should not be proceesed because it "
"has ignore_subscriptions set to True."
),
registration_deadline=now + datetime.timedelta(days=2, hours=2),
ignore_subscriptions=True,
)
db_session.add(event1)
db_session.add(event2)
db_session.add(ignore)
db_session.add(Household(name="Klaus", id=1))
db_session.add(
Subscription(
household_id=1,
num_adult_meals=2,
num_children_meals=1,
num_small_children_meals=0,
)
)
db_session.commit()
assert not event1.subscriptions_applied
assert not event2.subscriptions_applied
apply_subscriptions(db_session)
assert len(event1.registrations) == 0
assert len(event2.registrations) == 1
assert len(ignore.registrations) == 0
assert not event1.subscriptions_applied
assert not ignore.subscriptions_applied
assert event2.subscriptions_applied

View File

@@ -1,6 +1,20 @@
version = 1
revision = 3
requires-python = ">=3.13"
requires-python = "==3.13.*"
[[package]]
name = "alembic"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/45/6f4555f2039f364c3ce31399529dcf48dd60726ff3715ad67f547d87dfd2/alembic-1.17.0.tar.gz", hash = "sha256:4652a0b3e19616b57d652b82bfa5e38bf5dbea0813eed971612671cb9e90c0fe", size = 1975526, upload-time = "2025-10-11T18:40:13.585Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/1f/38e29b06bfed7818ebba1f84904afdc8153ef7b6c7e0d8f3bc6643f5989c/alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99", size = 247449, upload-time = "2025-10-11T18:40:16.288Z" },
]
[[package]]
name = "annotated-types"
@@ -53,6 +67,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
version = "8.2.1"
@@ -173,13 +212,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
[[package]]
@@ -243,6 +275,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "isort"
version = "6.0.1"
@@ -264,6 +305,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" },
]
[[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]]
name = "markdown-it-py"
version = "4.0.0"
@@ -314,39 +367,51 @@ wheels = [
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "new-registration-app"
version = "0.1.0"
source = { virtual = "." }
name = "meal-manager"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "fastapi", extra = ["standard"] },
{ name = "sqlmodel" },
{ name = "pygrister" },
{ name = "python-dotenv" },
{ name = "reportlab" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "black" },
{ name = "isort" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.17.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.116.0" },
{ name = "sqlmodel", specifier = ">=0.0.24" },
{ name = "pygrister", specifier = ">=0.8.0" },
{ name = "python-dotenv", specifier = ">=1.1.1" },
{ name = "reportlab", specifier = ">=4.4.4" },
{ name = "sqlalchemy", specifier = ">=2.0.44" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = ">=25.1.0" },
{ name = "isort", specifier = ">=6.0.1" },
{ name = "pytest", specifier = ">=8.4.2" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
@@ -367,6 +432,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]]
name = "pillow"
version = "11.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
{ url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
{ url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
{ url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
{ url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
{ url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
{ url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
{ url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
{ url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
{ url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
{ url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
{ url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
{ url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
{ url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
{ url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
{ url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
{ url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
{ url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
]
[[package]]
name = "platformdirs"
version = "4.4.0"
@@ -376,6 +474,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
@@ -433,6 +540,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pygrister"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/a2/804b3e63bce91fb0c7f093b6f3e522e476d9f3074cd3384d713f73fff78b/pygrister-0.8.0.tar.gz", hash = "sha256:4faaad23b27c9ae46dc7b321a0de376f3bfbdaa5faa7ffd769566105667cd478", size = 41472, upload-time = "2025-08-10T10:53:47.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/47/e43f2d8b88477a9b27b39d97e3c61534ae3cda4c99771f8b3c81d2469486/pygrister-0.8.0-py3-none-any.whl", hash = "sha256:b882a93db0aae642435d23f8e6f6a50f737befa35e3cce72f234bedd0ef4bee6", size = 31863, upload-time = "2025-08-10T10:53:46.345Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"
@@ -468,6 +604,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
]
[[package]]
name = "reportlab"
version = "4.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/fa/ed71f3e750afb77497641eb0194aeda069e271ce6d6931140f8787e0e69a/reportlab-4.4.4.tar.gz", hash = "sha256:cb2f658b7f4a15be2cc68f7203aa67faef67213edd4f2d4bdd3eb20dab75a80d", size = 3711935, upload-time = "2025-09-19T10:43:36.502Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/66/e040586fe6f9ae7f3a6986186653791fb865947f0b745290ee4ab026b834/reportlab-4.4.4-py3-none-any.whl", hash = "sha256:299b3b0534e7202bb94ed2ddcd7179b818dcda7de9d8518a57c85a58a1ebaadb", size = 1954981, upload-time = "2025-09-19T10:43:33.589Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rich"
version = "14.1.0"
@@ -523,8 +687,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/3a/7e7ea6f0d31d3f5beb0f2cf2c4c362672f5f7f125714458673fc579e2bed/rignore-0.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:91dc94b1cc5af8d6d25ce6edd29e7351830f19b0a03b75cb3adf1f76d00f3007", size = 1134598, upload-time = "2025-07-19T19:24:15.039Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/1b3307f6437d29bede5a95738aa89e6d910ba68d4054175c9f60d8e2c6b1/rignore-0.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4d1918221a249e5342b60fd5fa513bf3d6bf272a8738e66023799f0c82ecd788", size = 1108862, upload-time = "2025-07-19T19:24:26.765Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d5/b37c82519f335f2c472a63fc6215c6f4c51063ecf3166e3acf508011afbd/rignore-0.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:240777332b859dc89dcba59ab6e3f1e062bc8e862ffa3e5f456e93f7fd5cb415", size = 1120002, upload-time = "2025-07-19T19:24:38.952Z" },
{ url = "https://files.pythonhosted.org/packages/ac/72/2f05559ed5e69bdfdb56ea3982b48e6c0017c59f7241f7e1c5cae992b347/rignore-0.6.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b0e548753e55cc648f1e7b02d9f74285fe48bb49cec93643d31e563773ab3f", size = 949454, upload-time = "2025-07-19T19:23:38.664Z" },
{ url = "https://files.pythonhosted.org/packages/0b/92/186693c8f838d670510ac1dfb35afbe964320fbffb343ba18f3d24441941/rignore-0.6.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6971ac9fdd5a0bd299a181096f091c4f3fd286643adceba98eccc03c688a6637", size = 974663, upload-time = "2025-07-19T19:23:28.24Z" },
]
[[package]]
@@ -560,36 +722,23 @@ wheels = [
[[package]]
name = "sqlalchemy"
version = "2.0.43"
version = "2.0.44"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598, upload-time = "2025-08-11T15:51:15.903Z" },
{ url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415, upload-time = "2025-08-11T15:51:17.256Z" },
{ url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707, upload-time = "2025-08-11T15:52:38.444Z" },
{ url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602, upload-time = "2025-08-11T15:56:37.348Z" },
{ url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248, upload-time = "2025-08-11T15:52:39.865Z" },
{ url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363, upload-time = "2025-08-11T15:56:39.11Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718, upload-time = "2025-08-11T15:55:05.349Z" },
{ url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200, upload-time = "2025-08-11T15:55:07.932Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" },
]
[[package]]
name = "sqlmodel"
version = "0.0.24"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/4b/c2ad0496f5bdc6073d9b4cef52be9c04f2b37a5773441cc6600b1857648b/sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423", size = 116780, upload-time = "2025-03-07T05:43:32.887Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/91/484cd2d05569892b7fef7f5ceab3bc89fb0f8a8c0cde1030d383dbc5449c/sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193", size = 28622, upload-time = "2025-03-07T05:43:30.37Z" },
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
]
[[package]]
@@ -719,26 +868,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" },
{ url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" },
{ url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" },
{ url = "https://files.pythonhosted.org/packages/2c/00/70f75c47f05dea6fd30df90f047765f6fc2d6eb8b5a3921379b0b04defa2/watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297", size = 402114, upload-time = "2025-06-15T19:06:06.186Z" },
{ url = "https://files.pythonhosted.org/packages/53/03/acd69c48db4a1ed1de26b349d94077cca2238ff98fd64393f3e97484cae6/watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018", size = 393879, upload-time = "2025-06-15T19:06:07.369Z" },
{ url = "https://files.pythonhosted.org/packages/2f/c8/a9a2a6f9c8baa4eceae5887fecd421e1b7ce86802bcfc8b6a942e2add834/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0", size = 450026, upload-time = "2025-06-15T19:06:08.476Z" },
{ url = "https://files.pythonhosted.org/packages/fe/51/d572260d98388e6e2b967425c985e07d47ee6f62e6455cefb46a6e06eda5/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12", size = 457917, upload-time = "2025-06-15T19:06:09.988Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2d/4258e52917bf9f12909b6ec314ff9636276f3542f9d3807d143f27309104/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb", size = 483602, upload-time = "2025-06-15T19:06:11.088Z" },
{ url = "https://files.pythonhosted.org/packages/84/99/bee17a5f341a4345fe7b7972a475809af9e528deba056f8963d61ea49f75/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77", size = 596758, upload-time = "2025-06-15T19:06:12.197Z" },
{ url = "https://files.pythonhosted.org/packages/40/76/e4bec1d59b25b89d2b0716b41b461ed655a9a53c60dc78ad5771fda5b3e6/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92", size = 477601, upload-time = "2025-06-15T19:06:13.391Z" },
{ url = "https://files.pythonhosted.org/packages/1f/fa/a514292956f4a9ce3c567ec0c13cce427c158e9f272062685a8a727d08fc/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e", size = 451936, upload-time = "2025-06-15T19:06:14.656Z" },
{ url = "https://files.pythonhosted.org/packages/32/5d/c3bf927ec3bbeb4566984eba8dd7a8eb69569400f5509904545576741f88/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b", size = 626243, upload-time = "2025-06-15T19:06:16.232Z" },
{ url = "https://files.pythonhosted.org/packages/e6/65/6e12c042f1a68c556802a84d54bb06d35577c81e29fba14019562479159c/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259", size = 623073, upload-time = "2025-06-15T19:06:17.457Z" },
{ url = "https://files.pythonhosted.org/packages/89/ab/7f79d9bf57329e7cbb0a6fd4c7bd7d0cee1e4a8ef0041459f5409da3506c/watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f", size = 400872, upload-time = "2025-06-15T19:06:18.57Z" },
{ url = "https://files.pythonhosted.org/packages/df/d5/3f7bf9912798e9e6c516094db6b8932df53b223660c781ee37607030b6d3/watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e", size = 392877, upload-time = "2025-06-15T19:06:19.55Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c5/54ec7601a2798604e01c75294770dbee8150e81c6e471445d7601610b495/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa", size = 449645, upload-time = "2025-06-15T19:06:20.66Z" },
{ url = "https://files.pythonhosted.org/packages/0a/04/c2f44afc3b2fce21ca0b7802cbd37ed90a29874f96069ed30a36dfe57c2b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8", size = 457424, upload-time = "2025-06-15T19:06:21.712Z" },
{ url = "https://files.pythonhosted.org/packages/9f/b0/eec32cb6c14d248095261a04f290636da3df3119d4040ef91a4a50b29fa5/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f", size = 481584, upload-time = "2025-06-15T19:06:22.777Z" },
{ url = "https://files.pythonhosted.org/packages/d1/e2/ca4bb71c68a937d7145aa25709e4f5d68eb7698a25ce266e84b55d591bbd/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e", size = 596675, upload-time = "2025-06-15T19:06:24.226Z" },
{ url = "https://files.pythonhosted.org/packages/a1/dd/b0e4b7fb5acf783816bc950180a6cd7c6c1d2cf7e9372c0ea634e722712b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb", size = 477363, upload-time = "2025-06-15T19:06:25.42Z" },
{ url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240, upload-time = "2025-06-15T19:06:26.552Z" },
{ url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607, upload-time = "2025-06-15T19:06:27.606Z" },
{ url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" },
]
[[package]]