import base64 import json import time from typing import List, Dict, Any, Optional from django.http import Http404, HttpResponse from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods, require_POST from django.contrib import messages from django.core.exceptions import PermissionDenied from django.conf import settings from django.views.decorators.csrf import csrf_exempt from . import api from .api import ApiError # -------- helpers (cookies/JWT) -------- def _cookie_secure() -> bool: # в dev можно False, в prod обязательно True return not getattr(settings, "DEBUG", True) def _jwt_exp_seconds(token: Optional[str], default_sec: int = 12 * 3600) -> int: """ Пытаемся вытащить exp из JWT и посчитать max_age для куки. Если не получилось — даём дефолт (12 часов). """ if not token or token.count(".") != 2: return default_sec try: payload_b64 = token.split(".")[1] payload_b64 += "=" * (-len(payload_b64) % 4) payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode()).decode("utf-8")) exp = int(payload.get("exp", 0)) now = int(time.time()) if exp > now: # добавим небольшой «запас» (минус 60 сек) return max(60, exp - now - 60) return default_sec except Exception: return default_sec def _set_auth_cookies(resp: HttpResponse, access_token: Optional[str], refresh_token: Optional[str]) -> None: """ Кладём токены в HttpOnly cookies с корректным сроком жизни. """ if access_token: resp.set_cookie( "access_token", access_token, max_age=_jwt_exp_seconds(access_token), httponly=True, samesite="Lax", secure=_cookie_secure(), path="/", ) if refresh_token: resp.set_cookie( "refresh_token", refresh_token, max_age=_jwt_exp_seconds(refresh_token, default_sec=7 * 24 * 3600), httponly=True, samesite="Lax", secure=_cookie_secure(), path="/", ) def _clear_auth_cookies(resp: HttpResponse) -> None: resp.delete_cookie("access_token", path="/") resp.delete_cookie("refresh_token", path="/") # -------- UI helpers -------- def index(request): return render(request, "ui/index.html", {}) def _auth_required_partial(request) -> HttpResponse: # Мини‑partial под HTMX для кнопки "в избранное", если нет логина return render(request, "ui/components/like_button_login_required.html", {}) def _is_admin(request) -> bool: return (request.session.get("user_role") or "").upper() == "ADMIN" def _user_to_profile_stub(u: Dict[str, Any]) -> Dict[str, Any]: name = u.get("full_name") or u.get("email") or "Без имени" return { "id": u.get("id"), "name": name, "email": u.get("email") or "", "role": (u.get("role") or "").upper() or "CLIENT", "verified": u.get("is_active", False), # ↓ ключевые правки — чтобы шаблон не генерил src="None" "age": None, "city": None, "about": "", "photo": "", # было: None "interests": [], "liked": False, } def _format_validation(payload: Optional[dict]) -> Optional[str]: """Сборка сообщений 422 ValidationError в одну строку.""" if not payload: return None det = payload.get("detail") if isinstance(det, list): msgs = [] for item in det: msg = item.get("msg") loc = item.get("loc") if isinstance(loc, list) and loc: field = ".".join(str(x) for x in loc if isinstance(x, (str, int))) if field and field not in ("body",): msgs.append(f"{field}: {msg}") else: msgs.append(msg) elif msg: msgs.append(msg) return "; ".join(m for m in msgs if m) return payload.get("message") or payload.get("detail") # ---------------- Auth ---------------- @require_http_methods(["GET", "POST"]) def login_view(request): if request.method == "POST": email = (request.POST.get("email") or "").strip() password = (request.POST.get("password") or "").strip() if not email or not password: messages.error(request, "Укажите email и пароль") else: try: token_pair = api.login(request, email, password) # POST /auth/v1/token access = token_pair.get("access_token") refresh = token_pair.get("refresh_token") or token_pair.get("refresh") if not access: raise ApiError(0, "API не вернул access_token") # Пока храним и в сессии, и в куки (куки — для твоей задачи; сессия — на случай refresh внутри запроса) request.session["access_token"] = access if refresh: request.session["refresh_token"] = refresh me = api.get_current_user(request) # GET /auth/v1/me request.session["user_id"] = me.get("id") or me.get("user_id") request.session["user_email"] = me.get("email") request.session["user_full_name"] = me.get("full_name") or me.get("email") request.session["user_role"] = me.get("role") or "CLIENT" request.session.modified = True user_label = request.session.get("user_full_name") or request.session.get("user_email") or "пользователь" messages.success(request, f"Здравствуйте, {user_label}!") next_url = request.GET.get("next") if not next_url: next_url = "profiles" if _is_admin(request) else "cabinet" resp = redirect(next_url) _set_auth_cookies(resp, access, refresh) return resp except PermissionDenied: messages.error(request, "Неверные учётные данные") except ApiError as e: messages.error(request, f"Ошибка входа: {e}") return render(request, "ui/login.html", {}) @require_http_methods(["GET", "POST"]) def register_view(request): """ Регистрация → авто‑логин → установка токенов в cookies → редирект. """ if request.method == "POST": email = (request.POST.get("email") or "").strip() password = (request.POST.get("password") or "").strip() full_name = (request.POST.get("full_name") or "").strip() or None if not email or not password: messages.error(request, "Укажите email и пароль") else: try: api.register_user(request, email=email, password=password, full_name=full_name, role='CLIENT') token_pair = api.login(request, email=email, password=password) access = token_pair.get("access_token") refresh = token_pair.get("refresh_token") or token_pair.get("refresh") if not access: raise ApiError(0, "API не вернул access_token после регистрации") request.session["access_token"] = access if refresh: request.session["refresh_token"] = refresh me = api.get_current_user(request) request.session["user_id"] = me.get("id") or me.get("user_id") request.session["user_email"] = me.get("email") request.session["user_full_name"] = me.get("full_name") or me.get("email") request.session["user_role"] = me.get("role") or "CLIENT" request.session.modified = True messages.success(request, "Регистрация успешна! Вы вошли в систему.") next_url = request.GET.get("next") if not next_url: next_url = "profiles" if _is_admin(request) else "cabinet" resp = redirect(next_url) _set_auth_cookies(resp, access, refresh) return resp except ApiError as e: payload = getattr(e, "payload", None) if payload and isinstance(payload, dict): nice = _format_validation(payload) messages.error(request, nice or f"Ошибка регистрации: {e}") else: messages.error(request, f"Ошибка регистрации: {e}") except PermissionDenied: messages.info(request, "Регистрация прошла, войдите под своим email/паролем") return redirect("login") return render(request, "ui/register.html", {}) @require_http_methods(["POST", "GET"]) def logout_view(request): for key in ("access_token", "refresh_token", "user_id", "user_email", "user_full_name", "user_role", "likes"): request.session.pop(key, None) request.session.modified = True messages.info(request, "Вы вышли из аккаунта") resp = redirect("index") _clear_auth_cookies(resp) return resp # ---------------- Catalog (Admin‑only) ---------------- @require_http_methods(["GET"]) def profile_list(request): """ Каталог анкет (по сути — пользователей) с фильтрами и пагинацией. Доступно только ADMIN (по API /auth/v1/users; у клиента прав нет). Фильтры клиентские: q (имя/email), role, active, email_domain; сортировка; page/limit. """ if not (request.session.get("access_token") or request.COOKIES.get("access_token")): messages.info(request, "Войдите, чтобы открыть каталог") return redirect("login") if not _is_admin(request): messages.info(request, "Каталог доступен только администраторам. Перенаправляем в Кабинет.") return redirect("cabinet") # --- читаем query-параметры --- q = (request.GET.get("q") or "").strip().lower() role = (request.GET.get("role") or "").strip().upper() # CLIENT|ADMIN|"" (любой) active = request.GET.get("active") # "1"|"0"|None email_domain = (request.GET.get("domain") or "").strip().lower().lstrip("@") # сортировка: name, name_desc, email, email_desc (по умолчанию name) sort = (request.GET.get("sort") or "name").strip().lower() page = max(1, int(request.GET.get("page") or 1)) limit = min(max(1, int(request.GET.get("limit") or 20)), 200) # API максимум 200, см. спеки :contentReference[oaicite:1]{index=1} offset = (page - 1) * limit error: Optional[str] = None profiles: List[Dict[str, Any]] = [] page_info = {"page": page, "limit": limit, "has_prev": page > 1, "has_next": False} try: # Серверная пагинация есть, фильтров — нет (кроме offset/limit) → забираем страницу data = api.list_users(request, offset=offset, limit=limit) # GET /auth/v1/users :contentReference[oaicite:2]{index=2} users: List[Dict[str, Any]] = (data.get("items") if isinstance(data, dict) else data) or [] # --- клиентская фильтрация --- def keep(u: Dict[str, Any]) -> bool: if q: fn = (u.get("full_name") or "").lower() em = (u.get("email") or "").lower() if q not in fn and q not in em: return False if role and (u.get("role") or "").upper() != role: return False if active in ("1", "0"): is_act = bool(u.get("is_active")) if (active == "1" and not is_act) or (active == "0" and is_act): return False if email_domain: em = (u.get("email") or "").lower() dom = em.split("@")[-1] if "@" in em else "" if dom != email_domain: return False return True users = [u for u in users if keep(u)] # --- сортировка --- def key_name(u): return (u.get("full_name") or u.get("email") or "").lower() def key_email(u): return (u.get("email") or "").lower() if sort == "name": users.sort(key=key_name) elif sort == "name_desc": users.sort(key=key_name, reverse=True) elif sort == "email": users.sort(key=key_email) elif sort == "email_desc": users.sort(key=key_email, reverse=True) # Преобразуем в «анкеты» profiles = [_user_to_profile_stub(u) for u in users] # отметка лайков (локальная сессия) liked_ids = set(request.session.get("likes", [])) for p in profiles: p["liked"] = p.get("id") in liked_ids # has_next — на глаз: если сервер отдал ровно limit без наших фильтров, # считаем, что следующая страница потенциально есть page_info["has_next"] = (len(users) == limit and not (q or role or active in ("1","0") or email_domain)) # (если включены клиентские фильтры — не знаем полный объём; оставим conservative False) except PermissionDenied as e: messages.error(request, f"Нет доступа к каталогу: {e}") return redirect("cabinet") except ApiError as e: error = str(e) ctx = { "profiles": profiles, "filters": { "q": (request.GET.get("q") or "").strip(), "role": role, "active": (active or ""), "domain": (request.GET.get("domain") or "").strip(), "sort": sort, "page": page, "limit": limit, }, "count": len(profiles), "page": page_info, "error": error, "page_sizes": [10, 20, 50, 100, 200], } return render(request, "ui/profiles_list.html", ctx) @require_http_methods(["GET"]) def profile_detail(request, pk: str): """ Детальная карточка пользователя — тоже ADMIN‑only. """ if not (request.session.get("access_token") or request.COOKIES.get("access_token")): return redirect("login") if not _is_admin(request): messages.info(request, "Детали пользователей доступны только администраторам.") return redirect("cabinet") try: user = api.get_user(request, user_id=str(pk)) except PermissionDenied as e: messages.error(request, f"Нет доступа: {e}") return redirect("cabinet") except ApiError as e: if e.status == 404: raise Http404("Пользователь не найден") messages.error(request, str(e)) return redirect("profiles") profile = _user_to_profile_stub(user) liked_ids = set(request.session.get("likes", [])) liked = profile.get("id") in liked_ids return render(request, "ui/profile_detail.html", {"profile": profile, "liked": liked}) @require_POST def like_profile(request, pk: str): """ Локальный «лайк» (для демо). Не требует API. """ if not (request.session.get("access_token") or request.COOKIES.get("access_token")): return _auth_required_partial(request) likes = set(request.session.get("likes", [])) if str(pk) in likes: likes.remove(str(pk)) liked = False else: likes.add(str(pk)) liked = True request.session["likes"] = list(likes) request.session.modified = True return render(request, "ui/components/like_button.html", {"profile_id": pk, "liked": liked}) # ---------------- Кабинет: мой профиль ---------------- @require_http_methods(["GET", "POST"]) def cabinet_view(request): """ Мой профиль: - GET: /profiles/v1/profiles/me; если 404 — пустая форма создания - POST: /profiles/v1/profiles (создать/заполнить свой профиль) """ if not (request.session.get("access_token") or request.COOKIES.get("access_token")): messages.info(request, "Для доступа к кабинету войдите в систему") return redirect("login") if request.method == "POST": gender = (request.POST.get("gender") or "").strip() city = (request.POST.get("city") or "").strip() languages = [s.strip() for s in (request.POST.get("languages") or "").split(",") if s.strip()] interests = [s.strip() for s in (request.POST.get("interests") or "").split(",") if s.strip()] if not gender or not city: messages.error(request, "Укажите пол и город") else: try: profile = api.create_my_profile(request, gender=gender, city=city, languages=languages, interests=interests) messages.success(request, "Профиль создан") return render(request, "ui/cabinet.html", {"profile": profile, "has_profile": True}) except PermissionDenied: messages.error(request, "Сессия истекла, войдите снова") return redirect("login") except ApiError as e: payload = getattr(e, "payload", None) nice = _format_validation(payload) if isinstance(payload, dict) else None messages.error(request, nice or f"Ошибка сохранения профиля: {e}") # GET try: profile = api.get_my_profile(request) # шапка кабинета — имя из сессии (или email) header_name = request.session.get("user_full_name") or request.session.get("user_email") or "" return render(request, "ui/cabinet.html", {"profile": profile, "has_profile": True, "header_name": header_name}) except ApiError as e: if e.status == 404: header_name = request.session.get("user_full_name") or request.session.get("user_email") or "" return render(request, "ui/cabinet.html", {"profile": None, "has_profile": False, "header_name": header_name}) messages.error(request, f"Ошибка загрузки профиля: {e}") return render(request, "ui/cabinet.html", {"profile": None, "has_profile": False}) except PermissionDenied: messages.error(request, "Сессия истекла, войдите снова") return redirect("login") @require_POST def cabinet_upload_photo(request): # Требуется логин if not (request.session.get("access_token") or request.COOKIES.get("access_token")): messages.info(request, "Войдите, чтобы загрузить фото") return redirect("login") f = request.FILES.get("photo") if not f: messages.error(request, "Файл не выбран") return redirect("cabinet") if f.size and f.size > 5 * 1024 * 1024: messages.error(request, "Файл слишком большой (макс. 5 МБ)") return redirect("cabinet") try: api.upload_my_photo(request, f) messages.success(request, "Фото обновлено") except PermissionDenied: messages.error(request, "Сессия истекла, войдите снова") return redirect("login") except ApiError as e: if e.status in (404, 405): messages.error(request, "Бэкенд пока не поддерживает загрузку фото (нет эндпоинта).") else: messages.error(request, f"Ошибка загрузки: {e}") return redirect("cabinet") @require_POST def cabinet_delete_photo(request): if not (request.session.get("access_token") or request.COOKIES.get("access_token")): messages.info(request, "Войдите, чтобы удалить фото") return redirect("login") try: api.delete_my_photo(request) messages.success(request, "Фото удалено") except PermissionDenied: messages.error(request, "Сессия истекла, войдите снова") return redirect("login") except ApiError as e: if e.status in (404, 405): messages.error(request, "Удаление фото не поддерживается бэкендом.") else: messages.error(request, f"Ошибка удаления: {e}") return redirect("cabinet")