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>
2026-04-28 12:01:25 +03:00
|
|
|
"""
|
|
|
|
|
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
|
test: add test suite with 65 tests, 73% coverage
- Unit tests: password hashing, notification providers, webhook field parsing
- Integration tests: auth routes (register/login/confirm-email/logout),
invite flow, Evotor webhooks (/user/create, /user/verify, /user/token),
admin panel (access control, activate/suspend/delete/reset-password)
- conftest: SQLite in-memory engine, transactional sessions, factory-boy
factories (UserFactory with UserRoleEnum variants)
- Fix bcrypt: replace passlib (broken on Python 3.14 + bcrypt 5.x) with
direct bcrypt calls; drop passlib from requirements.txt
- Fix datetime.utcnow() deprecation across routes and tests
- Fix Jinja2 TemplateResponse signature (request as first positional arg)
- Add coverage config to pyproject.toml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:27:42 +03:00
|
|
|
from datetime import datetime, timezone, timedelta
|
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>
2026-04-28 12:01:25 +03:00
|
|
|
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()
|
test: add test suite with 65 tests, 73% coverage
- Unit tests: password hashing, notification providers, webhook field parsing
- Integration tests: auth routes (register/login/confirm-email/logout),
invite flow, Evotor webhooks (/user/create, /user/verify, /user/token),
admin panel (access control, activate/suspend/delete/reset-password)
- conftest: SQLite in-memory engine, transactional sessions, factory-boy
factories (UserFactory with UserRoleEnum variants)
- Fix bcrypt: replace passlib (broken on Python 3.14 + bcrypt 5.x) with
direct bcrypt calls; drop passlib from requirements.txt
- Fix datetime.utcnow() deprecation across routes and tests
- Fix Jinja2 TemplateResponse signature (request as first positional arg)
- Add coverage config to pyproject.toml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:27:42 +03:00
|
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
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>
2026-04-28 12:01:25 +03:00
|
|
|
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()
|
|
|
|
|
|
test: add test suite with 65 tests, 73% coverage
- Unit tests: password hashing, notification providers, webhook field parsing
- Integration tests: auth routes (register/login/confirm-email/logout),
invite flow, Evotor webhooks (/user/create, /user/verify, /user/token),
admin panel (access control, activate/suspend/delete/reset-password)
- conftest: SQLite in-memory engine, transactional sessions, factory-boy
factories (UserFactory with UserRoleEnum variants)
- Fix bcrypt: replace passlib (broken on Python 3.14 + bcrypt 5.x) with
direct bcrypt calls; drop passlib from requirements.txt
- Fix datetime.utcnow() deprecation across routes and tests
- Fix Jinja2 TemplateResponse signature (request as first positional arg)
- Add coverage config to pyproject.toml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:27:42 +03:00
|
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
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>
2026-04-28 12:01:25 +03:00
|
|
|
|
|
|
|
|
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()
|
test: add test suite with 65 tests, 73% coverage
- Unit tests: password hashing, notification providers, webhook field parsing
- Integration tests: auth routes (register/login/confirm-email/logout),
invite flow, Evotor webhooks (/user/create, /user/verify, /user/token),
admin panel (access control, activate/suspend/delete/reset-password)
- conftest: SQLite in-memory engine, transactional sessions, factory-boy
factories (UserFactory with UserRoleEnum variants)
- Fix bcrypt: replace passlib (broken on Python 3.14 + bcrypt 5.x) with
direct bcrypt calls; drop passlib from requirements.txt
- Fix datetime.utcnow() deprecation across routes and tests
- Fix Jinja2 TemplateResponse signature (request as first positional arg)
- Add coverage config to pyproject.toml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:27:42 +03:00
|
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
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>
2026-04-28 12:01:25 +03:00
|
|
|
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({})
|