From 24f3444f3f40d76cb4d6352486618d98f854ffdc Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Wed, 10 Jun 2026 05:51:01 +0200 Subject: [PATCH 01/31] Add MSSQL support: ODBC driver, pyodbc, sa.false()/sa.true() fix, dev compose - Dockerfile: install ODBC Driver 18 + libgssapi-krb5-2 - requirements: add pyodbc - Replace .is_(False)/.is_(True) with == sa.false()/sa.true() (61 occurrences) MSSQL only supports IS NULL/IS NOT NULL, not IS - Add docker-compose.mssql-dev.yml for local dev with MSSQL 2022 - Add mssql-init/setup.sh for database/user creation --- Dockerfile | 10 + app/digest/blocks/new_users.py | 2 +- app/digest/blocks/upcoming_events.py | 2 +- app/queries.py | 15 +- app/routes/admin.py | 7 +- app/routes/admin_digest.py | 2 +- app/routes/calendar.py | 4 +- app/routes/events/_helpers.py | 5 +- app/routes/events/crud.py | 19 +- app/routes/events/equipment.py | 3 +- app/routes/events/spots.py | 5 +- app/routes/events/transitions.py | 9 +- app/routes/import_events.py | 5 +- app/routes/main.py | 11 +- app/routes/master_events.py | 9 +- app/routes/notifications.py | 5 +- app/routes/qualifications.py | 11 +- app/routes/setup.py | 3 +- app/routes/templates.py | 7 +- app/routes/users.py | 11 +- app/scheduler_tasks.py | 4 +- app/work_report_generator.py | 2 +- docker-compose.mssql-dev.yml | 103 +++++ mssql-init/setup.sh | 69 +++ requirements-dev.txt | 633 +++++++++++++++------------ requirements-e2e.txt | 6 +- requirements.in | 1 + requirements.txt | 189 +++++--- tests/test_rp_logic.py | 4 +- 29 files changed, 742 insertions(+), 414 deletions(-) create mode 100644 docker-compose.mssql-dev.yml create mode 100755 mssql-init/setup.sh diff --git a/Dockerfile b/Dockerfile index 3f7a3ec5..b6c496f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,16 @@ FROM python:3.14-slim WORKDIR /app +# Install Microsoft ODBC Driver 18 for SQL Server +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl gnupg2 \ + && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ + && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" > /etc/apt/sources.list.d/mssql-release.list \ + && apt-get update \ + && ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 unixodbc-dev libgssapi-krb5-2 \ + && apt-get purge -y --auto-remove curl gnupg2 \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir --require-hashes -r requirements.txt diff --git a/app/digest/blocks/new_users.py b/app/digest/blocks/new_users.py index 2f121cbf..6a876f5d 100644 --- a/app/digest/blocks/new_users.py +++ b/app/digest/blocks/new_users.py @@ -30,7 +30,7 @@ def collect(self, db_session: Any, config: dict[str, Any]) -> dict[str, Any]: q = sa.select(UserAccount).where(UserAccount.created_at >= since) if pending_only: - q = q.where(UserAccount.is_active.is_(False)) + q = q.where(UserAccount.is_active == sa.false()) q = q.order_by(UserAccount.created_at.desc()).limit(max_rows) users = db_session.scalars(q).all() diff --git a/app/digest/blocks/upcoming_events.py b/app/digest/blocks/upcoming_events.py index cac77de9..72507011 100644 --- a/app/digest/blocks/upcoming_events.py +++ b/app/digest/blocks/upcoming_events.py @@ -41,7 +41,7 @@ def collect(self, db_session: Any, config: dict[str, Any]) -> dict[str, Any]: EventStatus.ASSIGNMENTS_CLOSED, ] ), - Event.archived.is_(False), + Event.archived == sa.false(), ) .order_by(Event.start_datetime.asc()) .limit(max_rows) diff --git a/app/queries.py b/app/queries.py index 86e859e3..04c4c1a7 100644 --- a/app/queries.py +++ b/app/queries.py @@ -7,6 +7,7 @@ from collections.abc import Sequence +import sqlalchemy as sa from sqlalchemy import collate from app.extensions import db @@ -23,8 +24,8 @@ def active_users_query(): # type: ignore[no-untyped-def] """ return ( db.select(UserAccount) - .where(UserAccount.is_active.is_(True)) - .where(UserAccount.is_archived.is_(False)) + .where(UserAccount.is_active == sa.true()) + .where(UserAccount.is_archived == sa.false()) .order_by(collate(UserAccount.name, CS_COLLATION)) ) @@ -45,9 +46,9 @@ def rp_eligible_users_list() -> list[UserAccount]: .join(uq_table, UserAccount.id == uq_table.c.user_id) .join(Qualification, Qualification.id == uq_table.c.qualification_id) .where( - UserAccount.is_active.is_(True), - UserAccount.is_archived.is_(False), - Qualification.can_be_rp.is_(True), + UserAccount.is_active == sa.true(), + UserAccount.is_archived == sa.false(), + Qualification.can_be_rp == sa.true(), ) .distinct() .subquery() @@ -64,7 +65,7 @@ def active_master_events_list() -> Sequence[MasterEvent]: """Return non-archived master events ordered (general first, then by name).""" return db.session.scalars( db.select(MasterEvent) - .where(MasterEvent.archived.is_(False)) + .where(MasterEvent.archived == sa.false()) .order_by(MasterEvent.is_general.desc(), collate(MasterEvent.name, CS_COLLATION)) ).all() @@ -84,7 +85,7 @@ def user_fillable_qual_ids(user: UserAccount) -> set[int]: from app.models.qualification import Qualification # pylint: disable=import-outside-toplevel all_quals: list[Qualification] = list( - db.session.scalars(db.select(Qualification).where(Qualification.is_deleted.is_(False))).all() + db.session.scalars(db.select(Qualification).where(Qualification.is_deleted == sa.false())).all() ) user_qual_ids = {q.id for q in user.qualifications if not q.is_deleted} diff --git a/app/routes/admin.py b/app/routes/admin.py index 3d506130..dbd59879 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -2,6 +2,7 @@ import time from datetime import datetime, timedelta, timezone +import sqlalchemy as sa from flask import Blueprint, render_template, request from flask_login import login_required from sqlalchemy import collate @@ -79,12 +80,12 @@ def _admin_statistics(now: datetime) -> dict: from app.models.outbox import OutboxEmail # pylint: disable=import-outside-toplevel user_total = db.session.scalar( - db.select(db.func.count()).select_from(UserAccount).where(UserAccount.is_archived.is_(False)) + db.select(db.func.count()).select_from(UserAccount).where(UserAccount.is_archived == sa.false()) ) user_active = db.session.scalar( db.select(db.func.count()) .select_from(UserAccount) - .where(UserAccount.is_active.is_(True), UserAccount.is_archived.is_(False)) + .where(UserAccount.is_active == sa.true(), UserAccount.is_archived == sa.false()) ) event_counts = { @@ -122,7 +123,7 @@ def _admin_statistics(now: datetime) -> dict: "user_active": user_active, "user_pending": user_total - user_active, "user_archived": db.session.scalar( - db.select(db.func.count()).select_from(UserAccount).where(UserAccount.is_archived.is_(True)) + db.select(db.func.count()).select_from(UserAccount).where(UserAccount.is_archived == sa.true()) ), "event_total": sum(event_counts.values()), "event_counts": event_counts, diff --git a/app/routes/admin_digest.py b/app/routes/admin_digest.py index d0614506..0534c60c 100644 --- a/app/routes/admin_digest.py +++ b/app/routes/admin_digest.py @@ -361,7 +361,7 @@ def send_now() -> Response: html = render_digest(db.session) recipients = db.session.scalars( - sa.select(UserAccount).where(UserAccount.is_active.is_(True)).where(UserAccount.is_archived.is_(False)) + sa.select(UserAccount).where(UserAccount.is_active == sa.true()).where(UserAccount.is_archived == sa.false()) ).all() count = 0 diff --git a/app/routes/calendar.py b/app/routes/calendar.py index d93a9113..75e69837 100644 --- a/app/routes/calendar.py +++ b/app/routes/calendar.py @@ -75,7 +75,7 @@ def feed(token: str) -> Response: .where( Assignment.user_id == user.id, Event.status.notin_(_PERSONAL_EXCLUDED_STATUSES), - Event.archived.is_(False), + Event.archived == sa.false(), ) .options(selectinload(Assignment.spot).selectinload(EventSpot.event)) # type: ignore[arg-type] ).all() @@ -125,7 +125,7 @@ def feed_all(token: str) -> Response: events = db.session.scalars( sa.select(Event) .where( - Event.archived.is_(False), + Event.archived == sa.false(), Event.status != EventStatus.CANCELLED, ) .options( diff --git a/app/routes/events/_helpers.py b/app/routes/events/_helpers.py index beec205d..40c38bd4 100644 --- a/app/routes/events/_helpers.py +++ b/app/routes/events/_helpers.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone +import sqlalchemy as sa from flask import url_for from flask_login import current_user @@ -197,7 +198,7 @@ def build_spots(event: Event, form: dict) -> None: qual_ids = [int(c) for c in form.getlist(f"spot_cred_{i}") if str(c).isdigit()] qualifications = ( db.session.scalars( - db.select(Qualification).where(Qualification.id.in_(qual_ids), Qualification.is_deleted.is_(False)) + db.select(Qualification).where(Qualification.id.in_(qual_ids), Qualification.is_deleted == sa.false()) ).all() if qual_ids else [] @@ -272,7 +273,7 @@ def equipment_warnings_for_event(event: Event) -> list[dict]: EventEquipmentAssignment.equipment_item_id.in_(available_ids), EventEquipmentAssignment.event_id != event.id, Event.status != EventStatus.CANCELLED, - Event.archived.is_(False), + Event.archived == sa.false(), Event.start_datetime < event.end_datetime, Event.end_datetime > event.start_datetime, ) diff --git a/app/routes/events/crud.py b/app/routes/events/crud.py index 2a41abda..3d61149f 100644 --- a/app/routes/events/crud.py +++ b/app/routes/events/crud.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone +import sqlalchemy as sa from flask import Response, abort, flash, jsonify, redirect, render_template, request, url_for from flask_login import current_user, login_required from sqlalchemy import case, collate, func @@ -111,7 +112,7 @@ def _apply_index_order( return query.order_by(Event.status.asc() if _asc else Event.status.desc()) if sort_col == "me_name": me_name_expr = ( - db.select(case((MasterEvent.is_general.is_(True), None), else_=MasterEvent.name)) + db.select(case((MasterEvent.is_general == sa.true(), None), else_=MasterEvent.name)) .where(MasterEvent.id == Event.master_event_id) .correlate(Event) .scalar_subquery() @@ -121,7 +122,7 @@ def _apply_index_order( if sort_col == "total": spot_count_sq = ( db.select(func.count(EventSpot.id)) - .where(EventSpot.event_id == Event.id, EventSpot.is_optional.is_(False)) + .where(EventSpot.event_id == Event.id, EventSpot.is_optional == sa.false()) .correlate(Event) .scalar_subquery() ) @@ -181,7 +182,7 @@ def index() -> str: if not current_user.has_permission("event.view_draft"): query = query.where(Event.status != EventStatus.DRAFT) if not f["show_archived"]: - query = query.where(Event.archived.is_(False)) + query = query.where(Event.archived == sa.false()) if f["active_me"]: query = query.where(Event.master_event_id == f["active_me"].id) @@ -204,7 +205,9 @@ def index() -> str: events = pagination.items active_named_mes = db.session.scalars( - db.select(MasterEvent).where(MasterEvent.archived.is_(False)).order_by(collate(MasterEvent.name, CS_COLLATION)) + db.select(MasterEvent) + .where(MasterEvent.archived == sa.false()) + .order_by(collate(MasterEvent.name, CS_COLLATION)) ).all() event_templates: list[EventTemplate] = [] @@ -250,7 +253,7 @@ def feed() -> Response: if not current_user.has_permission("event.view_draft"): query = query.where(Event.status != EventStatus.DRAFT) if not show_archived: - query = query.where(Event.archived.is_(False)) + query = query.where(Event.archived == sa.false()) events = db.session.scalars(query).all() @@ -309,7 +312,7 @@ def create() -> str | Response: users = rp_eligible_users_list() all_qualifications = db.session.scalars( db.select(Qualification) - .where(Qualification.is_deleted.is_(False)) + .where(Qualification.is_deleted == sa.false()) .order_by(collate(Qualification.name, CS_COLLATION)) ).all() equipment_groups = assignable_equipment_items() if current_user.has_permission("event.equipment.assign") else [] @@ -387,7 +390,7 @@ def create_from_template(template_id: int) -> str | Response: users = rp_eligible_users_list() all_qualifications = db.session.scalars( db.select(Qualification) - .where(Qualification.is_deleted.is_(False)) + .where(Qualification.is_deleted == sa.false()) .order_by(collate(Qualification.name, CS_COLLATION)) ).all() equipment_groups = assignable_equipment_items() if current_user.has_permission("event.equipment.assign") else [] @@ -454,7 +457,7 @@ def detail(event_id: int) -> str | Response: all_qualifications = db.session.scalars( db.select(Qualification) - .where(Qualification.is_deleted.is_(False)) + .where(Qualification.is_deleted == sa.false()) .order_by(collate(Qualification.name, CS_COLLATION)) ).all() diff --git a/app/routes/events/equipment.py b/app/routes/events/equipment.py index a32f6453..d0a257ab 100644 --- a/app/routes/events/equipment.py +++ b/app/routes/events/equipment.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone +import sqlalchemy as sa from flask import Response, flash, jsonify, redirect, request, url_for from flask_login import login_required @@ -220,7 +221,7 @@ def equipment_check() -> Response: conflict_filter = [ EventEquipmentAssignment.equipment_item_id.in_(available_ids), Event.status != EventStatus.CANCELLED, - Event.archived.is_(False), + Event.archived == sa.false(), Event.start_datetime < end_dt, Event.end_datetime > start_dt, ] diff --git a/app/routes/events/spots.py b/app/routes/events/spots.py index f41f0f37..9924f46f 100644 --- a/app/routes/events/spots.py +++ b/app/routes/events/spots.py @@ -2,6 +2,7 @@ import uuid +import sqlalchemy as sa from flask import Response, abort, flash, redirect, request, url_for from flask_login import current_user, login_required @@ -117,7 +118,7 @@ def add_spot(event_id: int) -> Response: qual_ids = [int(c) for c in request.form.getlist("qualification_ids") if c.isdigit()] qualifications = ( db.session.scalars( - db.select(Qualification).where(Qualification.id.in_(qual_ids), Qualification.is_deleted.is_(False)) + db.select(Qualification).where(Qualification.id.in_(qual_ids), Qualification.is_deleted == sa.false()) ).all() if qual_ids else [] @@ -159,7 +160,7 @@ def edit_spot(event_id: int, spot_id: int) -> Response: qual_ids = [int(c) for c in request.form.getlist("qualification_ids") if c.isdigit()] qualifications = ( db.session.scalars( - db.select(Qualification).where(Qualification.id.in_(qual_ids), Qualification.is_deleted.is_(False)) + db.select(Qualification).where(Qualification.id.in_(qual_ids), Qualification.is_deleted == sa.false()) ).all() if qual_ids else [] diff --git a/app/routes/events/transitions.py b/app/routes/events/transitions.py index 6ba7d95d..a0c09f35 100644 --- a/app/routes/events/transitions.py +++ b/app/routes/events/transitions.py @@ -2,6 +2,7 @@ from datetime import datetime +import sqlalchemy as sa from flask import Response, abort, flash, redirect, request, url_for from flask_login import current_user, login_required @@ -57,13 +58,17 @@ def transition(event_id: int) -> Response: # Email notifications if target_status == EventStatus.PUBLISHED: active_users = db.session.scalars( - db.select(UserAccount).where(UserAccount.is_active.is_(True)).where(UserAccount.is_archived.is_(False)) + db.select(UserAccount) + .where(UserAccount.is_active == sa.true()) + .where(UserAccount.is_archived == sa.false()) ).all() for u in active_users: mailer.send_event_published(u, event) elif target_status == EventStatus.ASSIGNMENTS_OPEN: active_users = db.session.scalars( - db.select(UserAccount).where(UserAccount.is_active.is_(True)).where(UserAccount.is_archived.is_(False)) + db.select(UserAccount) + .where(UserAccount.is_active == sa.true()) + .where(UserAccount.is_archived == sa.false()) ).all() for u in active_users: mailer.send_assignments_opened(u, event) diff --git a/app/routes/import_events.py b/app/routes/import_events.py index af3eef78..85d16805 100644 --- a/app/routes/import_events.py +++ b/app/routes/import_events.py @@ -13,6 +13,7 @@ from typing import Any from zoneinfo import ZoneInfo +import sqlalchemy as sa from flask import Blueprint, Response, flash, redirect, render_template, request, url_for from flask_login import current_user, login_required from sqlalchemy import collate @@ -90,7 +91,7 @@ def _qual_by_substr(keyword: str) -> Qualification | None: kw = keyword.lower() return db.session.scalars( db.select(Qualification).where( - Qualification.is_deleted.is_(False), + Qualification.is_deleted == sa.false(), db.func.lower(Qualification.name).contains(kw), ) ).first() @@ -515,7 +516,7 @@ def events_preview() -> str | Response: qualifications = list( db.session.scalars( db.select(Qualification) - .where(Qualification.is_deleted.is_(False)) + .where(Qualification.is_deleted == sa.false()) .order_by(collate(Qualification.name, CS_COLLATION)) ).all() ) diff --git a/app/routes/main.py b/app/routes/main.py index df35aa48..42def4d4 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone +import sqlalchemy as sa from flask import Blueprint, Response, jsonify, render_template from flask_login import current_user, login_required from sqlalchemy import or_ @@ -43,7 +44,7 @@ def _my_events_section(now: datetime, horizon: datetime) -> tuple[list[tuple[Eve my_events_query = ( db.select(Event) .where( - Event.archived.is_(False), + Event.archived == sa.false(), Event.end_datetime >= now, Event.start_datetime <= horizon, Event.status != EventStatus.CANCELLED, @@ -108,7 +109,7 @@ def _attention_events_section(now: datetime, horizon: datetime) -> list[Event]: db.session.scalars( db.select(Event) .where( - Event.archived.is_(False), + Event.archived == sa.false(), Event.status.in_([EventStatus.DRAFT, EventStatus.PUBLISHED, EventStatus.ASSIGNMENTS_OPEN]), Event.start_datetime <= horizon, Event.end_datetime >= now, @@ -133,7 +134,7 @@ def _missing_rp_events_section(now: datetime) -> list[Event]: db.session.scalars( db.select(Event) .where( - Event.archived.is_(False), + Event.archived == sa.false(), Event.status.notin_([EventStatus.DRAFT, EventStatus.CANCELLED]), Event.responsible_person_id == None, # noqa: E711 Event.start_datetime >= now, @@ -180,8 +181,8 @@ def dashboard() -> str: pending_activations = list( db.session.scalars( db.select(UserAccount) - .where(UserAccount.is_active.is_(False)) - .where(UserAccount.is_archived.is_(False)) + .where(UserAccount.is_active == sa.false()) + .where(UserAccount.is_archived == sa.false()) .order_by(UserAccount.created_at) ).all() ) diff --git a/app/routes/master_events.py b/app/routes/master_events.py index 3e93a6ce..6b5549ff 100644 --- a/app/routes/master_events.py +++ b/app/routes/master_events.py @@ -19,6 +19,7 @@ from datetime import datetime, timedelta, timezone from decimal import Decimal +import sqlalchemy as sa from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, url_for from flask_login import current_user, login_required from sqlalchemy import collate @@ -54,7 +55,7 @@ def index() -> str: show_archived = request.args.get("archived") == "1" query = db.select(MasterEvent) if not show_archived: - query = query.where(MasterEvent.archived.is_(False)) + query = query.where(MasterEvent.archived == sa.false()) query = query.order_by(MasterEvent.is_general.desc(), collate(MasterEvent.name, CS_COLLATION)) master_events = db.session.scalars(query).all() @@ -310,7 +311,7 @@ def _compute_eligible_users(rows: list[dict], all_users: list) -> None: """Annotate each row with ``eligible_users`` list (users who can fill those spots).""" from app.models.qualification import Qualification # pylint: disable=import-outside-toplevel - all_quals = db.session.scalars(db.select(Qualification).where(Qualification.is_deleted.is_(False))).all() + all_quals = db.session.scalars(db.select(Qualification).where(Qualification.is_deleted == sa.false())).all() parents_map: dict[int, list[int]] = {q.id: [p.id for p in q.parents] for q in all_quals} def _can_fill(uq_ids: set[int], target: int, visited: frozenset[int]) -> bool: @@ -500,7 +501,7 @@ def _handle_advance_status(event: Event) -> Response: from app.models.user import UserAccount # pylint: disable=import-outside-toplevel active_users = db.session.scalars( - db.select(UserAccount).where(UserAccount.is_active.is_(True)).where(UserAccount.is_archived.is_(False)) + db.select(UserAccount).where(UserAccount.is_active == sa.true()).where(UserAccount.is_archived == sa.false()) ).all() if target_status == EventStatus.PUBLISHED: for u in active_users: @@ -695,7 +696,7 @@ def table_spots_update(me_id: int) -> Response: if new_count > current_count: qualifications = ( db.session.scalars( - db.select(Qualification).where(Qualification.id.in_(qual_ids), Qualification.is_deleted.is_(False)) + db.select(Qualification).where(Qualification.id.in_(qual_ids), Qualification.is_deleted == sa.false()) ).all() if qual_ids else [] diff --git a/app/routes/notifications.py b/app/routes/notifications.py index e6ccf862..f9b58537 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -5,6 +5,7 @@ and allows admins to toggle each configurable type on/off via AppSettings. """ +import sqlalchemy as sa from flask import Blueprint, Response, flash, g, redirect, render_template, request, url_for from flask_login import current_user, login_required @@ -74,7 +75,7 @@ def index() -> str | Response: def _recent_events() -> list[Event]: """Return the 20 most recently created non-archived events for the test dropdown.""" return db.session.scalars( - db.select(Event).where(Event.archived.is_(False)).order_by(Event.start_datetime.desc()).limit(20) + db.select(Event).where(Event.archived == sa.false()).order_by(Event.start_datetime.desc()).limit(20) ).all() @@ -106,7 +107,7 @@ def test_notification(code: str) -> Response: event = None if event is None: event = db.session.scalar( - db.select(Event).where(Event.archived.is_(False)).order_by(Event.start_datetime.desc()) + db.select(Event).where(Event.archived == sa.false()).order_by(Event.start_datetime.desc()) ) if event is None: flash("Nepodařilo se najít žádnou akci pro zkušební oznámení.", "warning") diff --git a/app/routes/qualifications.py b/app/routes/qualifications.py index cb4c2bbd..09fbcf39 100644 --- a/app/routes/qualifications.py +++ b/app/routes/qualifications.py @@ -8,6 +8,7 @@ qualification.delete — delete (only if no users or spots hold it) """ +import sqlalchemy as sa from flask import Blueprint, Response, flash, redirect, render_template, request, url_for from flask_login import login_required from sqlalchemy import collate @@ -28,7 +29,7 @@ def index() -> str: require_permission("qualification.view") qualifications = db.session.scalars( db.select(Qualification) - .where(Qualification.is_deleted.is_(False)) + .where(Qualification.is_deleted == sa.false()) .order_by(collate(Qualification.name, CS_COLLATION)) ).all() return render_template("qualifications/index.html", qualifications=qualifications) @@ -44,7 +45,7 @@ def create() -> str | Response: all_qualifications = db.session.scalars( db.select(Qualification) - .where(Qualification.is_deleted.is_(False)) + .where(Qualification.is_deleted == sa.false()) .order_by(collate(Qualification.name, CS_COLLATION)) ).all() @@ -58,7 +59,7 @@ def create() -> str | Response: return render_template("qualifications/create.html", all_qualifications=all_qualifications) if db.session.scalar( - db.select(Qualification).where(Qualification.name == name, Qualification.is_deleted.is_(False)) + db.select(Qualification).where(Qualification.name == name, Qualification.is_deleted == sa.false()) ): flash("Kvalifikace s tímto názvem již existuje.", "danger") return render_template("qualifications/create.html", all_qualifications=all_qualifications) @@ -92,7 +93,7 @@ def edit(cred_id: int) -> str | Response: all_qualifications = db.session.scalars( db.select(Qualification) - .where(Qualification.id != cred_id, Qualification.is_deleted.is_(False)) + .where(Qualification.id != cred_id, Qualification.is_deleted == sa.false()) .order_by(collate(Qualification.name, CS_COLLATION)) ).all() @@ -107,7 +108,7 @@ def edit(cred_id: int) -> str | Response: conflict = db.session.scalar( db.select(Qualification).where( - Qualification.name == name, Qualification.id != cred_id, Qualification.is_deleted.is_(False) + Qualification.name == name, Qualification.id != cred_id, Qualification.is_deleted == sa.false() ) ) if conflict: diff --git a/app/routes/setup.py b/app/routes/setup.py index 648bdaf7..bd7b5f49 100644 --- a/app/routes/setup.py +++ b/app/routes/setup.py @@ -7,6 +7,7 @@ """ import pytz +import sqlalchemy as sa from flask import Blueprint, Response, current_app, flash, redirect, render_template, request, url_for from flask_login import login_user from flask_mail import Message @@ -197,7 +198,7 @@ def _ensure_general_me() -> None: """Idempotently create the built-in General master event.""" from app.models.master_event import MasterEvent # pylint: disable=import-outside-toplevel - if not db.session.scalar(db.select(MasterEvent).where(MasterEvent.is_general.is_(True))): + if not db.session.scalar(db.select(MasterEvent).where(MasterEvent.is_general == sa.true())): db.session.add( MasterEvent( name="Obecné", diff --git a/app/routes/templates.py b/app/routes/templates.py index 68ab8625..2e4645be 100644 --- a/app/routes/templates.py +++ b/app/routes/templates.py @@ -8,6 +8,7 @@ event_template.delete — delete templates """ +import sqlalchemy as sa from flask import Blueprint, Response, flash, redirect, render_template, request, url_for from flask_login import login_required from sqlalchemy import collate @@ -73,7 +74,7 @@ def _rebuild_spot_templates(template: EventTemplate, slots: list[tuple[str | Non st = EventSpotTemplate(template_id=template.id, description=desc, is_optional=is_optional) if qual_ids: creds = db.session.scalars( - db.select(Qualification).where(Qualification.id.in_(qual_ids), Qualification.is_deleted.is_(False)) + db.select(Qualification).where(Qualification.id.in_(qual_ids), Qualification.is_deleted == sa.false()) ).all() st.required_qualifications = list(creds) db.session.add(st) @@ -103,7 +104,7 @@ def create() -> str | Response: qualifications = db.session.scalars( db.select(Qualification) - .where(Qualification.is_deleted.is_(False)) + .where(Qualification.is_deleted == sa.false()) .order_by(collate(Qualification.name, CS_COLLATION)) ).all() equipment_types = db.session.scalars( @@ -181,7 +182,7 @@ def edit(template_id: int) -> str | Response: qualifications = db.session.scalars( db.select(Qualification) - .where(Qualification.is_deleted.is_(False)) + .where(Qualification.is_deleted == sa.false()) .order_by(collate(Qualification.name, CS_COLLATION)) ).all() equipment_types = db.session.scalars( diff --git a/app/routes/users.py b/app/routes/users.py index 571a5635..9dfa82bf 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone from typing import Any +import sqlalchemy as sa from flask import Blueprint, Response, abort, flash, redirect, render_template, request, url_for from flask_login import current_user, login_required from sqlalchemy import collate @@ -208,9 +209,9 @@ def index() -> str: query = db.select(UserAccount).order_by(order) if not show_archived: - query = query.where(UserAccount.is_archived.is_(False)) + query = query.where(UserAccount.is_archived == sa.false()) else: - query = query.where(UserAccount.is_archived.is_(True)) + query = query.where(UserAccount.is_archived == sa.true()) if q: query = query.where( db.or_( @@ -255,7 +256,7 @@ def create_user() -> str | Response: all_roles = db.session.scalars(db.select(Role).order_by(collate(Role.name, CS_COLLATION))).all() all_qualifications = db.session.scalars( db.select(Qualification) - .where(Qualification.is_deleted.is_(False)) + .where(Qualification.is_deleted == sa.false()) .order_by(collate(Qualification.name, CS_COLLATION)) ).all() @@ -316,7 +317,7 @@ def detail(user_id: uuid.UUID) -> str: qualifications = db.session.scalars( db.select(Qualification) - .where(Qualification.is_deleted.is_(False)) + .where(Qualification.is_deleted == sa.false()) .order_by(collate(Qualification.name, CS_COLLATION)) ).all() return render_template( @@ -363,7 +364,7 @@ def _apply_qualification_update(user: UserAccount, qual_ids: list[int]) -> bool: db.session.scalars( db.select(Qualification).where( Qualification.id.in_(qual_ids), - Qualification.is_deleted.is_(False), + Qualification.is_deleted == sa.false(), ) ).all() if qual_ids diff --git a/app/scheduler_tasks.py b/app/scheduler_tasks.py index 7e95abfe..cea00efc 100644 --- a/app/scheduler_tasks.py +++ b/app/scheduler_tasks.py @@ -35,7 +35,7 @@ def run_send_reminders(db_session: Any, now: datetime | None = None) -> int: events = db_session.scalars( sa.select(Event).where( Event.status == EventStatus.ASSIGNMENTS_OPEN, - Event.archived.is_(False), + Event.archived == sa.false(), Event.start_datetime > now, ) ).all() @@ -164,7 +164,7 @@ def run_admin_digest(db_session: Any, now: datetime | None = None) -> bool: return False eligible = db_session.scalars( - sa.select(UserAccount).join(UserAccount.roles).where(UserAccount.is_active.is_(True), Role.name == "Admin") + sa.select(UserAccount).join(UserAccount.roles).where(UserAccount.is_active == sa.true(), Role.name == "Admin") ).all() if not eligible: diff --git a/app/work_report_generator.py b/app/work_report_generator.py index 790fd91e..13f4b90d 100644 --- a/app/work_report_generator.py +++ b/app/work_report_generator.py @@ -283,7 +283,7 @@ def _fetch_events_for_month(user_id: str, year: int, month: int) -> dict[int, tu .where( Assignment.user_id == user_id, Event.status == EventStatus.COMPLETED, - Event.paid.is_(True), + Event.paid == sa.true(), Event.start_datetime >= period_start, Event.start_datetime <= period_end, ) diff --git a/docker-compose.mssql-dev.yml b/docker-compose.mssql-dev.yml new file mode 100644 index 00000000..cc361c92 --- /dev/null +++ b/docker-compose.mssql-dev.yml @@ -0,0 +1,103 @@ +# docker-compose.mssql-dev.yml +# +# Self-contained dev stack with MSSQL Server 2022 (LOCAL DEV ONLY). +# Production in Azure uses Azure SQL Database as a managed service. +# +# Usage: +# podman compose -f docker-compose.mssql-dev.yml up -d --build +# # or: docker compose -f docker-compose.mssql-dev.yml up -d --build +# +# After first startup, create the database: +# podman exec medcover-mssql-db-1 /docker-entrypoint-initdb.d/setup.sh +# +# .env must contain: +# DATABASE_URL=mssql+pyodbc://medcover:Dev_Password1!@db:1433/medcover_dev?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes + +services: + web: + platform: linux/amd64 + build: + context: . + args: + GIT_COMMIT: ${GIT_COMMIT:-dev} + command: flask run --host=0.0.0.0 --debug + restart: unless-stopped + volumes: + - .:/app + env_file: .env + ports: + - "5000:5000" + dns: + - 8.8.8.8 + - 1.1.1.1 + depends_on: + db: + condition: service_healthy + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "7" + healthcheck: + test: ["CMD", "python", "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + scheduler: + platform: linux/amd64 + build: + context: . + args: + GIT_COMMIT: ${GIT_COMMIT:-dev} + entrypoint: ["/docker-entrypoint-scheduler.sh"] + command: python scheduler/main.py + restart: unless-stopped + volumes: + - .:/app + env_file: .env + dns: + - 8.8.8.8 + - 1.1.1.1 + depends_on: + web: + condition: service_healthy + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "7" + healthcheck: + test: ["CMD", "python", "-c", + "import os, time; f='/tmp/scheduler_heartbeat'; assert os.path.exists(f) and time.time()-os.path.getmtime(f)<30"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + db: + image: mcr.microsoft.com/mssql/server:2022-latest + platform: linux/amd64 + restart: unless-stopped + stop_grace_period: 30s + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "DevPassword123!" + MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" + MSSQL_MEMORY_LIMIT_MB: "512" + ports: + - "1433:1433" + volumes: + - mssql_data:/var/opt/mssql + - ./mssql-init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "DevPassword123!", "-C", "-Q", "SELECT 1", "-b"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + mssql_data: diff --git a/mssql-init/setup.sh b/mssql-init/setup.sh new file mode 100755 index 00000000..6b7332b1 --- /dev/null +++ b/mssql-init/setup.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# mssql-init/setup.sh +# +# Wait for MSSQL to become ready, then create the dev database with +# Czech collation and enable RCSI (Read Committed Snapshot Isolation). +# +# This script is NOT run automatically by the MSSQL container (unlike +# PostgreSQL's docker-entrypoint-initdb.d). You need to run it manually +# after first startup: +# +# docker compose -f docker-compose.mssql.yml exec mssql /docker-entrypoint-initdb.d/setup.sh + +set -e + +SQLCMD="/opt/mssql-tools18/bin/sqlcmd" +SA_PASSWORD="DevPassword123!" + +echo "Waiting for SQL Server to be ready..." +for i in $(seq 1 30); do + if $SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q "SELECT 1" &>/dev/null; then + echo "SQL Server is ready." + break + fi + echo " Attempt $i/30 — not ready yet..." + sleep 2 +done + +echo "Creating database medcover_dev..." +$SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q " +IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'medcover_dev') +BEGIN + CREATE DATABASE medcover_dev + COLLATE Czech_100_CI_AS_SC_UTF8; + ALTER DATABASE medcover_dev SET READ_COMMITTED_SNAPSHOT ON; + PRINT 'Database medcover_dev created with RCSI enabled.'; +END +ELSE +BEGIN + PRINT 'Database medcover_dev already exists.'; +END +" + +echo "Creating login and user..." +$SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q " +IF NOT EXISTS (SELECT name FROM sys.server_principals WHERE name = 'medcover') +BEGIN + CREATE LOGIN medcover WITH PASSWORD = 'Dev_Password1!'; + PRINT 'Login medcover created.'; +END +ELSE + PRINT 'Login medcover already exists.'; +" + +$SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -d medcover_dev -Q " +IF NOT EXISTS (SELECT name FROM sys.database_principals WHERE name = 'medcover') +BEGIN + CREATE USER medcover FOR LOGIN medcover; + ALTER ROLE db_owner ADD MEMBER medcover; + PRINT 'User medcover created and added to db_owner.'; +END +ELSE + PRINT 'User medcover already exists.'; +" + +echo "✓ MSSQL setup complete." +echo " Connection string for MedCover:" +echo " DATABASE_URL=mssql+pyodbc://medcover:Dev_Password1!@127.0.0.1:1433/medcover_dev?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes" +echo "" +echo " Note: Use 127.0.0.1 (not localhost) on macOS to avoid IPv6 issues." diff --git a/requirements-dev.txt b/requirements-dev.txt index 7e24ed7d..fb27d87b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -286,113 +286,113 @@ colorama==0.4.6 \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 # via tox -coverage[toml]==7.14.0 \ - --hash=sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74 \ - --hash=sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e \ - --hash=sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a \ - --hash=sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c \ - --hash=sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a \ - --hash=sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe \ - --hash=sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1 \ - --hash=sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db \ - --hash=sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9 \ - --hash=sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e \ - --hash=sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b \ - --hash=sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66 \ - --hash=sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644 \ - --hash=sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490 \ - --hash=sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5 \ - --hash=sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8 \ - --hash=sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f \ - --hash=sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212 \ - --hash=sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb \ - --hash=sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917 \ - --hash=sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893 \ - --hash=sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c \ - --hash=sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90 \ - --hash=sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d \ - --hash=sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef \ - --hash=sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9 \ - --hash=sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087 \ - --hash=sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5 \ - --hash=sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27 \ - --hash=sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020 \ - --hash=sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3 \ - --hash=sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47 \ - --hash=sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca \ - --hash=sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323 \ - --hash=sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d \ - --hash=sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd \ - --hash=sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426 \ - --hash=sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828 \ - --hash=sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480 \ - --hash=sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97 \ - --hash=sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8 \ - --hash=sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899 \ - --hash=sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2 \ - --hash=sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5 \ - --hash=sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3 \ - --hash=sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20 \ - --hash=sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436 \ - --hash=sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327 \ - --hash=sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b \ - --hash=sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb \ - --hash=sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe \ - --hash=sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab \ - --hash=sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82 \ - --hash=sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627 \ - --hash=sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5 \ - --hash=sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3 \ - --hash=sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477 \ - --hash=sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662 \ - --hash=sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075 \ - --hash=sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f \ - --hash=sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1 \ - --hash=sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7 \ - --hash=sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52 \ - --hash=sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d \ - --hash=sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9 \ - --hash=sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed \ - --hash=sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90 \ - --hash=sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1 \ - --hash=sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d \ - --hash=sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980 \ - --hash=sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca \ - --hash=sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa \ - --hash=sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6 \ - --hash=sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc \ - --hash=sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4 \ - --hash=sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c \ - --hash=sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d \ - --hash=sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84 \ - --hash=sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2 \ - --hash=sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742 \ - --hash=sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2 \ - --hash=sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb \ - --hash=sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a \ - --hash=sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9 \ - --hash=sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96 \ - --hash=sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f \ - --hash=sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb \ - --hash=sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63 \ - --hash=sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c \ - --hash=sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1 \ - --hash=sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec \ - --hash=sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367 \ - --hash=sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20 \ - --hash=sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67 \ - --hash=sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b \ - --hash=sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85 \ - --hash=sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0 \ - --hash=sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4 \ - --hash=sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7 \ - --hash=sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218 \ - --hash=sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757 \ - --hash=sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef \ - --hash=sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1 \ - --hash=sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea \ - --hash=sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae \ - --hash=sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595 +coverage[toml]==7.14.1 \ + --hash=sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86 \ + --hash=sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd \ + --hash=sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d \ + --hash=sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5 \ + --hash=sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42 \ + --hash=sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de \ + --hash=sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548 \ + --hash=sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1 \ + --hash=sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7 \ + --hash=sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59 \ + --hash=sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906 \ + --hash=sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af \ + --hash=sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1 \ + --hash=sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d \ + --hash=sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1 \ + --hash=sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be \ + --hash=sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02 \ + --hash=sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42 \ + --hash=sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129 \ + --hash=sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e \ + --hash=sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be \ + --hash=sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e \ + --hash=sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65 \ + --hash=sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54 \ + --hash=sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1 \ + --hash=sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5 \ + --hash=sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df \ + --hash=sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47 \ + --hash=sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f \ + --hash=sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf \ + --hash=sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37 \ + --hash=sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4 \ + --hash=sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f \ + --hash=sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84 \ + --hash=sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1 \ + --hash=sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c \ + --hash=sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8 \ + --hash=sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e \ + --hash=sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec \ + --hash=sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d \ + --hash=sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54 \ + --hash=sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890 \ + --hash=sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b \ + --hash=sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d \ + --hash=sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2 \ + --hash=sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33 \ + --hash=sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9 \ + --hash=sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e \ + --hash=sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6 \ + --hash=sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce \ + --hash=sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247 \ + --hash=sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901 \ + --hash=sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36 \ + --hash=sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69 \ + --hash=sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416 \ + --hash=sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5 \ + --hash=sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500 \ + --hash=sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad \ + --hash=sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1 \ + --hash=sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b \ + --hash=sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b \ + --hash=sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a \ + --hash=sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e \ + --hash=sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee \ + --hash=sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07 \ + --hash=sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a \ + --hash=sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d \ + --hash=sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c \ + --hash=sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343 \ + --hash=sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4 \ + --hash=sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2 \ + --hash=sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8 \ + --hash=sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf \ + --hash=sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb \ + --hash=sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c \ + --hash=sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff \ + --hash=sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e \ + --hash=sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550 \ + --hash=sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860 \ + --hash=sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793 \ + --hash=sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f \ + --hash=sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851 \ + --hash=sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7 \ + --hash=sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332 \ + --hash=sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b \ + --hash=sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2 \ + --hash=sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d \ + --hash=sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a \ + --hash=sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef \ + --hash=sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474 \ + --hash=sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee \ + --hash=sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43 \ + --hash=sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034 \ + --hash=sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3 \ + --hash=sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c \ + --hash=sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d \ + --hash=sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7 \ + --hash=sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e \ + --hash=sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d \ + --hash=sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4 \ + --hash=sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9 \ + --hash=sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52 \ + --hash=sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a \ + --hash=sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c \ + --hash=sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253 \ + --hash=sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c # via pytest-cov cryptography==48.0.0 \ --hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \ @@ -445,9 +445,9 @@ cryptography==48.0.0 \ --hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \ --hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b # via -r requirements.in -distlib==0.4.0 \ - --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ - --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d +distlib==0.4.2 \ + --hash=sha256:baeb401c90f27acd15c4861ae0847d1e731c27ac3dbf4210643ba61fa1e813db \ + --hash=sha256:ca4cb11e5d746b5ec13c199cbf19ae27a241f89702b54e153a74332955446067 # via virtualenv docker==7.1.0 \ --hash=sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c \ @@ -465,9 +465,9 @@ faker==40.21.0 \ --hash=sha256:2fdee1b650a723a54432db9c6dfe17cfa29d1adc8bd60520444a07698524ba4d \ --hash=sha256:cb6601b2ae8e128895dc96814d271eab6b930a2d2d7932c6f9ff26785c24ee18 # via -r requirements-dev.in -filelock==3.29.0 \ - --hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \ - --hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258 +filelock==3.29.1 \ + --hash=sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b \ + --hash=sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e # via # python-discovery # tox @@ -605,9 +605,9 @@ identify==2.6.19 \ --hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \ --hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842 # via pre-commit -idna==3.16 \ - --hash=sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5 \ - --hash=sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via requests iniconfig==2.3.0 \ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ @@ -890,9 +890,9 @@ pathspec==1.1.1 \ --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 # via mypy -platformdirs==4.9.6 \ - --hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \ - --hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917 +platformdirs==4.10.0 \ + --hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \ + --hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a # via # python-discovery # tox @@ -993,9 +993,75 @@ pygments==2.20.0 \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 # via pytest -pyproject-api==1.10.0 \ - --hash=sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330 \ - --hash=sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09 +pyodbc==5.3.0 \ + --hash=sha256:01166162149adf2b8a6dc21a212718f205cabbbdff4047dc0c415af3fd85867e \ + --hash=sha256:0263323fc47082c2bf02562f44149446bbbfe91450d271e44bffec0c3143bfb1 \ + --hash=sha256:08b2439500e212625471d32f8fde418075a5ddec556e095e5a4ba56d61df2dc6 \ + --hash=sha256:0df7ff47fab91ea05548095b00e5eb87ed88ddf4648c58c67b4db95ea4913e23 \ + --hash=sha256:101313a21d2654df856a60e4a13763e4d9f6c5d3fd974bcf3fc6b4e86d1bbe8e \ + --hash=sha256:13656184faa3f2d5c6f19b701b8f247342ed581484f58bf39af7315c054e69db \ + --hash=sha256:1629af4706e9228d79dabb4863c11cceb22a6dab90700db0ef449074f0150c0d \ + --hash=sha256:197bb6ddafe356a916b8ee1b8752009057fce58e216e887e2174b24c7ab99269 \ + --hash=sha256:2035c7dfb71677cd5be64d3a3eb0779560279f0a8dc6e33673499498caa88937 \ + --hash=sha256:25b6766e56748eb1fc1d567d863e06cbb7b7c749a41dfed85db0031e696fa39a \ + --hash=sha256:25c4cfb2c08e77bc6e82f666d7acd52f0e52a0401b1876e60f03c73c3b8aedc0 \ + --hash=sha256:2eb7151ed0a1959cae65b6ac0454f5c8bbcd2d8bafeae66483c09d58b0c7a7fc \ + --hash=sha256:2fe0e063d8fb66efd0ac6dc39236c4de1a45f17c33eaded0d553d21c199f4d05 \ + --hash=sha256:349a9abae62a968b98f6bbd23d2825151f8d9de50b3a8f5f3271b48958fdb672 \ + --hash=sha256:363311bd40320b4a61454bebf7c38b243cd67c762ed0f8a5219de3ec90c96353 \ + --hash=sha256:3cc472c8ae2feea5b4512e23b56e2b093d64f7cbc4b970af51da488429ff7818 \ + --hash=sha256:3f1bdb3ce6480a17afaaef4b5242b356d4997a872f39e96f015cabef00613797 \ + --hash=sha256:452e7911a35ee12a56b111ac5b596d6ed865b83fcde8427127913df53132759e \ + --hash=sha256:46185a1a7f409761716c71de7b95e7bbb004390c650d00b0b170193e3d6224bb \ + --hash=sha256:46869b9a6555ff003ed1d8ebad6708423adf2a5c88e1a578b9f029fb1435186e \ + --hash=sha256:58635a1cc859d5af3f878c85910e5d7228fe5c406d4571bffcdd281375a54b39 \ + --hash=sha256:5cbe4d753723c8a8f65020b7a259183ef5f14307587165ce37e8c7e251951852 \ + --hash=sha256:5ceaed87ba2ea848c11223f66f629ef121f6ebe621f605cde9cfdee4fd9f4b68 \ + --hash=sha256:5dd3d5e469f89a3112cf8b0658c43108a4712fad65e576071e4dd44d2bd763c7 \ + --hash=sha256:5ebf6b5d989395efe722b02b010cb9815698a4d681921bf5db1c0e1195ac1bde \ + --hash=sha256:6132554ffbd7910524d643f13ce17f4a72f3a6824b0adef4e9a7f66efac96350 \ + --hash=sha256:6682cdec78f1302d0c559422c8e00991668e039ed63dece8bf99ef62173376a5 \ + --hash=sha256:676031723aac7dcbbd2813bddda0e8abf171b20ec218ab8dfb21d64a193430ea \ + --hash=sha256:705903acf6f43c44fc64e764578d9a88649eb21bf7418d78677a9d2e337f56f2 \ + --hash=sha256:729c535341bb09c476f219d6f7ab194bcb683c4a0a368010f1cb821a35136f05 \ + --hash=sha256:74528fe148980d0c735c0ebb4a4dc74643ac4574337c43c1006ac4d09593f92d \ + --hash=sha256:754d052030d00c3ac38da09ceb9f3e240e8dd1c11da8906f482d5419c65b9ef5 \ + --hash=sha256:7713c740a10f33df3cb08f49a023b7e1e25de0c7c99650876bbe717bc95ee780 \ + --hash=sha256:7e9ab0b91de28a5ab838ac4db0253d7cc8ce2452efe4ad92ee6a57b922bf0c24 \ + --hash=sha256:8339d3094858893c1a68ee1af93efc4dff18b8b65de54d99104b99af6306320d \ + --hash=sha256:8aa396c6d6af52ccd51b8c8a5bffbb46fd44e52ce07ea4272c1d28e5e5b12722 \ + --hash=sha256:9b987a25a384f31e373903005554230f5a6d59af78bce62954386736a902a4b3 \ + --hash=sha256:9cd3f0a9796b3e1170a9fa168c7e7ca81879142f30e20f46663b882db139b7d2 \ + --hash=sha256:a48d731432abaee5256ed6a19a3e1528b8881f9cb25cb9cf72d8318146ea991b \ + --hash=sha256:ac23feb7ddaa729f6b840639e92f83ff0ccaa7072801d944f1332cd5f5b05f47 \ + --hash=sha256:af4d8c9842fc4a6360c31c35508d6594d5a3b39922f61b282c2b4c9d9da99514 \ + --hash=sha256:afe7c4ac555a8d10a36234788fc6cfc22a86ce37fc5ba88a1f75b3e6696665dc \ + --hash=sha256:b180bc5e49b74fd40a24ef5b0fe143d0c234ac1506febe810d7434bf47cb925b \ + --hash=sha256:b35b9983ad300e5aea82b8d1661fc9d3afe5868de527ee6bd252dd550e61ecd6 \ + --hash=sha256:bc834567c2990584b9726cba365834d039380c9dbbcef3030ddeb00c6541b943 \ + --hash=sha256:bfeb3e34795d53b7d37e66dd54891d4f9c13a3889a8f5fe9640e56a82d770955 \ + --hash=sha256:c25dc9c41f61573bdcf61a3408c34b65e4c0f821b8f861ca7531b1353b389804 \ + --hash=sha256:c2eb0b08e24fe5c40c7ebe9240c5d3bd2f18cd5617229acee4b0a0484dc226f2 \ + --hash=sha256:c5c30c5cd40b751f77bbc73edd32c4498630939bcd4e72ee7e6c9a4b982cc5ca \ + --hash=sha256:c67e7f2ce649155ea89beb54d3b42d83770488f025cf3b6f39ca82e9c598a02e \ + --hash=sha256:c68d9c225a97aedafb7fff1c0e1bfe293093f77da19eaf200d0e988fa2718d16 \ + --hash=sha256:c6ccb5315ec9e081f5cbd66f36acbc820ad172b8fa3736cf7f993cdf69bd8a96 \ + --hash=sha256:c79df54bbc25bce9f2d87094e7b39089c28428df5443d1902b0cc5f43fd2da6f \ + --hash=sha256:cf18797a12e70474e1b7f5027deeeccea816372497e3ff2d46b15bec2d18a0cc \ + --hash=sha256:d255f6b117d05cfc046a5201fdf39535264045352ea536c35777cf66d321fbb8 \ + --hash=sha256:d32c3259762bef440707098010035bbc83d1c73d81a434018ab8c688158bd3bb \ + --hash=sha256:d89a7f2e24227150c13be8164774b7e1f9678321a4248f1356a465b9cc17d31e \ + --hash=sha256:e3c39de3005fff3ae79246f952720d44affc6756b4b85398da4c5ea76bf8f506 \ + --hash=sha256:e981db84fee4cebec67f41bd266e1e7926665f1b99c3f8f4ea73cd7f7666e381 \ + --hash=sha256:ebc3be93f61ea0553db88589e683ace12bf975baa954af4834ab89f5ee7bf8ae \ + --hash=sha256:f1ad0e93612a6201621853fc661209d82ff2a35892b7d590106fe8f97d9f1f2a \ + --hash=sha256:f927b440c38ade1668f0da64047ffd20ec34e32d817f9a60d07553301324b364 \ + --hash=sha256:fc5ac4f2165f7088e74ecec5413b5c304247949f9702c8853b0e43023b4187e8 \ + --hash=sha256:fe77eb9dcca5fc1300c9121f81040cc9011d28cff383e2c35416e9ec06d4bc95 + # via -r requirements.in +pyproject-api==1.10.1 \ + --hash=sha256:c2b2726bd7aa9217b6c50b621fef5b2ae5def4d55b779c9e0694c15e0a8517ba \ + --hash=sha256:fa9e6f66c35b5017e909825d8f2b5d5482ea699d7be809d21c03bd1f7317f36a # via tox pytest==9.0.3 \ --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ @@ -1023,9 +1089,9 @@ python-dateutil==2.9.0.post0 \ # via # holidays # icalendar -python-discovery==1.3.1 \ - --hash=sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6 \ - --hash=sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c +python-discovery==1.4.0 \ + --hash=sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da \ + --hash=sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3 # via # tox # virtualenv @@ -1126,70 +1192,65 @@ six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 # via python-dateutil -sqlalchemy==2.0.49 \ - --hash=sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72 \ - --hash=sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe \ - --hash=sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75 \ - --hash=sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5 \ - --hash=sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148 \ - --hash=sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7 \ - --hash=sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e \ - --hash=sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518 \ - --hash=sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7 \ - --hash=sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700 \ - --hash=sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717 \ - --hash=sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672 \ - --hash=sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88 \ - --hash=sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f \ - --hash=sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f \ - --hash=sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08 \ - --hash=sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a \ - --hash=sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3 \ - --hash=sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b \ - --hash=sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536 \ - --hash=sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0 \ - --hash=sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b \ - --hash=sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a \ - --hash=sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3 \ - --hash=sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4 \ - --hash=sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339 \ - --hash=sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158 \ - --hash=sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066 \ - --hash=sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662 \ - --hash=sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1 \ - --hash=sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3 \ - --hash=sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5 \ - --hash=sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01 \ - --hash=sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613 \ - --hash=sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a \ - --hash=sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0 \ - --hash=sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f \ - --hash=sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a \ - --hash=sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e \ - --hash=sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2 \ - --hash=sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af \ - --hash=sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014 \ - --hash=sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33 \ - --hash=sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61 \ - --hash=sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d \ - --hash=sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187 \ - --hash=sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401 \ - --hash=sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b \ - --hash=sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d \ - --hash=sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f \ - --hash=sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba \ - --hash=sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977 \ - --hash=sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a \ - --hash=sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe \ - --hash=sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b \ - --hash=sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f \ - --hash=sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1 \ - --hash=sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4 \ - --hash=sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d \ - --hash=sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120 \ - --hash=sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750 \ - --hash=sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0 \ - --hash=sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982 +sqlalchemy==2.0.50 \ + --hash=sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064 \ + --hash=sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093 \ + --hash=sha256:0a31c5963d58d3e3d11c5b97709e248305705de1fdf51ec3bf396674c5898b7e \ + --hash=sha256:0e104e196f457ec608eb8af736c5eb4c6bc58f481b546f485a7f9c628ee532be \ + --hash=sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e \ + --hash=sha256:0fe7822866f3a9fc5f3db21a290ce8961a53050115f05edf9402b6a5feb92a9f \ + --hash=sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86 \ + --hash=sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600 \ + --hash=sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a \ + --hash=sha256:13b85b20f9ab714a666df9d8e72e253ec33c16c7e1e375c877e5bf6367a3e917 \ + --hash=sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39 \ + --hash=sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a \ + --hash=sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508 \ + --hash=sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5 \ + --hash=sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e \ + --hash=sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb \ + --hash=sha256:27b7062af702c61994e8806ad87e42d0a2c879e0a8e5c61c7f69d81dabe24fdf \ + --hash=sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3 \ + --hash=sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f \ + --hash=sha256:2c1920cde9d741ba3dda9b1aa5acd8c23ea17780ccfb2252d01878d5d0d628d3 \ + --hash=sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c \ + --hash=sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db \ + --hash=sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70 \ + --hash=sha256:3d10700bd519573f6ce5badbabbfe7f5baea84cdf370f2cbbfb4be28dfddbf1d \ + --hash=sha256:409a8121b917116b035bedc5e532ad470c74a2d279f6c302100985b6304e9f9e \ + --hash=sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89 \ + --hash=sha256:4a8e8af330cbb3a1931d3d6c91b239fc2ef135f7dd471dfa34c575028e0b1fa8 \ + --hash=sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3 \ + --hash=sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4 \ + --hash=sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5 \ + --hash=sha256:66e374271ecb7101273f57af1a62446a953d327eec4f8089147de57c591bbacc \ + --hash=sha256:68b154b08088b4ec32bb4d2958bfbb50e57549f91a4cd3e7f928e3553ed69031 \ + --hash=sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e \ + --hash=sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615 \ + --hash=sha256:7af6eeb84985bf840ba779018ff9424d61ff69b52e66b8789d3c8da7bf5341b2 \ + --hash=sha256:7b1ddb7b5fc60dfa9df6a487f06a143c77def47c0351849da2bcea59b244a56c \ + --hash=sha256:7e36efdcc5493f8024ec873a4ee3855bfd2de0c5b19eba16f920e9d2a0d28622 \ + --hash=sha256:83a9fce296b7e052316d8c6943237b31b9c00f58ca9c253f2d165df52637a293 \ + --hash=sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873 \ + --hash=sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8 \ + --hash=sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9 \ + --hash=sha256:9602c07b03e1449747ecb69f9998a7194a589124475788b370adce57c9e9a56e \ + --hash=sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f \ + --hash=sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7 \ + --hash=sha256:adc0fe7d38d8c8058f7421c25508fcbc74df38233a42aa8324409844122dce8f \ + --hash=sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9 \ + --hash=sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d \ + --hash=sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d \ + --hash=sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52 \ + --hash=sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51 \ + --hash=sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0 \ + --hash=sha256:e195687f1af431c9515416288373b323b6eb599f774409814e89e9d603a56e39 \ + --hash=sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22 \ + --hash=sha256:e8e1b0f6a4dcd9b4839e2320afb5df37a6981cbc20ff9c423ae11c5537bdbd21 \ + --hash=sha256:ea1a8a2db4b2217d456c8d7a873bfc605f06fe3584d315264ea18c2a17585d0b \ + --hash=sha256:eefd9a03cc0047b14153872d228499d048bd7deaf926109c9ec25b15157b8e23 \ + --hash=sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086 \ + --hash=sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb # via # alembic # flask-sqlalchemy @@ -1201,9 +1262,9 @@ tomli-w==1.2.0 \ --hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \ --hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021 # via tox -tox==4.55.0 \ - --hash=sha256:7ede1e1e70f8fe984f7985d7ca58a1e1c15fe9f8715897e38accc607c8de9f70 \ - --hash=sha256:83603a222e7e2ffbeb9e92ed6516e31a0ce355b37aea13c82a2c5344274a9391 +tox==4.55.1 \ + --hash=sha256:0678fbf26dd5b559b1ef128fa4388325920219322ebc8cc5f3497627c00f4472 \ + --hash=sha256:e2084be6dfdef96ba1bed4948e6a1f73613d6952e1477be5dca45653d4c053c8 # via -r requirements-dev.in typing-extensions==4.15.0 \ --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ @@ -1224,9 +1285,9 @@ urllib3==2.7.0 \ # docker # requests # testcontainers -virtualenv==21.3.3 \ - --hash=sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3 \ - --hash=sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328 +virtualenv==21.4.2 \ + --hash=sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c \ + --hash=sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae # via # pre-commit # tox @@ -1236,97 +1297,97 @@ werkzeug==3.1.8 \ # via # flask # flask-login -wrapt==2.2.0 \ - --hash=sha256:03b77d3ecab6c38e5da7a5709cee6899083d08fc1bcd648b4fa78b346fc66282 \ - --hash=sha256:0680304db389599691bac06a2f9fb3f0ed06af59f132d35801a38cf6c321ab59 \ - --hash=sha256:07dd562ebb774cad070eeedb93c7a29647979e30f0cfd1f5c9b9f803f687b6f4 \ - --hash=sha256:10e8f78948d13369b770fc17bf72272aac98b4b92d49a38f479abf718f6b615b \ - --hash=sha256:115ff1501c11ac0e267c4afd6f6b3dd24b48afcc77b029e6062f71b12bce1d79 \ - --hash=sha256:12331011cbf76b782d0beec7c7ed880f51454c127ab12012cfaecf56de01a80c \ - --hash=sha256:195db5b92deba6feb818732694ad478abb8a529d97a113cc256e5e49ee2dd80d \ - --hash=sha256:199abadf7dcceab4bdc5bfe356275a56b1cb429296e283da2fe90c20b09f8d07 \ - --hash=sha256:1bf3ea62734b24c0241442d8b7684ef53a8de6cad0c2eba1e99fd2297b4a92e4 \ - --hash=sha256:1f663528d6ea1804d279462671b2bf98a4c0d8a4a8dd319bb3ee0629b743387f \ - --hash=sha256:22c7ee3a3737d9656ddf2c9cc1f1548ec963d966251e899561da142697d33a9d \ - --hash=sha256:231e2728ba04536821d2327ad2b3cb2c20cc79197fe5c30ddf71b12d95febe10 \ - --hash=sha256:298cfa8de891b9aae945b47323a012fe3f1cac5e6b2f69b150961b9ed0df1fc8 \ - --hash=sha256:29c0b2c075f8854b3345be584ab3d84f8968c45605d1914be1c94939cef5d702 \ - --hash=sha256:2b3946f0ff079623dc4f117363040433be390bfebce3719de50dfecbf31efdf0 \ - --hash=sha256:2f0d4a79d9af893d80caa5b709e024dd2d387f3f047008286036143f118d7010 \ - --hash=sha256:2ff803b3607cd76cb9b853b03d15279c7ffc8ba69e69f76304cd23d2722f2b65 \ - --hash=sha256:319720847afa6c58c32f84f9743bdcf34448ae56908c00f409764c627ff2c1fe \ - --hash=sha256:33ff34dc349320dc16ebe0cdf70dddf5ae9328f4a448823a00f37976d0cc2234 \ - --hash=sha256:370b2c36e8fee503c275e39b4588d74412cd0a7792f7f3a7b54c44c4d33d4884 \ - --hash=sha256:3f1dc1d1a2f0b081d8c1eef2203e61717b537a1bcb0d8e4d1405aeb15aa85c34 \ - --hash=sha256:3fab0258114702859bb9d410e6a886e79477e677ac92580f81b876e7c55590cc \ - --hash=sha256:4297b7338cfa48b5cfefc7416d2ae52b0aad89e9b24da479ec010717b987c07f \ - --hash=sha256:43c36019a690b2cb089665eab01a50c92d814553c6e57ff03d2c68e63ce8f00b \ - --hash=sha256:45d4156fd35d0bdab58eac4a6854fbd053a59544fc57eb66e977b3c13c087a1c \ - --hash=sha256:484015d345548472c54c97a318c6eba92db583d9d5a966dde7cf3ae0c1461cf4 \ - --hash=sha256:49c7ad697d6b13f322a1c3bb22a1c66827d5c0d303a4479e327210ee4d4ad179 \ - --hash=sha256:4b0aa81f4a3d0203ae8450eae5e794540afbf00a97dd0b81accbe5b4a5362cbb \ - --hash=sha256:4d5b485a6f617825fa7449f5025ebcdad9355acb328cb6d198ba225762219bc0 \ - --hash=sha256:5248171d3cd33f12c144e7aa1222983cb6ab42651e985ce51fec400a876afbfd \ - --hash=sha256:57bc3691043b158605c5ceee6b06b3720caf8ac43bd4195d1bfe12457e7014f6 \ - --hash=sha256:5b7f10aa09d1f5abfe3ccd022dec566a5010465b98b3755cc0705a762547101f \ - --hash=sha256:5b865e611c186d15366964e3d9500af504920ce7b92a211d61a83d2d3c42a508 \ - --hash=sha256:5b9733ef187cf05e774484ed2f703992a44429050f1cfea2e94dac543da78292 \ - --hash=sha256:5b9f9d351eb8e5798066b505c705ec25e19a793367edaa3280a3f171b6950fc3 \ - --hash=sha256:5c17982ccfece323bb297a195c9602ef407819199d8dbf99b8041770513fd68f \ - --hash=sha256:60bef9dc4348a76e9c2981ec4b06b779bac02556af4479030e6f62b18545b3cc \ - --hash=sha256:615be1d2b21450748e759bed7bf9ba8bc28307e91cb96b6e968f54f39e938ee5 \ - --hash=sha256:628fbd908649611c8b9293e2e050231f1e230be152e7d38140e3b818ec6aade0 \ - --hash=sha256:63a09b40bba3b2482983e2aeba6e45e20e1f567821ac89c8922229ecc1de7f65 \ - --hash=sha256:686f1798727bf4a708df015ca782b20abe99b3664e1ee9786b7712b0e2310586 \ - --hash=sha256:74b7949da2ffcd79869ac1e90946c14ce61a714269403a879ea9ed85a993c81f \ - --hash=sha256:76b8111f8f5b8553c066caa26193921dea4185efecf1f9b38473054205137800 \ - --hash=sha256:778aa2f59615973f2637d9025a708b69196c4814f38d905647fa1a56d7ff6b79 \ - --hash=sha256:7c5ffaf6e2d35e80bea210e6969910e2ae10c1166831651c22a315425db4f831 \ - --hash=sha256:7e291fa9129d9998ed5035390d4bb9cf429c489f40e5ddaa06a1e83ed52048a7 \ - --hash=sha256:8062689c0e6faf0c2532f566a492fb48ba60923c2cd6effda7cac9639dbdc1f3 \ - --hash=sha256:852bbcc75eab1771d4f294fb6abcc23cd38813e34fa3c71e6d579799493c4db2 \ - --hash=sha256:885638ab4f8765c5deaab41d1e4452b6d212d231091b84172e3e13df2cb280fb \ - --hash=sha256:8a094508b7cd6e583378f3cf50f125814961660225bad88f4ecaa691e30b09e1 \ - --hash=sha256:8a76b27fe0d600f8a34313e1a528309aa807a16aa3a72000619bc56339020125 \ - --hash=sha256:8d40f1fb34d600b3eaf812941d6bcf313075728868cad1dafb7021e6a4e77983 \ - --hash=sha256:9040b15216e07ed68762e44ff231a460036e4bf3543f83988f669e7078847b2c \ - --hash=sha256:914fdca0ee2a29ede32c61c28abdaf9c57b0d8c5de9dc1e28ce7e4f0400df877 \ - --hash=sha256:952ec99e71d584a0e451795dbd468909c8794727ecddd9ebb4fe9803e2803f1e \ - --hash=sha256:97fbe7a0df35afe37e7e2f053dee6300a3eed00055cfd907fa51161e22c40236 \ - --hash=sha256:9ad894d5dc5960ebd546a87a78160a8c645b99899e7e45a538436919bc9be5a6 \ - --hash=sha256:9b58e2cdbcfe2278a031a12a7d73836d66bc1e9e65f97c63ea0a022f2f9f351b \ - --hash=sha256:9c95f72d212e1f178f9619b77fd7ee3533e82ded6a5ad119dd88134e185ee3b0 \ - --hash=sha256:a3848854af260eb4cc33602c685524fff7c8816f033325f750c7fc75c6deccf9 \ - --hash=sha256:a4482d1d4108052827b354850bd6e3d1ed56262cbe4b0e8051876c298fb99280 \ - --hash=sha256:a50822bbbefb90b132a780c17356062a2452cd5525bfa4b5b596fd6474cceaa6 \ - --hash=sha256:a8ce59cad2ee5a4d58ee647c4ed4d9adc4282ffdc31e98cba7f831536776a0f9 \ - --hash=sha256:af17d3ce1e2cc5d22ae8fe8921d7801c980ea3f5d6da4ecbd0f85c4f9e030181 \ - --hash=sha256:b208a5dd6f9da3d4b17aa2e4f8ca9c5dc6b9a2ed571fdef9ed465102487b445c \ - --hash=sha256:b4ce4240a3f095e77cfcc5aed6001bd63af13ea53c35ef496af1a5a972e7eaa9 \ - --hash=sha256:b55f1fcbf83637f42eaf19c553ed69864ff25ac38c653ab024fccfaec8bd2e68 \ - --hash=sha256:b62f40eb24ccf05246d203461c8920889fd38dce76978df16fe28e6f0128447d \ - --hash=sha256:b70a0b75b0a5a58d04aad06b3f167d49e729381d3417413656220c0cd7617847 \ - --hash=sha256:b93e1ccddbdf59cec4f7683dc84bc56eb61628eb01b22bdefc15f04cd09f8fae \ - --hash=sha256:bb7c060c3faa78fe066b6b1c65de285d8d61fb6e01ee8195625b9636c3cd9775 \ - --hash=sha256:c7af243871699358ebf34a770205bf2b61ccb17a0b003e8726d2028cc36ce364 \ - --hash=sha256:c990d58100f9ebb8e7a20bd2e7bd3c60838be38c5bbccdd35041bc9f36dc0cea \ - --hash=sha256:cb9336f2dc99de00c9e58487cae5541ee4d79e859377b6312d98973d4661c584 \ - --hash=sha256:cccce5c70a209eb385c82d063f332ed97fc02d1cf7bffb95b2e6995b5a9b8388 \ - --hash=sha256:cf93c441b11c1f3ae2ccf1e8d876939b301b3234ec19f311ab0e7543a9d4427e \ - --hash=sha256:d23ea5a8e4ae99640d027d2fd05c9d03f8d24d561fc26c0462e96affa31bf408 \ - --hash=sha256:d2aab40474b6adae53d14d1f6a7785f4346a93c072adf1e69ca11a1b6afc789e \ - --hash=sha256:d8f6cf451ec4aab0cdbad128d9be1219e95ceaa9940566d71570b2d820ee50b3 \ - --hash=sha256:d98bf0078736df226e36875aa58a78f9d3b0888bcf585144fb30edbbf7145238 \ - --hash=sha256:db48e2623a8aca63dfcfa7e574a5f3a9f760be1c464ee23f6387f70cc9112aa2 \ - --hash=sha256:db93eebcf951f9ee41d75dc0423378fa918fc6706db59bc20c02f6563b6b210d \ - --hash=sha256:e8ae3f4b50a3befa56da0f09d2b71a192454ce48e8887823dbc9228cdbb610f3 \ - --hash=sha256:eb9d0c3f416e2c7c37498d1716fe323379da8b4e860da3d3818a6ec8fff7b7e5 \ - --hash=sha256:ec257eedd8c3988cf76e351e949e3a56a61d90f4bb4e060de2ebfa6603df2a42 \ - --hash=sha256:f0318a47d23c9407f4f94c06824662499e889ab8c192c1162e4f542a118fd700 \ - --hash=sha256:f58e1aa46c204171a2faa49b1ef2953edebb3913d270bb3bae7e970f254c9293 \ - --hash=sha256:f86e46490908a0ae2b2d633020c12e5283c85332d7ae0846f8a351a8a2da0b82 \ - --hash=sha256:f990f1b5c8ee4ff980bdef3f73f50728fd911b9ab8de8c43144e8019dcd845ff \ - --hash=sha256:fb240700f3b597c1d40d0932bfed2f4130fec2f02b8c2cb0bcdae45d321cb691 +wrapt==2.2.1 \ + --hash=sha256:036dfb40128819a751c6f451c6b9c10172c49e4c401aebcdb8ecf2aec1683598 \ + --hash=sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624 \ + --hash=sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85 \ + --hash=sha256:07be671fa8875971222b0ba9059ed8b4dc738631122feba17c93aa36b4213e9a \ + --hash=sha256:09ac16c081bebfd15d8e4dfa5bdc805990bbd52249ecff22530da7a129d6120b \ + --hash=sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710 \ + --hash=sha256:0f68f478004475d97906686e702ddbddeaf717c0b68ad2794384308f2dc713ae \ + --hash=sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188 \ + --hash=sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c \ + --hash=sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36 \ + --hash=sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508 \ + --hash=sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337 \ + --hash=sha256:2076d2335085eb09b9547e7688656fa8f5cf0183eab589d33499cd353489d797 \ + --hash=sha256:211f595f8e7faae5c5930fcc64708f2ba36849e0ba0fd653a843de9fa8d7db77 \ + --hash=sha256:24c52546acf2ab82412f2ab6fc5948a7fe958d3b4f070202e8dcdd865489eaf9 \ + --hash=sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956 \ + --hash=sha256:2de9e20769fe9c1f6dcdc893c6a89287c5ccf8537c90b5de78aed8017697aad5 \ + --hash=sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85 \ + --hash=sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052 \ + --hash=sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215 \ + --hash=sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f \ + --hash=sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0 \ + --hash=sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0 \ + --hash=sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a \ + --hash=sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243 \ + --hash=sha256:44255c84bc57554fed822e83e70036b51afa9edb56fc7ca56c54410ece7898c9 \ + --hash=sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b \ + --hash=sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53 \ + --hash=sha256:585916e210db57b23543342c2f298e42331b617fd0c934caf5c64df44de8640e \ + --hash=sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283 \ + --hash=sha256:5fa9bf3b9e66336589d03f42abce2da1055ad5c69b0c2b764852a8471c9b9114 \ + --hash=sha256:61a0013344674d2b648bc6e6fe9828dd4fc1d3b4eb7523809792f8cb952e2f16 \ + --hash=sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e \ + --hash=sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8 \ + --hash=sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c \ + --hash=sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9 \ + --hash=sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18 \ + --hash=sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413 \ + --hash=sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5 \ + --hash=sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab \ + --hash=sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926 \ + --hash=sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d \ + --hash=sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e \ + --hash=sha256:7975bc88ab4b0f72ef2a2d5ae9d77d87efb5ef95e8f8046242fa9afdaaf2030b \ + --hash=sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b \ + --hash=sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f \ + --hash=sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797 \ + --hash=sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd \ + --hash=sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579 \ + --hash=sha256:9011395be8db1827d106c6449b4bb6dd17e331ff6ec521f227e4588f1c78e46f \ + --hash=sha256:93fc2bf40cd7f4a0256010dce073d44eeb4a351b9bca94d0477ce2b6e62532b3 \ + --hash=sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a \ + --hash=sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562 \ + --hash=sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245 \ + --hash=sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a \ + --hash=sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027 \ + --hash=sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343 \ + --hash=sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440 \ + --hash=sha256:a8f7176b83664af44567e9cc06e0d3827823fcc1a5e52307ebb8ac3aa95860b9 \ + --hash=sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199 \ + --hash=sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27 \ + --hash=sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474 \ + --hash=sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1 \ + --hash=sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f \ + --hash=sha256:b6c0febfe38f22df2eb565c0ce8a092bb80411e56861ca382c443da83105423f \ + --hash=sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c \ + --hash=sha256:ba519b2d765df9871a25879e6f7fa78948ea59a2a31f9c1a257e34b651994afc \ + --hash=sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e \ + --hash=sha256:c3723ff8eb8721f4daac98bc0256f15158e05316d5e52648ce9cebee434fbdd5 \ + --hash=sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f \ + --hash=sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9 \ + --hash=sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394 \ + --hash=sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e \ + --hash=sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e \ + --hash=sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb \ + --hash=sha256:d7f513d3185e6fec82d0c3518f2e6365d8b4e49f5f45f29640d5162d56a23b54 \ + --hash=sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c \ + --hash=sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80 \ + --hash=sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143 \ + --hash=sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5 \ + --hash=sha256:e422b2d647a65d6b080cad5accd09055d3809bdff00c76fba8dca00ca935572a \ + --hash=sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a \ + --hash=sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8 \ + --hash=sha256:f4e1a92032a39cd5e3c647ca57dbf33b6a1938fd975623175793f9dbb63236de \ + --hash=sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31 \ + --hash=sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9 \ + --hash=sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181 \ + --hash=sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00 \ + --hash=sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8 \ + --hash=sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50 # via testcontainers wtforms==3.2.2 \ --hash=sha256:72b90d5d921bd3119252069cf0301e9c13915f9e52792652bc91c5dda4b79e56 \ diff --git a/requirements-e2e.txt b/requirements-e2e.txt index 145b18f0..4ea73a8a 100644 --- a/requirements-e2e.txt +++ b/requirements-e2e.txt @@ -220,9 +220,9 @@ greenlet==3.5.1 \ --hash=sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2 \ --hash=sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed # via playwright -idna==3.16 \ - --hash=sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5 \ - --hash=sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via requests iniconfig==2.3.0 \ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ diff --git a/requirements.in b/requirements.in index be0f9b58..a1e9a6a5 100644 --- a/requirements.in +++ b/requirements.in @@ -10,6 +10,7 @@ Flask-Mail Flask-WTF gunicorn psycopg2-binary +pyodbc schedule cryptography itsdangerous diff --git a/requirements.txt b/requirements.txt index 63bbfb22..86204489 100644 --- a/requirements.txt +++ b/requirements.txt @@ -476,6 +476,72 @@ pycparser==3.0 \ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via cffi +pyodbc==5.3.0 \ + --hash=sha256:01166162149adf2b8a6dc21a212718f205cabbbdff4047dc0c415af3fd85867e \ + --hash=sha256:0263323fc47082c2bf02562f44149446bbbfe91450d271e44bffec0c3143bfb1 \ + --hash=sha256:08b2439500e212625471d32f8fde418075a5ddec556e095e5a4ba56d61df2dc6 \ + --hash=sha256:0df7ff47fab91ea05548095b00e5eb87ed88ddf4648c58c67b4db95ea4913e23 \ + --hash=sha256:101313a21d2654df856a60e4a13763e4d9f6c5d3fd974bcf3fc6b4e86d1bbe8e \ + --hash=sha256:13656184faa3f2d5c6f19b701b8f247342ed581484f58bf39af7315c054e69db \ + --hash=sha256:1629af4706e9228d79dabb4863c11cceb22a6dab90700db0ef449074f0150c0d \ + --hash=sha256:197bb6ddafe356a916b8ee1b8752009057fce58e216e887e2174b24c7ab99269 \ + --hash=sha256:2035c7dfb71677cd5be64d3a3eb0779560279f0a8dc6e33673499498caa88937 \ + --hash=sha256:25b6766e56748eb1fc1d567d863e06cbb7b7c749a41dfed85db0031e696fa39a \ + --hash=sha256:25c4cfb2c08e77bc6e82f666d7acd52f0e52a0401b1876e60f03c73c3b8aedc0 \ + --hash=sha256:2eb7151ed0a1959cae65b6ac0454f5c8bbcd2d8bafeae66483c09d58b0c7a7fc \ + --hash=sha256:2fe0e063d8fb66efd0ac6dc39236c4de1a45f17c33eaded0d553d21c199f4d05 \ + --hash=sha256:349a9abae62a968b98f6bbd23d2825151f8d9de50b3a8f5f3271b48958fdb672 \ + --hash=sha256:363311bd40320b4a61454bebf7c38b243cd67c762ed0f8a5219de3ec90c96353 \ + --hash=sha256:3cc472c8ae2feea5b4512e23b56e2b093d64f7cbc4b970af51da488429ff7818 \ + --hash=sha256:3f1bdb3ce6480a17afaaef4b5242b356d4997a872f39e96f015cabef00613797 \ + --hash=sha256:452e7911a35ee12a56b111ac5b596d6ed865b83fcde8427127913df53132759e \ + --hash=sha256:46185a1a7f409761716c71de7b95e7bbb004390c650d00b0b170193e3d6224bb \ + --hash=sha256:46869b9a6555ff003ed1d8ebad6708423adf2a5c88e1a578b9f029fb1435186e \ + --hash=sha256:58635a1cc859d5af3f878c85910e5d7228fe5c406d4571bffcdd281375a54b39 \ + --hash=sha256:5cbe4d753723c8a8f65020b7a259183ef5f14307587165ce37e8c7e251951852 \ + --hash=sha256:5ceaed87ba2ea848c11223f66f629ef121f6ebe621f605cde9cfdee4fd9f4b68 \ + --hash=sha256:5dd3d5e469f89a3112cf8b0658c43108a4712fad65e576071e4dd44d2bd763c7 \ + --hash=sha256:5ebf6b5d989395efe722b02b010cb9815698a4d681921bf5db1c0e1195ac1bde \ + --hash=sha256:6132554ffbd7910524d643f13ce17f4a72f3a6824b0adef4e9a7f66efac96350 \ + --hash=sha256:6682cdec78f1302d0c559422c8e00991668e039ed63dece8bf99ef62173376a5 \ + --hash=sha256:676031723aac7dcbbd2813bddda0e8abf171b20ec218ab8dfb21d64a193430ea \ + --hash=sha256:705903acf6f43c44fc64e764578d9a88649eb21bf7418d78677a9d2e337f56f2 \ + --hash=sha256:729c535341bb09c476f219d6f7ab194bcb683c4a0a368010f1cb821a35136f05 \ + --hash=sha256:74528fe148980d0c735c0ebb4a4dc74643ac4574337c43c1006ac4d09593f92d \ + --hash=sha256:754d052030d00c3ac38da09ceb9f3e240e8dd1c11da8906f482d5419c65b9ef5 \ + --hash=sha256:7713c740a10f33df3cb08f49a023b7e1e25de0c7c99650876bbe717bc95ee780 \ + --hash=sha256:7e9ab0b91de28a5ab838ac4db0253d7cc8ce2452efe4ad92ee6a57b922bf0c24 \ + --hash=sha256:8339d3094858893c1a68ee1af93efc4dff18b8b65de54d99104b99af6306320d \ + --hash=sha256:8aa396c6d6af52ccd51b8c8a5bffbb46fd44e52ce07ea4272c1d28e5e5b12722 \ + --hash=sha256:9b987a25a384f31e373903005554230f5a6d59af78bce62954386736a902a4b3 \ + --hash=sha256:9cd3f0a9796b3e1170a9fa168c7e7ca81879142f30e20f46663b882db139b7d2 \ + --hash=sha256:a48d731432abaee5256ed6a19a3e1528b8881f9cb25cb9cf72d8318146ea991b \ + --hash=sha256:ac23feb7ddaa729f6b840639e92f83ff0ccaa7072801d944f1332cd5f5b05f47 \ + --hash=sha256:af4d8c9842fc4a6360c31c35508d6594d5a3b39922f61b282c2b4c9d9da99514 \ + --hash=sha256:afe7c4ac555a8d10a36234788fc6cfc22a86ce37fc5ba88a1f75b3e6696665dc \ + --hash=sha256:b180bc5e49b74fd40a24ef5b0fe143d0c234ac1506febe810d7434bf47cb925b \ + --hash=sha256:b35b9983ad300e5aea82b8d1661fc9d3afe5868de527ee6bd252dd550e61ecd6 \ + --hash=sha256:bc834567c2990584b9726cba365834d039380c9dbbcef3030ddeb00c6541b943 \ + --hash=sha256:bfeb3e34795d53b7d37e66dd54891d4f9c13a3889a8f5fe9640e56a82d770955 \ + --hash=sha256:c25dc9c41f61573bdcf61a3408c34b65e4c0f821b8f861ca7531b1353b389804 \ + --hash=sha256:c2eb0b08e24fe5c40c7ebe9240c5d3bd2f18cd5617229acee4b0a0484dc226f2 \ + --hash=sha256:c5c30c5cd40b751f77bbc73edd32c4498630939bcd4e72ee7e6c9a4b982cc5ca \ + --hash=sha256:c67e7f2ce649155ea89beb54d3b42d83770488f025cf3b6f39ca82e9c598a02e \ + --hash=sha256:c68d9c225a97aedafb7fff1c0e1bfe293093f77da19eaf200d0e988fa2718d16 \ + --hash=sha256:c6ccb5315ec9e081f5cbd66f36acbc820ad172b8fa3736cf7f993cdf69bd8a96 \ + --hash=sha256:c79df54bbc25bce9f2d87094e7b39089c28428df5443d1902b0cc5f43fd2da6f \ + --hash=sha256:cf18797a12e70474e1b7f5027deeeccea816372497e3ff2d46b15bec2d18a0cc \ + --hash=sha256:d255f6b117d05cfc046a5201fdf39535264045352ea536c35777cf66d321fbb8 \ + --hash=sha256:d32c3259762bef440707098010035bbc83d1c73d81a434018ab8c688158bd3bb \ + --hash=sha256:d89a7f2e24227150c13be8164774b7e1f9678321a4248f1356a465b9cc17d31e \ + --hash=sha256:e3c39de3005fff3ae79246f952720d44affc6756b4b85398da4c5ea76bf8f506 \ + --hash=sha256:e981db84fee4cebec67f41bd266e1e7926665f1b99c3f8f4ea73cd7f7666e381 \ + --hash=sha256:ebc3be93f61ea0553db88589e683ace12bf975baa954af4834ab89f5ee7bf8ae \ + --hash=sha256:f1ad0e93612a6201621853fc661209d82ff2a35892b7d590106fe8f97d9f1f2a \ + --hash=sha256:f927b440c38ade1668f0da64047ffd20ec34e32d817f9a60d07553301324b364 \ + --hash=sha256:fc5ac4f2165f7088e74ecec5413b5c304247949f9702c8853b0e43023b4187e8 \ + --hash=sha256:fe77eb9dcca5fc1300c9121f81040cc9011d28cff383e2c35416e9ec06d4bc95 + # via -r requirements.in python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 @@ -494,70 +560,65 @@ six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 # via python-dateutil -sqlalchemy==2.0.49 \ - --hash=sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72 \ - --hash=sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe \ - --hash=sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75 \ - --hash=sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5 \ - --hash=sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148 \ - --hash=sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7 \ - --hash=sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e \ - --hash=sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518 \ - --hash=sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7 \ - --hash=sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700 \ - --hash=sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717 \ - --hash=sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672 \ - --hash=sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88 \ - --hash=sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f \ - --hash=sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f \ - --hash=sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08 \ - --hash=sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a \ - --hash=sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3 \ - --hash=sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b \ - --hash=sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536 \ - --hash=sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0 \ - --hash=sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b \ - --hash=sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a \ - --hash=sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3 \ - --hash=sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4 \ - --hash=sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339 \ - --hash=sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158 \ - --hash=sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066 \ - --hash=sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662 \ - --hash=sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1 \ - --hash=sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3 \ - --hash=sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5 \ - --hash=sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01 \ - --hash=sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613 \ - --hash=sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a \ - --hash=sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0 \ - --hash=sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f \ - --hash=sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a \ - --hash=sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e \ - --hash=sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2 \ - --hash=sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af \ - --hash=sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014 \ - --hash=sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33 \ - --hash=sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61 \ - --hash=sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d \ - --hash=sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187 \ - --hash=sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401 \ - --hash=sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b \ - --hash=sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d \ - --hash=sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f \ - --hash=sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba \ - --hash=sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977 \ - --hash=sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a \ - --hash=sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe \ - --hash=sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b \ - --hash=sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f \ - --hash=sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1 \ - --hash=sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4 \ - --hash=sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d \ - --hash=sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120 \ - --hash=sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750 \ - --hash=sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0 \ - --hash=sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982 +sqlalchemy==2.0.50 \ + --hash=sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064 \ + --hash=sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093 \ + --hash=sha256:0a31c5963d58d3e3d11c5b97709e248305705de1fdf51ec3bf396674c5898b7e \ + --hash=sha256:0e104e196f457ec608eb8af736c5eb4c6bc58f481b546f485a7f9c628ee532be \ + --hash=sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e \ + --hash=sha256:0fe7822866f3a9fc5f3db21a290ce8961a53050115f05edf9402b6a5feb92a9f \ + --hash=sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86 \ + --hash=sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600 \ + --hash=sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a \ + --hash=sha256:13b85b20f9ab714a666df9d8e72e253ec33c16c7e1e375c877e5bf6367a3e917 \ + --hash=sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39 \ + --hash=sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a \ + --hash=sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508 \ + --hash=sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5 \ + --hash=sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e \ + --hash=sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb \ + --hash=sha256:27b7062af702c61994e8806ad87e42d0a2c879e0a8e5c61c7f69d81dabe24fdf \ + --hash=sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3 \ + --hash=sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f \ + --hash=sha256:2c1920cde9d741ba3dda9b1aa5acd8c23ea17780ccfb2252d01878d5d0d628d3 \ + --hash=sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c \ + --hash=sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db \ + --hash=sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70 \ + --hash=sha256:3d10700bd519573f6ce5badbabbfe7f5baea84cdf370f2cbbfb4be28dfddbf1d \ + --hash=sha256:409a8121b917116b035bedc5e532ad470c74a2d279f6c302100985b6304e9f9e \ + --hash=sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89 \ + --hash=sha256:4a8e8af330cbb3a1931d3d6c91b239fc2ef135f7dd471dfa34c575028e0b1fa8 \ + --hash=sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3 \ + --hash=sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4 \ + --hash=sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5 \ + --hash=sha256:66e374271ecb7101273f57af1a62446a953d327eec4f8089147de57c591bbacc \ + --hash=sha256:68b154b08088b4ec32bb4d2958bfbb50e57549f91a4cd3e7f928e3553ed69031 \ + --hash=sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e \ + --hash=sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615 \ + --hash=sha256:7af6eeb84985bf840ba779018ff9424d61ff69b52e66b8789d3c8da7bf5341b2 \ + --hash=sha256:7b1ddb7b5fc60dfa9df6a487f06a143c77def47c0351849da2bcea59b244a56c \ + --hash=sha256:7e36efdcc5493f8024ec873a4ee3855bfd2de0c5b19eba16f920e9d2a0d28622 \ + --hash=sha256:83a9fce296b7e052316d8c6943237b31b9c00f58ca9c253f2d165df52637a293 \ + --hash=sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873 \ + --hash=sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8 \ + --hash=sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9 \ + --hash=sha256:9602c07b03e1449747ecb69f9998a7194a589124475788b370adce57c9e9a56e \ + --hash=sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f \ + --hash=sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7 \ + --hash=sha256:adc0fe7d38d8c8058f7421c25508fcbc74df38233a42aa8324409844122dce8f \ + --hash=sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9 \ + --hash=sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d \ + --hash=sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d \ + --hash=sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52 \ + --hash=sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51 \ + --hash=sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0 \ + --hash=sha256:e195687f1af431c9515416288373b323b6eb599f774409814e89e9d603a56e39 \ + --hash=sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22 \ + --hash=sha256:e8e1b0f6a4dcd9b4839e2320afb5df37a6981cbc20ff9c423ae11c5537bdbd21 \ + --hash=sha256:ea1a8a2db4b2217d456c8d7a873bfc605f06fe3584d315264ea18c2a17585d0b \ + --hash=sha256:eefd9a03cc0047b14153872d228499d048bd7deaf926109c9ec25b15157b8e23 \ + --hash=sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086 \ + --hash=sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb # via # alembic # flask-sqlalchemy diff --git a/tests/test_rp_logic.py b/tests/test_rp_logic.py index 7d0c4705..1f98d852 100644 --- a/tests/test_rp_logic.py +++ b/tests/test_rp_logic.py @@ -2,6 +2,8 @@ from datetime import datetime, timedelta, timezone +import sqlalchemy as sa + from app.extensions import db from app.models.assignment import Assignment from app.models.event import Event, EventSpot, EventStatus @@ -859,7 +861,7 @@ def test_rp_eligible_users_sorted_czech(self, app): """Users with RP qualification are returned sorted by Czech collation.""" with app.app_context(): - qual = db.session.scalar(db.select(Qualification).where(Qualification.can_be_rp.is_(True))) + qual = db.session.scalar(db.select(Qualification).where(Qualification.can_be_rp == sa.true())) if not qual: qual = Qualification(name="RPQual", can_be_rp=True) db.session.add(qual) From e10f30f6489467228c6c63931e3da439e6ec2242 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Wed, 10 Jun 2026 05:53:58 +0200 Subject: [PATCH 02/31] Make CS_COLLATION dialect-aware (MSSQL uses Czech_100_CI_AS_SC_UTF8) --- app/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/utils.py b/app/utils.py index 70e50cfb..fb3766b0 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,5 +1,6 @@ """Shared utility helpers for the MedCover application.""" +import os from calendar import monthrange from datetime import date from typing import TypeVar @@ -33,10 +34,9 @@ def get_app_tz() -> ZoneInfo: # ── Czech locale-aware sorting ──────────────────────────────────────────────── -# PostgreSQL ICU collation name to use in all order_by() calls where -# user-visible names are sorted. cs-x-icu provides proper Czech alphabet -# ordering (a á b c č d ď e é ě … ch … ř š … ž) within the DB engine. -CS_COLLATION = "cs-x-icu" +# Collation name for ORDER BY on user-visible text columns. +# PostgreSQL uses ICU collation "cs-x-icu"; MSSQL uses "Czech_100_CI_AS_SC_UTF8". +CS_COLLATION: str = "Czech_100_CI_AS_SC_UTF8" if os.environ.get("DATABASE_URL", "").startswith("mssql") else "cs-x-icu" # Czech alphabet in correct order including digraph 'ch'. _CS_ALPHABET: list[str] = [ From 873be6dc44b406ea72a52e180d4dfac876d6a842 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Wed, 10 Jun 2026 06:20:47 +0200 Subject: [PATCH 03/31] Make backup/restore dialect-aware (MSSQL + PostgreSQL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LIMIT 1 → TOP 1 on MSSQL for alembic version query - TRUNCATE CASCADE → DELETE with NOCHECK CONSTRAINT on MSSQL - pg_get_serial_sequence/setval → DBCC CHECKIDENT on MSSQL --- app/backup.py | 53 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/app/backup.py b/app/backup.py index 0e20845e..40f9aaec 100644 --- a/app/backup.py +++ b/app/backup.py @@ -94,7 +94,10 @@ def _get_alembic_head() -> str: """ try: with db.engine.connect() as conn: - row = conn.execute(sa.text("SELECT version_num FROM alembic_version LIMIT 1")).fetchone() + if db.engine.dialect.name == "mssql": + row = conn.execute(sa.text("SELECT TOP 1 version_num FROM alembic_version")).fetchone() + else: + row = conn.execute(sa.text("SELECT version_num FROM alembic_version LIMIT 1")).fetchone() return str(row[0]) if row else "unknown" except Exception: return "unknown" @@ -222,8 +225,17 @@ def restore_from_zip(zip_path: str | Path) -> None: } if tables_to_clear: - quoted = ", ".join(f'"{t}"' for t in tables_to_clear) - conn.execute(sa.text(f"TRUNCATE {quoted} RESTART IDENTITY CASCADE")) + if db.engine.dialect.name == "mssql": + # MSSQL: disable FK checks, delete all rows, re-enable + for t in tables_to_clear: + conn.execute(sa.text(f"ALTER TABLE [{t}] NOCHECK CONSTRAINT ALL")) + for t in tables_to_clear: + conn.execute(sa.text(f"DELETE FROM [{t}]")) + for t in tables_to_clear: + conn.execute(sa.text(f"ALTER TABLE [{t}] CHECK CONSTRAINT ALL")) + else: + quoted = ", ".join(f'"{t}"' for t in tables_to_clear) + conn.execute(sa.text(f"TRUNCATE {quoted} RESTART IDENTITY CASCADE")) # Re-insert rows, skipping columns that no longer exist in the schema. for table_name in restore_sequence: @@ -250,19 +262,32 @@ def restore_from_zip(zip_path: str | Path) -> None: conn.commit() # Reset sequences so future INSERTs don't collide with restored IDs. - # Each table is in its own commit so a failure on one doesn't affect others. + # PostgreSQL uses sequences; MSSQL uses IDENTITY — reseed via DBCC. for table_name in tables_to_clear: try: - pk_cols = inspector.get_pk_constraint(table_name).get("constrained_columns", []) - for pk in pk_cols: - seq = conn.execute(sa.text(f"SELECT pg_get_serial_sequence('{table_name}', '{pk}')")).scalar() - if seq: - next_id = conn.execute( - sa.text(f'SELECT COALESCE(MAX("{pk}"), 0) + 1 FROM "{table_name}"') - ).scalar() - if next_id is not None: - conn.execute(sa.text(f"SELECT setval('{seq}', {int(next_id)}, false)")) - conn.commit() + if db.engine.dialect.name == "mssql": + # Reseed IDENTITY columns to max(pk)+1 (or 0 if table is empty) + pk_cols = inspector.get_pk_constraint(table_name).get("constrained_columns", []) + for pk in pk_cols: + col_info = next((c for c in inspector.get_columns(table_name) if c["name"] == pk), None) + if col_info and col_info.get("autoincrement", False): + max_id = conn.execute( + sa.text(f"SELECT COALESCE(MAX([{pk}]), 0) FROM [{table_name}]") + ).scalar() + if max_id is not None: + conn.execute(sa.text(f"DBCC CHECKIDENT('[{table_name}]', RESEED, {int(max_id)})")) + conn.commit() + else: + pk_cols = inspector.get_pk_constraint(table_name).get("constrained_columns", []) + for pk in pk_cols: + seq = conn.execute(sa.text(f"SELECT pg_get_serial_sequence('{table_name}', '{pk}')")).scalar() + if seq: + next_id = conn.execute( + sa.text(f'SELECT COALESCE(MAX("{pk}"), 0) + 1 FROM "{table_name}"') + ).scalar() + if next_id is not None: + conn.execute(sa.text(f"SELECT setval('{seq}', {int(next_id)}, false)")) + conn.commit() except Exception as exc: conn.rollback() log.debug("Could not reset sequence for %s: %s", table_name, exc) From d66034ee3bd7a29b2e166298d086f1bcd3a5791e Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Wed, 10 Jun 2026 06:21:40 +0200 Subject: [PATCH 04/31] Make server_stats digest block dialect-aware (MSSQL + PostgreSQL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pg_database_size → sys.database_files size sum on MSSQL - pg_statio_user_tables → sys.tables/allocation_units on MSSQL --- app/digest/blocks/server_stats.py | 52 ++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/app/digest/blocks/server_stats.py b/app/digest/blocks/server_stats.py index 8cfccf2c..8baf4a4d 100644 --- a/app/digest/blocks/server_stats.py +++ b/app/digest/blocks/server_stats.py @@ -45,26 +45,50 @@ def collect(self, db_session: Any, config: dict[str, Any]) -> dict[str, Any]: if config.get("show_db_size", True): try: - row = db_session.execute( - sa.text("SELECT pg_size_pretty(pg_database_size(current_database()))") - ).fetchone() - data["db_size"] = row[0] if row else "N/A" + dialect = db_session.bind.dialect.name if db_session.bind else "postgresql" + if dialect == "mssql": + row = db_session.execute( + sa.text("SELECT CAST(SUM(size) * 8.0 / 1024 AS DECIMAL(10,2)) " "FROM sys.database_files") + ).fetchone() + data["db_size"] = f"{row[0]} MB" if row and row[0] else "N/A" + else: + row = db_session.execute( + sa.text("SELECT pg_size_pretty(pg_database_size(current_database()))") + ).fetchone() + data["db_size"] = row[0] if row else "N/A" except Exception: # noqa: BLE001 data["db_size"] = "N/A" if config.get("show_table_sizes", True): try: max_table_rows = int(config.get("max_table_rows", 5)) - rows = db_session.execute( - sa.text(""" - SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS pretty_size - FROM pg_catalog.pg_statio_user_tables - ORDER BY pg_total_relation_size(relid) DESC - LIMIT :limit - """), - {"limit": max_table_rows}, - ).fetchall() - data["table_sizes"] = [(r[0], r[1]) for r in rows] + dialect = db_session.bind.dialect.name if db_session.bind else "postgresql" + if dialect == "mssql": + rows = db_session.execute( + sa.text(""" + SELECT TOP(:limit) t.name, + CAST(SUM(a.total_pages) * 8.0 / 1024 AS DECIMAL(10,2)) AS size_mb + FROM sys.tables t + JOIN sys.indexes i ON t.object_id = i.object_id + JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id + JOIN sys.allocation_units a ON p.partition_id = a.container_id + GROUP BY t.name + ORDER BY SUM(a.total_pages) DESC + """), + {"limit": max_table_rows}, + ).fetchall() + data["table_sizes"] = [(r[0], f"{r[1]} MB") for r in rows] + else: + rows = db_session.execute( + sa.text(""" + SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS pretty_size + FROM pg_catalog.pg_statio_user_tables + ORDER BY pg_total_relation_size(relid) DESC + LIMIT :limit + """), + {"limit": max_table_rows}, + ).fetchall() + data["table_sizes"] = [(r[0], r[1]) for r in rows] except Exception: # noqa: BLE001 data["table_sizes"] = [] From f22636ac4c75a518e9621a47e73c1a4fc49b334a Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Wed, 10 Jun 2026 06:22:49 +0200 Subject: [PATCH 05/31] Make outbox polling dialect-aware: READPAST hint on MSSQL MSSQL does not support FOR UPDATE ... SKIP LOCKED. Use WITH (UPDLOCK, ROWLOCK, READPAST) table hint instead. --- app/mail.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/mail.py b/app/mail.py index b510cf2a..27cbede1 100644 --- a/app/mail.py +++ b/app/mail.py @@ -534,7 +534,7 @@ def drain_one_outbox_email() -> bool: from app.extensions import mail as _mail # pylint: disable=import-outside-toplevel - row: OutboxEmail | None = db.session.scalars( + query = ( db.select(OutboxEmail) .where( OutboxEmail.status == "pending", @@ -542,8 +542,14 @@ def drain_one_outbox_email() -> bool: ) .order_by(OutboxEmail.created_at.asc()) .limit(1) - .with_for_update(skip_locked=True) - ).first() + ) + if db.engine.dialect.name == "mssql": + # MSSQL: UPDLOCK + READPAST = skip locked rows (equivalent of skip_locked) + query = query.with_hint(OutboxEmail, "WITH (UPDLOCK, ROWLOCK, READPAST)") + else: + query = query.with_for_update(skip_locked=True) + + row: OutboxEmail | None = db.session.scalars(query).first() if row is None: return False From 8ff31a9ef152f64bcd227e28034a437454eaedc6 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Wed, 10 Jun 2026 06:37:14 +0200 Subject: [PATCH 06/31] Add e2e test support for MSSQL - docker-compose.e2e-mssql.yml: standalone e2e stack with MSSQL 2022 - mssql-init/setup-e2e.sh: e2e database creation script - e2e-entrypoint.sh: dialect-aware DB wait, auto-creates MSSQL DB+user All 123 e2e tests pass (Chromium, Firefox, WebKit) against MSSQL. --- docker-compose.e2e-mssql.yml | 90 ++++++++++++++++++++++++++++++++++++ mssql-init/setup-e2e.sh | 60 ++++++++++++++++++++++++ scripts/e2e-entrypoint.sh | 59 +++++++++++++++++++++-- 3 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 docker-compose.e2e-mssql.yml create mode 100755 mssql-init/setup-e2e.sh diff --git a/docker-compose.e2e-mssql.yml b/docker-compose.e2e-mssql.yml new file mode 100644 index 00000000..6f9b618a --- /dev/null +++ b/docker-compose.e2e-mssql.yml @@ -0,0 +1,90 @@ +# docker-compose.e2e-mssql.yml +# +# E2E test stack backed by MSSQL Server 2022 (LOCAL DEV ONLY). +# Mirrors docker-compose.e2e.yml but replaces PostgreSQL with MSSQL. +# +# Usage: +# podman compose -f docker-compose.e2e-mssql.yml up -d --build +# # Wait for db to be healthy, then init: +# podman exec medcover-e2e-mssql-db-1 /docker-entrypoint-initdb.d/setup-e2e.sh +# # Then run e2e tests (or just: podman compose -f docker-compose.e2e-mssql.yml up e2e) + +services: + db-e2e: + image: mcr.microsoft.com/mssql/server:2022-latest + platform: linux/amd64 + tmpfs: + - /var/opt/mssql/data + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "E2e_Password1!" + MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" + MSSQL_MEMORY_LIMIT_MB: "512" + volumes: + - ./mssql-init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "E2e_Password1!", "-C", "-Q", "SELECT 1", "-b"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 20s + + web-e2e: + build: + context: . + args: + GIT_COMMIT: e2e-mssql + entrypoint: ["/e2e-entrypoint.sh"] + command: ["flask", "run", "--debug", "--host=0.0.0.0"] + volumes: + - ./scripts/e2e-entrypoint.sh:/e2e-entrypoint.sh:ro + - ./scripts:/app/scripts:ro + environment: + FLASK_ENV: development + FLASK_DEBUG: "1" + SECRET_KEY: e2e-test-secret-key + DATABASE_URL: "mssql+pyodbc://medcover:E2e_Password1!@db-e2e:1433/medcover_e2e?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes" + MSSQL_SA_PASSWORD: "E2e_Password1!" + DEV_LOGIN_ENABLED: "true" + INSTANCE_ID: e2e-mssql + depends_on: + db-e2e: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 30s + + e2e: + build: + context: . + dockerfile: Dockerfile.e2e + working_dir: /e2e + dns: + - 8.8.8.8 + - 1.1.1.1 + volumes: + - ./e2e_tests:/e2e + - ./e2e-report:/e2e-report + environment: + BASE_URL: http://web-e2e:5000 + E2E_BROWSERS: ${E2E_BROWSERS:---browser chromium --browser firefox --browser webkit} + entrypoint: + - "sh" + - "-c" + - | + echo 'Waiting for web-e2e...' + retries=60 + until curl -sf http://web-e2e:5000/health >/dev/null 2>&1; do + retries=$$((retries-1)) + [ "$$retries" -le 0 ] && echo 'web-e2e did not become ready in time' && exit 1 + sleep 2 + done + echo 'web-e2e ready' + pytest /e2e $E2E_BROWSERS -v --screenshot on --output /e2e-report/traces --html=/e2e-report/report.html --self-contained-html -o 'addopts=' + depends_on: + web-e2e: + condition: service_healthy diff --git a/mssql-init/setup-e2e.sh b/mssql-init/setup-e2e.sh new file mode 100755 index 00000000..443bd419 --- /dev/null +++ b/mssql-init/setup-e2e.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# mssql-init/setup-e2e.sh +# +# Create the e2e test database in the MSSQL container. +# Run after the container is healthy: +# podman exec /docker-entrypoint-initdb.d/setup-e2e.sh + +set -e + +SQLCMD="/opt/mssql-tools18/bin/sqlcmd" +SA_PASSWORD="E2e_Password1!" + +echo "Waiting for SQL Server to be ready..." +for i in $(seq 1 30); do + if $SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q "SELECT 1" &>/dev/null; then + echo "SQL Server is ready." + break + fi + echo " Attempt $i/30 — not ready yet..." + sleep 2 +done + +echo "Creating database medcover_e2e..." +$SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q " +IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'medcover_e2e') +BEGIN + CREATE DATABASE medcover_e2e + COLLATE Czech_100_CI_AS_SC_UTF8; + ALTER DATABASE medcover_e2e SET READ_COMMITTED_SNAPSHOT ON; + PRINT 'Database medcover_e2e created with RCSI enabled.'; +END +ELSE +BEGIN + PRINT 'Database medcover_e2e already exists.'; +END +" + +echo "Creating login and user..." +$SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q " +IF NOT EXISTS (SELECT name FROM sys.server_principals WHERE name = 'medcover') +BEGIN + CREATE LOGIN medcover WITH PASSWORD = 'E2e_Password1!'; + PRINT 'Login medcover created.'; +END +ELSE + PRINT 'Login medcover already exists.'; +" + +$SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -d medcover_e2e -Q " +IF NOT EXISTS (SELECT name FROM sys.database_principals WHERE name = 'medcover') +BEGIN + CREATE USER medcover FOR LOGIN medcover; + ALTER ROLE db_owner ADD MEMBER medcover; + PRINT 'User medcover created and added to db_owner.'; +END +ELSE + PRINT 'User medcover already exists.'; +" + +echo "✓ MSSQL e2e setup complete." diff --git a/scripts/e2e-entrypoint.sh b/scripts/e2e-entrypoint.sh index 53476e5f..bd8c3ba2 100755 --- a/scripts/e2e-entrypoint.sh +++ b/scripts/e2e-entrypoint.sh @@ -4,10 +4,31 @@ set -e echo "=== E2E: Waiting for database ===" MAX_RETRIES=${DB_WAIT_RETRIES:-60} RETRY=0 -until python -c " + +# Detect DB type from DATABASE_URL +case "${DATABASE_URL}" in + mssql*) + DB_CHECK_FILE=$(mktemp) + # Wait for MSSQL server (connect to master — target DB may not exist yet) + cat > "$DB_CHECK_FILE" << 'PYEOF' +import os, pyodbc, re +url = os.environ["DATABASE_URL"] +m = re.match(r"mssql\+pyodbc://([^:]+):([^@]+)@([^:]+):(\d+)/([^?]+)", url) +user, pwd, host, port, db = m.groups() +sa_pwd = os.environ.get("MSSQL_SA_PASSWORD", pwd) +pyodbc.connect(f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={host},{port};DATABASE=master;UID=sa;PWD={sa_pwd};Encrypt=no;TrustServerCertificate=yes", timeout=5).close() +PYEOF + ;; + *) + DB_CHECK_FILE=$(mktemp) + cat > "$DB_CHECK_FILE" << 'PYEOF' import os, psycopg2 -psycopg2.connect(os.environ['DATABASE_URL'], connect_timeout=5).close() -" 2>/dev/null; do +psycopg2.connect(os.environ["DATABASE_URL"], connect_timeout=5).close() +PYEOF + ;; +esac + +until python "$DB_CHECK_FILE" 2>/dev/null; do RETRY=$((RETRY+1)) if [ "$RETRY" -ge "$MAX_RETRIES" ]; then echo " ...database not ready after ${MAX_RETRIES} retries, exiting" @@ -16,9 +37,39 @@ psycopg2.connect(os.environ['DATABASE_URL'], connect_timeout=5).close() echo " ...database not ready, retrying in 2s (attempt $RETRY/$MAX_RETRIES)" sleep 2 done +rm -f "$DB_CHECK_FILE" echo "=== E2E: Running database migrations ===" -flask db upgrade +case "${DATABASE_URL}" in + mssql*) + # Create DB and user via sa account, then stamp+migrate + echo " Creating MSSQL database (if not exists)..." + python << 'PYEOF' +import os, pyodbc, re +url = os.environ["DATABASE_URL"] +m = re.match(r"mssql\+pyodbc://([^:]+):([^@]+)@([^:]+):(\d+)/([^?]+)", url) +user, pwd, host, port, db = m.groups() +sa_pwd = os.environ.get("MSSQL_SA_PASSWORD", pwd) +conn = pyodbc.connect(f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={host},{port};DATABASE=master;UID=sa;PWD={sa_pwd};Encrypt=no;TrustServerCertificate=yes", autocommit=True) +c = conn.cursor() +c.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name='{db}') CREATE DATABASE [{db}] COLLATE Czech_100_CI_AS_SC_UTF8") +c.execute(f"ALTER DATABASE [{db}] SET READ_COMMITTED_SNAPSHOT ON") +c.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name='{user}') CREATE LOGIN [{user}] WITH PASSWORD='{pwd}'") +conn.close() +conn2 = pyodbc.connect(f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={host},{port};DATABASE={db};UID=sa;PWD={sa_pwd};Encrypt=no;TrustServerCertificate=yes", autocommit=True) +c2 = conn2.cursor() +c2.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name='{user}') BEGIN CREATE USER [{user}] FOR LOGIN [{user}]; ALTER ROLE db_owner ADD MEMBER [{user}]; END") +conn2.close() +print(f" Database '{db}' ready.") +PYEOF + flask db stamp head + flask db migrate -m "e2e_mssql_auto" + flask db upgrade + ;; + *) + flask db upgrade + ;; +esac echo "=== E2E: Seeding test data ===" python scripts/seed_dev.py From 61238a2e3f1a356622128eb25936ddc2ca07ce4f Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Wed, 10 Jun 2026 07:28:22 +0200 Subject: [PATCH 07/31] Address CodeRabbit review: safe identifier quoting, input validation, timeout handling --- app/backup.py | 41 +++++++++++++++++++++++++-------------- mssql-init/setup-e2e.sh | 7 +++++++ mssql-init/setup.sh | 7 +++++++ scripts/e2e-entrypoint.sh | 22 ++++++++++++++++----- 4 files changed, 57 insertions(+), 20 deletions(-) diff --git a/app/backup.py b/app/backup.py index 40f9aaec..6407a6b9 100644 --- a/app/backup.py +++ b/app/backup.py @@ -225,16 +225,20 @@ def restore_from_zip(zip_path: str | Path) -> None: } if tables_to_clear: + preparer = db.engine.dialect.identifier_preparer if db.engine.dialect.name == "mssql": # MSSQL: disable FK checks, delete all rows, re-enable for t in tables_to_clear: - conn.execute(sa.text(f"ALTER TABLE [{t}] NOCHECK CONSTRAINT ALL")) + qt = preparer.quote(t) + conn.execute(sa.text(f"ALTER TABLE {qt} NOCHECK CONSTRAINT ALL")) for t in tables_to_clear: - conn.execute(sa.text(f"DELETE FROM [{t}]")) + qt = preparer.quote(t) + conn.execute(sa.text(f"DELETE FROM {qt}")) for t in tables_to_clear: - conn.execute(sa.text(f"ALTER TABLE [{t}] CHECK CONSTRAINT ALL")) + qt = preparer.quote(t) + conn.execute(sa.text(f"ALTER TABLE {qt} CHECK CONSTRAINT ALL")) else: - quoted = ", ".join(f'"{t}"' for t in tables_to_clear) + quoted = ", ".join(preparer.quote(t) for t in tables_to_clear) conn.execute(sa.text(f"TRUNCATE {quoted} RESTART IDENTITY CASCADE")) # Re-insert rows, skipping columns that no longer exist in the schema. @@ -252,10 +256,10 @@ def restore_from_zip(zip_path: str | Path) -> None: # values (JSON columns) must be serialized manually. filtered = {k: json.dumps(v) if isinstance(v, (dict, list)) else v for k, v in filtered.items()} if filtered: - col_list = ", ".join(f'"{c}"' for c in filtered) + col_list = ", ".join(preparer.quote(c) for c in filtered) val_list = ", ".join(f":{c}" for c in filtered) conn.execute( - sa.text(f'INSERT INTO "{table_name}" ({col_list}) VALUES ({val_list})'), + sa.text(f"INSERT INTO {preparer.quote(table_name)} ({col_list}) VALUES ({val_list})"), filtered, ) @@ -265,28 +269,35 @@ def restore_from_zip(zip_path: str | Path) -> None: # PostgreSQL uses sequences; MSSQL uses IDENTITY — reseed via DBCC. for table_name in tables_to_clear: try: + qt = preparer.quote(table_name) if db.engine.dialect.name == "mssql": # Reseed IDENTITY columns to max(pk)+1 (or 0 if table is empty) pk_cols = inspector.get_pk_constraint(table_name).get("constrained_columns", []) for pk in pk_cols: col_info = next((c for c in inspector.get_columns(table_name) if c["name"] == pk), None) if col_info and col_info.get("autoincrement", False): - max_id = conn.execute( - sa.text(f"SELECT COALESCE(MAX([{pk}]), 0) FROM [{table_name}]") - ).scalar() + qpk = preparer.quote(pk) + max_id = conn.execute(sa.text(f"SELECT COALESCE(MAX({qpk}), 0) FROM {qt}")).scalar() if max_id is not None: - conn.execute(sa.text(f"DBCC CHECKIDENT('[{table_name}]', RESEED, {int(max_id)})")) + conn.execute( + sa.text(f"DBCC CHECKIDENT({qt!r}, RESEED, :max_id)"), {"max_id": int(max_id)} + ) conn.commit() else: pk_cols = inspector.get_pk_constraint(table_name).get("constrained_columns", []) for pk in pk_cols: - seq = conn.execute(sa.text(f"SELECT pg_get_serial_sequence('{table_name}', '{pk}')")).scalar() + seq = conn.execute( + sa.text("SELECT pg_get_serial_sequence(:tbl, :col)"), + {"tbl": table_name, "col": pk}, + ).scalar() if seq: - next_id = conn.execute( - sa.text(f'SELECT COALESCE(MAX("{pk}"), 0) + 1 FROM "{table_name}"') - ).scalar() + qpk = preparer.quote(pk) + next_id = conn.execute(sa.text(f"SELECT COALESCE(MAX({qpk}), 0) + 1 FROM {qt}")).scalar() if next_id is not None: - conn.execute(sa.text(f"SELECT setval('{seq}', {int(next_id)}, false)")) + conn.execute( + sa.text("SELECT setval(:seq, :val, false)"), + {"seq": seq, "val": int(next_id)}, + ) conn.commit() except Exception as exc: conn.rollback() diff --git a/mssql-init/setup-e2e.sh b/mssql-init/setup-e2e.sh index 443bd419..38dea74c 100755 --- a/mssql-init/setup-e2e.sh +++ b/mssql-init/setup-e2e.sh @@ -11,15 +11,22 @@ SQLCMD="/opt/mssql-tools18/bin/sqlcmd" SA_PASSWORD="E2e_Password1!" echo "Waiting for SQL Server to be ready..." +READY=false for i in $(seq 1 30); do if $SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q "SELECT 1" &>/dev/null; then echo "SQL Server is ready." + READY=true break fi echo " Attempt $i/30 — not ready yet..." sleep 2 done +if [ "$READY" = "false" ]; then + echo "ERROR: SQL Server did not become ready within 60 seconds. Exiting." + exit 1 +fi + echo "Creating database medcover_e2e..." $SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q " IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'medcover_e2e') diff --git a/mssql-init/setup.sh b/mssql-init/setup.sh index 6b7332b1..dacf0453 100755 --- a/mssql-init/setup.sh +++ b/mssql-init/setup.sh @@ -16,15 +16,22 @@ SQLCMD="/opt/mssql-tools18/bin/sqlcmd" SA_PASSWORD="DevPassword123!" echo "Waiting for SQL Server to be ready..." +READY=false for i in $(seq 1 30); do if $SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q "SELECT 1" &>/dev/null; then echo "SQL Server is ready." + READY=true break fi echo " Attempt $i/30 — not ready yet..." sleep 2 done +if [ "$READY" = "false" ]; then + echo "ERROR: SQL Server did not become ready within 60 seconds. Exiting." + exit 1 +fi + echo "Creating database medcover_dev..." $SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q " IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'medcover_dev') diff --git a/scripts/e2e-entrypoint.sh b/scripts/e2e-entrypoint.sh index bd8c3ba2..de7939ac 100755 --- a/scripts/e2e-entrypoint.sh +++ b/scripts/e2e-entrypoint.sh @@ -11,9 +11,11 @@ case "${DATABASE_URL}" in DB_CHECK_FILE=$(mktemp) # Wait for MSSQL server (connect to master — target DB may not exist yet) cat > "$DB_CHECK_FILE" << 'PYEOF' -import os, pyodbc, re -url = os.environ["DATABASE_URL"] +import os, pyodbc, re, sys +url = os.environ.get("DATABASE_URL", "") m = re.match(r"mssql\+pyodbc://([^:]+):([^@]+)@([^:]+):(\d+)/([^?]+)", url) +if not m: + sys.exit(f"Cannot parse MSSQL DATABASE_URL: {url}") user, pwd, host, port, db = m.groups() sa_pwd = os.environ.get("MSSQL_SA_PASSWORD", pwd) pyodbc.connect(f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={host},{port};DATABASE=master;UID=sa;PWD={sa_pwd};Encrypt=no;TrustServerCertificate=yes", timeout=5).close() @@ -45,16 +47,26 @@ case "${DATABASE_URL}" in # Create DB and user via sa account, then stamp+migrate echo " Creating MSSQL database (if not exists)..." python << 'PYEOF' -import os, pyodbc, re -url = os.environ["DATABASE_URL"] +import os, pyodbc, re, sys +url = os.environ.get("DATABASE_URL", "") m = re.match(r"mssql\+pyodbc://([^:]+):([^@]+)@([^:]+):(\d+)/([^?]+)", url) +if not m: + sys.exit(f"Cannot parse MSSQL DATABASE_URL: {url}") user, pwd, host, port, db = m.groups() +# Validate identifiers to prevent SQL injection accidents +ident_re = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +if not ident_re.fullmatch(db): + sys.exit(f"Invalid database name: {db}") +if not ident_re.fullmatch(user): + sys.exit(f"Invalid username: {user}") sa_pwd = os.environ.get("MSSQL_SA_PASSWORD", pwd) conn = pyodbc.connect(f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={host},{port};DATABASE=master;UID=sa;PWD={sa_pwd};Encrypt=no;TrustServerCertificate=yes", autocommit=True) c = conn.cursor() c.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name='{db}') CREATE DATABASE [{db}] COLLATE Czech_100_CI_AS_SC_UTF8") c.execute(f"ALTER DATABASE [{db}] SET READ_COMMITTED_SNAPSHOT ON") -c.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name='{user}') CREATE LOGIN [{user}] WITH PASSWORD='{pwd}'") +# Escape single quotes in password for SQL literal +safe_pwd = pwd.replace("'", "''") +c.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name='{user}') CREATE LOGIN [{user}] WITH PASSWORD='{safe_pwd}'") conn.close() conn2 = pyodbc.connect(f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={host},{port};DATABASE={db};UID=sa;PWD={sa_pwd};Encrypt=no;TrustServerCertificate=yes", autocommit=True) c2 = conn2.cursor() From ce7183f8e280debc98edf1afcef92d2a4072e596 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Fri, 12 Jun 2026 10:49:18 +0200 Subject: [PATCH 08/31] Use SQL Server Express edition (free for production) --- docker-compose.e2e-mssql.yml | 1 + docker-compose.mssql-dev.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/docker-compose.e2e-mssql.yml b/docker-compose.e2e-mssql.yml index 6f9b618a..e5254787 100644 --- a/docker-compose.e2e-mssql.yml +++ b/docker-compose.e2e-mssql.yml @@ -17,6 +17,7 @@ services: - /var/opt/mssql/data environment: ACCEPT_EULA: "Y" + MSSQL_PID: "Express" MSSQL_SA_PASSWORD: "E2e_Password1!" MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" MSSQL_MEMORY_LIMIT_MB: "512" diff --git a/docker-compose.mssql-dev.yml b/docker-compose.mssql-dev.yml index cc361c92..13678271 100644 --- a/docker-compose.mssql-dev.yml +++ b/docker-compose.mssql-dev.yml @@ -84,6 +84,7 @@ services: stop_grace_period: 30s environment: ACCEPT_EULA: "Y" + MSSQL_PID: "Express" MSSQL_SA_PASSWORD: "DevPassword123!" MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" MSSQL_MEMORY_LIMIT_MB: "512" From 49cb52d32e2edb0cf3cc176877272d47dae02274 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Fri, 12 Jun 2026 10:57:18 +0200 Subject: [PATCH 09/31] Fix DBCC CHECKIDENT: use string literal instead of repr() --- app/backup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/backup.py b/app/backup.py index 6407a6b9..82dea086 100644 --- a/app/backup.py +++ b/app/backup.py @@ -280,7 +280,8 @@ def restore_from_zip(zip_path: str | Path) -> None: max_id = conn.execute(sa.text(f"SELECT COALESCE(MAX({qpk}), 0) FROM {qt}")).scalar() if max_id is not None: conn.execute( - sa.text(f"DBCC CHECKIDENT({qt!r}, RESEED, :max_id)"), {"max_id": int(max_id)} + sa.text(f"DBCC CHECKIDENT('{table_name}', RESEED, :max_id)"), + {"max_id": int(max_id)}, ) conn.commit() else: From 4d69baeac513197c593339f862878bf0f15a18de Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Thu, 18 Jun 2026 13:57:31 +0200 Subject: [PATCH 10/31] Drop PostgreSQL support, MSSQL-only --- .env.example | 9 +- .github/workflows/ci.yml | 36 +++-- CHANGELOG.md | 10 ++ app/backup.py | 90 +++++------ app/config.py | 31 +--- app/digest/blocks/server_stats.py | 56 +++---- app/mail.py | 6 +- app/models/qualification.py | 2 +- app/utils.py | 6 +- db-init/01-create-test-db.sql | 4 - docker-compose.e2e-mssql.yml | 91 ----------- docker-compose.e2e.yml | 27 ++-- docker-compose.mssql-dev.yml | 104 ------------ docker-compose.prod.yml | 32 ++-- docker-compose.yml | 37 ++--- postgres.conf | 26 --- requirements.in | 1 - requirements.txt | 69 -------- scripts/e2e-entrypoint.sh | 37 +---- tests/conftest.py | 253 +++++++++++++++++++----------- tests/test_config.py | 41 +++-- 21 files changed, 364 insertions(+), 604 deletions(-) delete mode 100644 db-init/01-create-test-db.sql delete mode 100644 docker-compose.e2e-mssql.yml delete mode 100644 docker-compose.mssql-dev.yml delete mode 100644 postgres.conf diff --git a/.env.example b/.env.example index 9eb2f569..7d9d70db 100644 --- a/.env.example +++ b/.env.example @@ -8,13 +8,12 @@ FLASK_ENV=development # Generate with: python -c "import secrets; print(secrets.token_hex(32))" SECRET_KEY=change-me-generate-a-strong-random-value -# PostgreSQL connection string +# MSSQL connection string # 'db' is the Docker Compose service name — use this when running inside # a Docker container (e.g. flask run via docker compose). -# Change to 'localhost' if connecting from the host directly. -DATABASE_URL=postgresql://medcover:devpassword@db:5432/medcover_dev -# PRODUCTION: must include sslmode=require, e.g.: -# DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require +DATABASE_URL=mssql+pyodbc://medcover:Dev_Password1!@db:1433/medcover_dev?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes +# PRODUCTION (Azure SQL with Managed Identity — no password needed): +# DATABASE_URL=mssql+pyodbc://@server.database.windows.net/MedCover?driver=ODBC+Driver+18+for+SQL+Server&Authentication=ActiveDirectoryMsi&Encrypt=yes # Outbound email (SMTP) is configured through the web UI setup wizard # and stored encrypted in the database. No SMTP variables are needed here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 621d7feb..5bdd11f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,23 +26,24 @@ jobs: runs-on: ubuntu-latest services: - postgres: - image: postgres:17-alpine + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest env: - POSTGRES_USER: medcover - POSTGRES_PASSWORD: testpassword - POSTGRES_DB: medcover_test + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "CiPassword123!" + MSSQL_PID: "Express" + MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" ports: - - 5432:5432 + - 1433:1433 options: >- - --health-cmd pg_isready - --health-interval 5s + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'CiPassword123!' -C -Q 'SELECT 1' -b" + --health-interval 10s --health-timeout 5s - --health-retries 5 + --health-retries 10 env: - DATABASE_URL: postgresql://medcover:testpassword@localhost:5432/medcover_test - TEST_DATABASE_URL: postgresql://medcover:testpassword@localhost:5432/medcover_test + TEST_DATABASE_URL: "mssql+pyodbc://SA:CiPassword123!@localhost:1433/medcover_test?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes" + DATABASE_URL: "mssql+pyodbc://SA:CiPassword123!@localhost:1433/medcover_test?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes" FLASK_ENV: testing SECRET_KEY: ci-test-secret-not-real @@ -53,6 +54,19 @@ jobs: with: python-version: "3.14" + - name: Install ODBC Driver for SQL Server + run: | + curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mssql-release.list + sudo apt-get update + sudo ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 unixodbc-dev + + - name: Create test database + run: | + /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P 'CiPassword123!' -C -Q \ + "CREATE DATABASE medcover_test COLLATE Czech_100_CI_AS_SC_UTF8; \ + ALTER DATABASE medcover_test SET READ_COMMITTED_SNAPSHOT ON" + - name: Install dependencies run: pip install --require-hashes -r requirements-dev.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index d8ed655c..d2b5f1c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Database engine switched from PostgreSQL to Microsoft SQL Server (MSSQL 2022 / Azure SQL). PostgreSQL is no longer supported. +- `docker-compose.yml` now uses the MSSQL 2022 Express container instead of PostgreSQL. +- `docker-compose.e2e.yml` updated to use MSSQL. +- `docker-compose.prod.yml` updated to use MSSQL. +- CI pipeline updated to use an MSSQL service container. +- `psycopg2-binary` removed from dependencies; `pyodbc` is the sole DB driver. +- Backup/restore engine updated for MSSQL (IDENTITY_INSERT, DBCC CHECKIDENT, FK constraint handling). +- Test suite migrated to MSSQL; uses a temporary MSSQL container via testcontainers when `TEST_DATABASE_URL` is not pre-set. + ## [0.16.0] - 2026-06-09 ### Added diff --git a/app/backup.py b/app/backup.py index 82dea086..0dc955e0 100644 --- a/app/backup.py +++ b/app/backup.py @@ -94,10 +94,7 @@ def _get_alembic_head() -> str: """ try: with db.engine.connect() as conn: - if db.engine.dialect.name == "mssql": - row = conn.execute(sa.text("SELECT TOP 1 version_num FROM alembic_version")).fetchone() - else: - row = conn.execute(sa.text("SELECT version_num FROM alembic_version LIMIT 1")).fetchone() + row = conn.execute(sa.text("SELECT TOP 1 version_num FROM alembic_version")).fetchone() return str(row[0]) if row else "unknown" except Exception: return "unknown" @@ -220,26 +217,30 @@ def restore_from_zip(zip_path: str | Path) -> None: tables_to_clear = [t for t in all_table_names if t not in _EXCLUDED_TABLES] # Pre-collect column info before TRUNCATE acquires AccessExclusiveLock. + # Also track which tables have IDENTITY columns (MSSQL requires + # SET IDENTITY_INSERT ON to insert explicit values into them). + from sqlalchemy import inspect as sa_inspect # pylint: disable=import-outside-toplevel + + _col_cache = {t: sa_inspect(db.engine).get_columns(t) for t in all_table_names if t not in _EXCLUDED_TABLES} current_columns_map: dict[str, set[str]] = { - t: {col["name"] for col in inspector.get_columns(t)} for t in all_table_names if t not in _EXCLUDED_TABLES + t: {str(col["name"]) for col in cols} for t, cols in _col_cache.items() + } + identity_tables: set[str] = { + t for t, cols in _col_cache.items() if any(col.get("autoincrement") for col in cols) } if tables_to_clear: preparer = db.engine.dialect.identifier_preparer - if db.engine.dialect.name == "mssql": - # MSSQL: disable FK checks, delete all rows, re-enable - for t in tables_to_clear: - qt = preparer.quote(t) - conn.execute(sa.text(f"ALTER TABLE {qt} NOCHECK CONSTRAINT ALL")) - for t in tables_to_clear: - qt = preparer.quote(t) - conn.execute(sa.text(f"DELETE FROM {qt}")) - for t in tables_to_clear: - qt = preparer.quote(t) - conn.execute(sa.text(f"ALTER TABLE {qt} CHECK CONSTRAINT ALL")) - else: - quoted = ", ".join(preparer.quote(t) for t in tables_to_clear) - conn.execute(sa.text(f"TRUNCATE {quoted} RESTART IDENTITY CASCADE")) + # MSSQL: disable FK constraints, delete all rows, re-enable + for t in tables_to_clear: + qt = preparer.quote(t) + conn.execute(sa.text(f"ALTER TABLE {qt} NOCHECK CONSTRAINT ALL")) + for t in tables_to_clear: + qt = preparer.quote(t) + conn.execute(sa.text(f"DELETE FROM {qt}")) + for t in tables_to_clear: + qt = preparer.quote(t) + conn.execute(sa.text(f"ALTER TABLE {qt} CHECK CONSTRAINT ALL")) # Re-insert rows, skipping columns that no longer exist in the schema. for table_name in restore_sequence: @@ -250,6 +251,10 @@ def restore_from_zip(zip_path: str | Path) -> None: if current_columns is None: log.warning("Table %r in backup does not exist in current schema — skipping", table_name) continue + qt = preparer.quote(table_name) + has_identity = table_name in identity_tables + if has_identity: + conn.execute(sa.text(f"SET IDENTITY_INSERT {qt} ON")) for row in rows: filtered = {k: v for k, v in row.items() if k in current_columns} # sa.text() bypasses SQLAlchemy type coercion, so dict/list @@ -259,9 +264,11 @@ def restore_from_zip(zip_path: str | Path) -> None: col_list = ", ".join(preparer.quote(c) for c in filtered) val_list = ", ".join(f":{c}" for c in filtered) conn.execute( - sa.text(f"INSERT INTO {preparer.quote(table_name)} ({col_list}) VALUES ({val_list})"), + sa.text(f"INSERT INTO {qt} ({col_list}) VALUES ({val_list})"), filtered, ) + if has_identity: + conn.execute(sa.text(f"SET IDENTITY_INSERT {qt} OFF")) conn.commit() @@ -270,36 +277,19 @@ def restore_from_zip(zip_path: str | Path) -> None: for table_name in tables_to_clear: try: qt = preparer.quote(table_name) - if db.engine.dialect.name == "mssql": - # Reseed IDENTITY columns to max(pk)+1 (or 0 if table is empty) - pk_cols = inspector.get_pk_constraint(table_name).get("constrained_columns", []) - for pk in pk_cols: - col_info = next((c for c in inspector.get_columns(table_name) if c["name"] == pk), None) - if col_info and col_info.get("autoincrement", False): - qpk = preparer.quote(pk) - max_id = conn.execute(sa.text(f"SELECT COALESCE(MAX({qpk}), 0) FROM {qt}")).scalar() - if max_id is not None: - conn.execute( - sa.text(f"DBCC CHECKIDENT('{table_name}', RESEED, :max_id)"), - {"max_id": int(max_id)}, - ) - conn.commit() - else: - pk_cols = inspector.get_pk_constraint(table_name).get("constrained_columns", []) - for pk in pk_cols: - seq = conn.execute( - sa.text("SELECT pg_get_serial_sequence(:tbl, :col)"), - {"tbl": table_name, "col": pk}, - ).scalar() - if seq: - qpk = preparer.quote(pk) - next_id = conn.execute(sa.text(f"SELECT COALESCE(MAX({qpk}), 0) + 1 FROM {qt}")).scalar() - if next_id is not None: - conn.execute( - sa.text("SELECT setval(:seq, :val, false)"), - {"seq": seq, "val": int(next_id)}, - ) - conn.commit() + # Reseed MSSQL IDENTITY columns to max(pk) so future INSERTs don't collide + pk_cols = inspector.get_pk_constraint(table_name).get("constrained_columns", []) + for pk in pk_cols: + col_info = next((c for c in inspector.get_columns(table_name) if c["name"] == pk), None) + if col_info and col_info.get("autoincrement", False): + qpk = preparer.quote(pk) + max_id = conn.execute(sa.text(f"SELECT COALESCE(MAX({qpk}), 0) FROM {qt}")).scalar() + if max_id is not None: + conn.execute( + sa.text(f"DBCC CHECKIDENT('{table_name}', RESEED, :max_id)"), + {"max_id": int(max_id)}, + ) + conn.commit() except Exception as exc: conn.rollback() log.debug("Could not reset sequence for %s: %s", table_name, exc) diff --git a/app/config.py b/app/config.py index d801a1bc..5d78a410 100644 --- a/app/config.py +++ b/app/config.py @@ -12,24 +12,9 @@ _VERSION_FILE = pathlib.Path(__file__).parent.parent / "VERSION" -def _fix_db_url(url: str) -> str: - """Translate postgres:// → postgresql:// for SQLAlchemy 2.x compatibility. - - Render (and Heroku) inject DATABASE_URL with the legacy 'postgres://' scheme. - SQLAlchemy 2.x only accepts 'postgresql://'. - """ - if url.startswith("postgres://"): - return url.replace("postgres://", "postgresql://", 1) - return url - - class Config: SECRET_KEY = os.environ.get("SECRET_KEY", "") - # Development and production configs require DATABASE_URL to be set. - # TestingConfig overrides this with TEST_DATABASE_URL so this may be empty - # during test runs — that is fine as long as TestingConfig is used. - # Render injects DATABASE_URL as postgres:// — _fix_db_url normalises it. - SQLALCHEMY_DATABASE_URI = _fix_db_url(os.environ.get("DATABASE_URL", "")) + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "") SQLALCHEMY_TRACK_MODIFICATIONS = False WTF_CSRF_ENABLED = True WTF_CSRF_TIME_LIMIT: int | None = ( @@ -63,11 +48,10 @@ class TestingConfig(Config): # Always use the dedicated test database — never the dev/prod DATABASE_URL. # This ensures that conftest.py's drop_all() teardown cannot wipe the dev DB. SECRET_KEY = os.getenv("SECRET_KEY", "test-secret-not-for-production") - SQLALCHEMY_DATABASE_URI = _fix_db_url( - os.getenv( - "TEST_DATABASE_URL", - "postgresql://medcover:devpassword@localhost:5432/medcover_test", - ) + SQLALCHEMY_DATABASE_URI = os.getenv( + "TEST_DATABASE_URL", + "mssql+pyodbc://SA:DevPassword123!@localhost:1433/medcover_test" + "?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes", ) WTF_CSRF_ENABLED = False # Required so url_for() works outside an active request context (e.g. in @@ -85,9 +69,10 @@ def __init_subclass__(cls, **kwargs: object) -> None: @classmethod def init_app(cls, app: object) -> None: # type: ignore[override] db_url = os.environ.get("DATABASE_URL", "") - if db_url and "sslmode" not in db_url: + if db_url and "Encrypt=yes" not in db_url and "Authentication=ActiveDirectoryMsi" not in db_url: warnings.warn( - "DATABASE_URL does not include sslmode=require. " "Add ?sslmode=require for production security.", + "DATABASE_URL does not include Encrypt=yes. " + "Add Encrypt=yes to the MSSQL connection string for production security.", stacklevel=2, ) diff --git a/app/digest/blocks/server_stats.py b/app/digest/blocks/server_stats.py index 8baf4a4d..557a51d2 100644 --- a/app/digest/blocks/server_stats.py +++ b/app/digest/blocks/server_stats.py @@ -45,50 +45,30 @@ def collect(self, db_session: Any, config: dict[str, Any]) -> dict[str, Any]: if config.get("show_db_size", True): try: - dialect = db_session.bind.dialect.name if db_session.bind else "postgresql" - if dialect == "mssql": - row = db_session.execute( - sa.text("SELECT CAST(SUM(size) * 8.0 / 1024 AS DECIMAL(10,2)) " "FROM sys.database_files") - ).fetchone() - data["db_size"] = f"{row[0]} MB" if row and row[0] else "N/A" - else: - row = db_session.execute( - sa.text("SELECT pg_size_pretty(pg_database_size(current_database()))") - ).fetchone() - data["db_size"] = row[0] if row else "N/A" + row = db_session.execute( + sa.text("SELECT CAST(SUM(size) * 8.0 / 1024 AS DECIMAL(10,2)) FROM sys.database_files") + ).fetchone() + data["db_size"] = f"{row[0]} MB" if row and row[0] else "N/A" except Exception: # noqa: BLE001 data["db_size"] = "N/A" if config.get("show_table_sizes", True): try: max_table_rows = int(config.get("max_table_rows", 5)) - dialect = db_session.bind.dialect.name if db_session.bind else "postgresql" - if dialect == "mssql": - rows = db_session.execute( - sa.text(""" - SELECT TOP(:limit) t.name, - CAST(SUM(a.total_pages) * 8.0 / 1024 AS DECIMAL(10,2)) AS size_mb - FROM sys.tables t - JOIN sys.indexes i ON t.object_id = i.object_id - JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id - JOIN sys.allocation_units a ON p.partition_id = a.container_id - GROUP BY t.name - ORDER BY SUM(a.total_pages) DESC - """), - {"limit": max_table_rows}, - ).fetchall() - data["table_sizes"] = [(r[0], f"{r[1]} MB") for r in rows] - else: - rows = db_session.execute( - sa.text(""" - SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS pretty_size - FROM pg_catalog.pg_statio_user_tables - ORDER BY pg_total_relation_size(relid) DESC - LIMIT :limit - """), - {"limit": max_table_rows}, - ).fetchall() - data["table_sizes"] = [(r[0], r[1]) for r in rows] + rows = db_session.execute( + sa.text(""" + SELECT TOP(:limit) t.name, + CAST(SUM(a.total_pages) * 8.0 / 1024 AS DECIMAL(10,2)) AS size_mb + FROM sys.tables t + JOIN sys.indexes i ON t.object_id = i.object_id + JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id + JOIN sys.allocation_units a ON p.partition_id = a.container_id + GROUP BY t.name + ORDER BY SUM(a.total_pages) DESC + """), + {"limit": max_table_rows}, + ).fetchall() + data["table_sizes"] = [(r[0], f"{r[1]} MB") for r in rows] except Exception: # noqa: BLE001 data["table_sizes"] = [] diff --git a/app/mail.py b/app/mail.py index 27cbede1..679182ec 100644 --- a/app/mail.py +++ b/app/mail.py @@ -542,12 +542,8 @@ def drain_one_outbox_email() -> bool: ) .order_by(OutboxEmail.created_at.asc()) .limit(1) + .with_hint(OutboxEmail, "WITH (UPDLOCK, ROWLOCK, READPAST)") ) - if db.engine.dialect.name == "mssql": - # MSSQL: UPDLOCK + READPAST = skip locked rows (equivalent of skip_locked) - query = query.with_hint(OutboxEmail, "WITH (UPDLOCK, ROWLOCK, READPAST)") - else: - query = query.with_for_update(skip_locked=True) row: OutboxEmail | None = db.session.scalars(query).first() diff --git a/app/models/qualification.py b/app/models/qualification.py index 1e8e6030..6093f191 100644 --- a/app/models/qualification.py +++ b/app/models/qualification.py @@ -30,7 +30,7 @@ class Qualification(db.Model): # type: ignore[misc] "ix_qualification_name_active_unique", "name", unique=True, - postgresql_where=db.text("is_deleted = false"), + mssql_where=db.text("is_deleted = 0"), ), ) diff --git a/app/utils.py b/app/utils.py index fb3766b0..4f7aa218 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,6 +1,5 @@ """Shared utility helpers for the MedCover application.""" -import os from calendar import monthrange from datetime import date from typing import TypeVar @@ -34,9 +33,8 @@ def get_app_tz() -> ZoneInfo: # ── Czech locale-aware sorting ──────────────────────────────────────────────── -# Collation name for ORDER BY on user-visible text columns. -# PostgreSQL uses ICU collation "cs-x-icu"; MSSQL uses "Czech_100_CI_AS_SC_UTF8". -CS_COLLATION: str = "Czech_100_CI_AS_SC_UTF8" if os.environ.get("DATABASE_URL", "").startswith("mssql") else "cs-x-icu" +# Collation name for ORDER BY on user-visible text columns (MSSQL Czech sort order). +CS_COLLATION: str = "Czech_100_CI_AS_SC_UTF8" # Czech alphabet in correct order including digraph 'ch'. _CS_ALPHABET: list[str] = [ diff --git a/db-init/01-create-test-db.sql b/db-init/01-create-test-db.sql deleted file mode 100644 index c5d6568f..00000000 --- a/db-init/01-create-test-db.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Create the test database alongside the dev database. --- This script runs only on first container creation via /docker-entrypoint-initdb.d/ --- The dev DB is already created by POSTGRES_DB env var. -CREATE DATABASE medcover_test OWNER medcover; diff --git a/docker-compose.e2e-mssql.yml b/docker-compose.e2e-mssql.yml deleted file mode 100644 index e5254787..00000000 --- a/docker-compose.e2e-mssql.yml +++ /dev/null @@ -1,91 +0,0 @@ -# docker-compose.e2e-mssql.yml -# -# E2E test stack backed by MSSQL Server 2022 (LOCAL DEV ONLY). -# Mirrors docker-compose.e2e.yml but replaces PostgreSQL with MSSQL. -# -# Usage: -# podman compose -f docker-compose.e2e-mssql.yml up -d --build -# # Wait for db to be healthy, then init: -# podman exec medcover-e2e-mssql-db-1 /docker-entrypoint-initdb.d/setup-e2e.sh -# # Then run e2e tests (or just: podman compose -f docker-compose.e2e-mssql.yml up e2e) - -services: - db-e2e: - image: mcr.microsoft.com/mssql/server:2022-latest - platform: linux/amd64 - tmpfs: - - /var/opt/mssql/data - environment: - ACCEPT_EULA: "Y" - MSSQL_PID: "Express" - MSSQL_SA_PASSWORD: "E2e_Password1!" - MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" - MSSQL_MEMORY_LIMIT_MB: "512" - volumes: - - ./mssql-init:/docker-entrypoint-initdb.d:ro - healthcheck: - test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "E2e_Password1!", "-C", "-Q", "SELECT 1", "-b"] - interval: 5s - timeout: 5s - retries: 20 - start_period: 20s - - web-e2e: - build: - context: . - args: - GIT_COMMIT: e2e-mssql - entrypoint: ["/e2e-entrypoint.sh"] - command: ["flask", "run", "--debug", "--host=0.0.0.0"] - volumes: - - ./scripts/e2e-entrypoint.sh:/e2e-entrypoint.sh:ro - - ./scripts:/app/scripts:ro - environment: - FLASK_ENV: development - FLASK_DEBUG: "1" - SECRET_KEY: e2e-test-secret-key - DATABASE_URL: "mssql+pyodbc://medcover:E2e_Password1!@db-e2e:1433/medcover_e2e?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes" - MSSQL_SA_PASSWORD: "E2e_Password1!" - DEV_LOGIN_ENABLED: "true" - INSTANCE_ID: e2e-mssql - depends_on: - db-e2e: - condition: service_healthy - healthcheck: - test: ["CMD", "python", "-c", - "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"] - interval: 5s - timeout: 5s - retries: 10 - start_period: 30s - - e2e: - build: - context: . - dockerfile: Dockerfile.e2e - working_dir: /e2e - dns: - - 8.8.8.8 - - 1.1.1.1 - volumes: - - ./e2e_tests:/e2e - - ./e2e-report:/e2e-report - environment: - BASE_URL: http://web-e2e:5000 - E2E_BROWSERS: ${E2E_BROWSERS:---browser chromium --browser firefox --browser webkit} - entrypoint: - - "sh" - - "-c" - - | - echo 'Waiting for web-e2e...' - retries=60 - until curl -sf http://web-e2e:5000/health >/dev/null 2>&1; do - retries=$$((retries-1)) - [ "$$retries" -le 0 ] && echo 'web-e2e did not become ready in time' && exit 1 - sleep 2 - done - echo 'web-e2e ready' - pytest /e2e $E2E_BROWSERS -v --screenshot on --output /e2e-report/traces --html=/e2e-report/report.html --self-contained-html -o 'addopts=' - depends_on: - web-e2e: - condition: service_healthy diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 2c44e372..e34b6f90 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -1,17 +1,23 @@ services: db-e2e: - image: postgres:17-alpine + image: mcr.microsoft.com/mssql/server:2022-latest + platform: linux/amd64 tmpfs: - - /var/lib/postgresql/data + - /var/opt/mssql/data environment: - POSTGRES_DB: medcover_e2e - POSTGRES_USER: medcover - POSTGRES_PASSWORD: e2epassword + ACCEPT_EULA: "Y" + MSSQL_PID: "Express" + MSSQL_SA_PASSWORD: "E2e_Password1!" + MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" + MSSQL_MEMORY_LIMIT_MB: "512" + volumes: + - ./mssql-init:/docker-entrypoint-initdb.d:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U medcover -d medcover_e2e"] - interval: 3s - timeout: 3s - retries: 10 + test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "E2e_Password1!", "-C", "-Q", "SELECT 1", "-b"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 20s web-e2e: build: @@ -27,7 +33,8 @@ services: FLASK_ENV: development FLASK_DEBUG: "1" SECRET_KEY: e2e-test-secret-key - DATABASE_URL: postgresql://medcover:e2epassword@db-e2e:5432/medcover_e2e + DATABASE_URL: "mssql+pyodbc://medcover:E2e_Password1!@db-e2e:1433/medcover_e2e?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes" + MSSQL_SA_PASSWORD: "E2e_Password1!" DEV_LOGIN_ENABLED: "true" INSTANCE_ID: e2e depends_on: diff --git a/docker-compose.mssql-dev.yml b/docker-compose.mssql-dev.yml deleted file mode 100644 index 13678271..00000000 --- a/docker-compose.mssql-dev.yml +++ /dev/null @@ -1,104 +0,0 @@ -# docker-compose.mssql-dev.yml -# -# Self-contained dev stack with MSSQL Server 2022 (LOCAL DEV ONLY). -# Production in Azure uses Azure SQL Database as a managed service. -# -# Usage: -# podman compose -f docker-compose.mssql-dev.yml up -d --build -# # or: docker compose -f docker-compose.mssql-dev.yml up -d --build -# -# After first startup, create the database: -# podman exec medcover-mssql-db-1 /docker-entrypoint-initdb.d/setup.sh -# -# .env must contain: -# DATABASE_URL=mssql+pyodbc://medcover:Dev_Password1!@db:1433/medcover_dev?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes - -services: - web: - platform: linux/amd64 - build: - context: . - args: - GIT_COMMIT: ${GIT_COMMIT:-dev} - command: flask run --host=0.0.0.0 --debug - restart: unless-stopped - volumes: - - .:/app - env_file: .env - ports: - - "5000:5000" - dns: - - 8.8.8.8 - - 1.1.1.1 - depends_on: - db: - condition: service_healthy - logging: - driver: "json-file" - options: - max-size: "50m" - max-file: "7" - healthcheck: - test: ["CMD", "python", "-c", - "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 15s - - scheduler: - platform: linux/amd64 - build: - context: . - args: - GIT_COMMIT: ${GIT_COMMIT:-dev} - entrypoint: ["/docker-entrypoint-scheduler.sh"] - command: python scheduler/main.py - restart: unless-stopped - volumes: - - .:/app - env_file: .env - dns: - - 8.8.8.8 - - 1.1.1.1 - depends_on: - web: - condition: service_healthy - logging: - driver: "json-file" - options: - max-size: "50m" - max-file: "7" - healthcheck: - test: ["CMD", "python", "-c", - "import os, time; f='/tmp/scheduler_heartbeat'; assert os.path.exists(f) and time.time()-os.path.getmtime(f)<30"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 15s - - db: - image: mcr.microsoft.com/mssql/server:2022-latest - platform: linux/amd64 - restart: unless-stopped - stop_grace_period: 30s - environment: - ACCEPT_EULA: "Y" - MSSQL_PID: "Express" - MSSQL_SA_PASSWORD: "DevPassword123!" - MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" - MSSQL_MEMORY_LIMIT_MB: "512" - ports: - - "1433:1433" - volumes: - - mssql_data:/var/opt/mssql - - ./mssql-init:/docker-entrypoint-initdb.d:ro - healthcheck: - test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "DevPassword123!", "-C", "-Q", "SELECT 1", "-b"] - interval: 10s - timeout: 5s - retries: 10 - start_period: 30s - -volumes: - mssql_data: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a6841c69..7eacc4b0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -69,25 +69,27 @@ services: start_period: 15s db: - image: postgres:17-alpine + image: mcr.microsoft.com/mssql/server:2022-latest container_name: medcover-prod-db - stop_grace_period: 60s - volumes: - - postgres_data_prod:/var/lib/postgresql/data - - ./db-init:/docker-entrypoint-initdb.d:ro - - ./postgres.conf:/etc/postgresql/postgresql.conf:ro - command: postgres -c config_file=/etc/postgresql/postgresql.conf + platform: linux/amd64 + restart: unless-stopped + stop_grace_period: 30s environment: - POSTGRES_DB: ${POSTGRES_DB:-medcover_prod} - POSTGRES_USER: ${POSTGRES_USER:-medcover} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required in .env.prod} + ACCEPT_EULA: "Y" + MSSQL_PID: "Express" + MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?MSSQL_SA_PASSWORD is required in .env.prod} + MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" + volumes: + - mssql_data_prod:/var/opt/mssql + - ./mssql-init:/docker-entrypoint-initdb.d:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-medcover}"] - interval: 5s + test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", + "${MSSQL_SA_PASSWORD}", "-C", "-Q", "SELECT 1", "-b"] + interval: 10s timeout: 5s - retries: 5 + retries: 10 + start_period: 30s # DB is not exposed to the host — access it through the web/scheduler services - restart: unless-stopped volumes: - postgres_data_prod: + mssql_data_prod: diff --git a/docker-compose.yml b/docker-compose.yml index 303ea33a..03a4749c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,7 +55,6 @@ services: max-size: "50m" max-file: "7" healthcheck: - # Healthy if the heartbeat file was touched within the last 30 seconds test: ["CMD", "python", "-c", "import os, time; f='/tmp/scheduler_heartbeat'; assert os.path.exists(f) and time.time()-os.path.getmtime(f)<30"] interval: 30s @@ -64,29 +63,27 @@ services: start_period: 15s db: + image: mcr.microsoft.com/mssql/server:2022-latest platform: linux/amd64 - image: postgres:17-alpine restart: unless-stopped - # Give PostgreSQL up to 60 s to finish its final checkpoint before Docker - # sends SIGKILL on 'docker compose down/stop'. Default is 10 s which is - # often too short for a clean shutdown. - stop_grace_period: 60s - volumes: - - postgres_data:/var/lib/postgresql/data - - ./db-init:/docker-entrypoint-initdb.d:ro - - ./postgres.conf:/etc/postgresql/postgresql.conf:ro - command: postgres -c config_file=/etc/postgresql/postgresql.conf + stop_grace_period: 30s environment: - POSTGRES_DB: medcover_dev - POSTGRES_USER: medcover - POSTGRES_PASSWORD: devpassword + ACCEPT_EULA: "Y" + MSSQL_PID: "Express" + MSSQL_SA_PASSWORD: "DevPassword123!" + MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" + MSSQL_MEMORY_LIMIT_MB: "512" + ports: + - "1433:1433" + volumes: + - mssql_data:/var/opt/mssql + - ./mssql-init:/docker-entrypoint-initdb.d:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U medcover -d medcover_dev"] - interval: 5s + test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "DevPassword123!", "-C", "-Q", "SELECT 1", "-b"] + interval: 10s timeout: 5s - retries: 5 - ports: - - "5432:5432" + retries: 10 + start_period: 30s volumes: - postgres_data: + mssql_data: diff --git a/postgres.conf b/postgres.conf deleted file mode 100644 index ab275505..00000000 --- a/postgres.conf +++ /dev/null @@ -1,26 +0,0 @@ -# Custom PostgreSQL configuration for MedCover dev/prod. -# -# Key goal: reduce the dirty-page window so that an unclean shutdown -# (e.g. WSL2 force-kill, OOM) loses at most ~30 s of committed writes -# instead of the default 5 minutes. - -# --- Checkpointing --- -# Write dirty pages to disk every 30 s (default: 5min). -checkpoint_timeout = 30s - -# Spread checkpoint I/O over 90 % of the interval to avoid I/O spikes. -checkpoint_completion_target = 0.9 - -# --- WAL / durability (keep defaults — do NOT disable fsync) --- -fsync = on -synchronous_commit = on - -# --- Network --- -# Must be set explicitly when overriding the full config file; otherwise -# PostgreSQL defaults to localhost-only and other containers can't connect. -listen_addresses = '*' - -# --- Logging (helpful for diagnosing crashes) --- -log_checkpoints = on -log_connections = off -log_disconnections = off diff --git a/requirements.in b/requirements.in index a1e9a6a5..d86f4615 100644 --- a/requirements.in +++ b/requirements.in @@ -9,7 +9,6 @@ Flask-Login Flask-Mail Flask-WTF gunicorn -psycopg2-binary pyodbc schedule cryptography diff --git a/requirements.txt b/requirements.txt index 86204489..9ab4353f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -403,75 +403,6 @@ packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 # via gunicorn -psycopg2-binary==2.9.12 \ - --hash=sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f \ - --hash=sha256:049366c6d884bdcd65d66e6ca1fdbebe670b56c6c9ba46f164e6667e90881964 \ - --hash=sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c \ - --hash=sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2 \ - --hash=sha256:127467c6e476dd876634f17c3d870530e73ff454ff99bff73d36e80af28e1115 \ - --hash=sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c \ - --hash=sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be \ - --hash=sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6 \ - --hash=sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c \ - --hash=sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c \ - --hash=sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c \ - --hash=sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d \ - --hash=sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019 \ - --hash=sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7 \ - --hash=sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3 \ - --hash=sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777 \ - --hash=sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd \ - --hash=sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5 \ - --hash=sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39 \ - --hash=sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c \ - --hash=sha256:5c7cb4cbf894a1d36c720d713de507952c7c58f66d30834708f03dbe5c822ccf \ - --hash=sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b \ - --hash=sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433 \ - --hash=sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d \ - --hash=sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4 \ - --hash=sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290 \ - --hash=sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2 \ - --hash=sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3 \ - --hash=sha256:718e1fc18edf573b02cb8aea868de8d8d33f99ce9620206aa9144b67b0985e94 \ - --hash=sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d \ - --hash=sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b \ - --hash=sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965 \ - --hash=sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9 \ - --hash=sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f \ - --hash=sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354 \ - --hash=sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433 \ - --hash=sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9 \ - --hash=sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463 \ - --hash=sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5 \ - --hash=sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be \ - --hash=sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580 \ - --hash=sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4 \ - --hash=sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f \ - --hash=sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1 \ - --hash=sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915 \ - --hash=sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033 \ - --hash=sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03 \ - --hash=sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe \ - --hash=sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326 \ - --hash=sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0 \ - --hash=sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e \ - --hash=sha256:ba3df2fc42a1cfa45b72cf096d4acb2b885937eedc61461081d53538d4a82a86 \ - --hash=sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5 \ - --hash=sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e \ - --hash=sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06 \ - --hash=sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936 \ - --hash=sha256:cfa2517c94ea3af6deb46f81e1bbd884faa63e28481eb2f889989dd8d95e5f03 \ - --hash=sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56 \ - --hash=sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6 \ - --hash=sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256 \ - --hash=sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8 \ - --hash=sha256:ee2d84ef5eb6c04702d2e9c372ad557fb027f26a5d82804f749dfb14c7fdd2ab \ - --hash=sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980 \ - --hash=sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10 \ - --hash=sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a \ - --hash=sha256:fb1828cf3da68f99e45ebce1355d65d2d12b6a78fb5dfb16247aad6bdef5f5d2 \ - --hash=sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e - # via -r requirements.in pycparser==3.0 \ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 diff --git a/scripts/e2e-entrypoint.sh b/scripts/e2e-entrypoint.sh index de7939ac..a9a9ca2d 100755 --- a/scripts/e2e-entrypoint.sh +++ b/scripts/e2e-entrypoint.sh @@ -5,12 +5,8 @@ echo "=== E2E: Waiting for database ===" MAX_RETRIES=${DB_WAIT_RETRIES:-60} RETRY=0 -# Detect DB type from DATABASE_URL -case "${DATABASE_URL}" in - mssql*) - DB_CHECK_FILE=$(mktemp) - # Wait for MSSQL server (connect to master — target DB may not exist yet) - cat > "$DB_CHECK_FILE" << 'PYEOF' +DB_CHECK_FILE=$(mktemp) +cat > "$DB_CHECK_FILE" << 'PYEOF' import os, pyodbc, re, sys url = os.environ.get("DATABASE_URL", "") m = re.match(r"mssql\+pyodbc://([^:]+):([^@]+)@([^:]+):(\d+)/([^?]+)", url) @@ -20,15 +16,6 @@ user, pwd, host, port, db = m.groups() sa_pwd = os.environ.get("MSSQL_SA_PASSWORD", pwd) pyodbc.connect(f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={host},{port};DATABASE=master;UID=sa;PWD={sa_pwd};Encrypt=no;TrustServerCertificate=yes", timeout=5).close() PYEOF - ;; - *) - DB_CHECK_FILE=$(mktemp) - cat > "$DB_CHECK_FILE" << 'PYEOF' -import os, psycopg2 -psycopg2.connect(os.environ["DATABASE_URL"], connect_timeout=5).close() -PYEOF - ;; -esac until python "$DB_CHECK_FILE" 2>/dev/null; do RETRY=$((RETRY+1)) @@ -42,18 +29,14 @@ done rm -f "$DB_CHECK_FILE" echo "=== E2E: Running database migrations ===" -case "${DATABASE_URL}" in - mssql*) - # Create DB and user via sa account, then stamp+migrate - echo " Creating MSSQL database (if not exists)..." - python << 'PYEOF' +echo " Creating MSSQL database (if not exists)..." +python << 'PYEOF' import os, pyodbc, re, sys url = os.environ.get("DATABASE_URL", "") m = re.match(r"mssql\+pyodbc://([^:]+):([^@]+)@([^:]+):(\d+)/([^?]+)", url) if not m: sys.exit(f"Cannot parse MSSQL DATABASE_URL: {url}") user, pwd, host, port, db = m.groups() -# Validate identifiers to prevent SQL injection accidents ident_re = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") if not ident_re.fullmatch(db): sys.exit(f"Invalid database name: {db}") @@ -64,7 +47,6 @@ conn = pyodbc.connect(f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={host},{ c = conn.cursor() c.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name='{db}') CREATE DATABASE [{db}] COLLATE Czech_100_CI_AS_SC_UTF8") c.execute(f"ALTER DATABASE [{db}] SET READ_COMMITTED_SNAPSHOT ON") -# Escape single quotes in password for SQL literal safe_pwd = pwd.replace("'", "''") c.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name='{user}') CREATE LOGIN [{user}] WITH PASSWORD='{safe_pwd}'") conn.close() @@ -74,14 +56,9 @@ c2.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name='{u conn2.close() print(f" Database '{db}' ready.") PYEOF - flask db stamp head - flask db migrate -m "e2e_mssql_auto" - flask db upgrade - ;; - *) - flask db upgrade - ;; -esac +flask db stamp head +flask db migrate -m "e2e_mssql_auto" +flask db upgrade echo "=== E2E: Seeding test data ===" python scripts/seed_dev.py diff --git a/tests/conftest.py b/tests/conftest.py index d7a8f247..f7104a1e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import re from datetime import datetime, timezone from typing import TYPE_CHECKING +from urllib.parse import urlsplit, urlunsplit import pytest from sqlalchemy import create_engine, text @@ -20,74 +21,86 @@ # All mutable tables — reference data (role, permission, role_permissions, # app_settings, alembic_version) is preserved across the suite. -_MUTABLE_TABLES = " ,".join( - [ - "event_equipment_assignment", - "event_equipment_plan", - "equipment_item", - "equipment_type", - "debriefing_record", - "assignment", - "event_spot", - "spot_qualifications", - "spot_template_qualifications", - "event_spot_template", - "event_template", - "event", - "master_event", - "user_qualifications", - "qualification_parents", - "qualification", - "registration_invite", - "digest_metric_snapshot", - "digest_block", - "digest_schedule", - "outbox_email", - "audit_log_entry", - "user_feedback", - "user_roles", - "user_account", - ] -) - -# ── Testcontainers: automatic Postgres container lifecycle ───────────────────── +_MUTABLE_TABLES_LIST = [ + "event_equipment_assignment", + "event_equipment_plan", + "equipment_item", + "equipment_type", + "debriefing_record", + "assignment", + "event_spot", + "spot_qualifications", + "spot_template_qualifications", + "event_spot_template", + "event_template", + "event", + "master_event", + "user_qualifications", + "qualification_parents", + "qualification", + "registration_invite", + "digest_metric_snapshot", + "digest_block", + "digest_schedule", + "outbox_email", + "audit_log_entry", + "user_feedback", + "user_roles", + "user_account", +] + +# ── Testcontainers: automatic MSSQL container lifecycle ─────────────────────── # The controller starts one container; xdist workers receive the URL via -# workerinput. If TEST_DATABASE_URL is already set (CI service, local Postgres) +# workerinput. If TEST_DATABASE_URL is already set (CI service, local MSSQL) # the container is skipped entirely. -_tc_postgres: object | None = None +_tc_mssql: object | None = None def pytest_configure(config: pytest.Config) -> None: - """Start a Postgres container when TEST_DATABASE_URL is not pre-set.""" - global _tc_postgres + """Start an MSSQL container when TEST_DATABASE_URL is not pre-set.""" + global _tc_mssql worker_input = getattr(config, "workerinput", None) if worker_input is not None: - # We are an xdist worker — pick up the URL from the controller. if "test_db_url" in worker_input: os.environ["TEST_DATABASE_URL"] = worker_input["test_db_url"] return - # We are the controller (or a plain single-process run). if os.environ.get("TEST_DATABASE_URL"): - # User or CI already provided a DB — respect it, skip the container. - _check_db_reachable(os.environ["TEST_DATABASE_URL"]) + url = os.environ["TEST_DATABASE_URL"] + host, port, db_name, _, password = _parse_mssql_url(url) + _wait_for_mssql(host, port, password) + _create_mssql_db(host, port, password, db_name) + _check_db_reachable(url) return - from testcontainers.postgres import PostgresContainer # pylint: disable=import-outside-toplevel + from testcontainers.mssql import SqlServerContainer # pylint: disable=import-outside-toplevel - container = PostgresContainer( - image="postgres:17", - username="medcover", - password="devpassword", - dbname="medcover_test", - driver="psycopg2", + container = SqlServerContainer( + image="mcr.microsoft.com/mssql/server:2022-latest", + password="DevPassword123!", + dbname="master", + dialect="mssql+pyodbc", ) + container.with_env("MSSQL_PID", "Express") + container.with_env("MSSQL_COLLATION", "Czech_100_CI_AS_SC_UTF8") container.start() - url = container.get_connection_url() - os.environ["TEST_DATABASE_URL"] = url - _tc_postgres = container - config._testcontainer_url = url # type: ignore[attr-defined] + + host = container.get_container_host_ip() + port = container.get_exposed_port(1433) + + # Wait for external connectivity — the internal sqlcmd health check can pass + # before the TCP port is ready for external pyodbc connections. + _wait_for_mssql(host, port, "DevPassword123!") + _create_mssql_db(host, port, "DevPassword123!", "medcover_test") + + base_url = ( + f"mssql+pyodbc://SA:DevPassword123!@{host}:{port}/medcover_test" + "?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes" + ) + os.environ["TEST_DATABASE_URL"] = base_url + _tc_mssql = container + config._testcontainer_url = base_url # type: ignore[attr-defined] def _check_db_reachable(url: str) -> None: @@ -121,11 +134,11 @@ def pytest_configure_node(node: object) -> None: # type: ignore[type-arg] def pytest_unconfigure(config: pytest.Config) -> None: - """Stop the Postgres container once all tests have finished.""" - global _tc_postgres - if _tc_postgres is not None: - _tc_postgres.stop() # type: ignore[attr-defined] - _tc_postgres = None + """Stop the MSSQL container once all tests have finished.""" + global _tc_mssql + if _tc_mssql is not None: + _tc_mssql.stop() # type: ignore[attr-defined] + _tc_mssql = None # ── DB URL helpers ───────────────────────────────────────────────────────────── @@ -136,7 +149,8 @@ def pytest_unconfigure(config: pytest.Config) -> None: def _base_test_db_url() -> str: return os.environ.get( "TEST_DATABASE_URL", - "postgresql://medcover:devpassword@localhost:5432/medcover_test", + "mssql+pyodbc://SA:DevPassword123!@localhost:1433/medcover_test" + "?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes", ) @@ -144,45 +158,98 @@ def _worker_db_url(worker_id: str) -> str: """Return a worker-specific DB URL for xdist parallelism. Each xdist worker (gw0, gw1, …) gets its own database so that - concurrent TRUNCATE operations never conflict. Non-parallel runs + concurrent DELETE operations never conflict. Non-parallel runs (worker_id == 'master') use the base URL unchanged. """ base_url = _base_test_db_url() if worker_id == "master": return base_url - # e.g. medcover_test → medcover_test_gw0 - base, db_name = base_url.rsplit("/", 1) - return f"{base}/{db_name}_{worker_id}" + # Parse URL to replace only the database name segment before the query string + # e.g. mssql+pyodbc://SA:pwd@host:1433/medcover_test?driver=... → + # mssql+pyodbc://SA:pwd@host:1433/medcover_test_gw0?driver=... + parts = urlsplit(base_url) + db_name = parts.path.lstrip("/") + new_path = f"/{db_name}_{worker_id}" + return urlunsplit(parts._replace(path=new_path)) + + +def _parse_mssql_url(url: str) -> tuple[str, int, str, str, str]: + """Extract (host, port, db_name, user, password) from an MSSQL URL.""" + parts = urlsplit(url) + host = parts.hostname or "localhost" + port = parts.port or 1433 + db_name = parts.path.lstrip("/") + user = parts.username or "SA" + password = parts.password or "" + return host, port, db_name, user, password + + +def _wait_for_mssql(host: str, port: int, sa_password: str, timeout: int = 60) -> None: + """Wait until MSSQL accepts external pyodbc connections.""" + import time # pylint: disable=import-outside-toplevel + + import pyodbc # pylint: disable=import-outside-toplevel + + conn_str = ( + f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={host},{port};" + f"DATABASE=master;UID=SA;PWD={sa_password};Encrypt=no;TrustServerCertificate=yes" + ) + deadline = time.monotonic() + timeout + last_exc: Exception | None = None + while time.monotonic() < deadline: + try: + pyodbc.connect(conn_str, timeout=3).close() + return + except Exception as exc: # noqa: BLE001 + last_exc = exc + time.sleep(2) + raise RuntimeError(f"MSSQL not ready after {timeout}s: {last_exc}") from last_exc + + +def _create_mssql_db(host: str, port: int, sa_password: str, db_name: str) -> None: + """Create an MSSQL database with Czech collation and RCSI enabled.""" + import pyodbc # pylint: disable=import-outside-toplevel + + conn_str = ( + f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={host},{port};" + f"DATABASE=master;UID=SA;PWD={sa_password};Encrypt=no;TrustServerCertificate=yes" + ) + conn = pyodbc.connect(conn_str) + conn.autocommit = True + c = conn.cursor() + c.execute( + f"IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name='{db_name}') " + f"CREATE DATABASE [{db_name}] COLLATE Czech_100_CI_AS_SC_UTF8" + ) + c.execute(f"ALTER DATABASE [{db_name}] SET READ_COMMITTED_SNAPSHOT ON") + conn.close() def _ensure_db_exists(db_url: str) -> None: - """Create the database if it does not already exist.""" - base, db_name = db_url.rsplit("/", 1) - maintenance_url = f"{base}/postgres" - engine = create_engine(maintenance_url, isolation_level="AUTOCOMMIT") - with engine.connect() as conn: - exists = conn.execute(text("SELECT 1 FROM pg_database WHERE datname = :n"), {"n": db_name}).fetchone() - if not exists: - conn.execute(text(f'CREATE DATABASE "{db_name}"')) - engine.dispose() + """Create the worker database if it does not already exist.""" + host, port, db_name, _, password = _parse_mssql_url(db_url) + _create_mssql_db(host, port, password, db_name) def _drop_db(db_url: str) -> None: """Drop the worker database (only called for worker-specific DBs).""" - base, db_name = db_url.rsplit("/", 1) - maintenance_url = f"{base}/postgres" - engine = create_engine(maintenance_url, isolation_level="AUTOCOMMIT") - with engine.connect() as conn: - # Terminate open connections before dropping - conn.execute( - text( - "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " - "WHERE datname = :n AND pid <> pg_backend_pid()" - ), - {"n": db_name}, - ) - conn.execute(text(f'DROP DATABASE IF EXISTS "{db_name}"')) - engine.dispose() + import pyodbc # pylint: disable=import-outside-toplevel + + host, port, db_name, _, password = _parse_mssql_url(db_url) + conn_str = ( + f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={host},{port};" + f"DATABASE=master;UID=SA;PWD={password};Encrypt=no;TrustServerCertificate=yes" + ) + conn = pyodbc.connect(conn_str) + conn.autocommit = True + c = conn.cursor() + # Force disconnect all active connections before dropping + c.execute( + f"IF EXISTS (SELECT 1 FROM sys.databases WHERE name='{db_name}') " + f"ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE" + ) + c.execute(f"DROP DATABASE IF EXISTS [{db_name}]") + conn.close() @pytest.fixture(scope="session") @@ -251,12 +318,13 @@ def _seed_reference_data() -> None: @pytest.fixture(autouse=True) def clean_db(app): - """Truncate all mutable tables after every test to keep tests isolated. + """Delete all mutable rows after every test to keep tests isolated. - TRUNCATE ... CASCADE handles FK ordering automatically. The app fixture no - longer holds a persistent connection, so the ACCESS EXCLUSIVE lock is safe. + For MSSQL we disable FK constraints, delete all rows in each mutable + table, then re-enable constraints. Identity columns are left as-is + (tests don't depend on specific ID values). - AppSettings is NOT truncated (it is reference data seeded once) but any + AppSettings is NOT cleared (it is reference data seeded once) but any fields that tests may mutate are explicitly reset to their defaults so that test order does not matter. """ @@ -264,7 +332,16 @@ def clean_db(app): with app.app_context(): _db.session.remove() with _db.engine.connect() as conn: - conn.execute(_db.text(f"TRUNCATE TABLE {_MUTABLE_TABLES} RESTART IDENTITY CASCADE")) + preparer = _db.engine.dialect.identifier_preparer + for t in _MUTABLE_TABLES_LIST: + qt = preparer.quote(t) + conn.execute(_db.text(f"ALTER TABLE {qt} NOCHECK CONSTRAINT ALL")) + for t in _MUTABLE_TABLES_LIST: + qt = preparer.quote(t) + conn.execute(_db.text(f"DELETE FROM {qt}")) + for t in _MUTABLE_TABLES_LIST: + qt = preparer.quote(t) + conn.execute(_db.text(f"ALTER TABLE {qt} CHECK CONSTRAINT ALL")) conn.commit() # Reset mutable AppSettings fields to their defaults settings = _db.session.get(AppSettings, 1) diff --git a/tests/test_config.py b/tests/test_config.py index 95fbc6e1..3b4eea76 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,7 +22,7 @@ def test_raises_without_database_url(self): def test_raises_without_secret_key(self): env = {k: v for k, v in os.environ.items() if k not in ("SECRET_KEY",)} - env["DATABASE_URL"] = "postgresql://localhost/testdb" + env["DATABASE_URL"] = "mssql+pyodbc://SA:pwd@localhost:1433/testdb?driver=ODBC+Driver+18+for+SQL+Server" with patch.dict(os.environ, env, clear=True): try: DevelopmentConfig.init_app(object()) @@ -32,26 +32,49 @@ def test_raises_without_secret_key(self): def test_no_raise_when_both_set(self): - with patch.dict(os.environ, {"DATABASE_URL": "postgresql://x/y", "SECRET_KEY": "s"}): + with patch.dict( + os.environ, + { + "DATABASE_URL": "mssql+pyodbc://SA:pwd@localhost:1433/db?driver=ODBC+Driver+18+for+SQL+Server", + "SECRET_KEY": "s", + }, + ): DevelopmentConfig.init_app(object()) # must not raise class TestProductionConfigInitApp: - def test_warns_when_sslmode_missing(self): + def test_warns_when_encrypt_missing(self): - with patch.dict(os.environ, {"DATABASE_URL": "postgresql://localhost/prod"}): + _url = "mssql+pyodbc://SA:pwd@server.database.windows.net:1433/db" "?driver=ODBC+Driver+18+for+SQL+Server" + with patch.dict(os.environ, {"DATABASE_URL": _url}): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") ProductionConfig.init_app(object()) - assert any("sslmode" in str(warning.message) for warning in w) + assert any("Encrypt=yes" in str(warning.message) for warning in w) - def test_no_warn_when_sslmode_present(self): + def test_no_warn_when_encrypt_present(self): - with patch.dict(os.environ, {"DATABASE_URL": "postgresql://localhost/prod?sslmode=require"}): + _url = ( + "mssql+pyodbc://SA:pwd@server.database.windows.net:1433/db" + "?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=yes" + ) + with patch.dict(os.environ, {"DATABASE_URL": _url}): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") ProductionConfig.init_app(object()) - assert not any("sslmode" in str(warning.message) for warning in w) + assert not any("Encrypt=yes" in str(warning.message) for warning in w) + + def test_no_warn_when_msi(self): + + _url = ( + "mssql+pyodbc://@server.database.windows.net/db" + "?driver=ODBC+Driver+18+for+SQL+Server&Authentication=ActiveDirectoryMsi" + ) + with patch.dict(os.environ, {"DATABASE_URL": _url}): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + ProductionConfig.init_app(object()) + assert not any("Encrypt" in str(warning.message) for warning in w) def test_no_warn_when_database_url_empty(self): """Empty DATABASE_URL should not trigger the warning (setup wizard case).""" @@ -60,4 +83,4 @@ def test_no_warn_when_database_url_empty(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") ProductionConfig.init_app(object()) - assert not any("sslmode" in str(warning.message) for warning in w) + assert not any("Encrypt" in str(warning.message) for warning in w) From 2e67b08db93a73dd2aef5f883626b81e21860d9b Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Thu, 18 Jun 2026 14:11:56 +0200 Subject: [PATCH 11/31] Fix CI: bump cryptography to 49.0.0, fix gpg --batch flag for ODBC install --- .github/workflows/ci.yml | 2 +- requirements-dev.txt | 498 +++++++++++++++++---------------------- requirements-e2e.txt | 172 +++++++------- requirements.txt | 387 +++++++++++++++--------------- 4 files changed, 492 insertions(+), 567 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bdd11f4..4f52cc8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: - name: Install ODBC Driver for SQL Server run: | - curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg + curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --batch --dearmor -o /usr/share/keyrings/microsoft-prod.gpg echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update sudo ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 unixodbc-dev diff --git a/requirements-dev.txt b/requirements-dev.txt index fb27d87b..54ce9fa3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -53,9 +53,9 @@ cachetools==7.1.4 \ --hash=sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54 \ --hash=sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6 # via tox -certifi==2026.5.20 \ - --hash=sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897 \ - --hash=sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d +certifi==2026.6.17 \ + --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ + --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db # via requests cffi==2.0.0 \ --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ @@ -394,60 +394,57 @@ coverage[toml]==7.14.1 \ --hash=sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253 \ --hash=sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c # via pytest-cov -cryptography==48.0.0 \ - --hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \ - --hash=sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6 \ - --hash=sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8 \ - --hash=sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25 \ - --hash=sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c \ - --hash=sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832 \ - --hash=sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12 \ - --hash=sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c \ - --hash=sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7 \ - --hash=sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c \ - --hash=sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec \ - --hash=sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5 \ - --hash=sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355 \ - --hash=sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c \ - --hash=sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741 \ - --hash=sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86 \ - --hash=sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321 \ - --hash=sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a \ - --hash=sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7 \ - --hash=sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920 \ - --hash=sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e \ - --hash=sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff \ - --hash=sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd \ - --hash=sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3 \ - --hash=sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f \ - --hash=sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602 \ - --hash=sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855 \ - --hash=sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18 \ - --hash=sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a \ - --hash=sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336 \ - --hash=sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239 \ - --hash=sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74 \ - --hash=sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a \ - --hash=sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c \ - --hash=sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4 \ - --hash=sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c \ - --hash=sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f \ - --hash=sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4 \ - --hash=sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db \ - --hash=sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166 \ - --hash=sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5 \ - --hash=sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f \ - --hash=sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae \ - --hash=sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20 \ - --hash=sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a \ - --hash=sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057 \ - --hash=sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb \ - --hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \ - --hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b +cryptography==49.0.0 \ + --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ + --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ + --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ + --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ + --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ + --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ + --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ + --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ + --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ + --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ + --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ + --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ + --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ + --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ + --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ + --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ + --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ + --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ + --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ + --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ + --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ + --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ + --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ + --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ + --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ + --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ + --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ + --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ + --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ + --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ + --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ + --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ + --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ + --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ + --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ + --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ + --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ + --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ + --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ + --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ + --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ + --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ + --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ + --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ + --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ + --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b # via -r requirements.in -distlib==0.4.2 \ - --hash=sha256:baeb401c90f27acd15c4861ae0847d1e731c27ac3dbf4210643ba61fa1e813db \ - --hash=sha256:ca4cb11e5d746b5ec13c199cbf19ae27a241f89702b54e153a74332955446067 +distlib==0.4.3 \ + --hash=sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b \ + --hash=sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed # via virtualenv docker==7.1.0 \ --hash=sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c \ @@ -461,13 +458,13 @@ execnet==2.1.2 \ --hash=sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd \ --hash=sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec # via pytest-xdist -faker==40.21.0 \ - --hash=sha256:2fdee1b650a723a54432db9c6dfe17cfa29d1adc8bd60520444a07698524ba4d \ - --hash=sha256:cb6601b2ae8e128895dc96814d271eab6b930a2d2d7932c6f9ff26785c24ee18 +faker==40.23.0 \ + --hash=sha256:775922453e54afa42eaf60eac478fa3a969357f224d09a8022b93e3ad88f18ae \ + --hash=sha256:f135e563f1f95f19346bb680bc2e43570bc43b7893e566023746f51f32c69dfc # via -r requirements-dev.in -filelock==3.29.1 \ - --hash=sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b \ - --hash=sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e +filelock==3.29.4 \ + --hash=sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a \ + --hash=sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767 # via # python-discovery # tox @@ -508,98 +505,98 @@ flask-wtf==1.3.0 \ --hash=sha256:61d5dabc50c3df885c297dcbd80810443a5d632106c8a69cab8ce740f0cdd7cc \ --hash=sha256:dc5e3a4ce97f75c47bf6c1c72ad2c3b7bdf579a2ed13aebcc5d3d81fe2571160 # via -r requirements.in -greenlet==3.5.1 \ - --hash=sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061 \ - --hash=sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19 \ - --hash=sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747 \ - --hash=sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1 \ - --hash=sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10 \ - --hash=sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0 \ - --hash=sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9 \ - --hash=sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64 \ - --hash=sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659 \ - --hash=sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a \ - --hash=sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541 \ - --hash=sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e \ - --hash=sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c \ - --hash=sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d \ - --hash=sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b \ - --hash=sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986 \ - --hash=sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78 \ - --hash=sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2 \ - --hash=sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a \ - --hash=sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc \ - --hash=sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b \ - --hash=sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5 \ - --hash=sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829 \ - --hash=sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea \ - --hash=sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436 \ - --hash=sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c \ - --hash=sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360 \ - --hash=sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f \ - --hash=sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244 \ - --hash=sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283 \ - --hash=sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54 \ - --hash=sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f \ - --hash=sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2 \ - --hash=sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188 \ - --hash=sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e \ - --hash=sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249 \ - --hash=sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3 \ - --hash=sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563 \ - --hash=sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de \ - --hash=sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6 \ - --hash=sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368 \ - --hash=sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26 \ - --hash=sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de \ - --hash=sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d \ - --hash=sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2 \ - --hash=sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e \ - --hash=sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33 \ - --hash=sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d \ - --hash=sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c \ - --hash=sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd \ - --hash=sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207 \ - --hash=sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed \ - --hash=sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b \ - --hash=sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62 \ - --hash=sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1 \ - --hash=sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0 \ - --hash=sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c \ - --hash=sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823 \ - --hash=sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab \ - --hash=sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523 \ - --hash=sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd \ - --hash=sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c \ - --hash=sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce \ - --hash=sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c \ - --hash=sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07 \ - --hash=sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135 \ - --hash=sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e \ - --hash=sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071 \ - --hash=sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f \ - --hash=sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5 \ - --hash=sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee \ - --hash=sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e \ - --hash=sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f \ - --hash=sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad \ - --hash=sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97 \ - --hash=sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc \ - --hash=sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e \ - --hash=sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2 \ - --hash=sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed +greenlet==3.5.2 \ + --hash=sha256:01e32e9d2b1714a2b06184cb3071ff2a2fd9bc7d065e39198ab21f7253dad421 \ + --hash=sha256:0488ca77c94da5e09d1d9958f98b58cebba1b8fd9664c24898499133de927574 \ + --hash=sha256:049827baab63dda8ab8ec5a6d07fc6eb0f418319cfc757fc8737a605e99ca1ad \ + --hash=sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34 \ + --hash=sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473 \ + --hash=sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32 \ + --hash=sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094 \ + --hash=sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4 \ + --hash=sha256:1724499fc08388208408681c53c5062e9803c334e5a0bdaeb616228ba882aac8 \ + --hash=sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea \ + --hash=sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb \ + --hash=sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8 \ + --hash=sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3 \ + --hash=sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682 \ + --hash=sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163 \ + --hash=sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c \ + --hash=sha256:2c6d6bfa4fdd7c39a0dbf112cdf28edbd19c517c810eefb6e4e71b0d55933a4c \ + --hash=sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f \ + --hash=sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c \ + --hash=sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742 \ + --hash=sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32 \ + --hash=sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de \ + --hash=sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904 \ + --hash=sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e \ + --hash=sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a \ + --hash=sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5 \ + --hash=sha256:4e554809538bd4867f24421b43abde170f9c9b8192149b30df5e164bcac6124f \ + --hash=sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef \ + --hash=sha256:561dd919c02236a613fbf226791cbd77ee5002cbd5cb7e838869aa3ac7a71e16 \ + --hash=sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8 \ + --hash=sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b \ + --hash=sha256:5eba55076d79e8a5176e6925295cfb901ebc95dae493342ede22230f75d8bee2 \ + --hash=sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9 \ + --hash=sha256:6d9e19257794e28821c9ebd5e23f86d7c267cd9d390089374f068d2049f949e3 \ + --hash=sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d \ + --hash=sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f \ + --hash=sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39 \ + --hash=sha256:711028c953cd6ce5dc01bbb5a1747e3ad6bd8b2f7ded73778bb936e8dab9e3b6 \ + --hash=sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f \ + --hash=sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9 \ + --hash=sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce \ + --hash=sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00 \ + --hash=sha256:87359c23eb4e8f1b16da68faad29bf5aeb80e3628d7d8e4aa2e41c36879ddedd \ + --hash=sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146 \ + --hash=sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3 \ + --hash=sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c \ + --hash=sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e \ + --hash=sha256:9df9daae96848508450011d0d86ed7c95f8829a354ce438284a77b24896fd1f8 \ + --hash=sha256:9e194b996aa1b89d933cfe136e5eb39b22a8b72ba59d376ef39a55bca4dbf47f \ + --hash=sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb \ + --hash=sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5 \ + --hash=sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3 \ + --hash=sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317 \ + --hash=sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a \ + --hash=sha256:a3f76a94e2d6e1fee8f302265679d8cc47d71a203936dd03c6e2ace0f9cfd46d \ + --hash=sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad \ + --hash=sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014 \ + --hash=sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0 \ + --hash=sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1 \ + --hash=sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e \ + --hash=sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5 \ + --hash=sha256:bc18b8d33e6976804b9b792fe11cb3b1fee8b646e8a9e20bf521a429ddf73520 \ + --hash=sha256:bf493b3c1c0a2324c49b0472e2280ba4665f3510d8115f6f807759a6163b15f7 \ + --hash=sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a \ + --hash=sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c \ + --hash=sha256:c1c1e5ad80f1f38ea479b83b39dccb20874cfe9ad5e52f87225fa294ba4d39a1 \ + --hash=sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73 \ + --hash=sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9 \ + --hash=sha256:d7792398872f89466c6671d5d193537eff163ecf7fac78d82e6ddc25017fb4f5 \ + --hash=sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f \ + --hash=sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95 \ + --hash=sha256:e063263ce9047878480d7e536012fc8b7c8e1922989eb5f03b9ab998a2ee7b7e \ + --hash=sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c \ + --hash=sha256:e976f9f6941f57d87a194c91868622c8b22a142a741d2fde31655c319133ade6 \ + --hash=sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7 \ + --hash=sha256:f4d67c1684db3f9782c37ee4bade3f86f5a23a8fcf3f8359224106018ca40728 \ + --hash=sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4 \ + --hash=sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74 \ + --hash=sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c # via sqlalchemy gunicorn==26.0.0 \ --hash=sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc \ --hash=sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf # via -r requirements.in -holidays==0.98 \ - --hash=sha256:09b078a4695d53a35ea01517b331942f3232b3b7e3877f74f6d5227adbedc76e \ - --hash=sha256:65c9ba3402f1d10cc715af0510cdc847e60d8854df640515d546748ca4a24e60 +holidays==0.99 \ + --hash=sha256:9ef8278cdfb67dbd93309ec9b30c30609ab35fd57cb207ce4593f80dc91196f5 \ + --hash=sha256:bc47cefa781dbc6415e782767dea013794146cc629845354b393c53cdee90c64 # via -r requirements.in -icalendar==7.1.2 \ - --hash=sha256:01c76243c76c549f58bb51510a8f0a4edb7c539726adda1356dfd0dc04fb7a53 \ - --hash=sha256:ebc43ebeb357be98984b573d975118008dee3410d8df28b054ef2943cf3e367e +icalendar==7.1.3 \ + --hash=sha256:690f30aa50a76cbf854db5ad52654705db9c5cd0e1b152222f5d4b7854b60667 \ + --hash=sha256:eb03a0e215f30db689a72c49f18f998aabf17522eb0858a293c393510850727c # via -r requirements.in identify==2.6.19 \ --hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \ @@ -908,75 +905,6 @@ pre-commit==4.6.0 \ --hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \ --hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b # via -r requirements-dev.in -psycopg2-binary==2.9.12 \ - --hash=sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f \ - --hash=sha256:049366c6d884bdcd65d66e6ca1fdbebe670b56c6c9ba46f164e6667e90881964 \ - --hash=sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c \ - --hash=sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2 \ - --hash=sha256:127467c6e476dd876634f17c3d870530e73ff454ff99bff73d36e80af28e1115 \ - --hash=sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c \ - --hash=sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be \ - --hash=sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6 \ - --hash=sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c \ - --hash=sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c \ - --hash=sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c \ - --hash=sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d \ - --hash=sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019 \ - --hash=sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7 \ - --hash=sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3 \ - --hash=sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777 \ - --hash=sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd \ - --hash=sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5 \ - --hash=sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39 \ - --hash=sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c \ - --hash=sha256:5c7cb4cbf894a1d36c720d713de507952c7c58f66d30834708f03dbe5c822ccf \ - --hash=sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b \ - --hash=sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433 \ - --hash=sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d \ - --hash=sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4 \ - --hash=sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290 \ - --hash=sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2 \ - --hash=sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3 \ - --hash=sha256:718e1fc18edf573b02cb8aea868de8d8d33f99ce9620206aa9144b67b0985e94 \ - --hash=sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d \ - --hash=sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b \ - --hash=sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965 \ - --hash=sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9 \ - --hash=sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f \ - --hash=sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354 \ - --hash=sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433 \ - --hash=sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9 \ - --hash=sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463 \ - --hash=sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5 \ - --hash=sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be \ - --hash=sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580 \ - --hash=sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4 \ - --hash=sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f \ - --hash=sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1 \ - --hash=sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915 \ - --hash=sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033 \ - --hash=sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03 \ - --hash=sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe \ - --hash=sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326 \ - --hash=sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0 \ - --hash=sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e \ - --hash=sha256:ba3df2fc42a1cfa45b72cf096d4acb2b885937eedc61461081d53538d4a82a86 \ - --hash=sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5 \ - --hash=sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e \ - --hash=sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06 \ - --hash=sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936 \ - --hash=sha256:cfa2517c94ea3af6deb46f81e1bbd884faa63e28481eb2f889989dd8d95e5f03 \ - --hash=sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56 \ - --hash=sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6 \ - --hash=sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256 \ - --hash=sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8 \ - --hash=sha256:ee2d84ef5eb6c04702d2e9c372ad557fb027f26a5d82804f749dfb14c7fdd2ab \ - --hash=sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980 \ - --hash=sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10 \ - --hash=sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a \ - --hash=sha256:fb1828cf3da68f99e45ebce1355d65d2d12b6a78fb5dfb16247aad6bdef5f5d2 \ - --hash=sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e - # via -r requirements.in pycodestyle==2.14.0 \ --hash=sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783 \ --hash=sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d @@ -1063,9 +991,9 @@ pyproject-api==1.10.1 \ --hash=sha256:c2b2726bd7aa9217b6c50b621fef5b2ae5def4d55b779c9e0694c15e0a8517ba \ --hash=sha256:fa9e6f66c35b5017e909825d8f2b5d5482ea699d7be809d21c03bd1f7317f36a # via tox -pytest==9.0.3 \ - --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ - --hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c +pytest==9.1.0 \ + --hash=sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c \ + --hash=sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32 # via # -r requirements-dev.in # pytest-cov @@ -1089,9 +1017,9 @@ python-dateutil==2.9.0.post0 \ # via # holidays # icalendar -python-discovery==1.4.0 \ - --hash=sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da \ - --hash=sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3 +python-discovery==1.4.2 \ + --hash=sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500 \ + --hash=sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690 # via # tox # virtualenv @@ -1192,65 +1120,65 @@ six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 # via python-dateutil -sqlalchemy==2.0.50 \ - --hash=sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064 \ - --hash=sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093 \ - --hash=sha256:0a31c5963d58d3e3d11c5b97709e248305705de1fdf51ec3bf396674c5898b7e \ - --hash=sha256:0e104e196f457ec608eb8af736c5eb4c6bc58f481b546f485a7f9c628ee532be \ - --hash=sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e \ - --hash=sha256:0fe7822866f3a9fc5f3db21a290ce8961a53050115f05edf9402b6a5feb92a9f \ - --hash=sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86 \ - --hash=sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600 \ - --hash=sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a \ - --hash=sha256:13b85b20f9ab714a666df9d8e72e253ec33c16c7e1e375c877e5bf6367a3e917 \ - --hash=sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39 \ - --hash=sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a \ - --hash=sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508 \ - --hash=sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5 \ - --hash=sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e \ - --hash=sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb \ - --hash=sha256:27b7062af702c61994e8806ad87e42d0a2c879e0a8e5c61c7f69d81dabe24fdf \ - --hash=sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3 \ - --hash=sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f \ - --hash=sha256:2c1920cde9d741ba3dda9b1aa5acd8c23ea17780ccfb2252d01878d5d0d628d3 \ - --hash=sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c \ - --hash=sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db \ - --hash=sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70 \ - --hash=sha256:3d10700bd519573f6ce5badbabbfe7f5baea84cdf370f2cbbfb4be28dfddbf1d \ - --hash=sha256:409a8121b917116b035bedc5e532ad470c74a2d279f6c302100985b6304e9f9e \ - --hash=sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89 \ - --hash=sha256:4a8e8af330cbb3a1931d3d6c91b239fc2ef135f7dd471dfa34c575028e0b1fa8 \ - --hash=sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3 \ - --hash=sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4 \ - --hash=sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5 \ - --hash=sha256:66e374271ecb7101273f57af1a62446a953d327eec4f8089147de57c591bbacc \ - --hash=sha256:68b154b08088b4ec32bb4d2958bfbb50e57549f91a4cd3e7f928e3553ed69031 \ - --hash=sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e \ - --hash=sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615 \ - --hash=sha256:7af6eeb84985bf840ba779018ff9424d61ff69b52e66b8789d3c8da7bf5341b2 \ - --hash=sha256:7b1ddb7b5fc60dfa9df6a487f06a143c77def47c0351849da2bcea59b244a56c \ - --hash=sha256:7e36efdcc5493f8024ec873a4ee3855bfd2de0c5b19eba16f920e9d2a0d28622 \ - --hash=sha256:83a9fce296b7e052316d8c6943237b31b9c00f58ca9c253f2d165df52637a293 \ - --hash=sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873 \ - --hash=sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8 \ - --hash=sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9 \ - --hash=sha256:9602c07b03e1449747ecb69f9998a7194a589124475788b370adce57c9e9a56e \ - --hash=sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f \ - --hash=sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7 \ - --hash=sha256:adc0fe7d38d8c8058f7421c25508fcbc74df38233a42aa8324409844122dce8f \ - --hash=sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9 \ - --hash=sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d \ - --hash=sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d \ - --hash=sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52 \ - --hash=sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51 \ - --hash=sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0 \ - --hash=sha256:e195687f1af431c9515416288373b323b6eb599f774409814e89e9d603a56e39 \ - --hash=sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22 \ - --hash=sha256:e8e1b0f6a4dcd9b4839e2320afb5df37a6981cbc20ff9c423ae11c5537bdbd21 \ - --hash=sha256:ea1a8a2db4b2217d456c8d7a873bfc605f06fe3584d315264ea18c2a17585d0b \ - --hash=sha256:eefd9a03cc0047b14153872d228499d048bd7deaf926109c9ec25b15157b8e23 \ - --hash=sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086 \ - --hash=sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb +sqlalchemy==2.0.51 \ + --hash=sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23 \ + --hash=sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1 \ + --hash=sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8 \ + --hash=sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72 \ + --hash=sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0 \ + --hash=sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5 \ + --hash=sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e \ + --hash=sha256:111604e637da87031255ddc26c7d7bc22bc6af6f5d459ccff3af1b4660233a85 \ + --hash=sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d \ + --hash=sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2 \ + --hash=sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba \ + --hash=sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652 \ + --hash=sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f \ + --hash=sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9 \ + --hash=sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84 \ + --hash=sha256:247acaa29ccef6250dfd6a3eedf8f94ddf23564180a39fe362e32ae9dbdbde46 \ + --hash=sha256:2a97eaad21c84b4ef8010b11eeba9fe6153eb0b3df3ff8b6abc309df1b978ef7 \ + --hash=sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080 \ + --hash=sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d \ + --hash=sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d \ + --hash=sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54 \ + --hash=sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd \ + --hash=sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195 \ + --hash=sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc \ + --hash=sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e \ + --hash=sha256:59cab3686b1bc039dd9cded2f8d0c08a246e84e76bd4ab5b4f18c7cdae293825 \ + --hash=sha256:6b588fd681ddf0c196b8df1ea49a8913514894b2b8f945a9511b4b48871f99c8 \ + --hash=sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522 \ + --hash=sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491 \ + --hash=sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400 \ + --hash=sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a \ + --hash=sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07 \ + --hash=sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7 \ + --hash=sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a \ + --hash=sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9 \ + --hash=sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7 \ + --hash=sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499 \ + --hash=sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5 \ + --hash=sha256:a42ad6afcbaaa777241e347aa2e29155993045a0d6b7db74da61053ffe875fe0 \ + --hash=sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604 \ + --hash=sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265 \ + --hash=sha256:aa18ae738b5170e253ad0bb6c4b0f07585081e8a6e50893e4d911d47b39a0904 \ + --hash=sha256:ad30ae663711786303fbcd46a47516302d201ee49a877cb3fac61f672895110a \ + --hash=sha256:b21f0e7efc7a5c509e953784e9d1575ebb8b4318960e7e7d7a93bb803626cf64 \ + --hash=sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d \ + --hash=sha256:b7f08588854bbb724041d9ae9d980d40040c922382e1d9a2ecb390edc4fd5032 \ + --hash=sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b \ + --hash=sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5 \ + --hash=sha256:bb1f5062f98b0b3290e72b707747fdd7e0f22d6956b236ba7ca7f5c9971d2da2 \ + --hash=sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d \ + --hash=sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389 \ + --hash=sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080 \ + --hash=sha256:c95ef01f53233a305a874a44a63fbfb1d81cd79b49de0f8529b3548cde437e37 \ + --hash=sha256:ca216e8af5c05e326efc7e28716ac2381a7cf9791749f5ee1849dccdc99c9b00 \ + --hash=sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86 \ + --hash=sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260 \ + --hash=sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de \ + --hash=sha256:fa268106c8987639a17a18514cfe0cd9bf17420ab887e1e1bf486da8836135b1 # via # alembic # flask-sqlalchemy @@ -1285,9 +1213,9 @@ urllib3==2.7.0 \ # docker # requests # testcontainers -virtualenv==21.4.2 \ - --hash=sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c \ - --hash=sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae +virtualenv==21.5.1 \ + --hash=sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783 \ + --hash=sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8 # via # pre-commit # tox diff --git a/requirements-e2e.txt b/requirements-e2e.txt index 4ea73a8a..1178880d 100644 --- a/requirements-e2e.txt +++ b/requirements-e2e.txt @@ -4,9 +4,9 @@ # # pip-compile --generate-hashes --output-file=requirements-e2e.txt requirements-e2e.in # -certifi==2026.5.20 \ - --hash=sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897 \ - --hash=sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d +certifi==2026.6.17 \ + --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ + --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db # via requests charset-normalizer==3.4.7 \ --hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \ @@ -139,86 +139,86 @@ charset-normalizer==3.4.7 \ --hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \ --hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464 # via requests -greenlet==3.5.1 \ - --hash=sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061 \ - --hash=sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19 \ - --hash=sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747 \ - --hash=sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1 \ - --hash=sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10 \ - --hash=sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0 \ - --hash=sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9 \ - --hash=sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64 \ - --hash=sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659 \ - --hash=sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a \ - --hash=sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541 \ - --hash=sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e \ - --hash=sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c \ - --hash=sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d \ - --hash=sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b \ - --hash=sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986 \ - --hash=sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78 \ - --hash=sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2 \ - --hash=sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a \ - --hash=sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc \ - --hash=sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b \ - --hash=sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5 \ - --hash=sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829 \ - --hash=sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea \ - --hash=sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436 \ - --hash=sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c \ - --hash=sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360 \ - --hash=sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f \ - --hash=sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244 \ - --hash=sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283 \ - --hash=sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54 \ - --hash=sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f \ - --hash=sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2 \ - --hash=sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188 \ - --hash=sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e \ - --hash=sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249 \ - --hash=sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3 \ - --hash=sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563 \ - --hash=sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de \ - --hash=sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6 \ - --hash=sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368 \ - --hash=sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26 \ - --hash=sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de \ - --hash=sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d \ - --hash=sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2 \ - --hash=sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e \ - --hash=sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33 \ - --hash=sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d \ - --hash=sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c \ - --hash=sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd \ - --hash=sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207 \ - --hash=sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed \ - --hash=sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b \ - --hash=sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62 \ - --hash=sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1 \ - --hash=sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0 \ - --hash=sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c \ - --hash=sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823 \ - --hash=sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab \ - --hash=sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523 \ - --hash=sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd \ - --hash=sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c \ - --hash=sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce \ - --hash=sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c \ - --hash=sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07 \ - --hash=sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135 \ - --hash=sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e \ - --hash=sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071 \ - --hash=sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f \ - --hash=sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5 \ - --hash=sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee \ - --hash=sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e \ - --hash=sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f \ - --hash=sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad \ - --hash=sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97 \ - --hash=sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc \ - --hash=sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e \ - --hash=sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2 \ - --hash=sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed +greenlet==3.5.2 \ + --hash=sha256:01e32e9d2b1714a2b06184cb3071ff2a2fd9bc7d065e39198ab21f7253dad421 \ + --hash=sha256:0488ca77c94da5e09d1d9958f98b58cebba1b8fd9664c24898499133de927574 \ + --hash=sha256:049827baab63dda8ab8ec5a6d07fc6eb0f418319cfc757fc8737a605e99ca1ad \ + --hash=sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34 \ + --hash=sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473 \ + --hash=sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32 \ + --hash=sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094 \ + --hash=sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4 \ + --hash=sha256:1724499fc08388208408681c53c5062e9803c334e5a0bdaeb616228ba882aac8 \ + --hash=sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea \ + --hash=sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb \ + --hash=sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8 \ + --hash=sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3 \ + --hash=sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682 \ + --hash=sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163 \ + --hash=sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c \ + --hash=sha256:2c6d6bfa4fdd7c39a0dbf112cdf28edbd19c517c810eefb6e4e71b0d55933a4c \ + --hash=sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f \ + --hash=sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c \ + --hash=sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742 \ + --hash=sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32 \ + --hash=sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de \ + --hash=sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904 \ + --hash=sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e \ + --hash=sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a \ + --hash=sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5 \ + --hash=sha256:4e554809538bd4867f24421b43abde170f9c9b8192149b30df5e164bcac6124f \ + --hash=sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef \ + --hash=sha256:561dd919c02236a613fbf226791cbd77ee5002cbd5cb7e838869aa3ac7a71e16 \ + --hash=sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8 \ + --hash=sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b \ + --hash=sha256:5eba55076d79e8a5176e6925295cfb901ebc95dae493342ede22230f75d8bee2 \ + --hash=sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9 \ + --hash=sha256:6d9e19257794e28821c9ebd5e23f86d7c267cd9d390089374f068d2049f949e3 \ + --hash=sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d \ + --hash=sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f \ + --hash=sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39 \ + --hash=sha256:711028c953cd6ce5dc01bbb5a1747e3ad6bd8b2f7ded73778bb936e8dab9e3b6 \ + --hash=sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f \ + --hash=sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9 \ + --hash=sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce \ + --hash=sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00 \ + --hash=sha256:87359c23eb4e8f1b16da68faad29bf5aeb80e3628d7d8e4aa2e41c36879ddedd \ + --hash=sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146 \ + --hash=sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3 \ + --hash=sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c \ + --hash=sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e \ + --hash=sha256:9df9daae96848508450011d0d86ed7c95f8829a354ce438284a77b24896fd1f8 \ + --hash=sha256:9e194b996aa1b89d933cfe136e5eb39b22a8b72ba59d376ef39a55bca4dbf47f \ + --hash=sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb \ + --hash=sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5 \ + --hash=sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3 \ + --hash=sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317 \ + --hash=sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a \ + --hash=sha256:a3f76a94e2d6e1fee8f302265679d8cc47d71a203936dd03c6e2ace0f9cfd46d \ + --hash=sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad \ + --hash=sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014 \ + --hash=sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0 \ + --hash=sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1 \ + --hash=sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e \ + --hash=sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5 \ + --hash=sha256:bc18b8d33e6976804b9b792fe11cb3b1fee8b646e8a9e20bf521a429ddf73520 \ + --hash=sha256:bf493b3c1c0a2324c49b0472e2280ba4665f3510d8115f6f807759a6163b15f7 \ + --hash=sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a \ + --hash=sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c \ + --hash=sha256:c1c1e5ad80f1f38ea479b83b39dccb20874cfe9ad5e52f87225fa294ba4d39a1 \ + --hash=sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73 \ + --hash=sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9 \ + --hash=sha256:d7792398872f89466c6671d5d193537eff163ecf7fac78d82e6ddc25017fb4f5 \ + --hash=sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f \ + --hash=sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95 \ + --hash=sha256:e063263ce9047878480d7e536012fc8b7c8e1922989eb5f03b9ab998a2ee7b7e \ + --hash=sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c \ + --hash=sha256:e976f9f6941f57d87a194c91868622c8b22a142a741d2fde31655c319133ade6 \ + --hash=sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7 \ + --hash=sha256:f4d67c1684db3f9782c37ee4bade3f86f5a23a8fcf3f8359224106018ca40728 \ + --hash=sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4 \ + --hash=sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74 \ + --hash=sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c # via playwright idna==3.18 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ @@ -351,9 +351,9 @@ pygments==2.20.0 \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 # via pytest -pytest==9.0.3 \ - --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ - --hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c +pytest==9.1.0 \ + --hash=sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c \ + --hash=sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32 # via # -r requirements-e2e.in # pytest-base-url diff --git a/requirements.txt b/requirements.txt index 9ab4353f..31c3cd59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -104,56 +104,53 @@ click==8.4.1 \ --hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \ --hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96 # via flask -cryptography==48.0.0 \ - --hash=sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13 \ - --hash=sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6 \ - --hash=sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8 \ - --hash=sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25 \ - --hash=sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c \ - --hash=sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832 \ - --hash=sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12 \ - --hash=sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c \ - --hash=sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7 \ - --hash=sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c \ - --hash=sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec \ - --hash=sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5 \ - --hash=sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355 \ - --hash=sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c \ - --hash=sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741 \ - --hash=sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86 \ - --hash=sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321 \ - --hash=sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a \ - --hash=sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7 \ - --hash=sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920 \ - --hash=sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e \ - --hash=sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff \ - --hash=sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd \ - --hash=sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3 \ - --hash=sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f \ - --hash=sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602 \ - --hash=sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855 \ - --hash=sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18 \ - --hash=sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a \ - --hash=sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336 \ - --hash=sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239 \ - --hash=sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74 \ - --hash=sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a \ - --hash=sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c \ - --hash=sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4 \ - --hash=sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c \ - --hash=sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f \ - --hash=sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4 \ - --hash=sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db \ - --hash=sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166 \ - --hash=sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5 \ - --hash=sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f \ - --hash=sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae \ - --hash=sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20 \ - --hash=sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a \ - --hash=sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057 \ - --hash=sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb \ - --hash=sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c \ - --hash=sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b +cryptography==49.0.0 \ + --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ + --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ + --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ + --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ + --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ + --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ + --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ + --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ + --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ + --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ + --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ + --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ + --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ + --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ + --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ + --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ + --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ + --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ + --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ + --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ + --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ + --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ + --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ + --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ + --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ + --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ + --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ + --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ + --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ + --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ + --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ + --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ + --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ + --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ + --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ + --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ + --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ + --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ + --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ + --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ + --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ + --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ + --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ + --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ + --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ + --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b # via -r requirements.in et-xmlfile==2.0.0 \ --hash=sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa \ @@ -191,98 +188,98 @@ flask-wtf==1.3.0 \ --hash=sha256:61d5dabc50c3df885c297dcbd80810443a5d632106c8a69cab8ce740f0cdd7cc \ --hash=sha256:dc5e3a4ce97f75c47bf6c1c72ad2c3b7bdf579a2ed13aebcc5d3d81fe2571160 # via -r requirements.in -greenlet==3.5.1 \ - --hash=sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061 \ - --hash=sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19 \ - --hash=sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747 \ - --hash=sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1 \ - --hash=sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10 \ - --hash=sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0 \ - --hash=sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9 \ - --hash=sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64 \ - --hash=sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659 \ - --hash=sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a \ - --hash=sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541 \ - --hash=sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e \ - --hash=sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c \ - --hash=sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d \ - --hash=sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b \ - --hash=sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986 \ - --hash=sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78 \ - --hash=sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2 \ - --hash=sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a \ - --hash=sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc \ - --hash=sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b \ - --hash=sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5 \ - --hash=sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829 \ - --hash=sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea \ - --hash=sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436 \ - --hash=sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c \ - --hash=sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360 \ - --hash=sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f \ - --hash=sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244 \ - --hash=sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283 \ - --hash=sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54 \ - --hash=sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f \ - --hash=sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2 \ - --hash=sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188 \ - --hash=sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e \ - --hash=sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249 \ - --hash=sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3 \ - --hash=sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563 \ - --hash=sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de \ - --hash=sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6 \ - --hash=sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368 \ - --hash=sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26 \ - --hash=sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de \ - --hash=sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d \ - --hash=sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2 \ - --hash=sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e \ - --hash=sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33 \ - --hash=sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d \ - --hash=sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c \ - --hash=sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd \ - --hash=sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207 \ - --hash=sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed \ - --hash=sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b \ - --hash=sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62 \ - --hash=sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1 \ - --hash=sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0 \ - --hash=sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c \ - --hash=sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823 \ - --hash=sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab \ - --hash=sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523 \ - --hash=sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd \ - --hash=sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c \ - --hash=sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce \ - --hash=sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c \ - --hash=sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07 \ - --hash=sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135 \ - --hash=sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e \ - --hash=sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071 \ - --hash=sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f \ - --hash=sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5 \ - --hash=sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee \ - --hash=sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e \ - --hash=sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f \ - --hash=sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad \ - --hash=sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97 \ - --hash=sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc \ - --hash=sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e \ - --hash=sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2 \ - --hash=sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed +greenlet==3.5.2 \ + --hash=sha256:01e32e9d2b1714a2b06184cb3071ff2a2fd9bc7d065e39198ab21f7253dad421 \ + --hash=sha256:0488ca77c94da5e09d1d9958f98b58cebba1b8fd9664c24898499133de927574 \ + --hash=sha256:049827baab63dda8ab8ec5a6d07fc6eb0f418319cfc757fc8737a605e99ca1ad \ + --hash=sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34 \ + --hash=sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473 \ + --hash=sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32 \ + --hash=sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094 \ + --hash=sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4 \ + --hash=sha256:1724499fc08388208408681c53c5062e9803c334e5a0bdaeb616228ba882aac8 \ + --hash=sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea \ + --hash=sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb \ + --hash=sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8 \ + --hash=sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3 \ + --hash=sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682 \ + --hash=sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163 \ + --hash=sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c \ + --hash=sha256:2c6d6bfa4fdd7c39a0dbf112cdf28edbd19c517c810eefb6e4e71b0d55933a4c \ + --hash=sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f \ + --hash=sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c \ + --hash=sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742 \ + --hash=sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32 \ + --hash=sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de \ + --hash=sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904 \ + --hash=sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e \ + --hash=sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a \ + --hash=sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5 \ + --hash=sha256:4e554809538bd4867f24421b43abde170f9c9b8192149b30df5e164bcac6124f \ + --hash=sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef \ + --hash=sha256:561dd919c02236a613fbf226791cbd77ee5002cbd5cb7e838869aa3ac7a71e16 \ + --hash=sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8 \ + --hash=sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b \ + --hash=sha256:5eba55076d79e8a5176e6925295cfb901ebc95dae493342ede22230f75d8bee2 \ + --hash=sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9 \ + --hash=sha256:6d9e19257794e28821c9ebd5e23f86d7c267cd9d390089374f068d2049f949e3 \ + --hash=sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d \ + --hash=sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f \ + --hash=sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39 \ + --hash=sha256:711028c953cd6ce5dc01bbb5a1747e3ad6bd8b2f7ded73778bb936e8dab9e3b6 \ + --hash=sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f \ + --hash=sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9 \ + --hash=sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce \ + --hash=sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00 \ + --hash=sha256:87359c23eb4e8f1b16da68faad29bf5aeb80e3628d7d8e4aa2e41c36879ddedd \ + --hash=sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146 \ + --hash=sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3 \ + --hash=sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c \ + --hash=sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e \ + --hash=sha256:9df9daae96848508450011d0d86ed7c95f8829a354ce438284a77b24896fd1f8 \ + --hash=sha256:9e194b996aa1b89d933cfe136e5eb39b22a8b72ba59d376ef39a55bca4dbf47f \ + --hash=sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb \ + --hash=sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5 \ + --hash=sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3 \ + --hash=sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317 \ + --hash=sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a \ + --hash=sha256:a3f76a94e2d6e1fee8f302265679d8cc47d71a203936dd03c6e2ace0f9cfd46d \ + --hash=sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad \ + --hash=sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014 \ + --hash=sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0 \ + --hash=sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1 \ + --hash=sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e \ + --hash=sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5 \ + --hash=sha256:bc18b8d33e6976804b9b792fe11cb3b1fee8b646e8a9e20bf521a429ddf73520 \ + --hash=sha256:bf493b3c1c0a2324c49b0472e2280ba4665f3510d8115f6f807759a6163b15f7 \ + --hash=sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a \ + --hash=sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c \ + --hash=sha256:c1c1e5ad80f1f38ea479b83b39dccb20874cfe9ad5e52f87225fa294ba4d39a1 \ + --hash=sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73 \ + --hash=sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9 \ + --hash=sha256:d7792398872f89466c6671d5d193537eff163ecf7fac78d82e6ddc25017fb4f5 \ + --hash=sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f \ + --hash=sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95 \ + --hash=sha256:e063263ce9047878480d7e536012fc8b7c8e1922989eb5f03b9ab998a2ee7b7e \ + --hash=sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c \ + --hash=sha256:e976f9f6941f57d87a194c91868622c8b22a142a741d2fde31655c319133ade6 \ + --hash=sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7 \ + --hash=sha256:f4d67c1684db3f9782c37ee4bade3f86f5a23a8fcf3f8359224106018ca40728 \ + --hash=sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4 \ + --hash=sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74 \ + --hash=sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c # via sqlalchemy gunicorn==26.0.0 \ --hash=sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc \ --hash=sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf # via -r requirements.in -holidays==0.98 \ - --hash=sha256:09b078a4695d53a35ea01517b331942f3232b3b7e3877f74f6d5227adbedc76e \ - --hash=sha256:65c9ba3402f1d10cc715af0510cdc847e60d8854df640515d546748ca4a24e60 +holidays==0.99 \ + --hash=sha256:9ef8278cdfb67dbd93309ec9b30c30609ab35fd57cb207ce4593f80dc91196f5 \ + --hash=sha256:bc47cefa781dbc6415e782767dea013794146cc629845354b393c53cdee90c64 # via -r requirements.in -icalendar==7.1.2 \ - --hash=sha256:01c76243c76c549f58bb51510a8f0a4edb7c539726adda1356dfd0dc04fb7a53 \ - --hash=sha256:ebc43ebeb357be98984b573d975118008dee3410d8df28b054ef2943cf3e367e +icalendar==7.1.3 \ + --hash=sha256:690f30aa50a76cbf854db5ad52654705db9c5cd0e1b152222f5d4b7854b60667 \ + --hash=sha256:eb03a0e215f30db689a72c49f18f998aabf17522eb0858a293c393510850727c # via -r requirements.in itsdangerous==2.2.0 \ --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ @@ -491,65 +488,65 @@ six==1.17.0 \ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 # via python-dateutil -sqlalchemy==2.0.50 \ - --hash=sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064 \ - --hash=sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093 \ - --hash=sha256:0a31c5963d58d3e3d11c5b97709e248305705de1fdf51ec3bf396674c5898b7e \ - --hash=sha256:0e104e196f457ec608eb8af736c5eb4c6bc58f481b546f485a7f9c628ee532be \ - --hash=sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e \ - --hash=sha256:0fe7822866f3a9fc5f3db21a290ce8961a53050115f05edf9402b6a5feb92a9f \ - --hash=sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86 \ - --hash=sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600 \ - --hash=sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a \ - --hash=sha256:13b85b20f9ab714a666df9d8e72e253ec33c16c7e1e375c877e5bf6367a3e917 \ - --hash=sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39 \ - --hash=sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a \ - --hash=sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508 \ - --hash=sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5 \ - --hash=sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e \ - --hash=sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb \ - --hash=sha256:27b7062af702c61994e8806ad87e42d0a2c879e0a8e5c61c7f69d81dabe24fdf \ - --hash=sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3 \ - --hash=sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f \ - --hash=sha256:2c1920cde9d741ba3dda9b1aa5acd8c23ea17780ccfb2252d01878d5d0d628d3 \ - --hash=sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c \ - --hash=sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db \ - --hash=sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70 \ - --hash=sha256:3d10700bd519573f6ce5badbabbfe7f5baea84cdf370f2cbbfb4be28dfddbf1d \ - --hash=sha256:409a8121b917116b035bedc5e532ad470c74a2d279f6c302100985b6304e9f9e \ - --hash=sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89 \ - --hash=sha256:4a8e8af330cbb3a1931d3d6c91b239fc2ef135f7dd471dfa34c575028e0b1fa8 \ - --hash=sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3 \ - --hash=sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4 \ - --hash=sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5 \ - --hash=sha256:66e374271ecb7101273f57af1a62446a953d327eec4f8089147de57c591bbacc \ - --hash=sha256:68b154b08088b4ec32bb4d2958bfbb50e57549f91a4cd3e7f928e3553ed69031 \ - --hash=sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e \ - --hash=sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615 \ - --hash=sha256:7af6eeb84985bf840ba779018ff9424d61ff69b52e66b8789d3c8da7bf5341b2 \ - --hash=sha256:7b1ddb7b5fc60dfa9df6a487f06a143c77def47c0351849da2bcea59b244a56c \ - --hash=sha256:7e36efdcc5493f8024ec873a4ee3855bfd2de0c5b19eba16f920e9d2a0d28622 \ - --hash=sha256:83a9fce296b7e052316d8c6943237b31b9c00f58ca9c253f2d165df52637a293 \ - --hash=sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873 \ - --hash=sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8 \ - --hash=sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9 \ - --hash=sha256:9602c07b03e1449747ecb69f9998a7194a589124475788b370adce57c9e9a56e \ - --hash=sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f \ - --hash=sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7 \ - --hash=sha256:adc0fe7d38d8c8058f7421c25508fcbc74df38233a42aa8324409844122dce8f \ - --hash=sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9 \ - --hash=sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d \ - --hash=sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d \ - --hash=sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52 \ - --hash=sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51 \ - --hash=sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0 \ - --hash=sha256:e195687f1af431c9515416288373b323b6eb599f774409814e89e9d603a56e39 \ - --hash=sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22 \ - --hash=sha256:e8e1b0f6a4dcd9b4839e2320afb5df37a6981cbc20ff9c423ae11c5537bdbd21 \ - --hash=sha256:ea1a8a2db4b2217d456c8d7a873bfc605f06fe3584d315264ea18c2a17585d0b \ - --hash=sha256:eefd9a03cc0047b14153872d228499d048bd7deaf926109c9ec25b15157b8e23 \ - --hash=sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086 \ - --hash=sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb +sqlalchemy==2.0.51 \ + --hash=sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23 \ + --hash=sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1 \ + --hash=sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8 \ + --hash=sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72 \ + --hash=sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0 \ + --hash=sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5 \ + --hash=sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e \ + --hash=sha256:111604e637da87031255ddc26c7d7bc22bc6af6f5d459ccff3af1b4660233a85 \ + --hash=sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d \ + --hash=sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2 \ + --hash=sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba \ + --hash=sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652 \ + --hash=sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f \ + --hash=sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9 \ + --hash=sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84 \ + --hash=sha256:247acaa29ccef6250dfd6a3eedf8f94ddf23564180a39fe362e32ae9dbdbde46 \ + --hash=sha256:2a97eaad21c84b4ef8010b11eeba9fe6153eb0b3df3ff8b6abc309df1b978ef7 \ + --hash=sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080 \ + --hash=sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d \ + --hash=sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d \ + --hash=sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54 \ + --hash=sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd \ + --hash=sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195 \ + --hash=sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc \ + --hash=sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e \ + --hash=sha256:59cab3686b1bc039dd9cded2f8d0c08a246e84e76bd4ab5b4f18c7cdae293825 \ + --hash=sha256:6b588fd681ddf0c196b8df1ea49a8913514894b2b8f945a9511b4b48871f99c8 \ + --hash=sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522 \ + --hash=sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491 \ + --hash=sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400 \ + --hash=sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a \ + --hash=sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07 \ + --hash=sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7 \ + --hash=sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a \ + --hash=sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9 \ + --hash=sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7 \ + --hash=sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499 \ + --hash=sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5 \ + --hash=sha256:a42ad6afcbaaa777241e347aa2e29155993045a0d6b7db74da61053ffe875fe0 \ + --hash=sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604 \ + --hash=sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265 \ + --hash=sha256:aa18ae738b5170e253ad0bb6c4b0f07585081e8a6e50893e4d911d47b39a0904 \ + --hash=sha256:ad30ae663711786303fbcd46a47516302d201ee49a877cb3fac61f672895110a \ + --hash=sha256:b21f0e7efc7a5c509e953784e9d1575ebb8b4318960e7e7d7a93bb803626cf64 \ + --hash=sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d \ + --hash=sha256:b7f08588854bbb724041d9ae9d980d40040c922382e1d9a2ecb390edc4fd5032 \ + --hash=sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b \ + --hash=sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5 \ + --hash=sha256:bb1f5062f98b0b3290e72b707747fdd7e0f22d6956b236ba7ca7f5c9971d2da2 \ + --hash=sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d \ + --hash=sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389 \ + --hash=sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080 \ + --hash=sha256:c95ef01f53233a305a874a44a63fbfb1d81cd79b49de0f8529b3548cde437e37 \ + --hash=sha256:ca216e8af5c05e326efc7e28716ac2381a7cf9791749f5ee1849dccdc99c9b00 \ + --hash=sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86 \ + --hash=sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260 \ + --hash=sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de \ + --hash=sha256:fa268106c8987639a17a18514cfe0cd9bf17420ab887e1e1bf486da8836135b1 # via # alembic # flask-sqlalchemy From 3297daff0b2d9568171572f2ce612d54ea919272 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Thu, 18 Jun 2026 14:20:01 +0200 Subject: [PATCH 12/31] Fix CI: use tee instead of gpg --dearmor for ODBC key install --- .github/workflows/ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f52cc8b..93761f40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,9 +56,10 @@ jobs: - name: Install ODBC Driver for SQL Server run: | - curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --batch --dearmor -o /usr/share/keyrings/microsoft-prod.gpg - echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mssql-release.list - sudo apt-get update + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/keyrings/microsoft.asc > /dev/null + echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mssql-release.list + sudo apt-get update -q sudo ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 unixodbc-dev - name: Create test database From 8566ef4c851824108505ac1042b837b14e8f15c3 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Thu, 18 Jun 2026 14:25:34 +0200 Subject: [PATCH 13/31] Fix CI: remove conflicting Microsoft APT sources before adding ODBC repo --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93761f40..1e4cf505 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,9 +56,11 @@ jobs: - name: Install ODBC Driver for SQL Server run: | - sudo mkdir -p /etc/apt/keyrings - curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/keyrings/microsoft.asc > /dev/null - echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/microsoft.asc] https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mssql-release.list + # Remove any existing Microsoft prod sources to avoid Signed-By conflicts + sudo find /etc/apt/sources.list.d/ -name "*.list" -exec grep -l "packages.microsoft.com" {} \; | xargs sudo rm -f + # The runner ships with /usr/share/keyrings/microsoft-prod.gpg — use it + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" \ + | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update -q sudo ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 unixodbc-dev From d2cf475231d91bbfe97b6c7ef0c731de960e4c68 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Thu, 18 Jun 2026 14:28:09 +0200 Subject: [PATCH 14/31] Fix CI: create test DB via Python/pyodbc instead of sqlcmd --- .github/workflows/ci.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e4cf505..ab8be664 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,9 +66,21 @@ jobs: - name: Create test database run: | - /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P 'CiPassword123!' -C -Q \ - "CREATE DATABASE medcover_test COLLATE Czech_100_CI_AS_SC_UTF8; \ - ALTER DATABASE medcover_test SET READ_COMMITTED_SNAPSHOT ON" + pip install pyodbc + python - << 'EOF' + import pyodbc, time + conn_str = "DRIVER={ODBC Driver 18 for SQL Server};SERVER=localhost,1433;DATABASE=master;UID=SA;PWD=CiPassword123!;Encrypt=no;TrustServerCertificate=yes" + for _ in range(30): + try: + conn = pyodbc.connect(conn_str); conn.autocommit = True; break + except Exception: + time.sleep(2) + c = conn.cursor() + c.execute("IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name='medcover_test') CREATE DATABASE medcover_test COLLATE Czech_100_CI_AS_SC_UTF8") + c.execute("ALTER DATABASE medcover_test SET READ_COMMITTED_SNAPSHOT ON") + conn.close() + print("medcover_test ready") + EOF - name: Install dependencies run: pip install --require-hashes -r requirements-dev.txt From 954b6d95cd858e4a3160268634d7087083709002 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Thu, 18 Jun 2026 14:43:37 +0200 Subject: [PATCH 15/31] Document MSSQL one-time bootstrap requirement for production deployment --- DEVOPS.md | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/DEVOPS.md b/DEVOPS.md index 44a4d0e9..cc293d2d 100644 --- a/DEVOPS.md +++ b/DEVOPS.md @@ -417,9 +417,9 @@ Copy `.env.example` to `.env` for local development. Never commit `.env`. ## Production Deployment -> **Production hosting platform has not been chosen yet.** The application is fully containerised (Docker) and can be deployed to any container-capable platform. The target is a major cloud provider (GCP Cloud Run, Azure Container Apps, or AWS ECS) using NGO non-profit credits. See `architecture.md` AD09 for the decision rationale. -> -> **Why not Render.com?** Render was originally considered, but its free tier does not support background workers — which the scheduler container requires (see AD10). A paid Render tier is not justified given the availability of NGO cloud credits on major cloud platforms. +MedCover runs on **Azure Container Apps** (France Central) with **Azure SQL Database** as the managed database service. The CI/CD pipeline (`.github/workflows/deploy-azure.yml`) builds and deploys on every version tag push. + +See `azure-setup-guide.md` in the `medcover-infra` repo for the full Azure provisioning guide. ### What's ready @@ -428,11 +428,32 @@ Copy `.env.example` to `.env` for local development. Never commit `.env`. - **First-run setup wizard**: After the web service is live, navigate to the app URL. The wizard appears on first visit — configure the application name, admin account, and SMTP settings there. - **Production compose file**: `docker-compose.prod.yml` is available for self-hosted deployments (e.g. the zerver home-lab test server). -### What's needed when a platform is chosen +### ⚠️ One-time MSSQL bootstrap — required before first production deployment + +The migration history in `migrations/versions/` was written for PostgreSQL and cannot run on a fresh MSSQL database. Before starting the containers for the first time against a new Azure SQL database, you must bootstrap the schema manually: + +```bash +# 1. Set your Azure SQL DATABASE_URL (Managed Identity or SQL auth) +export DATABASE_URL="mssql+pyodbc://@medcover-sql.database.windows.net/MedCover?driver=ODBC+Driver+18+for+SQL+Server&Authentication=ActiveDirectoryMsi&Encrypt=yes" + +# 2. Run the one-time bootstrap (from the repo root, with the venv active) +flask db stamp head # mark all PG migrations as already applied +flask db migrate -m "mssql_prod_initial" # autogenerate a fresh MSSQL baseline migration +flask db upgrade # apply the new migration to create all tables +``` + +This only needs to run **once** per new database. After that, normal `flask db upgrade` (run automatically by the entrypoint on every deploy) handles future migrations correctly. + +The autogenerated migration file will appear in `migrations/versions/` — commit it as part of the deployment preparation. + +### Subsequent deployments -1. A CI/CD deployment workflow (`.github/workflows/deploy.yml`) to trigger deploys on merge to `main`. -2. Platform-specific environment variable configuration (`FLASK_ENV=production`, `SECRET_KEY`, `DATABASE_URL` with `?sslmode=require`). -3. Persistent storage configuration for scheduled backups (`backup_dir`). Work report files (`instance/work_report/`) are cleaned up after 1 day, so ephemeral storage is acceptable for those. +Tag a version → GitHub Actions builds image → deploys to both Container Apps automatically: + +```bash +git tag v1.2.3 +git push origin v1.2.3 +``` --- From 51874caffdd480c58fac058b15ea6d0b9ad42bd0 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Thu, 18 Jun 2026 15:01:15 +0200 Subject: [PATCH 16/31] Remove PostgreSQL references from docs; keep historical note in PR #381 --- DEVOPS.md | 176 ++++++++++++++++++------------------------------ README.md | 8 +-- architecture.md | 26 +++---- 3 files changed, 85 insertions(+), 125 deletions(-) diff --git a/DEVOPS.md b/DEVOPS.md index cc293d2d..ce8f4605 100644 --- a/DEVOPS.md +++ b/DEVOPS.md @@ -113,7 +113,7 @@ MedCover/ │ └── test_smoke_navigation.py │ ├── Dockerfile # Single image for both web and scheduler containers -├── docker-compose.yml # Local dev: web + scheduler + postgres (hot reload) +├── docker-compose.yml # Local dev: web + scheduler + MSSQL (hot reload) ├── docker-compose.e2e.yml # E2E tests: db-e2e + web-e2e + playwright runner ├── .env.example # Template for required env vars — COMMIT THIS ├── .env # Actual secrets — NEVER COMMIT (in .gitignore) @@ -138,7 +138,7 @@ Two containers share a single Docker image; they run different commands: | `web` | `flask run --host=0.0.0.0 --debug` | `gunicorn -w 2 -b 0.0.0.0:${PORT:-5000} "app:create_app()"` | Serves the Flask web application | | `scheduler` | `python scheduler/main.py` | `python scheduler/main.py` | Background tasks: auto-transitions, reminders, digests, file cleanup | -Both containers share the same codebase and connect to the same PostgreSQL database via `DATABASE_URL`. +Both containers share the same codebase and connect to the same MSSQL database via `DATABASE_URL`. The `web` container uses `docker-entrypoint.sh` which runs `flask db upgrade` + `flask verify-schema` before starting. The `scheduler` container uses `docker-entrypoint-scheduler.sh` (no migrations) and waits for `web` to be healthy before starting. @@ -156,7 +156,7 @@ The `scheduler` container uses `docker-entrypoint-scheduler.sh` (no migrations) git clone https://github.com/spidermila/MedCover.git cd MedCover cp .env.example .env # Fill in your local secrets -docker compose up --build # Starts web + scheduler + postgres +docker compose up --build # Starts web + scheduler + MSSQL ``` The app will be available at `http://localhost:5000`. @@ -190,13 +190,13 @@ docker compose exec web tox -e py314 ``` Or directly on the host with a local Python venv (`requirements-dev.txt` installed) -and `DATABASE_URL` / `TEST_DATABASE_URL` pointing at a running Postgres: +and `TEST_DATABASE_URL` pointing at a running MSSQL instance: ```bash pip install -r requirements-dev.txt -# Run directly — set TEST_DATABASE_URL to use an existing DB, -# or let testcontainers auto-spin a postgres:17 container if not set +# Run directly — set TEST_DATABASE_URL to use an existing MSSQL DB, +# or let testcontainers auto-spin an MSSQL 2022 Express container if not set pytest # Via tox — same behaviour @@ -218,7 +218,7 @@ set `CONTAINER_ENGINE=docker tox -e e2e`. | Container | Image | Purpose | |-----------|-------|---------| -| `db-e2e` | `postgres:17-alpine` | Fresh Postgres on tmpfs (destroyed after each run) | +| `db-e2e` | `mcr.microsoft.com/mssql/server:2022-latest` | Fresh MSSQL on tmpfs (destroyed after each run) | | `web-e2e` | App Dockerfile | Runs migrations, seeds data (`seed_dev.py`), serves Flask | | `e2e` | `mcr.microsoft.com/playwright/python` | Runs Playwright tests against `http://web-e2e:5000` | @@ -273,8 +273,7 @@ The embedded summary below reflects the actual file. Key points: - `web` uses `flask run --debug` (hot reload) in dev; production uses gunicorn via `CMD` in the Dockerfile - Both containers mount `.:/app` so local code changes reflect immediately - Both containers have healthchecks; the scheduler checks a heartbeat file written every ~10 s -- `db` uses **postgres:17-alpine** and a custom `postgres.conf` (tuned checkpoint settings for WSL2 stability — see Known Issues) -- `stop_grace_period: 60s` on `db` gives PostgreSQL time to checkpoint cleanly on shutdown +- `db` uses **MSSQL 2022 Express** (`mcr.microsoft.com/mssql/server:2022-latest`) with Czech collation and RCSI enabled ```yaml services: @@ -310,28 +309,21 @@ services: condition: service_healthy db: - image: postgres:17-alpine + image: mcr.microsoft.com/mssql/server:2022-latest restart: unless-stopped - stop_grace_period: 60s # Gives PostgreSQL time to checkpoint cleanly - volumes: - - postgres_data:/var/lib/postgresql/data - - ./db-init:/docker-entrypoint-initdb.d:ro - - ./postgres.conf:/etc/postgresql/postgresql.conf:ro - command: postgres -c config_file=/etc/postgresql/postgresql.conf environment: - POSTGRES_DB: medcover_dev - POSTGRES_USER: medcover - POSTGRES_PASSWORD: devpassword - healthcheck: - test: ["CMD-SHELL", "pg_isready -U medcover"] - interval: 5s - timeout: 5s - retries: 5 + ACCEPT_EULA: "Y" + MSSQL_PID: "Express" + MSSQL_SA_PASSWORD: "DevPassword123!" + MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" ports: - - "5432:5432" + - "1433:1433" + volumes: + - mssql_data:/var/opt/mssql + - ./mssql-init:/docker-entrypoint-initdb.d:ro volumes: - postgres_data: + mssql_data: ``` --- @@ -409,7 +401,7 @@ Copy `.env.example` to `.env` for local development. Never commit `.env`. |---|---|---| | `FLASK_ENV` | `development` or `production` | `development` | | `SECRET_KEY` | Flask session secret — generate a strong random value | `openssl rand -hex 32` | -| `DATABASE_URL` | PostgreSQL connection string | `postgresql://medcover:devpassword@db:5432/medcover_dev` | +| `DATABASE_URL` | MSSQL connection string | `mssql+pyodbc://medcover:Dev_Password1!@db:1433/medcover_dev?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes` | > **Email / SMTP:** SMTP credentials are configured through the web UI setup wizard on first run and stored Fernet-encrypted in the `app_settings` database table. No `MAIL_*` environment variables are required. @@ -523,7 +515,7 @@ PR opened / updated ↓ GitHub Actions: ci.yml ├── lint job: pre-commit (flake8, mypy, pyupgrade, whitespace) - ├── test job: postgres:17 service → pytest --cov + ├── test job: MSSQL 2022 service → pytest --cov └── audit job: pip-audit → check dependencies for known CVEs ↓ Review, approve, merge @@ -533,7 +525,7 @@ Dependabot submits weekly PRs for `pip` and `github-actions` dependency updates ### On merge to main -> **No automated deployment yet.** A deployment workflow will be added once the production hosting platform is chosen (see AD09 in `architecture.md`). Currently, deployment to the zerver test server is manual via `zerver_scp.sh`. +Tag a version to trigger the deploy workflow (`deploy-azure.yml`) which builds the Docker image and deploys to Azure Container Apps. ### .github/workflows/ci.yml @@ -550,52 +542,74 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: "3.14" - name: Install pre-commit run: pip install pre-commit - name: Run pre-commit hooks run: pre-commit run --all-files - # Runs: trailing-whitespace, end-of-file-fixer, check-yaml, - # flake8, pyupgrade, mypy test: runs-on: ubuntu-latest services: - postgres: - image: postgres:17-alpine + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest env: - POSTGRES_USER: medcover - POSTGRES_PASSWORD: testpassword - POSTGRES_DB: medcover_test + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "CiPassword123!" + MSSQL_PID: "Express" + MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" ports: - - 5432:5432 + - 1433:1433 options: >- - --health-cmd pg_isready - --health-interval 5s + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'CiPassword123!' -C -Q 'SELECT 1' -b" + --health-interval 10s --health-timeout 5s - --health-retries 5 + --health-retries 10 env: - DATABASE_URL: postgresql://medcover:testpassword@localhost:5432/medcover_test - TEST_DATABASE_URL: postgresql://medcover:testpassword@localhost:5432/medcover_test + TEST_DATABASE_URL: "mssql+pyodbc://SA:CiPassword123!@localhost:1433/medcover_test?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes" FLASK_ENV: testing SECRET_KEY: ci-test-secret-not-real steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: "3.14" + - name: Install ODBC Driver for SQL Server + run: | + sudo find /etc/apt/sources.list.d/ -name "*.list" -exec grep -l "packages.microsoft.com" {} \; | xargs sudo rm -f + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" \ + | sudo tee /etc/apt/sources.list.d/mssql-release.list + sudo apt-get update -q + sudo ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 unixodbc-dev + - name: Create test database + run: | + pip install pyodbc + python - << 'EOF' + import pyodbc, time + conn_str = "DRIVER={ODBC Driver 18 for SQL Server};SERVER=localhost,1433;DATABASE=master;UID=SA;PWD=CiPassword123!;Encrypt=no;TrustServerCertificate=yes" + for _ in range(30): + try: + conn = pyodbc.connect(conn_str); conn.autocommit = True; break + except Exception: + time.sleep(2) + c = conn.cursor() + c.execute("IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name='medcover_test') CREATE DATABASE medcover_test COLLATE Czech_100_CI_AS_SC_UTF8") + c.execute("ALTER DATABASE medcover_test SET READ_COMMITTED_SNAPSHOT ON") + conn.close() + print("medcover_test ready") + EOF - name: Install dependencies run: pip install --require-hashes -r requirements-dev.txt - name: Run tests with coverage run: pytest --cov=app --cov-report=term-missing --cov-report=xml - name: Upload coverage report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: coverage-report @@ -604,8 +618,8 @@ jobs: audit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: "3.14" - name: Install pip-audit @@ -787,69 +801,13 @@ connect-src 'self' https://cdn.jsdelivr.net; ## Known Issues & Mitigations -### WSL2 + Docker PostgreSQL schema loss - -**Symptom:** After a Windows restart, hibernate, or `wsl --shutdown`, the -app fails to start (or shows login errors) even though `alembic_version` -reports the correct migration head. Running `flask verify-schema` reveals -that all application tables are missing. - -**Root cause:** Docker named volumes on WSL2 live on `/dev/sdd`, the WSL2 -virtual disk (a `.vhdx` file managed by Hyper-V). PostgreSQL writes -committed data to the **Linux kernel page cache** first — fsync flushes it -to the page cache, not directly to the VHD. The page cache is only written -through to the underlying VHD periodically by the kernel. When WSL2 is -force-terminated (Windows shutdown, hibernate, `wsl --shutdown`), it kills -all processes immediately without going through Docker's stop sequence. -PostgreSQL therefore never runs a final checkpoint, and any dirty pages -still in the kernel page cache at that moment are lost. - -`alembic_version` survives because it was written early (during `flask db -upgrade`) and had time to be flushed to disk. The application tables, being -written later and containing more data, are typically still in the page -cache when the kill happens. - -**Why the default settings make it worse:** PostgreSQL's default -`checkpoint_timeout` is **5 minutes**, meaning up to 5 minutes of dirty -pages can accumulate in RAM between disk flushes. The default `stop_grace_period` -in Docker Compose is **10 seconds**, which is often too short for PostgreSQL -to finish a checkpoint before receiving SIGKILL from `docker compose down`. - -**Mitigations applied** (commit `4fd6d72`): - -| File | Change | Effect | -|---|---|---| -| `postgres.conf` | `checkpoint_timeout = 30s` | Dirty-page window reduced from 5 min → 30 s | -| `postgres.conf` | `checkpoint_completion_target = 0.9` | Spreads checkpoint I/O to avoid spikes | -| `postgres.conf` | `listen_addresses = '*'` | Required when supplying a full custom config — PostgreSQL defaults to `localhost`-only, which blocks inter-container connections | -| `docker-compose.yml` | `stop_grace_period: 60s` on `db` | Gives PostgreSQL enough time to checkpoint cleanly on `docker compose down/stop` | - -**Residual risk:** A hard WSL2 kill can still lose up to ~30 s of dev -writes. This is an inherent limitation of running PostgreSQL inside Docker -on WSL2 and cannot be fully eliminated without moving the database outside -Docker. For dev use this is acceptable; data can be re-seeded with -`python scripts/seed_dev.py`. - -**Fast-fail guard:** `docker-entrypoint.sh` runs `flask verify-schema` -after every `flask db upgrade`. If any table or column is missing, the -container exits immediately with a clear diagnostic rather than serving -traffic with a broken database. +### MSSQL on WSL2 -**Recovery procedure:** +MSSQL Server is a significantly heavier container than the PostgreSQL it replaced (approx. 1.5 GB image). On WSL2, allow extra time for the container to start and become healthy. The MSSQL health check retries up to 10 times with 10 s intervals, giving 100 s total — this is sufficient in practice. -```bash -# 1. Drop the stale migration marker -docker compose exec db psql -U medcover -d medcover_dev -c "DROP TABLE IF EXISTS alembic_version;" +If the container fails to start, check available memory: MSSQL Express requires at least 1 GB RAM. The compose file caps it at 512 MB buffer pool via `MSSQL_MEMORY_LIMIT_MB`; the OS-level limit should be at least 1.5 GB. -# 2. Re-apply all migrations -docker compose exec web flask db upgrade - -# 3. Verify -docker compose exec web flask verify-schema - -# 4. Re-seed dev data -docker compose exec web python scripts/seed_dev.py -``` +> **Historical note:** The dev stack originally used PostgreSQL 17. PostgreSQL was removed and replaced with MSSQL in [PR #381](https://github.com/spidermila/MedCover/pull/381). --- diff --git a/README.md b/README.md index f081ec40..2786eba6 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,13 @@ It manages events, spot assignments, user roles, equipment, and reporting — al |---|---| | Language | Python 3.14 | | Web framework | Flask 3 | -| Database | PostgreSQL 17 | +| Database | SQL Server 2022 (MSSQL / Azure SQL) | | ORM / migrations | SQLAlchemy · Flask-Migrate (Alembic) | | Auth | Flask-Login · Flask-Mail | | Frontend | Jinja2 · Bootstrap 5.3 · FullCalendar | | Infrastructure | Docker Compose (web + scheduler + db) | | CI | GitHub Actions (lint + test) | -| Hosting (target) | TBD — major cloud provider (GCP / Azure / AWS) | +| Hosting | Azure Container Apps (France Central) | --- @@ -77,11 +77,11 @@ Environment variables (`FLASK_ENV`, `SECRET_KEY`) are injected automatically by `pytest-env` via `pyproject.toml`. **`TEST_DATABASE_URL` is managed automatically** by `testcontainers`: -- If `TEST_DATABASE_URL` is **not set**, a temporary `postgres:17` Docker container +- If `TEST_DATABASE_URL` is **not set**, a temporary MSSQL 2022 Express Docker container is started at the beginning of the test session and stopped at the end. The only requirement is a running Docker daemon. - If `TEST_DATABASE_URL` **is set** (e.g. in CI or by a developer with a local - Postgres), testcontainers skips the container and uses the provided URL. + MSSQL instance), testcontainers skips the container and uses the provided URL. Coverage report is written to `htmlcov/` after each run. diff --git a/architecture.md b/architecture.md index 68ae6fc2..eaddf09f 100644 --- a/architecture.md +++ b/architecture.md @@ -325,7 +325,7 @@ When in doubt about the correct Czech UI label or English code name for a concep **Transport security** - All traffic between end users and the application must be encrypted with TLS (HTTPS). The hosting platform's reverse proxy / load balancer handles TLS termination at the edge; no additional configuration is needed in the app itself. -- The connection between the application and PostgreSQL must use TLS (`sslmode=require`) in production. This is enforced via the `DATABASE_URL` environment variable in the production config. +- The connection between the application and Azure SQL must use TLS (`Encrypt=yes`) in production. This is enforced via the `DATABASE_URL` environment variable in the production config. - Container-to-container traffic within the same Docker network is isolated from the public internet. We rely on the hosting platform's network isolation as the primary protection for intra-service traffic; DB SSL provides defence in depth. This is considered an acceptable risk for a non-critical internal application. If the deployment platform changes, this assumption must be re-evaluated. - The web ↔ scheduler pair communicates only through the shared database (no HTTP calls between them), so no inter-service TLS is needed. - We do **not** plan a multi-server / distributed deployment for MVP. If this changes, intra-cluster mTLS must be re-evaluated. @@ -405,16 +405,18 @@ When in doubt about the correct Czech UI label or English code name for a concep - Python Flask + relational database + lightweight JavaScript frontend - Python Django + relational database + lightweight JavaScript frontend - Other frameworks / languages - - Decision - **Python Flask + PostgreSQL + server-rendered HTML (Jinja2) + vanilla JS/jQuery** + - Decision - **Python Flask + SQL Server (MSSQL) + server-rendered HTML (Jinja2) + vanilla JS/jQuery** - Justification - Flask is lightweight and familiar to the project lead; keeps the codebase simple and easy for volunteers to contribute to - - PostgreSQL provides robustness and production-grade reliability without significant operational overhead + - SQL Server (Azure SQL Database) provides production-grade reliability as a managed service on Azure, eliminating operational overhead - Jinja2 server-rendered templates eliminate the need for a separate frontend build pipeline or SPA framework - Vanilla JS / jQuery is sufficient for the required interactivity (form enhancements, dynamic notifications); calendar views are handled by FullCalendar (see AD08) - - This stack is well-supported on all considered hosting platforms (VPS, PythonAnywhere, cloud container services, etc.) + - This stack is well-supported on all considered hosting platforms + - Notes + - The application was initially developed with PostgreSQL 17. PostgreSQL was replaced by MSSQL in [PR #381](https://github.com/spidermila/MedCover/pull/381) to align with Azure SQL Database on the target hosting platform. - Implications - REST API: can be added later using Flask blueprints without major architectural changes (auth mechanism TBD when REST API is scoped) - - ORM: SQLAlchemy (standard Flask ORM for PostgreSQL) + - ORM: SQLAlchemy (supports MSSQL via the `mssql+pyodbc` dialect) - AD05 Authentication Mechanism @@ -485,8 +487,8 @@ When in doubt about the correct Czech UI label or English code name for a concep - Container-first is the industry standard and familiar to most developers, making it easier for future volunteers to contribute. - Notes - **Production target**: A major cloud provider (GCP Cloud Run, Azure Container Apps, or AWS ECS) using NGO non-profit credits. Same `Dockerfile` applies to all candidates. - - **Local development**: Docker Compose with Flask app + PostgreSQL containers; `.env` file for secrets (not committed). - - **CI/CD**: GitHub Actions runs lint, test, and dependency audit on every PR. A deployment workflow will be added once the production platform is chosen. + - **Local development**: Docker Compose with Flask app + MSSQL 2022 Express container; `.env` file for secrets (not committed). + - **CI/CD**: GitHub Actions runs lint, test (MSSQL service container), and dependency audit on every PR. Deploy workflow triggers on version tag push. - The Deployment Model section should be updated once the NGO credit application is approved and a cloud provider is chosen. - AD10 Background Task / Scheduler Architecture @@ -547,7 +549,7 @@ When in doubt about the correct Czech UI label or English code name for a concep - **Context:** The application is deployed as multiple containers (web, scheduler, database). The question is: what encryption and isolation is needed for traffic between containers, and is TLS between containers necessary? - **Decision:** 1. **External traffic (user ↔ web):** TLS is terminated at the hosting platform's reverse proxy / load balancer. No in-app TLS configuration needed. - 2. **Web ↔ PostgreSQL:** TLS (`sslmode=require`) is enforced via `DATABASE_URL` in production. The `ProductionConfig` asserts this. Dev/test use plaintext (acceptable; no real data). + 2. **Web ↔ MSSQL (Azure SQL):** TLS (`Encrypt=yes`) is enforced via `DATABASE_URL` in production. The `ProductionConfig` warns if missing. Dev/test use plaintext (acceptable; no real data). 3. **Web ↔ Scheduler:** These containers communicate exclusively through the shared database. No HTTP calls exist between them. No inter-service TLS is needed. 4. **Container-to-container isolation:** The hosting platform's private network (Docker bridge / VPC / private subnet) is non-routable from the public internet. We rely on this network isolation as the primary protection for intra-service traffic. We do **not** implement mTLS between containers — it is not justified for a single-region, non-distributed, non-critical internal app of this scale. 5. **If deployment platform changes:** The transport security assumptions must be re-evaluated. A cloud platform with a true VPC and private subnets (AWS, Azure, GCP) provides equivalent or stronger isolation. @@ -733,7 +735,7 @@ flowchart TD |---|---| | **Frontend Web Client** | Server-rendered HTML pages (Jinja2 templates) with vanilla JS/jQuery for interactivity. Served directly by the Flask application. Optimised for desktop and mobile. | | **Backend Application** | Python Flask application. Implements all business logic, RBAC, event lifecycle, assignment management, qualification matching, notification triggers, audit logging, scheduled tasks. Serves the web UI via Jinja2 templates and will expose a REST API (future). Uses SQLAlchemy as the ORM. | -| **Relational Database** | PostgreSQL. Persistent storage for all domain data: users, roles, qualifications, master events, events, event spots, assignments, equipment, audit log, notification settings, debriefing records. | +| **Relational Database** | SQL Server 2022 / Azure SQL Database. Persistent storage for all domain data: users, roles, qualifications, master events, events, event spots, assignments, equipment, audit log, notification settings, debriefing records. | | **Email / Notification Service** | Outbound email delivery: invite links, account activation, password reset, event notifications/reminders, admin digests, debriefing links. May be an external SMTP relay or third-party email API. | @@ -909,7 +911,7 @@ erDiagram | **UserFeedback** | user, message, submitted_at | In-app feedback form; viewable by Admin | ### Data Store -- Single **relational database**: **PostgreSQL** (see AD04) +- Single **relational database**: **SQL Server 2022 / Azure SQL Database** (see AD04) - All domain data is stored in one database; no separate read replicas or caches planned for MVP - Audit log is append-only and stored in the same database @@ -948,7 +950,7 @@ The application runs as **two containers** sharing the same Docker image: | `web` | Flask web application | `flask run --host=0.0.0.0 --debug` | `gunicorn -w 2 -b 0.0.0.0:5000 "app:create_app()"` | | `scheduler` | Background task runner — email outbox drain, Event auto-transitions, reminder emails, admin digests, backup, work-report cleanup | `python scheduler/main.py` | `python scheduler/main.py` | -Both containers connect to the same PostgreSQL database. See AD10 for the scheduler decision rationale. Full details in `DEVOPS.md`. +Both containers connect to the same MSSQL database. See AD10 for the scheduler decision rationale. Full details in `DEVOPS.md`. ### Environments @@ -956,7 +958,7 @@ Both containers connect to the same PostgreSQL database. See AD10 for the schedu |---|---|---|---| | **Local dev** | Developer playground; rapid iteration | Generated mock/seed data (`scripts/seed_dev.py`) | Docker Compose on developer laptop | | **Zerver (home lab)** | Integration testing; mirrors production config | Seeded dev data | Self-hosted server (LAN); synced via `zerver_scp.sh` after each commit | -| **Production** | Live system serving real users | Real data | TBD — major cloud provider (GCP / Azure / AWS) with NGO credits — see AD09 | +| **Production** | Live system serving real users | Real data | Azure Container Apps (France Central) + Azure SQL Database | No permanent staging environment for MVP. The zerver home-lab server fulfils this role during active development. From 06d58c54ac7a7379caa149be5f33889301f813cc Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Thu, 18 Jun 2026 15:05:16 +0200 Subject: [PATCH 17/31] Increase MSSQL stop_grace_period to 60s in prod compose --- docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7eacc4b0..8959cbe7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -73,7 +73,7 @@ services: container_name: medcover-prod-db platform: linux/amd64 restart: unless-stopped - stop_grace_period: 30s + stop_grace_period: 60s environment: ACCEPT_EULA: "Y" MSSQL_PID: "Express" From 865e907ccf1344e8ca704c2d1845f75dbb91af49 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Thu, 18 Jun 2026 15:30:19 +0200 Subject: [PATCH 18/31] Fix flaky xdist tests: use NullPool in test app to eliminate connection state issues --- app/__init__.py | 4 ++++ tests/conftest.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index 5b5a8f05..1c94431e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -22,6 +22,7 @@ def create_app( config_name: str | None = None, db_url: str | None = None, + engine_options: dict | None = None, ) -> Flask: if config_name is None: config_name = os.getenv("FLASK_ENV", "development") @@ -34,6 +35,9 @@ def create_app( if db_url is not None: app.config["SQLALCHEMY_DATABASE_URI"] = db_url + if engine_options is not None: + app.config["SQLALCHEMY_ENGINE_OPTIONS"] = engine_options + db.init_app(app) migrate.init_app(app, db) login_manager.init_app(app) diff --git a/tests/conftest.py b/tests/conftest.py index f7104a1e..e3b6fce3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import pytest from sqlalchemy import create_engine, text +from sqlalchemy.pool import NullPool from app import create_app from app.extensions import db as _db @@ -263,7 +264,7 @@ def app(worker_id: str): db_url = _worker_db_url(worker_id) _ensure_db_exists(db_url) - flask_app = create_app("testing", db_url=db_url) + flask_app = create_app("testing", db_url=db_url, engine_options={"poolclass": NullPool}) with flask_app.app_context(): _db.drop_all() # clear leftover types/tables from previous runs From 76b696bfaffbdf1f3e8bb71f994b993026fd24a9 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Thu, 18 Jun 2026 15:52:54 +0200 Subject: [PATCH 19/31] Ensure fresh DB session before each test to prevent fixture state leakage --- tests/conftest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index e3b6fce3..eba63286 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -329,6 +329,10 @@ def clean_db(app): fields that tests may mutate are explicitly reset to their defaults so that test order does not matter. """ + # Ensure a completely fresh session at the start of every test — eliminates + # any lingering identity-map state or open transactions from fixture setup. + with app.app_context(): + _db.session.remove() yield with app.app_context(): _db.session.remove() From 9e2dc86d92b69059485c413e361fba8b16ad3c99 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Fri, 19 Jun 2026 15:25:35 +0200 Subject: [PATCH 20/31] fix: align prod config with Azure SQL deployment, drop Postgres leftovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production runs on Azure Container Apps + the managed Azure SQL service (passwordless via managed identity), not a containerized database. Several files still described the old Postgres/containerized model after the MSSQL migration: - .env.prod.example: replace POSTGRES_* vars and postgresql:// URL with the Azure SQL managed-identity connection string (Authentication=ActiveDirectoryMsi) - docker-compose.prod.yml: remove the bundled MSSQL db service/volume/depends_on; prod connects to managed Azure SQL - .dockerignore: drop deleted postgres.conf and db-init/ entries - .pre-commit-config.yaml: drop types-psycopg2 (psycopg2 removed) - digest template: "PostgreSQL databáze" -> "databáze" tooltip Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X1TSmeNk897sPHRWD6ny4G --- .dockerignore | 2 -- .env.prod.example | 12 ++++------ .pre-commit-config.yaml | 1 - app/templates/admin/digest/index.html | 2 +- docker-compose.prod.yml | 33 ++------------------------- 5 files changed, 8 insertions(+), 42 deletions(-) diff --git a/.dockerignore b/.dockerignore index ce68d7f8..87f5d050 100644 --- a/.dockerignore +++ b/.dockerignore @@ -39,5 +39,3 @@ docker-compose.prod.yml render.yaml deploy.sh zerver_scp.sh -postgres.conf -db-init/ diff --git a/.env.prod.example b/.env.prod.example index 6cc1b756..34e019c6 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -8,13 +8,11 @@ FLASK_ENV=production # Generate with: python -c "import secrets; print(secrets.token_hex(32))" SECRET_KEY=change-me-generate-a-strong-random-value -# PostgreSQL — must match the POSTGRES_* values below -DATABASE_URL=postgresql://medcover:@db:5432/medcover_prod - -# PostgreSQL container credentials -POSTGRES_DB=medcover_prod -POSTGRES_USER=medcover -POSTGRES_PASSWORD=change-me-use-a-strong-password +# Database — managed Azure SQL service, authenticated passwordlessly via the +# container app's system-assigned managed identity (no credentials stored here). +# Replace with the Azure SQL server name. TLS is enforced (Encrypt=yes). +# See the medcover-infra repo (azure-sql-managed-identity.md) for setup details. +DATABASE_URL=mssql+pyodbc://@.database.windows.net/MedCover?driver=ODBC+Driver+18+for+SQL+Server&Authentication=ActiveDirectoryMsi&Encrypt=yes # Outbound email (SMTP) is configured through the web UI setup wizard # and stored encrypted in the database. No SMTP variables are needed here. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d6a08bcd..134280e7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -79,7 +79,6 @@ repos: - openpyxl - alembic - schedule - - types-psycopg2 args: [--ignore-missing-imports, app/, scheduler/] pass_filenames: false always_run: true diff --git a/app/templates/admin/digest/index.html b/app/templates/admin/digest/index.html index 3f05c204..aa9e8df3 100644 --- a/app/templates/admin/digest/index.html +++ b/app/templates/admin/digest/index.html @@ -156,7 +156,7 @@

Přehledový e-mail

{% for key, lbl, tip_text in [ ('show_user_count', 'Počet uživatelů', 'Celkový počet aktivních uživatelských účtů.'), ('show_event_count', 'Počet akcí', 'Celkový počet akcí v systému (všechny stavy).'), - ('show_db_size', 'Velikost DB', 'Aktuální velikost PostgreSQL databáze.'), + ('show_db_size', 'Velikost DB', 'Aktuální velikost databáze.'), ('show_table_sizes', 'Velikosti tabulek','Přehled velikostí jednotlivých DB tabulek, seřazený od největší.'), ('show_scheduler_heartbeat','Scheduler', 'Čas posledního heartbeatu scheduleru. Upozorní, pokud scheduler neběží.'), ('show_outbox_pending', 'Fronta e-mailů', 'Aktuální počet e-mailů čekajících na odeslání.'), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8959cbe7..9a779326 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -11,10 +11,10 @@ # - Gunicorn (4 workers) instead of flask run --debug # - No source-code volume mount; code is baked into the image # - Web runs on host port 5001 (dev uses 5000) to allow side-by-side on same host -# - DB port is NOT exposed to the host (internal network only) # - restart: unless-stopped on every service # - Reads secrets from .env.prod (never .env) -# - Separate postgres_data_prod volume so prod data is isolated from dev +# - No bundled database: connects to the managed Azure SQL service +# (passwordless via managed identity); see .env.prod.example name: medcover-prod @@ -32,9 +32,6 @@ services: dns: - 8.8.8.8 - 1.1.1.1 - depends_on: - db: - condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "python", "-c", @@ -67,29 +64,3 @@ services: timeout: 5s retries: 3 start_period: 15s - - db: - image: mcr.microsoft.com/mssql/server:2022-latest - container_name: medcover-prod-db - platform: linux/amd64 - restart: unless-stopped - stop_grace_period: 60s - environment: - ACCEPT_EULA: "Y" - MSSQL_PID: "Express" - MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?MSSQL_SA_PASSWORD is required in .env.prod} - MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" - volumes: - - mssql_data_prod:/var/opt/mssql - - ./mssql-init:/docker-entrypoint-initdb.d:ro - healthcheck: - test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", - "${MSSQL_SA_PASSWORD}", "-C", "-Q", "SELECT 1", "-b"] - interval: 10s - timeout: 5s - retries: 10 - start_period: 30s - # DB is not exposed to the host — access it through the web/scheduler services - -volumes: - mssql_data_prod: From 0646cce1fe4eac062c82d746fc26474f01ddf13c Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Fri, 19 Jun 2026 15:30:07 +0200 Subject: [PATCH 21/31] refactor(migrations): squash PostgreSQL history into single MSSQL baseline The 45 migrations in migrations/versions/ were written for PostgreSQL and could not run on a fresh MSSQL database (they used postgresql_where=, PG enum/boolean DDL, etc.), forcing a manual "stamp head + autogenerate" bootstrap before first deploy and breaking clean dev re-spins (alembic walked the old PG-only files). Replace the entire history with a single autogenerated MSSQL baseline (down_revision = None) created against an empty SQL Server database: - 30 tables, MSSQL-native types, mssql_where partial unique index on qualification (matching the model) - Validated: `flask db upgrade` on an empty DB followed by re-running autogenerate reports "No changes in schema detected", and `flask verify-schema` confirms all 30 tables/columns present `flask db upgrade` now works on a clean MSSQL database with no manual bootstrap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X1TSmeNk897sPHRWD6ny4G --- ...a748aa4f_add_ical_token_to_user_account.py | 28 - ...dd_password_reset_nonce_to_user_account.py | 32 -- ...044_add_dev_email_block_to_app_settings.py | 34 -- ...bcfe_debriefing_redesign_new_fields_on_.py | 62 --- .../2b096852ef02_template_equipment_plan.py | 35 -- .../2dbbf9629c3f_mssql_baseline_schema.py | 478 ++++++++++++++++++ ...707e1bbc_add_can_be_rp_to_qualification.py | 32 -- .../4ec25aa941b8_add_invite_cancelled_at.py | 32 -- ...cfc_add_dashboard_horizon_days_to_user_.py | 32 -- ...568d4f009_add_dark_mode_to_user_account.py | 32 -- ...00825_add_last_login_at_to_user_account.py | 26 - ...add_scheduler_last_seen_to_app_settings.py | 32 -- .../6afec7c90ac1_qualification_soft_delete.py | 38 -- ...ec4167e_add_user_role_permission_tables.py | 77 --- ...8fbf9_add_backup_config_to_app_settings.py | 38 -- ...7c0c6_add_invite_email_tracking_columns.py | 36 -- ...rename_preferred_hour_utc_to_preferred_.py | 28 - ...38cf432_add_reminder_sent_json_to_event.py | 32 -- ...e848e_add_instance_name_to_outbox_email.py | 28 - ...cffa1cb85_add_vykaz_generate_permission.py | 86 ---- ...9dc10_add_session_timeout_hours_to_app_.py | 30 -- ...41_notification_toggles_and_outbox_type.py | 38 -- ...8b3d50_add_app_base_url_to_app_settings.py | 32 -- ...e5f6_rename_credential_to_qualification.py | 77 --- ...c4d5e6f7_lowercase_existing_user_emails.py | 23 - ...0e4af594e_add_version_to_event_template.py | 32 -- ...dd_outbox_email_table_for_queued_email_.py | 47 -- ...1_add_invite_custom_subject_and_message.py | 34 -- .../b1c2d3e4f5a6_add_performance_indexes.py | 49 -- .../b39e55598ad1_add_equipment_models.py | 76 --- ...953d30cb_add_equipment_item_status_and_.py | 66 --- .../bbd2db07fc29_add_user_feedback_table.py | 44 -- ...a765181_add_is_archived_to_user_account.py | 26 - ...1afc34311c_add_event_type_enum_planned_.py | 61 --- ...dd_notify_event_changed_to_app_settings.py | 26 - ...dd_login_lockout_fields_to_user_account.py | 40 -- ...9a8a_split_notify_event_lifecycle_into_.py | 30 -- ...6_merge_indexes_and_login_lockout_heads.py | 22 - .../d37d41a4e38d_add_all_domain_models.py | 197 -------- .../d697cc60c5d2_add_ical_all_token.py | 23 - ...add_feedback_version_and_enabled_toggle.py | 38 -- .../dfc13fca938f_add_app_settings_table.py | 38 -- .../versions/ebe3ddc11f1e_optional_spots.py | 35 -- ..._add_digest_tables_and_outbox_html_body.py | 79 --- ...722_add_version_columns_for_optimistic_.py | 50 -- migrations/versions/merge_heads_ical_all.py | 24 - 46 files changed, 478 insertions(+), 1977 deletions(-) delete mode 100644 migrations/versions/150aa748aa4f_add_ical_token_to_user_account.py delete mode 100644 migrations/versions/1613fcb025fb_add_password_reset_nonce_to_user_account.py delete mode 100644 migrations/versions/1a19cb432044_add_dev_email_block_to_app_settings.py delete mode 100644 migrations/versions/20dbd30fbcfe_debriefing_redesign_new_fields_on_.py delete mode 100644 migrations/versions/2b096852ef02_template_equipment_plan.py create mode 100644 migrations/versions/2dbbf9629c3f_mssql_baseline_schema.py delete mode 100644 migrations/versions/36dc707e1bbc_add_can_be_rp_to_qualification.py delete mode 100644 migrations/versions/4ec25aa941b8_add_invite_cancelled_at.py delete mode 100644 migrations/versions/5a65ab309cfc_add_dashboard_horizon_days_to_user_.py delete mode 100644 migrations/versions/5bf568d4f009_add_dark_mode_to_user_account.py delete mode 100644 migrations/versions/661e4a600825_add_last_login_at_to_user_account.py delete mode 100644 migrations/versions/67dfa2385f16_add_scheduler_last_seen_to_app_settings.py delete mode 100644 migrations/versions/6afec7c90ac1_qualification_soft_delete.py delete mode 100644 migrations/versions/7fd90ec4167e_add_user_role_permission_tables.py delete mode 100644 migrations/versions/86ba4668fbf9_add_backup_config_to_app_settings.py delete mode 100644 migrations/versions/8af3e067c0c6_add_invite_email_tracking_columns.py delete mode 100644 migrations/versions/904fa21b5ed3_rename_preferred_hour_utc_to_preferred_.py delete mode 100644 migrations/versions/9159338cf432_add_reminder_sent_json_to_event.py delete mode 100644 migrations/versions/92f9337e848e_add_instance_name_to_outbox_email.py delete mode 100644 migrations/versions/953cffa1cb85_add_vykaz_generate_permission.py delete mode 100644 migrations/versions/9b422409dc10_add_session_timeout_hours_to_app_.py delete mode 100644 migrations/versions/9d1ee14eb241_notification_toggles_and_outbox_type.py delete mode 100644 migrations/versions/9fd3c08b3d50_add_app_base_url_to_app_settings.py delete mode 100644 migrations/versions/a1b2c3d4e5f6_rename_credential_to_qualification.py delete mode 100644 migrations/versions/a2f3c4d5e6f7_lowercase_existing_user_emails.py delete mode 100644 migrations/versions/a730e4af594e_add_version_to_event_template.py delete mode 100644 migrations/versions/ac1ab7d64f6c_add_outbox_email_table_for_queued_email_.py delete mode 100644 migrations/versions/ad27f656e221_add_invite_custom_subject_and_message.py delete mode 100644 migrations/versions/b1c2d3e4f5a6_add_performance_indexes.py delete mode 100644 migrations/versions/b39e55598ad1_add_equipment_models.py delete mode 100644 migrations/versions/b801953d30cb_add_equipment_item_status_and_.py delete mode 100644 migrations/versions/bbd2db07fc29_add_user_feedback_table.py delete mode 100644 migrations/versions/bbd3aa765181_add_is_archived_to_user_account.py delete mode 100644 migrations/versions/c11afc34311c_add_event_type_enum_planned_.py delete mode 100644 migrations/versions/c384ea97aef5_add_notify_event_changed_to_app_settings.py delete mode 100644 migrations/versions/c7d8e9f0a1b2_add_login_lockout_fields_to_user_account.py delete mode 100644 migrations/versions/ca37e9989a8a_split_notify_event_lifecycle_into_.py delete mode 100644 migrations/versions/d1e2f3a4b5c6_merge_indexes_and_login_lockout_heads.py delete mode 100644 migrations/versions/d37d41a4e38d_add_all_domain_models.py delete mode 100644 migrations/versions/d697cc60c5d2_add_ical_all_token.py delete mode 100644 migrations/versions/d946eda56491_add_feedback_version_and_enabled_toggle.py delete mode 100644 migrations/versions/dfc13fca938f_add_app_settings_table.py delete mode 100644 migrations/versions/ebe3ddc11f1e_optional_spots.py delete mode 100644 migrations/versions/f0c168424643_add_digest_tables_and_outbox_html_body.py delete mode 100644 migrations/versions/f8edde653722_add_version_columns_for_optimistic_.py delete mode 100644 migrations/versions/merge_heads_ical_all.py diff --git a/migrations/versions/150aa748aa4f_add_ical_token_to_user_account.py b/migrations/versions/150aa748aa4f_add_ical_token_to_user_account.py deleted file mode 100644 index 8c5e323e..00000000 --- a/migrations/versions/150aa748aa4f_add_ical_token_to_user_account.py +++ /dev/null @@ -1,28 +0,0 @@ -"""add ical_token to user_account - -Revision ID: 150aa748aa4f -Revises: 92f9337e848e -Create Date: 2026-05-13 13:08:36.240393 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '150aa748aa4f' -down_revision = '92f9337e848e' -branch_labels = None -depends_on = None - - -def upgrade(): - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.add_column(sa.Column('ical_token', sa.String(length=64), nullable=True)) - batch_op.create_index(batch_op.f('ix_user_account_ical_token'), ['ical_token'], unique=True) - - -def downgrade(): - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_user_account_ical_token')) - batch_op.drop_column('ical_token') diff --git a/migrations/versions/1613fcb025fb_add_password_reset_nonce_to_user_account.py b/migrations/versions/1613fcb025fb_add_password_reset_nonce_to_user_account.py deleted file mode 100644 index 25ebf506..00000000 --- a/migrations/versions/1613fcb025fb_add_password_reset_nonce_to_user_account.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add password_reset_nonce to user_account - -Revision ID: 1613fcb025fb -Revises: 36dc707e1bbc -Create Date: 2026-05-09 10:20:03.791110 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '1613fcb025fb' -down_revision = '36dc707e1bbc' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.add_column(sa.Column('password_reset_nonce', sa.String(length=64), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.drop_column('password_reset_nonce') - - # ### end Alembic commands ### diff --git a/migrations/versions/1a19cb432044_add_dev_email_block_to_app_settings.py b/migrations/versions/1a19cb432044_add_dev_email_block_to_app_settings.py deleted file mode 100644 index f418ab31..00000000 --- a/migrations/versions/1a19cb432044_add_dev_email_block_to_app_settings.py +++ /dev/null @@ -1,34 +0,0 @@ -"""add_dev_email_block_to_app_settings - -Revision ID: 1a19cb432044 -Revises: 9159338cf432 -Create Date: 2026-05-08 15:42:09.138554 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '1a19cb432044' -down_revision = '9159338cf432' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.add_column(sa.Column('dev_email_block', sa.Boolean(), server_default='false', nullable=False)) - batch_op.add_column(sa.Column('dev_email_allowlist', sa.Text(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.drop_column('dev_email_allowlist') - batch_op.drop_column('dev_email_block') - - # ### end Alembic commands ### diff --git a/migrations/versions/20dbd30fbcfe_debriefing_redesign_new_fields_on_.py b/migrations/versions/20dbd30fbcfe_debriefing_redesign_new_fields_on_.py deleted file mode 100644 index 2209aa87..00000000 --- a/migrations/versions/20dbd30fbcfe_debriefing_redesign_new_fields_on_.py +++ /dev/null @@ -1,62 +0,0 @@ -"""debriefing redesign: new fields on assignment, event, debriefing_record - -Revision ID: 20dbd30fbcfe -Revises: 86ba4668fbf9 -Create Date: 2026-05-09 15:24:55.347000 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '20dbd30fbcfe' -down_revision = '86ba4668fbf9' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('assignment', schema=None) as batch_op: - batch_op.add_column(sa.Column('debriefing_email_sent', sa.Boolean(), nullable=False, server_default='false')) - - with op.batch_alter_table('debriefing_record', schema=None) as batch_op: - batch_op.add_column(sa.Column('grade', sa.Integer(), nullable=False, server_default='3')) - batch_op.add_column(sa.Column('feedback_event', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('feedback_customer', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('feedback_colleagues', sa.Text(), nullable=True)) - batch_op.drop_column('actual_hours') - batch_op.drop_column('patients_treated') - batch_op.drop_column('feedback') - batch_op.drop_column('materials_used') - - with op.batch_alter_table('event', schema=None) as batch_op: - batch_op.add_column(sa.Column('actual_start_datetime', sa.DateTime(timezone=True), nullable=True)) - batch_op.add_column(sa.Column('actual_end_datetime', sa.DateTime(timezone=True), nullable=True)) - batch_op.add_column(sa.Column('patients_count', sa.Integer(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('event', schema=None) as batch_op: - batch_op.drop_column('patients_count') - batch_op.drop_column('actual_end_datetime') - batch_op.drop_column('actual_start_datetime') - - with op.batch_alter_table('debriefing_record', schema=None) as batch_op: - batch_op.add_column(sa.Column('materials_used', sa.TEXT(), autoincrement=False, nullable=True)) - batch_op.add_column(sa.Column('feedback', sa.TEXT(), autoincrement=False, nullable=True)) - batch_op.add_column(sa.Column('patients_treated', sa.INTEGER(), autoincrement=False, nullable=False)) - batch_op.add_column(sa.Column('actual_hours', sa.NUMERIC(precision=5, scale=2), autoincrement=False, nullable=False)) - batch_op.drop_column('feedback_colleagues') - batch_op.drop_column('feedback_customer') - batch_op.drop_column('feedback_event') - batch_op.drop_column('grade') - - with op.batch_alter_table('assignment', schema=None) as batch_op: - batch_op.drop_column('debriefing_email_sent') - - # ### end Alembic commands ### diff --git a/migrations/versions/2b096852ef02_template_equipment_plan.py b/migrations/versions/2b096852ef02_template_equipment_plan.py deleted file mode 100644 index bc2b64b3..00000000 --- a/migrations/versions/2b096852ef02_template_equipment_plan.py +++ /dev/null @@ -1,35 +0,0 @@ -"""template_equipment_plan - -Revision ID: 2b096852ef02 -Revises: ebe3ddc11f1e -Create Date: 2026-05-08 00:09:39.540720 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '2b096852ef02' -down_revision = 'ebe3ddc11f1e' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('event_template_equipment_plan', - sa.Column('template_id', sa.Integer(), nullable=False), - sa.Column('equipment_type_id', sa.Integer(), nullable=False), - sa.Column('quantity_required', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['equipment_type_id'], ['equipment_type.id'], ), - sa.ForeignKeyConstraint(['template_id'], ['event_template.id'], ), - sa.PrimaryKeyConstraint('template_id', 'equipment_type_id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('event_template_equipment_plan') - # ### end Alembic commands ### diff --git a/migrations/versions/2dbbf9629c3f_mssql_baseline_schema.py b/migrations/versions/2dbbf9629c3f_mssql_baseline_schema.py new file mode 100644 index 00000000..b3b31e31 --- /dev/null +++ b/migrations/versions/2dbbf9629c3f_mssql_baseline_schema.py @@ -0,0 +1,478 @@ +"""mssql baseline schema + +Revision ID: 2dbbf9629c3f +Revises: +Create Date: 2026-06-19 15:27:36.374850 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2dbbf9629c3f' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('app_settings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('org_name', sa.String(length=255), nullable=True), + sa.Column('timezone', sa.String(length=64), nullable=False), + sa.Column('app_base_url', sa.String(length=512), nullable=True), + sa.Column('smtp_server', sa.String(length=255), nullable=True), + sa.Column('smtp_port', sa.Integer(), nullable=False), + sa.Column('smtp_use_tls', sa.Boolean(), nullable=False), + sa.Column('smtp_username', sa.String(length=255), nullable=True), + sa.Column('smtp_password_enc', sa.Text(), nullable=True), + sa.Column('smtp_default_sender', sa.String(length=255), nullable=True), + sa.Column('dev_email_block', sa.Boolean(), server_default='false', nullable=False), + sa.Column('dev_email_allowlist', sa.Text(), nullable=True), + sa.Column('backup_dir', sa.String(length=512), server_default='backups', nullable=False), + sa.Column('backup_keep_count', sa.Integer(), server_default='7', nullable=False), + sa.Column('backup_schedule_enabled', sa.Boolean(), server_default='false', nullable=False), + sa.Column('backup_schedule_hour', sa.Integer(), server_default='2', nullable=False), + sa.Column('session_timeout_hours', sa.Integer(), server_default='24', nullable=False), + sa.Column('notify_assignment', sa.Boolean(), server_default='true', nullable=False), + sa.Column('notify_event_published', sa.Boolean(), server_default='true', nullable=False), + sa.Column('notify_assignments_opened', sa.Boolean(), server_default='true', nullable=False), + sa.Column('notify_event_cancelled', sa.Boolean(), server_default='true', nullable=False), + sa.Column('notify_event_changed', sa.Boolean(), server_default='true', nullable=False), + sa.Column('notify_unfilled_reminder', sa.Boolean(), server_default='true', nullable=False), + sa.Column('notify_debriefing', sa.Boolean(), server_default='true', nullable=False), + sa.Column('setup_complete', sa.Boolean(), nullable=False), + sa.Column('feedback_enabled', sa.Boolean(), nullable=False), + sa.Column('scheduler_last_seen', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('digest_metric_snapshot', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('snapshot_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('metric_name', sa.String(length=64), nullable=False), + sa.Column('metric_value', sa.Float(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('digest_metric_snapshot', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_digest_metric_snapshot_metric_name'), ['metric_name'], unique=False) + batch_op.create_index(batch_op.f('ix_digest_metric_snapshot_snapshot_at'), ['snapshot_at'], unique=False) + + op.create_table('digest_schedule', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('enabled', sa.Boolean(), server_default='false', nullable=False), + sa.Column('frequency_hours', sa.Integer(), server_default='24', nullable=False), + sa.Column('preferred_hour', sa.Integer(), server_default='7', nullable=False), + sa.Column('last_sent_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('email_subject', sa.String(length=255), server_default='MedCover — Přehledový e-mail', nullable=False), + sa.Column('header_html', sa.Text(), nullable=True), + sa.Column('footer_html', sa.Text(), nullable=True), + sa.Column('version', sa.Integer(), server_default='0', nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('equipment_type', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('category', sa.Enum('PERSONAL', 'SHARED', name='equipment_category_enum'), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('event_template', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('paid', sa.Boolean(), nullable=False), + sa.Column('event_type', sa.Enum('MEDICAL_COVER', 'TRAINING', 'PRESENTATION', name='event_type_enum'), nullable=False), + sa.Column('reminder_schedule', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('outbox_email', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('to_email', sa.String(length=255), nullable=False), + sa.Column('subject', sa.String(length=255), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('html_body', sa.Text(), nullable=True), + sa.Column('notification_type', sa.String(length=64), nullable=True), + sa.Column('instance_name', sa.String(length=64), nullable=True), + sa.Column('status', sa.String(length=16), server_default='pending', nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('retry_count', sa.Integer(), server_default='0', nullable=False), + sa.Column('last_error', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('outbox_email', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_outbox_email_created_at'), ['created_at'], unique=False) + batch_op.create_index(batch_op.f('ix_outbox_email_instance_name'), ['instance_name'], unique=False) + batch_op.create_index(batch_op.f('ix_outbox_email_notification_type'), ['notification_type'], unique=False) + batch_op.create_index(batch_op.f('ix_outbox_email_status'), ['status'], unique=False) + + op.create_table('permission', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('code') + ) + op.create_table('qualification', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('can_be_rp', sa.Boolean(), server_default='false', nullable=False), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('qualification', schema=None) as batch_op: + batch_op.create_index('ix_qualification_name_active_unique', ['name'], unique=True, mssql_where=sa.text('is_deleted = 0')) + + op.create_table('role', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('user_account', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('phone', sa.String(length=50), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_archived', sa.Boolean(), server_default='false', nullable=False), + sa.Column('preferred_calendar_view', sa.Enum('MONTH', 'WEEK', 'DAY', 'LIST', name='calendar_view_enum'), nullable=False), + sa.Column('dashboard_horizon_days', sa.Integer(), server_default='30', nullable=False), + sa.Column('dark_mode', sa.Boolean(), server_default='false', nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('password_reset_nonce', sa.String(length=64), nullable=True), + sa.Column('ical_token', sa.String(length=64), nullable=True), + sa.Column('ical_all_token', sa.String(length=64), nullable=True), + sa.Column('failed_login_attempts', sa.Integer(), server_default='0', nullable=False), + sa.Column('login_locked_until', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('user_account', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_user_account_email'), ['email'], unique=True) + batch_op.create_index(batch_op.f('ix_user_account_ical_all_token'), ['ical_all_token'], unique=True) + batch_op.create_index(batch_op.f('ix_user_account_ical_token'), ['ical_token'], unique=True) + + op.create_table('audit_log_entry', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False), + sa.Column('actor_id', sa.Uuid(), nullable=True), + sa.Column('action_type', sa.String(length=32), nullable=False), + sa.Column('entity_type', sa.String(length=64), nullable=False), + sa.Column('entity_id', sa.String(length=64), nullable=False), + sa.Column('summary', sa.Text(), nullable=False), + sa.Column('changes_json', sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(['actor_id'], ['user_account.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('audit_log_entry', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_audit_log_entry_timestamp'), ['timestamp'], unique=False) + + op.create_table('digest_block', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('digest_schedule_id', sa.Integer(), nullable=False), + sa.Column('block_type', sa.String(length=64), nullable=False), + sa.Column('enabled', sa.Boolean(), server_default='true', nullable=False), + sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False), + sa.Column('config_json', sa.JSON(), server_default='{}', nullable=False), + sa.Column('version', sa.Integer(), server_default='0', nullable=False), + sa.ForeignKeyConstraint(['digest_schedule_id'], ['digest_schedule.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('digest_block', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_digest_block_digest_schedule_id'), ['digest_schedule_id'], unique=False) + + op.create_table('equipment_item', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('type_id', sa.Integer(), nullable=False), + sa.Column('serial_number', sa.String(length=100), nullable=True), + sa.Column('home_location', sa.String(length=255), nullable=True), + sa.Column('issued_to_id', sa.Uuid(), nullable=True), + sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('status', sa.Enum('AVAILABLE', 'UNAVAILABLE', name='equipment_item_status_enum'), server_default='AVAILABLE', nullable=False), + sa.Column('unavailability_reason', sa.Text(), nullable=True), + sa.Column('unavailability_since', sa.DateTime(timezone=True), nullable=True), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['issued_to_id'], ['user_account.id'], ), + sa.ForeignKeyConstraint(['type_id'], ['equipment_type.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('event_spot_template', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('template_id', sa.Integer(), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('is_optional', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['template_id'], ['event_template.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('event_template_equipment_plan', + sa.Column('template_id', sa.Integer(), nullable=False), + sa.Column('equipment_type_id', sa.Integer(), nullable=False), + sa.Column('quantity_required', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['equipment_type_id'], ['equipment_type.id'], ), + sa.ForeignKeyConstraint(['template_id'], ['event_template.id'], ), + sa.PrimaryKeyConstraint('template_id', 'equipment_type_id') + ) + op.create_table('master_event', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('coordinator_id', sa.Uuid(), nullable=True), + sa.Column('is_general', sa.Boolean(), nullable=False), + sa.Column('archived', sa.Boolean(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['coordinator_id'], ['user_account.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('qualification_parents', + sa.Column('qualification_id', sa.Integer(), nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['parent_id'], ['qualification.id'], ), + sa.ForeignKeyConstraint(['qualification_id'], ['qualification.id'], ), + sa.PrimaryKeyConstraint('qualification_id', 'parent_id') + ) + op.create_table('registration_invite', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('token', sa.String(length=64), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('created_by_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('outbox_email_id', sa.Integer(), nullable=True), + sa.Column('link_clicked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('custom_subject', sa.String(length=255), nullable=True), + sa.Column('custom_message', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['created_by_id'], ['user_account.id'], ), + sa.ForeignKeyConstraint(['outbox_email_id'], ['outbox_email.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('registration_invite', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_registration_invite_token'), ['token'], unique=True) + + op.create_table('role_permissions', + sa.Column('role_id', sa.Integer(), nullable=False), + sa.Column('permission_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['permission_id'], ['permission.id'], ), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ), + sa.PrimaryKeyConstraint('role_id', 'permission_id') + ) + op.create_table('user_feedback', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=True), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('page_url', sa.String(length=2048), nullable=True), + sa.Column('user_agent', sa.Text(), nullable=True), + sa.Column('screen_info', sa.String(length=255), nullable=True), + sa.Column('app_version', sa.String(length=64), nullable=True), + sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('user_feedback', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_user_feedback_user_id'), ['user_id'], unique=False) + + op.create_table('user_qualifications', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('qualification_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['qualification_id'], ['qualification.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], ), + sa.PrimaryKeyConstraint('user_id', 'qualification_id') + ) + op.create_table('user_roles', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('role_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], ), + sa.PrimaryKeyConstraint('user_id', 'role_id') + ) + op.create_table('event', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('master_event_id', sa.Integer(), nullable=False), + sa.Column('event_type', sa.Enum('MEDICAL_COVER', 'TRAINING', 'PRESENTATION', name='event_type_enum'), nullable=False), + sa.Column('status', sa.Enum('DRAFT', 'PUBLISHED', 'ASSIGNMENTS_OPEN', 'ASSIGNMENTS_CLOSED', 'COMPLETED', 'CANCELLED', name='event_status_enum'), nullable=False), + sa.Column('archived', sa.Boolean(), nullable=False), + sa.Column('start_datetime', sa.DateTime(timezone=True), nullable=False), + sa.Column('end_datetime', sa.DateTime(timezone=True), nullable=False), + sa.Column('assignments_open_datetime', sa.DateTime(timezone=True), nullable=True), + sa.Column('address', sa.String(length=500), nullable=True), + sa.Column('contact_person', sa.String(length=255), nullable=True), + sa.Column('paid', sa.Boolean(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('responsible_person_id', sa.Uuid(), nullable=True), + sa.Column('created_by_id', sa.Uuid(), nullable=True), + sa.Column('reminder_schedule', sa.String(length=255), nullable=True), + sa.Column('reminder_sent_json', sa.JSON(), nullable=True), + sa.Column('actual_start_datetime', sa.DateTime(timezone=True), nullable=True), + sa.Column('actual_end_datetime', sa.DateTime(timezone=True), nullable=True), + sa.Column('planned_participants_count', sa.Integer(), nullable=True), + sa.Column('post_event_count', sa.Integer(), nullable=True), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['user_account.id'], ), + sa.ForeignKeyConstraint(['master_event_id'], ['master_event.id'], ), + sa.ForeignKeyConstraint(['responsible_person_id'], ['user_account.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('spot_template_qualifications', + sa.Column('spot_template_id', sa.Integer(), nullable=False), + sa.Column('qualification_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['qualification_id'], ['qualification.id'], ), + sa.ForeignKeyConstraint(['spot_template_id'], ['event_spot_template.id'], ), + sa.PrimaryKeyConstraint('spot_template_id', 'qualification_id') + ) + op.create_table('event_equipment_assignment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('event_id', sa.Integer(), nullable=False), + sa.Column('equipment_item_id', sa.Integer(), nullable=False), + sa.Column('assigned_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('returned_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['equipment_item_id'], ['equipment_item.id'], ), + sa.ForeignKeyConstraint(['event_id'], ['event.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('event_id', 'equipment_item_id', name='uq_event_equipment_item') + ) + op.create_table('event_equipment_plan', + sa.Column('event_id', sa.Integer(), nullable=False), + sa.Column('equipment_type_id', sa.Integer(), nullable=False), + sa.Column('quantity_required', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['equipment_type_id'], ['equipment_type.id'], ), + sa.ForeignKeyConstraint(['event_id'], ['event.id'], ), + sa.PrimaryKeyConstraint('event_id', 'equipment_type_id') + ) + op.create_table('event_spot', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('event_id', sa.Integer(), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('is_optional', sa.Boolean(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['event_id'], ['event.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('assignment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('spot_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('assigned_by_id', sa.Uuid(), nullable=True), + sa.Column('assigned_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('debriefing_email_sent', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['assigned_by_id'], ['user_account.id'], ), + sa.ForeignKeyConstraint(['spot_id'], ['event_spot.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('spot_id') + ) + op.create_table('spot_qualifications', + sa.Column('spot_id', sa.Integer(), nullable=False), + sa.Column('qualification_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['qualification_id'], ['qualification.id'], ), + sa.ForeignKeyConstraint(['spot_id'], ['event_spot.id'], ), + sa.PrimaryKeyConstraint('spot_id', 'qualification_id') + ) + op.create_table('debriefing_record', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('assignment_id', sa.Integer(), nullable=False), + sa.Column('submitted_by_id', sa.Uuid(), nullable=False), + sa.Column('grade', sa.Integer(), nullable=False), + sa.Column('feedback_event', sa.Text(), nullable=True), + sa.Column('feedback_customer', sa.Text(), nullable=True), + sa.Column('feedback_colleagues', sa.Text(), nullable=True), + sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['assignment_id'], ['assignment.id'], ), + sa.ForeignKeyConstraint(['submitted_by_id'], ['user_account.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('assignment_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('debriefing_record') + op.drop_table('spot_qualifications') + op.drop_table('assignment') + op.drop_table('event_spot') + op.drop_table('event_equipment_plan') + op.drop_table('event_equipment_assignment') + op.drop_table('spot_template_qualifications') + op.drop_table('event') + op.drop_table('user_roles') + op.drop_table('user_qualifications') + with op.batch_alter_table('user_feedback', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_feedback_user_id')) + + op.drop_table('user_feedback') + op.drop_table('role_permissions') + with op.batch_alter_table('registration_invite', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_registration_invite_token')) + + op.drop_table('registration_invite') + op.drop_table('qualification_parents') + op.drop_table('master_event') + op.drop_table('event_template_equipment_plan') + op.drop_table('event_spot_template') + op.drop_table('equipment_item') + with op.batch_alter_table('digest_block', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_digest_block_digest_schedule_id')) + + op.drop_table('digest_block') + with op.batch_alter_table('audit_log_entry', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_audit_log_entry_timestamp')) + + op.drop_table('audit_log_entry') + with op.batch_alter_table('user_account', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_account_ical_token')) + batch_op.drop_index(batch_op.f('ix_user_account_ical_all_token')) + batch_op.drop_index(batch_op.f('ix_user_account_email')) + + op.drop_table('user_account') + op.drop_table('role') + with op.batch_alter_table('qualification', schema=None) as batch_op: + batch_op.drop_index('ix_qualification_name_active_unique', mssql_where=sa.text('is_deleted = 0')) + + op.drop_table('qualification') + op.drop_table('permission') + with op.batch_alter_table('outbox_email', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_outbox_email_status')) + batch_op.drop_index(batch_op.f('ix_outbox_email_notification_type')) + batch_op.drop_index(batch_op.f('ix_outbox_email_instance_name')) + batch_op.drop_index(batch_op.f('ix_outbox_email_created_at')) + + op.drop_table('outbox_email') + op.drop_table('event_template') + op.drop_table('equipment_type') + op.drop_table('digest_schedule') + with op.batch_alter_table('digest_metric_snapshot', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_digest_metric_snapshot_snapshot_at')) + batch_op.drop_index(batch_op.f('ix_digest_metric_snapshot_metric_name')) + + op.drop_table('digest_metric_snapshot') + op.drop_table('app_settings') + # ### end Alembic commands ### diff --git a/migrations/versions/36dc707e1bbc_add_can_be_rp_to_qualification.py b/migrations/versions/36dc707e1bbc_add_can_be_rp_to_qualification.py deleted file mode 100644 index d221fc49..00000000 --- a/migrations/versions/36dc707e1bbc_add_can_be_rp_to_qualification.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add can_be_rp to qualification - -Revision ID: 36dc707e1bbc -Revises: f0c168424643 -Create Date: 2026-05-08 21:00:20.557256 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '36dc707e1bbc' -down_revision = 'f0c168424643' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('qualification', schema=None) as batch_op: - batch_op.add_column(sa.Column('can_be_rp', sa.Boolean(), server_default='false', nullable=False)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('qualification', schema=None) as batch_op: - batch_op.drop_column('can_be_rp') - - # ### end Alembic commands ### diff --git a/migrations/versions/4ec25aa941b8_add_invite_cancelled_at.py b/migrations/versions/4ec25aa941b8_add_invite_cancelled_at.py deleted file mode 100644 index 3d82bf2e..00000000 --- a/migrations/versions/4ec25aa941b8_add_invite_cancelled_at.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add invite cancelled_at - -Revision ID: 4ec25aa941b8 -Revises: ad27f656e221 -Create Date: 2026-05-07 21:06:30.909279 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '4ec25aa941b8' -down_revision = 'ad27f656e221' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('registration_invite', schema=None) as batch_op: - batch_op.add_column(sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('registration_invite', schema=None) as batch_op: - batch_op.drop_column('cancelled_at') - - # ### end Alembic commands ### diff --git a/migrations/versions/5a65ab309cfc_add_dashboard_horizon_days_to_user_.py b/migrations/versions/5a65ab309cfc_add_dashboard_horizon_days_to_user_.py deleted file mode 100644 index d99b54ef..00000000 --- a/migrations/versions/5a65ab309cfc_add_dashboard_horizon_days_to_user_.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add dashboard_horizon_days to user_account - -Revision ID: 5a65ab309cfc -Revises: f8edde653722 -Create Date: 2026-05-07 07:20:38.582311 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '5a65ab309cfc' -down_revision = 'f8edde653722' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.add_column(sa.Column('dashboard_horizon_days', sa.Integer(), server_default='30', nullable=False)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.drop_column('dashboard_horizon_days') - - # ### end Alembic commands ### diff --git a/migrations/versions/5bf568d4f009_add_dark_mode_to_user_account.py b/migrations/versions/5bf568d4f009_add_dark_mode_to_user_account.py deleted file mode 100644 index 926aac4c..00000000 --- a/migrations/versions/5bf568d4f009_add_dark_mode_to_user_account.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add dark_mode to user_account - -Revision ID: 5bf568d4f009 -Revises: a730e4af594e -Create Date: 2026-05-07 12:53:30.722629 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '5bf568d4f009' -down_revision = 'a730e4af594e' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.add_column(sa.Column('dark_mode', sa.Boolean(), server_default='false', nullable=False)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.drop_column('dark_mode') - - # ### end Alembic commands ### diff --git a/migrations/versions/661e4a600825_add_last_login_at_to_user_account.py b/migrations/versions/661e4a600825_add_last_login_at_to_user_account.py deleted file mode 100644 index 5e56ea8f..00000000 --- a/migrations/versions/661e4a600825_add_last_login_at_to_user_account.py +++ /dev/null @@ -1,26 +0,0 @@ -"""add last_login_at to user_account - -Revision ID: 661e4a600825 -Revises: 904fa21b5ed3 -Create Date: 2026-05-12 18:41:07.623913 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '661e4a600825' -down_revision = '904fa21b5ed3' -branch_labels = None -depends_on = None - - -def upgrade(): - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.add_column(sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True)) - - -def downgrade(): - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.drop_column('last_login_at') diff --git a/migrations/versions/67dfa2385f16_add_scheduler_last_seen_to_app_settings.py b/migrations/versions/67dfa2385f16_add_scheduler_last_seen_to_app_settings.py deleted file mode 100644 index 4526219f..00000000 --- a/migrations/versions/67dfa2385f16_add_scheduler_last_seen_to_app_settings.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add scheduler_last_seen to app_settings - -Revision ID: 67dfa2385f16 -Revises: ac1ab7d64f6c -Create Date: 2026-05-07 11:03:07.643412 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '67dfa2385f16' -down_revision = 'ac1ab7d64f6c' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.add_column(sa.Column('scheduler_last_seen', sa.DateTime(timezone=True), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.drop_column('scheduler_last_seen') - - # ### end Alembic commands ### diff --git a/migrations/versions/6afec7c90ac1_qualification_soft_delete.py b/migrations/versions/6afec7c90ac1_qualification_soft_delete.py deleted file mode 100644 index 7daef50b..00000000 --- a/migrations/versions/6afec7c90ac1_qualification_soft_delete.py +++ /dev/null @@ -1,38 +0,0 @@ -"""qualification_soft_delete - -Revision ID: 6afec7c90ac1 -Revises: 4ec25aa941b8 -Create Date: 2026-05-07 22:40:26.217761 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '6afec7c90ac1' -down_revision = '4ec25aa941b8' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('qualification', schema=None) as batch_op: - batch_op.add_column(sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False)) - batch_op.add_column(sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True)) - batch_op.drop_constraint(batch_op.f('credential_name_key'), type_='unique') - batch_op.create_index('ix_qualification_name_active_unique', ['name'], unique=True, postgresql_where=sa.text('is_deleted = false')) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('qualification', schema=None) as batch_op: - batch_op.drop_index('ix_qualification_name_active_unique', postgresql_where=sa.text('is_deleted = false')) - batch_op.create_unique_constraint(batch_op.f('credential_name_key'), ['name'], postgresql_nulls_not_distinct=False) - batch_op.drop_column('deleted_at') - batch_op.drop_column('is_deleted') - - # ### end Alembic commands ### diff --git a/migrations/versions/7fd90ec4167e_add_user_role_permission_tables.py b/migrations/versions/7fd90ec4167e_add_user_role_permission_tables.py deleted file mode 100644 index 65cf5e63..00000000 --- a/migrations/versions/7fd90ec4167e_add_user_role_permission_tables.py +++ /dev/null @@ -1,77 +0,0 @@ -"""add user, role, permission tables - -Revision ID: 7fd90ec4167e -Revises: -Create Date: 2026-05-07 00:01:35.503580 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '7fd90ec4167e' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('permission', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('code', sa.String(length=64), nullable=False), - sa.Column('description', sa.String(length=255), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('code') - ) - op.create_table('role', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=64), nullable=False), - sa.Column('description', sa.String(length=255), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name') - ) - op.create_table('user_account', - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('email', sa.String(length=255), nullable=False), - sa.Column('password_hash', sa.String(length=255), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('phone', sa.String(length=50), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('preferred_calendar_view', sa.Enum('MONTH', 'WEEK', 'DAY', 'LIST', name='calendar_view_enum'), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_user_account_email'), ['email'], unique=True) - - op.create_table('role_permissions', - sa.Column('role_id', sa.Integer(), nullable=False), - sa.Column('permission_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['permission_id'], ['permission.id'], ), - sa.ForeignKeyConstraint(['role_id'], ['role.id'], ), - sa.PrimaryKeyConstraint('role_id', 'permission_id') - ) - op.create_table('user_roles', - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('role_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['role_id'], ['role.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], ), - sa.PrimaryKeyConstraint('user_id', 'role_id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('user_roles') - op.drop_table('role_permissions') - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_user_account_email')) - - op.drop_table('user_account') - op.drop_table('role') - op.drop_table('permission') - # ### end Alembic commands ### diff --git a/migrations/versions/86ba4668fbf9_add_backup_config_to_app_settings.py b/migrations/versions/86ba4668fbf9_add_backup_config_to_app_settings.py deleted file mode 100644 index c0151c1f..00000000 --- a/migrations/versions/86ba4668fbf9_add_backup_config_to_app_settings.py +++ /dev/null @@ -1,38 +0,0 @@ -"""add backup config to app_settings - -Revision ID: 86ba4668fbf9 -Revises: 1613fcb025fb -Create Date: 2026-05-09 10:49:39.996039 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '86ba4668fbf9' -down_revision = '1613fcb025fb' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.add_column(sa.Column('backup_dir', sa.String(length=512), server_default='backups', nullable=False)) - batch_op.add_column(sa.Column('backup_keep_count', sa.Integer(), server_default='7', nullable=False)) - batch_op.add_column(sa.Column('backup_schedule_enabled', sa.Boolean(), server_default='false', nullable=False)) - batch_op.add_column(sa.Column('backup_schedule_hour', sa.Integer(), server_default='2', nullable=False)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.drop_column('backup_schedule_hour') - batch_op.drop_column('backup_schedule_enabled') - batch_op.drop_column('backup_keep_count') - batch_op.drop_column('backup_dir') - - # ### end Alembic commands ### diff --git a/migrations/versions/8af3e067c0c6_add_invite_email_tracking_columns.py b/migrations/versions/8af3e067c0c6_add_invite_email_tracking_columns.py deleted file mode 100644 index 5ed682a7..00000000 --- a/migrations/versions/8af3e067c0c6_add_invite_email_tracking_columns.py +++ /dev/null @@ -1,36 +0,0 @@ -"""add invite email tracking columns - -Revision ID: 8af3e067c0c6 -Revises: a1b2c3d4e5f6 -Create Date: 2026-05-07 20:54:26.459428 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '8af3e067c0c6' -down_revision = 'a1b2c3d4e5f6' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('registration_invite', schema=None) as batch_op: - batch_op.add_column(sa.Column('outbox_email_id', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('link_clicked_at', sa.DateTime(timezone=True), nullable=True)) - batch_op.create_foreign_key(None, 'outbox_email', ['outbox_email_id'], ['id']) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('registration_invite', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_column('link_clicked_at') - batch_op.drop_column('outbox_email_id') - - # ### end Alembic commands ### diff --git a/migrations/versions/904fa21b5ed3_rename_preferred_hour_utc_to_preferred_.py b/migrations/versions/904fa21b5ed3_rename_preferred_hour_utc_to_preferred_.py deleted file mode 100644 index b515d9a0..00000000 --- a/migrations/versions/904fa21b5ed3_rename_preferred_hour_utc_to_preferred_.py +++ /dev/null @@ -1,28 +0,0 @@ -"""rename preferred_hour_utc to preferred_hour in digest_schedule - -Revision ID: 904fa21b5ed3 -Revises: bbd3aa765181 -Create Date: 2026-05-12 09:58:18.146596 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '904fa21b5ed3' -down_revision = 'bbd3aa765181' -branch_labels = None -depends_on = None - - -def upgrade(): - with op.batch_alter_table('digest_schedule', schema=None) as batch_op: - batch_op.add_column(sa.Column('preferred_hour', sa.Integer(), server_default='7', nullable=False)) - batch_op.drop_column('preferred_hour_utc') - - -def downgrade(): - with op.batch_alter_table('digest_schedule', schema=None) as batch_op: - batch_op.add_column(sa.Column('preferred_hour_utc', sa.INTEGER(), server_default=sa.text('7'), autoincrement=False, nullable=False)) - batch_op.drop_column('preferred_hour') diff --git a/migrations/versions/9159338cf432_add_reminder_sent_json_to_event.py b/migrations/versions/9159338cf432_add_reminder_sent_json_to_event.py deleted file mode 100644 index dead6e8c..00000000 --- a/migrations/versions/9159338cf432_add_reminder_sent_json_to_event.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add_reminder_sent_json_to_event - -Revision ID: 9159338cf432 -Revises: d946eda56491 -Create Date: 2026-05-08 15:37:12.072855 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '9159338cf432' -down_revision = 'd946eda56491' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('event', schema=None) as batch_op: - batch_op.add_column(sa.Column('reminder_sent_json', sa.JSON(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('event', schema=None) as batch_op: - batch_op.drop_column('reminder_sent_json') - - # ### end Alembic commands ### diff --git a/migrations/versions/92f9337e848e_add_instance_name_to_outbox_email.py b/migrations/versions/92f9337e848e_add_instance_name_to_outbox_email.py deleted file mode 100644 index fd412e15..00000000 --- a/migrations/versions/92f9337e848e_add_instance_name_to_outbox_email.py +++ /dev/null @@ -1,28 +0,0 @@ -"""add instance_name to outbox_email - -Revision ID: 92f9337e848e -Revises: ca37e9989a8a -Create Date: 2026-05-13 12:39:55.730349 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '92f9337e848e' -down_revision = 'ca37e9989a8a' -branch_labels = None -depends_on = None - - -def upgrade(): - with op.batch_alter_table('outbox_email', schema=None) as batch_op: - batch_op.add_column(sa.Column('instance_name', sa.String(length=64), nullable=True)) - batch_op.create_index(batch_op.f('ix_outbox_email_instance_name'), ['instance_name'], unique=False) - - -def downgrade(): - with op.batch_alter_table('outbox_email', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_outbox_email_instance_name')) - batch_op.drop_column('instance_name') diff --git a/migrations/versions/953cffa1cb85_add_vykaz_generate_permission.py b/migrations/versions/953cffa1cb85_add_vykaz_generate_permission.py deleted file mode 100644 index e01a89d7..00000000 --- a/migrations/versions/953cffa1cb85_add_vykaz_generate_permission.py +++ /dev/null @@ -1,86 +0,0 @@ -"""add work_report.generate permission - -Revision ID: 953cffa1cb85 -Revises: 20dbd30fbcfe -Create Date: 2026-05-10 01:10:00.000000 - -Inserts the new 'work_report.generate' permission and assigns it to the -Admin, Coordinator, and Member roles. Viewer and Debriefing Manager -roles do NOT receive this permission. -""" -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = '953cffa1cb85' -down_revision = '20dbd30fbcfe' -branch_labels = None -depends_on = None - -# Roles that may generate a výkaz práce -_ALLOWED_ROLES = ("Admin", "Coordinator", "Member") - - -def upgrade() -> None: - conn = op.get_bind() - - # Insert permission (idempotent: skip if already present) - existing = conn.execute( - sa.text("SELECT id FROM permission WHERE code = 'work_report.generate'") - ).fetchone() - - if existing is None: - conn.execute( - sa.text( - "INSERT INTO permission (code, description) " - "VALUES ('work_report.generate', 'Generate own monthly work report (výkaz práce)')" - ) - ) - - # Assign permission to allowed roles - perm_row = conn.execute( - sa.text("SELECT id FROM permission WHERE code = 'work_report.generate'") - ).fetchone() - perm_id = perm_row[0] - - for role_name in _ALLOWED_ROLES: - role_row = conn.execute( - sa.text("SELECT id FROM role WHERE name = :name"), - {"name": role_name}, - ).fetchone() - if role_row is None: - continue - role_id = role_row[0] - already = conn.execute( - sa.text( - "SELECT 1 FROM role_permissions " - "WHERE role_id = :rid AND permission_id = :pid" - ), - {"rid": role_id, "pid": perm_id}, - ).fetchone() - if already is None: - conn.execute( - sa.text( - "INSERT INTO role_permissions (role_id, permission_id) " - "VALUES (:rid, :pid)" - ), - {"rid": role_id, "pid": perm_id}, - ) - - -def downgrade() -> None: - conn = op.get_bind() - perm_row = conn.execute( - sa.text("SELECT id FROM permission WHERE code = 'work_report.generate'") - ).fetchone() - if perm_row is None: - return - perm_id = perm_row[0] - conn.execute( - sa.text("DELETE FROM role_permissions WHERE permission_id = :pid"), - {"pid": perm_id}, - ) - conn.execute( - sa.text("DELETE FROM permission WHERE id = :pid"), - {"pid": perm_id}, - ) diff --git a/migrations/versions/9b422409dc10_add_session_timeout_hours_to_app_.py b/migrations/versions/9b422409dc10_add_session_timeout_hours_to_app_.py deleted file mode 100644 index 7e64fd76..00000000 --- a/migrations/versions/9b422409dc10_add_session_timeout_hours_to_app_.py +++ /dev/null @@ -1,30 +0,0 @@ -"""add session_timeout_hours to app_settings - -Revision ID: 9b422409dc10 -Revises: 661e4a600825 -Create Date: 2026-05-13 07:13:21.009345 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '9b422409dc10' -down_revision = '661e4a600825' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.add_column(sa.Column('session_timeout_hours', sa.Integer(), server_default='24', nullable=False)) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.drop_column('session_timeout_hours') - # ### end Alembic commands ### diff --git a/migrations/versions/9d1ee14eb241_notification_toggles_and_outbox_type.py b/migrations/versions/9d1ee14eb241_notification_toggles_and_outbox_type.py deleted file mode 100644 index 2f8bd8b5..00000000 --- a/migrations/versions/9d1ee14eb241_notification_toggles_and_outbox_type.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Add notification toggles to app_settings and notification_type to outbox_email. - -Revision ID: 9d1ee14eb241 -Revises: 953cffa1cb85 -Create Date: 2026-05-10 - -""" - -import sqlalchemy as sa -from alembic import op - -revision = '9d1ee14eb241' -down_revision = 'd1e2f3a4b5c6' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Notification toggles on app_settings (all enabled by default) - op.add_column('app_settings', sa.Column('notify_assignment', sa.Boolean(), nullable=False, server_default='true')) - op.add_column('app_settings', sa.Column('notify_event_lifecycle', sa.Boolean(), nullable=False, server_default='true')) - op.add_column('app_settings', sa.Column('notify_event_cancelled', sa.Boolean(), nullable=False, server_default='true')) - op.add_column('app_settings', sa.Column('notify_unfilled_reminder', sa.Boolean(), nullable=False, server_default='true')) - op.add_column('app_settings', sa.Column('notify_debriefing', sa.Boolean(), nullable=False, server_default='true')) - - # Notification type label on outbox_email for traceability - op.add_column('outbox_email', sa.Column('notification_type', sa.String(64), nullable=True)) - op.create_index(op.f('ix_outbox_email_notification_type'), 'outbox_email', ['notification_type'], unique=False) - - -def downgrade() -> None: - op.drop_index(op.f('ix_outbox_email_notification_type'), table_name='outbox_email') - op.drop_column('outbox_email', 'notification_type') - op.drop_column('app_settings', 'notify_debriefing') - op.drop_column('app_settings', 'notify_unfilled_reminder') - op.drop_column('app_settings', 'notify_event_cancelled') - op.drop_column('app_settings', 'notify_event_lifecycle') - op.drop_column('app_settings', 'notify_assignment') diff --git a/migrations/versions/9fd3c08b3d50_add_app_base_url_to_app_settings.py b/migrations/versions/9fd3c08b3d50_add_app_base_url_to_app_settings.py deleted file mode 100644 index abbf00a1..00000000 --- a/migrations/versions/9fd3c08b3d50_add_app_base_url_to_app_settings.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add_app_base_url_to_app_settings - -Revision ID: 9fd3c08b3d50 -Revises: bbd2db07fc29 -Create Date: 2026-05-08 15:09:43.396867 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = '9fd3c08b3d50' -down_revision = 'bbd2db07fc29' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.add_column(sa.Column('app_base_url', sa.String(length=512), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.drop_column('app_base_url') - - # ### end Alembic commands ### diff --git a/migrations/versions/a1b2c3d4e5f6_rename_credential_to_qualification.py b/migrations/versions/a1b2c3d4e5f6_rename_credential_to_qualification.py deleted file mode 100644 index 10d52aad..00000000 --- a/migrations/versions/a1b2c3d4e5f6_rename_credential_to_qualification.py +++ /dev/null @@ -1,77 +0,0 @@ -"""rename credential tables and permissions to qualification - -Revision ID: a1b2c3d4e5f6 -Revises: f8edde653722 -Create Date: 2026-05-07 20:00:00.000000 - -""" -from alembic import op - -revision = 'a1b2c3d4e5f6' -down_revision = '5bf568d4f009' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Rename tables - op.rename_table('credential', 'qualification') - op.rename_table('credential_parents', 'qualification_parents') - op.rename_table('user_credentials', 'user_qualifications') - op.rename_table('spot_credentials', 'spot_qualifications') - op.rename_table('spot_template_credentials', 'spot_template_qualifications') - - # Fix foreign key column names inside renamed tables - with op.batch_alter_table('qualification_parents') as batch_op: - batch_op.alter_column('credential_id', new_column_name='qualification_id') - - with op.batch_alter_table('user_qualifications') as batch_op: - batch_op.alter_column('credential_id', new_column_name='qualification_id') - - with op.batch_alter_table('spot_qualifications') as batch_op: - batch_op.alter_column('credential_id', new_column_name='qualification_id') - - with op.batch_alter_table('spot_template_qualifications') as batch_op: - batch_op.alter_column('credential_id', new_column_name='qualification_id') - - # Rename permission codes - op.execute(""" - UPDATE permission SET code = 'qualification.view' WHERE code = 'credential.view'; - UPDATE permission SET code = 'qualification.create' WHERE code = 'credential.create'; - UPDATE permission SET code = 'qualification.edit' WHERE code = 'credential.edit'; - UPDATE permission SET code = 'qualification.delete' WHERE code = 'credential.delete'; - UPDATE permission SET code = 'user.assign_qualification' WHERE code = 'user.assign_credential'; - """) - - # Update entity_type in audit_log_entry - op.execute(""" - UPDATE audit_log_entry SET entity_type = 'Qualification' WHERE entity_type = 'Credential'; - """) - - -def downgrade() -> None: - op.execute(""" - UPDATE audit_log_entry SET entity_type = 'Credential' WHERE entity_type = 'Qualification'; - """) - op.execute(""" - UPDATE permission SET code = 'credential.view' WHERE code = 'qualification.view'; - UPDATE permission SET code = 'credential.create' WHERE code = 'qualification.create'; - UPDATE permission SET code = 'credential.edit' WHERE code = 'qualification.edit'; - UPDATE permission SET code = 'credential.delete' WHERE code = 'qualification.delete'; - UPDATE permission SET code = 'user.assign_credential' WHERE code = 'user.assign_qualification'; - """) - - with op.batch_alter_table('spot_template_qualifications') as batch_op: - batch_op.alter_column('qualification_id', new_column_name='credential_id') - with op.batch_alter_table('spot_qualifications') as batch_op: - batch_op.alter_column('qualification_id', new_column_name='credential_id') - with op.batch_alter_table('user_qualifications') as batch_op: - batch_op.alter_column('qualification_id', new_column_name='credential_id') - with op.batch_alter_table('qualification_parents') as batch_op: - batch_op.alter_column('qualification_id', new_column_name='credential_id') - - op.rename_table('spot_template_qualifications', 'spot_template_credentials') - op.rename_table('spot_qualifications', 'spot_credentials') - op.rename_table('user_qualifications', 'user_credentials') - op.rename_table('qualification_parents', 'credential_parents') - op.rename_table('qualification', 'credential') diff --git a/migrations/versions/a2f3c4d5e6f7_lowercase_existing_user_emails.py b/migrations/versions/a2f3c4d5e6f7_lowercase_existing_user_emails.py deleted file mode 100644 index 65b1b5e7..00000000 --- a/migrations/versions/a2f3c4d5e6f7_lowercase_existing_user_emails.py +++ /dev/null @@ -1,23 +0,0 @@ -"""lowercase existing user emails - -Revision ID: a2f3c4d5e6f7 -Revises: 953cffa1cb85 -Create Date: 2026-05-10 10:00:00.000000 - -""" -from alembic import op - - -# revision identifiers, used by Alembic. -revision = "a2f3c4d5e6f7" -down_revision = "953cffa1cb85" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.execute("UPDATE user_account SET email = LOWER(email) WHERE email != LOWER(email)") - - -def downgrade() -> None: - pass diff --git a/migrations/versions/a730e4af594e_add_version_to_event_template.py b/migrations/versions/a730e4af594e_add_version_to_event_template.py deleted file mode 100644 index 77765687..00000000 --- a/migrations/versions/a730e4af594e_add_version_to_event_template.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add version to event_template - -Revision ID: a730e4af594e -Revises: b39e55598ad1 -Create Date: 2026-05-07 12:10:53.405158 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'a730e4af594e' -down_revision = 'b39e55598ad1' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('event_template', schema=None) as batch_op: - batch_op.add_column(sa.Column('version', sa.Integer(), nullable=False)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('event_template', schema=None) as batch_op: - batch_op.drop_column('version') - - # ### end Alembic commands ### diff --git a/migrations/versions/ac1ab7d64f6c_add_outbox_email_table_for_queued_email_.py b/migrations/versions/ac1ab7d64f6c_add_outbox_email_table_for_queued_email_.py deleted file mode 100644 index c659cd8a..00000000 --- a/migrations/versions/ac1ab7d64f6c_add_outbox_email_table_for_queued_email_.py +++ /dev/null @@ -1,47 +0,0 @@ -"""add outbox_email table for queued email delivery - -Revision ID: ac1ab7d64f6c -Revises: 5a65ab309cfc -Create Date: 2026-05-07 07:51:46.126473 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'ac1ab7d64f6c' -down_revision = '5a65ab309cfc' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('outbox_email', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('to_email', sa.String(length=255), nullable=False), - sa.Column('subject', sa.String(length=255), nullable=False), - sa.Column('body', sa.Text(), nullable=False), - sa.Column('status', sa.String(length=16), server_default='pending', nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('retry_count', sa.Integer(), server_default='0', nullable=False), - sa.Column('last_error', sa.Text(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - with op.batch_alter_table('outbox_email', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_outbox_email_created_at'), ['created_at'], unique=False) - batch_op.create_index(batch_op.f('ix_outbox_email_status'), ['status'], unique=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('outbox_email', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_outbox_email_status')) - batch_op.drop_index(batch_op.f('ix_outbox_email_created_at')) - - op.drop_table('outbox_email') - # ### end Alembic commands ### diff --git a/migrations/versions/ad27f656e221_add_invite_custom_subject_and_message.py b/migrations/versions/ad27f656e221_add_invite_custom_subject_and_message.py deleted file mode 100644 index 7617adfc..00000000 --- a/migrations/versions/ad27f656e221_add_invite_custom_subject_and_message.py +++ /dev/null @@ -1,34 +0,0 @@ -"""add invite custom subject and message - -Revision ID: ad27f656e221 -Revises: 8af3e067c0c6 -Create Date: 2026-05-07 21:01:55.690434 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'ad27f656e221' -down_revision = '8af3e067c0c6' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('registration_invite', schema=None) as batch_op: - batch_op.add_column(sa.Column('custom_subject', sa.String(length=255), nullable=True)) - batch_op.add_column(sa.Column('custom_message', sa.Text(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('registration_invite', schema=None) as batch_op: - batch_op.drop_column('custom_message') - batch_op.drop_column('custom_subject') - - # ### end Alembic commands ### diff --git a/migrations/versions/b1c2d3e4f5a6_add_performance_indexes.py b/migrations/versions/b1c2d3e4f5a6_add_performance_indexes.py deleted file mode 100644 index 7c8c7289..00000000 --- a/migrations/versions/b1c2d3e4f5a6_add_performance_indexes.py +++ /dev/null @@ -1,49 +0,0 @@ -"""add performance indexes on event, event_spot, assignment - -Revision ID: b1c2d3e4f5a6 -Revises: a2f3c4d5e6f7 -Create Date: 2026-05-10 14:10:00.000000 - -Indexes added: - - event(status) – filtered in events list, dashboard candidates query - - event(start_datetime) – ORDER BY in events list and ME report - - event(master_event_id) – ME report WHERE clause - - event(archived) – nearly every event query filters on this - - event_spot(event_id) – selectin loads by SQLAlchemy for spots relationship - - assignment(user_id) – dashboard pending-debriefings filter - -Also adds a composite covering index (archived, status, start_datetime) that -satisfies the events-list hot-path query in a single index-only scan. -""" -from alembic import op - - -# revision identifiers, used by Alembic. -revision = "b1c2d3e4f5a6" -down_revision = "a2f3c4d5e6f7" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_index("ix_event_status", "event", ["status"]) - op.create_index("ix_event_start_datetime", "event", ["start_datetime"]) - op.create_index("ix_event_master_event_id", "event", ["master_event_id"]) - op.create_index("ix_event_archived", "event", ["archived"]) - op.create_index( - "ix_event_archived_status_start", - "event", - ["archived", "status", "start_datetime"], - ) - op.create_index("ix_event_spot_event_id", "event_spot", ["event_id"]) - op.create_index("ix_assignment_user_id", "assignment", ["user_id"]) - - -def downgrade() -> None: - op.drop_index("ix_assignment_user_id", table_name="assignment") - op.drop_index("ix_event_spot_event_id", table_name="event_spot") - op.drop_index("ix_event_archived_status_start", table_name="event") - op.drop_index("ix_event_archived", table_name="event") - op.drop_index("ix_event_master_event_id", table_name="event") - op.drop_index("ix_event_start_datetime", table_name="event") - op.drop_index("ix_event_status", table_name="event") diff --git a/migrations/versions/b39e55598ad1_add_equipment_models.py b/migrations/versions/b39e55598ad1_add_equipment_models.py deleted file mode 100644 index 81531905..00000000 --- a/migrations/versions/b39e55598ad1_add_equipment_models.py +++ /dev/null @@ -1,76 +0,0 @@ -"""add equipment models - -Revision ID: b39e55598ad1 -Revises: 67dfa2385f16 -Create Date: 2026-05-07 11:59:23.951638 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'b39e55598ad1' -down_revision = '67dfa2385f16' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('equipment_type', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('category', sa.Enum('PERSONAL', 'SHARED', name='equipment_category_enum'), nullable=False), - sa.Column('version', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name') - ) - op.create_table('equipment_item', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('type_id', sa.Integer(), nullable=False), - sa.Column('serial_number', sa.String(length=100), nullable=True), - sa.Column('home_location', sa.String(length=255), nullable=True), - sa.Column('issued_to_id', sa.Uuid(), nullable=True), - sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('notes', sa.Text(), nullable=True), - sa.Column('version', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['issued_to_id'], ['user_account.id'], ), - sa.ForeignKeyConstraint(['type_id'], ['equipment_type.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('event_equipment_assignment', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('event_id', sa.Integer(), nullable=False), - sa.Column('equipment_item_id', sa.Integer(), nullable=False), - sa.Column('assigned_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('returned_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(['equipment_item_id'], ['equipment_item.id'], ), - sa.ForeignKeyConstraint(['event_id'], ['event.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('event_id', 'equipment_item_id', name='uq_event_equipment_item') - ) - op.create_table('event_equipment_plan', - sa.Column('event_id', sa.Integer(), nullable=False), - sa.Column('equipment_type_id', sa.Integer(), nullable=False), - sa.Column('quantity_required', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['equipment_type_id'], ['equipment_type.id'], ), - sa.ForeignKeyConstraint(['event_id'], ['event.id'], ), - sa.PrimaryKeyConstraint('event_id', 'equipment_type_id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('event_equipment_plan') - op.drop_table('event_equipment_assignment') - op.drop_table('equipment_item') - op.drop_table('equipment_type') - # ### end Alembic commands ### diff --git a/migrations/versions/b801953d30cb_add_equipment_item_status_and_.py b/migrations/versions/b801953d30cb_add_equipment_item_status_and_.py deleted file mode 100644 index 16a121b4..00000000 --- a/migrations/versions/b801953d30cb_add_equipment_item_status_and_.py +++ /dev/null @@ -1,66 +0,0 @@ -"""add equipment item status and unavailability fields - -Revision ID: b801953d30cb -Revises: 150aa748aa4f -Create Date: 2026-05-14 16:29:27.284836 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'b801953d30cb' -down_revision = '150aa748aa4f' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('assignment', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_assignment_user_id')) - - op.execute("CREATE TYPE equipment_item_status_enum AS ENUM ('AVAILABLE', 'UNAVAILABLE')") - - with op.batch_alter_table('equipment_item', schema=None) as batch_op: - batch_op.add_column(sa.Column('status', sa.Enum('AVAILABLE', 'UNAVAILABLE', name='equipment_item_status_enum', create_type=False), server_default='AVAILABLE', nullable=False)) - batch_op.add_column(sa.Column('unavailability_reason', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('unavailability_since', sa.DateTime(timezone=True), nullable=True)) - - with op.batch_alter_table('event', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_event_archived')) - batch_op.drop_index(batch_op.f('ix_event_archived_status_start')) - batch_op.drop_index(batch_op.f('ix_event_master_event_id')) - batch_op.drop_index(batch_op.f('ix_event_start_datetime')) - batch_op.drop_index(batch_op.f('ix_event_status')) - - with op.batch_alter_table('event_spot', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_event_spot_event_id')) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('event_spot', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_event_spot_event_id'), ['event_id'], unique=False) - - with op.batch_alter_table('event', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_event_status'), ['status'], unique=False) - batch_op.create_index(batch_op.f('ix_event_start_datetime'), ['start_datetime'], unique=False) - batch_op.create_index(batch_op.f('ix_event_master_event_id'), ['master_event_id'], unique=False) - batch_op.create_index(batch_op.f('ix_event_archived_status_start'), ['archived', 'status', 'start_datetime'], unique=False) - batch_op.create_index(batch_op.f('ix_event_archived'), ['archived'], unique=False) - - with op.batch_alter_table('equipment_item', schema=None) as batch_op: - batch_op.drop_column('unavailability_since') - batch_op.drop_column('unavailability_reason') - batch_op.drop_column('status') - - op.execute("DROP TYPE IF EXISTS equipment_item_status_enum") - - with op.batch_alter_table('assignment', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_assignment_user_id'), ['user_id'], unique=False) - - # ### end Alembic commands ### diff --git a/migrations/versions/bbd2db07fc29_add_user_feedback_table.py b/migrations/versions/bbd2db07fc29_add_user_feedback_table.py deleted file mode 100644 index 8952edf3..00000000 --- a/migrations/versions/bbd2db07fc29_add_user_feedback_table.py +++ /dev/null @@ -1,44 +0,0 @@ -"""add user_feedback table - -Revision ID: bbd2db07fc29 -Revises: 2b096852ef02 -Create Date: 2026-05-08 14:24:55.674563 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'bbd2db07fc29' -down_revision = '2b096852ef02' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('user_feedback', - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('user_id', sa.Uuid(), nullable=True), - sa.Column('message', sa.Text(), nullable=False), - sa.Column('page_url', sa.String(length=2048), nullable=True), - sa.Column('user_agent', sa.Text(), nullable=True), - sa.Column('screen_info', sa.String(length=255), nullable=True), - sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], ondelete='SET NULL'), - sa.PrimaryKeyConstraint('id') - ) - with op.batch_alter_table('user_feedback', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_user_feedback_user_id'), ['user_id'], unique=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_feedback', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_user_feedback_user_id')) - - op.drop_table('user_feedback') - # ### end Alembic commands ### diff --git a/migrations/versions/bbd3aa765181_add_is_archived_to_user_account.py b/migrations/versions/bbd3aa765181_add_is_archived_to_user_account.py deleted file mode 100644 index 3b12f446..00000000 --- a/migrations/versions/bbd3aa765181_add_is_archived_to_user_account.py +++ /dev/null @@ -1,26 +0,0 @@ -"""add is_archived to user_account - -Revision ID: bbd3aa765181 -Revises: c11afc34311c -Create Date: 2026-05-11 08:52:23.299286 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'bbd3aa765181' -down_revision = 'c11afc34311c' -branch_labels = None -depends_on = None - - -def upgrade(): - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.add_column(sa.Column('is_archived', sa.Boolean(), server_default='false', nullable=False)) - - -def downgrade(): - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.drop_column('is_archived') diff --git a/migrations/versions/c11afc34311c_add_event_type_enum_planned_.py b/migrations/versions/c11afc34311c_add_event_type_enum_planned_.py deleted file mode 100644 index b34bf9b4..00000000 --- a/migrations/versions/c11afc34311c_add_event_type_enum_planned_.py +++ /dev/null @@ -1,61 +0,0 @@ -"""add event_type enum, planned_participants_count, rename patients_count to post_event_count - -Revision ID: c11afc34311c -Revises: c384ea97aef5 -Create Date: 2026-05-11 08:07:43.665218 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'c11afc34311c' -down_revision = 'c384ea97aef5' -branch_labels = None -depends_on = None - - -def upgrade(): - # Create the shared enum type first (used by both event and event_template) - event_type_enum = sa.Enum('MEDICAL_COVER', 'TRAINING', 'PRESENTATION', name='event_type_enum') - event_type_enum.create(op.get_bind(), checkfirst=True) - - with op.batch_alter_table('event', schema=None) as batch_op: - # Rename patients_count → post_event_count to preserve existing data - batch_op.alter_column('patients_count', new_column_name='post_event_count') - batch_op.add_column(sa.Column('planned_participants_count', sa.Integer(), nullable=True)) - # Add event_type with server default so all existing rows get MEDICAL_COVER - batch_op.add_column(sa.Column( - 'event_type', - sa.Enum('MEDICAL_COVER', 'TRAINING', 'PRESENTATION', name='event_type_enum'), - nullable=False, - server_default='MEDICAL_COVER', - )) - - # Remove the server default now that all existing rows have been populated - with op.batch_alter_table('event', schema=None) as batch_op: - batch_op.alter_column('event_type', server_default=None) - - with op.batch_alter_table('event_template', schema=None) as batch_op: - batch_op.add_column(sa.Column( - 'event_type', - sa.Enum('MEDICAL_COVER', 'TRAINING', 'PRESENTATION', name='event_type_enum'), - nullable=False, - server_default='MEDICAL_COVER', - )) - - with op.batch_alter_table('event_template', schema=None) as batch_op: - batch_op.alter_column('event_type', server_default=None) - - -def downgrade(): - with op.batch_alter_table('event_template', schema=None) as batch_op: - batch_op.drop_column('event_type') - - with op.batch_alter_table('event', schema=None) as batch_op: - batch_op.drop_column('event_type') - batch_op.drop_column('planned_participants_count') - batch_op.alter_column('post_event_count', new_column_name='patients_count') - - sa.Enum(name='event_type_enum').drop(op.get_bind(), checkfirst=True) diff --git a/migrations/versions/c384ea97aef5_add_notify_event_changed_to_app_settings.py b/migrations/versions/c384ea97aef5_add_notify_event_changed_to_app_settings.py deleted file mode 100644 index 72c9513e..00000000 --- a/migrations/versions/c384ea97aef5_add_notify_event_changed_to_app_settings.py +++ /dev/null @@ -1,26 +0,0 @@ -"""add notify_event_changed to app_settings - -Revision ID: c384ea97aef5 -Revises: 9d1ee14eb241 -Create Date: 2026-05-10 23:40:53.948590 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'c384ea97aef5' -down_revision = '9d1ee14eb241' -branch_labels = None -depends_on = None - - -def upgrade(): - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.add_column(sa.Column('notify_event_changed', sa.Boolean(), server_default='true', nullable=False)) - - -def downgrade(): - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.drop_column('notify_event_changed') diff --git a/migrations/versions/c7d8e9f0a1b2_add_login_lockout_fields_to_user_account.py b/migrations/versions/c7d8e9f0a1b2_add_login_lockout_fields_to_user_account.py deleted file mode 100644 index 31f6d407..00000000 --- a/migrations/versions/c7d8e9f0a1b2_add_login_lockout_fields_to_user_account.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Add login lockout fields to user_account. - -Revision ID: c7d8e9f0a1b2 -Revises: 1613fcb025fb -Create Date: 2026-05-10 15:00:00.000000 - -""" - -import sqlalchemy as sa -from alembic import op - -revision = "c7d8e9f0a1b2" -down_revision = "1613fcb025fb" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.add_column( - "user_account", - sa.Column( - "failed_login_attempts", - sa.Integer(), - nullable=False, - server_default="0", - ), - ) - op.add_column( - "user_account", - sa.Column( - "login_locked_until", - sa.DateTime(timezone=True), - nullable=True, - ), - ) - - -def downgrade() -> None: - op.drop_column("user_account", "login_locked_until") - op.drop_column("user_account", "failed_login_attempts") diff --git a/migrations/versions/ca37e9989a8a_split_notify_event_lifecycle_into_.py b/migrations/versions/ca37e9989a8a_split_notify_event_lifecycle_into_.py deleted file mode 100644 index ef23e91f..00000000 --- a/migrations/versions/ca37e9989a8a_split_notify_event_lifecycle_into_.py +++ /dev/null @@ -1,30 +0,0 @@ -"""split notify_event_lifecycle into notify_event_published and notify_assignments_opened - -Revision ID: ca37e9989a8a -Revises: 9b422409dc10 -Create Date: 2026-05-13 08:36:58.895145 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'ca37e9989a8a' -down_revision = '9b422409dc10' -branch_labels = None -depends_on = None - - -def upgrade(): - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.add_column(sa.Column('notify_event_published', sa.Boolean(), server_default='true', nullable=False)) - batch_op.add_column(sa.Column('notify_assignments_opened', sa.Boolean(), server_default='true', nullable=False)) - batch_op.drop_column('notify_event_lifecycle') - - -def downgrade(): - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.add_column(sa.Column('notify_event_lifecycle', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=False)) - batch_op.drop_column('notify_assignments_opened') - batch_op.drop_column('notify_event_published') diff --git a/migrations/versions/d1e2f3a4b5c6_merge_indexes_and_login_lockout_heads.py b/migrations/versions/d1e2f3a4b5c6_merge_indexes_and_login_lockout_heads.py deleted file mode 100644 index 48cdf023..00000000 --- a/migrations/versions/d1e2f3a4b5c6_merge_indexes_and_login_lockout_heads.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Merge indexes and login-lockout migration heads. - -Revision ID: d1e2f3a4b5c6 -Revises: b1c2d3e4f5a6, c7d8e9f0a1b2 -Create Date: 2026-05-10 15:32:00.000000 - -""" -from alembic import op - -# revision identifiers, used by Alembic. -revision = "d1e2f3a4b5c6" -down_revision = ("b1c2d3e4f5a6", "c7d8e9f0a1b2") -branch_labels = None -depends_on = None - - -def upgrade() -> None: - pass - - -def downgrade() -> None: - pass diff --git a/migrations/versions/d37d41a4e38d_add_all_domain_models.py b/migrations/versions/d37d41a4e38d_add_all_domain_models.py deleted file mode 100644 index f95ce4fa..00000000 --- a/migrations/versions/d37d41a4e38d_add_all_domain_models.py +++ /dev/null @@ -1,197 +0,0 @@ -"""add all domain models - -Revision ID: d37d41a4e38d -Revises: 7fd90ec4167e -Create Date: 2026-05-07 00:23:14.356512 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'd37d41a4e38d' -down_revision = '7fd90ec4167e' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('credential', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=128), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name') - ) - op.create_table('event_template', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('paid', sa.Boolean(), nullable=False), - sa.Column('reminder_schedule', sa.String(length=255), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name') - ) - op.create_table('audit_log_entry', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False), - sa.Column('actor_id', sa.Uuid(), nullable=True), - sa.Column('action_type', sa.String(length=32), nullable=False), - sa.Column('entity_type', sa.String(length=64), nullable=False), - sa.Column('entity_id', sa.String(length=64), nullable=False), - sa.Column('summary', sa.Text(), nullable=False), - sa.Column('changes_json', sa.JSON(), nullable=True), - sa.ForeignKeyConstraint(['actor_id'], ['user_account.id'], ), - sa.PrimaryKeyConstraint('id') - ) - with op.batch_alter_table('audit_log_entry', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_audit_log_entry_timestamp'), ['timestamp'], unique=False) - - op.create_table('credential_parents', - sa.Column('credential_id', sa.Integer(), nullable=False), - sa.Column('parent_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['credential_id'], ['credential.id'], ), - sa.ForeignKeyConstraint(['parent_id'], ['credential.id'], ), - sa.PrimaryKeyConstraint('credential_id', 'parent_id') - ) - op.create_table('event_spot_template', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('template_id', sa.Integer(), nullable=False), - sa.Column('description', sa.String(length=255), nullable=True), - sa.ForeignKeyConstraint(['template_id'], ['event_template.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('master_event', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('coordinator_id', sa.Uuid(), nullable=True), - sa.Column('is_general', sa.Boolean(), nullable=False), - sa.Column('archived', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['coordinator_id'], ['user_account.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name') - ) - op.create_table('registration_invite', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('token', sa.String(length=64), nullable=False), - sa.Column('email', sa.String(length=255), nullable=False), - sa.Column('created_by_id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(['created_by_id'], ['user_account.id'], ), - sa.PrimaryKeyConstraint('id') - ) - with op.batch_alter_table('registration_invite', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_registration_invite_token'), ['token'], unique=True) - - op.create_table('user_credentials', - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('credential_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['credential_id'], ['credential.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], ), - sa.PrimaryKeyConstraint('user_id', 'credential_id') - ) - op.create_table('event', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('master_event_id', sa.Integer(), nullable=False), - sa.Column('status', sa.Enum('DRAFT', 'PUBLISHED', 'ASSIGNMENTS_OPEN', 'ASSIGNMENTS_CLOSED', 'COMPLETED', 'CANCELLED', name='event_status_enum'), nullable=False), - sa.Column('archived', sa.Boolean(), nullable=False), - sa.Column('start_datetime', sa.DateTime(timezone=True), nullable=False), - sa.Column('end_datetime', sa.DateTime(timezone=True), nullable=False), - sa.Column('assignments_open_datetime', sa.DateTime(timezone=True), nullable=True), - sa.Column('address', sa.String(length=500), nullable=True), - sa.Column('contact_person', sa.String(length=255), nullable=True), - sa.Column('paid', sa.Boolean(), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('responsible_person_id', sa.Uuid(), nullable=True), - sa.Column('created_by_id', sa.Uuid(), nullable=True), - sa.Column('reminder_schedule', sa.String(length=255), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['created_by_id'], ['user_account.id'], ), - sa.ForeignKeyConstraint(['master_event_id'], ['master_event.id'], ), - sa.ForeignKeyConstraint(['responsible_person_id'], ['user_account.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('spot_template_credentials', - sa.Column('spot_template_id', sa.Integer(), nullable=False), - sa.Column('credential_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['credential_id'], ['credential.id'], ), - sa.ForeignKeyConstraint(['spot_template_id'], ['event_spot_template.id'], ), - sa.PrimaryKeyConstraint('spot_template_id', 'credential_id') - ) - op.create_table('event_spot', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('event_id', sa.Integer(), nullable=False), - sa.Column('description', sa.String(length=255), nullable=True), - sa.ForeignKeyConstraint(['event_id'], ['event.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('assignment', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('spot_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('assigned_by_id', sa.Uuid(), nullable=True), - sa.Column('assigned_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['assigned_by_id'], ['user_account.id'], ), - sa.ForeignKeyConstraint(['spot_id'], ['event_spot.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('spot_id') - ) - op.create_table('spot_credentials', - sa.Column('spot_id', sa.Integer(), nullable=False), - sa.Column('credential_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['credential_id'], ['credential.id'], ), - sa.ForeignKeyConstraint(['spot_id'], ['event_spot.id'], ), - sa.PrimaryKeyConstraint('spot_id', 'credential_id') - ) - op.create_table('debriefing_record', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('assignment_id', sa.Integer(), nullable=False), - sa.Column('submitted_by_id', sa.Uuid(), nullable=False), - sa.Column('actual_hours', sa.Numeric(precision=5, scale=2), nullable=False), - sa.Column('patients_treated', sa.Integer(), nullable=False), - sa.Column('materials_used', sa.Text(), nullable=True), - sa.Column('feedback', sa.Text(), nullable=True), - sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['assignment_id'], ['assignment.id'], ), - sa.ForeignKeyConstraint(['submitted_by_id'], ['user_account.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('assignment_id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('debriefing_record') - op.drop_table('spot_credentials') - op.drop_table('assignment') - op.drop_table('event_spot') - op.drop_table('spot_template_credentials') - op.drop_table('event') - op.drop_table('user_credentials') - with op.batch_alter_table('registration_invite', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_registration_invite_token')) - - op.drop_table('registration_invite') - op.drop_table('master_event') - op.drop_table('event_spot_template') - op.drop_table('credential_parents') - with op.batch_alter_table('audit_log_entry', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_audit_log_entry_timestamp')) - - op.drop_table('audit_log_entry') - op.drop_table('event_template') - op.drop_table('credential') - # ### end Alembic commands ### diff --git a/migrations/versions/d697cc60c5d2_add_ical_all_token.py b/migrations/versions/d697cc60c5d2_add_ical_all_token.py deleted file mode 100644 index f9154dc3..00000000 --- a/migrations/versions/d697cc60c5d2_add_ical_all_token.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Add ical_all_token to UserAccount. - -Revision ID: d697cc60c5d2 -Revises: ebe3ddc11f1e -Create Date: 2026-05-30 -""" -import sqlalchemy as sa -from alembic import op - -revision = 'd697cc60c5d2' -down_revision = 'ebe3ddc11f1e' -branch_labels = None -depends_on = None - - -def upgrade(): - op.add_column('user_account', sa.Column('ical_all_token', sa.String(64), nullable=True)) - op.create_index('ix_user_account_ical_all_token', 'user_account', ['ical_all_token'], unique=True) - - -def downgrade(): - op.drop_index('ix_user_account_ical_all_token', table_name='user_account') - op.drop_column('user_account', 'ical_all_token') diff --git a/migrations/versions/d946eda56491_add_feedback_version_and_enabled_toggle.py b/migrations/versions/d946eda56491_add_feedback_version_and_enabled_toggle.py deleted file mode 100644 index 0125af55..00000000 --- a/migrations/versions/d946eda56491_add_feedback_version_and_enabled_toggle.py +++ /dev/null @@ -1,38 +0,0 @@ -"""add_feedback_version_and_enabled_toggle - -Revision ID: d946eda56491 -Revises: 9fd3c08b3d50 -Create Date: 2026-05-08 15:23:06.495967 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'd946eda56491' -down_revision = '9fd3c08b3d50' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.add_column(sa.Column('feedback_enabled', sa.Boolean(), nullable=False, server_default=sa.true())) - - with op.batch_alter_table('user_feedback', schema=None) as batch_op: - batch_op.add_column(sa.Column('app_version', sa.String(length=64), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_feedback', schema=None) as batch_op: - batch_op.drop_column('app_version') - - with op.batch_alter_table('app_settings', schema=None) as batch_op: - batch_op.drop_column('feedback_enabled') - - # ### end Alembic commands ### diff --git a/migrations/versions/dfc13fca938f_add_app_settings_table.py b/migrations/versions/dfc13fca938f_add_app_settings_table.py deleted file mode 100644 index 07e46051..00000000 --- a/migrations/versions/dfc13fca938f_add_app_settings_table.py +++ /dev/null @@ -1,38 +0,0 @@ -"""add app_settings table - -Revision ID: dfc13fca938f -Revises: d37d41a4e38d -Create Date: 2026-05-07 00:34:52.114203 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'dfc13fca938f' -down_revision = 'd37d41a4e38d' -branch_labels = None -depends_on = None - - -def upgrade(): - op.create_table( - 'app_settings', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('org_name', sa.String(length=255), nullable=True), - sa.Column('timezone', sa.String(length=64), nullable=False), - sa.Column('smtp_server', sa.String(length=255), nullable=True), - sa.Column('smtp_port', sa.Integer(), nullable=False), - sa.Column('smtp_use_tls', sa.Boolean(), nullable=False), - sa.Column('smtp_username', sa.String(length=255), nullable=True), - sa.Column('smtp_password_enc', sa.Text(), nullable=True), - sa.Column('smtp_default_sender', sa.String(length=255), nullable=True), - sa.Column('setup_complete', sa.Boolean(), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.PrimaryKeyConstraint('id'), - ) - - -def downgrade(): - op.drop_table('app_settings') diff --git a/migrations/versions/ebe3ddc11f1e_optional_spots.py b/migrations/versions/ebe3ddc11f1e_optional_spots.py deleted file mode 100644 index 80ce010e..00000000 --- a/migrations/versions/ebe3ddc11f1e_optional_spots.py +++ /dev/null @@ -1,35 +0,0 @@ -"""optional_spots - -Revision ID: ebe3ddc11f1e -Revises: 6afec7c90ac1 -Create Date: 2026-05-07 23:52:52.839155 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'ebe3ddc11f1e' -down_revision = '6afec7c90ac1' -branch_labels = None -depends_on = None - - -def upgrade(): - with op.batch_alter_table('event_spot', schema=None) as batch_op: - batch_op.add_column(sa.Column('is_optional', sa.Boolean(), nullable=False, server_default=sa.false())) - - with op.batch_alter_table('event_spot_template', schema=None) as batch_op: - batch_op.add_column(sa.Column('is_optional', sa.Boolean(), nullable=False, server_default=sa.false())) - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('event_spot_template', schema=None) as batch_op: - batch_op.drop_column('is_optional') - - with op.batch_alter_table('event_spot', schema=None) as batch_op: - batch_op.drop_column('is_optional') - - # ### end Alembic commands ### diff --git a/migrations/versions/f0c168424643_add_digest_tables_and_outbox_html_body.py b/migrations/versions/f0c168424643_add_digest_tables_and_outbox_html_body.py deleted file mode 100644 index 0bc654b1..00000000 --- a/migrations/versions/f0c168424643_add_digest_tables_and_outbox_html_body.py +++ /dev/null @@ -1,79 +0,0 @@ -"""add_digest_tables_and_outbox_html_body - -Revision ID: f0c168424643 -Revises: 1a19cb432044 -Create Date: 2026-05-08 17:34:51.229603 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'f0c168424643' -down_revision = '1a19cb432044' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('digest_metric_snapshot', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('snapshot_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('metric_name', sa.String(length=64), nullable=False), - sa.Column('metric_value', sa.Float(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - with op.batch_alter_table('digest_metric_snapshot', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_digest_metric_snapshot_metric_name'), ['metric_name'], unique=False) - batch_op.create_index(batch_op.f('ix_digest_metric_snapshot_snapshot_at'), ['snapshot_at'], unique=False) - - op.create_table('digest_schedule', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('enabled', sa.Boolean(), server_default='false', nullable=False), - sa.Column('frequency_hours', sa.Integer(), server_default='24', nullable=False), - sa.Column('preferred_hour_utc', sa.Integer(), server_default='7', nullable=False), - sa.Column('last_sent_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('email_subject', sa.String(length=255), server_default='MedCover — Přehledový e-mail', nullable=False), - sa.Column('header_html', sa.Text(), nullable=True), - sa.Column('footer_html', sa.Text(), nullable=True), - sa.Column('version', sa.Integer(), server_default='0', nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('digest_block', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('digest_schedule_id', sa.Integer(), nullable=False), - sa.Column('block_type', sa.String(length=64), nullable=False), - sa.Column('enabled', sa.Boolean(), server_default='true', nullable=False), - sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False), - sa.Column('config_json', sa.JSON(), server_default='{}', nullable=False), - sa.Column('version', sa.Integer(), server_default='0', nullable=False), - sa.ForeignKeyConstraint(['digest_schedule_id'], ['digest_schedule.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - with op.batch_alter_table('digest_block', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_digest_block_digest_schedule_id'), ['digest_schedule_id'], unique=False) - - with op.batch_alter_table('outbox_email', schema=None) as batch_op: - batch_op.add_column(sa.Column('html_body', sa.Text(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('outbox_email', schema=None) as batch_op: - batch_op.drop_column('html_body') - - with op.batch_alter_table('digest_block', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_digest_block_digest_schedule_id')) - - op.drop_table('digest_block') - op.drop_table('digest_schedule') - with op.batch_alter_table('digest_metric_snapshot', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_digest_metric_snapshot_snapshot_at')) - batch_op.drop_index(batch_op.f('ix_digest_metric_snapshot_metric_name')) - - op.drop_table('digest_metric_snapshot') - # ### end Alembic commands ### diff --git a/migrations/versions/f8edde653722_add_version_columns_for_optimistic_.py b/migrations/versions/f8edde653722_add_version_columns_for_optimistic_.py deleted file mode 100644 index 2240fb98..00000000 --- a/migrations/versions/f8edde653722_add_version_columns_for_optimistic_.py +++ /dev/null @@ -1,50 +0,0 @@ -"""add version columns for optimistic locking - -Revision ID: f8edde653722 -Revises: dfc13fca938f -Create Date: 2026-05-07 06:36:33.891891 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = 'f8edde653722' -down_revision = 'dfc13fca938f' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('event', schema=None) as batch_op: - batch_op.add_column(sa.Column('version', sa.Integer(), nullable=False, server_default='1')) - - with op.batch_alter_table('event_spot', schema=None) as batch_op: - batch_op.add_column(sa.Column('version', sa.Integer(), nullable=False, server_default='1')) - - with op.batch_alter_table('master_event', schema=None) as batch_op: - batch_op.add_column(sa.Column('version', sa.Integer(), nullable=False, server_default='1')) - - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.add_column(sa.Column('version', sa.Integer(), nullable=False, server_default='1')) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('user_account', schema=None) as batch_op: - batch_op.drop_column('version') - - with op.batch_alter_table('master_event', schema=None) as batch_op: - batch_op.drop_column('version') - - with op.batch_alter_table('event_spot', schema=None) as batch_op: - batch_op.drop_column('version') - - with op.batch_alter_table('event', schema=None) as batch_op: - batch_op.drop_column('version') - - # ### end Alembic commands ### diff --git a/migrations/versions/merge_heads_ical_all.py b/migrations/versions/merge_heads_ical_all.py deleted file mode 100644 index d56dcf87..00000000 --- a/migrations/versions/merge_heads_ical_all.py +++ /dev/null @@ -1,24 +0,0 @@ -"""merge heads: ical_all_token + equipment item status - -Revision ID: a1f2e3d4c5b6 -Revises: b801953d30cb, d697cc60c5d2 -Create Date: 2026-05-30 10:00:00.000000 - -""" -import sqlalchemy as sa # noqa: F401 -from alembic import op # noqa: F401 - - -# revision identifiers, used by Alembic. -revision = 'a1f2e3d4c5b6' -down_revision = ('b801953d30cb', 'd697cc60c5d2') -branch_labels = None -depends_on = None - - -def upgrade(): - pass - - -def downgrade(): - pass From d040c485837bd078b7153018d94470db465c04a4 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Fri, 19 Jun 2026 15:35:15 +0200 Subject: [PATCH 22/31] feat(dev): auto-run MSSQL init via one-shot db-init service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MSSQL container has no auto-init directory (unlike Postgres' docker-entrypoint-initdb.d), so the previous setup required a manual `docker compose exec` step after first startup — `docker compose up` alone left the app without its database/login. The old `./mssql-init:/docker- entrypoint-initdb.d` mount was misleading: that path does nothing in the MSSQL image. Add a dedicated one-shot `db-init` service that waits for the db to be healthy, runs setup.sh (create medcover_dev with Czech collation + RCSI, create the app login/user), then exits. `web` now depends on it via service_completed_successfully, so `docker compose up` provisions everything automatically. setup.sh is parameterized (MSSQL_HOST / MSSQL_SA_PASSWORD / SQLCMD) and remains idempotent. Verified: `docker compose up` brings up db -> db-init (runs & exits 0) -> web (healthy) with no manual intervention. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X1TSmeNk897sPHRWD6ny4G --- docker-compose.yml | 21 ++++++++++++++++++++- mssql-init/setup.sh | 30 ++++++++++++++++++------------ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 03a4749c..c715c583 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,8 @@ services: depends_on: db: condition: service_healthy + db-init: + condition: service_completed_successfully logging: driver: "json-file" options: @@ -77,7 +79,6 @@ services: - "1433:1433" volumes: - mssql_data:/var/opt/mssql - - ./mssql-init:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "DevPassword123!", "-C", "-Q", "SELECT 1", "-b"] interval: 10s @@ -85,5 +86,23 @@ services: retries: 10 start_period: 30s + # One-shot initializer: the MSSQL image has no auto-init directory, so this + # creates the medcover_dev database (Czech collation + RCSI) and the app + # login/user once the db container is healthy, then exits. Runs automatically + # on `docker compose up`; idempotent, so re-runs are safe. + db-init: + image: mcr.microsoft.com/mssql/server:2022-latest + platform: linux/amd64 + depends_on: + db: + condition: service_healthy + environment: + MSSQL_HOST: db + MSSQL_SA_PASSWORD: "DevPassword123!" + volumes: + - ./mssql-init:/mssql-init:ro + entrypoint: ["/bin/bash", "/mssql-init/setup.sh"] + restart: "no" + volumes: mssql_data: diff --git a/mssql-init/setup.sh b/mssql-init/setup.sh index dacf0453..b863ae86 100755 --- a/mssql-init/setup.sh +++ b/mssql-init/setup.sh @@ -2,23 +2,29 @@ # mssql-init/setup.sh # # Wait for MSSQL to become ready, then create the dev database with -# Czech collation and enable RCSI (Read Committed Snapshot Isolation). +# Czech collation and enable RCSI (Read Committed Snapshot Isolation), +# plus the application login/user. # -# This script is NOT run automatically by the MSSQL container (unlike -# PostgreSQL's docker-entrypoint-initdb.d). You need to run it manually -# after first startup: +# The MSSQL container has no auto-init directory (unlike PostgreSQL's +# docker-entrypoint-initdb.d), so this runs as the dedicated one-shot +# `db-init` service in docker-compose.yml — `docker compose up` invokes it +# automatically once the db container is healthy. No manual step required. # -# docker compose -f docker-compose.mssql.yml exec mssql /docker-entrypoint-initdb.d/setup.sh +# Configurable via environment: +# MSSQL_HOST target server (default: localhost) +# MSSQL_SA_PASSWORD sa password (default: DevPassword123!) +# SQLCMD sqlcmd path (default: /opt/mssql-tools18/bin/sqlcmd) set -e -SQLCMD="/opt/mssql-tools18/bin/sqlcmd" -SA_PASSWORD="DevPassword123!" +SQLCMD="${SQLCMD:-/opt/mssql-tools18/bin/sqlcmd}" +MSSQL_HOST="${MSSQL_HOST:-localhost}" +SA_PASSWORD="${MSSQL_SA_PASSWORD:-DevPassword123!}" -echo "Waiting for SQL Server to be ready..." +echo "Waiting for SQL Server at ${MSSQL_HOST} to be ready..." READY=false for i in $(seq 1 30); do - if $SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q "SELECT 1" &>/dev/null; then + if $SQLCMD -S "$MSSQL_HOST" -U sa -P "$SA_PASSWORD" -C -Q "SELECT 1" &>/dev/null; then echo "SQL Server is ready." READY=true break @@ -33,7 +39,7 @@ if [ "$READY" = "false" ]; then fi echo "Creating database medcover_dev..." -$SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q " +$SQLCMD -S "$MSSQL_HOST" -U sa -P "$SA_PASSWORD" -C -Q " IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'medcover_dev') BEGIN CREATE DATABASE medcover_dev @@ -48,7 +54,7 @@ END " echo "Creating login and user..." -$SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -Q " +$SQLCMD -S "$MSSQL_HOST" -U sa -P "$SA_PASSWORD" -C -Q " IF NOT EXISTS (SELECT name FROM sys.server_principals WHERE name = 'medcover') BEGIN CREATE LOGIN medcover WITH PASSWORD = 'Dev_Password1!'; @@ -58,7 +64,7 @@ ELSE PRINT 'Login medcover already exists.'; " -$SQLCMD -S localhost -U sa -P "$SA_PASSWORD" -C -d medcover_dev -Q " +$SQLCMD -S "$MSSQL_HOST" -U sa -P "$SA_PASSWORD" -C -d medcover_dev -Q " IF NOT EXISTS (SELECT name FROM sys.database_principals WHERE name = 'medcover') BEGIN CREATE USER medcover FOR LOGIN medcover; From a0b774807c628cccdec2de1dc380f38ffc36bbbe Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Fri, 19 Jun 2026 15:36:56 +0200 Subject: [PATCH 23/31] docs(devops): update for squashed baseline, auto-init, and host ODBC driver - Remove the obsolete "one-time MSSQL bootstrap" (stamp head + autogenerate); the single MSSQL baseline now applies via `flask db upgrade` on a fresh DB - Document the host ODBC Driver 18 + unixODBC prerequisite for running the test suite (pyodbc needs libodbc.so.2 at runtime; pip alone is insufficient) - Correct the "Run tests" section: the prod-lean image has no pytest/tox, so tests run on the host venv (as CI does), not via `docker compose exec web` - Reflect the new one-shot db-init service in the compose snippet Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X1TSmeNk897sPHRWD6ny4G --- DEVOPS.md | 85 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/DEVOPS.md b/DEVOPS.md index ce8f4605..d9489fde 100644 --- a/DEVOPS.md +++ b/DEVOPS.md @@ -181,26 +181,38 @@ docker compose exec web flask db upgrade ### Run tests -```bash -# Inside the running web container (day-to-day dev) -docker compose exec web pytest +Tests run on the **host** in a local Python 3.14 virtualenv. The application +image (`Dockerfile`) installs only `requirements.txt` (production), so the +running `web`/`scheduler` containers do **not** contain pytest/tox — running +the suite inside them does not work. CI follows the same host-based approach +(see `.github/workflows/ci.yml`). + +Because the only database driver is now `pyodbc`, the host needs the +**Microsoft ODBC Driver 18** and unixODBC installed once (pyodbc links against +`libodbc.so.2` at runtime — pip alone is not enough): -# Via tox (mirrors CI — same pinned deps) -docker compose exec web tox -e py314 +```bash +# Debian/Ubuntu — install the ODBC driver + unixODBC (one-time, needs sudo) +curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \ + | sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg +curl -fsSL https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list \ + | sudo tee /etc/apt/sources.list.d/mssql-release.list +sudo apt-get update +sudo ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 unixodbc-dev +# macOS: brew install msodbcsql18 unixodbc ``` -Or directly on the host with a local Python venv (`requirements-dev.txt` installed) -and `TEST_DATABASE_URL` pointing at a running MSSQL instance: +Then create a venv, install dev dependencies, and run the suite: ```bash -pip install -r requirements-dev.txt +python3.14 -m venv .venv && source .venv/bin/activate +pip install --require-hashes -r requirements-dev.txt -# Run directly — set TEST_DATABASE_URL to use an existing MSSQL DB, -# or let testcontainers auto-spin an MSSQL 2022 Express container if not set -pytest - -# Via tox — same behaviour -tox -e py314 +# Set TEST_DATABASE_URL to use an existing MSSQL DB (the dev db works fine — +# the suite uses an isolated medcover_test database), or leave it unset to let +# testcontainers auto-spin an MSSQL 2022 Express container. +export TEST_DATABASE_URL="mssql+pyodbc://SA:DevPassword123!@localhost:1433/medcover_test?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes" +pytest # or: tox -e py314 (mirrors CI) ``` ### Run E2E browser tests (Playwright) @@ -292,6 +304,8 @@ services: depends_on: db: condition: service_healthy + db-init: + condition: service_completed_successfully scheduler: build: @@ -320,7 +334,22 @@ services: - "1433:1433" volumes: - mssql_data:/var/opt/mssql - - ./mssql-init:/docker-entrypoint-initdb.d:ro + + # One-shot initializer — the MSSQL image has no auto-init directory, so this + # creates the dev database (Czech collation + RCSI) and the app login/user + # once the db is healthy, then exits. web waits for it to complete. + db-init: + image: mcr.microsoft.com/mssql/server:2022-latest + depends_on: + db: + condition: service_healthy + environment: + MSSQL_HOST: db + MSSQL_SA_PASSWORD: "DevPassword123!" + volumes: + - ./mssql-init:/mssql-init:ro + entrypoint: ["/bin/bash", "/mssql-init/setup.sh"] + restart: "no" volumes: mssql_data: @@ -420,23 +449,19 @@ See `azure-setup-guide.md` in the `medcover-infra` repo for the full Azure provi - **First-run setup wizard**: After the web service is live, navigate to the app URL. The wizard appears on first visit — configure the application name, admin account, and SMTP settings there. - **Production compose file**: `docker-compose.prod.yml` is available for self-hosted deployments (e.g. the zerver home-lab test server). -### ⚠️ One-time MSSQL bootstrap — required before first production deployment - -The migration history in `migrations/versions/` was written for PostgreSQL and cannot run on a fresh MSSQL database. Before starting the containers for the first time against a new Azure SQL database, you must bootstrap the schema manually: - -```bash -# 1. Set your Azure SQL DATABASE_URL (Managed Identity or SQL auth) -export DATABASE_URL="mssql+pyodbc://@medcover-sql.database.windows.net/MedCover?driver=ODBC+Driver+18+for+SQL+Server&Authentication=ActiveDirectoryMsi&Encrypt=yes" - -# 2. Run the one-time bootstrap (from the repo root, with the venv active) -flask db stamp head # mark all PG migrations as already applied -flask db migrate -m "mssql_prod_initial" # autogenerate a fresh MSSQL baseline migration -flask db upgrade # apply the new migration to create all tables -``` +### Database schema on first deploy -This only needs to run **once** per new database. After that, normal `flask db upgrade` (run automatically by the entrypoint on every deploy) handles future migrations correctly. +The migration history is a single MSSQL-native baseline +(`migrations/versions/*_mssql_baseline_schema.py`, `down_revision = None`), so +no manual bootstrap is needed. The web container's entrypoint runs +`flask db upgrade` on every start, which creates the full schema on a fresh +Azure SQL database and applies any later migrations on subsequent deploys. -The autogenerated migration file will appear in `migrations/versions/` — commit it as part of the deployment preparation. +> **Historical note:** earlier revisions carried 45 PostgreSQL-era migrations +> that could not run on MSSQL and required a manual `stamp head` + +> autogenerate bootstrap. Those were squashed into the single baseline in +> [PR #381](https://github.com/spidermila/MedCover/pull/381); the manual step +> is gone. ### Subsequent deployments From e8240c83b32a3623308bbb9f0c7ff7587d4dbd70 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Fri, 19 Jun 2026 16:48:17 +0200 Subject: [PATCH 24/31] fix(e2e): apply MSSQL baseline via plain upgrade, drop obsolete stamp dance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e entrypoint still ran the pre-squash bootstrap (`flask db stamp head` + `flask db migrate` + `flask db upgrade`), which broke after the migration squash: stamping/autogenerating against the single baseline failed with `KeyError: 'a1f2e3d4c5b6'` (a deleted PG-era revision), so the web-e2e container exited 1 and the E2E job failed. Replace the dance with a plain `flask db upgrade` — identical to the production entrypoint — which creates the full schema from the single MSSQL baseline on the fresh e2e database. Verified locally: `docker compose -f docker-compose.e2e.yml up --build` (chromium) applies `2dbbf9629c3f` cleanly, seeds, and passes 41/41 e2e tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X1TSmeNk897sPHRWD6ny4G --- scripts/e2e-entrypoint.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/e2e-entrypoint.sh b/scripts/e2e-entrypoint.sh index a9a9ca2d..c8f45bed 100755 --- a/scripts/e2e-entrypoint.sh +++ b/scripts/e2e-entrypoint.sh @@ -56,8 +56,10 @@ c2.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name='{u conn2.close() print(f" Database '{db}' ready.") PYEOF -flask db stamp head -flask db migrate -m "e2e_mssql_auto" +# Apply the single MSSQL baseline migration to the fresh database. (Previously +# this did a "stamp head + autogenerate" dance to work around PG-only migration +# history; that history has been squashed into one MSSQL baseline, so a plain +# upgrade — same as the production entrypoint — now creates the full schema.) flask db upgrade echo "=== E2E: Seeding test data ===" From 02805c14d8d8ed0f19122770db2e55c61ed4d052 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Fri, 19 Jun 2026 16:55:59 +0200 Subject: [PATCH 25/31] refactor(migrations): re-squash baseline after merging main (include color) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging main brought in migration e1f2a3b4c5d6 (add color to event), whose down_revision points into the PG chain this branch squashed away — so the PR merge ref had a dangling revision and `flask db upgrade` failed in CI with KeyError 'a1f2e3d4c5b6'. Local branch-only runs missed it because CI tests the branch merged with main. Regenerate the single MSSQL baseline from the merged models so it includes event.color, and drop the now-redundant e1f2a3b4c5d6. Validated: upgrade on an empty DB + re-run autogenerate = "No changes detected"; verify-schema confirms 30 tables. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01X1TSmeNk897sPHRWD6ny4G --- ... => 2c159bca01be_mssql_baseline_schema.py} | 7 +- .../e1f2a3b4c5d6_add_color_to_event.py | 65 ------------------- 2 files changed, 4 insertions(+), 68 deletions(-) rename migrations/versions/{2dbbf9629c3f_mssql_baseline_schema.py => 2c159bca01be_mssql_baseline_schema.py} (99%) delete mode 100644 migrations/versions/e1f2a3b4c5d6_add_color_to_event.py diff --git a/migrations/versions/2dbbf9629c3f_mssql_baseline_schema.py b/migrations/versions/2c159bca01be_mssql_baseline_schema.py similarity index 99% rename from migrations/versions/2dbbf9629c3f_mssql_baseline_schema.py rename to migrations/versions/2c159bca01be_mssql_baseline_schema.py index b3b31e31..1b5277dd 100644 --- a/migrations/versions/2dbbf9629c3f_mssql_baseline_schema.py +++ b/migrations/versions/2c159bca01be_mssql_baseline_schema.py @@ -1,8 +1,8 @@ """mssql baseline schema -Revision ID: 2dbbf9629c3f +Revision ID: 2c159bca01be Revises: -Create Date: 2026-06-19 15:27:36.374850 +Create Date: 2026-06-19 16:55:00.727357 """ from alembic import op @@ -10,7 +10,7 @@ # revision identifiers, used by Alembic. -revision = '2dbbf9629c3f' +revision = '2c159bca01be' down_revision = None branch_labels = None depends_on = None @@ -325,6 +325,7 @@ def upgrade(): sa.Column('contact_person', sa.String(length=255), nullable=True), sa.Column('paid', sa.Boolean(), nullable=False), sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=50), nullable=True), sa.Column('responsible_person_id', sa.Uuid(), nullable=True), sa.Column('created_by_id', sa.Uuid(), nullable=True), sa.Column('reminder_schedule', sa.String(length=255), nullable=True), diff --git a/migrations/versions/e1f2a3b4c5d6_add_color_to_event.py b/migrations/versions/e1f2a3b4c5d6_add_color_to_event.py deleted file mode 100644 index f86d2ed6..00000000 --- a/migrations/versions/e1f2a3b4c5d6_add_color_to_event.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Add color column to event and migrate existing color tags from description. - -Revision ID: e1f2a3b4c5d6 -Revises: a1f2e3d4c5b6 -Create Date: 2026-06-09 - -""" - -import re - -import sqlalchemy as sa -from alembic import op - -revision = 'e1f2a3b4c5d6' -down_revision = 'a1f2e3d4c5b6' -branch_labels = None -depends_on = None - -_TM_COLOR_RE = re.compile(r'\[color:(#[0-9A-Fa-f]{6})\]', re.IGNORECASE) - - -def upgrade() -> None: - op.add_column('event', sa.Column('color', sa.String(50), nullable=True)) - - conn = op.get_bind() - rows = conn.execute( - sa.text("SELECT id, description FROM event WHERE description LIKE '%[color:%'") - ).fetchall() - - for row in rows: - event_id, description = row[0], row[1] - if not description: - continue - m = _TM_COLOR_RE.search(description) - if not m: - continue - color = m.group(1).upper() - clean_description = _TM_COLOR_RE.sub('', description).strip() or None - conn.execute( - sa.text( - "UPDATE event SET color = :color, description = :description WHERE id = :id" - ), - {'color': color, 'description': clean_description, 'id': event_id}, - ) - - -def downgrade() -> None: - conn = op.get_bind() - rows = conn.execute( - sa.text("SELECT id, description, color FROM event WHERE color IS NOT NULL") - ).fetchall() - - for row in rows: - event_id, description, color = row[0], row[1], row[2] - tag = f'[color:{color.upper()}]' - if description: - new_description = f'{description} {tag}' - else: - new_description = tag - conn.execute( - sa.text("UPDATE event SET description = :description WHERE id = :id"), - {'description': new_description, 'id': event_id}, - ) - - op.drop_column('event', 'color') From f1d4a9a1295e908ecff20adfba0d64bd82053b74 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Sat, 20 Jun 2026 09:34:38 +0200 Subject: [PATCH 26/31] ci(migrations): guard against re-squashing the baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A re-squash rewrites the single MSSQL baseline's revision id (and can fold in new columns), stranding every durable DB: alembic_version points at a deleted revision and flask db upgrade aborts on deploy. This hit the zerver dev deploy of feat/mssql-support (the "include color" re-squash left dev DBs without event.color). The risk applies to prod too — an identical-schema re-squash still breaks it, since the revision id changes. Add scripts/check_migrations.py enforcing: one root, root id == frozen EXPECTED_BASELINE_REVISION, single head. Wire into CI lint job and pre-commit (on migrations/versions changes). Document the failure mode, prod exposure, and a Sanctioned re-baseline procedure (schema-neutral squash + re-stamp every durable DB) in DEVOPS.md. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 3 + .pre-commit-config.yaml | 9 +++ DEVOPS.md | 93 +++++++++++++++++++++++++++++ scripts/check_migrations.py | 115 ++++++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+) create mode 100644 scripts/check_migrations.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab8be664..1463cb99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: - name: Run pre-commit hooks run: pre-commit run --all-files + - name: Guard migration baseline (no re-squash) + run: python scripts/check_migrations.py + test: runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 134280e7..864aa5e3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,6 +17,15 @@ repos: - id: black exclude: ^migrations/ + - repo: local + hooks: + - id: check-migration-baseline + name: Guard migration baseline (no re-squash) + entry: python scripts/check_migrations.py + language: system + pass_filenames: false + files: ^migrations/versions/.*\.py$ + - repo: local hooks: - id: no-inline-event-handlers diff --git a/DEVOPS.md b/DEVOPS.md index d9489fde..e9ae949c 100644 --- a/DEVOPS.md +++ b/DEVOPS.md @@ -799,6 +799,99 @@ flask db downgrade Migrations run automatically on `web` container start via `docker-entrypoint.sh` (`flask db upgrade`). The scheduler container skips migrations and waits for web to be healthy. +### ⚠️ Do NOT re-squash the baseline once a DB exists + +The migration history is a single MSSQL baseline (`*_mssql_baseline_schema.py`, +`down_revision = None`). That squash was a **one-time bootstrap**. Once any +long-lived database (dev on zerver, staging, or prod) has been created from a +baseline, **never re-squash or rewrite that baseline** — always add **forward +migrations** for schema changes instead: + +```bash +flask db migrate -m "add color to event" # new revision, down_revision = current head +``` + +**Why:** re-squashing rewrites the baseline's revision id and folds new columns +into it. An existing DB then (1) has an `alembic_version` pointing at a revision +that no longer exists (`Can't locate revision identified by ''`), and +(2) is missing any columns the new baseline added — and because a single +baseline has no incremental step, `flask db upgrade` can never add them. The web +container fails its `flask verify-schema` health check and the deploy aborts. +This exact failure hit the zerver dev deploy of `feat/mssql-support` (the +re-squash that "included color" left dev DBs without `event.color`). + +**This is not a dev-only risk — production is more exposed.** The mechanism is +environment-independent: it triggers for *any* database stamped at the old +baseline, including staging and prod. In fact, a re-squash that produces a +schema **identical** to the old one still breaks prod, because the squash mints +a *new* revision id and the dangling-`alembic_version` failure (1) fires +regardless. Prod is the worst place for it to land: + +- The auto-run entrypoint (`flask db upgrade` → `flask verify-schema`) aborts on + deploy, so the new version never becomes healthy — a **failed/blocked + production release**, possibly mid-rollout. +- There is no disposable-volume escape hatch with real data: recovery is the + manual path only — stamp `alembic_version` to head, then hand-write + `ALTER TABLE`s to reconcile every divergence with the baseline, verifying with + `flask verify-schema`. Error-prone under release pressure. + +**CI / pre-commit guard.** `scripts/check_migrations.py` enforces this +mechanically and runs both in the CI `lint` job and as a pre-commit hook +(triggered when any `migrations/versions/*.py` changes). It fails the build if +the baseline (root) revision id ever changes from the frozen +`EXPECTED_BASELINE_REVISION`, if there is more than one root, or if history has +more than one head. The guard is a **forcing function, not an absolute ban** — +bumping `EXPECTED_BASELINE_REVISION` is allowed, but only as the documented step +4 of the procedure below, so a re-baseline can't merge by accident. + +### Sanctioned re-baseline (squash) procedure + +A re-squash *is* allowed when migration history has grown unwieldy — but it must +be done so that every durable DB survives it. The two rules that make it safe: + +- **It must be schema-neutral.** The new baseline must reproduce the *current* + head schema **exactly** — do not fold any new or changed columns into it. Any + real schema change ships separately as a normal forward migration, either + before or after the squash, never baked into it. (The `event.color` incident + happened because a schema change rode along inside the squash.) +- **Every durable DB must be re-stamped** to the new baseline id during the + deploy window — because the entrypoint's `flask db upgrade` reads the old, + now-deleted revision and aborts before anything else runs. + +Steps: + +1. **Snapshot the current schema** of a representative up-to-date DB (dev is + fine): `flask verify-schema` should pass on it first, so you know it matches + today's head. +2. **Squash** the history into a single new baseline whose `down_revision = None`. + Apply it to a **fresh, empty** DB and run `flask verify-schema` against it — + it must report the *same* objects as the snapshot in step 1. If anything + differs, the squash is not schema-neutral; fix it before proceeding. +3. **Record the new baseline revision id** (call it `NEW_ID`). +4. **Bump the guard:** set `EXPECTED_BASELINE_REVISION = ""` in + `scripts/check_migrations.py` in the *same commit* as the squash. CI now + passes; the diff is the audit trail. +5. **Re-stamp every durable DB** (dev on zerver, staging, prod) in the deploy + window, *before* the new app image starts its auto-`upgrade`. Since the old + code can't reach `NEW_ID`, do it with a plain SQL update against + `alembic_version`: + ```sql + UPDATE alembic_version SET version_num = ''; + ``` + (Equivalent to `flask db stamp ` once you can run the new code with + auto-upgrade disabled. Take a DB backup first for prod.) +6. **Deploy** the new image normally. `flask db upgrade` now finds `NEW_ID` as + head and is a no-op; `flask verify-schema` passes; the container goes healthy. +7. **Verify** each environment: container healthy + `flask verify-schema` OK. + +If step 2 ever shows a schema difference, stop — that is exactly the failure this +whole section exists to prevent. + +**Recovering a DB already stranded** by an *un*sanctioned past re-squash: stamp +`alembic_version` to the current head, then `ALTER TABLE` in the missing columns +to match the baseline (confirm with `flask verify-schema`) — or, **for dev only, +if the data is disposable**, drop the DB volume so the baseline applies fresh. + --- ## Security Notes diff --git a/scripts/check_migrations.py b/scripts/check_migrations.py new file mode 100644 index 00000000..59994a86 --- /dev/null +++ b/scripts/check_migrations.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Guard against rewriting the Alembic migration baseline. + +MedCover ships a single squashed MSSQL baseline. That squash was a **one-time** +bootstrap performed before any durable database existed. Re-squashing — or +otherwise rewriting the baseline — after a long-lived DB (dev on zerver, +staging, prod) has been created from it is a breaking change: the DB's stored +``alembic_version`` then names a revision that no longer exists, ``flask db +upgrade`` aborts, and the ``web`` container fails its ``flask verify-schema`` +health check, blocking the deploy. See DEVOPS.md → "Database Migrations". + +This script enforces the invariants that make that mistake impossible to merge: + + 1. Exactly one root revision (``down_revision is None``). + 2. The root revision id equals the frozen baseline id below — so rewriting or + re-squashing the baseline (which mints a new id) fails CI. + 3. Exactly one head — no accidentally divergent/unmerged branches. + +Re-squashing is *not* forbidden outright — when you genuinely need it, follow the +**Sanctioned re-baseline procedure** in DEVOPS.md (squash schema-neutrally, +re-stamp every durable DB in the deploy window, then bump +``EXPECTED_BASELINE_REVISION`` below in the same commit). Editing that constant is +step 4 of that procedure, not a shortcut around it. + +Run: ``python scripts/check_migrations.py`` (also wired into CI and pre-commit). +""" + +import ast +import pathlib +import sys + +# The frozen root of the migration graph. Only change this as step 4 of the +# Sanctioned re-baseline procedure in DEVOPS.md (squash schema-neutrally + re-stamp +# every durable DB), never on its own. +EXPECTED_BASELINE_REVISION = "2c159bca01be" + +VERSIONS_DIR = pathlib.Path(__file__).resolve().parent.parent / "migrations" / "versions" + + +def _module_assignment(tree: ast.Module, name: str) -> object: + """Return the value of a module-level ``name = `` assignment.""" + for node in tree.body: + if isinstance(node, ast.Assign): + targets = [t.id for t in node.targets if isinstance(t, ast.Name)] + if name in targets: + return ast.literal_eval(node.value) + raise KeyError(name) + + +def _collect_revisions() -> dict[str, object]: + """Map each migration's ``revision`` id to its ``down_revision``.""" + revisions: dict[str, object] = {} + for path in sorted(VERSIONS_DIR.glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + try: + revision = _module_assignment(tree, "revision") + down_revision = _module_assignment(tree, "down_revision") + except (KeyError, ValueError) as exc: + sys.exit(f"ERROR: {path.name}: could not parse revision metadata ({exc}).") + revisions[str(revision)] = down_revision + return revisions + + +def main() -> int: + if not VERSIONS_DIR.is_dir(): + sys.exit(f"ERROR: migrations versions dir not found: {VERSIONS_DIR}") + + revisions = _collect_revisions() + if not revisions: + sys.exit("ERROR: no migration files found under migrations/versions/.") + + errors: list[str] = [] + + # 1 + 2: exactly one root, and it is the frozen baseline. + roots = [rev for rev, down in revisions.items() if down is None] + if len(roots) != 1: + errors.append( + f"expected exactly one root migration (down_revision = None), found {len(roots)}: " + f"{sorted(roots)}. Re-squashing or splitting the baseline is forbidden once a " + f"durable DB exists — add a forward migration instead." + ) + elif roots[0] != EXPECTED_BASELINE_REVISION: + errors.append( + f"baseline revision changed: found root '{roots[0]}', expected " + f"'{EXPECTED_BASELINE_REVISION}'. Rewriting/re-squashing the baseline strands every " + f"existing database (dangling alembic_version + missing columns). If this re-squash is " + f"intentional, follow the Sanctioned re-baseline procedure in DEVOPS.md (schema-neutral " + f"squash + re-stamp every durable DB), of which bumping EXPECTED_BASELINE_REVISION is " + f"step 4." + ) + + # 3: exactly one head (no divergent branches). + referenced = {d for down in revisions.values() for d in (down if isinstance(down, (list, tuple)) else [down]) if d} + heads = [rev for rev in revisions if rev not in referenced] + if len(heads) != 1: + errors.append( + f"expected exactly one head, found {len(heads)}: {sorted(heads)}. " + f"Merge the divergent branches (flask db merge) so history stays linear." + ) + + if errors: + print("Migration baseline guard FAILED:", file=sys.stderr) + for err in errors: + print(f" ✘ {err}", file=sys.stderr) + return 1 + + print( + f"Migration baseline guard OK — {len(revisions)} revision(s), " + f"baseline '{EXPECTED_BASELINE_REVISION}', single head." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9bcbcf67100f66763e36d4719b39f9115699bd2c Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Mon, 22 Jun 2026 09:36:40 +0200 Subject: [PATCH 27/31] docs(changelog): note migration baseline guard Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b5f1c6..7ba85d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Migration baseline guard (`scripts/check_migrations.py`, wired into CI and pre-commit): fails the build if the squashed Alembic baseline is re-squashed/rewritten (changed root revision id), or if history has multiple roots or heads. Prevents stranding existing databases on deploy. A sanctioned re-baseline procedure is documented in DEVOPS.md. + ### Changed - Database engine switched from PostgreSQL to Microsoft SQL Server (MSSQL 2022 / Azure SQL). PostgreSQL is no longer supported. - `docker-compose.yml` now uses the MSSQL 2022 Express container instead of PostgreSQL. From 8778fb5f019f5abaf5a1d822357ac5a2e52d00a9 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Mon, 22 Jun 2026 10:02:10 +0200 Subject: [PATCH 28/31] Pre-create all xdist worker DBs with RCSI enabled before pytest runs --- .github/workflows/ci.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1463cb99..8c6076d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,10 +79,16 @@ jobs: except Exception: time.sleep(2) c = conn.cursor() - c.execute("IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name='medcover_test') CREATE DATABASE medcover_test COLLATE Czech_100_CI_AS_SC_UTF8") - c.execute("ALTER DATABASE medcover_test SET READ_COMMITTED_SNAPSHOT ON") + # Create base DB + all 4 xdist worker DBs up-front, all with RCSI enabled. + # Pre-creating them here (as SA) is more reliable than creating them + # on-the-fly from Python test code under parallel xdist load. + for db_name in ["medcover_test", "medcover_test_gw0", "medcover_test_gw1", + "medcover_test_gw2", "medcover_test_gw3"]: + c.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name='{db_name}') " + f"CREATE DATABASE [{db_name}] COLLATE Czech_100_CI_AS_SC_UTF8") + c.execute(f"ALTER DATABASE [{db_name}] SET READ_COMMITTED_SNAPSHOT ON") + print(f"{db_name} ready") conn.close() - print("medcover_test ready") EOF - name: Install dependencies From 0d1051de0d97d91596f6cfad9d39851835ad5be8 Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Mon, 22 Jun 2026 11:04:35 +0200 Subject: [PATCH 29/31] Fix flaky tests: pool_size=2/max_overflow=0 prevents RCSI snapshot gaps while allowing dual-connection paths --- tests/conftest.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index eba63286..075a9ea4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,6 @@ import pytest from sqlalchemy import create_engine, text -from sqlalchemy.pool import NullPool from app import create_app from app.extensions import db as _db @@ -264,7 +263,20 @@ def app(worker_id: str): db_url = _worker_db_url(worker_id) _ensure_db_exists(db_url) - flask_app = create_app("testing", db_url=db_url, engine_options={"poolclass": NullPool}) + # Use a pool of size 1 per worker: avoids per-query TCP connection overhead + # (which caused MSSQL RCSI snapshot gaps in CI) while still ensuring each + # worker has its own isolated connection. pool_reset_on_return="rollback" + # keeps the connection clean between uses. + flask_app = create_app( + "testing", + db_url=db_url, + engine_options={ + "pool_size": 2, + "max_overflow": 0, + "pool_pre_ping": True, + "pool_reset_on_return": "rollback", + }, + ) with flask_app.app_context(): _db.drop_all() # clear leftover types/tables from previous runs From 673e0e521b68af076c4fd04f73803700a5a9c49c Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Mon, 22 Jun 2026 11:31:37 +0200 Subject: [PATCH 30/31] Fix CI flakiness: disable RCSI on test DBs, use standard READ COMMITTED --- .github/workflows/ci.yml | 7 +++---- tests/conftest.py | 9 +++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c6076d1..885fce6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,14 +79,13 @@ jobs: except Exception: time.sleep(2) c = conn.cursor() - # Create base DB + all 4 xdist worker DBs up-front, all with RCSI enabled. - # Pre-creating them here (as SA) is more reliable than creating them - # on-the-fly from Python test code under parallel xdist load. + # Create base DB + all 4 xdist worker DBs up-front without RCSI. + # Standard READ COMMITTED (no snapshot) guarantees committed rows are + # immediately visible to all connections, eliminating CI snapshot-gap flakiness. for db_name in ["medcover_test", "medcover_test_gw0", "medcover_test_gw1", "medcover_test_gw2", "medcover_test_gw3"]: c.execute(f"IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name='{db_name}') " f"CREATE DATABASE [{db_name}] COLLATE Czech_100_CI_AS_SC_UTF8") - c.execute(f"ALTER DATABASE [{db_name}] SET READ_COMMITTED_SNAPSHOT ON") print(f"{db_name} ready") conn.close() EOF diff --git a/tests/conftest.py b/tests/conftest.py index 075a9ea4..5b962c61 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -207,7 +207,13 @@ def _wait_for_mssql(host: str, port: int, sa_password: str, timeout: int = 60) - def _create_mssql_db(host: str, port: int, sa_password: str, db_name: str) -> None: - """Create an MSSQL database with Czech collation and RCSI enabled.""" + """Create an MSSQL database with Czech collation. + + Note: RCSI (READ_COMMITTED_SNAPSHOT) is intentionally NOT enabled for test + databases. Standard READ COMMITTED with locking guarantees committed rows are + immediately visible to any connection, eliminating snapshot-gap flakiness in CI. + Production databases use RCSI (set via mssql-init/setup.sh). + """ import pyodbc # pylint: disable=import-outside-toplevel conn_str = ( @@ -221,7 +227,6 @@ def _create_mssql_db(host: str, port: int, sa_password: str, db_name: str) -> No f"IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name='{db_name}') " f"CREATE DATABASE [{db_name}] COLLATE Czech_100_CI_AS_SC_UTF8" ) - c.execute(f"ALTER DATABASE [{db_name}] SET READ_COMMITTED_SNAPSHOT ON") conn.close() From abd50cd4fd349901ec34371b5b86540ef187ea4e Mon Sep 17 00:00:00 2001 From: "Milan H." Date: Mon, 22 Jun 2026 13:14:27 +0200 Subject: [PATCH 31/31] Address PR review: fail hard on missing Encrypt, remove dead mssql-init mount, clarify setup-e2e.sh --- app/config.py | 6 ++---- docker-compose.e2e.yml | 2 -- mssql-init/setup-e2e.sh | 7 +++++-- tests/test_config.py | 33 ++++++++++++--------------------- 4 files changed, 19 insertions(+), 29 deletions(-) diff --git a/app/config.py b/app/config.py index 5d78a410..4651fbde 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,5 @@ import os import pathlib -import warnings RESET_TOKEN_MINUTES = 10 INVITE_TOKEN_HOURS = 72 @@ -70,10 +69,9 @@ def __init_subclass__(cls, **kwargs: object) -> None: def init_app(cls, app: object) -> None: # type: ignore[override] db_url = os.environ.get("DATABASE_URL", "") if db_url and "Encrypt=yes" not in db_url and "Authentication=ActiveDirectoryMsi" not in db_url: - warnings.warn( + raise RuntimeError( "DATABASE_URL does not include Encrypt=yes. " - "Add Encrypt=yes to the MSSQL connection string for production security.", - stacklevel=2, + "Add Encrypt=yes to the MSSQL connection string for production security." ) diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index e34b6f90..99fec2e6 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -10,8 +10,6 @@ services: MSSQL_SA_PASSWORD: "E2e_Password1!" MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8" MSSQL_MEMORY_LIMIT_MB: "512" - volumes: - - ./mssql-init:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "E2e_Password1!", "-C", "-Q", "SELECT 1", "-b"] interval: 5s diff --git a/mssql-init/setup-e2e.sh b/mssql-init/setup-e2e.sh index 38dea74c..826945cb 100755 --- a/mssql-init/setup-e2e.sh +++ b/mssql-init/setup-e2e.sh @@ -1,8 +1,11 @@ #!/bin/bash # mssql-init/setup-e2e.sh # -# Create the e2e test database in the MSSQL container. -# Run after the container is healthy: +# MANUAL DEV HELPER — not used by the automated e2e stack. +# +# The docker-compose.e2e.yml e2e stack creates the database automatically +# via scripts/e2e-entrypoint.sh (Python/pyodbc). This script is retained +# as a convenience for manually inspecting or resetting the e2e database: # podman exec /docker-entrypoint-initdb.d/setup-e2e.sh set -e diff --git a/tests/test_config.py b/tests/test_config.py index 3b4eea76..94cac3bc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,6 @@ """Unit tests for Config classes.""" import os -import warnings from unittest.mock import patch from app.config import DevelopmentConfig, ProductionConfig @@ -43,44 +42,36 @@ def test_no_raise_when_both_set(self): class TestProductionConfigInitApp: - def test_warns_when_encrypt_missing(self): + def test_raises_when_encrypt_missing(self): _url = "mssql+pyodbc://SA:pwd@server.database.windows.net:1433/db" "?driver=ODBC+Driver+18+for+SQL+Server" with patch.dict(os.environ, {"DATABASE_URL": _url}): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") + try: ProductionConfig.init_app(object()) - assert any("Encrypt=yes" in str(warning.message) for warning in w) + assert False, "Expected RuntimeError" + except RuntimeError as exc: + assert "Encrypt=yes" in str(exc) - def test_no_warn_when_encrypt_present(self): + def test_no_raise_when_encrypt_present(self): _url = ( "mssql+pyodbc://SA:pwd@server.database.windows.net:1433/db" "?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=yes" ) with patch.dict(os.environ, {"DATABASE_URL": _url}): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - ProductionConfig.init_app(object()) - assert not any("Encrypt=yes" in str(warning.message) for warning in w) + ProductionConfig.init_app(object()) # must not raise - def test_no_warn_when_msi(self): + def test_no_raise_when_msi(self): _url = ( "mssql+pyodbc://@server.database.windows.net/db" "?driver=ODBC+Driver+18+for+SQL+Server&Authentication=ActiveDirectoryMsi" ) with patch.dict(os.environ, {"DATABASE_URL": _url}): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - ProductionConfig.init_app(object()) - assert not any("Encrypt" in str(warning.message) for warning in w) + ProductionConfig.init_app(object()) # must not raise - def test_no_warn_when_database_url_empty(self): - """Empty DATABASE_URL should not trigger the warning (setup wizard case).""" + def test_no_raise_when_database_url_empty(self): + """Empty DATABASE_URL should not raise (setup wizard case).""" with patch.dict(os.environ, {"DATABASE_URL": ""}): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - ProductionConfig.init_app(object()) - assert not any("Encrypt" in str(warning.message) for warning in w) + ProductionConfig.init_app(object()) # must not raise