diff --git a/.dockerignore b/.dockerignore index 1ebb544..ca3c9c9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,3 +10,9 @@ __pycache__/ *.plan.md data/ .curosr/ +# Tests and dev artifacts (not needed in image) +tests/ +.pytest_cache/ +conftest.py +*.cover +htmlcov/ diff --git a/Dockerfile b/Dockerfile index cf49edd..e106af1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,11 +20,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends gosu \ COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages COPY --from=builder /usr/local/bin /usr/local/bin -# Application code -COPY config.py main.py alembic.ini entrypoint.sh ./ -COPY db/ ./db/ -COPY api/ ./api/ -COPY handlers/ ./handlers/ +# Application code (duty_teller package + entrypoint, migrations, webapp) +ENV PYTHONPATH=/app +COPY main.py alembic.ini entrypoint.sh ./ +COPY duty_teller/ ./duty_teller/ COPY alembic/ ./alembic/ COPY webapp/ ./webapp/ diff --git a/README.md b/README.md index 204b9aa..12a40db 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,12 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co python main.py ``` +Or after `pip install -e .`: + +```bash +duty-teller +``` + The bot runs in polling mode. Send `/start` or `/help` to your bot in Telegram to test. ## Run with Docker @@ -75,18 +81,21 @@ Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`. ## Project layout -- `main.py` – Builds the `Application`, registers handlers, runs polling and FastAPI in a thread. -- `config.py` – Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, `ADMIN_USERNAMES`, `CORS_ORIGINS`, etc. from env; exits if `BOT_TOKEN` is missing. Optional `Settings` dataclass for tests. -- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), auth/session Depends, static webapp mount. -- `db/` – SQLAlchemy models, session (use `session_scope` for all DB access), repository, schemas. One `DATABASE_URL` per process; set env before first import if you need a different URL in tests. -- `handlers/` – Telegram command and chat handlers; thin layer that call services and utils. -- `services/` – Business logic (group duty pin, import); accept session from caller. -- `utils/` – Shared date, user, and handover helpers. -- `alembic/` – Migrations (use `config.DATABASE_URL`). +- `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. +- `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. + - `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)`. + - `services/` – Business logic (group duty pin, import); accept session from caller. + - `utils/` – Shared date, user, and handover helpers. + - `importers/` – Duty-schedule JSON parser. +- `alembic/` – Migrations (use `duty_teller.config.DATABASE_URL` and `duty_teller.db.models.Base`). - `webapp/` – Miniapp UI (calendar, duty list); served at `/app`. -- `pyproject.toml` – Installable package (`pip install -e .`); `requirements.txt` – pinned deps. +- `tests/` – Tests; `helpers.py` provides `make_init_data` for auth tests. +- `pyproject.toml` – Installable package (`pip install -e .`). -To add commands, define async handlers in `handlers/commands.py` (or a new module) and register them in `handlers/__init__.py`. +To add commands, define async handlers in `duty_teller/handlers/commands.py` (or a new module) and register them in `duty_teller/handlers/__init__.py`. ## Импорт расписания дежурств (duty-schedule) diff --git a/alembic/env.py b/alembic/env.py index 0fe9bf6..6734dd9 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,4 +1,4 @@ -"""Alembic env: use config DATABASE_URL and db.models.Base.""" +"""Alembic env: use duty_teller config DATABASE_URL and db.models.Base.""" import os import sys @@ -9,8 +9,8 @@ from alembic import context sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -import config -from db.models import Base +import duty_teller.config as config +from duty_teller.db.models import Base config_alembic = context.config if config_alembic.config_file_name is not None: diff --git a/conftest.py b/conftest.py index 363c9d1..60fc989 100644 --- a/conftest.py +++ b/conftest.py @@ -1,7 +1,6 @@ -"""Pytest configuration. Set BOT_TOKEN so config module can be imported.""" +"""Pytest configuration. BOT_TOKEN no longer required at import; set for tests that need it.""" import os -# Set before any project code imports config (which requires BOT_TOKEN). if not os.environ.get("BOT_TOKEN"): os.environ["BOT_TOKEN"] = "test-token-for-pytest" diff --git a/duty_teller/__init__.py b/duty_teller/__init__.py new file mode 100644 index 0000000..788122b --- /dev/null +++ b/duty_teller/__init__.py @@ -0,0 +1,8 @@ +"""Duty Teller: Telegram bot for team duty shift calendar and group reminder.""" + +from importlib.metadata import version, PackageNotFoundError + +try: + __version__ = version("duty-teller") +except PackageNotFoundError: + __version__ = "0.1.0" diff --git a/api/__init__.py b/duty_teller/api/__init__.py similarity index 100% rename from api/__init__.py rename to duty_teller/api/__init__.py diff --git a/duty_teller/api/app.py b/duty_teller/api/app.py new file mode 100644 index 0000000..7b895c9 --- /dev/null +++ b/duty_teller/api/app.py @@ -0,0 +1,62 @@ +"""FastAPI app: /api/duties and static webapp.""" + +import logging + +import duty_teller.config as config +from fastapi import Depends, FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from sqlalchemy.orm import Session + +from duty_teller.api.calendar_ics import get_calendar_events +from duty_teller.api.dependencies import ( + fetch_duties_response, + get_db_session, + get_validated_dates, + require_miniapp_username, +) +from duty_teller.db.schemas import CalendarEvent, DutyWithUser + +log = logging.getLogger(__name__) + +app = FastAPI(title="Duty Teller API") +app.add_middleware( + CORSMiddleware, + allow_origins=config.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/api/duties", response_model=list[DutyWithUser]) +def list_duties( + request: Request, + dates: tuple[str, str] = Depends(get_validated_dates), + _username: str = Depends(require_miniapp_username), + session: Session = Depends(get_db_session), +): + from_date_val, to_date_val = dates + log.info( + "GET /api/duties from %s", + request.client.host if request.client else "?", + ) + return fetch_duties_response(session, from_date_val, to_date_val) + + +@app.get("/api/calendar-events", response_model=list[CalendarEvent]) +def list_calendar_events( + dates: tuple[str, str] = Depends(get_validated_dates), + _username: str = Depends(require_miniapp_username), +): + from_date_val, to_date_val = dates + url = config.EXTERNAL_CALENDAR_ICS_URL + if not url: + return [] + events = get_calendar_events(url, from_date=from_date_val, to_date=to_date_val) + return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events] + + +webapp_path = config.PROJECT_ROOT / "webapp" +if webapp_path.is_dir(): + app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp") diff --git a/api/calendar_ics.py b/duty_teller/api/calendar_ics.py similarity index 100% rename from api/calendar_ics.py rename to duty_teller/api/calendar_ics.py diff --git a/api/app.py b/duty_teller/api/dependencies.py similarity index 51% rename from api/app.py rename to duty_teller/api/dependencies.py index b6cbe6d..4449081 100644 --- a/api/app.py +++ b/duty_teller/api/dependencies.py @@ -1,27 +1,22 @@ -"""FastAPI app: /api/duties and static webapp.""" +"""FastAPI dependencies: DB session, auth, date validation.""" import logging -from pathlib import Path from typing import Annotated, Generator -import config -from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles +from fastapi import Header, HTTPException, Query, Request from sqlalchemy.orm import Session -from db.session import session_scope -from db.repository import get_duties -from db.schemas import DutyWithUser, CalendarEvent -from api.telegram_auth import validate_init_data_with_reason -from api.calendar_ics import get_calendar_events -from utils.dates import validate_date_range +import duty_teller.config as config +from duty_teller.api.telegram_auth import validate_init_data_with_reason +from duty_teller.db.repository import get_duties +from duty_teller.db.schemas import DutyWithUser +from duty_teller.db.session import session_scope +from duty_teller.utils.dates import validate_date_range log = logging.getLogger(__name__) def _validate_duty_dates(from_date: str, to_date: str) -> None: - """Raise HTTPException 400 if dates are invalid or from_date > to_date.""" try: validate_date_range(from_date, to_date) except ValueError as e: @@ -32,13 +27,11 @@ def get_validated_dates( from_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="from"), to_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="to"), ) -> tuple[str, str]: - """FastAPI dependency: validate from_date/to_date and return (from_date, to_date). Raises 400 if invalid.""" _validate_duty_dates(from_date, to_date) return (from_date, to_date) def get_db_session() -> Generator[Session, None, None]: - """FastAPI dependency: yield a DB session from session_scope.""" with session_scope(config.DATABASE_URL) as session: yield session @@ -49,34 +42,10 @@ def require_miniapp_username( str | None, Header(alias="X-Telegram-Init-Data") ] = None, ) -> str: - """FastAPI dependency: return authenticated username or raise 403.""" return get_authenticated_username(request, x_telegram_init_data) -def _fetch_duties_response( - session: Session, from_date: str, to_date: str -) -> list[DutyWithUser]: - """Fetch duties in range and return list of DutyWithUser.""" - rows = get_duties(session, from_date=from_date, to_date=to_date) - return [ - DutyWithUser( - id=duty.id, - user_id=duty.user_id, - start_at=duty.start_at, - end_at=duty.end_at, - full_name=full_name, - event_type=( - duty.event_type - if duty.event_type in ("duty", "unavailable", "vacation") - else "duty" - ), - ) - for duty, full_name in rows - ] - - def _auth_error_detail(auth_reason: str) -> str: - """Return user-facing detail message for 403 when initData validation fails.""" if auth_reason == "hash_mismatch": return ( "Неверная подпись. Убедитесь, что BOT_TOKEN на сервере совпадает с токеном бота, " @@ -86,21 +55,12 @@ def _auth_error_detail(auth_reason: str) -> str: def _is_private_client(client_host: str | None) -> bool: - """True if client is localhost or private LAN (dev / same-machine access). - - Note: Behind a reverse proxy (e.g. nginx, Caddy), request.client.host is often - the proxy address (e.g. 127.0.0.1). Then "private client" would be true for all - requests when initData is missing. For production, either rely on the Mini App - always sending initData, or configure the proxy to forward the real client IP - (e.g. X-Forwarded-For) and use that for this check. Do not rely on the private-IP - bypass when deployed behind a proxy without one of these measures. - """ if not client_host: return False if client_host in ("127.0.0.1", "::1"): return True parts = client_host.split(".") - if len(parts) == 4: # IPv4 + if len(parts) == 4: try: a, b, c, d = (int(x) for x in parts) if (a == 10) or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168): @@ -111,10 +71,8 @@ def _is_private_client(client_host: str | None) -> bool: def get_authenticated_username( - request: Request, - x_telegram_init_data: str | None, + request: Request, x_telegram_init_data: str | None ) -> str: - """Validate Mini App auth. Returns username (or "" when bypass allowed); raises HTTPException 403 otherwise.""" init_data = (x_telegram_init_data or "").strip() if not init_data: client_host = request.client.host if request.client else None @@ -137,46 +95,22 @@ def get_authenticated_username( return username -app = FastAPI(title="Duty Teller API") -app.add_middleware( - CORSMiddleware, - allow_origins=config.CORS_ORIGINS, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/api/duties", response_model=list[DutyWithUser]) -def list_duties( - request: Request, - x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"), - dates: tuple[str, str] = Depends(get_validated_dates), - _username: str = Depends(require_miniapp_username), - session: Session = Depends(get_db_session), +def fetch_duties_response( + session: Session, from_date: str, to_date: str ) -> list[DutyWithUser]: - from_date_val, to_date_val = dates - log.info( - "GET /api/duties from %s, has initData: %s", - request.client.host if request.client else "?", - bool((x_telegram_init_data or "").strip()), - ) - return _fetch_duties_response(session, from_date_val, to_date_val) - - -@app.get("/api/calendar-events", response_model=list[CalendarEvent]) -def list_calendar_events( - dates: tuple[str, str] = Depends(get_validated_dates), - _username: str = Depends(require_miniapp_username), -) -> list[CalendarEvent]: - from_date_val, to_date_val = dates - url = config.EXTERNAL_CALENDAR_ICS_URL - if not url: - return [] - events = get_calendar_events(url, from_date=from_date_val, to_date=to_date_val) - return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events] - - -webapp_path = Path(__file__).resolve().parent.parent / "webapp" -if webapp_path.is_dir(): - app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp") + rows = get_duties(session, from_date=from_date, to_date=to_date) + return [ + DutyWithUser( + id=duty.id, + user_id=duty.user_id, + start_at=duty.start_at, + end_at=duty.end_at, + full_name=full_name, + event_type=( + duty.event_type + if duty.event_type in ("duty", "unavailable", "vacation") + else "duty" + ), + ) + for duty, full_name in rows + ] diff --git a/api/telegram_auth.py b/duty_teller/api/telegram_auth.py similarity index 100% rename from api/telegram_auth.py rename to duty_teller/api/telegram_auth.py diff --git a/config.py b/duty_teller/config.py similarity index 80% rename from config.py rename to duty_teller/config.py index 64b3788..d7a68bb 100644 --- a/config.py +++ b/duty_teller/config.py @@ -1,12 +1,16 @@ -"""Load configuration from environment. Fail fast if BOT_TOKEN is missing.""" +"""Load configuration from environment. BOT_TOKEN is not validated on import; check in main/entry point.""" import os from dataclasses import dataclass +from pathlib import Path from dotenv import load_dotenv load_dotenv() +# Project root (parent of duty_teller package). Used for webapp path, etc. +PROJECT_ROOT = Path(__file__).resolve().parent.parent + @dataclass(frozen=True) class Settings: @@ -61,17 +65,12 @@ class Settings: ) -BOT_TOKEN = os.getenv("BOT_TOKEN") -if not BOT_TOKEN: - raise SystemExit( - "BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather." - ) - +# Module-level vars: no validation on import; entry point must check BOT_TOKEN when needed. +BOT_TOKEN = os.getenv("BOT_TOKEN") or "" DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db") MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/") HTTP_PORT = int(os.getenv("HTTP_PORT", "8080")) -# Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed. _raw_allowed = os.getenv("ALLOWED_USERNAMES", "").strip() ALLOWED_USERNAMES = { s.strip().lstrip("@").lower() for s in _raw_allowed.split(",") if s.strip() @@ -82,13 +81,9 @@ ADMIN_USERNAMES = { s.strip().lstrip("@").lower() for s in _raw_admin.split(",") if s.strip() } -# Dev only: set to 1 to allow /api/duties without Telegram initData (insecure, no user check). MINI_APP_SKIP_AUTH = os.getenv("MINI_APP_SKIP_AUTH", "").strip() in ("1", "true", "yes") - -# Optional replay protection: reject initData older than this many seconds. 0 = disabled (default). INIT_DATA_MAX_AGE_SECONDS = int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0")) -# CORS: comma-separated origins, or empty/"*" for allow all. For production, set to MINI_APP_BASE_URL or specific origins. _raw_cors = os.getenv("CORS_ORIGINS", "").strip() CORS_ORIGINS = ( [_o.strip() for _o in _raw_cors.split(",") if _o.strip()] @@ -96,10 +91,7 @@ CORS_ORIGINS = ( else ["*"] ) -# Optional: URL of a public ICS calendar (e.g. holidays). Empty = no external calendar; /api/calendar-events returns []. EXTERNAL_CALENDAR_ICS_URL = os.getenv("EXTERNAL_CALENDAR_ICS_URL", "").strip() - -# Timezone for displaying duty times in the pinned group message (e.g. Europe/Moscow). DUTY_DISPLAY_TZ = ( os.getenv("DUTY_DISPLAY_TZ", "Europe/Moscow").strip() or "Europe/Moscow" ) @@ -114,3 +106,11 @@ def can_access_miniapp(username: str) -> bool: """True if username is in ALLOWED_USERNAMES or ADMIN_USERNAMES.""" u = (username or "").strip().lower() return u in ALLOWED_USERNAMES or u in ADMIN_USERNAMES + + +def require_bot_token() -> None: + """Raise SystemExit with a clear message if BOT_TOKEN is not set. Call from entry point.""" + if not BOT_TOKEN: + raise SystemExit( + "BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather." + ) diff --git a/db/__init__.py b/duty_teller/db/__init__.py similarity index 71% rename from db/__init__.py rename to duty_teller/db/__init__.py index c1f5d6f..60127ce 100644 --- a/db/__init__.py +++ b/duty_teller/db/__init__.py @@ -1,9 +1,20 @@ """Database layer: SQLAlchemy models, Pydantic schemas, repository, init.""" -from db.models import Base, User, Duty -from db.schemas import UserCreate, UserInDb, DutyCreate, DutyInDb, DutyWithUser -from db.session import get_engine, get_session_factory, get_session, session_scope -from db.repository import ( +from duty_teller.db.models import Base, User, Duty +from duty_teller.db.schemas import ( + UserCreate, + UserInDb, + DutyCreate, + DutyInDb, + DutyWithUser, +) +from duty_teller.db.session import ( + get_engine, + get_session_factory, + get_session, + session_scope, +) +from duty_teller.db.repository import ( delete_duties_in_range, get_or_create_user, get_or_create_user_by_full_name, diff --git a/db/models.py b/duty_teller/db/models.py similarity index 100% rename from db/models.py rename to duty_teller/db/models.py diff --git a/db/repository.py b/duty_teller/db/repository.py similarity index 87% rename from db/repository.py rename to duty_teller/db/repository.py index fc1597b..221fc9b 100644 --- a/db/repository.py +++ b/duty_teller/db/repository.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta from sqlalchemy.orm import Session -from db.models import User, Duty, GroupDutyPin +from duty_teller.db.models import User, Duty, GroupDutyPin def get_or_create_user( @@ -62,7 +62,6 @@ def delete_duties_in_range( to_date: str, ) -> int: """Delete all duties of the user that overlap [from_date, to_date] (YYYY-MM-DD). Returns count deleted.""" - # start_at < to_date + 1 day so duties starting on to_date are included (start_at is ISO with T) to_next = ( datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1) ).strftime("%Y-%m-%d") @@ -82,12 +81,7 @@ def get_duties( from_date: str, to_date: str, ) -> list[tuple[Duty, str]]: - """Return list of (Duty, full_name) overlapping the given date range. - - from_date/to_date are YYYY-MM-DD (inclusive). Duty.start_at and end_at are stored - in UTC (ISO 8601 with Z). Use to_date_next so duties starting on to_date are included - (start_at like 2025-01-31T09:00:00Z is > "2025-01-31" lexicographically). - """ + """Return list of (Duty, full_name) overlapping the given date range.""" to_date_next = ( datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1) ).strftime("%Y-%m-%d") @@ -106,8 +100,7 @@ def insert_duty( end_at: str, event_type: str = "duty", ) -> Duty: - """Create a duty. start_at and end_at must be UTC, ISO 8601 with Z. - event_type: 'duty' | 'unavailable' | 'vacation'.""" + """Create a duty. start_at and end_at must be UTC, ISO 8601 with Z.""" duty = Duty( user_id=user_id, start_at=start_at, @@ -121,8 +114,7 @@ def insert_duty( def get_current_duty(session: Session, at_utc: datetime) -> tuple[Duty, User] | None: - """Return the duty (and user) for which start_at <= at_utc < end_at, event_type='duty'. - at_utc is in UTC (naive or aware); comparison uses ISO strings.""" + """Return the duty (and user) for which start_at <= at_utc < end_at, event_type='duty'.""" from datetime import timezone if at_utc.tzinfo is not None: @@ -144,14 +136,12 @@ def get_current_duty(session: Session, at_utc: datetime) -> tuple[Duty, User] | def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None: - """Return the end_at of the current duty (if after_utc is inside one) or of the next duty. - For scheduling the next pin update. Returns naive UTC datetime.""" + """Return the end_at of the current duty or of the next duty. Naive UTC.""" from datetime import timezone if after_utc.tzinfo is not None: after_utc = after_utc.astimezone(timezone.utc) after_iso = after_utc.strftime("%Y-%m-%dT%H:%M:%S") + "Z" - # Current duty: start_at <= after_iso < end_at → use this end_at current = ( session.query(Duty) .filter( @@ -165,7 +155,6 @@ def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None return datetime.fromisoformat(current.end_at.replace("Z", "+00:00")).replace( tzinfo=None ) - # Next future duty: start_at > after_iso, order by start_at next_duty = ( session.query(Duty) .filter(Duty.event_type == "duty", Duty.start_at > after_iso) diff --git a/db/schemas.py b/duty_teller/db/schemas.py similarity index 100% rename from db/schemas.py rename to duty_teller/db/schemas.py diff --git a/db/session.py b/duty_teller/db/session.py similarity index 100% rename from db/session.py rename to duty_teller/db/session.py diff --git a/handlers/__init__.py b/duty_teller/handlers/__init__.py similarity index 100% rename from handlers/__init__.py rename to duty_teller/handlers/__init__.py diff --git a/handlers/commands.py b/duty_teller/handlers/commands.py similarity index 92% rename from handlers/commands.py rename to duty_teller/handlers/commands.py index 2553ce4..5eeb811 100644 --- a/handlers/commands.py +++ b/duty_teller/handlers/commands.py @@ -2,13 +2,13 @@ import asyncio -import config +import duty_teller.config as config from telegram import Update from telegram.ext import CommandHandler, ContextTypes -from db.session import session_scope -from db.repository import get_or_create_user, set_user_phone -from utils.user import build_full_name +from duty_teller.db.session import session_scope +from duty_teller.db.repository import get_or_create_user, set_user_phone +from duty_teller.utils.user import build_full_name async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -41,13 +41,11 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Set or clear phone for the current user (private chat only).""" if not update.message or not update.effective_user: return if update.effective_chat and update.effective_chat.type != "private": await update.message.reply_text("Команда /set_phone доступна только в личке.") return - # Optional: restrict to allowed usernames; plan says "or without restrictions" args = context.args or [] phone = " ".join(args).strip() if args else None telegram_user_id = update.effective_user.id diff --git a/handlers/errors.py b/duty_teller/handlers/errors.py similarity index 100% rename from handlers/errors.py rename to duty_teller/handlers/errors.py diff --git a/handlers/group_duty_pin.py b/duty_teller/handlers/group_duty_pin.py similarity index 91% rename from handlers/group_duty_pin.py rename to duty_teller/handlers/group_duty_pin.py index b411114..44c9989 100644 --- a/handlers/group_duty_pin.py +++ b/duty_teller/handlers/group_duty_pin.py @@ -4,14 +4,14 @@ import asyncio import logging from datetime import datetime, timezone -import config +import duty_teller.config as config from telegram import Update from telegram.constants import ChatMemberStatus from telegram.error import BadRequest, Forbidden from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes -from db.session import session_scope -from services.group_duty_pin_service import ( +from duty_teller.db.session import session_scope +from duty_teller.services.group_duty_pin_service import ( get_duty_message_text, get_next_shift_end_utc, save_pin, @@ -27,13 +27,11 @@ RETRY_WHEN_NO_DUTY_MINUTES = 15 def _get_duty_message_text_sync() -> str: - """Get current duty message (sync, for run_in_executor).""" with session_scope(config.DATABASE_URL) as session: return get_duty_message_text(session, config.DUTY_DISPLAY_TZ) def _get_next_shift_end_sync(): - """Return next shift end as naive UTC (sync, for run_in_executor).""" with session_scope(config.DATABASE_URL) as session: return get_next_shift_end_utc(session) @@ -56,7 +54,6 @@ def _sync_get_message_id(chat_id: int) -> int | None: async def _schedule_next_update( application, chat_id: int, when_utc: datetime | None ) -> None: - """Schedule run_once for update_group_pin. Remove existing job with same name first.""" job_queue = application.job_queue if job_queue is None: logger.warning("Job queue not available, cannot schedule pin update") @@ -93,7 +90,6 @@ async def _schedule_next_update( async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None: - """Job callback: edit pinned message with current duty and schedule next update.""" chat_id = context.job.data.get("chat_id") if chat_id is None: return @@ -118,7 +114,6 @@ async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None: async def my_chat_member_handler( update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: - """On bot added to group: send, pin, save, schedule. On removed: delete pin, cancel job.""" if not update.my_chat_member or not update.effective_user: return old = update.my_chat_member.old_chat_member @@ -130,7 +125,6 @@ async def my_chat_member_handler( return chat_id = chat.id - # Bot added to group if new.status in ( ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, @@ -145,7 +139,6 @@ async def my_chat_member_handler( except (BadRequest, Forbidden) as e: logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e) return - # Make it a pinned post (bot must be admin with "Pin messages" right) pinned = False try: await context.bot.pin_chat_message( @@ -171,7 +164,6 @@ async def my_chat_member_handler( await _schedule_next_update(context.application, chat_id, next_end) return - # Bot removed from group if new.status in (ChatMemberStatus.LEFT, ChatMemberStatus.BANNED): await asyncio.get_running_loop().run_in_executor( None, _sync_delete_pin, chat_id @@ -189,7 +181,6 @@ def _get_all_pin_chat_ids_sync() -> list[int]: async def restore_group_pin_jobs(application) -> None: - """On startup: for each chat_id in group_duty_pins, schedule next update at shift end.""" loop = asyncio.get_running_loop() chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync) for chat_id in chat_ids: @@ -199,7 +190,6 @@ async def restore_group_pin_jobs(application) -> None: async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Pin the current duty message (use after granting the bot 'Pin messages' right).""" if not update.message or not update.effective_chat: return chat = update.effective_chat diff --git a/handlers/import_duty_schedule.py b/duty_teller/handlers/import_duty_schedule.py similarity index 93% rename from handlers/import_duty_schedule.py rename to duty_teller/handlers/import_duty_schedule.py index 906af97..16665aa 100644 --- a/handlers/import_duty_schedule.py +++ b/duty_teller/handlers/import_duty_schedule.py @@ -2,14 +2,17 @@ import asyncio -import config +import duty_teller.config as config from telegram import Update from telegram.ext import CommandHandler, ContextTypes, MessageHandler, filters -from db.session import session_scope -from importers.duty_schedule import DutyScheduleParseError, parse_duty_schedule -from services.import_service import run_import -from utils.handover import parse_handover_time +from duty_teller.db.session import session_scope +from duty_teller.importers.duty_schedule import ( + DutyScheduleParseError, + parse_duty_schedule, +) +from duty_teller.services.import_service import run_import +from duty_teller.utils.handover import parse_handover_time async def import_duty_schedule_cmd( @@ -67,7 +70,6 @@ async def handle_duty_schedule_document( hour_utc, minute_utc = handover file_id = update.message.document.file_id - # Download and parse in async context file = await context.bot.get_file(file_id) raw = bytes(await file.download_as_bytearray()) try: diff --git a/importers/__init__.py b/duty_teller/importers/__init__.py similarity index 100% rename from importers/__init__.py rename to duty_teller/importers/__init__.py diff --git a/importers/duty_schedule.py b/duty_teller/importers/duty_schedule.py similarity index 100% rename from importers/duty_schedule.py rename to duty_teller/importers/duty_schedule.py diff --git a/duty_teller/run.py b/duty_teller/run.py new file mode 100644 index 0000000..29d230b --- /dev/null +++ b/duty_teller/run.py @@ -0,0 +1,84 @@ +"""Application entry point: build bot Application, run HTTP server + polling.""" + +import asyncio +import json +import logging +import threading +import urllib.request + +from telegram.ext import ApplicationBuilder + +from duty_teller import config +from duty_teller.config import require_bot_token +from duty_teller.handlers import group_duty_pin, register_handlers + +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.INFO, +) +logger = logging.getLogger(__name__) + + +def _set_default_menu_button_webapp() -> None: + if not (config.MINI_APP_BASE_URL and config.BOT_TOKEN): + return + menu_url = (config.MINI_APP_BASE_URL.rstrip("/") + "/app/").strip() + if not menu_url.startswith("https://"): + return + payload = { + "menu_button": { + "type": "web_app", + "text": "Календарь", + "web_app": {"url": menu_url}, + } + } + req = urllib.request.Request( + f"https://api.telegram.org/bot{config.BOT_TOKEN}/setChatMenuButton", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status == 200: + logger.info("Default menu button set to Web App: %s", menu_url) + else: + logger.warning("setChatMenuButton returned %s", resp.status) + except Exception as e: + logger.warning("Could not set menu button: %s", e) + + +def _run_uvicorn(web_app, port: int) -> None: + import uvicorn + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + server = uvicorn.Server( + uvicorn.Config(web_app, host="0.0.0.0", port=port, log_level="info"), + ) + loop.run_until_complete(server.serve()) + + +def main() -> None: + """Build the bot and FastAPI, start uvicorn in a thread, run polling.""" + require_bot_token() + # _set_default_menu_button_webapp() + app = ( + ApplicationBuilder() + .token(config.BOT_TOKEN) + .post_init(group_duty_pin.restore_group_pin_jobs) + .build() + ) + register_handlers(app) + + from duty_teller.api.app import app as web_app + + t = threading.Thread( + target=_run_uvicorn, + args=(web_app, config.HTTP_PORT), + daemon=True, + ) + t.start() + + logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT) + app.run_polling(allowed_updates=["message", "my_chat_member"]) diff --git a/services/__init__.py b/duty_teller/services/__init__.py similarity index 53% rename from services/__init__.py rename to duty_teller/services/__init__.py index 533b18e..2e65bfb 100644 --- a/services/__init__.py +++ b/duty_teller/services/__init__.py @@ -1,10 +1,6 @@ -"""Service layer: business logic and orchestration. +"""Service layer: business logic and orchestration.""" -Services accept a DB session from the caller (handlers open session_scope and pass session). -No Telegram or HTTP dependencies; repository handles persistence. -""" - -from services.group_duty_pin_service import ( +from duty_teller.services.group_duty_pin_service import ( format_duty_message, get_duty_message_text, get_next_shift_end_utc, @@ -13,7 +9,7 @@ from services.group_duty_pin_service import ( get_message_id, get_all_pin_chat_ids, ) -from services.import_service import run_import +from duty_teller.services.import_service import run_import __all__ = [ "format_duty_message", diff --git a/services/group_duty_pin_service.py b/duty_teller/services/group_duty_pin_service.py similarity index 98% rename from services/group_duty_pin_service.py rename to duty_teller/services/group_duty_pin_service.py index c3c9d37..cab7037 100644 --- a/services/group_duty_pin_service.py +++ b/duty_teller/services/group_duty_pin_service.py @@ -5,7 +5,7 @@ from zoneinfo import ZoneInfo from sqlalchemy.orm import Session -from db.repository import ( +from duty_teller.db.repository import ( get_current_duty, get_next_shift_end, get_group_duty_pin, diff --git a/services/import_service.py b/duty_teller/services/import_service.py similarity index 92% rename from services/import_service.py rename to duty_teller/services/import_service.py index f6d6af5..b65bdf6 100644 --- a/services/import_service.py +++ b/duty_teller/services/import_service.py @@ -4,13 +4,13 @@ from datetime import date, timedelta from sqlalchemy.orm import Session -from db.repository import ( +from duty_teller.db.repository import ( get_or_create_user_by_full_name, delete_duties_in_range, insert_duty, ) -from importers.duty_schedule import DutyScheduleResult -from utils.dates import day_start_iso, day_end_iso, duty_to_iso +from duty_teller.importers.duty_schedule import DutyScheduleResult +from duty_teller.utils.dates import day_start_iso, day_end_iso, duty_to_iso def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]: diff --git a/utils/__init__.py b/duty_teller/utils/__init__.py similarity index 62% rename from utils/__init__.py rename to duty_teller/utils/__init__.py index 484905f..e170d36 100644 --- a/utils/__init__.py +++ b/duty_teller/utils/__init__.py @@ -1,17 +1,14 @@ -"""Shared utilities: date/ISO helpers, user display names, handover time parsing. +"""Shared utilities: date/ISO helpers, user display names, handover time parsing.""" -Used by handlers, API, and services. No DB or Telegram dependencies. -""" - -from utils.dates import ( +from duty_teller.utils.dates import ( day_end_iso, day_start_iso, duty_to_iso, parse_iso_date, validate_date_range, ) -from utils.user import build_full_name -from utils.handover import parse_handover_time +from duty_teller.utils.user import build_full_name +from duty_teller.utils.handover import parse_handover_time __all__ = [ "day_start_iso", diff --git a/utils/dates.py b/duty_teller/utils/dates.py similarity index 92% rename from utils/dates.py rename to duty_teller/utils/dates.py index 33dcb1e..8cbd219 100644 --- a/utils/dates.py +++ b/duty_teller/utils/dates.py @@ -20,7 +20,6 @@ def duty_to_iso(d: date, hour_utc: int, minute_utc: int) -> str: return dt.strftime("%Y-%m-%dT%H:%M:%SZ") -# ISO date YYYY-MM-DD _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") @@ -35,11 +34,7 @@ def parse_iso_date(s: str) -> date | None: def validate_date_range(from_date: str, to_date: str) -> None: - """Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date. - - Raises: - ValueError: With a user-facing message if invalid. - """ + """Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date. Raises ValueError if invalid.""" if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""): raise ValueError("Параметры from и to должны быть в формате YYYY-MM-DD") if from_date > to_date: diff --git a/utils/handover.py b/duty_teller/utils/handover.py similarity index 100% rename from utils/handover.py rename to duty_teller/utils/handover.py diff --git a/utils/user.py b/duty_teller/utils/user.py similarity index 100% rename from utils/user.py rename to duty_teller/utils/user.py diff --git a/main.py b/main.py index d659879..c803f83 100644 --- a/main.py +++ b/main.py @@ -1,88 +1,6 @@ -"""Single entry point: build Application, run HTTP server + polling. Migrations run in Docker entrypoint.""" - -import asyncio -import json -import logging -import threading -import urllib.request - -import config -from telegram.ext import ApplicationBuilder - -from handlers import group_duty_pin, register_handlers - -logging.basicConfig( - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - level=logging.INFO, -) -logger = logging.getLogger(__name__) - - -def _set_default_menu_button_webapp() -> None: - """Set the bot's default menu button to Web App so Telegram sends tgWebAppData when users open the app from the menu.""" - if not (config.MINI_APP_BASE_URL and config.BOT_TOKEN): - return - menu_url = (config.MINI_APP_BASE_URL.rstrip("/") + "/app/").strip() - if not menu_url.startswith("https://"): - return - payload = { - "menu_button": { - "type": "web_app", - "text": "Календарь", - "web_app": {"url": menu_url}, - } - } - req = urllib.request.Request( - f"https://api.telegram.org/bot{config.BOT_TOKEN}/setChatMenuButton", - data=json.dumps(payload).encode(), - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=10) as resp: - if resp.status == 200: - logger.info("Default menu button set to Web App: %s", menu_url) - else: - logger.warning("setChatMenuButton returned %s", resp.status) - except Exception as e: - logger.warning("Could not set menu button: %s", e) - - -def _run_uvicorn(web_app, port: int) -> None: - """Run uvicorn in a dedicated thread with its own event loop.""" - import uvicorn - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - server = uvicorn.Server( - uvicorn.Config(web_app, host="0.0.0.0", port=port, log_level="info"), - ) - loop.run_until_complete(server.serve()) - - -def main() -> None: - # Menu button (Календарь) and inline Calendar button are disabled; users open the app by link if needed. - # _set_default_menu_button_webapp() - app = ( - ApplicationBuilder() - .token(config.BOT_TOKEN) - .post_init(group_duty_pin.restore_group_pin_jobs) - .build() - ) - register_handlers(app) - - from api.app import app as web_app - - t = threading.Thread( - target=_run_uvicorn, - args=(web_app, config.HTTP_PORT), - daemon=True, - ) - t.start() - - logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT) - app.run_polling(allowed_updates=["message", "my_chat_member"]) +"""Entry point: delegate to duty_teller.run.main(). Run with python main.py or duty-teller.""" +from duty_teller.run import main if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml index d7ea13f..eb5f155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,9 @@ dependencies = [ "icalendar>=5.0,<6.0", ] +[project.scripts] +duty-teller = "duty_teller.run:main" + [project.optional-dependencies] dev = [ "pytest>=8.0,<9.0", @@ -28,7 +31,7 @@ dev = [ [tool.setuptools.packages.find] where = ["."] -include = ["db", "handlers", "api", "importers", "utils", "services"] +include = ["duty_teller*"] [tool.black] line-length = 120 diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..ace646e --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,33 @@ +"""Shared test helpers (e.g. make_init_data for Telegram auth tests).""" + +import hashlib +import hmac +import json +from urllib.parse import quote, unquote + + +def make_init_data( + user: dict | None, + bot_token: str, + auth_date: int | None = None, +) -> str: + """Build initData string with valid HMAC for testing.""" + params = {} + if user is not None: + params["user"] = quote(json.dumps(user)) + if auth_date is not None: + params["auth_date"] = str(auth_date) + pairs = sorted(params.items()) + data_string = "\n".join(f"{k}={unquote(v)}" for k, v in pairs) + secret_key = hmac.new( + b"WebAppData", + msg=bot_token.encode(), + digestmod=hashlib.sha256, + ).digest() + computed = hmac.new( + secret_key, + msg=data_string.encode(), + digestmod=hashlib.sha256, + ).hexdigest() + params["hash"] = computed + return "&".join(f"{k}={v}" for k, v in sorted(params.items())) diff --git a/tests/test_app.py b/tests/test_app.py index b669ddc..d694556 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,9 +6,9 @@ from unittest.mock import ANY, patch import pytest from fastapi.testclient import TestClient -import config -from api.app import app -from tests.test_telegram_auth import _make_init_data +import duty_teller.config as config +from duty_teller.api.app import app +from tests.helpers import make_init_data @pytest.fixture @@ -28,11 +28,10 @@ def test_duties_from_after_to(client): assert "from" in r.json()["detail"].lower() or "позже" in r.json()["detail"] -@patch("api.app._is_private_client") -@patch("api.app.config.MINI_APP_SKIP_AUTH", False) +@patch("duty_teller.api.dependencies._is_private_client") +@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False) def test_duties_403_without_init_data_from_public_client(mock_private, client): - """Without initData and without private IP / skip-auth, should get 403.""" - mock_private.return_value = False # simulate public client + mock_private.return_value = False r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, @@ -40,8 +39,8 @@ def test_duties_403_without_init_data_from_public_client(mock_private, client): assert r.status_code == 403 -@patch("api.app.config.MINI_APP_SKIP_AUTH", True) -@patch("api.app._fetch_duties_response") +@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True) +@patch("duty_teller.api.app.fetch_duties_response") def test_duties_200_when_skip_auth(mock_fetch, client): mock_fetch.return_value = [] r = client.get( @@ -53,7 +52,7 @@ def test_duties_200_when_skip_auth(mock_fetch, client): mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31") -@patch("api.app.validate_init_data_with_reason") +@patch("duty_teller.api.dependencies.validate_init_data_with_reason") def test_duties_403_when_init_data_invalid(mock_validate, client): mock_validate.return_value = (None, "hash_mismatch") r = client.get( @@ -66,12 +65,12 @@ def test_duties_403_when_init_data_invalid(mock_validate, client): assert "авторизации" in detail or "Неверные" in detail or "Неверная" in detail -@patch("api.app.validate_init_data_with_reason") -@patch("api.app.config.can_access_miniapp") +@patch("duty_teller.api.dependencies.validate_init_data_with_reason") +@patch("duty_teller.api.dependencies.config.can_access_miniapp") def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, client): mock_validate.return_value = ("someuser", "ok") mock_can_access.return_value = False - with patch("api.app._fetch_duties_response") as mock_fetch: + with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, @@ -82,12 +81,12 @@ def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, cl mock_fetch.assert_not_called() -@patch("api.app.validate_init_data_with_reason") -@patch("api.app.config.can_access_miniapp") +@patch("duty_teller.api.dependencies.validate_init_data_with_reason") +@patch("duty_teller.api.dependencies.config.can_access_miniapp") def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client): mock_validate.return_value = ("alloweduser", "ok") mock_can_access.return_value = True - with patch("api.app._fetch_duties_response") as mock_fetch: + with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: mock_fetch.return_value = [ { "id": 1, @@ -109,19 +108,18 @@ def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client): def test_duties_e2e_auth_real_validation(client, monkeypatch): - """E2E: valid initData + allowlist, no mocks on validate_init_data_with_reason; full auth path.""" test_token = "123:ABC" test_username = "e2euser" monkeypatch.setattr(config, "BOT_TOKEN", test_token) monkeypatch.setattr(config, "ALLOWED_USERNAMES", {test_username}) monkeypatch.setattr(config, "ADMIN_USERNAMES", set()) monkeypatch.setattr(config, "INIT_DATA_MAX_AGE_SECONDS", 0) - init_data = _make_init_data( + init_data = make_init_data( {"id": 1, "username": test_username}, test_token, auth_date=int(time.time()), ) - with patch("api.app._fetch_duties_response") as mock_fetch: + with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: mock_fetch.return_value = [] r = client.get( "/api/duties", @@ -133,9 +131,8 @@ def test_duties_e2e_auth_real_validation(client, monkeypatch): mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31") -@patch("api.app.config.MINI_APP_SKIP_AUTH", True) +@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True) def test_duties_200_with_unknown_event_type_mapped_to_duty(client): - """When DB returns duty with event_type not in (duty, unavailable, vacation), API returns 200 with event_type='duty'.""" from types import SimpleNamespace fake_duty = SimpleNamespace( @@ -149,7 +146,7 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client): def fake_get_duties(session, from_date, to_date): return [(fake_duty, "User A")] - with patch("api.app.get_duties", side_effect=fake_get_duties): + with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties): r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, diff --git a/tests/test_config.py b/tests/test_config.py index fd3d37a..da03ad4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,6 @@ """Tests for config.is_admin and config.can_access_miniapp.""" -import config +import duty_teller.config as config def test_is_admin_true_when_in_admin_list(monkeypatch): diff --git a/tests/test_duty_schedule_parser.py b/tests/test_duty_schedule_parser.py index 03241ae..6560e2d 100644 --- a/tests/test_duty_schedule_parser.py +++ b/tests/test_duty_schedule_parser.py @@ -4,7 +4,7 @@ from datetime import date import pytest -from importers.duty_schedule import ( +from duty_teller.importers.duty_schedule import ( DUTY_MARKERS, UNAVAILABLE_MARKER, VACATION_MARKER, diff --git a/tests/test_import_duty_schedule_integration.py b/tests/test_import_duty_schedule_integration.py index 641b32a..0278dab 100644 --- a/tests/test_import_duty_schedule_integration.py +++ b/tests/test_import_duty_schedule_integration.py @@ -4,15 +4,15 @@ from datetime import date import pytest -from db import init_db -from db.repository import get_duties -from db.session import get_session, session_scope -from importers.duty_schedule import ( +from duty_teller.db import init_db +from duty_teller.db.repository import get_duties +from duty_teller.db.session import get_session, session_scope +from duty_teller.importers.duty_schedule import ( DutyScheduleEntry, DutyScheduleResult, parse_duty_schedule, ) -from services.import_service import run_import +from duty_teller.services.import_service import run_import @pytest.fixture @@ -23,7 +23,7 @@ def db_url(): @pytest.fixture(autouse=True) def _reset_db_session(db_url): """Ensure each test uses a fresh engine for :memory: (clear global cache for test URL).""" - import db.session as session_module + import duty_teller.db.session as session_module session_module._engine = None session_module._SessionLocal = None diff --git a/tests/test_repository_duty_range.py b/tests/test_repository_duty_range.py index 5904707..2090328 100644 --- a/tests/test_repository_duty_range.py +++ b/tests/test_repository_duty_range.py @@ -4,8 +4,8 @@ import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from db.models import Base, User -from db.repository import ( +from duty_teller.db.models import Base, User +from duty_teller.db.repository import ( delete_duties_in_range, get_or_create_user_by_full_name, get_duties, diff --git a/tests/test_telegram_auth.py b/tests/test_telegram_auth.py index 598f8d0..dcfcc79 100644 --- a/tests/test_telegram_auth.py +++ b/tests/test_telegram_auth.py @@ -1,59 +1,27 @@ -"""Tests for api.telegram_auth.validate_init_data.""" +"""Tests for duty_teller.api.telegram_auth.validate_init_data.""" -import hashlib -import hmac -import json -from urllib.parse import quote, unquote - - -from api.telegram_auth import validate_init_data - - -def _make_init_data( - user: dict | None, - bot_token: str, - auth_date: int | None = None, -) -> str: - """Build initData string with valid HMAC for testing.""" - params = {} - if user is not None: - params["user"] = quote(json.dumps(user)) - if auth_date is not None: - params["auth_date"] = str(auth_date) - pairs = sorted(params.items()) - data_string = "\n".join(f"{k}={unquote(v)}" for k, v in pairs) - secret_key = hmac.new( - b"WebAppData", - msg=bot_token.encode(), - digestmod=hashlib.sha256, - ).digest() - computed = hmac.new( - secret_key, - msg=data_string.encode(), - digestmod=hashlib.sha256, - ).hexdigest() - params["hash"] = computed - return "&".join(f"{k}={v}" for k, v in sorted(params.items())) +from duty_teller.api.telegram_auth import validate_init_data +from tests.helpers import make_init_data def test_valid_payload_returns_username(): bot_token = "123:ABC" user = {"id": 123, "username": "testuser", "first_name": "Test"} - init_data = _make_init_data(user, bot_token) + init_data = make_init_data(user, bot_token) assert validate_init_data(init_data, bot_token) == "testuser" def test_valid_payload_username_lowercase(): bot_token = "123:ABC" user = {"id": 123, "username": "TestUser", "first_name": "Test"} - init_data = _make_init_data(user, bot_token) + init_data = make_init_data(user, bot_token) assert validate_init_data(init_data, bot_token) == "testuser" def test_invalid_hash_returns_none(): bot_token = "123:ABC" user = {"id": 123, "username": "testuser"} - init_data = _make_init_data(user, bot_token) + init_data = make_init_data(user, bot_token) # Tamper with hash init_data = init_data.replace("hash=", "hash=x") assert validate_init_data(init_data, bot_token) is None @@ -62,20 +30,20 @@ def test_invalid_hash_returns_none(): def test_wrong_bot_token_returns_none(): bot_token = "123:ABC" user = {"id": 123, "username": "testuser"} - init_data = _make_init_data(user, bot_token) + init_data = make_init_data(user, bot_token) assert validate_init_data(init_data, "other:token") is None def test_missing_user_returns_none(): bot_token = "123:ABC" - init_data = _make_init_data(None, bot_token) # no user key + init_data = make_init_data(None, bot_token) # no user key assert validate_init_data(init_data, bot_token) is None def test_user_without_username_returns_none(): bot_token = "123:ABC" user = {"id": 123, "first_name": "Test"} # no username - init_data = _make_init_data(user, bot_token) + init_data = make_init_data(user, bot_token) assert validate_init_data(init_data, bot_token) is None @@ -86,7 +54,7 @@ def test_empty_init_data_returns_none(): def test_empty_bot_token_returns_none(): user = {"id": 1, "username": "u"} - init_data = _make_init_data(user, "token") + init_data = make_init_data(user, "token") assert validate_init_data(init_data, "") is None @@ -98,7 +66,7 @@ def test_auth_date_expiry_rejects_old_init_data(): user = {"id": 1, "username": "testuser"} # auth_date 100 seconds ago old_ts = int(t.time()) - 100 - init_data = _make_init_data(user, bot_token, auth_date=old_ts) + init_data = make_init_data(user, bot_token, auth_date=old_ts) assert validate_init_data(init_data, bot_token, max_age_seconds=60) is None assert validate_init_data(init_data, bot_token, max_age_seconds=200) == "testuser" @@ -110,7 +78,7 @@ def test_auth_date_expiry_accepts_fresh_init_data(): bot_token = "123:ABC" user = {"id": 1, "username": "testuser"} fresh_ts = int(t.time()) - 10 - init_data = _make_init_data(user, bot_token, auth_date=fresh_ts) + init_data = make_init_data(user, bot_token, auth_date=fresh_ts) assert validate_init_data(init_data, bot_token, max_age_seconds=60) == "testuser" @@ -118,6 +86,6 @@ def test_auth_date_expiry_requires_auth_date_when_max_age_set(): """When max_age_seconds is set but auth_date is missing, return None.""" bot_token = "123:ABC" user = {"id": 1, "username": "testuser"} - init_data = _make_init_data(user, bot_token) # no auth_date + init_data = make_init_data(user, bot_token) # no auth_date assert validate_init_data(init_data, bot_token, max_age_seconds=86400) is None assert validate_init_data(init_data, bot_token) == "testuser" diff --git a/tests/test_utils.py b/tests/test_utils.py index bf8ca0a..28906d3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,15 +4,15 @@ from datetime import date import pytest -from utils.dates import ( +from duty_teller.utils.dates import ( day_start_iso, day_end_iso, duty_to_iso, parse_iso_date, validate_date_range, ) -from utils.user import build_full_name -from utils.handover import parse_handover_time +from duty_teller.utils.user import build_full_name +from duty_teller.utils.handover import parse_handover_time # --- dates ---