feat: remove register, add evo webhooks, admin view-as user

- Remove /register route and nav links (users created via Evotor webhook)
- Fix evotor_webhooks.py: use phone=None instead of phone="" to avoid unique constraint
- Add admin "view as user" feature: POST /admin/users/{id}/view-as sets viewed_user_id
  in session; POST /admin/view-as/stop clears it
- catalog, vk_catalog, sync, connections GET routes use get_viewed_user() so admins
  see another user's data while browsing
- Orange banner shown at top when admin is viewing as another user

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-05-13 20:44:25 +03:00
parent 1729ff9b7b
commit 5e7be16755
11 changed files with 113 additions and 96 deletions

View File

@@ -3,7 +3,7 @@ from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.requests import Request from starlette.requests import Request
from web.models.user import User, UserStatusEnum from web.models.user import User, UserRoleEnum, UserStatusEnum
def get_session_user_id(request: Request) -> int | None: def get_session_user_id(request: Request) -> int | None:
@@ -21,5 +21,22 @@ def get_current_user(request: Request, db: Session) -> User:
return user return user
def get_viewed_user(request: Request, db: Session) -> tuple[User, User]:
"""Return (real_user, viewed_user).
Admins/system users can view another user's data by having
`viewed_user_id` set in the session (via /admin/users/{id}/view-as).
For regular users, both values are the same.
"""
real_user = get_current_user(request, db)
is_admin = real_user.role in (UserRoleEnum.admin, UserRoleEnum.system)
viewed_id = request.session.get("viewed_user_id") if is_admin else None
if viewed_id:
viewed = db.get(User, viewed_id)
if viewed:
return real_user, viewed
return real_user, real_user
def login_redirect() -> RedirectResponse: def login_redirect() -> RedirectResponse:
return RedirectResponse("/login", status_code=303) return RedirectResponse("/login", status_code=303)

View File

