Files
marriage_frontend/ui/views.py
2025-08-10 15:31:47 +09:00

492 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (Adminonly) ----------------
@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):
"""
Детальная карточка пользователя — тоже ADMINonly.
"""
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")