feat: Evotor user lifecycle, RBAC, admin panel

- Receive Evotor webhooks: POST /user/create, /user/verify, /user/token
- Create users in pending status; match to existing users by email/phone
- Send invite link via Celery notification task; user sets password at /invite
- Abstract EmailProvider/SMSProvider with ConsoleEmailProvider default
- Role-based access control: role enum on users + roles/permissions tables
- Admin panel: /admin/users (list, filter, search, paginate), user detail card
  with activate/suspend/reset-password/send-invite/edit/delete actions
- Admin roles management: /admin/roles with per-role permission assignment
- Extend user profile card: role, status, Evotor ID, email confirmation badge
- Auth routes: register, login, logout, confirm-email, forgot/reset password
- Alembic migrations 0002 (full schema + new fields) and 0003 (RBAC + seeds)
- Port Pico CSS + Bootstrap Icons UI from Node.js commit (854c912)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-04-28 12:01:25 +03:00
parent ba34adbbcf
commit 5ead89e0cf
44 changed files with 3101 additions and 3 deletions

0
web/routes/__init__.py Normal file
View File

271
web/routes/admin.py Normal file
View File

@@ -0,0 +1,271 @@
import secrets
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from web.auth.password import hash_password
from web.auth.rbac import require_role
from web.auth.session import get_current_user
from web.config import settings
from web.database import get_db
from web.models.rbac import Permission, Role, UserRole, role_permissions
from web.models.user import User, UserRoleEnum, UserStatusEnum
from web.notifications.tasks import send_email_task
from web.templates_env import templates
router = APIRouter(prefix="/admin")
PAGE_SIZE = 25
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(template, ctx)
def _admin_user(request: Request, db: Session) -> User:
"""Get current user and verify admin/system role."""
try:
user = get_current_user(request, db)
except Exception:
raise
if user.role not in (UserRoleEnum.admin, UserRoleEnum.system):
from fastapi import HTTPException
raise HTTPException(403, "Недостаточно прав")
return user
# ── User list ─────────────────────────────────────────────────────────────────
@router.get("/users")
async def admin_users(request: Request, db: Session = Depends(get_db)):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
q = db.query(User)
search = request.query_params.get("search", "").strip()
status_filter = request.query_params.get("status", "")
role_filter = request.query_params.get("role", "")
page = max(1, int(request.query_params.get("page", 1)))
if search:
q = q.filter(
(User.first_name.ilike(f"%{search}%")) |
(User.last_name.ilike(f"%{search}%")) |
(User.email.ilike(f"%{search}%")) |
(User.phone.ilike(f"%{search}%"))
)
if status_filter:
try:
q = q.filter(User.status == UserStatusEnum(status_filter))
except ValueError:
pass
if role_filter:
try:
q = q.filter(User.role == UserRoleEnum(role_filter))
except ValueError:
pass
total = q.count()
users = q.order_by(User.created_at.desc()).offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).all()
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
return _render(request, "admin/users.html", {
"user": admin,
"users": users,
"search": search,
"status_filter": status_filter,
"role_filter": role_filter,
"page": page,
"total_pages": total_pages,
"total": total,
})
# ── User detail ───────────────────────────────────────────────────────────────
@router.get("/users/{user_id}")
async def admin_user_detail(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
target = db.get(User, user_id)
if not target:
return RedirectResponse("/admin/users", 303)
return _render(request, "admin/user_detail.html", {"user": admin, "target": target})
# ── User actions ──────────────────────────────────────────────────────────────
@router.post("/users/{user_id}/activate")
async def admin_activate(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if user:
user.status = UserStatusEnum.active
db.commit()
return RedirectResponse(f"/admin/users/{user_id}", 303)
@router.post("/users/{user_id}/suspend")
async def admin_suspend(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if user:
user.status = UserStatusEnum.suspended
db.commit()
return RedirectResponse(f"/admin/users/{user_id}", 303)
@router.post("/users/{user_id}/reset-password")
async def admin_reset_password(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if user:
token = secrets.token_urlsafe(32)
user.password_reset_token = token
user.password_reset_expires = datetime.utcnow() + timedelta(
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
)
db.commit()
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
html = f'<p>Сброс пароля (запрошен администратором): <a href="{reset_url}">{reset_url}</a></p>'
send_email_task.delay(user.email, "Сброс пароля — ЭВОСИНК", html)
return RedirectResponse(f"/admin/users/{user_id}?success=reset_sent", 303)
@router.post("/users/{user_id}/send-invite")
async def admin_send_invite(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if user:
token = secrets.token_urlsafe(32)
user.invite_token = token
user.invite_expires = datetime.utcnow() + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
db.commit()
invite_url = f"{settings.BASE_URL}/invite?token={token}"
html = (
f"<p>Вам отправлено приглашение в ЭВОСИНК.</p>"
f'<p><a href="{invite_url}">{invite_url}</a></p>'
f"<p>Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.</p>"
)
send_email_task.delay(user.email, "Приглашение в ЭВОСИНК", html)
return RedirectResponse(f"/admin/users/{user_id}?success=invite_sent", 303)
@router.post("/users/{user_id}/edit")
async def admin_edit_user(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if not user:
return RedirectResponse("/admin/users", 303)
form = await request.form()
data = {k: str(v).strip() for k, v in form.items()}
errors = []
if not data.get("first_name"):
errors.append("Имя обязательно")
if not data.get("last_name"):
errors.append("Фамилия обязательна")
if errors:
return _render(request, "admin/user_detail.html", {
"user": admin, "target": user, "errors": errors,
})
user.first_name = data["first_name"]
user.last_name = data["last_name"]
if data.get("email"):
user.email = data["email"]
if data.get("phone"):
user.phone = data["phone"]
if data.get("role") and admin.role == UserRoleEnum.system:
try:
user.role = UserRoleEnum(data["role"])
except ValueError:
pass
db.commit()
return RedirectResponse(f"/admin/users/{user_id}?success=saved", 303)
@router.post("/users/{user_id}/delete")
async def admin_delete_user(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
if admin.role != UserRoleEnum.system:
return RedirectResponse(f"/admin/users/{user_id}", 303)
user = db.get(User, user_id)
if user:
db.delete(user)
db.commit()
return RedirectResponse("/admin/users", 303)
# ── Roles ─────────────────────────────────────────────────────────────────────
@router.get("/roles")
async def admin_roles(request: Request, db: Session = Depends(get_db)):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
if admin.role != UserRoleEnum.system:
return RedirectResponse("/admin/users", 303)
roles = db.query(Role).order_by(Role.id).all()
permissions = db.query(Permission).order_by(Permission.name).all()
role_perm_ids: dict[int, set[int]] = {}
for role in roles:
rows = db.execute(
role_permissions.select().where(role_permissions.c.role_id == role.id)
).fetchall()
role_perm_ids[role.id] = {r.permission_id for r in rows}
return _render(request, "admin/roles.html", {
"user": admin, "roles": roles, "permissions": permissions,
"role_perm_ids": role_perm_ids,
})
@router.post("/roles/{role_id}/permissions")
async def admin_update_role_permissions(
role_id: int, request: Request, db: Session = Depends(get_db)
):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
if admin.role != UserRoleEnum.system:
return RedirectResponse("/admin/roles", 303)
form = await request.form()
selected_ids = {int(v) for k, v in form.items() if k.startswith("perm_")}
# Remove all existing, re-insert selected
db.execute(role_permissions.delete().where(role_permissions.c.role_id == role_id))
for perm_id in selected_ids:
db.execute(role_permissions.insert().values(role_id=role_id, permission_id=perm_id))
db.commit()
return RedirectResponse("/admin/roles", 303)

170
web/routes/auth.py Normal file
View File

@@ -0,0 +1,170 @@
import secrets
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from web.auth.password import hash_password, verify_password
from web.auth.session import get_session_user_id
from web.config import settings
from web.database import get_db
from web.models.user import User, UserStatusEnum
from web.notifications.tasks import send_email_task
from web.templates_env import templates
router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(template, ctx)
@router.get("/register")
async def register_get(request: Request, db: Session = Depends(get_db)):
if get_session_user_id(request):
return RedirectResponse("/profile", 303)
return _render(request, "register.html", {"user": None})
@router.post("/register")
async def register_post(request: Request, db: Session = Depends(get_db)):
form = await request.form()
data = {k: str(v).strip() for k, v in form.items()}
errors = []
if not data.get("email"):
errors.append("Email обязателен")
if not data.get("phone"):
errors.append("Телефон обязателен")
if not data.get("password"):
errors.append("Пароль обязателен")
if len(data.get("password", "")) < 8:
errors.append("Пароль должен содержать минимум 8 символов")
if data.get("password") != data.get("password_confirm"):
errors.append("Пароли не совпадают")
if not errors:
existing = db.query(User).filter(
or_(User.email == data["email"], User.phone == data["phone"])
).first()
if existing:
if existing.email == data["email"]:
errors.append("Пользователь с таким email уже существует")
else:
errors.append("Пользователь с таким телефоном уже существует")
if errors:
return _render(request, "register.html", {"user": None, "errors": errors, "form": data})
token = secrets.token_urlsafe(32)
user = User(
first_name=data.get("first_name", ""),
last_name=data.get("last_name", ""),
email=data["email"],
phone=data["phone"],
password_hash=await _hash(data["password"]),
email_confirm_token=token,
status=UserStatusEnum.pending,
)
db.add(user)
db.commit()
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
html = f'<p>Подтвердите email: <a href="{confirm_url}">{confirm_url}</a></p>'
send_email_task.delay(user.email, "Подтвердите ваш email — ЭВОСИНК", html)
return _render(request, "confirm_email.html", {"user": None})
@router.get("/confirm-email")
async def confirm_email(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token")
if not token:
return _render(request, "message.html", {
"user": None, "title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
"link": "/login", "link_text": "Войти",
})
user = db.query(User).filter(User.email_confirm_token == token).first()
if not user:
return _render(request, "message.html", {
"user": None, "title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
"link": "/login", "link_text": "Войти",
})
user.is_email_confirmed = True
user.email_confirm_token = None
user.status = UserStatusEnum.active
db.commit()
return _render(request, "email_confirmed.html", {"user": None})
@router.get("/resend-confirm")
async def resend_confirm(request: Request, db: Session = Depends(get_db)):
user_id = get_session_user_id(request)
if not user_id:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if not user or user.is_email_confirmed:
return RedirectResponse("/profile", 303)
token = secrets.token_urlsafe(32)
user.email_confirm_token = token
db.commit()
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
html = f'<p>Подтвердите email: <a href="{confirm_url}">{confirm_url}</a></p>'
send_email_task.delay(user.email, "Подтвердите ваш email — ЭВОСИНК", html)
return _render(request, "message.html", {
"user": user,
"title": "Письмо отправлено",
"message": "Проверьте почту и нажмите на ссылку для подтверждения.",
"link": "/profile", "link_text": "Назад",
})
@router.get("/login")
async def login_get(request: Request, db: Session = Depends(get_db)):
if get_session_user_id(request):
return RedirectResponse("/profile", 303)
return _render(request, "login.html", {"user": None})
@router.post("/login")
async def login_post(request: Request, db: Session = Depends(get_db)):
form = await request.form()
email = str(form.get("email", "")).strip()
password = str(form.get("password", ""))
errors = []
if not email:
errors.append("Email обязателен")
if not password:
errors.append("Пароль обязателен")
if not errors:
user = db.query(User).filter(User.email == email).first()
if not user or not user.password_hash or not verify_password(password, user.password_hash):
errors.append("Неверный email или пароль")
elif user.status == UserStatusEnum.suspended:
errors.append("Ваш аккаунт заблокирован. Обратитесь к администратору.")
elif not user.is_email_confirmed:
errors.append("Пожалуйста, подтвердите ваш email")
if errors:
return _render(request, "login.html", {
"user": None, "errors": errors, "form": {"email": email},
})
request.session["user_id"] = user.id
return RedirectResponse("/profile", 303)
@router.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", 303)
async def _hash(plain: str) -> str:
import asyncio
return await asyncio.get_event_loop().run_in_executor(None, hash_password, plain)

View File

@@ -0,0 +1,269 @@
"""
Evotor webhook endpoints.
POST /user/create — Evotor creates a new subscriber; we create/link a local user and return a token.
POST /user/verify — Evotor verifies credentials for a user trying to log in via the Evotor interface.
POST /user/token — Evotor sends us its own API token for the user.
"""
import json
import logging
import secrets
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from web.auth.password import verify_password
from web.config import settings
from web.database import get_db
from web.models.connections import EvotorConnection
from web.models.user import User, UserRoleEnum, UserStatusEnum
from web.notifications.tasks import send_email_task
logger = logging.getLogger(__name__)
router = APIRouter()
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
def _verify_secret(request: Request) -> bool:
secret = settings.EVOTOR_WEBHOOK_SECRET
if not secret:
return True # dev mode: no secret configured
auth = request.headers.get("Authorization", "")
return auth == f"Bearer {secret}"
def _parse_custom_fields(raw: Any) -> dict:
"""Extract known fields from Evotor customField (may be JSON string or dict)."""
if raw is None:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
except (json.JSONDecodeError, ValueError):
pass
return {}
def _upsert_evotor_connection(
db: Session,
user_id: int | None,
evotor_user_id: str,
access_token: str | None = None,
) -> str:
"""Create or update an evotor_connections row; always regenerates api_token."""
api_token = secrets.token_urlsafe(32)
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == evotor_user_id
).first()
now = datetime.utcnow()
if conn:
conn.api_token = api_token
if user_id is not None:
conn.user_id = user_id
if access_token:
conn.access_token = access_token
conn.updated_at = now
else:
conn = EvotorConnection(
user_id=user_id,
evotor_user_id=evotor_user_id,
access_token=access_token or "",
api_token=api_token,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.flush()
return api_token
@router.post("/user/create")
async def user_create(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
evotor_user_id: str = body.get("userId", "")
if not evotor_user_id:
return JSONResponse({"error": "userId required"}, status_code=400)
custom = _parse_custom_fields(body.get("customField"))
email = (custom.get("email") or "").strip().lower() or None
phone = (custom.get("phone") or "").strip() or None
first_name = (custom.get("first_name") or custom.get("firstName") or "").strip() or None
last_name = (custom.get("last_name") or custom.get("lastName") or "").strip() or None
# Try to find existing user
user: User | None = None
# 1. By evotor_user_id
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
# 2. By email
if user is None and email:
user = db.query(User).filter(User.email == email).first()
# 3. By phone
if user is None and phone:
user = db.query(User).filter(User.phone == phone).first()
now = datetime.utcnow()
if user:
# Link Evotor to existing user
user.evotor_user_id = evotor_user_id
user.evotor_meta = custom or body
if user.status == UserStatusEnum.pending:
user.status = UserStatusEnum.active
db.flush()
else:
# Create new pending user
user = User(
first_name=first_name or "",
last_name=last_name or "",
email=email or f"{evotor_user_id}@evotor.placeholder",
phone=phone or "",
password_hash=None,
role=UserRoleEnum.user,
status=UserStatusEnum.pending,
evotor_user_id=evotor_user_id,
evotor_meta=custom or body,
created_at=now,
updated_at=now,
)
db.add(user)
db.flush() # get user.id
# Generate invite
invite_token = secrets.token_urlsafe(32)
user.invite_token = invite_token
user.invite_expires = now + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
api_token = _upsert_evotor_connection(db, user.id, evotor_user_id)
db.commit()
# Send invite email if we have a real email address
if email:
invite_url = f"{settings.BASE_URL}/invite?token={invite_token}"
html = (
f"<p>Здравствуйте!</p>"
f"<p>Вам открыт доступ к ЭВОСИНК. Завершите регистрацию по ссылке:</p>"
f'<p><a href="{invite_url}">{invite_url}</a></p>'
f"<p>Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.</p>"
)
send_email_task.delay(email, "Приглашение в ЭВОСИНК", html)
else:
logger.info("No email for evotor_user_id=%s, invite URL: %s/invite?token=%s",
evotor_user_id, settings.BASE_URL, invite_token)
return JSONResponse({"userId": evotor_user_id, "token": api_token})
@router.post("/user/verify")
async def user_verify(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
evotor_user_id: str = body.get("userId", "")
username: str = body.get("username", "").strip()
password: str = body.get("password", "")
if not username or not password:
return JSONResponse({"error": "username and password required"}, status_code=400)
# username is email or phone
user = db.query(User).filter(
or_(User.email == username, User.phone == username)
).first()
if not user or not user.password_hash:
return JSONResponse({"error": "Неверные данные"}, status_code=401)
if user.status == UserStatusEnum.suspended:
return JSONResponse({"error": "Аккаунт заблокирован"}, status_code=403)
if not verify_password(password, user.password_hash):
return JSONResponse({"error": "Неверные данные"}, status_code=401)
# Get or create connection to retrieve api_token
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == (user.evotor_user_id or evotor_user_id)
).first()
if not conn:
# Auto-link: create connection with Evotor userId from request
if evotor_user_id and not user.evotor_user_id:
user.evotor_user_id = evotor_user_id
db.flush()
api_token = _upsert_evotor_connection(db, user.id, evotor_user_id or (user.evotor_user_id or ""))
db.commit()
else:
api_token = conn.api_token or secrets.token_urlsafe(32)
if not conn.api_token:
conn.api_token = api_token
db.commit()
return JSONResponse({"userId": user.evotor_user_id or evotor_user_id, "token": api_token})
@router.post("/user/token")
async def user_token(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
evotor_user_id: str = body.get("userId", "")
evotor_token: str = body.get("token", "")
if not evotor_user_id or not evotor_token:
return JSONResponse({"error": "userId and token required"}, status_code=400)
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
if not user:
return JSONResponse({"error": "User not found"}, status_code=404)
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == evotor_user_id
).first()
now = datetime.utcnow()
if conn:
conn.access_token = evotor_token
conn.is_online = True
conn.last_checked_at = now
conn.updated_at = now
else:
conn = EvotorConnection(
user_id=user.id,
evotor_user_id=evotor_user_id,
access_token=evotor_token,
api_token=secrets.token_urlsafe(32),
is_online=True,
last_checked_at=now,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.commit()
return JSONResponse({})

99
web/routes/invite.py Normal file
View File

@@ -0,0 +1,99 @@
from datetime import datetime
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from web.auth.password import hash_password
from web.config import settings
from web.database import get_db
from web.models.user import User, UserStatusEnum
from web.templates_env import templates
router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(template, ctx)
def _bad_token(request: Request) -> HTMLResponse:
return _render(request, "message.html", {
"user": None,
"title": "Ссылка недействительна",
"message": "Ссылка приглашения устарела или недействительна. Обратитесь к администратору.",
"link": "/login", "link_text": "Войти",
})
@router.get("/invite")
async def invite_get(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
user = db.query(User).filter(User.invite_token == token).first()
if not user or not user.invite_expires or user.invite_expires < datetime.utcnow():
return _bad_token(request)
return _render(request, "invite.html", {"user": None, "invite_user": user, "token": token})
@router.post("/invite")
async def invite_post(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
invite_user = db.query(User).filter(User.invite_token == token).first()
if not invite_user or not invite_user.invite_expires or invite_user.invite_expires < datetime.utcnow():
return _bad_token(request)
form = await request.form()
data = {k: str(v).strip() for k, v in form.items()}
errors = []
if not data.get("first_name"):
errors.append("Имя обязательно")
if not data.get("last_name"):
errors.append("Фамилия обязательна")
if not data.get("email"):
errors.append("Email обязателен")
if not data.get("phone"):
errors.append("Телефон обязателен")
if len(data.get("password", "")) < 8:
errors.append("Пароль должен содержать минимум 8 символов")
if data.get("password") != data.get("password_confirm"):
errors.append("Пароли не совпадают")
if not errors:
# Check uniqueness (excluding current invite_user)
dup = db.query(User).filter(
or_(User.email == data["email"], User.phone == data["phone"]),
User.id != invite_user.id,
).first()
if dup:
if dup.email == data["email"]:
errors.append("Пользователь с таким email уже существует")
else:
errors.append("Пользователь с таким телефоном уже существует")
if errors:
return _render(request, "invite.html", {
"user": None, "invite_user": invite_user, "token": token,
"errors": errors, "form": data,
})
invite_user.first_name = data["first_name"]
invite_user.last_name = data["last_name"]
invite_user.email = data["email"]
invite_user.phone = data["phone"]
invite_user.password_hash = hash_password(data["password"])
invite_user.is_email_confirmed = True
invite_user.status = UserStatusEnum.active
invite_user.invite_token = None
invite_user.invite_expires = None
db.commit()
return _render(request, "message.html", {
"user": None,
"title": "Регистрация завершена!",
"message": "Ваш аккаунт активирован. Теперь вы можете войти.",
"link": "/login", "link_text": "Войти",
})

143
web/routes/profile.py Normal file
View File

@@ -0,0 +1,143 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from web.auth.password import hash_password, verify_password
from web.auth.session import get_current_user
from web.config import settings
from web.database import get_db
from web.models.user import User
from web.templates_env import templates
router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(template, ctx)
@router.get("/profile")
async def profile_view(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
return _render(request, "profile_view.html", {"user": user})
@router.get("/profile/edit")
async def profile_edit_get(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
return _render(request, "profile_edit.html", {"user": user})
@router.post("/profile/edit")
async def profile_edit_post(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
data = {k: str(v).strip() for k, v in form.items()}
errors = []
if not data.get("first_name"):
errors.append("Имя обязательно")
if not data.get("last_name"):
errors.append("Фамилия обязательна")
if not data.get("phone"):
errors.append("Телефон обязателен")
if not errors:
dup = db.query(User).filter(
User.phone == data["phone"], User.id != user.id
).first()
if dup:
errors.append("Пользователь с таким телефоном уже существует")
if errors:
return _render(request, "profile_edit.html", {"user": user, "errors": errors, "form": data})
user.first_name = data["first_name"]
user.last_name = data["last_name"]
user.phone = data["phone"]
db.commit()
return _render(request, "profile_edit.html", {
"user": user, "success": "Профиль обновлён",
})
@router.get("/profile/change-password")
async def change_pw_get(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
return _render(request, "profile_change_password.html", {"user": user})
@router.post("/profile/change-password")
async def change_pw_post(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
current = str(form.get("current_password", ""))
new_pw = str(form.get("password", ""))
confirm = str(form.get("password_confirm", ""))
errors = []
if not user.password_hash or not verify_password(current, user.password_hash):
errors.append("Неверный текущий пароль")
if len(new_pw) < 8:
errors.append("Новый пароль должен содержать минимум 8 символов")
if new_pw != confirm:
errors.append("Пароли не совпадают")
if errors:
return _render(request, "profile_change_password.html", {"user": user, "errors": errors})
user.password_hash = hash_password(new_pw)
db.commit()
return _render(request, "profile_change_password.html", {
"user": user, "success": "Пароль изменён",
})
@router.get("/profile/delete")
async def delete_get(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
return _render(request, "profile_delete.html", {"user": user})
@router.post("/profile/delete")
async def delete_post(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
password = str(form.get("password", ""))
if not user.password_hash or not verify_password(password, user.password_hash):
return _render(request, "profile_delete.html", {
"user": user, "errors": ["Неверный пароль"],
})
db.delete(user)
db.commit()
request.session.clear()
return RedirectResponse("/login", 303)

101
web/routes/reset.py Normal file
View File

@@ -0,0 +1,101 @@
import secrets
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from web.auth.password import hash_password
from web.config import settings
from web.database import get_db
from web.models.user import User
from web.notifications.tasks import send_email_task
from web.templates_env import templates
router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(template, ctx)
@router.get("/forgot-password")
async def forgot_get(request: Request):
return _render(request, "forgot_password.html", {"user": None})
@router.post("/forgot-password")
async def forgot_post(request: Request, db: Session = Depends(get_db)):
form = await request.form()
email = str(form.get("email", "")).strip()
user = db.query(User).filter(User.email == email).first()
if user:
token = secrets.token_urlsafe(32)
user.password_reset_token = token
user.password_reset_expires = datetime.utcnow() + timedelta(
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
)
db.commit()
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
html = f'<p>Сброс пароля: <a href="{reset_url}">{reset_url}</a></p>'
send_email_task.delay(user.email, "Сброс пароля — ЭВОСИНК", html)
# Always show same message to prevent user enumeration
return _render(request, "message.html", {
"user": None,
"title": "Ссылка отправлена",
"message": "Если указанный email зарегистрирован, вы получите ссылку для сброса пароля.",
"link": "/login", "link_text": "Войти",
})
@router.get("/reset-password")
async def reset_get(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
user = db.query(User).filter(User.password_reset_token == token).first()
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.utcnow():
return _render(request, "message.html", {
"user": None, "title": "Ссылка недействительна",
"message": "Ссылка для сброса пароля устарела или недействительна.",
"link": "/forgot-password", "link_text": "Запросить новую ссылку",
})
return _render(request, "reset_password.html", {"user": None, "token": token})
@router.post("/reset-password")
async def reset_post(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
form = await request.form()
password = str(form.get("password", ""))
password_confirm = str(form.get("password_confirm", ""))
errors = []
user = db.query(User).filter(User.password_reset_token == token).first()
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.utcnow():
return _render(request, "message.html", {
"user": None, "title": "Ссылка недействительна",
"message": "Ссылка для сброса пароля устарела.",
"link": "/forgot-password", "link_text": "Запросить новую ссылку",
})
if len(password) < 8:
errors.append("Пароль должен содержать минимум 8 символов")
if password != password_confirm:
errors.append("Пароли не совпадают")
if errors:
return _render(request, "reset_password.html", {
"user": None, "token": token, "errors": errors,
})
user.password_hash = hash_password(password)
user.password_reset_token = None
user.password_reset_expires = None
db.commit()
return _render(request, "message.html", {
"user": None, "title": "Пароль изменён",
"message": "Ваш пароль успешно изменён.",
"link": "/login", "link_text": "Войти",
})