@@ -101,6 +101,30 @@ async def admin_user_detail(user_id: int, request: Request, db: Session = Depend
return _render(request, "admin/user_detail.html", {"user": admin, "target": target}) return _render(request, "admin/user_detail.html", {"user": admin, "target": target})
# ── View-as ───────────────────────────────────────────────────────────────────
@router.post("/users/{user_id}/view-as")
async def admin_view_as(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
target = db.get(User, user_id)
if target:
request.session["viewed_user_id"] = user_id
return RedirectResponse("/connections", 303)
@router.post("/view-as/stop")
async def admin_view_as_stop(request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
request.session.pop("viewed_user_id", None)
return RedirectResponse("/admin/users", 303)
# ── User actions ────────────────────────────────────────────────────────────── # ── User actions ──────────────────────────────────────────────────────────────
@router.post("/users/{user_id}/activate") @router.post("/users/{user_id}/activate")

View File

@@ -1,11 +1,10 @@
import secrets import secrets
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth.password import hash_password, verify_password from web.auth.password import verify_password
from web.auth.session import get_session_user_id from web.auth.session import get_session_user_id
from web.config import settings from web.config import settings
from web.database import get_db from web.database import get_db
@@ -23,60 +22,13 @@ def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
@router.get("/register") @router.get("/register")
async def register_get(request: Request, db: Session = Depends(get_db)): async def register_get(request: Request):
if get_session_user_id(request): return Response(status_code=404)
return RedirectResponse("/profile", 303)
return _render(request, "register.html", {"user": None})
@router.post("/register") @router.post("/register")
async def register_post(request: Request, db: Session = Depends(get_db)): async def register_post(request: Request):
form = await request.form() return Response(status_code=404)
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") @router.get("/confirm-email")
@@ -167,4 +119,5 @@ async def logout(request: Request):
async def _hash(plain: str) -> str: async def _hash(plain: str) -> str:
import asyncio import asyncio
from web.auth.password import hash_password
return await asyncio.get_event_loop().run_in_executor(None, hash_password, plain) return await asyncio.get_event_loop().run_in_executor(None, hash_password, plain)

View File

@@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth.session import get_current_user from web.auth.session import get_current_user, get_viewed_user
from web.config import settings from web.config import settings
from web.database import get_db from web.database import get_db
from web.models.connections import CachedGroup, CachedProduct, CachedStore, SyncConfig, SyncFilter from web.models.connections import CachedGroup, CachedProduct, CachedStore, SyncConfig, SyncFilter
@@ -55,21 +55,22 @@ def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
@router.get("/catalog/stores") @router.get("/catalog/stores")
async def catalog_stores(request: Request, db: Session = Depends(get_db)): async def catalog_stores(request: Request, db: Session = Depends(get_db)):
try: try:
user = get_current_user(request, db) real_user, viewed_user = get_viewed_user(request, db)
except Exception: except Exception:
return RedirectResponse("/login", 303) return RedirectResponse("/login", 303)
stores = ( stores = (
db.query(CachedStore) db.query(CachedStore)
.filter(CachedStore.user_id == user.id) .filter(CachedStore.user_id == viewed_user.id)
.order_by(CachedStore.name) .order_by(CachedStore.name)
.all() .all()
) )
enabled_ids = _enabled_store_ids(db, user.id) enabled_ids = _enabled_store_ids(db, viewed_user.id)
return _render(request, "catalog/stores.html", { return _render(request, "catalog/stores.html", {
"user": user, "user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"stores": stores, "stores": stores,
"enabled_ids": enabled_ids, # None = all enabled, set = explicit list "enabled_ids": enabled_ids,
"refresh_interval": settings.CATALOG_REFRESH_INTERVAL_SECONDS, "refresh_interval": settings.CATALOG_REFRESH_INTERVAL_SECONDS,
}) })
@@ -77,13 +78,13 @@ async def catalog_stores(request: Request, db: Session = Depends(get_db)):
@router.get("/catalog/stores/{store_evotor_id}/groups") @router.get("/catalog/stores/{store_evotor_id}/groups")
async def catalog_groups(store_evotor_id: str, request: Request, db: Session = Depends(get_db)): async def catalog_groups(store_evotor_id: str, request: Request, db: Session = Depends(get_db)):
try: try:
user = get_current_user(request, db) real_user, viewed_user = get_viewed_user(request, db)
except Exception: except Exception:
return RedirectResponse("/login", 303) return RedirectResponse("/login", 303)
store = ( store = (
db.query(CachedStore) db.query(CachedStore)
.filter(CachedStore.user_id == user.id, CachedStore.evotor_id == store_evotor_id) .filter(CachedStore.user_id == viewed_user.id, CachedStore.evotor_id == store_evotor_id)
.first() .first()
) )
if not store: if not store:
@@ -91,22 +92,24 @@ async def catalog_groups(store_evotor_id: str, request: Request, db: Session = D
groups = ( groups = (
db.query(CachedGroup) db.query(CachedGroup)
.filter(CachedGroup.user_id == user.id, CachedGroup.store_evotor_id == store_evotor_id) .filter(CachedGroup.user_id == viewed_user.id, CachedGroup.store_evotor_id == store_evotor_id)
.order_by(CachedGroup.name) .order_by(CachedGroup.name)
.all() .all()
) )
enabled_ids = _enabled_group_ids(db, user.id, store_evotor_id) enabled_ids = _enabled_group_ids(db, viewed_user.id, store_evotor_id)
counts_q = ( counts_q = (
db.query(CachedProduct.group_evotor_id, func.count().label("cnt")) db.query(CachedProduct.group_evotor_id, func.count().label("cnt"))
.filter(CachedProduct.user_id == user.id, CachedProduct.store_evotor_id == store_evotor_id) .filter(CachedProduct.user_id == viewed_user.id, CachedProduct.store_evotor_id == store_evotor_id)
.group_by(CachedProduct.group_evotor_id) .group_by(CachedProduct.group_evotor_id)
.all() .all()
) )
product_counts = {row.group_evotor_id: row.cnt for row in counts_q} product_counts = {row.group_evotor_id: row.cnt for row in counts_q}
return _render(request, "catalog/groups.html", { return _render(request, "catalog/groups.html", {
"user": user, "store": store, "groups": groups, "user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"store": store, "groups": groups,
"enabled_ids": enabled_ids, "enabled_ids": enabled_ids,
"product_counts": product_counts, "product_counts": product_counts,
}) })
@@ -115,13 +118,13 @@ async def catalog_groups(store_evotor_id: str, request: Request, db: Session = D
@router.get("/catalog/stores/{store_evotor_id}/products") @router.get("/catalog/stores/{store_evotor_id}/products")
async def catalog_products(store_evotor_id: str, request: Request, db: Session = Depends(get_db)): async def catalog_products(store_evotor_id: str, request: Request, db: Session = Depends(get_db)):
try: try:
user = get_current_user(request, db) real_user, viewed_user = get_viewed_user(request, db)
except Exception: except Exception:
return RedirectResponse("/login", 303) return RedirectResponse("/login", 303)
store = ( store = (
db.query(CachedStore) db.query(CachedStore)
.filter(CachedStore.user_id == user.id, CachedStore.evotor_id == store_evotor_id) .filter(CachedStore.user_id == viewed_user.id, CachedStore.evotor_id == store_evotor_id)
.first() .first()
) )
if not store: if not store:
@@ -129,7 +132,7 @@ async def catalog_products(store_evotor_id: str, request: Request, db: Session =
group_id = request.query_params.get("group") group_id = request.query_params.get("group")
q = db.query(CachedProduct).filter( q = db.query(CachedProduct).filter(
CachedProduct.user_id == user.id, CachedProduct.user_id == viewed_user.id,
CachedProduct.store_evotor_id == store_evotor_id, CachedProduct.store_evotor_id == store_evotor_id,
) )
if group_id: if group_id:
@@ -138,13 +141,14 @@ async def catalog_products(store_evotor_id: str, request: Request, db: Session =
products = q.order_by(CachedProduct.name).all() products = q.order_by(CachedProduct.name).all()
groups = ( groups = (
db.query(CachedGroup) db.query(CachedGroup)
.filter(CachedGroup.user_id == user.id, CachedGroup.store_evotor_id == store_evotor_id) .filter(CachedGroup.user_id == viewed_user.id, CachedGroup.store_evotor_id == store_evotor_id)
.order_by(CachedGroup.name) .order_by(CachedGroup.name)
.all() .all()
) )
group_map = {g.evotor_id: g.name for g in groups} group_map = {g.evotor_id: g.name for g in groups}
return _render(request, "catalog/products.html", { return _render(request, "catalog/products.html", {
"user": user, "user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"store": store, "store": store,
"products": products, "products": products,
"groups": groups, "groups": groups,

View File

@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth.session import get_current_user from web.auth.session import get_current_user, get_viewed_user
from web.config import settings from web.config import settings
from web.database import get_db from web.database import get_db
from web.models.connections import EvotorConnection, VkConnection from web.models.connections import EvotorConnection, VkConnection
@@ -32,13 +32,18 @@ def _now() -> datetime:
@router.get("/connections") @router.get("/connections")
async def connections_get(request: Request, db: Session = Depends(get_db)): async def connections_get(request: Request, db: Session = Depends(get_db)):
try: try:
user = get_current_user(request, db) real_user, viewed_user = get_viewed_user(request, db)
except Exception: except Exception:
return RedirectResponse("/login", 303) return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first() evotor = db.query(EvotorConnection).filter_by(user_id=viewed_user.id).first()
vk = db.query(VkConnection).filter_by(user_id=user.id).first() vk = db.query(VkConnection).filter_by(user_id=viewed_user.id).first()
return _render(request, "connections.html", {"user": user, "evotor": evotor, "vk": vk}) return _render(request, "connections.html", {
"user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"evotor": evotor,
"vk": vk,
})
@router.post("/connections/evotor") @router.post("/connections/evotor")

View File

@@ -134,8 +134,8 @@ async def user_create(request: Request, db: Session = Depends(get_db)):
user = User( user = User(
first_name=first_name or "", first_name=first_name or "",
last_name=last_name or "", last_name=last_name or "",
email=email or f"{evotor_user_id}@evotor.placeholder", email=email or f"{evotor_user_id}@evotor.invalid",
phone=phone or "", phone=phone or None,
password_hash=None, password_hash=None,
role=UserRoleEnum.user, role=UserRoleEnum.user,
status=UserStatusEnum.pending, status=UserStatusEnum.pending,

View File

@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth.session import get_current_user from web.auth.session import get_current_user, get_viewed_user
from web.config import settings from web.config import settings
from web.database import get_db from web.database import get_db
from web.models.connections import SyncConfig from web.models.connections import SyncConfig
@@ -23,13 +23,14 @@ def _render(request: Request, ctx: dict):
@router.get("/sync") @router.get("/sync")
async def sync_get(request: Request, db: Session = Depends(get_db)): async def sync_get(request: Request, db: Session = Depends(get_db)):
try: try:
user = get_current_user(request, db) real_user, viewed_user = get_viewed_user(request, db)
except Exception: except Exception:
return RedirectResponse("/login", 303) return RedirectResponse("/login", 303)
config = db.query(SyncConfig).filter_by(user_id=user.id).first() config = db.query(SyncConfig).filter_by(user_id=viewed_user.id).first()
return _render(request, { return _render(request, {
"user": user, "user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"config": config, "config": config,
"saved": request.query_params.get("saved"), "saved": request.query_params.get("saved"),
}) })

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth.session import get_current_user from web.auth.session import get_viewed_user
from web.config import settings from web.config import settings
from web.database import get_db from web.database import get_db
from web.models.connections import VkCachedAlbum, VkCachedProduct, VkConnection from web.models.connections import VkCachedAlbum, VkCachedProduct, VkConnection
@@ -20,19 +20,20 @@ def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
@router.get("/vk-catalog/albums") @router.get("/vk-catalog/albums")
async def vk_catalog_albums(request: Request, db: Session = Depends(get_db)): async def vk_catalog_albums(request: Request, db: Session = Depends(get_db)):
try: try:
user = get_current_user(request, db) real_user, viewed_user = get_viewed_user(request, db)
except Exception: except Exception:
return RedirectResponse("/login", 303) return RedirectResponse("/login", 303)
vk_conn = db.query(VkConnection).filter_by(user_id=user.id).first() vk_conn = db.query(VkConnection).filter_by(user_id=viewed_user.id).first()
albums = ( albums = (
db.query(VkCachedAlbum) db.query(VkCachedAlbum)
.filter(VkCachedAlbum.user_id == user.id) .filter(VkCachedAlbum.user_id == viewed_user.id)
.order_by(VkCachedAlbum.title) .order_by(VkCachedAlbum.title)
.all() .all()
) )
return _render(request, "vk_catalog/albums.html", { return _render(request, "vk_catalog/albums.html", {
"user": user, "user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"albums": albums, "albums": albums,
"vk_conn": vk_conn, "vk_conn": vk_conn,
"refresh_interval": settings.CATALOG_REFRESH_INTERVAL_SECONDS, "refresh_interval": settings.CATALOG_REFRESH_INTERVAL_SECONDS,
@@ -42,22 +43,23 @@ async def vk_catalog_albums(request: Request, db: Session = Depends(get_db)):
@router.get("/vk-catalog/albums/{album_id}/products") @router.get("/vk-catalog/albums/{album_id}/products")
async def vk_catalog_products(album_id: str, request: Request, db: Session = Depends(get_db)): async def vk_catalog_products(album_id: str, request: Request, db: Session = Depends(get_db)):
try: try:
user = get_current_user(request, db) real_user, viewed_user = get_viewed_user(request, db)
except Exception: except Exception:
return RedirectResponse("/login", 303) return RedirectResponse("/login", 303)
album = db.query(VkCachedAlbum).filter_by(user_id=user.id, album_id=album_id).first() album = db.query(VkCachedAlbum).filter_by(user_id=viewed_user.id, album_id=album_id).first()
if not album: if not album:
return RedirectResponse("/vk-catalog/albums", 303) return RedirectResponse("/vk-catalog/albums", 303)
products = ( products = (
db.query(VkCachedProduct) db.query(VkCachedProduct)
.filter(VkCachedProduct.user_id == user.id, VkCachedProduct.album_id == album_id) .filter(VkCachedProduct.user_id == viewed_user.id, VkCachedProduct.album_id == album_id)
.order_by(VkCachedProduct.name) .order_by(VkCachedProduct.name)
.all() .all()
) )
return _render(request, "vk_catalog/products.html", { return _render(request, "vk_catalog/products.html", {
"user": user, "user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"album": album, "album": album,
"products": products, "products": products,
}) })

View File

@@ -96,6 +96,11 @@
<i class="bi bi-envelope me-1"></i>Отправить приглашение <i class="bi bi-envelope me-1"></i>Отправить приглашение
</button> </button>
</form> </form>
<form method="post" action="/admin/users/{{ target.id }}/view-as">
<button type="submit" class="w-100 outline">
<i class="bi bi-eye me-1"></i>Просмотр от имени пользователя
</button>
</form>
{% if user.role == 'system' and target.id != user.id %} {% if user.role == 'system' and target.id != user.id %}
<form method="post" action="/admin/users/{{ target.id }}/delete" <form method="post" action="/admin/users/{{ target.id }}/delete"
onsubmit="return confirm('Удалить пользователя {{ target.email }}? Это действие необратимо.')"> onsubmit="return confirm('Удалить пользователя {{ target.email }}? Это действие необратимо.')">

View File

@@ -28,7 +28,6 @@
<li><a href="/logout" class="secondary">Выход</a></li> <li><a href="/logout" class="secondary">Выход</a></li>
{% else %} {% else %}
<li><a href="/login">Вход</a></li> <li><a href="/login">Вход</a></li>
<li><a href="/register">Регистрация</a></li>
{% endif %} {% endif %}
</ul> </ul>
{% if user %} {% if user %}
@@ -52,13 +51,21 @@
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary> <summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
<ul> <ul>
<li><a href="/login">Вход</a></li> <li><a href="/login">Вход</a></li>
<li><a href="/register">Регистрация</a></li>
</ul> </ul>
</details> </details>
{% endif %} {% endif %}
</nav> </nav>
</header> </header>
{% if viewed_user %}
<div style="background:#e65c00;color:#fff;text-align:center;padding:0.4rem 1rem;font-size:0.9rem;">
<i class="bi bi-eye me-1"></i>Просмотр от имени: <strong>{{ viewed_user.first_name }} {{ viewed_user.last_name }}</strong> ({{ viewed_user.email }})
<form method="post" action="/admin/view-as/stop" style="display:inline;margin-left:1rem;">
<button type="submit" style="background:none;border:1px solid #fff;color:#fff;padding:0.1rem 0.6rem;font-size:0.85rem;cursor:pointer;border-radius:4px;">Выйти</button>
</form>
</div>
{% endif %}
<main class="container py-4"> <main class="container py-4">
{% if errors %} {% if errors %}
<div role="alert" class="alert alert-danger"> <div role="alert" class="alert alert-danger">

View File

@@ -17,8 +17,7 @@
<button type="submit" class="w-100">Войти</button> <button type="submit" class="w-100">Войти</button>
</form> </form>
<div class="text-center small mt-3"> <div class="text-center small mt-3">
<a href="/forgot-password">Забыли пароль?</a><br> <a href="/forgot-password">Забыли пароль?</a>
<a href="/register">Зарегистрироваться</a>
</div> </div>
</div> </div>
</article> </article>