From c390a4dd6ea07f45f5d82b1d568fef2eb54960d2 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Fri, 6 Mar 2026 09:57:26 +0300 Subject: [PATCH] feat: implement admin panel functionality in Mini App - Added new API endpoints for admin features: `GET /api/admin/me`, `GET /api/admin/users`, and `PATCH /api/admin/duties/:id` to manage user duties. - Introduced `UserForAdmin` and `AdminDutyReassignBody` schemas for handling admin-related data. - Updated documentation to include Mini App design guidelines and admin panel functionalities. - Enhanced tests for admin API to ensure proper access control and functionality. - Improved error handling and localization for admin actions. --- .cursor/rules/frontend.mdc | 11 + AGENTS.md | 3 + docs/architecture.md | 3 + docs/index.md | 1 + docs/miniapp-design.md | 217 +++++++++ duty_teller/api/app.py | 113 ++++- duty_teller/api/dependencies.py | 98 ++++ duty_teller/db/__init__.py | 12 +- duty_teller/db/repository.py | 55 +++ duty_teller/db/schemas.py | 15 + duty_teller/i18n/messages.py | 8 + tests/test_admin_api.py | 360 ++++++++++++++ tests/test_repository_duty_range.py | 49 ++ webapp-next/src/app/admin/page.test.tsx | 246 ++++++++++ webapp-next/src/app/admin/page.tsx | 450 ++++++++++++++++++ webapp-next/src/app/global-error.tsx | 2 +- webapp-next/src/app/not-found.tsx | 2 +- webapp-next/src/app/page.tsx | 13 +- webapp-next/src/components/CalendarPage.tsx | 18 +- .../components/calendar/CalendarHeader.tsx | 7 + .../src/components/day-detail/DayDetail.tsx | 2 +- .../components/states/AccessDeniedScreen.tsx | 2 +- webapp-next/src/components/ui/sheet.tsx | 2 +- webapp-next/src/components/ui/tooltip.tsx | 2 +- webapp-next/src/i18n/messages.ts | 30 ++ webapp-next/src/lib/api.test.ts | 166 ++++++- webapp-next/src/lib/api.ts | 166 +++++++ webapp-next/src/store/app-store.ts | 7 +- 28 files changed, 2045 insertions(+), 15 deletions(-) create mode 100644 docs/miniapp-design.md create mode 100644 tests/test_admin_api.py create mode 100644 webapp-next/src/app/admin/page.test.tsx create mode 100644 webapp-next/src/app/admin/page.tsx diff --git a/.cursor/rules/frontend.mdc b/.cursor/rules/frontend.mdc index f2ef0f2..b25a852 100644 --- a/.cursor/rules/frontend.mdc +++ b/.cursor/rules/frontend.mdc @@ -50,4 +50,15 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served - **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. + +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. diff --git a/AGENTS.md b/AGENTS.md index f1c31a1..fd85d23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro | 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 @@ -35,6 +36,7 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro ## 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`. @@ -48,4 +50,5 @@ Docstrings and code comments must be in English (Google-style docstrings). UI st - **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 from it, apply layout/safe-area/accessibility rules, and use the checklist for new screens or components. - **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. diff --git a/docs/architecture.md b/docs/architecture.md index 394ace5..41d8af3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`). diff --git a/docs/index.md b/docs/index.md index 809c519..b6dddc1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,7 @@ 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). diff --git a/docs/miniapp-design.md b/docs/miniapp-design.md new file mode 100644 index 0000000..2cbead9 --- /dev/null +++ b/docs/miniapp-design.md @@ -0,0 +1,217 @@ +# 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 inline script in `webapp-next/src/app/layout.tsx`). +2. At runtime: `Telegram.WebApp.colorScheme` and `Telegram.WebApp.themeParams` via `use-telegram-theme.ts` and `TelegramProvider`. + +The inline script in the layout maps all Telegram theme keys to `--tg-theme-*` CSS variables on the document root. The hook 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 + +- **Class `.content-safe`** (in `globals.css`): Applies: + - `padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0))` + - `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0))` +- Use `.content-safe` on the **root container of each page** so content is not covered by the Telegram header or bottom bar (Bot API 8.0+). +- 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`). + +### 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 + +- **Header and background:** On init (layout script and `use-telegram-theme.ts`), 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. + +--- + +## 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). diff --git a/duty_teller/api/app.py b/duty_teller/api/app.py index 5e1d6d8..c96b092 100644 --- a/duty_teller/api/app.py +++ b/duty_teller/api/app.py @@ -6,7 +6,7 @@ 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 JSONResponse, Response from fastapi.staticfiles import StaticFiles @@ -14,19 +14,34 @@ 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 ics_calendar_cache +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__) @@ -283,6 +298,98 @@ def get_personal_calendar_ical( ) +# --- 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") diff --git a/duty_teller/api/dependencies.py b/duty_teller/api/dependencies.py index 35a3d78..33b15f2 100644 --- a/duty_teller/api/dependencies.py +++ b/duty_teller/api/dependencies.py @@ -12,6 +12,7 @@ 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 @@ -159,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]: diff --git a/duty_teller/db/__init__.py b/duty_teller/db/__init__.py index 1602fa2..93cd76b 100644 --- a/duty_teller/db/__init__.py +++ b/duty_teller/db/__init__.py @@ -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", ] diff --git a/duty_teller/db/repository.py b/duty_teller/db/repository.py index 3ef6a93..14fb2fc 100644 --- a/duty_teller/db/repository.py +++ b/duty_teller/db/repository.py @@ -322,6 +322,61 @@ def delete_duties_in_range( 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, diff --git a/duty_teller/db/schemas.py b/duty_teller/db/schemas.py index fdd943e..599c746 100644 --- a/duty_teller/db/schemas.py +++ b/duty_teller/db/schemas.py @@ -69,6 +69,21 @@ class DutyWithUser(DutyInDb): 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.""" diff --git a/duty_teller/i18n/messages.py b/duty_teller/i18n/messages.py index 6c137c3..7ced1b0 100644 --- a/duty_teller/i18n/messages.py +++ b/duty_teller/i18n/messages.py @@ -88,6 +88,7 @@ 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.", @@ -98,6 +99,9 @@ MESSAGES: dict[str, dict[str, str]] = { "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 для списка команд.", @@ -174,6 +178,7 @@ 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": "Диапазон дат слишком большой. Запросите более короткий период.", @@ -184,5 +189,8 @@ MESSAGES: dict[str, dict[str, str]] = { "current_duty.shift": "Смена", "current_duty.remaining": "Осталось: {hours}ч {minutes}мин", "current_duty.back": "Назад к календарю", + "admin.duty_not_found": "Дежурство не найдено", + "admin.user_not_found": "Пользователь не найден", + "admin.reassign_success": "Дежурство успешно переназначено", }, } diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py new file mode 100644 index 0000000..20803f7 --- /dev/null +++ b/tests/test_admin_api.py @@ -0,0 +1,360 @@ +"""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 duty_teller.api.app import app + + +@pytest.fixture +def client(): + return TestClient(app) + + +# --- 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).""" + r = client.get("/api/admin/me") + assert r.status_code == 200 + assert r.json() == {"is_admin": False} + + +@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 + with patch("duty_teller.api.app.get_db_session") as mock_db: + mock_db.return_value.__enter__.return_value = mock_session + mock_db.return_value.__exit__.return_value = None + 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"}, + ) + 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 + with patch("duty_teller.api.app.get_db_session") as mock_db: + mock_db.return_value.__enter__.return_value = mock_session + mock_db.return_value.__exit__.return_value = None + 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"}, + ) + 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"] == "Дежурство не найдено" diff --git a/tests/test_repository_duty_range.py b/tests/test_repository_duty_range.py index 9e5c064..dd55e7e 100644 --- a/tests/test_repository_duty_range.py +++ b/tests/test_repository_duty_range.py @@ -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( diff --git a/webapp-next/src/app/admin/page.test.tsx b/webapp-next/src/app/admin/page.test.tsx new file mode 100644 index 0000000..890a797 --- /dev/null +++ b/webapp-next/src/app/admin/page.test.tsx @@ -0,0 +1,246 @@ +/** + * 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 and back link when allowed and data loaded", async () => { + mockFetchForAdmin(); + render(); + await waitFor(() => { + expect(screen.getByRole("heading", { name: /admin|админка/i })).toBeInTheDocument(); + }); + expect(screen.getByRole("link", { name: /back to calendar|назад к календарю/i })).toBeInTheDocument(); + }); + + it("shows access denied when fetchAdminMe returns is_admin false", async () => { + mockFetchForAdmin(sampleUsers, [], { adminMe: { is_admin: false } }); + render(); + 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(); + 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(); + await waitFor(() => { + expect(screen.getByText("Alice")).toBeInTheDocument(); + }); + const dutyButton = screen.getByRole("button", { name: /Alice/ }); + fireEvent.click(dutyButton); + await waitFor(() => { + expect( + screen.getByLabelText(/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(); + 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(); + await waitFor(() => { + expect(screen.getByText("Alice")).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole("button", { name: /Alice/ })); + await waitFor(() => { + expect(screen.getByLabelText(/select user|выберите пользователя/i)).toBeInTheDocument(); + }); + const select = screen.getByLabelText(/select user|выберите пользователя/i); + fireEvent.change(select, { target: { value: "2" } }); + fireEvent.click(screen.getByRole("button", { name: /save|сохранить/i })); + await waitFor(() => { + expect( + screen.getByText(/Duty reassigned|Дежурство переназначено/i) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/webapp-next/src/app/admin/page.tsx b/webapp-next/src/app/admin/page.tsx new file mode 100644 index 0000000..5cbc483 --- /dev/null +++ b/webapp-next/src/app/admin/page.tsx @@ -0,0 +1,450 @@ +/** + * 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). + * Requires GET /api/admin/users and PATCH /api/admin/duties/:id (admin-only). + */ + +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { backButton } from "@telegram-apps/sdk-react"; +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 { + fetchDuties, + fetchAdminMe, + fetchAdminUsers, + patchAdminDuty, + AccessDeniedError, + type UserForAdmin, +} from "@/lib/api"; +import type { DutyWithUser } from "@/types"; +import { + firstDayOfMonth, + lastDayOfMonth, + localDateString, + formatHHMM, +} from "@/lib/date-utils"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, +} from "@/components/ui/sheet"; +import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen"; +import { LoadingState } from "@/components/states/LoadingState"; +import { ErrorState } from "@/components/states/ErrorState"; + +const PAGE_SIZE = 20; + +export default function AdminPage() { + const router = useRouter(); + const { initDataRaw, isLocalhost } = useTelegramAuth(); + const isAllowed = isLocalhost || !!initDataRaw; + + const { lang } = useAppStore(useShallow((s) => ({ lang: s.lang }))); + const { t, monthName } = useTranslation(); + + // Telegram BackButton: show on mount when in Mini App, navigate to calendar on click. + useEffect(() => { + if (isLocalhost) return; + let offClick: (() => void) | undefined; + try { + if (backButton.mount.isAvailable()) { + backButton.mount(); + } + if (backButton.show.isAvailable()) { + backButton.show(); + } + if (backButton.onClick.isAvailable()) { + offClick = backButton.onClick(() => router.push("/")); + } + } catch { + // Non-Telegram environment; BackButton not available. + } + return () => { + try { + if (typeof offClick === "function") offClick(); + if (backButton.hide.isAvailable()) { + backButton.hide(); + } + } catch { + // Ignore cleanup errors in non-Telegram environment. + } + }; + }, [isLocalhost, router]); + + const [users, setUsers] = useState([]); + const [duties, setDuties] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(true); + const [loadingDuties, setLoadingDuties] = useState(true); + /** null = not yet checked, true = is admin, false = not admin (then adminAccessDenied is set). */ + const [adminCheckComplete, setAdminCheckComplete] = useState(null); + const [adminAccessDenied, setAdminAccessDenied] = useState(false); + const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState(null); + const [error, setError] = useState(null); + const [selectedDuty, setSelectedDuty] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(""); + const [saving, setSaving] = useState(false); + const [reassignError, setReassignError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const sentinelRef = useRef(null); + + const currentMonth = useAppStore((s) => s.currentMonth); + const from = localDateString(firstDayOfMonth(currentMonth)); + const to = localDateString(lastDayOfMonth(currentMonth)); + + // Check admin status first; only then load users and duties (avoids extra 403 for non-admins). + useEffect(() => { + if (!isAllowed || !initDataRaw) return; + setAdminCheckComplete(null); + setAdminAccessDenied(false); + fetchAdminMe(initDataRaw, lang) + .then(({ is_admin }) => { + if (!is_admin) { + setAdminAccessDenied(true); + setAdminAccessDeniedDetail(null); + setAdminCheckComplete(false); + } else { + setAdminCheckComplete(true); + } + }) + .catch(() => { + setAdminAccessDenied(true); + setAdminAccessDeniedDetail(null); + setAdminCheckComplete(false); + }); + }, [isAllowed, initDataRaw, lang]); + + 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) { + setAdminAccessDenied(true); + setAdminAccessDeniedDetail(e.serverDetail ?? null); + } else { + setError(e instanceof Error ? e.message : String(e)); + } + }) + .finally(() => setLoadingUsers(false)); + return () => controller.abort(); + }, [isAllowed, initDataRaw, lang, adminCheckComplete]); + + useEffect(() => { + if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return; + + const controller = new AbortController(); + setLoadingDuties(true); + setError(null); + fetchDuties(from, to, initDataRaw, lang, controller.signal) + .then((list) => setDuties(list)) + .catch((e) => { + if ((e as Error)?.name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => setLoadingDuties(false)); + return () => controller.abort(); + }, [isAllowed, initDataRaw, lang, from, to, adminCheckComplete]); + + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [from, to]); + + const openReassign = useCallback((duty: DutyWithUser) => { + setSelectedDuty(duty); + setSelectedUserId(duty.user_id); + setReassignError(null); + }, []); + + const closeReassign = useCallback(() => { + setSelectedDuty(null); + setSelectedUserId(""); + setReassignError(null); + }, []); + + const handleReassign = useCallback(() => { + if (!selectedDuty || selectedUserId === "" || !initDataRaw) return; + if (selectedUserId === selectedDuty.user_id) { + closeReassign(); + return; + } + setSaving(true); + setReassignError(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")); + closeReassign(); + setTimeout(() => setSuccessMessage(null), 3000); + }) + .catch((e) => { + setReassignError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => setSaving(false)); + }, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign]); + + const now = new Date(); + const dutyOnly = 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() + ); + + /** Users with role_id 1 (user) or 2 (admin) shown in reassign dropdown. */ + const usersForSelect = users.filter( + (u) => u.role_id === 1 || u.role_id === 2 + ); + + const visibleDuties = dutyOnly.slice(0, visibleCount); + const hasMore = visibleCount < dutyOnly.length; + + useEffect(() => { + if (!hasMore || !sentinelRef.current) return; + const el = sentinelRef.current; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setVisibleCount((prev) => + Math.min(prev + PAGE_SIZE, dutyOnly.length) + ); + } + }, + { root: null, rootMargin: "200px", threshold: 0 } + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasMore, dutyOnly.length]); + + const loading = loadingUsers || loadingDuties; + + if (!isAllowed) { + return ( +
+ +
+ ); + } + + if (isAllowed && initDataRaw && adminCheckComplete === null) { + return ( +
+
+ +

{t("admin.loading_users")}

+
+
+ ); + } + + if (adminAccessDenied) { + return ( +
+
+

{adminAccessDeniedDetail ?? t("admin.access_denied")}

+ +
+
+ ); + } + + return ( +
+
+

+ {t("admin.title")} — {monthName(currentMonth.getMonth())} {currentMonth.getFullYear()} +

+ +
+ + {successMessage && ( +

+ {successMessage} +

+ )} + + {loading && ( +
+ +

{t("admin.loading_users")}

+
+ )} + + {error && !loading && ( + window.location.reload()} className="my-3" /> + )} + + {!loading && !error && ( +
+

+ {t("admin.reassign_duty")}: {t("admin.select_user")} +

+ {dutyOnly.length === 0 ? ( +

{t("admin.no_duties")}

+ ) : ( +
    + {visibleDuties.map((duty) => { + const start = new Date(duty.start_at); + const end = new Date(duty.end_at); + const dateStr = localDateString(start); + const timeStr = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`; + return ( +
  • + +
  • + ); + })} + {hasMore && ( +
+ )} +
+ )} + + {selectedDuty !== null && ( + !open && closeReassign()}> + +
+ +
+ + {t("admin.reassign_duty")} + {t("admin.select_user")} + + {selectedDuty && ( +
+

+ {localDateString(new Date(selectedDuty.start_at))}{" "} + {formatHHMM(selectedDuty.start_at)} – {formatHHMM(selectedDuty.end_at)} +

+ {usersForSelect.length === 0 ? ( +

{t("admin.no_users_for_assign")}

+ ) : ( +
+ + +
+ )} + {reassignError && ( +

+ {reassignError} +

+ )} +
+ )} +
+ + + + + + )} +
+ ); +} diff --git a/webapp-next/src/app/global-error.tsx b/webapp-next/src/app/global-error.tsx index 78082d5..351eb6c 100644 --- a/webapp-next/src/app/global-error.tsx +++ b/webapp-next/src/app/global-error.tsx @@ -31,7 +31,7 @@ export default function GlobalError({ /> -
+

{translate(lang, "error_boundary.message")}

diff --git a/webapp-next/src/app/not-found.tsx b/webapp-next/src/app/not-found.tsx index 9dcbf55..061f4d7 100644 --- a/webapp-next/src/app/not-found.tsx +++ b/webapp-next/src/app/not-found.tsx @@ -11,7 +11,7 @@ import { useTranslation } from "@/i18n/use-translation"; export default function NotFound() { const { t } = useTranslation(); return ( -
+

{t("not_found.title")}

{t("not_found.description")}

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) => ({ @@ -50,7 +61,7 @@ export default function Home() { const content = accessDenied ? ( ) : currentView === "currentDuty" ? ( -
+
+
+ {t("admin.link")} + + ) : undefined + } /> void; onNextMonth: () => void; + /** Optional content shown above the nav row (e.g. Admin link). */ + trailingContent?: ReactNode; className?: string; } @@ -28,6 +31,7 @@ export function CalendarHeader({ disabled = false, onPrevMonth, onNextMonth, + trailingContent, className, }: CalendarHeaderProps) { const { t, monthName, weekdayLabels } = useTranslation(); @@ -37,6 +41,9 @@ export function CalendarHeader({ return (
+ {trailingContent != null && ( +
{trailingContent}
+ )}