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.
This commit is contained in:
2026-03-06 09:57:26 +03:00
parent 68b1884b73
commit c390a4dd6e
28 changed files with 2045 additions and 15 deletions

View File

@@ -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")