Compare commits

..

6 Commits

14 changed files with 289 additions and 34 deletions

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ test.php
**/__pycache__ **/__pycache__
database.db* database.db*
.idea .idea
db_fixtures db_fixtures
backups

View File

@@ -1,6 +1,6 @@
[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"
@@ -21,11 +21,12 @@ 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] [project.scripts]
apply-subscriptions = "meal_manager.scripts:apply_subscriptions_cli" 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"

View File

@@ -22,7 +22,19 @@ def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column( op.add_column(
"event", sa.Column("subscriptions_applied", sa.Boolean(), nullable=False) "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 ### # ### end Alembic commands ###
@@ -31,4 +43,5 @@ def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_column("event", "subscriptions_applied") op.drop_column("event", "subscriptions_applied")
op.drop_column("event", "ignore_subscriptions")
# ### end Alembic commands ### # ### 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 ###

View File

@@ -14,7 +14,11 @@ config = {
} }
def sync_with_grist(event): 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) grist = GristApi(config=config)
status_code, grist_response = grist.list_records( status_code, grist_response = grist.list_records(
@@ -53,4 +57,8 @@ def sync_with_grist(event):
"meal_manager_event_id": event.id, "meal_manager_event_id": event.id,
} }
) )
grist.add_records("Transactions", new_records) if new_records:
grist.add_records("Transactions", new_records)
return len(new_records)
else:
return 0

View File

