Compare commits

25 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
27 changed files with 1063 additions and 539 deletions

7
.gitignore vendored
View File

@@ -1,5 +1,8 @@
config.php config.php
test.php test.php
melly-to-grist/.env .env
**/__pycache__ **/__pycache__
new-registration-app/database.db 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

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,12 +1,15 @@
[project] [project]
name = "meal-manager" name = "meal-manager"
version = "0.1.0" version = "0.2.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = "~=3.13.0" requires-python = "~=3.13.0"
dependencies = [ dependencies = [
"alembic>=1.17.0", "alembic>=1.17.0",
"fastapi[standard]>=0.116.0", "fastapi[standard]>=0.116.0",
"pygrister>=0.8.0",
"python-dotenv>=1.1.1",
"reportlab>=4.4.4",
"sqlalchemy>=2.0.44", "sqlalchemy>=2.0.44",
"uvicorn[standard]>=0.35.0", "uvicorn[standard]>=0.35.0",
] ]
@@ -18,7 +21,13 @@ build-backend = "uv_build"
dev = [ dev = [
"black>=25.1.0", "black>=25.1.0",
"isort>=6.0.1", "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] [tool.isort]
profile = "black" profile = "black"
@@ -28,7 +37,7 @@ profile = "black"
# this is typically a path given in POSIX (e.g. forward slashes) # 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 # format, relative to the token %(here)s which refers to the location of this
# ini file # ini file
script_location = "%(here)s/alembic" script_location = "%(here)s/src/meal_manager/alembic"
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # 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 # Uncomment the line below if you want the files to be prepended with date and time

View File

@@ -84,7 +84,7 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint("household_id"), sa.PrimaryKeyConstraint("household_id"),
) )
op.create_table( op.create_table(
"team_registration", "teamregistration",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("event_id", sa.Integer(), nullable=False), sa.Column("event_id", sa.Integer(), nullable=False),
sa.Column("person_name", sa.String(), nullable=False), sa.Column("person_name", sa.String(), nullable=False),

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

View File

@@ -1,16 +1,20 @@
import locale import locale
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import partial
from typing import Annotated from typing import Annotated
from urllib.parse import quote_plus
import starlette.status as status import starlette.status as status
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import create_engine, select from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from meal_manager.grist import sync_with_grist
from meal_manager.models import ( from meal_manager.models import (
Base, Base,
Event, Event,
@@ -19,6 +23,7 @@ from meal_manager.models import (
Subscription, Subscription,
TeamRegistration, TeamRegistration,
) )
from meal_manager.pdf import build_dinner_overview_pdf
sqlite_file_name = "database.db" sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}" sqlite_url = f"sqlite:///{sqlite_file_name}"
@@ -34,6 +39,39 @@ def get_session():
yield 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(): def create_db_and_tables():
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
@@ -51,9 +89,12 @@ templates = Jinja2Templates(directory="src/meal_manager/templates")
SessionDep = Annotated[Session, Depends(get_session)] SessionDep = Annotated[Session, Depends(get_session)]
UserDep = Annotated[dict, Depends(get_user)]
StrictUserDep = Annotated[dict, Depends(partial(get_user, allow_none=False))]
@app.get("/") @app.get("/")
async def index(request: Request, session: SessionDep): async def index(request: Request, session: SessionDep, user: UserDep):
"""Displays coming events and a button to register new ones""" """Displays coming events and a button to register new ones"""
now = datetime.now() now = datetime.now()
# TODO: Once we refactored to use SQLAlchemy directly, we can probably do a nicer filtering on the date alone # TODO: Once we refactored to use SQLAlchemy directly, we can probably do a nicer filtering on the date alone
@@ -66,10 +107,15 @@ async def index(request: Request, session: SessionDep):
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="index.html", name="index.html",
context={"events": events, "current_page": "home", "now": now}, 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") @app.get("/past_events")
async def past_events(request: Request, session: SessionDep): async def past_events(request: Request, session: SessionDep):
now = datetime.now() now = datetime.now()
@@ -88,11 +134,11 @@ async def past_events(request: Request, session: SessionDep):
@app.get("/subscribe") @app.get("/subscribe")
async def subscribe(request: Request, session: SessionDep): async def subscribe(request: Request, session: SessionDep, user: UserDep):
statement = select(Household) statement = select(Household)
households = session.scalars(statement) households = session.scalars(statement)
subscriptions = session.scalars(select(Subscription)) subscriptions = session.scalars(select(Subscription)).all()
# filter out households with existing subscriptions # filter out households with existing subscriptions
households = [ households = [
@@ -102,7 +148,11 @@ async def subscribe(request: Request, session: SessionDep):
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="subscribe.html", name="subscribe.html",
context={"households": households, "subscriptions": subscriptions}, context={
"households": households,
"subscriptions": subscriptions,
"user": user,
},
) )
@@ -143,7 +193,9 @@ async def add_subscribe(request: Request, session: SessionDep):
@app.get("/subscribe/{household_id}/delete") @app.get("/subscribe/{household_id}/delete")
async def delete_subscription(request: Request, session: SessionDep, household_id: int): async def delete_subscription(
request: Request, session: SessionDep, household_id: int, user: StrictUserDep
):
statement = select(Subscription).where(Subscription.household_id == household_id) statement = select(Subscription).where(Subscription.household_id == household_id)
sub = session.scalars(statement).one() sub = session.scalars(statement).one()
@@ -155,14 +207,70 @@ async def delete_subscription(request: Request, session: SessionDep, household_i
@app.get("/event/add") @app.get("/event/add")
async def add_event_form(request: Request, session: SessionDep): async def add_event_form(request: Request, user: StrictUserDep):
return templates.TemplateResponse(request=request, name="add_event.html") 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") @app.post("/event/add")
async def add_event(request: Request, session: SessionDep): async def add_event(request: Request, session: SessionDep, user: StrictUserDep):
form_data = await request.form() 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"]) event_time = datetime.fromisoformat(form_data["eventTime"])
registration_deadline = form_data.get("registrationDeadline") registration_deadline = form_data.get("registrationDeadline")
if not registration_deadline: if not registration_deadline:
@@ -175,21 +283,32 @@ async def add_event(request: Request, session: SessionDep):
) )
else: else:
registration_deadline = datetime.fromisoformat(registration_deadline) registration_deadline = datetime.fromisoformat(registration_deadline)
return event_time, registration_deadline
event = Event(
title=form_data["eventName"], @app.get("/event/{event_id}/delete")
event_time=event_time, async def delete_event(
registration_deadline=registration_deadline, request: Request, session: SessionDep, event_id: int, user: StrictUserDep
description=form_data.get("eventDescription"), ):
recipe_link=form_data.get("recipeLink"), if not user["admin"]:
) raise HTTPException(status_code=403, detail="Not authorized")
session.add(event)
statement = select(Event).where(Event.id == event_id)
event = session.scalars(statement).one()
session.delete(event)
session.commit() session.commit()
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
@app.get("/event/{event_id}") @app.get("/event/{event_id}")
async def read_event(request: Request, event_id: int, session: SessionDep): 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) statement = select(Event).where(Event.id == event_id)
event = session.scalars(statement).one() event = session.scalars(statement).one()
@@ -206,7 +325,13 @@ async def read_event(request: Request, event_id: int, session: SessionDep):
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="event.html", name="event.html",
context={"event": event, "households": households, "now": datetime.now()}, context={
"event": event,
"households": households,
"now": datetime.now(),
"user": user,
"message": message,
},
) )
@@ -239,7 +364,11 @@ async def add_registration(request: Request, event_id: int, session: SessionDep)
@app.get("/event/{event_id}/registration/{household_id}/delete") @app.get("/event/{event_id}/registration/{household_id}/delete")
async def delete_registration( async def delete_registration(
request: Request, event_id: int, household_id: int, session: SessionDep 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 Deletes a registration record for a specific household at a given event. This endpoint
@@ -262,7 +391,9 @@ async def add_team_registration(request: Request, event_id: int, session: Sessio
work_type = form_data["workType"] work_type = form_data["workType"]
statement = select(TeamRegistration).where( statement = select(TeamRegistration).where(
TeamRegistration.person_name == person, TeamRegistration.work_type == work_type 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 the person has already registered for the same work type, just ignore
if session.scalars(statement).one_or_none() is None: if session.scalars(statement).one_or_none() is None:
@@ -282,8 +413,52 @@ async def delete_team_registration(
event_id: int, event_id: int,
entry_id: int, entry_id: int,
session: SessionDep, session: SessionDep,
user: StrictUserDep,
): ):
statement = select(TeamRegistration).where(TeamRegistration.id == entry_id) statement = select(TeamRegistration).where(TeamRegistration.id == entry_id)
session.delete(session.scalars(statement).one()) session.delete(session.scalars(statement).one())
session.commit() session.commit()
return RedirectResponse(url=f"/event/{event_id}", status_code=status.HTTP_302_FOUND) 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,
)

View File

@@ -1,5 +1,5 @@
import typing import typing
from datetime import datetime from datetime import date, datetime
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -19,8 +19,8 @@ class Event(Base):
title: Mapped[str] = mapped_column(nullable=False) title: Mapped[str] = mapped_column(nullable=False)
event_time: Mapped[datetime] = mapped_column(nullable=False) event_time: Mapped[datetime] = mapped_column(nullable=False)
registration_deadline: Mapped[datetime] = mapped_column(nullable=False) registration_deadline: Mapped[datetime] = mapped_column(nullable=False)
description: Mapped[str] = mapped_column() description: Mapped[str] = mapped_column(nullable=True)
recipe_link: Mapped[str] = mapped_column() recipe_link: Mapped[str] = mapped_column(nullable=True)
# Min and max number of people needed for cooking, doing the dishes and preparing the tables # 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_min: Mapped[int] = mapped_column(default=3, nullable=False)
@@ -33,8 +33,20 @@ class Event(Base):
team_prep_min: Mapped[int] = mapped_column(default=1, nullable=False) team_prep_min: Mapped[int] = mapped_column(default=1, nullable=False)
team_prep_max: Mapped[int] = mapped_column(default=1, nullable=False) team_prep_max: Mapped[int] = mapped_column(default=1, nullable=False)
registrations: Mapped[list["Registration"]] = relationship("Registration") subscriptions_applied: Mapped[bool] = mapped_column(default=False, nullable=False)
team: Mapped[list["TeamRegistration"]] = relationship("TeamRegistration") 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): def team_min_reached(self, work_type: WorkTypes):
threshold = { threshold = {
@@ -62,9 +74,17 @@ class Event(Base):
self.team_max_reached(work_type) for work_type in typing.get_args(WorkTypes) 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): class TeamRegistration(Base):
__tablename__ = "team_registration" __tablename__ = "teamregistration"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
event_id: Mapped[int] = mapped_column(ForeignKey("event.id")) event_id: Mapped[int] = mapped_column(ForeignKey("event.id"))
person_name: Mapped[str] = mapped_column(nullable=False) person_name: Mapped[str] = mapped_column(nullable=False)
@@ -141,3 +161,15 @@ class Subscription(Base):
return ", ".join(result) return ", ".join(result)
else: else:
return "Alle" 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,2 @@
User-agent: *
Disallow: /

View File

@@ -3,35 +3,57 @@
{% block content %} {% block content %}
<div class="row justify-content-center mt-4"> <div class="row justify-content-center mt-4">
<div class="col-md-8"> <div class="col-md-8">
<h2>Neues Event erstellen</h2> {% if edit_mode %}<h2>Event bearbeiten</h2>{% else %}<h2>Neues Event erstellen</h2>{% endif %}
<form method="post" action="/event/add"> {% if edit_mode %}<form method="post" action="/event/{{ event.id }}/edit">{% else %}<form method="post" action="/event/add">{% endif %}
<div class="mb-3"> <div class="mb-3">
<label for="eventName" class="form-label">Event Name</label> <label for="eventName" class="form-label">Event Name</label>
<input type="text" class="form-control" id="eventName" name="eventName" required> <input type="text" class="form-control" id="eventName" name="eventName" required {% if edit_mode %}value="{{ event.title }}"{% endif %}>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="eventTime" class="form-label">Datum und Uhrzeit</label> <label for="eventTime" class="form-label">Datum und Uhrzeit</label>
<input type="datetime-local" class="form-control" id="eventTime" name="eventTime" required> <input type="datetime-local" class="form-control" id="eventTime" name="eventTime" required {% if edit_mode %}value="{{ event.event_time }}"{% endif %}>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="registrationDeadline" class="form-label">Anmeldungs-Deadline</label> <label for="registrationDeadline" class="form-label">Anmeldungs-Deadline</label>
<input type="datetime-local" class="form-control" id="registrationDeadline" name="registrationDeadline"> <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> <small class="form-text text-muted">Leer lassen für Sonntag Abend vor dem Event</small>
</div> </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"> <div class="mb-3">
<label for="recipeLink" class="form-label">Rezept-Link</label> <label for="recipeLink" class="form-label">Rezept-Link</label>
<input type="text" class="form-control" id="recipeLink" name="recipeLink"> <input type="text" class="form-control" id="recipeLink" name="recipeLink" {% if edit_mode %}value="{{ event.recipe_link }}"{% endif %}>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="eventDescription" class="form-label">Beschreibung</label> <label for="eventDescription" class="form-label">Beschreibung</label>
<textarea class="form-control" id="eventDescription" name="eventDescription" rows="3"></textarea> <textarea class="form-control" id="eventDescription" name="eventDescription" rows="3" {% if edit_mode %}value="{{ event.description }}"{% endif %}></textarea>
</div> </div>
<button type="submit" class="btn btn-primary">Event erstellen</button> <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> </form>
</div> </div>
</div> </div>

View File

@@ -1,12 +1,15 @@
{% import "macros.j2" as macros %}
{% macro teamEntries(event, work_type) -%} {% macro teamEntries(event, work_type) -%}
{% for entry in event.team | selectattr("work_type", "equalto", 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"> <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> <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"> <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"> <a href="/event/{{event.id}}/register_team/{{entry.id}}/delete">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</a> </a>
</button> </button>
{% endif -%}
</div> </div>
{% endfor %} {% endfor %}
{%- endmacro %} {%- endmacro %}
@@ -15,24 +18,73 @@
{% block content %} {% block content %}
<p class="h1">{{ event.title }}</p> <p class="h1">{{ event.title }}</p>
<p class="text-muted">{{ event.event_time.strftime('%A, %d.%m.%Y') }}</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> <p>{{ event.description }}</p>
<hr class="hr"/> <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"> <div class="container">
<p class="h3">Anmeldungen</p> <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="row m-2">
<div class="col-md-4 d-flex justify-content-center align-items-center flex-column"> <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> <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 trigger modal -->
<button type="button" class="btn btn-primary mb-2 w-100" {% if event.registration_deadline < now %}disabled{% endif%} data-bs-toggle="modal" data-bs-target="#registration"> <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 Anmeldung hinzufügen
</button> </button>
<button type="button" class="btn btn-primary mb-2 w-100" {% if event.all_teams_max() or event.event_time < now %}disabled{% endif %} data-bs-toggle="modal" data-bs-target="#teamRegistration"> <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 Dienst übernehmen
</button> </button>
{% if event.recipe_link %} {% if event.recipe_link %}
<a href="{{ event.recipe_link }}" class="btn btn-outline-primary mb-2 w-100" target="_blank"> <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 <i class="bi bi-book"></i> Original Rezept ansehen
</a> </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 %} {% endif %}
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
@@ -105,7 +157,7 @@
<th scope="col">Kinder</th> <th scope="col">Kinder</th>
<th scope="col">Kleinkinder</th> <th scope="col">Kleinkinder</th>
<th scope="col">Kommentar</th> <th scope="col">Kommentar</th>
<th scope="col">Löschen</th> {% if user %}<th scope="col">Löschen</th>{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -115,8 +167,8 @@
<td>{{ reg.num_adult_meals }}</td> <td>{{ reg.num_adult_meals }}</td>
<td>{{ reg.num_children_meals }}</td> <td>{{ reg.num_children_meals }}</td>
<td>{{ reg.num_small_children_meals }}</td> <td>{{ reg.num_small_children_meals }}</td>
<td>{{ reg.comment }}</td> <td>{% if reg.comment %}{{ reg.comment }}{% endif %}</td>
<td><a href="/event/{{event.id}}/registration/{{reg.household_id}}/delete"><i class="bi bi-trash"></i></a></td> {% if user %}<td><a href="/event/{{event.id}}/registration/{{reg.household_id}}/delete"><i class="bi bi-trash"></i></a></td>{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -130,9 +182,9 @@
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2"> <div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">{{ reg.household.name }}</h5> <h5 class="card-title mb-0">{{ reg.household.name }}</h5>
<a href="/event/{{event.id}}/registration/{{reg.household_id}}/delete" class="text-danger"> {% if user %}<a href="/event/{{event.id}}/registration/{{reg.household_id}}/delete" class="text-danger">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</a> </a>{% endif -%}
</div> </div>
<div class="row mt-3"> <div class="row mt-3">
<div class="col-3 text-center"> <div class="col-3 text-center">
@@ -254,4 +306,31 @@
</div> </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 %} {% endblock %}

View File

@@ -1,11 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import "macros.j2" as macros %}
{% block content %} {% block content %}
<div class="row mt-4 mb-3"> <div class="row mt-4 mb-3">
<div class="col d-flex justify-content-between align-items-center"> <div class="col d-flex justify-content-between align-items-center">
<h2>{% if current_page == "home" %}Kommende{% else %}Vergangene{% endif %} Kochabende</h2> <h2>{% if current_page == "home" %}Kommende{% else %}Vergangene{% endif %} Kochabende</h2>
{% if user %}
<a href="/event/add" class="btn btn-primary"> <a href="/event/add" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Neues Event erstellen <i class="bi bi-plus-circle"></i> Neues Event erstellen
</a> </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> </div>
<div class="mb-4"> <div class="mb-4">
@@ -26,18 +33,28 @@
{% for event in events %} {% for event in events %}
<div class="col"> <div class="col">
<div class="card h-100 shadow-sm"> <div class="card h-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<h5 class="card-title mb-1">{{ event.title }}</h5> <div class="d-flex justify-content-between align-items-start mb-2">
<p class="text-muted mb-3"><i class="bi bi-calendar"></i> {{ event.event_time.strftime('%A, %d.%m.%Y') <div>
}} <h5 class="card-title mb-0">
</p> {{ event.title }}
<p class="card-text">{{ event.description }}</p> {% if event.billed %}<i class="bi bi-cash-coin" title="Abgerechnet"></i>{% endif %}
<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> {% if event.exclude_from_billing %}<i class="bi bi-ban" title="Keine Abrechung"></i>{% endif %}
</h5>
</div> </div>
</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>
</div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Modal -->
{{ macros.login_info_modal() }}
{% endblock %} {% 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

@@ -4,6 +4,12 @@
<div class="row mt-4"> <div class="row mt-4">
<!-- Left column: subscription form --> <!-- Left column: subscription form -->
<div class="col-12 col-lg-6"> <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="row justify-content-center">
<div class="col"> <div class="col">
<div class="card shadow-sm mt-4"> <div class="card shadow-sm mt-4">
@@ -106,9 +112,9 @@
<div class="card-body py-2 px-3"> <div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<h6 class="mb-0">{{ sub.household.name }}</h6> <h6 class="mb-0">{{ sub.household.name }}</h6>
<a href="/subscribe/{{sub.household.id}}/delete" class="text-danger"> {% if user %}<a href="/subscribe/{{sub.household.id}}/delete" class="text-danger">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</a> </a>{% endif %}
</div> </div>
<div class="row g-2"> <div class="row g-2">
<div class="col-3 text-center"> <div class="col-3 text-center">

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

143
uv.lock generated
View File

@@ -67,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" }, { 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]] [[package]]
name = "click" name = "click"
version = "8.2.1" version = "8.2.1"
@@ -250,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" }, { 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]] [[package]]
name = "isort" name = "isort"
version = "6.0.1" version = "6.0.1"
@@ -334,11 +368,14 @@ wheels = [
[[package]] [[package]]
name = "meal-manager" name = "meal-manager"
version = "0.1.0" version = "0.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "pygrister" },
{ name = "python-dotenv" },
{ name = "reportlab" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@@ -347,12 +384,16 @@ dependencies = [
dev = [ dev = [
{ name = "black" }, { name = "black" },
{ name = "isort" }, { name = "isort" },
{ name = "pytest" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.17.0" }, { name = "alembic", specifier = ">=1.17.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.116.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.116.0" },
{ 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 = "sqlalchemy", specifier = ">=2.0.44" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" },
] ]
@@ -361,6 +402,7 @@ requires-dist = [
dev = [ dev = [
{ name = "black", specifier = ">=25.1.0" }, { name = "black", specifier = ">=25.1.0" },
{ name = "isort", specifier = ">=6.0.1" }, { name = "isort", specifier = ">=6.0.1" },
{ name = "pytest", specifier = ">=8.4.2" },
] ]
[[package]] [[package]]
@@ -390,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" }, { 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]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.4.0" version = "4.4.0"
@@ -399,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" }, { 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]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.11.7" version = "2.11.7"
@@ -456,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" }, { 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]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.1.1" version = "1.1.1"
@@ -491,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" }, { 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]] [[package]]
name = "rich" name = "rich"
version = "14.1.0" version = "14.1.0"