Refactor project structure and enhance Docker configuration
- Updated `.dockerignore` to exclude test and development artifacts, optimizing the Docker image size. - Refactored `main.py` to delegate execution to `duty_teller.run.main()`, simplifying the entry point. - Introduced a new `duty_teller` package to encapsulate core functionality, improving modularity and organization. - Enhanced `pyproject.toml` to define a script for running the application, streamlining the execution process. - Updated README documentation to reflect changes in project structure and usage instructions. - Improved Alembic environment configuration to utilize the new package structure for database migrations.
This commit is contained in:
@@ -10,3 +10,9 @@ __pycache__/
|
|||||||
*.plan.md
|
*.plan.md
|
||||||
data/
|
data/
|
||||||
.curosr/
|
.curosr/
|
||||||
|
# Tests and dev artifacts (not needed in image)
|
||||||
|
tests/
|
||||||
|
.pytest_cache/
|
||||||
|
conftest.py
|
||||||
|
*.cover
|
||||||
|
htmlcov/
|
||||||
|
|||||||
@@ -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/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
|
||||||
# Application code
|
# Application code (duty_teller package + entrypoint, migrations, webapp)
|
||||||
COPY config.py main.py alembic.ini entrypoint.sh ./
|
ENV PYTHONPATH=/app
|
||||||
COPY db/ ./db/
|
COPY main.py alembic.ini entrypoint.sh ./
|
||||||
COPY api/ ./api/
|
COPY duty_teller/ ./duty_teller/
|
||||||
COPY handlers/ ./handlers/
|
|
||||||
COPY alembic/ ./alembic/
|
COPY alembic/ ./alembic/
|
||||||
COPY webapp/ ./webapp/
|
COPY webapp/ ./webapp/
|
||||||
|
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -53,6 +53,12 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co
|
|||||||
python main.py
|
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.
|
The bot runs in polling mode. Send `/start` or `/help` to your bot in Telegram to test.
|
||||||
|
|
||||||
## Run with Docker
|
## Run with Docker
|
||||||
@@ -75,18 +81,21 @@ Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`.
|
|||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
- `main.py` – Builds the `Application`, registers handlers, runs polling and FastAPI in a thread.
|
- `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.
|
||||||
- `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.
|
- `duty_teller/` – Main package (install with `pip install -e .`). Contains:
|
||||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), auth/session Depends, static webapp mount.
|
- `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.
|
||||||
- `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.
|
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
|
||||||
- `handlers/` – Telegram command and chat handlers; thin layer that call services and utils.
|
- `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.
|
- `services/` – Business logic (group duty pin, import); accept session from caller.
|
||||||
- `utils/` – Shared date, user, and handover helpers.
|
- `utils/` – Shared date, user, and handover helpers.
|
||||||
- `alembic/` – Migrations (use `config.DATABASE_URL`).
|
- `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`.
|
- `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)
|
## Импорт расписания дежурств (duty-schedule)
|
||||||
|
|
||||||
|
|||||||
@@ -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 os
|
||||||
import sys
|
import sys
|
||||||
@@ -9,8 +9,8 @@ from alembic import context
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
import config
|
import duty_teller.config as config
|
||||||
from db.models import Base
|
from duty_teller.db.models import Base
|
||||||
|
|
||||||
config_alembic = context.config
|
config_alembic = context.config
|
||||||
if config_alembic.config_file_name is not None:
|
if config_alembic.config_file_name is not None:
|
||||||
|
|||||||
@@ -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
|
import os
|
||||||
|
|
||||||
# Set before any project code imports config (which requires BOT_TOKEN).
|
|
||||||
if not os.environ.get("BOT_TOKEN"):
|
if not os.environ.get("BOT_TOKEN"):
|
||||||
os.environ["BOT_TOKEN"] = "test-token-for-pytest"
|
os.environ["BOT_TOKEN"] = "test-token-for-pytest"
|
||||||
|
|||||||
8
duty_teller/__init__.py
Normal file
8
duty_teller/__init__.py
Normal file
@@ -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"
|
||||||
62
duty_teller/api/app.py
Normal file
62
duty_teller/api/app.py
Normal file
@@ -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")
|
||||||
@@ -1,27 +1,22 @@
|
|||||||
"""FastAPI app: /api/duties and static webapp."""
|
"""FastAPI dependencies: DB session, auth, date validation."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated, Generator
|
from typing import Annotated, Generator
|
||||||
|
|
||||||
import config
|
from fastapi import Header, HTTPException, Query, Request
|
||||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from db.session import session_scope
|
import duty_teller.config as config
|
||||||
from db.repository import get_duties
|
from duty_teller.api.telegram_auth import validate_init_data_with_reason
|
||||||
from db.schemas import DutyWithUser, CalendarEvent
|
from duty_teller.db.repository import get_duties
|
||||||
from api.telegram_auth import validate_init_data_with_reason
|
from duty_teller.db.schemas import DutyWithUser
|
||||||
from api.calendar_ics import get_calendar_events
|
from duty_teller.db.session import session_scope
|
||||||
from utils.dates import validate_date_range
|
from duty_teller.utils.dates import validate_date_range
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _validate_duty_dates(from_date: str, to_date: str) -> None:
|
def _validate_duty_dates(from_date: str, to_date: str) -> None:
|
||||||
"""Raise HTTPException 400 if dates are invalid or from_date > to_date."""
|
|
||||||
try:
|
try:
|
||||||
validate_date_range(from_date, to_date)
|
validate_date_range(from_date, to_date)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -32,13 +27,11 @@ def get_validated_dates(
|
|||||||
from_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="from"),
|
from_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="from"),
|
||||||
to_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="to"),
|
to_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="to"),
|
||||||
) -> tuple[str, str]:
|
) -> 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)
|
_validate_duty_dates(from_date, to_date)
|
||||||
return (from_date, to_date)
|
return (from_date, to_date)
|
||||||
|
|
||||||
|
|
||||||
def get_db_session() -> Generator[Session, None, None]:
|
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:
|
with session_scope(config.DATABASE_URL) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
@@ -49,34 +42,10 @@ def require_miniapp_username(
|
|||||||
str | None, Header(alias="X-Telegram-Init-Data")
|
str | None, Header(alias="X-Telegram-Init-Data")
|
||||||
] = None,
|
] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""FastAPI dependency: return authenticated username or raise 403."""
|
|
||||||
return get_authenticated_username(request, x_telegram_init_data)
|
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:
|
def _auth_error_detail(auth_reason: str) -> str:
|
||||||
"""Return user-facing detail message for 403 when initData validation fails."""
|
|
||||||
if auth_reason == "hash_mismatch":
|
if auth_reason == "hash_mismatch":
|
||||||
return (
|
return (
|
||||||
"Неверная подпись. Убедитесь, что BOT_TOKEN на сервере совпадает с токеном бота, "
|
"Неверная подпись. Убедитесь, что BOT_TOKEN на сервере совпадает с токеном бота, "
|
||||||
@@ -86,21 +55,12 @@ def _auth_error_detail(auth_reason: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _is_private_client(client_host: str | None) -> bool:
|
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:
|
if not client_host:
|
||||||
return False
|
return False
|
||||||
if client_host in ("127.0.0.1", "::1"):
|
if client_host in ("127.0.0.1", "::1"):
|
||||||
return True
|
return True
|
||||||
parts = client_host.split(".")
|
parts = client_host.split(".")
|
||||||
if len(parts) == 4: # IPv4
|
if len(parts) == 4:
|
||||||
try:
|
try:
|
||||||
a, b, c, d = (int(x) for x in parts)
|
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):
|
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(
|
def get_authenticated_username(
|
||||||
request: Request,
|
request: Request, x_telegram_init_data: str | None
|
||||||
x_telegram_init_data: str | None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Validate Mini App auth. Returns username (or "" when bypass allowed); raises HTTPException 403 otherwise."""
|
|
||||||
init_data = (x_telegram_init_data or "").strip()
|
init_data = (x_telegram_init_data or "").strip()
|
||||||
if not init_data:
|
if not init_data:
|
||||||
client_host = request.client.host if request.client else None
|
client_host = request.client.host if request.client else None
|
||||||
@@ -137,46 +95,22 @@ def get_authenticated_username(
|
|||||||
return username
|
return username
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Duty Teller API")
|
def fetch_duties_response(
|
||||||
app.add_middleware(
|
session: Session, from_date: str, to_date: str
|
||||||
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),
|
|
||||||
) -> list[DutyWithUser]:
|
) -> list[DutyWithUser]:
|
||||||
from_date_val, to_date_val = dates
|
rows = get_duties(session, from_date=from_date, to_date=to_date)
|
||||||
log.info(
|
return [
|
||||||
"GET /api/duties from %s, has initData: %s",
|
DutyWithUser(
|
||||||
request.client.host if request.client else "?",
|
id=duty.id,
|
||||||
bool((x_telegram_init_data or "").strip()),
|
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"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return _fetch_duties_response(session, from_date_val, to_date_val)
|
for duty, full_name in rows
|
||||||
|
]
|
||||||
|
|
||||||
@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")
|
|
||||||
@@ -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
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
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)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
@@ -61,17 +65,12 @@ class Settings:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
# Module-level vars: no validation on import; entry point must check BOT_TOKEN when needed.
|
||||||
if not BOT_TOKEN:
|
BOT_TOKEN = os.getenv("BOT_TOKEN") or ""
|
||||||
raise SystemExit(
|
|
||||||
"BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather."
|
|
||||||
)
|
|
||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
|
||||||
MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/")
|
MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/")
|
||||||
HTTP_PORT = int(os.getenv("HTTP_PORT", "8080"))
|
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()
|
_raw_allowed = os.getenv("ALLOWED_USERNAMES", "").strip()
|
||||||
ALLOWED_USERNAMES = {
|
ALLOWED_USERNAMES = {
|
||||||
s.strip().lstrip("@").lower() for s in _raw_allowed.split(",") if s.strip()
|
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()
|
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")
|
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"))
|
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()
|
_raw_cors = os.getenv("CORS_ORIGINS", "").strip()
|
||||||
CORS_ORIGINS = (
|
CORS_ORIGINS = (
|
||||||
[_o.strip() for _o in _raw_cors.split(",") if _o.strip()]
|
[_o.strip() for _o in _raw_cors.split(",") if _o.strip()]
|
||||||
@@ -96,10 +91,7 @@ CORS_ORIGINS = (
|
|||||||
else ["*"]
|
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()
|
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 = (
|
DUTY_DISPLAY_TZ = (
|
||||||
os.getenv("DUTY_DISPLAY_TZ", "Europe/Moscow").strip() or "Europe/Moscow"
|
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."""
|
"""True if username is in ALLOWED_USERNAMES or ADMIN_USERNAMES."""
|
||||||
u = (username or "").strip().lower()
|
u = (username or "").strip().lower()
|
||||||
return u in ALLOWED_USERNAMES or u in ADMIN_USERNAMES
|
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."
|
||||||
|
)
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
"""Database layer: SQLAlchemy models, Pydantic schemas, repository, init."""
|
"""Database layer: SQLAlchemy models, Pydantic schemas, repository, init."""
|
||||||
|
|
||||||
from db.models import Base, User, Duty
|
from duty_teller.db.models import Base, User, Duty
|
||||||
from db.schemas import UserCreate, UserInDb, DutyCreate, DutyInDb, DutyWithUser
|
from duty_teller.db.schemas import (
|
||||||
from db.session import get_engine, get_session_factory, get_session, session_scope
|
UserCreate,
|
||||||
from db.repository import (
|
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,
|
delete_duties_in_range,
|
||||||
get_or_create_user,
|
get_or_create_user,
|
||||||
get_or_create_user_by_full_name,
|
get_or_create_user_by_full_name,
|
||||||
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
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(
|
def get_or_create_user(
|
||||||
@@ -62,7 +62,6 @@ def delete_duties_in_range(
|
|||||||
to_date: str,
|
to_date: str,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Delete all duties of the user that overlap [from_date, to_date] (YYYY-MM-DD). Returns count deleted."""
|
"""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 = (
|
to_next = (
|
||||||
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
|
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
|
||||||
).strftime("%Y-%m-%d")
|
).strftime("%Y-%m-%d")
|
||||||
@@ -82,12 +81,7 @@ def get_duties(
|
|||||||
from_date: str,
|
from_date: str,
|
||||||
to_date: str,
|
to_date: str,
|
||||||
) -> list[tuple[Duty, str]]:
|
) -> list[tuple[Duty, str]]:
|
||||||
"""Return list of (Duty, full_name) overlapping the given date range.
|
"""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).
|
|
||||||
"""
|
|
||||||
to_date_next = (
|
to_date_next = (
|
||||||
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
|
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
|
||||||
).strftime("%Y-%m-%d")
|
).strftime("%Y-%m-%d")
|
||||||
@@ -106,8 +100,7 @@ def insert_duty(
|
|||||||
end_at: str,
|
end_at: str,
|
||||||
event_type: str = "duty",
|
event_type: str = "duty",
|
||||||
) -> Duty:
|
) -> Duty:
|
||||||
"""Create a duty. start_at and end_at must be UTC, ISO 8601 with Z.
|
"""Create a duty. start_at and end_at must be UTC, ISO 8601 with Z."""
|
||||||
event_type: 'duty' | 'unavailable' | 'vacation'."""
|
|
||||||
duty = Duty(
|
duty = Duty(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
start_at=start_at,
|
start_at=start_at,
|
||||||
@@ -121,8 +114,7 @@ def insert_duty(
|
|||||||
|
|
||||||
|
|
||||||
def get_current_duty(session: Session, at_utc: datetime) -> tuple[Duty, User] | None:
|
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'.
|
"""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."""
|
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
|
|
||||||
if at_utc.tzinfo is not None:
|
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:
|
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.
|
"""Return the end_at of the current duty or of the next duty. Naive UTC."""
|
||||||
For scheduling the next pin update. Returns naive UTC datetime."""
|
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
|
|
||||||
if after_utc.tzinfo is not None:
|
if after_utc.tzinfo is not None:
|
||||||
after_utc = after_utc.astimezone(timezone.utc)
|
after_utc = after_utc.astimezone(timezone.utc)
|
||||||
after_iso = after_utc.strftime("%Y-%m-%dT%H:%M:%S") + "Z"
|
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 = (
|
current = (
|
||||||
session.query(Duty)
|
session.query(Duty)
|
||||||
.filter(
|
.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(
|
return datetime.fromisoformat(current.end_at.replace("Z", "+00:00")).replace(
|
||||||
tzinfo=None
|
tzinfo=None
|
||||||
)
|
)
|
||||||
# Next future duty: start_at > after_iso, order by start_at
|
|
||||||
next_duty = (
|
next_duty = (
|
||||||
session.query(Duty)
|
session.query(Duty)
|
||||||
.filter(Duty.event_type == "duty", Duty.start_at > after_iso)
|
.filter(Duty.event_type == "duty", Duty.start_at > after_iso)
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import config
|
import duty_teller.config as config
|
||||||
from telegram import Update
|
from telegram import Update
|
||||||
from telegram.ext import CommandHandler, ContextTypes
|
from telegram.ext import CommandHandler, ContextTypes
|
||||||
|
|
||||||
from db.session import session_scope
|
from duty_teller.db.session import session_scope
|
||||||
from db.repository import get_or_create_user, set_user_phone
|
from duty_teller.db.repository import get_or_create_user, set_user_phone
|
||||||
from utils.user import build_full_name
|
from duty_teller.utils.user import build_full_name
|
||||||
|
|
||||||
|
|
||||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
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:
|
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:
|
if not update.message or not update.effective_user:
|
||||||
return
|
return
|
||||||
if update.effective_chat and update.effective_chat.type != "private":
|
if update.effective_chat and update.effective_chat.type != "private":
|
||||||
await update.message.reply_text("Команда /set_phone доступна только в личке.")
|
await update.message.reply_text("Команда /set_phone доступна только в личке.")
|
||||||
return
|
return
|
||||||
# Optional: restrict to allowed usernames; plan says "or without restrictions"
|
|
||||||
args = context.args or []
|
args = context.args or []
|
||||||
phone = " ".join(args).strip() if args else None
|
phone = " ".join(args).strip() if args else None
|
||||||
telegram_user_id = update.effective_user.id
|
telegram_user_id = update.effective_user.id
|
||||||
@@ -4,14 +4,14 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import config
|
import duty_teller.config as config
|
||||||
from telegram import Update
|
from telegram import Update
|
||||||
from telegram.constants import ChatMemberStatus
|
from telegram.constants import ChatMemberStatus
|
||||||
from telegram.error import BadRequest, Forbidden
|
from telegram.error import BadRequest, Forbidden
|
||||||
from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes
|
from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes
|
||||||
|
|
||||||
from db.session import session_scope
|
from duty_teller.db.session import session_scope
|
||||||
from services.group_duty_pin_service import (
|
from duty_teller.services.group_duty_pin_service import (
|
||||||
get_duty_message_text,
|
get_duty_message_text,
|
||||||
get_next_shift_end_utc,
|
get_next_shift_end_utc,
|
||||||
save_pin,
|
save_pin,
|
||||||
@@ -27,13 +27,11 @@ RETRY_WHEN_NO_DUTY_MINUTES = 15
|
|||||||
|
|
||||||
|
|
||||||
def _get_duty_message_text_sync() -> str:
|
def _get_duty_message_text_sync() -> str:
|
||||||
"""Get current duty message (sync, for run_in_executor)."""
|
|
||||||
with session_scope(config.DATABASE_URL) as session:
|
with session_scope(config.DATABASE_URL) as session:
|
||||||
return get_duty_message_text(session, config.DUTY_DISPLAY_TZ)
|
return get_duty_message_text(session, config.DUTY_DISPLAY_TZ)
|
||||||
|
|
||||||
|
|
||||||
def _get_next_shift_end_sync():
|
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:
|
with session_scope(config.DATABASE_URL) as session:
|
||||||
return get_next_shift_end_utc(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(
|
async def _schedule_next_update(
|
||||||
application, chat_id: int, when_utc: datetime | None
|
application, chat_id: int, when_utc: datetime | None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Schedule run_once for update_group_pin. Remove existing job with same name first."""
|
|
||||||
job_queue = application.job_queue
|
job_queue = application.job_queue
|
||||||
if job_queue is None:
|
if job_queue is None:
|
||||||
logger.warning("Job queue not available, cannot schedule pin update")
|
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:
|
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")
|
chat_id = context.job.data.get("chat_id")
|
||||||
if chat_id is None:
|
if chat_id is None:
|
||||||
return
|
return
|
||||||
@@ -118,7 +114,6 @@ async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:
|
|||||||
async def my_chat_member_handler(
|
async def my_chat_member_handler(
|
||||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||||
) -> None:
|
) -> 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:
|
if not update.my_chat_member or not update.effective_user:
|
||||||
return
|
return
|
||||||
old = update.my_chat_member.old_chat_member
|
old = update.my_chat_member.old_chat_member
|
||||||
@@ -130,7 +125,6 @@ async def my_chat_member_handler(
|
|||||||
return
|
return
|
||||||
chat_id = chat.id
|
chat_id = chat.id
|
||||||
|
|
||||||
# Bot added to group
|
|
||||||
if new.status in (
|
if new.status in (
|
||||||
ChatMemberStatus.MEMBER,
|
ChatMemberStatus.MEMBER,
|
||||||
ChatMemberStatus.ADMINISTRATOR,
|
ChatMemberStatus.ADMINISTRATOR,
|
||||||
@@ -145,7 +139,6 @@ async def my_chat_member_handler(
|
|||||||
except (BadRequest, Forbidden) as e:
|
except (BadRequest, Forbidden) as e:
|
||||||
logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e)
|
logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e)
|
||||||
return
|
return
|
||||||
# Make it a pinned post (bot must be admin with "Pin messages" right)
|
|
||||||
pinned = False
|
pinned = False
|
||||||
try:
|
try:
|
||||||
await context.bot.pin_chat_message(
|
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)
|
await _schedule_next_update(context.application, chat_id, next_end)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Bot removed from group
|
|
||||||
if new.status in (ChatMemberStatus.LEFT, ChatMemberStatus.BANNED):
|
if new.status in (ChatMemberStatus.LEFT, ChatMemberStatus.BANNED):
|
||||||
await asyncio.get_running_loop().run_in_executor(
|
await asyncio.get_running_loop().run_in_executor(
|
||||||
None, _sync_delete_pin, chat_id
|
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:
|
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()
|
loop = asyncio.get_running_loop()
|
||||||
chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync)
|
chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync)
|
||||||
for chat_id in chat_ids:
|
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:
|
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:
|
if not update.message or not update.effective_chat:
|
||||||
return
|
return
|
||||||
chat = update.effective_chat
|
chat = update.effective_chat
|
||||||
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import config
|
import duty_teller.config as config
|
||||||
from telegram import Update
|
from telegram import Update
|
||||||
from telegram.ext import CommandHandler, ContextTypes, MessageHandler, filters
|
from telegram.ext import CommandHandler, ContextTypes, MessageHandler, filters
|
||||||
|
|
||||||
from db.session import session_scope
|
from duty_teller.db.session import session_scope
|
||||||
from importers.duty_schedule import DutyScheduleParseError, parse_duty_schedule
|
from duty_teller.importers.duty_schedule import (
|
||||||
from services.import_service import run_import
|
DutyScheduleParseError,
|
||||||
from utils.handover import parse_handover_time
|
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(
|
async def import_duty_schedule_cmd(
|
||||||
@@ -67,7 +70,6 @@ async def handle_duty_schedule_document(
|
|||||||
hour_utc, minute_utc = handover
|
hour_utc, minute_utc = handover
|
||||||
file_id = update.message.document.file_id
|
file_id = update.message.document.file_id
|
||||||
|
|
||||||
# Download and parse in async context
|
|
||||||
file = await context.bot.get_file(file_id)
|
file = await context.bot.get_file(file_id)
|
||||||
raw = bytes(await file.download_as_bytearray())
|
raw = bytes(await file.download_as_bytearray())
|
||||||
try:
|
try:
|
||||||
84
duty_teller/run.py
Normal file
84
duty_teller/run.py
Normal file
@@ -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"])
|
||||||
@@ -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).
|
from duty_teller.services.group_duty_pin_service import (
|
||||||
No Telegram or HTTP dependencies; repository handles persistence.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from services.group_duty_pin_service import (
|
|
||||||
format_duty_message,
|
format_duty_message,
|
||||||
get_duty_message_text,
|
get_duty_message_text,
|
||||||
get_next_shift_end_utc,
|
get_next_shift_end_utc,
|
||||||
@@ -13,7 +9,7 @@ from services.group_duty_pin_service import (
|
|||||||
get_message_id,
|
get_message_id,
|
||||||
get_all_pin_chat_ids,
|
get_all_pin_chat_ids,
|
||||||
)
|
)
|
||||||
from services.import_service import run_import
|
from duty_teller.services.import_service import run_import
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"format_duty_message",
|
"format_duty_message",
|
||||||
@@ -5,7 +5,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from db.repository import (
|
from duty_teller.db.repository import (
|
||||||
get_current_duty,
|
get_current_duty,
|
||||||
get_next_shift_end,
|
get_next_shift_end,
|
||||||
get_group_duty_pin,
|
get_group_duty_pin,
|
||||||
@@ -4,13 +4,13 @@ from datetime import date, timedelta
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from db.repository import (
|
from duty_teller.db.repository import (
|
||||||
get_or_create_user_by_full_name,
|
get_or_create_user_by_full_name,
|
||||||
delete_duties_in_range,
|
delete_duties_in_range,
|
||||||
insert_duty,
|
insert_duty,
|
||||||
)
|
)
|
||||||
from importers.duty_schedule import DutyScheduleResult
|
from duty_teller.importers.duty_schedule import DutyScheduleResult
|
||||||
from utils.dates import day_start_iso, day_end_iso, duty_to_iso
|
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]]:
|
def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]:
|
||||||
@@ -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 duty_teller.utils.dates import (
|
||||||
"""
|
|
||||||
|
|
||||||
from utils.dates import (
|
|
||||||
day_end_iso,
|
day_end_iso,
|
||||||
day_start_iso,
|
day_start_iso,
|
||||||
duty_to_iso,
|
duty_to_iso,
|
||||||
parse_iso_date,
|
parse_iso_date,
|
||||||
validate_date_range,
|
validate_date_range,
|
||||||
)
|
)
|
||||||
from utils.user import build_full_name
|
from duty_teller.utils.user import build_full_name
|
||||||
from utils.handover import parse_handover_time
|
from duty_teller.utils.handover import parse_handover_time
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"day_start_iso",
|
"day_start_iso",
|
||||||
@@ -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")
|
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}$")
|
_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:
|
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.
|
"""Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date. Raises ValueError if invalid."""
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: With a user-facing message if invalid.
|
|
||||||
"""
|
|
||||||
if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""):
|
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")
|
raise ValueError("Параметры from и to должны быть в формате YYYY-MM-DD")
|
||||||
if from_date > to_date:
|
if from_date > to_date:
|
||||||
86
main.py
86
main.py
@@ -1,88 +1,6 @@
|
|||||||
"""Single entry point: build Application, run HTTP server + polling. Migrations run in Docker entrypoint."""
|
"""Entry point: delegate to duty_teller.run.main(). Run with python main.py or duty-teller."""
|
||||||
|
|
||||||
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"])
|
|
||||||
|
|
||||||
|
from duty_teller.run import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ dependencies = [
|
|||||||
"icalendar>=5.0,<6.0",
|
"icalendar>=5.0,<6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
duty-teller = "duty_teller.run:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0,<9.0",
|
"pytest>=8.0,<9.0",
|
||||||
@@ -28,7 +31,7 @@ dev = [
|
|||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["db", "handlers", "api", "importers", "utils", "services"]
|
include = ["duty_teller*"]
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
|
|||||||
33
tests/helpers.py
Normal file
33
tests/helpers.py
Normal file
@@ -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()))
|
||||||
@@ -6,9 +6,9 @@ from unittest.mock import ANY, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
import config
|
import duty_teller.config as config
|
||||||
from api.app import app
|
from duty_teller.api.app import app
|
||||||
from tests.test_telegram_auth import _make_init_data
|
from tests.helpers import make_init_data
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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"]
|
assert "from" in r.json()["detail"].lower() or "позже" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
@patch("api.app._is_private_client")
|
@patch("duty_teller.api.dependencies._is_private_client")
|
||||||
@patch("api.app.config.MINI_APP_SKIP_AUTH", False)
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
def test_duties_403_without_init_data_from_public_client(mock_private, client):
|
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
|
||||||
mock_private.return_value = False # simulate public client
|
|
||||||
r = client.get(
|
r = client.get(
|
||||||
"/api/duties",
|
"/api/duties",
|
||||||
params={"from": "2025-01-01", "to": "2025-01-31"},
|
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
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
@patch("api.app.config.MINI_APP_SKIP_AUTH", True)
|
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
|
||||||
@patch("api.app._fetch_duties_response")
|
@patch("duty_teller.api.app.fetch_duties_response")
|
||||||
def test_duties_200_when_skip_auth(mock_fetch, client):
|
def test_duties_200_when_skip_auth(mock_fetch, client):
|
||||||
mock_fetch.return_value = []
|
mock_fetch.return_value = []
|
||||||
r = client.get(
|
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")
|
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):
|
def test_duties_403_when_init_data_invalid(mock_validate, client):
|
||||||
mock_validate.return_value = (None, "hash_mismatch")
|
mock_validate.return_value = (None, "hash_mismatch")
|
||||||
r = client.get(
|
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
|
assert "авторизации" in detail or "Неверные" in detail or "Неверная" in detail
|
||||||
|
|
||||||
|
|
||||||
@patch("api.app.validate_init_data_with_reason")
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
@patch("api.app.config.can_access_miniapp")
|
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
|
||||||
def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, client):
|
def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, client):
|
||||||
mock_validate.return_value = ("someuser", "ok")
|
mock_validate.return_value = ("someuser", "ok")
|
||||||
mock_can_access.return_value = False
|
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(
|
r = client.get(
|
||||||
"/api/duties",
|
"/api/duties",
|
||||||
params={"from": "2025-01-01", "to": "2025-01-31"},
|
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()
|
mock_fetch.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@patch("api.app.validate_init_data_with_reason")
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
@patch("api.app.config.can_access_miniapp")
|
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
|
||||||
def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
|
def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
|
||||||
mock_validate.return_value = ("alloweduser", "ok")
|
mock_validate.return_value = ("alloweduser", "ok")
|
||||||
mock_can_access.return_value = True
|
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 = [
|
mock_fetch.return_value = [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"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):
|
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_token = "123:ABC"
|
||||||
test_username = "e2euser"
|
test_username = "e2euser"
|
||||||
monkeypatch.setattr(config, "BOT_TOKEN", test_token)
|
monkeypatch.setattr(config, "BOT_TOKEN", test_token)
|
||||||
monkeypatch.setattr(config, "ALLOWED_USERNAMES", {test_username})
|
monkeypatch.setattr(config, "ALLOWED_USERNAMES", {test_username})
|
||||||
monkeypatch.setattr(config, "ADMIN_USERNAMES", set())
|
monkeypatch.setattr(config, "ADMIN_USERNAMES", set())
|
||||||
monkeypatch.setattr(config, "INIT_DATA_MAX_AGE_SECONDS", 0)
|
monkeypatch.setattr(config, "INIT_DATA_MAX_AGE_SECONDS", 0)
|
||||||
init_data = _make_init_data(
|
init_data = make_init_data(
|
||||||
{"id": 1, "username": test_username},
|
{"id": 1, "username": test_username},
|
||||||
test_token,
|
test_token,
|
||||||
auth_date=int(time.time()),
|
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 = []
|
mock_fetch.return_value = []
|
||||||
r = client.get(
|
r = client.get(
|
||||||
"/api/duties",
|
"/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")
|
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):
|
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
|
from types import SimpleNamespace
|
||||||
|
|
||||||
fake_duty = 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):
|
def fake_get_duties(session, from_date, to_date):
|
||||||
return [(fake_duty, "User A")]
|
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(
|
r = client.get(
|
||||||
"/api/duties",
|
"/api/duties",
|
||||||
params={"from": "2025-01-01", "to": "2025-01-31"},
|
params={"from": "2025-01-01", "to": "2025-01-31"},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for config.is_admin and config.can_access_miniapp."""
|
"""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):
|
def test_is_admin_true_when_in_admin_list(monkeypatch):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import date
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from importers.duty_schedule import (
|
from duty_teller.importers.duty_schedule import (
|
||||||
DUTY_MARKERS,
|
DUTY_MARKERS,
|
||||||
UNAVAILABLE_MARKER,
|
UNAVAILABLE_MARKER,
|
||||||
VACATION_MARKER,
|
VACATION_MARKER,
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ from datetime import date
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from db import init_db
|
from duty_teller.db import init_db
|
||||||
from db.repository import get_duties
|
from duty_teller.db.repository import get_duties
|
||||||
from db.session import get_session, session_scope
|
from duty_teller.db.session import get_session, session_scope
|
||||||
from importers.duty_schedule import (
|
from duty_teller.importers.duty_schedule import (
|
||||||
DutyScheduleEntry,
|
DutyScheduleEntry,
|
||||||
DutyScheduleResult,
|
DutyScheduleResult,
|
||||||
parse_duty_schedule,
|
parse_duty_schedule,
|
||||||
)
|
)
|
||||||
from services.import_service import run_import
|
from duty_teller.services.import_service import run_import
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -23,7 +23,7 @@ def db_url():
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_db_session(db_url):
|
def _reset_db_session(db_url):
|
||||||
"""Ensure each test uses a fresh engine for :memory: (clear global cache for test 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._engine = None
|
||||||
session_module._SessionLocal = None
|
session_module._SessionLocal = None
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import pytest
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from db.models import Base, User
|
from duty_teller.db.models import Base, User
|
||||||
from db.repository import (
|
from duty_teller.db.repository import (
|
||||||
delete_duties_in_range,
|
delete_duties_in_range,
|
||||||
get_or_create_user_by_full_name,
|
get_or_create_user_by_full_name,
|
||||||
get_duties,
|
get_duties,
|
||||||
|
|||||||
@@ -1,59 +1,27 @@
|
|||||||
"""Tests for api.telegram_auth.validate_init_data."""
|
"""Tests for duty_teller.api.telegram_auth.validate_init_data."""
|
||||||
|
|
||||||
import hashlib
|
from duty_teller.api.telegram_auth import validate_init_data
|
||||||
import hmac
|
from tests.helpers import make_init_data
|
||||||
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()))
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_payload_returns_username():
|
def test_valid_payload_returns_username():
|
||||||
bot_token = "123:ABC"
|
bot_token = "123:ABC"
|
||||||
user = {"id": 123, "username": "testuser", "first_name": "Test"}
|
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"
|
assert validate_init_data(init_data, bot_token) == "testuser"
|
||||||
|
|
||||||
|
|
||||||
def test_valid_payload_username_lowercase():
|
def test_valid_payload_username_lowercase():
|
||||||
bot_token = "123:ABC"
|
bot_token = "123:ABC"
|
||||||
user = {"id": 123, "username": "TestUser", "first_name": "Test"}
|
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"
|
assert validate_init_data(init_data, bot_token) == "testuser"
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_hash_returns_none():
|
def test_invalid_hash_returns_none():
|
||||||
bot_token = "123:ABC"
|
bot_token = "123:ABC"
|
||||||
user = {"id": 123, "username": "testuser"}
|
user = {"id": 123, "username": "testuser"}
|
||||||
init_data = _make_init_data(user, bot_token)
|
init_data = make_init_data(user, bot_token)
|
||||||
# Tamper with hash
|
# Tamper with hash
|
||||||
init_data = init_data.replace("hash=", "hash=x")
|
init_data = init_data.replace("hash=", "hash=x")
|
||||||
assert validate_init_data(init_data, bot_token) is None
|
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():
|
def test_wrong_bot_token_returns_none():
|
||||||
bot_token = "123:ABC"
|
bot_token = "123:ABC"
|
||||||
user = {"id": 123, "username": "testuser"}
|
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
|
assert validate_init_data(init_data, "other:token") is None
|
||||||
|
|
||||||
|
|
||||||
def test_missing_user_returns_none():
|
def test_missing_user_returns_none():
|
||||||
bot_token = "123:ABC"
|
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
|
assert validate_init_data(init_data, bot_token) is None
|
||||||
|
|
||||||
|
|
||||||
def test_user_without_username_returns_none():
|
def test_user_without_username_returns_none():
|
||||||
bot_token = "123:ABC"
|
bot_token = "123:ABC"
|
||||||
user = {"id": 123, "first_name": "Test"} # no username
|
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
|
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():
|
def test_empty_bot_token_returns_none():
|
||||||
user = {"id": 1, "username": "u"}
|
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
|
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"}
|
user = {"id": 1, "username": "testuser"}
|
||||||
# auth_date 100 seconds ago
|
# auth_date 100 seconds ago
|
||||||
old_ts = int(t.time()) - 100
|
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=60) is None
|
||||||
assert validate_init_data(init_data, bot_token, max_age_seconds=200) == "testuser"
|
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"
|
bot_token = "123:ABC"
|
||||||
user = {"id": 1, "username": "testuser"}
|
user = {"id": 1, "username": "testuser"}
|
||||||
fresh_ts = int(t.time()) - 10
|
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"
|
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."""
|
"""When max_age_seconds is set but auth_date is missing, return None."""
|
||||||
bot_token = "123:ABC"
|
bot_token = "123:ABC"
|
||||||
user = {"id": 1, "username": "testuser"}
|
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, max_age_seconds=86400) is None
|
||||||
assert validate_init_data(init_data, bot_token) == "testuser"
|
assert validate_init_data(init_data, bot_token) == "testuser"
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ from datetime import date
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from utils.dates import (
|
from duty_teller.utils.dates import (
|
||||||
day_start_iso,
|
day_start_iso,
|
||||||
day_end_iso,
|
day_end_iso,
|
||||||
duty_to_iso,
|
duty_to_iso,
|
||||||
parse_iso_date,
|
parse_iso_date,
|
||||||
validate_date_range,
|
validate_date_range,
|
||||||
)
|
)
|
||||||
from utils.user import build_full_name
|
from duty_teller.utils.user import build_full_name
|
||||||
from utils.handover import parse_handover_time
|
from duty_teller.utils.handover import parse_handover_time
|
||||||
|
|
||||||
|
|
||||||
# --- dates ---
|
# --- dates ---
|
||||||
|
|||||||
Reference in New Issue
Block a user