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:
@@ -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")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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": "Дежурство успешно переназначено",
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user