492 lines
21 KiB
Python
492 lines
21 KiB
Python
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") |