chore: add changelog and documentation updates
All checks were successful
CI / lint-and-test (push) Successful in 17s
All checks were successful
CI / lint-and-test (push) Successful in 17s
- Created a new `CHANGELOG.md` file to document all notable changes to the project, adhering to the Keep a Changelog format. - Updated `CONTRIBUTING.md` to include instructions for building and previewing documentation using MkDocs. - Added `mkdocs.yml` configuration for documentation generation, including navigation structure and theme settings. - Enhanced various documentation files, including API reference, architecture overview, configuration reference, and runbook, to provide comprehensive guidance for users and developers. - Included new sections in the README for changelog and documentation links, improving accessibility to project information.
This commit is contained in:
@@ -1 +1 @@
|
||||
# HTTP API for Mini App
|
||||
"""HTTP API for the calendar Mini App: duties, calendar events, and static webapp."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""FastAPI app: /api/duties and static webapp."""
|
||||
"""FastAPI app: /api/duties, /api/calendar-events, personal ICS, and static webapp at /app."""
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
@@ -33,7 +33,12 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/duties", response_model=list[DutyWithUser])
|
||||
@app.get(
|
||||
"/api/duties",
|
||||
response_model=list[DutyWithUser],
|
||||
summary="List duties",
|
||||
description="Returns duties for the given date range. Requires Telegram Mini App initData (or MINI_APP_SKIP_AUTH / private IP in dev).",
|
||||
)
|
||||
def list_duties(
|
||||
request: Request,
|
||||
dates: tuple[str, str] = Depends(get_validated_dates),
|
||||
@@ -48,7 +53,12 @@ def list_duties(
|
||||
return fetch_duties_response(session, from_date_val, to_date_val)
|
||||
|
||||
|
||||
@app.get("/api/calendar-events", response_model=list[CalendarEvent])
|
||||
@app.get(
|
||||
"/api/calendar-events",
|
||||
response_model=list[CalendarEvent],
|
||||
summary="List calendar events",
|
||||
description="Returns calendar events for the date range, including external ICS when EXTERNAL_CALENDAR_ICS_URL is set. Auth same as /api/duties.",
|
||||
)
|
||||
def list_calendar_events(
|
||||
dates: tuple[str, str] = Depends(get_validated_dates),
|
||||
_username: str = Depends(require_miniapp_username),
|
||||
@@ -61,7 +71,11 @@ def list_calendar_events(
|
||||
return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events]
|
||||
|
||||
|
||||
@app.get("/api/calendar/ical/{token}.ics")
|
||||
@app.get(
|
||||
"/api/calendar/ical/{token}.ics",
|
||||
summary="Personal calendar ICS",
|
||||
description="Returns an ICS calendar with only the subscribing user's duties. No Telegram auth; access is by secret token in the URL.",
|
||||
)
|
||||
def get_personal_calendar_ical(
|
||||
token: str,
|
||||
session: Session = Depends(get_db_session),
|
||||
|
||||
@@ -102,9 +102,18 @@ def get_calendar_events(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Return list of {date: "YYYY-MM-DD", summary: "..."} for events in [from_date, to_date].
|
||||
Uses in-memory cache with TTL 7 days. On fetch/parse error returns [].
|
||||
"""Fetch ICS from URL and return events in the given date range.
|
||||
|
||||
Uses in-memory cache with TTL 7 days. Recurring events are skipped.
|
||||
On fetch or parse error returns an empty list.
|
||||
|
||||
Args:
|
||||
url: URL of the ICS calendar.
|
||||
from_date: Start date YYYY-MM-DD.
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys "date" (YYYY-MM-DD) and "summary". Empty on error.
|
||||
"""
|
||||
if not url or from_date > to_date:
|
||||
return []
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""FastAPI dependencies: DB session, auth, date validation."""
|
||||
"""FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
@@ -22,7 +22,14 @@ _ACCEPT_LANG_TAG_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-[a-zA-Z0-9]+)?\s*(?:;|,|$
|
||||
|
||||
|
||||
def _lang_from_accept_language(header: str | None) -> str:
|
||||
"""Normalize Accept-Language to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE."""
|
||||
"""Normalize Accept-Language header to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE.
|
||||
|
||||
Args:
|
||||
header: Raw Accept-Language header value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
||||
|
||||
Returns:
|
||||
'ru' or 'en'.
|
||||
"""
|
||||
if not header or not header.strip():
|
||||
return config.DEFAULT_LANGUAGE
|
||||
first = header.strip().split(",")[0].strip()
|
||||
@@ -58,13 +65,26 @@ 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]:
|
||||
"""Validate from/to dates; lang from Accept-Language for error messages."""
|
||||
"""Validate from/to date query params; use Accept-Language for error messages.
|
||||
|
||||
Args:
|
||||
request: FastAPI request (for Accept-Language).
|
||||
from_date: Start date YYYY-MM-DD.
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
(from_date, to_date) as strings.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if format invalid or from_date > to_date.
|
||||
"""
|
||||
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||
_validate_duty_dates(from_date, to_date, lang)
|
||||
return (from_date, to_date)
|
||||
|
||||
|
||||
def get_db_session() -> Generator[Session, None, None]:
|
||||
"""Yield a DB session for the request; closed automatically by FastAPI."""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
yield session
|
||||
|
||||
@@ -76,6 +96,11 @@ def require_miniapp_username(
|
||||
] = None,
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> str:
|
||||
"""FastAPI dependency: require valid Miniapp auth; return username/identifier.
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if initData missing/invalid or user not in allowlist.
|
||||
"""
|
||||
return get_authenticated_username(request, x_telegram_init_data, session)
|
||||
|
||||
|
||||
@@ -100,7 +125,20 @@ def get_authenticated_username(
|
||||
x_telegram_init_data: str | None,
|
||||
session: Session,
|
||||
) -> str:
|
||||
"""Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth."""
|
||||
"""Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth.
|
||||
|
||||
Args:
|
||||
request: FastAPI request (client host for private-IP bypass).
|
||||
x_telegram_init_data: Raw X-Telegram-Init-Data header value.
|
||||
session: DB session (for phone allowlist lookup).
|
||||
|
||||
Returns:
|
||||
Username, full_name, or "id:<telegram_id>"; empty string if MINI_APP_SKIP_AUTH
|
||||
or private IP and no initData.
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if initData missing/invalid or user not in allowlist.
|
||||
"""
|
||||
if config.MINI_APP_SKIP_AUTH:
|
||||
log.warning("allowing without any auth check (MINI_APP_SKIP_AUTH is set)")
|
||||
return ""
|
||||
@@ -142,6 +180,16 @@ def get_authenticated_username(
|
||||
def fetch_duties_response(
|
||||
session: Session, from_date: str, to_date: str
|
||||
) -> list[DutyWithUser]:
|
||||
"""Load duties in range and return as DutyWithUser list for API response.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
from_date: Start date YYYY-MM-DD.
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).
|
||||
"""
|
||||
rows = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
return [
|
||||
DutyWithUser(
|
||||
|
||||
@@ -25,10 +25,14 @@ def _parse_utc_iso(iso_str: str) -> datetime:
|
||||
|
||||
|
||||
def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:
|
||||
"""
|
||||
Build a single VCALENDAR with one VEVENT per duty.
|
||||
duties_with_name: list of (Duty, full_name); full_name is unused for SUMMARY
|
||||
if we use event_type only; can be used later for DESCRIPTION.
|
||||
"""Build a VCALENDAR (ICS) with one VEVENT per duty.
|
||||
|
||||
Args:
|
||||
duties_with_name: List of (Duty, full_name). full_name is available for
|
||||
DESCRIPTION; SUMMARY is taken from event_type (duty/unavailable/vacation).
|
||||
|
||||
Returns:
|
||||
ICS file content as bytes (UTF-8).
|
||||
"""
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//Duty Teller//Personal Calendar//EN")
|
||||
|
||||
@@ -15,7 +15,16 @@ def validate_init_data(
|
||||
bot_token: str,
|
||||
max_age_seconds: int | None = None,
|
||||
) -> str | None:
|
||||
"""Validate initData and return username; see validate_init_data_with_reason for failure reason."""
|
||||
"""Validate Telegram Web App initData and return username if valid.
|
||||
|
||||
Args:
|
||||
init_data: Raw initData string from tgWebAppData.
|
||||
bot_token: Bot token (must match the bot that signed the data).
|
||||
max_age_seconds: Reject if auth_date older than this; None to disable.
|
||||
|
||||
Returns:
|
||||
Username (lowercase, no @) or None if validation fails.
|
||||
"""
|
||||
_, username, _, _ = validate_init_data_with_reason(
|
||||
init_data, bot_token, max_age_seconds
|
||||
)
|
||||
@@ -35,12 +44,19 @@ def validate_init_data_with_reason(
|
||||
bot_token: str,
|
||||
max_age_seconds: int | None = None,
|
||||
) -> tuple[int | None, str | None, str, str]:
|
||||
"""
|
||||
Validate initData signature and return (telegram_user_id, username, reason, lang).
|
||||
reason is one of: "ok", "empty", "no_hash", "hash_mismatch", "auth_date_expired",
|
||||
"no_user", "user_invalid", "no_user_id", "no_username" (legacy; no_user_id used when user.id missing).
|
||||
lang is from user.language_code normalized to 'ru' or 'en'; 'en' when no user.
|
||||
On success: (user.id, user.get('username') or None, "ok", lang) — username may be None.
|
||||
"""Validate initData signature and return user id, username, reason, and lang.
|
||||
|
||||
Args:
|
||||
init_data: Raw initData string from tgWebAppData.
|
||||
bot_token: Bot token (must match the bot that signed the data).
|
||||
max_age_seconds: Reject if auth_date older than this; None to disable.
|
||||
|
||||
Returns:
|
||||
Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok",
|
||||
"empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user",
|
||||
"user_invalid", "no_user_id". lang is from user.language_code normalized
|
||||
to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,
|
||||
"ok", lang).
|
||||
"""
|
||||
if not init_data or not bot_token:
|
||||
return (None, None, "empty", "en")
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Load configuration from environment. BOT_TOKEN is not validated on import; check in main/entry point."""
|
||||
"""Load configuration from environment (e.g. .env via python-dotenv).
|
||||
|
||||
BOT_TOKEN is not validated on import; call require_bot_token() in the entry point
|
||||
when running the bot.
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
@@ -17,7 +21,14 @@ _PHONE_DIGITS_RE = re.compile(r"\D")
|
||||
|
||||
|
||||
def normalize_phone(phone: str | None) -> str:
|
||||
"""Return phone as digits only (spaces, +, parentheses, dashes removed). Empty string if None/empty."""
|
||||
"""Return phone as digits only (spaces, +, parentheses, dashes removed).
|
||||
|
||||
Args:
|
||||
phone: Raw phone string or None.
|
||||
|
||||
Returns:
|
||||
Digits-only string, or empty string if None or empty.
|
||||
"""
|
||||
if not phone or not isinstance(phone, str):
|
||||
return ""
|
||||
return _PHONE_DIGITS_RE.sub("", phone.strip())
|
||||
@@ -43,7 +54,7 @@ def _parse_phone_list(raw: str) -> set[str]:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
"""Optional injectable settings built from env. Tests can override or build from env."""
|
||||
"""Injectable settings built from environment. Used in tests or when env is overridden."""
|
||||
|
||||
bot_token: str
|
||||
database_url: str
|
||||
@@ -62,7 +73,11 @@ class Settings:
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
"""Build Settings from current environment (same logic as module-level vars)."""
|
||||
"""Build Settings from current environment (same logic as module-level variables).
|
||||
|
||||
Returns:
|
||||
Settings instance with all fields populated from env.
|
||||
"""
|
||||
bot_token = os.getenv("BOT_TOKEN") or ""
|
||||
raw_allowed = os.getenv("ALLOWED_USERNAMES", "").strip()
|
||||
allowed = {
|
||||
@@ -143,18 +158,39 @@ DEFAULT_LANGUAGE = _normalize_default_language(
|
||||
|
||||
|
||||
def is_admin(username: str) -> bool:
|
||||
"""True if the given Telegram username (no @, any case) is in ADMIN_USERNAMES."""
|
||||
"""Check if Telegram username is in ADMIN_USERNAMES.
|
||||
|
||||
Args:
|
||||
username: Telegram username (with or without @; case-insensitive).
|
||||
|
||||
Returns:
|
||||
True if in ADMIN_USERNAMES.
|
||||
"""
|
||||
return (username or "").strip().lower() in ADMIN_USERNAMES
|
||||
|
||||
|
||||
def can_access_miniapp(username: str) -> bool:
|
||||
"""True if username is in ALLOWED_USERNAMES or ADMIN_USERNAMES."""
|
||||
"""Check if username is allowed to open the calendar Miniapp.
|
||||
|
||||
Args:
|
||||
username: Telegram username (with or without @; case-insensitive).
|
||||
|
||||
Returns:
|
||||
True if in ALLOWED_USERNAMES or ADMIN_USERNAMES.
|
||||
"""
|
||||
u = (username or "").strip().lower()
|
||||
return u in ALLOWED_USERNAMES or u in ADMIN_USERNAMES
|
||||
|
||||
|
||||
def can_access_miniapp_by_phone(phone: str | None) -> bool:
|
||||
"""True if normalized phone is in ALLOWED_PHONES or ADMIN_PHONES."""
|
||||
"""Check if phone (set via /set_phone) is allowed to open the Miniapp.
|
||||
|
||||
Args:
|
||||
phone: Raw phone string or None.
|
||||
|
||||
Returns:
|
||||
True if normalized phone is in ALLOWED_PHONES or ADMIN_PHONES.
|
||||
"""
|
||||
normalized = normalize_phone(phone)
|
||||
if not normalized:
|
||||
return False
|
||||
@@ -162,13 +198,27 @@ def can_access_miniapp_by_phone(phone: str | None) -> bool:
|
||||
|
||||
|
||||
def is_admin_by_phone(phone: str | None) -> bool:
|
||||
"""True if normalized phone is in ADMIN_PHONES."""
|
||||
"""Check if phone is in ADMIN_PHONES.
|
||||
|
||||
Args:
|
||||
phone: Raw phone string or None.
|
||||
|
||||
Returns:
|
||||
True if normalized phone is in ADMIN_PHONES.
|
||||
"""
|
||||
normalized = normalize_phone(phone)
|
||||
return bool(normalized and normalized in ADMIN_PHONES)
|
||||
|
||||
|
||||
def require_bot_token() -> None:
|
||||
"""Raise SystemExit with a clear message if BOT_TOKEN is not set. Call from entry point."""
|
||||
"""Raise SystemExit with a clear message if BOT_TOKEN is not set.
|
||||
|
||||
Call from the application entry point (e.g. main.py or duty_teller.run) so the
|
||||
process exits with a helpful message instead of failing later.
|
||||
|
||||
Raises:
|
||||
SystemExit: If BOT_TOKEN is empty.
|
||||
"""
|
||||
if not BOT_TOKEN:
|
||||
raise SystemExit(
|
||||
"BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather."
|
||||
|
||||
@@ -49,6 +49,12 @@ __all__ = [
|
||||
|
||||
|
||||
def init_db(database_url: str) -> None:
|
||||
"""Create tables from metadata (Alembic migrations handle schema in production)."""
|
||||
"""Create all tables from SQLAlchemy metadata.
|
||||
|
||||
Prefer Alembic migrations for schema changes in production.
|
||||
|
||||
Args:
|
||||
database_url: SQLAlchemy database URL.
|
||||
"""
|
||||
engine = get_engine(database_url)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -11,6 +11,8 @@ class Base(DeclarativeBase):
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Telegram user and display name; may have telegram_user_id=None for import-only users."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -43,6 +45,8 @@ class CalendarSubscriptionToken(Base):
|
||||
|
||||
|
||||
class Duty(Base):
|
||||
"""Single duty/unavailable/vacation slot (UTC start_at/end_at, event_type)."""
|
||||
|
||||
__tablename__ = "duties"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
@@ -11,7 +11,15 @@ from duty_teller.db.models import User, Duty, GroupDutyPin, CalendarSubscription
|
||||
|
||||
|
||||
def get_user_by_telegram_id(session: Session, telegram_user_id: int) -> User | None:
|
||||
"""Find user by telegram_user_id. Returns None if not found (no creation)."""
|
||||
"""Find user by Telegram user ID.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
telegram_user_id: Telegram user id.
|
||||
|
||||
Returns:
|
||||
User or None if not found. Does not create a user.
|
||||
"""
|
||||
return session.query(User).filter(User.telegram_user_id == telegram_user_id).first()
|
||||
|
||||
|
||||
@@ -23,10 +31,22 @@ def get_or_create_user(
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
) -> User:
|
||||
"""
|
||||
Get or create user by telegram_user_id. On create, name comes from Telegram.
|
||||
On update: username is always synced; full_name/first_name/last_name are only
|
||||
updated if name_manually_edited is False (otherwise keep existing display name).
|
||||
"""Get or create user by Telegram user ID.
|
||||
|
||||
On create, name fields come from Telegram. On update: username is always
|
||||
synced; full_name, first_name, last_name are updated only if
|
||||
name_manually_edited is False (otherwise existing display name is kept).
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
telegram_user_id: Telegram user id.
|
||||
full_name: Display full name.
|
||||
username: Telegram username (optional).
|
||||
first_name: Telegram first name (optional).
|
||||
last_name: Telegram last name (optional).
|
||||
|
||||
Returns:
|
||||
User instance (created or updated).
|
||||
"""
|
||||
user = get_user_by_telegram_id(session, telegram_user_id)
|
||||
if user:
|
||||
@@ -53,9 +73,16 @@ def get_or_create_user(
|
||||
|
||||
|
||||
def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
|
||||
"""
|
||||
Find user by exact full_name or create one with telegram_user_id=None (for duty-schedule import).
|
||||
New users get name_manually_edited=True since the name comes from import, not Telegram.
|
||||
"""Find user by exact full_name or create one (for duty-schedule import).
|
||||
|
||||
New users have telegram_user_id=None and name_manually_edited=True.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
full_name: Exact full name to match or set.
|
||||
|
||||
Returns:
|
||||
User instance (existing or newly created).
|
||||
"""
|
||||
user = session.query(User).filter(User.full_name == full_name).first()
|
||||
if user:
|
||||
@@ -81,10 +108,20 @@ def update_user_display_name(
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
) -> User | None:
|
||||
"""
|
||||
Update display name for user by telegram_user_id and set name_manually_edited=True.
|
||||
Use from API or admin when name is changed manually; subsequent get_or_create_user
|
||||
will not overwrite these fields. Returns User or None if not found.
|
||||
"""Update display name and set name_manually_edited=True.
|
||||
|
||||
Use from API or admin when name is changed manually; subsequent
|
||||
get_or_create_user will not overwrite these fields.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
telegram_user_id: Telegram user id.
|
||||
full_name: New full name.
|
||||
first_name: New first name (optional).
|
||||
last_name: New last name (optional).
|
||||
|
||||
Returns:
|
||||
Updated User or None if not found.
|
||||
"""
|
||||
user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()
|
||||
if not user:
|
||||
@@ -104,7 +141,17 @@ def delete_duties_in_range(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> 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 the given date range.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
user_id: User id.
|
||||
from_date: Start date YYYY-MM-DD.
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
Number of duties deleted.
|
||||
"""
|
||||
to_next = (
|
||||
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
|
||||
).strftime("%Y-%m-%d")
|
||||
@@ -124,7 +171,16 @@ def get_duties(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> list[tuple[Duty, str]]:
|
||||
"""Return list of (Duty, full_name) overlapping the given date range."""
|
||||
"""Return duties overlapping the given date range with user full_name.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
from_date: Start date YYYY-MM-DD.
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of (Duty, full_name) tuples.
|
||||
"""
|
||||
to_date_next = (
|
||||
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
|
||||
).strftime("%Y-%m-%d")
|
||||
@@ -142,7 +198,17 @@ def get_duties_for_user(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> list[tuple[Duty, str]]:
|
||||
"""Return list of (Duty, full_name) for the given user overlapping the date range."""
|
||||
"""Return duties for one user overlapping the date range.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
user_id: User id.
|
||||
from_date: Start date YYYY-MM-DD.
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of (Duty, full_name) tuples.
|
||||
"""
|
||||
to_date_next = (
|
||||
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
|
||||
).strftime("%Y-%m-%d")
|
||||
@@ -164,9 +230,17 @@ def _token_hash(token: str) -> str:
|
||||
|
||||
|
||||
def create_calendar_token(session: Session, user_id: int) -> str:
|
||||
"""
|
||||
Create a new calendar subscription token for the user.
|
||||
Removes any existing tokens for this user. Returns the raw token string.
|
||||
"""Create a new calendar subscription token for the user.
|
||||
|
||||
Any existing tokens for this user are removed. The raw token is returned
|
||||
only once (not stored in plain text).
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
user_id: User id.
|
||||
|
||||
Returns:
|
||||
Raw token string (e.g. for URL /api/calendar/ical/{token}.ics).
|
||||
"""
|
||||
session.query(CalendarSubscriptionToken).filter(
|
||||
CalendarSubscriptionToken.user_id == user_id
|
||||
@@ -185,9 +259,16 @@ def create_calendar_token(session: Session, user_id: int) -> str:
|
||||
|
||||
|
||||
def get_user_by_calendar_token(session: Session, token: str) -> User | None:
|
||||
"""
|
||||
Find user by calendar subscription token. Uses constant-time comparison.
|
||||
Returns None if token is invalid or not found.
|
||||
"""Find user by calendar subscription token.
|
||||
|
||||
Uses constant-time comparison to avoid timing leaks.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
token: Raw token from URL.
|
||||
|
||||
Returns:
|
||||
User or None if token is invalid or not found.
|
||||
"""
|
||||
token_hash_val = _token_hash(token)
|
||||
row = (
|
||||
@@ -211,7 +292,18 @@ 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."""
|
||||
"""Create a duty record.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
user_id: User id.
|
||||
start_at: Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).
|
||||
end_at: End time UTC, ISO 8601 with Z.
|
||||
event_type: One of "duty", "unavailable", "vacation". Default "duty".
|
||||
|
||||
Returns:
|
||||
Created Duty instance.
|
||||
"""
|
||||
duty = Duty(
|
||||
user_id=user_id,
|
||||
start_at=start_at,
|
||||
@@ -225,7 +317,15 @@ 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'."""
|
||||
"""Return the duty and user active at the given UTC time (event_type='duty').
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
at_utc: Point in time (timezone-aware or naive UTC).
|
||||
|
||||
Returns:
|
||||
(Duty, User) or None if no duty at that time.
|
||||
"""
|
||||
from datetime import timezone
|
||||
|
||||
if at_utc.tzinfo is not None:
|
||||
@@ -247,7 +347,15 @@ 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 or of the next duty. Naive UTC."""
|
||||
"""Return the end_at of the current or next duty (event_type='duty').
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
after_utc: Point in time (timezone-aware or naive UTC).
|
||||
|
||||
Returns:
|
||||
End datetime (naive UTC) or None if no current or future duty.
|
||||
"""
|
||||
from datetime import timezone
|
||||
|
||||
if after_utc.tzinfo is not None:
|
||||
@@ -280,14 +388,31 @@ def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None
|
||||
|
||||
|
||||
def get_group_duty_pin(session: Session, chat_id: int) -> GroupDutyPin | None:
|
||||
"""Get the pinned message record for a chat, if any."""
|
||||
"""Get the pinned duty message record for a chat.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
|
||||
Returns:
|
||||
GroupDutyPin or None.
|
||||
"""
|
||||
return session.query(GroupDutyPin).filter(GroupDutyPin.chat_id == chat_id).first()
|
||||
|
||||
|
||||
def save_group_duty_pin(
|
||||
session: Session, chat_id: int, message_id: int
|
||||
) -> GroupDutyPin:
|
||||
"""Save or update the pinned message for a chat."""
|
||||
"""Save or update the pinned duty message for a chat.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
message_id: Message id to pin/update.
|
||||
|
||||
Returns:
|
||||
GroupDutyPin instance (created or updated).
|
||||
"""
|
||||
pin = session.query(GroupDutyPin).filter(GroupDutyPin.chat_id == chat_id).first()
|
||||
if pin:
|
||||
pin.message_id = message_id
|
||||
@@ -300,13 +425,27 @@ def save_group_duty_pin(
|
||||
|
||||
|
||||
def delete_group_duty_pin(session: Session, chat_id: int) -> None:
|
||||
"""Remove the pinned message record when the bot leaves the group."""
|
||||
"""Remove the pinned duty message record for the chat (e.g. when bot leaves group).
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
"""
|
||||
session.query(GroupDutyPin).filter(GroupDutyPin.chat_id == chat_id).delete()
|
||||
session.commit()
|
||||
|
||||
|
||||
def get_all_group_duty_pin_chat_ids(session: Session) -> list[int]:
|
||||
"""Return all chat_ids that have a pinned duty message (for restoring jobs on startup)."""
|
||||
"""Return all chat_ids that have a pinned duty message.
|
||||
|
||||
Used to restore update jobs on bot startup.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
|
||||
Returns:
|
||||
List of chat ids.
|
||||
"""
|
||||
rows = session.query(GroupDutyPin.chat_id).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
@@ -314,7 +453,16 @@ def get_all_group_duty_pin_chat_ids(session: Session) -> list[int]:
|
||||
def set_user_phone(
|
||||
session: Session, telegram_user_id: int, phone: str | None
|
||||
) -> User | None:
|
||||
"""Set phone for user by telegram_user_id. Returns User or None if not found."""
|
||||
"""Set or clear phone for user by Telegram user id.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
telegram_user_id: Telegram user id.
|
||||
phone: Phone string or None to clear.
|
||||
|
||||
Returns:
|
||||
Updated User or None if not found.
|
||||
"""
|
||||
user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Pydantic schemas for API and validation."""
|
||||
"""Pydantic schemas for API request/response and validation."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
@@ -6,6 +6,8 @@ from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base user fields (full_name, username, first/last name)."""
|
||||
|
||||
full_name: str
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
@@ -13,10 +15,14 @@ class UserBase(BaseModel):
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""User creation payload including Telegram user id."""
|
||||
|
||||
telegram_user_id: int
|
||||
|
||||
|
||||
class UserInDb(UserBase):
|
||||
"""User as stored in DB (includes id and telegram_user_id)."""
|
||||
|
||||
id: int
|
||||
telegram_user_id: int
|
||||
|
||||
@@ -24,16 +30,22 @@ class UserInDb(UserBase):
|
||||
|
||||
|
||||
class DutyBase(BaseModel):
|
||||
"""Duty fields: user_id, start_at, end_at (UTC ISO 8601 with Z)."""
|
||||
|
||||
user_id: int
|
||||
start_at: str # UTC, ISO 8601 with Z
|
||||
end_at: str # UTC, ISO 8601 with Z
|
||||
|
||||
|
||||
class DutyCreate(DutyBase):
|
||||
"""Duty creation payload."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DutyInDb(DutyBase):
|
||||
"""Duty as stored in DB (includes id)."""
|
||||
|
||||
id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -21,7 +21,14 @@ _SessionLocal = None
|
||||
|
||||
@contextmanager
|
||||
def session_scope(database_url: str) -> Generator[Session, None, None]:
|
||||
"""Context manager: yields a session, rolls back on exception, closes on exit."""
|
||||
"""Context manager that yields a session; rolls back on exception, closes on exit.
|
||||
|
||||
Args:
|
||||
database_url: SQLAlchemy database URL.
|
||||
|
||||
Yields:
|
||||
Session instance. Caller must not use it after exit.
|
||||
"""
|
||||
session = get_session(database_url)
|
||||
try:
|
||||
yield session
|
||||
@@ -33,6 +40,7 @@ def session_scope(database_url: str) -> Generator[Session, None, None]:
|
||||
|
||||
|
||||
def get_engine(database_url: str):
|
||||
"""Return cached SQLAlchemy engine for the given URL (one per process)."""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(
|
||||
@@ -46,6 +54,7 @@ def get_engine(database_url: str):
|
||||
|
||||
|
||||
def get_session_factory(database_url: str) -> sessionmaker[Session]:
|
||||
"""Return cached session factory for the given URL (one per process)."""
|
||||
global _SessionLocal
|
||||
if _SessionLocal is None:
|
||||
engine = get_engine(database_url)
|
||||
@@ -54,4 +63,5 @@ def get_session_factory(database_url: str) -> sessionmaker[Session]:
|
||||
|
||||
|
||||
def get_session(database_url: str) -> Session:
|
||||
"""Create a new session from the factory for the given URL."""
|
||||
return get_session_factory(database_url)()
|
||||
|
||||
@@ -6,6 +6,11 @@ from . import commands, errors, group_duty_pin, import_duty_schedule
|
||||
|
||||
|
||||
def register_handlers(app: Application) -> None:
|
||||
"""Register all Telegram handlers (commands, import, group pin, error handler) on the application.
|
||||
|
||||
Args:
|
||||
app: python-telegram-bot Application instance.
|
||||
"""
|
||||
app.add_handler(commands.start_handler)
|
||||
app.add_handler(commands.help_handler)
|
||||
app.add_handler(commands.set_phone_handler)
|
||||
|
||||
@@ -17,6 +17,7 @@ from duty_teller.utils.user import build_full_name
|
||||
|
||||
|
||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /start: register user in DB and send greeting."""
|
||||
if not update.message:
|
||||
return
|
||||
user = update.effective_user
|
||||
@@ -47,6 +48,7 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
|
||||
|
||||
async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /set_phone [number]: set or clear phone (private chat only)."""
|
||||
if not update.message or not update.effective_user:
|
||||
return
|
||||
lang = get_lang(update.effective_user)
|
||||
@@ -87,7 +89,7 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
|
||||
|
||||
async def calendar_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Send personal calendar subscription URL (private chat only, access check)."""
|
||||
"""Handle /calendar_link: send personal ICS URL (private chat only; user must be in allowlist)."""
|
||||
if not update.message or not update.effective_user:
|
||||
return
|
||||
lang = get_lang(update.effective_user)
|
||||
@@ -136,6 +138,7 @@ async def calendar_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
|
||||
|
||||
|
||||
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /help: send list of commands (admins see import_duty_schedule)."""
|
||||
if not update.message or not update.effective_user:
|
||||
return
|
||||
lang = get_lang(update.effective_user)
|
||||
|
||||
@@ -14,6 +14,12 @@ logger = logging.getLogger(__name__)
|
||||
async def error_handler(
|
||||
update: Update | None, context: ContextTypes.DEFAULT_TYPE
|
||||
) -> None:
|
||||
"""Global error handler: log exception and reply with generic message if possible.
|
||||
|
||||
Args:
|
||||
update: Update that caused the error (may be None).
|
||||
context: Callback context.
|
||||
"""
|
||||
logger.exception("Exception while handling an update")
|
||||
if isinstance(update, Update) and update.effective_message:
|
||||
user = getattr(update, "effective_user", None)
|
||||
|
||||
@@ -91,6 +91,7 @@ async def _schedule_next_update(
|
||||
|
||||
|
||||
async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Job callback: refresh pinned duty message and schedule next update at shift end."""
|
||||
chat_id = context.job.data.get("chat_id")
|
||||
if chat_id is None:
|
||||
return
|
||||
@@ -117,6 +118,7 @@ async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
async def my_chat_member_handler(
|
||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
) -> None:
|
||||
"""Handle bot added to or removed from group: send/pin duty message or delete pin record."""
|
||||
if not update.my_chat_member or not update.effective_user:
|
||||
return
|
||||
old = update.my_chat_member.old_chat_member
|
||||
@@ -185,6 +187,7 @@ def _get_all_pin_chat_ids_sync() -> list[int]:
|
||||
|
||||
|
||||
async def restore_group_pin_jobs(application) -> None:
|
||||
"""Restore scheduled pin-update jobs for all chats that have a pinned message (on startup)."""
|
||||
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:
|
||||
@@ -194,6 +197,7 @@ async def restore_group_pin_jobs(application) -> None:
|
||||
|
||||
|
||||
async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /pin_duty: pin the current duty message in the group (reply to bot's message)."""
|
||||
if not update.message or not update.effective_chat or not update.effective_user:
|
||||
return
|
||||
chat = update.effective_chat
|
||||
|
||||
@@ -19,6 +19,7 @@ from duty_teller.utils.handover import parse_handover_time
|
||||
async def import_duty_schedule_cmd(
|
||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
) -> None:
|
||||
"""Handle /import_duty_schedule: start two-step import (admin only); asks for handover time."""
|
||||
if not update.message or not update.effective_user:
|
||||
return
|
||||
lang = get_lang(update.effective_user)
|
||||
@@ -32,6 +33,7 @@ async def import_duty_schedule_cmd(
|
||||
async def handle_handover_time_text(
|
||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
) -> None:
|
||||
"""Handle text message when awaiting handover time (e.g. 09:00 Europe/Moscow)."""
|
||||
if not update.message or not update.effective_user or not update.message.text:
|
||||
return
|
||||
if not context.user_data.get("awaiting_handover_time"):
|
||||
@@ -54,6 +56,7 @@ async def handle_handover_time_text(
|
||||
async def handle_duty_schedule_document(
|
||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
) -> None:
|
||||
"""Handle uploaded JSON file: parse duty-schedule and run import."""
|
||||
if not update.message or not update.message.document or not update.effective_user:
|
||||
return
|
||||
if not context.user_data.get("awaiting_duty_schedule_file"):
|
||||
|
||||
@@ -36,12 +36,21 @@ class DutyScheduleParseError(Exception):
|
||||
|
||||
|
||||
def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
"""Parse duty-schedule JSON. Returns start_date, end_date, and list of DutyScheduleEntry.
|
||||
"""Parse duty-schedule JSON into DutyScheduleResult.
|
||||
|
||||
- meta.start_date (YYYY-MM-DD) and schedule (array) required.
|
||||
- meta.weeks optional; number of days from max duty string length (split by ';').
|
||||
- For each schedule item: name (required), duty = CSV with ';'; index i = start_date + i days.
|
||||
- Cell value after strip: в/В/б/Б => duty, Н => unavailable, О => vacation; rest ignored.
|
||||
Expects meta.start_date (YYYY-MM-DD) and schedule (array). For each schedule
|
||||
item: name (required), duty string with ';' separator; index i = start_date + i days.
|
||||
Cell values: в/В/б/Б => duty, Н => unavailable, О => vacation; rest ignored.
|
||||
|
||||
Args:
|
||||
raw_bytes: UTF-8 encoded JSON bytes.
|
||||
|
||||
Returns:
|
||||
DutyScheduleResult with start_date, end_date, and entries (per-person dates).
|
||||
|
||||
Raises:
|
||||
DutyScheduleParseError: On invalid JSON, missing/invalid meta or schedule,
|
||||
or invalid item fields.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(raw_bytes.decode("utf-8"))
|
||||
|
||||
@@ -17,7 +17,17 @@ from duty_teller.i18n import t
|
||||
|
||||
|
||||
def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
|
||||
"""Build the text for the pinned message. duty, user may be None."""
|
||||
"""Build the text for the pinned duty message.
|
||||
|
||||
Args:
|
||||
duty: Duty instance or None.
|
||||
user: User instance or None.
|
||||
tz_name: Timezone name for display (e.g. Europe/Moscow).
|
||||
lang: Language code for i18n ('ru' or 'en').
|
||||
|
||||
Returns:
|
||||
Formatted message string; "No duty" if duty or user is None.
|
||||
"""
|
||||
if duty is None or user is None:
|
||||
return t(lang, "duty.no_duty")
|
||||
try:
|
||||
@@ -53,7 +63,16 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
|
||||
|
||||
|
||||
def get_duty_message_text(session: Session, tz_name: str, lang: str = "en") -> str:
|
||||
"""Get current duty from DB and return formatted message."""
|
||||
"""Get current duty from DB and return formatted message text.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
tz_name: Timezone name for display.
|
||||
lang: Language code for i18n.
|
||||
|
||||
Returns:
|
||||
Formatted duty message or "No duty" if none.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = get_current_duty(session, now)
|
||||
if result is None:
|
||||
@@ -63,26 +82,61 @@ def get_duty_message_text(session: Session, tz_name: str, lang: str = "en") -> s
|
||||
|
||||
|
||||
def get_next_shift_end_utc(session: Session) -> datetime | None:
|
||||
"""Return next shift end as naive UTC datetime for job scheduling."""
|
||||
"""Return next shift end as naive UTC datetime for job scheduling.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
|
||||
Returns:
|
||||
Next shift end (naive UTC) or None.
|
||||
"""
|
||||
return get_next_shift_end(session, datetime.now(timezone.utc))
|
||||
|
||||
|
||||
def save_pin(session: Session, chat_id: int, message_id: int) -> None:
|
||||
"""Save or update the pinned message record for a chat."""
|
||||
"""Save or update the pinned duty message record for a chat.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
message_id: Message id to store.
|
||||
"""
|
||||
save_group_duty_pin(session, chat_id, message_id)
|
||||
|
||||
|
||||
def delete_pin(session: Session, chat_id: int) -> None:
|
||||
"""Remove the pinned message record when the bot leaves the group."""
|
||||
"""Remove the pinned message record for the chat (e.g. when bot leaves).
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
"""
|
||||
delete_group_duty_pin(session, chat_id)
|
||||
|
||||
|
||||
def get_message_id(session: Session, chat_id: int) -> int | None:
|
||||
"""Return message_id for the pin in this chat, or None."""
|
||||
"""Return message_id for the pinned duty message in this chat.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
|
||||
Returns:
|
||||
Message id or None if no pin record.
|
||||
"""
|
||||
pin = get_group_duty_pin(session, chat_id)
|
||||
return pin.message_id if pin else None
|
||||
|
||||
|
||||
def get_all_pin_chat_ids(session: Session) -> list[int]:
|
||||
"""Return all chat_ids that have a pinned duty message (for restoring jobs on startup)."""
|
||||
"""Return all chat_ids that have a pinned duty message.
|
||||
|
||||
Used to restore update jobs on bot startup.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
|
||||
Returns:
|
||||
List of chat ids.
|
||||
"""
|
||||
return get_all_group_duty_pin_chat_ids(session)
|
||||
|
||||
@@ -36,7 +36,21 @@ def run_import(
|
||||
hour_utc: int,
|
||||
minute_utc: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Run import: delete range per user, insert duty/unavailable/vacation. Returns (num_users, num_duty, num_unavailable, num_vacation)."""
|
||||
"""Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.
|
||||
|
||||
For each entry: get_or_create_user_by_full_name, delete_duties_in_range for
|
||||
the result date range, then insert duties (handover time in UTC), unavailable
|
||||
(all-day), and vacation (consecutive ranges).
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
result: Parsed duty schedule (start_date, end_date, entries).
|
||||
hour_utc: Handover hour in UTC (0-23).
|
||||
minute_utc: Handover minute in UTC (0-59).
|
||||
|
||||
Returns:
|
||||
Tuple (num_users, num_duty, num_unavailable, num_vacation).
|
||||
"""
|
||||
from_date_str = result.start_date.isoformat()
|
||||
to_date_str = result.end_date.isoformat()
|
||||
num_duty = num_unavailable = num_vacation = 0
|
||||
|
||||
Reference in New Issue
Block a user