diff --git a/web/auth/session.py b/web/auth/session.py index 67c126f..e6103e3 100644 --- a/web/auth/session.py +++ b/web/auth/session.py @@ -3,7 +3,7 @@ from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session 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: @@ -21,5 +21,22 @@ def get_current_user(request: Request, db: Session) -> 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: return RedirectResponse("/login", status_code=303) diff --git a/web/routes/admin.py b/web/routes/admin.py index 15aee53..c999d4e 100644 --- a/web/routes/admin.py +++ b/web/routes/admin.py @@ -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}) +# ── 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 ────────────────────────────────────────────────────────────── @router.post("/users/{user_id}/activate") diff --git a/web/routes/auth.py b/web/routes/auth.py index 0d0657b..36d6d77 100644 --- a/web/routes/auth.py +++ b/web/routes/auth.py @@ -1,11 +1,10 @@ import secrets from fastapi import APIRouter, Depends, Request -from fastapi.responses import HTMLResponse, RedirectResponse -from sqlalchemy import or_ +from fastapi.responses import HTMLResponse, RedirectResponse, Response 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.config import settings from web.database import get_db @@ -23,60 +22,13 @@ def _render(request: Request, template: str, ctx: dict) -> HTMLResponse: @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}) +async def register_get(request: Request): + return Response(status_code=404) @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'

Подтвердите email: {confirm_url}