@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import partial 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, HTTPException, Request, Response from fastapi import Depends, FastAPI, HTTPException, Request, Response
@@ -63,7 +64,7 @@ def get_user(request: Request, allow_none: bool = True) -> dict | None:
return { return {
"username": request.headers["ynh_user"], "username": request.headers["ynh_user"],
# TODO: This should obviously be replaced with a role based check # TODO: This should obviously be replaced with a role based check
"admin": request.headers["ynh_user"] == "niklas.m", "admin": request.headers["ynh_user"] in ["niklas.m", "martin.k"]
} }
if allow_none: if allow_none:
return None return None
@@ -239,6 +240,9 @@ async def edit_event(
event.registration_deadline = registration_deadline event.registration_deadline = registration_deadline
event.description = form_data.get("eventDescription") event.description = form_data.get("eventDescription")
event.recipe_link = form_data.get("recipeLink") 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() session.commit()
@@ -257,6 +261,9 @@ async def add_event(request: Request, session: SessionDep, user: StrictUserDep):
registration_deadline=registration_deadline, registration_deadline=registration_deadline,
description=form_data.get("eventDescription"), description=form_data.get("eventDescription"),
recipe_link=form_data.get("recipeLink"), 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.add(event)
session.commit() session.commit()
@@ -444,9 +451,14 @@ def sync_with_grist_route(event_id: int, session: SessionDep, user: StrictUserDe
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()
sync_with_grist(event) 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( return RedirectResponse(
url=f"/event/{event_id}?message=Erfolgreich%20an%20Abrechnung%20%C3%BCbertragen", url=f"/event/{event_id}?message={quote_plus(message)}",
status_code=status.HTTP_302_FOUND, status_code=status.HTTP_302_FOUND,
) )

View File

@@ -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)
@@ -34,6 +34,12 @@ class Event(Base):
team_prep_max: Mapped[int] = mapped_column(default=1, nullable=False) team_prep_max: Mapped[int] = mapped_column(default=1, nullable=False)
subscriptions_applied: Mapped[bool] = mapped_column(default=False, nullable=False) subscriptions_applied: Mapped[bool] = mapped_column(default=False, nullable=False)
ignore_subscriptions: Mapped[bool] = mapped_column(default=False, nullable=False)
billed: Mapped[bool] = mapped_column(default=False, nullable=False)
exclude_from_billing: Mapped[bool] = mapped_column(default=False, nullable=False)
organizer_name: Mapped[str] = mapped_column(nullable=True)
registrations: Mapped[list["Registration"]] = relationship( registrations: Mapped[list["Registration"]] = relationship(
"Registration", cascade="all, delete" "Registration", cascade="all, delete"

View File

@@ -1,13 +1,36 @@
import argparse import argparse
import datetime import datetime
import os
import shutil
from pathlib import Path
from sqlalchemy import Date, cast, func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from meal_manager.main import engine from meal_manager.main import engine, sqlite_file_name
from meal_manager.models import Event, Registration, Subscription 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): def apply_subscriptions(session: Session, event: Event = None, dry_run: bool = False):
subscriptions = session.scalars(select(Subscription)).all() subscriptions = session.scalars(select(Subscription)).all()
@@ -18,9 +41,10 @@ def apply_subscriptions(session: Session, event: Event = None, dry_run: bool = F
today = datetime.date.today() today = datetime.date.today()
query = select(Event).where( query = select(Event).where(
~Event.subscriptions_applied, ~Event.subscriptions_applied,
func.strftime("%Y-%m-%d %H:%M:%S", Event.event_time) >= today.isoformat(), ~Event.ignore_subscriptions,
func.strftime("%Y-%m-%d %H:%M:%S", Event.event_time) func.strftime("%Y-%m-%d", Event.event_time) >= today.strftime("%Y-%m-%d"),
<= (today + datetime.timedelta(days=7)).isoformat(), func.strftime("%Y-%m-%d", Event.event_time)
<= (today + datetime.timedelta(days=7)).strftime("%Y-%m-%d"),
) )
events = session.scalars(query).all() events = session.scalars(query).all()

View File

@@ -9,7 +9,7 @@
<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 {% if edit_mode %}value="{{ event.title }}"{% endif %}> <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 {% if edit_mode %}value="{{ event.event_time }}"{% endif %}> <input type="datetime-local" class="form-control" id="eventTime" name="eventTime" required {% if edit_mode %}value="{{ event.event_time }}"{% endif %}>
@@ -20,17 +20,39 @@
<input type="datetime-local" class="form-control" id="registrationDeadline" name="registrationDeadline" {% if edit_mode %}value="{{ event.registration_deadline }}"{% endif %}> <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" {% if edit_mode %}value="{{ event.recipe_link }}"{% endif %}> <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" {% if edit_mode %}value="{{ event.description }}"{% endif %}></textarea> <textarea class="form-control" id="eventDescription" name="eventDescription" rows="3" {% if edit_mode %}value="{{ event.description }}"{% endif %}></textarea>
</div> </div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="ignoreSubscriptions" name="ignoreSubscriptions" {% if (edit_mode and event.ignore_subscriptions) or not edit_mode %}checked{% endif %}>
<label class="form-check-label" for="ignoreSubscriptions">Dauerhafte Anmeldung ignorieren</label>
<small class="form-text text-muted">
Aktivieren, um dauerhafte Anmeldungen für dieses Event zu ignorieren. Das sollte für alle Events getan werden, die keine offiziellen AG Kochen Kochabende sind.
</small>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="excludeFromBilling" name="excludeFromBilling" {% if edit_mode and event.exclude_from_billing %}checked{% endif %}>
<label class="form-check-label" for="excludeFromBilling">Keine Abrechnung</label>
<small class="form-text text-muted">
Aktivieren, um dieses Event von der Abrechnung auszuschließen.
</small>
</div>
<button type="submit" class="btn btn-primary">Event {% if edit_mode %}bearbeiten{% else %}erstellen{% endif %}</button> <button type="submit" class="btn btn-primary">Event {% if edit_mode %}bearbeiten{% else %}erstellen{% endif %}</button>
</form> </form>
</div> </div>

View File

@@ -18,6 +18,7 @@
{% 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 %} {% if message %}
@@ -28,7 +29,7 @@
</div> </div>
{% endif %} {% endif %}
<div class="container"> <div class="container">
<p class="h3">Anmeldungen</p> <p class="h3">Anmeldungen</p>
{% if not user %} {% if not user %}
<div class="alert alert-warning m-2"> <div class="alert alert-warning m-2">
@@ -59,7 +60,7 @@
</div> </div>
<div class="col-6 p-1"> <div class="col-6 p-1">
{% if user -%} {% if user -%}
<a href="/event/{{event.id}}/edit" class="btn btn-secondary w-100" target="_blank"> <a href="/event/{{event.id}}/edit" class="btn btn-secondary w-100">
<i class="bi bi-pen m-2"></i> Event bearbeiten <i class="bi bi-pen m-2"></i> Event bearbeiten
</a> </a>
{% else -%} {% else -%}
@@ -77,7 +78,7 @@
</button> </button>
</div> </div>
<div class="col-6 p-1"> <div class="col-6 p-1">
<a href="/event/{{event.id}}/sync_with_grist" class="btn btn-secondary w-100"> <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 <i class="bi bi-cash-coin m-2"></i> Abrechnen
</a> </a>
</div> </div>

View File

@@ -33,18 +33,25 @@
{% 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>

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

38
uv.lock generated
View File

@@ -275,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"
@@ -359,7 +368,7 @@ 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" },
@@ -375,6 +384,7 @@ dependencies = [
dev = [ dev = [
{ name = "black" }, { name = "black" },
{ name = "isort" }, { name = "isort" },
{ name = "pytest" },
] ]
[package.metadata] [package.metadata]
@@ -392,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]]
@@ -463,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"
@@ -533,6 +553,22 @@ 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" }, { 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"