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:
0
web/routes/__init__.py
Normal file
0
web/routes/__init__.py
Normal file
271
web/routes/admin.py
Normal file
271
web/routes/admin.py
Normal 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
170
web/routes/auth.py
Normal 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)
|
||||
269
web/routes/evotor_webhooks.py
Normal file
269
web/routes/evotor_webhooks.py
Normal 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
99
web/routes/invite.py
Normal 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
143
web/routes/profile.py
Normal 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
101
web/routes/reset.py
Normal 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": "Войти",
|
||||
})
|
||||
Reference in New Issue
Block a user