' - send_email_task.delay(user.email, "Подтвердите ваш email — ЭВОСИНК", html) - - return _render(request, "confirm_email.html", {"user": None}) +async def register_post(request: Request): + return Response(status_code=404) @router.get("/confirm-email") @@ -167,4 +119,5 @@ async def logout(request: Request): async def _hash(plain: str) -> str: import asyncio + from web.auth.password import hash_password return await asyncio.get_event_loop().run_in_executor(None, hash_password, plain) diff --git a/web/routes/catalog.py b/web/routes/catalog.py index 0c63d7f..f3aa4c5 100644 --- a/web/routes/catalog.py +++ b/web/routes/catalog.py @@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy import func 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.database import get_db 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") async def catalog_stores(request: Request, db: Session = Depends(get_db)): try: - user = get_current_user(request, db) + real_user, viewed_user = get_viewed_user(request, db) except Exception: return RedirectResponse("/login", 303) stores = ( db.query(CachedStore) - .filter(CachedStore.user_id == user.id) + .filter(CachedStore.user_id == viewed_user.id) .order_by(CachedStore.name) .all() ) - enabled_ids = _enabled_store_ids(db, user.id) + enabled_ids = _enabled_store_ids(db, viewed_user.id) 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, - "enabled_ids": enabled_ids, # None = all enabled, set = explicit list + "enabled_ids": enabled_ids, "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") async def catalog_groups(store_evotor_id: str, request: Request, db: Session = Depends(get_db)): try: - user = get_current_user(request, db) + real_user, viewed_user = get_viewed_user(request, db) except Exception: return RedirectResponse("/login", 303) store = ( 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() ) if not store: @@ -91,22 +92,24 @@ async def catalog_groups(store_evotor_id: str, request: Request, db: Session = D groups = ( 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) .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 = ( 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) .all() ) product_counts = {row.group_evotor_id: row.cnt for row in counts_q} 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, "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") async def catalog_products(store_evotor_id: str, request: Request, db: Session = Depends(get_db)): try: - user = get_current_user(request, db) + real_user, viewed_user = get_viewed_user(request, db) except Exception: return RedirectResponse("/login", 303) store = ( 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() ) 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") q = db.query(CachedProduct).filter( - CachedProduct.user_id == user.id, + CachedProduct.user_id == viewed_user.id, CachedProduct.store_evotor_id == store_evotor_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() groups = ( 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) .all() ) group_map = {g.evotor_id: g.name for g in groups} 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, "products": products, "groups": groups, diff --git a/web/routes/connections.py b/web/routes/connections.py index be06dbf..114c542 100644 --- a/web/routes/connections.py +++ b/web/routes/connections.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse 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.database import get_db from web.models.connections import EvotorConnection, VkConnection @@ -32,13 +32,18 @@ def _now() -> datetime: @router.get("/connections") async def connections_get(request: Request, db: Session = Depends(get_db)): try: - user = get_current_user(request, db) + real_user, viewed_user = get_viewed_user(request, db) except Exception: return RedirectResponse("/login", 303) - evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first() - vk = db.query(VkConnection).filter_by(user_id=user.id).first() - return _render(request, "connections.html", {"user": user, "evotor": evotor, "vk": vk}) + evotor = db.query(EvotorConnection).filter_by(user_id=viewed_user.id).first() + vk = db.query(VkConnection).filter_by(user_id=viewed_user.id).first() + 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") diff --git a/web/routes/evotor_webhooks.py b/web/routes/evotor_webhooks.py index d0c9c54..799e4a3 100644 --- a/web/routes/evotor_webhooks.py +++ b/web/routes/evotor_webhooks.py @@ -134,8 +134,8 @@ async def user_create(request: Request, db: Session = Depends(get_db)): user = User( first_name=first_name or "", last_name=last_name or "", - email=email or f"{evotor_user_id}@evotor.placeholder", - phone=phone or "", + email=email or f"{evotor_user_id}@evotor.invalid", + phone=phone or None, password_hash=None, role=UserRoleEnum.user, status=UserStatusEnum.pending, diff --git a/web/routes/sync.py b/web/routes/sync.py index 6e423b5..fa796b4 100644 --- a/web/routes/sync.py +++ b/web/routes/sync.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import RedirectResponse 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.database import get_db from web.models.connections import SyncConfig @@ -23,13 +23,14 @@ def _render(request: Request, ctx: dict): @router.get("/sync") async def sync_get(request: Request, db: Session = Depends(get_db)): try: - user = get_current_user(request, db) + real_user, viewed_user = get_viewed_user(request, db) except Exception: 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, { - "user": user, + "user": real_user, + "viewed_user": viewed_user if viewed_user.id != real_user.id else None, "config": config, "saved": request.query_params.get("saved"), }) diff --git a/web/routes/vk_catalog.py b/web/routes/vk_catalog.py index 46233f2..7a6279a 100644 --- a/web/routes/vk_catalog.py +++ b/web/routes/vk_catalog.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse, RedirectResponse 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.database import get_db 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") async def vk_catalog_albums(request: Request, db: Session = Depends(get_db)): try: - user = get_current_user(request, db) + real_user, viewed_user = get_viewed_user(request, db) except Exception: 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 = ( db.query(VkCachedAlbum) - .filter(VkCachedAlbum.user_id == user.id) + .filter(VkCachedAlbum.user_id == viewed_user.id) .order_by(VkCachedAlbum.title) .all() ) 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, "vk_conn": vk_conn, "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") async def vk_catalog_products(album_id: str, request: Request, db: Session = Depends(get_db)): try: - user = get_current_user(request, db) + real_user, viewed_user = get_viewed_user(request, db) except Exception: 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: return RedirectResponse("/vk-catalog/albums", 303) products = ( 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) .all() ) 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, "products": products, }) diff --git a/web/templates/admin/user_detail.html b/web/templates/admin/user_detail.html index 25c6523..0cfa501 100644 --- a/web/templates/admin/user_detail.html +++ b/web/templates/admin/user_detail.html @@ -96,6 +96,11 @@ Отправить приглашение +
+ +
{% if user.role == 'system' and target.id != user.id %}
diff --git a/web/templates/base.html b/web/templates/base.html index e3b179c..582c248 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -28,7 +28,6 @@
  • Выход
  • {% else %}
  • Вход
  • -
  • Регистрация
  • {% endif %} {% if user %} @@ -52,13 +51,21 @@ {% endif %} + {% if viewed_user %} +
    + Просмотр от имени: {{ viewed_user.first_name }} {{ viewed_user.last_name }} ({{ viewed_user.email }}) + + + +
    + {% endif %} +
    {% if errors %}