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:
46
alembic/versions/005_calendar_subscription_tokens.py
Normal file
46
alembic/versions/005_calendar_subscription_tokens.py
Normal 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")
|
||||
@@ -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 bot’s 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 orchestrator’s 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/`).
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
57
duty_teller/api/personal_calendar_ics.py
Normal file
57
duty_teller/api/personal_calendar_ics.py
Normal 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()
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 работает только в группах.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
67
tests/test_calendar_token_repository.py
Normal file
67
tests/test_calendar_token_repository.py
Normal 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
|
||||
60
tests/test_personal_calendar_ics.py
Normal file
60
tests/test_personal_calendar_ics.py
Normal 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
|
||||
@@ -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>";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user