Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cd00893ad | |||
| 95d3af4930 | |||
| 24d6ecbedb | |||
| 34001d22d9 | |||
| 4d09c8641c | |||
| 172d145f0e | |||
| 45c65e3025 | |||
| fa22976e75 | |||
| 43cd3bbd7d | |||
| 26a9443e1b | |||
| 40e2b5adc4 | |||
| 76bff6dc05 | |||
| 6da6c87d3c | |||
| 02a586a1c5 | |||
| 53a899ea26 | |||
| a3152a4545 | |||
| c390a4dd6e | |||
| 68b1884b73 | |||
| fb786c4c3a | |||
| 07e22079ee | |||
| 13aba85e28 | |||
| 8ad8dffd0a | |||
| 6d087d1b26 | |||
| 94545dc8c3 | |||
| 33359f589a | |||
| 3244fbe505 | |||
| d99912b080 | |||
| 6adec62b5f | |||
| a8d4afb101 | |||
| 106e42a81d | |||
| 3c4c28a1ac | |||
| 119661628e | |||
| 336e6d48c5 | |||
| 07d08bb179 | |||
| 378daad503 | |||
| 54f85a8f14 | |||
| 8bf92bd4a1 | |||
| 68a153e4a7 | |||
| cac06f22fa | |||
| 87e8417675 | |||
| 37218a436a | |||
| 50d734e192 | |||
| edf0186682 | |||
| 6e2188787e | |||
| fd527917e0 | |||
| 95c9e23c33 | |||
| 95f65141e1 | |||
| 3b68e29d7b | |||
| 16bf1a1043 | |||
| 2de5c1cb81 | |||
| 70b9050cb7 | |||
| 7ffa727832 | |||
| 43386b15fa | |||
| 67ba9826c7 | |||
| 54446d7b0f | |||
| 37d4226beb | |||
| 0d28123d0b | |||
| 2e78b3c1e6 | |||
| bdead6eef7 | |||
| 2fb553567f | |||
| e3240d0981 | |||
| f8aceabab5 | |||
| 322b553b80 | |||
| a4d8d085c6 | |||
| b906bfa777 | |||
| 8a80af32d8 | |||
| 3c3a2c507c | |||
| 71d56d2491 | |||
| 0e8d1453e2 |
150
.cursor/rules/backend.mdc
Normal file
150
.cursor/rules/backend.mdc
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
description: Rules for working with the Python backend (duty_teller/)
|
||||
globs:
|
||||
- duty_teller/**
|
||||
- alembic/**
|
||||
- tests/**
|
||||
---
|
||||
|
||||
# Backend — Python
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
duty_teller/
|
||||
├── main.py / run.py # Entry point: bot + uvicorn
|
||||
├── config.py # Settings from env vars (python-dotenv)
|
||||
├── cache.py # TTL caches with pattern invalidation
|
||||
├── api/ # FastAPI app, routes, auth, ICS endpoints
|
||||
│ ├── app.py # FastAPI app creation, route registration, static mount
|
||||
│ ├── dependencies.py # get_db_session, require_miniapp_username, get_validated_dates
|
||||
│ ├── telegram_auth.py # initData HMAC validation
|
||||
│ ├── calendar_ics.py # External ICS fetch & parse
|
||||
│ └── personal_calendar_ics.py
|
||||
├── db/ # Database layer
|
||||
│ ├── models.py # SQLAlchemy ORM models (Base)
|
||||
│ ├── repository.py # CRUD functions (receive Session)
|
||||
│ ├── schemas.py # Pydantic response schemas
|
||||
│ └── session.py # session_scope, get_engine, get_session
|
||||
├── handlers/ # Telegram bot command/message handlers
|
||||
│ ├── commands.py # /start, /help, /set_phone, /calendar_link, /set_role
|
||||
│ ├── common.py # is_admin_async, invalidate_is_admin_cache
|
||||
│ ├── errors.py # Global error handler
|
||||
│ ├── group_duty_pin.py # Pinned duty message in group chats
|
||||
│ └── import_duty_schedule.py
|
||||
├── i18n/ # Translations
|
||||
│ ├── core.py # get_lang, t()
|
||||
│ ├── lang.py # normalize_lang
|
||||
│ └── messages.py # MESSAGES dict (ru/en)
|
||||
├── importers/ # File parsers
|
||||
│ └── duty_schedule.py # parse_duty_schedule
|
||||
├── services/ # Business logic (receives Session)
|
||||
│ ├── import_service.py # run_import
|
||||
│ └── group_duty_pin_service.py
|
||||
└── utils/ # Shared helpers
|
||||
├── dates.py
|
||||
├── handover.py
|
||||
├── http_client.py
|
||||
└── user.py
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
- Use absolute imports from the `duty_teller` package: `from duty_teller.db.repository import get_or_create_user`.
|
||||
- Never import handler modules from services or repository — dependency flows
|
||||
downward: handlers → services → repository → models.
|
||||
|
||||
## DB access pattern
|
||||
|
||||
Handlers are async; SQLAlchemy sessions are synchronous. Two patterns:
|
||||
|
||||
### In bot handlers — `session_scope` + `run_in_executor`
|
||||
|
||||
```python
|
||||
def do_work():
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
# synchronous DB code here
|
||||
...
|
||||
|
||||
await asyncio.get_running_loop().run_in_executor(None, do_work)
|
||||
```
|
||||
|
||||
### In FastAPI endpoints — dependency injection
|
||||
|
||||
```python
|
||||
@router.get("/api/duties")
|
||||
def get_duties(session: Session = Depends(get_db_session)):
|
||||
...
|
||||
```
|
||||
|
||||
`get_db_session` yields a `session_scope` context — FastAPI closes it after the request.
|
||||
|
||||
## Handler patterns
|
||||
|
||||
### Admin-only commands
|
||||
|
||||
```python
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
await update.message.reply_text(t(lang, "import.admin_only"))
|
||||
return
|
||||
```
|
||||
|
||||
`is_admin_async` is cached for 60 s and invalidated on role changes.
|
||||
|
||||
### i18n in handlers
|
||||
|
||||
```python
|
||||
lang = get_lang(update.effective_user)
|
||||
text = t(lang, "start.greeting")
|
||||
```
|
||||
|
||||
`t(lang, key, **kwargs)` substitutes `{placeholders}` and falls back to English → raw key.
|
||||
|
||||
### Error handling
|
||||
|
||||
The global `error_handler` (registered via `app.add_error_handler`) logs the
|
||||
exception and sends a generic localized error reply.
|
||||
|
||||
## Service layer
|
||||
|
||||
- Services receive a `Session` — they never open their own.
|
||||
- Services call repository functions, never handler code.
|
||||
- After mutations that affect cached data, call `invalidate_duty_related_caches()`.
|
||||
|
||||
## Cache invalidation
|
||||
|
||||
Three TTL caches in `cache.py`:
|
||||
|
||||
| Cache | TTL | Invalidation trigger |
|
||||
|-------|-----|---------------------|
|
||||
| `ics_calendar_cache` | 600 s | After duty import |
|
||||
| `duty_pin_cache` | 90 s | After duty import |
|
||||
| `is_admin_cache` | 60 s | After `set_user_role` |
|
||||
|
||||
- `invalidate_duty_related_caches()` clears ICS and pin caches (call after any duty mutation).
|
||||
- `invalidate_is_admin_cache(telegram_user_id)` clears a single admin entry.
|
||||
- Pattern invalidation: `cache.invalidate_pattern(key_prefix_tuple)`.
|
||||
|
||||
## Alembic migrations
|
||||
|
||||
- Config in `pyproject.toml` under `[tool.alembic]`, script location: `alembic/`.
|
||||
- **Naming convention:** `NNN_description.py` — sequential zero-padded number (`001`, `002`, …).
|
||||
- Revision IDs are the string number: `revision = "008"`, `down_revision = "007"`.
|
||||
- Run: `alembic -c pyproject.toml upgrade head` / `downgrade -1`.
|
||||
- `entrypoint.sh` runs `alembic upgrade head` before starting the app in Docker.
|
||||
|
||||
## Configuration
|
||||
|
||||
- All config comes from environment variables, loaded with `python-dotenv`.
|
||||
- `duty_teller/config.py` exposes module-level constants (`BOT_TOKEN`, `DATABASE_URL`, etc.)
|
||||
built from `Settings.from_env()`.
|
||||
- Never hardcode secrets or URLs — always use `config.*` constants.
|
||||
- Key variables: `BOT_TOKEN`, `DATABASE_URL`, `MINI_APP_BASE_URL`, `HTTP_HOST`, `HTTP_PORT`,
|
||||
`ADMIN_USERNAMES`, `ALLOWED_USERNAMES`, `DUTY_DISPLAY_TZ`, `DEFAULT_LANGUAGE`.
|
||||
|
||||
## Code style
|
||||
|
||||
- Formatter: Black (line-length 120).
|
||||
- Linter: Ruff (`ruff check duty_teller tests`).
|
||||
- Follow PEP 8 and Google Python Style Guide for docstrings.
|
||||
- Max line length: 120 characters.
|
||||
70
.cursor/rules/frontend.mdc
Normal file
70
.cursor/rules/frontend.mdc
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
description: Rules for working with the Telegram Mini App frontend (webapp-next/)
|
||||
globs:
|
||||
- webapp-next/**
|
||||
---
|
||||
|
||||
# Frontend — Telegram Mini App (Next.js)
|
||||
|
||||
The Mini App lives in `webapp-next/`. It is built as a static export and served by FastAPI at `/app`.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Next.js** (App Router, `output: 'export'`, `basePath: '/app'`)
|
||||
- **TypeScript**
|
||||
- **Tailwind CSS** — theme extended with custom tokens (surface, muted, accent, duty, today, etc.)
|
||||
- **shadcn/ui** — Button, Card, Sheet, Popover, Tooltip, Skeleton, Badge
|
||||
- **Zustand** — app store (month, lang, duties, calendar events, loading, view state)
|
||||
- **@telegram-apps/sdk-react** — SDKProvider, useThemeParams, useLaunchParams, useMiniApp, useBackButton
|
||||
|
||||
## Structure
|
||||
|
||||
| Area | Location |
|
||||
|------|----------|
|
||||
| App entry, layout | `src/app/layout.tsx`, `src/app/page.tsx` |
|
||||
| Providers | `src/components/providers/TelegramProvider.tsx` |
|
||||
| Calendar | `src/components/calendar/` — CalendarHeader, CalendarGrid, CalendarDay, DayIndicators |
|
||||
| Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem |
|
||||
| Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent |
|
||||
| Current duty view | `src/components/current-duty/CurrentDutyView.tsx` |
|
||||
| Admin | `src/components/admin/` — useAdminPage, AdminDutyList, ReassignSheet |
|
||||
| Contact links | `src/components/contact/ContactLinks.tsx` |
|
||||
| State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied |
|
||||
| Hooks | `src/hooks/` — use-telegram-theme, use-telegram-auth, use-month-data, use-swipe, use-media-query, use-sticky-scroll, use-auto-refresh |
|
||||
| Lib | `src/lib/` — api, calendar-data, date-utils, phone-format, constants, utils |
|
||||
| i18n | `src/i18n/` — messages.ts, use-translation.ts |
|
||||
| Store | `src/store/app-store.ts` |
|
||||
| Types | `src/types/index.ts` |
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client).
|
||||
- **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on `<html>`.
|
||||
- **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost.
|
||||
- **i18n:** `useTranslation()` for React UI; `getLang()/translate()` only for early bootstrap or non-React boundaries.
|
||||
- **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED.
|
||||
- **Heavy pages:** For feature-heavy routes (e.g. admin), use a custom hook (state, effects, callbacks) plus presentational components; keep the page as a thin layer (early returns + composition). Example: `admin/page.tsx` uses `useAdminPage`, `AdminDutyList`, and `ReassignSheet`.
|
||||
- **Platform boundary:** Use `src/hooks/telegram/*` adapters for Back/Settings/Close/swipe/closing behavior instead of direct SDK control calls in feature components.
|
||||
- **Screen shells:** Reuse `src/components/layout/MiniAppScreen.tsx` wrappers for route and full-screen states.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Runner:** Vitest in `webapp-next/`; environment: jsdom; React Testing Library.
|
||||
- **Config:** `webapp-next/vitest.config.ts`; setup in `src/test/setup.ts`.
|
||||
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
|
||||
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
|
||||
|
||||
## Design guideline
|
||||
|
||||
When adding or changing UI in the Mini App, **follow the [Mini App design guideline](../../docs/miniapp-design.md)**:
|
||||
|
||||
- Use only design tokens from `globals.css` and Tailwind/shadcn aliases (no hardcoded colours).
|
||||
- Page wrappers: `content-safe`, `max-w-[var(--max-width-app)]`, viewport height; respect safe area for sheets/modals.
|
||||
- Reuse component patterns (buttons, cards, calendar grid, timeline list) and left-stripe semantics (`border-l-duty`, `border-l-today`, etc.).
|
||||
- Add ARIA labels and roles for interactive elements and grids; respect `prefers-reduced-motion` and `data-perf="low"` for animations.
|
||||
- Keep all user-facing strings and `aria-label`/`sr-only` text localized.
|
||||
- Follow Telegram interaction policy from the design guideline: vertical swipes enabled by default, closing confirmation only for stateful flows.
|
||||
|
||||
Use the checklist in the design doc when introducing new screens or components.
|
||||
|
||||
Consider these rules when changing the Mini App or adding frontend features.
|
||||
113
.cursor/rules/project.mdc
Normal file
113
.cursor/rules/project.mdc
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
description: Overall project architecture and context for duty-teller
|
||||
globs:
|
||||
- "**"
|
||||
---
|
||||
|
||||
# Project — duty-teller
|
||||
|
||||
A Telegram bot for team duty shift calendar management and group reminders,
|
||||
with a Telegram Mini App (webapp) for calendar visualization.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ polling ┌──────────────────────┐
|
||||
│ Telegram │◄────────────►│ python-telegram-bot │
|
||||
│ Bot API │ │ (handlers/) │
|
||||
└──────────────┘ └──────────┬───────────┘
|
||||
│
|
||||
┌──────────────┐ HTTP ┌──────────▼───────────┐
|
||||
│ Telegram │◄────────────►│ FastAPI (api/) │
|
||||
│ Mini App │ initData │ + static webapp │
|
||||
│ (webapp-next/) │ auth └──────────┬───────────┘
|
||||
└──────────────┘ │
|
||||
┌──────────▼───────────┐
|
||||
│ SQLite + SQLAlchemy │
|
||||
│ (db/) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
- **Bot:** python-telegram-bot v22, async polling mode.
|
||||
- **API:** FastAPI served by uvicorn in a daemon thread alongside the bot.
|
||||
- **Database:** SQLite via SQLAlchemy 2.x ORM; Alembic for migrations.
|
||||
- **Frontend:** Next.js (TypeScript, Tailwind, shadcn/ui) static export at `/app`; source in `webapp-next/`.
|
||||
|
||||
## Key packages
|
||||
|
||||
| Package | Role |
|
||||
|---------|------|
|
||||
| `duty_teller/handlers/` | Telegram bot command and message handlers |
|
||||
| `duty_teller/api/` | FastAPI app, REST endpoints, Telegram initData auth, ICS feeds |
|
||||
| `duty_teller/db/` | ORM models, repository (CRUD), session management, Pydantic schemas |
|
||||
| `duty_teller/services/` | Business logic (import, duty pin) — receives Session |
|
||||
| `duty_teller/importers/` | Duty schedule file parsers |
|
||||
| `duty_teller/i18n/` | Bilingual translations (ru/en), `t()` function |
|
||||
| `duty_teller/utils/` | Date helpers, user utilities, HTTP client |
|
||||
| `duty_teller/cache.py` | TTL caches with pattern-based invalidation |
|
||||
| `duty_teller/config.py` | Environment-based configuration |
|
||||
| `webapp-next/` | Telegram Mini App (Next.js, Tailwind, shadcn/ui; build → `out/`) |
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| GET | `/health` | None | Health check |
|
||||
| GET | `/api/duties` | initData | Duties for date range |
|
||||
| GET | `/api/calendar-events` | initData | External calendar events |
|
||||
| GET | `/api/calendar/ical/team/{token}.ics` | Token | Team ICS feed |
|
||||
| GET | `/api/calendar/ical/{token}.ics` | Token | Personal ICS feed |
|
||||
| GET | `/app` | None | Static Mini App (HTML/JS/CSS) |
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker:** Multi-stage build (`Dockerfile`), `docker-compose.prod.yml` for production.
|
||||
- **Entrypoint:** `entrypoint.sh` runs `alembic -c pyproject.toml upgrade head`, then
|
||||
starts `python main.py` as `botuser`.
|
||||
- **Data:** SQLite DB persisted via Docker volume at `/app/data`.
|
||||
- **Health:** `curl -f http://localhost:8080/health` every 30 s.
|
||||
|
||||
## CI/CD (Gitea Actions)
|
||||
|
||||
### Lint & test (`.gitea/workflows/ci.yml`)
|
||||
|
||||
Triggered on push/PR to `main` and `develop`:
|
||||
|
||||
1. **Ruff:** `ruff check duty_teller tests`
|
||||
2. **Pytest:** `pytest tests/ -v` (80% coverage gate)
|
||||
3. **Bandit:** `bandit -r duty_teller -ll` (security scan)
|
||||
|
||||
### Docker build & release (`.gitea/workflows/docker-build.yml`)
|
||||
|
||||
Triggered on `v*` tags:
|
||||
|
||||
1. Build and push image to Gitea registry.
|
||||
2. Create release with auto-generated notes.
|
||||
|
||||
## Key environment variables
|
||||
|
||||
| Variable | Default | Required | Purpose |
|
||||
|----------|---------|----------|---------|
|
||||
| `BOT_TOKEN` | — | Yes | Telegram bot API token |
|
||||
| `DATABASE_URL` | `sqlite:///data/duty_teller.db` | No | SQLAlchemy database URL |
|
||||
| `MINI_APP_BASE_URL` | — | Yes (prod) | Public URL for the Mini App |
|
||||
| `HTTP_HOST` | `127.0.0.1` | No | FastAPI bind host |
|
||||
| `HTTP_PORT` | `8080` | No | FastAPI bind port |
|
||||
| `ADMIN_USERNAMES` | — | No | Comma-separated admin Telegram usernames |
|
||||
| `ALLOWED_USERNAMES` | — | No | Comma-separated allowed usernames |
|
||||
| `DUTY_DISPLAY_TZ` | `Europe/Moscow` | No | Timezone for duty display |
|
||||
| `DEFAULT_LANGUAGE` | `en` | No | Default UI language (`en` or `ru`) |
|
||||
| `EXTERNAL_CALENDAR_ICS_URL` | — | No | External ICS URL for holidays |
|
||||
| `CORS_ORIGINS` | `*` | No | Comma-separated CORS origins |
|
||||
|
||||
## Languages
|
||||
|
||||
- **Backend:** Python 3.12+
|
||||
- **Frontend:** Next.js (TypeScript), Tailwind CSS, shadcn/ui; Vitest for tests
|
||||
- **i18n:** Russian (default) and English
|
||||
|
||||
## Version control
|
||||
|
||||
- Git with Gitea Flow branching strategy.
|
||||
- Conventional Commits for commit messages.
|
||||
- PRs reviewed via Gitea Pull Requests.
|
||||
83
.cursor/rules/testing.mdc
Normal file
83
.cursor/rules/testing.mdc
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
description: Rules for writing and running tests
|
||||
globs:
|
||||
- tests/**
|
||||
- webapp-next/src/**/*.test.{ts,tsx}
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
## Python tests (pytest)
|
||||
|
||||
### Configuration
|
||||
|
||||
- Config in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||
- `asyncio_mode = "auto"` — async test functions are detected automatically, no decorator needed.
|
||||
- Coverage: `--cov=duty_teller --cov-fail-under=80`.
|
||||
- Run: `pytest tests/ -v` from project root.
|
||||
|
||||
### File layout
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # Shared fixtures (in-memory DB, sessions, bot app)
|
||||
├── helpers.py # Test helper functions
|
||||
└── test_*.py # Test modules
|
||||
```
|
||||
|
||||
### Key fixtures (conftest.py)
|
||||
|
||||
- `conftest.py` sets `BOT_TOKEN=test-token-for-pytest` if the env var is missing,
|
||||
so tests run without a real token.
|
||||
- Database fixtures use in-memory SQLite for isolation.
|
||||
|
||||
### Writing Python tests
|
||||
|
||||
- File naming: `tests/test_<module>.py` (e.g. `tests/test_import_service.py`).
|
||||
- Function naming: `test_<what>_<scenario>` or `test_<what>_<expected_result>`.
|
||||
- Use `session_scope` with the test database URL for DB tests.
|
||||
- Async tests: just use `async def test_...` — `asyncio_mode = "auto"` handles it.
|
||||
- Mock external dependencies (Telegram API, HTTP calls) with `unittest.mock` or `pytest-mock`.
|
||||
|
||||
### Example structure
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from duty_teller.db.session import session_scope
|
||||
|
||||
def test_get_or_create_user_creates_new(test_db_url):
|
||||
with session_scope(test_db_url) as session:
|
||||
user = get_or_create_user(session, telegram_user_id=123, full_name="Test")
|
||||
assert user.telegram_user_id == 123
|
||||
```
|
||||
|
||||
## Frontend tests (Vitest + React Testing Library)
|
||||
|
||||
### Configuration
|
||||
|
||||
- Config: `webapp-next/vitest.config.ts`.
|
||||
- Environment: jsdom; React Testing Library for components.
|
||||
- Test files: `webapp-next/src/**/*.test.{ts,tsx}` (co-located or in test files).
|
||||
- Setup: `webapp-next/src/test/setup.ts`.
|
||||
- Run: `cd webapp-next && npm test` (or `npm run test`).
|
||||
|
||||
### Writing frontend tests
|
||||
|
||||
- Pure lib modules: unit test with Vitest (`describe` / `it` / `expect`).
|
||||
- React components: use `@testing-library/react` (render, screen, userEvent); wrap with required providers (e.g. TelegramProvider, store) via `src/test/test-utils.tsx` where needed.
|
||||
- Mock Telegram SDK and API fetch where necessary.
|
||||
- File naming: `<module>.test.ts` or `<Component>.test.tsx`.
|
||||
|
||||
### Example structure
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { localDateString } from "./date-utils";
|
||||
|
||||
describe("localDateString", () => {
|
||||
it("formats date as YYYY-MM-DD", () => {
|
||||
const d = new Date(2025, 0, 15);
|
||||
expect(localDateString(d)).toBe("2025-01-15");
|
||||
});
|
||||
});
|
||||
```
|
||||
55
.cursor/skills/project-release/SKILL.md
Normal file
55
.cursor/skills/project-release/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: project-release
|
||||
description: Performs a project release by updating CHANGELOG for the new version, committing all changes, and pushing a version tag. Use when the user asks to release, cut a release, publish a version, or to update changelog and push a tag.
|
||||
---
|
||||
|
||||
# Project Release
|
||||
|
||||
Release workflow for duty-teller: update changelog, commit, tag, and push. Triggers Gitea Actions (Docker build) on `v*` tags.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Decide the **new version** (e.g. `2.1.0`). Use [Semantic Versioning](https://semver.org/).
|
||||
- Ensure `CHANGELOG.md` has entries under `## [Unreleased]` (or add a short note like "No changes" if intentional).
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Update CHANGELOG.md
|
||||
|
||||
- Replace the `## [Unreleased]` section with a dated release section:
|
||||
- `## [X.Y.Z] - YYYY-MM-DD` (use today's date in `YYYY-MM-DD`).
|
||||
- Leave a new empty `## [Unreleased]` section after it (for future edits).
|
||||
- At the bottom of the file, add the comparison link for the new version:
|
||||
- `[X.Y.Z]: https://github.com/your-org/duty-teller/releases/tag/vX.Y.Z`
|
||||
- (Replace `your-org/duty-teller` with the real repo URL if different.)
|
||||
- Update the `[Unreleased]` link to compare against this release, e.g.:
|
||||
- `[Unreleased]: https://github.com/your-org/duty-teller/compare/vX.Y.Z...HEAD`
|
||||
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); keep existing subsections (Added, Changed, Security, etc.) under the new version.
|
||||
|
||||
### 2. Bump version in pyproject.toml (optional)
|
||||
|
||||
- Set `version = "X.Y.Z"` in `[project]` so it matches the release. Skip if the project does not sync version here.
|
||||
|
||||
### 3. Commit and tag
|
||||
|
||||
- Stage all changes: `git add -A`
|
||||
- Commit with Conventional Commits: `git commit -m "chore(release): vX.Y.Z"`
|
||||
- Create annotated tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z"`
|
||||
- Push branch: `git push origin main` (or current branch)
|
||||
- Push tag: `git push origin vX.Y.Z`
|
||||
|
||||
Pushing the `v*` tag triggers `.gitea/workflows/docker-build.yml` (Docker build and release).
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] CHANGELOG: `[Unreleased]` → `[X.Y.Z] - YYYY-MM-DD`, new empty `[Unreleased]`, links at bottom updated
|
||||
- [ ] pyproject.toml version set to X.Y.Z (if used)
|
||||
- [ ] `git add -A` && `git commit -m "chore(release): vX.Y.Z"`
|
||||
- [ ] `git tag -a vX.Y.Z -m "Release vX.Y.Z"`
|
||||
- [ ] `git push origin main` && `git push origin vX.Y.Z`
|
||||
|
||||
## Notes
|
||||
|
||||
- Do not push tags from unreleased or uncommitted changelog.
|
||||
- If the repo URL in CHANGELOG links is a placeholder, keep it or ask the user for the correct base URL.
|
||||
60
.cursor/skills/run-tests/SKILL.md
Normal file
60
.cursor/skills/run-tests/SKILL.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: run-tests
|
||||
description: Runs backend (pytest) and frontend (Vitest) tests for the duty-teller project. Use when the user asks to run tests, verify changes, or run pytest/vitest.
|
||||
---
|
||||
|
||||
# Run tests
|
||||
|
||||
## When to use
|
||||
|
||||
- User asks to "run tests", "run the test suite", or "verify tests pass".
|
||||
- After making code changes and user wants to confirm nothing is broken.
|
||||
- User explicitly asks for backend tests (pytest) or frontend tests (vitest/npm test).
|
||||
|
||||
## Backend tests (Python)
|
||||
|
||||
From the **repository root**:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
If imports fail, set `PYTHONPATH`:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=. pytest
|
||||
```
|
||||
|
||||
- Config: `pyproject.toml` → `[tool.pytest.ini_options]` (coverage on `duty_teller`, 80% gate, asyncio mode).
|
||||
- Tests live in `tests/`.
|
||||
|
||||
## Frontend tests (Next.js / Vitest)
|
||||
|
||||
From the **repository root**:
|
||||
|
||||
```bash
|
||||
cd webapp-next && npm test
|
||||
```
|
||||
|
||||
- Runner: Vitest (`vitest run`); env: jsdom; React Testing Library.
|
||||
- Config: `webapp-next/vitest.config.ts`; setup: `webapp-next/src/test/setup.ts`.
|
||||
|
||||
## Running both
|
||||
|
||||
To run backend and frontend tests in sequence:
|
||||
|
||||
```bash
|
||||
pytest && (cd webapp-next && npm test)
|
||||
```
|
||||
|
||||
If the user did not specify "backend only" or "frontend only", run both and report results for each.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Single file or dir:** `pytest path/to/test_file.py` or `pytest path/to/test_dir/`. For frontend, use Vitest’s path args as per its docs (e.g. under `webapp-next/`).
|
||||
- **Verbosity:** Use `pytest -v` if the user wants more detail.
|
||||
|
||||
## Failures
|
||||
|
||||
- Do not send raw exception strings from tests to the user; summarize failures and point to failing test names/locations.
|
||||
- If pytest fails with import errors, suggest `PYTHONPATH=. pytest` and ensure the venv is activated and dev deps are installed (`pip install -r requirements-dev.txt` or `pip install -e ".[dev]"`).
|
||||
5
.cursor/worktrees.json
Normal file
5
.cursor/worktrees.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"setup-worktree": [
|
||||
"npm install"
|
||||
]
|
||||
}
|
||||
@@ -26,7 +26,10 @@ ADMIN_USERNAMES=admin1,admin2
|
||||
# When the pinned duty message is updated on schedule, re-pin so members get a notification (default: 1). Set to 0 or false to disable.
|
||||
# DUTY_PIN_NOTIFY=1
|
||||
|
||||
# Default UI language when user language is unknown: en or ru (default: en).
|
||||
# Log level for backend and Mini App console logs: DEBUG, INFO, WARNING, ERROR. Default: INFO.
|
||||
# LOG_LEVEL=INFO
|
||||
|
||||
# Single source of language for bot, API, and Mini App (en or ru). Default: en. No auto-detection.
|
||||
# DEFAULT_LANGUAGE=en
|
||||
|
||||
# Reject Telegram initData older than this (seconds). 0 = do not check (default).
|
||||
|
||||
@@ -26,6 +26,15 @@ jobs:
|
||||
run: |
|
||||
pip install ruff bandit
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Webapp (Next.js) build and test
|
||||
run: |
|
||||
cd webapp-next && npm ci && npm test && npm run build
|
||||
|
||||
- name: Lint with Ruff
|
||||
run: |
|
||||
ruff check duty_teller tests
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -7,10 +7,18 @@ venv/
|
||||
*.pyo
|
||||
data/
|
||||
*.db
|
||||
.cursor/
|
||||
|
||||
# Test and coverage artifacts
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
*.cover
|
||||
*.plan.md
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Next.js webapp
|
||||
webapp-next/out/
|
||||
webapp-next/node_modules/
|
||||
webapp-next/.next/
|
||||
54
AGENTS.md
Normal file
54
AGENTS.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Duty Teller — AI agent documentation
|
||||
|
||||
This file is for AI assistants (e.g. Cursor) and maintainers. All project documentation and docstrings must be in **English**. User-facing UI strings remain localized (ru/en) in `duty_teller/i18n/`.
|
||||
|
||||
## Project summary
|
||||
|
||||
Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and group reminders. Stack: python-telegram-bot v22, FastAPI, SQLite (SQLAlchemy), Next.js (static export) Mini App. The bot and web UI support Russian and English; configuration and docs are in English.
|
||||
|
||||
## Key entry points
|
||||
|
||||
- **CLI / process:** `main.py` or, after `pip install -e .`, the `duty-teller` console command. Both delegate to `duty_teller.run.main()`.
|
||||
- **Application setup:** `duty_teller/run.py` — builds the Telegram `Application`, registers handlers via `register_handlers(app)`, runs polling and FastAPI in a thread, calls `config.require_bot_token()` so the app exits clearly if `BOT_TOKEN` is missing.
|
||||
- **HTTP API:** `duty_teller/api/app.py` — FastAPI app, route registration, static webapp mounted at `/app`.
|
||||
|
||||
## Where to change what
|
||||
|
||||
| Area | Location |
|
||||
|------|----------|
|
||||
| Telegram handlers | `duty_teller/handlers/` |
|
||||
| REST API | `duty_teller/api/` |
|
||||
| Business logic | `duty_teller/services/` |
|
||||
| Database (models, repository, schemas) | `duty_teller/db/` |
|
||||
| Translations (ru/en) | `duty_teller/i18n/` |
|
||||
| Duty-schedule parser | `duty_teller/importers/` |
|
||||
| Config (env vars) | `duty_teller/config.py` |
|
||||
| Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
|
||||
| Admin panel (Mini App) | `webapp-next/src/app/admin/page.tsx`; API: `GET /api/admin/me`, `GET /api/admin/users`, `PATCH /api/admin/duties/:id` |
|
||||
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
|
||||
|
||||
## Running and testing
|
||||
|
||||
- **Tests:** From repository root: `pytest`. Use `PYTHONPATH=.` if imports fail. See [CONTRIBUTING.md](CONTRIBUTING.md) and [README.md](README.md) for full setup. Frontend: `cd webapp-next && npm test && npm run build`.
|
||||
- **Lint:** `ruff check duty_teller tests`
|
||||
- **Security:** `bandit -r duty_teller -ll`
|
||||
|
||||
## Documentation
|
||||
|
||||
- User and architecture docs: [docs/](docs/), [docs/architecture.md](docs/architecture.md).
|
||||
- [Mini App design guideline](docs/miniapp-design.md) — Theme, layout, safe areas, component patterns, accessibility for webapp-next.
|
||||
- Configuration reference: [docs/configuration.md](docs/configuration.md).
|
||||
- Build docs: `pip install -e ".[docs]"`, then `mkdocs build` / `mkdocs serve`.
|
||||
|
||||
Docstrings and code comments must be in English (Google-style docstrings). UI strings are the only exception; they live in `duty_teller/i18n/`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `feat:`, `fix:`, `docs:`).
|
||||
- **Branches:** Gitea Flow; changes via Pull Request.
|
||||
- **Testing:** pytest, 80% coverage target; unit and integration tests.
|
||||
- **Config:** Environment variables (e.g. `.env`); no hardcoded secrets.
|
||||
- **Database:** One logical transaction per `session_scope` — a single `commit` at the end of the business operation (e.g. in `run_import`). Repository helpers used inside such a flow (e.g. `get_or_create_user_by_full_name`) accept `commit=False` and let the caller commit once.
|
||||
- **Error handling:** Do not send `str(exception)` from parsers or DB to the user. Use generic i18n keys (e.g. `import.parse_error_generic`, `import.import_error_generic`) and log the full exception server-side.
|
||||
- **Mini App (webapp-next):** When adding or changing UI in `webapp-next/`, follow the [Mini App design guideline](docs/miniapp-design.md): use only design tokens and Tailwind aliases, use shared Mini App screen shells, keep Telegram SDK access behind `src/hooks/telegram/*`, apply safe-area/content-safe-area and accessibility rules, and run the Mini App verification matrix (light/dark, iOS/Android safe area, low-perf Android, deep links, admin flow, fallback states).
|
||||
- **Cursor:** The project does not version `.cursor/`. You can mirror this file in `.cursor/rules/` locally; [AGENTS.md](AGENTS.md) is the single versioned reference for AI and maintainers.
|
||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -7,9 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.1.2] - 2025-03-06
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.1.1] - 2025-03-06
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.0.6] - 2025-03-04
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.0.4] - 2025-03-04
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.0.3] - 2025-03-04
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.0.2] - 2025-03-04
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.0.0] - 2026-03-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Group duty pin**: when the pinned duty message is updated on schedule, the bot re-pins it so group members get a Telegram notification. Configurable via `DUTY_PIN_NOTIFY` (default: enabled); set to `0` or `false` to only edit the message without re-pinning.
|
||||
- **Group duty pin**: when the pinned duty message is updated on schedule, the bot re-pins it so group members get a Telegram notification. Configurable via `DUTY_PIN_NOTIFY` (default: enabled); set to `0` or `false` to pin without notification. The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent.
|
||||
- **Command `/refresh_pin`**: in a group, immediately refresh the pinned duty message (send new message, unpin old, pin new).
|
||||
- **Role-based access and `/set_role`**: Miniapp and admin access are determined by roles stored in the database (`roles` table, `users.role_id`). Roles: `user` (miniapp access), `admin` (miniapp + `/import_duty_schedule`, `/set_role`). Admins assign roles with `/set_role @username user|admin` (or reply to a message with `/set_role user|admin`). `ALLOWED_USERNAMES` and `ALLOWED_PHONES` are no longer used for access (kept for reference).
|
||||
- **Command `/calendar_link`**: in private chat, send the user their personal ICS subscription URL (and team calendar URL) for calendar apps.
|
||||
- **Config `MINI_APP_SHORT_NAME`**: when set, the pinned duty message "View contacts" button uses a direct Mini App link (`https://t.me/BotName/ShortName?startapp=duty`) so the app opens on the current-duty view.
|
||||
- **Config `LOG_LEVEL`**: control backend logging and the Miniapp console logger (`window.__DT_LOG_LEVEL`); one of `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `INFO`).
|
||||
- **Mini App**: migrated to Next.js (TypeScript, Tailwind, shadcn/ui) with static export; improved loading states, duty timeline styling, and content readiness handling; configurable loopback host for health checks.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -36,4 +68,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Input validation and initData hash verification for Miniapp access.
|
||||
- Optional CORS and init_data_max_age; use env for secrets.
|
||||
|
||||
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.1.2...HEAD
|
||||
[2.1.2]: https://github.com/your-org/duty-teller/releases/tag/v2.1.2
|
||||
[2.1.1]: https://github.com/your-org/duty-teller/releases/tag/v2.1.1 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[2.0.6]: https://github.com/your-org/duty-teller/releases/tag/v2.0.6 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[2.0.4]: https://github.com/your-org/duty-teller/releases/tag/v2.0.4 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[2.0.3]: https://github.com/your-org/duty-teller/releases/tag/v2.0.3 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[2.0.2]: https://github.com/your-org/duty-teller/releases/tag/v2.0.2 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[2.0.0]: https://github.com/your-org/duty-teller/releases/tag/v2.0.0 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[0.1.0]: https://github.com/your-org/duty-teller/releases/tag/v0.1.0 <!-- placeholder: set to your repo URL when publishing -->
|
||||
|
||||
@@ -53,6 +53,15 @@
|
||||
bandit -r duty_teller -ll
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
All project documentation must be in **English**. This includes:
|
||||
|
||||
- README, files in `docs/`, docstrings, and commit messages that touch documentation.
|
||||
- Exception: user-facing UI strings are localized (Russian/English) in `duty_teller/i18n/` and are not considered project documentation.
|
||||
|
||||
Docstrings and code comments must be in English (Google-style docstrings). See [AGENTS.md](AGENTS.md) for AI/maintainer context.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/), e.g.:
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -1,14 +1,22 @@
|
||||
# Multi-stage: builder installs deps; runtime copies only site-packages and app code.
|
||||
# Multi-stage: webapp build (Next.js), Python builder, runtime.
|
||||
# Single image for both dev and prod; Compose files differentiate behavior.
|
||||
|
||||
# --- Stage 1: builder (dependencies only) ---
|
||||
# --- Stage 1: webapp build (Next.js static export) ---
|
||||
FROM node:20-slim AS webapp-builder
|
||||
WORKDIR /webapp
|
||||
COPY webapp-next/package.json webapp-next/package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY webapp-next/ ./
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: builder (Python dependencies only) ---
|
||||
FROM python:3.12-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
COPY duty_teller/ ./duty_teller/
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# --- Stage 2: runtime (minimal final image) ---
|
||||
# --- Stage 3: runtime (minimal final image) ---
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
@@ -27,7 +35,7 @@ COPY main.py pyproject.toml entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
COPY duty_teller/ ./duty_teller/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY webapp/ ./webapp/
|
||||
COPY --from=webapp-builder /webapp/out ./webapp-next/out
|
||||
|
||||
# Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser
|
||||
RUN adduser --disabled-password --gecos "" botuser \
|
||||
|
||||
@@ -106,7 +106,7 @@ High-level architecture (components, data flow, package relationships) is descri
|
||||
- `main.py` – Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
|
||||
- `duty_teller/` – Main package (install with `pip install -e .`). Contains:
|
||||
- `config.py` – Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
|
||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
|
||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp-next/out` (built from `webapp-next/`).
|
||||
- `db/` – SQLAlchemy models, session (`session_scope`), repository, schemas.
|
||||
- `handlers/` – Telegram command and chat handlers; register via `register_handlers(app)`.
|
||||
- `i18n/` – Translations and language detection (ru/en); used by handlers and API.
|
||||
@@ -114,7 +114,7 @@ High-level architecture (components, data flow, package relationships) is descri
|
||||
- `utils/` – Shared date, user, and handover helpers.
|
||||
- `importers/` – Duty-schedule JSON parser.
|
||||
- `alembic/` – Migrations; config in `pyproject.toml` under `[tool.alembic]`; URL and metadata from `duty_teller.config` and `duty_teller.db.models.Base`. Run: `alembic -c pyproject.toml upgrade head`.
|
||||
- `webapp/` – Miniapp UI (calendar, duty list); served at `/app`.
|
||||
- `webapp-next/` – Miniapp UI (Next.js, TypeScript, Tailwind, shadcn/ui); build output in `webapp-next/out/`, served at `/app`.
|
||||
- `tests/` – Tests; `helpers.py` provides `make_init_data` for auth tests.
|
||||
- `pyproject.toml` – Installable package (`pip install -e .`).
|
||||
|
||||
@@ -151,6 +151,7 @@ Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config`
|
||||
- **Commits:** Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, etc.
|
||||
- **Branches:** Follow [Gitea Flow](https://docs.gitea.io/en-us/workflow-branching/): main branch `main`, features and fixes in separate branches.
|
||||
- **Changes:** Via **Pull Request** in Gitea; run linters and tests (`ruff check .`, `pytest`) before merge.
|
||||
- **Documentation:** Project documentation is in English; see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
## Logs and rotation
|
||||
|
||||
|
||||
44
alembic/versions/008_duties_indexes.py
Normal file
44
alembic/versions/008_duties_indexes.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Add indexes on duties table for performance.
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2025-02-25
|
||||
|
||||
Indexes for get_current_duty, get_next_shift_end, get_duties, get_duties_for_user.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "008"
|
||||
down_revision: Union[str, None] = "007"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ix_duties_event_type_start_at",
|
||||
"duties",
|
||||
["event_type", "start_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_duties_event_type_end_at",
|
||||
"duties",
|
||||
["event_type", "end_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_duties_user_id_start_at",
|
||||
"duties",
|
||||
["user_id", "start_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_duties_user_id_start_at", table_name="duties")
|
||||
op.drop_index("ix_duties_event_type_end_at", table_name="duties")
|
||||
op.drop_index("ix_duties_event_type_start_at", table_name="duties")
|
||||
32
alembic/versions/009_trusted_groups.py
Normal file
32
alembic/versions/009_trusted_groups.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Add trusted_groups table.
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2025-03-02
|
||||
|
||||
Table for groups authorized to receive duty information.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "009"
|
||||
down_revision: Union[str, None] = "008"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"trusted_groups",
|
||||
sa.Column("chat_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("added_by_user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("added_at", sa.Text(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("chat_id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("trusted_groups")
|
||||
@@ -5,7 +5,7 @@ High-level architecture of Duty Teller: components, data flow, and package relat
|
||||
## Components
|
||||
|
||||
- **Bot** — [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 (Application API). Handles commands and group messages; runs in polling mode.
|
||||
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app`. Runs in a separate thread alongside the bot.
|
||||
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app` (built from `webapp-next/`, Next.js static export). Runs in a separate thread alongside the bot.
|
||||
- **Database** — SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (`data/duty_teller.db`). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens.
|
||||
- **Duty-schedule import** — Two-step admin flow: handover time (timezone → UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones.
|
||||
- **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. When the duty changes on schedule, the bot sends a new message, unpins the previous one and pins the new one; if `DUTY_PIN_NOTIFY` is enabled (default), pinning the new message triggers a Telegram notification for members. The first pin (bot added to group or `/pin_duty`) is always silent.
|
||||
@@ -18,6 +18,9 @@ High-level architecture of Duty Teller: components, data flow, and package relat
|
||||
- **Miniapp → API**
|
||||
Browser opens `/app`; frontend calls `GET /api/duties` and `GET /api/calendar-events` with date range. FastAPI dependencies: DB session, Telegram initData validation (`require_miniapp_username`), date validation. Data is read via `duty_teller.db.repository`.
|
||||
|
||||
- **Admin panel (Mini App)**
|
||||
Admins see an "Admin" link on the calendar (when `GET /api/admin/me` returns `is_admin: true`). The admin page at `/app/admin` lists duties for the current month and allows reassigning a duty to another user. It uses `GET /api/admin/users` (admin-only) for the user dropdown and `PATCH /api/admin/duties/:id` with `{ user_id }` to reassign. All admin endpoints require valid initData; `/users` and PATCH `/duties` additionally require the user to have the admin role (`require_admin_telegram_id`). PATCH error messages (e.g. duty not found, user not found) use the request `Accept-Language` header for i18n. The reassign dropdown shows only users with role `user` or `admin` (role_id 1 or 2 per migration 007).
|
||||
|
||||
- **Import**
|
||||
Admin sends JSON file via `/import_duty_schedule`. Handler reads file → `duty_teller.importers.duty_schedule.parse_duty_schedule()` → `DutyScheduleResult` → `duty_teller.services.import_service.run_import()` → repository (`get_or_create_user_by_full_name`, `delete_duties_in_range`, `insert_duty`).
|
||||
|
||||
|
||||
@@ -5,21 +5,23 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv).
|
||||
| Variable | Type / format | Default | Description |
|
||||
|----------|----------------|---------|-------------|
|
||||
| **BOT_TOKEN** | string | *(empty)* | Telegram bot token from [@BotFather](https://t.me/BotFather). Required for the bot to run; if unset, the entry point exits with a clear message. The server that serves the Mini App API must use the **same** token as the bot; otherwise initData validation returns `hash_mismatch`. |
|
||||
| **DATABASE_URL** | string (SQLAlchemy URL) | `sqlite:///data/duty_teller.db` | Database connection URL. Example: `sqlite:///data/duty_teller.db`. |
|
||||
| **DATABASE_URL** | string (SQLAlchemy URL) | `sqlite:///data/duty_teller.db` | Database connection URL. Should start with `sqlite://` or `postgresql://`; a warning is logged at startup if the format is unexpected. Example: `sqlite:///data/duty_teller.db`. |
|
||||
| **MINI_APP_BASE_URL** | string (URL, no trailing slash) | *(empty)* | Base URL of the miniapp (for documentation and CORS). Trailing slash is stripped. Example: `https://your-domain.com/app`. |
|
||||
| **MINI_APP_SHORT_NAME** | string | *(empty)* | Short name of the Web App in BotFather (e.g. `DutyApp`). When set, the pinned duty message "View contacts" button uses a direct Mini App link `https://t.me/BotName/ShortName?startapp=duty` so the app opens on the current-duty view. If unset, the button uses `https://t.me/BotName?startapp=duty` (user may land in bot chat first). |
|
||||
| **HTTP_HOST** | string | `127.0.0.1` | Host to bind the HTTP server to. Use `127.0.0.1` to listen only on localhost; use `0.0.0.0` to accept connections from all interfaces (e.g. when behind a reverse proxy on another machine). |
|
||||
| **HTTP_PORT** | integer | `8080` | Port for the HTTP server (FastAPI + static webapp). |
|
||||
| **HTTP_PORT** | integer (1–65535) | `8080` | Port for the HTTP server (FastAPI + static webapp). Invalid or out-of-range values are clamped; non-numeric values fall back to 8080. |
|
||||
| **ALLOWED_USERNAMES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. Access to the miniapp is controlled by **roles in the DB** (assigned by an admin via `/set_role`). |
|
||||
| **ADMIN_USERNAMES** | comma-separated list | *(empty)* | Telegram usernames treated as **admin fallback** when the user has **no role in the DB**. If a user has a role in the DB, only that role applies. Example: `admin1,admin2`. |
|
||||
| **ALLOWED_PHONES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. |
|
||||
| **ADMIN_PHONES** | comma-separated list | *(empty)* | Phones treated as **admin fallback** when the user has **no role in the DB** (user sets phone via `/set_phone`). Comparison uses digits only. Example: `+7 999 123-45-67`. |
|
||||
| **MINI_APP_SKIP_AUTH** | `1`, `true`, or `yes` | *(unset)* | If set, `/api/duties` and `/api/calendar-events` are allowed without Telegram initData. **Dev only — never use in production.** |
|
||||
| **INIT_DATA_MAX_AGE_SECONDS** | integer | `0` | Reject Telegram initData older than this many seconds. `0` = disabled. Example: `86400` for 24 hours. |
|
||||
| **CORS_ORIGINS** | comma-separated list | `*` | Allowed origins for CORS. Leave unset or set to `*` for allow-all. Example: `https://your-domain.com`. |
|
||||
| **MINI_APP_SKIP_AUTH** | `1`, `true`, or `yes` | *(unset)* | If set, `/api/duties` and `/api/calendar-events` are allowed without Telegram initData. **Dev only — never use in production.** The process exits with an error if this is set and **HTTP_HOST** is not localhost (127.0.0.1). |
|
||||
| **INIT_DATA_MAX_AGE_SECONDS** | integer (≥ 0) | `0` | Reject Telegram initData older than this many seconds. `0` = disabled. Invalid values fall back to 0. Example: `86400` for 24 hours. |
|
||||
| **CORS_ORIGINS** | comma-separated list | `*` | Allowed origins for CORS. Leave unset or set to `*` for allow-all. **In production**, set an explicit list (e.g. `https://your-domain.com`) instead of `*` to avoid allowing arbitrary origins. Example: `https://your-domain.com`. |
|
||||
| **EXTERNAL_CALENDAR_ICS_URL** | string (URL) | *(empty)* | URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap "i" on a cell to see the event summary. Empty = no external calendar. |
|
||||
| **DUTY_DISPLAY_TZ** | string (timezone name) | `Europe/Moscow` | Timezone for the pinned duty message in groups. Example: `Europe/Moscow`, `UTC`. |
|
||||
| **DUTY_PIN_NOTIFY** | `0`, `false`, or `no` to disable | `1` (enabled) | When the pinned duty message is updated on schedule, the bot sends a new message, unpins the old one and pins the new one. If enabled, pinning the new message sends a Telegram notification (“Bot pinned a message”). Set to `0`, `false`, or `no` to pin without notification. The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent. |
|
||||
| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | Default UI language when the user's Telegram language is unknown. Values starting with `ru` are normalized to `ru`, otherwise `en`. |
|
||||
| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | **Single source of language for the whole deployment:** bot messages, API error texts, and Mini App UI all use this value. No auto-detection from Telegram user, browser, or `Accept-Language`. Values starting with `ru` are normalized to `ru`; anything else becomes `en`. |
|
||||
| **LOG_LEVEL** | `DEBUG`, `INFO`, `WARNING`, or `ERROR` | `INFO` | Logging level for the backend (Python `logging`) and for the Mini App console logger (`window.__DT_LOG_LEVEL`). Use `DEBUG` for troubleshooting; in production `INFO` or higher is recommended. |
|
||||
|
||||
## Roles and access
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ Telegram bot for team duty shift calendar and group reminder. The bot and web UI
|
||||
|
||||
- [Configuration](configuration.md) — Environment variables (types, defaults, examples).
|
||||
- [Architecture](architecture.md) — Components, data flow, package relationships.
|
||||
- [Mini App design](miniapp-design.md) — Design guideline for the Telegram Mini App (webapp-next): theme, layout, components, accessibility.
|
||||
- [Import format](import-format.md) — Duty-schedule JSON format and example.
|
||||
- [Runbook](runbook.md) — Running the app, logs, common errors, DB and migrations.
|
||||
- [API Reference](api-reference.md) — Generated from code (api, db, services, handlers, importers, config).
|
||||
|
||||
For quick start, setup, and API overview see the main [README](../README.md).
|
||||
|
||||
**For maintainers and AI:** Project documentation and docstrings must be in English; see [CONTRIBUTING.md](../CONTRIBUTING.md#documentation). [AGENTS.md](../AGENTS.md) in the repo root provides entry points, conventions, and where to change what.
|
||||
|
||||
263
docs/miniapp-design.md
Normal file
263
docs/miniapp-design.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Mini App Design Guideline
|
||||
|
||||
This document defines the design rules for the Duty Teller Mini App (Next.js frontend in `webapp-next/`). It aligns with [Telegram’s official Mini App design guidelines](https://core.telegram.org/bots/webapps#designing-mini-apps) (Color Schemes and Design Guidelines) and codifies the current implementation (calendar, duty list, admin) as the reference for new screens and components.
|
||||
|
||||
---
|
||||
|
||||
## 1. Telegram design principles
|
||||
|
||||
Telegram’s guidelines state:
|
||||
|
||||
- **Mobile-first:** All elements must be responsive and designed for mobile first.
|
||||
- **Consistency:** Interactive elements should match the style, behaviour, and intent of existing Telegram UI components.
|
||||
- **Performance:** Animations should be smooth, ideally 60fps.
|
||||
- **Accessibility:** Inputs and images must have labels.
|
||||
- **Theme:** The app must use the dynamic theme-based colors provided by the API (Day/Night and custom themes).
|
||||
- **Safe areas:** The interface must respect the **safe area** and **content safe area** so content does not overlap system or Telegram UI, especially in fullscreen.
|
||||
- **Android:** On Android, use the extra User-Agent data (e.g. performance class) and reduce animations and effects on low-performance devices for smooth operation.
|
||||
|
||||
**Color schemes:** Mini Apps receive the user’s current **theme** in real time. Use the `ThemeParams` object and the CSS variables Telegram exposes (e.g. `--tg-theme-bg-color`, `--tg-theme-text-color`) so the UI adapts when the user switches Day/Night or custom themes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Theme and colors
|
||||
|
||||
### 2.1 Sources
|
||||
|
||||
Theme is resolved in this order:
|
||||
|
||||
1. Hash parameters: `tgWebAppColorScheme`, `tgWebAppThemeParams` (parsed in the shared inline script from `webapp-next/src/lib/theme-bootstrap-script.ts`, used in layout and global-error).
|
||||
2. At runtime: `Telegram.WebApp.colorScheme` and `Telegram.WebApp.themeParams` via **TelegramProvider** (theme sync is provider-owned in `ThemeSync` / `useTelegramTheme`), so every route (/, /admin, not-found, error) receives live theme updates.
|
||||
|
||||
The inline script maps all Telegram theme keys to `--tg-theme-*` CSS variables on the document root. The provider sets `data-theme` (`light` / `dark`) and applies Mini App background/header colors.
|
||||
|
||||
### 2.2 Mapping (ThemeParams → app tokens)
|
||||
|
||||
In `webapp-next/src/app/globals.css`, `:root` and `[data-theme="light"]` / `[data-theme="dark"]` map Telegram’s ThemeParams to internal design tokens:
|
||||
|
||||
| Telegram ThemeParam (CSS var) | App token / usage |
|
||||
|--------------------------------------|-------------------|
|
||||
| `--tg-theme-bg-color` | `--bg` (background) |
|
||||
| `--tg-theme-secondary-bg-color` | `--surface` (cards, panels) |
|
||||
| `--tg-theme-text-color` | `--text` (primary text) |
|
||||
| `--tg-theme-hint-color`, `--tg-theme-subtitle-text-color` | `--muted` (secondary text) |
|
||||
| `--tg-theme-link-color` | `--accent` (links, secondary actions) |
|
||||
| `--tg-theme-header-bg-color` | `--header-bg` |
|
||||
| `--tg-theme-section-bg-color` | `--card` (sections) |
|
||||
| `--tg-theme-section-header-text-color` | `--section-header` |
|
||||
| `--tg-theme-section-separator-color` | `--border` |
|
||||
| `--tg-theme-button-color` | `--primary` |
|
||||
| `--tg-theme-button-text-color` | `--primary-foreground` |
|
||||
| `--tg-theme-destructive-text-color` | `--error` |
|
||||
| `--tg-theme-accent-text-color` | `--accent-text` |
|
||||
|
||||
Tailwind/shadcn semantic tokens are wired to these: `--background` → `--bg`, `--foreground` → `--text`, `--primary`, `--secondary` → `--surface`, etc.
|
||||
|
||||
### 2.3 Domain colors
|
||||
|
||||
Duty-specific semantics (fixed per theme, not from ThemeParams):
|
||||
|
||||
- `--duty` — duty shift (e.g. green).
|
||||
- `--unavailable` — unavailable (e.g. amber).
|
||||
- `--vacation` — vacation (e.g. blue).
|
||||
- `--today` — “today” highlight; tied to `--tg-theme-accent-text-color` / `--tg-theme-link-color`.
|
||||
|
||||
Use Tailwind classes such as `bg-duty`, `border-l-unavailable`, `text-today`, `bg-today`, etc.
|
||||
|
||||
### 2.4 Derived tokens (color-mix)
|
||||
|
||||
Prefer these instead of ad-hoc color mixes:
|
||||
|
||||
- `--surface-hover`, `--surface-hover-10` — hover states for surfaces.
|
||||
- `--surface-today-tint`, `--surface-muted-tint` — subtle tints.
|
||||
- `--today-hover`, `--today-border`, `--today-border-selected`, `--today-gradient-end` — today state.
|
||||
- `--muted-fade`, `--shadow-card`, etc.
|
||||
|
||||
Defined in `globals.css`; use via `var(--surface-hover)` in classes (e.g. `hover:bg-[var(--surface-hover)]`).
|
||||
|
||||
### 2.5 Rule for new work
|
||||
|
||||
Use **only** these tokens and Tailwind/shadcn aliases (`bg-background`, `text-muted`, `bg-surface`, `text-accent`, `border-l-duty`, `bg-today`, etc.). Do not hardcode hex or RGB in new components or screens.
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout and safe areas
|
||||
|
||||
### 3.1 Width
|
||||
|
||||
- **Token:** `--max-width-app: 420px` (in `@theme` in `globals.css`).
|
||||
- **Usage:** Page wrapper uses `max-w-[var(--max-width-app)]` (e.g. in `page.tsx`, `CalendarPage.tsx`, `admin/page.tsx`). Content is centred with `mx-auto`.
|
||||
|
||||
### 3.2 Height
|
||||
|
||||
- **Viewport:** Prefer `min-h-[var(--tg-viewport-stable-height,100vh)]` for the main content area so the Mini App fills the visible height correctly when expanded/collapsed. Fallback `100vh` when not in Telegram.
|
||||
- **Body:** In `globals.css`, `body` already has `min-height: var(--tg-viewport-stable-height, 100vh)` and `background: var(--bg)`.
|
||||
|
||||
### 3.3 Safe area and content safe area
|
||||
|
||||
- **CSS custom properties** (in `globals.css`): `--app-safe-top`, `--app-safe-bottom`, `--app-safe-left`, `--app-safe-right` use Telegram viewport content-safe-area insets with `env(safe-area-inset-*)` fallbacks. Use these for sticky positioning and padding so layout works on notched and landscape devices.
|
||||
- **Class `.content-safe`**: Applies padding on all four sides using the above tokens so content does not sit under Telegram header, bottom bar, or side chrome (Bot API 8.0+). Use `.content-safe` on the **root container of each page** and on full-screen fallback screens (not-found, error, access denied).
|
||||
- **Sticky headers:** Use `top-[var(--app-safe-top)]` (not `top-0`) for sticky elements (e.g. calendar header, admin header) so they sit below the Telegram UI instead of overlapping it.
|
||||
- Lists that extend to the bottom should also account for bottom inset (e.g. `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, 12px)` in `.container-app`).
|
||||
|
||||
Official terminology (Telegram docs) uses `safeAreaInset` and `contentSafeAreaInset`
|
||||
plus events `safeAreaChanged` and `contentSafeAreaChanged`. In our code, these values
|
||||
are exposed through SDK CSS bindings and consumed via app aliases (`--app-safe-*`).
|
||||
When updating safe-area code, preserve this mapping and avoid mixing raw `env(...)`
|
||||
and Telegram content-safe insets within the same component.
|
||||
|
||||
### 3.4 Sheets and modals
|
||||
|
||||
Bottom sheets and modals that sit at the bottom of the screen must add safe area to their padding, e.g.:
|
||||
|
||||
`pb-[calc(24px+env(safe-area-inset-bottom,0px))]`
|
||||
|
||||
See `webapp-next/src/components/day-detail/DayDetail.tsx` for the Sheet content.
|
||||
|
||||
---
|
||||
|
||||
## 4. Typography and spacing
|
||||
|
||||
### 4.1 Font
|
||||
|
||||
- **Family:** `system-ui, -apple-system, sans-serif` (set in `globals.css` and Tailwind theme).
|
||||
|
||||
### 4.2 Patterns from the calendar and duty list
|
||||
|
||||
| Element | Classes / tokens |
|
||||
|--------|-------------------|
|
||||
| Month title | `text-[1.1rem]` / `sm:text-[1.25rem]`, `font-semibold` |
|
||||
| Year (above month) | `text-xs`, `text-muted` |
|
||||
| Nav buttons (prev/next month) | `size-10`, `rounded-[10px]` |
|
||||
| Calendar day cell | `text-[0.85rem]`, `rounded-lg`, `p-1` |
|
||||
| Duty timeline card | `px-2.5 py-2`, `rounded-lg` |
|
||||
|
||||
### 4.3 Page and block spacing
|
||||
|
||||
- Page container: `px-3 pb-6` in addition to `.content-safe`.
|
||||
- Between sections: `mb-3`, `mb-4` as appropriate.
|
||||
- Grids: `gap-1` for tight layouts (e.g. calendar grid), larger gaps where needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. Component patterns
|
||||
|
||||
### 5.1 Buttons
|
||||
|
||||
- **Primary:** Use the default Button variant: `bg-primary text-primary-foreground` (from `webapp-next/src/components/ui/button.tsx`).
|
||||
- **Secondary icon buttons** (e.g. calendar nav):
|
||||
`bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95`
|
||||
with `size-10` and `rounded-[10px]`.
|
||||
- Keep focus visible (e.g. `focus-visible:outline-accent` or ring); do not remove outline without a visible replacement.
|
||||
|
||||
### 5.2 Cards
|
||||
|
||||
- **Background:** `bg-surface` or `bg-card` (both resolve to theme tokens).
|
||||
- **Borders:** `border`, `--border` (section separator color).
|
||||
- **Emphasis:** `var(--shadow-card)` for highlighted cards (e.g. current duty).
|
||||
- **Left stripe by type:** `border-l-[3px]` with:
|
||||
- `border-l-duty`, `border-l-unavailable`, `border-l-vacation` for event types;
|
||||
- `border-l-today` for “current duty” (see `.border-l-today` in `globals.css`).
|
||||
|
||||
### 5.3 Calendar grid
|
||||
|
||||
- **Structure:** 7 columns × 6 rows; use `role="grid"` on the container and `role="gridcell"` on each cell.
|
||||
- **Layout:** `min-h-[var(--calendar-grid-min-height)]`, cells `aspect-square` with `min-h-8`, `rounded-lg`, `gap-1`.
|
||||
- **Today:** `bg-today text-[var(--bg)]`; hover `hover:bg-[var(--today-hover)]`.
|
||||
- **Other month:** `opacity-40`, `pointer-events-none`, `bg-[var(--surface-muted-tint)]`.
|
||||
|
||||
### 5.4 Timeline list (duties)
|
||||
|
||||
- **Dates:** Horizontal line and vertical tick from shared CSS in `globals.css`:
|
||||
`.duty-timeline-date`, `.duty-timeline-date--today` (with `::before` / `::after`).
|
||||
- **Cards:** Same card rules as above; `border-l-[3px]` + type class; optional flip card for contacts (see `DutyTimelineCard.tsx`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Motion and performance
|
||||
|
||||
### 6.1 Timing
|
||||
|
||||
- **Tokens:** `--transition-fast: 0.15s`, `--transition-normal: 0.25s`, `--ease-out: cubic-bezier(0.32, 0.72, 0, 1)`.
|
||||
- Use these for transitions and short animations so behaviour is consistent and predictable.
|
||||
|
||||
### 6.2 Reduced motion
|
||||
|
||||
- **Rule:** `@media (prefers-reduced-motion: reduce)` in `globals.css` shortens animation and transition durations globally. New animations should remain optional or short so they degrade gracefully when reduced.
|
||||
|
||||
### 6.3 Android low-performance devices
|
||||
|
||||
- **Detection:** `webapp-next/src/lib/telegram-android-perf.ts` reads Telegram’s User-Agent and sets `data-perf="low"` on the document root when the device performance class is LOW.
|
||||
- **CSS:** `[data-perf="low"] *` in `globals.css` minimizes animation/transition duration. Avoid adding heavy or long animations without considering this; prefer simple or no animation on low-end devices.
|
||||
|
||||
---
|
||||
|
||||
## 7. Accessibility
|
||||
|
||||
- **Focus:** Use `focus-visible:outline-accent` (or equivalent ring) on interactive elements; do not remove focus outline without a visible alternative.
|
||||
- **Calendar:** Use `role="grid"` and `role="gridcell"`, `aria-label` on nav buttons (e.g. “Previous month”), and a composite `aria-label` on each day cell (date + event types). See `webapp-next/src/components/calendar/CalendarDay.tsx`.
|
||||
- **Images and inputs:** Always provide labels (per Telegram’s guidelines and WCAG).
|
||||
|
||||
---
|
||||
|
||||
## 8. Telegram integration
|
||||
|
||||
- **Ready gate:** `callMiniAppReadyOnce()` (in `lib/telegram-ready.ts`) is invoked by the layout’s `ReadyGate` when `appContentReady` becomes true. Any route (/, /admin, not-found, in-app error) that sets `appContentReady` will trigger it so Telegram hides its loader; no route-specific logic is required.
|
||||
- **Header and background:** On init (layout script and provider’s theme sync), call:
|
||||
- `setBackgroundColor('bg_color')`
|
||||
- `setHeaderColor('bg_color')`
|
||||
- `setBottomBarColor('bottom_bar_bg_color')` when available (Bot API 7.10+).
|
||||
- **Surface contrast:** When `--surface` equals `--bg` (e.g. some iOS OLED themes), `fixSurfaceContrast()` in `use-telegram-theme.ts` adjusts `--surface` using ThemeParams or a light color-mix so cards and panels remain visible.
|
||||
|
||||
### 8.1 Native control policy
|
||||
|
||||
- Use platform wrappers in `src/hooks/telegram/` rather than direct SDK calls in
|
||||
feature components.
|
||||
- **BackButton:** preferred for route-level back navigation in Telegram context.
|
||||
- **SettingsButton:** use for route actions like opening `/admin` from calendar.
|
||||
- **Main/Secondary button:** optional; use only if action must align with Telegram
|
||||
bottom action affordance (do not duplicate with conflicting in-app primary CTA).
|
||||
- **Haptics:** trigger only on meaningful user actions (submit, confirm, close).
|
||||
|
||||
### 8.2 Swipe and closing policy
|
||||
|
||||
- Keep vertical swipes enabled by default (`enableVerticalSwipes` behavior).
|
||||
- Disable vertical swipes only on screens with explicit gesture conflict and document
|
||||
the reason in code review.
|
||||
- Enable closing confirmation only for stateful flows where accidental close can
|
||||
lose user intent (e.g. reassignment flow in admin sheet).
|
||||
|
||||
### 8.3 Fullscreen/newer APIs policy
|
||||
|
||||
- Fullscreen APIs (`requestFullscreen`, `exitFullscreen`) are currently optional and
|
||||
out of scope unless a feature explicitly requires immersive mode.
|
||||
- If fullscreen is introduced, review safe area/content safe area and verify
|
||||
`safeAreaChanged`, `contentSafeAreaChanged`, and `fullscreenChanged` handling.
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist for new screens and components
|
||||
|
||||
Use this for review when adding or changing UI:
|
||||
|
||||
- [ ] Use only design tokens from `globals.css` and Tailwind/shadcn aliases; no hardcoded colours.
|
||||
- [ ] Page wrapper has `.content-safe`, `max-w-[var(--max-width-app)]`, and appropriate min-height (viewport-stable-height or `min-h-screen` with fallback).
|
||||
- [ ] Buttons and cards follow the patterns above (variants, surfaces, border-l by type).
|
||||
- [ ] Safe area is respected for bottom padding and for sheets/modals.
|
||||
- [ ] Interactive elements and grids/lists have appropriate `aria-label`s and roles.
|
||||
- [ ] New animations respect `prefers-reduced-motion` and `data-perf="low"` (short or minimal on low-end Android).
|
||||
- [ ] User-facing strings and `aria-label`/`sr-only` text are localized via i18n (no hardcoded English in shared UI).
|
||||
- [ ] Telegram controls are connected through platform hooks (`src/hooks/telegram/*`) instead of direct SDK calls.
|
||||
- [ ] Vertical swipe and closing confirmation behavior follows the policy above.
|
||||
|
||||
## 10. Verification matrix (Mini App)
|
||||
|
||||
At minimum verify:
|
||||
|
||||
- Telegram light + dark themes.
|
||||
- iOS and Android safe area/content-safe-area behavior (portrait + landscape).
|
||||
- Android low-performance behavior (`data-perf="low"`).
|
||||
- Deep link current duty (`startParam=duty`).
|
||||
- Direct `/admin` open and reassignment flow.
|
||||
- Access denied, not-found, and error boundary screens.
|
||||
- Calendar swipe navigation with sticky header and native Telegram controls.
|
||||
119
docs/webapp-next-refactor-audit.md
Normal file
119
docs/webapp-next-refactor-audit.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Webapp-next Refactor Baseline Audit
|
||||
|
||||
This note captures the baseline before the phased refactor. It defines current risks,
|
||||
duplication hotspots, and expected behavior that must not regress.
|
||||
|
||||
## 1) Screens and boundaries
|
||||
|
||||
- Home route orchestration: `webapp-next/src/app/page.tsx`
|
||||
- Chooses among `AccessDeniedScreen`, `CurrentDutyView`, `CalendarPage`.
|
||||
- Controls app visibility via `appContentReady`.
|
||||
- Admin route orchestration: `webapp-next/src/app/admin/page.tsx`
|
||||
- Thin route, but still owns shell duplication and content-ready signaling.
|
||||
- Calendar composition root: `webapp-next/src/components/CalendarPage.tsx`
|
||||
- Combines sticky layout, swipe, month loading, auto-refresh, settings button.
|
||||
- Current duty feature root: `webapp-next/src/components/current-duty/CurrentDutyView.tsx`
|
||||
- Combines data loading, error/access states, back button, and close action.
|
||||
- Admin feature state root: `webapp-next/src/components/admin/useAdminPage.ts`
|
||||
- Combines SDK button handling, admin access, users/duties loading, sheet state,
|
||||
mutation and infinite scroll concerns.
|
||||
|
||||
## 2) Telegram integration touchpoints
|
||||
|
||||
- SDK/provider bootstrap:
|
||||
- `webapp-next/src/components/providers/TelegramProvider.tsx`
|
||||
- `webapp-next/src/components/ReadyGate.tsx`
|
||||
- `webapp-next/src/lib/telegram-ready.ts`
|
||||
- Direct control usage in feature code:
|
||||
- `backButton` in `CurrentDutyView` and `useAdminPage`
|
||||
- `settingsButton` in `CalendarPage`
|
||||
- `closeMiniApp` in `CurrentDutyView`
|
||||
- Haptics in feature-level handlers:
|
||||
- `webapp-next/src/lib/telegram-haptic.ts`
|
||||
|
||||
Risk: platform behavior is spread across feature components instead of a narrow
|
||||
platform boundary.
|
||||
|
||||
## 3) Layout and shell duplication
|
||||
|
||||
Repeated outer wrappers appear across route and state screens:
|
||||
- `content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background`
|
||||
- `mx-auto flex w-full max-w-[var(--max-width-app)] flex-col`
|
||||
|
||||
Known locations:
|
||||
- `webapp-next/src/app/page.tsx`
|
||||
- `webapp-next/src/app/admin/page.tsx`
|
||||
- `webapp-next/src/components/CalendarPage.tsx`
|
||||
- `webapp-next/src/components/states/FullScreenStateShell.tsx`
|
||||
- `webapp-next/src/app/not-found.tsx`
|
||||
- `webapp-next/src/app/global-error.tsx`
|
||||
|
||||
Risk: future safe-area or viewport fixes require multi-file edits.
|
||||
|
||||
## 4) Readiness and lifecycle coupling
|
||||
|
||||
`appContentReady` is set by multiple screens/routes:
|
||||
- `page.tsx`
|
||||
- `admin/page.tsx`
|
||||
- `CalendarPage.tsx`
|
||||
- `CurrentDutyView.tsx`
|
||||
|
||||
`ReadyGate` is route-agnostic, but signaling is currently ad hoc.
|
||||
Risk: race conditions or deadlock-like "hidden app" scenarios when screen states
|
||||
change in future refactors.
|
||||
|
||||
## 5) Async/data-loading duplication
|
||||
|
||||
Repeated manual patterns (abort, retries, state machine):
|
||||
- `webapp-next/src/hooks/use-month-data.ts`
|
||||
- `webapp-next/src/components/current-duty/CurrentDutyView.tsx`
|
||||
- `webapp-next/src/components/admin/useAdminPage.ts`
|
||||
|
||||
Risk: inconsistent retry/access-denied behavior and difficult maintenance.
|
||||
|
||||
## 6) Store mixing concerns
|
||||
|
||||
`webapp-next/src/store/app-store.ts` currently mixes:
|
||||
- session/platform concerns (`lang`, `appContentReady`, `isAdmin`)
|
||||
- calendar/domain concerns (`currentMonth`, `pendingMonth`, duties/events)
|
||||
- view concerns (`currentView`, `selectedDay`, `error`, `accessDenied`)
|
||||
|
||||
Risk: high coupling and larger blast radius for otherwise local changes.
|
||||
|
||||
## 7) i18n/a11y gaps to close
|
||||
|
||||
- Hardcoded grid label in `CalendarGrid`: `aria-label="Calendar"`.
|
||||
- Hardcoded sr-only close text in shared `Sheet`: `"Close"`.
|
||||
- Mixed language access strategy (`useTranslation()` vs `getLang()/translate()`),
|
||||
valid for bootstrap/error boundary, but not explicitly codified in one place.
|
||||
|
||||
## 8) Telegram Mini Apps compliance checklist (baseline)
|
||||
|
||||
Already implemented well:
|
||||
- Dynamic theme + runtime sync.
|
||||
- Safe-area/content-safe-area usage via CSS vars and layout classes.
|
||||
- `ready()` gate and Telegram loader handoff.
|
||||
- Android low-performance class handling.
|
||||
|
||||
Needs explicit policy/consistency:
|
||||
- Vertical swipes policy for gesture-heavy screens.
|
||||
- Closing confirmation policy for stateful admin flows.
|
||||
- Main/Secondary button usage policy for primary actions.
|
||||
- Terminology alignment with current official docs:
|
||||
`safeAreaInset`, `contentSafeAreaInset`, fullscreen events.
|
||||
|
||||
## 9) Expected behavior (non-regression)
|
||||
|
||||
- `/`:
|
||||
- Shows access denied screen if not allowed.
|
||||
- Opens current-duty view for `startParam=duty`.
|
||||
- Otherwise opens calendar.
|
||||
- `/admin`:
|
||||
- Denies non-admin users.
|
||||
- Loads users and duties for selected admin month.
|
||||
- Allows reassignment with visible feedback.
|
||||
- Error/fallback states:
|
||||
- `not-found` and global error remain full-screen and theme-safe.
|
||||
- Telegram UX:
|
||||
- Back/settings controls remain functional in Telegram context.
|
||||
- Ready handoff happens when first useful screen is visible.
|
||||
@@ -1,181 +0,0 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: duty-teller
|
||||
Version: 0.1.0
|
||||
Summary: Telegram bot for team duty shift calendar and group reminder
|
||||
Requires-Python: >=3.11
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: python-telegram-bot[job-queue]<23.0,>=22.0
|
||||
Requires-Dist: python-dotenv<2.0,>=1.0
|
||||
Requires-Dist: fastapi<1.0,>=0.115
|
||||
Requires-Dist: uvicorn[standard]<1.0,>=0.32
|
||||
Requires-Dist: sqlalchemy<3.0,>=2.0
|
||||
Requires-Dist: alembic<2.0,>=1.14
|
||||
Requires-Dist: pydantic<3.0,>=2.0
|
||||
Requires-Dist: icalendar<6.0,>=5.0
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
||||
Requires-Dist: pytest-asyncio<2.0,>=1.0; extra == "dev"
|
||||
Requires-Dist: pytest-cov<7.0,>=6.0; extra == "dev"
|
||||
Requires-Dist: httpx<1.0,>=0.27; extra == "dev"
|
||||
Provides-Extra: docs
|
||||
Requires-Dist: mkdocs<2,>=1.5; extra == "docs"
|
||||
Requires-Dist: mkdocstrings[python]<1,>=0.24; extra == "docs"
|
||||
Requires-Dist: mkdocs-material<10,>=9.0; extra == "docs"
|
||||
|
||||
# Duty Teller (Telegram Bot)
|
||||
|
||||
A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 with the `Application` API. The bot and web UI support **Russian and English** (language from Telegram or `DEFAULT_LANGUAGE`).
|
||||
|
||||
**History of changes:** [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Get a bot token
|
||||
|
||||
1. Open Telegram and search for [@BotFather](https://t.me/BotFather).
|
||||
2. Send `/newbot` and follow the prompts to create a bot.
|
||||
3. Copy the token BotFather gives you.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Clone and enter the project**
|
||||
```bash
|
||||
cd duty-teller
|
||||
```
|
||||
|
||||
2. **Create a virtual environment (recommended)**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/macOS
|
||||
# or: venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **Configure the bot**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Edit `.env` and set `BOT_TOKEN` to the token from BotFather.
|
||||
|
||||
5. **Miniapp access (calendar)**
|
||||
Access is controlled by **roles in the DB** (assigned by an admin with `/set_role @username user|admin`). Set `ADMIN_USERNAMES` (and optionally `ADMIN_PHONES`) so that at least one admin can use the bot and assign roles; these also act as a fallback for admin when a user has no role in the DB. See [docs/configuration.md](docs/configuration.md).
|
||||
**Mini App URL:** When configuring the bot's menu button or Web App URL (e.g. in @BotFather or via `setChatMenuButton`), use the URL **with a trailing slash**, e.g. `https://your-domain.com/app/`. A redirect from `/app` to `/app/` can cause the browser to drop the fragment that Telegram sends, which breaks authorization.
|
||||
**How to open:** Users must open the calendar **via the bot's menu button** (⋮ → "Calendar" or the configured label) or a **Web App inline button**. If they use "Open in browser" or a direct link, Telegram may not send user data (`tgWebAppData`), and access will be denied.
|
||||
**BOT_TOKEN:** The server that serves `/api/duties` (e.g. your production host) must have in `.env` the **same** bot token as the bot from which users open the Mini App. If the token differs (e.g. test vs production bot), validation returns "hash_mismatch" and access is denied.
|
||||
|
||||
6. **Other options**
|
||||
Full list of environment variables (types, defaults, examples): **[docs/configuration.md](docs/configuration.md)**.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
## Bot commands
|
||||
|
||||
- **`/start`** — Greeting and user registration in the database.
|
||||
- **`/help`** — Help on available commands.
|
||||
- **`/set_phone [number]`** — Set or clear phone number (private chat only); used for access via `ALLOWED_PHONES` / `ADMIN_PHONES`.
|
||||
- **`/import_duty_schedule`** — Import duty schedule (admin only); see **Duty schedule import** below for the two-step flow.
|
||||
- **`/set_role @username user|admin`** — Set a user’s role (admin only). Alternatively, reply to a message and send `/set_role user|admin`.
|
||||
- **`/pin_duty`** — Pin the current duty message in a group (reply to the bot’s duty message); time/timezone for the pinned message come from `DUTY_DISPLAY_TZ`.
|
||||
|
||||
## Run with Docker
|
||||
|
||||
Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`.
|
||||
|
||||
- **Dev** (volume mount; code changes apply without rebuild):
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
Stop with `Ctrl+C` or `docker compose -f docker-compose.dev.yml down`.
|
||||
|
||||
- **Prod** (no volume; runs the built image; restarts on failure):
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
For production deployments you may use Docker secrets or your orchestrator’s env instead of a `.env` file.
|
||||
|
||||
The image is built from `Dockerfile`; on start, `entrypoint.sh` runs Alembic migrations then starts the app as user `botuser`.
|
||||
|
||||
**Production behind a reverse proxy:** When the app is behind nginx/Caddy etc., `request.client.host` is usually the proxy (e.g. 127.0.0.1). The "private IP" bypass (allowing requests without initData from localhost) then applies to the proxy, not the real client. Either ensure the Mini App always sends initData, or forward the real client IP (e.g. `X-Forwarded-For`) and use it for that check. See `api/app.py` `_is_private_client` for details.
|
||||
|
||||
## API
|
||||
|
||||
The HTTP server is FastAPI; the miniapp is served at `/app`.
|
||||
|
||||
**Interactive API documentation (Swagger UI)** is available at **`/docs`**, e.g. `http://localhost:8080/docs` when running locally.
|
||||
|
||||
- **`GET /api/duties`** — List of duties (date params; auth via Telegram initData or, in dev, `MINI_APP_SKIP_AUTH` / private IP).
|
||||
- **`GET /api/calendar-events`** — Calendar events (including external ICS when `EXTERNAL_CALENDAR_ICS_URL` is set).
|
||||
- **`GET /api/calendar/ical/{token}.ics`** — Personal ICS calendar (by secret token; no Telegram auth).
|
||||
|
||||
For production, initData validation is required; see the reverse-proxy paragraph above for proxy/headers.
|
||||
|
||||
## Project layout
|
||||
|
||||
High-level architecture (components, data flow, package relationships) is described in [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
- `main.py` – Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
|
||||
- `duty_teller/` – Main package (install with `pip install -e .`). Contains:
|
||||
- `config.py` – Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
|
||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
|
||||
- `db/` – SQLAlchemy models, session (`session_scope`), repository, schemas.
|
||||
- `handlers/` – Telegram command and chat handlers; register via `register_handlers(app)`.
|
||||
- `i18n/` – Translations and language detection (ru/en); used by handlers and API.
|
||||
- `services/` – Business logic (group duty pin, import); accept session from caller.
|
||||
- `utils/` – Shared date, user, and handover helpers.
|
||||
- `importers/` – Duty-schedule JSON parser.
|
||||
- `alembic/` – Migrations; config in `pyproject.toml` under `[tool.alembic]`; URL and metadata from `duty_teller.config` and `duty_teller.db.models.Base`. Run: `alembic -c pyproject.toml upgrade head`.
|
||||
- `webapp/` – Miniapp UI (calendar, duty list); served at `/app`.
|
||||
- `tests/` – Tests; `helpers.py` provides `make_init_data` for auth tests.
|
||||
- `pyproject.toml` – Installable package (`pip install -e .`).
|
||||
|
||||
**Documentation:** The `docs/` folder contains configuration reference, architecture, import format, and runbook. API reference is generated from the code. Build: `mkdocs build` (requires `pip install -e ".[docs]"`). Preview: `mkdocs serve`.
|
||||
|
||||
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 import (duty-schedule)
|
||||
|
||||
The **`/import_duty_schedule`** command is available only to users in `ADMIN_USERNAMES` or `ADMIN_PHONES`. Import is done in two steps:
|
||||
|
||||
1. **Handover time** — The bot asks for the shift handover time and optional timezone (e.g. `09:00 Europe/Moscow` or `06:00 UTC`). This is converted to UTC and used as the boundary between duty periods when creating records.
|
||||
2. **JSON file** — Send a file in duty-schedule format.
|
||||
|
||||
Format: at the root of the JSON — a **meta** object with `start_date` (YYYY-MM-DD) and a **schedule** array of objects with `name` (full name) and `duty` (string with separator `;`; characters **в/В/б/Б** = duty, **Н** = unavailable, **О** = vacation). The number of days is given by the length of the `duty` string. On re-import, duties in the same date range for each user are replaced by the new data.
|
||||
|
||||
**Full format description and example JSON:** [docs/import-format.md](docs/import-format.md).
|
||||
|
||||
## Tests
|
||||
|
||||
Run from the repository root (no `src/` directory; package is `duty_teller` at the root). Use `PYTHONPATH=.` if needed:
|
||||
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
pytest
|
||||
```
|
||||
|
||||
Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config` (is_admin, can_access_miniapp), and the API (date validation, 403/200 with mocked auth, plus an E2E auth test without auth mocks).
|
||||
|
||||
**CI (Gitea Actions):** Lint (ruff), tests (pytest), security (bandit). If the workflow uses `PYTHONPATH: src` or `bandit -r src`, update it to match the repo layout (no `src/`).
|
||||
|
||||
## Contributing
|
||||
|
||||
- **Commits:** Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, etc.
|
||||
- **Branches:** Follow [Gitea Flow](https://docs.gitea.io/en-us/workflow-branching/): main branch `main`, features and fixes in separate branches.
|
||||
- **Changes:** Via **Pull Request** in Gitea; run linters and tests (`ruff check .`, `pytest`) before merge.
|
||||
|
||||
## Logs and rotation
|
||||
|
||||
To meet the 7-day log retention policy, configure log rotation at deploy time: e.g. [logrotate](https://manpages.ubuntu.com/logrotate), systemd logging settings, or Docker (size/time retention limits). Keep application logs for no more than 7 days.
|
||||
@@ -1,63 +0,0 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
duty_teller/__init__.py
|
||||
duty_teller/config.py
|
||||
duty_teller/run.py
|
||||
duty_teller.egg-info/PKG-INFO
|
||||
duty_teller.egg-info/SOURCES.txt
|
||||
duty_teller.egg-info/dependency_links.txt
|
||||
duty_teller.egg-info/entry_points.txt
|
||||
duty_teller.egg-info/requires.txt
|
||||
duty_teller.egg-info/top_level.txt
|
||||
duty_teller/api/__init__.py
|
||||
duty_teller/api/app.py
|
||||
duty_teller/api/calendar_ics.py
|
||||
duty_teller/api/dependencies.py
|
||||
duty_teller/api/personal_calendar_ics.py
|
||||
duty_teller/api/telegram_auth.py
|
||||
duty_teller/db/__init__.py
|
||||
duty_teller/db/models.py
|
||||
duty_teller/db/repository.py
|
||||
duty_teller/db/schemas.py
|
||||
duty_teller/db/session.py
|
||||
duty_teller/handlers/__init__.py
|
||||
duty_teller/handlers/commands.py
|
||||
duty_teller/handlers/common.py
|
||||
duty_teller/handlers/errors.py
|
||||
duty_teller/handlers/group_duty_pin.py
|
||||
duty_teller/handlers/import_duty_schedule.py
|
||||
duty_teller/i18n/__init__.py
|
||||
duty_teller/i18n/core.py
|
||||
duty_teller/i18n/lang.py
|
||||
duty_teller/i18n/messages.py
|
||||
duty_teller/importers/__init__.py
|
||||
duty_teller/importers/duty_schedule.py
|
||||
duty_teller/services/__init__.py
|
||||
duty_teller/services/group_duty_pin_service.py
|
||||
duty_teller/services/import_service.py
|
||||
duty_teller/utils/__init__.py
|
||||
duty_teller/utils/dates.py
|
||||
duty_teller/utils/handover.py
|
||||
duty_teller/utils/user.py
|
||||
tests/test_api_dependencies.py
|
||||
tests/test_app.py
|
||||
tests/test_calendar_ics.py
|
||||
tests/test_calendar_token_repository.py
|
||||
tests/test_config.py
|
||||
tests/test_db_session.py
|
||||
tests/test_duty_schedule_parser.py
|
||||
tests/test_group_duty_pin_service.py
|
||||
tests/test_handlers_commands.py
|
||||
tests/test_handlers_errors.py
|
||||
tests/test_handlers_group_duty_pin.py
|
||||
tests/test_handlers_init.py
|
||||
tests/test_i18n.py
|
||||
tests/test_import_duty_schedule_integration.py
|
||||
tests/test_import_service.py
|
||||
tests/test_package_init.py
|
||||
tests/test_personal_calendar_ics.py
|
||||
tests/test_repository_duty_range.py
|
||||
tests/test_repository_roles.py
|
||||
tests/test_run.py
|
||||
tests/test_telegram_auth.py
|
||||
tests/test_utils.py
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[console_scripts]
|
||||
duty-teller = duty_teller.run:main
|
||||
@@ -1,19 +0,0 @@
|
||||
python-telegram-bot[job-queue]<23.0,>=22.0
|
||||
python-dotenv<2.0,>=1.0
|
||||
fastapi<1.0,>=0.115
|
||||
uvicorn[standard]<1.0,>=0.32
|
||||
sqlalchemy<3.0,>=2.0
|
||||
alembic<2.0,>=1.14
|
||||
pydantic<3.0,>=2.0
|
||||
icalendar<6.0,>=5.0
|
||||
|
||||
[dev]
|
||||
pytest<9.0,>=8.0
|
||||
pytest-asyncio<2.0,>=1.0
|
||||
pytest-cov<7.0,>=6.0
|
||||
httpx<1.0,>=0.27
|
||||
|
||||
[docs]
|
||||
mkdocs<2,>=1.5
|
||||
mkdocstrings[python]<1,>=0.24
|
||||
mkdocs-material<10,>=9.0
|
||||
@@ -1 +0,0 @@
|
||||
duty_teller
|
||||
@@ -3,27 +3,45 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
|
||||
import duty_teller.config as config
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
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 (
|
||||
_lang_from_accept_language,
|
||||
fetch_duties_response,
|
||||
get_authenticated_telegram_id_dep,
|
||||
get_db_session,
|
||||
get_validated_dates,
|
||||
require_admin_telegram_id,
|
||||
require_miniapp_username,
|
||||
)
|
||||
from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics
|
||||
from duty_teller.cache import invalidate_duty_related_caches, ics_calendar_cache
|
||||
from duty_teller.db.repository import (
|
||||
get_duties,
|
||||
get_duties_for_user,
|
||||
get_duty_by_id,
|
||||
get_user_by_calendar_token,
|
||||
get_users_for_admin,
|
||||
is_admin_for_telegram_user,
|
||||
update_duty_user,
|
||||
)
|
||||
from duty_teller.db.schemas import CalendarEvent, DutyWithUser
|
||||
from duty_teller.db.models import User
|
||||
from duty_teller.db.schemas import (
|
||||
AdminDutyReassignBody,
|
||||
CalendarEvent,
|
||||
DutyInDb,
|
||||
DutyWithUser,
|
||||
UserForAdmin,
|
||||
)
|
||||
from duty_teller.i18n import t
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,6 +57,16 @@ def _is_valid_calendar_token(token: str) -> bool:
|
||||
app = FastAPI(title="Duty Teller API")
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Log unhandled exceptions and return 500 without exposing details to the client."""
|
||||
log.exception("Unhandled exception: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", summary="Health check")
|
||||
def health() -> dict:
|
||||
"""Return 200 when the app is up. Used by Docker HEALTHCHECK."""
|
||||
@@ -54,6 +82,103 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
class NoCacheStaticMiddleware:
|
||||
"""
|
||||
Raw ASGI middleware: Cache-Control: no-store for all /app and /app/* static files;
|
||||
Vary: Accept-Language on all responses so reverse proxies do not serve one user's response to another.
|
||||
"""
|
||||
|
||||
def __init__(self, app, **kwargs):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
is_app_path = path == "/app" or path.startswith("/app/")
|
||||
|
||||
async def send_wrapper(message):
|
||||
if message["type"] == "http.response.start":
|
||||
headers = list(message.get("headers", []))
|
||||
header_names = {h[0].lower(): i for i, h in enumerate(headers)}
|
||||
if is_app_path:
|
||||
cache_control = (b"cache-control", b"no-store")
|
||||
if b"cache-control" in header_names:
|
||||
headers[header_names[b"cache-control"]] = cache_control
|
||||
else:
|
||||
headers.append(cache_control)
|
||||
vary_val = b"Accept-Language"
|
||||
if b"vary" in header_names:
|
||||
idx = header_names[b"vary"]
|
||||
existing = headers[idx][1]
|
||||
tokens = [p.strip() for p in existing.split(b",")]
|
||||
if vary_val not in tokens:
|
||||
headers[idx] = (b"vary", existing + b", " + vary_val)
|
||||
else:
|
||||
headers.append((b"vary", vary_val))
|
||||
message = {
|
||||
"type": "http.response.start",
|
||||
"status": message["status"],
|
||||
"headers": headers,
|
||||
}
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
|
||||
|
||||
app.add_middleware(NoCacheStaticMiddleware)
|
||||
|
||||
|
||||
# Allowed values for config.js to prevent script injection.
|
||||
_VALID_LANGS = frozenset({"en", "ru"})
|
||||
_VALID_LOG_LEVELS = frozenset({"debug", "info", "warning", "error"})
|
||||
|
||||
|
||||
def _safe_js_string(value: str, allowed: frozenset[str], default: str) -> str:
|
||||
"""Return value if it is in allowed set, else default. Prevents injection in config.js."""
|
||||
if value in allowed:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
# Timezone for duty display: allow only safe chars (letters, digits, /, _, -, +) to prevent injection.
|
||||
_TZ_SAFE_RE = re.compile(r"^[A-Za-z0-9_/+-]{1,50}$")
|
||||
|
||||
|
||||
def _safe_tz_string(value: str) -> str:
|
||||
"""Return value if it matches safe timezone pattern, else empty string."""
|
||||
if value and _TZ_SAFE_RE.match(value.strip()):
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
@app.get(
|
||||
"/app/config.js",
|
||||
summary="Mini App config (language, log level, timezone)",
|
||||
description=(
|
||||
"Returns JS that sets window.__DT_LANG, window.__DT_LOG_LEVEL and window.__DT_TZ. "
|
||||
"Loaded before main.js."
|
||||
),
|
||||
)
|
||||
def app_config_js() -> Response:
|
||||
"""Return JS assigning window.__DT_LANG, __DT_LOG_LEVEL and __DT_TZ for the webapp. No caching."""
|
||||
lang = _safe_js_string(config.DEFAULT_LANGUAGE, _VALID_LANGS, "en")
|
||||
log_level = _safe_js_string(config.LOG_LEVEL_STR.lower(), _VALID_LOG_LEVELS, "info")
|
||||
tz = _safe_tz_string(config.DUTY_DISPLAY_TZ)
|
||||
tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;"
|
||||
body = (
|
||||
f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";{tz_js}\n'
|
||||
'if (typeof window !== "undefined") window.dispatchEvent(new Event("dt-config-loaded"));'
|
||||
)
|
||||
return Response(
|
||||
content=body,
|
||||
media_type="application/javascript; charset=utf-8",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/duties",
|
||||
response_model=list[DutyWithUser],
|
||||
@@ -112,18 +237,24 @@ def get_team_calendar_ical(
|
||||
) -> Response:
|
||||
"""Return ICS calendar with all duties (event_type duty only). Token validates user."""
|
||||
if not _is_valid_calendar_token(token):
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
user = get_user_by_calendar_token(session, token)
|
||||
if user is None:
|
||||
return Response(status_code=404, content="Not found")
|
||||
today = date.today()
|
||||
from_date = (today - timedelta(days=365)).strftime("%Y-%m-%d")
|
||||
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
|
||||
all_duties = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
duties_duty_only = [
|
||||
(d, name) for d, name in all_duties if (d.event_type or "duty") == "duty"
|
||||
]
|
||||
ics_bytes = build_team_ics(duties_duty_only)
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
cache_key = ("team_ics",)
|
||||
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
||||
if not found:
|
||||
today = date.today()
|
||||
from_date = (today - timedelta(days=365)).strftime("%Y-%m-%d")
|
||||
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
|
||||
all_duties = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
duties_duty_only = [
|
||||
(d, name)
|
||||
for d, name, *_ in all_duties
|
||||
if (d.event_type or "duty") == "duty"
|
||||
]
|
||||
ics_bytes = build_team_ics(duties_duty_only)
|
||||
ics_calendar_cache.set(cache_key, ics_bytes)
|
||||
return Response(
|
||||
content=ics_bytes,
|
||||
media_type="text/calendar; charset=utf-8",
|
||||
@@ -147,23 +278,117 @@ def get_personal_calendar_ical(
|
||||
No Telegram auth; access is by secret token in the URL.
|
||||
"""
|
||||
if not _is_valid_calendar_token(token):
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
user = get_user_by_calendar_token(session, token)
|
||||
if user is None:
|
||||
return Response(status_code=404, content="Not found")
|
||||
today = date.today()
|
||||
from_date = (today - timedelta(days=365)).strftime("%Y-%m-%d")
|
||||
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
|
||||
duties_with_name = get_duties_for_user(
|
||||
session, user.id, from_date=from_date, to_date=to_date, event_types=["duty"]
|
||||
)
|
||||
ics_bytes = build_personal_ics(duties_with_name)
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
cache_key = ("personal_ics", user.id)
|
||||
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
||||
if not found:
|
||||
today = date.today()
|
||||
from_date = (today - timedelta(days=365)).strftime("%Y-%m-%d")
|
||||
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
|
||||
duties_with_name = get_duties_for_user(
|
||||
session, user.id, from_date=from_date, to_date=to_date, event_types=["duty"]
|
||||
)
|
||||
ics_bytes = build_personal_ics(duties_with_name)
|
||||
ics_calendar_cache.set(cache_key, ics_bytes)
|
||||
return Response(
|
||||
content=ics_bytes,
|
||||
media_type="text/calendar; charset=utf-8",
|
||||
)
|
||||
|
||||
|
||||
webapp_path = config.PROJECT_ROOT / "webapp"
|
||||
# --- Admin API (initData + admin role required for GET /users and PATCH /duties) ---
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/admin/me",
|
||||
summary="Check admin status",
|
||||
description=(
|
||||
"Returns is_admin for the authenticated Mini App user. "
|
||||
"Requires valid initData (same as /api/duties)."
|
||||
),
|
||||
)
|
||||
def admin_me(
|
||||
_username: str = Depends(require_miniapp_username),
|
||||
telegram_id: int = Depends(get_authenticated_telegram_id_dep),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Return { is_admin: true } or { is_admin: false } for the current user."""
|
||||
is_admin = is_admin_for_telegram_user(session, telegram_id)
|
||||
return {"is_admin": is_admin}
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/admin/users",
|
||||
response_model=list[UserForAdmin],
|
||||
summary="List users for admin dropdown",
|
||||
description="Returns id, full_name, username for all users. Admin only.",
|
||||
)
|
||||
def admin_list_users(
|
||||
_admin_telegram_id: int = Depends(require_admin_telegram_id),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> list[UserForAdmin]:
|
||||
"""Return all users ordered by full_name for admin reassign dropdown."""
|
||||
users = get_users_for_admin(session)
|
||||
return [
|
||||
UserForAdmin(
|
||||
id=u.id,
|
||||
full_name=u.full_name,
|
||||
username=u.username,
|
||||
role_id=u.role_id,
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@app.patch(
|
||||
"/api/admin/duties/{duty_id}",
|
||||
response_model=DutyInDb,
|
||||
summary="Reassign duty to another user",
|
||||
description="Update duty's user_id. Admin only. Invalidates ICS and pin caches.",
|
||||
)
|
||||
def admin_reassign_duty(
|
||||
duty_id: int,
|
||||
body: AdminDutyReassignBody,
|
||||
request: Request,
|
||||
_admin_telegram_id: int = Depends(require_admin_telegram_id),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> DutyInDb:
|
||||
"""Reassign duty to another user; return updated duty or 404/400 with i18n detail."""
|
||||
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||
if duty_id <= 0 or body.user_id <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=t(lang, "api.bad_request"),
|
||||
)
|
||||
duty = get_duty_by_id(session, duty_id)
|
||||
if duty is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=t(lang, "admin.duty_not_found"),
|
||||
)
|
||||
if session.get(User, body.user_id) is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=t(lang, "admin.user_not_found"),
|
||||
)
|
||||
updated = update_duty_user(session, duty_id, body.user_id, commit=True)
|
||||
if updated is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=t(lang, "admin.duty_not_found"),
|
||||
)
|
||||
invalidate_duty_related_caches()
|
||||
return DutyInDb(
|
||||
id=updated.id,
|
||||
user_id=updated.user_id,
|
||||
start_at=updated.start_at,
|
||||
end_at=updated.end_at,
|
||||
)
|
||||
|
||||
|
||||
webapp_path = config.PROJECT_ROOT / "webapp-next" / "out"
|
||||
if webapp_path.is_dir():
|
||||
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
||||
|
||||
@@ -7,12 +7,15 @@ from urllib.error import URLError
|
||||
|
||||
from icalendar import Calendar
|
||||
|
||||
from duty_teller.cache import TTLCache
|
||||
from duty_teller.utils.http_client import safe_urlopen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# In-memory cache: url -> (cached_at_timestamp, raw_ics_bytes)
|
||||
# Raw ICS bytes cache: url -> (cached_at_timestamp, raw_ics_bytes)
|
||||
_ics_cache: dict[str, tuple[float, bytes]] = {}
|
||||
# Parsed events cache: url -> list of {date, summary}. TTL 7 days.
|
||||
_parsed_events_cache = TTLCache(ttl_seconds=7 * 24 * 3600, max_size=100)
|
||||
CACHE_TTL_SECONDS = 7 * 24 * 3600 # 1 week
|
||||
FETCH_TIMEOUT_SECONDS = 15
|
||||
|
||||
@@ -68,8 +71,8 @@ def _event_date_range(component) -> tuple[date | None, date | None]:
|
||||
return (start_d, last_d)
|
||||
|
||||
|
||||
def _get_events_from_ics(raw: bytes, from_date: str, to_date: str) -> list[dict]:
|
||||
"""Parse ICS bytes and return list of {date, summary} in [from_date, to_date]. One-time events only."""
|
||||
def _parse_ics_to_events(raw: bytes) -> list[dict]:
|
||||
"""Parse ICS bytes and return all events as list of {date, summary}. One-time events only."""
|
||||
result: list[dict] = []
|
||||
try:
|
||||
cal = Calendar.from_ical(raw)
|
||||
@@ -79,9 +82,6 @@ def _get_events_from_ics(raw: bytes, from_date: str, to_date: str) -> list[dict]
|
||||
log.warning("Failed to parse ICS: %s", e)
|
||||
return result
|
||||
|
||||
from_d = date.fromisoformat(from_date)
|
||||
to_d = date.fromisoformat(to_date)
|
||||
|
||||
for component in cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
@@ -95,13 +95,27 @@ def _get_events_from_ics(raw: bytes, from_date: str, to_date: str) -> list[dict]
|
||||
|
||||
d = start_d
|
||||
while d <= end_d:
|
||||
if from_d <= d <= to_d:
|
||||
result.append({"date": d.strftime("%Y-%m-%d"), "summary": summary_str})
|
||||
result.append({"date": d.strftime("%Y-%m-%d"), "summary": summary_str})
|
||||
d += timedelta(days=1)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _filter_events_by_range(
|
||||
events: list[dict], from_date: str, to_date: str
|
||||
) -> list[dict]:
|
||||
"""Filter events list to [from_date, to_date] range."""
|
||||
from_d = date.fromisoformat(from_date)
|
||||
to_d = date.fromisoformat(to_date)
|
||||
return [e for e in events if from_d <= date.fromisoformat(e["date"]) <= to_d]
|
||||
|
||||
|
||||
def _get_events_from_ics(raw: bytes, from_date: str, to_date: str) -> list[dict]:
|
||||
"""Parse ICS bytes and return events in [from_date, to_date]. Wrapper for tests."""
|
||||
events = _parse_ics_to_events(raw)
|
||||
return _filter_events_by_range(events, from_date, to_date)
|
||||
|
||||
|
||||
def get_calendar_events(
|
||||
url: str,
|
||||
from_date: str,
|
||||
@@ -135,4 +149,10 @@ def get_calendar_events(
|
||||
return []
|
||||
_ics_cache[url] = (now, raw)
|
||||
|
||||
return _get_events_from_ics(raw, from_date, to_date)
|
||||
# Use parsed events cache to avoid repeated Calendar.from_ical() + walk()
|
||||
cache_key = (url,)
|
||||
events, found = _parsed_events_cache.get(cache_key)
|
||||
if not found:
|
||||
events = _parse_ics_to_events(raw)
|
||||
_parsed_events_cache.set(cache_key, events)
|
||||
return _filter_events_by_range(events, from_date, to_date)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated, Generator
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Query, Request
|
||||
@@ -13,46 +12,23 @@ from duty_teller.db.repository import (
|
||||
get_duties,
|
||||
get_user_by_telegram_id,
|
||||
can_access_miniapp_for_telegram_user,
|
||||
is_admin_for_telegram_user,
|
||||
)
|
||||
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
||||
from duty_teller.db.session import session_scope
|
||||
from duty_teller.i18n import t
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
from duty_teller.utils.dates import DateRangeValidationError, validate_date_range
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Extract primary language code from first Accept-Language tag (e.g. "ru-RU" -> "ru").
|
||||
_ACCEPT_LANG_CODE_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-|;|,|\s|$)")
|
||||
|
||||
|
||||
def _parse_first_language_code(header: str | None) -> str | None:
|
||||
"""Extract the first language code from Accept-Language header.
|
||||
|
||||
Args:
|
||||
header: Raw Accept-Language value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
||||
|
||||
Returns:
|
||||
Two- or three-letter code (e.g. 'ru', 'en') or None if missing/invalid.
|
||||
"""
|
||||
if not header or not header.strip():
|
||||
return None
|
||||
first = header.strip().split(",")[0].strip()
|
||||
m = _ACCEPT_LANG_CODE_RE.match(first)
|
||||
return m.group(1).lower() if m else None
|
||||
|
||||
|
||||
def _lang_from_accept_language(header: str | None) -> str:
|
||||
"""Normalize Accept-Language header to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE.
|
||||
"""Return the application language: always 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'.
|
||||
The header argument is kept for backward compatibility but is ignored.
|
||||
The whole deployment uses a single language from DEFAULT_LANGUAGE.
|
||||
"""
|
||||
code = _parse_first_language_code(header)
|
||||
return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE)
|
||||
return config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def _auth_error_detail(auth_reason: str, lang: str) -> str:
|
||||
@@ -67,7 +43,12 @@ def _validate_duty_dates(from_date: str, to_date: str, lang: str) -> None:
|
||||
try:
|
||||
validate_date_range(from_date, to_date)
|
||||
except DateRangeValidationError as e:
|
||||
key = "dates.bad_format" if e.kind == "bad_format" else "dates.from_after_to"
|
||||
key_map = {
|
||||
"bad_format": "dates.bad_format",
|
||||
"from_after_to": "dates.from_after_to",
|
||||
"range_too_large": "dates.range_too_large",
|
||||
}
|
||||
key = key_map.get(e.kind, "dates.bad_format")
|
||||
raise HTTPException(status_code=400, detail=t(lang, key)) from e
|
||||
except ValueError as e:
|
||||
# Backward compatibility if something else raises ValueError.
|
||||
@@ -179,6 +160,103 @@ def get_authenticated_username(
|
||||
return username or (user.full_name or "") or f"id:{telegram_user_id}"
|
||||
|
||||
|
||||
def get_authenticated_telegram_id(
|
||||
request: Request,
|
||||
x_telegram_init_data: str | None,
|
||||
session: Session,
|
||||
) -> int:
|
||||
"""Return Telegram user id for the authenticated miniapp user; 0 if skip-auth.
|
||||
|
||||
Same validation as get_authenticated_username. Used to check is_admin.
|
||||
|
||||
Args:
|
||||
request: FastAPI request (for Accept-Language in error messages).
|
||||
x_telegram_init_data: Raw X-Telegram-Init-Data header value.
|
||||
session: DB session.
|
||||
|
||||
Returns:
|
||||
telegram_user_id (int). When MINI_APP_SKIP_AUTH, returns 0 (no real user).
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if initData missing/invalid or user not in allowlist.
|
||||
"""
|
||||
if config.MINI_APP_SKIP_AUTH:
|
||||
return 0
|
||||
init_data = (x_telegram_init_data or "").strip()
|
||||
if not init_data:
|
||||
log.warning("no X-Telegram-Init-Data header")
|
||||
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||
raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram"))
|
||||
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
|
||||
telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason(
|
||||
init_data, config.BOT_TOKEN, max_age_seconds=max_age
|
||||
)
|
||||
if auth_reason != "ok":
|
||||
log.warning("initData validation failed: %s", auth_reason)
|
||||
raise HTTPException(
|
||||
status_code=403, detail=_auth_error_detail(auth_reason, lang)
|
||||
)
|
||||
if telegram_user_id is None:
|
||||
log.warning("initData valid but telegram_user_id missing")
|
||||
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||
user = get_user_by_telegram_id(session, telegram_user_id)
|
||||
if not user:
|
||||
log.warning(
|
||||
"user not in DB (username=%s, telegram_id=%s)",
|
||||
username,
|
||||
telegram_user_id,
|
||||
)
|
||||
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||
if not can_access_miniapp_for_telegram_user(session, telegram_user_id):
|
||||
failed_phone = config.normalize_phone(user.phone) if user.phone else None
|
||||
log.warning(
|
||||
"access denied (username=%s, telegram_id=%s, phone=%s)",
|
||||
username,
|
||||
telegram_user_id,
|
||||
failed_phone or "—",
|
||||
)
|
||||
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||
return telegram_user_id
|
||||
|
||||
|
||||
def get_authenticated_telegram_id_dep(
|
||||
request: Request,
|
||||
x_telegram_init_data: Annotated[
|
||||
str | None, Header(alias="X-Telegram-Init-Data")
|
||||
] = None,
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> int:
|
||||
"""FastAPI dependency: return telegram_user_id for authenticated miniapp user (0 if skip-auth)."""
|
||||
return get_authenticated_telegram_id(request, x_telegram_init_data, session)
|
||||
|
||||
|
||||
def require_admin_telegram_id(
|
||||
request: Request,
|
||||
x_telegram_init_data: Annotated[
|
||||
str | None, Header(alias="X-Telegram-Init-Data")
|
||||
] = None,
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> int:
|
||||
"""FastAPI dependency: require valid miniapp auth and admin role; return telegram_user_id.
|
||||
|
||||
When MINI_APP_SKIP_AUTH is True, admin routes are disabled (403).
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if initData missing/invalid, user not in allowlist, or not admin.
|
||||
"""
|
||||
if config.MINI_APP_SKIP_AUTH:
|
||||
log.warning("Admin routes disabled when MINI_APP_SKIP_AUTH is set")
|
||||
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
|
||||
telegram_user_id = get_authenticated_telegram_id(
|
||||
request, x_telegram_init_data, session
|
||||
)
|
||||
if not is_admin_for_telegram_user(session, telegram_user_id):
|
||||
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
|
||||
return telegram_user_id
|
||||
|
||||
|
||||
def fetch_duties_response(
|
||||
session: Session, from_date: str, to_date: str
|
||||
) -> list[DutyWithUser]:
|
||||
@@ -190,7 +268,7 @@ def fetch_duties_response(
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).
|
||||
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type, phone, username).
|
||||
"""
|
||||
rows = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
return [
|
||||
@@ -203,6 +281,8 @@ def fetch_duties_response(
|
||||
event_type=(
|
||||
duty.event_type if duty.event_type in DUTY_EVENT_TYPES else "duty"
|
||||
),
|
||||
phone=phone,
|
||||
username=username,
|
||||
)
|
||||
for duty, full_name in rows
|
||||
for duty, full_name, phone, username in rows
|
||||
]
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import time
|
||||
from urllib.parse import unquote
|
||||
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
import duty_teller.config as config
|
||||
|
||||
# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
# Data-check string: sorted key=value with URL-decoded values, then HMAC-SHA256(WebAppData, token) as secret.
|
||||
@@ -48,12 +48,12 @@ def validate_init_data_with_reason(
|
||||
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).
|
||||
"user_invalid", "no_user_id". lang is always config.DEFAULT_LANGUAGE.
|
||||
On success: (user.id, username or None, "ok", lang).
|
||||
"""
|
||||
lang = config.DEFAULT_LANGUAGE
|
||||
if not init_data or not bot_token:
|
||||
return (None, None, "empty", "en")
|
||||
return (None, None, "empty", lang)
|
||||
init_data = init_data.strip()
|
||||
params = {}
|
||||
for part in init_data.split("&"):
|
||||
@@ -65,7 +65,7 @@ def validate_init_data_with_reason(
|
||||
params[key] = value
|
||||
hash_val = params.pop("hash", None)
|
||||
if not hash_val:
|
||||
return (None, None, "no_hash", "en")
|
||||
return (None, None, "no_hash", lang)
|
||||
data_pairs = sorted(params.items())
|
||||
# Data-check string: key=value with URL-decoded values (per Telegram example)
|
||||
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in data_pairs)
|
||||
@@ -81,27 +81,26 @@ def validate_init_data_with_reason(
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(computed.lower(), hash_val.lower()):
|
||||
return (None, None, "hash_mismatch", "en")
|
||||
return (None, None, "hash_mismatch", lang)
|
||||
if max_age_seconds is not None and max_age_seconds > 0:
|
||||
auth_date_raw = params.get("auth_date")
|
||||
if not auth_date_raw:
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
try:
|
||||
auth_date = int(float(auth_date_raw))
|
||||
except (ValueError, TypeError):
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
if time.time() - auth_date > max_age_seconds:
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
user_raw = params.get("user")
|
||||
if not user_raw:
|
||||
return (None, None, "no_user", "en")
|
||||
return (None, None, "no_user", lang)
|
||||
try:
|
||||
user = json.loads(unquote(user_raw))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return (None, None, "user_invalid", "en")
|
||||
return (None, None, "user_invalid", lang)
|
||||
if not isinstance(user, dict):
|
||||
return (None, None, "user_invalid", "en")
|
||||
lang = normalize_lang(user.get("language_code"))
|
||||
return (None, None, "user_invalid", lang)
|
||||
raw_id = user.get("id")
|
||||
if raw_id is None:
|
||||
return (None, None, "no_user_id", lang)
|
||||
|
||||
125
duty_teller/cache.py
Normal file
125
duty_teller/cache.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Simple in-memory TTL cache. Thread-safe for get/set."""
|
||||
|
||||
import logging
|
||||
from threading import Lock
|
||||
from time import time
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TTLCache:
|
||||
"""Thread-safe TTL cache with optional max size and pattern invalidation."""
|
||||
|
||||
def __init__(self, ttl_seconds: float, max_size: int = 1000) -> None:
|
||||
"""Initialize cache with TTL and optional max size.
|
||||
|
||||
Args:
|
||||
ttl_seconds: Time-to-live in seconds for each entry.
|
||||
max_size: Maximum number of entries (0 = unlimited). LRU eviction when exceeded.
|
||||
"""
|
||||
self._ttl = ttl_seconds
|
||||
self._max_size = max_size
|
||||
self._data: dict[tuple, tuple[float, object]] = {} # key -> (cached_at, value)
|
||||
self._lock = Lock()
|
||||
self._access_order: list[tuple] = [] # For LRU when max_size > 0
|
||||
|
||||
def get(self, key: tuple) -> tuple[object, bool]:
|
||||
"""Get value by key if present and not expired.
|
||||
|
||||
Args:
|
||||
key: Cache key (must be hashable, typically tuple).
|
||||
|
||||
Returns:
|
||||
(value, found) — found is True if valid cached value exists.
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._data.get(key)
|
||||
if entry is None:
|
||||
return (None, False)
|
||||
cached_at, value = entry
|
||||
if time() - cached_at >= self._ttl:
|
||||
del self._data[key]
|
||||
if self._max_size > 0 and key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
return (None, False)
|
||||
if self._max_size > 0 and key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
self._access_order.append(key)
|
||||
return (value, True)
|
||||
|
||||
def set(self, key: tuple, value: object) -> None:
|
||||
"""Store value with current timestamp.
|
||||
|
||||
Args:
|
||||
key: Cache key (must be hashable).
|
||||
value: Value to cache.
|
||||
"""
|
||||
with self._lock:
|
||||
now = time()
|
||||
if (
|
||||
self._max_size > 0
|
||||
and len(self._data) >= self._max_size
|
||||
and key not in self._data
|
||||
):
|
||||
# Evict oldest
|
||||
while self._access_order and len(self._data) >= self._max_size:
|
||||
old_key = self._access_order.pop(0)
|
||||
if old_key in self._data:
|
||||
del self._data[old_key]
|
||||
self._data[key] = (now, value)
|
||||
if self._max_size > 0:
|
||||
if key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
self._access_order.append(key)
|
||||
|
||||
def invalidate(self, key: tuple) -> None:
|
||||
"""Remove a single key from cache.
|
||||
|
||||
Args:
|
||||
key: Cache key to remove.
|
||||
"""
|
||||
with self._lock:
|
||||
if key in self._data:
|
||||
del self._data[key]
|
||||
if self._max_size > 0 and key in self._access_order:
|
||||
self._access_order.remove(key)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all entries. Useful for tests."""
|
||||
with self._lock:
|
||||
self._data.clear()
|
||||
self._access_order.clear()
|
||||
|
||||
def invalidate_pattern(self, key_prefix: tuple) -> None:
|
||||
"""Remove all keys that start with the given prefix.
|
||||
|
||||
Args:
|
||||
key_prefix: Prefix tuple (e.g. ("personal",) matches ("personal", 1), ("personal", 2)).
|
||||
"""
|
||||
with self._lock:
|
||||
to_remove = [k for k in self._data if self._key_starts_with(k, key_prefix)]
|
||||
for k in to_remove:
|
||||
del self._data[k]
|
||||
if self._max_size > 0 and k in self._access_order:
|
||||
self._access_order.remove(k)
|
||||
|
||||
@staticmethod
|
||||
def _key_starts_with(key: tuple, prefix: tuple) -> bool:
|
||||
"""Check if key starts with prefix (both tuples)."""
|
||||
if len(key) < len(prefix):
|
||||
return False
|
||||
return key[: len(prefix)] == prefix
|
||||
|
||||
|
||||
# Shared caches for duty-related data. Invalidate on import.
|
||||
ics_calendar_cache = TTLCache(ttl_seconds=600, max_size=500)
|
||||
duty_pin_cache = TTLCache(ttl_seconds=90, max_size=100) # current_duty, next_shift_end
|
||||
is_admin_cache = TTLCache(ttl_seconds=60, max_size=200)
|
||||
|
||||
|
||||
def invalidate_duty_related_caches() -> None:
|
||||
"""Invalidate caches that depend on duties data. Call after import."""
|
||||
ics_calendar_cache.invalidate_pattern(("personal_ics",))
|
||||
ics_calendar_cache.invalidate_pattern(("team_ics",))
|
||||
duty_pin_cache.invalidate_pattern(("duty_message_text",))
|
||||
duty_pin_cache.invalidate(("next_shift_end",))
|
||||
@@ -1,9 +1,11 @@
|
||||
"""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.
|
||||
when running the bot. Numeric env vars (HTTP_PORT, INIT_DATA_MAX_AGE_SECONDS) use
|
||||
safe parsing with defaults on invalid values.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
@@ -15,6 +17,14 @@ from duty_teller.i18n.lang import normalize_lang
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid port range for HTTP_PORT.
|
||||
HTTP_PORT_MIN, HTTP_PORT_MAX = 1, 65535
|
||||
|
||||
# Host values treated as loopback (for health-check URL and MINI_APP_SKIP_AUTH safety).
|
||||
LOOPBACK_HTTP_HOSTS = ("127.0.0.1", "localhost", "::1", "")
|
||||
|
||||
# Project root (parent of duty_teller package). Used for webapp path, etc.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -46,13 +56,65 @@ def _parse_phone_list(raw: str) -> set[str]:
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_log_level(raw: str) -> str:
|
||||
"""Return a valid log level name (DEBUG, INFO, WARNING, ERROR); default INFO."""
|
||||
level = (raw or "").strip().upper()
|
||||
if level in ("DEBUG", "INFO", "WARNING", "ERROR"):
|
||||
return level
|
||||
return "INFO"
|
||||
|
||||
|
||||
def _parse_int_env(
|
||||
name: str, default: int, min_val: int | None = None, max_val: int | None = None
|
||||
) -> int:
|
||||
"""Parse an integer from os.environ; use default on invalid or out-of-range. Log on fallback."""
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
try:
|
||||
value = int(raw.strip())
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid %s=%r (expected integer); using default %s",
|
||||
name,
|
||||
raw,
|
||||
default,
|
||||
)
|
||||
return default
|
||||
if min_val is not None and value < min_val:
|
||||
logger.warning(
|
||||
"%s=%s is below minimum %s; using %s", name, value, min_val, min_val
|
||||
)
|
||||
return min_val
|
||||
if max_val is not None and value > max_val:
|
||||
logger.warning(
|
||||
"%s=%s is above maximum %s; using %s", name, value, max_val, max_val
|
||||
)
|
||||
return max_val
|
||||
return value
|
||||
|
||||
|
||||
def _validate_database_url(url: str) -> bool:
|
||||
"""Return True if URL looks like a supported SQLAlchemy URL (sqlite or postgres)."""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
u = url.strip().split("?", 1)[0].lower()
|
||||
return (
|
||||
u.startswith("sqlite://")
|
||||
or u.startswith("postgresql://")
|
||||
or u.startswith("postgres://")
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
"""Injectable settings built from environment. Used in tests or when env is overridden."""
|
||||
|
||||
bot_token: str
|
||||
database_url: str
|
||||
bot_username: str
|
||||
mini_app_base_url: str
|
||||
mini_app_short_name: str
|
||||
http_host: str
|
||||
http_port: int
|
||||
allowed_usernames: set[str]
|
||||
@@ -66,6 +128,7 @@ class Settings:
|
||||
duty_display_tz: str
|
||||
default_language: str
|
||||
duty_pin_notify: bool
|
||||
log_level: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
@@ -93,19 +156,34 @@ class Settings:
|
||||
)
|
||||
raw_host = (os.getenv("HTTP_HOST") or "127.0.0.1").strip()
|
||||
http_host = raw_host if raw_host else "127.0.0.1"
|
||||
bot_username = (os.getenv("BOT_USERNAME", "") or "").strip().lstrip("@").lower()
|
||||
database_url = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
|
||||
if not _validate_database_url(database_url):
|
||||
logger.warning(
|
||||
"DATABASE_URL does not look like a supported URL (sqlite:// or postgresql://); "
|
||||
"DB connection may fail."
|
||||
)
|
||||
http_port = _parse_int_env(
|
||||
"HTTP_PORT", 8080, min_val=HTTP_PORT_MIN, max_val=HTTP_PORT_MAX
|
||||
)
|
||||
init_data_max_age = _parse_int_env("INIT_DATA_MAX_AGE_SECONDS", 0, min_val=0)
|
||||
return cls(
|
||||
bot_token=bot_token,
|
||||
database_url=os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db"),
|
||||
database_url=database_url,
|
||||
bot_username=bot_username,
|
||||
mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
|
||||
mini_app_short_name=(os.getenv("MINI_APP_SHORT_NAME", "") or "")
|
||||
.strip()
|
||||
.strip("/"),
|
||||
http_host=http_host,
|
||||
http_port=int(os.getenv("HTTP_PORT", "8080")),
|
||||
http_port=http_port,
|
||||
allowed_usernames=allowed,
|
||||
admin_usernames=admin,
|
||||
allowed_phones=allowed_phones,
|
||||
admin_phones=admin_phones,
|
||||
mini_app_skip_auth=os.getenv("MINI_APP_SKIP_AUTH", "").strip()
|
||||
in ("1", "true", "yes"),
|
||||
init_data_max_age_seconds=int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0")),
|
||||
init_data_max_age_seconds=init_data_max_age,
|
||||
cors_origins=cors,
|
||||
external_calendar_ics_url=os.getenv(
|
||||
"EXTERNAL_CALENDAR_ICS_URL", ""
|
||||
@@ -115,6 +193,7 @@ class Settings:
|
||||
default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")),
|
||||
duty_pin_notify=os.getenv("DUTY_PIN_NOTIFY", "1").strip().lower()
|
||||
not in ("0", "false", "no"),
|
||||
log_level=_normalize_log_level(os.getenv("LOG_LEVEL", "INFO")),
|
||||
)
|
||||
|
||||
|
||||
@@ -123,7 +202,9 @@ _settings = Settings.from_env()
|
||||
|
||||
BOT_TOKEN = _settings.bot_token
|
||||
DATABASE_URL = _settings.database_url
|
||||
BOT_USERNAME = _settings.bot_username
|
||||
MINI_APP_BASE_URL = _settings.mini_app_base_url
|
||||
MINI_APP_SHORT_NAME = _settings.mini_app_short_name
|
||||
HTTP_HOST = _settings.http_host
|
||||
HTTP_PORT = _settings.http_port
|
||||
ALLOWED_USERNAMES = _settings.allowed_usernames
|
||||
@@ -137,6 +218,8 @@ EXTERNAL_CALENDAR_ICS_URL = _settings.external_calendar_ics_url
|
||||
DUTY_DISPLAY_TZ = _settings.duty_display_tz
|
||||
DEFAULT_LANGUAGE = _settings.default_language
|
||||
DUTY_PIN_NOTIFY = _settings.duty_pin_notify
|
||||
LOG_LEVEL = getattr(logging, _settings.log_level.upper(), logging.INFO)
|
||||
LOG_LEVEL_STR = _settings.log_level
|
||||
|
||||
|
||||
def is_admin(username: str) -> bool:
|
||||
|
||||
@@ -4,6 +4,7 @@ from duty_teller.db.models import Base, User, Duty, Role
|
||||
from duty_teller.db.schemas import (
|
||||
UserCreate,
|
||||
UserInDb,
|
||||
UserForAdmin,
|
||||
DutyCreate,
|
||||
DutyInDb,
|
||||
DutyWithUser,
|
||||
@@ -16,11 +17,14 @@ from duty_teller.db.session import (
|
||||
)
|
||||
from duty_teller.db.repository import (
|
||||
delete_duties_in_range,
|
||||
get_duties,
|
||||
get_duty_by_id,
|
||||
get_or_create_user,
|
||||
get_or_create_user_by_full_name,
|
||||
get_duties,
|
||||
get_users_for_admin,
|
||||
insert_duty,
|
||||
set_user_phone,
|
||||
update_duty_user,
|
||||
update_user_display_name,
|
||||
)
|
||||
|
||||
@@ -31,6 +35,7 @@ __all__ = [
|
||||
"Role",
|
||||
"UserCreate",
|
||||
"UserInDb",
|
||||
"UserForAdmin",
|
||||
"DutyCreate",
|
||||
"DutyInDb",
|
||||
"DutyWithUser",
|
||||
@@ -39,11 +44,14 @@ __all__ = [
|
||||
"get_session",
|
||||
"session_scope",
|
||||
"delete_duties_in_range",
|
||||
"get_duties",
|
||||
"get_duty_by_id",
|
||||
"get_or_create_user",
|
||||
"get_or_create_user_by_full_name",
|
||||
"get_duties",
|
||||
"get_users_for_admin",
|
||||
"insert_duty",
|
||||
"set_user_phone",
|
||||
"update_duty_user",
|
||||
"update_user_display_name",
|
||||
"init_db",
|
||||
]
|
||||
|
||||
@@ -84,3 +84,13 @@ class GroupDutyPin(Base):
|
||||
|
||||
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
message_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
|
||||
class TrustedGroup(Base):
|
||||
"""Groups authorized to receive duty information."""
|
||||
|
||||
__tablename__ = "trusted_groups"
|
||||
|
||||
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
added_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
added_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
@@ -7,10 +7,12 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.db.schemas import DUTY_EVENT_TYPES
|
||||
from duty_teller.db.models import (
|
||||
User,
|
||||
Duty,
|
||||
GroupDutyPin,
|
||||
TrustedGroup,
|
||||
CalendarSubscriptionToken,
|
||||
Role,
|
||||
)
|
||||
@@ -200,14 +202,19 @@ def get_or_create_user(
|
||||
return user
|
||||
|
||||
|
||||
def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
|
||||
def get_or_create_user_by_full_name(
|
||||
session: Session, full_name: str, *, commit: bool = True
|
||||
) -> User:
|
||||
"""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.
|
||||
When commit=False, caller is responsible for committing (e.g. single commit
|
||||
per import in run_import).
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
full_name: Exact full name to match or set.
|
||||
commit: If True, commit immediately. If False, caller commits.
|
||||
|
||||
Returns:
|
||||
User instance (existing or newly created).
|
||||
@@ -224,11 +231,30 @@ def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
|
||||
name_manually_edited=True,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
if commit:
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
else:
|
||||
session.flush() # Assign id so caller can use user.id before commit
|
||||
return user
|
||||
|
||||
|
||||
def get_users_by_full_names(session: Session, full_names: list[str]) -> dict[str, User]:
|
||||
"""Get users by full_name. Returns dict full_name -> User. Does not create missing.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
full_names: List of full names to look up.
|
||||
|
||||
Returns:
|
||||
Dict mapping full_name to User for found users.
|
||||
"""
|
||||
if not full_names:
|
||||
return {}
|
||||
users = session.query(User).filter(User.full_name.in_(full_names)).all()
|
||||
return {u.full_name: u for u in users}
|
||||
|
||||
|
||||
def update_user_display_name(
|
||||
session: Session,
|
||||
telegram_user_id: int,
|
||||
@@ -268,6 +294,8 @@ def delete_duties_in_range(
|
||||
user_id: int,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> int:
|
||||
"""Delete all duties of the user that overlap the given date range.
|
||||
|
||||
@@ -276,6 +304,7 @@ def delete_duties_in_range(
|
||||
user_id: User id.
|
||||
from_date: Start date YYYY-MM-DD.
|
||||
to_date: End date YYYY-MM-DD.
|
||||
commit: If True, commit immediately. If False, caller commits (for batch import).
|
||||
|
||||
Returns:
|
||||
Number of duties deleted.
|
||||
@@ -288,16 +317,72 @@ def delete_duties_in_range(
|
||||
)
|
||||
count = q.count()
|
||||
q.delete(synchronize_session=False)
|
||||
session.commit()
|
||||
if commit:
|
||||
session.commit()
|
||||
return count
|
||||
|
||||
|
||||
def get_duty_by_id(session: Session, duty_id: int) -> Duty | None:
|
||||
"""Return duty by primary key.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
duty_id: Duty id (duties.id).
|
||||
|
||||
Returns:
|
||||
Duty or None if not found.
|
||||
"""
|
||||
return session.get(Duty, duty_id)
|
||||
|
||||
|
||||
def update_duty_user(
|
||||
session: Session,
|
||||
duty_id: int,
|
||||
new_user_id: int,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> Duty | None:
|
||||
"""Update the assigned user of a duty.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
duty_id: Duty id (duties.id).
|
||||
new_user_id: New user id (users.id).
|
||||
commit: If True, commit immediately. If False, caller commits.
|
||||
|
||||
Returns:
|
||||
Updated Duty or None if duty not found.
|
||||
"""
|
||||
duty = session.get(Duty, duty_id)
|
||||
if duty is None:
|
||||
return None
|
||||
duty.user_id = new_user_id
|
||||
if commit:
|
||||
session.commit()
|
||||
session.refresh(duty)
|
||||
else:
|
||||
session.flush()
|
||||
return duty
|
||||
|
||||
|
||||
def get_users_for_admin(session: Session) -> list[User]:
|
||||
"""Return all users ordered by full_name for admin dropdown (id, full_name, username).
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
|
||||
Returns:
|
||||
List of User instances ordered by full_name.
|
||||
"""
|
||||
return session.query(User).order_by(User.full_name).all()
|
||||
|
||||
|
||||
def get_duties(
|
||||
session: Session,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> list[tuple[Duty, str]]:
|
||||
"""Return duties overlapping the given date range with user full_name.
|
||||
) -> list[tuple[Duty, str, str | None, str | None]]:
|
||||
"""Return duties overlapping the given date range with user full_name, phone, username.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
@@ -305,11 +390,11 @@ def get_duties(
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of (Duty, full_name) tuples.
|
||||
List of (Duty, full_name, phone, username) tuples.
|
||||
"""
|
||||
to_date_next = to_date_exclusive_iso(to_date)
|
||||
q = (
|
||||
session.query(Duty, User.full_name)
|
||||
session.query(Duty, User.full_name, User.phone, User.username)
|
||||
.join(User, Duty.user_id == User.id)
|
||||
.filter(Duty.start_at < to_date_next, Duty.end_at >= from_date)
|
||||
)
|
||||
@@ -322,7 +407,7 @@ def get_duties_for_user(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
event_types: list[str] | None = None,
|
||||
) -> list[tuple[Duty, str]]:
|
||||
) -> list[tuple[Duty, str, str | None, str | None]]:
|
||||
"""Return duties for one user overlapping the date range.
|
||||
|
||||
Optionally filter by event_type (e.g. "duty", "unavailable", "vacation").
|
||||
@@ -336,7 +421,7 @@ def get_duties_for_user(
|
||||
event_types: If not None, only return duties whose event_type is in this list.
|
||||
|
||||
Returns:
|
||||
List of (Duty, full_name) tuples.
|
||||
List of (Duty, full_name, phone, username) tuples.
|
||||
"""
|
||||
to_date_next = to_date_exclusive_iso(to_date)
|
||||
filters = [
|
||||
@@ -347,7 +432,7 @@ def get_duties_for_user(
|
||||
if event_types is not None:
|
||||
filters.append(Duty.event_type.in_(event_types))
|
||||
q = (
|
||||
session.query(Duty, User.full_name)
|
||||
session.query(Duty, User.full_name, User.phone, User.username)
|
||||
.join(User, Duty.user_id == User.id)
|
||||
.filter(*filters)
|
||||
)
|
||||
@@ -426,11 +511,13 @@ def insert_duty(
|
||||
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".
|
||||
event_type: One of "duty", "unavailable", "vacation". Invalid values are stored as "duty".
|
||||
|
||||
Returns:
|
||||
Created Duty instance.
|
||||
"""
|
||||
if event_type not in DUTY_EVENT_TYPES:
|
||||
event_type = "duty"
|
||||
duty = Duty(
|
||||
user_id=user_id,
|
||||
start_at=start_at,
|
||||
@@ -573,6 +660,71 @@ def get_all_group_duty_pin_chat_ids(session: Session) -> list[int]:
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def is_trusted_group(session: Session, chat_id: int) -> bool:
|
||||
"""Check if the chat is in the trusted groups list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
|
||||
Returns:
|
||||
True if the group is trusted.
|
||||
"""
|
||||
return (
|
||||
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def add_trusted_group(
|
||||
session: Session, chat_id: int, added_by_user_id: int | None = None
|
||||
) -> TrustedGroup:
|
||||
"""Add a group to the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
added_by_user_id: Telegram user id of the admin who added the group (optional).
|
||||
|
||||
Returns:
|
||||
Created TrustedGroup instance.
|
||||
"""
|
||||
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
record = TrustedGroup(
|
||||
chat_id=chat_id,
|
||||
added_by_user_id=added_by_user_id,
|
||||
added_at=now_iso,
|
||||
)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
session.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
def remove_trusted_group(session: Session, chat_id: int) -> None:
|
||||
"""Remove a group from the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
"""
|
||||
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).delete()
|
||||
session.commit()
|
||||
|
||||
|
||||
def get_all_trusted_group_ids(session: Session) -> list[int]:
|
||||
"""Return all chat_ids that are trusted.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
|
||||
Returns:
|
||||
List of trusted chat ids.
|
||||
"""
|
||||
rows = session.query(TrustedGroup.chat_id).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def set_user_phone(
|
||||
session: Session, telegram_user_id: int, phone: str | None
|
||||
) -> User | None:
|
||||
|
||||
@@ -55,17 +55,35 @@ class DutyInDb(DutyBase):
|
||||
|
||||
|
||||
class DutyWithUser(DutyInDb):
|
||||
"""Duty with full_name and event_type for calendar display.
|
||||
"""Duty with full_name, event_type, and optional contact fields for calendar display.
|
||||
|
||||
event_type: only these values are returned; unknown DB values are mapped to "duty" in the API.
|
||||
phone and username are exposed only to authenticated Mini App users (role-gated).
|
||||
"""
|
||||
|
||||
full_name: str
|
||||
event_type: Literal["duty", "unavailable", "vacation"] = "duty"
|
||||
phone: str | None = None
|
||||
username: str | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserForAdmin(BaseModel):
|
||||
"""User summary for admin dropdown: id, full_name, username, role_id."""
|
||||
|
||||
id: int
|
||||
full_name: str
|
||||
username: str | None = None
|
||||
role_id: int | None = None
|
||||
|
||||
|
||||
class AdminDutyReassignBody(BaseModel):
|
||||
"""Request body for PATCH /api/admin/duties/:id — reassign duty to another user."""
|
||||
|
||||
user_id: int
|
||||
|
||||
|
||||
class CalendarEvent(BaseModel):
|
||||
"""External calendar event (e.g. holiday) for a single day."""
|
||||
|
||||
|
||||
@@ -22,4 +22,6 @@ def register_handlers(app: Application) -> None:
|
||||
app.add_handler(group_duty_pin.group_duty_pin_handler)
|
||||
app.add_handler(group_duty_pin.pin_duty_handler)
|
||||
app.add_handler(group_duty_pin.refresh_pin_handler)
|
||||
app.add_handler(group_duty_pin.trust_group_handler)
|
||||
app.add_handler(group_duty_pin.untrust_group_handler)
|
||||
app.add_error_handler(errors.error_handler)
|
||||
|
||||
@@ -19,7 +19,7 @@ from duty_teller.db.repository import (
|
||||
ROLE_USER,
|
||||
ROLE_ADMIN,
|
||||
)
|
||||
from duty_teller.handlers.common import is_admin_async
|
||||
from duty_teller.handlers.common import invalidate_is_admin_cache, is_admin_async
|
||||
from duty_teller.i18n import get_lang, t
|
||||
from duty_teller.utils.user import build_full_name
|
||||
|
||||
@@ -67,7 +67,8 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
phone = " ".join(args).strip() if args else None
|
||||
telegram_user_id = update.effective_user.id
|
||||
|
||||
def do_set_phone() -> str | None:
|
||||
def do_set_phone() -> tuple[str, str | None]:
|
||||
"""Returns (status, display_phone). status is 'error'|'saved'|'cleared'. display_phone for 'saved'."""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
full_name = build_full_name(
|
||||
update.effective_user.first_name, update.effective_user.last_name
|
||||
@@ -82,16 +83,20 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
)
|
||||
user = set_user_phone(session, telegram_user_id, phone or None)
|
||||
if user is None:
|
||||
return "error"
|
||||
return ("error", None)
|
||||
if phone:
|
||||
return "saved"
|
||||
return "cleared"
|
||||
return ("saved", user.phone or config.normalize_phone(phone))
|
||||
return ("cleared", None)
|
||||
|
||||
result = await asyncio.get_running_loop().run_in_executor(None, do_set_phone)
|
||||
result, display_phone = await asyncio.get_running_loop().run_in_executor(
|
||||
None, do_set_phone
|
||||
)
|
||||
if result == "error":
|
||||
await update.message.reply_text(t(lang, "set_phone.error"))
|
||||
elif result == "saved":
|
||||
await update.message.reply_text(t(lang, "set_phone.saved", phone=phone or ""))
|
||||
await update.message.reply_text(
|
||||
t(lang, "set_phone.saved", phone=display_phone or "")
|
||||
)
|
||||
else:
|
||||
await update.message.reply_text(t(lang, "set_phone.cleared"))
|
||||
|
||||
@@ -168,6 +173,8 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if await is_admin_async(update.effective_user.id):
|
||||
lines.append(t(lang, "help.import_schedule"))
|
||||
lines.append(t(lang, "help.set_role"))
|
||||
lines.append(t(lang, "help.trust_group"))
|
||||
lines.append(t(lang, "help.untrust_group"))
|
||||
await update.message.reply_text("\n".join(lines))
|
||||
|
||||
|
||||
@@ -230,6 +237,7 @@ async def set_role(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
|
||||
ok = await asyncio.get_running_loop().run_in_executor(None, do_set_role)
|
||||
if ok:
|
||||
invalidate_is_admin_cache(target_user.telegram_user_id)
|
||||
await update.message.reply_text(
|
||||
t(lang, "set_role.done", name=target_user.full_name, role=role_name)
|
||||
)
|
||||
|
||||
@@ -3,22 +3,35 @@
|
||||
import asyncio
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.cache import is_admin_cache
|
||||
from duty_teller.db.repository import is_admin_for_telegram_user
|
||||
from duty_teller.db.session import session_scope
|
||||
|
||||
|
||||
async def is_admin_async(telegram_user_id: int) -> bool:
|
||||
"""Check if Telegram user is admin (username or phone). Runs DB check in executor.
|
||||
"""Check if Telegram user is admin. Cached 60s. Invalidated on set_user_role.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user id.
|
||||
|
||||
Returns:
|
||||
True if user is in ADMIN_USERNAMES or their stored phone is in ADMIN_PHONES.
|
||||
True if user is admin (DB role or env fallback).
|
||||
"""
|
||||
cache_key = ("is_admin", telegram_user_id)
|
||||
value, found = is_admin_cache.get(cache_key)
|
||||
if found:
|
||||
return value
|
||||
|
||||
def _check() -> bool:
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
return is_admin_for_telegram_user(session, telegram_user_id)
|
||||
|
||||
return await asyncio.get_running_loop().run_in_executor(None, _check)
|
||||
result = await asyncio.get_running_loop().run_in_executor(None, _check)
|
||||
is_admin_cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
|
||||
def invalidate_is_admin_cache(telegram_user_id: int | None) -> None:
|
||||
"""Invalidate is_admin cache for user. Call after set_user_role."""
|
||||
if telegram_user_id is not None:
|
||||
is_admin_cache.invalidate(("is_admin", telegram_user_id))
|
||||
|
||||
@@ -2,32 +2,50 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
import duty_teller.config as config
|
||||
from telegram import Update
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||
from telegram.constants import ChatMemberStatus
|
||||
from telegram.error import BadRequest, Forbidden
|
||||
from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes
|
||||
|
||||
from duty_teller.db.session import session_scope
|
||||
from duty_teller.i18n import get_lang, t
|
||||
from duty_teller.handlers.common import is_admin_async
|
||||
from duty_teller.services.group_duty_pin_service import (
|
||||
get_duty_message_text,
|
||||
get_message_id,
|
||||
get_next_shift_end_utc,
|
||||
get_pin_refresh_data,
|
||||
save_pin,
|
||||
delete_pin,
|
||||
get_message_id,
|
||||
get_all_pin_chat_ids,
|
||||
is_group_trusted,
|
||||
trust_group,
|
||||
untrust_group,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-chat locks to prevent concurrent refresh for the same chat (avoids duplicate messages).
|
||||
_refresh_locks: dict[int, asyncio.Lock] = {}
|
||||
_lock_for_refresh_locks = asyncio.Lock()
|
||||
|
||||
JOB_NAME_PREFIX = "duty_pin_"
|
||||
RETRY_WHEN_NO_DUTY_MINUTES = 15
|
||||
|
||||
|
||||
def _sync_get_pin_refresh_data(
|
||||
chat_id: int, lang: str = "en"
|
||||
) -> tuple[int | None, str, datetime | None]:
|
||||
"""Get message_id, duty text, next_shift_end in one DB session."""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
return get_pin_refresh_data(session, chat_id, config.DUTY_DISPLAY_TZ, lang)
|
||||
|
||||
|
||||
def _get_duty_message_text_sync(lang: str = "en") -> str:
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
return get_duty_message_text(session, config.DUTY_DISPLAY_TZ, lang)
|
||||
@@ -53,9 +71,70 @@ def _sync_get_message_id(chat_id: int) -> int | None:
|
||||
return get_message_id(session, chat_id)
|
||||
|
||||
|
||||
def _sync_is_trusted(chat_id: int) -> bool:
|
||||
"""Check if the group is trusted (sync wrapper for handlers)."""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
return is_group_trusted(session, chat_id)
|
||||
|
||||
|
||||
def _sync_trust_group(chat_id: int, added_by_user_id: int | None) -> bool:
|
||||
"""Add group to trusted list. Returns True if already trusted (no-op)."""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
if is_group_trusted(session, chat_id):
|
||||
return True
|
||||
trust_group(session, chat_id, added_by_user_id)
|
||||
return False
|
||||
|
||||
|
||||
def _get_contact_button_markup(lang: str) -> InlineKeyboardMarkup | None:
|
||||
"""Return inline keyboard with 'View contacts' URL button, or None if BOT_USERNAME not set.
|
||||
|
||||
Uses a t.me Mini App deep link so the app opens inside Telegram. Uses url (not web_app):
|
||||
InlineKeyboardButton with web_app is allowed only in private chats, so in groups
|
||||
Telegram returns Button_type_invalid. A plain URL button works everywhere.
|
||||
|
||||
When MINI_APP_SHORT_NAME is set, the URL is a direct Mini App link so the app opens
|
||||
with start_param=duty (current duty view). Otherwise the link is to the bot with
|
||||
?startapp=duty (user may land in bot chat; opening the app from menu does not pass
|
||||
start_param in some clients).
|
||||
"""
|
||||
if not config.BOT_USERNAME:
|
||||
return None
|
||||
short = (config.MINI_APP_SHORT_NAME or "").strip().strip("/")
|
||||
if short:
|
||||
url = f"https://t.me/{config.BOT_USERNAME}/{short}?startapp=duty"
|
||||
else:
|
||||
url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty"
|
||||
button = InlineKeyboardButton(
|
||||
text=t(lang, "pin_duty.view_contacts"),
|
||||
url=url,
|
||||
)
|
||||
return InlineKeyboardMarkup([[button]])
|
||||
|
||||
|
||||
def _sync_untrust_group(chat_id: int) -> tuple[bool, int | None]:
|
||||
"""Remove group from trusted list.
|
||||
|
||||
Returns:
|
||||
(was_trusted, message_id): was_trusted False if group was not in list;
|
||||
message_id of pinned message if any (for cleanup), else None.
|
||||
"""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
if not is_group_trusted(session, chat_id):
|
||||
return (False, None)
|
||||
message_id = get_message_id(session, chat_id)
|
||||
delete_pin(session, chat_id)
|
||||
untrust_group(session, chat_id)
|
||||
return (True, message_id)
|
||||
|
||||
|
||||
async def _schedule_next_update(
|
||||
application, chat_id: int, when_utc: datetime | None
|
||||
application,
|
||||
chat_id: int,
|
||||
when_utc: datetime | None,
|
||||
jitter_seconds: float | None = None,
|
||||
) -> None:
|
||||
"""Schedule the next pin refresh job. Optional jitter spreads jobs when scheduling many chats."""
|
||||
job_queue = application.job_queue
|
||||
if job_queue is None:
|
||||
logger.warning("Job queue not available, cannot schedule pin update")
|
||||
@@ -66,8 +145,10 @@ async def _schedule_next_update(
|
||||
if when_utc is not None:
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
delay = when_utc - now_utc
|
||||
if jitter_seconds is not None and jitter_seconds > 0:
|
||||
delay += timedelta(seconds=random.uniform(0, jitter_seconds))
|
||||
if delay.total_seconds() < 1:
|
||||
delay = 1
|
||||
delay = timedelta(seconds=1)
|
||||
job_queue.run_once(
|
||||
update_group_pin,
|
||||
when=delay,
|
||||
@@ -76,8 +157,6 @@ async def _schedule_next_update(
|
||||
)
|
||||
logger.info("Scheduled pin update for chat_id=%s at %s", chat_id, when_utc)
|
||||
else:
|
||||
from datetime import timedelta
|
||||
|
||||
job_queue.run_once(
|
||||
update_group_pin,
|
||||
when=timedelta(minutes=RETRY_WHEN_NO_DUTY_MINUTES),
|
||||
@@ -93,47 +172,103 @@ async def _schedule_next_update(
|
||||
|
||||
async def _refresh_pin_for_chat(
|
||||
context: ContextTypes.DEFAULT_TYPE, chat_id: int
|
||||
) -> Literal["updated", "no_message", "failed"]:
|
||||
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id.
|
||||
) -> Literal["updated", "no_message", "failed", "untrusted"]:
|
||||
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id, delete old.
|
||||
|
||||
Uses single DB session for message_id, text, next_shift_end (consolidated).
|
||||
If the group is no longer trusted, removes pin record, job, and message; returns "untrusted".
|
||||
Unpin is best-effort (e.g. if user already unpinned we still pin the new message and save state).
|
||||
Per-chat lock prevents concurrent refresh for the same chat.
|
||||
|
||||
Returns:
|
||||
"updated" if the message was sent, pinned and saved successfully;
|
||||
"no_message" if there is no pin record for this chat;
|
||||
"failed" if send_message or permissions failed.
|
||||
"failed" if send_message or pin failed;
|
||||
"untrusted" if the group was removed from trusted list (pin record and message cleaned up).
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
old_message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
||||
await loop.run_in_executor(None, _sync_delete_pin, chat_id)
|
||||
name = f"{JOB_NAME_PREFIX}{chat_id}"
|
||||
if context.application.job_queue:
|
||||
for job in context.application.job_queue.get_jobs_by_name(name):
|
||||
job.schedule_removal()
|
||||
if old_message_id is not None:
|
||||
try:
|
||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
try:
|
||||
await context.bot.delete_message(
|
||||
chat_id=chat_id, message_id=old_message_id
|
||||
)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
logger.info("Chat_id=%s no longer trusted, removed pin record and job", chat_id)
|
||||
return "untrusted"
|
||||
message_id, text, next_end = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _sync_get_pin_refresh_data(chat_id, config.DEFAULT_LANGUAGE),
|
||||
)
|
||||
if message_id is None:
|
||||
logger.info("No pin record for chat_id=%s, skipping update", chat_id)
|
||||
return "no_message"
|
||||
text = await loop.run_in_executor(
|
||||
None, lambda: _get_duty_message_text_sync(config.DEFAULT_LANGUAGE)
|
||||
)
|
||||
old_message_id = message_id
|
||||
|
||||
async with _lock_for_refresh_locks:
|
||||
lock = _refresh_locks.setdefault(chat_id, asyncio.Lock())
|
||||
try:
|
||||
msg = await context.bot.send_message(chat_id=chat_id, text=text)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message for pin refresh chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "failed"
|
||||
try:
|
||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||
await context.bot.pin_chat_message(
|
||||
chat_id=chat_id,
|
||||
message_id=msg.message_id,
|
||||
disable_notification=not config.DUTY_PIN_NOTIFY,
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning("Unpin or pin after refresh failed chat_id=%s: %s", chat_id, e)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "failed"
|
||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "updated"
|
||||
async with lock:
|
||||
try:
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(config.DEFAULT_LANGUAGE),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message for pin refresh chat_id=%s: %s",
|
||||
chat_id,
|
||||
e,
|
||||
)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "failed"
|
||||
try:
|
||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.debug(
|
||||
"Unpin failed (e.g. no pinned message) chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
try:
|
||||
await context.bot.pin_chat_message(
|
||||
chat_id=chat_id,
|
||||
message_id=msg.message_id,
|
||||
disable_notification=not config.DUTY_PIN_NOTIFY,
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning("Pin after refresh failed chat_id=%s: %s", chat_id, e)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "failed"
|
||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||
if old_message_id is not None:
|
||||
try:
|
||||
await context.bot.delete_message(
|
||||
chat_id=chat_id, message_id=old_message_id
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Could not delete old pinned message %s in chat_id=%s: %s",
|
||||
old_message_id,
|
||||
chat_id,
|
||||
e,
|
||||
)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "updated"
|
||||
finally:
|
||||
async with _lock_for_refresh_locks:
|
||||
_refresh_locks.pop(chat_id, None)
|
||||
|
||||
|
||||
async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
@@ -167,12 +302,27 @@ async def my_chat_member_handler(
|
||||
ChatMemberStatus.BANNED,
|
||||
):
|
||||
loop = asyncio.get_running_loop()
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
lang = get_lang(update.effective_user)
|
||||
try:
|
||||
await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=t(lang, "group.not_trusted"),
|
||||
)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
return
|
||||
lang = get_lang(update.effective_user)
|
||||
text = await loop.run_in_executor(
|
||||
None, lambda: _get_duty_message_text_sync(lang)
|
||||
)
|
||||
try:
|
||||
msg = await context.bot.send_message(chat_id=chat_id, text=text)
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(lang),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e)
|
||||
return
|
||||
@@ -216,12 +366,15 @@ 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)."""
|
||||
"""Restore scheduled pin-update jobs for all chats that have a pinned message (on startup).
|
||||
|
||||
Uses jitter (0–60 s) per chat to avoid thundering herd when many groups share the same shift end.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
for chat_id in chat_ids:
|
||||
await _schedule_next_update(application, chat_id, next_end)
|
||||
await _schedule_next_update(application, chat_id, next_end, jitter_seconds=60.0)
|
||||
logger.info("Restored %s group pin jobs", len(chat_ids))
|
||||
|
||||
|
||||
@@ -236,9 +389,48 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
await update.message.reply_text(t(lang, "group.not_trusted"))
|
||||
return
|
||||
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
||||
if message_id is None:
|
||||
await update.message.reply_text(t(lang, "pin_duty.no_message"))
|
||||
text = await loop.run_in_executor(
|
||||
None, lambda: _get_duty_message_text_sync(lang)
|
||||
)
|
||||
try:
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(lang),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
await update.message.reply_text(t(lang, "pin_duty.failed"))
|
||||
return
|
||||
pinned = False
|
||||
try:
|
||||
await context.bot.pin_chat_message(
|
||||
chat_id=chat_id,
|
||||
message_id=msg.message_id,
|
||||
disable_notification=True,
|
||||
)
|
||||
pinned = True
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to pin message for pin_duty chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
if pinned:
|
||||
await update.message.reply_text(t(lang, "pin_duty.pinned"))
|
||||
else:
|
||||
await update.message.reply_text(
|
||||
t(lang, "pin_duty.could_not_pin_make_admin")
|
||||
)
|
||||
return
|
||||
try:
|
||||
await context.bot.pin_chat_message(
|
||||
@@ -246,6 +438,8 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
||||
message_id=message_id,
|
||||
disable_notification=True,
|
||||
)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
await update.message.reply_text(t(lang, "pin_duty.pinned"))
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning("pin_duty failed chat_id=%s: %s", chat_id, e)
|
||||
@@ -262,13 +456,113 @@ async def refresh_pin_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
await update.message.reply_text(t(lang, "refresh_pin.group_only"))
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
await update.message.reply_text(t(lang, "group.not_trusted"))
|
||||
return
|
||||
result = await _refresh_pin_for_chat(context, chat_id)
|
||||
await update.message.reply_text(t(lang, f"refresh_pin.{result}"))
|
||||
|
||||
|
||||
async def trust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /trust_group: add current group to trusted list (admin only)."""
|
||||
if not update.message or not update.effective_chat or not update.effective_user:
|
||||
return
|
||||
chat = update.effective_chat
|
||||
lang = get_lang(update.effective_user)
|
||||
if chat.type not in ("group", "supergroup"):
|
||||
await update.message.reply_text(t(lang, "trust_group.group_only"))
|
||||
return
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
await update.message.reply_text(t(lang, "import.admin_only"))
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
already_trusted = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _sync_trust_group(
|
||||
chat_id, update.effective_user.id if update.effective_user else None
|
||||
),
|
||||
)
|
||||
if already_trusted:
|
||||
await update.message.reply_text(t(lang, "trust_group.already_trusted"))
|
||||
return
|
||||
await update.message.reply_text(t(lang, "trust_group.added"))
|
||||
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
||||
if message_id is None:
|
||||
text = await loop.run_in_executor(
|
||||
None, lambda: _get_duty_message_text_sync(lang)
|
||||
)
|
||||
try:
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(lang),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message after trust_group chat_id=%s: %s",
|
||||
chat_id,
|
||||
e,
|
||||
)
|
||||
return
|
||||
try:
|
||||
await context.bot.pin_chat_message(
|
||||
chat_id=chat_id,
|
||||
message_id=msg.message_id,
|
||||
disable_notification=not config.DUTY_PIN_NOTIFY,
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to pin message after trust_group chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
|
||||
|
||||
async def untrust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /untrust_group: remove current group from trusted list (admin only)."""
|
||||
if not update.message or not update.effective_chat or not update.effective_user:
|
||||
return
|
||||
chat = update.effective_chat
|
||||
lang = get_lang(update.effective_user)
|
||||
if chat.type not in ("group", "supergroup"):
|
||||
await update.message.reply_text(t(lang, "untrust_group.group_only"))
|
||||
return
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
await update.message.reply_text(t(lang, "import.admin_only"))
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
was_trusted, message_id = await loop.run_in_executor(
|
||||
None, _sync_untrust_group, chat_id
|
||||
)
|
||||
if not was_trusted:
|
||||
await update.message.reply_text(t(lang, "untrust_group.not_trusted"))
|
||||
return
|
||||
name = f"{JOB_NAME_PREFIX}{chat_id}"
|
||||
if context.application.job_queue:
|
||||
for job in context.application.job_queue.get_jobs_by_name(name):
|
||||
job.schedule_removal()
|
||||
if message_id is not None:
|
||||
try:
|
||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
try:
|
||||
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
await update.message.reply_text(t(lang, "untrust_group.removed"))
|
||||
|
||||
|
||||
group_duty_pin_handler = ChatMemberHandler(
|
||||
my_chat_member_handler,
|
||||
ChatMemberHandler.MY_CHAT_MEMBER,
|
||||
)
|
||||
pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd)
|
||||
refresh_pin_handler = CommandHandler("refresh_pin", refresh_pin_cmd)
|
||||
trust_group_handler = CommandHandler("trust_group", trust_group_cmd)
|
||||
untrust_group_handler = CommandHandler("untrust_group", untrust_group_cmd)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Import duty-schedule: /import_duty_schedule (admin only). Two steps: handover time -> JSON file."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import duty_teller.config as config
|
||||
from telegram import Update
|
||||
@@ -16,6 +17,8 @@ from duty_teller.importers.duty_schedule import (
|
||||
from duty_teller.services.import_service import run_import
|
||||
from duty_teller.utils.handover import parse_handover_time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def import_duty_schedule_cmd(
|
||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
@@ -80,9 +83,10 @@ async def handle_duty_schedule_document(
|
||||
try:
|
||||
result = parse_duty_schedule(raw)
|
||||
except DutyScheduleParseError as e:
|
||||
logger.warning("Duty schedule parse error: %s", e, exc_info=True)
|
||||
context.user_data.pop("awaiting_duty_schedule_file", None)
|
||||
context.user_data.pop("handover_utc_time", None)
|
||||
await update.message.reply_text(t(lang, "import.parse_error", error=str(e)))
|
||||
await update.message.reply_text(t(lang, "import.parse_error_generic"))
|
||||
return
|
||||
|
||||
def run_import_with_scope():
|
||||
@@ -95,7 +99,8 @@ async def handle_duty_schedule_document(
|
||||
None, run_import_with_scope
|
||||
)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(t(lang, "import.import_error", error=str(e)))
|
||||
logger.exception("Import failed: %s", e)
|
||||
await update.message.reply_text(t(lang, "import.import_error_generic"))
|
||||
else:
|
||||
total = num_duty + num_unavailable + num_vacation
|
||||
unavailable_suffix = (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""get_lang and t(): language from Telegram user, translate by key with fallback to en."""
|
||||
"""get_lang and t(): language from config (DEFAULT_LANGUAGE), translate by key with fallback to en."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
from duty_teller.i18n.messages import MESSAGES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -12,13 +11,12 @@ if TYPE_CHECKING:
|
||||
|
||||
def get_lang(user: "User | None") -> str:
|
||||
"""
|
||||
Normalize Telegram user language to 'ru' or 'en'.
|
||||
Uses normalize_lang for user.language_code; when user is None or has no
|
||||
language_code, returns config.DEFAULT_LANGUAGE.
|
||||
Return the application language: always config.DEFAULT_LANGUAGE.
|
||||
|
||||
The user argument is kept for backward compatibility but is ignored.
|
||||
The whole deployment uses a single language from DEFAULT_LANGUAGE.
|
||||
"""
|
||||
if user is None or not getattr(user, "language_code", None):
|
||||
return config.DEFAULT_LANGUAGE
|
||||
return normalize_lang(user.language_code)
|
||||
return config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def t(lang: str, key: str, **kwargs: str) -> str:
|
||||
|
||||
@@ -18,6 +18,19 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"refresh_pin.no_message": "There is no pinned duty message to refresh in this chat.",
|
||||
"refresh_pin.updated": "Pinned duty message updated.",
|
||||
"refresh_pin.failed": "Could not update the pinned message (permissions or edit error).",
|
||||
"refresh_pin.untrusted": "Group was removed from trusted list; pin record cleared.",
|
||||
"trust_group.added": "Group added to trusted list.",
|
||||
"trust_group.already_trusted": "This group is already trusted.",
|
||||
"trust_group.group_only": "The /trust_group command works only in groups.",
|
||||
"untrust_group.removed": "Group removed from trusted list.",
|
||||
"untrust_group.not_trusted": "This group is not in the trusted list.",
|
||||
"untrust_group.group_only": "The /untrust_group command works only in groups.",
|
||||
"group.not_trusted": (
|
||||
"This group is not authorized to receive duty data. "
|
||||
"An administrator can add the group with /trust_group."
|
||||
),
|
||||
"help.trust_group": "/trust_group — In a group: add group to trusted list (admin only)",
|
||||
"help.untrust_group": "/untrust_group — In a group: remove group from trusted list (admin only)",
|
||||
"calendar_link.private_only": "The /calendar_link command is only available in private chat.",
|
||||
"calendar_link.access_denied": "Access denied.",
|
||||
"calendar_link.success": (
|
||||
@@ -47,6 +60,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"administrator with «Pin messages» permission, then send /pin_duty in the "
|
||||
"chat — the current message will be pinned."
|
||||
),
|
||||
"pin_duty.view_contacts": "View contacts",
|
||||
"duty.no_duty": "No duty at the moment.",
|
||||
"duty.label": "Duty:",
|
||||
"import.admin_only": "Access for administrators only.",
|
||||
@@ -58,7 +72,9 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"import.send_json": "Send the duty-schedule file (JSON).",
|
||||
"import.need_json": "File must have .json extension.",
|
||||
"import.parse_error": "File parse error: {error}",
|
||||
"import.parse_error_generic": "The file could not be parsed. Check the format and try again.",
|
||||
"import.import_error": "Import error: {error}",
|
||||
"import.import_error_generic": "Import failed. Please try again or contact an administrator.",
|
||||
"import.done": (
|
||||
"Import done: {users} users, {duties} duties{unavailable}{vacation} "
|
||||
"({total} events total)."
|
||||
@@ -72,8 +88,20 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
),
|
||||
"api.auth_invalid": "Invalid auth data",
|
||||
"api.access_denied": "Access denied",
|
||||
"api.bad_request": "Bad request",
|
||||
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
|
||||
"dates.from_after_to": "from date must not be after to",
|
||||
"dates.range_too_large": "Date range is too large. Request a shorter period.",
|
||||
"contact.show": "Contacts",
|
||||
"contact.back": "Back",
|
||||
"current_duty.title": "Current Duty",
|
||||
"current_duty.no_duty": "No one is on duty right now",
|
||||
"current_duty.shift": "Shift",
|
||||
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
||||
"current_duty.back": "Back to calendar",
|
||||
"admin.duty_not_found": "Duty not found",
|
||||
"admin.user_not_found": "User not found",
|
||||
"admin.reassign_success": "Duty reassigned successfully",
|
||||
},
|
||||
"ru": {
|
||||
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
|
||||
@@ -92,6 +120,19 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"refresh_pin.no_message": "В этом чате нет закреплённого сообщения о дежурстве для обновления.",
|
||||
"refresh_pin.updated": "Закреплённое сообщение о дежурстве обновлено.",
|
||||
"refresh_pin.failed": "Не удалось обновить закреплённое сообщение (права или ошибка редактирования).",
|
||||
"refresh_pin.untrusted": "Группа удалена из доверенных; запись о закреплении сброшена.",
|
||||
"trust_group.added": "Группа добавлена в доверенные.",
|
||||
"trust_group.already_trusted": "Эта группа уже в доверенных.",
|
||||
"trust_group.group_only": "Команда /trust_group работает только в группах.",
|
||||
"untrust_group.removed": "Группа удалена из доверенных.",
|
||||
"untrust_group.not_trusted": "Эта группа не в доверенных.",
|
||||
"untrust_group.group_only": "Команда /untrust_group работает только в группах.",
|
||||
"group.not_trusted": (
|
||||
"Эта группа не авторизована для получения данных дежурных. "
|
||||
"Администратор может добавить группу командой /trust_group."
|
||||
),
|
||||
"help.trust_group": "/trust_group — В группе: добавить группу в доверенные (только админ)",
|
||||
"help.untrust_group": "/untrust_group — В группе: удалить группу из доверенных (только админ)",
|
||||
"calendar_link.private_only": "Команда /calendar_link доступна только в личке.",
|
||||
"calendar_link.access_denied": "Доступ запрещён.",
|
||||
"calendar_link.success": (
|
||||
@@ -116,6 +157,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"pin_duty.could_not_pin_make_admin": "Сообщение о дежурстве отправлено, но закрепить его не удалось. "
|
||||
"Сделайте бота администратором с правом «Закреплять сообщения» (Pin messages), "
|
||||
"затем отправьте в чат команду /pin_duty — текущее сообщение будет закреплено.",
|
||||
"pin_duty.view_contacts": "Контакты",
|
||||
"duty.no_duty": "Сейчас дежурства нет.",
|
||||
"duty.label": "Дежурство:",
|
||||
"import.admin_only": "Доступ только для администраторов.",
|
||||
@@ -125,7 +167,9 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"import.send_json": "Отправьте файл в формате duty-schedule (JSON).",
|
||||
"import.need_json": "Нужен файл с расширением .json",
|
||||
"import.parse_error": "Ошибка разбора файла: {error}",
|
||||
"import.parse_error_generic": "Не удалось разобрать файл. Проверьте формат и попробуйте снова.",
|
||||
"import.import_error": "Ошибка импорта: {error}",
|
||||
"import.import_error_generic": "Импорт не выполнен. Попробуйте снова или обратитесь к администратору.",
|
||||
"import.done": "Импорт выполнен: {users} пользователей, {duties} дежурств{unavailable}{vacation} (всего {total} событий).",
|
||||
"import.done_unavailable": ", {count} недоступностей",
|
||||
"import.done_vacation": ", {count} отпусков",
|
||||
@@ -134,7 +178,19 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"из которого открыт календарь (тот же бот, что в меню).",
|
||||
"api.auth_invalid": "Неверные данные авторизации",
|
||||
"api.access_denied": "Доступ запрещён",
|
||||
"api.bad_request": "Неверный запрос",
|
||||
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
|
||||
"dates.from_after_to": "Дата from не должна быть позже to",
|
||||
"dates.range_too_large": "Диапазон дат слишком большой. Запросите более короткий период.",
|
||||
"contact.show": "Контакты",
|
||||
"contact.back": "Назад",
|
||||
"current_duty.title": "Текущее дежурство",
|
||||
"current_duty.no_duty": "Сейчас никто не дежурит",
|
||||
"current_duty.shift": "Смена",
|
||||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||||
"current_duty.back": "Назад к календарю",
|
||||
"admin.duty_not_found": "Дежурство не найдено",
|
||||
"admin.user_not_found": "Пользователь не найден",
|
||||
"admin.reassign_success": "Дежурство успешно переназначено",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ DUTY_MARKERS = frozenset({"б", "Б", "в", "В"})
|
||||
UNAVAILABLE_MARKER = "Н"
|
||||
VACATION_MARKER = "О"
|
||||
|
||||
# Limits to avoid abuse and unreasonable input.
|
||||
MAX_SCHEDULE_ROWS = 500
|
||||
MAX_FULL_NAME_LENGTH = 200
|
||||
MAX_DUTY_STRING_LENGTH = 10000
|
||||
|
||||
|
||||
@dataclass
|
||||
class DutyScheduleEntry:
|
||||
@@ -69,10 +74,24 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
except ValueError as e:
|
||||
raise DutyScheduleParseError(f"Invalid meta.start_date: {start_str}") from e
|
||||
|
||||
# Reject dates outside current year ± 1.
|
||||
today = date.today()
|
||||
min_year = today.year - 1
|
||||
max_year = today.year + 1
|
||||
if not (min_year <= start_date.year <= max_year):
|
||||
raise DutyScheduleParseError(
|
||||
f"meta.start_date year must be between {min_year} and {max_year}"
|
||||
)
|
||||
|
||||
schedule = data.get("schedule")
|
||||
if not isinstance(schedule, list):
|
||||
raise DutyScheduleParseError("Missing or invalid 'schedule' (must be array)")
|
||||
|
||||
if len(schedule) > MAX_SCHEDULE_ROWS:
|
||||
raise DutyScheduleParseError(
|
||||
f"schedule has too many rows (max {MAX_SCHEDULE_ROWS})"
|
||||
)
|
||||
|
||||
max_days = 0
|
||||
entries: list[DutyScheduleEntry] = []
|
||||
|
||||
@@ -85,12 +104,20 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
full_name = name.strip()
|
||||
if not full_name:
|
||||
raise DutyScheduleParseError("schedule item 'name' cannot be empty")
|
||||
if len(full_name) > MAX_FULL_NAME_LENGTH:
|
||||
raise DutyScheduleParseError(
|
||||
f"schedule item 'name' must not exceed {MAX_FULL_NAME_LENGTH} characters"
|
||||
)
|
||||
|
||||
duty_str = row.get("duty")
|
||||
if duty_str is None:
|
||||
duty_str = ""
|
||||
if not isinstance(duty_str, str):
|
||||
raise DutyScheduleParseError("schedule item 'duty' must be string")
|
||||
if len(duty_str) > MAX_DUTY_STRING_LENGTH:
|
||||
raise DutyScheduleParseError(
|
||||
f"schedule item 'duty' must not exceed {MAX_DUTY_STRING_LENGTH} characters"
|
||||
)
|
||||
|
||||
cells = [c.strip() for c in duty_str.split(";")]
|
||||
max_days = max(max_days, len(cells))
|
||||
@@ -120,4 +147,9 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
else:
|
||||
end_date = start_date + timedelta(days=max_days - 1)
|
||||
|
||||
if not (min_year <= end_date.year <= max_year):
|
||||
raise DutyScheduleParseError(
|
||||
f"Computed end_date year must be between {min_year} and {max_year}"
|
||||
)
|
||||
|
||||
return DutyScheduleResult(start_date=start_date, end_date=end_date, entries=entries)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from telegram.ext import ApplicationBuilder
|
||||
@@ -13,9 +15,27 @@ from duty_teller.config import require_bot_token
|
||||
from duty_teller.handlers import group_duty_pin, register_handlers
|
||||
from duty_teller.utils.http_client import safe_urlopen
|
||||
|
||||
# Seconds to wait for HTTP server to bind before health check.
|
||||
_HTTP_STARTUP_WAIT_SEC = 3
|
||||
|
||||
|
||||
async def _post_init(application) -> None:
|
||||
"""Run startup tasks: restore group pin jobs, then resolve bot username."""
|
||||
await group_duty_pin.restore_group_pin_jobs(application)
|
||||
await _resolve_bot_username(application)
|
||||
|
||||
|
||||
async def _resolve_bot_username(application) -> None:
|
||||
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
|
||||
if not config.BOT_USERNAME:
|
||||
me = await application.bot.get_me()
|
||||
config.BOT_USERNAME = (me.username or "").lower()
|
||||
logger.info("Resolved BOT_USERNAME from API: %s", config.BOT_USERNAME)
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
level=logging.INFO,
|
||||
level=config.LOG_LEVEL,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,31 +80,59 @@ def _run_uvicorn(web_app, port: int) -> None:
|
||||
loop.run_until_complete(server.serve())
|
||||
|
||||
|
||||
def _wait_for_http_ready(port: int) -> bool:
|
||||
"""Return True if /health responds successfully within _HTTP_STARTUP_WAIT_SEC."""
|
||||
host = config.HTTP_HOST
|
||||
if host not in config.LOOPBACK_HTTP_HOSTS:
|
||||
host = "127.0.0.1"
|
||||
url = f"http://{host}:{port}/health"
|
||||
deadline = time.monotonic() + _HTTP_STARTUP_WAIT_SEC
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
req = urllib.request.Request(url)
|
||||
with safe_urlopen(req, timeout=2) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("Health check not ready yet: %s", e)
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Build the bot and FastAPI, start uvicorn in a thread, run polling."""
|
||||
require_bot_token()
|
||||
# Optional: set bot menu button to open the Miniapp. Uncomment to enable:
|
||||
# _set_default_menu_button_webapp()
|
||||
app = (
|
||||
ApplicationBuilder()
|
||||
.token(config.BOT_TOKEN)
|
||||
.post_init(group_duty_pin.restore_group_pin_jobs)
|
||||
.build()
|
||||
)
|
||||
app = ApplicationBuilder().token(config.BOT_TOKEN).post_init(_post_init).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()
|
||||
|
||||
if config.MINI_APP_SKIP_AUTH:
|
||||
logger.warning(
|
||||
"MINI_APP_SKIP_AUTH is set — API auth disabled (insecure); use only for dev"
|
||||
)
|
||||
if config.HTTP_HOST not in config.LOOPBACK_HTTP_HOSTS:
|
||||
print(
|
||||
"ERROR: MINI_APP_SKIP_AUTH must not be used in production (non-localhost).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
t = threading.Thread(
|
||||
target=_run_uvicorn,
|
||||
args=(web_app, config.HTTP_PORT),
|
||||
daemon=False,
|
||||
)
|
||||
t.start()
|
||||
|
||||
if not _wait_for_http_ready(config.HTTP_PORT):
|
||||
logger.error(
|
||||
"HTTP server did not become ready on port %s within %s s; check port and permissions.",
|
||||
config.HTTP_PORT,
|
||||
_HTTP_STARTUP_WAIT_SEC,
|
||||
)
|
||||
sys.exit(1)
|
||||
logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT)
|
||||
app.run_polling(allowed_updates=["message", "my_chat_member"])
|
||||
|
||||
@@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from duty_teller.cache import duty_pin_cache
|
||||
from duty_teller.db.repository import (
|
||||
get_current_duty,
|
||||
get_next_shift_end,
|
||||
@@ -12,11 +13,39 @@ from duty_teller.db.repository import (
|
||||
save_group_duty_pin,
|
||||
delete_group_duty_pin,
|
||||
get_all_group_duty_pin_chat_ids,
|
||||
is_trusted_group,
|
||||
add_trusted_group,
|
||||
remove_trusted_group,
|
||||
)
|
||||
from duty_teller.i18n import t
|
||||
from duty_teller.utils.dates import parse_utc_iso
|
||||
|
||||
|
||||
def get_pin_refresh_data(
|
||||
session: Session, chat_id: int, tz_name: str, lang: str = "en"
|
||||
) -> tuple[int | None, str, datetime | None]:
|
||||
"""Get all data needed for pin refresh in a single DB session.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
tz_name: Timezone name for display.
|
||||
lang: Language code for i18n.
|
||||
|
||||
Returns:
|
||||
(message_id, duty_message_text, next_shift_end_utc).
|
||||
message_id is None if no pin record. next_shift_end_utc is naive UTC or None.
|
||||
"""
|
||||
pin = get_group_duty_pin(session, chat_id)
|
||||
message_id = pin.message_id if pin else None
|
||||
if message_id is None:
|
||||
return (None, t(lang, "duty.no_duty"), None)
|
||||
now = datetime.now(timezone.utc)
|
||||
text = get_duty_message_text(session, tz_name, lang)
|
||||
next_end = get_next_shift_end(session, now)
|
||||
return (message_id, text, next_end)
|
||||
|
||||
|
||||
def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
|
||||
"""Build the text for the pinned duty message.
|
||||
|
||||
@@ -56,42 +85,35 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
|
||||
f"🕐 {label} {time_range}",
|
||||
f"👤 {user.full_name}",
|
||||
]
|
||||
if user.phone:
|
||||
lines.append(f"📞 {user.phone}")
|
||||
if user.username:
|
||||
lines.append(f"@{user.username}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_duty_message_text(session: Session, tz_name: str, lang: str = "en") -> str:
|
||||
"""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.
|
||||
"""
|
||||
"""Get current duty from DB and return formatted message text. Cached 90s."""
|
||||
cache_key = ("duty_message_text", tz_name, lang)
|
||||
text, found = duty_pin_cache.get(cache_key)
|
||||
if found:
|
||||
return text
|
||||
now = datetime.now(timezone.utc)
|
||||
result = get_current_duty(session, now)
|
||||
if result is None:
|
||||
return t(lang, "duty.no_duty")
|
||||
duty, user = result
|
||||
return format_duty_message(duty, user, tz_name, lang)
|
||||
text = t(lang, "duty.no_duty")
|
||||
else:
|
||||
duty, user = result
|
||||
text = format_duty_message(duty, user, tz_name, lang)
|
||||
duty_pin_cache.set(cache_key, text)
|
||||
return text
|
||||
|
||||
|
||||
def get_next_shift_end_utc(session: Session) -> datetime | None:
|
||||
"""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))
|
||||
"""Return next shift end as naive UTC datetime for job scheduling. Cached 90s."""
|
||||
cache_key = ("next_shift_end",)
|
||||
value, found = duty_pin_cache.get(cache_key)
|
||||
if found:
|
||||
return value
|
||||
result = get_next_shift_end(session, datetime.now(timezone.utc))
|
||||
duty_pin_cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
|
||||
def save_pin(session: Session, chat_id: int, message_id: int) -> None:
|
||||
@@ -141,3 +163,39 @@ def get_all_pin_chat_ids(session: Session) -> list[int]:
|
||||
List of chat ids.
|
||||
"""
|
||||
return get_all_group_duty_pin_chat_ids(session)
|
||||
|
||||
|
||||
def is_group_trusted(session: Session, chat_id: int) -> bool:
|
||||
"""Check if the group is in the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
|
||||
Returns:
|
||||
True if the group is trusted.
|
||||
"""
|
||||
return is_trusted_group(session, chat_id)
|
||||
|
||||
|
||||
def trust_group(
|
||||
session: Session, chat_id: int, added_by_user_id: int | None = None
|
||||
) -> None:
|
||||
"""Add the group to the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
added_by_user_id: Telegram user id of the admin who added the group (optional).
|
||||
"""
|
||||
add_trusted_group(session, chat_id, added_by_user_id)
|
||||
|
||||
|
||||
def untrust_group(session: Session, chat_id: int) -> None:
|
||||
"""Remove the group from the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
"""
|
||||
remove_trusted_group(session, chat_id)
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
"""Import duty schedule: delete range, insert duties/unavailable/vacation. Accepts session."""
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from duty_teller.cache import invalidate_duty_related_caches
|
||||
from duty_teller.db.models import Duty
|
||||
from duty_teller.db.repository import (
|
||||
get_or_create_user_by_full_name,
|
||||
delete_duties_in_range,
|
||||
insert_duty,
|
||||
get_or_create_user_by_full_name,
|
||||
get_users_by_full_names,
|
||||
)
|
||||
from duty_teller.importers.duty_schedule import DutyScheduleResult
|
||||
from duty_teller.utils.dates import day_start_iso, day_end_iso, duty_to_iso
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]:
|
||||
"""Sort dates and merge consecutive ones into (first, last) ranges. Empty list -> []."""
|
||||
@@ -37,11 +42,10 @@ def run_import(
|
||||
hour_utc: int,
|
||||
minute_utc: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.
|
||||
"""Run duty-schedule import: delete range per user, bulk insert duties.
|
||||
|
||||
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).
|
||||
Batched: users fetched in one query, missing created; bulk_insert_mappings.
|
||||
One commit at end.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
@@ -52,34 +56,79 @@ def run_import(
|
||||
Returns:
|
||||
Tuple (num_users, num_duty, num_unavailable, num_vacation).
|
||||
"""
|
||||
logger.info(
|
||||
"Import started: range %s..%s, %d entries",
|
||||
result.start_date,
|
||||
result.end_date,
|
||||
len(result.entries),
|
||||
)
|
||||
from_date_str = result.start_date.isoformat()
|
||||
to_date_str = result.end_date.isoformat()
|
||||
num_duty = num_unavailable = num_vacation = 0
|
||||
|
||||
# Batch: get all users by full_name, create missing (no commit until end)
|
||||
names = [e.full_name for e in result.entries]
|
||||
users_map = get_users_by_full_names(session, names)
|
||||
for name in names:
|
||||
if name not in users_map:
|
||||
users_map[name] = get_or_create_user_by_full_name(
|
||||
session, name, commit=False
|
||||
)
|
||||
|
||||
# Delete range per user (no commit)
|
||||
for entry in result.entries:
|
||||
user = get_or_create_user_by_full_name(session, entry.full_name)
|
||||
delete_duties_in_range(session, user.id, from_date_str, to_date_str)
|
||||
user = users_map[entry.full_name]
|
||||
delete_duties_in_range(
|
||||
session, user.id, from_date_str, to_date_str, commit=False
|
||||
)
|
||||
|
||||
# Build rows for bulk insert
|
||||
duty_rows: list[dict] = []
|
||||
for entry in result.entries:
|
||||
user = users_map[entry.full_name]
|
||||
for d in entry.duty_dates:
|
||||
start_at = duty_to_iso(d, hour_utc, minute_utc)
|
||||
d_next = d + timedelta(days=1)
|
||||
end_at = duty_to_iso(d_next, hour_utc, minute_utc)
|
||||
insert_duty(session, user.id, start_at, end_at, event_type="duty")
|
||||
duty_rows.append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"start_at": start_at,
|
||||
"end_at": end_at,
|
||||
"event_type": "duty",
|
||||
}
|
||||
)
|
||||
num_duty += 1
|
||||
for d in entry.unavailable_dates:
|
||||
insert_duty(
|
||||
session,
|
||||
user.id,
|
||||
day_start_iso(d),
|
||||
day_end_iso(d),
|
||||
event_type="unavailable",
|
||||
duty_rows.append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"start_at": day_start_iso(d),
|
||||
"end_at": day_end_iso(d),
|
||||
"event_type": "unavailable",
|
||||
}
|
||||
)
|
||||
num_unavailable += 1
|
||||
for start_d, end_d in _consecutive_date_ranges(entry.vacation_dates):
|
||||
insert_duty(
|
||||
session,
|
||||
user.id,
|
||||
day_start_iso(start_d),
|
||||
day_end_iso(end_d),
|
||||
event_type="vacation",
|
||||
duty_rows.append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"start_at": day_start_iso(start_d),
|
||||
"end_at": day_end_iso(end_d),
|
||||
"event_type": "vacation",
|
||||
}
|
||||
)
|
||||
num_vacation += 1
|
||||
|
||||
if duty_rows:
|
||||
session.bulk_insert_mappings(Duty, duty_rows)
|
||||
session.commit()
|
||||
invalidate_duty_related_caches()
|
||||
logger.info(
|
||||
"Import done: %d users, %d duty, %d unavailable, %d vacation",
|
||||
len(result.entries),
|
||||
num_duty,
|
||||
num_unavailable,
|
||||
num_vacation,
|
||||
)
|
||||
return (len(result.entries), num_duty, num_unavailable, num_vacation)
|
||||
|
||||
@@ -24,10 +24,17 @@ def duty_to_iso(d: date, hour_utc: int, minute_utc: int) -> str:
|
||||
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
# Maximum allowed date range in days (e.g. 731 = 2 years).
|
||||
MAX_DATE_RANGE_DAYS = 731
|
||||
|
||||
|
||||
class DateRangeValidationError(ValueError):
|
||||
"""Raised when from_date/to_date validation fails. API uses kind for i18n key."""
|
||||
|
||||
def __init__(self, kind: Literal["bad_format", "from_after_to"]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
kind: Literal["bad_format", "from_after_to", "range_too_large"],
|
||||
) -> None:
|
||||
self.kind = kind
|
||||
super().__init__(kind)
|
||||
|
||||
@@ -86,12 +93,20 @@ def parse_iso_date(s: str) -> date | None:
|
||||
|
||||
|
||||
def validate_date_range(from_date: str, to_date: str) -> None:
|
||||
"""Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date.
|
||||
"""Validate from_date and to_date are YYYY-MM-DD, from_date <= to_date, and range <= MAX_DATE_RANGE_DAYS.
|
||||
|
||||
Raises:
|
||||
DateRangeValidationError: bad_format if format invalid, from_after_to if from > to.
|
||||
DateRangeValidationError: bad_format if format invalid, from_after_to if from > to,
|
||||
range_too_large if (to_date - from_date) > MAX_DATE_RANGE_DAYS.
|
||||
"""
|
||||
if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""):
|
||||
raise DateRangeValidationError("bad_format")
|
||||
if from_date > to_date:
|
||||
raise DateRangeValidationError("from_after_to")
|
||||
try:
|
||||
from_d = date.fromisoformat(from_date)
|
||||
to_d = date.fromisoformat(to_date)
|
||||
except ValueError:
|
||||
raise DateRangeValidationError("bad_format") from None
|
||||
if (to_d - from_d).days > MAX_DATE_RANGE_DAYS:
|
||||
raise DateRangeValidationError("range_too_large")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "duty-teller"
|
||||
version = "0.1.0"
|
||||
version = "2.1.2"
|
||||
description = "Telegram bot for team duty shift calendar and group reminder"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
396
tests/test_admin_api.py
Normal file
396
tests/test_admin_api.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""Tests for admin API: GET /api/admin/me, GET /api/admin/users, PATCH /api/admin/duties/:id."""
|
||||
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from duty_teller.api.app import app
|
||||
from duty_teller.api.dependencies import get_db_session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _override_get_db_session(mock_session: MagicMock):
|
||||
"""Dependency override that returns mock_session (no real DB). Used as get_db_session override."""
|
||||
|
||||
def _override() -> Session:
|
||||
return mock_session
|
||||
|
||||
return _override
|
||||
|
||||
|
||||
# --- GET /api/admin/me ---
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_admin_me_skip_auth_returns_is_admin_false(client):
|
||||
"""With MINI_APP_SKIP_AUTH, GET /api/admin/me returns is_admin: false (no real user)."""
|
||||
# Override get_db_session so the endpoint does not open the real DB (CI has no data/ dir).
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = None
|
||||
app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session)
|
||||
try:
|
||||
r = client.get("/api/admin/me")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_admin": False}
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db_session, None)
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_me_returns_is_admin_true_when_admin(
|
||||
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||
):
|
||||
"""When user is admin, GET /api/admin/me returns is_admin: true."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (100, "user", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
r = client.get(
|
||||
"/api/admin/me",
|
||||
headers={
|
||||
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_admin": True}
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_me_returns_is_admin_false_when_not_admin(
|
||||
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||
):
|
||||
"""When user is not admin, GET /api/admin/me returns is_admin: false."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (200, "user", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="User", username="user")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = False
|
||||
r = client.get(
|
||||
"/api/admin/me",
|
||||
headers={
|
||||
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A200%7D&hash=x"
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_admin": False}
|
||||
|
||||
|
||||
# --- GET /api/admin/users ---
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_users_403_without_init_data(client):
|
||||
"""GET /api/admin/users without initData returns 403."""
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_users_403_when_not_admin(
|
||||
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||
):
|
||||
"""GET /api/admin/users when not admin returns 403 with admin_only message."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (100, "u", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="U", username="u")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = False # not admin
|
||||
r = client.get(
|
||||
"/api/admin/users",
|
||||
headers={
|
||||
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"
|
||||
},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
detail = r.json()["detail"]
|
||||
assert "admin" in detail.lower() or "администратор" in detail or "only" in detail
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.get_users_for_admin")
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_users_200_returns_list(
|
||||
mock_validate, mock_get_user, mock_can_access, mock_is_admin, mock_get_users, client
|
||||
):
|
||||
"""GET /api/admin/users returns list of id, full_name, username, role_id."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
mock_get_users.return_value = [
|
||||
SimpleNamespace(id=1, full_name="Alice", username="alice", role_id=1),
|
||||
SimpleNamespace(id=2, full_name="Bob", username=None, role_id=2),
|
||||
]
|
||||
r = client.get(
|
||||
"/api/admin/users",
|
||||
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["id"] == 1
|
||||
assert data[0]["full_name"] == "Alice"
|
||||
assert data[0]["username"] == "alice"
|
||||
assert data[0]["role_id"] == 1
|
||||
assert data[1]["id"] == 2
|
||||
assert data[1]["full_name"] == "Bob"
|
||||
assert data[1]["username"] is None
|
||||
assert data[1]["role_id"] == 2
|
||||
|
||||
|
||||
# --- PATCH /api/admin/duties/:id ---
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_403_without_auth(client):
|
||||
"""PATCH /api/admin/duties/1 without auth returns 403."""
|
||||
r = client.patch(
|
||||
"/api/admin/duties/1",
|
||||
json={"user_id": 2},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.require_admin_telegram_id")
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_403_when_not_admin(mock_require_admin, client):
|
||||
"""PATCH /api/admin/duties/1 when not admin returns 403."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from duty_teller.i18n import t
|
||||
|
||||
mock_require_admin.side_effect = HTTPException(
|
||||
status_code=403, detail=t("en", "import.admin_only")
|
||||
)
|
||||
r = client.patch(
|
||||
"/api/admin/duties/1",
|
||||
json={"user_id": 2},
|
||||
headers={"X-Telegram-Init-Data": "x"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||
@patch("duty_teller.api.app.update_duty_user")
|
||||
@patch("duty_teller.api.app.get_duty_by_id")
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_404_when_duty_missing(
|
||||
mock_validate,
|
||||
mock_get_user,
|
||||
mock_can_access,
|
||||
mock_is_admin,
|
||||
mock_get_duty,
|
||||
mock_update,
|
||||
mock_invalidate,
|
||||
client,
|
||||
):
|
||||
"""PATCH /api/admin/duties/999 returns 404 when duty not found."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
mock_get_duty.return_value = None
|
||||
r = client.patch(
|
||||
"/api/admin/duties/999",
|
||||
json={"user_id": 2},
|
||||
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
mock_update.assert_not_called()
|
||||
mock_invalidate.assert_not_called()
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||
@patch("duty_teller.api.app.update_duty_user")
|
||||
@patch("duty_teller.api.app.get_duty_by_id")
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_400_when_user_not_found(
|
||||
mock_validate,
|
||||
mock_get_user,
|
||||
mock_can_access,
|
||||
mock_is_admin,
|
||||
mock_get_duty,
|
||||
mock_update,
|
||||
mock_invalidate,
|
||||
client,
|
||||
):
|
||||
"""PATCH /api/admin/duties/1 returns 400 when user_id does not exist."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
mock_get_duty.return_value = SimpleNamespace(
|
||||
id=1, user_id=10, start_at="2026-01-15T09:00:00Z", end_at="2026-01-15T18:00:00Z"
|
||||
)
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = None # User not found
|
||||
app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session)
|
||||
try:
|
||||
r = client.patch(
|
||||
"/api/admin/duties/1",
|
||||
json={"user_id": 999},
|
||||
headers={
|
||||
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db_session, None)
|
||||
assert r.status_code == 400
|
||||
mock_update.assert_not_called()
|
||||
mock_invalidate.assert_not_called()
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||
@patch("duty_teller.api.app.update_duty_user")
|
||||
@patch("duty_teller.api.app.get_duty_by_id")
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_200_updates_and_invalidates(
|
||||
mock_validate,
|
||||
mock_get_user,
|
||||
mock_can_access,
|
||||
mock_is_admin,
|
||||
mock_get_duty,
|
||||
mock_update_duty_user,
|
||||
mock_invalidate,
|
||||
client,
|
||||
):
|
||||
"""PATCH /api/admin/duties/1 with valid body returns 200 and invalidates caches."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
duty = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=10,
|
||||
start_at="2026-01-15T09:00:00Z",
|
||||
end_at="2026-01-15T18:00:00Z",
|
||||
)
|
||||
updated_duty = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=2,
|
||||
start_at="2026-01-15T09:00:00Z",
|
||||
end_at="2026-01-15T18:00:00Z",
|
||||
)
|
||||
mock_get_duty.return_value = duty
|
||||
mock_update_duty_user.return_value = updated_duty
|
||||
mock_session = MagicMock()
|
||||
mock_session.get.return_value = SimpleNamespace(id=2) # User exists
|
||||
app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session)
|
||||
try:
|
||||
r = client.patch(
|
||||
"/api/admin/duties/1",
|
||||
json={"user_id": 2},
|
||||
headers={
|
||||
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db_session, None)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["id"] == 1
|
||||
assert data["user_id"] == 2
|
||||
assert data["start_at"] == "2026-01-15T09:00:00Z"
|
||||
assert data["end_at"] == "2026-01-15T18:00:00Z"
|
||||
mock_update_duty_user.assert_called_once_with(ANY, 1, 2, commit=True)
|
||||
mock_invalidate.assert_called_once()
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_admin_users_403_when_skip_auth(client):
|
||||
"""GET /api/admin/users with MINI_APP_SKIP_AUTH returns 403 (admin routes disabled)."""
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 403
|
||||
detail = r.json()["detail"]
|
||||
assert "admin" in detail.lower() or "администратор" in detail
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_admin_reassign_403_when_skip_auth(client):
|
||||
"""PATCH /api/admin/duties/1 with MINI_APP_SKIP_AUTH returns 403."""
|
||||
r = client.patch(
|
||||
"/api/admin/duties/1",
|
||||
json={"user_id": 2},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.get_duty_by_id")
|
||||
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_admin_reassign_404_uses_accept_language_for_detail(
|
||||
mock_validate,
|
||||
mock_get_user,
|
||||
mock_can_access,
|
||||
mock_is_admin,
|
||||
mock_get_duty,
|
||||
client,
|
||||
):
|
||||
"""PATCH with Accept-Language: ru returns 404 detail in Russian."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||
mock_can_access.return_value = True
|
||||
mock_is_admin.return_value = True
|
||||
mock_get_duty.return_value = None
|
||||
with patch("duty_teller.api.app._lang_from_accept_language") as mock_lang:
|
||||
mock_lang.return_value = "ru"
|
||||
r = client.patch(
|
||||
"/api/admin/duties/999",
|
||||
json={"user_id": 2},
|
||||
headers={
|
||||
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x",
|
||||
"Accept-Language": "ru",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"] == "Дежурство не найдено"
|
||||
@@ -10,24 +10,29 @@ import duty_teller.config as config
|
||||
|
||||
|
||||
class TestLangFromAcceptLanguage:
|
||||
"""Tests for _lang_from_accept_language."""
|
||||
"""Tests for _lang_from_accept_language: always returns config.DEFAULT_LANGUAGE."""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
def test_always_returns_default_language(self):
|
||||
"""Header is ignored; result is always config.DEFAULT_LANGUAGE."""
|
||||
assert deps._lang_from_accept_language(None) == config.DEFAULT_LANGUAGE
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
assert deps._lang_from_accept_language("") == config.DEFAULT_LANGUAGE
|
||||
assert deps._lang_from_accept_language(" ") == config.DEFAULT_LANGUAGE
|
||||
assert (
|
||||
deps._lang_from_accept_language("ru-RU,ru;q=0.9") == config.DEFAULT_LANGUAGE
|
||||
)
|
||||
assert deps._lang_from_accept_language("en-US") == config.DEFAULT_LANGUAGE
|
||||
assert deps._lang_from_accept_language("zz") == config.DEFAULT_LANGUAGE
|
||||
assert deps._lang_from_accept_language("x") == config.DEFAULT_LANGUAGE
|
||||
|
||||
def test_ru_ru_returns_ru(self):
|
||||
assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == "ru"
|
||||
def test_returns_ru_when_default_language_is_ru(self):
|
||||
with patch.object(config, "DEFAULT_LANGUAGE", "ru"):
|
||||
assert deps._lang_from_accept_language("en-US") == "ru"
|
||||
assert deps._lang_from_accept_language(None) == "ru"
|
||||
|
||||
def test_en_us_returns_en(self):
|
||||
assert deps._lang_from_accept_language("en-US") == "en"
|
||||
|
||||
def test_invalid_fallback_to_en(self):
|
||||
assert deps._lang_from_accept_language("zz") == "en"
|
||||
assert deps._lang_from_accept_language("x") == "en"
|
||||
def test_returns_en_when_default_language_is_en(self):
|
||||
with patch.object(config, "DEFAULT_LANGUAGE", "en"):
|
||||
assert deps._lang_from_accept_language("ru-RU") == "en"
|
||||
assert deps._lang_from_accept_language(None) == "en"
|
||||
|
||||
|
||||
class TestAuthErrorDetail:
|
||||
@@ -71,3 +76,31 @@ class TestValidateDutyDates:
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "From after to message"
|
||||
mock_t.assert_called_with("ru", "dates.from_after_to")
|
||||
|
||||
|
||||
class TestFetchDutiesResponse:
|
||||
"""Tests for fetch_duties_response (DutyWithUser list with phone, username)."""
|
||||
|
||||
def test_fetch_duties_response_includes_phone_and_username(self):
|
||||
"""get_duties returns (Duty, full_name, phone, username); response has phone, username."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from duty_teller.db.schemas import DutyWithUser
|
||||
|
||||
duty = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=10,
|
||||
start_at="2025-01-15T09:00:00Z",
|
||||
end_at="2025-01-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
rows = [(duty, "Alice", "+79001234567", "alice_dev")]
|
||||
with patch.object(deps, "get_duties", return_value=rows):
|
||||
result = deps.fetch_duties_response(
|
||||
type("Session", (), {})(), "2025-01-01", "2025-01-31"
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], DutyWithUser)
|
||||
assert result[0].full_name == "Alice"
|
||||
assert result[0].phone == "+79001234567"
|
||||
assert result[0].username == "alice_dev"
|
||||
|
||||
@@ -23,6 +23,80 @@ def test_health(client):
|
||||
assert r.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_unhandled_exception_returns_500_json(client):
|
||||
"""Global exception handler returns 500 JSON without leaking exception details."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from duty_teller.api.app import global_exception_handler
|
||||
|
||||
# Call the registered handler directly: it returns JSON and does not expose str(exc).
|
||||
request = MagicMock()
|
||||
exc = RuntimeError("internal failure")
|
||||
response = global_exception_handler(request, exc)
|
||||
assert response.status_code == 500
|
||||
assert response.body.decode() == '{"detail":"Internal server error"}'
|
||||
assert "internal failure" not in response.body.decode()
|
||||
|
||||
|
||||
def test_health_has_vary_accept_language(client):
|
||||
"""NoCacheStaticMiddleware adds Vary: Accept-Language to all responses."""
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert "accept-language" in r.headers.get("vary", "").lower()
|
||||
|
||||
|
||||
def test_app_static_has_no_store_and_vary(client):
|
||||
"""Static files under /app get Cache-Control: no-store and Vary: Accept-Language."""
|
||||
r = client.get("/app/")
|
||||
if r.status_code != 200:
|
||||
r = client.get("/app")
|
||||
assert r.status_code == 200, (
|
||||
"webapp static mount should serve index at /app or /app/"
|
||||
)
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
assert "accept-language" in r.headers.get("vary", "").lower()
|
||||
|
||||
|
||||
def test_app_js_has_no_store(client):
|
||||
"""JS and all static under /app get Cache-Control: no-store."""
|
||||
webapp_out = config.PROJECT_ROOT / "webapp-next" / "out"
|
||||
if not webapp_out.is_dir():
|
||||
pytest.skip("webapp-next/out not built")
|
||||
# Next.js static export serves JS under _next/static/chunks/<hash>.js
|
||||
js_files = list(webapp_out.glob("_next/static/chunks/*.js"))
|
||||
if not js_files:
|
||||
pytest.skip("no JS chunks in webapp-next/out")
|
||||
rel = js_files[0].relative_to(webapp_out)
|
||||
r = client.get(f"/app/{rel.as_posix()}")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
|
||||
|
||||
def test_app_config_js_returns_lang_from_default_language(client):
|
||||
"""GET /app/config.js returns JS setting window.__DT_LANG from config.DEFAULT_LANGUAGE."""
|
||||
r = client.get("/app/config.js")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("content-type", "").startswith("application/javascript")
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
body = r.text
|
||||
assert "window.__DT_LANG" in body
|
||||
assert config.DEFAULT_LANGUAGE in body
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.config.DEFAULT_LANGUAGE", '"; alert(1); "')
|
||||
@patch("duty_teller.api.app.config.LOG_LEVEL_STR", "DEBUG\x00INJECT")
|
||||
def test_app_config_js_sanitizes_lang_and_log_level(client):
|
||||
"""config.js uses whitelist: invalid lang/log_level produce safe defaults, no script injection."""
|
||||
r = client.get("/app/config.js")
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
# Must be valid JS and not contain the raw malicious strings.
|
||||
assert 'window.__DT_LANG = "en"' in body or 'window.__DT_LANG = "ru"' in body
|
||||
assert "alert" not in body
|
||||
assert "INJECT" not in body
|
||||
assert "window.__DT_LOG_LEVEL" in body
|
||||
|
||||
|
||||
def test_duties_invalid_date_format(client):
|
||||
r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
|
||||
assert r.status_code == 400
|
||||
@@ -37,6 +111,30 @@ def test_duties_from_after_to(client):
|
||||
assert "from" in detail or "to" in detail or "after" in detail or "позже" in detail
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_duties_range_too_large_400(client):
|
||||
"""Date range longer than MAX_DATE_RANGE_DAYS returns 400 with dates.range_too_large message."""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from duty_teller.utils.dates import MAX_DATE_RANGE_DAYS
|
||||
|
||||
from_d = date(2020, 1, 1)
|
||||
to_d = from_d + timedelta(days=MAX_DATE_RANGE_DAYS + 1)
|
||||
r = client.get(
|
||||
"/api/duties",
|
||||
params={"from": from_d.isoformat(), "to": to_d.isoformat()},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
detail = r.json()["detail"]
|
||||
# EN: "Date range is too large. Request a shorter period." / RU: "Диапазон дат слишком большой..."
|
||||
assert (
|
||||
"range" in detail.lower()
|
||||
or "short" in detail.lower()
|
||||
or "короткий" in detail
|
||||
or "большой" in detail
|
||||
)
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_duties_403_without_init_data(client):
|
||||
"""Without X-Telegram-Init-Data and without MINI_APP_SKIP_AUTH → 403 (any client)."""
|
||||
@@ -254,7 +352,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
||||
)
|
||||
|
||||
def fake_get_duties(session, from_date, to_date):
|
||||
return [(fake_duty, "User A")]
|
||||
# get_duties returns (Duty, full_name, phone, username) tuples.
|
||||
return [(fake_duty, "User A", "+79001234567", "user_a")]
|
||||
|
||||
with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties):
|
||||
r = client.get(
|
||||
@@ -266,23 +365,26 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
||||
assert len(data) == 1
|
||||
assert data[0]["event_type"] == "duty"
|
||||
assert data[0]["full_name"] == "User A"
|
||||
assert data[0].get("phone") == "+79001234567"
|
||||
assert data[0].get("username") == "user_a"
|
||||
|
||||
|
||||
def test_calendar_ical_team_404_invalid_token_format(client):
|
||||
"""GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 without DB."""
|
||||
"""GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 JSON."""
|
||||
r = client.get("/api/calendar/ical/team/short.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.headers.get("content-type", "").startswith("application/json")
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.get_user_by_calendar_token")
|
||||
def test_calendar_ical_team_404_unknown_token(mock_get_user, client):
|
||||
"""GET /api/calendar/ical/team/{token}.ics with unknown token returns 404."""
|
||||
"""GET /api/calendar/ical/team/{token}.ics with unknown token returns 404 JSON."""
|
||||
mock_get_user.return_value = None
|
||||
valid_format_token = "B" * 43
|
||||
r = client.get(f"/api/calendar/ical/team/{valid_format_token}.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
mock_get_user.assert_called_once()
|
||||
|
||||
|
||||
@@ -311,7 +413,11 @@ def test_calendar_ical_team_200_only_duty_and_description(
|
||||
end_at="2026-06-16T18:00:00Z",
|
||||
event_type="vacation",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A"), (non_duty, "User B")]
|
||||
# get_duties returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [
|
||||
(duty, "User A", None, None),
|
||||
(non_duty, "User B", None, None),
|
||||
]
|
||||
mock_build_team_ics.return_value = b"BEGIN:VCALENDAR\r\nPRODID:Team\r\nVEVENT\r\nDESCRIPTION:User A\r\nEND:VCALENDAR"
|
||||
token = "y" * 43
|
||||
|
||||
@@ -330,11 +436,10 @@ def test_calendar_ical_team_200_only_duty_and_description(
|
||||
|
||||
|
||||
def test_calendar_ical_404_invalid_token_format(client):
|
||||
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 without DB call."""
|
||||
# Token format must be base64url, 40–50 chars; short or invalid chars → 404
|
||||
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 JSON."""
|
||||
r = client.get("/api/calendar/ical/short.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
r2 = client.get("/api/calendar/ical/" + "x" * 60 + ".ics")
|
||||
assert r2.status_code == 404
|
||||
r3 = client.get("/api/calendar/ical/../../../etc/passwd.ics")
|
||||
@@ -343,13 +448,12 @@ def test_calendar_ical_404_invalid_token_format(client):
|
||||
|
||||
@patch("duty_teller.api.app.get_user_by_calendar_token")
|
||||
def test_calendar_ical_404_unknown_token(mock_get_user, client):
|
||||
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404."""
|
||||
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404 JSON."""
|
||||
mock_get_user.return_value = None
|
||||
# Use a token that passes format validation (base64url, 40–50 chars)
|
||||
valid_format_token = "A" * 43
|
||||
r = client.get(f"/api/calendar/ical/{valid_format_token}.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
mock_get_user.assert_called_once()
|
||||
|
||||
|
||||
@@ -371,7 +475,8 @@ def test_calendar_ical_200_returns_only_that_users_duties(
|
||||
end_at="2026-06-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A")]
|
||||
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [(duty, "User A", None, None)]
|
||||
mock_build_ics.return_value = (
|
||||
b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR"
|
||||
)
|
||||
@@ -403,6 +508,9 @@ def test_calendar_ical_ignores_unknown_query_params(
|
||||
"""Unknown query params (e.g. events=all) are ignored; response is duty-only."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from duty_teller.cache import ics_calendar_cache
|
||||
|
||||
ics_calendar_cache.invalidate(("personal_ics", 1))
|
||||
mock_user = SimpleNamespace(id=1, full_name="User A")
|
||||
mock_get_user.return_value = mock_user
|
||||
duty = SimpleNamespace(
|
||||
@@ -412,7 +520,8 @@ def test_calendar_ical_ignores_unknown_query_params(
|
||||
end_at="2026-06-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A")]
|
||||
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [(duty, "User A", None, None)]
|
||||
mock_build_ics.return_value = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
|
||||
token = "z" * 43
|
||||
|
||||
|
||||
@@ -91,3 +91,29 @@ def test_require_bot_token_does_not_raise_when_set(monkeypatch):
|
||||
"""require_bot_token() does nothing when BOT_TOKEN is set."""
|
||||
monkeypatch.setattr(config, "BOT_TOKEN", "123:ABC")
|
||||
config.require_bot_token()
|
||||
|
||||
|
||||
def test_settings_from_env_invalid_http_port_uses_default(monkeypatch):
|
||||
"""Invalid HTTP_PORT (non-numeric or out of range) yields default or clamped value."""
|
||||
monkeypatch.delenv("HTTP_PORT", raising=False)
|
||||
settings = config.Settings.from_env()
|
||||
assert 1 <= settings.http_port <= 65535
|
||||
|
||||
monkeypatch.setenv("HTTP_PORT", "not-a-number")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.http_port == 8080
|
||||
|
||||
monkeypatch.setenv("HTTP_PORT", "0")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.http_port == 1
|
||||
|
||||
monkeypatch.setenv("HTTP_PORT", "99999")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.http_port == 65535
|
||||
|
||||
|
||||
def test_settings_from_env_invalid_init_data_max_age_uses_default(monkeypatch):
|
||||
"""Invalid INIT_DATA_MAX_AGE_SECONDS yields default 0."""
|
||||
monkeypatch.setenv("INIT_DATA_MAX_AGE_SECONDS", "invalid")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.init_data_max_age_seconds == 0
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Tests for duty-schedule JSON parser."""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from duty_teller.importers.duty_schedule import (
|
||||
DUTY_MARKERS,
|
||||
MAX_FULL_NAME_LENGTH,
|
||||
MAX_SCHEDULE_ROWS,
|
||||
UNAVAILABLE_MARKER,
|
||||
VACATION_MARKER,
|
||||
DutyScheduleParseError,
|
||||
@@ -118,3 +121,38 @@ def test_unavailable_and_vacation_markers():
|
||||
assert entry.unavailable_dates == [date(2026, 2, 1)]
|
||||
assert entry.vacation_dates == [date(2026, 2, 2)]
|
||||
assert entry.duty_dates == [date(2026, 2, 3)]
|
||||
|
||||
|
||||
def test_parse_start_date_year_out_of_range():
|
||||
"""start_date year must be current ± 1; otherwise DutyScheduleParseError."""
|
||||
# Use a year far in the past/future so it fails regardless of test run date.
|
||||
raw_future = b'{"meta": {"start_date": "2030-01-01"}, "schedule": [{"name": "A", "duty": ""}]}'
|
||||
with pytest.raises(DutyScheduleParseError, match="year|2030"):
|
||||
parse_duty_schedule(raw_future)
|
||||
raw_past = b'{"meta": {"start_date": "2019-01-01"}, "schedule": [{"name": "A", "duty": ""}]}'
|
||||
with pytest.raises(DutyScheduleParseError, match="year|2019"):
|
||||
parse_duty_schedule(raw_past)
|
||||
|
||||
|
||||
def test_parse_too_many_schedule_rows():
|
||||
"""More than MAX_SCHEDULE_ROWS rows raises DutyScheduleParseError."""
|
||||
rows = [{"name": f"User {i}", "duty": ""} for i in range(MAX_SCHEDULE_ROWS + 1)]
|
||||
today = date.today()
|
||||
start = today.replace(month=1, day=1)
|
||||
payload = {"meta": {"start_date": start.isoformat()}, "schedule": rows}
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
with pytest.raises(DutyScheduleParseError, match="too many|max"):
|
||||
parse_duty_schedule(raw)
|
||||
|
||||
|
||||
def test_parse_full_name_too_long():
|
||||
"""full_name longer than MAX_FULL_NAME_LENGTH raises DutyScheduleParseError."""
|
||||
long_name = "A" * (MAX_FULL_NAME_LENGTH + 1)
|
||||
today = date.today()
|
||||
start = today.replace(month=1, day=1)
|
||||
raw = (
|
||||
f'{{"meta": {{"start_date": "{start.isoformat()}"}}, '
|
||||
f'"schedule": [{{"name": "{long_name}", "duty": ""}}]}}'
|
||||
).encode("utf-8")
|
||||
with pytest.raises(DutyScheduleParseError, match="exceed|character"):
|
||||
parse_duty_schedule(raw)
|
||||
|
||||
@@ -81,6 +81,7 @@ class TestFormatDutyMessage:
|
||||
assert result == "No duty"
|
||||
|
||||
def test_with_duty_and_user_returns_formatted(self):
|
||||
"""Formatted message includes time range and full name only; no contact info (phone/username)."""
|
||||
duty = SimpleNamespace(
|
||||
start_at="2025-01-15T09:00:00Z",
|
||||
end_at="2025-01-15T18:00:00Z",
|
||||
@@ -94,15 +95,17 @@ class TestFormatDutyMessage:
|
||||
mock_t.side_effect = lambda lang, key: "Duty" if key == "duty.label" else ""
|
||||
result = svc.format_duty_message(duty, user, "Europe/Moscow", "ru")
|
||||
assert "Иван Иванов" in result
|
||||
assert "+79001234567" in result or "79001234567" in result
|
||||
assert "@ivan" in result
|
||||
assert "Duty" in result
|
||||
# Contact info is restricted to Mini App; not shown in pinned group message.
|
||||
assert "+79001234567" not in result and "79001234567" not in result
|
||||
assert "@ivan" not in result
|
||||
|
||||
|
||||
class TestGetDutyMessageText:
|
||||
"""Tests for get_duty_message_text."""
|
||||
|
||||
def test_no_current_duty_returns_no_duty(self, session):
|
||||
svc.duty_pin_cache.invalidate_pattern(("duty_message_text",))
|
||||
with patch(
|
||||
"duty_teller.services.group_duty_pin_service.get_current_duty",
|
||||
return_value=None,
|
||||
@@ -113,6 +116,7 @@ class TestGetDutyMessageText:
|
||||
assert result == "No duty"
|
||||
|
||||
def test_with_current_duty_returns_formatted(self, session, duty, user):
|
||||
svc.duty_pin_cache.invalidate_pattern(("duty_message_text",))
|
||||
with patch(
|
||||
"duty_teller.services.group_duty_pin_service.get_current_duty",
|
||||
return_value=(duty, user),
|
||||
@@ -130,6 +134,7 @@ class TestGetNextShiftEndUtc:
|
||||
"""Tests for get_next_shift_end_utc."""
|
||||
|
||||
def test_no_next_shift_returns_none(self, session):
|
||||
svc.duty_pin_cache.invalidate(("next_shift_end",))
|
||||
with patch(
|
||||
"duty_teller.services.group_duty_pin_service.get_next_shift_end",
|
||||
return_value=None,
|
||||
@@ -138,6 +143,7 @@ class TestGetNextShiftEndUtc:
|
||||
assert result is None
|
||||
|
||||
def test_has_next_shift_returns_naive_utc(self, session):
|
||||
svc.duty_pin_cache.invalidate(("next_shift_end",))
|
||||
naive = datetime(2025, 2, 21, 6, 0, 0)
|
||||
with patch(
|
||||
"duty_teller.services.group_duty_pin_service.get_next_shift_end",
|
||||
|
||||
@@ -11,6 +11,13 @@ import duty_teller.config as config
|
||||
from duty_teller.handlers import group_duty_pin as mod
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_mini_app_url():
|
||||
"""Ensure BOT_USERNAME is empty so duty messages are sent without contact button (reply_markup=None)."""
|
||||
with patch.object(config, "BOT_USERNAME", ""):
|
||||
yield
|
||||
|
||||
|
||||
class TestSyncWrappers:
|
||||
"""Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync."""
|
||||
|
||||
@@ -76,6 +83,49 @@ class TestSyncWrappers:
|
||||
# --- _schedule_next_update ---
|
||||
|
||||
|
||||
def test_get_contact_button_markup_empty_username_returns_none():
|
||||
"""_get_contact_button_markup: BOT_USERNAME empty -> returns None."""
|
||||
with patch.object(config, "BOT_USERNAME", ""):
|
||||
assert mod._get_contact_button_markup("en") is None
|
||||
|
||||
|
||||
def test_get_contact_button_markup_returns_markup_when_username_set():
|
||||
"""_get_contact_button_markup: BOT_USERNAME set, no short name -> t.me bot link with startapp=duty."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
with (
|
||||
patch.object(config, "BOT_USERNAME", "MyDutyBot"),
|
||||
patch.object(config, "MINI_APP_SHORT_NAME", ""),
|
||||
):
|
||||
with patch.object(mod, "t", return_value="View contacts"):
|
||||
result = mod._get_contact_button_markup("en")
|
||||
assert result is not None
|
||||
assert isinstance(result, InlineKeyboardMarkup)
|
||||
assert len(result.inline_keyboard) == 1
|
||||
assert len(result.inline_keyboard[0]) == 1
|
||||
btn = result.inline_keyboard[0][0]
|
||||
assert btn.text == "View contacts"
|
||||
assert btn.url.startswith("https://t.me/")
|
||||
assert "startapp=duty" in btn.url
|
||||
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
|
||||
|
||||
|
||||
def test_get_contact_button_markup_with_short_name_uses_direct_miniapp_link():
|
||||
"""_get_contact_button_markup: MINI_APP_SHORT_NAME set -> direct Mini App URL with startapp=duty."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
with (
|
||||
patch.object(config, "BOT_USERNAME", "MyDutyBot"),
|
||||
patch.object(config, "MINI_APP_SHORT_NAME", "DutyApp"),
|
||||
):
|
||||
with patch.object(mod, "t", return_value="View contacts"):
|
||||
result = mod._get_contact_button_markup("en")
|
||||
assert result is not None
|
||||
assert isinstance(result, InlineKeyboardMarkup)
|
||||
btn = result.inline_keyboard[0][0]
|
||||
assert btn.url == "https://t.me/MyDutyBot/DutyApp?startapp=duty"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_next_update_job_queue_none_returns_early():
|
||||
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
|
||||
@@ -127,7 +177,7 @@ async def test_schedule_next_update_when_utc_none_runs_once_with_retry_delay():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
|
||||
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, schedules next."""
|
||||
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, deletes old, schedules next."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 999
|
||||
context = MagicMock()
|
||||
@@ -137,26 +187,70 @@ async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=1):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Current duty"
|
||||
mod,
|
||||
"_sync_get_pin_refresh_data",
|
||||
return_value=(1, "Current duty", None),
|
||||
):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=123, text="Current duty")
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=123, text="Current duty", reply_markup=None
|
||||
)
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=123)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=123, message_id=999, disable_notification=False
|
||||
)
|
||||
mock_save.assert_called_once_with(123, 999)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=123, message_id=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_delete_message_raises_bad_request_still_schedules():
|
||||
"""update_group_pin: delete_message raises BadRequest -> save and schedule still done, log warning."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 999
|
||||
context = MagicMock()
|
||||
context.job = MagicMock()
|
||||
context.job.data = {"chat_id": 123}
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock(
|
||||
side_effect=BadRequest("Message to delete not found")
|
||||
)
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod,
|
||||
"_sync_get_pin_refresh_data",
|
||||
return_value=(1, "Current duty", None),
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(mod, "logger") as mock_logger:
|
||||
await mod.update_group_pin(context)
|
||||
mock_save.assert_called_once_with(123, 999)
|
||||
mock_schedule.assert_called_once_with(context.application, 123, None)
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "Could not delete old pinned message" in mock_logger.warning.call_args[0][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -168,8 +262,11 @@ async def test_update_group_pin_no_message_id_skips():
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
await mod.update_group_pin(context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
|
||||
):
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@@ -185,21 +282,22 @@ async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=2):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
await mod.update_group_pin(context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.unpin_chat_message.assert_not_called()
|
||||
context.bot.pin_chat_message.assert_not_called()
|
||||
mock_schedule.assert_called_once_with(context.application, 111, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_repin_raises_still_schedules_next():
|
||||
"""update_group_pin: send_message ok, unpin or pin raises -> no _sync_save_pin, schedule still called, log."""
|
||||
async def test_update_group_pin_unpin_raises_pin_succeeds_saves_and_schedules():
|
||||
"""update_group_pin: send_message ok, unpin raises (e.g. no pinned message), pin succeeds -> save_pin and schedule called."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 888
|
||||
context = MagicMock()
|
||||
@@ -208,31 +306,70 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock(
|
||||
side_effect=Forbidden("Not enough rights")
|
||||
side_effect=BadRequest("Chat has no pinned message")
|
||||
)
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=3):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(mod, "logger") as mock_logger:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=222, text="Text")
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=222, text="Text", reply_markup=None
|
||||
)
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=222)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=222, message_id=888, disable_notification=False
|
||||
)
|
||||
mock_save.assert_called_once_with(222, 888)
|
||||
mock_schedule.assert_called_once_with(context.application, 222, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_pin_raises_no_save_still_schedules_next():
|
||||
"""update_group_pin: send_message ok, unpin ok, pin raises -> no _sync_save_pin, schedule still called, log."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 888
|
||||
context = MagicMock()
|
||||
context.job = MagicMock()
|
||||
context.job.data = {"chat_id": 222}
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(mod, "logger") as mock_logger:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=222, text="Text", reply_markup=None
|
||||
)
|
||||
mock_save.assert_not_called()
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "Unpin or pin" in mock_logger.warning.call_args[0][0]
|
||||
assert "Pin after refresh failed" in mock_logger.warning.call_args[0][0]
|
||||
mock_schedule.assert_called_once_with(context.application, 222, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_duty_pin_notify_false_pins_silent():
|
||||
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, schedule."""
|
||||
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, delete old, schedule."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 777
|
||||
context = MagicMock()
|
||||
@@ -242,23 +379,28 @@ async def test_update_group_pin_duty_pin_notify_false_pins_silent():
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", False):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=4):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=333, text="Text")
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=333, text="Text", reply_markup=None
|
||||
)
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=333)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=333, message_id=777, disable_notification=True
|
||||
)
|
||||
mock_save.assert_called_once_with(333, 777)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=333, message_id=4)
|
||||
mock_schedule.assert_called_once_with(context.application, 333, None)
|
||||
|
||||
|
||||
@@ -284,7 +426,7 @@ async def test_pin_duty_cmd_group_only_reply():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
||||
"""pin_duty_cmd in group with existing pin record -> pin and reply pinned."""
|
||||
"""pin_duty_cmd in group with existing pin record -> pin, schedule next update, reply pinned."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
@@ -295,21 +437,28 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=100, message_id=5, disable_notification=True
|
||||
)
|
||||
mock_schedule.assert_called_once_with(context.application, 100, None)
|
||||
update.message.reply_text.assert_called_once_with("Pinned")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_no_message_id_replies_no_message():
|
||||
"""pin_duty_cmd: no pin record (_sync_get_message_id -> None) -> reply pin_duty.no_message."""
|
||||
async def test_pin_duty_cmd_untrusted_group_rejects():
|
||||
"""pin_duty_cmd in untrusted group -> reply group.not_trusted, no send/pin."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
@@ -318,14 +467,150 @@ async def test_pin_duty_cmd_no_message_id_replies_no_message():
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "No message to pin"
|
||||
mock_t.return_value = "Not authorized"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("No message to pin")
|
||||
mock_t.assert_called_with("en", "pin_duty.no_message")
|
||||
update.message.reply_text.assert_called_once_with("Not authorized")
|
||||
mock_t.assert_called_with("en", "group.not_trusted")
|
||||
context.bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_replies_pinned():
|
||||
"""pin_duty_cmd: no pin record -> send_message, pin, save_pin, schedule, reply pinned."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 42
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=100, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=100, message_id=42, disable_notification=True
|
||||
)
|
||||
mock_save.assert_called_once_with(100, 42)
|
||||
update.message.reply_text.assert_called_once_with("Pinned")
|
||||
mock_t.assert_called_with("en", "pin_duty.pinned")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed():
|
||||
"""pin_duty_cmd: no pin record, send_message raises BadRequest -> reply pin_duty.failed."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock(side_effect=BadRequest("Chat not found"))
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Failed"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Failed")
|
||||
mock_t.assert_called_with("en", "pin_duty.failed")
|
||||
mock_save.assert_not_called()
|
||||
mock_schedule.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not_pin():
|
||||
"""pin_duty_cmd: no pin record, pin_chat_message raises -> save pin, reply could_not_pin_make_admin."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 43
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Make me admin to pin"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=100, text="Duty", reply_markup=None
|
||||
)
|
||||
mock_save.assert_called_once_with(100, 43)
|
||||
update.message.reply_text.assert_called_once_with("Make me admin to pin")
|
||||
mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -345,10 +630,11 @@ async def test_pin_duty_cmd_pin_raises_replies_failed():
|
||||
)
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed to pin"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed to pin"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Failed to pin")
|
||||
mock_t.assert_called_with("en", "pin_duty.failed")
|
||||
|
||||
@@ -389,12 +675,13 @@ async def test_refresh_pin_cmd_group_updated_replies_updated():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Updated"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Updated"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Updated")
|
||||
mock_t.assert_called_with("en", "refresh_pin.updated")
|
||||
|
||||
@@ -412,12 +699,13 @@ async def test_refresh_pin_cmd_group_no_message_replies_no_message():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "No message"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "No message"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("No message")
|
||||
mock_t.assert_called_with("en", "refresh_pin.no_message")
|
||||
|
||||
@@ -435,16 +723,42 @@ async def test_refresh_pin_cmd_group_edit_raises_replies_failed():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Failed")
|
||||
mock_t.assert_called_with("en", "refresh_pin.failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_pin_cmd_untrusted_group_rejects():
|
||||
"""refresh_pin_cmd in untrusted group -> reply group.not_trusted, _refresh_pin_for_chat not called."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock()
|
||||
) as mock_refresh:
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Not authorized"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Not authorized")
|
||||
mock_t.assert_called_with("en", "group.not_trusted")
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
|
||||
# --- my_chat_member_handler ---
|
||||
|
||||
|
||||
@@ -493,12 +807,80 @@ async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules():
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty text"):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=200, text="Duty text")
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=200, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=200, message_id=42, disable_notification=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_chat_member_handler_untrusted_group_does_not_send_duty():
|
||||
"""my_chat_member_handler: bot added to untrusted group -> send group.not_trusted only, no duty message/pin/schedule."""
|
||||
update = _make_my_chat_member_update(
|
||||
old_status=ChatMemberStatus.LEFT,
|
||||
new_status=ChatMemberStatus.ADMINISTRATOR,
|
||||
chat_id=200,
|
||||
bot_id=999,
|
||||
)
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.id = 999
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Not authorized"
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=200, text="Not authorized")
|
||||
mock_t.assert_called_with("en", "group.not_trusted")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_chat_member_handler_trusted_group_sends_duty():
|
||||
"""my_chat_member_handler: bot added to trusted group -> send duty, pin, schedule (same as test_my_chat_member_handler_bot_added_sends_pins_and_schedules)."""
|
||||
update = _make_my_chat_member_update(
|
||||
old_status=ChatMemberStatus.LEFT,
|
||||
new_status=ChatMemberStatus.ADMINISTRATOR,
|
||||
chat_id=200,
|
||||
bot_id=999,
|
||||
)
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.id = 999
|
||||
context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=42))
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=200, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=200, message_id=42, disable_notification=True
|
||||
)
|
||||
@@ -524,13 +906,18 @@ async def test_my_chat_member_handler_pin_raises_sends_could_not_pin():
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Make me admin to pin"
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Make me admin to pin"
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
assert context.bot.send_message.call_count >= 2
|
||||
pin_hint_calls = [
|
||||
c
|
||||
@@ -565,8 +952,8 @@ async def test_my_chat_member_handler_bot_removed_deletes_pin_and_jobs():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
|
||||
"""restore_group_pin_jobs: for each chat_id from _get_all_pin_chat_ids_sync, calls _schedule_next_update."""
|
||||
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat_with_jitter():
|
||||
"""restore_group_pin_jobs: for each chat_id calls _schedule_next_update with jitter_seconds=60."""
|
||||
application = MagicMock()
|
||||
application.job_queue = MagicMock()
|
||||
application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
@@ -579,5 +966,198 @@ async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
|
||||
) as mock_schedule:
|
||||
await mod.restore_group_pin_jobs(application)
|
||||
assert mock_schedule.call_count == 2
|
||||
mock_schedule.assert_any_call(application, 10, None)
|
||||
mock_schedule.assert_any_call(application, 20, None)
|
||||
mock_schedule.assert_any_call(application, 10, None, jitter_seconds=60.0)
|
||||
mock_schedule.assert_any_call(application, 20, None, jitter_seconds=60.0)
|
||||
|
||||
|
||||
# --- _refresh_pin_for_chat untrusted ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_pin_for_chat_untrusted_removes_pin():
|
||||
"""_refresh_pin_for_chat: when group not trusted -> delete_pin, remove job, unpin/delete message, return untrusted."""
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
mock_job = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
|
||||
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=11):
|
||||
with patch.object(mod, "_sync_delete_pin") as mock_delete_pin:
|
||||
result = await mod._refresh_pin_for_chat(context, 100)
|
||||
assert result == "untrusted"
|
||||
mock_delete_pin.assert_called_once_with(100)
|
||||
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
|
||||
"duty_pin_100"
|
||||
)
|
||||
mock_job.schedule_removal.assert_called_once()
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=11)
|
||||
|
||||
|
||||
# --- trust_group_cmd / untrust_group_cmd ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trust_group_cmd_non_admin_rejects():
|
||||
"""trust_group_cmd: non-admin -> reply import.admin_only."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=False)):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Admin only"
|
||||
await mod.trust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Admin only")
|
||||
mock_t.assert_called_with("en", "import.admin_only")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trust_group_cmd_admin_adds_group():
|
||||
"""trust_group_cmd: admin in group, group not yet trusted -> _sync_trust_group, reply added, then send+pin if no pin."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 50
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_trust_group", return_value=False):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Added"
|
||||
with patch.object(
|
||||
config, "DUTY_PIN_NOTIFY", False
|
||||
):
|
||||
await mod.trust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_any_call("Added")
|
||||
mock_t.assert_any_call("en", "trust_group.added")
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=100, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=100, message_id=50, disable_notification=True
|
||||
)
|
||||
mock_save.assert_called_once_with(100, 50)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trust_group_cmd_admin_already_trusted_replies_already_trusted():
|
||||
"""trust_group_cmd: admin, group already trusted -> reply already_trusted, no send/pin."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_trust_group", return_value=True):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Already trusted"
|
||||
await mod.trust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Already trusted")
|
||||
mock_t.assert_called_with("en", "trust_group.already_trusted")
|
||||
context.bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_untrust_group_cmd_removes_group():
|
||||
"""untrust_group_cmd: admin, trusted group with pin -> remove from trusted, delete pin, remove job, unpin/delete message, reply removed."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
mock_job = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_untrust_group", return_value=(True, 99)):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Removed"
|
||||
await mod.untrust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Removed")
|
||||
mock_t.assert_called_with("en", "untrust_group.removed")
|
||||
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
|
||||
"duty_pin_100"
|
||||
)
|
||||
mock_job.schedule_removal.assert_called_once()
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=99)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_untrust_group_cmd_not_trusted_replies_not_trusted():
|
||||
"""untrust_group_cmd: group not in trusted list -> reply not_trusted."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_untrust_group", return_value=(False, None)):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Not trusted"
|
||||
await mod.untrust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Not trusted")
|
||||
mock_t.assert_called_with("en", "untrust_group.not_trusted")
|
||||
|
||||
@@ -278,8 +278,8 @@ async def test_handle_duty_schedule_document_non_json_replies_need_json():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_duty_schedule_document_parse_error_replies_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: parse_duty_schedule raises DutyScheduleParseError -> reply, clear user_data."""
|
||||
async def test_handle_duty_schedule_document_parse_error_replies_generic_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: DutyScheduleParseError -> reply generic message (no str(e)), clear user_data."""
|
||||
message = MagicMock()
|
||||
message.document = _make_document()
|
||||
message.reply_text = AsyncMock()
|
||||
@@ -299,17 +299,17 @@ async def test_handle_duty_schedule_document_parse_error_replies_and_clears_user
|
||||
with patch.object(mod, "parse_duty_schedule") as mock_parse:
|
||||
mock_parse.side_effect = DutyScheduleParseError("Bad JSON")
|
||||
with patch.object(mod, "t") as mock_t:
|
||||
mock_t.return_value = "Parse error: Bad JSON"
|
||||
mock_t.return_value = "The file could not be parsed."
|
||||
await mod.handle_duty_schedule_document(update, context)
|
||||
message.reply_text.assert_called_once_with("Parse error: Bad JSON")
|
||||
mock_t.assert_called_with("en", "import.parse_error", error="Bad JSON")
|
||||
message.reply_text.assert_called_once_with("The file could not be parsed.")
|
||||
mock_t.assert_called_with("en", "import.parse_error_generic")
|
||||
assert "awaiting_duty_schedule_file" not in context.user_data
|
||||
assert "handover_utc_time" not in context.user_data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_duty_schedule_document_import_error_replies_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: run_import in executor raises -> reply import_error, clear user_data."""
|
||||
async def test_handle_duty_schedule_document_import_error_replies_generic_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: run_import raises -> reply generic message (no str(e)), clear user_data."""
|
||||
message = MagicMock()
|
||||
message.document = _make_document()
|
||||
message.reply_text = AsyncMock()
|
||||
@@ -340,10 +340,10 @@ async def test_handle_duty_schedule_document_import_error_replies_and_clears_use
|
||||
with patch.object(mod, "run_import") as mock_run:
|
||||
mock_run.side_effect = ValueError("DB error")
|
||||
with patch.object(mod, "t") as mock_t:
|
||||
mock_t.return_value = "Import error: DB error"
|
||||
mock_t.return_value = "Import failed. Please try again."
|
||||
await mod.handle_duty_schedule_document(update, context)
|
||||
message.reply_text.assert_called_once_with("Import error: DB error")
|
||||
mock_t.assert_called_with("en", "import.import_error", error="DB error")
|
||||
message.reply_text.assert_called_once_with("Import failed. Please try again.")
|
||||
mock_t.assert_called_with("en", "import.import_error_generic")
|
||||
assert "awaiting_duty_schedule_file" not in context.user_data
|
||||
assert "handover_utc_time" not in context.user_data
|
||||
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
"""Unit tests for duty_teller.i18n: get_lang, t, fallback to en."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.i18n import get_lang, t
|
||||
|
||||
|
||||
def test_get_lang_none_returns_en():
|
||||
assert get_lang(None) == "en"
|
||||
def test_get_lang_always_returns_default_language():
|
||||
"""get_lang ignores user and always returns config.DEFAULT_LANGUAGE."""
|
||||
assert get_lang(None) == config.DEFAULT_LANGUAGE
|
||||
user_ru = MagicMock()
|
||||
user_ru.language_code = "ru"
|
||||
assert get_lang(user_ru) == config.DEFAULT_LANGUAGE
|
||||
user_en = MagicMock()
|
||||
user_en.language_code = "en"
|
||||
assert get_lang(user_en) == config.DEFAULT_LANGUAGE
|
||||
user_any = MagicMock(spec=[])
|
||||
assert get_lang(user_any) == config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def test_get_lang_ru_returns_ru():
|
||||
user = MagicMock()
|
||||
user.language_code = "ru"
|
||||
assert get_lang(user) == "ru"
|
||||
def test_get_lang_returns_ru_when_default_language_is_ru():
|
||||
"""When DEFAULT_LANGUAGE is ru, get_lang returns 'ru' regardless of user."""
|
||||
with patch("duty_teller.i18n.core.config") as mock_cfg:
|
||||
mock_cfg.DEFAULT_LANGUAGE = "ru"
|
||||
from duty_teller.i18n.core import get_lang as core_get_lang
|
||||
|
||||
assert core_get_lang(None) == "ru"
|
||||
user = MagicMock()
|
||||
user.language_code = "en"
|
||||
assert core_get_lang(user) == "ru"
|
||||
|
||||
|
||||
def test_get_lang_ru_ru_returns_ru():
|
||||
user = MagicMock()
|
||||
user.language_code = "ru-RU"
|
||||
assert get_lang(user) == "ru"
|
||||
def test_get_lang_returns_en_when_default_language_is_en():
|
||||
"""When DEFAULT_LANGUAGE is en, get_lang returns 'en' regardless of user."""
|
||||
with patch("duty_teller.i18n.core.config") as mock_cfg:
|
||||
mock_cfg.DEFAULT_LANGUAGE = "en"
|
||||
from duty_teller.i18n.core import get_lang as core_get_lang
|
||||
|
||||
|
||||
def test_get_lang_en_returns_en():
|
||||
user = MagicMock()
|
||||
user.language_code = "en"
|
||||
assert get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_get_lang_uk_returns_en():
|
||||
user = MagicMock()
|
||||
user.language_code = "uk"
|
||||
assert get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_get_lang_empty_returns_en():
|
||||
user = MagicMock()
|
||||
user.language_code = ""
|
||||
assert get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_get_lang_missing_attr_returns_en():
|
||||
user = MagicMock(spec=[]) # no language_code
|
||||
assert get_lang(user) == "en"
|
||||
assert core_get_lang(None) == "en"
|
||||
user = MagicMock()
|
||||
user.language_code = "ru"
|
||||
assert core_get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_t_en_start_greeting():
|
||||
|
||||
@@ -77,7 +77,7 @@ def test_import_creates_users_and_duties(db_url):
|
||||
assert "2026-02-16T06:00:00Z" in starts
|
||||
assert "2026-02-17T06:00:00Z" in starts
|
||||
assert "2026-02-18T06:00:00Z" in starts
|
||||
for d, _ in duties:
|
||||
for d, *_ in duties:
|
||||
assert d.event_type == "duty"
|
||||
|
||||
|
||||
|
||||
@@ -9,9 +9,12 @@ from duty_teller.db.repository import (
|
||||
delete_duties_in_range,
|
||||
get_duties,
|
||||
get_duties_for_user,
|
||||
get_duty_by_id,
|
||||
get_or_create_user,
|
||||
get_or_create_user_by_full_name,
|
||||
get_users_for_admin,
|
||||
insert_duty,
|
||||
update_duty_user,
|
||||
update_user_display_name,
|
||||
)
|
||||
|
||||
@@ -217,6 +220,52 @@ def test_get_or_create_user_keeps_name_when_flag_true_updates_username(session):
|
||||
assert u2.username == "new_username"
|
||||
|
||||
|
||||
def test_get_duty_by_id_returns_duty(session, user_a):
|
||||
"""get_duty_by_id returns the duty when it exists."""
|
||||
duty = insert_duty(
|
||||
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
|
||||
)
|
||||
found = get_duty_by_id(session, duty.id)
|
||||
assert found is not None
|
||||
assert found.id == duty.id
|
||||
assert found.user_id == user_a.id
|
||||
assert found.start_at == "2026-02-01T09:00:00Z"
|
||||
|
||||
|
||||
def test_get_duty_by_id_returns_none_when_missing(session):
|
||||
"""get_duty_by_id returns None for non-existent id."""
|
||||
assert get_duty_by_id(session, 99999) is None
|
||||
|
||||
|
||||
def test_update_duty_user_changes_user(session, user_a):
|
||||
"""update_duty_user updates user_id and returns the duty."""
|
||||
user_b = get_or_create_user_by_full_name(session, "User B")
|
||||
duty = insert_duty(
|
||||
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
|
||||
)
|
||||
updated = update_duty_user(session, duty.id, user_b.id, commit=True)
|
||||
assert updated is not None
|
||||
assert updated.id == duty.id
|
||||
assert updated.user_id == user_b.id
|
||||
session.refresh(duty)
|
||||
assert duty.user_id == user_b.id
|
||||
|
||||
|
||||
def test_update_duty_user_returns_none_when_duty_missing(session, user_a):
|
||||
"""update_duty_user returns None when duty does not exist."""
|
||||
assert update_duty_user(session, 99999, user_a.id, commit=True) is None
|
||||
|
||||
|
||||
def test_get_users_for_admin_returns_all_ordered_by_full_name(session, user_a):
|
||||
"""get_users_for_admin returns all users ordered by full_name."""
|
||||
get_or_create_user_by_full_name(session, "Alice")
|
||||
get_or_create_user_by_full_name(session, "Борис")
|
||||
users = get_users_for_admin(session)
|
||||
assert len(users) >= 3
|
||||
full_names = [u.full_name for u in users]
|
||||
assert full_names == sorted(full_names)
|
||||
|
||||
|
||||
def test_update_user_display_name_sets_flag_then_get_or_create_user_keeps_name(session):
|
||||
"""update_user_display_name sets name and flag; get_or_create_user then does not overwrite name."""
|
||||
get_or_create_user(
|
||||
|
||||
@@ -24,14 +24,23 @@ def test_main_builds_app_and_starts_thread():
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
|
||||
with patch("duty_teller.run.require_bot_token"):
|
||||
with patch("duty_teller.run.ApplicationBuilder", return_value=mock_builder):
|
||||
with patch("duty_teller.run.register_handlers") as mock_register:
|
||||
with patch("duty_teller.run.threading.Thread") as mock_thread_class:
|
||||
with patch("duty_teller.db.session.session_scope", mock_scope):
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_class.return_value = mock_thread
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
main()
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_SKIP_AUTH = False
|
||||
mock_cfg.HTTP_HOST = "127.0.0.1"
|
||||
mock_cfg.HTTP_PORT = 8080
|
||||
with patch("duty_teller.run.ApplicationBuilder", return_value=mock_builder):
|
||||
with patch("duty_teller.run.register_handlers") as mock_register:
|
||||
with patch("duty_teller.run.threading.Thread") as mock_thread_class:
|
||||
with patch(
|
||||
"duty_teller.run._wait_for_http_ready", return_value=True
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.db.session.session_scope", mock_scope
|
||||
):
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_class.return_value = mock_thread
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
main()
|
||||
mock_register.assert_called_once_with(mock_app)
|
||||
mock_builder.token.assert_called_once()
|
||||
mock_thread.start.assert_called_once()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Tests for duty_teller.api.telegram_auth.validate_init_data and validate_init_data_with_reason."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.api.telegram_auth import (
|
||||
validate_init_data,
|
||||
validate_init_data_with_reason,
|
||||
@@ -52,7 +55,7 @@ def test_user_without_username_returns_none_from_validate_init_data():
|
||||
|
||||
|
||||
def test_user_without_username_but_with_id_succeeds_with_reason():
|
||||
"""With validate_init_data_with_reason, valid user.id is enough; username may be None."""
|
||||
"""With validate_init_data_with_reason, valid user.id is enough; lang is DEFAULT_LANGUAGE."""
|
||||
bot_token = "123:ABC"
|
||||
user = {"id": 456, "first_name": "Test", "language_code": "ru"}
|
||||
init_data = make_init_data(user, bot_token)
|
||||
@@ -62,11 +65,11 @@ def test_user_without_username_but_with_id_succeeds_with_reason():
|
||||
assert telegram_user_id == 456
|
||||
assert username is None
|
||||
assert reason == "ok"
|
||||
assert lang == "ru"
|
||||
assert lang == config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def test_user_without_id_returns_no_user_id():
|
||||
"""When user object exists but has no 'id', return no_user_id."""
|
||||
"""When user object exists but has no 'id', return no_user_id; lang is DEFAULT_LANGUAGE."""
|
||||
bot_token = "123:ABC"
|
||||
user = {"first_name": "Test"} # no id
|
||||
init_data = make_init_data(user, bot_token)
|
||||
@@ -76,7 +79,17 @@ def test_user_without_id_returns_no_user_id():
|
||||
assert telegram_user_id is None
|
||||
assert username is None
|
||||
assert reason == "no_user_id"
|
||||
assert lang == "en"
|
||||
assert lang == config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def test_validate_init_data_with_reason_returns_default_language_ignoring_user_lang():
|
||||
"""Returned lang is always config.DEFAULT_LANGUAGE, not user.language_code."""
|
||||
with patch("duty_teller.api.telegram_auth.config.DEFAULT_LANGUAGE", "ru"):
|
||||
user = {"id": 1, "first_name": "U", "language_code": "en"}
|
||||
init_data = make_init_data(user, "123:ABC")
|
||||
_, _, reason, lang = validate_init_data_with_reason(init_data, "123:ABC")
|
||||
assert reason == "ok"
|
||||
assert lang == "ru"
|
||||
|
||||
|
||||
def test_empty_init_data_returns_none():
|
||||
|
||||
84
tests/test_trusted_groups_repository.py
Normal file
84
tests/test_trusted_groups_repository.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for trusted_groups repository functions."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from duty_teller.db.models import Base
|
||||
from duty_teller.db.repository import (
|
||||
is_trusted_group,
|
||||
add_trusted_group,
|
||||
remove_trusted_group,
|
||||
get_all_trusted_group_ids,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
"""In-memory SQLite session with all tables (including trusted_groups)."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:", connect_args={"check_same_thread": False}
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
s = Session()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_is_trusted_group_empty_returns_false(session):
|
||||
"""is_trusted_group returns False when no record exists."""
|
||||
assert is_trusted_group(session, 100) is False
|
||||
assert is_trusted_group(session, 200) is False
|
||||
|
||||
|
||||
def test_add_trusted_group_creates_record(session):
|
||||
"""add_trusted_group creates a record and returns TrustedGroup."""
|
||||
record = add_trusted_group(session, 100, added_by_user_id=12345)
|
||||
assert record.chat_id == 100
|
||||
assert record.added_by_user_id == 12345
|
||||
assert record.added_at is not None
|
||||
|
||||
|
||||
def test_is_trusted_group_after_add_returns_true(session):
|
||||
"""is_trusted_group returns True after add_trusted_group."""
|
||||
add_trusted_group(session, 100)
|
||||
assert is_trusted_group(session, 100) is True
|
||||
assert is_trusted_group(session, 101) is False
|
||||
|
||||
|
||||
def test_add_trusted_group_without_added_by_user_id(session):
|
||||
"""add_trusted_group accepts added_by_user_id None."""
|
||||
record = add_trusted_group(session, 200, added_by_user_id=None)
|
||||
assert record.chat_id == 200
|
||||
assert record.added_by_user_id is None
|
||||
|
||||
|
||||
def test_remove_trusted_group_removes_record(session):
|
||||
"""remove_trusted_group removes the record."""
|
||||
add_trusted_group(session, 100)
|
||||
assert is_trusted_group(session, 100) is True
|
||||
remove_trusted_group(session, 100)
|
||||
assert is_trusted_group(session, 100) is False
|
||||
|
||||
|
||||
def test_remove_trusted_group_idempotent(session):
|
||||
"""remove_trusted_group on non-existent chat_id does not raise."""
|
||||
remove_trusted_group(session, 999)
|
||||
|
||||
|
||||
def test_get_all_trusted_group_ids_empty(session):
|
||||
"""get_all_trusted_group_ids returns empty list when no trusted groups."""
|
||||
assert get_all_trusted_group_ids(session) == []
|
||||
|
||||
|
||||
def test_get_all_trusted_group_ids_returns_added_chats(session):
|
||||
"""get_all_trusted_group_ids returns all trusted chat_ids."""
|
||||
add_trusted_group(session, 10)
|
||||
add_trusted_group(session, 20)
|
||||
add_trusted_group(session, 30)
|
||||
ids = get_all_trusted_group_ids(session)
|
||||
assert set(ids) == {10, 20, 30}
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Unit tests for utils (dates, user, handover)."""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -62,6 +62,23 @@ def test_validate_date_range_from_after_to():
|
||||
assert exc_info.value.kind == "from_after_to"
|
||||
|
||||
|
||||
def test_validate_date_range_too_large():
|
||||
"""Range longer than MAX_DATE_RANGE_DAYS raises range_too_large."""
|
||||
from duty_teller.utils.dates import MAX_DATE_RANGE_DAYS
|
||||
|
||||
# from 2023-01-01 to 2025-06-01 is more than 731 days
|
||||
with pytest.raises(DateRangeValidationError) as exc_info:
|
||||
validate_date_range("2023-01-01", "2025-06-01")
|
||||
assert exc_info.value.kind == "range_too_large"
|
||||
|
||||
# Exactly MAX_DATE_RANGE_DAYS + 1 day
|
||||
from_d = date(2024, 1, 1)
|
||||
to_d = from_d + timedelta(days=MAX_DATE_RANGE_DAYS + 1)
|
||||
with pytest.raises(DateRangeValidationError) as exc_info:
|
||||
validate_date_range(from_d.isoformat(), to_d.isoformat())
|
||||
assert exc_info.value.kind == "range_too_large"
|
||||
|
||||
|
||||
# --- user ---
|
||||
|
||||
|
||||
|
||||
41
webapp-next/.gitignore
vendored
Normal file
41
webapp-next/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
webapp-next/README.md
Normal file
36
webapp-next/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
23
webapp-next/components.json
Normal file
23
webapp-next/components.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
18
webapp-next/eslint.config.mjs
Normal file
18
webapp-next/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
10
webapp-next/next.config.ts
Normal file
10
webapp-next/next.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
basePath: "/app",
|
||||
trailingSlash: true,
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
13495
webapp-next/package-lock.json
generated
Normal file
13495
webapp-next/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
webapp-next/package.json
Normal file
45
webapp-next/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "webapp-next",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@telegram-apps/sdk-react": "^3.3.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.576.0",
|
||||
"next": "16.1.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
7
webapp-next/postcss.config.mjs
Normal file
7
webapp-next/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
244
webapp-next/src/app/admin/page.test.tsx
Normal file
244
webapp-next/src/app/admin/page.test.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Component tests for admin page: render, access denied, duty list, reassign sheet.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import AdminPage from "./page";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||
useTelegramAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||||
}));
|
||||
|
||||
const mockUseTelegramAuth = vi.mocked(
|
||||
await import("@/hooks/use-telegram-auth").then((m) => m.useTelegramAuth)
|
||||
);
|
||||
|
||||
const sampleUsers = [
|
||||
{ id: 1, full_name: "Alice", username: "alice", role_id: 1 },
|
||||
{ id: 2, full_name: "Bob", username: null, role_id: 2 },
|
||||
];
|
||||
|
||||
const sampleDuties: Array<{
|
||||
id: number;
|
||||
user_id: number;
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
full_name: string;
|
||||
event_type: string;
|
||||
phone: string | null;
|
||||
username: string | null;
|
||||
}> = [
|
||||
{
|
||||
id: 10,
|
||||
user_id: 1,
|
||||
start_at: "2030-01-15T09:00:00Z",
|
||||
end_at: "2030-01-15T18:00:00Z",
|
||||
full_name: "Alice",
|
||||
event_type: "duty",
|
||||
phone: null,
|
||||
username: "alice",
|
||||
},
|
||||
];
|
||||
|
||||
function mockFetchForAdmin(
|
||||
users: Array<{ id: number; full_name: string; username: string | null; role_id?: number }> = sampleUsers,
|
||||
duties = sampleDuties,
|
||||
options?: { adminMe?: { is_admin: boolean } }
|
||||
) {
|
||||
const adminMe = options?.adminMe ?? { is_admin: true };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string, _init?: RequestInit) => {
|
||||
if (url.includes("/api/admin/me")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(adminMe),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/admin/users")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(users),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/duties")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(duties),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe("AdminPage", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
useAppStore.getState().setCurrentMonth(new Date(2025, 0, 1));
|
||||
mockUseTelegramAuth.mockReturnValue({
|
||||
initDataRaw: "test-init",
|
||||
startParam: undefined,
|
||||
isLocalhost: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows admin title (month/year) when allowed and data loaded", async () => {
|
||||
mockFetchForAdmin();
|
||||
render(<AdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: /admin|админка/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows access denied when fetchAdminMe returns is_admin false", async () => {
|
||||
mockFetchForAdmin(sampleUsers, [], { adminMe: { is_admin: false } });
|
||||
render(<AdminPage />);
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(
|
||||
screen.getByText(/Access only for administrators|Доступ только для администраторов/i)
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
});
|
||||
|
||||
it("shows access denied message when GET /api/admin/users returns 403", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string) => {
|
||||
if (url.includes("/api/admin/me")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ is_admin: true }),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/admin/users")) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ detail: "Admin only" }),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/duties")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected: ${url}`));
|
||||
})
|
||||
);
|
||||
render(<AdminPage />);
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(
|
||||
screen.getByText(/Admin only|Access only for administrators|Доступ только для администраторов/i)
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
});
|
||||
|
||||
it("shows duty row and opens reassign sheet on click", async () => {
|
||||
mockFetchForAdmin();
|
||||
render(<AdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
const dutyButton = screen.getByRole("button", { name: /Alice/ });
|
||||
fireEvent.click(dutyButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("radiogroup", { name: /select user|выберите пользователя/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /save|сохранить/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no users message in sheet when usersForSelect is empty", async () => {
|
||||
mockFetchForAdmin([], sampleDuties);
|
||||
render(<AdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/No users available for assignment|Нет пользователей для назначения/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success message after successful reassign", async () => {
|
||||
mockFetchForAdmin();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string, init?: RequestInit) => {
|
||||
if (url.includes("/api/admin/me")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ is_admin: true }),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/admin/users")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(sampleUsers),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/duties")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(sampleDuties),
|
||||
} as Response);
|
||||
}
|
||||
if (url.includes("/api/admin/duties/") && init?.method === "PATCH") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 10,
|
||||
user_id: 2,
|
||||
start_at: "2030-01-15T09:00:00Z",
|
||||
end_at: "2030-01-15T18:00:00Z",
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
})
|
||||
);
|
||||
render(<AdminPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("radiogroup", { name: /select user|выберите пользователя/i })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Bob/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /save|сохранить/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Duty reassigned|Дежурство переназначено/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
131
webapp-next/src/app/admin/page.tsx
Normal file
131
webapp-next/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Admin page: list duties for the month and reassign duty to another user.
|
||||
* Visible only to admins (link shown on calendar when GET /api/admin/me returns is_admin).
|
||||
* Logic and heavy UI live in components/admin (useAdminPage, AdminDutyList, ReassignSheet).
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { MonthNavHeader } from "@/components/calendar/MonthNavHeader";
|
||||
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||
import { LoadingState } from "@/components/states/LoadingState";
|
||||
import { ErrorState } from "@/components/states/ErrorState";
|
||||
import { MiniAppScreen, MiniAppScreenContent, MiniAppStickyHeader } from "@/components/layout/MiniAppScreen";
|
||||
import { useScreenReady } from "@/hooks/use-screen-ready";
|
||||
|
||||
export default function AdminPage() {
|
||||
const { t, monthName } = useTranslation();
|
||||
const admin = useAdminPage();
|
||||
useScreenReady(true);
|
||||
|
||||
if (!admin.isAllowed) {
|
||||
return (
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent>
|
||||
<AccessDeniedScreen primaryAction="reload" />
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
if (admin.adminCheckComplete === null) {
|
||||
return (
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent>
|
||||
<div className="py-4 flex flex-col items-center gap-2">
|
||||
<LoadingState />
|
||||
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
|
||||
</div>
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
if (admin.adminAccessDenied) {
|
||||
return (
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent>
|
||||
<div className="flex flex-col gap-4 py-6">
|
||||
<p className="text-muted-foreground">
|
||||
{admin.adminAccessDeniedDetail ?? t("admin.access_denied")}
|
||||
</p>
|
||||
</div>
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const month = admin.adminMonth.getMonth();
|
||||
const year = admin.adminMonth.getFullYear();
|
||||
|
||||
return (
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent>
|
||||
<MiniAppStickyHeader className="flex flex-col items-center border-b border-border py-3">
|
||||
<MonthNavHeader
|
||||
month={admin.adminMonth}
|
||||
disabled={admin.loading}
|
||||
onPrevMonth={admin.onPrevMonth}
|
||||
onNextMonth={admin.onNextMonth}
|
||||
titleAriaLabel={`${t("admin.title")}, ${monthName(month)} ${year}`}
|
||||
className="w-full px-1"
|
||||
/>
|
||||
</MiniAppStickyHeader>
|
||||
|
||||
{admin.successMessage && (
|
||||
<p className="mt-3 text-sm text-[var(--duty)]" role="status" aria-live="polite">
|
||||
{admin.successMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{admin.loading && (
|
||||
<div className="py-4 flex flex-col items-center gap-2">
|
||||
<LoadingState />
|
||||
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{admin.error && !admin.loading && (
|
||||
<ErrorState
|
||||
message={admin.error}
|
||||
onRetry={() => window.location.reload()}
|
||||
className="my-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!admin.loading && !admin.error && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{admin.visibleGroups.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.reassign_duty")}: {t("admin.select_user")}
|
||||
</p>
|
||||
)}
|
||||
<AdminDutyList
|
||||
groups={admin.visibleGroups}
|
||||
hasMore={admin.hasMore}
|
||||
sentinelRef={admin.sentinelRef}
|
||||
onSelectDuty={admin.openReassign}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReassignSheet
|
||||
open={!admin.sheetExiting && admin.selectedDuty !== null}
|
||||
selectedDuty={admin.selectedDuty}
|
||||
selectedUserId={admin.selectedUserId}
|
||||
setSelectedUserId={admin.setSelectedUserId}
|
||||
users={admin.usersForSelect}
|
||||
saving={admin.saving}
|
||||
reassignErrorKey={admin.reassignErrorKey}
|
||||
onReassign={admin.handleReassign}
|
||||
onRequestClose={admin.requestCloseSheet}
|
||||
onCloseAnimationEnd={admin.closeReassign}
|
||||
t={t}
|
||||
/>
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
);
|
||||
}
|
||||
BIN
webapp-next/src/app/favicon.ico
Normal file
BIN
webapp-next/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
55
webapp-next/src/app/global-error.tsx
Normal file
55
webapp-next/src/app/global-error.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Next.js root error boundary. Replaces the root layout when an unhandled error occurs.
|
||||
* Must define its own html/body. For most runtime errors the in-app AppErrorBoundary is used.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import "./globals.css";
|
||||
import { getLang, translate } from "@/i18n/messages";
|
||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const lang = getLang();
|
||||
|
||||
useEffect(() => {
|
||||
callMiniAppReadyOnce();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={lang === "ru" ? "ru" : "en"}
|
||||
data-theme="dark"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent className="items-center justify-center gap-4 px-4 text-foreground">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{translate(lang, "error_boundary.message")}
|
||||
</h1>
|
||||
<p className="text-center text-muted-foreground">
|
||||
{translate(lang, "error_boundary.description")}
|
||||
</p>
|
||||
<Button type="button" onClick={() => reset()}>
|
||||
{translate(lang, "error_boundary.reload")}
|
||||
</Button>
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
337
webapp-next/src/app/globals.css
Normal file
337
webapp-next/src/app/globals.css
Normal file
@@ -0,0 +1,337 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is([data-theme="dark"] *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
/* App design tokens (Telegram Mini App) */
|
||||
--color-surface: var(--surface);
|
||||
--color-duty: var(--duty);
|
||||
--color-today: var(--today);
|
||||
--color-unavailable: var(--unavailable);
|
||||
--color-vacation: var(--vacation);
|
||||
--color-error: var(--error);
|
||||
--max-width-app: 420px;
|
||||
}
|
||||
|
||||
/* App design tokens: use Telegram theme vars with dark fallbacks so TG vars apply before data-theme */
|
||||
:root {
|
||||
--bg: var(--tg-theme-bg-color, #17212b);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
|
||||
--text: var(--tg-theme-text-color, #f5f5f5);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #708499));
|
||||
--accent: var(--tg-theme-link-color, #6ab3f3);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
|
||||
--card: var(--tg-theme-section-bg-color, var(--surface));
|
||||
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
|
||||
--primary: var(--tg-theme-button-color, var(--accent));
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
|
||||
--error: var(--tg-theme-destructive-text-color, #e06c75);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
|
||||
--duty: #5c9b4a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #5a9bb8;
|
||||
--timeline-date-width: 3.6em;
|
||||
--timeline-track-width: 10px;
|
||||
/* Reusable color-mix tokens (avoid repeating in Tailwind classes). */
|
||||
--surface-hover: color-mix(in srgb, var(--accent) 15%, var(--surface));
|
||||
--surface-hover-10: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
--surface-today-tint: color-mix(in srgb, var(--today) 12%, var(--surface));
|
||||
--surface-muted-tint: color-mix(in srgb, var(--muted) 8%, var(--surface));
|
||||
--today-hover: color-mix(in srgb, var(--bg) 15%, var(--today));
|
||||
--today-border: color-mix(in srgb, var(--today) 35%, transparent);
|
||||
--today-border-selected: color-mix(in srgb, var(--bg) 50%, transparent);
|
||||
--today-gradient-end: color-mix(in srgb, var(--today) 15%, transparent);
|
||||
--muted-fade: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
--handle-bg: color-mix(in srgb, var(--muted) 80%, var(--text));
|
||||
--indicator-today-duty: color-mix(in srgb, var(--duty) 65%, var(--bg));
|
||||
--indicator-today-unavailable: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
|
||||
--indicator-today-vacation: color-mix(in srgb, var(--vacation) 65%, var(--bg));
|
||||
--indicator-today-events: color-mix(in srgb, var(--accent) 65%, var(--bg));
|
||||
--shadow-card: 0 4px 12px color-mix(in srgb, var(--text) 12%, transparent);
|
||||
--transition-fast: 0.15s;
|
||||
--transition-normal: 0.25s;
|
||||
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--radius: 0.625rem;
|
||||
--calendar-block-min-height: 260px;
|
||||
/** Safe-area insets for sticky headers and full-screen (Telegram viewport + env fallbacks). */
|
||||
--app-safe-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
|
||||
--app-safe-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
|
||||
--app-safe-left: var(--tg-viewport-content-safe-area-inset-left, env(safe-area-inset-left, 0));
|
||||
--app-safe-right: var(--tg-viewport-content-safe-area-inset-right, env(safe-area-inset-right, 0));
|
||||
/** Minimum height for the 6-row calendar grid so cells stay comfortably large. */
|
||||
--calendar-grid-min-height: 264px;
|
||||
/** Minimum height per calendar row (6 rows × 44px ≈ 264px). */
|
||||
--calendar-row-min-height: 2.75rem;
|
||||
/* Align Tailwind/shadcn semantic tokens with app tokens for Mini App */
|
||||
--background: var(--bg);
|
||||
--foreground: var(--text);
|
||||
--card-foreground: var(--text);
|
||||
--popover: var(--surface);
|
||||
--popover-foreground: var(--text);
|
||||
--secondary: var(--surface);
|
||||
--secondary-foreground: var(--text);
|
||||
--muted-foreground: var(--muted);
|
||||
--accent-foreground: var(--bg);
|
||||
--destructive: var(--error);
|
||||
--input: color-mix(in srgb, var(--text) 15%, transparent);
|
||||
--ring: var(--accent);
|
||||
--chart-1: var(--duty);
|
||||
--chart-2: var(--vacation);
|
||||
--chart-3: var(--unavailable);
|
||||
--chart-4: var(--today);
|
||||
--chart-5: var(--accent);
|
||||
}
|
||||
|
||||
/* Light theme: full Telegram themeParams (14 params) mapping */
|
||||
[data-theme="light"] {
|
||||
--bg: var(--tg-theme-bg-color, #f0f1f3);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #e0e2e6);
|
||||
--text: var(--tg-theme-text-color, #343b58);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #6b7089));
|
||||
--accent: var(--tg-theme-link-color, #2e7de0);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #e0e2e6);
|
||||
--card: var(--tg-theme-section-bg-color, #e0e2e6);
|
||||
--section-header: var(--tg-theme-section-header-text-color, #343b58);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 15%, transparent));
|
||||
--primary: var(--tg-theme-button-color, #2e7de0);
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #ffffff);
|
||||
--error: var(--tg-theme-destructive-text-color, #c43b3b);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #2481cc);
|
||||
--duty: #587d0a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #2481cc));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #0d6b9e;
|
||||
}
|
||||
|
||||
/* Dark theme: full Telegram themeParams (14 params) mapping */
|
||||
[data-theme="dark"] {
|
||||
--bg: var(--tg-theme-bg-color, #17212b);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
|
||||
--text: var(--tg-theme-text-color, #f5f5f5);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #708499));
|
||||
--accent: var(--tg-theme-link-color, #6ab3f3);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
|
||||
--card: var(--tg-theme-section-bg-color, #232e3c);
|
||||
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
|
||||
--primary: var(--tg-theme-button-color, #6ab3f3);
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
|
||||
--error: var(--tg-theme-destructive-text-color, #e06c75);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
|
||||
--duty: #5c9b4a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #5a9bb8;
|
||||
}
|
||||
|
||||
/* === Layout & base (ported from webapp/css/base.css) */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Container: max-width, padding, safe area. Use .container for main wrapper if needed. */
|
||||
.container-app {
|
||||
max-width: var(--max-width-app, 420px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 12px;
|
||||
padding-top: 0;
|
||||
padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 12px));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Duty list: timeline date cell (non-today) — horizontal line and vertical tick to track */
|
||||
.duty-timeline-date:not(.duty-timeline-date--today)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 4px;
|
||||
width: calc(100% + var(--timeline-track-width) / 2);
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--muted-fade) 0%,
|
||||
var(--muted-fade) 50%,
|
||||
var(--muted) 70%,
|
||||
var(--muted) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.duty-timeline-date:not(.duty-timeline-date--today)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Duty list: timeline date cell (today) — horizontal stripe + vertical tick in today color (same geometry as non-today) */
|
||||
.duty-timeline-date--today::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 5px;
|
||||
width: calc(100% + var(--timeline-track-width) / 2);
|
||||
height: 1px;
|
||||
background: var(--today);
|
||||
}
|
||||
|
||||
.duty-timeline-date--today::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
height: 7px;
|
||||
background: var(--today);
|
||||
}
|
||||
|
||||
/* Duty list: current duty card — ensure left stripe uses --today (matches "Today" label and date stripe) */
|
||||
[data-current-duty] .border-l-today {
|
||||
border-left-color: var(--today);
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
/* Duty list: flip card (front = duty info, back = contacts) */
|
||||
.duty-flip-card {
|
||||
perspective: 600px;
|
||||
}
|
||||
.duty-flip-inner {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.duty-flip-front {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
.duty-flip-back {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Android low-performance devices: minimize animations (Telegram User-Agent). */
|
||||
[data-perf="low"] *,
|
||||
[data-perf="low"] *::before,
|
||||
[data-perf="low"] *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
/* Safe area for Telegram Mini App (notch / status bar). */
|
||||
.pt-safe {
|
||||
padding-top: var(--app-safe-top);
|
||||
}
|
||||
|
||||
/* Content safe area: top/bottom/left/right so content and sticky chrome sit below Telegram UI.
|
||||
Horizontal padding has a minimum of 0.75rem (12px) when safe insets are zero. */
|
||||
.content-safe {
|
||||
padding-top: var(--app-safe-top);
|
||||
padding-bottom: var(--app-safe-bottom);
|
||||
padding-left: max(var(--app-safe-left), 0.75rem);
|
||||
padding-right: max(var(--app-safe-right), 0.75rem);
|
||||
}
|
||||
|
||||
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
||||
.sticky.is-scrolled {
|
||||
box-shadow: 0 1px 0 0 var(--border);
|
||||
}
|
||||
|
||||
/* Calendar grid: 6 rows with minimum height so cells stay large (restore pre-audit look). */
|
||||
.calendar-grid {
|
||||
grid-template-rows: repeat(6, minmax(var(--calendar-row-min-height), 1fr));
|
||||
}
|
||||
|
||||
/* Current duty card: entrance animation (respects prefers-reduced-motion via global rule). */
|
||||
@keyframes card-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.current-duty-card {
|
||||
box-shadow: var(--shadow-card);
|
||||
animation: card-appear 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.current-duty-card--no-duty {
|
||||
border-top-color: var(--muted);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: var(--tg-viewport-stable-height, 100vh);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
47
webapp-next/src/app/layout.tsx
Normal file
47
webapp-next/src/app/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { TelegramProvider } from "@/components/providers/TelegramProvider";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
|
||||
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Duty Teller",
|
||||
description: "Team duty shift calendar and reminders",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
|
||||
}}
|
||||
/>
|
||||
<script src="/app/config.js" />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<TelegramProvider>
|
||||
<AppErrorBoundary>
|
||||
<TooltipProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</TooltipProvider>
|
||||
</AppErrorBoundary>
|
||||
</TelegramProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
30
webapp-next/src/app/not-found.tsx
Normal file
30
webapp-next/src/app/not-found.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Next.js 404 page. Shown when notFound() is called or route is unknown.
|
||||
* For static export with a single route this is rarely hit; added for consistency.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
|
||||
import { useScreenReady } from "@/hooks/use-screen-ready";
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
useScreenReady(true);
|
||||
|
||||
return (
|
||||
<FullScreenStateShell
|
||||
title={t("not_found.title")}
|
||||
description={t("not_found.description")}
|
||||
primaryAction={
|
||||
<Button asChild>
|
||||
<Link href="/">{t("not_found.open_calendar")}</Link>
|
||||
</Button>
|
||||
}
|
||||
role="status"
|
||||
/>
|
||||
);
|
||||
}
|
||||
65
webapp-next/src/app/page.test.tsx
Normal file
65
webapp-next/src/app/page.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Integration test for main page: calendar and header visible, lang from store.
|
||||
* Ported from webapp/js/main.test.js applyLangToUi.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import Page from "./page";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||
useTelegramAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-month-data", () => ({
|
||||
useMonthData: () => ({
|
||||
retry: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Page", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
vi.mocked(useTelegramAuth).mockReturnValue({
|
||||
initDataRaw: "test-init",
|
||||
startParam: undefined,
|
||||
isLocalhost: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders calendar and header when store has default state", async () => {
|
||||
render(<Page />);
|
||||
expect(await screen.findByRole("grid", { name: "Calendar" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /previous month/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders AccessDeniedScreen when not allowed and delay has passed", async () => {
|
||||
const { RETRY_DELAY_MS } = await import("@/lib/constants");
|
||||
vi.mocked(useTelegramAuth).mockReturnValue({
|
||||
initDataRaw: undefined,
|
||||
startParam: undefined,
|
||||
isLocalhost: false,
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
render(<Page />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(RETRY_DELAY_MS);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
expect(
|
||||
await screen.findByText(/Access denied|Доступ запрещён/i, {}, { timeout: 2000 })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Reload|Обновить/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("grid", { name: "Calendar" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
83
webapp-next/src/app/page.tsx
Normal file
83
webapp-next/src/app/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Main Mini App page: current duty deep link or calendar view.
|
||||
* Delegates to CurrentDutyView or CalendarPage; runs theme and app init.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useAppStore, type AppState } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
import { useAppInit } from "@/hooks/use-app-init";
|
||||
import { fetchAdminMe } from "@/lib/api";
|
||||
import { getLang } from "@/i18n/messages";
|
||||
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
|
||||
import { CalendarPage } from "@/components/CalendarPage";
|
||||
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
|
||||
import { useScreenReady } from "@/hooks/use-screen-ready";
|
||||
|
||||
export default function Home() {
|
||||
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
|
||||
const isAllowed = isLocalhost || !!initDataRaw;
|
||||
|
||||
useAppInit({ isAllowed, startParam });
|
||||
|
||||
const setIsAdmin = useAppStore((s) => s.setIsAdmin);
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw) {
|
||||
setIsAdmin(false);
|
||||
return;
|
||||
}
|
||||
fetchAdminMe(initDataRaw, getLang()).then(({ is_admin }) => setIsAdmin(is_admin));
|
||||
}, [isAllowed, initDataRaw, setIsAdmin]);
|
||||
|
||||
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
|
||||
useAppStore(
|
||||
useShallow((s: AppState) => ({
|
||||
accessDenied: s.accessDenied,
|
||||
currentView: s.currentView,
|
||||
setCurrentView: s.setCurrentView,
|
||||
setSelectedDay: s.setSelectedDay,
|
||||
appContentReady: s.appContentReady,
|
||||
}))
|
||||
);
|
||||
|
||||
useScreenReady(accessDenied || currentView === "currentDuty");
|
||||
|
||||
const handleBackFromCurrentDuty = useCallback(() => {
|
||||
setCurrentView("calendar");
|
||||
setSelectedDay(null);
|
||||
}, [setCurrentView, setSelectedDay]);
|
||||
|
||||
const content = accessDenied ? (
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent>
|
||||
<AccessDeniedScreen primaryAction="reload" />
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
) : currentView === "currentDuty" ? (
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent>
|
||||
<CurrentDutyView
|
||||
onBack={handleBackFromCurrentDuty}
|
||||
openedFromPin={startParam === "duty"}
|
||||
/>
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
) : (
|
||||
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-[var(--tg-viewport-stable-height,100vh)]"
|
||||
style={{
|
||||
visibility: appContentReady ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
71
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Error boundary that catches render errors in the app tree and shows a fallback
|
||||
* with a reload option. Uses pure i18n (getLang/translate) so it does not depend
|
||||
* on React context that might be broken.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { getLang, translate } from "@/i18n/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
|
||||
|
||||
interface AppErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface AppErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches JavaScript errors in the child tree and renders a fallback UI
|
||||
* instead of crashing. Provides a Reload button to recover.
|
||||
*/
|
||||
export class AppErrorBoundary extends React.Component<
|
||||
AppErrorBoundaryProps,
|
||||
AppErrorBoundaryState
|
||||
> {
|
||||
constructor(props: AppErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): AppErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
if (typeof console !== "undefined" && console.error) {
|
||||
console.error("AppErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
const lang = getLang();
|
||||
const message = translate(lang, "error_boundary.message");
|
||||
const description = translate(lang, "error_boundary.description");
|
||||
const reloadLabel = translate(lang, "error_boundary.reload");
|
||||
return (
|
||||
<FullScreenStateShell
|
||||
title={message}
|
||||
description={description}
|
||||
primaryAction={
|
||||
<Button type="button" variant="default" onClick={this.handleReload}>
|
||||
{reloadLabel}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
17
webapp-next/src/components/AppShell.tsx
Normal file
17
webapp-next/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* App shell: wraps children with ReadyGate so any route can trigger miniAppReady().
|
||||
* Rendered inside TelegramProvider so theme and SDK are available.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { ReadyGate } from "@/components/ReadyGate";
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<ReadyGate />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
181
webapp-next/src/components/CalendarPage.tsx
Normal file
181
webapp-next/src/components/CalendarPage.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Calendar view layout: header, grid, duty list, day detail.
|
||||
* Composes calendar UI and owns sticky scroll, swipe, month data, and day-detail ref.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMonthData } from "@/hooks/use-month-data";
|
||||
import { useSwipe } from "@/hooks/use-swipe";
|
||||
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
|
||||
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
|
||||
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
|
||||
import { DutyList } from "@/components/duty/DutyList";
|
||||
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
|
||||
import { ErrorState } from "@/components/states/ErrorState";
|
||||
import { MiniAppScreen, MiniAppScreenContent, MiniAppStickyHeader } from "@/components/layout/MiniAppScreen";
|
||||
import { useTelegramSettingsButton, useTelegramVerticalSwipePolicy } from "@/hooks/telegram";
|
||||
import { DISABLE_VERTICAL_SWIPES_BY_DEFAULT } from "@/lib/telegram-interaction-policy";
|
||||
import { useScreenReady } from "@/hooks/use-screen-ready";
|
||||
|
||||
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
|
||||
const STICKY_HEIGHT_FALLBACK_PX = 268;
|
||||
|
||||
export interface CalendarPageProps {
|
||||
/** Whether the user is allowed (for data loading). */
|
||||
isAllowed: boolean;
|
||||
/** Raw initData string for API auth. */
|
||||
initDataRaw: string | undefined;
|
||||
}
|
||||
|
||||
export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
||||
const dayDetailRef = useRef<DayDetailHandle>(null);
|
||||
const calendarStickyRef = useRef<HTMLDivElement>(null);
|
||||
const [stickyBlockHeight, setStickyBlockHeight] = useState(STICKY_HEIGHT_FALLBACK_PX);
|
||||
|
||||
useEffect(() => {
|
||||
const el = calendarStickyRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setStickyBlockHeight(entry.contentRect.height);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
currentMonth,
|
||||
pendingMonth,
|
||||
loading,
|
||||
error,
|
||||
accessDenied,
|
||||
duties,
|
||||
calendarEvents,
|
||||
selectedDay,
|
||||
isAdmin,
|
||||
nextMonth,
|
||||
prevMonth,
|
||||
setCurrentMonth,
|
||||
setSelectedDay,
|
||||
} = useAppStore(
|
||||
useShallow((s) => ({
|
||||
currentMonth: s.currentMonth,
|
||||
pendingMonth: s.pendingMonth,
|
||||
loading: s.loading,
|
||||
error: s.error,
|
||||
accessDenied: s.accessDenied,
|
||||
duties: s.duties,
|
||||
calendarEvents: s.calendarEvents,
|
||||
selectedDay: s.selectedDay,
|
||||
isAdmin: s.isAdmin,
|
||||
nextMonth: s.nextMonth,
|
||||
prevMonth: s.prevMonth,
|
||||
setCurrentMonth: s.setCurrentMonth,
|
||||
setSelectedDay: s.setSelectedDay,
|
||||
}))
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { retry } = useMonthData({
|
||||
initDataRaw,
|
||||
enabled: isAllowed,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const isCurrentMonth =
|
||||
currentMonth.getFullYear() === now.getFullYear() &&
|
||||
currentMonth.getMonth() === now.getMonth();
|
||||
useAutoRefresh(retry, isCurrentMonth);
|
||||
|
||||
const navDisabled = loading || accessDenied || selectedDay !== null;
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
if (navDisabled) return;
|
||||
prevMonth();
|
||||
}, [navDisabled, prevMonth]);
|
||||
const handleNextMonth = useCallback(() => {
|
||||
if (navDisabled) return;
|
||||
nextMonth();
|
||||
}, [navDisabled, nextMonth]);
|
||||
|
||||
useSwipe(
|
||||
calendarStickyRef,
|
||||
handleNextMonth,
|
||||
handlePrevMonth,
|
||||
{ threshold: 50, disabled: navDisabled }
|
||||
);
|
||||
useStickyScroll(calendarStickyRef);
|
||||
useTelegramVerticalSwipePolicy(DISABLE_VERTICAL_SWIPES_BY_DEFAULT);
|
||||
|
||||
const handleDayClick = useCallback(
|
||||
(dateKey: string, anchorRect: DOMRect) => {
|
||||
const [y, m] = dateKey.split("-").map(Number);
|
||||
if (
|
||||
y !== currentMonth.getFullYear() ||
|
||||
m !== currentMonth.getMonth() + 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
dayDetailRef.current?.openWithRect(dateKey, anchorRect);
|
||||
},
|
||||
[currentMonth]
|
||||
);
|
||||
|
||||
const handleCloseDayDetail = useCallback(() => {
|
||||
setSelectedDay(null);
|
||||
}, [setSelectedDay]);
|
||||
|
||||
useScreenReady(!loading || accessDenied);
|
||||
|
||||
useTelegramSettingsButton({
|
||||
enabled: isAdmin,
|
||||
onClick: () => router.push("/admin"),
|
||||
});
|
||||
|
||||
return (
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent>
|
||||
<MiniAppStickyHeader
|
||||
ref={calendarStickyRef}
|
||||
className="min-h-[var(--calendar-block-min-height)] pb-2 touch-pan-y"
|
||||
>
|
||||
<CalendarHeader
|
||||
month={currentMonth}
|
||||
disabled={navDisabled}
|
||||
onPrevMonth={handlePrevMonth}
|
||||
onNextMonth={handleNextMonth}
|
||||
/>
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={duties}
|
||||
calendarEvents={calendarEvents}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
</MiniAppStickyHeader>
|
||||
|
||||
{error && (
|
||||
<ErrorState message={error} onRetry={retry} className="my-3" />
|
||||
)}
|
||||
{!error && (
|
||||
<DutyList
|
||||
scrollMarginTop={stickyBlockHeight}
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DayDetail
|
||||
ref={dayDetailRef}
|
||||
duties={duties}
|
||||
calendarEvents={calendarEvents}
|
||||
onClose={handleCloseDayDetail}
|
||||
/>
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
);
|
||||
}
|
||||
23
webapp-next/src/components/ReadyGate.tsx
Normal file
23
webapp-next/src/components/ReadyGate.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Route-agnostic gate: when appContentReady becomes true, calls miniAppReady() once
|
||||
* so Telegram hides its native loader. Used in layout so any route (/, /admin, not-found,
|
||||
* error) can trigger ready when its first screen is shown.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||
|
||||
export function ReadyGate() {
|
||||
const appContentReady = useAppStore((s) => s.appContentReady);
|
||||
|
||||
useEffect(() => {
|
||||
if (appContentReady) {
|
||||
callMiniAppReadyOnce();
|
||||
}
|
||||
}, [appContentReady]);
|
||||
|
||||
return null;
|
||||
}
|
||||
120
webapp-next/src/components/admin/AdminDutyList.tsx
Normal file
120
webapp-next/src/components/admin/AdminDutyList.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Admin duty list: visible duties with infinite-scroll sentinel. Presentational only.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { localDateString, formatHHMM, dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AdminDutyGroup {
|
||||
dateKey: string;
|
||||
duties: DutyWithUser[];
|
||||
}
|
||||
|
||||
/** Empty state when there are no duties: message and "Back to calendar" CTA. */
|
||||
function AdminEmptyState({
|
||||
t,
|
||||
}: {
|
||||
t: (key: string, params?: Record<string, string>) => string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||
<h2 className="text-[1.1rem] font-semibold leading-tight m-0">
|
||||
{t("admin.no_duties")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground m-0 max-w-[280px]">
|
||||
{t("duty.none_this_month_hint")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => router.push("/")}
|
||||
className="bg-surface text-accent hover:bg-[var(--surface-hover)]"
|
||||
>
|
||||
{t("admin.back_to_calendar")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AdminDutyListProps {
|
||||
/** Duty groups by date (already sliced to visibleCount by parent). */
|
||||
groups: AdminDutyGroup[];
|
||||
/** Whether there are more items; when true, sentinel is rendered for intersection observer. */
|
||||
hasMore: boolean;
|
||||
/** Ref for the sentinel element (infinite scroll). */
|
||||
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Called when user selects a duty to reassign. */
|
||||
onSelectDuty: (duty: DutyWithUser) => void;
|
||||
/** Translation function. */
|
||||
t: (key: string, params?: Record<string, string>) => string;
|
||||
}
|
||||
|
||||
export function AdminDutyList({
|
||||
groups,
|
||||
hasMore,
|
||||
sentinelRef,
|
||||
onSelectDuty,
|
||||
t,
|
||||
}: AdminDutyListProps) {
|
||||
if (groups.length === 0) {
|
||||
return <AdminEmptyState t={t} />;
|
||||
}
|
||||
|
||||
const todayKey = localDateString(new Date());
|
||||
return (
|
||||
<div className="flex flex-col gap-3" role="list" aria-label={t("admin.list_aria")}>
|
||||
{groups.map(({ dateKey, duties }) => {
|
||||
const isToday = dateKey === todayKey;
|
||||
const dateLabel = isToday ? t("duty.today") : dateKeyToDDMM(dateKey);
|
||||
return (
|
||||
<section
|
||||
key={dateKey}
|
||||
className="flex flex-col gap-1.5"
|
||||
aria-label={t("admin.section_aria", { date: dateLabel })}
|
||||
>
|
||||
<ul className="flex flex-col gap-1.5 list-none m-0 p-0">
|
||||
{duties.map((duty) => {
|
||||
const dateStr = localDateString(new Date(duty.start_at));
|
||||
const timeStr = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`;
|
||||
return (
|
||||
<li key={duty.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectDuty(duty)}
|
||||
aria-label={t("admin.reassign_aria", {
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
name: duty.full_name,
|
||||
})}
|
||||
className={cn(
|
||||
"w-full min-h-0 rounded-lg border-l-[3px] bg-surface px-2.5 py-2 shadow-sm text-left text-sm transition-colors",
|
||||
"hover:bg-[var(--surface-hover)] focus-visible:outline-accent flex flex-col gap-0.5",
|
||||
isToday ? "border-l-today bg-[var(--surface-today-tint)]" : "border-l-duty"
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
<span className="font-medium">{dateStr}</span>
|
||||
<span className="mx-2 text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground">{timeStr}</span>
|
||||
</span>
|
||||
<span className="font-semibold">{duty.full_name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="h-2" data-sentinel aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
webapp-next/src/components/admin/ReassignSheet.tsx
Normal file
197
webapp-next/src/components/admin/ReassignSheet.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Bottom sheet for reassigning a duty to another user. Uses design tokens and safe area.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import type { UserForAdmin } from "@/lib/api";
|
||||
import { localDateString, formatHHMM, dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ReassignSheetProps {
|
||||
/** Whether the sheet is open (and not in exiting state). */
|
||||
open: boolean;
|
||||
/** Selected duty to reassign; when null, content may still render with previous duty during close. */
|
||||
selectedDuty: DutyWithUser | null;
|
||||
/** Current selected user id for the select. */
|
||||
selectedUserId: number | "";
|
||||
/** Called when user changes selection. */
|
||||
setSelectedUserId: (value: number | "") => void;
|
||||
/** Users to show in the dropdown (role_id 1 or 2). */
|
||||
users: UserForAdmin[];
|
||||
/** Reassign request in progress. */
|
||||
saving: boolean;
|
||||
/** i18n key for error message from last reassign attempt; null when no error. */
|
||||
reassignErrorKey: string | null;
|
||||
/** Called when user confirms reassign. */
|
||||
onReassign: () => void;
|
||||
/** Called when user requests close (start close animation). */
|
||||
onRequestClose: () => void;
|
||||
/** Called when close animation ends (clear state). */
|
||||
onCloseAnimationEnd: () => void;
|
||||
/** Translation function. */
|
||||
t: (key: string, params?: Record<string, string>) => string;
|
||||
}
|
||||
|
||||
export function ReassignSheet({
|
||||
open,
|
||||
selectedDuty,
|
||||
selectedUserId,
|
||||
setSelectedUserId,
|
||||
users,
|
||||
saving,
|
||||
reassignErrorKey,
|
||||
onReassign,
|
||||
onRequestClose,
|
||||
onCloseAnimationEnd,
|
||||
t,
|
||||
}: ReassignSheetProps) {
|
||||
const todayKey = localDateString(new Date());
|
||||
const dateKey = selectedDuty
|
||||
? localDateString(new Date(selectedDuty.start_at))
|
||||
: "";
|
||||
const sheetTitle = selectedDuty
|
||||
? dateKey === todayKey
|
||||
? t("duty.today") + ", " + dateKeyToDDMM(dateKey)
|
||||
: dateKeyToDDMM(dateKey)
|
||||
: t("admin.reassign_duty");
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onRequestClose()}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="flex max-h-[85vh] flex-col rounded-t-2xl bg-[var(--surface)] p-0 pt-3"
|
||||
overlayClassName="backdrop-blur-md"
|
||||
showCloseButton={false}
|
||||
closeLabel={t("day_detail.close")}
|
||||
onCloseAnimationEnd={onCloseAnimationEnd}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto px-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
|
||||
onClick={onRequestClose}
|
||||
aria-label={t("day_detail.close")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</Button>
|
||||
<div
|
||||
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
|
||||
aria-hidden
|
||||
/>
|
||||
<SheetHeader className="p-0">
|
||||
<SheetTitle>{sheetTitle}</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("admin.select_user")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedDuty && (
|
||||
<div className="flex flex-col gap-4 pt-2 pb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatHHMM(selectedDuty.start_at)} – {formatHHMM(selectedDuty.end_at)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.current_assignee")}: {selectedDuty.full_name}
|
||||
</p>
|
||||
{users.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.no_users_for_assign")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.select_user")}
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-col gap-1 max-h-[40vh] overflow-y-auto rounded-lg border border-border py-1"
|
||||
role="radiogroup"
|
||||
aria-label={t("admin.select_user")}
|
||||
>
|
||||
{users.map((u) => {
|
||||
const isCurrent = u.id === selectedDuty.user_id;
|
||||
const isSelected = selectedUserId === u.id;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
disabled={saving}
|
||||
onClick={() => setSelectedUserId(u.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-0.5 rounded-md px-3 py-2.5 text-left text-sm transition-colors",
|
||||
"hover:bg-[var(--surface-hover)] focus-visible:outline-accent focus-visible:ring-2 focus-visible:ring-accent",
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
isSelected && "ring-2 ring-accent ring-inset"
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{u.full_name}
|
||||
{isCurrent && (
|
||||
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
||||
({t("admin.current_assignee")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{u.username && (
|
||||
<span className="text-xs text-muted-foreground">@{u.username}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{reassignErrorKey && (
|
||||
<p id="admin-reassign-error" className="text-sm text-destructive" role="alert">
|
||||
{t(reassignErrorKey)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SheetFooter className="flex-shrink-0 flex-row justify-end gap-2 border-t border-border bg-[var(--surface)] px-4 py-3 pb-[calc(12px+var(--app-safe-bottom,env(safe-area-inset-bottom,0px)))]">
|
||||
<Button
|
||||
onClick={onReassign}
|
||||
disabled={
|
||||
saving ||
|
||||
selectedUserId === "" ||
|
||||
selectedUserId === selectedDuty?.user_id ||
|
||||
users.length === 0
|
||||
}
|
||||
aria-describedby={reassignErrorKey ? "admin-reassign-error" : undefined}
|
||||
>
|
||||
{saving ? t("loading") : t("admin.save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
5
webapp-next/src/components/admin/hooks/index.ts
Normal file
5
webapp-next/src/components/admin/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./use-admin-access";
|
||||
export * from "./use-admin-users";
|
||||
export * from "./use-admin-duties";
|
||||
export * from "./use-infinite-duty-groups";
|
||||
export * from "./use-admin-reassign";
|
||||
44
webapp-next/src/components/admin/hooks/use-admin-access.ts
Normal file
44
webapp-next/src/components/admin/hooks/use-admin-access.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchAdminMe } from "@/lib/api";
|
||||
|
||||
export interface UseAdminAccessOptions {
|
||||
isAllowed: boolean;
|
||||
initDataRaw: string | undefined;
|
||||
lang: "ru" | "en";
|
||||
}
|
||||
|
||||
export function useAdminAccess({ isAllowed, initDataRaw, lang }: UseAdminAccessOptions) {
|
||||
const [adminCheckComplete, setAdminCheckComplete] = useState<boolean | null>(null);
|
||||
const [adminAccessDenied, setAdminAccessDenied] = useState(false);
|
||||
const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw) return;
|
||||
setAdminCheckComplete(null);
|
||||
setAdminAccessDenied(false);
|
||||
setAdminAccessDeniedDetail(null);
|
||||
fetchAdminMe(initDataRaw, lang)
|
||||
.then(({ is_admin }) => {
|
||||
if (!is_admin) {
|
||||
setAdminAccessDenied(true);
|
||||
setAdminCheckComplete(false);
|
||||
return;
|
||||
}
|
||||
setAdminCheckComplete(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setAdminAccessDenied(true);
|
||||
setAdminCheckComplete(false);
|
||||
});
|
||||
}, [isAllowed, initDataRaw, lang]);
|
||||
|
||||
return {
|
||||
adminCheckComplete,
|
||||
adminAccessDenied,
|
||||
adminAccessDeniedDetail,
|
||||
setAdminAccessDenied,
|
||||
setAdminAccessDeniedDetail,
|
||||
};
|
||||
}
|
||||
49
webapp-next/src/components/admin/hooks/use-admin-duties.ts
Normal file
49
webapp-next/src/components/admin/hooks/use-admin-duties.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { fetchDuties } from "@/lib/api";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { firstDayOfMonth, lastDayOfMonth, localDateString } from "@/lib/date-utils";
|
||||
|
||||
export interface UseAdminDutiesOptions {
|
||||
isAllowed: boolean;
|
||||
initDataRaw: string | undefined;
|
||||
lang: "ru" | "en";
|
||||
adminCheckComplete: boolean | null;
|
||||
adminMonth: Date;
|
||||
onError: (message: string) => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export function useAdminDuties({
|
||||
isAllowed,
|
||||
initDataRaw,
|
||||
lang,
|
||||
adminCheckComplete,
|
||||
adminMonth,
|
||||
onError,
|
||||
clearError,
|
||||
}: UseAdminDutiesOptions) {
|
||||
const [duties, setDuties] = useState<DutyWithUser[]>([]);
|
||||
const [loadingDuties, setLoadingDuties] = useState(true);
|
||||
|
||||
const from = useMemo(() => localDateString(firstDayOfMonth(adminMonth)), [adminMonth]);
|
||||
const to = useMemo(() => localDateString(lastDayOfMonth(adminMonth)), [adminMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
|
||||
const controller = new AbortController();
|
||||
setLoadingDuties(true);
|
||||
clearError();
|
||||
fetchDuties(from, to, initDataRaw, lang, controller.signal)
|
||||
.then((list) => setDuties(list))
|
||||
.catch((e) => {
|
||||
if ((e as Error)?.name === "AbortError") return;
|
||||
onError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => setLoadingDuties(false));
|
||||
return () => controller.abort();
|
||||
}, [isAllowed, initDataRaw, lang, from, to, adminCheckComplete, onError, clearError]);
|
||||
|
||||
return { duties, setDuties, loadingDuties, from, to };
|
||||
}
|
||||
116
webapp-next/src/components/admin/hooks/use-admin-reassign.ts
Normal file
116
webapp-next/src/components/admin/hooks/use-admin-reassign.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { AccessDeniedError, patchAdminDuty, type UserForAdmin } from "@/lib/api";
|
||||
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
export interface UseAdminReassignOptions {
|
||||
initDataRaw: string | undefined;
|
||||
lang: "ru" | "en";
|
||||
users: UserForAdmin[];
|
||||
setDuties: Dispatch<SetStateAction<DutyWithUser[]>>;
|
||||
t: (key: string, params?: Record<string, string>) => string;
|
||||
}
|
||||
|
||||
export function useAdminReassign({
|
||||
initDataRaw,
|
||||
lang,
|
||||
users,
|
||||
setDuties,
|
||||
t,
|
||||
}: UseAdminReassignOptions) {
|
||||
const [selectedDuty, setSelectedDuty] = useState<DutyWithUser | null>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | "">("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reassignErrorKey, setReassignErrorKey] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [sheetExiting, setSheetExiting] = useState(false);
|
||||
|
||||
const closeReassign = useCallback(() => {
|
||||
setSelectedDuty(null);
|
||||
setSelectedUserId("");
|
||||
setReassignErrorKey(null);
|
||||
setSheetExiting(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sheetExiting) return;
|
||||
const fallback = window.setTimeout(() => {
|
||||
closeReassign();
|
||||
}, 320);
|
||||
return () => window.clearTimeout(fallback);
|
||||
}, [sheetExiting, closeReassign]);
|
||||
|
||||
const openReassign = useCallback((duty: DutyWithUser) => {
|
||||
setSelectedDuty(duty);
|
||||
setSelectedUserId(duty.user_id);
|
||||
setReassignErrorKey(null);
|
||||
}, []);
|
||||
|
||||
const requestCloseSheet = useCallback(() => {
|
||||
setSheetExiting(true);
|
||||
}, []);
|
||||
|
||||
const handleReassign = useCallback(() => {
|
||||
if (!selectedDuty || selectedUserId === "" || !initDataRaw) return;
|
||||
if (selectedUserId === selectedDuty.user_id) {
|
||||
closeReassign();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setReassignErrorKey(null);
|
||||
patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang)
|
||||
.then((updated) => {
|
||||
setDuties((prev) =>
|
||||
prev.map((d) =>
|
||||
d.id === updated.id
|
||||
? {
|
||||
...d,
|
||||
user_id: updated.user_id,
|
||||
full_name:
|
||||
users.find((u) => u.id === updated.user_id)?.full_name ?? d.full_name,
|
||||
}
|
||||
: d
|
||||
)
|
||||
);
|
||||
setSuccessMessage(t("admin.reassign_success"));
|
||||
try {
|
||||
triggerHapticLight();
|
||||
} catch {
|
||||
// Haptic not available (e.g. non-Telegram).
|
||||
}
|
||||
requestCloseSheet();
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e instanceof AccessDeniedError) {
|
||||
setReassignErrorKey("admin.reassign_error_denied");
|
||||
} else if (e instanceof Error && /not found|не найден/i.test(e.message)) {
|
||||
setReassignErrorKey("admin.reassign_error_not_found");
|
||||
} else if (
|
||||
e instanceof TypeError ||
|
||||
(e instanceof Error && (e.message === "Failed to fetch" || e.message === "Load failed"))
|
||||
) {
|
||||
setReassignErrorKey("admin.reassign_error_network");
|
||||
} else {
|
||||
setReassignErrorKey("admin.reassign_error_generic");
|
||||
}
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign, requestCloseSheet, t, setDuties]);
|
||||
|
||||
return {
|
||||
selectedDuty,
|
||||
selectedUserId,
|
||||
setSelectedUserId,
|
||||
saving,
|
||||
reassignErrorKey,
|
||||
successMessage,
|
||||
sheetExiting,
|
||||
openReassign,
|
||||
requestCloseSheet,
|
||||
handleReassign,
|
||||
closeReassign,
|
||||
};
|
||||
}
|
||||
45
webapp-next/src/components/admin/hooks/use-admin-users.ts
Normal file
45
webapp-next/src/components/admin/hooks/use-admin-users.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AccessDeniedError, fetchAdminUsers, type UserForAdmin } from "@/lib/api";
|
||||
|
||||
export interface UseAdminUsersOptions {
|
||||
isAllowed: boolean;
|
||||
initDataRaw: string | undefined;
|
||||
lang: "ru" | "en";
|
||||
adminCheckComplete: boolean | null;
|
||||
onAccessDenied: (detail: string | null) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export function useAdminUsers({
|
||||
isAllowed,
|
||||
initDataRaw,
|
||||
lang,
|
||||
adminCheckComplete,
|
||||
onAccessDenied,
|
||||
onError,
|
||||
}: UseAdminUsersOptions) {
|
||||
const [users, setUsers] = useState<UserForAdmin[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
|
||||
const controller = new AbortController();
|
||||
setLoadingUsers(true);
|
||||
fetchAdminUsers(initDataRaw, lang, controller.signal)
|
||||
.then((list) => setUsers(list))
|
||||
.catch((e) => {
|
||||
if ((e as Error)?.name === "AbortError") return;
|
||||
if (e instanceof AccessDeniedError) {
|
||||
onAccessDenied(e.serverDetail ?? null);
|
||||
return;
|
||||
}
|
||||
onError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => setLoadingUsers(false));
|
||||
return () => controller.abort();
|
||||
}, [isAllowed, initDataRaw, lang, adminCheckComplete, onAccessDenied, onError]);
|
||||
|
||||
return { users, setUsers, loadingUsers };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { localDateString } from "@/lib/date-utils";
|
||||
|
||||
export const ADMIN_PAGE_SIZE = 20;
|
||||
|
||||
export function useInfiniteDutyGroups(duties: DutyWithUser[], from: string, to: string) {
|
||||
const [visibleCount, setVisibleCount] = useState(ADMIN_PAGE_SIZE);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const dutyOnly = useMemo(() => {
|
||||
const now = new Date();
|
||||
return duties
|
||||
.filter((d) => d.event_type === "duty" && new Date(d.end_at) > now)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
|
||||
);
|
||||
}, [duties]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(ADMIN_PAGE_SIZE);
|
||||
}, [from, to]);
|
||||
|
||||
const visibleDuties = useMemo(() => dutyOnly.slice(0, visibleCount), [dutyOnly, visibleCount]);
|
||||
const hasMore = visibleCount < dutyOnly.length;
|
||||
|
||||
const visibleGroups = useMemo(() => {
|
||||
const map = new Map<string, DutyWithUser[]>();
|
||||
for (const d of visibleDuties) {
|
||||
const key = localDateString(new Date(d.start_at));
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(d);
|
||||
}
|
||||
return Array.from(map.entries()).map(([dateKey, items]) => ({ dateKey, duties: items }));
|
||||
}, [visibleDuties]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || !sentinelRef.current) return;
|
||||
const el = sentinelRef.current;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setVisibleCount((prev) => Math.min(prev + ADMIN_PAGE_SIZE, dutyOnly.length));
|
||||
}
|
||||
},
|
||||
{ root: null, rootMargin: "200px", threshold: 0 }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, dutyOnly.length]);
|
||||
|
||||
return { dutyOnly, visibleDuties, visibleGroups, hasMore, sentinelRef };
|
||||
}
|
||||
9
webapp-next/src/components/admin/index.ts
Normal file
9
webapp-next/src/components/admin/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Admin feature: hook and presentational components for the admin page.
|
||||
*/
|
||||
|
||||
export { useAdminPage } from "./useAdminPage";
|
||||
export { AdminDutyList } from "./AdminDutyList";
|
||||
export { ReassignSheet } from "./ReassignSheet";
|
||||
export type { AdminDutyListProps, AdminDutyGroup } from "./AdminDutyList";
|
||||
export type { ReassignSheetProps } from "./ReassignSheet";
|
||||
129
webapp-next/src/components/admin/useAdminPage.ts
Normal file
129
webapp-next/src/components/admin/useAdminPage.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Admin page composition hook.
|
||||
* Delegates access/users/duties/reassign/infinite-list concerns to focused hooks.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTelegramSdkReady } from "@/components/providers/TelegramProvider";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { useTelegramBackButton, useTelegramClosingConfirmation } from "@/hooks/telegram";
|
||||
import { ENABLE_CLOSING_CONFIRMATION_FOR_STATEFUL_ADMIN_FLOW } from "@/lib/telegram-interaction-policy";
|
||||
import {
|
||||
useAdminAccess,
|
||||
useAdminUsers,
|
||||
useAdminDuties,
|
||||
useInfiniteDutyGroups,
|
||||
useAdminReassign,
|
||||
} from "@/components/admin/hooks";
|
||||
import { useRequestState } from "@/hooks/use-request-state";
|
||||
|
||||
export function useAdminPage() {
|
||||
const router = useRouter();
|
||||
const { sdkReady } = useTelegramSdkReady();
|
||||
const { initDataRaw, isLocalhost } = useTelegramAuth();
|
||||
const isAllowed = isLocalhost || !!initDataRaw;
|
||||
|
||||
const { lang } = useAppStore(useShallow((s) => ({ lang: s.lang })));
|
||||
const currentMonth = useAppStore((s) => s.currentMonth);
|
||||
const { t } = useTranslation();
|
||||
|
||||
/** Local month for admin view; does not change global calendar month. */
|
||||
const [adminMonth, setAdminMonth] = useState<Date>(() => new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1));
|
||||
const navigateHomeRef = useRef(() => router.push("/"));
|
||||
navigateHomeRef.current = () => router.push("/");
|
||||
const request = useRequestState("idle");
|
||||
const access = useAdminAccess({ isAllowed, initDataRaw, lang });
|
||||
const handleAdminAccessDenied = useCallback(
|
||||
(detail: string | null) => {
|
||||
access.setAdminAccessDenied(true);
|
||||
access.setAdminAccessDeniedDetail(detail);
|
||||
},
|
||||
[access.setAdminAccessDenied, access.setAdminAccessDeniedDetail]
|
||||
);
|
||||
|
||||
const onPrevMonth = useCallback(() => {
|
||||
setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1));
|
||||
}, []);
|
||||
const onNextMonth = useCallback(() => {
|
||||
setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1));
|
||||
}, []);
|
||||
|
||||
useTelegramBackButton({
|
||||
enabled: sdkReady && !isLocalhost,
|
||||
onClick: () => navigateHomeRef.current(),
|
||||
});
|
||||
|
||||
const usersRequest = useAdminUsers({
|
||||
isAllowed,
|
||||
initDataRaw,
|
||||
lang,
|
||||
adminCheckComplete: access.adminCheckComplete,
|
||||
onAccessDenied: handleAdminAccessDenied,
|
||||
onError: request.setError,
|
||||
});
|
||||
|
||||
const dutiesRequest = useAdminDuties({
|
||||
isAllowed,
|
||||
initDataRaw,
|
||||
lang,
|
||||
adminCheckComplete: access.adminCheckComplete,
|
||||
adminMonth,
|
||||
onError: request.setError,
|
||||
clearError: request.reset,
|
||||
});
|
||||
|
||||
const reassign = useAdminReassign({
|
||||
initDataRaw,
|
||||
lang,
|
||||
users: usersRequest.users,
|
||||
setDuties: dutiesRequest.setDuties,
|
||||
t,
|
||||
});
|
||||
|
||||
useTelegramClosingConfirmation(
|
||||
ENABLE_CLOSING_CONFIRMATION_FOR_STATEFUL_ADMIN_FLOW && reassign.selectedDuty !== null
|
||||
);
|
||||
|
||||
const list = useInfiniteDutyGroups(dutiesRequest.duties, dutiesRequest.from, dutiesRequest.to);
|
||||
const usersForSelect = useMemo(
|
||||
() => usersRequest.users.filter((u) => u.role_id === 1 || u.role_id === 2),
|
||||
[usersRequest.users]
|
||||
);
|
||||
const error = request.state.error;
|
||||
const loading = usersRequest.loadingUsers || dutiesRequest.loadingDuties;
|
||||
|
||||
return {
|
||||
isAllowed,
|
||||
adminCheckComplete: access.adminCheckComplete,
|
||||
adminAccessDenied: access.adminAccessDenied,
|
||||
adminAccessDeniedDetail: access.adminAccessDeniedDetail,
|
||||
error,
|
||||
loading,
|
||||
adminMonth,
|
||||
onPrevMonth,
|
||||
onNextMonth,
|
||||
successMessage: reassign.successMessage,
|
||||
dutyOnly: list.dutyOnly,
|
||||
usersForSelect,
|
||||
visibleDuties: list.visibleDuties,
|
||||
visibleGroups: list.visibleGroups,
|
||||
hasMore: list.hasMore,
|
||||
sentinelRef: list.sentinelRef,
|
||||
selectedDuty: reassign.selectedDuty,
|
||||
selectedUserId: reassign.selectedUserId,
|
||||
setSelectedUserId: reassign.setSelectedUserId,
|
||||
saving: reassign.saving,
|
||||
reassignErrorKey: reassign.reassignErrorKey,
|
||||
sheetExiting: reassign.sheetExiting,
|
||||
openReassign: reassign.openReassign,
|
||||
requestCloseSheet: reassign.requestCloseSheet,
|
||||
handleReassign: reassign.handleReassign,
|
||||
closeReassign: reassign.closeReassign,
|
||||
};
|
||||
}
|
||||
79
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
79
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Unit tests for CalendarDay: click opens day detail only for current month;
|
||||
* other-month cells do not call onDayClick and are non-interactive (aria-disabled).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CalendarDay } from "./CalendarDay";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("CalendarDay", () => {
|
||||
const defaultProps = {
|
||||
dateKey: "2025-02-15",
|
||||
dayOfMonth: 15,
|
||||
isToday: false,
|
||||
duties: [],
|
||||
eventSummaries: [],
|
||||
onDayClick: () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("calls onDayClick with dateKey and rect when clicked and isOtherMonth is false", () => {
|
||||
const onDayClick = vi.fn();
|
||||
render(
|
||||
<CalendarDay
|
||||
{...defaultProps}
|
||||
isOtherMonth={false}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onDayClick).toHaveBeenCalledTimes(1);
|
||||
expect(onDayClick).toHaveBeenCalledWith(
|
||||
"2025-02-15",
|
||||
expect.objectContaining({
|
||||
width: expect.any(Number),
|
||||
height: expect.any(Number),
|
||||
top: expect.any(Number),
|
||||
left: expect.any(Number),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call onDayClick when clicked and isOtherMonth is true", () => {
|
||||
const onDayClick = vi.fn();
|
||||
render(
|
||||
<CalendarDay
|
||||
{...defaultProps}
|
||||
isOtherMonth={true}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onDayClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets aria-disabled on the button when isOtherMonth is true", () => {
|
||||
render(
|
||||
<CalendarDay {...defaultProps} isOtherMonth={true} onDayClick={() => {}} />
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("is not disabled for interaction when isOtherMonth is false", () => {
|
||||
render(
|
||||
<CalendarDay {...defaultProps} isOtherMonth={false} onDayClick={() => {}} />
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
expect(button.getAttribute("aria-disabled")).not.toBe("true");
|
||||
});
|
||||
});
|
||||
123
webapp-next/src/components/calendar/CalendarDay.tsx
Normal file
123
webapp-next/src/components/calendar/CalendarDay.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Single calendar day cell: date number and day indicators. Click opens day detail.
|
||||
* Ported from webapp/js/calendar.js day cell rendering.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { DayIndicators } from "./DayIndicators";
|
||||
|
||||
export interface CalendarDayProps {
|
||||
/** YYYY-MM-DD key for this day. */
|
||||
dateKey: string;
|
||||
/** Day of month (1–31) for display. */
|
||||
dayOfMonth: number;
|
||||
isToday: boolean;
|
||||
isOtherMonth: boolean;
|
||||
/** Duties overlapping this day (for indicators and tooltip). */
|
||||
duties: DutyWithUser[];
|
||||
/** External calendar event summaries for this day. */
|
||||
eventSummaries: string[];
|
||||
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
}
|
||||
|
||||
function CalendarDayInner({
|
||||
dateKey,
|
||||
dayOfMonth,
|
||||
isToday,
|
||||
isOtherMonth,
|
||||
duties,
|
||||
eventSummaries,
|
||||
onDayClick,
|
||||
}: CalendarDayProps) {
|
||||
const { t } = useTranslation();
|
||||
const { dutyList, unavailableList, vacationList } = useMemo(
|
||||
() => ({
|
||||
dutyList: duties.filter((d) => d.event_type === "duty"),
|
||||
unavailableList: duties.filter((d) => d.event_type === "unavailable"),
|
||||
vacationList: duties.filter((d) => d.event_type === "vacation"),
|
||||
}),
|
||||
[duties]
|
||||
);
|
||||
const hasEvent = eventSummaries.length > 0;
|
||||
const showIndicator = !isOtherMonth;
|
||||
const hasAny = duties.length > 0 || hasEvent;
|
||||
|
||||
const ariaParts: string[] = [dateKeyToDDMM(dateKey)];
|
||||
if (hasAny && showIndicator) {
|
||||
const counts: string[] = [];
|
||||
if (dutyList.length) counts.push(`${dutyList.length} ${t("event_type.duty")}`);
|
||||
if (unavailableList.length)
|
||||
counts.push(`${unavailableList.length} ${t("event_type.unavailable")}`);
|
||||
if (vacationList.length)
|
||||
counts.push(`${vacationList.length} ${t("event_type.vacation")}`);
|
||||
if (hasEvent) counts.push(t("hint.events"));
|
||||
ariaParts.push(counts.join(", "));
|
||||
} else {
|
||||
ariaParts.push(t("aria.day_info"));
|
||||
}
|
||||
const ariaLabel = ariaParts.join("; ");
|
||||
|
||||
const content = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-disabled={isOtherMonth}
|
||||
data-date={dateKey}
|
||||
className={cn(
|
||||
"relative flex w-full aspect-square min-h-8 min-w-0 flex-col items-center justify-start rounded-lg p-1 text-[0.85rem] transition-[background-color,transform] overflow-hidden",
|
||||
"focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
|
||||
isOtherMonth &&
|
||||
"pointer-events-none opacity-40 bg-[var(--surface-muted-tint)] cursor-default",
|
||||
!isOtherMonth && [
|
||||
"bg-surface hover:bg-[var(--surface-hover-10)]",
|
||||
"active:scale-[0.98] cursor-pointer",
|
||||
isToday && "bg-today text-[var(--bg)] hover:bg-[var(--today-hover)]",
|
||||
],
|
||||
showIndicator && hasAny && "font-bold",
|
||||
showIndicator &&
|
||||
hasEvent &&
|
||||
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
|
||||
isToday &&
|
||||
hasEvent &&
|
||||
"bg-today text-[var(--bg)] border border-[var(--today-border-selected)]"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isOtherMonth) return;
|
||||
onDayClick(dateKey, e.currentTarget.getBoundingClientRect());
|
||||
}}
|
||||
>
|
||||
<span className="num">{dayOfMonth}</span>
|
||||
{showIndicator && (duties.length > 0 || hasEvent) && (
|
||||
<DayIndicators
|
||||
dutyCount={dutyList.length}
|
||||
unavailableCount={unavailableList.length}
|
||||
vacationCount={vacationList.length}
|
||||
hasEvents={hasEvent}
|
||||
isToday={isToday}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function arePropsEqual(prev: CalendarDayProps, next: CalendarDayProps): boolean {
|
||||
return (
|
||||
prev.dateKey === next.dateKey &&
|
||||
prev.dayOfMonth === next.dayOfMonth &&
|
||||
prev.isToday === next.isToday &&
|
||||
prev.isOtherMonth === next.isOtherMonth &&
|
||||
prev.duties === next.duties &&
|
||||
prev.eventSummaries === next.eventSummaries &&
|
||||
prev.onDayClick === next.onDayClick
|
||||
);
|
||||
}
|
||||
|
||||
export const CalendarDay = React.memo(CalendarDayInner, arePropsEqual);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user