main features

This commit is contained in:
2025-08-10 17:28:38 +09:00
parent 3e4a21d5b1
commit 95bef94c53
30 changed files with 4246 additions and 1066 deletions

View File

@@ -0,0 +1,192 @@
{% load static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Кабинет</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="{% static 'style.css' %}" rel="stylesheet">
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; }
.topbar { display:flex; gap:16px; align-items:center; padding:14px 18px; background:#111827; color:#fff; }
.topbar a { color:#cfe3ff; text-decoration:none; }
.container { max-width:1100px; margin:24px auto; padding:0 16px; }
.heading { display:flex; align-items:flex-end; gap:12px; margin:8px 0 18px; }
.heading h1 { margin:0; font-size:24px; }
.muted { color:#6b7280; font-size:14px; }
.card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; box-shadow: 0 1px 2px rgba(0,0,0,.03); }
.grid { display:grid; gap:16px; }
.grid-2 { grid-template-columns: 1fr 1fr; }
.form { display:grid; gap:14px; }
.form label { font-weight:600; font-size:14px; }
.form input[type="text"], .form select, .form textarea {
width:100%; border:1px solid #d1d5db; border-radius:8px; padding:10px 12px; font:inherit; background:#fff;
}
.form small { color:#6b7280; }
.btnrow { display:flex; gap:10px; margin-top:8px; }
.btn { display:inline-block; padding:10px 14px; border-radius:10px; border:1px solid transparent; font-weight:600; cursor:pointer; }
.btn-primary { background:#2563eb; color:#fff; }
.btn-outline { background:#fff; color:#111; border-color:#d1d5db; }
.messages { list-style:none; padding:0; margin:0 0 16px; }
.messages li { padding:10px 12px; margin-bottom:8px; border-radius:10px; }
.messages li.success { background:#ecfdf5; color:#065f46; border:1px solid #a7f3d0; }
.messages li.error { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
.messages li.info { background:#eff6ff; color:#1e40af; border:1px solid #bfdbfe; }
dl { display:grid; grid-template-columns: 200px 1fr; gap:8px 14px; margin: 0; }
dt { font-weight:600; color:#374151; }
dd { margin:0; color:#111827; }
.pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
details summary { cursor:pointer; }
code, pre { background:#111827; color:#e5e7eb; padding:10px 12px; border-radius:10px; display:block; overflow:auto; }
</style>
</head>
<body>
<header class="topbar">
<div style="flex:1 1 auto;">
{% with header_name=header_name|default:request.session.user_full_name|default:request.session.user_email %}
Здравствуйте, <strong>{{ header_name }}</strong>!
{% endwith %}
</div>
<nav style="display:flex; gap:14px;">
<a href="{% url 'index' %}">Главная</a>
<a href="{% url 'cabinet' %}">Кабинет</a>
<a href="{% url 'profiles' %}">Каталог</a>
<a href="{% url 'logout' %}">Выход</a>
</nav>
</header>
<main class="container">
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li class="{{ message.tags }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="heading">
<h1>Кабинет</h1>
{% if has_profile %}
<span class="muted">профиль создан</span>
{% else %}
<span class="muted">профиль ещё не создан</span>
{% endif %}
</div>
<!-- Две колонки: Аккаунт + Профиль -->
<div class="grid grid-2">
<!-- ======= ДАННЫЕ АККАУНТА ======= -->
<section class="card">
<h2 class="muted" style="margin-top:0;">Данные аккаунта</h2>
<dl>
<dt>Имя</dt>
<dd>{{ request.session.user_full_name|default:"—" }}</dd>
<dt>Email</dt>
<dd>{{ request.session.user_email|default:"—" }}</dd>
<dt>Роль</dt>
<dd><span class="pill">{{ request.session.user_role|default:"—" }}</span></dd>
<dt>ID пользователя</dt>
<dd><code>{{ request.session.user_id|default:"—" }}</code></dd>
</dl>
</section>
<!-- ======= ДАННЫЕ ПРОФИЛЯ ======= -->
<section class="card">
<h2 class="muted" style="margin-top:0;">Данные профиля</h2>
{% if has_profile and profile %}
<dl>
<dt>Пол</dt>
<dd>{{ profile.gender|default:"—" }}</dd>
<dt>Город</dt>
<dd>{{ profile.city|default:"—" }}</dd>
<dt>Языки</dt>
<dd>
{% if profile.languages %}
{% for lang in profile.languages %}<span class="pill">{{ lang }}</span>{% endfor %}
{% else %} — {% endif %}
</dd>
<dt>Интересы</dt>
<dd>
{% if profile.interests %}
{% for it in profile.interests %}<span class="pill">{{ it }}</span>{% endfor %}
{% else %} — {% endif %}
</dd>
<dt>ID профиля</dt>
<dd><code>{{ profile.id }}</code></dd>
<dt>ID пользователя (в профиле)</dt>
<dd><code>{{ profile.user_id }}</code></dd>
</dl>
<details style="margin-top:12px;">
<summary class="muted">Показать сырой JSON профиля</summary>
<pre>{{ profile|safe }}</pre>
</details>
{% else %}
<p class="muted">Профиль ещё не создан. Заполните форму ниже.</p>
{% endif %}
</section>
</div>
{% if not has_profile or not profile %}
<!-- ======= ФОРМА СОЗДАНИЯ ПРОФИЛЯ ======= -->
<section class="card" style="margin-top:16px;" aria-labelledby="section-create">
<h2 id="section-create" class="muted" style="margin-top:0;">Создать профиль</h2>
<form class="form" method="post" action="{% url 'cabinet' %}">
{% csrf_token %}
<div class="grid grid-2">
<div>
<label for="gender">Пол</label>
<select id="gender" name="gender" required>
<option value="">— выберите —</option>
<option value="male" {% if request.POST.gender == "male" %}selected{% endif %}>Мужской</option>
<option value="female" {% if request.POST.gender == "female" %}selected{% endif %}>Женский</option>
<option value="other" {% if request.POST.gender == "other" %}selected{% endif %}>Другое</option>
</select>
</div>
<div>
<label for="city">Город</label>
<input id="city" name="city" type="text" required
value="{{ request.POST.city|default_if_none:'' }}" placeholder="Москва">
</div>
</div>
<div>
<label for="languages">Языки</label>
<input id="languages" name="languages" type="text"
value="{{ request.POST.languages|default_if_none:'' }}" placeholder="ru,en">
<small>Несколько — через запятую: <code>ru,en</code></small>
</div>
<div>
<label for="interests">Интересы</label>
<input id="interests" name="interests" type="text"
value="{{ request.POST.interests|default_if_none:'' }}" placeholder="music,travel">
<small>Несколько — через запятую: <code>music,travel</code></small>
</div>
<div class="btnrow">
<button class="btn btn-primary" type="submit">Создать профиль</button>
<a class="btn btn-outline" href="{% url 'cabinet' %}">Сбросить</a>
</div>
</form>
</section>
{% endif %}
</main>
</body>
</html>

View File

@@ -0,0 +1,17 @@
{# expects: profile_id, liked: bool #}
<form method="post"
hx-post="{% url 'like_profile' profile_id %}"
hx-target="this"
hx-swap="outerHTML"
aria-label="Добавить в избранное">
{% csrf_token %}
{% if liked %}
<button type="submit" class="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-white">
<span aria-hidden="true">❤️</span> В избранном
</button>
{% else %}
<button type="submit" class="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-white">
<span aria-hidden="true">🤍</span> В избранное
</button>
{% endif %}
</form>

View File

@@ -0,0 +1,3 @@
<a href="/login/?next={{ request.path }}" class="inline-flex items-center gap-1 rounded-md bg-indigo-600 text-white px-3 py-1.5 text-sm hover:bg-indigo-700">
Войти, чтобы добавить
</a>

View File

@@ -0,0 +1,84 @@
{% load static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Анкета пользователя</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="{% static 'style.css' %}" rel="stylesheet">
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; }
.topbar { display:flex; gap:16px; align-items:center; padding:14px 18px; background:#111827; color:#fff; }
.topbar a { color:#cfe3ff; text-decoration:none; }
.container { max-width:900px; margin:24px auto; padding:0 16px; }
.card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; }
.row { display:flex; gap:18px; align-items:center; }
.grow { flex:1 1 auto; }
.muted { color:#6b7280; font-size:14px; }
.pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
.avatar { width:96px; height:96px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:32px; background:#e5e7eb; color:#374151; }
.avatar img { width:96px; height:96px; object-fit:cover; border-radius:50%; display:block; }
dl { display:grid; grid-template-columns: 220px 1fr; gap:8px 14px; margin:0; }
dt { font-weight:600; color:#374151; }
dd { margin:0; color:#111827; }
.btn { display:inline-block; padding:9px 12px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; text-decoration:none; color:#111; }
</style>
</head>
<body>
<header class="topbar">
<div style="flex:1 1 auto;">Карточка пользователя (ADMIN)</div>
<nav style="display:flex; gap:14px;">
<a href="{% url 'profiles' %}">← Каталог</a>
<a href="{% url 'cabinet' %}">Кабинет</a>
<a href="{% url 'logout' %}">Выход</a>
</nav>
</header>
<main class="container">
<div class="card">
<div class="row">
<div class="avatar">
{% if profile.photo %}
<img src="{{ profile.photo }}" alt="">
{% else %}
{{ profile.name|first|upper }}
{% endif %}
</div>
<div class="grow">
<div style="font-weight:700; font-size:20px;">{{ profile.name }}</div>
</div>
<div>
<form method="post" action="{% url 'like_profile' profile.id %}">
{% csrf_token %}
{% include "ui/components/like_button.html" with profile_id=profile.id liked=liked %}
</form>
</div>
</div>
</div>
<div class="card" style="margin-top:16px;">
<h2 class="muted" style="margin-top:0;">Профиль</h2>
<dl>
<dt>Пол</dt><dd>{{ profile.gender|default:"—" }}</dd>
<dt>Город</dt><dd>{{ profile.city|default:"—" }}</dd>
<dt>Языки</dt>
<dd>
{% if profile.languages %}
{% for lang in profile.languages %}<span class="pill">{{ lang }}</span>{% endfor %}
{% else %} — {% endif %}
</dd>
<dt>Интересы</dt>
<dd>
{% if profile.interests %}
{% for it in profile.interests %}<span class="pill">{{ it }}</span>{% endfor %}
{% else %} — {% endif %}
</dd>
</dl>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,195 @@
{% load static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Каталог анкет</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="{% static 'style.css' %}" rel="stylesheet">
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; }
.topbar { display:flex; gap:16px; align-items:center; padding:14px 18px; background:#111827; color:#fff; }
.topbar a { color:#cfe3ff; text-decoration:none; }
.container { max-width:1100px; margin:24px auto; padding:0 16px; }
.messages { list-style:none; padding:0; margin:0 0 16px; }
.messages li { padding:10px 12px; margin-bottom:8px; border-radius:10px; }
.messages li.success { background:#ecfdf5; color:#065f46; border:1px solid #a7f3d0; }
.messages li.error { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
.messages li.info { background:#eff6ff; color:#1e40af; border:1px solid #bfdbfe; }
.filters { display:grid; grid-template-columns: repeat(8, 1fr); gap:10px; background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:14px; }
.filters .full { grid-column: 1 / -1; }
.filters input[type="text"], .filters select {
width:100%; border:1px solid #d1d5db; border-radius:8px; padding:8px 10px; font:inherit; background:#fff;
}
.btn { display:inline-block; padding:9px 12px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; }
.btn-primary { background:#2563eb; color:#fff; border-color:#2563eb; }
.list { margin-top:16px; display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:14px; }
.card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:14px; }
.row { display:flex; align-items:center; gap:12px; }
.grow { flex:1 1 auto; }
.muted { color:#6b7280; font-size:14px; }
.pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
.meta { margin-top:8px; display:flex; flex-wrap:wrap; gap:10px 18px; }
.meta .k { color:#6b7280; }
.meta .v { color:#111827; font-weight:600; }
.pagination { display:flex; gap:10px; margin:16px 0; align-items:center; }
.pagination a, .pagination span {
padding:8px 12px; border-radius:10px; border:1px solid #d1d5db; text-decoration:none; color:#111;
background:#fff;
}
.pagination .disabled { opacity:.5; pointer-events:none; }
</style>
</head>
<body>
<header class="topbar">
<div style="flex:1 1 auto;">
Каталог анкет (ADMIN)
</div>
<nav style="display:flex; gap:14px;">
<a href="{% url 'index' %}">Главная</a>
<a href="{% url 'cabinet' %}">Кабинет</a>
<a href="{% url 'profiles' %}">Каталог</a>
<a href="{% url 'logout' %}">Выход</a>
</nav>
</header>
<main class="container">
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li class="{{ message.tags }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<form class="filters" method="get" action="{% url 'profiles' %}">
<div>
<label class="muted">Поиск</label>
<input type="text" name="q" value="{{ filters.q }}" placeholder="имя или email">
</div>
<div>
<label class="muted">Роль</label>
<select name="role">
<option value="">Любая</option>
<option value="CLIENT" {% if filters.role == "CLIENT" %}selected{% endif %}>CLIENT</option>
<option value="ADMIN" {% if filters.role == "ADMIN" %}selected{% endif %}>ADMIN</option>
</select>
</div>
<div>
<label class="muted">Активность</label>
<select name="active">
<option value="">Любая</option>
<option value="1" {% if filters.active == "1" %}selected{% endif %}>Активные</option>
<option value="0" {% if filters.active == "0" %}selected{% endif %}>Неактивные</option>
</select>
</div>
<div>
<label class="muted">Домен</label>
<input type="text" name="domain" value="{{ filters.domain }}" placeholder="example.com">
</div>
<div>
<label class="muted">Сортировка</label>
<select name="sort">
<option value="name" {% if filters.sort == "name" %}selected{% endif %}>Имя ↑</option>
<option value="name_desc" {% if filters.sort == "name_desc" %}selected{% endif %}>Имя ↓</option>
<option value="email" {% if filters.sort == "email" %}selected{% endif %}>Email ↑</option>
<option value="email_desc" {% if filters.sort == "email_desc" %}selected{% endif %}>Email ↓</option>
</select>
</div>
<div>
<label class="muted">На странице</label>
<select name="limit">
{% for n in page_sizes %}
<option value="{{ n }}"{% if filters.limit == n %} selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="muted">Страница</label>
<input type="text" name="page" value="{{ filters.page }}" style="width:90px;">
</div>
<div style="display:flex; align-items:flex-end;">
<button class="btn btn-primary" type="submit">Применить</button>
</div>
</form>
<div class="pagination">
{% with q=filters.q role=filters.role active=filters.active domain=filters.domain sort=filters.sort limit=filters.limit %}
{% if page.has_prev %}
<a href="?q={{ q }}&role={{ role }}&active={{ active }}&domain={{ domain }}&sort={{ sort }}&limit={{ limit }}&page={{ page.page|add:"-1" }}">« Предыдущая</a>
{% else %}
<span class="disabled">« Предыдущая</span>
{% endif %}
<span>Стр. {{ page.page }}</span>
{% if page.has_next %}
<a href="?q={{ q }}&role={{ role }}&active={{ active }}&domain={{ domain }}&sort={{ sort }}&limit={{ limit }}&page={{ page.page|add:"1" }}">Следующая »</a>
{% else %}
<span class="disabled">Следующая »</span>
{% endif %}
{% endwith %}
</div>
{% if error %}
<div class="messages"><li class="error">{{ error }}</li></div>
{% endif %}
<div class="list">
{% for p in profiles %}
<div class="card">
<div class="row">
<div class="grow">
<div style="font-weight:700; font-size:16px;">{{ p.name }}</div>
</div>
<div>
<form method="post" action="{% url 'like_profile' p.id %}">
{% csrf_token %}
{% include "ui/components/like_button.html" with profile_id=p.id liked=p.liked %}
</form>
</div>
</div>
<div class="meta">
<div><span class="pill">{{ p.verified|yesno:"ACTIVE,INACTIVE" }}</span></div>
<div class="pill">{{ p.role|default:"USER" }}</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="grow"></div>
<a class="btn" href="{% url 'profile_detail' p.id %}">Открыть</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="muted">Ничего не найдено. Попробуйте изменить фильтры.</div>
</div>
{% endfor %}
</div>
<div class="pagination">
{% with q=filters.q role=filters.role active=filters.active domain=filters.domain sort=filters.sort limit=filters.limit %}
{% if page.has_prev %}
<a href="?q={{ q }}&role={{ role }}&active={{ active }}&domain={{ domain }}&sort={{ sort }}&limit={{ limit }}&page={{ page.page|add:"-1" }}">« Предыдущая</a>
{% else %}
<span class="disabled">« Предыдущая</span>
{% endif %}
<span>Стр. {{ page.page }}</span>
{% if page.has_next %}
<a href="?q={{ q }}&role={{ role }}&active={{ active }}&domain={{ domain }}&sort={{ sort }}&limit={{ limit }}&page={{ page.page|add:"1" }}">Следующая »</a>
{% else %}
<span class="disabled">Следующая »</span>
{% endif %}
{% endwith %}
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,527 @@
import logging
import os
import os.path
import json
import time
import uuid
from typing import Any, Dict, Optional, Tuple, List, Union
import requests
from django.conf import settings
from django.core.exceptions import PermissionDenied
logger = logging.getLogger(__name__)
# ===== Логирование / флаги =====
API_DEBUG = os.environ.get('API_DEBUG', '1') == '1'
API_LOG_BODY_MAX = int(os.environ.get('API_LOG_BODY_MAX', '2000'))
API_LOG_HEADERS = os.environ.get('API_LOG_HEADERS', '1') == '1'
API_LOG_CURL = os.environ.get('API_LOG_CURL', '0') == '1'
# Переключение базы при 404: сначала servers[0].url из openapi.json, потом жёстко http://localhost:8080
API_FALLBACK_OPENAPI_ON_404 = os.environ.get('API_FALLBACK_OPENAPI_ON_404', '1') == '1'
SENSITIVE_KEYS = {'password', 'refresh_token', 'access_token', 'authorization', 'token', 'api_key'}
def _sanitize(obj: Any) -> Any:
try:
if isinstance(obj, dict):
return {k: ('***' if isinstance(k, str) and k.lower() in SENSITIVE_KEYS else _sanitize(v))
for k, v in obj.items()}
if isinstance(obj, list):
return [_sanitize(x) for x in obj]
return obj
except Exception:
return obj
def _shorten(s: str, limit: int) -> str:
if s is None:
return ''
return s if len(s) <= limit else (s[:limit] + f'... <truncated {len(s)-limit} chars>')
def _build_curl(method: str, url: str, headers: Dict[str, str],
params: Optional[dict], json_body: Optional[dict], data: Optional[dict]) -> str:
parts = [f"curl -X '{method.upper()}' \\\n '{url}'"]
for k, v in headers.items():
if k.lower() == 'authorization':
v = 'Bearer ***'
parts.append(f" -H '{k}: {v}'")
if json_body is not None:
parts.append(" -H 'Content-Type: application/json'")
try:
body_str = json.dumps(_sanitize(json_body), ensure_ascii=False)
except Exception:
body_str = str(_sanitize(json_body))
parts.append(f" -d '{body_str}'")
elif data is not None:
try:
body_str = json.dumps(_sanitize(data), ensure_ascii=False)
except Exception:
body_str = str(_sanitize(data))
parts.append(f" --data-raw '{body_str}'")
return " \\\n".join(parts)
# ===== Пути эндпоинтов (как в swagger) =====
EP_DEFAULTS: Dict[str, str] = {
# Auth
'AUTH_REGISTER_PATH': '/auth/v1/register',
'AUTH_TOKEN_PATH': '/auth/v1/token',
'AUTH_REFRESH_PATH': '/auth/v1/refresh',
'ME_PATH': '/auth/v1/me',
'USERS_LIST_PATH': '/auth/v1/users',
'USER_DETAIL_PATH': '/auth/v1/users/{user_id}',
# Profiles
'PROFILE_ME_PATH': '/profiles/v1/profiles/me',
'PROFILES_CREATE_PATH': '/profiles/v1/profiles',
'PROFILE_PHOTO_UPLOAD_PATH': '/profiles/v1/profiles/me/photo',
'PROFILE_PHOTO_DELETE_PATH': '/profiles/v1/profiles/me/photo',
# Pairs
'PAIRS_PATH': '/match/v1/pairs',
'PAIR_DETAIL_PATH': '/match/v1/pairs/{pair_id}',
'PAIR_ACCEPT_PATH': '/match/v1/pairs/{pair_id}/accept',
'PAIR_REJECT_PATH': '/match/v1/pairs/{pair_id}/reject',
# Chat
'ROOMS_PATH': '/chat/v1/rooms',
'ROOM_DETAIL_PATH': '/chat/v1/rooms/{room_id}',
'ROOM_MESSAGES_PATH': '/chat/v1/rooms/{room_id}/messages',
# Payments
'INVOICES_PATH': '/payments/v1/invoices',
'INVOICE_DETAIL_PATH': '/payments/v1/invoices/{inv_id}',
'INVOICE_MARK_PAID_PATH': '/payments/v1/invoices/{inv_id}/mark-paid',
}
def EP(key: str) -> str:
return os.environ.get(f'API_{key}', EP_DEFAULTS[key])
# ===== База API с авто‑детектом =====
_API_BASE_CACHE: Optional[str] = None
_API_LAST_SELECT_SRC = 'DEFAULT' # для логов
def _detect_api_base_from_openapi() -> Optional[str]:
"""
Берём servers[0].url из openapi.json (по схеме — http://localhost:8080).
"""
candidates = [
os.environ.get('API_SPEC_PATH'),
getattr(settings, 'API_SPEC_PATH', None),
os.path.join(getattr(settings, 'BASE_DIR', ''), 'openapi.json') if getattr(settings, 'BASE_DIR', None) else None,
os.path.join(getattr(settings, 'BASE_DIR', ''), 'agency', 'openapi.json') if getattr(settings, 'BASE_DIR', None) else None,
'/mnt/data/openapi.json',
]
for p in candidates:
if p and os.path.isfile(p):
try:
with open(p, 'r', encoding='utf-8') as f:
spec = json.load(f)
servers = spec.get('servers') or []
if servers and isinstance(servers[0], dict):
url = servers[0].get('url')
if url:
return url
except Exception as e:
if API_DEBUG:
logger.debug('API: cannot read OpenAPI from %s: %s', p, e)
return None
def _get_api_base_url() -> str:
"""
Источники (по приоритету):
1) ENV/Settings: API_BASE_URL, затем BASE_URL
2) servers[0].url из openapi.json
3) 'http://localhost:8080'
"""
global _API_BASE_CACHE, _API_LAST_SELECT_SRC
if _API_BASE_CACHE:
return _API_BASE_CACHE
base = (os.environ.get('API_BASE_URL') or getattr(settings, 'API_BASE_URL', '')
or os.environ.get('BASE_URL') or getattr(settings, 'BASE_URL', ''))
if base:
_API_BASE_CACHE = base.rstrip('/')
_API_LAST_SELECT_SRC = 'ENV/SETTINGS'
else:
detected = _detect_api_base_from_openapi()
if detected:
_API_BASE_CACHE = detected.rstrip('/')
_API_LAST_SELECT_SRC = 'OPENAPI'
else:
_API_BASE_CACHE = 'http://localhost:8080'
_API_LAST_SELECT_SRC = 'HARDCODED'
if API_DEBUG:
logger.info("API base selected [%s]: %s", _API_LAST_SELECT_SRC, _API_BASE_CACHE)
return _API_BASE_CACHE
class ApiError(Exception):
def __init__(self, status: int, message: str = 'API error', payload: Optional[dict] = None, req_id: Optional[str] = None):
if req_id and message and 'req_id=' not in message:
message = f"{message} (req_id={req_id})"
super().__init__(message)
self.status = status
self.payload = payload or {}
self.req_id = req_id
def _base_headers(request, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""
Достаём токен сначала из сессии, затем из куки — чтобы «куки‑режим» работал без доп. настроек.
"""
headers: Dict[str, str] = {'Accept': 'application/json'}
token = request.session.get('access_token') or request.COOKIES.get('access_token')
if token:
headers['Authorization'] = f'Bearer {token}'
api_key = getattr(settings, 'API_KEY', '') or os.environ.get('API_KEY', '')
if api_key:
headers['X-API-Key'] = api_key
if extra:
headers.update(extra)
return headers
def _url(path: str) -> str:
base = _get_api_base_url()
path = path if path.startswith('/') else '/' + path
return base + path
def request_api(
request,
method: str,
path: str,
*,
params: Optional[dict] = None,
json: Optional[dict] = None,
files: Optional[dict] = None,
data: Optional[dict] = None,
) -> Tuple[int, Any]:
"""
Универсальный HTTP-вызов (упрощённая версия):
- токен/ключ в заголовках (токен берём из сессии или куки),
- auto-refresh при 401 (refresh берём тоже из сессии или куки),
- 404 → переключаем базу: servers[0].url из openapi.json → 'http://localhost:8080',
- подробные логи; БЕЗ «retry со слэшем».
"""
global _API_BASE_CACHE, _API_LAST_SELECT_SRC
req_id = uuid.uuid4().hex[:8]
base_before = _get_api_base_url()
url = _url(path)
def _do(_url: str):
headers = _base_headers(request)
if json is not None and files is None and data is None:
headers['Content-Type'] = 'application/json'
if API_DEBUG:
log_headers = _sanitize(headers) if API_LOG_HEADERS else {}
log_body = _sanitize(json if json is not None else data)
if API_LOG_CURL:
try:
curl = _build_curl(method, _url, headers, params, json, data)
logger.debug("API[req_id=%s] cURL:\n%s", req_id, curl)
except Exception:
pass
logger.info(
"API[req_id=%s] REQUEST %s %s params=%s headers=%s body=%s",
req_id, method.upper(), _url, _sanitize(params), log_headers, log_body
)
t0 = time.time()
resp = requests.request(
method=method.upper(),
url=_url,
headers=headers,
params=params,
json=json,
data=data,
files=files,
timeout=float(getattr(settings, 'API_TIMEOUT', 8.0)),
)
dt = int((time.time() - t0) * 1000)
content_type = resp.headers.get('Content-Type', '')
try:
payload = resp.json() if 'application/json' in content_type else {}
except ValueError:
payload = {}
if API_DEBUG:
body_str = ""
try:
body_str = json.dumps(_sanitize(payload), ensure_ascii=False)
except Exception:
body_str = str(_sanitize(payload))
headers_out = _sanitize(dict(resp.headers)) if API_LOG_HEADERS else {}
logger.info(
"API[req_id=%s] RESPONSE %s %sms ct=%s headers=%s body=%s",
req_id, resp.status_code, dt, content_type, headers_out, _shorten(body_str, API_LOG_BODY_MAX)
)
return resp, payload
# 1) Первый запрос
try:
resp, payload = _do(url)
except requests.RequestException as e:
logger.exception('API[req_id=%s] network error: %s', req_id, e)
raise ApiError(0, f'Network unavailable or timeout when accessing API ({e})', req_id=req_id)
# 2) 404 → переключаем базу (openapi → 8080) и повторяем
if resp.status_code == 404:
candidates: List[tuple[str, str]] = []
if API_FALLBACK_OPENAPI_ON_404:
detected = _detect_api_base_from_openapi()
if detected:
candidates.append((detected.rstrip('/'), 'OPENAPI(FAILOVER)'))
candidates.append(('http://localhost:8080', 'DEFAULT(FAILOVER)'))
for cand_base, label in candidates:
if not cand_base or cand_base == _API_BASE_CACHE:
continue
if API_DEBUG:
logger.warning("API[req_id=%s] 404 on base %s → switch API base to %s and retry",
req_id, _API_BASE_CACHE, cand_base)
_API_BASE_CACHE = cand_base
_API_LAST_SELECT_SRC = label
try:
resp, payload = _do(_url(path))
if resp.status_code != 404:
break
except requests.RequestException:
continue
# 3) 401 → refresh и повтор
refresh_token = request.session.get('refresh_token') or request.COOKIES.get('refresh_token')
if resp.status_code == 401 and refresh_token:
if API_DEBUG:
logger.info("API[req_id=%s] 401 → try refresh token", req_id)
try:
refresh_url = _url(EP('AUTH_REFRESH_PATH'))
refresh_body = {'refresh_token': refresh_token}
logger.info("API[req_id=%s] REFRESH POST %s body=%s", req_id, refresh_url, _sanitize(refresh_body))
refresh_resp = requests.post(refresh_url, json=refresh_body, timeout=float(getattr(settings, 'API_TIMEOUT', 8.0)))
if refresh_resp.status_code == 200:
try:
rj = refresh_resp.json()
except ValueError:
rj = {}
if rj.get('access_token'):
request.session['access_token'] = rj['access_token']
if rj.get('refresh_token'):
request.session['refresh_token'] = rj['refresh_token']
request.session.modified = True
if API_DEBUG:
logger.info("API[req_id=%s] REFRESH OK → retry original request", req_id)
resp, payload = _do(_url(path))
else:
logger.warning("API[req_id=%s] REFRESH failed: %s", req_id, refresh_resp.status_code)
except requests.RequestException as e:
logger.exception('API[req_id=%s] Refresh token network error: %s', req_id, e)
raise ApiError(401, f'Token refresh failed ({e})', req_id=req_id)
# 4) Ошибки
if not (200 <= resp.status_code < 300):
msg = None
if isinstance(payload, dict):
msg = payload.get('detail') or payload.get('message')
msg = msg or f'API error: {resp.status_code}'
if resp.status_code in (401, 403):
# PermissionDenied обрабатываем во view (не всегда это «выйти и войти заново»)
raise PermissionDenied(f"{msg} (req_id={req_id})")
raise ApiError(resp.status_code, msg, payload if isinstance(payload, dict) else {}, req_id=req_id)
# 5) База сменилась — отметим
base_after = _get_api_base_url()
if API_DEBUG and base_before != base_after:
logger.warning("API[req_id=%s] BASE SWITCHED: %s%s", req_id, base_before, base_after)
return resp.status_code, payload
# ==========================
# AUTH
# ==========================
def register_user(request, email: str, password: str, full_name: Optional[str] = None, role: str = 'CLIENT') -> Dict[str, Any]:
body = {'email': email, 'password': password, 'full_name': full_name, 'role': role}
_, data = request_api(request, 'POST', EP('AUTH_REGISTER_PATH'), json=body)
return data # UserRead
def login(request, email: str, password: str) -> Dict[str, Any]:
body = {'email': email, 'password': password}
_, data = request_api(request, 'POST', EP('AUTH_TOKEN_PATH'), json=body)
return data # TokenPair
def get_current_user(request) -> Dict[str, Any]:
_, data = request_api(request, 'GET', EP('ME_PATH'))
return data # UserRead
def list_users(request, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
params = {'offset': offset, 'limit': limit}
_, data = request_api(request, 'GET', EP('USERS_LIST_PATH'), params=params)
return data
def get_user(request, user_id: str) -> Dict[str, Any]:
path = EP('USER_DETAIL_PATH').format(user_id=user_id)
_, data = request_api(request, 'GET', path)
return data
def update_user(request, user_id: str, **fields) -> Dict[str, Any]:
path = EP('USER_DETAIL_PATH').format(user_id=user_id)
_, data = request_api(request, 'PATCH', path, json=fields)
return data
def delete_user(request, user_id: str) -> None:
path = EP('USER_DETAIL_PATH').format(user_id=user_id)
request_api(request, 'DELETE', path)
# ==========================
# PROFILES
# ==========================
def get_my_profile(request) -> Dict[str, Any]:
_, data = request_api(request, 'GET', EP('PROFILE_ME_PATH'))
return data # ProfileOut
def create_my_profile(request, gender: str, city: str, languages: List[str], interests: List[str]) -> Dict[str, Any]:
body = {'gender': gender, 'city': city, 'languages': languages, 'interests': interests}
_, data = request_api(request, 'POST', EP('PROFILES_CREATE_PATH'), json=body)
return data # ProfileOut
# ==========================
# PAIRS
# ==========================
def create_pair(request, user_id_a: str, user_id_b: str, score: Optional[float] = None, notes: Optional[str] = None) -> Dict[str, Any]:
body = {'user_id_a': user_id_a, 'user_id_b': user_id_b, 'score': score, 'notes': notes}
_, data = request_api(request, 'POST', EP('PAIRS_PATH'), json=body)
return data # PairRead
def list_pairs(request, for_user_id: Optional[str] = None, status: Optional[str] = None, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
params = {'for_user_id': for_user_id, 'status': status, 'offset': offset, 'limit': limit}
params = {k: v for k, v in params.items() if v is not None}
_, data = request_api(request, 'GET', EP('PAIRS_PATH'), params=params)
return data
def get_pair(request, pair_id: str) -> Dict[str, Any]:
path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id)
_, data = request_api(request, 'GET', path)
return data
def update_pair(request, pair_id: str, **fields) -> Dict[str, Any]:
path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id)
_, data = request_api(request, 'PATCH', path, json=fields)
return data
def delete_pair(request, pair_id: str) -> None:
path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id)
request_api(request, 'DELETE', path)
def accept_pair(request, pair_id: str) -> Dict[str, Any]:
path = EP('PAIR_ACCEPT_PATH').format(pair_id=pair_id)
_, data = request_api(request, 'POST', path)
return data
def reject_pair(request, pair_id: str) -> Dict[str, Any]:
path = EP('PAIR_REJECT_PATH').format(pair_id=pair_id)
_, data = request_api(request, 'POST', path)
return data
# ==========================
# CHAT
# ==========================
def my_rooms(request) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
_, data = request_api(request, 'GET', EP('ROOMS_PATH'))
return data
def get_room(request, room_id: str) -> Dict[str, Any]:
path = EP('ROOM_DETAIL_PATH').format(room_id=room_id)
_, data = request_api(request, 'GET', path)
return data
def create_room(request, title: Optional[str] = None, participants: List[str] = []) -> Dict[str, Any]:
body = {'title': title, 'participants': participants}
_, data = request_api(request, 'POST', EP('ROOMS_PATH'), json=body)
return data
def send_message(request, room_id: str, content: str) -> Dict[str, Any]:
body = {'content': content}
path = EP('ROOM_MESSAGES_PATH').format(room_id=room_id)
_, data = request_api(request, 'POST', path, json=body)
return data
def list_messages(request, room_id: str, offset: int = 0, limit: int = 100) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
params = {'offset': offset, 'limit': limit}
path = EP('ROOM_MESSAGES_PATH').format(room_id=room_id)
_, data = request_api(request, 'GET', path, params=params)
return data
# ==========================
# PAYMENTS
# ==========================
def create_invoice(request, client_id: str, amount: float, currency: str, description: Optional[str] = None) -> Dict[str, Any]:
body = {'client_id': client_id, 'amount': amount, 'currency': currency, 'description': description}
_, data = request_api(request, 'POST', EP('INVOICES_PATH'), json=body)
return data
def list_invoices(request, client_id: Optional[str] = None, status: Optional[str] = None, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
params = {'client_id': client_id, 'status': status, 'offset': offset, 'limit': limit}
params = {k: v for k, v in params.items() if v is not None}
_, data = request_api(request, 'GET', EP('INVOICES_PATH'), params=params)
return data
def get_invoice(request, inv_id: str) -> Dict[str, Any]:
path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id)
_, data = request_api(request, 'GET', path)
return data
def update_invoice(request, inv_id: str, **fields) -> Dict[str, Any]:
path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id)
_, data = request_api(request, 'PATCH', path, json=fields)
return data
def delete_invoice(request, inv_id: str) -> None:
path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id)
request_api(request, 'DELETE', path)
def mark_invoice_paid(request, inv_id: str) -> Dict[str, Any]:
path = EP('INVOICE_MARK_PAID_PATH').format(inv_id=inv_id)
_, data = request_api(request, 'POST', path)
return data
def upload_my_photo(request, file_obj) -> Dict[str, Any]:
"""
Отправляет multipart/form-data на бекенд для загрузки фото профиля.
Ожидаем, что сервер примет поле 'file' и вернёт обновлённый профиль или {photo_url: "..."}.
"""
path = EP('PROFILE_PHOTO_UPLOAD_PATH')
filename = getattr(file_obj, 'name', 'photo.jpg')
content_type = getattr(file_obj, 'content_type', 'application/octet-stream')
files = {'file': (filename, file_obj, content_type)}
_, data = request_api(request, 'POST', path, files=files)
return data
def delete_my_photo(request) -> Dict[str, Any]:
"""
Удаляет фото профиля (если сервер поддерживает DELETE на том же пути).
"""
path = EP('PROFILE_PHOTO_DELETE_PATH')
_, data = request_api(request, 'DELETE', path)
return data

View File

@@ -0,0 +1,20 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
# Кабинет
path("cabinet/", views.cabinet_view, name="cabinet"),
path("cabinet/photo/upload/", views.cabinet_upload_photo, name="cabinet_upload_photo"),
path("cabinet/photo/delete/", views.cabinet_delete_photo, name="cabinet_delete_photo"),
# Каталог
path("profiles/", views.profile_list, name="profiles"),
path("profiles/<uuid:pk>/", views.profile_detail, name="profile_detail"),
path("profiles/<uuid:pk>/like/", views.like_profile, name="like_profile"),
# Регистрация и авторизация
path('register/', views.register_view, name='register'),
path('login/', views.login_view, name='login'),
path('logout/', views.logout_view, name='logout'),
]

View File

@@ -0,0 +1,492 @@
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")

View File

@@ -0,0 +1,204 @@
{% load static ui_extras %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Кабинет</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="{% static 'style.css' %}" rel="stylesheet">
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; }
.topbar { display:flex; gap:16px; align-items:center; padding:14px 18px; background:#111827; color:#fff; }
.topbar a { color:#cfe3ff; text-decoration:none; }
.container { max-width:1100px; margin:24px auto; padding:0 16px; }
.heading { display:flex; align-items:flex-end; gap:12px; margin:8px 0 18px; }
.heading h1 { margin:0; font-size:24px; }
.muted { color:#6b7280; font-size:14px; }
.card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; box-shadow: 0 1px 2px rgba(0,0,0,.03); }
.grid { display:grid; gap:16px; }
.grid-2 { grid-template-columns: 1fr 1fr; }
dl { display:grid; grid-template-columns: 200px 1fr; gap:8px 14px; margin: 0; }
dt { font-weight:600; color:#374151; }
dd { margin:0; color:#111827; }
.pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
.row { display:flex; gap:18px; align-items:center; }
.avatar { width:96px; height:96px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:32px; background:#e5e7eb; color:#374151; }
.avatar img { width:96px; height:96px; object-fit:cover; border-radius:50%; display:block; }
.form { display:grid; gap:14px; }
.form input[type="text"], .form select, .form textarea { width:100%; border:1px solid #d1d5db; border-radius:8px; padding:10px 12px; font:inherit; background:#fff; }
.btnrow { display:flex; gap:10px; margin-top:8px; }
.btn { display:inline-block; padding:10px 14px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; color:#111; }
.btn-primary { background:#2563eb; color:#fff; border-color:#2563eb; }
.btn-outline { background:#fff; color:#111; border-color:#d1d5db; }
.messages { list-style:none; padding:0; margin:0 0 16px; }
.messages li { padding:10px 12px; margin-bottom:8px; border-radius:10px; }
.messages li.success { background:#ecfdf5; color:#065f46; border:1px solid #a7f3d0; }
.messages li.error { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
.messages li.info { background:#eff6ff; color:#1e40af; border:1px solid #bfdbfe; }
</style>
</head>
<body>
<header class="topbar">
<div style="flex:1 1 auto;">
Здравствуйте, <strong>{{ request.session.user_full_name|default:request.session.user_email }}</strong>!
</div>
<nav style="display:flex; gap:14px;">
<a href="{% url 'index' %}">Главная</a>
<a href="{% url 'cabinet' %}">Кабинет</a>
<a href="{% url 'profiles' %}">Каталог</a>
<a href="{% url 'logout' %}">Выход</a>
</nav>
</header>
<main class="container">
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li class="{{ message.tags }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="heading">
<h1>Кабинет</h1>
{% if has_profile %}
<span class="muted">профиль создан</span>
{% else %}
<span class="muted">профиль ещё не создан</span>
{% endif %}
</div>
<div class="grid grid-2">
<!-- Аккаунт -->
<section class="card">
<h2 class="muted" style="margin-top:0;">Данные аккаунта</h2>
<div class="row" style="margin-bottom:14px;">
<div class="avatar">
{% if request.session.user_email %}
<img src="{{ request.session.user_email|gravatar_url:96 }}" alt="">
{% else %}
{{ request.session.user_full_name|default:request.session.user_email|initial }}
{% endif %}
</div>
<div>
<div style="font-weight:700;">{{ request.session.user_full_name|default:"Без имени" }}</div>
<div class="muted">{{ request.session.user_email }}</div>
<div class="pill" style="margin-top:6px;">{{ request.session.user_role|default:"CLIENT" }}</div>
</div>
</div>
<form class="form" method="post" action="{% url 'cabinet' %}">
{% csrf_token %}
<input type="hidden" name="action" value="update_name">
<label for="full_name">Имя / ФИО</label>
<input id="full_name" name="full_name" type="text" value="{{ request.session.user_full_name|default_if_none:'' }}" placeholder="Ваше имя">
<div class="btnrow">
<button class="btn btn-primary" type="submit">Сохранить имя</button>
</div>
</form>
</section>
<!-- Фото профиля -->
<section class="card">
<h2 class="muted" style="margin-top:0;">Фото профиля</h2>
<div class="row">
<div class="avatar">
{% if profile and profile.photo_url %}
<img src="{{ profile.photo_url }}" alt="">
{% else %}
{% if request.session.user_email %}
<img src="{{ request.session.user_email|gravatar_url:96 }}" alt="">
{% else %}
{{ request.session.user_full_name|default:request.session.user_email|initial }}
{% endif %}
{% endif %}
</div>
<form method="post" action="{% url 'cabinet_upload_photo' %}" enctype="multipart/form-data" style="display:flex; gap:10px; align-items:center;">
{% csrf_token %}
<input type="file" name="photo" accept="image/*" required>
<button class="btn btn-primary" type="submit">Загрузить</button>
</form>
<form method="post" action="{% url 'cabinet_delete_photo' %}" style="margin-left:auto;">
{% csrf_token %}
<button class="btn btn-outline" type="submit">Удалить фото</button>
</form>
</div>
<p class="muted" style="margin-top:8px;">Если сервер не поддерживает загрузку, вы увидите соответствующее сообщение. Пока используется Gravatar/инициалы.</p>
</section>
</div>
{% if not has_profile or not profile %}
<section class="card" style="margin-top:16px;" aria-labelledby="section-create">
<h2 id="section-create" class="muted" style="margin-top:0;">Создать профиль</h2>
<form class="form" method="post" action="{% url 'cabinet' %}">
{% csrf_token %}
<input type="hidden" name="action" value="create_profile">
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:16px;">
<div>
<label for="gender">Пол</label>
<select id="gender" name="gender" required>
<option value="">— выберите —</option>
<option value="male">Мужской</option>
<option value="female">Женский</option>
<option value="other">Другое</option>
</select>
</div>
<div>
<label for="city">Город</label>
<input id="city" name="city" type="text" required placeholder="Москва">
</div>
</div>
<div>
<label for="languages">Языки</label>
<input id="languages" name="languages" type="text" placeholder="ru,en">
<small class="muted">Несколько — через запятую</small>
</div>
<div>
<label for="interests">Интересы</label>
<input id="interests" name="interests" type="text" placeholder="music,travel">
<small class="muted">Несколько — через запятую</small>
</div>
<div class="btnrow">
<button class="btn btn-primary" type="submit">Создать профиль</button>
<a class="btn btn-outline" href="{% url 'cabinet' %}">Сбросить</a>
</div>
</form>
</section>
{% else %}
<section class="card" style="margin-top:16px;">
<h2 class="muted" style="margin-top:0;">Данные профиля</h2>
<dl>
<dt>Пол</dt><dd>{{ profile.gender|default:"—" }}</dd>
<dt>Город</dt><dd>{{ profile.city|default:"—" }}</dd>
<dt>Языки</dt>
<dd>
{% if profile.languages %}
{% for lang in profile.languages %}<span class="pill">{{ lang }}</span>{% endfor %}
{% else %} — {% endif %}
</dd>
<dt>Интересы</dt>
<dd>
{% if profile.interests %}
{% for it in profile.interests %}<span class="pill">{{ it }}</span>{% endfor %}
{% else %} — {% endif %}
</dd>
<dt>ID профиля</dt><dd><code>{{ profile.id }}</code></dd>
<dt>ID пользователя</dt><dd><code>{{ profile.user_id }}</code></dd>
</dl>
<p class="muted" style="margin-top:8px;">Редактирование полей профиля появится, как только сервер добавит PATCH /profiles/v1/profiles/me.</p>
</section>
{% endif %}
</main>
</body>
</html>

View File

@@ -0,0 +1,31 @@
{% extends 'base.html' %}
{% block title %}Главная — MatchAgency{% endblock %}
{% block content %}
<section class="grid md:grid-cols-2 gap-8 items-center">
<div>
<h1 class="text-3xl md:text-5xl font-semibold leading-tight">Подбор идеальных пар под ключ</h1>
<p class="mt-4 text-gray-600 text-lg">Фронтенд полностью на API: ни одной локальной таблицы.</p>
<div class="mt-6 flex gap-3">
<a href="/profiles/" class="inline-flex items-center rounded-md bg-rose-600 px-4 py-2 text-white font-medium hover:bg-rose-700">Смотреть анкеты</a>
{% if not api_user %}
<a href="/login/" class="inline-flex items-center rounded-md border px-4 py-2 font-medium hover:bg-white">Войти</a>
{% endif %}
</div>
</div>
<div class="rounded-xl bg-white/70 backdrop-blur p-4 md:p-6 shadow">
<form action="/profiles/" method="get" class="grid sm:grid-cols-2 gap-4">
<input name="q" placeholder="Ключевые слова (хобби, имя, город)"
class="w-full rounded-md border px-3 py-2" />
<select name="gender" class="w-full rounded-md border px-3 py-2">
<option value="">Пол (любой)</option>
<option value="female">Женщины</option>
<option value="male">Мужчины</option>
</select>
<input name="age_min" type="number" min="18" max="100" placeholder="От, лет" class="w-full rounded-md border px-3 py-2"/>
<input name="age_max" type="number" min="18" max="100" placeholder="До, лет" class="w-full rounded-md border px-3 py-2"/>
<input name="city" placeholder="Город" class="w-full rounded-md border px-3 py-2"/>
<button class="sm:col-span-2 rounded-md bg-indigo-600 text-white px-4 py-2 hover:bg-indigo-700">Найти</button>
</form>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block title %}Вход — MatchAgency{% endblock %}
{% block content %}
<div class="max-w-md mx-auto rounded-xl bg-white/80 backdrop-blur border shadow p-6">
<h1 class="text-xl font-semibold mb-4">Вход</h1>
<form action="" method="post" class="space-y-3">
{% csrf_token %}
<div>
<label class="block text-sm mb-1">Email</label>
<input type="email" name="email" required class="w-full rounded-md border px-3 py-2"/>
</div>
<div>
<label class="block text-sm mb-1">Пароль</label>
<input type="password" name="password" required class="w-full rounded-md border px-3 py-2"/>
</div>
<div class="flex items-center justify-between">
<label class="block text-sm mb-1">Запомнить меня</label>
<input type="checkbox" name="remember_me" class="rounded border px-2 py-1"/>
</div>
{% if error_message %}
<p class="text-red-500 text-sm">{{ error_message }}</p>
{% endif %}
<p class="text-sm">Нет аккаунта? <a class="text-indigo-700 hover:underline" href="{% url 'register' %}">Зарегистрироваться</a></p>
<button class="w-full rounded-md bg-indigo-600 text-white px-4 py-2 hover:bg-indigo-700">Войти</button>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,66 @@
{% load static ui_extras %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Анкета пользователя</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="{% static 'style.css' %}" rel="stylesheet">
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; }
.topbar { display:flex; gap:16px; align-items:center; padding:14px 18px; background:#111827; color:#fff; }
.topbar a { color:#cfe3ff; text-decoration:none; }
.container { max-width:900px; margin:24px auto; padding:0 16px; }
.card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; }
.row { display:flex; gap:18px; align-items:center; }
.grow { flex:1 1 auto; }
.muted { color:#6b7280; font-size:14px; }
.pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
.avatar { width:96px; height:96px; border-radius:50%; overflow:hidden; background:#e5e7eb; display:flex; align-items:center; justify-content:center; font-weight:700; color:#374151; }
.avatar img { width:96px; height:96px; object-fit:cover; display:block; }
dl { display:grid; grid-template-columns: 220px 1fr; gap:8px 14px; margin:0; }
dt { font-weight:600; color:#374151; }
dd { margin:0; color:#111827; }
.btn { display:inline-block; padding:9px 12px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; text-decoration:none; color:#111; }
</style>
</head>
<body>
<header class="topbar">
<div style="flex:1 1 auto;">Карточка пользователя (ADMIN)</div>
<nav style="display:flex; gap:14px;">
<a href="{% url 'profiles' %}">← Каталог</a>
<a href="{% url 'cabinet' %}">Кабинет</a>
<a href="{% url 'logout' %}">Выход</a>
</nav>
</header>
<main class="container">
<div class="card">
<div class="row">
<div class="avatar">
{% if profile.email %}
<img src="{{ profile.email|gravatar_url:96 }}" alt="">
{% else %}
{{ profile.name|initial }}
{% endif %}
</div>
<div class="grow">
<div style="font-weight:700; font-size:20px;">{{ profile.name }}</div>
<div class="muted" style="margin-top:4px;">ID: {{ profile.id }}</div>
<div class="muted">Email: {{ profile.email|default:"—" }}</div>
<div class="muted">Роль: <span class="pill">{{ profile.role|default:"CLIENT" }}</span></div>
</div>
<div>
<form method="post" action="{% url 'like_profile' profile.id %}">
{% csrf_token %}
{% include "ui/components/like_button.html" with profile_id=profile.id liked=liked %}
</form>
</div>
</div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,194 @@
{% load static ui_extras %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Каталог анкет</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="{% static 'style.css' %}" rel="stylesheet">
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; }
.topbar { display:flex; gap:16px; align-items:center; padding:14px 18px; background:#111827; color:#fff; }
.topbar a { color:#cfe3ff; text-decoration:none; }
.container { max-width:1100px; margin:24px auto; padding:0 16px; }
.messages { list-style:none; padding:0; margin:0 0 16px; }
.messages li { padding:10px 12px; margin-bottom:8px; border-radius:10px; }
.messages li.success { background:#ecfdf5; color:#065f46; border:1px solid #a7f3d0; }
.messages li.error { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
.messages li.info { background:#eff6ff; color:#1e40af; border:1px solid #bfdbfe; }
.filters { display:grid; grid-template-columns: repeat(8, 1fr); gap:10px; background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:14px; }
.filters input[type="text"], .filters select { width:100%; border:1px solid #d1d5db; border-radius:8px; padding:8px 10px; font:inherit; background:#fff; }
.list { margin-top:16px; display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:14px; }
.card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:14px; }
.row { display:flex; align-items:center; gap:12px; }
.grow { flex:1 1 auto; }
.muted { color:#6b7280; font-size:14px; }
.pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
.avatar { width:48px; height:48px; border-radius:50%; overflow:hidden; background:#e5e7eb; display:flex; align-items:center; justify-content:center; font-weight:700; color:#374151; }
.avatar img { width:48px; height:48px; object-fit:cover; display:block; }
.meta { margin-top:8px; display:flex; flex-wrap:wrap; gap:10px 18px; }
.meta .k { color:#6b7280; }
.meta .v { color:#111827; font-weight:600; }
.pagination { display:flex; gap:10px; margin:16px 0; align-items:center; }
.pagination a, .pagination span { padding:8px 12px; border-radius:10px; border:1px solid #d1d5db; text-decoration:none; color:#111; background:#fff; }
.pagination .disabled { opacity:.5; pointer-events:none; }
.btn { display:inline-block; padding:9px 12px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; }
</style>
</head>
<body>
<header class="topbar">
<div style="flex:1 1 auto;">Каталог анкет (ADMIN)</div>
<nav style="display:flex; gap:14px;">
<a href="{% url 'index' %}">Главная</a>
<a href="{% url 'cabinet' %}">Кабинет</a>
<a href="{% url 'profiles' %}">Каталог</a>
<a href="{% url 'logout' %}">Выход</a>
</nav>
</header>
<main class="container">
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li class="{{ message.tags }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<form class="filters" method="get" action="{% url 'profiles' %}">
<div>
<label class="muted">Поиск</label>
<input type="text" name="q" value="{{ filters.q }}" placeholder="имя или email">
</div>
<div>
<label class="muted">Роль</label>
<select name="role">
<option value="">Любая</option>
<option value="CLIENT" {% if filters.role == "CLIENT" %}selected{% endif %}>CLIENT</option>
<option value="ADMIN" {% if filters.role == "ADMIN" %}selected{% endif %}>ADMIN</option>
</select>
</div>
<div>
<label class="muted">Активность</label>
<select name="active">
<option value="">Любая</option>
<option value="1" {% if filters.active == "1" %}selected{% endif %}>Активные</option>
<option value="0" {% if filters.active == "0" %}selected{% endif %}>Неактивные</option>
</select>
</div>
<div>
<label class="muted">Домен</label>
<input type="text" name="domain" value="{{ filters.domain }}" placeholder="example.com">
</div>
<div>
<label class="muted">Сортировка</label>
<select name="sort">
<option value="name" {% if filters.sort == "name" %}selected{% endif %}>Имя ↑</option>
<option value="name_desc" {% if filters.sort == "name_desc" %}selected{% endif %}>Имя ↓</option>
<option value="email" {% if filters.sort == "email" %}selected{% endif %}>Email ↑</option>
<option value="email_desc" {% if filters.sort == "email_desc" %}selected{% endif %}>Email ↓</option>
</select>
</div>
<div>
<label class="muted">На странице</label>
<select name="limit">
{% for n in page_sizes %}
<option value="{{ n }}"{% if filters.limit == n %} selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="muted">Страница</label>
<input type="text" name="page" value="{{ filters.page }}" style="width:90px;">
</div>
<div style="display:flex; align-items:flex-end;">
<button class="btn" type="submit">Применить</button>
</div>
</form>
<div class="pagination">
{% with q=filters.q role=filters.role active=filters.active domain=filters.domain sort=filters.sort limit=filters.limit %}
{% if page.has_prev %}
<a href="?q={{ q }}&role={{ role }}&active={{ active }}&domain={{ domain }}&sort={{ sort }}&limit={{ limit }}&page={{ page.page|add:"-1" }}">« Предыдущая</a>
{% else %}
<span class="disabled">« Предыдущая</span>
{% endif %}
<span>Стр. {{ page.page }}</span>
{% if page.has_next %}
<a href="?q={{ q }}&role={{ role }}&active={{ active }}&domain={{ domain }}&sort={{ sort }}&limit={{ limit }}&page={{ page.page|add:"1" }}">Следующая »</a>
{% else %}
<span class="disabled">Следующая »</span>
{% endif %}
{% endwith %}
</div>
{% if error %}
<div class="messages"><li class="error">{{ error }}</li></div>
{% endif %}
<div class="list">
{% for p in profiles %}
<div class="card">
<div class="row">
<div class="avatar">
{% if p.email %}
<img src="{{ p.email|gravatar_url:48 }}" alt="">
{% else %}
{{ p.name|initial }}
{% endif %}
</div>
<div class="grow">
<div style="font-weight:700; font-size:16px;">{{ p.name }}</div>
<div class="muted">{{ p.email }}</div>
</div>
<div>
<form method="post" action="{% url 'like_profile' p.id %}">
{% csrf_token %}
{% include "ui/components/like_button.html" with profile_id=p.id liked=p.liked %}
</form>
</div>
</div>
<div class="meta">
<div><span class="pill">{{ p.verified|yesno:"ACTIVE,INACTIVE" }}</span></div>
<div class="pill">{{ p.role|default:"USER" }}</div>
<div><span class="k">ID:</span> <span class="v">{{ p.id }}</span></div>
</div>
<div class="row" style="margin-top:10px;">
<div class="grow"></div>
<a class="btn" href="{% url 'profile_detail' p.id %}">Открыть</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="muted">Ничего не найдено. Попробуйте изменить фильтры.</div>
</div>
{% endfor %}
</div>
<div class="pagination">
{% with q=filters.q role=filters.role active=filters.active domain=filters.domain sort=filters.sort limit=filters.limit %}
{% if page.has_prev %}
<a href="?q={{ q }}&role={{ role }}&active={{ active }}&domain={{ domain }}&sort={{ sort }}&limit={{ limit }}&page={{ page.page|add:"-1" }}">« Предыдущая</a>
{% else %}
<span class="disabled">« Предыдущая</span>
{% endif %}
<span>Стр. {{ page.page }}</span>
{% if page.has_next %}
<a href="?q={{ q }}&role={{ role }}&active={{ active }}&domain={{ domain }}&sort={{ sort }}&limit={{ limit }}&page={{ page.page|add:"1" }}">Следующая »</a>
{% else %}
<span class="disabled">Следующая »</span>
{% endif %}
{% endwith %}
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}Регистрация — MatchAgency{% endblock %}
{% block content %}
<div class="max-w-md mx-auto rounded-xl bg-white/80 backdrop-blur border shadow p-6">
<h1 class="text-xl font-semibold mb-4">Регистрация</h1>
<form action="" method="post" class="space-y-3">
{% csrf_token %}
<div>
<label class="block text-sm mb-1">Email</label>
<input type="email" name="email" required class="w-full rounded-md border px-3 py-2"/>
</div>
<div>
<label class="block text-sm mb-1">Пароль</label>
<input type="password" name="password" required class="w-full rounded-md border px-3 py-2"/>
</div>
<div>
<label class="block text-sm mb-1">Полное имя (необязательно)</label>
<input type="text" name="full_name" class="w-full rounded-md border px-3 py-2"/>
</div>
<button class="w-full rounded-md bg-indigo-600 text-white px-4 py-2 hover:bg-indigo-700">Зарегистрироваться</button>
</form>
<p class="mt-3 text-sm">Уже есть аккаунт? <a class="text-indigo-700 hover:underline" href="{% url 'login' %}">Войти</a></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
from django.urls import path
from . import views
app_name = "ui"
urlpatterns = [
path("", views.index, name="index"),
# auth
path("login/", views.login_view, name="login"),
path("register/", views.register_view, name="register"),
path("logout/", views.logout_view, name="logout"),
# cabinet
path("cabinet/", views.cabinet_view, name="cabinet"),
path("cabinet/photo/upload/", views.cabinet_upload_photo, name="cabinet_upload_photo"),
path("cabinet/photo/delete/", views.cabinet_delete_photo, name="cabinet_delete_photo"),
# admin catalog (users ≈ анкеты)
path("profiles/", views.profile_list, name="profiles"),
path("profiles/<uuid:pk>/", views.profile_detail, name="profile_detail"),
path("profiles/<uuid:pk>/like/", views.like_profile, name="like_profile"),
]

View File

@@ -0,0 +1,337 @@
from __future__ import annotations
from typing import List, Dict, Any, Optional
import uuid as _uuid
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 . import api
from .api import ApiError
# === helpers ==================================================================
def _is_logged(request) -> bool:
return bool(request.session.get("access_token") or request.COOKIES.get("access_token"))
def _is_admin(request) -> bool:
return (request.session.get("user_role") or "").upper() == "ADMIN"
def _set_tokens_and_user(request, tokens: Dict[str, Any], me: Dict[str, Any]):
# tokens
request.session["access_token"] = tokens.get("access_token")
request.session["refresh_token"] = tokens.get("refresh_token")
# user
request.session["user_id"] = me.get("id")
request.session["user_email"] = me.get("email")
request.session["user_full_name"] = me.get("full_name")
request.session["user_role"] = me.get("role")
request.session.modified = True
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": bool(u.get("is_active", False)),
"age": None,
"city": None,
"about": "",
"photo": "", # важно: не None → не будет src="None"
"interests": [],
"liked": False,
}
# === public pages ==============================================================
def index(request):
return render(request, "ui/index.html", {})
# === catalog (admin) ==========================================================
@require_http_methods(["GET"])
def profile_list(request):
if not _is_logged(request):
messages.info(request, "Войдите, чтобы открыть каталог")
return redirect("login")
if not _is_admin(request):
messages.info(request, "Каталог доступен только администраторам. Перенаправляем в Кабинет.")
return redirect("cabinet")
q = (request.GET.get("q") or "").strip().lower()
role = (request.GET.get("role") or "").strip().upper()
active = request.GET.get("active")
email_domain = (request.GET.get("domain") or "").strip().lower().lstrip("@")
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)
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:
data = api.list_users(request, offset=offset, limit=limit)
users: List[Dict[str, Any]] = (data.get("items") if isinstance(data, dict) else data) or []
# client-side filters
def keep(u: Dict[str, Any]) -> bool:
fn = (u.get("full_name") or "")
em = (u.get("email") or "")
if q and (q not in fn.lower() and q not in em.lower()):
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", False))
if (active == "1" and not is_act) or (active == "0" and is_act):
return False
if email_domain:
dom = em.lower().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
page_info["has_next"] = (len(users) == limit and not (q or role or active in ("1","0") or email_domain))
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):
# pk — uuid в маршруте; приводим к строке
try:
user = api.get_user(request, str(pk))
except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова")
return redirect("login")
except ApiError as e:
if e.status == 404:
raise Http404("Пользователь не найден")
messages.error(request, str(e))
return redirect("profiles")
stub = _user_to_profile_stub(user)
liked_ids = set(request.session.get("likes", []))
liked = stub["id"] in liked_ids
return render(request, "ui/profile_detail.html", {"profile": stub, "liked": liked})
@require_POST
def like_profile(request, pk):
# простая реализация лайков в сессии
likes = set(request.session.get("likes", []))
pk_str = str(pk)
if pk_str in likes:
likes.remove(pk_str); liked = False
else:
likes.add(pk_str); liked = True
request.session["likes"] = list(likes)
request.session.modified = True
return render(request, "ui/components/like_button.html", {"profile_id": pk_str, "liked": liked})
# === 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:
tokens = api.login(request, email, password)
me = api.get_me(request)
_set_tokens_and_user(request, tokens, me)
# ставим httpOnly cookie для API-клиента (серверные запросы читают из сессии)
resp = redirect(request.GET.get("next") or ("profiles" if (me.get("role") == "ADMIN") else "cabinet"))
max_age = 7 * 24 * 3600
resp.set_cookie("access_token", tokens.get("access_token"), max_age=max_age, httponly=True, samesite="Lax")
resp.set_cookie("refresh_token", tokens.get("refresh_token"), max_age=max_age, httponly=True, samesite="Lax")
messages.success(request, "Вы успешно вошли")
return resp
except PermissionDenied as e:
messages.error(request, f"Доступ запрещён: {e}")
except ApiError as e:
messages.error(request, f"Ошибка входа: {e.args[0]}")
return render(request, "ui/login.html", {})
@require_http_methods(["GET", "POST"])
def register_view(request):
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, password, full_name, role="CLIENT")
# сразу логиним
tokens = api.login(request, email, password)
me = api.get_me(request)
_set_tokens_and_user(request, tokens, me)
resp = redirect("cabinet")
max_age = 7 * 24 * 3600
resp.set_cookie("access_token", tokens.get("access_token"), max_age=max_age, httponly=True, samesite="Lax")
resp.set_cookie("refresh_token", tokens.get("refresh_token"), max_age=max_age, httponly=True, samesite="Lax")
messages.success(request, "Регистрация успешна")
return resp
except ApiError as e:
messages.error(request, f"Ошибка регистрации: {e.args[0]}")
return render(request, "ui/register.html", {})
@require_http_methods(["POST", "GET"])
def logout_view(request):
resp = redirect("index")
# чистим куки
resp.delete_cookie("access_token")
resp.delete_cookie("refresh_token")
# и сессию
for k in ("auth", "access_token", "refresh_token", "user_id", "user_email", "user_full_name", "user_role"):
request.session.pop(k, None)
request.session.modified = True
messages.info(request, "Вы вышли из аккаунта")
return resp
# === cabinet ==================================================================
@require_http_methods(["GET", "POST"])
def cabinet_view(request):
if not _is_logged(request):
messages.info(request, "Войдите, чтобы открыть кабинет")
return redirect("login")
# запрашиваем мой профиль (может быть 404, если не создан)
profile = None
has_profile = False
try:
profile = api.get_my_profile(request)
has_profile = bool(profile)
except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова")
return redirect("login")
except ApiError as e:
if e.status != 404:
messages.error(request, f"Ошибка чтения профиля: {e}")
if request.method == "POST":
action = (request.POST.get("action") or "").strip()
try:
if action == "create_profile":
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()]
api.create_my_profile(request, gender, city, languages, interests)
messages.success(request, "Профиль создан")
return redirect("cabinet")
elif action == "update_name":
full_name = (request.POST.get("full_name") or "").strip()
if not full_name:
messages.error(request, "Имя не может быть пустым")
else:
api.update_user_me(request, request.session.get("user_id"), full_name=full_name)
request.session["user_full_name"] = full_name
request.session.modified = True
messages.success(request, "Имя обновлено")
return redirect("cabinet")
except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова")
return redirect("login")
except ApiError as e:
messages.error(request, f"Ошибка: {e}")
ctx = {
"has_profile": has_profile,
"profile": profile,
}
return render(request, "ui/cabinet.html", ctx)
@require_POST
def cabinet_upload_photo(request):
if not _is_logged(request):
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 _is_logged(request):
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")

1031
scripts/patch_frontend.sh Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
{% load static %} {% load static ui_extras %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
@@ -17,42 +17,37 @@
.card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; box-shadow: 0 1px 2px rgba(0,0,0,.03); } .card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; box-shadow: 0 1px 2px rgba(0,0,0,.03); }
.grid { display:grid; gap:16px; } .grid { display:grid; gap:16px; }
.grid-2 { grid-template-columns: 1fr 1fr; } .grid-2 { grid-template-columns: 1fr 1fr; }
dl { display:grid; grid-template-columns: 200px 1fr; gap:8px 14px; margin: 0; }
dt { font-weight:600; color:#374151; }
dd { margin:0; color:#111827; }
.pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
.row { display:flex; gap:18px; align-items:center; }
.avatar { width:96px; height:96px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:32px; background:#e5e7eb; color:#374151; }
.avatar img { width:96px; height:96px; object-fit:cover; border-radius:50%; display:block; }
.form { display:grid; gap:14px; } .form { display:grid; gap:14px; }
.form label { font-weight:600; font-size:14px; } .form input[type="text"], .form select, .form textarea { width:100%; border:1px solid #d1d5db; border-radius:8px; padding:10px 12px; font:inherit; background:#fff; }
.form input[type="text"], .form select, .form textarea {
width:100%; border:1px solid #d1d5db; border-radius:8px; padding:10px 12px; font:inherit; background:#fff;
}
.form small { color:#6b7280; }
.btnrow { display:flex; gap:10px; margin-top:8px; } .btnrow { display:flex; gap:10px; margin-top:8px; }
.btn { display:inline-block; padding:10px 14px; border-radius:10px; border:1px solid transparent; font-weight:600; cursor:pointer; } .btn { display:inline-block; padding:10px 14px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; color:#111; }
.btn-primary { background:#2563eb; color:#fff; } .btn-primary { background:#2563eb; color:#fff; border-color:#2563eb; }
.btn-outline { background:#fff; color:#111; border-color:#d1d5db; } .btn-outline { background:#fff; color:#111; border-color:#d1d5db; }
.messages { list-style:none; padding:0; margin:0 0 16px; } .messages { list-style:none; padding:0; margin:0 0 16px; }
.messages li { padding:10px 12px; margin-bottom:8px; border-radius:10px; } .messages li { padding:10px 12px; margin-bottom:8px; border-radius:10px; }
.messages li.success { background:#ecfdf5; color:#065f46; border:1px solid #a7f3d0; } .messages li.success { background:#ecfdf5; color:#065f46; border:1px solid #a7f3d0; }
.messages li.error { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; } .messages li.error { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
.messages li.info { background:#eff6ff; color:#1e40af; border:1px solid #bfdbfe; } .messages li.info { background:#eff6ff; color:#1e40af; border:1px solid #bfdbfe; }
dl { display:grid; grid-template-columns: 200px 1fr; gap:8px 14px; margin: 0; }
dt { font-weight:600; color:#374151; }
dd { margin:0; color:#111827; }
.pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
details summary { cursor:pointer; }
code, pre { background:#111827; color:#e5e7eb; padding:10px 12px; border-radius:10px; display:block; overflow:auto; }
</style> </style>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div style="flex:1 1 auto;"> <div style="flex:1 1 auto;">
{% with header_name=header_name|default:request.session.user_full_name|default:request.session.user_email %} Здравствуйте, <strong>{{ request.session.user_full_name|default:request.session.user_email }}</strong>!
Здравствуйте, <strong>{{ header_name }}</strong>!
{% endwith %}
</div> </div>
<nav style="display:flex; gap:14px;"> <nav style="display:flex; gap:14px;">
<a href="{% url 'index' %}">Главная</a> <a href="{% url 'ui:index' %}">Главная</a>
<a href="{% url 'cabinet' %}">Кабинет</a> <a href="{% url 'ui:cabinet' %}">Кабинет</a>
<a href="{% url 'profiles' %}">Каталог</a> <a href="{% url 'ui:profiles' %}">Каталог</a>
<a href="{% url 'logout' %}">Выход</a> <a href="{% url 'ui:logout' %}">Выход</a>
</nav> </nav>
</header> </header>
@@ -75,118 +70,129 @@
{% endif %} {% endif %}
</div> </div>
<!-- Две колонки: Аккаунт + Профиль -->
<div class="grid grid-2"> <div class="grid grid-2">
<!-- ======= ДАННЫЕ АККАУНТА ======= --> <!-- Аккаунт -->
<section class="card"> <section class="card">
<h2 class="muted" style="margin-top:0;">Данные аккаунта</h2> <h2 class="muted" style="margin-top:0;">Данные аккаунта</h2>
<dl> <div class="row" style="margin-bottom:14px;">
<dt>Имя</dt> <div class="avatar">
<dd>{{ request.session.user_full_name|default:"—" }}</dd> {% if request.session.user_email %}
<img src="{{ request.session.user_email|gravatar_url:96 }}" alt="">
{% else %}
{{ request.session.user_full_name|default:request.session.user_email|initial }}
{% endif %}
</div>
<div>
<div style="font-weight:700;">{{ request.session.user_full_name|default:"Без имени" }}</div>
<div class="muted">{{ request.session.user_email }}</div>
<div class="pill" style="margin-top:6px;">{{ request.session.user_role|default:"CLIENT" }}</div>
</div>
</div>
<dt>Email</dt> <form class="form" method="post" action="{% url 'ui:cabinet' %}">
<dd>{{ request.session.user_email|default:"—" }}</dd> {% csrf_token %}
<input type="hidden" name="action" value="update_name">
<dt>Роль</dt> <label for="full_name">Имя / ФИО</label>
<dd><span class="pill">{{ request.session.user_role|default:"—" }}</span></dd> <input id="full_name" name="full_name" type="text" value="{{ request.session.user_full_name|default_if_none:'' }}" placeholder="Ваше имя">
<div class="btnrow">
<dt>ID пользователя</dt> <button class="btn btn-primary" type="submit">Сохранить имя</button>
<dd><code>{{ request.session.user_id|default:"—" }}</code></dd> </div>
</dl> </form>
</section> </section>
<!-- ======= ДАННЫЕ ПРОФИЛЯ ======= -->
<section class="card">
<h2 class="muted" style="margin-top:0;">Данные профиля</h2>
{% if has_profile and profile %} {% if has_profile and profile %}
<section class="card" style="margin-top:16px;">
<h2 class="muted" style="margin-top:0;">Фото профиля</h2>
<div style="display:flex; gap:16px; align-items:center;">
<div style="width:120px; height:120px; border-radius:12px; overflow:hidden; background:#e5e7eb;">
{% if profile.photo_url %}
<img src="{{ profile.photo_url }}" alt="" style="width:100%; height:100%; object-fit:cover;">
{% elif profile.photo %}
<img src="{{ profile.photo }}" alt="" style="width:100%; height:100%; object-fit:cover;">
{% elif request.session.user_email %}
<img src="{{ request.session.user_email|gravatar_url:240 }}" alt="" style="width:100%; height:100%; object-fit:cover;">
{% else %}
<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:#374151;font-weight:700;"></div>
{% endif %}
</div>
<form method="post" action="{% url 'ui:cabinet_upload_photo' %}" enctype="multipart/form-data" style="display:flex; gap:10px; align-items:center;">
{% csrf_token %}
<input type="file" name="file" accept="image/*" required>
<button class="btn btn-primary" type="submit">Загрузить фото</button>
</form>
</div>
<p class="muted" style="margin-top:10px;">Поддерживается multipart загрузка изображения в поле <code>file</code>. Эндпоинт: <code>/profiles/v1/profiles/me/photo</code>. :contentReference[oaicite:4]{index=4}</p>
</section>
{% endif %}
{% if not has_profile or not profile %}
<section class="card" style="margin-top:16px;" aria-labelledby="section-create">
<h2 id="section-create" class="muted" style="margin-top:0;">Создать профиль</h2>
<form class="form" method="post" action="{% url 'ui:cabinet' %}">
{% csrf_token %}
<input type="hidden" name="action" value="create_profile">
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:16px;">
<div>
<label for="gender">Пол</label>
<select id="gender" name="gender" required>
<option value="">— выберите —</option>
<option value="male">Мужской</option>
<option value="female">Женский</option>
<option value="other">Другое</option>
</select>
</div>
<div>
<label for="city">Город</label>
<input id="city" name="city" type="text" required placeholder="Москва">
</div>
</div>
<div>
<label for="languages">Языки</label>
<input id="languages" name="languages" type="text" placeholder="ru,en">
<small class="muted">Несколько — через запятую</small>
</div>
<div>
<label for="interests">Интересы</label>
<input id="interests" name="interests" type="text" placeholder="music,travel">
<small class="muted">Несколько — через запятую</small>
</div>
<div class="btnrow">
<button class="btn btn-primary" type="submit">Создать профиль</button>
<a class="btn btn-outline" href="{% url 'ui:cabinet' %}">Сбросить</a>
</div>
</form>
</section>
{% else %}
<section class="card" style="margin-top:16px;">
<h2 class="muted" style="margin-top:0;">Данные профиля</h2>
<dl> <dl>
<dt>Пол</dt> <dt>Пол</dt><dd>{{ profile.gender|default:"—" }}</dd>
<dd>{{ profile.gender|default:"—" }}</dd> <dt>Город</dt><dd>{{ profile.city|default:"—" }}</dd>
<dt>Город</dt>
<dd>{{ profile.city|default:"—" }}</dd>
<dt>Языки</dt> <dt>Языки</dt>
<dd> <dd>
{% if profile.languages %} {% if profile.languages %}
{% for lang in profile.languages %}<span class="pill">{{ lang }}</span>{% endfor %} {% for lang in profile.languages %}<span class="pill">{{ lang }}</span>{% endfor %}
{% else %} — {% endif %} {% else %} — {% endif %}
</dd> </dd>
<dt>Интересы</dt> <dt>Интересы</dt>
<dd> <dd>
{% if profile.interests %} {% if profile.interests %}
{% for it in profile.interests %}<span class="pill">{{ it }}</span>{% endfor %} {% for it in profile.interests %}<span class="pill">{{ it }}</span>{% endfor %}
{% else %} — {% endif %} {% else %} — {% endif %}
</dd> </dd>
<dt>ID профиля</dt><dd><code>{{ profile.id }}</code></dd>
<dt>ID профиля</dt> <dt>ID пользователя</dt><dd><code>{{ profile.user_id }}</code></dd>
<dd><code>{{ profile.id }}</code></dd>
<dt>ID пользователя (в профиле)</dt>
<dd><code>{{ profile.user_id }}</code></dd>
</dl> </dl>
<p class="muted" style="margin-top:8px;">Редактирование полей профиля появится, как только сервер добавит PATCH /profiles/v1/profiles/me.</p>
<details style="margin-top:12px;">
<summary class="muted">Показать сырой JSON профиля</summary>
<pre>{{ profile|safe }}</pre>
</details>
{% else %}
<p class="muted">Профиль ещё не создан. Заполните форму ниже.</p>
{% endif %}
</section>
</div>
{% if not has_profile or not profile %}
<!-- ======= ФОРМА СОЗДАНИЯ ПРОФИЛЯ ======= -->
<section class="card" style="margin-top:16px;" aria-labelledby="section-create">
<h2 id="section-create" class="muted" style="margin-top:0;">Создать профиль</h2>
<form class="form" method="post" action="{% url 'cabinet' %}">
{% csrf_token %}
<div class="grid grid-2">
<div>
<label for="gender">Пол</label>
<select id="gender" name="gender" required>
<option value="">— выберите —</option>
<option value="male" {% if request.POST.gender == "male" %}selected{% endif %}>Мужской</option>
<option value="female" {% if request.POST.gender == "female" %}selected{% endif %}>Женский</option>
<option value="other" {% if request.POST.gender == "other" %}selected{% endif %}>Другое</option>
</select>
</div>
<div>
<label for="city">Город</label>
<input id="city" name="city" type="text" required
value="{{ request.POST.city|default_if_none:'' }}" placeholder="Москва">
</div>
</div>
<div>
<label for="languages">Языки</label>
<input id="languages" name="languages" type="text"
value="{{ request.POST.languages|default_if_none:'' }}" placeholder="ru,en">
<small>Несколько — через запятую: <code>ru,en</code></small>
</div>
<div>
<label for="interests">Интересы</label>
<input id="interests" name="interests" type="text"
value="{{ request.POST.interests|default_if_none:'' }}" placeholder="music,travel">
<small>Несколько — через запятую: <code>music,travel</code></small>
</div>
<div class="btnrow">
<button class="btn btn-primary" type="submit">Создать профиль</button>
<a class="btn btn-outline" href="{% url 'cabinet' %}">Сбросить</a>
</div>
</form>
</section> </section>
{% endif %} {% endif %}
</main> </main>
</body> </body>
</html> </html>

View File

@@ -1,17 +1,5 @@
{# expects: profile_id, liked: bool #}
<form method="post"
hx-post="{% url 'like_profile' profile_id %}"
hx-target="this"
hx-swap="outerHTML"
aria-label="Добавить в избранное">
{% csrf_token %}
{% if liked %} {% if liked %}
<button type="submit" class="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-white"> <button class="btn" type="submit">В избранном</button>
<span aria-hidden="true">❤️</span> В избранном
</button>
{% else %} {% else %}
<button type="submit" class="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-sm hover:bg-white"> <button class="btn" type="submit">В избранное</button>
<span aria-hidden="true">🤍</span> В избранное
</button>
{% endif %} {% endif %}
</form>

View File

@@ -1,3 +1 @@
<a href="/login/?next={{ request.path }}" class="inline-flex items-center gap-1 rounded-md bg-indigo-600 text-white px-3 py-1.5 text-sm hover:bg-indigo-700"> <button class="btn" disabled>В избранное (войдите)</button>
Войти, чтобы добавить
</a>

View File

@@ -1,31 +1,36 @@
{% extends 'base.html' %} {% load static %}
{% block title %}Главная — MatchAgency{% endblock %} <!doctype html>
{% block content %} <html lang="ru">
<section class="grid md:grid-cols-2 gap-8 items-center"> <head>
<div> <meta charset="utf-8">
<h1 class="text-3xl md:text-5xl font-semibold leading-tight">Подбор идеальных пар под ключ</h1> <title>Главная</title>
<p class="mt-4 text-gray-600 text-lg">Фронтенд полностью на API: ни одной локальной таблицы.</p> <meta name="viewport" content="width=device-width,initial-scale=1">
<div class="mt-6 flex gap-3"> <link href="{% static 'style.css' %}" rel="stylesheet">
<a href="/profiles/" class="inline-flex items-center rounded-md bg-rose-600 px-4 py-2 text-white font-medium hover:bg-rose-700">Смотреть анкеты</a> <style>
{% if not api_user %} body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; }
<a href="/login/" class="inline-flex items-center rounded-md border px-4 py-2 font-medium hover:bg-white">Войти</a> header { background:#111827; color:#fff; padding:14px 18px; display:flex; gap:14px; }
{% endif %} header a { color:#cfe3ff; text-decoration:none; }
</div> .container { max-width:900px; margin:24px auto; padding:0 16px; }
</div> .btn { padding:10px 14px; border-radius:10px; background:#2563eb; color:#fff; border:none; text-decoration:none; }
<div class="rounded-xl bg-white/70 backdrop-blur p-4 md:p-6 shadow"> </style>
<form action="/profiles/" method="get" class="grid sm:grid-cols-2 gap-4"> </head>
<input name="q" placeholder="Ключевые слова (хобби, имя, город)" <body>
class="w-full rounded-md border px-3 py-2" /> <header>
<select name="gender" class="w-full rounded-md border px-3 py-2"> <div style="flex:1 1 auto;">Agency Frontend</div>
<option value="">Пол (любой)</option> <nav>
<option value="female">Женщины</option> <a href="{% url 'ui:index' %}">Главная</a>
<option value="male">Мужчины</option> <a href="{% url 'ui:cabinet' %}">Кабинет</a>
</select> <a href="{% url 'ui:profiles' %}">Каталог</a>
<input name="age_min" type="number" min="18" max="100" placeholder="От, лет" class="w-full rounded-md border px-3 py-2"/> <a href="{% url 'ui:login' %}">Войти</a>
<input name="age_max" type="number" min="18" max="100" placeholder="До, лет" class="w-full rounded-md border px-3 py-2"/> </nav>
<input name="city" placeholder="Город" class="w-full rounded-md border px-3 py-2"/> </header>
<button class="sm:col-span-2 rounded-md bg-indigo-600 text-white px-4 py-2 hover:bg-indigo-700">Найти</button> <main class="container">
</form> <h1>Добро пожаловать</h1>
</div> <p>Это фронтенд для API брачного агентства.</p>
</section> <p>
{% endblock %} <a class="btn" href="{% url 'ui:login' %}">Войти</a>
<a class="btn" style="background:#10b981;" href="{% url 'ui:register' %}">Регистрация</a>
</p>
</main>
</body>
</html>

View File

@@ -1,27 +1,43 @@
{% extends 'base.html' %} {% load static %}
{% block title %}Вход — MatchAgency{% endblock %} <!doctype html>
{% block content %} <html lang="ru">
<div class="max-w-md mx-auto rounded-xl bg-white/80 backdrop-blur border shadow p-6"> <head>
<h1 class="text-xl font-semibold mb-4">Вход</h1> <meta charset="utf-8">
<form action="" method="post" class="space-y-3"> <title>Вход</title>
{% csrf_token %} <meta name="viewport" content="width=device-width,initial-scale=1">
<div> <link href="{% static 'style.css' %}" rel="stylesheet">
<label class="block text-sm mb-1">Email</label> <style>
<input type="email" name="email" required class="w-full rounded-md border px-3 py-2"/> body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; }
</div> .wrap { max-width:420px; margin:60px auto; background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; }
<div> .form { display:grid; gap:12px; }
<label class="block text-sm mb-1">Пароль</label> .form input { border:1px solid #d1d5db; border-radius:8px; padding:10px 12px; font:inherit; }
<input type="password" name="password" required class="w-full rounded-md border px-3 py-2"/> .btn { padding:10px 14px; border-radius:10px; background:#2563eb; color:#fff; border:none; cursor:pointer; font-weight:600; }
</div> .muted { color:#6b7280; }
<div class="flex items-center justify-between"> a { color:#2563eb; text-decoration:none; }
<label class="block text-sm mb-1">Запомнить меня</label> </style>
<input type="checkbox" name="remember_me" class="rounded border px-2 py-1"/> </head>
</div> <body>
{% if error_message %} <div class="wrap">
<p class="text-red-500 text-sm">{{ error_message }}</p> <h1>Вход</h1>
{% if messages %}
<ul>
{% for message in messages %}
<li class="{{ message.tags }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %} {% endif %}
<p class="text-sm">Нет аккаунта? <a class="text-indigo-700 hover:underline" href="{% url 'register' %}">Зарегистрироваться</a></p>
<button class="w-full rounded-md bg-indigo-600 text-white px-4 py-2 hover:bg-indigo-700">Войти</button> <form class="form" method="post" action="{% url 'ui:login' %}">
{% csrf_token %}
<input type="email" name="email" placeholder="Email">
<input type="password" name="password" placeholder="Пароль">
<button class="btn" type="submit">Войти</button>
</form> </form>
<p class="muted" style="margin-top:10px;">Нет аккаунта?
<a href="{% url 'ui:register' %}">Зарегистрируйтесь</a>
</p>
</div> </div>
{% endblock %} </body>
</html>

View File

@@ -1,4 +1,4 @@
{% load static %} {% load static ui_extras %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
@@ -10,18 +10,24 @@
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; } body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; }
.topbar { display:flex; gap:16px; align-items:center; padding:14px 18px; background:#111827; color:#fff; } .topbar { display:flex; gap:16px; align-items:center; padding:14px 18px; background:#111827; color:#fff; }
.topbar a { color:#cfe3ff; text-decoration:none; } .topbar a { color:#cfe3ff; text-decoration:none; }
.container { max-width:900px; margin:24px auto; padding:0 16px; } .container { max-width:960px; margin:24px auto; padding:0 16px; }
.card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; } .card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; }
.row { display:flex; gap:18px; align-items:center; } .row { display:flex; gap:18px; align-items:center; }
.grow { flex:1 1 auto; } .grow { flex:1 1 auto; }
.muted { color:#6b7280; font-size:14px; } .muted { color:#6b7280; font-size:14px; }
.pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; } .pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
.avatar { width:96px; height:96px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:32px; background:#e5e7eb; color:#374151; } .avatar { width:96px; height:96px; border-radius:50%; overflow:hidden; background:#e5e7eb; display:flex; align-items:center; justify-content:center; font-weight:700; color:#374151; }
.avatar img { width:96px; height:96px; object-fit:cover; border-radius:50%; display:block; } .avatar img { width:96px; height:96px; object-fit:cover; display:block; }
.btn { display:inline-block; padding:9px 12px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; text-decoration:none; color:#111; }
/* Детальные списки */
dl { display:grid; grid-template-columns: 220px 1fr; gap:8px 14px; margin:0; } dl { display:grid; grid-template-columns: 220px 1fr; gap:8px 14px; margin:0; }
dt { font-weight:600; color:#374151; } dt { font-weight:600; color:#374151; }
dd { margin:0; color:#111827; } dd { margin:0; color:#111827; }
.btn { display:inline-block; padding:9px 12px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; text-decoration:none; color:#111; } .pill-wrap { display:flex; flex-wrap:wrap; gap:6px; }
.grid { display:grid; gap:16px; }
.grid-2 { grid-template-columns: 1fr 1fr; }
@media (max-width: 800px) { .grid-2 { grid-template-columns: 1fr; } }
</style> </style>
</head> </head>
<body> <body>
@@ -29,29 +35,40 @@
<header class="topbar"> <header class="topbar">
<div style="flex:1 1 auto;">Карточка пользователя (ADMIN)</div> <div style="flex:1 1 auto;">Карточка пользователя (ADMIN)</div>
<nav style="display:flex; gap:14px;"> <nav style="display:flex; gap:14px;">
<a href="{% url 'profiles' %}">← Каталог</a> <a href="{% url 'ui:profiles' %}">← Каталог</a>
<a href="{% url 'cabinet' %}">Кабинет</a> <a href="{% url 'ui:cabinet' %}">Кабинет</a>
<a href="{% url 'logout' %}">Выход</a> <a href="{% url 'ui:logout' %}">Выход</a>
</nav> </nav>
</header> </header>
<main class="container"> <main class="container">
<div class="card"> <!-- Шапка с аватаром, именем и лайком -->
<div class="card" style="margin-bottom:16px;">
<div class="row"> <div class="row">
<div class="avatar"> <div class="avatar">
{% if profile.photo %} {% if profile.photo_url %}
<img src="{{ profile.photo_url }}" alt="">
{% elif profile.photo %}
<img src="{{ profile.photo }}" alt=""> <img src="{{ profile.photo }}" alt="">
{% elif profile.email %}
<img src="{{ profile.email|gravatar_url:96 }}" alt="">
{% else %} {% else %}
{{ profile.name|first|upper }} {{ profile.name|initial }}
{% endif %} {% endif %}
</div> </div>
<div class="grow"> <div class="grow">
<div style="font-weight:700; font-size:20px;">{{ profile.name }}</div> <div style="font-weight:700; font-size:20px;">{{ profile.name }}</div>
<div class="muted" style="margin-top:4px;">ID пользователя: {{ profile.id }}</div>
{% if profile.email %}
<div class="muted">Email: {{ profile.email }}</div>
{% endif %}
{% if profile.role %}
<div class="muted">Роль: <span class="pill">{{ profile.role }}</span></div>
{% endif %}
</div> </div>
<div> <div>
<form method="post" action="{% url 'like_profile' profile.id %}"> <form method="post" action="{% url 'ui:like_profile' profile.id %}">
{% csrf_token %} {% csrf_token %}
{% include "ui/components/like_button.html" with profile_id=profile.id liked=liked %} {% include "ui/components/like_button.html" with profile_id=profile.id liked=liked %}
</form> </form>
@@ -59,24 +76,57 @@
</div> </div>
</div> </div>
<div class="card" style="margin-top:16px;"> <!-- Полные данные -->
<h2 class="muted" style="margin-top:0;">Профиль</h2> <div class="grid grid-2">
<!-- Данные аккаунта (из UserRead) -->
<section class="card">
<h2 class="muted" style="margin-top:0;">Данные аккаунта</h2>
<dl>
<dt>Имя</dt><dd>{{ profile.name|default:"—" }}</dd>
<dt>Email</dt><dd>{{ profile.email|default:"—" }}</dd>
<dt>Роль</dt><dd>{{ profile.role|default:"—" }}</dd>
<dt>Статус</dt><dd>{% if profile.verified %}<span class="pill">ACTIVE</span>{% else %}<span class="pill">INACTIVE</span>{% endif %}</dd>
<dt>ID пользователя</dt><dd><code>{{ profile.id|default:"—" }}</code></dd>
</dl>
</section>
<!-- Данные профиля (ProfileOut) -->
<section class="card">
<h2 class="muted" style="margin-top:0;">Данные профиля</h2>
<dl> <dl>
<dt>Пол</dt><dd>{{ profile.gender|default:"—" }}</dd> <dt>Пол</dt><dd>{{ profile.gender|default:"—" }}</dd>
<dt>Город</dt><dd>{{ profile.city|default:"—" }}</dd> <dt>Город</dt><dd>{{ profile.city|default:"—" }}</dd>
<dt>Языки</dt> <dt>Языки</dt>
<dd> <dd>
{% if profile.languages %} {% if profile.languages %}
<div class="pill-wrap">
{% for lang in profile.languages %}<span class="pill">{{ lang }}</span>{% endfor %} {% for lang in profile.languages %}<span class="pill">{{ lang }}</span>{% endfor %}
</div>
{% else %} — {% endif %} {% else %} — {% endif %}
</dd> </dd>
<dt>Интересы</dt> <dt>Интересы</dt>
<dd> <dd>
{% if profile.interests %} {% if profile.interests %}
<div class="pill-wrap">
{% for it in profile.interests %}<span class="pill">{{ it }}</span>{% endfor %} {% for it in profile.interests %}<span class="pill">{{ it }}</span>{% endfor %}
</div>
{% else %} — {% endif %}
</dd>
<dt>О себе</dt><dd>{{ profile.about|default:"—" }}</dd>
<dt>Возраст</dt><dd>{{ profile.age|default:"—" }}</dd>
<dt>ID профиля</dt><dd><code>{{ profile.profile_id|default:profile.id|default:"—" }}</code></dd>
<dt>ID пользователя (в профиле)</dt><dd><code>{{ profile.user_id|default:"—" }}</code></dd>
<dt>Фото (URL)</dt><dd>
{% if profile.photo_url %}<code>{{ profile.photo_url }}</code>
{% elif profile.photo %}<code>{{ profile.photo }}</code>
{% else %} — {% endif %} {% else %} — {% endif %}
</dd> </dd>
</dl> </dl>
<p class="muted" style="margin-top:8px;">
Поля профиля основаны на контракте <code>ProfileOut</code> (gender, city, languages, interests, id, user_id).
Если это чужой пользователь, сервер может не возвращать профиль, поэтому часть значений будет пустой. :contentReference[oaicite:1]{index=1}
</p>
</section>
</div> </div>
</main> </main>

View File

@@ -1,4 +1,4 @@
{% load static %} {% load static ui_extras %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
@@ -11,6 +11,7 @@
.topbar { display:flex; gap:16px; align-items:center; padding:14px 18px; background:#111827; color:#fff; } .topbar { display:flex; gap:16px; align-items:center; padding:14px 18px; background:#111827; color:#fff; }
.topbar a { color:#cfe3ff; text-decoration:none; } .topbar a { color:#cfe3ff; text-decoration:none; }
.container { max-width:1100px; margin:24px auto; padding:0 16px; } .container { max-width:1100px; margin:24px auto; padding:0 16px; }
.messages { list-style:none; padding:0; margin:0 0 16px; } .messages { list-style:none; padding:0; margin:0 0 16px; }
.messages li { padding:10px 12px; margin-bottom:8px; border-radius:10px; } .messages li { padding:10px 12px; margin-bottom:8px; border-radius:10px; }
.messages li.success { background:#ecfdf5; color:#065f46; border:1px solid #a7f3d0; } .messages li.success { background:#ecfdf5; color:#065f46; border:1px solid #a7f3d0; }
@@ -18,43 +19,37 @@
.messages li.info { background:#eff6ff; color:#1e40af; border:1px solid #bfdbfe; } .messages li.info { background:#eff6ff; color:#1e40af; border:1px solid #bfdbfe; }
.filters { display:grid; grid-template-columns: repeat(8, 1fr); gap:10px; background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:14px; } .filters { display:grid; grid-template-columns: repeat(8, 1fr); gap:10px; background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:14px; }
.filters .full { grid-column: 1 / -1; } .filters input[type="text"], .filters select { width:100%; border:1px solid #d1d5db; border-radius:8px; padding:8px 10px; font:inherit; background:#fff; }
.filters input[type="text"], .filters select {
width:100%; border:1px solid #d1d5db; border-radius:8px; padding:8px 10px; font:inherit; background:#fff; /* Фото‑карточки */
.list { margin-top:16px; display:grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap:14px; }
.card-photo { position:relative; display:block; border-radius:14px; overflow:hidden; border:1px solid #e5e7eb; background:#e5e7eb; }
.card-photo img { width:100%; height:280px; object-fit:cover; display:block; }
.card-photo__overlay {
position:absolute; left:0; right:0; bottom:0;
padding:12px 14px;
background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.65) 90%);
color:#fff;
} }
.btn { display:inline-block; padding:9px 12px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; } .card-photo__title { font-weight:700; font-size:18px; text-shadow:0 1px 2px rgba(0,0,0,.4); }
.btn-primary { background:#2563eb; color:#fff; border-color:#2563eb; }
.list { margin-top:16px; display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:14px; }
.card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:14px; }
.row { display:flex; align-items:center; gap:12px; }
.grow { flex:1 1 auto; }
.muted { color:#6b7280; font-size:14px; }
.pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
.meta { margin-top:8px; display:flex; flex-wrap:wrap; gap:10px 18px; }
.meta .k { color:#6b7280; }
.meta .v { color:#111827; font-weight:600; }
.pagination { display:flex; gap:10px; margin:16px 0; align-items:center; } .pagination { display:flex; gap:10px; margin:16px 0; align-items:center; }
.pagination a, .pagination span { .pagination a, .pagination span { padding:8px 12px; border-radius:10px; border:1px solid #d1d5db; text-decoration:none; color:#111; background:#fff; }
padding:8px 12px; border-radius:10px; border:1px solid #d1d5db; text-decoration:none; color:#111;
background:#fff;
}
.pagination .disabled { opacity:.5; pointer-events:none; } .pagination .disabled { opacity:.5; pointer-events:none; }
.btn { padding:9px 12px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; }
.muted { color:#6b7280; font-size:14px; }
.pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
</style> </style>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div style="flex:1 1 auto;"> <div style="flex:1 1 auto;">Каталог анкет (ADMIN)</div>
Каталог анкет (ADMIN)
</div>
<nav style="display:flex; gap:14px;"> <nav style="display:flex; gap:14px;">
<a href="{% url 'index' %}">Главная</a> <a href="{% url 'ui:index' %}">Главная</a>
<a href="{% url 'cabinet' %}">Кабинет</a> <a href="{% url 'ui:cabinet' %}">Кабинет</a>
<a href="{% url 'profiles' %}">Каталог</a> <a href="{% url 'ui:profiles' %}">Каталог</a>
<a href="{% url 'logout' %}">Выход</a> <a href="{% url 'ui:logout' %}">Выход</a>
</nav> </nav>
</header> </header>
@@ -68,7 +63,8 @@
</ul> </ul>
{% endif %} {% endif %}
<form class="filters" method="get" action="{% url 'profiles' %}"> <!-- Фильтры оставил: админ всё ещё может искать/сортировать -->
<form class="filters" method="get" action="{% url 'ui:profiles' %}">
<div> <div>
<label class="muted">Поиск</label> <label class="muted">Поиск</label>
<input type="text" name="q" value="{{ filters.q }}" placeholder="имя или email"> <input type="text" name="q" value="{{ filters.q }}" placeholder="имя или email">
@@ -81,25 +77,12 @@
<option value="ADMIN" {% if filters.role == "ADMIN" %}selected{% endif %}>ADMIN</option> <option value="ADMIN" {% if filters.role == "ADMIN" %}selected{% endif %}>ADMIN</option>
</select> </select>
</div> </div>
<div>
<label class="muted">Активность</label>
<select name="active">
<option value="">Любая</option>
<option value="1" {% if filters.active == "1" %}selected{% endif %}>Активные</option>
<option value="0" {% if filters.active == "0" %}selected{% endif %}>Неактивные</option>
</select>
</div>
<div>
<label class="muted">Домен</label>
<input type="text" name="domain" value="{{ filters.domain }}" placeholder="example.com">
</div>
<div> <div>
<label class="muted">Сортировка</label> <label class="muted">Сортировка</label>
<select name="sort"> <select name="sort">
<option value="name" {% if filters.sort == "name" %}selected{% endif %}>Имя ↑</option> <option value="name" {% if filters.sort == "name" %}selected{% endif %}>Имя ↑</option>
<option value="name_desc" {% if filters.sort == "name_desc" %}selected{% endif %}>Имя ↓</option> <option value="name_desc" {% if filters.sort == "name_desc" %}selected{% endif %}>Имя ↓</option>
<option value="email" {% if filters.sort == "email" %}selected{% endif %}>Email ↑</option>
<option value="email_desc" {% if filters.sort == "email_desc" %}selected{% endif %}>Email ↓</option>
</select> </select>
</div> </div>
<div> <div>
@@ -115,7 +98,7 @@
<input type="text" name="page" value="{{ filters.page }}" style="width:90px;"> <input type="text" name="page" value="{{ filters.page }}" style="width:90px;">
</div> </div>
<div style="display:flex; align-items:flex-end;"> <div style="display:flex; align-items:flex-end;">
<button class="btn btn-primary" type="submit">Применить</button> <button class="btn" type="submit">Применить</button>
</div> </div>
</form> </form>
@@ -139,33 +122,25 @@
<div class="messages"><li class="error">{{ error }}</li></div> <div class="messages"><li class="error">{{ error }}</li></div>
{% endif %} {% endif %}
<!-- Фото‑плитка карточек -->
<div class="list"> <div class="list">
{% for p in profiles %} {% for p in profiles %}
<div class="card"> <a class="card-photo" href="{% url 'ui:profile_detail' p.id %}" aria-label="Открыть {{ p.name }}">
<div class="row"> {% if p.photo_url %}
<div class="grow"> <img src="{{ p.photo_url }}" alt="{{ p.name }}">
<div style="font-weight:700; font-size:16px;">{{ p.name }}</div> {% elif p.photo %}
<img src="{{ p.photo }}" alt="{{ p.name }}">
</div> {% elif p.email %}
<div> <img src="{{ p.email|gravatar_url:600 }}" alt="{{ p.name }}">
<form method="post" action="{% url 'like_profile' p.id %}"> {% else %}
{% csrf_token %} <img src="{% static 'img/profile_placeholder.jpg' %}" alt="{{ p.name }}">
{% include "ui/components/like_button.html" with profile_id=p.id liked=p.liked %} {% endif %}
</form> <div class="card-photo__overlay">
</div> <div class="card-photo__title">
</div> {{ p.name }}{% if p.age %}, {{ p.age }}{% endif %}
<div class="meta">
<div><span class="pill">{{ p.verified|yesno:"ACTIVE,INACTIVE" }}</span></div>
<div class="pill">{{ p.role|default:"USER" }}</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="grow"></div>
<a class="btn" href="{% url 'profile_detail' p.id %}">Открыть</a>
</div> </div>
</div> </div>
</a>
{% empty %} {% empty %}
<div class="card"> <div class="card">
<div class="muted">Ничего не найдено. Попробуйте изменить фильтры.</div> <div class="muted">Ничего не найдено. Попробуйте изменить фильтры.</div>
@@ -190,6 +165,5 @@
</div> </div>
</main> </main>
</body> </body>
</html> </html>

View File

@@ -1,24 +1,44 @@
{% extends 'base.html' %} {% load static %}
{% block title %}Регистрация — MatchAgency{% endblock %} <!doctype html>
{% block content %} <html lang="ru">
<div class="max-w-md mx-auto rounded-xl bg-white/80 backdrop-blur border shadow p-6"> <head>
<h1 class="text-xl font-semibold mb-4">Регистрация</h1> <meta charset="utf-8">
<form action="" method="post" class="space-y-3"> <title>Регистрация</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="{% static 'style.css' %}" rel="stylesheet">
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; }
.wrap { max-width:520px; margin:60px auto; background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; }
.form { display:grid; gap:12px; }
.form input { border:1px solid #d1d5db; border-radius:8px; padding:10px 12px; font:inherit; }
.btn { padding:10px 14px; border-radius:10px; background:#2563eb; color:#fff; border:none; cursor:pointer; font-weight:600; }
.muted { color:#6b7280; }
a { color:#2563eb; text-decoration:none; }
</style>
</head>
<body>
<div class="wrap">
<h1>Регистрация</h1>
{% if messages %}
<ul>
{% for message in messages %}
<li class="{{ message.tags }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<form class="form" method="post" action="{% url 'ui:register' %}">
{% csrf_token %} {% csrf_token %}
<div> <input type="text" name="full_name" placeholder="Имя / ФИО">
<label class="block text-sm mb-1">Email</label> <input type="email" name="email" placeholder="Email" required>
<input type="email" name="email" required class="w-full rounded-md border px-3 py-2"/> <input type="password" name="password" placeholder="Пароль" required>
</div> <button class="btn" type="submit">Создать аккаунт</button>
<div>
<label class="block text-sm mb-1">Пароль</label>
<input type="password" name="password" required class="w-full rounded-md border px-3 py-2"/>
</div>
<div>
<label class="block text-sm mb-1">Полное имя (необязательно)</label>
<input type="text" name="full_name" class="w-full rounded-md border px-3 py-2"/>
</div>
<button class="w-full rounded-md bg-indigo-600 text-white px-4 py-2 hover:bg-indigo-700">Зарегистрироваться</button>
</form> </form>
<p class="mt-3 text-sm">Уже есть аккаунт? <a class="text-indigo-700 hover:underline" href="{% url 'login' %}">Войти</a></p>
<p class="muted" style="margin-top:10px;">Уже есть аккаунт?
<a href="{% url 'ui:login' %}">Войти</a>
</p>
</div> </div>
{% endblock %} </body>
</html>

636
ui/api.py
View File

@@ -1,10 +1,7 @@
import logging import logging
import os import os
import os.path
import json
import time
import uuid import uuid
from typing import Any, Dict, Optional, Tuple, List, Union from typing import Any, Dict, Optional, Tuple, List, Union, IO
import requests import requests
from django.conf import settings from django.conf import settings
@@ -12,189 +9,111 @@ from django.core.exceptions import PermissionDenied
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ===== Логирование / флаги ===== # === Конфиг лога (можно подкрутить через .env): ===============================
API_DEBUG = os.environ.get('API_DEBUG', '1') == '1' API_DEBUG = bool(int(os.environ.get("API_DEBUG", getattr(settings, "API_DEBUG", 1))))
API_LOG_BODY_MAX = int(os.environ.get('API_LOG_BODY_MAX', '2000')) API_LOG_BODY_MAX = int(os.environ.get("API_LOG_BODY_MAX", getattr(settings, "API_LOG_BODY_MAX", 2000)))
API_LOG_HEADERS = os.environ.get('API_LOG_HEADERS', '1') == '1' API_LOG_HEADERS = bool(int(os.environ.get("API_LOG_HEADERS", getattr(settings, "API_LOG_HEADERS", 1))))
API_LOG_CURL = os.environ.get('API_LOG_CURL', '0') == '1' API_LOG_CURL = bool(int(os.environ.get("API_LOG_CURL", getattr(settings, "API_LOG_CURL", 1))))
API_FALLBACK_OPENAPI_ON_404 = bool(int(os.environ.get("API_FALLBACK_OPENAPI_ON_404", getattr(settings, "API_FALLBACK_OPENAPI_ON_404", 1))))
# Переключение базы при 404: сначала servers[0].url из openapi.json, потом жёстко http://localhost:8080 # === База API и пути по умолчанию (OpenAPI v1): ===============================
API_FALLBACK_OPENAPI_ON_404 = os.environ.get('API_FALLBACK_OPENAPI_ON_404', '1') == '1' def _from_settings_or_env(name: str, default: Optional[str] = None) -> Optional[str]:
return getattr(settings, name, None) or os.environ.get(name, None) or default
SENSITIVE_KEYS = {'password', 'refresh_token', 'access_token', 'authorization', 'token', 'api_key'} _API_BASE_PRIMARY = (_from_settings_or_env("API_BASE_URL", None) or "http://localhost:8080").rstrip("/")
_API_BASE_CACHE = None # текущая выбранная база
_API_LAST_SELECT_SRC = "ENV/SETTINGS"
def _sanitize(obj: Any) -> Any:
try:
if isinstance(obj, dict):
return {k: ('***' if isinstance(k, str) and k.lower() in SENSITIVE_KEYS else _sanitize(v))
for k, v in obj.items()}
if isinstance(obj, list):
return [_sanitize(x) for x in obj]
return obj
except Exception:
return obj
def _shorten(s: str, limit: int) -> str:
if s is None:
return ''
return s if len(s) <= limit else (s[:limit] + f'... <truncated {len(s)-limit} chars>')
def _build_curl(method: str, url: str, headers: Dict[str, str],
params: Optional[dict], json_body: Optional[dict], data: Optional[dict]) -> str:
parts = [f"curl -X '{method.upper()}' \\\n '{url}'"]
for k, v in headers.items():
if k.lower() == 'authorization':
v = 'Bearer ***'
parts.append(f" -H '{k}: {v}'")
if json_body is not None:
parts.append(" -H 'Content-Type: application/json'")
try:
body_str = json.dumps(_sanitize(json_body), ensure_ascii=False)
except Exception:
body_str = str(_sanitize(json_body))
parts.append(f" -d '{body_str}'")
elif data is not None:
try:
body_str = json.dumps(_sanitize(data), ensure_ascii=False)
except Exception:
body_str = str(_sanitize(data))
parts.append(f" --data-raw '{body_str}'")
return " \\\n".join(parts)
# ===== Пути эндпоинтов (как в swagger) =====
EP_DEFAULTS: Dict[str, str] = { EP_DEFAULTS: Dict[str, str] = {
# Auth # AUTH
'AUTH_REGISTER_PATH': '/auth/v1/register', "AUTH_REGISTER_PATH": "/auth/v1/register",
'AUTH_TOKEN_PATH': '/auth/v1/token', "AUTH_TOKEN_PATH": "/auth/v1/token",
'AUTH_REFRESH_PATH': '/auth/v1/refresh', "AUTH_REFRESH_PATH": "/auth/v1/refresh",
'ME_PATH': '/auth/v1/me', "AUTH_ME_PATH": "/auth/v1/me",
'USERS_LIST_PATH': '/auth/v1/users', # USERS (admin and/or owner)
'USER_DETAIL_PATH': '/auth/v1/users/{user_id}', "USERS_LIST_PATH": "/auth/v1/users",
"USER_DETAIL_PATH": "/auth/v1/users/{user_id}",
# Profiles # PROFILES
'PROFILE_ME_PATH': '/profiles/v1/profiles/me', "PROFILE_ME_PATH": "/profiles/v1/profiles/me",
'PROFILES_CREATE_PATH': '/profiles/v1/profiles', "PROFILE_CREATE_PATH": "/profiles/v1/profiles",
'PROFILE_PHOTO_UPLOAD_PATH': '/profiles/v1/profiles/me/photo', # Optional, если сервер будет поддерживать:
'PROFILE_PHOTO_DELETE_PATH': '/profiles/v1/profiles/me/photo', "PROFILE_ME_PATCH_PATH": "", # пусто → нет на бэкенде
"PROFILE_PHOTO_UPLOAD_PATH": "", # "/profiles/v1/profiles/me/photo"
# Pairs "PROFILE_PHOTO_DELETE_PATH": "", # "/profiles/v1/profiles/me/photo"
'PAIRS_PATH': '/match/v1/pairs',
'PAIR_DETAIL_PATH': '/match/v1/pairs/{pair_id}',
'PAIR_ACCEPT_PATH': '/match/v1/pairs/{pair_id}/accept',
'PAIR_REJECT_PATH': '/match/v1/pairs/{pair_id}/reject',
# Chat
'ROOMS_PATH': '/chat/v1/rooms',
'ROOM_DETAIL_PATH': '/chat/v1/rooms/{room_id}',
'ROOM_MESSAGES_PATH': '/chat/v1/rooms/{room_id}/messages',
# Payments
'INVOICES_PATH': '/payments/v1/invoices',
'INVOICE_DETAIL_PATH': '/payments/v1/invoices/{inv_id}',
'INVOICE_MARK_PAID_PATH': '/payments/v1/invoices/{inv_id}/mark-paid',
} }
def EP(key: str) -> str: def EP(key: str, **fmt) -> str:
return os.environ.get(f'API_{key}', EP_DEFAULTS[key]) # при желании можно переопределить путями в settings или ENV: API_<KEY>
override = _from_settings_or_env(f"API_{key}", None)
path = (override or EP_DEFAULTS[key]).format(**fmt)
if not path.startswith("/"):
path = "/" + path
return path
def _get_api_base() -> str:
# ===== База API с авто‑детектом =====
_API_BASE_CACHE: Optional[str] = None
_API_LAST_SELECT_SRC = 'DEFAULT' # для логов
def _detect_api_base_from_openapi() -> Optional[str]:
"""
Берём servers[0].url из openapi.json (по схеме — http://localhost:8080).
"""
candidates = [
os.environ.get('API_SPEC_PATH'),
getattr(settings, 'API_SPEC_PATH', None),
os.path.join(getattr(settings, 'BASE_DIR', ''), 'openapi.json') if getattr(settings, 'BASE_DIR', None) else None,
os.path.join(getattr(settings, 'BASE_DIR', ''), 'agency', 'openapi.json') if getattr(settings, 'BASE_DIR', None) else None,
'/mnt/data/openapi.json',
]
for p in candidates:
if p and os.path.isfile(p):
try:
with open(p, 'r', encoding='utf-8') as f:
spec = json.load(f)
servers = spec.get('servers') or []
if servers and isinstance(servers[0], dict):
url = servers[0].get('url')
if url:
return url
except Exception as e:
if API_DEBUG:
logger.debug('API: cannot read OpenAPI from %s: %s', p, e)
return None
def _get_api_base_url() -> str:
"""
Источники (по приоритету):
1) ENV/Settings: API_BASE_URL, затем BASE_URL
2) servers[0].url из openapi.json
3) 'http://localhost:8080'
"""
global _API_BASE_CACHE, _API_LAST_SELECT_SRC global _API_BASE_CACHE, _API_LAST_SELECT_SRC
if _API_BASE_CACHE: if _API_BASE_CACHE:
return _API_BASE_CACHE return _API_BASE_CACHE
# пробуем взять из ENV/SETTINGS
base = (os.environ.get('API_BASE_URL') or getattr(settings, 'API_BASE_URL', '') _API_BASE_CACHE = _API_BASE_PRIMARY
or os.environ.get('BASE_URL') or getattr(settings, 'BASE_URL', '')) _API_LAST_SELECT_SRC = "ENV/SETTINGS"
if base:
_API_BASE_CACHE = base.rstrip('/')
_API_LAST_SELECT_SRC = 'ENV/SETTINGS'
else:
detected = _detect_api_base_from_openapi()
if detected:
_API_BASE_CACHE = detected.rstrip('/')
_API_LAST_SELECT_SRC = 'OPENAPI'
else:
_API_BASE_CACHE = 'http://localhost:8080'
_API_LAST_SELECT_SRC = 'HARDCODED'
if API_DEBUG: if API_DEBUG:
logger.info("API base selected [%s]: %s", _API_LAST_SELECT_SRC, _API_BASE_CACHE) logger.info("API base selected [%s]: %s", _API_LAST_SELECT_SRC, _API_BASE_CACHE)
return _API_BASE_CACHE return _API_BASE_CACHE
def _set_api_base(new_base: str):
global _API_BASE_CACHE, _API_LAST_SELECT_SRC
if new_base and new_base.rstrip("/") != _API_BASE_CACHE:
old = _API_BASE_CACHE
_API_BASE_CACHE = new_base.rstrip("/")
_API_LAST_SELECT_SRC = "FALLBACK"
if API_DEBUG:
logger.warning("API BASE SWITCHED: %s%s", old, _API_BASE_CACHE)
# === Исключение API: ==========================================================
class ApiError(Exception): class ApiError(Exception):
def __init__(self, status: int, message: str = 'API error', payload: Optional[dict] = None, req_id: Optional[str] = None): def __init__(self, status: int, message: str = "API error", payload: Optional[dict] = None):
if req_id and message and 'req_id=' not in message:
message = f"{message} (req_id={req_id})"
super().__init__(message) super().__init__(message)
self.status = status self.status = status
self.payload = payload or {} self.payload = payload or {}
self.req_id = req_id
# === Вспомогательные: =========================================================
def _base_headers(request, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: def _headers(request, *, extra: Optional[Dict[str, str]] = None, json_mode: bool = True) -> Dict[str, str]:
""" h = {"Accept": "application/json"}
Достаём токен сначала из сессии, затем из куки — чтобы «куки‑режим» работал без доп. настроек. if json_mode:
""" h["Content-Type"] = "application/json"
headers: Dict[str, str] = {'Accept': 'application/json'} # Токен: из сессии или из cookie (HTTPCookie)
token = request.session.get('access_token') or request.COOKIES.get('access_token') token = request.session.get("access_token") or request.COOKIES.get("access_token")
if token: if token:
headers['Authorization'] = f'Bearer {token}' h["Authorization"] = f"Bearer {token}"
api_key = getattr(settings, 'API_KEY', '') or os.environ.get('API_KEY', '') api_key = getattr(settings, "API_KEY", "") or os.environ.get("API_KEY", "")
if api_key: if api_key:
headers['X-API-Key'] = api_key h["X-API-Key"] = api_key
if extra: if extra:
headers.update(extra) h.update(extra)
return headers return h
def _url(path: str) -> str: def _url(path: str) -> str:
base = _get_api_base_url() base = _get_api_base()
path = path if path.startswith('/') else '/' + path path = path if path.startswith("/") else "/" + path
return base + path return base + path
def _log_curl(method: str, url: str, headers: Dict[str, str], body: Optional[Union[dict, str]]):
if not API_LOG_CURL:
return
parts = ["curl", "-X", method.upper(), f"'{url}'"]
if headers:
for k, v in headers.items():
if k.lower() == "authorization":
v = "*****"
parts += ["-H", f"'{k}: {v}'"]
if body is not None and body != {}:
import json as _json
b = body if isinstance(body, str) else _json.dumps(body, ensure_ascii=False)
parts += ["-d", f"'{b}'"]
logger.debug("CURL> %s", " ".join(parts))
# === Базовый HTTP вызов с ретраями/refresh/fallback: ==========================
def request_api( def request_api(
request, request,
method: str, method: str,
@@ -203,325 +122,184 @@ def request_api(
params: Optional[dict] = None, params: Optional[dict] = None,
json: Optional[dict] = None, json: Optional[dict] = None,
files: Optional[dict] = None, files: Optional[dict] = None,
data: Optional[dict] = None,
) -> Tuple[int, Any]: ) -> Tuple[int, Any]:
""" rid = uuid.uuid4().hex[:8]
Универсальный HTTP-вызов (упрощённая версия):
- токен/ключ в заголовках (токен берём из сессии или куки),
- auto-refresh при 401 (refresh берём тоже из сессии или куки),
- 404 → переключаем базу: servers[0].url из openapi.json → 'http://localhost:8080',
- подробные логи; БЕЗ «retry со слэшем».
"""
global _API_BASE_CACHE, _API_LAST_SELECT_SRC
req_id = uuid.uuid4().hex[:8]
base_before = _get_api_base_url()
url = _url(path) url = _url(path)
json_mode = files is None
def _do(_url: str): headers = _headers(request, json_mode=json_mode)
headers = _base_headers(request)
if json is not None and files is None and data is None:
headers['Content-Type'] = 'application/json'
if API_DEBUG: if API_DEBUG:
log_headers = _sanitize(headers) if API_LOG_HEADERS else {} b_preview = ("***" if json and "password" in (json or {}) else json)
log_body = _sanitize(json if json is not None else data) if isinstance(b_preview, dict) and "password" in (b_preview or {}):
if API_LOG_CURL: b_preview = dict(b_preview); b_preview["password"] = "***"
try: logger.info("API[req_id=%s] REQUEST %s %s params=%s headers=%s body=%s",
curl = _build_curl(method, _url, headers, params, json, data) rid, method.upper(), url, params, (headers if API_LOG_HEADERS else "{…}"),
logger.debug("API[req_id=%s] cURL:\n%s", req_id, curl) (str(b_preview)[:API_LOG_BODY_MAX] if b_preview else None))
except Exception: _log_curl(method, url, headers, b_preview)
pass
logger.info(
"API[req_id=%s] REQUEST %s %s params=%s headers=%s body=%s",
req_id, method.upper(), _url, _sanitize(params), log_headers, log_body
)
t0 = time.time() try:
resp = requests.request( resp = requests.request(
method=method.upper(), method=method.upper(),
url=_url, url=url,
headers=headers, headers=headers,
params=params, params=params,
json=json, json=json if json_mode else None,
data=data, files=files if not json_mode else None,
files=files, timeout=float(getattr(settings, "API_TIMEOUT", os.environ.get("API_TIMEOUT", 6.0))),
timeout=float(getattr(settings, 'API_TIMEOUT', 8.0)),
) )
dt = int((time.time() - t0) * 1000)
content_type = resp.headers.get('Content-Type', '')
try:
payload = resp.json() if 'application/json' in content_type else {}
except ValueError:
payload = {}
if API_DEBUG:
body_str = ""
try:
body_str = json.dumps(_sanitize(payload), ensure_ascii=False)
except Exception:
body_str = str(_sanitize(payload))
headers_out = _sanitize(dict(resp.headers)) if API_LOG_HEADERS else {}
logger.info(
"API[req_id=%s] RESPONSE %s %sms ct=%s headers=%s body=%s",
req_id, resp.status_code, dt, content_type, headers_out, _shorten(body_str, API_LOG_BODY_MAX)
)
return resp, payload
# 1) Первый запрос
try:
resp, payload = _do(url)
except requests.RequestException as e: except requests.RequestException as e:
logger.exception('API[req_id=%s] network error: %s', req_id, e) logger.exception("API network error: %s", e)
raise ApiError(0, f'Network unavailable or timeout when accessing API ({e})', req_id=req_id) raise ApiError(0, f"Network unavailable or timeout when accessing API ({e})")
# 2) 404 → переключаем базу (openapi → 8080) и повторяем content_type = resp.headers.get("Content-Type", "")
if resp.status_code == 404:
candidates: List[tuple[str, str]] = []
if API_FALLBACK_OPENAPI_ON_404:
detected = _detect_api_base_from_openapi()
if detected:
candidates.append((detected.rstrip('/'), 'OPENAPI(FAILOVER)'))
candidates.append(('http://localhost:8080', 'DEFAULT(FAILOVER)'))
for cand_base, label in candidates:
if not cand_base or cand_base == _API_BASE_CACHE:
continue
if API_DEBUG:
logger.warning("API[req_id=%s] 404 on base %s → switch API base to %s and retry",
req_id, _API_BASE_CACHE, cand_base)
_API_BASE_CACHE = cand_base
_API_LAST_SELECT_SRC = label
try: try:
resp, payload = _do(_url(path)) data = resp.json() if "application/json" in content_type else {}
if resp.status_code != 404:
break
except requests.RequestException:
continue
# 3) 401 → refresh и повтор
refresh_token = request.session.get('refresh_token') or request.COOKIES.get('refresh_token')
if resp.status_code == 401 and refresh_token:
if API_DEBUG:
logger.info("API[req_id=%s] 401 → try refresh token", req_id)
try:
refresh_url = _url(EP('AUTH_REFRESH_PATH'))
refresh_body = {'refresh_token': refresh_token}
logger.info("API[req_id=%s] REFRESH POST %s body=%s", req_id, refresh_url, _sanitize(refresh_body))
refresh_resp = requests.post(refresh_url, json=refresh_body, timeout=float(getattr(settings, 'API_TIMEOUT', 8.0)))
if refresh_resp.status_code == 200:
try:
rj = refresh_resp.json()
except ValueError: except ValueError:
rj = {} data = {}
if rj.get('access_token'):
request.session['access_token'] = rj['access_token'] if API_DEBUG:
if rj.get('refresh_token'): b_preview = data
request.session['refresh_token'] = rj['refresh_token'] logger.info("API[req_id=%s] RESPONSE %s %sms ct=%s headers=%s body=%s",
rid, resp.status_code, int(resp.elapsed.total_seconds()*1000),
content_type, (dict(resp.headers) if API_LOG_HEADERS else "{…}"),
(str(b_preview)[:API_LOG_BODY_MAX] if b_preview else None))
# авто-ретрай на 401: пробуем refresh
if resp.status_code == 401 and (request.session.get("refresh_token") or request.COOKIES.get("refresh_token")):
if API_DEBUG:
logger.info("API[req_id=%s] 401 → try refresh token", rid)
try:
refresh_json = {"refresh_token": request.session.get("refresh_token") or request.COOKIES.get("refresh_token")}
r = requests.post(_url(EP("AUTH_REFRESH_PATH")), json=refresh_json, timeout=float(getattr(settings, "API_TIMEOUT", 6.0)))
if r.status_code == 200:
tokens = r.json()
request.session["access_token"] = tokens.get("access_token")
request.session["refresh_token"] = tokens.get("refresh_token")
request.session.modified = True request.session.modified = True
if API_DEBUG: if API_DEBUG:
logger.info("API[req_id=%s] REFRESH OK → retry original request", req_id) logger.info("API[req_id=%s] REFRESH OK → retry original request", rid)
resp, payload = _do(_url(path)) # повторяем исходный
headers = _headers(request, json_mode=json_mode)
resp = requests.request(
method=method.upper(), url=url, headers=headers,
params=params, json=json if json_mode else None, files=files if not json_mode else None,
timeout=float(getattr(settings, "API_TIMEOUT", 6.0)),
)
content_type = resp.headers.get("Content-Type", "")
data = resp.json() if "application/json" in content_type else {}
else: else:
logger.warning("API[req_id=%s] REFRESH failed: %s", req_id, refresh_resp.status_code) if API_DEBUG:
logger.warning("API[req_id=%s] REFRESH FAIL %s body=%s", rid, r.status_code, r.text[:200])
except requests.RequestException as e: except requests.RequestException as e:
logger.exception('API[req_id=%s] Refresh token network error: %s', req_id, e) logger.exception("Refresh token error: %s", e)
raise ApiError(401, f'Token refresh failed ({e})', req_id=req_id) raise ApiError(401, f"Token refresh failed ({e})")
# 4) Ошибки # fallback базы на 404 (например, локальный gateway vs 8080)
if resp.status_code == 404 and API_FALLBACK_OPENAPI_ON_404:
# пробуем со слешем
if not url.endswith("/"):
url_slash = url + "/"
if API_DEBUG:
logger.info("API[req_id=%s] 404 → retry with trailing slash", rid)
resp2 = requests.request(method=method.upper(), url=url_slash, headers=headers,
params=params, json=json if json_mode else None, files=files if not json_mode else None,
timeout=float(getattr(settings, "API_TIMEOUT", 6.0)))
content_type2 = resp2.headers.get("Content-Type", "")
data2 = resp2.json() if "application/json" in content_type2 else {}
if resp2.status_code != 404:
resp, data, content_type = resp2, data2, content_type2
# если всё ещё 404 и текущая база не 8080 — переключаемся и повторяем
if resp.status_code == 404 and _get_api_base() != "http://localhost:8080":
if API_DEBUG:
logger.warning("API[req_id=%s] 404 on base %s → switch API base to http://localhost:8080 and retry",
rid, _get_api_base())
_set_api_base("http://localhost:8080")
url = _url(path)
headers = _headers(request, json_mode=json_mode)
resp = requests.request(method=method.upper(), url=url, headers=headers,
params=params, json=json if json_mode else None, files=files if not json_mode else None,
timeout=float(getattr(settings, "API_TIMEOUT", 6.0)))
content_type = resp.headers.get("Content-Type", "")
try:
data = resp.json() if "application/json" in content_type else {}
except ValueError:
data = {}
# финальная проверка кода
if not (200 <= resp.status_code < 300): if not (200 <= resp.status_code < 300):
msg = None msg = data.get("detail") or data.get("message") or f"API error: {resp.status_code}"
if isinstance(payload, dict):
msg = payload.get('detail') or payload.get('message')
msg = msg or f'API error: {resp.status_code}'
if resp.status_code in (401, 403): if resp.status_code in (401, 403):
# PermissionDenied обрабатываем во view (не всегда это «выйти и войти заново») raise PermissionDenied(msg)
raise PermissionDenied(f"{msg} (req_id={req_id})") raise ApiError(resp.status_code, msg, data)
raise ApiError(resp.status_code, msg, payload if isinstance(payload, dict) else {}, req_id=req_id)
# 5) База сменилась — отметим return resp.status_code, data
base_after = _get_api_base_url()
if API_DEBUG and base_before != base_after:
logger.warning("API[req_id=%s] BASE SWITCHED: %s%s", req_id, base_before, base_after)
return resp.status_code, payload # === High-level helpers в соответствии с OpenAPI v1 ==========================
def register_user(request, email: str, password: str, full_name: Optional[str] = None, role: str = "CLIENT") -> Dict[str, Any]:
# ========================== body = {"email": email, "password": password, "full_name": full_name, "role": role}
# AUTH _, data = request_api(request, "POST", EP("AUTH_REGISTER_PATH"), json=body)
# ==========================
def register_user(request, email: str, password: str, full_name: Optional[str] = None, role: str = 'CLIENT') -> Dict[str, Any]:
body = {'email': email, 'password': password, 'full_name': full_name, 'role': role}
_, data = request_api(request, 'POST', EP('AUTH_REGISTER_PATH'), json=body)
return data # UserRead return data # UserRead
def login(request, email: str, password: str) -> Dict[str, Any]: def login(request, email: str, password: str) -> Dict[str, Any]:
body = {'email': email, 'password': password} body = {"email": email, "password": password}
_, data = request_api(request, 'POST', EP('AUTH_TOKEN_PATH'), json=body) _, data = request_api(request, "POST", EP("AUTH_TOKEN_PATH"), json=body)
return data # TokenPair return data # TokenPair
def get_current_user(request) -> Dict[str, Any]: def get_me(request) -> Dict[str, Any]:
_, data = request_api(request, 'GET', EP('ME_PATH')) _, data = request_api(request, "GET", EP("AUTH_ME_PATH"))
return data # UserRead return data # UserRead
def list_users(request, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]: def list_users(request, offset: int = 0, limit: int = 50) -> List[Dict[str, Any]] | Dict[str, Any]:
params = {'offset': offset, 'limit': limit} params = {"offset": offset, "limit": min(max(limit, 1), 200)}
_, data = request_api(request, 'GET', EP('USERS_LIST_PATH'), params=params) _, data = request_api(request, "GET", EP("USERS_LIST_PATH"), params=params)
return data return data # array[UserRead] (в схеме — массив) или {"items": [...]} если backend так возвращает
def get_user(request, user_id: str) -> Dict[str, Any]: def get_user(request, user_id: str) -> Dict[str, Any]:
path = EP('USER_DETAIL_PATH').format(user_id=user_id) _, data = request_api(request, "GET", EP("USER_DETAIL_PATH", user_id=user_id))
_, data = request_api(request, 'GET', path) return data # UserRead
return data
def update_user(request, user_id: str, **fields) -> Dict[str, Any]: def update_user_me(request, user_id: str, *, full_name: Optional[str] = None, password: Optional[str] = None) -> Dict[str, Any]:
path = EP('USER_DETAIL_PATH').format(user_id=user_id) body: Dict[str, Any] = {}
_, data = request_api(request, 'PATCH', path, json=fields) if full_name is not None:
return data body["full_name"] = full_name
if password is not None:
def delete_user(request, user_id: str) -> None: body["password"] = password
path = EP('USER_DETAIL_PATH').format(user_id=user_id) _, data = request_api(request, "PATCH", EP("USER_DETAIL_PATH", user_id=user_id), json=body)
request_api(request, 'DELETE', path) return data # UserRead
# ==========================
# PROFILES
# ==========================
def get_my_profile(request) -> Dict[str, Any]: def get_my_profile(request) -> Dict[str, Any]:
_, data = request_api(request, 'GET', EP('PROFILE_ME_PATH')) _, data = request_api(request, "GET", EP("PROFILE_ME_PATH"))
return data # ProfileOut return data # ProfileOut
def create_my_profile(request, gender: str, city: str, languages: List[str], interests: List[str]) -> Dict[str, Any]: def create_my_profile(request, gender: str, city: str, languages: List[str], interests: List[str]) -> Dict[str, Any]:
body = {'gender': gender, 'city': city, 'languages': languages, 'interests': interests} body = {"gender": gender, "city": city, "languages": languages, "interests": interests}
_, data = request_api(request, 'POST', EP('PROFILES_CREATE_PATH'), json=body) _, data = request_api(request, "POST", EP("PROFILE_CREATE_PATH"), json=body)
return data # ProfileOut return data # ProfileOut
# Опционально: если сервер добавит PATCH /profiles/v1/profiles/me
# ========================== def patch_my_profile(request, **fields) -> Dict[str, Any]:
# PAIRS patch_path = EP("PROFILE_ME_PATCH_PATH")
# ========================== if not patch_path or patch_path.strip("/") == "":
raise ApiError(405, "Profile update endpoint is not available on backend")
def create_pair(request, user_id_a: str, user_id_b: str, score: Optional[float] = None, notes: Optional[str] = None) -> Dict[str, Any]: _, data = request_api(request, "PATCH", patch_path, json=fields)
body = {'user_id_a': user_id_a, 'user_id_b': user_id_b, 'score': score, 'notes': notes}
_, data = request_api(request, 'POST', EP('PAIRS_PATH'), json=body)
return data # PairRead
def list_pairs(request, for_user_id: Optional[str] = None, status: Optional[str] = None, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
params = {'for_user_id': for_user_id, 'status': status, 'offset': offset, 'limit': limit}
params = {k: v for k, v in params.items() if v is not None}
_, data = request_api(request, 'GET', EP('PAIRS_PATH'), params=params)
return data return data
def get_pair(request, pair_id: str) -> Dict[str, Any]: def upload_my_profile_photo(request, file_obj: IO[bytes]) -> Dict[str, Any]:
path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id)
_, data = request_api(request, 'GET', path)
return data
def update_pair(request, pair_id: str, **fields) -> Dict[str, Any]:
path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id)
_, data = request_api(request, 'PATCH', path, json=fields)
return data
def delete_pair(request, pair_id: str) -> None:
path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id)
request_api(request, 'DELETE', path)
def accept_pair(request, pair_id: str) -> Dict[str, Any]:
path = EP('PAIR_ACCEPT_PATH').format(pair_id=pair_id)
_, data = request_api(request, 'POST', path)
return data
def reject_pair(request, pair_id: str) -> Dict[str, Any]:
path = EP('PAIR_REJECT_PATH').format(pair_id=pair_id)
_, data = request_api(request, 'POST', path)
return data
# ==========================
# CHAT
# ==========================
def my_rooms(request) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
_, data = request_api(request, 'GET', EP('ROOMS_PATH'))
return data
def get_room(request, room_id: str) -> Dict[str, Any]:
path = EP('ROOM_DETAIL_PATH').format(room_id=room_id)
_, data = request_api(request, 'GET', path)
return data
def create_room(request, title: Optional[str] = None, participants: List[str] = []) -> Dict[str, Any]:
body = {'title': title, 'participants': participants}
_, data = request_api(request, 'POST', EP('ROOMS_PATH'), json=body)
return data
def send_message(request, room_id: str, content: str) -> Dict[str, Any]:
body = {'content': content}
path = EP('ROOM_MESSAGES_PATH').format(room_id=room_id)
_, data = request_api(request, 'POST', path, json=body)
return data
def list_messages(request, room_id: str, offset: int = 0, limit: int = 100) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
params = {'offset': offset, 'limit': limit}
path = EP('ROOM_MESSAGES_PATH').format(room_id=room_id)
_, data = request_api(request, 'GET', path, params=params)
return data
# ==========================
# PAYMENTS
# ==========================
def create_invoice(request, client_id: str, amount: float, currency: str, description: Optional[str] = None) -> Dict[str, Any]:
body = {'client_id': client_id, 'amount': amount, 'currency': currency, 'description': description}
_, data = request_api(request, 'POST', EP('INVOICES_PATH'), json=body)
return data
def list_invoices(request, client_id: Optional[str] = None, status: Optional[str] = None, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
params = {'client_id': client_id, 'status': status, 'offset': offset, 'limit': limit}
params = {k: v for k, v in params.items() if v is not None}
_, data = request_api(request, 'GET', EP('INVOICES_PATH'), params=params)
return data
def get_invoice(request, inv_id: str) -> Dict[str, Any]:
path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id)
_, data = request_api(request, 'GET', path)
return data
def update_invoice(request, inv_id: str, **fields) -> Dict[str, Any]:
path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id)
_, data = request_api(request, 'PATCH', path, json=fields)
return data
def delete_invoice(request, inv_id: str) -> None:
path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id)
request_api(request, 'DELETE', path)
def mark_invoice_paid(request, inv_id: str) -> Dict[str, Any]:
path = EP('INVOICE_MARK_PAID_PATH').format(inv_id=inv_id)
_, data = request_api(request, 'POST', path)
return data
def upload_my_photo(request, file_obj) -> Dict[str, Any]:
""" """
Отправляет multipart/form-data на бекенд для загрузки фото профиля. POST /profiles/v1/profiles/me/photo (multipart/form-data, field: file)
Ожидаем, что сервер примет поле 'file' и вернёт обновлённый профиль или {photo_url: "..."}. Возвращает ProfileOut с photo_url. (см. OpenAPI) :contentReference[oaicite:1]{index=1}
""" """
path = EP('PROFILE_PHOTO_UPLOAD_PATH')
filename = getattr(file_obj, 'name', 'photo.jpg') filename = getattr(file_obj, 'name', 'photo.jpg')
content_type = getattr(file_obj, 'content_type', 'application/octet-stream') content_type = getattr(file_obj, 'content_type', 'application/octet-stream')
files = {'file': (filename, file_obj, content_type)} files = {'file': (filename, file_obj, content_type)}
_, data = request_api(request, 'POST', path, files=files) _, data = request_api(request, 'POST', '/profiles/v1/profiles/me/photo', files=files)
return data return data
def delete_my_photo(request) -> Dict[str, Any]: def delete_my_photo(request) -> Dict[str, Any]:
""" path = EP("PROFILE_PHOTO_DELETE_PATH")
Удаляет фото профиля (если сервер поддерживает DELETE на том же пути). if not path or path.strip("/") == "":
""" raise ApiError(405, "Photo delete endpoint is not available on backend")
path = EP('PROFILE_PHOTO_DELETE_PATH') _, data = request_api(request, "DELETE", path)
_, data = request_api(request, 'DELETE', path)
return data return data

View File

@@ -0,0 +1 @@
# required for Django to discover custom template tags

View File

@@ -0,0 +1,20 @@
import hashlib
from django import template
register = template.Library()
@register.filter
def gravatar_url(email: str, size: int = 96) -> str:
"""Return gravatar URL for given email (or identicon)."""
if not email:
email = ""
em = (email or "").strip().lower().encode("utf-8")
h = hashlib.md5(em).hexdigest()
# identicon fallback to have a nice default
return f"https://www.gravatar.com/avatar/{h}?d=identicon&s={int(size)}"
@register.filter
def initial(s: str) -> str:
if not s:
return "?"
return str(s).strip()[:1].upper()

View File

@@ -1,20 +1,23 @@
from django.urls import path from django.urls import path
from . import views from . import views
app_name = "ui"
urlpatterns = [ urlpatterns = [
path('', views.index, name='index'), path("", views.index, name="index"),
# Кабинет
# auth
path("login/", views.login_view, name="login"),
path("register/", views.register_view, name="register"),
path("logout/", views.logout_view, name="logout"),
# cabinet
path("cabinet/", views.cabinet_view, name="cabinet"), path("cabinet/", views.cabinet_view, name="cabinet"),
path("cabinet/photo/upload/", views.cabinet_upload_photo, name="cabinet_upload_photo"), path("cabinet/photo/", views.cabinet_upload_photo, name="cabinet_upload_photo"),
path("cabinet/photo/delete/", views.cabinet_delete_photo, name="cabinet_delete_photo"), path("cabinet/photo/delete/", views.cabinet_delete_photo, name="cabinet_delete_photo"),
# Каталог # admin catalog (users ≈ анкеты)
path("profiles/", views.profile_list, name="profiles"), path("profiles/", views.profile_list, name="profiles"),
path("profiles/<uuid:pk>/", views.profile_detail, name="profile_detail"), path("profiles/<uuid:pk>/", views.profile_detail, name="profile_detail"),
path("profiles/<uuid:pk>/like/", views.like_profile, name="like_profile"), path("profiles/<uuid:pk>/like/", views.like_profile, name="like_profile"),
# Регистрация и авторизация
path('register/', views.register_view, name='register'),
path('login/', views.login_view, name='login'),
path('logout/', views.logout_view, name='logout'),
] ]

View File

@@ -1,6 +1,4 @@
import base64 from __future__ import annotations
import json
import time
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
@@ -8,81 +6,41 @@ from django.shortcuts import render, redirect
from django.views.decorators.http import require_http_methods, require_POST from django.views.decorators.http import require_http_methods, require_POST
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.conf import settings from django.urls import reverse, NoReverseMatch
from django.views.decorators.csrf import csrf_exempt
from . import api from . import api
from .api import ApiError from .api import ApiError
# -------- helpers (cookies/JWT) -------- # --- Reverse helper -----------------------------------------------------------
def _reverse_first(*names: str) -> str:
def _cookie_secure() -> bool: """Return the first successfully reversed URL among candidates, else '/'."""
# в dev можно False, в prod обязательно True for n in names:
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: try:
payload_b64 = token.split(".")[1] return reverse(n)
payload_b64 += "=" * (-len(payload_b64) % 4) except NoReverseMatch:
payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode()).decode("utf-8")) continue
exp = int(payload.get("exp", 0)) # last resort:
now = int(time.time()) try:
if exp > now: return reverse("ui:index")
# добавим небольшой «запас» (минус 60 сек) except NoReverseMatch:
return max(60, exp - now - 60) return "/"
return default_sec
except Exception:
return default_sec
def _set_auth_cookies(resp: HttpResponse, access_token: Optional[str], refresh_token: Optional[str]) -> None: def _is_logged(request) -> bool:
""" return bool(request.session.get("access_token") or request.COOKIES.get("access_token"))
Кладём токены в 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: def _is_admin(request) -> bool:
return (request.session.get("user_role") or "").upper() == "ADMIN" return (request.session.get("user_role") or "").upper() == "ADMIN"
def _set_tokens_and_user(request, tokens: Dict[str, Any], me: Dict[str, Any]):
request.session["access_token"] = tokens.get("access_token")
request.session["refresh_token"] = tokens.get("refresh_token")
request.session["user_id"] = me.get("id")
request.session["user_email"] = me.get("email")
request.session["user_full_name"] = me.get("full_name")
request.session["user_role"] = me.get("role")
request.session.modified = True
def _user_to_profile_stub(u: Dict[str, Any]) -> Dict[str, Any]: def _user_to_profile_stub(u: Dict[str, Any]) -> Dict[str, Any]:
name = u.get("full_name") or u.get("email") or "Без имени" name = u.get("full_name") or u.get("email") or "Без имени"
return { return {
@@ -90,178 +48,38 @@ def _user_to_profile_stub(u: Dict[str, Any]) -> Dict[str, Any]:
"name": name, "name": name,
"email": u.get("email") or "", "email": u.get("email") or "",
"role": (u.get("role") or "").upper() or "CLIENT", "role": (u.get("role") or "").upper() or "CLIENT",
"verified": u.get("is_active", False), "verified": bool(u.get("is_active", False)),
# ↓ ключевые правки — чтобы шаблон не генерил src="None"
"age": None, "age": None,
"city": None, "city": None,
"about": "", "about": "",
"photo": "", # было: None "photo": "",
"interests": [], "interests": [],
"liked": False, "liked": False,
} }
def _format_validation(payload: Optional[dict]) -> Optional[str]: # === public pages ==============================================================
"""Сборка сообщений 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")
def index(request):
return render(request, "ui/index.html", {})
# ---------------- Auth ---------------- # === catalog (admin) ==========================================================
@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"]) @require_http_methods(["GET"])
def profile_list(request): def profile_list(request):
""" if not _is_logged(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, "Войдите, чтобы открыть каталог") messages.info(request, "Войдите, чтобы открыть каталог")
return redirect("login") return redirect(_reverse_first("login", "ui:login"))
if not _is_admin(request): if not _is_admin(request):
messages.info(request, "Каталог доступен только администраторам. Перенаправляем в Кабинет.") messages.info(request, "Каталог доступен только администраторам. Перенаправляем в Кабинет.")
return redirect("cabinet") return redirect(_reverse_first("cabinet", "ui:cabinet"))
# --- читаем query-параметры ---
q = (request.GET.get("q") or "").strip().lower() q = (request.GET.get("q") or "").strip().lower()
role = (request.GET.get("role") or "").strip().upper() # CLIENT|ADMIN|"" (любой) role = (request.GET.get("role") or "").strip().upper()
active = request.GET.get("active") # "1"|"0"|None active = request.GET.get("active")
email_domain = (request.GET.get("domain") or "").strip().lower().lstrip("@") 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() sort = (request.GET.get("sort") or "name").strip().lower()
page = max(1, int(request.GET.get("page") or 1)) 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} limit = min(max(1, int(request.GET.get("limit") or 20)), 200)
offset = (page - 1) * limit offset = (page - 1) * limit
error: Optional[str] = None error: Optional[str] = None
@@ -269,33 +87,28 @@ def profile_list(request):
page_info = {"page": page, "limit": limit, "has_prev": page > 1, "has_next": False} page_info = {"page": page, "limit": limit, "has_prev": page > 1, "has_next": False}
try: try:
# Серверная пагинация есть, фильтров — нет (кроме offset/limit) → забираем страницу data = api.list_users(request, offset=offset, limit=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 [] users: List[Dict[str, Any]] = (data.get("items") if isinstance(data, dict) else data) or []
# --- клиентская фильтрация ---
def keep(u: Dict[str, Any]) -> bool: def keep(u: Dict[str, Any]) -> bool:
if q: fn = (u.get("full_name") or "")
fn = (u.get("full_name") or "").lower() em = (u.get("email") or "")
em = (u.get("email") or "").lower() if q and (q not in fn.lower() and q not in em.lower()):
if q not in fn and q not in em:
return False return False
if role and (u.get("role") or "").upper() != role: if role and (u.get("role") or "").upper() != role:
return False return False
if active in ("1", "0"): if active in ("1", "0"):
is_act = bool(u.get("is_active")) is_act = bool(u.get("is_active", False))
if (active == "1" and not is_act) or (active == "0" and is_act): if (active == "1" and not is_act) or (active == "0" and is_act):
return False return False
if email_domain: if email_domain:
em = (u.get("email") or "").lower() dom = em.lower().split("@")[-1] if "@" in em else ""
dom = em.split("@")[-1] if "@" in em else ""
if dom != email_domain: if dom != email_domain:
return False return False
return True return True
users = [u for u in users if keep(u)] 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_name(u): return (u.get("full_name") or u.get("email") or "").lower()
def key_email(u): return (u.get("email") or "").lower() def key_email(u): return (u.get("email") or "").lower()
if sort == "name": if sort == "name":
@@ -307,22 +120,16 @@ def profile_list(request):
elif sort == "email_desc": elif sort == "email_desc":
users.sort(key=key_email, reverse=True) users.sort(key=key_email, reverse=True)
# Преобразуем в «анкеты»
profiles = [_user_to_profile_stub(u) for u in users] profiles = [_user_to_profile_stub(u) for u in users]
# отметка лайков (локальная сессия)
liked_ids = set(request.session.get("likes", [])) liked_ids = set(request.session.get("likes", []))
for p in profiles: for p in profiles:
p["liked"] = p.get("id") in liked_ids 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)) 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: except PermissionDenied as e:
messages.error(request, f"Нет доступа к каталогу: {e}") messages.error(request, f"Нет доступа к каталогу: {e}")
return redirect("cabinet") return redirect(_reverse_first("cabinet", "ui:cabinet"))
except ApiError as e: except ApiError as e:
error = str(e) error = str(e)
@@ -345,148 +152,195 @@ def profile_list(request):
return render(request, "ui/profiles_list.html", ctx) return render(request, "ui/profiles_list.html", ctx)
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def profile_detail(request, pk: str): def profile_detail(request, pk):
"""
Детальная карточка пользователя — тоже 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: try:
user = api.get_user(request, user_id=str(pk)) user = api.get_user(request, str(pk))
except PermissionDenied as e: except PermissionDenied:
messages.error(request, f"Нет доступа: {e}") messages.error(request, "Сессия истекла, войдите снова")
return redirect("cabinet") return redirect(_reverse_first("login", "ui:login"))
except ApiError as e: except ApiError as e:
if e.status == 404: if e.status == 404:
raise Http404("Пользователь не найден") raise Http404("Пользователь не найден")
messages.error(request, str(e)) messages.error(request, str(e))
return redirect("profiles") return redirect(_reverse_first("profiles", "ui:profiles"))
profile = _user_to_profile_stub(user) stub = _user_to_profile_stub(user)
liked_ids = set(request.session.get("likes", [])) liked_ids = set(request.session.get("likes", []))
liked = profile.get("id") in liked_ids liked = stub["id"] in liked_ids
return render(request, "ui/profile_detail.html", {"profile": profile, "liked": liked}) return render(request, "ui/profile_detail.html", {"profile": stub, "liked": liked})
@require_POST @require_POST
def like_profile(request, pk: str): def like_profile(request, pk):
"""
Локальный «лайк» (для демо). Не требует 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", [])) likes = set(request.session.get("likes", []))
if str(pk) in likes: pk_str = str(pk)
likes.remove(str(pk)) if pk_str in likes:
liked = False likes.remove(pk_str); liked = False
else: else:
likes.add(str(pk)) likes.add(pk_str); liked = True
liked = True
request.session["likes"] = list(likes) request.session["likes"] = list(likes)
request.session.modified = True request.session.modified = True
return render(request, "ui/components/like_button.html", {"profile_id": pk_str, "liked": liked})
return render(request, "ui/components/like_button.html", {"profile_id": pk, "liked": liked}) # === 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:
tokens = api.login(request, email, password)
me = api.get_me(request)
_set_tokens_and_user(request, tokens, me)
# ---------------- Кабинет: мой профиль ---------------- next_url = request.GET.get("next")
if not next_url:
if (me.get("role") or "").upper() == "ADMIN":
next_url = _reverse_first("profiles", "ui:profiles")
else:
next_url = _reverse_first("cabinet", "ui:cabinet")
resp = redirect(next_url)
max_age = 7 * 24 * 3600
resp.set_cookie("access_token", tokens.get("access_token"), max_age=max_age, httponly=True, samesite="Lax")
resp.set_cookie("refresh_token", tokens.get("refresh_token"), max_age=max_age, httponly=True, samesite="Lax")
messages.success(request, "Вы успешно вошли")
return resp
except PermissionDenied as e:
messages.error(request, f"Доступ запрещён: {e}")
except ApiError as e:
messages.error(request, f"Ошибка входа: {e.args[0]}")
return render(request, "ui/login.html", {})
@require_http_methods(["GET", "POST"])
def register_view(request):
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, password, full_name, role="CLIENT")
tokens = api.login(request, email, password)
me = api.get_me(request)
_set_tokens_and_user(request, tokens, me)
resp = redirect(_reverse_first("cabinet", "ui:cabinet"))
max_age = 7 * 24 * 3600
resp.set_cookie("access_token", tokens.get("access_token"), max_age=max_age, httponly=True, samesite="Lax")
resp.set_cookie("refresh_token", tokens.get("refresh_token"), max_age=max_age, httponly=True, samesite="Lax")
messages.success(request, "Регистрация успешна")
return resp
except ApiError as e:
messages.error(request, f"Ошибка регистрации: {e.args[0]}")
return render(request, "ui/register.html", {})
@require_http_methods(["POST", "GET"])
def logout_view(request):
resp = redirect(_reverse_first("index", "ui:index"))
resp.delete_cookie("access_token")
resp.delete_cookie("refresh_token")
for k in ("auth", "access_token", "refresh_token", "user_id", "user_email", "user_full_name", "user_role"):
request.session.pop(k, None)
request.session.modified = True
messages.info(request, "Вы вышли из аккаунта")
return resp
# === cabinet ==================================================================
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def cabinet_view(request): def cabinet_view(request):
""" if not _is_logged(request):
Мой профиль: messages.info(request, "Войдите, чтобы открыть кабинет")
- GET: /profiles/v1/profiles/me; если 404 — пустая форма создания return redirect(_reverse_first("login", "ui:login"))
- POST: /profiles/v1/profiles (создать/заполнить свой профиль)
""" profile = None
if not (request.session.get("access_token") or request.COOKIES.get("access_token")): has_profile = False
messages.info(request, "Для доступа к кабинету войдите в систему") try:
return redirect("login") profile = api.get_my_profile(request)
has_profile = bool(profile)
except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова")
return redirect(_reverse_first("login", "ui:login"))
except ApiError as e:
if e.status != 404:
messages.error(request, f"Ошибка чтения профиля: {e}")
if request.method == "POST": if request.method == "POST":
action = (request.POST.get("action") or "").strip()
try:
if action == "create_profile":
gender = (request.POST.get("gender") or "").strip() gender = (request.POST.get("gender") or "").strip()
city = (request.POST.get("city") or "").strip() city = (request.POST.get("city") or "").strip()
languages = [s.strip() for s in (request.POST.get("languages") or "").split(",") if s.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()] interests = [s.strip() for s in (request.POST.get("interests") or "").split(",") if s.strip()]
if not gender or not city: api.create_my_profile(request, gender, city, languages, interests)
messages.error(request, "Укажите пол и город")
else:
try:
profile = api.create_my_profile(request, gender=gender, city=city, languages=languages, interests=interests)
messages.success(request, "Профиль создан") messages.success(request, "Профиль создан")
return render(request, "ui/cabinet.html", {"profile": profile, "has_profile": True}) return redirect(_reverse_first("cabinet", "ui:cabinet"))
elif action == "update_name":
full_name = (request.POST.get("full_name") or "").strip()
if not full_name:
messages.error(request, "Имя не может быть пустым")
else:
api.update_user_me(request, request.session.get("user_id"), full_name=full_name)
request.session["user_full_name"] = full_name
request.session.modified = True
messages.success(request, "Имя обновлено")
return redirect(_reverse_first("cabinet", "ui:cabinet"))
except PermissionDenied: except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова") messages.error(request, "Сессия истекла, войдите снова")
return redirect("login") return redirect(_reverse_first("login", "ui:login"))
except ApiError as e: except ApiError as e:
payload = getattr(e, "payload", None) messages.error(request, f"Ошибка: {e}")
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")
ctx = {
"has_profile": has_profile,
"profile": profile,
}
return render(request, "ui/cabinet.html", ctx)
@require_POST @require_POST
def cabinet_upload_photo(request): def cabinet_upload_photo(request):
# Требуется логин # Требуем авторизацию
if not (request.session.get("access_token") or request.COOKIES.get("access_token")): if not (request.COOKIES.get('access_token') or request.session.get('access_token') or (request.session.get('auth') or {}).get('access_token')):
messages.info(request, "Войдите, чтобы загрузить фото") messages.error(request, "Сначала войдите")
return redirect("login") return redirect('ui:login')
f = request.FILES.get("photo") file_obj = request.FILES.get('file') or request.FILES.get('photo')
if not f: if not file_obj:
messages.error(request, "Файл не выбран") messages.error(request, "Не выбрано фото")
return redirect("cabinet") return redirect('ui:cabinet')
if f.size and f.size > 5 * 1024 * 1024:
messages.error(request, "Файл слишком большой (макс. 5 МБ)")
return redirect("cabinet")
try: try:
api.upload_my_photo(request, f) prof = api.upload_my_profile_photo(request, file_obj)
messages.success(request, "Фото обновлено") # Обновлённый профиль приходит от API (в т.ч. photo_url)
messages.success(request, "Фото успешно обновлено")
except PermissionDenied: except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова") messages.error(request, "Сессия истекла, войдите снова")
return redirect("login") return redirect('ui:login')
except ApiError as e: except ApiError as e:
if e.status in (404, 405): messages.error(request, f"Не удалось загрузить фото: {e.payload.get('detail') or e.args[0]}")
messages.error(request, "Бэкенд пока не поддерживает загрузку фото (нет эндпоинта).")
else:
messages.error(request, f"Ошибка загрузки: {e}")
return redirect("cabinet")
return redirect('ui:cabinet')
@require_POST @require_POST
def cabinet_delete_photo(request): def cabinet_delete_photo(request):
if not (request.session.get("access_token") or request.COOKIES.get("access_token")): if not _is_logged(request):
messages.info(request, "Войдите, чтобы удалить фото") messages.info(request, "Войдите, чтобы удалить фото")
return redirect("login") return redirect(_reverse_first("login", "ui:login"))
try: try:
api.delete_my_photo(request) api.delete_my_photo(request)
messages.success(request, "Фото удалено") messages.success(request, "Фото удалено")
except PermissionDenied: except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова") messages.error(request, "Сессия истекла, войдите снова")
return redirect("login") return redirect(_reverse_first("login", "ui:login"))
except ApiError as e: except ApiError as e:
if e.status in (404, 405): if e.status in (404, 405):
messages.error(request, "Удаление фото не поддерживается бэкендом.") messages.error(request, "Удаление фото не поддерживается бэкендом.")
else: else:
messages.error(request, f"Ошибка удаления: {e}") messages.error(request, f"Ошибка удаления: {e}")
return redirect("cabinet") return redirect(_reverse_first("cabinet", "ui:cabinet"))