From d534588e27c408970d943616eb54b97da6222195 Mon Sep 17 00:00:00 2001 From: Joao Carreira Date: Wed, 7 Oct 2026 13:13:20 +0100 Subject: [PATCH] feat: monorepo migration phase 4 --- .github/workflows/ci.yaml | 5 +- Makefile | 6 +- apps/shifts/AGENTS.md | 9 +- apps/shifts/README.md | 18 +- apps/shifts/backend/app/cli.py | 6 +- apps/shifts/backend/app/main.py | 4 +- apps/shifts/backend/app/models.py | 199 - apps/shifts/backend/app/models/__init__.py | 9 + apps/shifts/backend/app/models/assignment.py | 26 + .../shifts/backend/app/models/notification.py | 27 + apps/shifts/backend/app/models/schedule.py | 71 + apps/shifts/backend/app/models/swap.py | 36 + apps/shifts/backend/app/models/team.py | 42 + apps/shifts/backend/app/models/user.py | 34 + apps/shifts/backend/app/routers/__init__.py | 29 + apps/shifts/backend/app/routers/account.py | 43 + .../backend/app/routers/admin/__init__.py | 0 .../backend/app/routers/admin/assignments.py | 312 + .../app/routers/admin/notifications.py | 26 + .../backend/app/routers/admin/schedules.py | 265 + .../shifts/backend/app/routers/admin/teams.py | 246 + .../backend/app/routers/admin/user_helpers.py | 35 + .../shifts/backend/app/routers/admin/users.py | 182 + .../backend/app/routers/assignment_helpers.py | 259 + apps/shifts/backend/app/routers/auth.py | 51 + apps/shifts/backend/app/routers/calendar.py | 79 + apps/shifts/backend/app/routers/dashboard.py | 93 + .../backend/app/routers/dependencies.py | 115 + apps/shifts/backend/app/routers/exports.py | 110 + .../backend/app/routers/schedule_helpers.py | 189 + .../backend/app/routers/swap_helpers.py | 118 + apps/shifts/backend/app/routers/swaps.py | 478 ++ apps/shifts/backend/app/security/__init__.py | 4 + apps/shifts/backend/app/security/csrf.py | 6 + .../{security.py => security/passwords.py} | 5 - apps/shifts/backend/app/services/__init__.py | 0 .../backend/app/{ => services}/bootstrap.py | 6 +- .../calendar_feed.py} | 6 +- .../app/{ => services}/notifications.py | 4 +- apps/shifts/backend/app/{ => services}/pdf.py | 8 +- .../backend/app/{ => services}/scheduling.py | 2 +- apps/shifts/backend/app/web.py | 2456 ------- apps/shifts/backend/tests/conftest.py | 2 +- .../backend/tests/test_export_schedule.py | 2 +- apps/shifts/docs/ARCHITECTURE.md | 51 +- migration/README.md | 64 +- migration/baseline/artifacts.json | 1 + migration/check_shifts_structure.py | 319 + migration/fixtures/shifts.sqlite | Bin 0 -> 94208 bytes migration/phase4/baseline.json | 6275 +++++++++++++++++ migration/phase4/structure.json | 464 ++ migration/phase4/verification.json | 14 + tools/refactor_shifts_structure.py | 201 + 53 files changed, 10311 insertions(+), 2701 deletions(-) delete mode 100644 apps/shifts/backend/app/models.py create mode 100644 apps/shifts/backend/app/models/__init__.py create mode 100644 apps/shifts/backend/app/models/assignment.py create mode 100644 apps/shifts/backend/app/models/notification.py create mode 100644 apps/shifts/backend/app/models/schedule.py create mode 100644 apps/shifts/backend/app/models/swap.py create mode 100644 apps/shifts/backend/app/models/team.py create mode 100644 apps/shifts/backend/app/models/user.py create mode 100644 apps/shifts/backend/app/routers/__init__.py create mode 100644 apps/shifts/backend/app/routers/account.py create mode 100644 apps/shifts/backend/app/routers/admin/__init__.py create mode 100644 apps/shifts/backend/app/routers/admin/assignments.py create mode 100644 apps/shifts/backend/app/routers/admin/notifications.py create mode 100644 apps/shifts/backend/app/routers/admin/schedules.py create mode 100644 apps/shifts/backend/app/routers/admin/teams.py create mode 100644 apps/shifts/backend/app/routers/admin/user_helpers.py create mode 100644 apps/shifts/backend/app/routers/admin/users.py create mode 100644 apps/shifts/backend/app/routers/assignment_helpers.py create mode 100644 apps/shifts/backend/app/routers/auth.py create mode 100644 apps/shifts/backend/app/routers/calendar.py create mode 100644 apps/shifts/backend/app/routers/dashboard.py create mode 100644 apps/shifts/backend/app/routers/dependencies.py create mode 100644 apps/shifts/backend/app/routers/exports.py create mode 100644 apps/shifts/backend/app/routers/schedule_helpers.py create mode 100644 apps/shifts/backend/app/routers/swap_helpers.py create mode 100644 apps/shifts/backend/app/routers/swaps.py create mode 100644 apps/shifts/backend/app/security/__init__.py create mode 100644 apps/shifts/backend/app/security/csrf.py rename apps/shifts/backend/app/{security.py => security/passwords.py} (92%) create mode 100644 apps/shifts/backend/app/services/__init__.py rename apps/shifts/backend/app/{ => services}/bootstrap.py (94%) rename apps/shifts/backend/app/{calendar.py => services/calendar_feed.py} (97%) rename apps/shifts/backend/app/{ => services}/notifications.py (96%) rename apps/shifts/backend/app/{ => services}/pdf.py (98%) rename apps/shifts/backend/app/{ => services}/scheduling.py (98%) delete mode 100644 apps/shifts/backend/app/web.py create mode 100644 migration/check_shifts_structure.py create mode 100644 migration/fixtures/shifts.sqlite create mode 100644 migration/phase4/baseline.json create mode 100644 migration/phase4/structure.json create mode 100644 migration/phase4/verification.json create mode 100644 tools/refactor_shifts_structure.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 76c07e7..cbde1fa 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,7 +27,7 @@ jobs: - 'apps/tournament/**' - 'migration/contracts/**' - 'migration/python_*' - - 'migration/fixtures/**' + - 'migration/fixtures/tournament*' - 'tools/check_tournament_compat.py' - 'packages/crr-brand/**' - 'packages/crr-python/**' @@ -39,6 +39,9 @@ jobs: - '.github/workflows/**' shifts: - 'apps/shifts/**' + - 'migration/check_shifts_structure.py' + - 'migration/phase4/baseline.json' + - 'migration/fixtures/shifts.sqlite' - 'packages/crr-brand/**' - 'packages/crr-python/**' - 'pyproject.toml' diff --git a/Makefile b/Makefile index 0bd9c22..7e6d80b 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ UV ?= uv PYTHON ?= python3 .DEFAULT_GOAL := test -.PHONY: baseline-test baseline-fixtures baseline-screenshots baseline-preserve migration-visual-check branding-visual-check images-smoke-test tournament-install tournament-backend-dev tournament-frontend-dev tournament-test tournament-compat-test tournament-build shifts-dev shifts-test shifts-build test +.PHONY: baseline-test baseline-fixtures baseline-screenshots baseline-preserve migration-visual-check branding-visual-check images-smoke-test tournament-install tournament-backend-dev tournament-frontend-dev tournament-test tournament-compat-test tournament-build shifts-dev shifts-test shifts-compat-test shifts-build test baseline-test: $(PYTHON) tools/migration_baseline.py all @@ -56,6 +56,10 @@ shifts-dev: shifts-test: $(UV) run --directory apps/shifts/backend --package crr-shifts --locked pytest + $(UV) run --directory apps/shifts/backend --package crr-shifts --locked python $(abspath migration/check_shifts_structure.py) + +shifts-compat-test: + $(UV) run --directory apps/shifts/backend --package crr-shifts --locked python $(abspath migration/check_shifts_structure.py) shifts-build: docker build -f apps/shifts/Dockerfile -t crr-shifts . diff --git a/apps/shifts/AGENTS.md b/apps/shifts/AGENTS.md index 3ea9169..6297881 100644 --- a/apps/shifts/AGENTS.md +++ b/apps/shifts/AGENTS.md @@ -3,7 +3,14 @@ - Preserve Portuguese messages, Jinja templates, form fields, routes, session auth, CSRF, `ROOT_PATH` deployment, existing SQLite data, and Alembic history. - Backend commands run from `backend/`; production code must not import originals. -- Keep the Phase 1 service/model/router layout until the planned Phase 4 refactor. +- `services/` owns bootstrap, scheduling, calendar feeds, notifications and PDF + rendering. `models/` contains app-specific persistence classes; its package + exports the established model names. +- `routers/` is split by auth/account/calendar/exports/dashboard/swaps and admin + responsibility. Shared request/session/CSRF/flash/context helpers belong in + `routers/dependencies.py`; password and token primitives belong in `security/`. - Preserve manual/swapped assignments, atomic swaps, and commit-before-email rules. - Run `make shifts-test` from the repository root after relevant changes. +- This includes the preserved Phase 4 schema/ORM/OpenAPI and 96-request workflow + comparison. Use `make shifts-compat-test` to run that check separately. - Version and release this application independently using `shifts-v...` tags. diff --git a/apps/shifts/README.md b/apps/shifts/README.md index 200bc13..5d96e91 100644 --- a/apps/shifts/README.md +++ b/apps/shifts/README.md @@ -1,8 +1,9 @@ # Contínuos CRR — Shifts -The server-rendered FastAPI application lives in `backend/`. Phase 1 preserves -its service/model/router layout, Jinja templates and `0001_initial` Alembic -history. Python dependencies are managed with `uv` and the root `uv.lock`. +The server-rendered FastAPI application lives in `backend/`. Its models, +services, security primitives and domain routers are organized separately, with +the established Jinja/form contracts and `0001_initial` Alembic history. Python +dependencies are managed with `uv` and the root `uv.lock`. The canonical logo, favicon and common CSS live in `packages/crr-brand`, mounted at `/brand` with root-path-aware Jinja links. PDF exports read that same logo. @@ -42,6 +43,11 @@ uv run --directory apps/shifts/backend --package crr-shifts --locked python -m a The requirements files are retained during migration until CI verifies the lockfile workflow. Tests use isolated synthetic SQLite data and disabled SMTP. -See `docs/DOMAIN.md` for domain rules and `docs/ARCHITECTURE.md` for the preserved -application lifecycle (legacy dependency/deployment commands there are being -superseded by this README). +`make shifts-test` runs pytest and the Phase 4 compatibility check: ORM/schema +definitions and relationships, OpenAPI, migration history, existing data at +startup and 96 requests across `/` and `/crr`. `make shifts-compat-test` runs the +compatibility check alone. The synthetic fixture is under `migration/fixtures/` +and the expected contract under `migration/phase4/`. + +See `docs/DOMAIN.md` for domain rules and `docs/ARCHITECTURE.md` for the current +module layout, application lifecycle and deployment commands. diff --git a/apps/shifts/backend/app/cli.py b/apps/shifts/backend/app/cli.py index a3b890d..fcd5e18 100644 --- a/apps/shifts/backend/app/cli.py +++ b/apps/shifts/backend/app/cli.py @@ -7,7 +7,7 @@ from datetime import date, timedelta from sqlalchemy import select from sqlalchemy.orm import selectinload -from .bootstrap import ensure_initial_data +from .services.bootstrap import ensure_initial_data from .config import settings from .database import ( SessionLocal, @@ -27,8 +27,8 @@ from .models import ( Team, ) from .i18n import day_long, day_numeric -from .notifications import send_email_notification -from .scheduling import ensure_month_assignments +from .services.notifications import send_email_notification +from .services.scheduling import ensure_month_assignments from .security import hash_password BASELINE_REVISION = "0001_initial" diff --git a/apps/shifts/backend/app/main.py b/apps/shifts/backend/app/main.py index b1143ca..00645f3 100644 --- a/apps/shifts/backend/app/main.py +++ b/apps/shifts/backend/app/main.py @@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware -from .bootstrap import ensure_initial_data +from .services.bootstrap import ensure_initial_data from .branding import BRAND_DIR from .config import settings from .database import ( @@ -32,7 +32,7 @@ from .i18n import ( weekday_name, window_label, ) -from .web import router +from .routers import router BASE_DIR = Path(__file__).resolve().parent diff --git a/apps/shifts/backend/app/models.py b/apps/shifts/backend/app/models.py deleted file mode 100644 index cadaa31..0000000 --- a/apps/shifts/backend/app/models.py +++ /dev/null @@ -1,199 +0,0 @@ -from __future__ import annotations - -from datetime import date, datetime, time - -from sqlalchemy import ( - Boolean, - CheckConstraint, - Date, - DateTime, - ForeignKey, - Integer, - String, - Text, - Time, - UniqueConstraint, -) -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from .database import Base - - -class User(Base): - """A login: a username, a password and the administrator flag. Several teams can be - assigned to it, so one person can sign in once and control the shifts of all of them.""" - - __tablename__ = "users" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(120), nullable=False) - username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) - password_hash: Mapped[str] = mapped_column(String(300), nullable=False) - is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) - - teams: Mapped[list[Team]] = relationship(back_populates="user") - - @property - def can_login(self) -> bool: - return bool(self.username and self.password_hash) - - @property - def label(self) -> str: - return self.name or self.username - - -class Team(Base): - """Who works a shift: one person, or the people who cover it together. Every rota - row, rotation position, pattern day and change request names a team.""" - - __tablename__ = "teams" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(120), nullable=False) - user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) - email: Mapped[str | None] = mapped_column(String(254), unique=True, nullable=True) - phone: Mapped[str | None] = mapped_column(String(40), nullable=True) - calendar_token: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - notify_email: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) - - user: Mapped[User | None] = relationship(back_populates="teams") - - @property - def can_login(self) -> bool: - """Whether anyone signs in for this team.""" - return self.user is not None - - @property - def is_admin(self) -> bool: - """Administrator rights belong to the user, so every team it covers has them.""" - return bool(self.user and self.user.is_admin) - - @property - def username(self) -> str | None: - return self.user.username if self.user else None - - -class Schedule(Base): - __tablename__ = "schedules" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) - slug: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) - schedule_type: Mapped[str] = mapped_column(String(20), nullable=False) # fixed | rotation - weekdays: Mapped[str] = mapped_column(String(30), nullable=False) # comma-separated 0..6 - # When the shift runs, in the bar's local time. An end at or before the start means the next day. - start_time: Mapped[time] = mapped_column(Time, default=time(20, 30), nullable=False) - end_time: Mapped[time] = mapped_column(Time, default=time(23, 59), nullable=False) - requires_manager_approval: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - rotation_anchor_date: Mapped[date | None] = mapped_column(Date, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) - - rotation_members: Mapped[list[RotationMember]] = relationship( - back_populates="schedule", cascade="all, delete-orphan", order_by="RotationMember.position" - ) - pattern_days: Mapped[list[MonthlyPattern]] = relationship( - back_populates="schedule", cascade="all, delete-orphan", order_by="MonthlyPattern.day_of_month" - ) - - @property - def weekday_set(self) -> set[int]: - return {int(v) for v in self.weekdays.split(",") if v.strip()} - - -class MonthlyPattern(Base): - """One team per calendar day of the month, repeating every month.""" - - __tablename__ = "monthly_patterns" - __table_args__ = ( - UniqueConstraint("schedule_id", "day_of_month", name="uq_pattern_schedule_day"), - CheckConstraint("day_of_month BETWEEN 1 AND 31", name="ck_pattern_day_of_month"), - ) - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - schedule_id: Mapped[int] = mapped_column(ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False) - day_of_month: Mapped[int] = mapped_column(Integer, nullable=False) - team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) - - schedule: Mapped[Schedule] = relationship(back_populates="pattern_days") - team: Mapped[Team | None] = relationship() - - -class RotationMember(Base): - __tablename__ = "rotation_members" - __table_args__ = ( - UniqueConstraint("schedule_id", "team_id", name="uq_rotation_schedule_team"), - UniqueConstraint("schedule_id", "position", name="uq_rotation_schedule_position"), - ) - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - schedule_id: Mapped[int] = mapped_column(ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False) - team_id: Mapped[int] = mapped_column(ForeignKey("teams.id", ondelete="CASCADE"), nullable=False) - position: Mapped[int] = mapped_column(Integer, nullable=False) - - schedule: Mapped[Schedule] = relationship(back_populates="rotation_members") - team: Mapped[Team] = relationship() - - -class Assignment(Base): - __tablename__ = "assignments" - __table_args__ = (UniqueConstraint("schedule_id", "date", name="uq_assignment_schedule_date"),) - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - schedule_id: Mapped[int] = mapped_column(ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False) - date: Mapped[date] = mapped_column(Date, nullable=False, index=True) - team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) - source: Mapped[str] = mapped_column(String(20), default="generated", nullable=False) # generated | manual | swap - note: Mapped[str | None] = mapped_column(String(500), nullable=True) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - - schedule: Mapped[Schedule] = relationship() - team: Mapped[Team | None] = relationship() - - -class SwapRequest(Base): - """A swap is one shift for one shift: `assignment_id` goes to the target and - `target_assignment_id` comes back to the requester.""" - - __tablename__ = "swap_requests" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=False) - target_assignment_id: Mapped[int | None] = mapped_column( - ForeignKey("assignments.id", ondelete="SET NULL"), nullable=True - ) - requester_id: Mapped[int] = mapped_column(ForeignKey("teams.id", ondelete="CASCADE"), nullable=False) - target_team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) - accepted_team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) - status: Mapped[str] = mapped_column(String(30), default="open", nullable=False) - message: Mapped[str | None] = mapped_column(String(500), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - - assignment: Mapped[Assignment] = relationship(foreign_keys=[assignment_id]) - target_assignment: Mapped[Assignment | None] = relationship(foreign_keys=[target_assignment_id]) - requester: Mapped[Team] = relationship(foreign_keys=[requester_id]) - target_team: Mapped[Team | None] = relationship(foreign_keys=[target_team_id]) - accepted_by: Mapped[Team | None] = relationship(foreign_keys=[accepted_team_id]) - - -class NotificationLog(Base): - __tablename__ = "notification_logs" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) - event_type: Mapped[str] = mapped_column(String(60), nullable=False) - channel: Mapped[str] = mapped_column(String(20), default="email", nullable=False) - recipient: Mapped[str | None] = mapped_column(String(254), nullable=True) - subject: Mapped[str] = mapped_column(String(250), nullable=False) - body: Mapped[str] = mapped_column(Text, nullable=False) - status: Mapped[str] = mapped_column(String(20), nullable=False) # sent | skipped | failed - error: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) - sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - - team: Mapped[Team | None] = relationship() diff --git a/apps/shifts/backend/app/models/__init__.py b/apps/shifts/backend/app/models/__init__.py new file mode 100644 index 0000000..03f364b --- /dev/null +++ b/apps/shifts/backend/app/models/__init__.py @@ -0,0 +1,9 @@ +from ..database import Base +from .user import User +from .team import Team +from .schedule import MonthlyPattern, RotationMember, Schedule +from .assignment import Assignment +from .swap import SwapRequest +from .notification import NotificationLog + +__all__ = ['Base', 'User', 'Team', 'Schedule', 'MonthlyPattern', 'RotationMember', 'Assignment', 'SwapRequest', 'NotificationLog'] diff --git a/apps/shifts/backend/app/models/assignment.py b/apps/shifts/backend/app/models/assignment.py new file mode 100644 index 0000000..3a0a7d9 --- /dev/null +++ b/apps/shifts/backend/app/models/assignment.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import date, datetime +from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from ..database import Base +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .schedule import Schedule + from .team import Team + +class Assignment(Base): + __tablename__ = "assignments" + __table_args__ = (UniqueConstraint("schedule_id", "date", name="uq_assignment_schedule_date"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + schedule_id: Mapped[int] = mapped_column(ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False) + date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) + source: Mapped[str] = mapped_column(String(20), default="generated", nullable=False) # generated | manual | swap + note: Mapped[str | None] = mapped_column(String(500), nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + schedule: Mapped[Schedule] = relationship() + team: Mapped[Team | None] = relationship() diff --git a/apps/shifts/backend/app/models/notification.py b/apps/shifts/backend/app/models/notification.py new file mode 100644 index 0000000..e48e19a --- /dev/null +++ b/apps/shifts/backend/app/models/notification.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import datetime +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from ..database import Base +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .team import Team + +class NotificationLog(Base): + __tablename__ = "notification_logs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) + event_type: Mapped[str] = mapped_column(String(60), nullable=False) + channel: Mapped[str] = mapped_column(String(20), default="email", nullable=False) + recipient: Mapped[str | None] = mapped_column(String(254), nullable=True) + subject: Mapped[str] = mapped_column(String(250), nullable=False) + body: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False) # sent | skipped | failed + error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + team: Mapped[Team | None] = relationship() diff --git a/apps/shifts/backend/app/models/schedule.py b/apps/shifts/backend/app/models/schedule.py new file mode 100644 index 0000000..247c9f8 --- /dev/null +++ b/apps/shifts/backend/app/models/schedule.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from datetime import date, datetime, time +from sqlalchemy import Boolean, CheckConstraint, Date, DateTime, ForeignKey, Integer, String, Time, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from ..database import Base +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .team import Team + +class Schedule(Base): + __tablename__ = "schedules" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) + slug: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) + schedule_type: Mapped[str] = mapped_column(String(20), nullable=False) # fixed | rotation + weekdays: Mapped[str] = mapped_column(String(30), nullable=False) # comma-separated 0..6 + # When the shift runs, in the bar's local time. An end at or before the start means the next day. + start_time: Mapped[time] = mapped_column(Time, default=time(20, 30), nullable=False) + end_time: Mapped[time] = mapped_column(Time, default=time(23, 59), nullable=False) + requires_manager_approval: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + rotation_anchor_date: Mapped[date | None] = mapped_column(Date, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + rotation_members: Mapped[list[RotationMember]] = relationship( + back_populates="schedule", cascade="all, delete-orphan", order_by="RotationMember.position" + ) + pattern_days: Mapped[list[MonthlyPattern]] = relationship( + back_populates="schedule", cascade="all, delete-orphan", order_by="MonthlyPattern.day_of_month" + ) + + @property + def weekday_set(self) -> set[int]: + return {int(v) for v in self.weekdays.split(",") if v.strip()} + + +class MonthlyPattern(Base): + """One team per calendar day of the month, repeating every month.""" + + __tablename__ = "monthly_patterns" + __table_args__ = ( + UniqueConstraint("schedule_id", "day_of_month", name="uq_pattern_schedule_day"), + CheckConstraint("day_of_month BETWEEN 1 AND 31", name="ck_pattern_day_of_month"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + schedule_id: Mapped[int] = mapped_column(ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False) + day_of_month: Mapped[int] = mapped_column(Integer, nullable=False) + team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) + + schedule: Mapped[Schedule] = relationship(back_populates="pattern_days") + team: Mapped[Team | None] = relationship() + + +class RotationMember(Base): + __tablename__ = "rotation_members" + __table_args__ = ( + UniqueConstraint("schedule_id", "team_id", name="uq_rotation_schedule_team"), + UniqueConstraint("schedule_id", "position", name="uq_rotation_schedule_position"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + schedule_id: Mapped[int] = mapped_column(ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False) + team_id: Mapped[int] = mapped_column(ForeignKey("teams.id", ondelete="CASCADE"), nullable=False) + position: Mapped[int] = mapped_column(Integer, nullable=False) + + schedule: Mapped[Schedule] = relationship(back_populates="rotation_members") + team: Mapped[Team] = relationship() diff --git a/apps/shifts/backend/app/models/swap.py b/apps/shifts/backend/app/models/swap.py new file mode 100644 index 0000000..5f15661 --- /dev/null +++ b/apps/shifts/backend/app/models/swap.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from datetime import datetime +from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from ..database import Base +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .assignment import Assignment + from .team import Team + +class SwapRequest(Base): + """A swap is one shift for one shift: `assignment_id` goes to the target and + `target_assignment_id` comes back to the requester.""" + + __tablename__ = "swap_requests" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=False) + target_assignment_id: Mapped[int | None] = mapped_column( + ForeignKey("assignments.id", ondelete="SET NULL"), nullable=True + ) + requester_id: Mapped[int] = mapped_column(ForeignKey("teams.id", ondelete="CASCADE"), nullable=False) + target_team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) + accepted_team_id: Mapped[int | None] = mapped_column(ForeignKey("teams.id", ondelete="SET NULL"), nullable=True) + status: Mapped[str] = mapped_column(String(30), default="open", nullable=False) + message: Mapped[str | None] = mapped_column(String(500), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + assignment: Mapped[Assignment] = relationship(foreign_keys=[assignment_id]) + target_assignment: Mapped[Assignment | None] = relationship(foreign_keys=[target_assignment_id]) + requester: Mapped[Team] = relationship(foreign_keys=[requester_id]) + target_team: Mapped[Team | None] = relationship(foreign_keys=[target_team_id]) + accepted_by: Mapped[Team | None] = relationship(foreign_keys=[accepted_team_id]) diff --git a/apps/shifts/backend/app/models/team.py b/apps/shifts/backend/app/models/team.py new file mode 100644 index 0000000..2e1f789 --- /dev/null +++ b/apps/shifts/backend/app/models/team.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from datetime import datetime +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from ..database import Base +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .user import User + +class Team(Base): + """Who works a shift: one person, or the people who cover it together. Every rota + row, rotation position, pattern day and change request names a team.""" + + __tablename__ = "teams" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + email: Mapped[str | None] = mapped_column(String(254), unique=True, nullable=True) + phone: Mapped[str | None] = mapped_column(String(40), nullable=True) + calendar_token: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + notify_email: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + user: Mapped[User | None] = relationship(back_populates="teams") + + @property + def can_login(self) -> bool: + """Whether anyone signs in for this team.""" + return self.user is not None + + @property + def is_admin(self) -> bool: + """Administrator rights belong to the user, so every team it covers has them.""" + return bool(self.user and self.user.is_admin) + + @property + def username(self) -> str | None: + return self.user.username if self.user else None diff --git a/apps/shifts/backend/app/models/user.py b/apps/shifts/backend/app/models/user.py new file mode 100644 index 0000000..22de8f3 --- /dev/null +++ b/apps/shifts/backend/app/models/user.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from datetime import datetime +from sqlalchemy import Boolean, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from ..database import Base +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .team import Team + +class User(Base): + """A login: a username, a password and the administrator flag. Several teams can be + assigned to it, so one person can sign in once and control the shifts of all of them.""" + + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(300), nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + teams: Mapped[list[Team]] = relationship(back_populates="user") + + @property + def can_login(self) -> bool: + return bool(self.username and self.password_hash) + + @property + def label(self) -> str: + return self.name or self.username diff --git a/apps/shifts/backend/app/routers/__init__.py b/apps/shifts/backend/app/routers/__init__.py new file mode 100644 index 0000000..56eb145 --- /dev/null +++ b/apps/shifts/backend/app/routers/__init__.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter + +from .auth import router as auth_router +from .account import router as account_router +from .calendar import router as calendar_router +from .exports import router as exports_router +from .dashboard import router as dashboard_router +from .swaps import router as swaps_router +from .admin.teams import router as admin_teams_router +from .admin.users import router as admin_users_router +from .admin.schedules import router as admin_schedules_router +from .admin.assignments import router as admin_assignments_router +from .admin.notifications import router as admin_notifications_router + +router = APIRouter() +for domain_router in ( + auth_router, + account_router, + calendar_router, + exports_router, + dashboard_router, + swaps_router, + admin_teams_router, + admin_users_router, + admin_schedules_router, + admin_assignments_router, + admin_notifications_router, +): + router.include_router(domain_router) diff --git a/apps/shifts/backend/app/routers/account.py b/apps/shifts/backend/app/routers/account.py new file mode 100644 index 0000000..9e42494 --- /dev/null +++ b/apps/shifts/backend/app/routers/account.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session +from ..database import get_db +from ..security import hash_password, verify_password +from .dependencies import _check_csrf, _context, _flash, _redirect, _require_user, _safe_back + +router = APIRouter() + +@router.get("/account", response_class=HTMLResponse) +def account_page(request: Request, db: Session = Depends(get_db)): + """The password editor lives on its own page, reached by clicking the user name.""" + _require_user(request, db) + return request.app.state.templates.TemplateResponse( + request=request, + name="account.html", + context=_context(request, db), + ) + + +@router.post("/account") +def account_password( + request: Request, + new_password: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + """A user changes its own password from `/account`: one field, no ceremony.""" + _check_csrf(request, csrf_token) + user = _require_user(request, db) + if verify_password(new_password, user.password_hash): + _flash(request, "A nova palavra-passe é igual à atual.", "error") + return _redirect(_safe_back(request, "/")) + try: + user.password_hash = hash_password(new_password) + except ValueError as exc: + _flash(request, str(exc), "error") + return _redirect(_safe_back(request, "/")) + db.commit() + _flash(request, "Palavra-passe alterada.") + return _redirect(_safe_back(request, "/")) diff --git a/apps/shifts/backend/app/routers/admin/__init__.py b/apps/shifts/backend/app/routers/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/shifts/backend/app/routers/admin/assignments.py b/apps/shifts/backend/app/routers/admin/assignments.py new file mode 100644 index 0000000..c7e09c7 --- /dev/null +++ b/apps/shifts/backend/app/routers/admin/assignments.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +from datetime import date, timedelta +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload +from ...config import settings +from ...database import get_db +from ...models import Assignment, SwapRequest, Team +from ...i18n import day_numeric_long, day_short, month_name +from ...services.notifications import send_many +from ...services.scheduling import ensure_month_assignments, month_bounds +from ..assignment_helpers import _apply_pattern_forward, _apply_rotation_to_rows, _clear_assignments_from, _default_team, _generate_rotation_range, _month_assignments, _notify_assignment_changes, _save_assignments, _set_assignee +from ..dependencies import _check_csrf, _context, _flash, _parse_month, _redirect, _require_admin, _user_label +from ..schedule_helpers import _active_schedules, _get_schedule +from ..swap_helpers import _swap_ownership_holds, _swap_partner + +router = APIRouter() + +@router.post("/admin/schedules/{schedule_id}/apply-pattern") +def admin_apply_pattern( + schedule_id: int, + request: Request, + year: int = Form(...), + month: int = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _require_admin(request, db) + _check_csrf(request, csrf_token) + schedule = _get_schedule(db, schedule_id) + if schedule.schedule_type != "fixed": + raise HTTPException(status_code=400, detail="Esta escala não usa um padrão mensal") + year, month = _parse_month(year, month) + first = date(year, month, 1) + touched = _apply_pattern_forward(db, schedule, first) + _flash( + request, + f"Padrão aplicado a partir de {day_numeric_long(first)}: {touched} turno(s) atualizado(s). " + f"As atribuições manuais e as trocas foram mantidas, e os dias sem equipa no padrão ficaram como estão.", + ) + return _redirect(f"/admin/schedules/{schedule.id}") + + +@router.post("/admin/schedules/{schedule_id}/clear-from") +def admin_clear_from( + schedule_id: int, + request: Request, + year: int = Form(...), + month: int = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _require_admin(request, db) + _check_csrf(request, csrf_token) + schedule = _get_schedule(db, schedule_id) + if schedule.schedule_type != "rotation": + raise HTTPException(status_code=400, detail="Esta escala não usa rotação") + year, month = _parse_month(year, month) + first = date(year, month, 1) + cleared = _clear_assignments_from(db, schedule, first) + _flash( + request, + f"Turnos limpos a partir de {day_numeric_long(first)}: {cleared} turno(s) sem atribuição. " + f"As atribuições manuais e as trocas foram mantidas. Use Aplicar rotação para os preencher de novo.", + ) + return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}") + + +@router.post("/admin/schedules/{schedule_id}/swaps/{swap_id}/assign") +def admin_schedule_assign_swap( + schedule_id: int, + swap_id: int, + request: Request, + team_id: int = Form(...), + year: str = Form(""), + month: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + """Complete a change request on behalf of the rota, including for rota-only teams.""" + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + schedule = _get_schedule(db, schedule_id) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + ) + ) + if not swap or swap.assignment.schedule_id != schedule.id: + raise HTTPException(status_code=404, detail="Pedido de troca não encontrado") + if swap.status not in {"open", "pending_approval"}: + raise HTTPException(status_code=400, detail="Este pedido já não está aberto") + new_team = _swap_partner(db, schedule, team_id) + if new_team.id == swap.requester_id: + raise HTTPException(status_code=400, detail="Escolha outra equipa para a troca") + if swap.assignment.date < date.today(): + raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") + # The request only applies while the team who asked for the change still owns the shift. + if not _swap_ownership_holds(db, swap): + swap.status = "cancelled" + db.commit() + raise HTTPException(status_code=409, detail="O turno já foi alterado") + + swap.assignment.team_id = new_team.id + swap.assignment.source = "swap" + swap.accepted_team_id = new_team.id + swap.status = "approved" + for other in db.scalars( + select(SwapRequest).where( + SwapRequest.assignment_id == swap.assignment_id, + SwapRequest.id != swap.id, + SwapRequest.status.in_(["open", "pending_approval"]), + ) + ).all(): + other.status = "cancelled" + db.commit() + + subject = "Troca de turno aprovada" + body = ( + f"{_user_label(admin)} atribuiu o turno de {schedule.name} de " + f"{day_numeric_long(swap.assignment.date)} a {new_team.name}, a pedido de {swap.requester.name}." + + ( + f" Em troca, {swap.requester.name} passa a ter o turno de " + f"{day_numeric_long(swap.target_assignment.date)}." + if swap.target_assignment is not None + else "" + ) + + f"\n\nVer a escala: {settings.normalized_base_url}/" + ) + send_many(db, [swap.requester, new_team], "swap_approved", subject, body) + _flash(request, f"Turno de {day_short(swap.assignment.date)} atribuído a {new_team.name}.") + return _redirect(f"{_schedule_page(schedule.id, year, month)}#swap-requests") + + +def _schedule_page(schedule_id: int, year: str, month: str) -> str: + if year.isdigit() and month.isdigit(): + return f"/admin/schedules/{schedule_id}?year={int(year)}&month={int(month)}" + return f"/admin/schedules/{schedule_id}" + + +@router.post("/admin/schedules/{schedule_id}/assignments") +async def admin_schedule_assignments( + schedule_id: int, + request: Request, + db: Session = Depends(get_db), +): + _require_admin(request, db) + form = await request.form() + _check_csrf(request, str(form.get("csrf_token", ""))) + schedule = _get_schedule(db, schedule_id) + year = int(form.get("year", date.today().year)) + month = int(form.get("month", date.today().month)) + assignments = [row for row in _month_assignments(db, year, month) if row.schedule_id == schedule.id] + changed = _save_assignments(db, assignments, form) + _flash(request, f"Foram guardadas {changed} alteração(ões) de turno.") + return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}") + + +@router.post("/admin/schedules/{schedule_id}/apply-rotation") +def admin_apply_rotation( + schedule_id: int, + request: Request, + year: int = Form(...), + month: int = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _require_admin(request, db) + _check_csrf(request, csrf_token) + schedule = _get_schedule(db, schedule_id) + if schedule.schedule_type != "rotation": + raise HTTPException(status_code=400, detail="Esta escala não usa rotação") + year, month = _parse_month(year, month) + created, changed = _generate_rotation_range(db, schedule, *month_bounds(year, month)) + _flash(request, f"Rotação aplicada em {month_name(month)} de {year}: " + f"{created} turno(s) criado(s), {changed} turno(s) atualizado(s). " + f"As atribuições manuais e as trocas foram mantidas.") + return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}") + + +@router.post("/admin/schedules/{schedule_id}/assignments/{assignment_id}/regenerate") +@router.post("/admin/schedules/{schedule_id}/assignments/{assignment_id}/use-rotation") +def admin_assignment_regenerate( + schedule_id: int, + assignment_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _require_admin(request, db) + _check_csrf(request, csrf_token) + schedule = _get_schedule(db, schedule_id) + if schedule.schedule_type != "rotation": + raise HTTPException(status_code=400, detail="Esta escala não usa rotação") + assignment = db.get(Assignment, assignment_id) + if assignment is None or assignment.schedule_id != schedule.id: + raise HTTPException(status_code=404, detail="Turno não encontrado") + if assignment.date.weekday() not in schedule.weekday_set: + raise HTTPException(status_code=400, detail="Esta data não corresponde à escala") + _apply_rotation_to_rows(db, schedule, [assignment]) + _flash(request, "Turno recalculado com a equipa a seguir à responsável pelo turno anterior.") + return _redirect(f"/admin/schedules/{schedule.id}?year={assignment.date.year}&month={assignment.date.month}") + + +@router.get("/admin/assign", response_class=HTMLResponse) +def admin_assign_day( + request: Request, + on: str | None = None, + db: Session = Depends(get_db), +): + """Pick a day and put someone on one of its shifts. No change request involved. + + `on` is a whole ISO date (`YYYY-MM-DD`), which is what the date input sends. + """ + _require_admin(request, db) + today = date.today() + chosen = today + if on: + try: + chosen = date.fromisoformat(on) + except ValueError: + raise HTTPException(status_code=400, detail="Data inválida") + rows = { + a.schedule_id: a + for a in ensure_month_assignments(db, chosen.year, chosen.month) + if a.date == chosen + } + shifts = [ + { + "schedule": schedule, + "assignment": rows[schedule.id], + "auto": _default_team(db, schedule, chosen), + } + for schedule in _active_schedules(db) + if schedule.id in rows + ] + prev_day = chosen - timedelta(days=1) + next_day = chosen + timedelta(days=1) + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_assign.html", + context=_context( + request, + db, + day=chosen, + shifts=shifts, + teams=db.scalars( + select(Team).where(Team.is_active.is_(True)).order_by(Team.name) + ).all(), + prev_day=prev_day, + next_day=next_day, + today=today, + ), + ) + + +@router.post("/admin/assign") +def admin_assign_team( + request: Request, + assignment_id: int = Form(...), + team_id: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + assignment = db.scalar( + select(Assignment) + .where(Assignment.id == assignment_id) + .options(selectinload(Assignment.schedule), selectinload(Assignment.team)) + ) + if not assignment: + raise HTTPException(status_code=404, detail="Turno não encontrado") + if assignment.date.weekday() not in assignment.schedule.weekday_set: + raise HTTPException(status_code=400, detail="Esta data não corresponde à escala") + + choice = team_id.strip() + source = "manual" + new_team = None + if choice == "auto": + source = "generated" + new_team = _default_team(db, assignment.schedule, assignment.date) + elif choice: + new_team = db.get(Team, int(choice)) + if not new_team or not new_team.is_active: + raise HTTPException(status_code=400, detail="Equipa inválida") + if new_team and new_team.id == assignment.team_id and assignment.source == source: + _flash(request, f"{new_team.name} já estava em {day_short(assignment.date)}.") + return _redirect(_assign_day_url(assignment.date)) + + previous_team_id = assignment.team_id + _set_assignee(db, assignment, new_team, source) + previous_team = db.get(Team, previous_team_id) if previous_team_id else None + _notify_assignment_changes(db, [(assignment, previous_team, new_team)]) + if new_team is None: + message = f"Turno de {day_short(assignment.date)} sem atribuição." + else: + message = f"{new_team.name} em {day_short(assignment.date)} · {assignment.schedule.name}." + if source == "generated": + message += " (automático)" + _flash(request, message) + return _redirect(_assign_day_url(assignment.date)) + + +def _assign_day_url(day: date) -> str: + return f"/admin/assign?on={day.isoformat()}" diff --git a/apps/shifts/backend/app/routers/admin/notifications.py b/apps/shifts/backend/app/routers/admin/notifications.py new file mode 100644 index 0000000..acea472 --- /dev/null +++ b/apps/shifts/backend/app/routers/admin/notifications.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload +from ...database import get_db +from ...models import NotificationLog +from ..dependencies import _context, _require_admin + +router = APIRouter() + +@router.get("/admin/notifications", response_class=HTMLResponse) +def admin_notifications(request: Request, db: Session = Depends(get_db)): + _require_admin(request, db) + logs = db.scalars( + select(NotificationLog) + .options(selectinload(NotificationLog.team)) + .order_by(NotificationLog.created_at.desc()) + .limit(200) + ).all() + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_notifications.html", + context=_context(request, db, logs=logs), + ) diff --git a/apps/shifts/backend/app/routers/admin/schedules.py b/apps/shifts/backend/app/routers/admin/schedules.py new file mode 100644 index 0000000..ce4b1fa --- /dev/null +++ b/apps/shifts/backend/app/routers/admin/schedules.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +from datetime import time +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload +from ...database import get_db +from ...models import Assignment, MonthlyPattern, RotationMember, Schedule, SwapRequest, Team +from ...i18n import MONTHS, month_name +from ...services.scheduling import ensure_month_assignments +from ..assignment_helpers import _apply_pattern_to_open_rows +from ..dependencies import _check_csrf, _context, _flash, _month_nav, _parse_month, _redirect, _require_admin +from ..schedule_helpers import _all_schedules, _get_schedule, _pattern_map, _render_admin_schedules, _schedule_card, _schedule_groups, _schedule_teams + +router = APIRouter() + +def _parse_time(value: str) -> time: + try: + return time.fromisoformat(value.strip()) + except ValueError: + raise HTTPException(status_code=400, detail="Hora inválida") + + +@router.get("/admin/schedules", response_class=HTMLResponse) +def admin_schedules( + request: Request, + schedule_id: int | None = None, + db: Session = Depends(get_db), +): + _require_admin(request, db) + return _render_admin_schedules(request, db, schedule_id) + + +@router.get("/admin/schedules/{schedule_id}", response_class=HTMLResponse) +def admin_schedule( + schedule_id: int, + request: Request, + year: int | None = None, + month: int | None = None, + db: Session = Depends(get_db), +): + _require_admin(request, db) + schedule = _get_schedule(db, schedule_id) + teams = db.scalars(select(Team).where(Team.is_active.is_(True)).order_by(Team.name)).all() + year, month = _parse_month(year, month) + assignments: list[Assignment] = [] + prev = nxt = (year, month) + if schedule.schedule_type == "rotation": + assignments = [ + row for row in ensure_month_assignments(db, year, month) if row.schedule_id == schedule.id + ] + prev, nxt = _month_nav(year, month) + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_schedule.html", + context=_context( + request, + db, + card=_schedule_card(schedule, db), + cards=_schedule_groups(_all_schedules(db), db), + teams=teams, + schedule_teams=_schedule_teams(db, schedule.id), + assignments=assignments, + swap_requests=_open_swap_requests(db, schedule.id), + pattern_days=list(range(1, 32)), + pattern=_pattern_map(db, schedule), + months=MONTHS, + year=year, + month=month, + month_name=month_name(month), + prev=prev, + nxt=nxt, + ), + ) + + +def _open_swap_requests(db: Session, schedule_id: int) -> list[SwapRequest]: + """Open and pending change requests for one schedule, oldest first.""" + return list( + db.scalars( + select(SwapRequest) + .where( + SwapRequest.assignment_id.in_( + select(Assignment.id).where(Assignment.schedule_id == schedule_id) + ), + SwapRequest.status.in_(["open", "pending_approval"]), + ) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + selectinload(SwapRequest.target_team), + selectinload(SwapRequest.accepted_by), + ) + .order_by(SwapRequest.created_at) + ).all() + ) + + +@router.post("/admin/schedules/{schedule_id}/pattern") +async def admin_schedule_pattern( + schedule_id: int, + request: Request, + db: Session = Depends(get_db), +): + _require_admin(request, db) + form = await request.form() + _check_csrf(request, str(form.get("csrf_token", ""))) + schedule = _get_schedule(db, schedule_id) + if schedule.schedule_type == "rotation": + raise HTTPException(status_code=400, detail="As rotações usam a sua própria ordem, não um padrão mensal") + teams_by_id = {t.id for t in db.scalars(select(Team).where(Team.is_active.is_(True))).all()} + + existing = { + row.day_of_month: row + for row in db.scalars( + select(MonthlyPattern).where(MonthlyPattern.schedule_id == schedule.id) + ).all() + } + saved = 0 + for day in range(1, 32): + raw = str(form.get(f"day_{day}", "")).strip() + team_id = int(raw) if raw else None + if team_id is not None and team_id not in teams_by_id: + continue + row = existing.get(day) + if row is None: + if team_id is None: + continue + db.add(MonthlyPattern(schedule_id=schedule.id, day_of_month=day, team_id=team_id)) + saved += 1 + elif row.team_id != team_id: + row.team_id = team_id + saved += 1 + db.commit() + touched = _apply_pattern_to_open_rows(db, schedule) + _flash( + request, + f"Padrão de noites guardado ({saved} alteração(ões)). " + f"Foram atualizados {touched} turno(s) sem atribuição neste mês e no próximo; " + f"as noites já atribuídas foram mantidas.", + ) + return _redirect(f"/admin/schedules/{schedule.id}") + + +@router.post("/admin/schedules/{schedule_id}/settings") +def admin_schedule_settings( + schedule_id: int, + request: Request, + requires_manager_approval: str = Form(""), + is_active: str = Form(""), + start_time: str = Form(""), + end_time: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + _require_admin(request, db) + schedule = db.get(Schedule, schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail="Escala não encontrada") + if start_time and end_time: + schedule.start_time = _parse_time(start_time) + schedule.end_time = _parse_time(end_time) + schedule.requires_manager_approval = bool(requires_manager_approval) + schedule.is_active = bool(is_active) + db.commit() + _flash(request, "Definições da escala guardadas.") + return _redirect(f"/admin/schedules/{schedule.id}") + + +@router.post("/admin/schedules/{schedule_id}/rotation") +def admin_rotation_add( + schedule_id: int, + request: Request, + team_id: int = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + _require_admin(request, db) + schedule = db.get(Schedule, schedule_id) + user = db.get(Team, team_id) + if not schedule or schedule.schedule_type != "rotation" or not user or not user.is_active: + raise HTTPException(status_code=400, detail="Membro da rotação inválido") + exists = db.scalar( + select(RotationMember.id).where( + RotationMember.schedule_id == schedule.id, + RotationMember.team_id == user.id, + ) + ) + if exists: + _flash(request, "Essa equipa já está nesta rotação.", "error") + return _redirect(f"/admin/schedules/{schedule.id}#rotation-order") + max_position = db.scalar( + select(func.max(RotationMember.position)).where(RotationMember.schedule_id == schedule.id) + ) or 0 + db.add(RotationMember(schedule_id=schedule.id, team_id=user.id, position=max_position + 1)) + db.commit() + _flash(request, "Membro adicionado à rotação.") + return _redirect(f"/admin/schedules/{schedule.id}#rotation-order") + + +@router.post("/admin/rotation/{member_id}/remove") +def admin_rotation_remove( + member_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + _require_admin(request, db) + member = db.get(RotationMember, member_id) + if not member: + raise HTTPException(status_code=404, detail="Membro da rotação não encontrado") + schedule_id = member.schedule_id + db.delete(member) + db.commit() + members = db.scalars( + select(RotationMember).where(RotationMember.schedule_id == schedule_id).order_by(RotationMember.position) + ).all() + for idx, row in enumerate(members, start=1): + row.position = idx + db.commit() + _flash(request, "Membro removido da rotação.") + return _redirect(f"/admin/schedules/{schedule_id}") + + +@router.post("/admin/rotation/{member_id}/move") +def admin_rotation_move( + member_id: int, + request: Request, + direction: str = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + _require_admin(request, db) + member = db.get(RotationMember, member_id) + if not member or direction not in {"up", "down"}: + raise HTTPException(status_code=400, detail="Movimento inválido") + target_position = member.position - 1 if direction == "up" else member.position + 1 + other = db.scalar( + select(RotationMember).where( + RotationMember.schedule_id == member.schedule_id, + RotationMember.position == target_position, + ) + ) + if other: + temporary = 1000000 + member.id + member.position = temporary + db.flush() + other.position = target_position + 1 if direction == "up" else target_position - 1 + db.flush() + member.position = target_position + db.commit() + return _redirect(f"/admin/schedules/{member.schedule_id}") + + +@router.get("/admin/month", response_class=HTMLResponse) +def admin_month(request: Request, db: Session = Depends(get_db)): + _require_admin(request, db) + # Each schedule is now configured on its own page. + return _redirect("/admin/schedules") diff --git a/apps/shifts/backend/app/routers/admin/teams.py b/apps/shifts/backend/app/routers/admin/teams.py new file mode 100644 index 0000000..e451334 --- /dev/null +++ b/apps/shifts/backend/app/routers/admin/teams.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session, selectinload +from ...database import get_db +from ...models import Assignment, MonthlyPattern, NotificationLog, RotationMember, SwapRequest, Team, User +from .user_helpers import _usable_admin_users +from ..dependencies import _check_csrf, _context, _flash, _redirect, _require_admin + +router = APIRouter() + +@router.get("/admin/teams", response_class=HTMLResponse) +def admin_teams(request: Request, db: Session = Depends(get_db)): + """Who works the rota. Users are not created here: a team only picks who signs in for it.""" + admin = _require_admin(request, db) + teams = db.scalars( + select(Team) + .options(selectinload(Team.user).selectinload(User.teams)) + .order_by(Team.name) + ).all() + removable_ids = {team.id for team in teams if _team_removal_blocker(db, admin, team) is None} + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_teams.html", + context=_context( + request, + db, + teams=teams, + removable_ids=removable_ids, + users_by_id=_users_by_id(db), + ), + ) + + +def _users_by_id(db: Session) -> dict[int, User]: + return {user.id: user for user in db.scalars(select(User).order_by(User.username)).all()} + + +@router.post("/admin/teams") +def admin_create_team( + request: Request, + name: str = Form(...), + email: str = Form(""), + phone: str = Form(""), + assigned_user_id: str = Form(""), + notify_email: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + """Add a team to the rota. The user who signs in for it is chosen here or later.""" + _check_csrf(request, csrf_token) + _require_admin(request, db) + email_value = email.strip().lower() or None + if email_value and db.scalar(select(Team.id).where(Team.email == email_value)): + _flash(request, "Esse endereço de e-mail já existe.", "error") + return _redirect("/admin/teams") + db.add( + Team( + name=name.strip(), + user=_chosen_user(db, assigned_user_id), + email=email_value, + phone=phone.strip() or None, + is_active=True, + notify_email=bool(notify_email), + ) + ) + db.commit() + _flash(request, "Equipa criada.") + return _redirect("/admin/teams") + + +def _chosen_user(db: Session, raw: str) -> User | None: + """The user picked in a form, or None for "nobody signs in for this team".""" + value = raw.strip() + if not value.isdigit(): + return None + user = db.get(User, int(value)) + if user is None: + raise HTTPException(status_code=400, detail="Utilizador inválido") + return user + + +@router.post("/admin/teams/{team_id}/update") +def admin_update_team( + team_id: int, + request: Request, + name: str = Form(...), + email: str = Form(""), + phone: str = Form(""), + assigned_user_id: str = Form(""), + is_active: str = Form(""), + notify_email: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + admin = _require_admin(request, db) + team = db.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Equipa não encontrada") + email_value = email.strip().lower() or None + duplicate = ( + db.scalar(select(Team.id).where(Team.email == email_value, Team.id != team.id)) + if email_value + else None + ) + if duplicate: + _flash(request, "Esse endereço de e-mail já está a ser usado.", "error") + return _redirect("/admin/teams") + + # Whoever is signed in must keep an active team to sign in with, not necessarily this one. + chosen = _chosen_user(db, assigned_user_id) + stays_active = bool(is_active) + if _loses_last_team(db, admin, team, chosen, stays_active): + _flash(request, "Não pode desativar a última equipa do utilizador com que está ligado.", "error") + return _redirect("/admin/teams") + + team.name = name.strip() + team.user = chosen + team.email = email_value + team.phone = phone.strip() or None + team.notify_email = bool(notify_email) + team.is_active = stays_active + db.commit() + _flash(request, "Equipa atualizada.") + return _redirect("/admin/teams") + + +def _loses_last_team( + db: Session, user: User, team: Team, chosen: User | None, stays_active: bool +) -> bool: + """True when this edit would leave the user without any active team to sign in with.""" + still_mine = chosen is not None and chosen.id == user.id and stays_active + if still_mine: + return False + return not db.scalar( + select(Team.id).where( + Team.user_id == user.id, Team.id != team.id, Team.is_active.is_(True) + ) + ) + + +def _team_removal_impact(db: Session, team: Team) -> dict[str, int]: + return { + "assignments": db.scalar( + select(func.count(Assignment.id)).where(Assignment.team_id == team.id) + ) or 0, # must be zero for the removal to be allowed + "rotation": db.scalar( + select(func.count(RotationMember.id)).where(RotationMember.team_id == team.id) + ) or 0, + "pattern_days": db.scalar( + select(func.count(MonthlyPattern.id)).where(MonthlyPattern.team_id == team.id) + ) or 0, + "requests": db.scalar( + select(func.count(SwapRequest.id)).where( + SwapRequest.status.in_(["open", "pending_approval"]), + ( + (SwapRequest.requester_id == team.id) + | (SwapRequest.target_team_id == team.id) + | (SwapRequest.accepted_team_id == team.id) + ), + ) + ) or 0, + } + + +def _team_removal_blocker(db: Session, admin: User, team: Team) -> str | None: + """Why this team cannot be removed, or None when the removal is allowed.""" + user = team.user + if user is not None and user.id == admin.id and not db.scalar( + select(Team.id).where(Team.user_id == user.id, Team.id != team.id) + ): + return "Não pode remover a si próprio(a)" + if user is not None and user.is_admin and not _usable_admin_users(db, exclude=user): + return "Não pode remover a equipa do último administrador" + if db.scalar(select(Assignment.id).where(Assignment.team_id == team.id)): + return "Não pode remover uma equipa que ainda tem turnos atribuídos" + return None + + +def _require_removable(db: Session, admin: User, team: Team) -> None: + reason = _team_removal_blocker(db, admin, team) + if reason: + raise HTTPException(status_code=400, detail=reason) + + +@router.get("/admin/teams/{team_id}/delete", response_class=HTMLResponse) +def admin_team_delete_page( + team_id: int, + request: Request, + db: Session = Depends(get_db), +): + admin = _require_admin(request, db) + team = db.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Equipa não encontrada") + _require_removable(db, admin, team) + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_team_delete.html", + context=_context(request, db, team=team, impact=_team_removal_impact(db, team)), + ) + + +@router.post("/admin/teams/{team_id}/delete") +def admin_team_delete( + team_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + team = db.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Equipa não encontrada") + _require_removable(db, admin, team) + + # No assignment can survive here: _require_removable refuses while the team holds one. + for swap in db.scalars( + select(SwapRequest).where(SwapRequest.requester_id == team.id) + ).all(): + db.delete(swap) + for swap in db.scalars( + select(SwapRequest).where( + (SwapRequest.target_team_id == team.id) | (SwapRequest.accepted_team_id == team.id) + ) + ).all(): + if swap.target_team_id == team.id: + swap.target_team_id = None + if swap.accepted_team_id == team.id: + swap.accepted_team_id = None + for pattern in db.scalars( + select(MonthlyPattern).where(MonthlyPattern.team_id == team.id) + ).all(): + pattern.team_id = None + for log in db.scalars( + select(NotificationLog).where(NotificationLog.team_id == team.id) + ).all(): + log.team_id = None + db.execute(delete(RotationMember).where(RotationMember.team_id == team.id)) + db.delete(team) + db.commit() + return _redirect("/admin/teams") diff --git a/apps/shifts/backend/app/routers/admin/user_helpers.py b/apps/shifts/backend/app/routers/admin/user_helpers.py new file mode 100644 index 0000000..0425d7e --- /dev/null +++ b/apps/shifts/backend/app/routers/admin/user_helpers.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload +from ...models import Team, User + +def _admin_teams(db: Session) -> list[Team]: + """Every active team whose user is an administrator.""" + return list( + db.scalars( + select(Team) + .join(User, Team.user_id == User.id) + .where( + User.is_admin.is_(True), + User.is_active.is_(True), + Team.is_active.is_(True), + ) + .order_by(Team.name) + ).all() + ) + + +def _usable_admin_users(db: Session, exclude: User | None = None) -> list[User]: + """Administrator users that can still sign in: active, and covering an active team.""" + users = db.scalars( + select(User) + .where(User.is_admin.is_(True), User.is_active.is_(True)) + .options(selectinload(User.teams)) + ).all() + return [ + user + for user in users + if user.id != (exclude.id if exclude else None) + and any(team.is_active for team in user.teams) + ] diff --git a/apps/shifts/backend/app/routers/admin/users.py b/apps/shifts/backend/app/routers/admin/users.py new file mode 100644 index 0000000..f66385f --- /dev/null +++ b/apps/shifts/backend/app/routers/admin/users.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from collections.abc import Sequence +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload +from ...database import get_db +from ...models import Team, User +from ...security import hash_password +from .user_helpers import _usable_admin_users +from ..dependencies import _check_csrf, _context, _flash, _redirect, _require_admin + +router = APIRouter() + +@router.get("/admin/users", response_class=HTMLResponse) +def admin_users(request: Request, db: Session = Depends(get_db)): + """The users: created here, then assigned to the teams they sign in for.""" + admin = _require_admin(request, db) + users = db.scalars( + select(User).options(selectinload(User.teams)).order_by(User.username) + ).all() + removable_ids = {user.id for user in users if _user_removal_blocker(db, admin, user) is None} + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_users.html", + context=_context( + request, + db, + users=users, + removable_ids=removable_ids, + teams=db.scalars(select(Team).order_by(Team.name)).all(), + ), + ) + + +def _user_removal_blocker(db: Session, admin: User, user: User) -> str | None: + """Why this user cannot be removed, or None when the removal is allowed.""" + teams = db.scalars(select(Team).where(Team.user_id == user.id).order_by(Team.name)).all() + if teams: + return f"Está atribuído a {len(teams)} equipa(s)" + if user.id == admin.id: + return "Não pode remover o seu próprio acesso" + if user.is_admin and not _usable_admin_users(db, exclude=user): + return "Não pode remover o último administrador" + return None + + +@router.post("/admin/users") +def admin_create_user( + request: Request, + name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + team_ids: list[str] = Form([]), + is_admin: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + """A new user, assigned to the teams it will sign in for.""" + _check_csrf(request, csrf_token) + _require_admin(request, db) + user, error = _new_user(db, name, username, password, is_admin=bool(is_admin)) + if error: + _flash(request, error, "error") + return _redirect("/admin/users") + db.add(user) + db.commit() + _assign_user_to_teams(db, user, team_ids) + db.commit() + _flash(request, "Utilizador criado.") + return _redirect("/admin/users") + + +def _new_user( + db: Session, name: str, username: str, password: str, *, is_admin: bool +) -> tuple[User | None, str | None]: + """A user with a username and a password, unsaved, or the reason it was refused.""" + value = username.strip().lower() + if not value: + return None, "Defina um nome de utilizador." + if db.scalar(select(User.id).where(User.username == value)): + return None, "Esse nome de utilizador já existe." + try: + password_hash = hash_password(password) + except ValueError as exc: + return None, str(exc) + return ( + User( + name=name.strip() or value, + username=value, + password_hash=password_hash, + is_admin=is_admin, + is_active=True, + ), + None, + ) + + +def _assign_user_to_teams(db: Session, user: User, team_ids: Sequence[str]) -> None: + """Attach this user to exactly the teams the form selected.""" + chosen = {int(value) for value in team_ids if str(value).isdigit()} + for team in db.scalars(select(Team)).all(): + if (team.id in chosen) != (team.user_id == user.id): + team.user = user if team.id in chosen else None + + +@router.post("/admin/users/{user_id}/update") +def admin_update_user( + user_id: int, + request: Request, + name: str = Form(...), + username: str = Form(...), + password: str = Form(""), + team_ids: list[str] = Form([]), + is_admin: str = Form(""), + is_active: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + admin = _require_admin(request, db) + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="Utilizador não encontrado") + username_value = username.strip().lower() + if not username_value: + _flash(request, "O nome de utilizador não pode ficar vazio.", "error") + return _redirect("/admin/users") + duplicate = db.scalar( + select(User.id).where(User.username == username_value, User.id != user.id) + ) + if duplicate: + _flash(request, "Esse nome de utilizador já está a ser usado.", "error") + return _redirect("/admin/users") + loses_admin = user.is_admin and not is_admin + loses_access = user.id == admin.id and not is_active + last_admin = loses_admin and not _usable_admin_users(db, exclude=user) + if last_admin: + _flash(request, "Não pode tirar a administração ao último utilizador que a tem.", "error") + return _redirect("/admin/users") + if loses_access: + _flash(request, "Não pode desativar o acesso com que está ligado.", "error") + return _redirect("/admin/users") + if password.strip(): + try: + user.password_hash = hash_password(password.strip()) + except ValueError as exc: + _flash(request, str(exc), "error") + return _redirect("/admin/users") + + user.name = name.strip() or username_value + user.username = username_value + user.is_admin = bool(is_admin) + user.is_active = True if user.id == admin.id else bool(is_active) + db.commit() + _assign_user_to_teams(db, user, team_ids) + db.commit() + _flash(request, "Utilizador atualizado.") + return _redirect("/admin/users") + + +@router.post("/admin/users/{user_id}/delete") +def admin_user_delete( + user_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + admin = _require_admin(request, db) + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="Utilizador não encontrado") + reason = _user_removal_blocker(db, admin, user) + if reason: + _flash(request, f"Não é possível remover este utilizador: {reason}.", "error") + return _redirect("/admin/users") + db.delete(user) + db.commit() + _flash(request, "Utilizador removido.") + return _redirect("/admin/users") diff --git a/apps/shifts/backend/app/routers/assignment_helpers.py b/apps/shifts/backend/app/routers/assignment_helpers.py new file mode 100644 index 0000000..1680dad --- /dev/null +++ b/apps/shifts/backend/app/routers/assignment_helpers.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import calendar +from collections.abc import Sequence +from datetime import date, timedelta +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload +from ..config import settings +from ..models import Assignment, Schedule, SwapRequest, Team +from ..i18n import day_numeric_long, day_short +from ..services.notifications import send_email_notification +from ..services.scheduling import month_bounds, pattern_teams, rotation_team_for_date +from .schedule_helpers import _pattern_map + +def _apply_pattern_to_open_rows(db: Session, schedule: Schedule) -> int: + """Fill empty generated nights from the pattern; never touch existing assignees.""" + patterns = _pattern_map(db, schedule) + if not patterns: + return 0 + today = date.today() + last_month = (today.month % 12) + 1 + last_year = today.year + (1 if today.month == 12 else 0) + rows = db.scalars( + select(Assignment).where( + Assignment.schedule_id == schedule.id, + Assignment.team_id.is_(None), + Assignment.source == "generated", + Assignment.date >= today.replace(day=1), + Assignment.date <= date(last_year, last_month, calendar.monthrange(last_year, last_month)[1]), + ) + ).all() + touched = 0 + for row in rows: + team_id = patterns.get(row.date.day) + if team_id and row.team_id != team_id: + row.team_id = team_id + touched += 1 + if touched: + db.commit() + return touched + + +def _apply_pattern_forward(db: Session, schedule: Schedule, first: date) -> int: + """Explicit admin edit: push the monthly pattern onto generated assignments from `first` on. + + Never creates or deletes rows, keeps assignment IDs, and preserves manual + assignments and completed swaps. A day with nobody in the pattern is left as + it is, so an empty pattern entry cannot blank out a shift. + """ + patterns = pattern_teams(db, schedule) + if not patterns: + return 0 + rows = db.scalars( + select(Assignment) + .where( + Assignment.schedule_id == schedule.id, + Assignment.date >= first, + Assignment.source == "generated", + ) + .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) + .order_by(Assignment.date) + ).all() + changed: list[tuple[Assignment, Team | None, Team | None]] = [] + touched = 0 + for row in rows: + team_id = patterns.get(row.date.day) + if team_id is None or row.team_id == team_id: + continue + old_team = db.get(Team, row.team_id) if row.team_id is not None else None + new_team = db.get(Team, team_id) + changed.append((row, old_team, new_team)) + for swap in db.scalars( + select(SwapRequest).where( + SwapRequest.assignment_id == row.id, + SwapRequest.status.in_(["open", "pending_approval"]), + ) + ).all(): + swap.status = "cancelled" + row.team_id = team_id + row.team = new_team + touched += 1 + if changed: + db.commit() + _notify_assignment_changes(db, changed) + return touched + + +def _clear_assignments_from(db: Session, schedule: Schedule, first: date) -> int: + """Explicit admin edit: leave the shifts from `first` on without an assignee. + + Only `generated` rows are touched, so manual assignments and completed swaps + survive. Rows keep their IDs, notes and swap history and stay `generated`, so + applying the rotation again fills them. + """ + rows = db.scalars( + select(Assignment) + .where( + Assignment.schedule_id == schedule.id, + Assignment.date >= first, + Assignment.source == "generated", + Assignment.team_id.is_not(None), + ) + .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) + .order_by(Assignment.date) + ).all() + changed: list[tuple[Assignment, Team | None, Team | None]] = [] + for row in rows: + old_team = db.get(Team, row.team_id) + changed.append((row, old_team, None)) + for swap in db.scalars( + select(SwapRequest).where( + SwapRequest.assignment_id == row.id, + SwapRequest.status.in_(["open", "pending_approval"]), + ) + ).all(): + swap.status = "cancelled" + row.team_id = None + row.team = None + if changed: + db.commit() + _notify_assignment_changes(db, changed) + return len(changed) + + +def _save_assignments(db: Session, assignments: Sequence[Assignment], form) -> int: + teams_by_id = {t.id: t for t in db.scalars(select(Team).where(Team.is_active.is_(True))).all()} + changed: list[tuple[Assignment, Team | None, Team | None]] = [] + for assignment in assignments: + raw = str(form.get(f"assignment_{assignment.id}", "")).strip() + new_team_id = int(raw) if raw else None + if new_team_id is not None and new_team_id not in teams_by_id: + continue + if assignment.team_id != new_team_id: + old_team = assignment.team + new_team = teams_by_id.get(new_team_id) if new_team_id else None + assignment.team_id = new_team_id + assignment.source = "manual" + changed.append((assignment, old_team, new_team)) + db.commit() + + _notify_assignment_changes(db, changed) + return len(changed) + + +def _default_team(db: Session, schedule: Schedule, day: date) -> Team | None: + """Who this shift would have by default: the rotation order or the night pattern.""" + if schedule.schedule_type == "rotation": + return rotation_team_for_date(db, schedule, day) + team_id = _pattern_map(db, schedule).get(day.day) + return db.get(Team, team_id) if team_id else None + + +def _set_assignee( + db: Session, assignment: Assignment, new_team: Team | None, source: str = "manual" +) -> None: + """Put someone on a shift directly: no change request, and any stale one is closed.""" + assignment.team_id = new_team.id if new_team else None + assignment.team = new_team + assignment.source = source + for swap in db.scalars( + select(SwapRequest).where( + SwapRequest.status.in_(["open", "pending_approval"]), + (SwapRequest.assignment_id == assignment.id) + | (SwapRequest.target_assignment_id == assignment.id), + ) + ).all(): + swap.status = "cancelled" + db.commit() + + +def _notify_assignment_changes(db: Session, changed: Sequence[tuple[Assignment, Team | None, Team | None]]) -> None: + for assignment, old_team, new_team in changed: + subject = f"Escala atualizada: {day_short(assignment.date)} – {assignment.schedule.name}" + if new_team: + send_email_notification( + db, + new_team, + "assignment_updated", + subject, + f"Está atribuído ao turno de {assignment.schedule.name} a {day_numeric_long(assignment.date)}.\n\n" + f"Ver a escala: {settings.normalized_base_url}/", + ) + if old_team and (not new_team or old_team.id != new_team.id): + send_email_notification( + db, + old_team, + "assignment_changed", + subject, + f"Já não está atribuído ao turno de {assignment.schedule.name} a {day_numeric_long(assignment.date)}.\n\n" + f"Ver a escala: {settings.normalized_base_url}/", + ) + + +def _month_assignments(db: Session, year: int, month: int) -> list[Assignment]: + first, last = month_bounds(year, month) + return list( + db.scalars( + select(Assignment) + .where(Assignment.date >= first, Assignment.date <= last) + .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) + .order_by(Assignment.date) + ).all() + ) + + +def _apply_rotation_to_rows(db: Session, schedule: Schedule, assignments: Sequence[Assignment]) -> int: + """Explicit admin edit: retain assignment IDs and invalidate requests when ownership changes.""" + changed: list[tuple[Assignment, Team | None, Team | None]] = [] + touched = 0 + db.flush() + for assignment in sorted(assignments, key=lambda row: row.date): + new_team = rotation_team_for_date(db, schedule, assignment.date) + new_team_id = new_team.id if new_team else None + if assignment.team_id == new_team_id and assignment.source == "generated": + continue + if assignment.team_id != new_team_id: + old_team = db.get(Team, assignment.team_id) if assignment.team_id is not None else None + changed.append((assignment, old_team, new_team)) + if assignment.id is not None: + requests = db.scalars(select(SwapRequest).where( + SwapRequest.assignment_id == assignment.id, + SwapRequest.status.in_(["open", "pending_approval"]), + )).all() + for swap in requests: + swap.status = "cancelled" + assignment.team_id = new_team_id + assignment.team = new_team + assignment.source = "generated" + # Later dates continue from the team selected for this date. + db.flush() + touched += 1 + db.commit() + _notify_assignment_changes(db, changed) + return touched + + +def _generate_rotation_range(db: Session, schedule: Schedule, first: date, last: date) -> tuple[int, int]: + rows = {row.date: row for row in db.scalars( + select(Assignment) + .where(Assignment.schedule_id == schedule.id, Assignment.date >= first, Assignment.date <= last) + .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) + ).all()} + assignments = [] + created = 0 + day = first + while day <= last: + if day.weekday() not in schedule.weekday_set: + day += timedelta(days=1) + continue + row = rows.get(day) + if row is None: + row = Assignment(schedule=schedule, date=day, source="generated") + db.add(row) + created += 1 + if row.source == "generated": + assignments.append(row) + day += timedelta(days=1) + changed = _apply_rotation_to_rows(db, schedule, assignments) + return created, changed diff --git a/apps/shifts/backend/app/routers/auth.py b/apps/shifts/backend/app/routers/auth.py new file mode 100644 index 0000000..5a68da4 --- /dev/null +++ b/apps/shifts/backend/app/routers/auth.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import func, select +from sqlalchemy.orm import Session +from ..database import get_db +from ..models import User +from ..security import verify_password +from .dependencies import _check_csrf, _context, _current_user, _flash, _redirect, _user_label, _user_teams + +router = APIRouter() + +@router.get("/login", response_class=HTMLResponse) +def login_page(request: Request, db: Session = Depends(get_db)): + if _current_user(request, db): + return _redirect("/") + return request.app.state.templates.TemplateResponse( + request=request, + name="login.html", + context=_context(request, db), + ) + + +@router.post("/login") +def login( + request: Request, + username: str = Form(...), + password: str = Form(...), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + user = db.scalar( + select(User).where(func.lower(User.username) == username.strip().lower()) + ) + if not user or not user.is_active or not verify_password(password, user.password_hash): + _flash(request, "Nome de utilizador ou palavra-passe inválidos.", "error") + return _redirect("/login") + request.session["user_id"] = user.id + teams = _user_teams(db, user) + who = ", ".join(team.name for team in teams) or _user_label(user) + _flash(request, f"Bem-vindo(a), {who}.") + return _redirect("/") + + +@router.post("/logout") +def logout(request: Request, csrf_token: str = Form(...)): + _check_csrf(request, csrf_token) + request.session.clear() + return _redirect("/login") diff --git a/apps/shifts/backend/app/routers/calendar.py b/apps/shifts/backend/app/routers/calendar.py new file mode 100644 index 0000000..a882a80 --- /dev/null +++ b/apps/shifts/backend/app/routers/calendar.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import secrets +from urllib.parse import urlencode +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse, Response +from sqlalchemy import select +from sqlalchemy.orm import Session +from ..services.calendar_feed import build_feed +from ..config import settings +from ..database import get_db +from ..models import Team +from .dependencies import _context, _require_user, _user_teams +from .schedule_helpers import _shared_calendar_url + +router = APIRouter() + +def _calendar_token(db: Session, team: Team) -> str: + """A private feed token for this team, created the first time it is needed.""" + if not team.calendar_token: + team.calendar_token = secrets.token_urlsafe(24) + db.commit() + return team.calendar_token + + +@router.get("/calendar/{token}.ics") +def calendar_feed( + token: str, + db: Session = Depends(get_db), +): + """Subscribe from any calendar app: one feed per team, plus a shared whole-rota feed.""" + team = db.scalar(select(Team).where(Team.calendar_token == token, Team.is_active.is_(True))) + shared = bool(settings.calendar_token) and token == settings.calendar_token + if team is None and not shared: + raise HTTPException(status_code=404, detail="Calendário não encontrado") + name = f"{settings.app_name} — {team.name}" if team else settings.app_name + return Response( + content=build_feed(db, team, name), + media_type="text/calendar; charset=utf-8", + headers={ + "Content-Disposition": 'inline; filename="rota.ics"', + "Cache-Control": "private, max-age=300", + }, + ) + + +@router.get("/calendar", response_class=HTMLResponse) +def calendar_page(request: Request, db: Session = Depends(get_db)): + """The links to subscribe, kept off the rota so that page stays about the shifts.""" + user = _require_user(request, db) + feeds = [ + { + "title": f"Turnos de {team.name}", + "url": f"{request.base_url.scheme}://{request.base_url.netloc}" + f"{settings.url(f'/calendar/{_calendar_token(db, team)}.ics')}", + } + for team in _user_teams(db, user) + if team.is_active + ] + if user.is_admin: + feeds.append( + { + "title": "Rota completa", + "url": _shared_calendar_url(request), + } + ) + for feed in feeds: + feed["webcal_url"] = "webcal://" + feed["url"].split("://", 1)[-1] + feed["google_url"] = "https://calendar.google.com/calendar/render?" + urlencode( + {"cid": feed["url"]} + ) + feed["outlook_url"] = "https://outlook.live.com/calendar/0/addfromweb?" + urlencode( + {"url": feed["url"], "name": feed["title"]} + ) + return request.app.state.templates.TemplateResponse( + request=request, + name="calendar.html", + context=_context(request, db, feeds=feeds), + ) diff --git a/apps/shifts/backend/app/routers/dashboard.py b/apps/shifts/backend/app/routers/dashboard.py new file mode 100644 index 0000000..4a58f1b --- /dev/null +++ b/apps/shifts/backend/app/routers/dashboard.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import calendar +from datetime import date +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload +from ..database import get_db +from ..models import Assignment, SwapRequest +from ..i18n import month_name +from ..services.scheduling import assignments_by_date, ensure_month_assignments +from .dependencies import _context, _current_user, _month_nav, _parse_month, _user_team_ids +from .schedule_helpers import _schedule_teams_map + +router = APIRouter() + +@router.get("/health") +def health(): + return {"status": "ok"} + + +@router.get("/", response_class=HTMLResponse) +def dashboard( + request: Request, + year: int | None = None, + month: int | None = None, + db: Session = Depends(get_db), +): + user = _current_user(request, db) + if user and not user.is_active: + user = None + my_ids = _user_team_ids(db, user) + year, month = _parse_month(year, month) + assignments = ensure_month_assignments(db, year, month) + grouped = assignments_by_date(assignments) + prev, nxt = _month_nav(year, month) + + my_assignment_ids = {a.id for a in assignments if a.team_id in my_ids} + open_swap_assignment_ids = set() + admin_requests: dict[int, SwapRequest] = {} + if my_ids: + open_swap_assignment_ids = set( + db.scalars( + select(SwapRequest.assignment_id).where( + SwapRequest.requester_id.in_(my_ids), + SwapRequest.status.in_(["open", "pending_approval"]), + ) + ).all() + ) + if user and user.is_admin: + # Administrators can settle any request on this month's rota. + admin_requests = { + request.assignment_id: request + for request in db.scalars( + select(SwapRequest) + .where( + SwapRequest.assignment_id.in_([a.id for a in assignments]), + SwapRequest.status.in_(["open", "pending_approval"]), + ) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + selectinload(SwapRequest.accepted_by), + ) + .order_by(SwapRequest.created_at) + ).all() + } + + today = date.today() + return request.app.state.templates.TemplateResponse( + request=request, + name="dashboard.html", + context=_context( + request, + db, + year=year, + month=month, + month_name=month_name(month), + month_days=[(date(year, month, d), grouped.get(date(year, month, d), [])) for d in range(1, calendar.monthrange(year, month)[1] + 1)], + grouped=grouped, + my_assignment_ids=my_assignment_ids, + open_swap_assignment_ids=open_swap_assignment_ids, + admin_requests=admin_requests, + prev=prev, + nxt=nxt, + schedule_teams=_schedule_teams_map( + db, {request.assignment.schedule_id for request in admin_requests.values()} + ), + today=today, + ), + ) diff --git a/apps/shifts/backend/app/routers/dependencies.py b/apps/shifts/backend/app/routers/dependencies.py new file mode 100644 index 0000000..2702020 --- /dev/null +++ b/apps/shifts/backend/app/routers/dependencies.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from datetime import date +from fastapi import HTTPException, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import select +from sqlalchemy.orm import Session +from ..config import settings +from ..models import Team, User +from ..security import new_csrf_token + +def _redirect(path: str, status_code: int = 303) -> RedirectResponse: + return RedirectResponse(settings.url(path), status_code=status_code) + + +def _current_user(request: Request, db: Session) -> User | None: + """The user the session belongs to. One user can be assigned to several teams.""" + user_id = request.session.get("user_id") + if not user_id: + return None + return db.get(User, int(user_id)) + + +def _require_user(request: Request, db: Session) -> User: + user = _current_user(request, db) + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="É necessário iniciar sessão") + return user + + +def _require_admin(request: Request, db: Session) -> User: + user = _require_user(request, db) + if not user.is_admin: + raise HTTPException(status_code=403, detail="É necessária a permissão de administrador") + return user + + +def _user_teams(db: Session, user: User | None) -> list[Team]: + """The teams assigned to this user. A user sees and controls the rota of all of them.""" + if user is None: + return [] + return list( + db.scalars(select(Team).where(Team.user_id == user.id).order_by(Team.name)).all() + ) + + +def _user_team_ids(db: Session, user: User | None) -> set[int]: + return {team.id for team in _user_teams(db, user)} + + +def _user_label(user: User) -> str: + """How a user is named in messages and e-mails.""" + return user.name or user.username + + +def _csrf(request: Request) -> str: + token = request.session.get("csrf") + if not token: + token = new_csrf_token() + request.session["csrf"] = token + return token + + +def _check_csrf(request: Request, token: str) -> None: + expected = request.session.get("csrf") + if not expected or token != expected: + raise HTTPException(status_code=400, detail="Token CSRF inválido") + + +def _flash(request: Request, message: str, level: str = "info") -> None: + request.session["flash"] = {"message": message, "level": level} + + +def _context(request: Request, db: Session, **extra): + user = _current_user(request, db) + teams = _user_teams(db, user) + return { + "request": request, + "settings": settings, + "current_user": user, + "current_teams": teams, + "my_team_ids": {team.id for team in teams}, + "csrf_token": _csrf(request), + "flash": request.session.pop("flash", None), + **extra, + } + + +def _parse_month(year: int | None, month: int | None) -> tuple[int, int]: + today = date.today() + y = year or today.year + m = month or today.month + if m < 1 or m > 12 or y < 2000 or y > 2100: + raise HTTPException(status_code=400, detail="Mês inválido") + return y, m + + +def _month_nav(year: int, month: int) -> tuple[tuple[int, int], tuple[int, int]]: + if month == 1: + prev = (year - 1, 12) + else: + prev = (year, month - 1) + if month == 12: + nxt = (year + 1, 1) + else: + nxt = (year, month + 1) + return prev, nxt + + +def _safe_back(request: Request, fallback: str) -> str: + """Return to the rota when the action was started there, never to an arbitrary URL.""" + back = str(request.query_params.get("back") or request.headers.get("referer") or "") + if back.startswith("/") and not back.startswith("//"): + return back.split("#")[0] + return fallback diff --git a/apps/shifts/backend/app/routers/exports.py b/apps/shifts/backend/app/routers/exports.py new file mode 100644 index 0000000..a17f48f --- /dev/null +++ b/apps/shifts/backend/app/routers/exports.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from datetime import date +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse, Response +from sqlalchemy.orm import Session +from ..database import get_db +from ..models import Assignment +from ..services.pdf import build_schedule_pdf +from ..services.scheduling import ensure_month_assignments, month_bounds +from .dependencies import _context, _redirect, _require_user +from .schedule_helpers import _active_schedules, _get_schedule, _render_admin_schedules + +router = APIRouter() + +@router.get("/export/schedule", response_class=HTMLResponse) +def export_schedule( + request: Request, + schedule_id: int | None = None, + db: Session = Depends(get_db), +): + """Choose a schedule and exact dates before downloading its PDF.""" + user = _require_user(request, db) + schedules = _active_schedules(db) + if not schedules: + raise HTTPException(status_code=404, detail="Não há escalas ativas") + if schedule_id is None: + schedule = schedules[0] + else: + schedule = _get_schedule(db, schedule_id) + if not schedule.is_active: + raise HTTPException(status_code=404, detail="Escala não encontrada") + + if user.is_admin: + return _redirect(f"/admin/schedules?schedule_id={schedule.id}#export-pdf") + + today = date.today() + start, end = month_bounds(today.year, today.month) + return request.app.state.templates.TemplateResponse( + request=request, + name="export_schedule.html", + context=_context( + request, + db, + schedules=schedules, + selected_schedule_id=schedule.id, + start=start.isoformat(), + end=end.isoformat(), + ), + ) + + +@router.get("/export/schedule.pdf") +def download_schedule_pdf( + request: Request, + schedule_id: int, + start_date: date, + end_date: date, + db: Session = Depends(get_db), +): + user = _require_user(request, db) + schedule = _get_schedule(db, schedule_id) + if not schedule.is_active: + raise HTTPException(status_code=404, detail="Escala não encontrada") + error = None + if not (2000 <= start_date.year <= 2100 and 2000 <= end_date.year <= 2100): + error = "Escolhe datas entre 2000 e 2100." + elif end_date < start_date: + error = "A data final deve ser igual ou posterior à data inicial." + elif (end_date.year - start_date.year) * 12 + end_date.month - start_date.month >= 12: + error = "Escolhe um intervalo de no máximo 12 meses." + if error: + if user.is_admin: + return _render_admin_schedules( + request, db, schedule.id, start_date, end_date, error, status_code=400, + ) + return request.app.state.templates.TemplateResponse( + request=request, + name="export_schedule.html", + status_code=400, + context=_context( + request, + db, + schedules=_active_schedules(db), + selected_schedule_id=schedule.id, + start=start_date.isoformat(), + end=end_date.isoformat(), + error=error, + ), + ) + + # Generate chronologically, keeping existing manual and swapped assignments. + first = start_date.year * 12 + start_date.month - 1 + last = end_date.year * 12 + end_date.month - 1 + assignments: list[Assignment] = [] + for index in range(first, last + 1): + year, month = divmod(index, 12) + assignments.extend( + row for row in ensure_month_assignments(db, year, month + 1) + if row.schedule_id == schedule.id and start_date <= row.date <= end_date + ) + filename = f"escala-{schedule.id}-{start_date.isoformat()}-{end_date.isoformat()}.pdf" + return Response( + content=build_schedule_pdf(schedule, assignments, start_date, end_date), + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "private, no-store", + }, + ) diff --git a/apps/shifts/backend/app/routers/schedule_helpers.py b/apps/shifts/backend/app/routers/schedule_helpers.py new file mode 100644 index 0000000..cc07257 --- /dev/null +++ b/apps/shifts/backend/app/routers/schedule_helpers.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from collections.abc import Sequence +import secrets +from datetime import date, timedelta +from fastapi import HTTPException, Request +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload +from ..config import settings +from ..models import Assignment, MonthlyPattern, RotationMember, Schedule, Team +from ..i18n import day_short, weekday_names +from ..services.scheduling import month_bounds +from .dependencies import _context + +ALL_WEEKDAYS = frozenset(range(7)) + +def _next_dates(weekdays: set[int], count: int = 6) -> list[str]: + today = date.today() + labels: list[str] = [] + day = today + while len(labels) < count: + if day.weekday() in weekdays: + labels.append(day_short(day)) + day += timedelta(days=1) + return labels + + +def _schedule_card(schedule: Schedule, db: Session | None = None) -> dict: + weekdays = schedule.weekday_set + fixed = schedule.schedule_type != "rotation" and weekdays == ALL_WEEKDAYS + if fixed: + kind_label = "Todos os dias · repete todos os meses" + weekday_label = "Todos os dias" + dates: list[str] = [] + else: + weekday_label = weekday_names(weekdays) + kind_label = f"Rotação · {weekday_label}" + dates = _next_dates(weekdays) + pattern: list[tuple[int, str]] = [] + if fixed and db is not None: + rows = db.scalars( + select(MonthlyPattern) + .where(MonthlyPattern.schedule_id == schedule.id) + .options(selectinload(MonthlyPattern.team)) + .order_by(MonthlyPattern.day_of_month) + ).all() + pattern = [ + (row.day_of_month, row.team.name) + for row in rows + if row.team is not None and row.team.is_active + ] + return { + "schedule": schedule, + "kind": "fixed" if fixed else "rotation", + "kind_label": kind_label, + "weekday_label": weekday_label, + "dates": dates, + "pattern": pattern, + "rotation_names": [member.team.name for member in schedule.rotation_members], + } + + +def _schedule_groups(schedules: Sequence[Schedule], db: Session | None = None) -> list[dict]: + cards = [_schedule_card(schedule, db) for schedule in schedules] + # Every rotation is independent: Saturday and Sunday lunch never share an order. + return sorted(cards, key=lambda card: (card["kind"] != "fixed", card["weekday_label"] != "Todos os dias")) + + +def _active_schedules(db: Session) -> list[Schedule]: + return list( + db.scalars( + select(Schedule) + .where(Schedule.is_active.is_(True)) + .options(selectinload(Schedule.rotation_members).selectinload(RotationMember.team)) + .order_by(Schedule.id) + ).all() + ) + + +def _all_schedules(db: Session) -> list[Schedule]: + return list( + db.scalars( + select(Schedule) + .options(selectinload(Schedule.rotation_members).selectinload(RotationMember.team)) + .order_by(Schedule.id) + ).all() + ) + + +def _get_schedule(db: Session, schedule_id: int) -> Schedule: + schedule = db.get(Schedule, schedule_id) + if not schedule: + raise HTTPException(status_code=404, detail="Escala não encontrada") + return schedule + + +def _schedule_team_ids(db: Session, schedule_id: int) -> set[int]: + """Everyone who works this schedule: its rotation, its pattern or one of its shifts. + + A swap is always one team for one team of the same schedule, so these are + the only teams who can be asked or chosen. + """ + ids: set[int] = set() + ids.update( + db.scalars( + select(Assignment.team_id).where( + Assignment.schedule_id == schedule_id, Assignment.team_id.is_not(None) + ) + ).all() + ) + ids.update( + db.scalars( + select(MonthlyPattern.team_id).where( + MonthlyPattern.schedule_id == schedule_id, MonthlyPattern.team_id.is_not(None) + ) + ).all() + ) + ids.update( + db.scalars(select(RotationMember.team_id).where(RotationMember.schedule_id == schedule_id)).all() + ) + return {i for i in ids if i is not None} + + +def _schedule_teams(db: Session, schedule_id: int) -> list[Team]: + ids = _schedule_team_ids(db, schedule_id) + if not ids: + return [] + return list( + db.scalars( + select(Team).where(Team.id.in_(ids), Team.is_active.is_(True)).order_by(Team.name) + ).all() + ) + + +def _schedule_teams_map(db: Session, schedule_ids: set[int]) -> dict[int, list[Team]]: + """Active teams per schedule, for the swap forms: a swap stays inside one schedule.""" + return {schedule_id: _schedule_teams(db, schedule_id) for schedule_id in schedule_ids} + + +def _render_admin_schedules( + request: Request, + db: Session, + schedule_id: int | None = None, + start: date | None = None, + end: date | None = None, + error: str | None = None, + status_code: int = 200, +): + cards = _schedule_groups(_all_schedules(db), db) + schedules = [card["schedule"] for card in cards if card["schedule"].is_active] + if schedule_id is not None: + selected = _get_schedule(db, schedule_id) + if not selected.is_active: + raise HTTPException(status_code=404, detail="Escala não encontrada") + else: + selected = schedules[0] if schedules else None + today = date.today() + first, last = month_bounds(today.year, today.month) + return request.app.state.templates.TemplateResponse( + request=request, + name="admin_schedules.html", + status_code=status_code, + context=_context( + request, + db, + cards=cards, + calendar_feed_url=_shared_calendar_url(request), + schedules=schedules, + selected_schedule_id=selected.id if selected else None, + start=(start or first).isoformat(), + end=(end or last).isoformat(), + error=error, + ), + ) + + +def _shared_calendar_url(request: Request) -> str: + """The whole-rota feed, with a team token created the first time it is asked for.""" + if not settings.calendar_token: + settings.calendar_token = secrets.token_urlsafe(24) + path = settings.url(f"/calendar/{settings.calendar_token}.ics") + return f"{request.base_url.scheme}://{request.base_url.netloc}{path}" + + +def _pattern_map(db: Session, schedule: Schedule) -> dict[int, int]: + rows = db.scalars( + select(MonthlyPattern).where(MonthlyPattern.schedule_id == schedule.id) + ).all() + return {row.day_of_month: row.team_id for row in rows if row.team_id is not None} diff --git a/apps/shifts/backend/app/routers/swap_helpers.py b/apps/shifts/backend/app/routers/swap_helpers.py new file mode 100644 index 0000000..d53180b --- /dev/null +++ b/apps/shifts/backend/app/routers/swap_helpers.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from datetime import date +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session +from ..models import Assignment, Schedule, SwapRequest, Team, User +from .dependencies import _user_teams +from .schedule_helpers import _schedule_team_ids + +def _active_request(db: Session, assignment_id: int) -> int | None: + return db.scalar( + select(SwapRequest.id).where( + SwapRequest.assignment_id == assignment_id, + SwapRequest.status.in_(["open", "pending_approval"]), + ) + ) + + +def _swap_side( + db: Session, schedule: Schedule, assignment_id: int | None, *, exclude_team_id: int | None = None +) -> Assignment: + """One side of a swap: an assigned, future shift of the same schedule.""" + assignment = db.get(Assignment, assignment_id) if assignment_id else None + if not assignment: + raise HTTPException(status_code=400, detail="Escolha o turno da outra equipa") + if assignment.schedule_id != schedule.id: + raise HTTPException(status_code=400, detail="Só pode trocar com quem trabalha nesta escala") + if assignment.team_id is None or assignment.team_id == exclude_team_id: + raise HTTPException(status_code=400, detail="Escolha o turno de outra equipa") + if assignment.date < date.today(): + raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") + if _active_request(db, assignment.id): + raise HTTPException(status_code=400, detail="Esse turno já tem um pedido de troca") + return assignment + + +def _swap_partner(db: Session, schedule: Schedule, team_id: int | None) -> Team | None: + """The one team this swap is with: active and working the same schedule.""" + if not team_id: + raise HTTPException(status_code=400, detail="Escolha a equipa que fica com o turno") + if team_id not in _schedule_team_ids(db, schedule.id): + raise HTTPException( + status_code=400, detail="Só pode trocar com quem trabalha nesta escala" + ) + partner = db.get(Team, team_id) + if not partner or not partner.is_active: + raise HTTPException(status_code=400, detail="Equipa inválida para este turno") + return partner + + +def _reject_swap(db: Session, swap: SwapRequest) -> None: + swap.status = "rejected" + db.commit() + + +def _rejectable(user: User, my_ids: set[int], swap: SwapRequest) -> bool: + """Anyone eligible may decline an open request; admins can also drop a pending one.""" + if user.is_admin: + return True + if swap.status != "open" or swap.requester_id in my_ids: + return False + return swap.target_team_id is None or swap.target_team_id in my_ids + + +def _swap_ownership_holds(db: Session, swap: SwapRequest) -> bool: + """Both teams must still own their own shift, otherwise the swap is void.""" + if swap.assignment.team_id != swap.requester_id: + return False + if swap.target_assignment_id is not None and swap.target_assignment is not None: + if swap.target_assignment.team_id != swap.target_team_id: + return False + return True + + +def _complete_swap(db: Session, swap: SwapRequest, new_team: Team) -> None: + """Exchange both shifts and close the request. Ownership must be checked by the caller.""" + swap.assignment.team_id = new_team.id + swap.assignment.source = "swap" + incoming = swap.target_assignment + if incoming is not None: + incoming.team_id = swap.requester_id + incoming.source = "swap" + swap.accepted_team_id = new_team.id + swap.status = "approved" + for other in db.scalars( + select(SwapRequest).where( + SwapRequest.id != swap.id, + SwapRequest.status.in_(["open", "pending_approval"]), + ( + (SwapRequest.assignment_id == swap.assignment_id) + | (SwapRequest.target_assignment_id == swap.assignment_id) + | ( + (SwapRequest.assignment_id.is_not(None)) + & (SwapRequest.assignment_id == swap.target_assignment_id) + ) + | ( + (SwapRequest.target_assignment_id.is_not(None)) + & (SwapRequest.target_assignment_id == swap.target_assignment_id) + ) + ), + ) + ).all(): + other.status = "cancelled" + db.commit() + + +def _accepting_team(db: Session, user: User, swap: SwapRequest) -> Team: + """Which team takes the shift: the one it was asked of, or the first active one + assigned to this user. The user answers as a whole; the rota records one team.""" + if swap.target_team_id: + target = db.get(Team, swap.target_team_id) + if target is not None and target.user_id == user.id and target.is_active: + return target + for team in _user_teams(db, user): + if team.is_active: + return team + raise HTTPException(status_code=400, detail="Este utilizador não tem nenhuma equipa ativa para o turno") diff --git a/apps/shifts/backend/app/routers/swaps.py b/apps/shifts/backend/app/routers/swaps.py new file mode 100644 index 0000000..26f6ac6 --- /dev/null +++ b/apps/shifts/backend/app/routers/swaps.py @@ -0,0 +1,478 @@ +from __future__ import annotations + +from datetime import date +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload +from ..config import settings +from ..database import get_db +from ..models import Assignment, SwapRequest, Team +from ..i18n import day_numeric_long, day_short, month_name +from ..services.notifications import send_email_notification, send_many +from ..services.scheduling import ensure_month_assignments +from .admin.user_helpers import _admin_teams +from .dependencies import _check_csrf, _context, _flash, _month_nav, _parse_month, _redirect, _require_admin, _require_user, _safe_back, _user_label, _user_team_ids +from .schedule_helpers import _active_schedules, _schedule_teams_map +from .swap_helpers import _accepting_team, _active_request, _complete_swap, _reject_swap, _rejectable, _swap_ownership_holds, _swap_partner, _swap_side + +router = APIRouter() + +@router.get("/swaps", response_class=HTMLResponse) +def swaps_page(request: Request, db: Session = Depends(get_db)): + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + query = ( + select(SwapRequest) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + selectinload(SwapRequest.target_team), + selectinload(SwapRequest.accepted_by), + ) + .order_by(SwapRequest.created_at.desc()) + ) + if not user.is_admin: + # A user sees and answers everything that involves the teams assigned to it. + query = query.where( + (SwapRequest.requester_id.in_(my_ids)) + | (SwapRequest.target_team_id.in_(my_ids)) + | ((SwapRequest.target_team_id.is_(None)) & (SwapRequest.status == "open")) + | (SwapRequest.accepted_team_id.in_(my_ids)) + ) + swaps = db.scalars(query).all() + return request.app.state.templates.TemplateResponse( + request=request, + name="swaps.html", + context=_context( + request, + db, + swaps=swaps, + today=date.today(), + schedule_teams=_schedule_teams_map( + db, {swap.assignment.schedule_id for swap in swaps} + ), + ), + ) + + +@router.get("/swaps/new", response_class=HTMLResponse) +def new_swap_page( + request: Request, + year: int | None = None, + month: int | None = None, + db: Session = Depends(get_db), +): + """Its own page, so the rota keeps showing only who works each day.""" + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + year, month = _parse_month(year, month) + assignments = ensure_month_assignments(db, year, month) + taken = set( + db.scalars( + select(SwapRequest.assignment_id).where( + SwapRequest.status.in_(["open", "pending_approval"]) + ) + ).all() + ) + # One team can work several days, so the shift to offer is chosen here, + # and each schedule only offers the teams who work that same schedule. + names = {t.id: t.name for t in db.scalars(select(Team)).all()} + groups: list[dict] = [] + for schedule in _active_schedules(db): + mine = [ + a for a in assignments + if a.schedule_id == schedule.id + and a.team_id + and (a.team_id in my_ids or user.is_admin) + and a.date >= date.today() + and a.id not in taken + ] + theirs = [ + a for a in assignments + if a.schedule_id == schedule.id + and a.team_id + and a.team_id not in my_ids + and a.date >= date.today() + and a.id not in taken + ] + if not mine or not theirs: + continue + groups.append( + { + "schedule": schedule, + "shifts": [ + {"id": a.id, "label": f"{day_short(a.date)} · {names[a.team_id]}"} + for a in sorted(mine, key=lambda r: r.date) + ], + "wanted": [ + { + "id": a.id, + "label": f"{day_short(a.date)} · {names[a.team_id]}", + } + for a in sorted(theirs, key=lambda r: r.date) + ], + } + ) + prev, nxt = _month_nav(year, month) + return request.app.state.templates.TemplateResponse( + request=request, + name="swap_new.html", + context=_context( + request, + db, + year=year, + month=month, + month_name=month_name(month), + prev=prev, + nxt=nxt, + groups=groups, + ), + ) + + +@router.post("/swaps/new") +def create_swap_for_chosen_shift( + request: Request, + assignment_id: int = Form(...), + target_assignment_id: int = Form(...), + message: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + return _create_swap(assignment_id, request, target_assignment_id, message, csrf_token, db) + + +@router.post("/assignments/{assignment_id}/swap") +def create_swap( + assignment_id: int, + request: Request, + target_assignment_id: int = Form(...), + message: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + return _create_swap(assignment_id, request, target_assignment_id, message, csrf_token, db) + + +def _create_swap( + assignment_id: int, + request: Request, + target_assignment_id: int, + message: str, + csrf_token: str, + db: Session, +): + _check_csrf(request, csrf_token) + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + assignment = db.get(Assignment, assignment_id) + if not assignment: + raise HTTPException(status_code=404, detail="Turno não encontrado") + # An administrator may open a request for someone else, but the shift stays + # the requester's own so the ownership rule still applies on acceptance. + owner_id = assignment.team_id + if owner_id is None or (owner_id not in my_ids and not user.is_admin): + raise HTTPException(status_code=403, detail="Só pode oferecer o turno da sua equipa") + owner = db.get(Team, owner_id) + if assignment.date < date.today(): + raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") + if _active_request(db, assignment.id): + _flash(request, "Já existe um pedido de troca ativo para este turno.", "error") + return _redirect("/swaps") + + # A swap is one shift for one shift, both of the same schedule. + incoming = _swap_side(db, assignment.schedule, target_assignment_id, exclude_team_id=owner.id) + if incoming.id == assignment.id: + raise HTTPException(status_code=400, detail="Escolha outro turno para a troca") + target = db.get(Team, incoming.team_id) + + swap = SwapRequest( + assignment_id=assignment.id, + target_assignment_id=incoming.id, + requester_id=owner.id, + target_team_id=target.id, + status="open", + message=message.strip() or None, + ) + db.add(swap) + db.commit() + + subject = ( + f"Troca de turnos: {day_short(assignment.date)} por {day_short(incoming.date)} " + f"– {assignment.schedule.name}" + ) + body = ( + f"{owner.name} propõe trocar o turno de {assignment.schedule.name} de " + f"{day_numeric_long(assignment.date)} pelo seu turno de {day_numeric_long(incoming.date)}.\n\n" + f"{message.strip()}\n\n" + f"Abrir a escala: {settings.normalized_base_url}/swaps" + ) + send_email_notification(db, target, "swap_requested", subject, body) + + _flash(request, "Pedido de troca criado.") + return _redirect("/swaps") + + +@router.post("/swaps/{swap_id}/accept") +def accept_swap( + swap_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + ) + ) + if not swap or swap.status != "open": + raise HTTPException(status_code=400, detail="Este pedido já não está aberto") + if swap.requester_id in my_ids: + raise HTTPException(status_code=400, detail="Não pode aceitar o seu próprio pedido") + if swap.target_team_id is not None and swap.target_team_id not in my_ids: + raise HTTPException(status_code=403, detail="Este pedido é para outra equipa") + if not _swap_ownership_holds(db, swap): + swap.status = "cancelled" + db.commit() + raise HTTPException(status_code=409, detail="O turno já foi alterado") + + team = _accepting_team(db, user, swap) + swap.accepted_team_id = team.id + if swap.assignment.schedule.requires_manager_approval: + swap.status = "pending_approval" + db.commit() + admins = _admin_teams(db) + send_many( + db, + admins, + "swap_needs_approval", + "Uma troca de turno precisa de aprovação", + f"{team.name} aceitou a troca de {swap.assignment.schedule.name} de " + f"{day_numeric_long(swap.assignment.date)}" + + ( + f" por {day_numeric_long(swap.target_assignment.date)}" + if swap.target_assignment is not None + else "" + ) + + f", pedida por {swap.requester.name}. Aprove em {settings.normalized_base_url}/swaps", + ) + send_email_notification( + db, + swap.requester, + "swap_accepted", + "A sua troca de turno foi aceite", + f"{team.name} aceitou o seu pedido. Está agora à espera da aprovação da gestão.", + ) + _flash(request, "Aceite. A aguardar aprovação da gestão.") + else: + _complete_swap(db, swap, team) + incoming = swap.target_assignment + for recipient, other in ((swap.requester, team), (team, swap.requester)): + send_email_notification( + db, + recipient, + "swap_approved", + "Troca de turno confirmada", + f"Troca concluída em {swap.assignment.schedule.name}: fica com o turno de " + f"{day_numeric_long(swap.assignment.date)}" + + ( + f" e passa o de {day_numeric_long(incoming.date)} para {other.name}." + if incoming is not None + else "." + ), + ) + _flash(request, "Troca concluída.") + return _redirect("/swaps") + + +@router.post("/swaps/{swap_id}/reject") +def reject_swap( + swap_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options(selectinload(SwapRequest.requester)) + ) + if not swap or swap.status not in {"open", "pending_approval"}: + raise HTTPException(status_code=400, detail="Este pedido já não está aberto") + if not _rejectable(user, my_ids, swap): + raise HTTPException(status_code=403, detail="Não pode recusar este pedido") + who = _user_label(user) + _reject_swap(db, swap) + send_email_notification( + db, + swap.requester, + "swap_rejected", + "Pedido de troca recusado", + f"{who} recusou o seu pedido de troca.", + ) + _flash(request, "Pedido recusado.") + return _redirect(_safe_back(request, "/swaps")) + + +@router.post("/swaps/{swap_id}/cancel") +def cancel_swap( + swap_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + _check_csrf(request, csrf_token) + user = _require_user(request, db) + my_ids = _user_team_ids(db, user) + swap = db.get(SwapRequest, swap_id) + if not swap or swap.requester_id not in my_ids or swap.status not in {"open", "pending_approval"}: + raise HTTPException(status_code=403, detail="Não é possível cancelar este pedido") + swap.status = "cancelled" + db.commit() + _flash(request, "Pedido cancelado.") + return _redirect("/swaps") + + +@router.post("/swaps/{swap_id}/approve") +def approve_swap( + swap_id: int, + request: Request, + team_id: str = Form(""), + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.requester), + selectinload(SwapRequest.accepted_by), + ) + ) + if not swap or swap.status not in {"open", "pending_approval"}: + raise HTTPException(status_code=400, detail="Este pedido já não pode ser aprovado") + if not _swap_ownership_holds(db, swap): + swap.status = "cancelled" + db.commit() + raise HTTPException(status_code=409, detail="O turno já foi alterado") + if swap.assignment.date < date.today(): + raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") + + if swap.accepted_by and swap.accepted_by.is_active: + new_team = swap.accepted_by + elif swap.target_team_id and db.get(Team, swap.target_team_id).is_active: + new_team = db.get(Team, swap.target_team_id) + else: + new_team = _swap_partner(db, swap.assignment.schedule, int(team_id) if team_id.strip() else None) + if new_team.id == swap.requester_id: + raise HTTPException(status_code=400, detail="Escolha outra equipa para a troca") + + _complete_swap(db, swap, new_team) + incoming = swap.target_assignment + subject = "Troca de turno aprovada" + body = ( + f"{_user_label(admin)} aprovou a troca de {swap.assignment.schedule.name}: " + f"{new_team.name} fica com o turno de {day_numeric_long(swap.assignment.date)}" + + ( + f" e {swap.requester.name} com o de {day_numeric_long(incoming.date)}." + if incoming is not None + else "." + ) + ) + send_many(db, [swap.requester, new_team], "swap_approved", subject, body) + _flash(request, "Troca aprovada e escala atualizada.") + return _redirect(_safe_back(request, "/swaps")) + + +@router.post("/swaps/{swap_id}/revert") +def revert_swap( + swap_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + """Administrator only: undo an approved swap and give both shifts back.""" + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options( + selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), + selectinload(SwapRequest.target_assignment), + selectinload(SwapRequest.requester), + ) + ) + if not swap or swap.target_assignment_id is None: + raise HTTPException(status_code=404, detail="Troca não encontrada") + if swap.status != "approved": + raise HTTPException(status_code=400, detail="Só uma troca aprovada pode ser revertida") + # The shifts must still be the ones the swap produced, or somebody edited them since. + if swap.assignment.team_id != swap.accepted_team_id or ( + swap.target_assignment is not None + and swap.target_assignment.team_id != swap.requester_id + ): + raise HTTPException( + status_code=409, detail="O turno foi alterado depois da troca; reverta com a atribuição por dia" + ) + + incoming = swap.target_assignment + note = f"Revertida a troca #{swap.id} por {_user_label(admin)}" + swap.assignment.team_id = swap.requester_id + swap.assignment.source = "manual" + swap.assignment.note = note + incoming.team_id = swap.target_team_id + incoming.source = "manual" + incoming.note = note + swap.status = "reverted" + db.commit() + + subject = "Troca de turno revertida" + body = ( + f"{_user_label(admin)} reverteu a troca de {swap.assignment.schedule.name}. " + f"{swap.requester.name} volta a {day_numeric_long(swap.assignment.date)} e " + f"{swap.target_team.name} volta a {day_numeric_long(incoming.date)}.\n\n" + f"Ver a escala: {settings.normalized_base_url}/" + ) + send_many(db, [swap.requester, swap.target_team], "swap_reverted", subject, body) + _flash(request, "Troca revertida: os dois turnos voltaram a quem os tinha.") + return _redirect(_safe_back(request, "/swaps")) + + +@router.post("/swaps/{swap_id}/admin-reject") +def admin_reject_swap( + swap_id: int, + request: Request, + csrf_token: str = Form(...), + db: Session = Depends(get_db), +): + admin = _require_admin(request, db) + _check_csrf(request, csrf_token) + swap = db.scalar( + select(SwapRequest) + .where(SwapRequest.id == swap_id) + .options(selectinload(SwapRequest.requester), selectinload(SwapRequest.accepted_by)) + ) + if not swap or swap.status not in {"open", "pending_approval"}: + raise HTTPException(status_code=400, detail="Este pedido não pode ser recusado") + _reject_swap(db, swap) + involved = [t for t in [swap.requester, swap.accepted_by] if t] + send_many(db, involved, "swap_rejected", "Troca de turno não aprovada", + f"{_user_label(admin)} não aprovou a troca de turno pedida.") + _flash(request, "Troca recusada.") + return _redirect(_safe_back(request, "/swaps")) diff --git a/apps/shifts/backend/app/security/__init__.py b/apps/shifts/backend/app/security/__init__.py new file mode 100644 index 0000000..741dad3 --- /dev/null +++ b/apps/shifts/backend/app/security/__init__.py @@ -0,0 +1,4 @@ +from .csrf import new_csrf_token +from .passwords import ITERATIONS, MIN_PASSWORD_LENGTH, hash_password, verify_password + +__all__ = ['ITERATIONS', 'MIN_PASSWORD_LENGTH', 'hash_password', 'verify_password', 'new_csrf_token'] diff --git a/apps/shifts/backend/app/security/csrf.py b/apps/shifts/backend/app/security/csrf.py new file mode 100644 index 0000000..6a9b9af --- /dev/null +++ b/apps/shifts/backend/app/security/csrf.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +import secrets + +def new_csrf_token() -> str: + return secrets.token_urlsafe(32) diff --git a/apps/shifts/backend/app/security.py b/apps/shifts/backend/app/security/passwords.py similarity index 92% rename from apps/shifts/backend/app/security.py rename to apps/shifts/backend/app/security/passwords.py index c97b05d..e6f4b23 100644 --- a/apps/shifts/backend/app/security.py +++ b/apps/shifts/backend/app/security/passwords.py @@ -3,7 +3,6 @@ from __future__ import annotations import hashlib import hmac import os -import secrets ITERATIONS = 310_000 @@ -34,7 +33,3 @@ def verify_password(password: str, encoded: str | None) -> bool: return hmac.compare_digest(digest.hex(), digest_hex) except (ValueError, TypeError): return False - - -def new_csrf_token() -> str: - return secrets.token_urlsafe(32) diff --git a/apps/shifts/backend/app/services/__init__.py b/apps/shifts/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/shifts/backend/app/bootstrap.py b/apps/shifts/backend/app/services/bootstrap.py similarity index 94% rename from apps/shifts/backend/app/bootstrap.py rename to apps/shifts/backend/app/services/bootstrap.py index fc91d39..fe22a56 100644 --- a/apps/shifts/backend/app/bootstrap.py +++ b/apps/shifts/backend/app/services/bootstrap.py @@ -5,9 +5,9 @@ from datetime import date, time from sqlalchemy import select from sqlalchemy.orm import Session -from .config import settings -from .models import Schedule, Team, User -from .security import hash_password +from ..config import settings +from ..models import Schedule, Team, User +from ..security import hash_password def ensure_initial_data(db: Session) -> None: diff --git a/apps/shifts/backend/app/calendar.py b/apps/shifts/backend/app/services/calendar_feed.py similarity index 97% rename from apps/shifts/backend/app/calendar.py rename to apps/shifts/backend/app/services/calendar_feed.py index 8bffdb5..f684187 100644 --- a/apps/shifts/backend/app/calendar.py +++ b/apps/shifts/backend/app/services/calendar_feed.py @@ -13,9 +13,9 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from sqlalchemy import select from sqlalchemy.orm import Session, selectinload -from .config import settings -from .i18n import day_long, day_numeric_long, window_label -from .models import Assignment, Schedule, Team +from ..config import settings +from ..i18n import day_long, day_numeric_long, window_label +from ..models import Assignment, Schedule, Team # How much of the rota the feed publishes: recent history and the next months. MONTHS_BACK = 1 diff --git a/apps/shifts/backend/app/notifications.py b/apps/shifts/backend/app/services/notifications.py similarity index 96% rename from apps/shifts/backend/app/notifications.py rename to apps/shifts/backend/app/services/notifications.py index 10076a3..58c2209 100644 --- a/apps/shifts/backend/app/notifications.py +++ b/apps/shifts/backend/app/services/notifications.py @@ -6,8 +6,8 @@ from email.message import EmailMessage from sqlalchemy.orm import Session -from .config import settings -from .models import NotificationLog, Team +from ..config import settings +from ..models import NotificationLog, Team def send_email_notification( diff --git a/apps/shifts/backend/app/pdf.py b/apps/shifts/backend/app/services/pdf.py similarity index 98% rename from apps/shifts/backend/app/pdf.py rename to apps/shifts/backend/app/services/pdf.py index b4152f2..d0218c2 100644 --- a/apps/shifts/backend/app/pdf.py +++ b/apps/shifts/backend/app/services/pdf.py @@ -20,10 +20,10 @@ from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen.canvas import Canvas from reportlab.platypus import Paragraph -from .config import settings -from .branding import LOGO_PATH -from .i18n import month_name -from .models import Assignment, Schedule +from ..config import settings +from ..branding import LOGO_PATH +from ..i18n import month_name +from ..models import Assignment, Schedule # ReportLab ships these Unicode fonts, including Portuguese accents, in its wheel. FONT_DIR = Path(reportlab.__file__).parent / "fonts" diff --git a/apps/shifts/backend/app/scheduling.py b/apps/shifts/backend/app/services/scheduling.py similarity index 98% rename from apps/shifts/backend/app/scheduling.py rename to apps/shifts/backend/app/services/scheduling.py index 12e1c78..c1a555f 100644 --- a/apps/shifts/backend/app/scheduling.py +++ b/apps/shifts/backend/app/services/scheduling.py @@ -7,7 +7,7 @@ from datetime import date, timedelta from sqlalchemy import select from sqlalchemy.orm import Session, selectinload -from .models import Assignment, MonthlyPattern, RotationMember, Schedule, Team +from ..models import Assignment, MonthlyPattern, RotationMember, Schedule, Team def month_bounds(year: int, month: int) -> tuple[date, date]: diff --git a/apps/shifts/backend/app/web.py b/apps/shifts/backend/app/web.py deleted file mode 100644 index f355eca..0000000 --- a/apps/shifts/backend/app/web.py +++ /dev/null @@ -1,2456 +0,0 @@ -from __future__ import annotations - -import calendar -from collections.abc import Sequence -import secrets -from datetime import date, datetime, time, timedelta -from urllib.parse import urlencode - -from fastapi import APIRouter, Depends, Form, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse, Response -from sqlalchemy import delete, func, select -from sqlalchemy.orm import Session, selectinload - -from .calendar import build_feed -from .config import settings -from .database import get_db -from .models import ( - Assignment, - User, - MonthlyPattern, - NotificationLog, - RotationMember, - Schedule, - SwapRequest, - Team, -) -from .i18n import ( - MONTHS, - day_numeric, - day_numeric_long, - day_short, - month_name, - weekday_names, - window_label, -) -from .notifications import send_email_notification, send_many -from .pdf import build_schedule_pdf -from .scheduling import ( - assignments_by_date, - ensure_month_assignments, - month_bounds, - pattern_teams, - rotation_team_for_date, -) -from .security import hash_password, new_csrf_token, verify_password - -router = APIRouter() - - -def _redirect(path: str, status_code: int = 303) -> RedirectResponse: - return RedirectResponse(settings.url(path), status_code=status_code) - - -def _parse_time(value: str) -> time: - try: - return time.fromisoformat(value.strip()) - except ValueError: - raise HTTPException(status_code=400, detail="Hora inválida") - - -def _current_user(request: Request, db: Session) -> User | None: - """The user the session belongs to. One user can be assigned to several teams.""" - user_id = request.session.get("user_id") - if not user_id: - return None - return db.get(User, int(user_id)) - - -def _require_user(request: Request, db: Session) -> User: - user = _current_user(request, db) - if not user or not user.is_active: - raise HTTPException(status_code=401, detail="É necessário iniciar sessão") - return user - - -def _require_admin(request: Request, db: Session) -> User: - user = _require_user(request, db) - if not user.is_admin: - raise HTTPException(status_code=403, detail="É necessária a permissão de administrador") - return user - - -def _user_teams(db: Session, user: User | None) -> list[Team]: - """The teams assigned to this user. A user sees and controls the rota of all of them.""" - if user is None: - return [] - return list( - db.scalars(select(Team).where(Team.user_id == user.id).order_by(Team.name)).all() - ) - - -def _user_team_ids(db: Session, user: User | None) -> set[int]: - return {team.id for team in _user_teams(db, user)} - - -def _user_label(user: User) -> str: - """How a user is named in messages and e-mails.""" - return user.name or user.username - - -def _csrf(request: Request) -> str: - token = request.session.get("csrf") - if not token: - token = new_csrf_token() - request.session["csrf"] = token - return token - - -def _check_csrf(request: Request, token: str) -> None: - expected = request.session.get("csrf") - if not expected or token != expected: - raise HTTPException(status_code=400, detail="Token CSRF inválido") - - -def _flash(request: Request, message: str, level: str = "info") -> None: - request.session["flash"] = {"message": message, "level": level} - - -def _context(request: Request, db: Session, **extra): - user = _current_user(request, db) - teams = _user_teams(db, user) - return { - "request": request, - "settings": settings, - "current_user": user, - "current_teams": teams, - "my_team_ids": {team.id for team in teams}, - "csrf_token": _csrf(request), - "flash": request.session.pop("flash", None), - **extra, - } - - -def _parse_month(year: int | None, month: int | None) -> tuple[int, int]: - today = date.today() - y = year or today.year - m = month or today.month - if m < 1 or m > 12 or y < 2000 or y > 2100: - raise HTTPException(status_code=400, detail="Mês inválido") - return y, m - - -ALL_WEEKDAYS = frozenset(range(7)) - - -def _next_dates(weekdays: set[int], count: int = 6) -> list[str]: - today = date.today() - labels: list[str] = [] - day = today - while len(labels) < count: - if day.weekday() in weekdays: - labels.append(day_short(day)) - day += timedelta(days=1) - return labels - - -def _schedule_card(schedule: Schedule, db: Session | None = None) -> dict: - weekdays = schedule.weekday_set - fixed = schedule.schedule_type != "rotation" and weekdays == ALL_WEEKDAYS - if fixed: - kind_label = "Todos os dias · repete todos os meses" - weekday_label = "Todos os dias" - dates: list[str] = [] - else: - weekday_label = weekday_names(weekdays) - kind_label = f"Rotação · {weekday_label}" - dates = _next_dates(weekdays) - pattern: list[tuple[int, str]] = [] - if fixed and db is not None: - rows = db.scalars( - select(MonthlyPattern) - .where(MonthlyPattern.schedule_id == schedule.id) - .options(selectinload(MonthlyPattern.team)) - .order_by(MonthlyPattern.day_of_month) - ).all() - pattern = [ - (row.day_of_month, row.team.name) - for row in rows - if row.team is not None and row.team.is_active - ] - return { - "schedule": schedule, - "kind": "fixed" if fixed else "rotation", - "kind_label": kind_label, - "weekday_label": weekday_label, - "dates": dates, - "pattern": pattern, - "rotation_names": [member.team.name for member in schedule.rotation_members], - } - - -def _schedule_groups(schedules: Sequence[Schedule], db: Session | None = None) -> list[dict]: - cards = [_schedule_card(schedule, db) for schedule in schedules] - # Every rotation is independent: Saturday and Sunday lunch never share an order. - return sorted(cards, key=lambda card: (card["kind"] != "fixed", card["weekday_label"] != "Todos os dias")) - - -def _active_schedules(db: Session) -> list[Schedule]: - return list( - db.scalars( - select(Schedule) - .where(Schedule.is_active.is_(True)) - .options(selectinload(Schedule.rotation_members).selectinload(RotationMember.team)) - .order_by(Schedule.id) - ).all() - ) - - -def _all_schedules(db: Session) -> list[Schedule]: - return list( - db.scalars( - select(Schedule) - .options(selectinload(Schedule.rotation_members).selectinload(RotationMember.team)) - .order_by(Schedule.id) - ).all() - ) - - -def _get_schedule(db: Session, schedule_id: int) -> Schedule: - schedule = db.get(Schedule, schedule_id) - if not schedule: - raise HTTPException(status_code=404, detail="Escala não encontrada") - return schedule - - -def _month_nav(year: int, month: int) -> tuple[tuple[int, int], tuple[int, int]]: - if month == 1: - prev = (year - 1, 12) - else: - prev = (year, month - 1) - if month == 12: - nxt = (year + 1, 1) - else: - nxt = (year, month + 1) - return prev, nxt - - -def _calendar_token(db: Session, team: Team) -> str: - """A private feed token for this team, created the first time it is needed.""" - if not team.calendar_token: - team.calendar_token = secrets.token_urlsafe(24) - db.commit() - return team.calendar_token - - -@router.get("/calendar/{token}.ics") -def calendar_feed( - token: str, - db: Session = Depends(get_db), -): - """Subscribe from any calendar app: one feed per team, plus a shared whole-rota feed.""" - team = db.scalar(select(Team).where(Team.calendar_token == token, Team.is_active.is_(True))) - shared = bool(settings.calendar_token) and token == settings.calendar_token - if team is None and not shared: - raise HTTPException(status_code=404, detail="Calendário não encontrado") - name = f"{settings.app_name} — {team.name}" if team else settings.app_name - return Response( - content=build_feed(db, team, name), - media_type="text/calendar; charset=utf-8", - headers={ - "Content-Disposition": 'inline; filename="rota.ics"', - "Cache-Control": "private, max-age=300", - }, - ) - - -@router.get("/account", response_class=HTMLResponse) -def account_page(request: Request, db: Session = Depends(get_db)): - """The password editor lives on its own page, reached by clicking the user name.""" - _require_user(request, db) - return request.app.state.templates.TemplateResponse( - request=request, - name="account.html", - context=_context(request, db), - ) - - -@router.post("/account") -def account_password( - request: Request, - new_password: str = Form(""), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - """A user changes its own password from `/account`: one field, no ceremony.""" - _check_csrf(request, csrf_token) - user = _require_user(request, db) - if verify_password(new_password, user.password_hash): - _flash(request, "A nova palavra-passe é igual à atual.", "error") - return _redirect(_safe_back(request, "/")) - try: - user.password_hash = hash_password(new_password) - except ValueError as exc: - _flash(request, str(exc), "error") - return _redirect(_safe_back(request, "/")) - db.commit() - _flash(request, "Palavra-passe alterada.") - return _redirect(_safe_back(request, "/")) - - -@router.get("/calendar", response_class=HTMLResponse) -def calendar_page(request: Request, db: Session = Depends(get_db)): - """The links to subscribe, kept off the rota so that page stays about the shifts.""" - user = _require_user(request, db) - feeds = [ - { - "title": f"Turnos de {team.name}", - "url": f"{request.base_url.scheme}://{request.base_url.netloc}" - f"{settings.url(f'/calendar/{_calendar_token(db, team)}.ics')}", - } - for team in _user_teams(db, user) - if team.is_active - ] - if user.is_admin: - feeds.append( - { - "title": "Rota completa", - "url": _shared_calendar_url(request), - } - ) - for feed in feeds: - feed["webcal_url"] = "webcal://" + feed["url"].split("://", 1)[-1] - feed["google_url"] = "https://calendar.google.com/calendar/render?" + urlencode( - {"cid": feed["url"]} - ) - feed["outlook_url"] = "https://outlook.live.com/calendar/0/addfromweb?" + urlencode( - {"url": feed["url"], "name": feed["title"]} - ) - return request.app.state.templates.TemplateResponse( - request=request, - name="calendar.html", - context=_context(request, db, feeds=feeds), - ) - - -@router.get("/export/schedule", response_class=HTMLResponse) -def export_schedule( - request: Request, - schedule_id: int | None = None, - db: Session = Depends(get_db), -): - """Choose a schedule and exact dates before downloading its PDF.""" - user = _require_user(request, db) - schedules = _active_schedules(db) - if not schedules: - raise HTTPException(status_code=404, detail="Não há escalas ativas") - if schedule_id is None: - schedule = schedules[0] - else: - schedule = _get_schedule(db, schedule_id) - if not schedule.is_active: - raise HTTPException(status_code=404, detail="Escala não encontrada") - - if user.is_admin: - return _redirect(f"/admin/schedules?schedule_id={schedule.id}#export-pdf") - - today = date.today() - start, end = month_bounds(today.year, today.month) - return request.app.state.templates.TemplateResponse( - request=request, - name="export_schedule.html", - context=_context( - request, - db, - schedules=schedules, - selected_schedule_id=schedule.id, - start=start.isoformat(), - end=end.isoformat(), - ), - ) - - -@router.get("/export/schedule.pdf") -def download_schedule_pdf( - request: Request, - schedule_id: int, - start_date: date, - end_date: date, - db: Session = Depends(get_db), -): - user = _require_user(request, db) - schedule = _get_schedule(db, schedule_id) - if not schedule.is_active: - raise HTTPException(status_code=404, detail="Escala não encontrada") - error = None - if not (2000 <= start_date.year <= 2100 and 2000 <= end_date.year <= 2100): - error = "Escolhe datas entre 2000 e 2100." - elif end_date < start_date: - error = "A data final deve ser igual ou posterior à data inicial." - elif (end_date.year - start_date.year) * 12 + end_date.month - start_date.month >= 12: - error = "Escolhe um intervalo de no máximo 12 meses." - if error: - if user.is_admin: - return _render_admin_schedules( - request, db, schedule.id, start_date, end_date, error, status_code=400, - ) - return request.app.state.templates.TemplateResponse( - request=request, - name="export_schedule.html", - status_code=400, - context=_context( - request, - db, - schedules=_active_schedules(db), - selected_schedule_id=schedule.id, - start=start_date.isoformat(), - end=end_date.isoformat(), - error=error, - ), - ) - - # Generate chronologically, keeping existing manual and swapped assignments. - first = start_date.year * 12 + start_date.month - 1 - last = end_date.year * 12 + end_date.month - 1 - assignments: list[Assignment] = [] - for index in range(first, last + 1): - year, month = divmod(index, 12) - assignments.extend( - row for row in ensure_month_assignments(db, year, month + 1) - if row.schedule_id == schedule.id and start_date <= row.date <= end_date - ) - filename = f"escala-{schedule.id}-{start_date.isoformat()}-{end_date.isoformat()}.pdf" - return Response( - content=build_schedule_pdf(schedule, assignments, start_date, end_date), - media_type="application/pdf", - headers={ - "Content-Disposition": f'attachment; filename="{filename}"', - "Cache-Control": "private, no-store", - }, - ) - - -@router.get("/health") -def health(): - return {"status": "ok"} - - -@router.get("/login", response_class=HTMLResponse) -def login_page(request: Request, db: Session = Depends(get_db)): - if _current_user(request, db): - return _redirect("/") - return request.app.state.templates.TemplateResponse( - request=request, - name="login.html", - context=_context(request, db), - ) - - -@router.post("/login") -def login( - request: Request, - username: str = Form(...), - password: str = Form(...), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _check_csrf(request, csrf_token) - user = db.scalar( - select(User).where(func.lower(User.username) == username.strip().lower()) - ) - if not user or not user.is_active or not verify_password(password, user.password_hash): - _flash(request, "Nome de utilizador ou palavra-passe inválidos.", "error") - return _redirect("/login") - request.session["user_id"] = user.id - teams = _user_teams(db, user) - who = ", ".join(team.name for team in teams) or _user_label(user) - _flash(request, f"Bem-vindo(a), {who}.") - return _redirect("/") - - -@router.post("/logout") -def logout(request: Request, csrf_token: str = Form(...)): - _check_csrf(request, csrf_token) - request.session.clear() - return _redirect("/login") - - -@router.get("/", response_class=HTMLResponse) -def dashboard( - request: Request, - year: int | None = None, - month: int | None = None, - db: Session = Depends(get_db), -): - user = _current_user(request, db) - if user and not user.is_active: - user = None - my_ids = _user_team_ids(db, user) - year, month = _parse_month(year, month) - assignments = ensure_month_assignments(db, year, month) - grouped = assignments_by_date(assignments) - prev, nxt = _month_nav(year, month) - - my_assignment_ids = {a.id for a in assignments if a.team_id in my_ids} - open_swap_assignment_ids = set() - admin_requests: dict[int, SwapRequest] = {} - if my_ids: - open_swap_assignment_ids = set( - db.scalars( - select(SwapRequest.assignment_id).where( - SwapRequest.requester_id.in_(my_ids), - SwapRequest.status.in_(["open", "pending_approval"]), - ) - ).all() - ) - if user and user.is_admin: - # Administrators can settle any request on this month's rota. - admin_requests = { - request.assignment_id: request - for request in db.scalars( - select(SwapRequest) - .where( - SwapRequest.assignment_id.in_([a.id for a in assignments]), - SwapRequest.status.in_(["open", "pending_approval"]), - ) - .options( - selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.requester), - selectinload(SwapRequest.accepted_by), - ) - .order_by(SwapRequest.created_at) - ).all() - } - - today = date.today() - return request.app.state.templates.TemplateResponse( - request=request, - name="dashboard.html", - context=_context( - request, - db, - year=year, - month=month, - month_name=month_name(month), - month_days=[(date(year, month, d), grouped.get(date(year, month, d), [])) for d in range(1, calendar.monthrange(year, month)[1] + 1)], - grouped=grouped, - my_assignment_ids=my_assignment_ids, - open_swap_assignment_ids=open_swap_assignment_ids, - admin_requests=admin_requests, - prev=prev, - nxt=nxt, - schedule_teams=_schedule_teams_map( - db, {request.assignment.schedule_id for request in admin_requests.values()} - ), - today=today, - ), - ) - - -@router.get("/swaps", response_class=HTMLResponse) -def swaps_page(request: Request, db: Session = Depends(get_db)): - user = _require_user(request, db) - my_ids = _user_team_ids(db, user) - query = ( - select(SwapRequest) - .options( - selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.requester), - selectinload(SwapRequest.target_team), - selectinload(SwapRequest.accepted_by), - ) - .order_by(SwapRequest.created_at.desc()) - ) - if not user.is_admin: - # A user sees and answers everything that involves the teams assigned to it. - query = query.where( - (SwapRequest.requester_id.in_(my_ids)) - | (SwapRequest.target_team_id.in_(my_ids)) - | ((SwapRequest.target_team_id.is_(None)) & (SwapRequest.status == "open")) - | (SwapRequest.accepted_team_id.in_(my_ids)) - ) - swaps = db.scalars(query).all() - return request.app.state.templates.TemplateResponse( - request=request, - name="swaps.html", - context=_context( - request, - db, - swaps=swaps, - today=date.today(), - schedule_teams=_schedule_teams_map( - db, {swap.assignment.schedule_id for swap in swaps} - ), - ), - ) - - -def _schedule_team_ids(db: Session, schedule_id: int) -> set[int]: - """Everyone who works this schedule: its rotation, its pattern or one of its shifts. - - A swap is always one team for one team of the same schedule, so these are - the only teams who can be asked or chosen. - """ - ids: set[int] = set() - ids.update( - db.scalars( - select(Assignment.team_id).where( - Assignment.schedule_id == schedule_id, Assignment.team_id.is_not(None) - ) - ).all() - ) - ids.update( - db.scalars( - select(MonthlyPattern.team_id).where( - MonthlyPattern.schedule_id == schedule_id, MonthlyPattern.team_id.is_not(None) - ) - ).all() - ) - ids.update( - db.scalars(select(RotationMember.team_id).where(RotationMember.schedule_id == schedule_id)).all() - ) - return {i for i in ids if i is not None} - - -def _schedule_teams(db: Session, schedule_id: int) -> list[Team]: - ids = _schedule_team_ids(db, schedule_id) - if not ids: - return [] - return list( - db.scalars( - select(Team).where(Team.id.in_(ids), Team.is_active.is_(True)).order_by(Team.name) - ).all() - ) - - -def _schedule_teams_map(db: Session, schedule_ids: set[int]) -> dict[int, list[Team]]: - """Active teams per schedule, for the swap forms: a swap stays inside one schedule.""" - return {schedule_id: _schedule_teams(db, schedule_id) for schedule_id in schedule_ids} - - -def _active_request(db: Session, assignment_id: int) -> int | None: - return db.scalar( - select(SwapRequest.id).where( - SwapRequest.assignment_id == assignment_id, - SwapRequest.status.in_(["open", "pending_approval"]), - ) - ) - - -def _swap_side( - db: Session, schedule: Schedule, assignment_id: int | None, *, exclude_team_id: int | None = None -) -> Assignment: - """One side of a swap: an assigned, future shift of the same schedule.""" - assignment = db.get(Assignment, assignment_id) if assignment_id else None - if not assignment: - raise HTTPException(status_code=400, detail="Escolha o turno da outra equipa") - if assignment.schedule_id != schedule.id: - raise HTTPException(status_code=400, detail="Só pode trocar com quem trabalha nesta escala") - if assignment.team_id is None or assignment.team_id == exclude_team_id: - raise HTTPException(status_code=400, detail="Escolha o turno de outra equipa") - if assignment.date < date.today(): - raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") - if _active_request(db, assignment.id): - raise HTTPException(status_code=400, detail="Esse turno já tem um pedido de troca") - return assignment - - -def _swap_partner(db: Session, schedule: Schedule, team_id: int | None) -> Team | None: - """The one team this swap is with: active and working the same schedule.""" - if not team_id: - raise HTTPException(status_code=400, detail="Escolha a equipa que fica com o turno") - if team_id not in _schedule_team_ids(db, schedule.id): - raise HTTPException( - status_code=400, detail="Só pode trocar com quem trabalha nesta escala" - ) - partner = db.get(Team, team_id) - if not partner or not partner.is_active: - raise HTTPException(status_code=400, detail="Equipa inválida para este turno") - return partner - - -@router.get("/swaps/new", response_class=HTMLResponse) -def new_swap_page( - request: Request, - year: int | None = None, - month: int | None = None, - db: Session = Depends(get_db), -): - """Its own page, so the rota keeps showing only who works each day.""" - user = _require_user(request, db) - my_ids = _user_team_ids(db, user) - year, month = _parse_month(year, month) - assignments = ensure_month_assignments(db, year, month) - taken = set( - db.scalars( - select(SwapRequest.assignment_id).where( - SwapRequest.status.in_(["open", "pending_approval"]) - ) - ).all() - ) - # One team can work several days, so the shift to offer is chosen here, - # and each schedule only offers the teams who work that same schedule. - names = {t.id: t.name for t in db.scalars(select(Team)).all()} - groups: list[dict] = [] - for schedule in _active_schedules(db): - mine = [ - a for a in assignments - if a.schedule_id == schedule.id - and a.team_id - and (a.team_id in my_ids or user.is_admin) - and a.date >= date.today() - and a.id not in taken - ] - theirs = [ - a for a in assignments - if a.schedule_id == schedule.id - and a.team_id - and a.team_id not in my_ids - and a.date >= date.today() - and a.id not in taken - ] - if not mine or not theirs: - continue - groups.append( - { - "schedule": schedule, - "shifts": [ - {"id": a.id, "label": f"{day_short(a.date)} · {names[a.team_id]}"} - for a in sorted(mine, key=lambda r: r.date) - ], - "wanted": [ - { - "id": a.id, - "label": f"{day_short(a.date)} · {names[a.team_id]}", - } - for a in sorted(theirs, key=lambda r: r.date) - ], - } - ) - prev, nxt = _month_nav(year, month) - return request.app.state.templates.TemplateResponse( - request=request, - name="swap_new.html", - context=_context( - request, - db, - year=year, - month=month, - month_name=month_name(month), - prev=prev, - nxt=nxt, - groups=groups, - ), - ) - - -@router.post("/swaps/new") -def create_swap_for_chosen_shift( - request: Request, - assignment_id: int = Form(...), - target_assignment_id: int = Form(...), - message: str = Form(""), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - return _create_swap(assignment_id, request, target_assignment_id, message, csrf_token, db) - - -@router.post("/assignments/{assignment_id}/swap") -def create_swap( - assignment_id: int, - request: Request, - target_assignment_id: int = Form(...), - message: str = Form(""), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - return _create_swap(assignment_id, request, target_assignment_id, message, csrf_token, db) - - -def _create_swap( - assignment_id: int, - request: Request, - target_assignment_id: int, - message: str, - csrf_token: str, - db: Session, -): - _check_csrf(request, csrf_token) - user = _require_user(request, db) - my_ids = _user_team_ids(db, user) - assignment = db.get(Assignment, assignment_id) - if not assignment: - raise HTTPException(status_code=404, detail="Turno não encontrado") - # An administrator may open a request for someone else, but the shift stays - # the requester's own so the ownership rule still applies on acceptance. - owner_id = assignment.team_id - if owner_id is None or (owner_id not in my_ids and not user.is_admin): - raise HTTPException(status_code=403, detail="Só pode oferecer o turno da sua equipa") - owner = db.get(Team, owner_id) - if assignment.date < date.today(): - raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") - if _active_request(db, assignment.id): - _flash(request, "Já existe um pedido de troca ativo para este turno.", "error") - return _redirect("/swaps") - - # A swap is one shift for one shift, both of the same schedule. - incoming = _swap_side(db, assignment.schedule, target_assignment_id, exclude_team_id=owner.id) - if incoming.id == assignment.id: - raise HTTPException(status_code=400, detail="Escolha outro turno para a troca") - target = db.get(Team, incoming.team_id) - - swap = SwapRequest( - assignment_id=assignment.id, - target_assignment_id=incoming.id, - requester_id=owner.id, - target_team_id=target.id, - status="open", - message=message.strip() or None, - ) - db.add(swap) - db.commit() - - subject = ( - f"Troca de turnos: {day_short(assignment.date)} por {day_short(incoming.date)} " - f"– {assignment.schedule.name}" - ) - body = ( - f"{owner.name} propõe trocar o turno de {assignment.schedule.name} de " - f"{day_numeric_long(assignment.date)} pelo seu turno de {day_numeric_long(incoming.date)}.\n\n" - f"{message.strip()}\n\n" - f"Abrir a escala: {settings.normalized_base_url}/swaps" - ) - send_email_notification(db, target, "swap_requested", subject, body) - - _flash(request, "Pedido de troca criado.") - return _redirect("/swaps") - - -def _safe_back(request: Request, fallback: str) -> str: - """Return to the rota when the action was started there, never to an arbitrary URL.""" - back = str(request.query_params.get("back") or request.headers.get("referer") or "") - if back.startswith("/") and not back.startswith("//"): - return back.split("#")[0] - return fallback - - -def _reject_swap(db: Session, swap: SwapRequest) -> None: - swap.status = "rejected" - db.commit() - - -def _rejectable(user: User, my_ids: set[int], swap: SwapRequest) -> bool: - """Anyone eligible may decline an open request; admins can also drop a pending one.""" - if user.is_admin: - return True - if swap.status != "open" or swap.requester_id in my_ids: - return False - return swap.target_team_id is None or swap.target_team_id in my_ids - - -def _swap_ownership_holds(db: Session, swap: SwapRequest) -> bool: - """Both teams must still own their own shift, otherwise the swap is void.""" - if swap.assignment.team_id != swap.requester_id: - return False - if swap.target_assignment_id is not None and swap.target_assignment is not None: - if swap.target_assignment.team_id != swap.target_team_id: - return False - return True - - -def _complete_swap(db: Session, swap: SwapRequest, new_team: Team) -> None: - """Exchange both shifts and close the request. Ownership must be checked by the caller.""" - swap.assignment.team_id = new_team.id - swap.assignment.source = "swap" - incoming = swap.target_assignment - if incoming is not None: - incoming.team_id = swap.requester_id - incoming.source = "swap" - swap.accepted_team_id = new_team.id - swap.status = "approved" - for other in db.scalars( - select(SwapRequest).where( - SwapRequest.id != swap.id, - SwapRequest.status.in_(["open", "pending_approval"]), - ( - (SwapRequest.assignment_id == swap.assignment_id) - | (SwapRequest.target_assignment_id == swap.assignment_id) - | ( - (SwapRequest.assignment_id.is_not(None)) - & (SwapRequest.assignment_id == swap.target_assignment_id) - ) - | ( - (SwapRequest.target_assignment_id.is_not(None)) - & (SwapRequest.target_assignment_id == swap.target_assignment_id) - ) - ), - ) - ).all(): - other.status = "cancelled" - db.commit() - - -def _accepting_team(db: Session, user: User, swap: SwapRequest) -> Team: - """Which team takes the shift: the one it was asked of, or the first active one - assigned to this user. The user answers as a whole; the rota records one team.""" - if swap.target_team_id: - target = db.get(Team, swap.target_team_id) - if target is not None and target.user_id == user.id and target.is_active: - return target - for team in _user_teams(db, user): - if team.is_active: - return team - raise HTTPException(status_code=400, detail="Este utilizador não tem nenhuma equipa ativa para o turno") - - -def _admin_teams(db: Session) -> list[Team]: - """Every active team whose user is an administrator.""" - return list( - db.scalars( - select(Team) - .join(User, Team.user_id == User.id) - .where( - User.is_admin.is_(True), - User.is_active.is_(True), - Team.is_active.is_(True), - ) - .order_by(Team.name) - ).all() - ) - - -@router.post("/swaps/{swap_id}/accept") -def accept_swap( - swap_id: int, - request: Request, - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _check_csrf(request, csrf_token) - user = _require_user(request, db) - my_ids = _user_team_ids(db, user) - swap = db.scalar( - select(SwapRequest) - .where(SwapRequest.id == swap_id) - .options( - selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.requester), - ) - ) - if not swap or swap.status != "open": - raise HTTPException(status_code=400, detail="Este pedido já não está aberto") - if swap.requester_id in my_ids: - raise HTTPException(status_code=400, detail="Não pode aceitar o seu próprio pedido") - if swap.target_team_id is not None and swap.target_team_id not in my_ids: - raise HTTPException(status_code=403, detail="Este pedido é para outra equipa") - if not _swap_ownership_holds(db, swap): - swap.status = "cancelled" - db.commit() - raise HTTPException(status_code=409, detail="O turno já foi alterado") - - team = _accepting_team(db, user, swap) - swap.accepted_team_id = team.id - if swap.assignment.schedule.requires_manager_approval: - swap.status = "pending_approval" - db.commit() - admins = _admin_teams(db) - send_many( - db, - admins, - "swap_needs_approval", - "Uma troca de turno precisa de aprovação", - f"{team.name} aceitou a troca de {swap.assignment.schedule.name} de " - f"{day_numeric_long(swap.assignment.date)}" - + ( - f" por {day_numeric_long(swap.target_assignment.date)}" - if swap.target_assignment is not None - else "" - ) - + f", pedida por {swap.requester.name}. Aprove em {settings.normalized_base_url}/swaps", - ) - send_email_notification( - db, - swap.requester, - "swap_accepted", - "A sua troca de turno foi aceite", - f"{team.name} aceitou o seu pedido. Está agora à espera da aprovação da gestão.", - ) - _flash(request, "Aceite. A aguardar aprovação da gestão.") - else: - _complete_swap(db, swap, team) - incoming = swap.target_assignment - for recipient, other in ((swap.requester, team), (team, swap.requester)): - send_email_notification( - db, - recipient, - "swap_approved", - "Troca de turno confirmada", - f"Troca concluída em {swap.assignment.schedule.name}: fica com o turno de " - f"{day_numeric_long(swap.assignment.date)}" - + ( - f" e passa o de {day_numeric_long(incoming.date)} para {other.name}." - if incoming is not None - else "." - ), - ) - _flash(request, "Troca concluída.") - return _redirect("/swaps") - - -@router.post("/swaps/{swap_id}/reject") -def reject_swap( - swap_id: int, - request: Request, - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _check_csrf(request, csrf_token) - user = _require_user(request, db) - my_ids = _user_team_ids(db, user) - swap = db.scalar( - select(SwapRequest) - .where(SwapRequest.id == swap_id) - .options(selectinload(SwapRequest.requester)) - ) - if not swap or swap.status not in {"open", "pending_approval"}: - raise HTTPException(status_code=400, detail="Este pedido já não está aberto") - if not _rejectable(user, my_ids, swap): - raise HTTPException(status_code=403, detail="Não pode recusar este pedido") - who = _user_label(user) - _reject_swap(db, swap) - send_email_notification( - db, - swap.requester, - "swap_rejected", - "Pedido de troca recusado", - f"{who} recusou o seu pedido de troca.", - ) - _flash(request, "Pedido recusado.") - return _redirect(_safe_back(request, "/swaps")) - - -@router.post("/swaps/{swap_id}/cancel") -def cancel_swap( - swap_id: int, - request: Request, - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _check_csrf(request, csrf_token) - user = _require_user(request, db) - my_ids = _user_team_ids(db, user) - swap = db.get(SwapRequest, swap_id) - if not swap or swap.requester_id not in my_ids or swap.status not in {"open", "pending_approval"}: - raise HTTPException(status_code=403, detail="Não é possível cancelar este pedido") - swap.status = "cancelled" - db.commit() - _flash(request, "Pedido cancelado.") - return _redirect("/swaps") - - -@router.post("/swaps/{swap_id}/approve") -def approve_swap( - swap_id: int, - request: Request, - team_id: str = Form(""), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - admin = _require_admin(request, db) - _check_csrf(request, csrf_token) - swap = db.scalar( - select(SwapRequest) - .where(SwapRequest.id == swap_id) - .options( - selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.requester), - selectinload(SwapRequest.accepted_by), - ) - ) - if not swap or swap.status not in {"open", "pending_approval"}: - raise HTTPException(status_code=400, detail="Este pedido já não pode ser aprovado") - if not _swap_ownership_holds(db, swap): - swap.status = "cancelled" - db.commit() - raise HTTPException(status_code=409, detail="O turno já foi alterado") - if swap.assignment.date < date.today(): - raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") - - if swap.accepted_by and swap.accepted_by.is_active: - new_team = swap.accepted_by - elif swap.target_team_id and db.get(Team, swap.target_team_id).is_active: - new_team = db.get(Team, swap.target_team_id) - else: - new_team = _swap_partner(db, swap.assignment.schedule, int(team_id) if team_id.strip() else None) - if new_team.id == swap.requester_id: - raise HTTPException(status_code=400, detail="Escolha outra equipa para a troca") - - _complete_swap(db, swap, new_team) - incoming = swap.target_assignment - subject = "Troca de turno aprovada" - body = ( - f"{_user_label(admin)} aprovou a troca de {swap.assignment.schedule.name}: " - f"{new_team.name} fica com o turno de {day_numeric_long(swap.assignment.date)}" - + ( - f" e {swap.requester.name} com o de {day_numeric_long(incoming.date)}." - if incoming is not None - else "." - ) - ) - send_many(db, [swap.requester, new_team], "swap_approved", subject, body) - _flash(request, "Troca aprovada e escala atualizada.") - return _redirect(_safe_back(request, "/swaps")) - - -@router.post("/swaps/{swap_id}/revert") -def revert_swap( - swap_id: int, - request: Request, - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - """Administrator only: undo an approved swap and give both shifts back.""" - admin = _require_admin(request, db) - _check_csrf(request, csrf_token) - swap = db.scalar( - select(SwapRequest) - .where(SwapRequest.id == swap_id) - .options( - selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.target_assignment), - selectinload(SwapRequest.requester), - ) - ) - if not swap or swap.target_assignment_id is None: - raise HTTPException(status_code=404, detail="Troca não encontrada") - if swap.status != "approved": - raise HTTPException(status_code=400, detail="Só uma troca aprovada pode ser revertida") - # The shifts must still be the ones the swap produced, or somebody edited them since. - if swap.assignment.team_id != swap.accepted_team_id or ( - swap.target_assignment is not None - and swap.target_assignment.team_id != swap.requester_id - ): - raise HTTPException( - status_code=409, detail="O turno foi alterado depois da troca; reverta com a atribuição por dia" - ) - - incoming = swap.target_assignment - note = f"Revertida a troca #{swap.id} por {_user_label(admin)}" - swap.assignment.team_id = swap.requester_id - swap.assignment.source = "manual" - swap.assignment.note = note - incoming.team_id = swap.target_team_id - incoming.source = "manual" - incoming.note = note - swap.status = "reverted" - db.commit() - - subject = "Troca de turno revertida" - body = ( - f"{_user_label(admin)} reverteu a troca de {swap.assignment.schedule.name}. " - f"{swap.requester.name} volta a {day_numeric_long(swap.assignment.date)} e " - f"{swap.target_team.name} volta a {day_numeric_long(incoming.date)}.\n\n" - f"Ver a escala: {settings.normalized_base_url}/" - ) - send_many(db, [swap.requester, swap.target_team], "swap_reverted", subject, body) - _flash(request, "Troca revertida: os dois turnos voltaram a quem os tinha.") - return _redirect(_safe_back(request, "/swaps")) - - -@router.post("/swaps/{swap_id}/admin-reject") -def admin_reject_swap( - swap_id: int, - request: Request, - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - admin = _require_admin(request, db) - _check_csrf(request, csrf_token) - swap = db.scalar( - select(SwapRequest) - .where(SwapRequest.id == swap_id) - .options(selectinload(SwapRequest.requester), selectinload(SwapRequest.accepted_by)) - ) - if not swap or swap.status not in {"open", "pending_approval"}: - raise HTTPException(status_code=400, detail="Este pedido não pode ser recusado") - _reject_swap(db, swap) - involved = [t for t in [swap.requester, swap.accepted_by] if t] - send_many(db, involved, "swap_rejected", "Troca de turno não aprovada", - f"{_user_label(admin)} não aprovou a troca de turno pedida.") - _flash(request, "Troca recusada.") - return _redirect(_safe_back(request, "/swaps")) - - -@router.get("/admin/teams", response_class=HTMLResponse) -def admin_teams(request: Request, db: Session = Depends(get_db)): - """Who works the rota. Users are not created here: a team only picks who signs in for it.""" - admin = _require_admin(request, db) - teams = db.scalars( - select(Team) - .options(selectinload(Team.user).selectinload(User.teams)) - .order_by(Team.name) - ).all() - removable_ids = {team.id for team in teams if _team_removal_blocker(db, admin, team) is None} - return request.app.state.templates.TemplateResponse( - request=request, - name="admin_teams.html", - context=_context( - request, - db, - teams=teams, - removable_ids=removable_ids, - users_by_id=_users_by_id(db), - ), - ) - - -def _users_by_id(db: Session) -> dict[int, User]: - return {user.id: user for user in db.scalars(select(User).order_by(User.username)).all()} - - -@router.post("/admin/teams") -def admin_create_team( - request: Request, - name: str = Form(...), - email: str = Form(""), - phone: str = Form(""), - assigned_user_id: str = Form(""), - notify_email: str = Form(""), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - """Add a team to the rota. The user who signs in for it is chosen here or later.""" - _check_csrf(request, csrf_token) - _require_admin(request, db) - email_value = email.strip().lower() or None - if email_value and db.scalar(select(Team.id).where(Team.email == email_value)): - _flash(request, "Esse endereço de e-mail já existe.", "error") - return _redirect("/admin/teams") - db.add( - Team( - name=name.strip(), - user=_chosen_user(db, assigned_user_id), - email=email_value, - phone=phone.strip() or None, - is_active=True, - notify_email=bool(notify_email), - ) - ) - db.commit() - _flash(request, "Equipa criada.") - return _redirect("/admin/teams") - - -def _chosen_user(db: Session, raw: str) -> User | None: - """The user picked in a form, or None for "nobody signs in for this team".""" - value = raw.strip() - if not value.isdigit(): - return None - user = db.get(User, int(value)) - if user is None: - raise HTTPException(status_code=400, detail="Utilizador inválido") - return user - - -@router.post("/admin/teams/{team_id}/update") -def admin_update_team( - team_id: int, - request: Request, - name: str = Form(...), - email: str = Form(""), - phone: str = Form(""), - assigned_user_id: str = Form(""), - is_active: str = Form(""), - notify_email: str = Form(""), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _check_csrf(request, csrf_token) - admin = _require_admin(request, db) - team = db.get(Team, team_id) - if not team: - raise HTTPException(status_code=404, detail="Equipa não encontrada") - email_value = email.strip().lower() or None - duplicate = ( - db.scalar(select(Team.id).where(Team.email == email_value, Team.id != team.id)) - if email_value - else None - ) - if duplicate: - _flash(request, "Esse endereço de e-mail já está a ser usado.", "error") - return _redirect("/admin/teams") - - # Whoever is signed in must keep an active team to sign in with, not necessarily this one. - chosen = _chosen_user(db, assigned_user_id) - stays_active = bool(is_active) - if _loses_last_team(db, admin, team, chosen, stays_active): - _flash(request, "Não pode desativar a última equipa do utilizador com que está ligado.", "error") - return _redirect("/admin/teams") - - team.name = name.strip() - team.user = chosen - team.email = email_value - team.phone = phone.strip() or None - team.notify_email = bool(notify_email) - team.is_active = stays_active - db.commit() - _flash(request, "Equipa atualizada.") - return _redirect("/admin/teams") - - -def _loses_last_team( - db: Session, user: User, team: Team, chosen: User | None, stays_active: bool -) -> bool: - """True when this edit would leave the user without any active team to sign in with.""" - still_mine = chosen is not None and chosen.id == user.id and stays_active - if still_mine: - return False - return not db.scalar( - select(Team.id).where( - Team.user_id == user.id, Team.id != team.id, Team.is_active.is_(True) - ) - ) - - -def _team_removal_impact(db: Session, team: Team) -> dict[str, int]: - return { - "assignments": db.scalar( - select(func.count(Assignment.id)).where(Assignment.team_id == team.id) - ) or 0, # must be zero for the removal to be allowed - "rotation": db.scalar( - select(func.count(RotationMember.id)).where(RotationMember.team_id == team.id) - ) or 0, - "pattern_days": db.scalar( - select(func.count(MonthlyPattern.id)).where(MonthlyPattern.team_id == team.id) - ) or 0, - "requests": db.scalar( - select(func.count(SwapRequest.id)).where( - SwapRequest.status.in_(["open", "pending_approval"]), - ( - (SwapRequest.requester_id == team.id) - | (SwapRequest.target_team_id == team.id) - | (SwapRequest.accepted_team_id == team.id) - ), - ) - ) or 0, - } - - -def _usable_admin_users(db: Session, exclude: User | None = None) -> list[User]: - """Administrator users that can still sign in: active, and covering an active team.""" - users = db.scalars( - select(User) - .where(User.is_admin.is_(True), User.is_active.is_(True)) - .options(selectinload(User.teams)) - ).all() - return [ - user - for user in users - if user.id != (exclude.id if exclude else None) - and any(team.is_active for team in user.teams) - ] - - -def _team_removal_blocker(db: Session, admin: User, team: Team) -> str | None: - """Why this team cannot be removed, or None when the removal is allowed.""" - user = team.user - if user is not None and user.id == admin.id and not db.scalar( - select(Team.id).where(Team.user_id == user.id, Team.id != team.id) - ): - return "Não pode remover a si próprio(a)" - if user is not None and user.is_admin and not _usable_admin_users(db, exclude=user): - return "Não pode remover a equipa do último administrador" - if db.scalar(select(Assignment.id).where(Assignment.team_id == team.id)): - return "Não pode remover uma equipa que ainda tem turnos atribuídos" - return None - - -def _require_removable(db: Session, admin: User, team: Team) -> None: - reason = _team_removal_blocker(db, admin, team) - if reason: - raise HTTPException(status_code=400, detail=reason) - - -@router.get("/admin/teams/{team_id}/delete", response_class=HTMLResponse) -def admin_team_delete_page( - team_id: int, - request: Request, - db: Session = Depends(get_db), -): - admin = _require_admin(request, db) - team = db.get(Team, team_id) - if not team: - raise HTTPException(status_code=404, detail="Equipa não encontrada") - _require_removable(db, admin, team) - return request.app.state.templates.TemplateResponse( - request=request, - name="admin_team_delete.html", - context=_context(request, db, team=team, impact=_team_removal_impact(db, team)), - ) - - -@router.post("/admin/teams/{team_id}/delete") -def admin_team_delete( - team_id: int, - request: Request, - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - admin = _require_admin(request, db) - _check_csrf(request, csrf_token) - team = db.get(Team, team_id) - if not team: - raise HTTPException(status_code=404, detail="Equipa não encontrada") - _require_removable(db, admin, team) - - # No assignment can survive here: _require_removable refuses while the team holds one. - for swap in db.scalars( - select(SwapRequest).where(SwapRequest.requester_id == team.id) - ).all(): - db.delete(swap) - for swap in db.scalars( - select(SwapRequest).where( - (SwapRequest.target_team_id == team.id) | (SwapRequest.accepted_team_id == team.id) - ) - ).all(): - if swap.target_team_id == team.id: - swap.target_team_id = None - if swap.accepted_team_id == team.id: - swap.accepted_team_id = None - for pattern in db.scalars( - select(MonthlyPattern).where(MonthlyPattern.team_id == team.id) - ).all(): - pattern.team_id = None - for log in db.scalars( - select(NotificationLog).where(NotificationLog.team_id == team.id) - ).all(): - log.team_id = None - db.execute(delete(RotationMember).where(RotationMember.team_id == team.id)) - db.delete(team) - db.commit() - return _redirect("/admin/teams") - - -@router.get("/admin/users", response_class=HTMLResponse) -def admin_users(request: Request, db: Session = Depends(get_db)): - """The users: created here, then assigned to the teams they sign in for.""" - admin = _require_admin(request, db) - users = db.scalars( - select(User).options(selectinload(User.teams)).order_by(User.username) - ).all() - removable_ids = {user.id for user in users if _user_removal_blocker(db, admin, user) is None} - return request.app.state.templates.TemplateResponse( - request=request, - name="admin_users.html", - context=_context( - request, - db, - users=users, - removable_ids=removable_ids, - teams=db.scalars(select(Team).order_by(Team.name)).all(), - ), - ) - - -def _user_removal_blocker(db: Session, admin: User, user: User) -> str | None: - """Why this user cannot be removed, or None when the removal is allowed.""" - teams = db.scalars(select(Team).where(Team.user_id == user.id).order_by(Team.name)).all() - if teams: - return f"Está atribuído a {len(teams)} equipa(s)" - if user.id == admin.id: - return "Não pode remover o seu próprio acesso" - if user.is_admin and not _usable_admin_users(db, exclude=user): - return "Não pode remover o último administrador" - return None - - -@router.post("/admin/users") -def admin_create_user( - request: Request, - name: str = Form(...), - username: str = Form(...), - password: str = Form(...), - team_ids: list[str] = Form([]), - is_admin: str = Form(""), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - """A new user, assigned to the teams it will sign in for.""" - _check_csrf(request, csrf_token) - _require_admin(request, db) - user, error = _new_user(db, name, username, password, is_admin=bool(is_admin)) - if error: - _flash(request, error, "error") - return _redirect("/admin/users") - db.add(user) - db.commit() - _assign_user_to_teams(db, user, team_ids) - db.commit() - _flash(request, "Utilizador criado.") - return _redirect("/admin/users") - - -def _new_user( - db: Session, name: str, username: str, password: str, *, is_admin: bool -) -> tuple[User | None, str | None]: - """A user with a username and a password, unsaved, or the reason it was refused.""" - value = username.strip().lower() - if not value: - return None, "Defina um nome de utilizador." - if db.scalar(select(User.id).where(User.username == value)): - return None, "Esse nome de utilizador já existe." - try: - password_hash = hash_password(password) - except ValueError as exc: - return None, str(exc) - return ( - User( - name=name.strip() or value, - username=value, - password_hash=password_hash, - is_admin=is_admin, - is_active=True, - ), - None, - ) - - -def _assign_user_to_teams(db: Session, user: User, team_ids: Sequence[str]) -> None: - """Attach this user to exactly the teams the form selected.""" - chosen = {int(value) for value in team_ids if str(value).isdigit()} - for team in db.scalars(select(Team)).all(): - if (team.id in chosen) != (team.user_id == user.id): - team.user = user if team.id in chosen else None - - -@router.post("/admin/users/{user_id}/update") -def admin_update_user( - user_id: int, - request: Request, - name: str = Form(...), - username: str = Form(...), - password: str = Form(""), - team_ids: list[str] = Form([]), - is_admin: str = Form(""), - is_active: str = Form(""), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _check_csrf(request, csrf_token) - admin = _require_admin(request, db) - user = db.get(User, user_id) - if not user: - raise HTTPException(status_code=404, detail="Utilizador não encontrado") - username_value = username.strip().lower() - if not username_value: - _flash(request, "O nome de utilizador não pode ficar vazio.", "error") - return _redirect("/admin/users") - duplicate = db.scalar( - select(User.id).where(User.username == username_value, User.id != user.id) - ) - if duplicate: - _flash(request, "Esse nome de utilizador já está a ser usado.", "error") - return _redirect("/admin/users") - loses_admin = user.is_admin and not is_admin - loses_access = user.id == admin.id and not is_active - last_admin = loses_admin and not _usable_admin_users(db, exclude=user) - if last_admin: - _flash(request, "Não pode tirar a administração ao último utilizador que a tem.", "error") - return _redirect("/admin/users") - if loses_access: - _flash(request, "Não pode desativar o acesso com que está ligado.", "error") - return _redirect("/admin/users") - if password.strip(): - try: - user.password_hash = hash_password(password.strip()) - except ValueError as exc: - _flash(request, str(exc), "error") - return _redirect("/admin/users") - - user.name = name.strip() or username_value - user.username = username_value - user.is_admin = bool(is_admin) - user.is_active = True if user.id == admin.id else bool(is_active) - db.commit() - _assign_user_to_teams(db, user, team_ids) - db.commit() - _flash(request, "Utilizador atualizado.") - return _redirect("/admin/users") - - -@router.post("/admin/users/{user_id}/delete") -def admin_user_delete( - user_id: int, - request: Request, - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _check_csrf(request, csrf_token) - admin = _require_admin(request, db) - user = db.get(User, user_id) - if not user: - raise HTTPException(status_code=404, detail="Utilizador não encontrado") - reason = _user_removal_blocker(db, admin, user) - if reason: - _flash(request, f"Não é possível remover este utilizador: {reason}.", "error") - return _redirect("/admin/users") - db.delete(user) - db.commit() - _flash(request, "Utilizador removido.") - return _redirect("/admin/users") - - -@router.get("/admin/schedules", response_class=HTMLResponse) -def admin_schedules( - request: Request, - schedule_id: int | None = None, - db: Session = Depends(get_db), -): - _require_admin(request, db) - return _render_admin_schedules(request, db, schedule_id) - - -def _render_admin_schedules( - request: Request, - db: Session, - schedule_id: int | None = None, - start: date | None = None, - end: date | None = None, - error: str | None = None, - status_code: int = 200, -): - cards = _schedule_groups(_all_schedules(db), db) - schedules = [card["schedule"] for card in cards if card["schedule"].is_active] - if schedule_id is not None: - selected = _get_schedule(db, schedule_id) - if not selected.is_active: - raise HTTPException(status_code=404, detail="Escala não encontrada") - else: - selected = schedules[0] if schedules else None - today = date.today() - first, last = month_bounds(today.year, today.month) - return request.app.state.templates.TemplateResponse( - request=request, - name="admin_schedules.html", - status_code=status_code, - context=_context( - request, - db, - cards=cards, - calendar_feed_url=_shared_calendar_url(request), - schedules=schedules, - selected_schedule_id=selected.id if selected else None, - start=(start or first).isoformat(), - end=(end or last).isoformat(), - error=error, - ), - ) - - -def _shared_calendar_url(request: Request) -> str: - """The whole-rota feed, with a team token created the first time it is asked for.""" - if not settings.calendar_token: - settings.calendar_token = secrets.token_urlsafe(24) - path = settings.url(f"/calendar/{settings.calendar_token}.ics") - return f"{request.base_url.scheme}://{request.base_url.netloc}{path}" - - -@router.get("/admin/schedules/{schedule_id}", response_class=HTMLResponse) -def admin_schedule( - schedule_id: int, - request: Request, - year: int | None = None, - month: int | None = None, - db: Session = Depends(get_db), -): - _require_admin(request, db) - schedule = _get_schedule(db, schedule_id) - teams = db.scalars(select(Team).where(Team.is_active.is_(True)).order_by(Team.name)).all() - year, month = _parse_month(year, month) - assignments: list[Assignment] = [] - prev = nxt = (year, month) - if schedule.schedule_type == "rotation": - assignments = [ - row for row in ensure_month_assignments(db, year, month) if row.schedule_id == schedule.id - ] - prev, nxt = _month_nav(year, month) - return request.app.state.templates.TemplateResponse( - request=request, - name="admin_schedule.html", - context=_context( - request, - db, - card=_schedule_card(schedule, db), - cards=_schedule_groups(_all_schedules(db), db), - teams=teams, - schedule_teams=_schedule_teams(db, schedule.id), - assignments=assignments, - swap_requests=_open_swap_requests(db, schedule.id), - pattern_days=list(range(1, 32)), - pattern=_pattern_map(db, schedule), - months=MONTHS, - year=year, - month=month, - month_name=month_name(month), - prev=prev, - nxt=nxt, - ), - ) - - -def _open_swap_requests(db: Session, schedule_id: int) -> list[SwapRequest]: - """Open and pending change requests for one schedule, oldest first.""" - return list( - db.scalars( - select(SwapRequest) - .where( - SwapRequest.assignment_id.in_( - select(Assignment.id).where(Assignment.schedule_id == schedule_id) - ), - SwapRequest.status.in_(["open", "pending_approval"]), - ) - .options( - selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.requester), - selectinload(SwapRequest.target_team), - selectinload(SwapRequest.accepted_by), - ) - .order_by(SwapRequest.created_at) - ).all() - ) - - -def _pattern_map(db: Session, schedule: Schedule) -> dict[int, int]: - rows = db.scalars( - select(MonthlyPattern).where(MonthlyPattern.schedule_id == schedule.id) - ).all() - return {row.day_of_month: row.team_id for row in rows if row.team_id is not None} - - -@router.post("/admin/schedules/{schedule_id}/pattern") -async def admin_schedule_pattern( - schedule_id: int, - request: Request, - db: Session = Depends(get_db), -): - _require_admin(request, db) - form = await request.form() - _check_csrf(request, str(form.get("csrf_token", ""))) - schedule = _get_schedule(db, schedule_id) - if schedule.schedule_type == "rotation": - raise HTTPException(status_code=400, detail="As rotações usam a sua própria ordem, não um padrão mensal") - teams_by_id = {t.id for t in db.scalars(select(Team).where(Team.is_active.is_(True))).all()} - - existing = { - row.day_of_month: row - for row in db.scalars( - select(MonthlyPattern).where(MonthlyPattern.schedule_id == schedule.id) - ).all() - } - saved = 0 - for day in range(1, 32): - raw = str(form.get(f"day_{day}", "")).strip() - team_id = int(raw) if raw else None - if team_id is not None and team_id not in teams_by_id: - continue - row = existing.get(day) - if row is None: - if team_id is None: - continue - db.add(MonthlyPattern(schedule_id=schedule.id, day_of_month=day, team_id=team_id)) - saved += 1 - elif row.team_id != team_id: - row.team_id = team_id - saved += 1 - db.commit() - touched = _apply_pattern_to_open_rows(db, schedule) - _flash( - request, - f"Padrão de noites guardado ({saved} alteração(ões)). " - f"Foram atualizados {touched} turno(s) sem atribuição neste mês e no próximo; " - f"as noites já atribuídas foram mantidas.", - ) - return _redirect(f"/admin/schedules/{schedule.id}") - - -def _apply_pattern_to_open_rows(db: Session, schedule: Schedule) -> int: - """Fill empty generated nights from the pattern; never touch existing assignees.""" - patterns = _pattern_map(db, schedule) - if not patterns: - return 0 - today = date.today() - last_month = (today.month % 12) + 1 - last_year = today.year + (1 if today.month == 12 else 0) - rows = db.scalars( - select(Assignment).where( - Assignment.schedule_id == schedule.id, - Assignment.team_id.is_(None), - Assignment.source == "generated", - Assignment.date >= today.replace(day=1), - Assignment.date <= date(last_year, last_month, calendar.monthrange(last_year, last_month)[1]), - ) - ).all() - touched = 0 - for row in rows: - team_id = patterns.get(row.date.day) - if team_id and row.team_id != team_id: - row.team_id = team_id - touched += 1 - if touched: - db.commit() - return touched - - -def _apply_pattern_forward(db: Session, schedule: Schedule, first: date) -> int: - """Explicit admin edit: push the monthly pattern onto generated assignments from `first` on. - - Never creates or deletes rows, keeps assignment IDs, and preserves manual - assignments and completed swaps. A day with nobody in the pattern is left as - it is, so an empty pattern entry cannot blank out a shift. - """ - patterns = pattern_teams(db, schedule) - if not patterns: - return 0 - rows = db.scalars( - select(Assignment) - .where( - Assignment.schedule_id == schedule.id, - Assignment.date >= first, - Assignment.source == "generated", - ) - .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) - .order_by(Assignment.date) - ).all() - changed: list[tuple[Assignment, Team | None, Team | None]] = [] - touched = 0 - for row in rows: - team_id = patterns.get(row.date.day) - if team_id is None or row.team_id == team_id: - continue - old_team = db.get(Team, row.team_id) if row.team_id is not None else None - new_team = db.get(Team, team_id) - changed.append((row, old_team, new_team)) - for swap in db.scalars( - select(SwapRequest).where( - SwapRequest.assignment_id == row.id, - SwapRequest.status.in_(["open", "pending_approval"]), - ) - ).all(): - swap.status = "cancelled" - row.team_id = team_id - row.team = new_team - touched += 1 - if changed: - db.commit() - _notify_assignment_changes(db, changed) - return touched - - -@router.post("/admin/schedules/{schedule_id}/apply-pattern") -def admin_apply_pattern( - schedule_id: int, - request: Request, - year: int = Form(...), - month: int = Form(...), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _require_admin(request, db) - _check_csrf(request, csrf_token) - schedule = _get_schedule(db, schedule_id) - if schedule.schedule_type != "fixed": - raise HTTPException(status_code=400, detail="Esta escala não usa um padrão mensal") - year, month = _parse_month(year, month) - first = date(year, month, 1) - touched = _apply_pattern_forward(db, schedule, first) - _flash( - request, - f"Padrão aplicado a partir de {day_numeric_long(first)}: {touched} turno(s) atualizado(s). " - f"As atribuições manuais e as trocas foram mantidas, e os dias sem equipa no padrão ficaram como estão.", - ) - return _redirect(f"/admin/schedules/{schedule.id}") - - -def _clear_assignments_from(db: Session, schedule: Schedule, first: date) -> int: - """Explicit admin edit: leave the shifts from `first` on without an assignee. - - Only `generated` rows are touched, so manual assignments and completed swaps - survive. Rows keep their IDs, notes and swap history and stay `generated`, so - applying the rotation again fills them. - """ - rows = db.scalars( - select(Assignment) - .where( - Assignment.schedule_id == schedule.id, - Assignment.date >= first, - Assignment.source == "generated", - Assignment.team_id.is_not(None), - ) - .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) - .order_by(Assignment.date) - ).all() - changed: list[tuple[Assignment, Team | None, Team | None]] = [] - for row in rows: - old_team = db.get(Team, row.team_id) - changed.append((row, old_team, None)) - for swap in db.scalars( - select(SwapRequest).where( - SwapRequest.assignment_id == row.id, - SwapRequest.status.in_(["open", "pending_approval"]), - ) - ).all(): - swap.status = "cancelled" - row.team_id = None - row.team = None - if changed: - db.commit() - _notify_assignment_changes(db, changed) - return len(changed) - - -@router.post("/admin/schedules/{schedule_id}/clear-from") -def admin_clear_from( - schedule_id: int, - request: Request, - year: int = Form(...), - month: int = Form(...), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _require_admin(request, db) - _check_csrf(request, csrf_token) - schedule = _get_schedule(db, schedule_id) - if schedule.schedule_type != "rotation": - raise HTTPException(status_code=400, detail="Esta escala não usa rotação") - year, month = _parse_month(year, month) - first = date(year, month, 1) - cleared = _clear_assignments_from(db, schedule, first) - _flash( - request, - f"Turnos limpos a partir de {day_numeric_long(first)}: {cleared} turno(s) sem atribuição. " - f"As atribuições manuais e as trocas foram mantidas. Use Aplicar rotação para os preencher de novo.", - ) - return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}") - - -@router.post("/admin/schedules/{schedule_id}/settings") -def admin_schedule_settings( - schedule_id: int, - request: Request, - requires_manager_approval: str = Form(""), - is_active: str = Form(""), - start_time: str = Form(""), - end_time: str = Form(""), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _check_csrf(request, csrf_token) - _require_admin(request, db) - schedule = db.get(Schedule, schedule_id) - if not schedule: - raise HTTPException(status_code=404, detail="Escala não encontrada") - if start_time and end_time: - schedule.start_time = _parse_time(start_time) - schedule.end_time = _parse_time(end_time) - schedule.requires_manager_approval = bool(requires_manager_approval) - schedule.is_active = bool(is_active) - db.commit() - _flash(request, "Definições da escala guardadas.") - return _redirect(f"/admin/schedules/{schedule.id}") - - -@router.post("/admin/schedules/{schedule_id}/rotation") -def admin_rotation_add( - schedule_id: int, - request: Request, - team_id: int = Form(...), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _check_csrf(request, csrf_token) - _require_admin(request, db) - schedule = db.get(Schedule, schedule_id) - user = db.get(Team, team_id) - if not schedule or schedule.schedule_type != "rotation" or not user or not user.is_active: - raise HTTPException(status_code=400, detail="Membro da rotação inválido") - exists = db.scalar( - select(RotationMember.id).where( - RotationMember.schedule_id == schedule.id, - RotationMember.team_id == user.id, - ) - ) - if exists: - _flash(request, "Essa equipa já está nesta rotação.", "error") - return _redirect(f"/admin/schedules/{schedule.id}#rotation-order") - max_position = db.scalar( - select(func.max(RotationMember.position)).where(RotationMember.schedule_id == schedule.id) - ) or 0 - db.add(RotationMember(schedule_id=schedule.id, team_id=user.id, position=max_position + 1)) - db.commit() - _flash(request, "Membro adicionado à rotação.") - return _redirect(f"/admin/schedules/{schedule.id}#rotation-order") - - -@router.post("/admin/rotation/{member_id}/remove") -def admin_rotation_remove( - member_id: int, - request: Request, - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _check_csrf(request, csrf_token) - _require_admin(request, db) - member = db.get(RotationMember, member_id) - if not member: - raise HTTPException(status_code=404, detail="Membro da rotação não encontrado") - schedule_id = member.schedule_id - db.delete(member) - db.commit() - members = db.scalars( - select(RotationMember).where(RotationMember.schedule_id == schedule_id).order_by(RotationMember.position) - ).all() - for idx, row in enumerate(members, start=1): - row.position = idx - db.commit() - _flash(request, "Membro removido da rotação.") - return _redirect(f"/admin/schedules/{schedule_id}") - - -@router.post("/admin/rotation/{member_id}/move") -def admin_rotation_move( - member_id: int, - request: Request, - direction: str = Form(...), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _check_csrf(request, csrf_token) - _require_admin(request, db) - member = db.get(RotationMember, member_id) - if not member or direction not in {"up", "down"}: - raise HTTPException(status_code=400, detail="Movimento inválido") - target_position = member.position - 1 if direction == "up" else member.position + 1 - other = db.scalar( - select(RotationMember).where( - RotationMember.schedule_id == member.schedule_id, - RotationMember.position == target_position, - ) - ) - if other: - temporary = 1000000 + member.id - member.position = temporary - db.flush() - other.position = target_position + 1 if direction == "up" else target_position - 1 - db.flush() - member.position = target_position - db.commit() - return _redirect(f"/admin/schedules/{member.schedule_id}") - - -@router.get("/admin/month", response_class=HTMLResponse) -def admin_month(request: Request, db: Session = Depends(get_db)): - _require_admin(request, db) - # Each schedule is now configured on its own page. - return _redirect("/admin/schedules") - - -def _save_assignments(db: Session, assignments: Sequence[Assignment], form) -> int: - teams_by_id = {t.id: t for t in db.scalars(select(Team).where(Team.is_active.is_(True))).all()} - changed: list[tuple[Assignment, Team | None, Team | None]] = [] - for assignment in assignments: - raw = str(form.get(f"assignment_{assignment.id}", "")).strip() - new_team_id = int(raw) if raw else None - if new_team_id is not None and new_team_id not in teams_by_id: - continue - if assignment.team_id != new_team_id: - old_team = assignment.team - new_team = teams_by_id.get(new_team_id) if new_team_id else None - assignment.team_id = new_team_id - assignment.source = "manual" - changed.append((assignment, old_team, new_team)) - db.commit() - - _notify_assignment_changes(db, changed) - return len(changed) - - -def _default_team(db: Session, schedule: Schedule, day: date) -> Team | None: - """Who this shift would have by default: the rotation order or the night pattern.""" - if schedule.schedule_type == "rotation": - return rotation_team_for_date(db, schedule, day) - team_id = _pattern_map(db, schedule).get(day.day) - return db.get(Team, team_id) if team_id else None - - -def _set_assignee( - db: Session, assignment: Assignment, new_team: Team | None, source: str = "manual" -) -> None: - """Put someone on a shift directly: no change request, and any stale one is closed.""" - assignment.team_id = new_team.id if new_team else None - assignment.team = new_team - assignment.source = source - for swap in db.scalars( - select(SwapRequest).where( - SwapRequest.status.in_(["open", "pending_approval"]), - (SwapRequest.assignment_id == assignment.id) - | (SwapRequest.target_assignment_id == assignment.id), - ) - ).all(): - swap.status = "cancelled" - db.commit() - - -def _notify_assignment_changes(db: Session, changed: Sequence[tuple[Assignment, Team | None, Team | None]]) -> None: - for assignment, old_team, new_team in changed: - subject = f"Escala atualizada: {day_short(assignment.date)} – {assignment.schedule.name}" - if new_team: - send_email_notification( - db, - new_team, - "assignment_updated", - subject, - f"Está atribuído ao turno de {assignment.schedule.name} a {day_numeric_long(assignment.date)}.\n\n" - f"Ver a escala: {settings.normalized_base_url}/", - ) - if old_team and (not new_team or old_team.id != new_team.id): - send_email_notification( - db, - old_team, - "assignment_changed", - subject, - f"Já não está atribuído ao turno de {assignment.schedule.name} a {day_numeric_long(assignment.date)}.\n\n" - f"Ver a escala: {settings.normalized_base_url}/", - ) - - -def _month_assignments(db: Session, year: int, month: int) -> list[Assignment]: - first, last = month_bounds(year, month) - return list( - db.scalars( - select(Assignment) - .where(Assignment.date >= first, Assignment.date <= last) - .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) - .order_by(Assignment.date) - ).all() - ) - - -@router.post("/admin/schedules/{schedule_id}/swaps/{swap_id}/assign") -def admin_schedule_assign_swap( - schedule_id: int, - swap_id: int, - request: Request, - team_id: int = Form(...), - year: str = Form(""), - month: str = Form(""), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - """Complete a change request on behalf of the rota, including for rota-only teams.""" - admin = _require_admin(request, db) - _check_csrf(request, csrf_token) - schedule = _get_schedule(db, schedule_id) - swap = db.scalar( - select(SwapRequest) - .where(SwapRequest.id == swap_id) - .options( - selectinload(SwapRequest.assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.target_assignment).selectinload(Assignment.schedule), - selectinload(SwapRequest.requester), - ) - ) - if not swap or swap.assignment.schedule_id != schedule.id: - raise HTTPException(status_code=404, detail="Pedido de troca não encontrado") - if swap.status not in {"open", "pending_approval"}: - raise HTTPException(status_code=400, detail="Este pedido já não está aberto") - new_team = _swap_partner(db, schedule, team_id) - if new_team.id == swap.requester_id: - raise HTTPException(status_code=400, detail="Escolha outra equipa para a troca") - if swap.assignment.date < date.today(): - raise HTTPException(status_code=400, detail="Turnos passados não podem ser alterados") - # The request only applies while the team who asked for the change still owns the shift. - if not _swap_ownership_holds(db, swap): - swap.status = "cancelled" - db.commit() - raise HTTPException(status_code=409, detail="O turno já foi alterado") - - swap.assignment.team_id = new_team.id - swap.assignment.source = "swap" - swap.accepted_team_id = new_team.id - swap.status = "approved" - for other in db.scalars( - select(SwapRequest).where( - SwapRequest.assignment_id == swap.assignment_id, - SwapRequest.id != swap.id, - SwapRequest.status.in_(["open", "pending_approval"]), - ) - ).all(): - other.status = "cancelled" - db.commit() - - subject = "Troca de turno aprovada" - body = ( - f"{_user_label(admin)} atribuiu o turno de {schedule.name} de " - f"{day_numeric_long(swap.assignment.date)} a {new_team.name}, a pedido de {swap.requester.name}." - + ( - f" Em troca, {swap.requester.name} passa a ter o turno de " - f"{day_numeric_long(swap.target_assignment.date)}." - if swap.target_assignment is not None - else "" - ) - + f"\n\nVer a escala: {settings.normalized_base_url}/" - ) - send_many(db, [swap.requester, new_team], "swap_approved", subject, body) - _flash(request, f"Turno de {day_short(swap.assignment.date)} atribuído a {new_team.name}.") - return _redirect(f"{_schedule_page(schedule.id, year, month)}#swap-requests") - - -def _schedule_page(schedule_id: int, year: str, month: str) -> str: - if year.isdigit() and month.isdigit(): - return f"/admin/schedules/{schedule_id}?year={int(year)}&month={int(month)}" - return f"/admin/schedules/{schedule_id}" - - -@router.post("/admin/schedules/{schedule_id}/assignments") -async def admin_schedule_assignments( - schedule_id: int, - request: Request, - db: Session = Depends(get_db), -): - _require_admin(request, db) - form = await request.form() - _check_csrf(request, str(form.get("csrf_token", ""))) - schedule = _get_schedule(db, schedule_id) - year = int(form.get("year", date.today().year)) - month = int(form.get("month", date.today().month)) - assignments = [row for row in _month_assignments(db, year, month) if row.schedule_id == schedule.id] - changed = _save_assignments(db, assignments, form) - _flash(request, f"Foram guardadas {changed} alteração(ões) de turno.") - return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}") - - -def _apply_rotation_to_rows(db: Session, schedule: Schedule, assignments: Sequence[Assignment]) -> int: - """Explicit admin edit: retain assignment IDs and invalidate requests when ownership changes.""" - changed: list[tuple[Assignment, Team | None, Team | None]] = [] - touched = 0 - db.flush() - for assignment in sorted(assignments, key=lambda row: row.date): - new_team = rotation_team_for_date(db, schedule, assignment.date) - new_team_id = new_team.id if new_team else None - if assignment.team_id == new_team_id and assignment.source == "generated": - continue - if assignment.team_id != new_team_id: - old_team = db.get(Team, assignment.team_id) if assignment.team_id is not None else None - changed.append((assignment, old_team, new_team)) - if assignment.id is not None: - requests = db.scalars(select(SwapRequest).where( - SwapRequest.assignment_id == assignment.id, - SwapRequest.status.in_(["open", "pending_approval"]), - )).all() - for swap in requests: - swap.status = "cancelled" - assignment.team_id = new_team_id - assignment.team = new_team - assignment.source = "generated" - # Later dates continue from the team selected for this date. - db.flush() - touched += 1 - db.commit() - _notify_assignment_changes(db, changed) - return touched - - -def _generate_rotation_range(db: Session, schedule: Schedule, first: date, last: date) -> tuple[int, int]: - rows = {row.date: row for row in db.scalars( - select(Assignment) - .where(Assignment.schedule_id == schedule.id, Assignment.date >= first, Assignment.date <= last) - .options(selectinload(Assignment.team), selectinload(Assignment.schedule)) - ).all()} - assignments = [] - created = 0 - day = first - while day <= last: - if day.weekday() not in schedule.weekday_set: - day += timedelta(days=1) - continue - row = rows.get(day) - if row is None: - row = Assignment(schedule=schedule, date=day, source="generated") - db.add(row) - created += 1 - if row.source == "generated": - assignments.append(row) - day += timedelta(days=1) - changed = _apply_rotation_to_rows(db, schedule, assignments) - return created, changed - - -@router.post("/admin/schedules/{schedule_id}/apply-rotation") -def admin_apply_rotation( - schedule_id: int, - request: Request, - year: int = Form(...), - month: int = Form(...), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _require_admin(request, db) - _check_csrf(request, csrf_token) - schedule = _get_schedule(db, schedule_id) - if schedule.schedule_type != "rotation": - raise HTTPException(status_code=400, detail="Esta escala não usa rotação") - year, month = _parse_month(year, month) - created, changed = _generate_rotation_range(db, schedule, *month_bounds(year, month)) - _flash(request, f"Rotação aplicada em {month_name(month)} de {year}: " - f"{created} turno(s) criado(s), {changed} turno(s) atualizado(s). " - f"As atribuições manuais e as trocas foram mantidas.") - return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}") - - -@router.post("/admin/schedules/{schedule_id}/assignments/{assignment_id}/regenerate") -@router.post("/admin/schedules/{schedule_id}/assignments/{assignment_id}/use-rotation") -def admin_assignment_regenerate( - schedule_id: int, - assignment_id: int, - request: Request, - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - _require_admin(request, db) - _check_csrf(request, csrf_token) - schedule = _get_schedule(db, schedule_id) - if schedule.schedule_type != "rotation": - raise HTTPException(status_code=400, detail="Esta escala não usa rotação") - assignment = db.get(Assignment, assignment_id) - if assignment is None or assignment.schedule_id != schedule.id: - raise HTTPException(status_code=404, detail="Turno não encontrado") - if assignment.date.weekday() not in schedule.weekday_set: - raise HTTPException(status_code=400, detail="Esta data não corresponde à escala") - _apply_rotation_to_rows(db, schedule, [assignment]) - _flash(request, "Turno recalculado com a equipa a seguir à responsável pelo turno anterior.") - return _redirect(f"/admin/schedules/{schedule.id}?year={assignment.date.year}&month={assignment.date.month}") - - -@router.get("/admin/assign", response_class=HTMLResponse) -def admin_assign_day( - request: Request, - on: str | None = None, - db: Session = Depends(get_db), -): - """Pick a day and put someone on one of its shifts. No change request involved. - - `on` is a whole ISO date (`YYYY-MM-DD`), which is what the date input sends. - """ - _require_admin(request, db) - today = date.today() - chosen = today - if on: - try: - chosen = date.fromisoformat(on) - except ValueError: - raise HTTPException(status_code=400, detail="Data inválida") - rows = { - a.schedule_id: a - for a in ensure_month_assignments(db, chosen.year, chosen.month) - if a.date == chosen - } - shifts = [ - { - "schedule": schedule, - "assignment": rows[schedule.id], - "auto": _default_team(db, schedule, chosen), - } - for schedule in _active_schedules(db) - if schedule.id in rows - ] - prev_day = chosen - timedelta(days=1) - next_day = chosen + timedelta(days=1) - return request.app.state.templates.TemplateResponse( - request=request, - name="admin_assign.html", - context=_context( - request, - db, - day=chosen, - shifts=shifts, - teams=db.scalars( - select(Team).where(Team.is_active.is_(True)).order_by(Team.name) - ).all(), - prev_day=prev_day, - next_day=next_day, - today=today, - ), - ) - - -@router.post("/admin/assign") -def admin_assign_team( - request: Request, - assignment_id: int = Form(...), - team_id: str = Form(""), - csrf_token: str = Form(...), - db: Session = Depends(get_db), -): - admin = _require_admin(request, db) - _check_csrf(request, csrf_token) - assignment = db.scalar( - select(Assignment) - .where(Assignment.id == assignment_id) - .options(selectinload(Assignment.schedule), selectinload(Assignment.team)) - ) - if not assignment: - raise HTTPException(status_code=404, detail="Turno não encontrado") - if assignment.date.weekday() not in assignment.schedule.weekday_set: - raise HTTPException(status_code=400, detail="Esta data não corresponde à escala") - - choice = team_id.strip() - source = "manual" - new_team = None - if choice == "auto": - source = "generated" - new_team = _default_team(db, assignment.schedule, assignment.date) - elif choice: - new_team = db.get(Team, int(choice)) - if not new_team or not new_team.is_active: - raise HTTPException(status_code=400, detail="Equipa inválida") - if new_team and new_team.id == assignment.team_id and assignment.source == source: - _flash(request, f"{new_team.name} já estava em {day_short(assignment.date)}.") - return _redirect(_assign_day_url(assignment.date)) - - previous_team_id = assignment.team_id - _set_assignee(db, assignment, new_team, source) - previous_team = db.get(Team, previous_team_id) if previous_team_id else None - _notify_assignment_changes(db, [(assignment, previous_team, new_team)]) - if new_team is None: - message = f"Turno de {day_short(assignment.date)} sem atribuição." - else: - message = f"{new_team.name} em {day_short(assignment.date)} · {assignment.schedule.name}." - if source == "generated": - message += " (automático)" - _flash(request, message) - return _redirect(_assign_day_url(assignment.date)) - - -def _assign_day_url(day: date) -> str: - return f"/admin/assign?on={day.isoformat()}" - - -@router.get("/admin/notifications", response_class=HTMLResponse) -def admin_notifications(request: Request, db: Session = Depends(get_db)): - _require_admin(request, db) - logs = db.scalars( - select(NotificationLog) - .options(selectinload(NotificationLog.team)) - .order_by(NotificationLog.created_at.desc()) - .limit(200) - ).all() - return request.app.state.templates.TemplateResponse( - request=request, - name="admin_notifications.html", - context=_context(request, db, logs=logs), - ) diff --git a/apps/shifts/backend/tests/conftest.py b/apps/shifts/backend/tests/conftest.py index 11e57d7..0f5895d 100644 --- a/apps/shifts/backend/tests/conftest.py +++ b/apps/shifts/backend/tests/conftest.py @@ -37,7 +37,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.config import settings -from app.bootstrap import ensure_initial_data +from app.services.bootstrap import ensure_initial_data from app.database import Base, SessionLocal, engine, run_migrations from app.main import app from app.models import Schedule, Team, User diff --git a/apps/shifts/backend/tests/test_export_schedule.py b/apps/shifts/backend/tests/test_export_schedule.py index c592a7f..85a2f6f 100644 --- a/apps/shifts/backend/tests/test_export_schedule.py +++ b/apps/shifts/backend/tests/test_export_schedule.py @@ -7,7 +7,7 @@ from sqlalchemy import select from app.config import settings from app.models import Assignment, Schedule, Team -from app.pdf import build_schedule_pdf +from app.services.pdf import build_schedule_pdf def download(client, schedule, start="2026-09-19", end="2026-12-26"): diff --git a/apps/shifts/docs/ARCHITECTURE.md b/apps/shifts/docs/ARCHITECTURE.md index 6dcf0b3..f1b1b65 100644 --- a/apps/shifts/docs/ARCHITECTURE.md +++ b/apps/shifts/docs/ARCHITECTURE.md @@ -4,18 +4,35 @@ Single FastAPI process with server-rendered HTML and a SQLite database volume. This keeps deployment suitable for a small VPS, NAS or home server. -For local development, `make dev` prepares the host virtualenv and default `data/` directory, then runs Uvicorn on `127.0.0.1:8000` with auto-reload for `app/`. Override the port with `make dev PORT=8001`. The app uses environment/`.env` settings and performs migrations and initial seeding during startup, just as it does in production. +From the monorepo root, `make shifts-dev` prepares the backend's default `data/` +directory and runs Uvicorn on `127.0.0.1:8000` through uv, with auto-reload. +Dependencies resolve through the root `uv.lock` into the root `.venv`. Settings +come from the environment and `backend/.env`; migrations and initial seeding run +on startup, as they do in production. ## Layers -- `models.py`: persistence schema. +- `backend/app/models/`: persistence schema split into user, team, schedule + (including patterns/rotation members), assignment, swap and notification + modules. The package exports the established model names and registers all + classes with the existing app-specific `Base`; mappings and relationships are + preserved. - `migrations/`: one Alembic revision, `0001_initial`, holding the whole schema (the history was squashed for a test environment). The app upgrades to `head` on start, and an existing database is adopted by stamping `head`; a database created before Alembic existed has no stamp and is adopted at the baseline. -- `scheduling.py`: deterministic month/rotation generation. -- `web.py`: authentication, admin editing and change-request orchestration. -- `notifications.py`: synchronous SMTP adapter + durable send log. -- `pdf.py`: ReportLab-rendered A4 schedule lists, with embedded Unicode fonts and +- `backend/app/services/`: bootstrap, deterministic scheduling, calendar feeds, + SMTP notifications and PDF rendering. +- `backend/app/routers/`: auth, account, calendar, exports, dashboard and swaps. + `admin/` splits teams, users, schedules, assignments and notification-log pages. + Each domain owns its routes; the package composes their routers for `main.py`. +- `routers/dependencies.py`: session/current-user/admin checks, CSRF validation, + flash/context, redirect and month-navigation helpers. Schedule, swap, assignment + and user helper modules support the domains that actually share them. They do + not import feature routers, keeping the dependency graph acyclic. +- `backend/app/security/`: the existing PBKDF2 password codec and CSRF-token + generator. HTTP/session orchestration stays in routers. +- `services/notifications.py`: synchronous SMTP adapter + durable send log. +- `services/pdf.py`: ReportLab-rendered A4 schedule lists, with embedded Unicode fonts and adaptive layout to keep the selected range on one page. -- `cli.py`: maintenance commands that can be run manually or by cron. +- `backend/app/cli.py`: maintenance commands that can be run manually or by cron. ## Data lifecycle @@ -28,7 +45,13 @@ For local development, `make dev` prepares the host virtualenv and default `data ## Deployment -`Dockerfile` provides a `production` target (the default) and a `dev` target with development dependencies and Uvicorn auto-reload. Both serve `app.main:app` on port 8000. `compose.yaml` is a minimal deployment example: it builds the production target, loads `.env`, publishes `${PORT:-8000}`, and mounts `${DATA_DIR:-./data}` at `/app/data`. It fixes the container database URL to the SQLite file on that mount; default backups also live there. See [the deployment guide](../README.md#run-with-docker-compose) for setup commands. +`apps/shifts/Dockerfile` uses a repository-root build context, Python 3.13 and uv, +and copies the shared brand package beside the backend. It serves `app.main:app` +on port 8000. Root `compose.yaml` loads `apps/shifts/backend/.env` and mounts the +independent `shifts-data` volume at `/data`, with its database URL pointing to +`/data/bar_rota.db`; default backups live beside it. Run +`docker compose up --build shifts` from the monorepo root. See +[the application README](../README.md) for environment and CLI commands. Maintenance commands are provided by `python -m app.cli`: `init-db`, `migrate`, `seed-demo`, `db-shell`, and `send-reminders`. Settings come from environment variables and `.env` through `app/config.py`. @@ -38,4 +61,14 @@ For larger installations, the SQLAlchemy configuration can point to PostgreSQL, ## Testing -Run `make test` on the host. It creates/reuses `.venv`, installs development dependencies, and invokes pytest; it needs no running application or container. `pyproject.toml` limits discovery to `tests/`. HTTPX supports FastAPI `TestClient` tests, and pypdf verifies PDF downloads. Fixtures configure temporary SQLite storage before importing the app, disable SMTP, run migrations and clean up after the suite. See [the testing guide](../README.md#run-tests). +Run `make shifts-test` from the monorepo root. It uses the locked uv project to +run pytest and the structural/workflow compatibility verifier. HTTPX supports +FastAPI `TestClient`; pypdf verifies downloaded documents. Fixtures configure +temporary SQLite storage before importing app modules, disable SMTP, run the +existing migration and clean up afterwards. The compatibility verifier copies +a populated synthetic SQLite fixture, checks startup preservation, and compares +schema/ORM/relationships/OpenAPI plus 96 HTTP requests across `/` and `/crr` +against the pre-refactor baseline. Runtime-only randomness/timestamps are +normalized, and calendar clocks are fixed for reproducible comparisons. Raw +comparison results are under ignored `.migration/phase4-results/`; verified +evidence is under `migration/phase4/`. diff --git a/migration/README.md b/migration/README.md index 14a05cc..31d945f 100644 --- a/migration/README.md +++ b/migration/README.md @@ -228,12 +228,70 @@ exit criteria are verified. Remote GitHub Actions execution is still unverified. On this machine, the pyenv override described above also applies to Tournament Makefile commands; uv still executes the applications on Python 3.13. +## Phase 4 — Shifts structural refactor: locally verified complete + +- Moved bootstrap, scheduling, calendar-feed, notification and PDF modules into + `apps/shifts/backend/app/services/`, updating CLI, startup and test imports. +- Split persistence into `models/{user,team,schedule,assignment,swap,notification}.py`. + `models/__init__.py` exports the established class names; forward relationship + typing uses `TYPE_CHECKING` while SQLAlchemy registers all classes normally. +- Split password hashing/verification and CSRF-token generation into `security/`. + Session/current-user/admin checks, CSRF validation, flash/context, redirects and + navigation helpers moved into `routers/dependencies.py`. +- Replaced `web.py` with the planned auth/account/calendar/exports/dashboard/swaps + routers and admin teams/users/schedules/assignments/notifications routers. + Supporting schedule/swap/assignment/user helpers have an acyclic dependency + graph. All 48 route declarations and their forms/dependencies moved intact. +- Updated architecture/contributor/development documentation and wired the new + compatibility check into `make shifts-test` and path-aware CI. + +Before changing application code, captured `phase4/baseline.json` and produced +`fixtures/shifts.sqlite` through the existing Shifts code. The fixture contains +synthetic admin/member users, a multi-team login, rotation membership, and +generated/manual/swapped assignments. It contains no production data. + +The one-time structural generator relocated definitions verbatim and selected +explicit imports, refusing existing destinations. Verification compares **173 +top-level function/class AST fingerprints**, ORM columns/defaults/constraints/ +indexes/relationships, full OpenAPI and existing migration-file checksums against +the baseline. It copies the populated fixture before each run and confirms that +startup preserves every existing row. + +| Check | Result | +| --- | --- | +| Shifts pytest | 16 passed | +| `make shifts-compat-test` | 96 request/response and resulting-data comparisons matched across `/` and `/crr` | +| ORM/schema and relationship comparison | Identical | +| Route/form/OpenAPI contract | Identical; 48 declarations retained | +| Existing migration history and function/class ASTs | Identical | +| Populated Shifts SQLite fixture on startup | All existing rows preserved | +| `make migration-visual-check` | All 14 captures match Phase 2 byte-for-byte, including print cards | +| Shifts image build | Passed after starting Docker Desktop | +| Container smoke checks | Shifts and both Tournament fresh/adopted-fixture checks passed | +| Tournament regression checks | 28 Python tests, 13 frontend/shared tests, 44 legacy assertions and Vue typecheck passed | + +Workflows cover authentication/CSRF/authorization, calendar feeds, PDF exports, +swap creation/acceptance/approval/reversal, administrative CRUD, rotation edits, +assignment changes, password editing and logout. Swap operations exchange both +assignments; explicit generation retains manual/swapped rows. Timestamps, CSRF +nonces and new password salts are normalized only in comparison artefacts; +application behavior is unchanged. The verifier fixes its runtime calendar date +to the capture date so CI can reproduce default-month/navigation HTML later. + +`phase4/structure.json` records router ownership and relocated function hashes; +`phase4/verification.json` records successful checks and fixture/baseline hashes. +Raw reports and difference diagnostics stay under `.migration/phase4-results/`. +`make shifts-test` includes this check; `make shifts-compat-test` runs it alone. +Do not rerun the one-time generator or overwrite the baseline. The original +snapshots and branding/compatibility baselines remain intact. Remote CI is still +unverified. + ## Next work 1. Verify the new CI jobs on Node 22/Python 3.13 after publishing the changes. -2. Phase 4: Shifts service/model/security extraction and router split, preserving - routes, templates, forms, authentication, SQLite data and migration history. +2. Phase 5: compare the two final Python backends and extract only infrastructure + that demonstrably removes generic duplication without app-specific switches. -Phases 4–6 are not complete. No shared Python package has been extracted yet. +Phases 5–6 are not complete. No shared Python package has been extracted yet. Independent tag-triggered release workflows and final dependency cleanup belong to later phases. diff --git a/migration/baseline/artifacts.json b/migration/baseline/artifacts.json index d78d945..654cfcc 100644 --- a/migration/baseline/artifacts.json +++ b/migration/baseline/artifacts.json @@ -1,5 +1,6 @@ { "fixtures": { + "shifts.sqlite": "95b06188ce5076a17b842523c6a969358cdd48e30221de4292e6628f40c9b292", "tournament-api.json": "cb6f1745a5c4158359326080d92958af08163d491476cadbee4af567bb404fc8", "tournament-cookie.json": "7f2797f7330acc4fae4dda40fc6c9d1ec500c0616d8777361b25a9439cc40afe", "tournament-routes.json": "79b605e2cb3e3845d0e1ec43a48d163b649e878d3c2089730f18c4b1291b94e7", diff --git a/migration/check_shifts_structure.py b/migration/check_shifts_structure.py new file mode 100644 index 0000000..febcbf5 --- /dev/null +++ b/migration/check_shifts_structure.py @@ -0,0 +1,319 @@ +"""Compare schema, ORM relationships, routes, existing data and Shifts workflows.""" + +from __future__ import annotations + +import argparse +import ast +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import sqlite3 +import sys +from tempfile import TemporaryDirectory + +ROOT = Path(__file__).resolve().parents[1] +REPORT = ROOT / "migration/phase4" +FIXTURE = ROOT / "migration/fixtures/shifts.sqlite" + + +def definitions(backend: Path) -> dict[str, str]: + result = {} + for path in (backend / "app").rglob("*.py"): + for node in ast.parse(path.read_text()).body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if node.name in result: + raise RuntimeError(f"Ambiguous top-level definition: {node.name}") + result[node.name] = hashlib.sha256(ast.dump(node, include_attributes=False).encode()).hexdigest() + return result + + +def rows(path: Path, normalize: bool = False) -> dict: + with sqlite3.connect(f"file:{path}?mode=ro", uri=True) as db: + db.row_factory = sqlite3.Row + tables = [row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")] + result = {table: [dict(row) for row in db.execute(f'SELECT * FROM "{table}" ORDER BY rowid')] for table in tables} + if normalize: + for values in result.values(): + for row in values: + for key in ["created_at", "updated_at", "sent_at"]: + if row.get(key) is not None: + row[key] = "" + if "password_hash" in row: + row["password_hash"] = "" + return result + + +def orm_signature(Base): + from sqlalchemy import CheckConstraint, ForeignKeyConstraint, PrimaryKeyConstraint, UniqueConstraint, inspect + def default(value): + if value is None: + return None + argument = value.arg + if callable(argument): + return {"callable": getattr(argument, "__qualname__", argument.__name__)} + return str(argument) + tables = {} + for name, table in sorted(Base.metadata.tables.items()): + constraints = [] + for constraint in table.constraints: + data = {"type": type(constraint).__name__, "name": constraint.name} + if isinstance(constraint, (PrimaryKeyConstraint, UniqueConstraint, ForeignKeyConstraint)): + data["columns"] = [column.name for column in constraint.columns] + if isinstance(constraint, CheckConstraint): + data["sql"] = str(constraint.sqltext) + if isinstance(constraint, ForeignKeyConstraint): + data.update(targets=[item.target_fullname for item in constraint.elements], + ondelete=constraint.ondelete, onupdate=constraint.onupdate) + constraints.append(data) + tables[name] = { + "columns": [{"name": column.name, "type": str(column.type), "nullable": column.nullable, + "primary_key": column.primary_key, "default": default(column.default), + "server_default": default(column.server_default), "onupdate": default(column.onupdate)} + for column in table.columns], + "constraints": sorted(constraints, key=lambda value: json.dumps(value, sort_keys=True)), + "indexes": sorted([{"name": index.name, "unique": index.unique, "columns": [column.name for column in index.columns]} + for index in table.indexes], key=lambda value: value["name"]), + } + relationships = {} + for mapper in Base.registry.mappers: + relationships[mapper.class_.__name__] = {relation.key: { + "target": relation.mapper.class_.__name__, "back_populates": relation.back_populates, + "cascade": sorted(relation.cascade), "uselist": relation.uselist, + "pairs": sorted((str(local), str(remote)) for local, remote in relation.local_remote_pairs), + "order_by": [str(item) for item in relation.order_by] if relation.order_by is not False else [], + } for relation in inspect(mapper.class_).relationships} + return {"tables": tables, "relationships": relationships} + + +def differences(expected, actual, path=""): + if expected == actual: + return [] + if isinstance(expected, dict) and isinstance(actual, dict): + result = [] + for key in sorted(expected.keys() | actual.keys()): + result.extend(differences(expected.get(key), actual.get(key), path + "/" + str(key))) + return result + if isinstance(expected, list) and isinstance(actual, list) and len(expected) == len(actual): + return [item for index, (before, after) in enumerate(zip(expected, actual)) for item in differences(before, after, path + f"/{index}")] + if isinstance(expected, str) and isinstance(actual, str): + first = next((index for index, (before, after) in enumerate(zip(expected, actual)) if before != after), 0) + return [{"path": path, "expected": expected[max(0, first - 60):first + 120], "actual": actual[max(0, first - 60):first + 120]}] + return [{"path": path, "expected": expected, "actual": actual}] + + +def normalized_report(value): + result = json.loads(json.dumps(value)) + for scenario in result["workflows"].values(): + for request in scenario["requests"]: + if request["path"] == "/admin/notifications": + # Delivery times vary between runs; retain all other log-page markup. + request["body"] = re.sub(r"\d{2}/\d{2}/\d{4} \d{2}:\d{2}", + "", request["body"]) + return result + + +def workflow(app, sessions, models, database: Path, prefix: str): + from fastapi.testclient import TestClient + from sqlalchemy import select + from pypdf import PdfReader + from io import BytesIO + records = [] + clients = [] + def client(): + value = TestClient(app, root_path=prefix) + clients.append(value) + return value + def call(c, method, path, data=None, expected=None): + response = c.request(method, prefix + path, data=data, follow_redirects=False) + if expected is not None: + assert response.status_code == expected, (method, path, response.status_code, response.text) + body = response.text if "application/pdf" not in response.headers.get("content-type", "") else "\n".join(page.extract_text() for page in PdfReader(BytesIO(response.content)).pages) + body = re.sub(r'(name="csrf_token" value=")[^"]+', r'\1', body) + body = re.sub(r"DTSTAMP:[^\r\n]+", "DTSTAMP:", body) + records.append({"method": method, "path": path, "status": response.status_code, + "headers": {key: response.headers[key] for key in ["content-type", "location", "content-disposition", "cache-control"] if key in response.headers}, + "body": body}) + return response + def csrf(c): + page = c.get(prefix + "/login", follow_redirects=False) + if page.status_code != 200: + page = c.get(prefix + "/account") + token = re.search(r'name="csrf_token" value="([^"]+)"', page.text) + assert token + return token.group(1) + def sign_in(username): + c = client() + token = csrf(c) + call(c, "POST", "/login", {"username": username, "password": "secret123", "csrf_token": token}, 303) + return c, token + def submit(c, token, path, data=None, status=303): + return call(c, "POST", path, {**(data or {}), "csrf_token": token}, status) + anonymous = client() + call(anonymous, "GET", "/health", expected=200) + call(anonymous, "GET", "/login", expected=200) + for path in ["/account", "/admin/teams", "/admin/users", "/admin/schedules", "/swaps"]: + call(anonymous, "GET", path, expected=401) + call(anonymous, "POST", "/login", {"username": "member-a", "password": "secret123", "csrf_token": "wrong"}, 400) + a, a_token = sign_in(" MEMBER-A ") + b, b_token = sign_in("member-b") + admin, admin_token = sign_in("phase4-admin") + for path in ["/account", "/calendar", "/swaps", "/swaps/new?year=2030&month=1", "/?year=2030&month=1", "/export/schedule"]: + call(a, "GET", path, expected=200) + call(a, "GET", "/admin/users", expected=403) + call(anonymous, "GET", "/calendar/phase4-feed-a.ics", expected=200) + call(anonymous, "GET", "/calendar/not-a-token.ics", expected=404) + with sessions() as db: + schedule = db.scalar(select(models.Schedule).where(models.Schedule.slug == "phase4-rotation")) + schedule_id = schedule.id + assignments = list(db.scalars(select(models.Assignment).where(models.Assignment.schedule_id == schedule_id).order_by(models.Assignment.date))) + ids = [row.id for row in assignments] + north = db.scalar(select(models.Team).where(models.Team.name == "Equipa Norte")).id + south = db.scalar(select(models.Team).where(models.Team.name == "Equipa Sul")).id + call(a, "GET", f"/export/schedule.pdf?schedule_id={schedule_id}&start_date=2030-01-01&end_date=2030-01-31", expected=200) + submit(a, a_token, f"/assignments/{ids[0]}/swap", {"target_assignment_id": ids[1], "message": "Teste de troca"}) + with sessions() as db: + swap_id = db.scalar(select(models.SwapRequest.id)) + assert db.get(models.SwapRequest, swap_id).status == "open" + submit(b, b_token, f"/swaps/{swap_id}/accept") + with sessions() as db: + assert db.get(models.SwapRequest, swap_id).status == "pending_approval" + assert db.get(models.Assignment, ids[0]).team_id == north + submit(admin, admin_token, f"/swaps/{swap_id}/approve") + with sessions() as db: + assert db.get(models.Assignment, ids[0]).team_id == south + assert db.get(models.Assignment, ids[1]).team_id == north + assert db.get(models.Assignment, ids[0]).source == "swap" + submit(admin, admin_token, f"/swaps/{swap_id}/revert") + with sessions() as db: + assert db.get(models.Assignment, ids[0]).team_id == north + assert db.get(models.Assignment, ids[1]).team_id == south + assert db.get(models.SwapRequest, swap_id).status == "reverted" + for path in ["/admin/teams", "/admin/users", "/admin/schedules", f"/admin/schedules/{schedule_id}?year=2030&month=1", "/admin/assign?on=2030-01-05", "/admin/notifications", "/admin/month"]: + call(admin, "GET", path) + submit(admin, admin_token, f"/admin/schedules/{schedule_id}/apply-rotation", {"year": 2030, "month": 1}) + with sessions() as db: + assert db.get(models.Assignment, ids[0]).source == "manual" + assert db.get(models.Assignment, ids[1]).source == "manual" + assert db.get(models.Assignment, ids[2]).source == "swap" + submit(admin, admin_token, f"/admin/schedules/{schedule_id}/clear-from", {"year": 2030, "month": 1}) + with sessions() as db: + assert db.get(models.Assignment, ids[2]).team_id == north + assert db.get(models.Assignment, ids[3]).team_id is None + assert db.get(models.Assignment, ids[3]).source == "generated" + submit(admin, admin_token, "/admin/assign", {"assignment_id": ids[3], "team_id": south}) + submit(admin, admin_token, "/admin/teams", {"name": "Equipa temporária", "email": "TEMP@example.com", "notify_email": "on"}) + with sessions() as db: + temporary_team = db.scalar(select(models.Team).where(models.Team.name == "Equipa temporária")).id + submit(admin, admin_token, f"/admin/teams/{temporary_team}/update", {"name": "Equipa editada", "is_active": "on"}) + call(admin, "GET", f"/admin/teams/{temporary_team}/delete", expected=200) + submit(admin, admin_token, f"/admin/teams/{temporary_team}/delete") + submit(admin, admin_token, "/admin/users", {"name": "Utilizador temporário", "username": "temporary", "password": "secret123"}) + with sessions() as db: + temporary_user = db.scalar(select(models.User).where(models.User.username == "temporary")).id + submit(admin, admin_token, f"/admin/users/{temporary_user}/update", {"name": "Editado", "username": "edited", "is_active": "on"}) + submit(admin, admin_token, f"/admin/users/{temporary_user}/delete") + submit(a, a_token, "/account", {"new_password": "secret123"}) + call(a, "GET", "/account", expected=200) + submit(a, a_token, "/account", {"new_password": "new-secret123"}) + call(a, "GET", "/account", expected=200) + submit(a, a_token, "/logout") + call(a, "GET", "/account", expected=401) + for c in clients: + c.close() + return {"requests": records, "rows": rows(database, normalize=True)} + + +def main(backend: Path, capture: bool): + backend = backend.resolve() + sys.path.insert(0, str(backend)) + REPORT.mkdir(parents=True, exist_ok=True) + (ROOT / ".migration").mkdir(exist_ok=True) + with TemporaryDirectory(prefix="shifts-phase4-", dir=ROOT / ".migration") as temporary: + database = Path(temporary) / "shifts.sqlite" + os.environ.update({"DATABASE_URL": f"sqlite:///{database}", "BACKUP_DIR": str(Path(temporary) / "backups"), + "SECRET_KEY": "phase4-test-only-secret", "ROOT_PATH": "", "BASE_URL": "http://testserver", + "INITIAL_ADMIN_USERNAME": "phase4-admin", "INITIAL_ADMIN_PASSWORD": "secret123", + "INITIAL_ADMIN_NAME": "Administrador de teste", "INITIAL_ADMIN_EMAIL": "admin@example.com", + "SMTP_HOST": "", "CALENDAR_TOKEN": "phase4-whole-rota", "SESSION_HTTPS_ONLY": "false"}) + from datetime import date, time + from fastapi.testclient import TestClient + from sqlalchemy import select + from app.main import app + from app import models + from app.database import Base, SessionLocal, engine, run_migrations + from app.config import settings + from app.security import hash_password + class CaptureDate(date): + @classmethod + def today(cls): + return cls(2026, 10, 7) + if capture and not FIXTURE.exists(): + run_migrations() + # App startup initializes the same initial administrator and schedules as production. + with TestClient(app): + pass + with SessionLocal() as db: + users = [models.User(name=f"Membro {letter}", username=f"member-{letter}", password_hash=hash_password("secret123"), is_active=True) for letter in ["a", "b"]] + db.add_all(users) + db.flush() + teams = [models.Team(name=name, user=user, email=f"{letter}@example.com", calendar_token=f"phase4-feed-{letter}") for name, user, letter in [("Equipa Norte", users[0], "a"), ("Equipa Sul", users[1], "b"), ("Equipa partilhada", users[0], "c")]] + db.add_all(teams) + schedule = models.Schedule(name="Rotação Phase 4", slug="phase4-rotation", schedule_type="rotation", weekdays="5", rotation_anchor_date=date(2030, 1, 5), start_time=time(12), end_time=time(14), requires_manager_approval=True) + db.add(schedule) + db.flush() + db.add_all([models.RotationMember(schedule=schedule, team=team, position=index) for index, team in enumerate(teams[:2], 1)]) + for day, team, source in [(5, teams[0], "generated"), (12, teams[1], "manual"), (19, teams[0], "swap"), (26, teams[1], "generated")]: + db.add(models.Assignment(schedule=schedule, date=date(2030, 1, day), team=team, source=source)) + db.commit() + with sqlite3.connect(database) as source, sqlite3.connect(FIXTURE) as destination: + source.backup(destination) + result = {"definitions": definitions(backend), "orm": orm_signature(Base), "openapi": app.openapi(), + "migrations": {str(path.relative_to(backend / "migrations")): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted((backend / "migrations").rglob("*")) if path.is_file() and "__pycache__" not in path.parts}, + "workflows": {}} + # Resolve FastAPI's deferred annotations before freezing application clocks. + # Only runtime date operations change; request schemas retain datetime.date. + for name, module in list(sys.modules.items()): + if name.startswith("app.") and getattr(module, "date", None) is date: + setattr(module, "date", CaptureDate) + fixture_rows = rows(FIXTURE) + for prefix in ["", "/crr"]: + engine.dispose() + shutil.copyfile(FIXTURE, database) + settings.root_path = prefix + app.root_path = prefix + with TestClient(app) as startup: + assert rows(database) == fixture_rows, "Startup altered the existing fixture" + result["workflows"][prefix] = workflow(app, SessionLocal, models, database, prefix) + engine.dispose() + results = ROOT / ".migration/phase4-results" + results.mkdir(parents=True, exist_ok=True) + output = REPORT / "baseline.json" if capture else results / "refactored.json" + if capture and output.exists(): + raise FileExistsError(f"Refusing to overwrite {output}") + output.write_text(json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True) + "\n") + if not capture: + baseline = json.loads((REPORT / "baseline.json").read_text()) + comparison = differences(normalized_report(baseline), normalized_report(result)) + (results / "differences.json").write_text(json.dumps(comparison, indent=2, ensure_ascii=False) + "\n") + assert not comparison, f"{len(comparison)} structural/workflow differences; inspect .migration/phase4-results/differences.json" + verification = {"definitions_preserved": len(result["definitions"]), + "orm_mapping_and_relationships_unchanged": True, + "openapi_unchanged": True, "migration_files_unchanged": True, + "existing_fixture_rows_preserved_at_startup": True, + "workflow_requests_matched": sum(len(value["requests"]) for value in result["workflows"].values()), + "deployment_prefixes": list(result["workflows"]), + "fixture_sha256": hashlib.sha256(FIXTURE.read_bytes()).hexdigest(), + "baseline_sha256": hashlib.sha256((REPORT / "baseline.json").read_bytes()).hexdigest()} + (REPORT / "verification.json").write_text(json.dumps(verification, indent=2) + "\n") + print(f"Shifts {'baseline captured' if capture else 'structural/workflow parity verified'}: {sum(len(value['requests']) for value in result['workflows'].values())} requests across both deployment prefixes.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", type=Path, default=ROOT / "apps/shifts/backend") + parser.add_argument("--capture", action="store_true") + args = parser.parse_args() + main(args.backend, args.capture) diff --git a/migration/fixtures/shifts.sqlite b/migration/fixtures/shifts.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..2a93097cba93b07babf65ee5e8201ff28d724f9c GIT binary patch literal 94208 zcmeI5O^n;veZWacmPVQn`eQf3qA<*bZFqzk?eIgNTPz%ThPxhktXXTui5;NeK9Nr| zj3rSfDcf_jC+i$?2#TUT7AShyLk>N3bLb)PVUZLGie3@~Z7wd*p3)RZvWFy14}I@R zlt_*=v$oN$?fDb7#P{{*`~Uv`_r*t!wr*@#9u`}+(?Oo7awj>S=e{b69LMqGf0q3B z4;oJR0rE}5M;$!(&R_oI1j)qizdQSl*_G6z^sSVb_&oVE zp68y#eii$k@cW~L2GaMfQ>kn|&wu-bhnj8d*dFpMyQ_C_r->am8a}<+&}!S7xLv!l zp^4E{aWRv&OmV%wt$m<1#QNs8SiiZku`FiNuCa^FejDpk(mjki)6#mjYtc$g6#QXh z{d%o&TfC;-78fluM=$pNWQ6%5Isafg*(-K=kIH)@23*uSffX!)1cM~dC7uiv<-iNVS)i+(Co=_paO zD&j;je#JAn%V(0=tvt_JT@ycW@3x8G>ZtG8EIh8>(Db@8s_-!8jmxK>Nq`&`aAtq? zWGcI|!apuDZ|mBg)v}C%$F=Pp_dvv{kiQ*BKE&aMYWqGz&57^fuBUr@JuH5$)>yq- zYb=(eT*yt0UDWO3_9$J6q&wKKdKM8g&QUBd1G)X?H?a|nj}&d%=AO8%-6Cxw*e*zW z>$_uVkwVyUY=@Pi3K$MX9yWE-(X5eHxV?T|3k#$A1rlkQquPh>9>KOj;|}=smNsb3 zncTj$5bQHLcGuf&@990{dD!W?(XbG*VKns+6OP)+ls>~md%E4ynMB{tAs!QaU=of% z+cNI>YUm;9t5>zvYvN)k?uxekbxo^_vRJFHiK_hNcRjv|sNDlfk-leW8-0Y7XfnCG z2&$MPeTHyYlaCrxm!mN!KblWw)${xw>tw74I&xiWr`sW(>Jr4nLgVnl&{5XwYuYVw zLSEWS2l7*UcsRGeHJi$wKhHnzuudhAA#geua>78u3;7=z_-R8t%IRFY?-=7wC)%ly zZf%@clqBXz{T@|rdRw35$Y1KXH{JQhw1fUfbe=d~F`H&`#kpj5{rt4SYAn*#hu{)ES#ZjvB1*VkagAbuC@9obbS4LW2)YGJcubtfA5aPvQp zb$ts^jUSjpk)}B%#ld&`mV;frgSu#k^Z=;WbL@Ml z{TzlI&5Tjk*tH!t60)I$Hm4V{GP7{7O;Vo3v5@CP5~=?Ti|O5dXXFv85^hX^X--6f z-n(Onq3$jqj}Un;mdxH*nnollOpZjv2W#lBEXXJ=q)XosSVEzA^7#Gw40zd!=00AHX1b_e#00KY& z2mk>fFeD(vFQ$X*f4cvlSSWGAUkeu({*Ii$0R(^m5C8%|00;m9AOHk_01yBIKww1R zeqxSWSvo5$Ev3_M*Ba!l>(4&<>~C%HL-Ohf77IQ4Rml5q3HaYdS$S8I$iH{U3Y0EF z3!$JQsZw5&^HNcflu}+MfwCw|?-ncXN`-giqEac!UYuO`qJQC9yJLU$i7n>EHJiMV zvSYh_@@CRrzTGFUCHPk@$!h2_aw!xf*DA29mM2|RDixGc`F?DUlXC~Jy7k%LG?8h$ zE_n&oIdI|P7rbz3>V-=m@pBw`9qbb2%+=~PdF8ol?d*Cj>j5^UWqDayR+kIQ#bqMn zT{VD~VIGs7dIg#8|0je`IpHV5zX`u1CvX4(AOHk_01yBIKmZ5;0U!VbfB+Bx0xt!D zH{&b(fqO0q8hhY|Nc<~tIS}jLu!zmXvuSoaVX*#x!jZNA6X8FEpS~0dLM4Cz5C8%| z00;m9AOHk_01yBIKmZ5;fgpi1@g+X;v_;}%{30KG5F&m$p5w!hD6sWEdy;@Y{~w?K zB_~{2_$%@S2M_=PKmZ5;0U!VbfB+Bx0zd!=yz&Gd=HhAY?AeEVGwIrO@|Wx#TWk(~ z87trHHSd@$MR#|RQY>9iW%_jf1*xRUrJ_`6sY<0%Dl5%u1(zgjwo0a|q7uH)GEG!4 zrIIO;lWMb6R@DN+WsEDbTrHxOX`q5qu1dwSsZ@=ELUKvvW4KNotImg|e(*Rh7*W#-*}UHKdY()S_w{Qn@OtMhP{W%9OfRO3K4O<lnf00e*l5C8%|00;m9AaJY*%*D_0 z{=J=eAb?`@@BanwM-2Y{-;X)r?}e7|CksD5R+vCpAOHk_01yBIKmZ5;0U!VbfB+D9 z^$2{J$Z#wCCGHa0hS%=)tsWA4$nmW9E;3Qf_$q#YI=wc2$FMuY^=}JfGe2=_TrR3g zp|~E;aBqhtZuQ&o<}^{wNxSR=QP*M_?qXO}-F7@2L(_zzslwE2e1@y0S2%9!0|)F$ zgP`O|!X&kzN|nL;|3Bh{{}FyE{EP6TSI_WJV;}$ofB+Bx0zd!=00AHX1b_e#00PH_ zz^V8p|IYvn?rJ2WG0k{12F2oWEf00e*l5C8%|;FuC9CuTVQZT=irOOQVsDCH$Nuax3DxQiX+VRO>M z^>VRLkyItl3ceXAC|6_debk#;Mk&NtnYV&vlz0bq`=~v&lw9TyR8cC%j;dn0S|05G z|9}&IEj$(eS@^**H8H3S1b_e#00KY&2mk>f00e*l5C8%|;N>N7GC^)j1nx;B!Vz*c z9*Iz50x6On6_JXA_5Tk!;kV=$0DdO?@a0t&Y6b*=01yBIKmZ5;0U!VbfB+Bx0zlxH z6Ih79&5xff00e*l z5C8(NIRU!=59|Ne+{mCF5C8%|00;m9AOHk_01yBIKmZ6Fdjhckf9zcV0003X00e*l z5C8%|00;m9AOHk_z%d{|*Z(o$zc}G{M00e*l5C8%|00;m9AOHk_01yBIK;YFU zFcUk$@jRc1$5K2!ODAIV`Tv;kTTb|I@&N}B00KY&2mk>f00e*l5C8%|00;m9An=+H zNG0ekfLQEAf={OtgZ2M!IN=Y%Z(b7xK@A`P1b_e#00KY&2mk>f00e*l5C8%|V6gm; z$CBhW0NMYC_y55qfB+Bx0zd!=00AHX1b_e#00KY&2)zCT=95pkg~aEaFfaVW+|z|$ zEPOKi@!Y5B@6LW>b|v*FeJdp|3W&*?gY=_6ZL)+t{%^ zXj zZfq=znY3%{Vzb}I`jm7Jqt3Lnp6yyx@ri;zY^-0eHExU7wAmSr< z++yIuIkBO=uQjy#stS>`PI$()^?*t zc!>SG`iPc)X?>*F&HDO{o0=G`?6T;mGL?=JMXMrC6ysMslS@90UERv_oYgh)1NUy* z^01Ejp3TDJ>J8mXS4I^c#=LR)bTa!40_3QGGyAJ2Q`waj{&A6cbl3K*mSqgQx^3^c z2O>@leR|+;2a*qQxS`s<&royXd${ZA-d+!jU#m4%uhtriB`FtjQ)3r(ySP0{S0d>S zHmsgS#Ef$k3(P=nzxhpU1mhz`o3^94WolNO7Oth!lEuBg9?HuAU!3QSc2(&HZj<1FulD>LXTfHVOhT^Vh z+h5nTx+sga`kJW9Uw+r)n~2&ypcLtQhPKg1NQowsyNjTTxlE1>SheX*K59%|j>eq) zXg--$&+~h%ld&G?$aSrqZijfPOAr$ajl&B=M_I40X}81)d1)^l$WQIz;oSb#Y$|*H zJpZ`EI+Z|%!0BAb2?Gf)yL^WewEET@yLFXWzlyQP*D@ zj3bt-Bg3=qVe!i5=7v_Qhn$OzfO|S-SDTdKs3GDgw;8Gyn&!~Bslx$P-nU~|Gv>yA z600%kPjZ=L_Py*hvof>j3j3PPrH{@kv#gm%%SmSSZC9Y&T>(q;BF zv#-Td*>mUk$F~_-fBbMqCy5Z@QS@`SEw|m@8L^8J_Qi)Mana^^ALBb@7#q*3)QAOK z4>@EaXpuI4U=Br^=9Cl%-|brtcJ&VGq8-u$pkB|h@1gc{7;-c-MqOjqcGyVBh7#JG zUc}1G!ofC4c@mS$Cz4t1+_ZM*b9HrPbefbCU#y5cDstjsj!$KmmiR~M!BE^LD^<(T z?~(q4tlvn#7YxQio)bx={xdA5cl(`@N2p4;F$JbM5ea(ljv>>0Y!ru+ZNnZMwKC-Z-po1gus+3%1596$gF00AHX1b_e#`2QvFasQ1} z_R=N(JGsG}{XXjHbn=Q_k1S9Efj2`luu#$=^Y`&`JFo^0t#HWndIys`k)vBXfsAx+ zI;>1+5j{x&UED>ABE!HvdS7Hh8kyDy@1;g({T=MOWZpiSO8R$Nj+$=2=$)8ol|yT5 zwtkInK?HB51n=pDfQH13Qw}WDr|f40Z4eSQu|`vbOfqyx*dg_rrsPoO(7qvQ6RYOS z$>c7dd8X}&|BL;N=i9M^-88x*H_^HQ#|td=UWVN?*vony_OdPuhxHlJ-HZwQ8~+Oq C{Q{=| literal 0 HcmV?d00001 diff --git a/migration/phase4/baseline.json b/migration/phase4/baseline.json new file mode 100644 index 0000000..5e71fd1 --- /dev/null +++ b/migration/phase4/baseline.json @@ -0,0 +1,6275 @@ +{ + "definitions": { + "Assignment": "2b8f7430f4db7252b25e9db231c6684f87ca2de21e2933d107c9773888ea661f", + "Base": "c1cbd280eee0b89102476fb3994dacd59844e10d24e86fda68c649fd3c1b1c59", + "MonthBlock": "287cce589bc05e5da096fabe126ddfffbf693c4ce32829aa97f9f3e6d6c0df84", + "MonthlyPattern": "00e73673967d1aa86d664c0c0b89ff55156638c63e544a16228574a0c4ef9c78", + "NotificationLog": "8b7291e357576d5ca0ca6d5bafe340bdbdfc5bcf541fcb10097be5a0367e4507", + "RotationMember": "43203a53701c7433992f94be9288c2dfaecd4ca6ab58b6b2577330068d61a3ac", + "Schedule": "50a3afabda23f150960b9a5372eeac29b49646243cffc0a1c24fb4e691b806e9", + "Settings": "9ec5987f4c5d99c6cc65597be1d95b4aa8d2bbb7ee8d6445690dfe41184f4957", + "SwapRequest": "a699dcbc2bfcc415d993ead48c028663ab06e8eb54bc77516235f174a51589c5", + "Team": "989de12ee34d86bd747d257441e2a2fb3718e0053790ed95c146df5cc1ab0924", + "User": "941134ed5efbd2464e8e8918a0138ee2bdaa228fa5e1f988ef0e3436075334d6", + "_accepting_team": "d65db64047442d79f4654985e0cf1aa15614570e7586eedda1f81376ca75a3db", + "_active_request": "df41ac832e5720158fbbb8b176979b3e50697dcdcef6ea123a7b7d28df153cc0", + "_active_schedules": "d0fca80985d60f88ccc47c8b4ea603761812c35fc3bcc8f80253c387c0775eea", + "_admin_teams": "841c05fa8ed4eef1426509763d55e950fd665f9f477fd97c95683b9439707699", + "_all_schedules": "8fe6504cecab40a403132f13031277d61e276b337b5fee5b1fe686831dde9bf8", + "_apply_pattern_forward": "50ad2b7df7843ec36efc3c61d4a7252d63d4121c3944d5e735f2160a421676f5", + "_apply_pattern_to_open_rows": "24076e69176477dd74da34c7f69a1342bef292e5ad0f42732674edda5b98f0d3", + "_apply_rotation_to_rows": "a2a02c003b9b9e4fc64023898bcc98c5aa5173be6fa8b86f8c023e2a9b5e35c6", + "_assign_day_url": "351850c9e71cc95cc25579bf3a7e145d284c5883ab749b8da49cbd21aa59cff1", + "_assign_user_to_teams": "cf2c8ab64e7bdccf149f7545c09a889ccd9d0383aa8a79fc3d0412dbba62edf7", + "_calendar_token": "d33a7dbd8af12b55164b2c544c8f3a4c7527b359136d8937592a9d79578c0076", + "_check_csrf": "96b5b121cc98f9043f0fa41b4bb19b3bf700ad892702c24ea8b3bce1f0782121", + "_chosen_user": "baa8d0dcc33d5cde5b0c9f236ea483508d11763f462033680703894d845d2f44", + "_clear_assignments_from": "573dbe74cb5555869db83a92968b5280d37cf9f0e5d72194b81d7a866c3e9403", + "_columns": "9e9ac42b8d5c8d5f3f6b81d9222df02b18f1c9670e3d064f1b7ef30f0af2fb3a", + "_complete_swap": "507ee15bb500ce0f40f77e90b5f6c02c4e94b1d4c95ce5366a76994f923eca59", + "_context": "baa2d501f6e418152cbebc56f07dfe55eb35154829416a15ff7fa79ba2a3e467", + "_create_swap": "64cb526a882e09a01945b960f87de7724cae7322206a0a5a65cafa472c352b17", + "_csrf": "4be30b4c04bd4246f08fed3782ff0155eea4eb691032b6c0cd6e98211e9a7a7b", + "_current_user": "42bb53fff71dff01cf1455a85817a8975178bfb9faab6221e11eef56deb7df11", + "_default_team": "6730fd6dbed43670b09335d507eef9e3ebfd0df7038912cfa28e7d926b97abb3", + "_escape": "0e70e07ca6156c79fed26b865c204f7022e83e5641a32b2d9a30b0ef76dca618", + "_event": "597dd397cd3369ce32e3b439b4d3143c09cbe97cd3ebebead88424087fec5b90", + "_flash": "f319e7a8b69c1b1654887f3b7e7ac724a8286e9d6f8c8def05e49be3b1936b42", + "_fold": "2082572e5cf97c4d236edd0f59b0729b4a169fceed9dc63b61b9bbf356e71ac7", + "_generate_rotation_range": "5a3c32c3e8a0066436fb34f55a8345a0ad36633462bfc2c8c00af07f99938068", + "_get_schedule": "c658002d06bcba95b148651477f09a125aaa6982f6e260d9f0d67aac04f64728", + "_host": "0587b3d3b29c9065e9d64bcf7559e80e2695dbc89929ae0cb28820297de8f9bf", + "_layout": "4c670e9cce524c679deabb4cbd1a17f89e431c2f059442a6c20e75a1573d73e9", + "_loses_last_team": "d23d67a6a08920b6eb31f7671f90eb0c1c2078f139182a8e17452ca035cda286", + "_month_assignments": "922fa4b0c0df6a77641168d2d2dbab5437977524c12958d5bc89f7289e4119d5", + "_month_nav": "0a5a7f4fe43eefab3b1b4a86e71f2b87251371d571786cec7729f40976d0db6b", + "_new_user": "6998933630f0044d5a9254ce1dc9ae8ac11dcb010c8ada965158ff114d82acf5", + "_next_dates": "f103e97d6350e7db09aa014c4cbcd4fcfcaadcf6b7c8dfe604eed07447a67740", + "_notify_assignment_changes": "29c60bf23d25aa77aaf770388f0a6fb8558216c8737182970b7331fb12ce8e33", + "_open_swap_requests": "f095afa9428aed7f1af9d524fd05b118a99518b919a9880089c1a9cf6b9b0e9b", + "_parse_month": "f364954e7acf36646e60bba89492d98d9a0b9474c1321c862db4afd369a44ed5", + "_parse_time": "3a2c7134356e971c5f5c30f60df74ea6f2de74ae7eb097efcc49e2b599c33dc0", + "_pattern_map": "8b94d9b9c5c57adfe91ae5287abfafaa260b888dae77b3d76f3e6d3d98360d6a", + "_redirect": "f78bec5bb9fabde6e3b768a3a6d4383785db3d42a8e355517f52f8d00af1240a", + "_reject_swap": "e9c09521a9b3dca0950bf61ee08dc5edefa9112619d37ecd0b8df1db5e18cd57", + "_rejectable": "6c86715704c166a334736e0cb79c5df74cddb39d42a91ee84b82d5e6b1ebaddd", + "_render_admin_schedules": "75c2260c9410ef8dec1fe023e7bca6ef6c719d2357eb90a9e05065d672f85bbc", + "_require_admin": "5b64c94b267b2e073e5346314b697e0159719f926f86d6baf9ba25c852b14098", + "_require_removable": "789c50862c3c058de4f88cf97e84a34de77d5bd846ad5982761490e58988e6c5", + "_require_user": "9b602a8921681453dd541d63af2fb5c1e59a0571398ae867e6f11037341dddcd", + "_rotation_occurrence_index": "e0a5e24763920abae350ffb3090f7f10b0d1034fa1ee618c646e6a9f9c2bcd4c", + "_safe_back": "99eae35a7eaea120c822dfa8a6c73dd3628a177ea896b34adccbbaca94e92fa3", + "_save_assignments": "6fbc815ff286c0500cec0860939aedea5876d5acffa4255f3c870d8740371685", + "_schedule_card": "e9f09d2c6a0695b219019b9ecdd4bebb3df9b4596020a5a8cd7e9b410392644d", + "_schedule_groups": "bdc5ede2e7cde69e09a1ac679b977cbf94414e0c56be16c92ad196e00de15ba5", + "_schedule_page": "3c6cf42c5c0182d017ddff4c48e54c32292a6074cded9a8e923179eaf9bf1d26", + "_schedule_team_ids": "9c8759326172090b172c581384ce50c0ec12a2f2e3f252bbd24d587d66ed193f", + "_schedule_teams": "0eb69900958f5cd1e2d2790a22f66b499585c5e1b1a6e9f37e1becd26a6005c8", + "_schedule_teams_map": "9b6434331fb2a6fc0603ddf2c4c049fc506710a23609890af8f2c9df384c0726", + "_set_assignee": "6661e42e0b623187b9a94f1967bf02e50471ec1f6ff4f9a402170dfdc1dada4f", + "_shared_calendar_url": "8cdc4d40e4286372570ee2ecd0d263ae31a6252700d3815ba1665f714c0885a2", + "_stamp": "6b94fc759e61245dc15fe0ad39892899cc4f4222317f36fd5f394d35424c1e26", + "_swap_ownership_holds": "3f73630e67f92ab23f7c6db4d02c4e83240b25b645fcf3f8ad26b0506249b9c6", + "_swap_partner": "0faa38b24ae828587bab0da613b14f64046a743f347d65e07e45bb3d73b04ec4", + "_swap_side": "08c5a56b48cf3a65e526b84c07efc890e902270d2945014cefc4fcf11c870efe", + "_team_removal_blocker": "54371319c371902d06dff4f21fc22c95470af2b44f222fb9c07a1e26a722c351", + "_team_removal_impact": "2881010e02c6b9cc55103d2f1a104222f113652f67c864e97a8dd2fbfcbff9a5", + "_timezone_block": "b653b1b2af407bca96cea3a5399566c57d0645239f9ae89a2307aa5300119abd", + "_usable_admin_users": "699592d85a158c406d747f042f58d35668cfa70f71800ed8ab78066c371f92ee", + "_user_label": "83c65b6ef7163866c30c4d0178143dc3723e00db7bfed515f73515878469d856", + "_user_removal_blocker": "66d9034be0907e2d142faa2b4cbc116e2f1a366fbaa44881ecd1a4f3781374b0", + "_user_team_ids": "d643445a1676ea45ae29f3c4272e60f297fc7cab934eb904fc30d93174a4e10f", + "_user_teams": "cf4a74bc2fe491f76dfd99ad943398b90cb0490b3853d9a9759303759159d72b", + "_users_by_id": "027ccd114b6404a77b73d99276a18758ff92a44f68ed7a0b5e4afa4fcfe97aca", + "accept_swap": "84d54fe1b19966de00e31d7db4ea85910053b6887ae1a31c89f4d3ccb125cb71", + "account_page": "eed96aa30d46aa92c42356a961cef63e3847e2ecc5d05740510dd3d413018a6c", + "account_password": "4545d86d3e2a1a2d2dbd266167b873900ffb82d981fc5d5d36d58faa5dc0ed6e", + "admin_apply_pattern": "a71528fe2456fe20201e97df6b936ca0a7d7edc59a5950b30ca8db57168d5d62", + "admin_apply_rotation": "d5c45d851ba1929c2fa637101f13bcb972c59a2c213a0a51f7e12721599b5275", + "admin_assign_day": "f14c110ae7654bcdcda09f5a2462bbd96fced63f216ff4b6b13d97370ed7d3b6", + "admin_assign_team": "9b76661f576260bb5325c242dd21c6c543756327b4d8416c2f8c0471adb44b88", + "admin_assignment_regenerate": "41082e4adcf257240ef5520268f8c611f2836fdddd6e5908564331c894a639bd", + "admin_clear_from": "ed1cc3f147a1318c8594edbce9785b13206ca5dbd8c8c3d560e07418dc2cf646", + "admin_create_team": "b33dd7f9dac0a1aa9557d20b4eee6ffee38ab2cb49ed2e7f12d63ecb6ebc8d51", + "admin_create_user": "2b08d3f2cb2ece36612585d1096b0c81e2b35d8bb39a1985cfaac8c03f2b14bb", + "admin_month": "eb75e48f5f33f84ade39b9f6a117d3b43c7fd340938d0618c33c418d1ae986d0", + "admin_notifications": "c9c76ee7f89a0c0d233dd29390bdeacf0b4b57bfae4a996c6aa0044f923ce886", + "admin_reject_swap": "ee3da8617187b8594a9f4e8d700c2dd2fe3fb71c7f4e64a3151acacd1838c1c2", + "admin_rotation_add": "6d688b41898fc4be7a6823b65fe51964e97971dcf9381ee5104d02f0a79b48a8", + "admin_rotation_move": "c140ad14df549d198b96e4f667b419eb9c4679d863ef4f8f5f8319310dff2924", + "admin_rotation_remove": "83ee03662a0e1e4eb0138e8312f347c694e066dff2959953f9395b4ffb53586d", + "admin_schedule": "8749d7d1e1964e73d91da3594e333d75bf7aa1e65beb44b001e4e3331c8b5e63", + "admin_schedule_assign_swap": "36c2cb1e871bcecd42fab64807d967f5e7c906fbb93763eda086b7a636315f15", + "admin_schedule_assignments": "f7d833d769a4a7967da81c25267080be799414b1d26459dbae8bcf5e37cb970a", + "admin_schedule_pattern": "fc11cb71db195140b36f33a6115ef63f0f9251c3b8b581c5988e1d4955c3a41e", + "admin_schedule_settings": "aa33b00fcebeebe288ea69c480f254c0e4fd92fe60cd2f0a366370df26cac576", + "admin_schedules": "456005bcb0cce35f16dc22d77cf9cab459814697da0fa1b71ed80b06abe6d571", + "admin_team_delete": "8202aeaaa7c478a874f6d5431b8e08c2c876b1adcb27c90c1b805d57de09f974", + "admin_team_delete_page": "2eb324bf154ddcb0efbd732637a9aa16da7694e0d2354bc9869463d0b2bd9fa2", + "admin_teams": "8a768f6b5e693318d37ea96e6f2f9ae05cbf0c87a785908ba79bf1338feda490", + "admin_update_team": "a214c0ce72227a0b2865a0a8a9dad73e6703ac4bc8f675903b01a4d0d436faab", + "admin_update_user": "320453b7a8a37ce03d01763b3192037df8b8149da4a9b4ab911d8a4a1dd8aea2", + "admin_user_delete": "b35988c6b835b8a88d67ec87ae9fe76ba80badc4057a975fd383d6dae7573cb0", + "admin_users": "ab6dfe526dce24d96c369e0602ac8207ef5af570d9b2765ce8b3fcb6b285e1e4", + "alembic_config": "2952ea6c5985853514aae3e8a7b74f0f1d0d49b56b45ca0c82874d7673ae574f", + "approve_swap": "fe63883c1247cdec87036e1093c8675a8dbbdf55a98cf891de8fd73fca23fe03", + "assignments_by_date": "3cf3707a6fd0431806e068c7513781e3f36b77a6c38bc261ba8c5817d9ba446f", + "backup_database": "ece880319f6cec2023f52c7c6cc6f1ba56df575b9263abb8773efa2f152c7f46", + "build_feed": "c161e5ec4587a8c188242cd891be89cec2cd760faf6f93f8b2a3bf815ae7c3c9", + "build_schedule_pdf": "99ec4859f0f8332e38a4ef974b1a24d873f8f227320a9c151924cf7f616edc10", + "calendar_feed": "a9092ad9e9c3a713f8b12425e708c183723ac1704f57b1828583b11c77ad6f03", + "calendar_page": "5af6e6706128abfda036e1f40f5e0cc2196870f58a12b558aad0e298ff0e8da0", + "cancel_swap": "f70e2ad1ea81e6e3a3de61341d2141a31b9ec2a5d7aa92060e1504b8f548b122", + "create_swap": "8d4046a547b2c977fe0d7a9e1d6befd032a4fefbf8a7a947cf1cd4f0bf46af4d", + "create_swap_for_chosen_shift": "e1d61736a3abd5f6781db7ff27dcc10dd150e608e3118f22b9108acbb1155a10", + "current_revision": "5cfbec0632f55ac47e35defd403e4fc376c1dfe41d3d7e2257cc979ce30df1e6", + "dashboard": "2fe7dc610a557407ba095415126ecd02410041b061188fe680608c6798194888", + "database_file": "3623fea87f7c0a7c7341c83acaab3ae265ec915f85e128e3cae61529c6a439bc", + "day_long": "6b5c7e11721516feb42c0e6c826ec8a86e0d523face81638c6012a3bb8217c1a", + "day_numeric": "70aff7071c0ecc5467a92e65a3a03b0c0a22c8dade6f38d8ef258369c95fca6d", + "day_numeric_long": "43e31e8fb96375cd431d46d02d624ab267890be75ed72d0fe3a10a0f5b5d2aa4", + "day_short": "4e070187de377679c1a84343eb19379c6e11612aff9dcd9af6962dc376e4d9a3", + "db_shell": "a035d7c3181bfba100122efe7d1b7c5db7560442179e82d30dc8dba3c9d101fe", + "download_schedule_pdf": "6664e4a367234ccaaef21ac51c505bd7269df98672cb3e272a0f6c7de0f00d58", + "ensure_initial_data": "50e3dccd6cf986881214f712f207a8e3286e438d87b310eec3b47013865ca625", + "ensure_month_assignments": "24a5fed180353adee1205d0f5fbd2eb82faa71f79d8a0c9884dba4ebe1010ab8", + "event_type_label": "1cbd11dd0645812c165723f4ac12330e5b3a79e21d55b894951ae091693dcca3", + "export_schedule": "55cf4b4cedf517ca639948c814bc073822bbf7cfd270c4b0f71b112a04ebbe5f", + "feed_timezone": "2cc659e76a7f6f83f57ff4b0d171edfbbdbc62a1a0d798185525836b42f6ecb1", + "get_db": "c77eb1c621611e31ab28319b17065da64d30533baa04c2a5aeba743a293bbb0c", + "has_tables": "9f5ad997bff49fb9276e0cf612de0e9a88eca2fccdac8692b088f71ad2b30129", + "hash_password": "40ddad2e156cf6dfc9fb4ee36f35e0d08ad8d541989b209fcab568d4204cc0d9", + "head_revision": "7d5edc10e7ee8f86a7dd77c28803c61b1adf88a649d74987f924bef8b1e0e073", + "health": "53803ecc82d556044c97eaf1c54171a0f2cccb6bb9b4d0ec0dea11d30e2f8623", + "init_db": "4d6e2c0fc4f010daa7cd8df474365f003b816d52345294d42e09fab0314f79c2", + "iter_month_dates": "5bdf4897859c15f5a3c3a66c80316d261831dbb4ae2faa36872202feec22d383", + "lifespan": "81b73c45932fcba7199a0d47ff4536f9961c293a7396f2cf82a911b48ac93b1e", + "login": "f0fa25a608d15dfaeec681697ddc9f5d49bfe302f7a5a017c4cee68d99c90db3", + "login_page": "465ec7327c51717a94fa4020bc3e4ca5d8af5d2f30352e87434f212ae999f054", + "logout": "60430dd402ba181a2aa2da2f8716ea3f4581a7d68ffe9156724be0446b213ec5", + "main": "f678f9d42b7760d3adb188e28bd32e1f437dcf373edd8433ba585c602deba288", + "migrate": "0f75de0991513bd44872cbd512b4fb34e266aa5894833f6efde1ae3a37acc516", + "moment": "236fb581bc10ba7d82702f3205fdd76475b7a0d2355229017dd2216bacb8a525", + "month_bounds": "0b1a85e2ad4419670077ed19ef9305e2b2ba61f43c86db27b931d1c7c21f06fe", + "month_name": "25928f05d2b10db526e15d96c442707d3d370e470cdc40a7a76f22905594d06b", + "new_csrf_token": "59bd529fd8605261ff2dac1348bd808cd1bfab8a062a68a0218e1fc8b65abd64", + "new_swap_page": "62fb0393a48703ad89379e6c1e12fe30797a3970366f97cb209ccaadd741b45d", + "notification_status_label": "1b63a299a84917b66e61ea76d42bc806e13fc147df5c25466f09efafaa4aeabe", + "pattern_team_for_date": "febe4323146b290652d2995078eda9f8b5761ea99fc267bd4ca9ffab9a78aecf", + "pattern_teams": "a1bbd5380c67eb9e142ec4a105fd3bdf0243e8d7642fcc24d0eb7b024ee8347c", + "reject_swap": "fc96bdd9d2ac28f6c46fd9a314c918f17c44d963beda0dd15a8511490091aa83", + "revert_swap": "c033fc0d5368da573ca33e41df6463c029fd96d61e2e36775149a98e221b79bf", + "rotation_team_for_date": "e6015fbc5d2bc3e152a02e22b3ea8f2cbb9075c2817f5e4f711048d16b180eb4", + "run_migrations": "9db018c5ab941a1fd5fee9b233b92ad649e2416cbf8ae605c8fc4b4b35712c9e", + "seed_demo": "d6141cc2710340cfc9ca3282b2d4a8018461d7134f4ab351a9d9dd653aeb40df", + "send_email_notification": "438763f138dc3103beaf1c3eac3e8a5501b57c8b1f520888c8da27e24c087c0a", + "send_many": "43af810d2e2a4f066c1785f6d0ae6738bec3063d95146c95076b84c9f7d13a4f", + "send_reminders": "351f084c07dcb6dc4433b43eb4493b195bfaaab7d8627774bd3ca0776e6a4912", + "source_label": "2b6551908dc0c237830b10aa17edee9822150f6c67cac4e9d53f2fbf62a8fc35", + "stamp": "953ddcd55bb2f00e293f821a5583565ef793704dffcfc85e0b7a5a95241acae1", + "swap_status_label": "bf46d5b8e6e685882063adbd33b363f39479e5f7e24ce2aeb970924d4fb5216b", + "swaps_page": "bb2bc978469a6e93f909d6a3c20af4aeaf5397c55013bc7edd85e7714ec9c020", + "verify_password": "bcc868c5552a7003a2fa8798c81403aa21d7fe5c35c7ecc81d9c29effaf8f974", + "weekday_name": "bad38b61f1dd398814f0033823c70ea9b0d57a638a0bf78fb9ff806c05da9e92", + "weekday_names": "a9d41b91b2d5b7b057f9b3325cf970df90a992348af215425119dfd7854096ac", + "window_label": "52b13470c59d8246d8ef3d28ffa0adae728eceef25e77320c388a26a28ef7fa0" + }, + "migrations": { + "README": "31595cf53626af9ed16e15c44fa43183209cc163fbc3ebcb904b22ac436a8884", + "env.py": "2094c197e04306806e783814065c4a47f98dd7764315ffac6e676130c0ee202e", + "script.py.mako": "d38920781b4d31ae1c0a71bc09f41c2ade8feab9e5a087e3f30cb4bbf0c1c9d3", + "versions/0001_initial.py": "6b5f1c37860c1cde017565a39464bf9b2635840c695f1f06a874845e3fa3ff6a" + }, + "openapi": { + "components": { + "schemas": { + "Body_accept_swap_swaps__swap_id__accept_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_accept_swap_swaps__swap_id__accept_post", + "type": "object" + }, + "Body_account_password_account_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "new_password": { + "default": "", + "title": "New Password", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_account_password_account_post", + "type": "object" + }, + "Body_admin_apply_pattern_admin_schedules__schedule_id__apply_pattern_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "month": { + "title": "Month", + "type": "integer" + }, + "year": { + "title": "Year", + "type": "integer" + } + }, + "required": [ + "year", + "month", + "csrf_token" + ], + "title": "Body_admin_apply_pattern_admin_schedules__schedule_id__apply_pattern_post", + "type": "object" + }, + "Body_admin_apply_rotation_admin_schedules__schedule_id__apply_rotation_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "month": { + "title": "Month", + "type": "integer" + }, + "year": { + "title": "Year", + "type": "integer" + } + }, + "required": [ + "year", + "month", + "csrf_token" + ], + "title": "Body_admin_apply_rotation_admin_schedules__schedule_id__apply_rotation_post", + "type": "object" + }, + "Body_admin_assign_team_admin_assign_post": { + "properties": { + "assignment_id": { + "title": "Assignment Id", + "type": "integer" + }, + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "team_id": { + "default": "", + "title": "Team Id", + "type": "string" + } + }, + "required": [ + "assignment_id", + "csrf_token" + ], + "title": "Body_admin_assign_team_admin_assign_post", + "type": "object" + }, + "Body_admin_assignment_regenerate_admin_schedules__schedule_id__assignments__assignment_id__regenerate_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_admin_assignment_regenerate_admin_schedules__schedule_id__assignments__assignment_id__regenerate_post", + "type": "object" + }, + "Body_admin_assignment_regenerate_admin_schedules__schedule_id__assignments__assignment_id__use_rotation_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_admin_assignment_regenerate_admin_schedules__schedule_id__assignments__assignment_id__use_rotation_post", + "type": "object" + }, + "Body_admin_clear_from_admin_schedules__schedule_id__clear_from_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "month": { + "title": "Month", + "type": "integer" + }, + "year": { + "title": "Year", + "type": "integer" + } + }, + "required": [ + "year", + "month", + "csrf_token" + ], + "title": "Body_admin_clear_from_admin_schedules__schedule_id__clear_from_post", + "type": "object" + }, + "Body_admin_create_team_admin_teams_post": { + "properties": { + "assigned_user_id": { + "default": "", + "title": "Assigned User Id", + "type": "string" + }, + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "email": { + "default": "", + "title": "Email", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "notify_email": { + "default": "", + "title": "Notify Email", + "type": "string" + }, + "phone": { + "default": "", + "title": "Phone", + "type": "string" + } + }, + "required": [ + "name", + "csrf_token" + ], + "title": "Body_admin_create_team_admin_teams_post", + "type": "object" + }, + "Body_admin_create_user_admin_users_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "is_admin": { + "default": "", + "title": "Is Admin", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + }, + "team_ids": { + "default": [], + "items": { + "type": "string" + }, + "title": "Team Ids", + "type": "array" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "name", + "username", + "password", + "csrf_token" + ], + "title": "Body_admin_create_user_admin_users_post", + "type": "object" + }, + "Body_admin_reject_swap_swaps__swap_id__admin_reject_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_admin_reject_swap_swaps__swap_id__admin_reject_post", + "type": "object" + }, + "Body_admin_rotation_add_admin_schedules__schedule_id__rotation_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "team_id": { + "title": "Team Id", + "type": "integer" + } + }, + "required": [ + "team_id", + "csrf_token" + ], + "title": "Body_admin_rotation_add_admin_schedules__schedule_id__rotation_post", + "type": "object" + }, + "Body_admin_rotation_move_admin_rotation__member_id__move_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "direction": { + "title": "Direction", + "type": "string" + } + }, + "required": [ + "direction", + "csrf_token" + ], + "title": "Body_admin_rotation_move_admin_rotation__member_id__move_post", + "type": "object" + }, + "Body_admin_rotation_remove_admin_rotation__member_id__remove_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_admin_rotation_remove_admin_rotation__member_id__remove_post", + "type": "object" + }, + "Body_admin_schedule_assign_swap_admin_schedules__schedule_id__swaps__swap_id__assign_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "month": { + "default": "", + "title": "Month", + "type": "string" + }, + "team_id": { + "title": "Team Id", + "type": "integer" + }, + "year": { + "default": "", + "title": "Year", + "type": "string" + } + }, + "required": [ + "team_id", + "csrf_token" + ], + "title": "Body_admin_schedule_assign_swap_admin_schedules__schedule_id__swaps__swap_id__assign_post", + "type": "object" + }, + "Body_admin_schedule_settings_admin_schedules__schedule_id__settings_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "end_time": { + "default": "", + "title": "End Time", + "type": "string" + }, + "is_active": { + "default": "", + "title": "Is Active", + "type": "string" + }, + "requires_manager_approval": { + "default": "", + "title": "Requires Manager Approval", + "type": "string" + }, + "start_time": { + "default": "", + "title": "Start Time", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_admin_schedule_settings_admin_schedules__schedule_id__settings_post", + "type": "object" + }, + "Body_admin_team_delete_admin_teams__team_id__delete_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_admin_team_delete_admin_teams__team_id__delete_post", + "type": "object" + }, + "Body_admin_update_team_admin_teams__team_id__update_post": { + "properties": { + "assigned_user_id": { + "default": "", + "title": "Assigned User Id", + "type": "string" + }, + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "email": { + "default": "", + "title": "Email", + "type": "string" + }, + "is_active": { + "default": "", + "title": "Is Active", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "notify_email": { + "default": "", + "title": "Notify Email", + "type": "string" + }, + "phone": { + "default": "", + "title": "Phone", + "type": "string" + } + }, + "required": [ + "name", + "csrf_token" + ], + "title": "Body_admin_update_team_admin_teams__team_id__update_post", + "type": "object" + }, + "Body_admin_update_user_admin_users__user_id__update_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "is_active": { + "default": "", + "title": "Is Active", + "type": "string" + }, + "is_admin": { + "default": "", + "title": "Is Admin", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "password": { + "default": "", + "title": "Password", + "type": "string" + }, + "team_ids": { + "default": [], + "items": { + "type": "string" + }, + "title": "Team Ids", + "type": "array" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "name", + "username", + "csrf_token" + ], + "title": "Body_admin_update_user_admin_users__user_id__update_post", + "type": "object" + }, + "Body_admin_user_delete_admin_users__user_id__delete_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_admin_user_delete_admin_users__user_id__delete_post", + "type": "object" + }, + "Body_approve_swap_swaps__swap_id__approve_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "team_id": { + "default": "", + "title": "Team Id", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_approve_swap_swaps__swap_id__approve_post", + "type": "object" + }, + "Body_cancel_swap_swaps__swap_id__cancel_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_cancel_swap_swaps__swap_id__cancel_post", + "type": "object" + }, + "Body_create_swap_assignments__assignment_id__swap_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "message": { + "default": "", + "title": "Message", + "type": "string" + }, + "target_assignment_id": { + "title": "Target Assignment Id", + "type": "integer" + } + }, + "required": [ + "target_assignment_id", + "csrf_token" + ], + "title": "Body_create_swap_assignments__assignment_id__swap_post", + "type": "object" + }, + "Body_create_swap_for_chosen_shift_swaps_new_post": { + "properties": { + "assignment_id": { + "title": "Assignment Id", + "type": "integer" + }, + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "message": { + "default": "", + "title": "Message", + "type": "string" + }, + "target_assignment_id": { + "title": "Target Assignment Id", + "type": "integer" + } + }, + "required": [ + "assignment_id", + "target_assignment_id", + "csrf_token" + ], + "title": "Body_create_swap_for_chosen_shift_swaps_new_post", + "type": "object" + }, + "Body_login_login_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password", + "csrf_token" + ], + "title": "Body_login_login_post", + "type": "object" + }, + "Body_logout_logout_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_logout_logout_post", + "type": "object" + }, + "Body_reject_swap_swaps__swap_id__reject_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_reject_swap_swaps__swap_id__reject_post", + "type": "object" + }, + "Body_revert_swap_swaps__swap_id__revert_post": { + "properties": { + "csrf_token": { + "title": "Csrf Token", + "type": "string" + } + }, + "required": [ + "csrf_token" + ], + "title": "Body_revert_swap_swaps__swap_id__revert_post", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + } + } + }, + "info": { + "title": "Contínuos CRR", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/": { + "get": { + "operationId": "dashboard__get", + "parameters": [ + { + "in": "query", + "name": "year", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Year" + } + }, + { + "in": "query", + "name": "month", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Month" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Dashboard" + } + }, + "/account": { + "get": { + "description": "The password editor lives on its own page, reached by clicking the user name.", + "operationId": "account_page_account_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Account Page" + }, + "post": { + "description": "A user changes its own password from `/account`: one field, no ceremony.", + "operationId": "account_password_account_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_account_password_account_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Account Password" + } + }, + "/admin/assign": { + "get": { + "description": "Pick a day and put someone on one of its shifts. No change request involved.\n\n`on` is a whole ISO date (`YYYY-MM-DD`), which is what the date input sends.", + "operationId": "admin_assign_day_admin_assign_get", + "parameters": [ + { + "in": "query", + "name": "on", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "On" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Assign Day" + }, + "post": { + "operationId": "admin_assign_team_admin_assign_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_assign_team_admin_assign_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Assign Team" + } + }, + "/admin/month": { + "get": { + "operationId": "admin_month_admin_month_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Admin Month" + } + }, + "/admin/notifications": { + "get": { + "operationId": "admin_notifications_admin_notifications_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Admin Notifications" + } + }, + "/admin/rotation/{member_id}/move": { + "post": { + "operationId": "admin_rotation_move_admin_rotation__member_id__move_post", + "parameters": [ + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "title": "Member Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_rotation_move_admin_rotation__member_id__move_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Rotation Move" + } + }, + "/admin/rotation/{member_id}/remove": { + "post": { + "operationId": "admin_rotation_remove_admin_rotation__member_id__remove_post", + "parameters": [ + { + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "title": "Member Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_rotation_remove_admin_rotation__member_id__remove_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Rotation Remove" + } + }, + "/admin/schedules": { + "get": { + "operationId": "admin_schedules_admin_schedules_get", + "parameters": [ + { + "in": "query", + "name": "schedule_id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Schedule Id" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Schedules" + } + }, + "/admin/schedules/{schedule_id}": { + "get": { + "operationId": "admin_schedule_admin_schedules__schedule_id__get", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + }, + { + "in": "query", + "name": "year", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Year" + } + }, + { + "in": "query", + "name": "month", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Month" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Schedule" + } + }, + "/admin/schedules/{schedule_id}/apply-pattern": { + "post": { + "operationId": "admin_apply_pattern_admin_schedules__schedule_id__apply_pattern_post", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_apply_pattern_admin_schedules__schedule_id__apply_pattern_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Apply Pattern" + } + }, + "/admin/schedules/{schedule_id}/apply-rotation": { + "post": { + "operationId": "admin_apply_rotation_admin_schedules__schedule_id__apply_rotation_post", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_apply_rotation_admin_schedules__schedule_id__apply_rotation_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Apply Rotation" + } + }, + "/admin/schedules/{schedule_id}/assignments": { + "post": { + "operationId": "admin_schedule_assignments_admin_schedules__schedule_id__assignments_post", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Schedule Assignments" + } + }, + "/admin/schedules/{schedule_id}/assignments/{assignment_id}/regenerate": { + "post": { + "operationId": "admin_assignment_regenerate_admin_schedules__schedule_id__assignments__assignment_id__regenerate_post", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + }, + { + "in": "path", + "name": "assignment_id", + "required": true, + "schema": { + "title": "Assignment Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_assignment_regenerate_admin_schedules__schedule_id__assignments__assignment_id__regenerate_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Assignment Regenerate" + } + }, + "/admin/schedules/{schedule_id}/assignments/{assignment_id}/use-rotation": { + "post": { + "operationId": "admin_assignment_regenerate_admin_schedules__schedule_id__assignments__assignment_id__use_rotation_post", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + }, + { + "in": "path", + "name": "assignment_id", + "required": true, + "schema": { + "title": "Assignment Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_assignment_regenerate_admin_schedules__schedule_id__assignments__assignment_id__use_rotation_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Assignment Regenerate" + } + }, + "/admin/schedules/{schedule_id}/clear-from": { + "post": { + "operationId": "admin_clear_from_admin_schedules__schedule_id__clear_from_post", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_clear_from_admin_schedules__schedule_id__clear_from_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Clear From" + } + }, + "/admin/schedules/{schedule_id}/pattern": { + "post": { + "operationId": "admin_schedule_pattern_admin_schedules__schedule_id__pattern_post", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Schedule Pattern" + } + }, + "/admin/schedules/{schedule_id}/rotation": { + "post": { + "operationId": "admin_rotation_add_admin_schedules__schedule_id__rotation_post", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_rotation_add_admin_schedules__schedule_id__rotation_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Rotation Add" + } + }, + "/admin/schedules/{schedule_id}/settings": { + "post": { + "operationId": "admin_schedule_settings_admin_schedules__schedule_id__settings_post", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_schedule_settings_admin_schedules__schedule_id__settings_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Schedule Settings" + } + }, + "/admin/schedules/{schedule_id}/swaps/{swap_id}/assign": { + "post": { + "description": "Complete a change request on behalf of the rota, including for rota-only teams.", + "operationId": "admin_schedule_assign_swap_admin_schedules__schedule_id__swaps__swap_id__assign_post", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + }, + { + "in": "path", + "name": "swap_id", + "required": true, + "schema": { + "title": "Swap Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_schedule_assign_swap_admin_schedules__schedule_id__swaps__swap_id__assign_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Schedule Assign Swap" + } + }, + "/admin/teams": { + "get": { + "description": "Who works the rota. Users are not created here: a team only picks who signs in for it.", + "operationId": "admin_teams_admin_teams_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Admin Teams" + }, + "post": { + "description": "Add a team to the rota. The user who signs in for it is chosen here or later.", + "operationId": "admin_create_team_admin_teams_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_create_team_admin_teams_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Create Team" + } + }, + "/admin/teams/{team_id}/delete": { + "get": { + "operationId": "admin_team_delete_page_admin_teams__team_id__delete_get", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "title": "Team Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Team Delete Page" + }, + "post": { + "operationId": "admin_team_delete_admin_teams__team_id__delete_post", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "title": "Team Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_team_delete_admin_teams__team_id__delete_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Team Delete" + } + }, + "/admin/teams/{team_id}/update": { + "post": { + "operationId": "admin_update_team_admin_teams__team_id__update_post", + "parameters": [ + { + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "title": "Team Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_update_team_admin_teams__team_id__update_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Update Team" + } + }, + "/admin/users": { + "get": { + "description": "The users: created here, then assigned to the teams they sign in for.", + "operationId": "admin_users_admin_users_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Admin Users" + }, + "post": { + "description": "A new user, assigned to the teams it will sign in for.", + "operationId": "admin_create_user_admin_users_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_create_user_admin_users_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Create User" + } + }, + "/admin/users/{user_id}/delete": { + "post": { + "operationId": "admin_user_delete_admin_users__user_id__delete_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_user_delete_admin_users__user_id__delete_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin User Delete" + } + }, + "/admin/users/{user_id}/update": { + "post": { + "operationId": "admin_update_user_admin_users__user_id__update_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_update_user_admin_users__user_id__update_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Update User" + } + }, + "/assignments/{assignment_id}/swap": { + "post": { + "operationId": "create_swap_assignments__assignment_id__swap_post", + "parameters": [ + { + "in": "path", + "name": "assignment_id", + "required": true, + "schema": { + "title": "Assignment Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_create_swap_assignments__assignment_id__swap_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Swap" + } + }, + "/calendar": { + "get": { + "description": "The links to subscribe, kept off the rota so that page stays about the shifts.", + "operationId": "calendar_page_calendar_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Calendar Page" + } + }, + "/calendar/{token}.ics": { + "get": { + "description": "Subscribe from any calendar app: one feed per team, plus a shared whole-rota feed.", + "operationId": "calendar_feed_calendar__token__ics_get", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "title": "Token", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Calendar Feed" + } + }, + "/export/schedule": { + "get": { + "description": "Choose a schedule and exact dates before downloading its PDF.", + "operationId": "export_schedule_export_schedule_get", + "parameters": [ + { + "in": "query", + "name": "schedule_id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Schedule Id" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Export Schedule" + } + }, + "/export/schedule.pdf": { + "get": { + "operationId": "download_schedule_pdf_export_schedule_pdf_get", + "parameters": [ + { + "in": "query", + "name": "schedule_id", + "required": true, + "schema": { + "title": "Schedule Id", + "type": "integer" + } + }, + { + "in": "query", + "name": "start_date", + "required": true, + "schema": { + "format": "date", + "title": "Start Date", + "type": "string" + } + }, + { + "in": "query", + "name": "end_date", + "required": true, + "schema": { + "format": "date", + "title": "End Date", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Download Schedule Pdf" + } + }, + "/health": { + "get": { + "operationId": "health_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Health" + } + }, + "/login": { + "get": { + "operationId": "login_page_login_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Login Page" + }, + "post": { + "operationId": "login_login_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_login_login_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Login" + } + }, + "/logout": { + "post": { + "operationId": "logout_logout_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_logout_logout_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Logout" + } + }, + "/swaps": { + "get": { + "operationId": "swaps_page_swaps_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Swaps Page" + } + }, + "/swaps/new": { + "get": { + "description": "Its own page, so the rota keeps showing only who works each day.", + "operationId": "new_swap_page_swaps_new_get", + "parameters": [ + { + "in": "query", + "name": "year", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Year" + } + }, + { + "in": "query", + "name": "month", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Month" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "New Swap Page" + }, + "post": { + "operationId": "create_swap_for_chosen_shift_swaps_new_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_create_swap_for_chosen_shift_swaps_new_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Swap For Chosen Shift" + } + }, + "/swaps/{swap_id}/accept": { + "post": { + "operationId": "accept_swap_swaps__swap_id__accept_post", + "parameters": [ + { + "in": "path", + "name": "swap_id", + "required": true, + "schema": { + "title": "Swap Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_accept_swap_swaps__swap_id__accept_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Accept Swap" + } + }, + "/swaps/{swap_id}/admin-reject": { + "post": { + "operationId": "admin_reject_swap_swaps__swap_id__admin_reject_post", + "parameters": [ + { + "in": "path", + "name": "swap_id", + "required": true, + "schema": { + "title": "Swap Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_admin_reject_swap_swaps__swap_id__admin_reject_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Reject Swap" + } + }, + "/swaps/{swap_id}/approve": { + "post": { + "operationId": "approve_swap_swaps__swap_id__approve_post", + "parameters": [ + { + "in": "path", + "name": "swap_id", + "required": true, + "schema": { + "title": "Swap Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_approve_swap_swaps__swap_id__approve_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Approve Swap" + } + }, + "/swaps/{swap_id}/cancel": { + "post": { + "operationId": "cancel_swap_swaps__swap_id__cancel_post", + "parameters": [ + { + "in": "path", + "name": "swap_id", + "required": true, + "schema": { + "title": "Swap Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_cancel_swap_swaps__swap_id__cancel_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Cancel Swap" + } + }, + "/swaps/{swap_id}/reject": { + "post": { + "operationId": "reject_swap_swaps__swap_id__reject_post", + "parameters": [ + { + "in": "path", + "name": "swap_id", + "required": true, + "schema": { + "title": "Swap Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_reject_swap_swaps__swap_id__reject_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Reject Swap" + } + }, + "/swaps/{swap_id}/revert": { + "post": { + "description": "Administrator only: undo an approved swap and give both shifts back.", + "operationId": "revert_swap_swaps__swap_id__revert_post", + "parameters": [ + { + "in": "path", + "name": "swap_id", + "required": true, + "schema": { + "title": "Swap Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_revert_swap_swaps__swap_id__revert_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Revert Swap" + } + } + } + }, + "orm": { + "relationships": { + "Assignment": { + "schedule": { + "back_populates": null, + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "assignments.schedule_id", + "schedules.id" + ] + ], + "target": "Schedule", + "uselist": false + }, + "team": { + "back_populates": null, + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "assignments.team_id", + "teams.id" + ] + ], + "target": "Team", + "uselist": false + } + }, + "MonthlyPattern": { + "schedule": { + "back_populates": "pattern_days", + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "monthly_patterns.schedule_id", + "schedules.id" + ] + ], + "target": "Schedule", + "uselist": false + }, + "team": { + "back_populates": null, + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "monthly_patterns.team_id", + "teams.id" + ] + ], + "target": "Team", + "uselist": false + } + }, + "NotificationLog": { + "team": { + "back_populates": null, + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "notification_logs.team_id", + "teams.id" + ] + ], + "target": "Team", + "uselist": false + } + }, + "RotationMember": { + "schedule": { + "back_populates": "rotation_members", + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "rotation_members.schedule_id", + "schedules.id" + ] + ], + "target": "Schedule", + "uselist": false + }, + "team": { + "back_populates": null, + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "rotation_members.team_id", + "teams.id" + ] + ], + "target": "Team", + "uselist": false + } + }, + "Schedule": { + "pattern_days": { + "back_populates": "schedule", + "cascade": [ + "delete", + "delete-orphan", + "expunge", + "merge", + "refresh-expire", + "save-update" + ], + "order_by": [ + "monthly_patterns.day_of_month" + ], + "pairs": [ + [ + "schedules.id", + "monthly_patterns.schedule_id" + ] + ], + "target": "MonthlyPattern", + "uselist": true + }, + "rotation_members": { + "back_populates": "schedule", + "cascade": [ + "delete", + "delete-orphan", + "expunge", + "merge", + "refresh-expire", + "save-update" + ], + "order_by": [ + "rotation_members.position" + ], + "pairs": [ + [ + "schedules.id", + "rotation_members.schedule_id" + ] + ], + "target": "RotationMember", + "uselist": true + } + }, + "SwapRequest": { + "accepted_by": { + "back_populates": null, + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "swap_requests.accepted_team_id", + "teams.id" + ] + ], + "target": "Team", + "uselist": false + }, + "assignment": { + "back_populates": null, + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "swap_requests.assignment_id", + "assignments.id" + ] + ], + "target": "Assignment", + "uselist": false + }, + "requester": { + "back_populates": null, + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "swap_requests.requester_id", + "teams.id" + ] + ], + "target": "Team", + "uselist": false + }, + "target_assignment": { + "back_populates": null, + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "swap_requests.target_assignment_id", + "assignments.id" + ] + ], + "target": "Assignment", + "uselist": false + }, + "target_team": { + "back_populates": null, + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "swap_requests.target_team_id", + "teams.id" + ] + ], + "target": "Team", + "uselist": false + } + }, + "Team": { + "user": { + "back_populates": "teams", + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "teams.user_id", + "users.id" + ] + ], + "target": "User", + "uselist": false + } + }, + "User": { + "teams": { + "back_populates": "user", + "cascade": [ + "merge", + "save-update" + ], + "order_by": [], + "pairs": [ + [ + "users.id", + "teams.user_id" + ] + ], + "target": "Team", + "uselist": true + } + } + }, + "tables": { + "assignments": { + "columns": [ + { + "default": null, + "name": "id", + "nullable": false, + "onupdate": null, + "primary_key": true, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "schedule_id", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "date", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "DATE" + }, + { + "default": null, + "name": "team_id", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": "generated", + "name": "source", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(20)" + }, + { + "default": null, + "name": "note", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(500)" + }, + { + "default": { + "callable": "datetime.utcnow" + }, + "name": "updated_at", + "nullable": false, + "onupdate": { + "callable": "datetime.utcnow" + }, + "primary_key": false, + "server_default": null, + "type": "DATETIME" + } + ], + "constraints": [ + { + "columns": [ + "id" + ], + "name": null, + "type": "PrimaryKeyConstraint" + }, + { + "columns": [ + "schedule_id", + "date" + ], + "name": "uq_assignment_schedule_date", + "type": "UniqueConstraint" + }, + { + "columns": [ + "schedule_id" + ], + "name": null, + "ondelete": "CASCADE", + "onupdate": null, + "targets": [ + "schedules.id" + ], + "type": "ForeignKeyConstraint" + }, + { + "columns": [ + "team_id" + ], + "name": null, + "ondelete": "SET NULL", + "onupdate": null, + "targets": [ + "teams.id" + ], + "type": "ForeignKeyConstraint" + } + ], + "indexes": [ + { + "columns": [ + "date" + ], + "name": "ix_assignments_date", + "unique": false + } + ] + }, + "monthly_patterns": { + "columns": [ + { + "default": null, + "name": "id", + "nullable": false, + "onupdate": null, + "primary_key": true, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "schedule_id", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "day_of_month", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "team_id", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + } + ], + "constraints": [ + { + "columns": [ + "id" + ], + "name": null, + "type": "PrimaryKeyConstraint" + }, + { + "columns": [ + "schedule_id", + "day_of_month" + ], + "name": "uq_pattern_schedule_day", + "type": "UniqueConstraint" + }, + { + "columns": [ + "schedule_id" + ], + "name": null, + "ondelete": "CASCADE", + "onupdate": null, + "targets": [ + "schedules.id" + ], + "type": "ForeignKeyConstraint" + }, + { + "columns": [ + "team_id" + ], + "name": null, + "ondelete": "SET NULL", + "onupdate": null, + "targets": [ + "teams.id" + ], + "type": "ForeignKeyConstraint" + }, + { + "name": "ck_pattern_day_of_month", + "sql": "day_of_month BETWEEN 1 AND 31", + "type": "CheckConstraint" + } + ], + "indexes": [] + }, + "notification_logs": { + "columns": [ + { + "default": null, + "name": "id", + "nullable": false, + "onupdate": null, + "primary_key": true, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "team_id", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "event_type", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(60)" + }, + { + "default": "email", + "name": "channel", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(20)" + }, + { + "default": null, + "name": "recipient", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(254)" + }, + { + "default": null, + "name": "subject", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(250)" + }, + { + "default": null, + "name": "body", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "TEXT" + }, + { + "default": null, + "name": "status", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(20)" + }, + { + "default": null, + "name": "error", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "TEXT" + }, + { + "default": { + "callable": "datetime.utcnow" + }, + "name": "created_at", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "DATETIME" + }, + { + "default": null, + "name": "sent_at", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "DATETIME" + } + ], + "constraints": [ + { + "columns": [ + "id" + ], + "name": null, + "type": "PrimaryKeyConstraint" + }, + { + "columns": [ + "team_id" + ], + "name": null, + "ondelete": "SET NULL", + "onupdate": null, + "targets": [ + "teams.id" + ], + "type": "ForeignKeyConstraint" + } + ], + "indexes": [] + }, + "rotation_members": { + "columns": [ + { + "default": null, + "name": "id", + "nullable": false, + "onupdate": null, + "primary_key": true, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "schedule_id", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "team_id", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "position", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + } + ], + "constraints": [ + { + "columns": [ + "id" + ], + "name": null, + "type": "PrimaryKeyConstraint" + }, + { + "columns": [ + "schedule_id", + "position" + ], + "name": "uq_rotation_schedule_position", + "type": "UniqueConstraint" + }, + { + "columns": [ + "schedule_id", + "team_id" + ], + "name": "uq_rotation_schedule_team", + "type": "UniqueConstraint" + }, + { + "columns": [ + "schedule_id" + ], + "name": null, + "ondelete": "CASCADE", + "onupdate": null, + "targets": [ + "schedules.id" + ], + "type": "ForeignKeyConstraint" + }, + { + "columns": [ + "team_id" + ], + "name": null, + "ondelete": "CASCADE", + "onupdate": null, + "targets": [ + "teams.id" + ], + "type": "ForeignKeyConstraint" + } + ], + "indexes": [] + }, + "schedules": { + "columns": [ + { + "default": null, + "name": "id", + "nullable": false, + "onupdate": null, + "primary_key": true, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "name", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(120)" + }, + { + "default": null, + "name": "slug", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(80)" + }, + { + "default": null, + "name": "schedule_type", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(20)" + }, + { + "default": null, + "name": "weekdays", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(30)" + }, + { + "default": "20:30:00", + "name": "start_time", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "TIME" + }, + { + "default": "23:59:00", + "name": "end_time", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "TIME" + }, + { + "default": "True", + "name": "requires_manager_approval", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "BOOLEAN" + }, + { + "default": "True", + "name": "is_active", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "BOOLEAN" + }, + { + "default": null, + "name": "rotation_anchor_date", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "DATE" + }, + { + "default": { + "callable": "datetime.utcnow" + }, + "name": "created_at", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "DATETIME" + } + ], + "constraints": [ + { + "columns": [ + "id" + ], + "name": null, + "type": "PrimaryKeyConstraint" + }, + { + "columns": [ + "name" + ], + "name": null, + "type": "UniqueConstraint" + }, + { + "columns": [ + "slug" + ], + "name": null, + "type": "UniqueConstraint" + } + ], + "indexes": [] + }, + "swap_requests": { + "columns": [ + { + "default": null, + "name": "id", + "nullable": false, + "onupdate": null, + "primary_key": true, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "assignment_id", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "target_assignment_id", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "requester_id", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "target_team_id", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "accepted_team_id", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": "open", + "name": "status", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(30)" + }, + { + "default": null, + "name": "message", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(500)" + }, + { + "default": { + "callable": "datetime.utcnow" + }, + "name": "created_at", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "DATETIME" + }, + { + "default": { + "callable": "datetime.utcnow" + }, + "name": "updated_at", + "nullable": false, + "onupdate": { + "callable": "datetime.utcnow" + }, + "primary_key": false, + "server_default": null, + "type": "DATETIME" + } + ], + "constraints": [ + { + "columns": [ + "accepted_team_id" + ], + "name": null, + "ondelete": "SET NULL", + "onupdate": null, + "targets": [ + "teams.id" + ], + "type": "ForeignKeyConstraint" + }, + { + "columns": [ + "assignment_id" + ], + "name": null, + "ondelete": "CASCADE", + "onupdate": null, + "targets": [ + "assignments.id" + ], + "type": "ForeignKeyConstraint" + }, + { + "columns": [ + "id" + ], + "name": null, + "type": "PrimaryKeyConstraint" + }, + { + "columns": [ + "requester_id" + ], + "name": null, + "ondelete": "CASCADE", + "onupdate": null, + "targets": [ + "teams.id" + ], + "type": "ForeignKeyConstraint" + }, + { + "columns": [ + "target_assignment_id" + ], + "name": null, + "ondelete": "SET NULL", + "onupdate": null, + "targets": [ + "assignments.id" + ], + "type": "ForeignKeyConstraint" + }, + { + "columns": [ + "target_team_id" + ], + "name": null, + "ondelete": "SET NULL", + "onupdate": null, + "targets": [ + "teams.id" + ], + "type": "ForeignKeyConstraint" + } + ], + "indexes": [] + }, + "teams": { + "columns": [ + { + "default": null, + "name": "id", + "nullable": false, + "onupdate": null, + "primary_key": true, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "name", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(120)" + }, + { + "default": null, + "name": "user_id", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "email", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(254)" + }, + { + "default": null, + "name": "phone", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(40)" + }, + { + "default": null, + "name": "calendar_token", + "nullable": true, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(64)" + }, + { + "default": "True", + "name": "is_active", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "BOOLEAN" + }, + { + "default": "True", + "name": "notify_email", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "BOOLEAN" + }, + { + "default": { + "callable": "datetime.utcnow" + }, + "name": "created_at", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "DATETIME" + } + ], + "constraints": [ + { + "columns": [ + "calendar_token" + ], + "name": null, + "type": "UniqueConstraint" + }, + { + "columns": [ + "email" + ], + "name": null, + "type": "UniqueConstraint" + }, + { + "columns": [ + "id" + ], + "name": null, + "type": "PrimaryKeyConstraint" + }, + { + "columns": [ + "user_id" + ], + "name": null, + "ondelete": "SET NULL", + "onupdate": null, + "targets": [ + "users.id" + ], + "type": "ForeignKeyConstraint" + } + ], + "indexes": [] + }, + "users": { + "columns": [ + { + "default": null, + "name": "id", + "nullable": false, + "onupdate": null, + "primary_key": true, + "server_default": null, + "type": "INTEGER" + }, + { + "default": null, + "name": "name", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(120)" + }, + { + "default": null, + "name": "username", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(80)" + }, + { + "default": null, + "name": "password_hash", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "VARCHAR(300)" + }, + { + "default": "False", + "name": "is_admin", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "BOOLEAN" + }, + { + "default": "True", + "name": "is_active", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "BOOLEAN" + }, + { + "default": { + "callable": "datetime.utcnow" + }, + "name": "created_at", + "nullable": false, + "onupdate": null, + "primary_key": false, + "server_default": null, + "type": "DATETIME" + } + ], + "constraints": [ + { + "columns": [ + "id" + ], + "name": null, + "type": "PrimaryKeyConstraint" + }, + { + "columns": [ + "username" + ], + "name": null, + "type": "UniqueConstraint" + } + ], + "indexes": [] + } + } + }, + "workflows": { + "": { + "requests": [ + { + "body": "{\"status\":\"ok\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/health", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Entrar · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n \n \n
\n
\n
\n \n \n
\n
\n \"\"\n
\n

Entrar

\n

Use o seu utilizador e a sua palavra-passe da escala.

\n
\n \">\n \n \n \n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/login", + "status": 200 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/account", + "status": 401 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/admin/teams", + "status": 401 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/admin/users", + "status": 401 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/admin/schedules", + "status": 401 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/swaps", + "status": 401 + }, + { + "body": "{\"detail\":\"Token CSRF inválido\"}", + "headers": { + "content-type": "application/json" + }, + "method": "POST", + "path": "/login", + "status": 400 + }, + { + "body": "", + "headers": { + "location": "/" + }, + "method": "POST", + "path": "/login", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/" + }, + "method": "POST", + "path": "/login", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/" + }, + "method": "POST", + "path": "/login", + "status": 303 + }, + { + "body": "\n\n\n \n \n \n A minha conta · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n
Bem-vindo(a), Equipa Norte, Equipa partilhada.
\n \n \n
\n
\n

Rota

\n

A minha conta

\n

Membro a · @member-a

\n \n

Este utilizador entra em nome de 2 equipa(s): Equipa Norte, Equipa partilhada. Vê e controla os turnos de todas.

\n \n
\n
\n\n
\n

Alterar palavra-passe

\n
\n \">\n \n
\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/account", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Calendário · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n \n
\n
\n

Rota

\n

Calendário

\n

Os teus turnos na app de calendário do telemóvel. Subscreve uma vez: as alterações à rota aparecem automaticamente, quando a app atualizar o calendário.

\n
\n
\n\n
\n\n
\n

Turnos de Equipa Norte

\n \n

No iPhone, confirma a subscrição na app Calendário. No Google e Outlook, adiciona pelo navegador à conta que usas no telemóvel. Ver os passos.

\n \n
\n \n \n
\n

\n
\n\n
\n

Turnos de Equipa partilhada

\n \n

No iPhone, confirma a subscrição na app Calendário. No Google e Outlook, adiciona pelo navegador à conta que usas no telemóvel. Ver os passos.

\n \n
\n \n \n
\n

\n
\n\n
\n\n
\n

Como adicionar no telemóvel

\n
\n iPhone / iPad — Apple Calendar\n
    \n
  1. Toca em iPhone / Apple Calendar no calendário que queres adicionar.
  2. \n
  3. Permite abrir a app Calendário e confirma Subscrever.
  4. \n
  5. Se o botão não abrir a app, copia o endereço. Na app Calendário, abre Calendários → Adicionar calendário → Adicionar calendário de assinatura e cola-o.
  6. \n
\n
\n
\n Android / Google Calendar\n
    \n
  1. Toca em Google Calendar / Android e entra na conta Google que usas no telemóvel.
  2. \n
  3. O Google exige a versão para computador para adicionar subscrições. No navegador do telemóvel, ativa Site para computador; se não conseguires, abre esta página num computador.
  4. \n
  5. Confirma a adição. Se não aparecer essa opção, copia o endereço e, no Google Calendar, escolhe Outros calendários → + → A partir de URL, cola-o e adiciona o calendário.
  6. \n
  7. Na app Google Calendar, abre o menu e ativa o novo calendário. Se não aparecer, verifica as definições de sincronização.
  8. \n
\n
\n
\n Outlook\n
    \n
  1. Toca em Outlook e entra na conta que usas na app.
  2. \n
  3. Confirma a subscrição no Outlook na Web. Se necessário, usa Adicionar calendário → Subscrever a partir da Web e cola o endereço copiado.
  4. \n
  5. Na app Outlook, seleciona o novo calendário nessa conta. Se a versão móvel não mostrar a opção de subscrever, usa o navegador em modo computador.
  6. \n
\n
\n

Escolhe uma subscrição por endereço, em vez de importar um ficheiro .ics, para receber futuras alterações. A atualização depende da app e pode demorar algumas horas. Para Google e Outlook, o endereço tem de estar acessível pela Internet.

\n
\n\n\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/calendar", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n \n
\n

Pedidos de troca

\n
\n\n
\n\n
Ainda não há pedidos de troca.
\n\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/swaps", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Pedir troca · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n \n
\n
\n

Trocas

\n

Pedir troca

\n

Uma troca é entre duas equipas da mesma escala: cada uma fica com o turno da outra.

\n
\n \n
\n\n
\n\n
\n
\n

Rotação Phase 4

\n Janeiro 2030\n
\n
\n \">\n \n \n \n
\n
\n
\n\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/swaps/new?year=2030&month=1", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n \n
\n
\n

Janeiro 2030

\n
\n \n
\n\n
\n\n
\n
\n Ter\n 1\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qua\n 2\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qui\n 3\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sex\n 4\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sáb\n 5\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Sábados
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Rotação Phase 4
\n
\n Equipa Norte\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Dom\n 6\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Domingo
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Seg\n 7\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Ter\n 8\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qua\n 9\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qui\n 10\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sex\n 11\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sáb\n 12\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Sábados
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Rotação Phase 4
\n
\n Equipa Sul\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Dom\n 13\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Domingo
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Seg\n 14\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Ter\n 15\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qua\n 16\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qui\n 17\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sex\n 18\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sáb\n 19\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Sábados
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Rotação Phase 4
\n
\n Equipa Norte\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Dom\n 20\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Domingo
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Seg\n 21\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Ter\n 22\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qua\n 23\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qui\n 24\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sex\n 25\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sáb\n 26\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Sábados
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Rotação Phase 4
\n
\n Equipa Sul\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Dom\n 27\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Domingo
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Seg\n 28\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Ter\n 29\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qua\n 30\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qui\n 31\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/?year=2030&month=1", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Exportar escala · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n \n
\n
\n

Rota

\n

Exportar escala

\n

Escolhe uma escala e as datas. O PDF é descarregado numa única folha A4, com os dias e as equipas agrupados por mês.

\n
\n
\n
\n \n
\n \n
\n \n \n
\n

As datas inicial e final estão incluídas. Máximo de 12 meses; o tamanho do texto adapta-se ao intervalo.

\n
\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/export/schedule", + "status": 200 + }, + { + "body": "{\"detail\":\"É necessária a permissão de administrador\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/admin/users", + "status": 403 + }, + { + "body": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Contínuos CRR//Rota//PT\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:Contínuos CRR — Equipa Norte\r\nX-WR-CALDESC:Contínuos CRR — 1 de agosto de 2026 a 31 de março de 2027\r\nREFRESH-INTERVAL;VALUE=DURATION:PT12H\r\nX-PUBLISHED-TTL:PT12H\r\nX-WR-TIMEZONE:Europe/Lisbon\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Lisbon\r\nX-LIC-LOCATION:Europe/Lisbon\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0000\r\nTZOFFSETTO:+0100\r\nTZNAME:WEST\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0000\r\nTZNAME:WET\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nEND:VCALENDAR\r\n", + "headers": { + "cache-control": "private, max-age=300", + "content-disposition": "inline; filename=\"rota.ics\"", + "content-type": "text/calendar; charset=utf-8" + }, + "method": "GET", + "path": "/calendar/phase4-feed-a.ics", + "status": 200 + }, + { + "body": "{\"detail\":\"Calendário não encontrado\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/calendar/not-a-token.ics", + "status": 404 + }, + { + "body": "CONTÍNUOS CRR\nRotação Phase 4\nJaneiro 2030\n 5\nEquipa Norte\n12\nEquipa Sul\n19\nEquipa Norte\n26\nEquipa Sul\n", + "headers": { + "cache-control": "private, no-store", + "content-disposition": "attachment; filename=\"escala-4-2030-01-01-2030-01-31.pdf\"", + "content-type": "application/pdf" + }, + "method": "GET", + "path": "/export/schedule.pdf?schedule_id=4&start_date=2030-01-01&end_date=2030-01-31", + "status": 200 + }, + { + "body": "", + "headers": { + "location": "/swaps" + }, + "method": "POST", + "path": "/assignments/1/swap", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/swaps" + }, + "method": "POST", + "path": "/swaps/1/accept", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/swaps" + }, + "method": "POST", + "path": "/swaps/1/approve", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/swaps" + }, + "method": "POST", + "path": "/swaps/1/revert", + "status": 303 + }, + { + "body": "\n\n\n \n \n \n Equipas · Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n
Troca revertida: os dois turnos voltaram a quem os tinha.
\n \n \n
\n
\n

Equipas

\n

Quem entra na rota. Uma equipa é quem fica com um turno, e pode ser uma pessoa ou várias. Os utilizadores que entram na aplicação criam-se em Utilizadores; aqui escolhe-se apenas quem entra para cada equipa.

\n
\n
\n
\n
\n

Adicionar equipa

\n \n
\n
\n \">\n
\n \n \n \n
\n
\n Utilizador (opcional)\n \n
\n \n
\n
\n
\n\n
\n Administrador de teste @phase4-admin\n
\n \">\n
\n \n \n \n
\n
\n Utilizador\n \n

O mesmo utilizador pode entrar em várias equipas: vê e controla os turnos de todas elas.

\n
\n \n \n
\n \n
\n
\n\n
\n Equipa Norte @member-a · partilhada com 1\n
\n \">\n
\n \n \n \n
\n
\n Utilizador\n \n

O mesmo utilizador pode entrar em várias equipas: vê e controla os turnos de todas elas.

\n
\n \n \n
\n \n
\n
\n\n
\n Equipa Sul @member-b\n
\n \">\n
\n \n \n \n
\n
\n Utilizador\n \n

O mesmo utilizador pode entrar em várias equipas: vê e controla os turnos de todas elas.

\n
\n \n \n
\n \n
\n
\n\n
\n Equipa partilhada @member-a · partilhada com 1\n
\n \">\n
\n \n \n \n
\n
\n Utilizador\n \n

O mesmo utilizador pode entrar em várias equipas: vê e controla os turnos de todas elas.

\n
\n \n \n
\n \n
\n
\n\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/teams", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Utilizadores · Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n \n
\n
\n

Utilizadores

\n

Cada utilizador tem utilizador e palavra-passe e pode entrar em várias equipas. Quem entra vê e controla os turnos de todas elas, e a administração é do utilizador, não da equipa.

\n
\n
\n
\n
\n

Adicionar utilizador

\n \n
\n
\n \">\n
\n \n \n \n
\n
\n Equipas (pode escolher várias)\n \n \n \n \n \n \n \n \n \n
\n \n
\n
\n
\n\n
\n @member-a Membro a · 2 equipa(s)\n
\n \">\n
\n \n \n \n
\n
\n Equipas que entra\n \n \n \n \n \n \n \n \n \n
\n \n \n
\n \n
\n \n

Equipas: Equipa Norte, Equipa partilhada

\n \n
\n\n
\n @member-b Membro b · 1 equipa(s)\n
\n \">\n
\n \n \n \n
\n
\n Equipas que entra\n \n \n \n \n \n \n \n \n \n
\n \n \n
\n \n
\n \n

Equipas: Equipa Sul

\n \n
\n\n
\n @phase4-admin Administrador de teste · administrador · 1 equipa(s)\n
\n \">\n
\n \n \n \n
\n
\n Equipas que entra\n \n \n \n \n \n \n \n \n \n
\n \n \n
\n \n
\n \n

Equipas: Administrador de teste

\n \n
\n\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/users", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n \n
\n
\n

Escalas

\n

Uma página por tipo de escala. A escala da noite é fixa e repete todos os meses; o almoço de sábado e o de domingo são rotações independentes.

\n
\n Atribuir por dia\n
\n
\n\n
\n

Exportar PDF

\n

Escolhe uma escala e as datas para descarregar a lista de dias e equipas numa única folha A4.

\n \n
\n \n
\n \n \n
\n

As datas inicial e final estão incluídas. Máximo de 12 meses; o tamanho do texto adapta-se ao intervalo.

\n
\n
\n
\n\n\n\n
\n
\n
\n

Noite

\n

Todos os dias · repete todos os meses · 20:30–00:00

\n
\n \n
\n \n

Padrão

\n \n \n
\n\n\n
\n
\n
\n

Almoço - Sábados

\n

Rotação · Sábado · 13:00–16:00

\n
\n \n
\n \n

Próximas datas

\n

sáb 10 out · sáb 17 out · sáb 24 out · sáb 31 out · sáb 07 nov · sáb 14 nov

\n

Ordem da rotação

\n
    \n
  1. Ainda não há membros.
  2. \n
\n \n
\n\n\n
\n
\n
\n

Almoço - Domingo

\n

Rotação · Domingo · 13:00–16:00

\n
\n \n
\n \n

Próximas datas

\n

dom 11 out · dom 18 out · dom 25 out · dom 01 nov · dom 08 nov · dom 15 nov

\n

Ordem da rotação

\n
    \n
  1. Ainda não há membros.
  2. \n
\n \n
\n\n\n
\n
\n
\n

Rotação Phase 4

\n

Rotação · Sábado · 12:00–14:00

\n
\n \n
\n \n

Próximas datas

\n

sáb 10 out · sáb 17 out · sáb 24 out · sáb 31 out · sáb 07 nov · sáb 14 nov

\n

Ordem da rotação

\n
    \n \n
  1. Equipa Norte
  2. \n \n
  3. Equipa Sul
  4. \n \n
\n \n
\n\n
\n

Calendário da equipa

\n

Um único endereço com a rota completa, para qualquer calendário.

\n

Adicionar ao calendário do telemóvel

\n
\n \n \n
\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/schedules", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n \n\n
\n
\n

Escalas

\n

Rotação Phase 4

\n

Rotação · Sábado · 12:00–14:00

\n
\n
\n \">\n \n \n \n \n \n
\n
\n
\n\n
\n
\n

Janeiro 2030

\n \n
\n
\n \">\n \n \n
\n

Deixa sem atribuição os turnos automáticos do mês e ano escolhidos e de todos os seguintes. Nenhum turno é apagado e as atribuições manuais e as trocas concluídas são mantidas. Use Limpar turnos antes de Aplicar rotação, para recalcular tudo a partir de um ponto.

\n
\n \">\n \n
\n \n \n \n \n \n \n \n \n \n
DataEquipaOrigemAção
sáb 05 janmanual
sáb 12 janmanual
sáb 19 jantroca
sáb 26 janautomático
\n
\n
\n \n
\n \">\n
\n \n
\n \">\n
\n \n
\n \">\n
\n \n
\n \">\n
\n \n
\n
\n

Próximas datas

\n

sáb 10 out · sáb 17 out · sáb 24 out · sáb 31 out · sáb 07 nov · sáb 14 nov

\n
\n
\n

Ordem da rotação

\n

Rotação independente, preenchida por ordem. Depois de alterar a ordem, use Aplicar rotação acima para atualizar os turnos automáticos deste mês.

\n
    \n \n
  1. \n Equipa Norte\n
    \n
    \">
    \n
    \">
    \n
    \">
    \n
    \n
  2. \n \n
  3. \n Equipa Sul\n
    \n
    \">
    \n
    \">
    \n
    \">
    \n
    \n
  4. \n \n
\n
\n \">\n \n \n
\n
\n\n
\n

Limpar atribuições

\n

Limpar atribuições a partir do mês de:

\n
\n \">\n \n \n \n
\n
\n
\n

Pedidos de troca

\n

Um administrador pode atribuir o turno a outra equipa, mesmo que nenhuma pessoa entre na aplicação por ela. Só se aplica se quem pediu a troca continuar a ser o titular do turno.

\n \n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/schedules/4?year=2030&month=1", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Atribuição por dia · Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n \n
\n
\n

Escalas

\n

Atribuição por dia

\n

Escolha o dia e a equipa que fica com cada turno. É uma atribuição direta, não uma troca.

\n
\n
\n \n
\n
\n\n\n\n
\n
\n

Sábado, 5 de janeiro de 2030

\n \n
\n \">\n \n
\n Noite\n · 20:30–00:00\n
\n \n Agora: sem atribuição\n \n
\n
\n
\n \n \n
\n
\n \n
\n \">\n \n
\n Almoço - Sábados\n · 13:00–16:00\n
\n \n Agora: sem atribuição\n \n
\n
\n
\n \n \n
\n
\n \n
\n \">\n \n
\n Rotação Phase 4\n · 12:00–14:00\n
\n \n Agora: Equipa Norte (manual)\n \n
\n
\n
\n \n \n
\n
\n \n
\n

Atribuir aqui fecha qualquer pedido de troca em aberto sobre esse turno e avisa a nova equipa e a anterior.

\n

«Automático» repõe a equipa que a escala dá a este dia — a ordem da rotação ou o padrão de noites — e volta a marcar o turno como automático.

\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/assign?on=2030-01-05", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Registo de e-mails · Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n \n

Registo de e-mails

«ignorado» significa que a equipa não tem e-mail, que tem as notificações desligadas ou que o SMTP não está configurado.

\n
\n\n
Data e horaEventoEquipaDestinatárioEstadoAssunto
07/10/2026 10:58Troca revertidaEquipa Sulb@example.comignoradoTroca de turno revertida
07/10/2026 10:58Troca revertidaEquipa Nortea@example.comignoradoTroca de turno revertida
07/10/2026 10:58Troca aprovadaEquipa Sulb@example.comignoradoTroca de turno aprovada
07/10/2026 10:58Troca aprovadaEquipa Nortea@example.comignoradoTroca de turno aprovada
07/10/2026 10:58Troca aceiteEquipa Nortea@example.comignoradoA sua troca de turno foi aceite
07/10/2026 10:58Troca aguarda aprovaçãoAdministrador de testeadmin@example.comignoradoUma troca de turno precisa de aprovação
07/10/2026 10:58Pedido de trocaEquipa Sulb@example.comignoradoTroca de turnos: sáb 05 jan por sáb 12 jan – Rotação Phase 4
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/notifications", + "status": 200 + }, + { + "body": "", + "headers": { + "location": "/admin/schedules" + }, + "method": "GET", + "path": "/admin/month", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/admin/schedules/4?year=2030&month=1" + }, + "method": "POST", + "path": "/admin/schedules/4/apply-rotation", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/admin/schedules/4?year=2030&month=1" + }, + "method": "POST", + "path": "/admin/schedules/4/clear-from", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/admin/assign?on=2030-01-26" + }, + "method": "POST", + "path": "/admin/assign", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/admin/teams" + }, + "method": "POST", + "path": "/admin/teams", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/admin/teams" + }, + "method": "POST", + "path": "/admin/teams/5/update", + "status": 303 + }, + { + "body": "\n\n\n \n \n \n Remover Equipa editada · Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n
Equipa atualizada.
\n \n \n
\n
\n

Equipas

\n

Remover equipa

\n

Isto é definitivo e não pode ser desfeito.

\n
\n
\n
\n

Equipa editada

\n

Sem utilizador, apenas aparece na escala.

\n

Só é possível remover quem ainda não tem turnos atribuídos. Primeiro transfira os turnos desta equipa para outras; assim o registo de quem trabalhou cada data mantém-se.

\n

Ao remover, acontece o seguinte:

\n \n

Se só quiser que esta equipa deixe de receber turnos, desative-a em vez de a remover.

\n
\n
\n \">\n \n
\n Cancelar\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/teams/5/delete", + "status": 200 + }, + { + "body": "", + "headers": { + "location": "/admin/teams" + }, + "method": "POST", + "path": "/admin/teams/5/delete", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/admin/users" + }, + "method": "POST", + "path": "/admin/users", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/admin/users" + }, + "method": "POST", + "path": "/admin/users/4/update", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/admin/users" + }, + "method": "POST", + "path": "/admin/users/4/delete", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/" + }, + "method": "POST", + "path": "/account", + "status": 303 + }, + { + "body": "\n\n\n \n \n \n A minha conta · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n
A nova palavra-passe é igual à atual.
\n \n \n
\n
\n

Rota

\n

A minha conta

\n

Membro a · @member-a

\n \n

Este utilizador entra em nome de 2 equipa(s): Equipa Norte, Equipa partilhada. Vê e controla os turnos de todas.

\n \n
\n
\n\n
\n

Alterar palavra-passe

\n
\n \">\n \n
\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/account", + "status": 200 + }, + { + "body": "", + "headers": { + "location": "/" + }, + "method": "POST", + "path": "/account", + "status": 303 + }, + { + "body": "\n\n\n \n \n \n A minha conta · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n
Palavra-passe alterada.
\n \n \n
\n
\n

Rota

\n

A minha conta

\n

Membro a · @member-a

\n \n

Este utilizador entra em nome de 2 equipa(s): Equipa Norte, Equipa partilhada. Vê e controla os turnos de todas.

\n \n
\n
\n\n
\n

Alterar palavra-passe

\n
\n \">\n \n
\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/account", + "status": 200 + }, + { + "body": "", + "headers": { + "location": "/login" + }, + "method": "POST", + "path": "/logout", + "status": 303 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/account", + "status": 401 + } + ], + "rows": { + "alembic_version": [ + { + "version_num": "0001_initial" + } + ], + "assignments": [ + { + "date": "2030-01-05", + "id": 1, + "note": "Revertida a troca #1 por Administrador de teste", + "schedule_id": 4, + "source": "manual", + "team_id": 2, + "updated_at": "" + }, + { + "date": "2030-01-12", + "id": 2, + "note": "Revertida a troca #1 por Administrador de teste", + "schedule_id": 4, + "source": "manual", + "team_id": 3, + "updated_at": "" + }, + { + "date": "2030-01-19", + "id": 3, + "note": null, + "schedule_id": 4, + "source": "swap", + "team_id": 2, + "updated_at": "" + }, + { + "date": "2030-01-26", + "id": 4, + "note": null, + "schedule_id": 4, + "source": "manual", + "team_id": 3, + "updated_at": "" + }, + { + "date": "2030-01-01", + "id": 5, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-02", + "id": 6, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-03", + "id": 7, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-04", + "id": 8, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-05", + "id": 9, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-06", + "id": 10, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-07", + "id": 11, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-08", + "id": 12, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-09", + "id": 13, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-10", + "id": 14, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-11", + "id": 15, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-12", + "id": 16, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-13", + "id": 17, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-14", + "id": 18, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-15", + "id": 19, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-16", + "id": 20, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-17", + "id": 21, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-18", + "id": 22, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-19", + "id": 23, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-20", + "id": 24, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-21", + "id": 25, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-22", + "id": 26, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-23", + "id": 27, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-24", + "id": 28, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-25", + "id": 29, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-26", + "id": 30, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-27", + "id": 31, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-28", + "id": 32, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-29", + "id": 33, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-30", + "id": 34, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-31", + "id": 35, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-05", + "id": 36, + "note": null, + "schedule_id": 2, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-12", + "id": 37, + "note": null, + "schedule_id": 2, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-19", + "id": 38, + "note": null, + "schedule_id": 2, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-26", + "id": 39, + "note": null, + "schedule_id": 2, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-06", + "id": 40, + "note": null, + "schedule_id": 3, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-13", + "id": 41, + "note": null, + "schedule_id": 3, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-20", + "id": 42, + "note": null, + "schedule_id": 3, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-27", + "id": 43, + "note": null, + "schedule_id": 3, + "source": "generated", + "team_id": null, + "updated_at": "" + } + ], + "monthly_patterns": [], + "notification_logs": [ + { + "body": "Equipa Norte propõe trocar o turno de Rotação Phase 4 de 5 de janeiro de 2030 pelo seu turno de 12 de janeiro de 2030.\n\nTeste de troca\n\nAbrir a escala: http://testserver/swaps", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_requested", + "id": 1, + "recipient": "b@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Troca de turnos: sáb 05 jan por sáb 12 jan – Rotação Phase 4", + "team_id": 3 + }, + { + "body": "Equipa Sul aceitou a troca de Rotação Phase 4 de 5 de janeiro de 2030 por 12 de janeiro de 2030, pedida por Equipa Norte. Aprove em http://testserver/swaps", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_needs_approval", + "id": 2, + "recipient": "admin@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Uma troca de turno precisa de aprovação", + "team_id": 1 + }, + { + "body": "Equipa Sul aceitou o seu pedido. Está agora à espera da aprovação da gestão.", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_accepted", + "id": 3, + "recipient": "a@example.com", + "sent_at": null, + "status": "skipped", + "subject": "A sua troca de turno foi aceite", + "team_id": 2 + }, + { + "body": "Administrador de teste aprovou a troca de Rotação Phase 4: Equipa Sul fica com o turno de 5 de janeiro de 2030 e Equipa Norte com o de 12 de janeiro de 2030.", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_approved", + "id": 4, + "recipient": "a@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Troca de turno aprovada", + "team_id": 2 + }, + { + "body": "Administrador de teste aprovou a troca de Rotação Phase 4: Equipa Sul fica com o turno de 5 de janeiro de 2030 e Equipa Norte com o de 12 de janeiro de 2030.", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_approved", + "id": 5, + "recipient": "b@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Troca de turno aprovada", + "team_id": 3 + }, + { + "body": "Administrador de teste reverteu a troca de Rotação Phase 4. Equipa Norte volta a 5 de janeiro de 2030 e Equipa Sul volta a 12 de janeiro de 2030.\n\nVer a escala: http://testserver/", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_reverted", + "id": 6, + "recipient": "a@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Troca de turno revertida", + "team_id": 2 + }, + { + "body": "Administrador de teste reverteu a troca de Rotação Phase 4. Equipa Norte volta a 5 de janeiro de 2030 e Equipa Sul volta a 12 de janeiro de 2030.\n\nVer a escala: http://testserver/", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_reverted", + "id": 7, + "recipient": "b@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Troca de turno revertida", + "team_id": 3 + }, + { + "body": "Já não está atribuído ao turno de Rotação Phase 4 a 26 de janeiro de 2030.\n\nVer a escala: http://testserver/", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "assignment_changed", + "id": 8, + "recipient": "b@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Escala atualizada: sáb 26 jan – Rotação Phase 4", + "team_id": 3 + }, + { + "body": "Está atribuído ao turno de Rotação Phase 4 a 26 de janeiro de 2030.\n\nVer a escala: http://testserver/", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "assignment_updated", + "id": 9, + "recipient": "b@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Escala atualizada: sáb 26 jan – Rotação Phase 4", + "team_id": 3 + } + ], + "rotation_members": [ + { + "id": 1, + "position": 1, + "schedule_id": 4, + "team_id": 2 + }, + { + "id": 2, + "position": 2, + "schedule_id": 4, + "team_id": 3 + } + ], + "schedules": [ + { + "created_at": "", + "end_time": "00:00:00.000000", + "id": 1, + "is_active": 1, + "name": "Noite", + "requires_manager_approval": 1, + "rotation_anchor_date": null, + "schedule_type": "fixed", + "slug": "night", + "start_time": "20:30:00.000000", + "weekdays": "0,1,2,3,4,5,6" + }, + { + "created_at": "", + "end_time": "16:00:00.000000", + "id": 2, + "is_active": 1, + "name": "Almoço - Sábados", + "requires_manager_approval": 1, + "rotation_anchor_date": "2024-01-06", + "schedule_type": "rotation", + "slug": "saturday-lunch", + "start_time": "13:00:00.000000", + "weekdays": "5" + }, + { + "created_at": "", + "end_time": "16:00:00.000000", + "id": 3, + "is_active": 1, + "name": "Almoço - Domingo", + "requires_manager_approval": 1, + "rotation_anchor_date": "2024-01-07", + "schedule_type": "rotation", + "slug": "sunday-lunch", + "start_time": "13:00:00.000000", + "weekdays": "6" + }, + { + "created_at": "", + "end_time": "14:00:00.000000", + "id": 4, + "is_active": 1, + "name": "Rotação Phase 4", + "requires_manager_approval": 1, + "rotation_anchor_date": "2030-01-05", + "schedule_type": "rotation", + "slug": "phase4-rotation", + "start_time": "12:00:00.000000", + "weekdays": "5" + } + ], + "swap_requests": [ + { + "accepted_team_id": 3, + "assignment_id": 1, + "created_at": "", + "id": 1, + "message": "Teste de troca", + "requester_id": 2, + "status": "reverted", + "target_assignment_id": 2, + "target_team_id": 3, + "updated_at": "" + } + ], + "teams": [ + { + "calendar_token": null, + "created_at": "", + "email": "admin@example.com", + "id": 1, + "is_active": 1, + "name": "Administrador de teste", + "notify_email": 1, + "phone": null, + "user_id": 1 + }, + { + "calendar_token": "phase4-feed-a", + "created_at": "", + "email": "a@example.com", + "id": 2, + "is_active": 1, + "name": "Equipa Norte", + "notify_email": 1, + "phone": null, + "user_id": 2 + }, + { + "calendar_token": "phase4-feed-b", + "created_at": "", + "email": "b@example.com", + "id": 3, + "is_active": 1, + "name": "Equipa Sul", + "notify_email": 1, + "phone": null, + "user_id": 3 + }, + { + "calendar_token": "phase4-feed-c", + "created_at": "", + "email": "c@example.com", + "id": 4, + "is_active": 1, + "name": "Equipa partilhada", + "notify_email": 1, + "phone": null, + "user_id": 2 + } + ], + "users": [ + { + "created_at": "", + "id": 1, + "is_active": 1, + "is_admin": 1, + "name": "Administrador de teste", + "password_hash": "", + "username": "phase4-admin" + }, + { + "created_at": "", + "id": 2, + "is_active": 1, + "is_admin": 0, + "name": "Membro a", + "password_hash": "", + "username": "member-a" + }, + { + "created_at": "", + "id": 3, + "is_active": 1, + "is_admin": 0, + "name": "Membro b", + "password_hash": "", + "username": "member-b" + } + ] + } + }, + "/crr": { + "requests": [ + { + "body": "{\"status\":\"ok\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/health", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Entrar · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n \n \n
\n
\n
\n \n \n
\n
\n \"\"\n
\n

Entrar

\n

Use o seu utilizador e a sua palavra-passe da escala.

\n
\n \">\n \n \n \n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/login", + "status": 200 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/account", + "status": 401 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/admin/teams", + "status": 401 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/admin/users", + "status": 401 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/admin/schedules", + "status": 401 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/swaps", + "status": 401 + }, + { + "body": "{\"detail\":\"Token CSRF inválido\"}", + "headers": { + "content-type": "application/json" + }, + "method": "POST", + "path": "/login", + "status": 400 + }, + { + "body": "", + "headers": { + "location": "/crr/" + }, + "method": "POST", + "path": "/login", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/" + }, + "method": "POST", + "path": "/login", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/" + }, + "method": "POST", + "path": "/login", + "status": 303 + }, + { + "body": "\n\n\n \n \n \n A minha conta · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n
Bem-vindo(a), Equipa Norte, Equipa partilhada.
\n \n \n
\n
\n

Rota

\n

A minha conta

\n

Membro a · @member-a

\n \n

Este utilizador entra em nome de 2 equipa(s): Equipa Norte, Equipa partilhada. Vê e controla os turnos de todas.

\n \n
\n
\n\n
\n

Alterar palavra-passe

\n
\n \">\n \n
\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/account", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Calendário · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n \n
\n
\n

Rota

\n

Calendário

\n

Os teus turnos na app de calendário do telemóvel. Subscreve uma vez: as alterações à rota aparecem automaticamente, quando a app atualizar o calendário.

\n
\n
\n\n
\n\n
\n

Turnos de Equipa Norte

\n \n

No iPhone, confirma a subscrição na app Calendário. No Google e Outlook, adiciona pelo navegador à conta que usas no telemóvel. Ver os passos.

\n \n
\n \n \n
\n

\n
\n\n
\n

Turnos de Equipa partilhada

\n \n

No iPhone, confirma a subscrição na app Calendário. No Google e Outlook, adiciona pelo navegador à conta que usas no telemóvel. Ver os passos.

\n \n
\n \n \n
\n

\n
\n\n
\n\n
\n

Como adicionar no telemóvel

\n
\n iPhone / iPad — Apple Calendar\n
    \n
  1. Toca em iPhone / Apple Calendar no calendário que queres adicionar.
  2. \n
  3. Permite abrir a app Calendário e confirma Subscrever.
  4. \n
  5. Se o botão não abrir a app, copia o endereço. Na app Calendário, abre Calendários → Adicionar calendário → Adicionar calendário de assinatura e cola-o.
  6. \n
\n
\n
\n Android / Google Calendar\n
    \n
  1. Toca em Google Calendar / Android e entra na conta Google que usas no telemóvel.
  2. \n
  3. O Google exige a versão para computador para adicionar subscrições. No navegador do telemóvel, ativa Site para computador; se não conseguires, abre esta página num computador.
  4. \n
  5. Confirma a adição. Se não aparecer essa opção, copia o endereço e, no Google Calendar, escolhe Outros calendários → + → A partir de URL, cola-o e adiciona o calendário.
  6. \n
  7. Na app Google Calendar, abre o menu e ativa o novo calendário. Se não aparecer, verifica as definições de sincronização.
  8. \n
\n
\n
\n Outlook\n
    \n
  1. Toca em Outlook e entra na conta que usas na app.
  2. \n
  3. Confirma a subscrição no Outlook na Web. Se necessário, usa Adicionar calendário → Subscrever a partir da Web e cola o endereço copiado.
  4. \n
  5. Na app Outlook, seleciona o novo calendário nessa conta. Se a versão móvel não mostrar a opção de subscrever, usa o navegador em modo computador.
  6. \n
\n
\n

Escolhe uma subscrição por endereço, em vez de importar um ficheiro .ics, para receber futuras alterações. A atualização depende da app e pode demorar algumas horas. Para Google e Outlook, o endereço tem de estar acessível pela Internet.

\n
\n\n\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/calendar", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n \n
\n

Pedidos de troca

\n
\n\n
\n\n
Ainda não há pedidos de troca.
\n\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/swaps", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Pedir troca · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n \n
\n
\n

Trocas

\n

Pedir troca

\n

Uma troca é entre duas equipas da mesma escala: cada uma fica com o turno da outra.

\n
\n \n
\n\n
\n\n
\n
\n

Rotação Phase 4

\n Janeiro 2030\n
\n
\n \">\n \n \n \n
\n
\n
\n\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/swaps/new?year=2030&month=1", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n \n
\n
\n

Janeiro 2030

\n
\n \n
\n\n
\n\n
\n
\n Ter\n 1\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qua\n 2\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qui\n 3\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sex\n 4\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sáb\n 5\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Sábados
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Rotação Phase 4
\n
\n Equipa Norte\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Dom\n 6\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Domingo
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Seg\n 7\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Ter\n 8\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qua\n 9\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qui\n 10\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sex\n 11\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sáb\n 12\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Sábados
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Rotação Phase 4
\n
\n Equipa Sul\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Dom\n 13\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Domingo
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Seg\n 14\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Ter\n 15\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qua\n 16\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qui\n 17\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sex\n 18\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sáb\n 19\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Sábados
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Rotação Phase 4
\n
\n Equipa Norte\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Dom\n 20\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Domingo
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Seg\n 21\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Ter\n 22\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qua\n 23\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qui\n 24\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sex\n 25\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Sáb\n 26\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Sábados
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Rotação Phase 4
\n
\n Equipa Sul\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Dom\n 27\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n
Almoço - Domingo
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Seg\n 28\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Ter\n 29\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qua\n 30\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n
\n Qui\n 31\n
\n
\n \n
\n
\n
Noite
\n
\n Sem atribuição\n
\n
\n \n \n
\n \n
\n
\n\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/?year=2030&month=1", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Exportar escala · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n \n
\n
\n

Rota

\n

Exportar escala

\n

Escolhe uma escala e as datas. O PDF é descarregado numa única folha A4, com os dias e as equipas agrupados por mês.

\n
\n
\n
\n \n
\n \n
\n \n \n
\n

As datas inicial e final estão incluídas. Máximo de 12 meses; o tamanho do texto adapta-se ao intervalo.

\n
\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/export/schedule", + "status": 200 + }, + { + "body": "{\"detail\":\"É necessária a permissão de administrador\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/admin/users", + "status": 403 + }, + { + "body": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Contínuos CRR//Rota//PT\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:Contínuos CRR — Equipa Norte\r\nX-WR-CALDESC:Contínuos CRR — 1 de agosto de 2026 a 31 de março de 2027\r\nREFRESH-INTERVAL;VALUE=DURATION:PT12H\r\nX-PUBLISHED-TTL:PT12H\r\nX-WR-TIMEZONE:Europe/Lisbon\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Lisbon\r\nX-LIC-LOCATION:Europe/Lisbon\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0000\r\nTZOFFSETTO:+0100\r\nTZNAME:WEST\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0000\r\nTZNAME:WET\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nEND:VCALENDAR\r\n", + "headers": { + "cache-control": "private, max-age=300", + "content-disposition": "inline; filename=\"rota.ics\"", + "content-type": "text/calendar; charset=utf-8" + }, + "method": "GET", + "path": "/calendar/phase4-feed-a.ics", + "status": 200 + }, + { + "body": "{\"detail\":\"Calendário não encontrado\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/calendar/not-a-token.ics", + "status": 404 + }, + { + "body": "CONTÍNUOS CRR\nRotação Phase 4\nJaneiro 2030\n 5\nEquipa Norte\n12\nEquipa Sul\n19\nEquipa Norte\n26\nEquipa Sul\n", + "headers": { + "cache-control": "private, no-store", + "content-disposition": "attachment; filename=\"escala-4-2030-01-01-2030-01-31.pdf\"", + "content-type": "application/pdf" + }, + "method": "GET", + "path": "/export/schedule.pdf?schedule_id=4&start_date=2030-01-01&end_date=2030-01-31", + "status": 200 + }, + { + "body": "", + "headers": { + "location": "/crr/swaps" + }, + "method": "POST", + "path": "/assignments/1/swap", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/swaps" + }, + "method": "POST", + "path": "/swaps/1/accept", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/swaps" + }, + "method": "POST", + "path": "/swaps/1/approve", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/swaps" + }, + "method": "POST", + "path": "/swaps/1/revert", + "status": 303 + }, + { + "body": "\n\n\n \n \n \n Equipas · Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n
Troca revertida: os dois turnos voltaram a quem os tinha.
\n \n \n
\n
\n

Equipas

\n

Quem entra na rota. Uma equipa é quem fica com um turno, e pode ser uma pessoa ou várias. Os utilizadores que entram na aplicação criam-se em Utilizadores; aqui escolhe-se apenas quem entra para cada equipa.

\n
\n
\n
\n
\n

Adicionar equipa

\n \n
\n
\n \">\n
\n \n \n \n
\n
\n Utilizador (opcional)\n \n
\n \n
\n
\n
\n\n
\n Administrador de teste @phase4-admin\n
\n \">\n
\n \n \n \n
\n
\n Utilizador\n \n

O mesmo utilizador pode entrar em várias equipas: vê e controla os turnos de todas elas.

\n
\n \n \n
\n \n
\n
\n\n
\n Equipa Norte @member-a · partilhada com 1\n
\n \">\n
\n \n \n \n
\n
\n Utilizador\n \n

O mesmo utilizador pode entrar em várias equipas: vê e controla os turnos de todas elas.

\n
\n \n \n
\n \n
\n
\n\n
\n Equipa Sul @member-b\n
\n \">\n
\n \n \n \n
\n
\n Utilizador\n \n

O mesmo utilizador pode entrar em várias equipas: vê e controla os turnos de todas elas.

\n
\n \n \n
\n \n
\n
\n\n
\n Equipa partilhada @member-a · partilhada com 1\n
\n \">\n
\n \n \n \n
\n
\n Utilizador\n \n

O mesmo utilizador pode entrar em várias equipas: vê e controla os turnos de todas elas.

\n
\n \n \n
\n \n
\n
\n\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/teams", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Utilizadores · Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n \n
\n
\n

Utilizadores

\n

Cada utilizador tem utilizador e palavra-passe e pode entrar em várias equipas. Quem entra vê e controla os turnos de todas elas, e a administração é do utilizador, não da equipa.

\n
\n
\n
\n
\n

Adicionar utilizador

\n \n
\n
\n \">\n
\n \n \n \n
\n
\n Equipas (pode escolher várias)\n \n \n \n \n \n \n \n \n \n
\n \n
\n
\n
\n\n
\n @member-a Membro a · 2 equipa(s)\n
\n \">\n
\n \n \n \n
\n
\n Equipas que entra\n \n \n \n \n \n \n \n \n \n
\n \n \n
\n \n
\n \n

Equipas: Equipa Norte, Equipa partilhada

\n \n
\n\n
\n @member-b Membro b · 1 equipa(s)\n
\n \">\n
\n \n \n \n
\n
\n Equipas que entra\n \n \n \n \n \n \n \n \n \n
\n \n \n
\n \n
\n \n

Equipas: Equipa Sul

\n \n
\n\n
\n @phase4-admin Administrador de teste · administrador · 1 equipa(s)\n
\n \">\n
\n \n \n \n
\n
\n Equipas que entra\n \n \n \n \n \n \n \n \n \n
\n \n \n
\n \n
\n \n

Equipas: Administrador de teste

\n \n
\n\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/users", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n \n
\n
\n

Escalas

\n

Uma página por tipo de escala. A escala da noite é fixa e repete todos os meses; o almoço de sábado e o de domingo são rotações independentes.

\n
\n Atribuir por dia\n
\n
\n\n
\n

Exportar PDF

\n

Escolhe uma escala e as datas para descarregar a lista de dias e equipas numa única folha A4.

\n \n
\n \n
\n \n \n
\n

As datas inicial e final estão incluídas. Máximo de 12 meses; o tamanho do texto adapta-se ao intervalo.

\n
\n
\n
\n\n\n\n
\n
\n
\n

Noite

\n

Todos os dias · repete todos os meses · 20:30–00:00

\n
\n \n
\n \n

Padrão

\n \n \n
\n\n\n
\n
\n
\n

Almoço - Sábados

\n

Rotação · Sábado · 13:00–16:00

\n
\n \n
\n \n

Próximas datas

\n

sáb 10 out · sáb 17 out · sáb 24 out · sáb 31 out · sáb 07 nov · sáb 14 nov

\n

Ordem da rotação

\n
    \n
  1. Ainda não há membros.
  2. \n
\n \n
\n\n\n
\n
\n
\n

Almoço - Domingo

\n

Rotação · Domingo · 13:00–16:00

\n
\n \n
\n \n

Próximas datas

\n

dom 11 out · dom 18 out · dom 25 out · dom 01 nov · dom 08 nov · dom 15 nov

\n

Ordem da rotação

\n
    \n
  1. Ainda não há membros.
  2. \n
\n \n
\n\n\n
\n
\n
\n

Rotação Phase 4

\n

Rotação · Sábado · 12:00–14:00

\n
\n \n
\n \n

Próximas datas

\n

sáb 10 out · sáb 17 out · sáb 24 out · sáb 31 out · sáb 07 nov · sáb 14 nov

\n

Ordem da rotação

\n
    \n \n
  1. Equipa Norte
  2. \n \n
  3. Equipa Sul
  4. \n \n
\n \n
\n\n
\n

Calendário da equipa

\n

Um único endereço com a rota completa, para qualquer calendário.

\n

Adicionar ao calendário do telemóvel

\n
\n \n \n
\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/schedules", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n \n\n
\n
\n

Escalas

\n

Rotação Phase 4

\n

Rotação · Sábado · 12:00–14:00

\n
\n
\n \">\n \n \n \n \n \n
\n
\n
\n\n
\n
\n

Janeiro 2030

\n \n
\n
\n \">\n \n \n
\n

Deixa sem atribuição os turnos automáticos do mês e ano escolhidos e de todos os seguintes. Nenhum turno é apagado e as atribuições manuais e as trocas concluídas são mantidas. Use Limpar turnos antes de Aplicar rotação, para recalcular tudo a partir de um ponto.

\n
\n \">\n \n
\n \n \n \n \n \n \n \n \n \n
DataEquipaOrigemAção
sáb 05 janmanual
sáb 12 janmanual
sáb 19 jantroca
sáb 26 janautomático
\n
\n
\n \n
\n \">\n
\n \n
\n \">\n
\n \n
\n \">\n
\n \n
\n \">\n
\n \n
\n
\n

Próximas datas

\n

sáb 10 out · sáb 17 out · sáb 24 out · sáb 31 out · sáb 07 nov · sáb 14 nov

\n
\n
\n

Ordem da rotação

\n

Rotação independente, preenchida por ordem. Depois de alterar a ordem, use Aplicar rotação acima para atualizar os turnos automáticos deste mês.

\n
    \n \n
  1. \n Equipa Norte\n
    \n
    \">
    \n
    \">
    \n
    \">
    \n
    \n
  2. \n \n
  3. \n Equipa Sul\n
    \n
    \">
    \n
    \">
    \n
    \">
    \n
    \n
  4. \n \n
\n
\n \">\n \n \n
\n
\n\n
\n

Limpar atribuições

\n

Limpar atribuições a partir do mês de:

\n
\n \">\n \n \n \n
\n
\n
\n

Pedidos de troca

\n

Um administrador pode atribuir o turno a outra equipa, mesmo que nenhuma pessoa entre na aplicação por ela. Só se aplica se quem pediu a troca continuar a ser o titular do turno.

\n \n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/schedules/4?year=2030&month=1", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Atribuição por dia · Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n \n
\n
\n

Escalas

\n

Atribuição por dia

\n

Escolha o dia e a equipa que fica com cada turno. É uma atribuição direta, não uma troca.

\n
\n
\n \n
\n
\n\n\n\n
\n
\n

Sábado, 5 de janeiro de 2030

\n \n
\n \">\n \n
\n Noite\n · 20:30–00:00\n
\n \n Agora: sem atribuição\n \n
\n
\n
\n \n \n
\n
\n \n
\n \">\n \n
\n Almoço - Sábados\n · 13:00–16:00\n
\n \n Agora: sem atribuição\n \n
\n
\n
\n \n \n
\n
\n \n
\n \">\n \n
\n Rotação Phase 4\n · 12:00–14:00\n
\n \n Agora: Equipa Norte (manual)\n \n
\n
\n
\n \n \n
\n
\n \n
\n

Atribuir aqui fecha qualquer pedido de troca em aberto sobre esse turno e avisa a nova equipa e a anterior.

\n

«Automático» repõe a equipa que a escala dá a este dia — a ordem da rotação ou o padrão de noites — e volta a marcar o turno como automático.

\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/assign?on=2030-01-05", + "status": 200 + }, + { + "body": "\n\n\n \n \n \n Registo de e-mails · Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n \n

Registo de e-mails

«ignorado» significa que a equipa não tem e-mail, que tem as notificações desligadas ou que o SMTP não está configurado.

\n
\n\n
Data e horaEventoEquipaDestinatárioEstadoAssunto
07/10/2026 10:58Troca revertidaEquipa Sulb@example.comignoradoTroca de turno revertida
07/10/2026 10:58Troca revertidaEquipa Nortea@example.comignoradoTroca de turno revertida
07/10/2026 10:58Troca aprovadaEquipa Sulb@example.comignoradoTroca de turno aprovada
07/10/2026 10:58Troca aprovadaEquipa Nortea@example.comignoradoTroca de turno aprovada
07/10/2026 10:58Troca aceiteEquipa Nortea@example.comignoradoA sua troca de turno foi aceite
07/10/2026 10:58Troca aguarda aprovaçãoAdministrador de testeadmin@example.comignoradoUma troca de turno precisa de aprovação
07/10/2026 10:58Pedido de trocaEquipa Sulb@example.comignoradoTroca de turnos: sáb 05 jan por sáb 12 jan – Rotação Phase 4
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/notifications", + "status": 200 + }, + { + "body": "", + "headers": { + "location": "/crr/admin/schedules" + }, + "method": "GET", + "path": "/admin/month", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/admin/schedules/4?year=2030&month=1" + }, + "method": "POST", + "path": "/admin/schedules/4/apply-rotation", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/admin/schedules/4?year=2030&month=1" + }, + "method": "POST", + "path": "/admin/schedules/4/clear-from", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/admin/assign?on=2030-01-26" + }, + "method": "POST", + "path": "/admin/assign", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/admin/teams" + }, + "method": "POST", + "path": "/admin/teams", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/admin/teams" + }, + "method": "POST", + "path": "/admin/teams/5/update", + "status": 303 + }, + { + "body": "\n\n\n \n \n \n Remover Equipa editada · Contínuos CRR\n \n \n \n \n \n\n\n
\n \n
\n
\n \n
Equipa atualizada.
\n \n \n
\n
\n

Equipas

\n

Remover equipa

\n

Isto é definitivo e não pode ser desfeito.

\n
\n
\n
\n

Equipa editada

\n

Sem utilizador, apenas aparece na escala.

\n

Só é possível remover quem ainda não tem turnos atribuídos. Primeiro transfira os turnos desta equipa para outras; assim o registo de quem trabalhou cada data mantém-se.

\n

Ao remover, acontece o seguinte:

\n \n

Se só quiser que esta equipa deixe de receber turnos, desative-a em vez de a remover.

\n
\n
\n \">\n \n
\n Cancelar\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/admin/teams/5/delete", + "status": 200 + }, + { + "body": "", + "headers": { + "location": "/crr/admin/teams" + }, + "method": "POST", + "path": "/admin/teams/5/delete", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/admin/users" + }, + "method": "POST", + "path": "/admin/users", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/admin/users" + }, + "method": "POST", + "path": "/admin/users/4/update", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/admin/users" + }, + "method": "POST", + "path": "/admin/users/4/delete", + "status": 303 + }, + { + "body": "", + "headers": { + "location": "/crr/" + }, + "method": "POST", + "path": "/account", + "status": 303 + }, + { + "body": "\n\n\n \n \n \n A minha conta · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n
A nova palavra-passe é igual à atual.
\n \n \n
\n
\n

Rota

\n

A minha conta

\n

Membro a · @member-a

\n \n

Este utilizador entra em nome de 2 equipa(s): Equipa Norte, Equipa partilhada. Vê e controla os turnos de todas.

\n \n
\n
\n\n
\n

Alterar palavra-passe

\n
\n \">\n \n
\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/account", + "status": 200 + }, + { + "body": "", + "headers": { + "location": "/crr/" + }, + "method": "POST", + "path": "/account", + "status": 303 + }, + { + "body": "\n\n\n \n \n \n A minha conta · Contínuos CRR\n \n \n \n \n \n\n\n
\n
\n \n \"CRR\"\n Contínuos CRR\n \n \n \n
\n \n Membro a\n @member-a\n \n
\n \">\n \n
\n
\n \n
\n
\n
\n \n
Palavra-passe alterada.
\n \n \n
\n
\n

Rota

\n

A minha conta

\n

Membro a · @member-a

\n \n

Este utilizador entra em nome de 2 equipa(s): Equipa Norte, Equipa partilhada. Vê e controla os turnos de todas.

\n \n
\n
\n\n
\n

Alterar palavra-passe

\n
\n \">\n \n
\n
\n
\n\n
\n\n", + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "method": "GET", + "path": "/account", + "status": 200 + }, + { + "body": "", + "headers": { + "location": "/crr/login" + }, + "method": "POST", + "path": "/logout", + "status": 303 + }, + { + "body": "{\"detail\":\"É necessário iniciar sessão\"}", + "headers": { + "content-type": "application/json" + }, + "method": "GET", + "path": "/account", + "status": 401 + } + ], + "rows": { + "alembic_version": [ + { + "version_num": "0001_initial" + } + ], + "assignments": [ + { + "date": "2030-01-05", + "id": 1, + "note": "Revertida a troca #1 por Administrador de teste", + "schedule_id": 4, + "source": "manual", + "team_id": 2, + "updated_at": "" + }, + { + "date": "2030-01-12", + "id": 2, + "note": "Revertida a troca #1 por Administrador de teste", + "schedule_id": 4, + "source": "manual", + "team_id": 3, + "updated_at": "" + }, + { + "date": "2030-01-19", + "id": 3, + "note": null, + "schedule_id": 4, + "source": "swap", + "team_id": 2, + "updated_at": "" + }, + { + "date": "2030-01-26", + "id": 4, + "note": null, + "schedule_id": 4, + "source": "manual", + "team_id": 3, + "updated_at": "" + }, + { + "date": "2030-01-01", + "id": 5, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-02", + "id": 6, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-03", + "id": 7, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-04", + "id": 8, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-05", + "id": 9, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-06", + "id": 10, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-07", + "id": 11, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-08", + "id": 12, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-09", + "id": 13, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-10", + "id": 14, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-11", + "id": 15, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-12", + "id": 16, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-13", + "id": 17, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-14", + "id": 18, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-15", + "id": 19, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-16", + "id": 20, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-17", + "id": 21, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-18", + "id": 22, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-19", + "id": 23, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-20", + "id": 24, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-21", + "id": 25, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-22", + "id": 26, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-23", + "id": 27, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-24", + "id": 28, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-25", + "id": 29, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-26", + "id": 30, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-27", + "id": 31, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-28", + "id": 32, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-29", + "id": 33, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-30", + "id": 34, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-31", + "id": 35, + "note": null, + "schedule_id": 1, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-05", + "id": 36, + "note": null, + "schedule_id": 2, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-12", + "id": 37, + "note": null, + "schedule_id": 2, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-19", + "id": 38, + "note": null, + "schedule_id": 2, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-26", + "id": 39, + "note": null, + "schedule_id": 2, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-06", + "id": 40, + "note": null, + "schedule_id": 3, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-13", + "id": 41, + "note": null, + "schedule_id": 3, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-20", + "id": 42, + "note": null, + "schedule_id": 3, + "source": "generated", + "team_id": null, + "updated_at": "" + }, + { + "date": "2030-01-27", + "id": 43, + "note": null, + "schedule_id": 3, + "source": "generated", + "team_id": null, + "updated_at": "" + } + ], + "monthly_patterns": [], + "notification_logs": [ + { + "body": "Equipa Norte propõe trocar o turno de Rotação Phase 4 de 5 de janeiro de 2030 pelo seu turno de 12 de janeiro de 2030.\n\nTeste de troca\n\nAbrir a escala: http://testserver/swaps", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_requested", + "id": 1, + "recipient": "b@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Troca de turnos: sáb 05 jan por sáb 12 jan – Rotação Phase 4", + "team_id": 3 + }, + { + "body": "Equipa Sul aceitou a troca de Rotação Phase 4 de 5 de janeiro de 2030 por 12 de janeiro de 2030, pedida por Equipa Norte. Aprove em http://testserver/swaps", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_needs_approval", + "id": 2, + "recipient": "admin@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Uma troca de turno precisa de aprovação", + "team_id": 1 + }, + { + "body": "Equipa Sul aceitou o seu pedido. Está agora à espera da aprovação da gestão.", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_accepted", + "id": 3, + "recipient": "a@example.com", + "sent_at": null, + "status": "skipped", + "subject": "A sua troca de turno foi aceite", + "team_id": 2 + }, + { + "body": "Administrador de teste aprovou a troca de Rotação Phase 4: Equipa Sul fica com o turno de 5 de janeiro de 2030 e Equipa Norte com o de 12 de janeiro de 2030.", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_approved", + "id": 4, + "recipient": "a@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Troca de turno aprovada", + "team_id": 2 + }, + { + "body": "Administrador de teste aprovou a troca de Rotação Phase 4: Equipa Sul fica com o turno de 5 de janeiro de 2030 e Equipa Norte com o de 12 de janeiro de 2030.", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_approved", + "id": 5, + "recipient": "b@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Troca de turno aprovada", + "team_id": 3 + }, + { + "body": "Administrador de teste reverteu a troca de Rotação Phase 4. Equipa Norte volta a 5 de janeiro de 2030 e Equipa Sul volta a 12 de janeiro de 2030.\n\nVer a escala: http://testserver/", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_reverted", + "id": 6, + "recipient": "a@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Troca de turno revertida", + "team_id": 2 + }, + { + "body": "Administrador de teste reverteu a troca de Rotação Phase 4. Equipa Norte volta a 5 de janeiro de 2030 e Equipa Sul volta a 12 de janeiro de 2030.\n\nVer a escala: http://testserver/", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "swap_reverted", + "id": 7, + "recipient": "b@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Troca de turno revertida", + "team_id": 3 + }, + { + "body": "Já não está atribuído ao turno de Rotação Phase 4 a 26 de janeiro de 2030.\n\nVer a escala: http://testserver/", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "assignment_changed", + "id": 8, + "recipient": "b@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Escala atualizada: sáb 26 jan – Rotação Phase 4", + "team_id": 3 + }, + { + "body": "Está atribuído ao turno de Rotação Phase 4 a 26 de janeiro de 2030.\n\nVer a escala: http://testserver/", + "channel": "email", + "created_at": "", + "error": "SMTP_HOST não está configurado", + "event_type": "assignment_updated", + "id": 9, + "recipient": "b@example.com", + "sent_at": null, + "status": "skipped", + "subject": "Escala atualizada: sáb 26 jan – Rotação Phase 4", + "team_id": 3 + } + ], + "rotation_members": [ + { + "id": 1, + "position": 1, + "schedule_id": 4, + "team_id": 2 + }, + { + "id": 2, + "position": 2, + "schedule_id": 4, + "team_id": 3 + } + ], + "schedules": [ + { + "created_at": "", + "end_time": "00:00:00.000000", + "id": 1, + "is_active": 1, + "name": "Noite", + "requires_manager_approval": 1, + "rotation_anchor_date": null, + "schedule_type": "fixed", + "slug": "night", + "start_time": "20:30:00.000000", + "weekdays": "0,1,2,3,4,5,6" + }, + { + "created_at": "", + "end_time": "16:00:00.000000", + "id": 2, + "is_active": 1, + "name": "Almoço - Sábados", + "requires_manager_approval": 1, + "rotation_anchor_date": "2024-01-06", + "schedule_type": "rotation", + "slug": "saturday-lunch", + "start_time": "13:00:00.000000", + "weekdays": "5" + }, + { + "created_at": "", + "end_time": "16:00:00.000000", + "id": 3, + "is_active": 1, + "name": "Almoço - Domingo", + "requires_manager_approval": 1, + "rotation_anchor_date": "2024-01-07", + "schedule_type": "rotation", + "slug": "sunday-lunch", + "start_time": "13:00:00.000000", + "weekdays": "6" + }, + { + "created_at": "", + "end_time": "14:00:00.000000", + "id": 4, + "is_active": 1, + "name": "Rotação Phase 4", + "requires_manager_approval": 1, + "rotation_anchor_date": "2030-01-05", + "schedule_type": "rotation", + "slug": "phase4-rotation", + "start_time": "12:00:00.000000", + "weekdays": "5" + } + ], + "swap_requests": [ + { + "accepted_team_id": 3, + "assignment_id": 1, + "created_at": "", + "id": 1, + "message": "Teste de troca", + "requester_id": 2, + "status": "reverted", + "target_assignment_id": 2, + "target_team_id": 3, + "updated_at": "" + } + ], + "teams": [ + { + "calendar_token": null, + "created_at": "", + "email": "admin@example.com", + "id": 1, + "is_active": 1, + "name": "Administrador de teste", + "notify_email": 1, + "phone": null, + "user_id": 1 + }, + { + "calendar_token": "phase4-feed-a", + "created_at": "", + "email": "a@example.com", + "id": 2, + "is_active": 1, + "name": "Equipa Norte", + "notify_email": 1, + "phone": null, + "user_id": 2 + }, + { + "calendar_token": "phase4-feed-b", + "created_at": "", + "email": "b@example.com", + "id": 3, + "is_active": 1, + "name": "Equipa Sul", + "notify_email": 1, + "phone": null, + "user_id": 3 + }, + { + "calendar_token": "phase4-feed-c", + "created_at": "", + "email": "c@example.com", + "id": 4, + "is_active": 1, + "name": "Equipa partilhada", + "notify_email": 1, + "phone": null, + "user_id": 2 + } + ], + "users": [ + { + "created_at": "", + "id": 1, + "is_active": 1, + "is_admin": 1, + "name": "Administrador de teste", + "password_hash": "", + "username": "phase4-admin" + }, + { + "created_at": "", + "id": 2, + "is_active": 1, + "is_admin": 0, + "name": "Membro a", + "password_hash": "", + "username": "member-a" + }, + { + "created_at": "", + "id": 3, + "is_active": 1, + "is_admin": 0, + "name": "Membro b", + "password_hash": "", + "username": "member-b" + } + ] + } + } + } +} diff --git a/migration/phase4/structure.json b/migration/phase4/structure.json new file mode 100644 index 0000000..e5514fc --- /dev/null +++ b/migration/phase4/structure.json @@ -0,0 +1,464 @@ +{ + "routes": [ + { + "method": "GET", + "path": "/calendar/{token}.ics", + "function": "calendar_feed" + }, + { + "method": "GET", + "path": "/account", + "function": "account_page" + }, + { + "method": "POST", + "path": "/account", + "function": "account_password" + }, + { + "method": "GET", + "path": "/calendar", + "function": "calendar_page" + }, + { + "method": "GET", + "path": "/export/schedule", + "function": "export_schedule" + }, + { + "method": "GET", + "path": "/export/schedule.pdf", + "function": "download_schedule_pdf" + }, + { + "method": "GET", + "path": "/health", + "function": "health" + }, + { + "method": "GET", + "path": "/login", + "function": "login_page" + }, + { + "method": "POST", + "path": "/login", + "function": "login" + }, + { + "method": "POST", + "path": "/logout", + "function": "logout" + }, + { + "method": "GET", + "path": "/", + "function": "dashboard" + }, + { + "method": "GET", + "path": "/swaps", + "function": "swaps_page" + }, + { + "method": "GET", + "path": "/swaps/new", + "function": "new_swap_page" + }, + { + "method": "POST", + "path": "/swaps/new", + "function": "create_swap_for_chosen_shift" + }, + { + "method": "POST", + "path": "/assignments/{assignment_id}/swap", + "function": "create_swap" + }, + { + "method": "POST", + "path": "/swaps/{swap_id}/accept", + "function": "accept_swap" + }, + { + "method": "POST", + "path": "/swaps/{swap_id}/reject", + "function": "reject_swap" + }, + { + "method": "POST", + "path": "/swaps/{swap_id}/cancel", + "function": "cancel_swap" + }, + { + "method": "POST", + "path": "/swaps/{swap_id}/approve", + "function": "approve_swap" + }, + { + "method": "POST", + "path": "/swaps/{swap_id}/revert", + "function": "revert_swap" + }, + { + "method": "POST", + "path": "/swaps/{swap_id}/admin-reject", + "function": "admin_reject_swap" + }, + { + "method": "GET", + "path": "/admin/teams", + "function": "admin_teams" + }, + { + "method": "POST", + "path": "/admin/teams", + "function": "admin_create_team" + }, + { + "method": "POST", + "path": "/admin/teams/{team_id}/update", + "function": "admin_update_team" + }, + { + "method": "GET", + "path": "/admin/teams/{team_id}/delete", + "function": "admin_team_delete_page" + }, + { + "method": "POST", + "path": "/admin/teams/{team_id}/delete", + "function": "admin_team_delete" + }, + { + "method": "GET", + "path": "/admin/users", + "function": "admin_users" + }, + { + "method": "POST", + "path": "/admin/users", + "function": "admin_create_user" + }, + { + "method": "POST", + "path": "/admin/users/{user_id}/update", + "function": "admin_update_user" + }, + { + "method": "POST", + "path": "/admin/users/{user_id}/delete", + "function": "admin_user_delete" + }, + { + "method": "GET", + "path": "/admin/schedules", + "function": "admin_schedules" + }, + { + "method": "GET", + "path": "/admin/schedules/{schedule_id}", + "function": "admin_schedule" + }, + { + "method": "POST", + "path": "/admin/schedules/{schedule_id}/pattern", + "function": "admin_schedule_pattern" + }, + { + "method": "POST", + "path": "/admin/schedules/{schedule_id}/apply-pattern", + "function": "admin_apply_pattern" + }, + { + "method": "POST", + "path": "/admin/schedules/{schedule_id}/clear-from", + "function": "admin_clear_from" + }, + { + "method": "POST", + "path": "/admin/schedules/{schedule_id}/settings", + "function": "admin_schedule_settings" + }, + { + "method": "POST", + "path": "/admin/schedules/{schedule_id}/rotation", + "function": "admin_rotation_add" + }, + { + "method": "POST", + "path": "/admin/rotation/{member_id}/remove", + "function": "admin_rotation_remove" + }, + { + "method": "POST", + "path": "/admin/rotation/{member_id}/move", + "function": "admin_rotation_move" + }, + { + "method": "GET", + "path": "/admin/month", + "function": "admin_month" + }, + { + "method": "POST", + "path": "/admin/schedules/{schedule_id}/swaps/{swap_id}/assign", + "function": "admin_schedule_assign_swap" + }, + { + "method": "POST", + "path": "/admin/schedules/{schedule_id}/assignments", + "function": "admin_schedule_assignments" + }, + { + "method": "POST", + "path": "/admin/schedules/{schedule_id}/apply-rotation", + "function": "admin_apply_rotation" + }, + { + "method": "POST", + "path": "/admin/schedules/{schedule_id}/assignments/{assignment_id}/regenerate", + "function": "admin_assignment_regenerate" + }, + { + "method": "POST", + "path": "/admin/schedules/{schedule_id}/assignments/{assignment_id}/use-rotation", + "function": "admin_assignment_regenerate" + }, + { + "method": "GET", + "path": "/admin/assign", + "function": "admin_assign_day" + }, + { + "method": "POST", + "path": "/admin/assign", + "function": "admin_assign_team" + }, + { + "method": "GET", + "path": "/admin/notifications", + "function": "admin_notifications" + } + ], + "function_sha256": { + "_redirect": "f78bec5bb9fabde6e3b768a3a6d4383785db3d42a8e355517f52f8d00af1240a", + "_parse_time": "3a2c7134356e971c5f5c30f60df74ea6f2de74ae7eb097efcc49e2b599c33dc0", + "_current_user": "42bb53fff71dff01cf1455a85817a8975178bfb9faab6221e11eef56deb7df11", + "_require_user": "9b602a8921681453dd541d63af2fb5c1e59a0571398ae867e6f11037341dddcd", + "_require_admin": "5b64c94b267b2e073e5346314b697e0159719f926f86d6baf9ba25c852b14098", + "_user_teams": "cf4a74bc2fe491f76dfd99ad943398b90cb0490b3853d9a9759303759159d72b", + "_user_team_ids": "d643445a1676ea45ae29f3c4272e60f297fc7cab934eb904fc30d93174a4e10f", + "_user_label": "83c65b6ef7163866c30c4d0178143dc3723e00db7bfed515f73515878469d856", + "_csrf": "4be30b4c04bd4246f08fed3782ff0155eea4eb691032b6c0cd6e98211e9a7a7b", + "_check_csrf": "96b5b121cc98f9043f0fa41b4bb19b3bf700ad892702c24ea8b3bce1f0782121", + "_flash": "f319e7a8b69c1b1654887f3b7e7ac724a8286e9d6f8c8def05e49be3b1936b42", + "_context": "baa2d501f6e418152cbebc56f07dfe55eb35154829416a15ff7fa79ba2a3e467", + "_parse_month": "f364954e7acf36646e60bba89492d98d9a0b9474c1321c862db4afd369a44ed5", + "_next_dates": "f103e97d6350e7db09aa014c4cbcd4fcfcaadcf6b7c8dfe604eed07447a67740", + "_schedule_card": "e9f09d2c6a0695b219019b9ecdd4bebb3df9b4596020a5a8cd7e9b410392644d", + "_schedule_groups": "bdc5ede2e7cde69e09a1ac679b977cbf94414e0c56be16c92ad196e00de15ba5", + "_active_schedules": "d0fca80985d60f88ccc47c8b4ea603761812c35fc3bcc8f80253c387c0775eea", + "_all_schedules": "8fe6504cecab40a403132f13031277d61e276b337b5fee5b1fe686831dde9bf8", + "_get_schedule": "c658002d06bcba95b148651477f09a125aaa6982f6e260d9f0d67aac04f64728", + "_month_nav": "0a5a7f4fe43eefab3b1b4a86e71f2b87251371d571786cec7729f40976d0db6b", + "_calendar_token": "d33a7dbd8af12b55164b2c544c8f3a4c7527b359136d8937592a9d79578c0076", + "calendar_feed": "a9092ad9e9c3a713f8b12425e708c183723ac1704f57b1828583b11c77ad6f03", + "account_page": "eed96aa30d46aa92c42356a961cef63e3847e2ecc5d05740510dd3d413018a6c", + "account_password": "4545d86d3e2a1a2d2dbd266167b873900ffb82d981fc5d5d36d58faa5dc0ed6e", + "calendar_page": "5af6e6706128abfda036e1f40f5e0cc2196870f58a12b558aad0e298ff0e8da0", + "export_schedule": "55cf4b4cedf517ca639948c814bc073822bbf7cfd270c4b0f71b112a04ebbe5f", + "download_schedule_pdf": "6664e4a367234ccaaef21ac51c505bd7269df98672cb3e272a0f6c7de0f00d58", + "health": "53803ecc82d556044c97eaf1c54171a0f2cccb6bb9b4d0ec0dea11d30e2f8623", + "login_page": "465ec7327c51717a94fa4020bc3e4ca5d8af5d2f30352e87434f212ae999f054", + "login": "f0fa25a608d15dfaeec681697ddc9f5d49bfe302f7a5a017c4cee68d99c90db3", + "logout": "60430dd402ba181a2aa2da2f8716ea3f4581a7d68ffe9156724be0446b213ec5", + "dashboard": "2fe7dc610a557407ba095415126ecd02410041b061188fe680608c6798194888", + "swaps_page": "bb2bc978469a6e93f909d6a3c20af4aeaf5397c55013bc7edd85e7714ec9c020", + "_schedule_team_ids": "9c8759326172090b172c581384ce50c0ec12a2f2e3f252bbd24d587d66ed193f", + "_schedule_teams": "0eb69900958f5cd1e2d2790a22f66b499585c5e1b1a6e9f37e1becd26a6005c8", + "_schedule_teams_map": "9b6434331fb2a6fc0603ddf2c4c049fc506710a23609890af8f2c9df384c0726", + "_active_request": "df41ac832e5720158fbbb8b176979b3e50697dcdcef6ea123a7b7d28df153cc0", + "_swap_side": "08c5a56b48cf3a65e526b84c07efc890e902270d2945014cefc4fcf11c870efe", + "_swap_partner": "0faa38b24ae828587bab0da613b14f64046a743f347d65e07e45bb3d73b04ec4", + "new_swap_page": "62fb0393a48703ad89379e6c1e12fe30797a3970366f97cb209ccaadd741b45d", + "create_swap_for_chosen_shift": "e1d61736a3abd5f6781db7ff27dcc10dd150e608e3118f22b9108acbb1155a10", + "create_swap": "8d4046a547b2c977fe0d7a9e1d6befd032a4fefbf8a7a947cf1cd4f0bf46af4d", + "_create_swap": "64cb526a882e09a01945b960f87de7724cae7322206a0a5a65cafa472c352b17", + "_safe_back": "99eae35a7eaea120c822dfa8a6c73dd3628a177ea896b34adccbbaca94e92fa3", + "_reject_swap": "e9c09521a9b3dca0950bf61ee08dc5edefa9112619d37ecd0b8df1db5e18cd57", + "_rejectable": "6c86715704c166a334736e0cb79c5df74cddb39d42a91ee84b82d5e6b1ebaddd", + "_swap_ownership_holds": "3f73630e67f92ab23f7c6db4d02c4e83240b25b645fcf3f8ad26b0506249b9c6", + "_complete_swap": "507ee15bb500ce0f40f77e90b5f6c02c4e94b1d4c95ce5366a76994f923eca59", + "_accepting_team": "d65db64047442d79f4654985e0cf1aa15614570e7586eedda1f81376ca75a3db", + "_admin_teams": "841c05fa8ed4eef1426509763d55e950fd665f9f477fd97c95683b9439707699", + "accept_swap": "84d54fe1b19966de00e31d7db4ea85910053b6887ae1a31c89f4d3ccb125cb71", + "reject_swap": "fc96bdd9d2ac28f6c46fd9a314c918f17c44d963beda0dd15a8511490091aa83", + "cancel_swap": "f70e2ad1ea81e6e3a3de61341d2141a31b9ec2a5d7aa92060e1504b8f548b122", + "approve_swap": "fe63883c1247cdec87036e1093c8675a8dbbdf55a98cf891de8fd73fca23fe03", + "revert_swap": "c033fc0d5368da573ca33e41df6463c029fd96d61e2e36775149a98e221b79bf", + "admin_reject_swap": "ee3da8617187b8594a9f4e8d700c2dd2fe3fb71c7f4e64a3151acacd1838c1c2", + "admin_teams": "8a768f6b5e693318d37ea96e6f2f9ae05cbf0c87a785908ba79bf1338feda490", + "_users_by_id": "027ccd114b6404a77b73d99276a18758ff92a44f68ed7a0b5e4afa4fcfe97aca", + "admin_create_team": "b33dd7f9dac0a1aa9557d20b4eee6ffee38ab2cb49ed2e7f12d63ecb6ebc8d51", + "_chosen_user": "baa8d0dcc33d5cde5b0c9f236ea483508d11763f462033680703894d845d2f44", + "admin_update_team": "a214c0ce72227a0b2865a0a8a9dad73e6703ac4bc8f675903b01a4d0d436faab", + "_loses_last_team": "d23d67a6a08920b6eb31f7671f90eb0c1c2078f139182a8e17452ca035cda286", + "_team_removal_impact": "2881010e02c6b9cc55103d2f1a104222f113652f67c864e97a8dd2fbfcbff9a5", + "_usable_admin_users": "699592d85a158c406d747f042f58d35668cfa70f71800ed8ab78066c371f92ee", + "_team_removal_blocker": "54371319c371902d06dff4f21fc22c95470af2b44f222fb9c07a1e26a722c351", + "_require_removable": "789c50862c3c058de4f88cf97e84a34de77d5bd846ad5982761490e58988e6c5", + "admin_team_delete_page": "2eb324bf154ddcb0efbd732637a9aa16da7694e0d2354bc9869463d0b2bd9fa2", + "admin_team_delete": "8202aeaaa7c478a874f6d5431b8e08c2c876b1adcb27c90c1b805d57de09f974", + "admin_users": "ab6dfe526dce24d96c369e0602ac8207ef5af570d9b2765ce8b3fcb6b285e1e4", + "_user_removal_blocker": "66d9034be0907e2d142faa2b4cbc116e2f1a366fbaa44881ecd1a4f3781374b0", + "admin_create_user": "2b08d3f2cb2ece36612585d1096b0c81e2b35d8bb39a1985cfaac8c03f2b14bb", + "_new_user": "6998933630f0044d5a9254ce1dc9ae8ac11dcb010c8ada965158ff114d82acf5", + "_assign_user_to_teams": "cf2c8ab64e7bdccf149f7545c09a889ccd9d0383aa8a79fc3d0412dbba62edf7", + "admin_update_user": "320453b7a8a37ce03d01763b3192037df8b8149da4a9b4ab911d8a4a1dd8aea2", + "admin_user_delete": "b35988c6b835b8a88d67ec87ae9fe76ba80badc4057a975fd383d6dae7573cb0", + "admin_schedules": "456005bcb0cce35f16dc22d77cf9cab459814697da0fa1b71ed80b06abe6d571", + "_render_admin_schedules": "75c2260c9410ef8dec1fe023e7bca6ef6c719d2357eb90a9e05065d672f85bbc", + "_shared_calendar_url": "8cdc4d40e4286372570ee2ecd0d263ae31a6252700d3815ba1665f714c0885a2", + "admin_schedule": "8749d7d1e1964e73d91da3594e333d75bf7aa1e65beb44b001e4e3331c8b5e63", + "_open_swap_requests": "f095afa9428aed7f1af9d524fd05b118a99518b919a9880089c1a9cf6b9b0e9b", + "_pattern_map": "8b94d9b9c5c57adfe91ae5287abfafaa260b888dae77b3d76f3e6d3d98360d6a", + "admin_schedule_pattern": "fc11cb71db195140b36f33a6115ef63f0f9251c3b8b581c5988e1d4955c3a41e", + "_apply_pattern_to_open_rows": "24076e69176477dd74da34c7f69a1342bef292e5ad0f42732674edda5b98f0d3", + "_apply_pattern_forward": "50ad2b7df7843ec36efc3c61d4a7252d63d4121c3944d5e735f2160a421676f5", + "admin_apply_pattern": "a71528fe2456fe20201e97df6b936ca0a7d7edc59a5950b30ca8db57168d5d62", + "_clear_assignments_from": "573dbe74cb5555869db83a92968b5280d37cf9f0e5d72194b81d7a866c3e9403", + "admin_clear_from": "ed1cc3f147a1318c8594edbce9785b13206ca5dbd8c8c3d560e07418dc2cf646", + "admin_schedule_settings": "aa33b00fcebeebe288ea69c480f254c0e4fd92fe60cd2f0a366370df26cac576", + "admin_rotation_add": "6d688b41898fc4be7a6823b65fe51964e97971dcf9381ee5104d02f0a79b48a8", + "admin_rotation_remove": "83ee03662a0e1e4eb0138e8312f347c694e066dff2959953f9395b4ffb53586d", + "admin_rotation_move": "c140ad14df549d198b96e4f667b419eb9c4679d863ef4f8f5f8319310dff2924", + "admin_month": "eb75e48f5f33f84ade39b9f6a117d3b43c7fd340938d0618c33c418d1ae986d0", + "_save_assignments": "6fbc815ff286c0500cec0860939aedea5876d5acffa4255f3c870d8740371685", + "_default_team": "6730fd6dbed43670b09335d507eef9e3ebfd0df7038912cfa28e7d926b97abb3", + "_set_assignee": "6661e42e0b623187b9a94f1967bf02e50471ec1f6ff4f9a402170dfdc1dada4f", + "_notify_assignment_changes": "29c60bf23d25aa77aaf770388f0a6fb8558216c8737182970b7331fb12ce8e33", + "_month_assignments": "922fa4b0c0df6a77641168d2d2dbab5437977524c12958d5bc89f7289e4119d5", + "admin_schedule_assign_swap": "36c2cb1e871bcecd42fab64807d967f5e7c906fbb93763eda086b7a636315f15", + "_schedule_page": "3c6cf42c5c0182d017ddff4c48e54c32292a6074cded9a8e923179eaf9bf1d26", + "admin_schedule_assignments": "f7d833d769a4a7967da81c25267080be799414b1d26459dbae8bcf5e37cb970a", + "_apply_rotation_to_rows": "a2a02c003b9b9e4fc64023898bcc98c5aa5173be6fa8b86f8c023e2a9b5e35c6", + "_generate_rotation_range": "5a3c32c3e8a0066436fb34f55a8345a0ad36633462bfc2c8c00af07f99938068", + "admin_apply_rotation": "d5c45d851ba1929c2fa637101f13bcb972c59a2c213a0a51f7e12721599b5275", + "admin_assignment_regenerate": "41082e4adcf257240ef5520268f8c611f2836fdddd6e5908564331c894a639bd", + "admin_assign_day": "f14c110ae7654bcdcda09f5a2462bbd96fced63f216ff4b6b13d97370ed7d3b6", + "admin_assign_team": "9b76661f576260bb5325c242dd21c6c543756327b4d8416c2f8c0471adb44b88", + "_assign_day_url": "351850c9e71cc95cc25579bf3a7e145d284c5883ab749b8da49cbd21aa59cff1", + "admin_notifications": "c9c76ee7f89a0c0d233dd29390bdeacf0b4b57bfae4a996c6aa0044f923ce886" + }, + "ownership": { + "_redirect": "app.routers.dependencies", + "_current_user": "app.routers.dependencies", + "_require_user": "app.routers.dependencies", + "_require_admin": "app.routers.dependencies", + "_user_teams": "app.routers.dependencies", + "_user_team_ids": "app.routers.dependencies", + "_user_label": "app.routers.dependencies", + "_csrf": "app.routers.dependencies", + "_check_csrf": "app.routers.dependencies", + "_flash": "app.routers.dependencies", + "_context": "app.routers.dependencies", + "_parse_month": "app.routers.dependencies", + "_month_nav": "app.routers.dependencies", + "_safe_back": "app.routers.dependencies", + "_next_dates": "app.routers.schedule_helpers", + "_schedule_card": "app.routers.schedule_helpers", + "_schedule_groups": "app.routers.schedule_helpers", + "_active_schedules": "app.routers.schedule_helpers", + "_all_schedules": "app.routers.schedule_helpers", + "_get_schedule": "app.routers.schedule_helpers", + "_schedule_team_ids": "app.routers.schedule_helpers", + "_schedule_teams": "app.routers.schedule_helpers", + "_schedule_teams_map": "app.routers.schedule_helpers", + "_render_admin_schedules": "app.routers.schedule_helpers", + "_shared_calendar_url": "app.routers.schedule_helpers", + "_pattern_map": "app.routers.schedule_helpers", + "_active_request": "app.routers.swap_helpers", + "_swap_side": "app.routers.swap_helpers", + "_swap_partner": "app.routers.swap_helpers", + "_reject_swap": "app.routers.swap_helpers", + "_rejectable": "app.routers.swap_helpers", + "_swap_ownership_holds": "app.routers.swap_helpers", + "_complete_swap": "app.routers.swap_helpers", + "_accepting_team": "app.routers.swap_helpers", + "_apply_pattern_to_open_rows": "app.routers.assignment_helpers", + "_apply_pattern_forward": "app.routers.assignment_helpers", + "_clear_assignments_from": "app.routers.assignment_helpers", + "_save_assignments": "app.routers.assignment_helpers", + "_default_team": "app.routers.assignment_helpers", + "_set_assignee": "app.routers.assignment_helpers", + "_notify_assignment_changes": "app.routers.assignment_helpers", + "_month_assignments": "app.routers.assignment_helpers", + "_apply_rotation_to_rows": "app.routers.assignment_helpers", + "_generate_rotation_range": "app.routers.assignment_helpers", + "_admin_teams": "app.routers.admin.user_helpers", + "_usable_admin_users": "app.routers.admin.user_helpers", + "_users_by_id": "app.routers.admin.teams", + "_chosen_user": "app.routers.admin.teams", + "_loses_last_team": "app.routers.admin.teams", + "_team_removal_impact": "app.routers.admin.teams", + "_team_removal_blocker": "app.routers.admin.teams", + "_require_removable": "app.routers.admin.teams", + "_user_removal_blocker": "app.routers.admin.users", + "_new_user": "app.routers.admin.users", + "_assign_user_to_teams": "app.routers.admin.users", + "_parse_time": "app.routers.admin.schedules", + "_open_swap_requests": "app.routers.admin.schedules", + "_schedule_page": "app.routers.admin.assignments", + "_assign_day_url": "app.routers.admin.assignments", + "_calendar_token": "app.routers.calendar", + "_create_swap": "app.routers.swaps", + "calendar_feed": "app.routers.calendar", + "account_page": "app.routers.account", + "account_password": "app.routers.account", + "calendar_page": "app.routers.calendar", + "export_schedule": "app.routers.exports", + "download_schedule_pdf": "app.routers.exports", + "health": "app.routers.dashboard", + "login_page": "app.routers.auth", + "login": "app.routers.auth", + "logout": "app.routers.auth", + "dashboard": "app.routers.dashboard", + "swaps_page": "app.routers.swaps", + "new_swap_page": "app.routers.swaps", + "create_swap_for_chosen_shift": "app.routers.swaps", + "create_swap": "app.routers.swaps", + "accept_swap": "app.routers.swaps", + "reject_swap": "app.routers.swaps", + "cancel_swap": "app.routers.swaps", + "approve_swap": "app.routers.swaps", + "revert_swap": "app.routers.swaps", + "admin_reject_swap": "app.routers.swaps", + "admin_teams": "app.routers.admin.teams", + "admin_create_team": "app.routers.admin.teams", + "admin_update_team": "app.routers.admin.teams", + "admin_team_delete_page": "app.routers.admin.teams", + "admin_team_delete": "app.routers.admin.teams", + "admin_users": "app.routers.admin.users", + "admin_create_user": "app.routers.admin.users", + "admin_update_user": "app.routers.admin.users", + "admin_user_delete": "app.routers.admin.users", + "admin_schedules": "app.routers.admin.schedules", + "admin_schedule": "app.routers.admin.schedules", + "admin_schedule_pattern": "app.routers.admin.schedules", + "admin_apply_pattern": "app.routers.admin.assignments", + "admin_clear_from": "app.routers.admin.assignments", + "admin_schedule_settings": "app.routers.admin.schedules", + "admin_rotation_add": "app.routers.admin.schedules", + "admin_rotation_remove": "app.routers.admin.schedules", + "admin_rotation_move": "app.routers.admin.schedules", + "admin_month": "app.routers.admin.schedules", + "admin_schedule_assign_swap": "app.routers.admin.assignments", + "admin_schedule_assignments": "app.routers.admin.assignments", + "admin_apply_rotation": "app.routers.admin.assignments", + "admin_assignment_regenerate": "app.routers.admin.assignments", + "admin_assign_day": "app.routers.admin.assignments", + "admin_assign_team": "app.routers.admin.assignments", + "admin_notifications": "app.routers.admin.notifications" + } +} diff --git a/migration/phase4/verification.json b/migration/phase4/verification.json new file mode 100644 index 0000000..d81f0e9 --- /dev/null +++ b/migration/phase4/verification.json @@ -0,0 +1,14 @@ +{ + "definitions_preserved": 173, + "orm_mapping_and_relationships_unchanged": true, + "openapi_unchanged": true, + "migration_files_unchanged": true, + "existing_fixture_rows_preserved_at_startup": true, + "workflow_requests_matched": 96, + "deployment_prefixes": [ + "", + "/crr" + ], + "fixture_sha256": "95b06188ce5076a17b842523c6a969358cdd48e30221de4292e6628f40c9b292", + "baseline_sha256": "2ecd64d83d35d482f8eed7f46b65754f6db30d34c48a06943c0b9bd11ddf2b59" +} diff --git a/tools/refactor_shifts_structure.py b/tools/refactor_shifts_structure.py new file mode 100644 index 0000000..c0ebec0 --- /dev/null +++ b/tools/refactor_shifts_structure.py @@ -0,0 +1,201 @@ +"""One-time structural generator: relocate definitions verbatim and wire explicit imports. + +It only creates new production modules. Existing callers and retired files are +updated separately, so no source snapshot or application work is overwritten. +""" + +from __future__ import annotations + +import ast +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "apps/shifts/backend/app" +REPORT = ROOT / "migration/phase4" + +HELPERS = { + "dependencies": "_redirect _current_user _require_user _require_admin _user_teams _user_team_ids _user_label _csrf _check_csrf _flash _context _parse_month _month_nav _safe_back", + "schedule_helpers": "_next_dates _schedule_card _schedule_groups _active_schedules _all_schedules _get_schedule _schedule_team_ids _schedule_teams _schedule_teams_map _render_admin_schedules _shared_calendar_url _pattern_map", + "swap_helpers": "_active_request _swap_side _swap_partner _reject_swap _rejectable _swap_ownership_holds _complete_swap _accepting_team", + "assignment_helpers": "_apply_pattern_to_open_rows _apply_pattern_forward _clear_assignments_from _save_assignments _default_team _set_assignee _notify_assignment_changes _month_assignments _apply_rotation_to_rows _generate_rotation_range", + "admin.user_helpers": "_admin_teams _usable_admin_users", + "admin.teams": "_users_by_id _chosen_user _loses_last_team _team_removal_impact _team_removal_blocker _require_removable", + "admin.users": "_user_removal_blocker _new_user _assign_user_to_teams", + "admin.schedules": "_parse_time _open_swap_requests", + "admin.assignments": "_schedule_page _assign_day_url", + "calendar": "_calendar_token", + "swaps": "_create_swap", +} +MODEL_MODULES = {"User": "user", "Team": "team", "Schedule": "schedule", + "MonthlyPattern": "schedule", "RotationMember": "schedule", + "Assignment": "assignment", "SwapRequest": "swap", "NotificationLog": "notification"} +SERVICE_PATHS = {"app.calendar": "app.services.calendar_feed", "app.pdf": "app.services.pdf", + "app.notifications": "app.services.notifications", "app.scheduling": "app.services.scheduling", + "app.bootstrap": "app.services.bootstrap"} + + +def segment(text: str, node: ast.AST) -> str: + start = min([node.lineno, *(item.lineno for item in getattr(node, "decorator_list", []))]) + return "\n".join(text.splitlines()[start - 1:node.end_lineno]) + + +def loaded(nodes) -> set[str]: + return {item.id for node in nodes for item in ast.walk(node) if isinstance(item, ast.Name) and isinstance(item.ctx, ast.Load)} + + +def relative_import(caller: str, target: str, names: list[str]) -> str: + package, target_parts = caller.split(".")[:-1], target.split(".") + common = 0 + while common < min(len(package), len(target_parts)) and package[common] == target_parts[common]: + common += 1 + module = "." * (len(package) - common + 1) + ".".join(target_parts[common:]) + return f"from {module} import {', '.join(sorted(names))}" + + +def imports_for(tree: ast.Module, names: set[str], caller: str) -> list[str]: + imports = [] + for node in tree.body: + if isinstance(node, ast.Import): + aliases = [item for item in node.names if (item.asname or item.name.split('.')[0]) in names] + if aliases: + imports.append(ast.unparse(ast.Import(names=aliases))) + elif isinstance(node, ast.ImportFrom) and node.module != "__future__": + aliases = [item for item in node.names if (item.asname or item.name) in names] + if aliases: + if node.level: + target = "app." + node.module + target = SERVICE_PATHS.get(target, target) + imports.append(relative_import(caller, target, [item.name for item in aliases])) + else: + imports.append(ast.unparse(ast.ImportFrom(module=node.module, names=aliases, level=0))) + return imports + + +def route_owner(path: str) -> str: + if path in {"/login", "/logout"}: + return "auth" + if path == "/account": + return "account" + if path.startswith("/calendar"): + return "calendar" + if path.startswith("/export/"): + return "exports" + if path in {"/", "/health"}: + return "dashboard" + if path.startswith("/swaps") or path.startswith("/assignments/"): + return "swaps" + if path.startswith("/admin/teams"): + return "admin.teams" + if path.startswith("/admin/users"): + return "admin.users" + if path == "/admin/notifications": + return "admin.notifications" + if path == "/admin/assign" or any(value in path for value in ("/assignments", "/swaps/", "/apply-pattern", "/clear-from", "/apply-rotation")): + return "admin.assignments" + if path.startswith(("/admin/schedules", "/admin/rotation")) or path == "/admin/month": + return "admin.schedules" + raise ValueError(f"Unmapped route: {path}") + + +def web_modules() -> tuple[dict[Path, str], dict]: + text = (APP / "web.py").read_text() + tree = ast.parse(text) + definitions = {node.name: node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))} + ownership = {name: f"app.routers.{module}" for module, names in HELPERS.items() for name in names.split()} + routes = [] + for name, node in definitions.items(): + decorators = [item for item in node.decorator_list if isinstance(item, ast.Call) and isinstance(item.func, ast.Attribute) and isinstance(item.func.value, ast.Name) and item.func.value.id == "router"] + if decorators: + owners = {route_owner(item.args[0].value) for item in decorators} + assert len(owners) == 1, name + ownership[name] = "app.routers." + owners.pop() + routes.extend({"method": item.func.attr.upper(), "path": item.args[0].value, "function": name} for item in decorators) + assert definitions.keys() == ownership.keys(), (definitions.keys() - ownership.keys(), ownership.keys() - definitions.keys()) + outputs, graph = {}, {} + for module in sorted(set(ownership.values())): + nodes = [node for name, node in definitions.items() if ownership[name] == module] + names = loaded(nodes) + imports = imports_for(tree, names | ({"APIRouter"} if "router" in names else set()), module) + dependencies = {} + for name in names & definitions.keys(): + owner = ownership[name] + if owner != module: + dependencies.setdefault(owner, []).append(name) + graph[module] = set(dependencies) + imports.extend(relative_import(module, owner, names) for owner, names in sorted(dependencies.items())) + content = "from __future__ import annotations\n\n" + "\n".join(imports) + "\n\n" + if "router" in names: + content += "router = APIRouter()\n\n" + if "ALL_WEEKDAYS" in names: + content += "ALL_WEEKDAYS = frozenset(range(7))\n\n" + content += "\n\n\n".join(segment(text, node) for node in nodes) + "\n" + outputs[APP / (module.removeprefix("app.").replace(".", "/") + ".py")] = content + def visit(module, stack): + assert module not in stack, f"Circular route-helper dependency: {stack + [module]}" + for dependency in graph[module]: + visit(dependency, stack + [module]) + for module in graph: + visit(module, []) + ordered = ["auth", "account", "calendar", "exports", "dashboard", "swaps", "admin.teams", "admin.users", "admin.schedules", "admin.assignments", "admin.notifications"] + outputs[APP / "routers/__init__.py"] = "from fastapi import APIRouter\n\n" + "\n".join(f"from .{name} import router as {name.replace('.', '_')}_router" for name in ordered) + "\n\nrouter = APIRouter()\nfor domain_router in (\n" + "".join(f" {name.replace('.', '_')}_router,\n" for name in ordered) + "):\n router.include_router(domain_router)\n" + outputs[APP / "routers/admin/__init__.py"] = "" + fingerprints = {name: hashlib.sha256(ast.dump(node, include_attributes=False).encode()).hexdigest() for name, node in definitions.items()} + return outputs, {"routes": routes, "function_sha256": fingerprints, "ownership": ownership} + + +def model_modules() -> dict[Path, str]: + text = (APP / "models.py").read_text() + tree = ast.parse(text) + classes = {node.name: node for node in tree.body if isinstance(node, ast.ClassDef)} + assert classes.keys() == MODEL_MODULES.keys() + outputs = {} + for name in sorted(set(MODEL_MODULES.values())): + module = "app.models." + name + nodes = [node for key, node in classes.items() if MODEL_MODULES[key] == name] + names = loaded(nodes) + imports = imports_for(tree, names, module) + references = {key: value for key, value in MODEL_MODULES.items() if key in names and value != name} + if references: + imports.append("from typing import TYPE_CHECKING") + content = "from __future__ import annotations\n\n" + "\n".join(imports) + "\n\n" + if references: + content += "if TYPE_CHECKING:\n" + "\n".join(f" from .{target} import {key}" for key, target in sorted(references.items())) + "\n\n" + content += "\n\n\n".join(segment(text, node) for node in nodes) + "\n" + outputs[APP / f"models/{name}.py"] = content + outputs[APP / "models/__init__.py"] = "from ..database import Base\n" + "\n".join(relative_import("app.models.__init__", f"app.models.{name}", [key for key, owner in MODEL_MODULES.items() if owner == name]) for name in ["user", "team", "schedule", "assignment", "swap", "notification"]) + "\n\n__all__ = " + repr(["Base", *MODEL_MODULES]) + "\n" + return outputs + + +def security_modules() -> dict[Path, str]: + text = (APP / "security.py").read_text() + tree = ast.parse(text) + nodes = [node for node in tree.body if not isinstance(node, (ast.Import, ast.ImportFrom))] + outputs = {} + for name in ["passwords", "csrf"]: + selected = [node for node in nodes if (isinstance(node, ast.FunctionDef) and node.name == "new_csrf_token") == (name == "csrf")] + imports = imports_for(tree, loaded(selected), f"app.security.{name}") + outputs[APP / f"security/{name}.py"] = "from __future__ import annotations\n\n" + "\n".join(imports) + "\n\n" + "\n\n\n".join(segment(text, node) for node in selected) + "\n" + outputs[APP / "security/__init__.py"] = "from .csrf import new_csrf_token\nfrom .passwords import ITERATIONS, MIN_PASSWORD_LENGTH, hash_password, verify_password\n\n__all__ = ['ITERATIONS', 'MIN_PASSWORD_LENGTH', 'hash_password', 'verify_password', 'new_csrf_token']\n" + return outputs + + +def main(): + outputs, manifest = web_modules() + outputs.update(model_modules()) + outputs.update(security_modules()) + for path in outputs: + if path.exists(): + raise FileExistsError(f"Refusing to overwrite {path}") + for path, text in outputs.items(): + ast.parse(text) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + REPORT.mkdir(parents=True, exist_ok=True) + (REPORT / "structure.json").write_text(json.dumps(manifest, indent=2) + "\n") + print(f"Generated {len(outputs)} modules; {len(manifest['routes'])} routes and all function bodies preserved verbatim.") + + +if __name__ == "__main__": + main()