201 lines
11 KiB
Python
201 lines
11 KiB
Python
import re
|
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
|
from fastapi.responses import RedirectResponse, Response
|
|
from sqlalchemy import desc, func, or_, select
|
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.admin import _render
|
|
from app.config import Settings, get_settings
|
|
from app.db import get_db
|
|
from app.models import SystemUser, UserAction, utcnow
|
|
from app.security import (SESSION_COOKIE, SESSION_SECONDS, USER_ROLES, check_csrf, hash_password,
|
|
require_admin, require_user, sign_session, temporary_password, verify_password)
|
|
|
|
router = APIRouter(prefix='/admin', dependencies=[Depends(check_csrf)])
|
|
|
|
|
|
def _history(db, user_id=None, offset=0):
|
|
query = select(UserAction)
|
|
if user_id is not None:
|
|
query = query.where(or_(UserAction.user_id == user_id, UserAction.target_user_id == user_id))
|
|
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
|
offset = min(max(0, offset), ((total - 1) // 30) * 30) if total else 0
|
|
items = db.scalars(query.order_by(desc(UserAction.created_at), desc(UserAction.id)).offset(offset).limit(30)).all()
|
|
return {'actions': items, 'history_total': total, 'history_offset': offset, 'history_limit': 30}
|
|
|
|
|
|
def _users_page(request, db, *, user_id=None, offset=0, error=None, temporary=None, status=200):
|
|
users = db.scalars(select(SystemUser).order_by(SystemUser.username)).all()
|
|
selected = next((u for u in users if u.id == user_id), None)
|
|
if user_id is not None and selected is None:
|
|
raise HTTPException(404, 'Пользователь не найден')
|
|
response = _render(request, 'users.html', {'users': users, 'selected_user': selected,
|
|
'user_names': {u.id: u.username for u in users}, 'error': error, 'temporary': temporary,
|
|
**_history(db, user_id, offset)}, status_code=status)
|
|
response.headers['Cache-Control'] = 'no-store'
|
|
return response
|
|
|
|
|
|
def _lock_admin(request, db, settings):
|
|
# Serialize role/availability changes so concurrent admins cannot disable each other.
|
|
db.scalars(select(SystemUser).order_by(SystemUser.id).with_for_update()
|
|
.execution_options(populate_existing=True)).all()
|
|
return require_admin(request, settings, db)
|
|
|
|
|
|
@router.get('/users')
|
|
def users(request: Request, user_id: int | None = None, offset: int = 0,
|
|
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
|
require_admin(request, settings, db)
|
|
return _users_page(request, db, user_id=user_id, offset=offset)
|
|
|
|
|
|
@router.post('/users')
|
|
def create_user(request: Request, username: str = Form(..., max_length=64), full_name: str = Form('', max_length=255),
|
|
role: str = Form('user'), db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
|
actor = require_admin(request, settings, db)
|
|
username = username.strip().casefold()
|
|
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,63}', username) or role not in USER_ROLES:
|
|
return _users_page(request, db, error='Логин должен содержать от 3 до 64 латинских букв, цифр, точек, дефисов или подчёркиваний. Выберите роль из списка', status=422)
|
|
try:
|
|
actor = _lock_admin(request, db, settings)
|
|
password = temporary_password()
|
|
user = SystemUser(username=username, full_name=full_name.strip(), role=role,
|
|
password_hash=hash_password(password), password_reset_at=utcnow())
|
|
db.add(user)
|
|
db.flush()
|
|
db.add(UserAction(user_id=actor.id, action='user_create', target_user_id=user.id,
|
|
changes={'role': {'before': None, 'after': role}}))
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
return _users_page(request, db, error='Этот логин уже занят. Выберите другой', status=409)
|
|
except SQLAlchemyError:
|
|
db.rollback()
|
|
return _users_page(request, db, error='Не удалось создать пользователя. Попробуйте ещё раз', status=503)
|
|
return _users_page(request, db, temporary={'username': user.username, 'password': password})
|
|
|
|
|
|
@router.post('/users/{user_id}/reset-password')
|
|
def reset_password(user_id: int, request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
|
actor = require_admin(request, settings, db)
|
|
try:
|
|
actor = _lock_admin(request, db, settings)
|
|
user = db.get(SystemUser, user_id)
|
|
if user is None:
|
|
raise HTTPException(404, 'Пользователь не найден')
|
|
if user.id == actor.id:
|
|
raise HTTPException(422, 'Свой пароль измените в личном кабинете')
|
|
password = temporary_password()
|
|
user.password_hash = hash_password(password)
|
|
user.must_change_password = True
|
|
user.password_reset_at = utcnow()
|
|
user.session_version += 1
|
|
user.failed_login_attempts = 0
|
|
user.locked_until = None
|
|
db.add(UserAction(user_id=actor.id, action='password_reset', target_user_id=user.id))
|
|
db.commit()
|
|
except SQLAlchemyError:
|
|
db.rollback()
|
|
return _users_page(request, db, error='Не удалось сбросить пароль. Попробуйте ещё раз', status=503)
|
|
return _users_page(request, db, temporary={'username': user.username, 'password': password})
|
|
|
|
|
|
@router.post('/users/{user_id}')
|
|
def update_user(user_id: int, request: Request, role: str = Form(...), is_active: str = Form(...),
|
|
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
|
actor = require_admin(request, settings, db)
|
|
if role not in USER_ROLES or is_active not in {'true', 'false'}:
|
|
raise HTTPException(422, 'Выберите роль и состояние доступа')
|
|
try:
|
|
actor = _lock_admin(request, db, settings)
|
|
user = db.get(SystemUser, user_id)
|
|
if user is None:
|
|
raise HTTPException(404, 'Пользователь не найден')
|
|
if user.id == actor.id:
|
|
raise HTTPException(422, 'Собственную роль и доступ изменить нельзя')
|
|
changes = {}
|
|
for field, value in (('role', role), ('is_active', is_active == 'true')):
|
|
old = getattr(user, field)
|
|
if old != value:
|
|
changes[field] = {'before': old, 'after': value}
|
|
setattr(user, field, value)
|
|
if changes:
|
|
user.session_version += 1
|
|
db.add(UserAction(user_id=actor.id, action='user_update', target_user_id=user.id, changes=changes))
|
|
db.commit()
|
|
except SQLAlchemyError:
|
|
db.rollback()
|
|
return _users_page(request, db, error='Не удалось изменить доступ. Попробуйте ещё раз', status=503)
|
|
return RedirectResponse('/admin/users', status_code=303)
|
|
|
|
|
|
@router.get('/account')
|
|
def account(request: Request, offset: int = 0, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
|
user = require_user(request, settings, db, allow_password_change=True)
|
|
return _account_page(request, db, user, offset=offset)
|
|
|
|
|
|
def _account_page(request, db, user, *, offset=0, error=None, error_field=None, status=200):
|
|
history = _history(db, user.id, offset)
|
|
ids = {a.user_id for a in history['actions']} | {a.target_user_id for a in history['actions'] if a.target_user_id is not None}
|
|
names = dict(db.execute(select(SystemUser.id, SystemUser.username).where(SystemUser.id.in_(ids))).all()) if ids else {}
|
|
return _render(request, 'account.html', {'account_user': user, 'user_names': names, 'error': error,
|
|
'error_field': error_field, 'password_changed': request.query_params.get('password_changed') == '1', **history}, status_code=status)
|
|
|
|
|
|
@router.post('/account/password')
|
|
def change_password(request: Request, current_password: str = Form(..., max_length=1024),
|
|
new_password: str = Form(..., max_length=128), confirmation: str = Form(..., max_length=128),
|
|
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
|
user = require_user(request, settings, db, allow_password_change=True, lock=True)
|
|
if not verify_password(current_password, user.password_hash):
|
|
return _account_page(request, db, user, error='Текущий пароль неверен. Введите пароль, с которым вошли', error_field='current_password', status=422)
|
|
if len(new_password) < 15 or new_password == current_password:
|
|
return _account_page(request, db, user, error='Выберите новый пароль от 15 до 128 символов, отличающийся от текущего. Можно использовать фразу', error_field='new_password', status=422)
|
|
if new_password != confirmation:
|
|
return _account_page(request, db, user, error='Новые пароли не совпадают. Повторите ввод', error_field='confirmation', status=422)
|
|
try:
|
|
first_password_change = user.password_changed_at is None
|
|
user.password_hash = hash_password(new_password)
|
|
user.must_change_password = False
|
|
user.password_changed_at = utcnow()
|
|
user.session_version += 1
|
|
user.failed_login_attempts = 0
|
|
user.locked_until = None
|
|
db.add(UserAction(user_id=user.id, action='password_change'))
|
|
if first_password_change:
|
|
db.add(UserAction(user_id=user.id, action='guide_start'))
|
|
db.commit()
|
|
except SQLAlchemyError:
|
|
db.rollback()
|
|
return _account_page(request, db, user, error='Не удалось сохранить пароль. Попробуйте ещё раз', status=503)
|
|
response = RedirectResponse('/admin/account?password_changed=1', status_code=303)
|
|
response.set_cookie(SESSION_COOKIE, sign_session(user.username, settings, user.session_version),
|
|
httponly=True, samesite='lax', max_age=SESSION_SECONDS, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
|
|
return response
|
|
|
|
|
|
@router.get('/guide')
|
|
def guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
|
require_user(request, settings, db)
|
|
return _render(request, 'guide.html', {'guide_page': True})
|
|
|
|
|
|
@router.post('/guide/dismiss')
|
|
def dismiss_guide(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
|
user = require_user(request, settings, db, lock=True)
|
|
try:
|
|
latest = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id,
|
|
UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1))
|
|
if latest == 'guide_start':
|
|
db.add(UserAction(user_id=user.id, action='guide_finish'))
|
|
db.commit()
|
|
except SQLAlchemyError:
|
|
db.rollback()
|
|
raise HTTPException(503, 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз')
|
|
if request.headers.get('accept') == 'application/json':
|
|
return Response(status_code=204)
|
|
return RedirectResponse('/admin', status_code=303)
|