feat: add calendar subscription token functionality and ICS generation

- Introduced a new database model for calendar subscription tokens, allowing users to generate unique tokens for accessing their personal calendar.
- Implemented API endpoint to return ICS files containing only the subscribing user's duties, enhancing user experience with personalized calendar access.
- Added utility functions for generating ICS files from user duties, ensuring proper formatting and timezone handling.
- Updated command handlers to support the new calendar link feature, providing users with easy access to their personal calendar subscriptions.
- Included unit tests for the new functionality, ensuring reliability and correctness of token generation and ICS file creation.
This commit is contained in:
2026-02-19 17:04:22 +03:00
parent 4afd0ca5cc
commit dc116270b7
14 changed files with 501 additions and 12 deletions

View File

@@ -0,0 +1,46 @@
"""Calendar subscription tokens table
Revision ID: 005
Revises: 004
Create Date: 2025-02-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "005"
down_revision: Union[str, None] = "004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"calendar_subscription_tokens",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("token_hash", sa.Text(), nullable=False),
sa.Column("created_at", sa.Text(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"token_hash", name="uq_calendar_subscription_tokens_token_hash"
),
)
op.create_index(
"ix_calendar_subscription_tokens_token_hash",
"calendar_subscription_tokens",
["token_hash"],
unique=True,
)
def downgrade() -> None:
op.drop_index(
"ix_calendar_subscription_tokens_token_hash",
table_name="calendar_subscription_tokens",
)
op.drop_table("calendar_subscription_tokens")

View File

@@ -19,7 +19,7 @@ Requires-Dist: httpx<1.0,>=0.27; extra == "dev"
# Duty Teller (Telegram Bot)
A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 with the `Application` API.
A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 with the `Application` API. The bot and web UI support **Russian and English** (language from Telegram or `DEFAULT_LANGUAGE`).
## Get a bot token
@@ -60,10 +60,14 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co
6. **Optional env**
- `DATABASE_URL` DB connection (default: `sqlite:///data/duty_teller.db`).
- `HTTP_PORT` HTTP server port (default: `8080`).
- `MINI_APP_BASE_URL` Base URL of the miniapp (for documentation / CORS).
- `MINI_APP_SKIP_AUTH` Set to `1` to allow `/api/duties` without Telegram initData (dev only; insecure).
- `INIT_DATA_MAX_AGE_SECONDS` Reject Telegram initData older than this (e.g. `86400` = 24h). `0` = disabled (default).
- `CORS_ORIGINS` Comma-separated allowed origins for CORS, or leave unset for `*`.
- `CORS_ORIGINS` Comma-separated allowed origins for CORS; leave unset for `*`.
- `ALLOWED_PHONES` / `ADMIN_PHONES` Access by phone (user sets via `/set_phone`); comma-separated; comparison uses digits only.
- `DUTY_DISPLAY_TZ` Timezone for the pinned duty message in groups (e.g. `Europe/Moscow`).
- `DEFAULT_LANGUAGE` Default language when user language is unknown: `en` or `ru` (default in code: `en`).
- `EXTERNAL_CALENDAR_ICS_URL` URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap «i» on a cell to see the event summary. Empty = no external calendar.
## Run
@@ -80,6 +84,14 @@ duty-teller
The bot runs in polling mode. Send `/start` or `/help` to your bot in Telegram to test.
## Bot commands
- **`/start`** — Greeting and user registration in the database.
- **`/help`** — Help on available commands.
- **`/set_phone [number]`** — Set or clear phone number (private chat only); used for access via `ALLOWED_PHONES` / `ADMIN_PHONES`.
- **`/import_duty_schedule`** — Import duty schedule (only for `ADMIN_USERNAMES` / `ADMIN_PHONES`); see «Импорт расписания» below for the two-step flow.
- **`/pin_duty`** — Pin the current duty message in a group (reply to the bots duty message); time/timezone for the pinned message come from `DUTY_DISPLAY_TZ`.
## Run with Docker
Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`.
@@ -96,16 +108,28 @@ Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`.
```
For production deployments you may use Docker secrets or your orchestrators env instead of a `.env` file.
The image is built from `Dockerfile`; on start, `entrypoint.sh` runs Alembic migrations then starts the app as user `botuser`.
**Production behind a reverse proxy:** When the app is behind nginx/Caddy etc., `request.client.host` is usually the proxy (e.g. 127.0.0.1). The "private IP" bypass (allowing requests without initData from localhost) then applies to the proxy, not the real client. Either ensure the Mini App always sends initData, or forward the real client IP (e.g. `X-Forwarded-For`) and use it for that check. See `api/app.py` `_is_private_client` for details.
## API
The HTTP server is FastAPI; the miniapp is served at `/app`.
- **`GET /api/duties`** — List of duties (date params; auth via Telegram initData or, in dev, `MINI_APP_SKIP_AUTH` / private IP).
- **`GET /api/calendar-events`** — Calendar events (including external ICS when `EXTERNAL_CALENDAR_ICS_URL` is set).
For production, initData validation is required; see the reverse-proxy paragraph above for proxy/headers.
## Project layout
- `main.py` Entry point: builds the `Application`, registers handlers, runs polling and FastAPI in a thread. Calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
- `main.py` Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
- `duty_teller/` Main package (install with `pip install -e .`). Contains:
- `config.py` Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in `main` when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
- `config.py` Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
- `api/` FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
- `db/` SQLAlchemy models, session (`session_scope`), repository, schemas.
- `handlers/` Telegram command and chat handlers; register via `register_handlers(app)`.
- `i18n/` Translations and language detection (ru/en); used by handlers and API.
- `services/` Business logic (group duty pin, import); accept session from caller.
- `utils/` Shared date, user, and handover helpers.
- `importers/` Duty-schedule JSON parser.
@@ -133,7 +157,7 @@ To add commands, define async handlers in `duty_teller/handlers/commands.py` (or
## Tests
Install dev dependencies and run pytest:
Run from the repository root (no `src/` directory; package is `duty_teller` at the root). Use `PYTHONPATH=.` if needed:
```bash
pip install -r requirements-dev.txt
@@ -141,3 +165,5 @@ pytest
```
Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config` (is_admin, can_access_miniapp), and the API (date validation, 403/200 with mocked auth, plus an E2E auth test without auth mocks).
**CI (Gitea Actions):** Lint (ruff), tests (pytest), security (bandit). If the workflow uses `PYTHONPATH: src` or `bandit -r src`, update it to match the repo layout (no `src/`).

View File

@@ -13,6 +13,7 @@ duty_teller/api/__init__.py
duty_teller/api/app.py
duty_teller/api/calendar_ics.py
duty_teller/api/dependencies.py
duty_teller/api/personal_calendar_ics.py
duty_teller/api/telegram_auth.py
duty_teller/db/__init__.py
duty_teller/db/models.py
@@ -37,10 +38,12 @@ duty_teller/utils/dates.py
duty_teller/utils/handover.py
duty_teller/utils/user.py
tests/test_app.py
tests/test_calendar_token_repository.py
tests/test_config.py
tests/test_duty_schedule_parser.py
tests/test_i18n.py
tests/test_import_duty_schedule_integration.py
tests/test_personal_calendar_ics.py
tests/test_repository_duty_range.py
tests/test_telegram_auth.py
tests/test_utils.py

View File

@@ -1,10 +1,12 @@
"""FastAPI app: /api/duties and static webapp."""
import logging
from datetime import date, timedelta
import duty_teller.config as config
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
@@ -15,6 +17,8 @@ from duty_teller.api.dependencies import (
get_validated_dates,
require_miniapp_username,
)
from duty_teller.api.personal_calendar_ics import build_personal_ics
from duty_teller.db.repository import get_duties_for_user, get_user_by_calendar_token
from duty_teller.db.schemas import CalendarEvent, DutyWithUser
log = logging.getLogger(__name__)
@@ -57,6 +61,31 @@ def list_calendar_events(
return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events]
@app.get("/api/calendar/ical/{token}.ics")
def get_personal_calendar_ical(
token: str,
session: Session = Depends(get_db_session),
) -> Response:
"""
Return ICS calendar with only the subscribing user's duties.
No Telegram auth; access is by secret token in the URL.
"""
user = get_user_by_calendar_token(session, token)
if user is None:
return Response(status_code=404, content="Not found")
today = date.today()
from_date = (today - timedelta(days=365)).strftime("%Y-%m-%d")
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
duties_with_name = get_duties_for_user(
session, user.id, from_date=from_date, to_date=to_date
)
ics_bytes = build_personal_ics(duties_with_name)
return Response(
content=ics_bytes,
media_type="text/calendar; charset=utf-8",
)
webapp_path = config.PROJECT_ROOT / "webapp"
if webapp_path.is_dir():
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")

View File

@@ -0,0 +1,57 @@
"""Generate ICS calendar containing only one user's duties (for subscription link)."""
from datetime import datetime, timezone
from icalendar import Calendar, Event
from duty_teller.db.models import Duty
# Summary labels by event_type (duty | unavailable | vacation)
SUMMARY_BY_TYPE: dict[str, str] = {
"duty": "Duty",
"unavailable": "Unavailable",
"vacation": "Vacation",
}
def _parse_utc_iso(iso_str: str) -> datetime:
"""Parse ISO 8601 UTC string (e.g. 2025-01-15T09:00:00Z) to timezone-aware datetime."""
s = iso_str.strip().rstrip("Z")
if "Z" in s:
s = s.replace("Z", "+00:00")
else:
s = s + "+00:00"
return datetime.fromisoformat(s)
def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:
"""
Build a single VCALENDAR with one VEVENT per duty.
duties_with_name: list of (Duty, full_name); full_name is unused for SUMMARY
if we use event_type only; can be used later for DESCRIPTION.
"""
cal = Calendar()
cal.add("prodid", "-//Duty Teller//Personal Calendar//EN")
cal.add("version", "2.0")
cal.add("calscale", "GREGORIAN")
for duty, _full_name in duties_with_name:
event = Event()
start_dt = _parse_utc_iso(duty.start_at)
end_dt = _parse_utc_iso(duty.end_at)
# Ensure timezone-aware for icalendar
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=timezone.utc)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=timezone.utc)
event.add("dtstart", start_dt)
event.add("dtend", end_dt)
summary = SUMMARY_BY_TYPE.get(
duty.event_type if duty.event_type else "duty", "Duty"
)
event.add("summary", summary)
event.add("uid", f"duty-{duty.id}@duty-teller")
event.add("dtstamp", datetime.now(timezone.utc))
cal.add_component(event)
return cal.to_ical()

View File

@@ -26,6 +26,19 @@ class User(Base):
duties: Mapped[list["Duty"]] = relationship("Duty", back_populates="user")
class CalendarSubscriptionToken(Base):
"""One active calendar subscription token per user; token_hash is unique."""
__tablename__ = "calendar_subscription_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False
)
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
created_at: Mapped[str] = mapped_column(Text, nullable=False)
class Duty(Base):
__tablename__ = "duties"

View File

@@ -1,10 +1,13 @@
"""Repository: get_or_create_user, get_duties, insert_duty, get_current_duty, group_duty_pins."""
from datetime import datetime, timedelta
import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import Session
from duty_teller.db.models import User, Duty, GroupDutyPin
from duty_teller.db.models import User, Duty, GroupDutyPin, CalendarSubscriptionToken
def get_user_by_telegram_id(session: Session, telegram_user_id: int) -> User | None:
@@ -98,6 +101,74 @@ def get_duties(
return list(q.all())
def get_duties_for_user(
session: Session,
user_id: int,
from_date: str,
to_date: str,
) -> list[tuple[Duty, str]]:
"""Return list of (Duty, full_name) for the given user overlapping the date range."""
to_date_next = (
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
).strftime("%Y-%m-%d")
q = (
session.query(Duty, User.full_name)
.join(User, Duty.user_id == User.id)
.filter(
Duty.user_id == user_id,
Duty.start_at < to_date_next,
Duty.end_at >= from_date,
)
)
return list(q.all())
def _token_hash(token: str) -> str:
"""Return SHA256 hex digest of the token (constant-time comparison via hmac)."""
return hashlib.sha256(token.encode()).hexdigest()
def create_calendar_token(session: Session, user_id: int) -> str:
"""
Create a new calendar subscription token for the user.
Removes any existing tokens for this user. Returns the raw token string.
"""
session.query(CalendarSubscriptionToken).filter(
CalendarSubscriptionToken.user_id == user_id
).delete(synchronize_session=False)
raw_token = secrets.token_urlsafe(32)
token_hash_val = _token_hash(raw_token)
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
record = CalendarSubscriptionToken(
user_id=user_id,
token_hash=token_hash_val,
created_at=now_iso,
)
session.add(record)
session.commit()
return raw_token
def get_user_by_calendar_token(session: Session, token: str) -> User | None:
"""
Find user by calendar subscription token. Uses constant-time comparison.
Returns None if token is invalid or not found.
"""
token_hash_val = _token_hash(token)
row = (
session.query(CalendarSubscriptionToken, User)
.join(User, CalendarSubscriptionToken.user_id == User.id)
.filter(CalendarSubscriptionToken.token_hash == token_hash_val)
.first()
)
if row is None:
return None
# Constant-time compare to avoid timing leaks (token_hash is already hashed).
if not hmac.compare_digest(row[0].token_hash, token_hash_val):
return None
return row[1]
def insert_duty(
session: Session,
user_id: int,

View File

@@ -9,6 +9,7 @@ def register_handlers(app: Application) -> None:
app.add_handler(commands.start_handler)
app.add_handler(commands.help_handler)
app.add_handler(commands.set_phone_handler)
app.add_handler(commands.calendar_link_handler)
app.add_handler(import_duty_schedule.import_duty_schedule_handler)
app.add_handler(import_duty_schedule.handover_time_handler)
app.add_handler(import_duty_schedule.duty_schedule_document_handler)

View File

@@ -7,7 +7,11 @@ from telegram import Update
from telegram.ext import CommandHandler, ContextTypes
from duty_teller.db.session import session_scope
from duty_teller.db.repository import get_or_create_user, set_user_phone
from duty_teller.db.repository import (
get_or_create_user,
set_user_phone,
create_calendar_token,
)
from duty_teller.i18n import get_lang, t
from duty_teller.utils.user import build_full_name
@@ -82,6 +86,55 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(t(lang, "set_phone.cleared"))
async def calendar_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Send personal calendar subscription URL (private chat only, access check)."""
if not update.message or not update.effective_user:
return
lang = get_lang(update.effective_user)
if update.effective_chat and update.effective_chat.type != "private":
await update.message.reply_text(t(lang, "calendar_link.private_only"))
return
telegram_user_id = update.effective_user.id
username = (update.effective_user.username or "").strip()
full_name = build_full_name(
update.effective_user.first_name, update.effective_user.last_name
)
def do_calendar_link() -> tuple[str | None, str | None]:
with session_scope(config.DATABASE_URL) as session:
user = get_or_create_user(
session,
telegram_user_id=telegram_user_id,
full_name=full_name,
username=update.effective_user.username,
first_name=update.effective_user.first_name,
last_name=update.effective_user.last_name,
)
if not config.can_access_miniapp(
username
) and not config.can_access_miniapp_by_phone(user.phone):
return (None, "denied")
token = create_calendar_token(session, user.id)
base = (config.MINI_APP_BASE_URL or "").rstrip("/")
url = f"{base}/api/calendar/ical/{token}.ics" if base else None
return (url, None)
result_url, error = await asyncio.get_running_loop().run_in_executor(
None, do_calendar_link
)
if error == "denied":
await update.message.reply_text(t(lang, "calendar_link.access_denied"))
return
if not result_url:
await update.message.reply_text(t(lang, "calendar_link.error"))
return
await update.message.reply_text(
t(lang, "calendar_link.success", url=result_url)
+ "\n\n"
+ t(lang, "calendar_link.help_hint")
)
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.message or not update.effective_user:
return
@@ -91,6 +144,7 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
t(lang, "help.start"),
t(lang, "help.help"),
t(lang, "help.set_phone"),
t(lang, "help.calendar_link"),
t(lang, "help.pin_duty"),
]
if config.is_admin(update.effective_user.username or ""):
@@ -101,3 +155,4 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
start_handler = CommandHandler("start", start)
help_handler = CommandHandler("help", help_cmd)
set_phone_handler = CommandHandler("set_phone", set_phone)
calendar_link_handler = CommandHandler("calendar_link", calendar_link)

View File

@@ -11,7 +11,13 @@ MESSAGES: dict[str, dict[str, str]] = {
"help.start": "/start — Start",
"help.help": "/help — Show this help",
"help.set_phone": "/set_phone — Set or clear phone for duty display",
"help.calendar_link": "/calendar_link — Get personal calendar subscription link (private chat)",
"help.pin_duty": "/pin_duty — In a group: pin the duty message (bot needs admin with Pin messages)",
"calendar_link.private_only": "The /calendar_link command is only available in private chat.",
"calendar_link.access_denied": "Access denied.",
"calendar_link.success": "Your personal calendar URL:\n{url}",
"calendar_link.help_hint": "Subscribe to this URL in Google Calendar, Apple Calendar, or Outlook to see only your duties.",
"calendar_link.error": "Could not generate link. Please try again later.",
"help.import_schedule": "/import_duty_schedule — Import duty schedule (JSON)",
"errors.generic": "An error occurred. Please try again later.",
"pin_duty.group_only": "The /pin_duty command works only in groups.",
@@ -48,7 +54,13 @@ MESSAGES: dict[str, dict[str, str]] = {
"help.start": "/start — Начать",
"help.help": "/help — Показать эту справку",
"help.set_phone": "/set_phone — Указать или очистить телефон для отображения в дежурстве",
"help.calendar_link": "/calendar_link — Получить ссылку на персональную подписку календаря (только в личке)",
"help.pin_duty": "/pin_duty — В группе: закрепить сообщение о дежурстве (нужны права админа у бота)",
"calendar_link.private_only": "Команда /calendar_link доступна только в личке.",
"calendar_link.access_denied": "Доступ запрещён.",
"calendar_link.success": "Ссылка на ваш календарь:\n{url}",
"calendar_link.help_hint": "Подпишитесь на эту ссылку в Google Календаре, Календаре Apple или Outlook, чтобы видеть только свои дежурства.",
"calendar_link.error": "Не удалось сформировать ссылку. Попробуйте позже.",
"help.import_schedule": "/import_duty_schedule — Импорт расписания дежурств (JSON)",
"errors.generic": "Произошла ошибка. Попробуйте позже.",
"pin_duty.group_only": "Команда /pin_duty работает только в группах.",

View File

@@ -248,3 +248,50 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
assert len(data) == 1
assert data[0]["event_type"] == "duty"
assert data[0]["full_name"] == "User A"
@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_404_unknown_token(mock_get_user, client):
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404."""
mock_get_user.return_value = None
r = client.get("/api/calendar/ical/unknown-token-xyz.ics")
assert r.status_code == 404
assert "not found" in r.text.lower()
mock_get_user.assert_called_once()
@patch("duty_teller.api.app.build_personal_ics")
@patch("duty_teller.api.app.get_duties_for_user")
@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_200_returns_only_that_users_duties(
mock_get_user, mock_get_duties, mock_build_ics, client
):
"""GET /api/calendar/ical/{token}.ics returns ICS with only the token owner's duties."""
from types import SimpleNamespace
mock_user = SimpleNamespace(id=1, full_name="User A")
mock_get_user.return_value = mock_user
duty = SimpleNamespace(
id=10,
user_id=1,
start_at="2026-06-15T09:00:00Z",
end_at="2026-06-15T18:00:00Z",
event_type="duty",
)
mock_get_duties.return_value = [(duty, "User A")]
mock_build_ics.return_value = (
b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR"
)
r = client.get("/api/calendar/ical/valid-token.ics")
assert r.status_code == 200
assert r.headers.get("content-type", "").startswith("text/calendar")
assert b"BEGIN:VCALENDAR" in r.content
mock_get_user.assert_called_once()
mock_get_duties.assert_called_once_with(ANY, 1, from_date=ANY, to_date=ANY)
mock_build_ics.assert_called_once()
# Only User A's duty was passed to build_personal_ics
duties_arg = mock_build_ics.call_args[0][0]
assert len(duties_arg) == 1
assert duties_arg[0][0].user_id == 1
assert duties_arg[0][1] == "User A"

View File

@@ -0,0 +1,67 @@
"""Unit tests for calendar subscription token repository functions."""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from duty_teller.db.models import Base
from duty_teller.db.repository import (
create_calendar_token,
get_user_by_calendar_token,
get_or_create_user_by_full_name,
)
@pytest.fixture
def session():
"""In-memory SQLite session with all tables (including calendar_subscription_tokens)."""
engine = create_engine(
"sqlite:///:memory:", connect_args={"check_same_thread": False}
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
s = Session()
try:
yield s
finally:
s.close()
@pytest.fixture
def user_a(session):
"""Create one user."""
u = get_or_create_user_by_full_name(session, "User A")
return u
def test_create_calendar_token_returns_token_string(session, user_a):
"""create_calendar_token returns a non-empty URL-safe string."""
token = create_calendar_token(session, user_a.id)
assert isinstance(token, str)
assert len(token) > 0
assert " " not in token
def test_get_user_by_calendar_token_valid_returns_user(session, user_a):
"""Valid token returns the correct user."""
token = create_calendar_token(session, user_a.id)
user = get_user_by_calendar_token(session, token)
assert user is not None
assert user.id == user_a.id
assert user.full_name == "User A"
def test_get_user_by_calendar_token_invalid_returns_none(session, user_a):
"""Invalid or unknown token returns None."""
create_calendar_token(session, user_a.id)
assert get_user_by_calendar_token(session, "wrong-token") is None
assert get_user_by_calendar_token(session, "") is None
def test_create_calendar_token_one_active_per_user(session, user_a):
"""Creating a new token removes the previous one for the same user."""
token1 = create_calendar_token(session, user_a.id)
token2 = create_calendar_token(session, user_a.id)
assert token1 != token2
assert get_user_by_calendar_token(session, token1) is None
assert get_user_by_calendar_token(session, token2).id == user_a.id

View File

@@ -0,0 +1,60 @@
"""Unit tests for personal calendar ICS generation."""
from types import SimpleNamespace
from icalendar import Calendar as ICalendar
from duty_teller.api.personal_calendar_ics import build_personal_ics
# Minimal Duty-like object for build_personal_ics(duties_with_name: list[tuple[Duty, str]])
def _duty(id_, start_at, end_at, event_type="duty"):
return (
SimpleNamespace(
id=id_, start_at=start_at, end_at=end_at, event_type=event_type
),
"Test User",
)
def test_build_personal_ics_empty_returns_valid_calendar():
"""Empty list produces VCALENDAR with no VEVENTs."""
ics = build_personal_ics([])
assert ics.startswith(b"BEGIN:VCALENDAR")
assert ics.rstrip().endswith(b"END:VCALENDAR")
cal = ICalendar.from_ical(ics)
assert cal is not None
events = [c for c in cal.walk() if c.name == "VEVENT"]
assert len(events) == 0
def test_build_personal_ics_one_duty():
"""One duty produces one VEVENT with correct DTSTART/DTEND/SUMMARY."""
duties = [
_duty(1, "2025-02-20T09:00:00Z", "2025-02-20T18:00:00Z", "duty"),
]
ics = build_personal_ics(duties)
cal = ICalendar.from_ical(ics)
assert cal is not None
events = [c for c in cal.walk() if c.name == "VEVENT"]
assert len(events) == 1
ev = events[0]
assert ev.get("summary") == "Duty"
assert "2025-02-20" in str(ev.get("dtstart").dt)
assert "2025-02-20" in str(ev.get("dtend").dt)
assert b"duty-1@duty-teller" in ics or "duty-1@duty-teller" in ics.decode("utf-8")
def test_build_personal_ics_event_types():
"""Unavailable and vacation get correct SUMMARY."""
duties = [
_duty(1, "2025-02-20T09:00:00Z", "2025-02-21T09:00:00Z", "unavailable"),
_duty(2, "2025-02-25T00:00:00Z", "2025-03-01T00:00:00Z", "vacation"),
]
ics = build_personal_ics(duties)
cal = ICalendar.from_ical(ics)
events = [c for c in cal.walk() if c.name == "VEVENT"]
assert len(events) == 2
summaries = [str(ev.get("summary")) for ev in events]
assert "Unavailable" in summaries
assert "Vacation" in summaries

View File

@@ -91,25 +91,27 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
}
if (unavailableList.length > 0) {
const uniqueUnavailable = [...new Set(unavailableList.map((d) => d.full_name || "").filter(Boolean))];
html +=
'<section class="day-detail-section day-detail-section--unavailable">' +
'<h3 class="day-detail-section-title">' +
escapeHtml(t(lang, "event_type.unavailable")) +
"</h3><ul class=\"day-detail-list\">";
unavailableList.forEach((d) => {
html += "<li>" + escapeHtml(d.full_name || "") + "</li>";
uniqueUnavailable.forEach((name) => {
html += "<li>" + escapeHtml(name) + "</li>";
});
html += "</ul></section>";
}
if (vacationList.length > 0) {
const uniqueVacation = [...new Set(vacationList.map((d) => d.full_name || "").filter(Boolean))];
html +=
'<section class="day-detail-section day-detail-section--vacation">' +
'<h3 class="day-detail-section-title">' +
escapeHtml(t(lang, "event_type.vacation")) +
"</h3><ul class=\"day-detail-list\">";
vacationList.forEach((d) => {
html += "<li>" + escapeHtml(d.full_name || "") + "</li>";
uniqueVacation.forEach((name) => {
html += "<li>" + escapeHtml(name) + "</li>";
});
html += "</ul></section>";
}