main features
This commit is contained in:
192
.patch_backup_20250810_160643/templates/ui/cabinet.html
Normal file
192
.patch_backup_20250810_160643/templates/ui/cabinet.html
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
195
.patch_backup_20250810_160643/templates/ui/profiles_list.html
Normal file
195
.patch_backup_20250810_160643/templates/ui/profiles_list.html
Normal 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>
|
||||||
527
.patch_backup_20250810_160643/ui/api.py
Normal file
527
.patch_backup_20250810_160643/ui/api.py
Normal 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
|
||||||
20
.patch_backup_20250810_160643/ui/urls.py
Normal file
20
.patch_backup_20250810_160643/ui/urls.py
Normal 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'),
|
||||||
|
]
|
||||||
492
.patch_backup_20250810_160643/ui/views.py
Normal file
492
.patch_backup_20250810_160643/ui/views.py
Normal 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 (Admin‑only) ----------------
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def profile_list(request):
|
||||||
|
"""
|
||||||
|
Каталог анкет (по сути — пользователей) с фильтрами и пагинацией.
|
||||||
|
Доступно только ADMIN (по API /auth/v1/users; у клиента прав нет).
|
||||||
|
Фильтры клиентские: q (имя/email), role, active, email_domain; сортировка; page/limit.
|
||||||
|
"""
|
||||||
|
if not (request.session.get("access_token") or request.COOKIES.get("access_token")):
|
||||||
|
messages.info(request, "Войдите, чтобы открыть каталог")
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
if not _is_admin(request):
|
||||||
|
messages.info(request, "Каталог доступен только администраторам. Перенаправляем в Кабинет.")
|
||||||
|
return redirect("cabinet")
|
||||||
|
|
||||||
|
# --- читаем query-параметры ---
|
||||||
|
q = (request.GET.get("q") or "").strip().lower()
|
||||||
|
role = (request.GET.get("role") or "").strip().upper() # CLIENT|ADMIN|"" (любой)
|
||||||
|
active = request.GET.get("active") # "1"|"0"|None
|
||||||
|
email_domain = (request.GET.get("domain") or "").strip().lower().lstrip("@")
|
||||||
|
|
||||||
|
# сортировка: name, name_desc, email, email_desc (по умолчанию name)
|
||||||
|
sort = (request.GET.get("sort") or "name").strip().lower()
|
||||||
|
page = max(1, int(request.GET.get("page") or 1))
|
||||||
|
limit = min(max(1, int(request.GET.get("limit") or 20)), 200) # API максимум 200, см. спеки :contentReference[oaicite:1]{index=1}
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
|
error: Optional[str] = None
|
||||||
|
profiles: List[Dict[str, Any]] = []
|
||||||
|
page_info = {"page": page, "limit": limit, "has_prev": page > 1, "has_next": False}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Серверная пагинация есть, фильтров — нет (кроме offset/limit) → забираем страницу
|
||||||
|
data = api.list_users(request, offset=offset, limit=limit) # GET /auth/v1/users :contentReference[oaicite:2]{index=2}
|
||||||
|
users: List[Dict[str, Any]] = (data.get("items") if isinstance(data, dict) else data) or []
|
||||||
|
|
||||||
|
# --- клиентская фильтрация ---
|
||||||
|
def keep(u: Dict[str, Any]) -> bool:
|
||||||
|
if q:
|
||||||
|
fn = (u.get("full_name") or "").lower()
|
||||||
|
em = (u.get("email") or "").lower()
|
||||||
|
if q not in fn and q not in em:
|
||||||
|
return False
|
||||||
|
if role and (u.get("role") or "").upper() != role:
|
||||||
|
return False
|
||||||
|
if active in ("1", "0"):
|
||||||
|
is_act = bool(u.get("is_active"))
|
||||||
|
if (active == "1" and not is_act) or (active == "0" and is_act):
|
||||||
|
return False
|
||||||
|
if email_domain:
|
||||||
|
em = (u.get("email") or "").lower()
|
||||||
|
dom = em.split("@")[-1] if "@" in em else ""
|
||||||
|
if dom != email_domain:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
users = [u for u in users if keep(u)]
|
||||||
|
|
||||||
|
# --- сортировка ---
|
||||||
|
def key_name(u): return (u.get("full_name") or u.get("email") or "").lower()
|
||||||
|
def key_email(u): return (u.get("email") or "").lower()
|
||||||
|
if sort == "name":
|
||||||
|
users.sort(key=key_name)
|
||||||
|
elif sort == "name_desc":
|
||||||
|
users.sort(key=key_name, reverse=True)
|
||||||
|
elif sort == "email":
|
||||||
|
users.sort(key=key_email)
|
||||||
|
elif sort == "email_desc":
|
||||||
|
users.sort(key=key_email, reverse=True)
|
||||||
|
|
||||||
|
# Преобразуем в «анкеты»
|
||||||
|
profiles = [_user_to_profile_stub(u) for u in users]
|
||||||
|
|
||||||
|
# отметка лайков (локальная сессия)
|
||||||
|
liked_ids = set(request.session.get("likes", []))
|
||||||
|
for p in profiles:
|
||||||
|
p["liked"] = p.get("id") in liked_ids
|
||||||
|
|
||||||
|
# has_next — на глаз: если сервер отдал ровно limit без наших фильтров,
|
||||||
|
# считаем, что следующая страница потенциально есть
|
||||||
|
page_info["has_next"] = (len(users) == limit and not (q or role or active in ("1","0") or email_domain))
|
||||||
|
# (если включены клиентские фильтры — не знаем полный объём; оставим conservative False)
|
||||||
|
|
||||||
|
except PermissionDenied as e:
|
||||||
|
messages.error(request, f"Нет доступа к каталогу: {e}")
|
||||||
|
return redirect("cabinet")
|
||||||
|
except ApiError as e:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"profiles": profiles,
|
||||||
|
"filters": {
|
||||||
|
"q": (request.GET.get("q") or "").strip(),
|
||||||
|
"role": role,
|
||||||
|
"active": (active or ""),
|
||||||
|
"domain": (request.GET.get("domain") or "").strip(),
|
||||||
|
"sort": sort,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
"count": len(profiles),
|
||||||
|
"page": page_info,
|
||||||
|
"error": error,
|
||||||
|
"page_sizes": [10, 20, 50, 100, 200],
|
||||||
|
}
|
||||||
|
return render(request, "ui/profiles_list.html", ctx)
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def profile_detail(request, pk: str):
|
||||||
|
"""
|
||||||
|
Детальная карточка пользователя — тоже ADMIN‑only.
|
||||||
|
"""
|
||||||
|
if not (request.session.get("access_token") or request.COOKIES.get("access_token")):
|
||||||
|
return redirect("login")
|
||||||
|
if not _is_admin(request):
|
||||||
|
messages.info(request, "Детали пользователей доступны только администраторам.")
|
||||||
|
return redirect("cabinet")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = api.get_user(request, user_id=str(pk))
|
||||||
|
except PermissionDenied as e:
|
||||||
|
messages.error(request, f"Нет доступа: {e}")
|
||||||
|
return redirect("cabinet")
|
||||||
|
except ApiError as e:
|
||||||
|
if e.status == 404:
|
||||||
|
raise Http404("Пользователь не найден")
|
||||||
|
messages.error(request, str(e))
|
||||||
|
return redirect("profiles")
|
||||||
|
|
||||||
|
profile = _user_to_profile_stub(user)
|
||||||
|
liked_ids = set(request.session.get("likes", []))
|
||||||
|
liked = profile.get("id") in liked_ids
|
||||||
|
return render(request, "ui/profile_detail.html", {"profile": profile, "liked": liked})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def like_profile(request, pk: str):
|
||||||
|
"""
|
||||||
|
Локальный «лайк» (для демо). Не требует API.
|
||||||
|
"""
|
||||||
|
if not (request.session.get("access_token") or request.COOKIES.get("access_token")):
|
||||||
|
return _auth_required_partial(request)
|
||||||
|
|
||||||
|
likes = set(request.session.get("likes", []))
|
||||||
|
if str(pk) in likes:
|
||||||
|
likes.remove(str(pk))
|
||||||
|
liked = False
|
||||||
|
else:
|
||||||
|
likes.add(str(pk))
|
||||||
|
liked = True
|
||||||
|
request.session["likes"] = list(likes)
|
||||||
|
request.session.modified = True
|
||||||
|
|
||||||
|
return render(request, "ui/components/like_button.html", {"profile_id": pk, "liked": liked})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Кабинет: мой профиль ----------------
|
||||||
|
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def cabinet_view(request):
|
||||||
|
"""
|
||||||
|
Мой профиль:
|
||||||
|
- GET: /profiles/v1/profiles/me; если 404 — пустая форма создания
|
||||||
|
- POST: /profiles/v1/profiles (создать/заполнить свой профиль)
|
||||||
|
"""
|
||||||
|
if not (request.session.get("access_token") or request.COOKIES.get("access_token")):
|
||||||
|
messages.info(request, "Для доступа к кабинету войдите в систему")
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
gender = (request.POST.get("gender") or "").strip()
|
||||||
|
city = (request.POST.get("city") or "").strip()
|
||||||
|
languages = [s.strip() for s in (request.POST.get("languages") or "").split(",") if s.strip()]
|
||||||
|
interests = [s.strip() for s in (request.POST.get("interests") or "").split(",") if s.strip()]
|
||||||
|
if not gender or not city:
|
||||||
|
messages.error(request, "Укажите пол и город")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
profile = api.create_my_profile(request, gender=gender, city=city, languages=languages, interests=interests)
|
||||||
|
messages.success(request, "Профиль создан")
|
||||||
|
return render(request, "ui/cabinet.html", {"profile": profile, "has_profile": True})
|
||||||
|
except PermissionDenied:
|
||||||
|
messages.error(request, "Сессия истекла, войдите снова")
|
||||||
|
return redirect("login")
|
||||||
|
except ApiError as e:
|
||||||
|
payload = getattr(e, "payload", None)
|
||||||
|
nice = _format_validation(payload) if isinstance(payload, dict) else None
|
||||||
|
messages.error(request, nice or f"Ошибка сохранения профиля: {e}")
|
||||||
|
|
||||||
|
# GET
|
||||||
|
try:
|
||||||
|
profile = api.get_my_profile(request)
|
||||||
|
# шапка кабинета — имя из сессии (или email)
|
||||||
|
header_name = request.session.get("user_full_name") or request.session.get("user_email") or ""
|
||||||
|
return render(request, "ui/cabinet.html", {"profile": profile, "has_profile": True, "header_name": header_name})
|
||||||
|
except ApiError as e:
|
||||||
|
if e.status == 404:
|
||||||
|
header_name = request.session.get("user_full_name") or request.session.get("user_email") or ""
|
||||||
|
return render(request, "ui/cabinet.html", {"profile": None, "has_profile": False, "header_name": header_name})
|
||||||
|
messages.error(request, f"Ошибка загрузки профиля: {e}")
|
||||||
|
return render(request, "ui/cabinet.html", {"profile": None, "has_profile": False})
|
||||||
|
except PermissionDenied:
|
||||||
|
messages.error(request, "Сессия истекла, войдите снова")
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def cabinet_upload_photo(request):
|
||||||
|
# Требуется логин
|
||||||
|
if not (request.session.get("access_token") or request.COOKIES.get("access_token")):
|
||||||
|
messages.info(request, "Войдите, чтобы загрузить фото")
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
f = request.FILES.get("photo")
|
||||||
|
if not f:
|
||||||
|
messages.error(request, "Файл не выбран")
|
||||||
|
return redirect("cabinet")
|
||||||
|
if f.size and f.size > 5 * 1024 * 1024:
|
||||||
|
messages.error(request, "Файл слишком большой (макс. 5 МБ)")
|
||||||
|
return redirect("cabinet")
|
||||||
|
|
||||||
|
try:
|
||||||
|
api.upload_my_photo(request, f)
|
||||||
|
messages.success(request, "Фото обновлено")
|
||||||
|
except PermissionDenied:
|
||||||
|
messages.error(request, "Сессия истекла, войдите снова")
|
||||||
|
return redirect("login")
|
||||||
|
except ApiError as e:
|
||||||
|
if e.status in (404, 405):
|
||||||
|
messages.error(request, "Бэкенд пока не поддерживает загрузку фото (нет эндпоинта).")
|
||||||
|
else:
|
||||||
|
messages.error(request, f"Ошибка загрузки: {e}")
|
||||||
|
return redirect("cabinet")
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def cabinet_delete_photo(request):
|
||||||
|
if not (request.session.get("access_token") or request.COOKIES.get("access_token")):
|
||||||
|
messages.info(request, "Войдите, чтобы удалить фото")
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
try:
|
||||||
|
api.delete_my_photo(request)
|
||||||
|
messages.success(request, "Фото удалено")
|
||||||
|
except PermissionDenied:
|
||||||
|
messages.error(request, "Сессия истекла, войдите снова")
|
||||||
|
return redirect("login")
|
||||||
|
except ApiError as e:
|
||||||
|
if e.status in (404, 405):
|
||||||
|
messages.error(request, "Удаление фото не поддерживается бэкендом.")
|
||||||
|
else:
|
||||||
|
messages.error(request, f"Ошибка удаления: {e}")
|
||||||
|
return redirect("cabinet")
|
||||||
204
.patch_backup_20250810_164608/templates/ui/cabinet.html
Normal file
204
.patch_backup_20250810_164608/templates/ui/cabinet.html
Normal 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>
|
||||||
31
.patch_backup_20250810_164608/templates/ui/index.html
Normal file
31
.patch_backup_20250810_164608/templates/ui/index.html
Normal 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 %}
|
||||||
27
.patch_backup_20250810_164608/templates/ui/login.html
Normal file
27
.patch_backup_20250810_164608/templates/ui/login.html
Normal 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 %}
|
||||||
@@ -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>
|
||||||
194
.patch_backup_20250810_164608/templates/ui/profiles_list.html
Normal file
194
.patch_backup_20250810_164608/templates/ui/profiles_list.html
Normal 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>
|
||||||
24
.patch_backup_20250810_164608/templates/ui/register.html
Normal file
24
.patch_backup_20250810_164608/templates/ui/register.html
Normal 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 %}
|
||||||
23
.patch_backup_20250810_164608/ui/urls.py
Normal file
23
.patch_backup_20250810_164608/ui/urls.py
Normal 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"),
|
||||||
|
]
|
||||||
337
.patch_backup_20250810_164608/ui/views.py
Normal file
337
.patch_backup_20250810_164608/ui/views.py
Normal 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
1031
scripts/patch_frontend.sh
Executable file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|
||||||
<!-- ======= ДАННЫЕ ПРОФИЛЯ ======= -->
|
{% if has_profile and profile %}
|
||||||
<section class="card">
|
<section class="card" style="margin-top:16px;">
|
||||||
<h2 class="muted" style="margin-top:0;">Данные профиля</h2>
|
<h2 class="muted" style="margin-top:0;">Фото профиля</h2>
|
||||||
|
<div style="display:flex; gap:16px; align-items:center;">
|
||||||
{% if has_profile and profile %}
|
<div style="width:120px; height:120px; border-radius:12px; overflow:hidden; background:#e5e7eb;">
|
||||||
<dl>
|
{% if profile.photo_url %}
|
||||||
<dt>Пол</dt>
|
<img src="{{ profile.photo_url }}" alt="" style="width:100%; height:100%; object-fit:cover;">
|
||||||
<dd>{{ profile.gender|default:"—" }}</dd>
|
{% elif profile.photo %}
|
||||||
|
<img src="{{ profile.photo }}" alt="" style="width:100%; height:100%; object-fit:cover;">
|
||||||
<dt>Город</dt>
|
{% elif request.session.user_email %}
|
||||||
<dd>{{ profile.city|default:"—" }}</dd>
|
<img src="{{ request.session.user_email|gravatar_url:240 }}" alt="" style="width:100%; height:100%; object-fit:cover;">
|
||||||
|
{% else %}
|
||||||
<dt>Языки</dt>
|
<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:#374151;font-weight:700;">—</div>
|
||||||
<dd>
|
{% endif %}
|
||||||
{% if profile.languages %}
|
</div>
|
||||||
{% 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>
|
|
||||||
|
|
||||||
|
<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 %}
|
{% if not has_profile or not profile %}
|
||||||
<!-- ======= ФОРМА СОЗДАНИЯ ПРОФИЛЯ ======= -->
|
|
||||||
<section class="card" style="margin-top:16px;" aria-labelledby="section-create">
|
<section class="card" style="margin-top:16px;" aria-labelledby="section-create">
|
||||||
<h2 id="section-create" class="muted" style="margin-top:0;">Создать профиль</h2>
|
<h2 id="section-create" class="muted" style="margin-top:0;">Создать профиль</h2>
|
||||||
|
|
||||||
<form class="form" method="post" action="{% url 'cabinet' %}">
|
<form class="form" method="post" action="{% url 'ui:cabinet' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="grid grid-2">
|
<input type="hidden" name="action" value="create_profile">
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:16px;">
|
||||||
<div>
|
<div>
|
||||||
<label for="gender">Пол</label>
|
<label for="gender">Пол</label>
|
||||||
<select id="gender" name="gender" required>
|
<select id="gender" name="gender" required>
|
||||||
<option value="">— выберите —</option>
|
<option value="">— выберите —</option>
|
||||||
<option value="male" {% if request.POST.gender == "male" %}selected{% endif %}>Мужской</option>
|
<option value="male">Мужской</option>
|
||||||
<option value="female" {% if request.POST.gender == "female" %}selected{% endif %}>Женский</option>
|
<option value="female">Женский</option>
|
||||||
<option value="other" {% if request.POST.gender == "other" %}selected{% endif %}>Другое</option>
|
<option value="other">Другое</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="city">Город</label>
|
<label for="city">Город</label>
|
||||||
<input id="city" name="city" type="text" required
|
<input id="city" name="city" type="text" required placeholder="Москва">
|
||||||
value="{{ request.POST.city|default_if_none:'' }}" placeholder="Москва">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="languages">Языки</label>
|
<label for="languages">Языки</label>
|
||||||
<input id="languages" name="languages" type="text"
|
<input id="languages" name="languages" type="text" placeholder="ru,en">
|
||||||
value="{{ request.POST.languages|default_if_none:'' }}" placeholder="ru,en">
|
<small class="muted">Несколько — через запятую</small>
|
||||||
<small>Несколько — через запятую: <code>ru,en</code></small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="interests">Интересы</label>
|
<label for="interests">Интересы</label>
|
||||||
<input id="interests" name="interests" type="text"
|
<input id="interests" name="interests" type="text" placeholder="music,travel">
|
||||||
value="{{ request.POST.interests|default_if_none:'' }}" placeholder="music,travel">
|
<small class="muted">Несколько — через запятую</small>
|
||||||
<small>Несколько — через запятую: <code>music,travel</code></small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btnrow">
|
<div class="btnrow">
|
||||||
<button class="btn btn-primary" type="submit">Создать профиль</button>
|
<button class="btn btn-primary" type="submit">Создать профиль</button>
|
||||||
<a class="btn btn-outline" href="{% url 'cabinet' %}">Сбросить</a>
|
<a class="btn btn-outline" href="{% url 'ui:cabinet' %}">Сбросить</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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 %}
|
{% endif %}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
{# expects: profile_id, liked: bool #}
|
{% if liked %}
|
||||||
<form method="post"
|
<button class="btn" type="submit">★ В избранном</button>
|
||||||
hx-post="{% url 'like_profile' profile_id %}"
|
{% else %}
|
||||||
hx-target="this"
|
<button class="btn" type="submit">☆ В избранное</button>
|
||||||
hx-swap="outerHTML"
|
{% endif %}
|
||||||
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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' %}">
|
||||||
</form>
|
{% csrf_token %}
|
||||||
</div>
|
<input type="email" name="email" placeholder="Email">
|
||||||
{% endblock %}
|
<input type="password" name="password" placeholder="Пароль">
|
||||||
|
<button class="btn" type="submit">Войти</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted" style="margin-top:10px;">Нет аккаунта?
|
||||||
|
<a href="{% url 'ui:register' %}">Зарегистрируйтесь</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -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">
|
||||||
<dl>
|
<!-- Данные аккаунта (из UserRead) -->
|
||||||
<dt>Пол</dt><dd>{{ profile.gender|default:"—" }}</dd>
|
<section class="card">
|
||||||
<dt>Город</dt><dd>{{ profile.city|default:"—" }}</dd>
|
<h2 class="muted" style="margin-top:0;">Данные аккаунта</h2>
|
||||||
<dt>Языки</dt>
|
<dl>
|
||||||
<dd>
|
<dt>Имя</dt><dd>{{ profile.name|default:"—" }}</dd>
|
||||||
{% if profile.languages %}
|
<dt>Email</dt><dd>{{ profile.email|default:"—" }}</dd>
|
||||||
{% for lang in profile.languages %}<span class="pill">{{ lang }}</span>{% endfor %}
|
<dt>Роль</dt><dd>{{ profile.role|default:"—" }}</dd>
|
||||||
{% else %} — {% endif %}
|
<dt>Статус</dt><dd>{% if profile.verified %}<span class="pill">ACTIVE</span>{% else %}<span class="pill">INACTIVE</span>{% endif %}</dd>
|
||||||
</dd>
|
<dt>ID пользователя</dt><dd><code>{{ profile.id|default:"—" }}</code></dd>
|
||||||
<dt>Интересы</dt>
|
</dl>
|
||||||
<dd>
|
</section>
|
||||||
{% if profile.interests %}
|
|
||||||
{% for it in profile.interests %}<span class="pill">{{ it }}</span>{% endfor %}
|
<!-- Данные профиля (ProfileOut) -->
|
||||||
{% else %} — {% endif %}
|
<section class="card">
|
||||||
</dd>
|
<h2 class="muted" style="margin-top:0;">Данные профиля</h2>
|
||||||
</dl>
|
<dl>
|
||||||
|
<dt>Пол</dt><dd>{{ profile.gender|default:"—" }}</dd>
|
||||||
|
<dt>Город</dt><dd>{{ profile.city|default:"—" }}</dd>
|
||||||
|
<dt>Языки</dt>
|
||||||
|
<dd>
|
||||||
|
{% if profile.languages %}
|
||||||
|
<div class="pill-wrap">
|
||||||
|
{% for lang in profile.languages %}<span class="pill">{{ lang }}</span>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %} — {% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt>Интересы</dt>
|
||||||
|
<dd>
|
||||||
|
{% if profile.interests %}
|
||||||
|
<div class="pill-wrap">
|
||||||
|
{% 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 %}
|
||||||
|
</dd>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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 class="card-photo__title">
|
||||||
|
{{ p.name }}{% if p.age %}, {{ p.age }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
<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 %}
|
{% 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
{% 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:520px; 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>
|
a { color:#2563eb; text-decoration:none; }
|
||||||
<label class="block text-sm mb-1">Полное имя (необязательно)</label>
|
</style>
|
||||||
<input type="text" name="full_name" class="w-full rounded-md border px-3 py-2"/>
|
</head>
|
||||||
</div>
|
<body>
|
||||||
<button class="w-full rounded-md bg-indigo-600 text-white px-4 py-2 hover:bg-indigo-700">Зарегистрироваться</button>
|
<div class="wrap">
|
||||||
</form>
|
<h1>Регистрация</h1>
|
||||||
<p class="mt-3 text-sm">Уже есть аккаунт? <a class="text-indigo-700 hover:underline" href="{% url 'login' %}">Войти</a></p>
|
|
||||||
</div>
|
{% if messages %}
|
||||||
{% endblock %}
|
<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 %}
|
||||||
|
<input type="text" name="full_name" placeholder="Имя / ФИО">
|
||||||
|
<input type="email" name="email" placeholder="Email" required>
|
||||||
|
<input type="password" name="password" placeholder="Пароль" required>
|
||||||
|
<button class="btn" type="submit">Создать аккаунт</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted" style="margin-top:10px;">Уже есть аккаунт?
|
||||||
|
<a href="{% url 'ui:login' %}">Войти</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
636
ui/api.py
636
ui/api.py
@@ -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
|
||||||
|
headers = _headers(request, json_mode=json_mode)
|
||||||
|
|
||||||
def _do(_url: str):
|
if API_DEBUG:
|
||||||
headers = _base_headers(request)
|
b_preview = ("***" if json and "password" in (json or {}) else json)
|
||||||
if json is not None and files is None and data is None:
|
if isinstance(b_preview, dict) and "password" in (b_preview or {}):
|
||||||
headers['Content-Type'] = 'application/json'
|
b_preview = dict(b_preview); b_preview["password"] = "***"
|
||||||
|
logger.info("API[req_id=%s] REQUEST %s %s params=%s headers=%s body=%s",
|
||||||
|
rid, method.upper(), url, params, (headers if API_LOG_HEADERS else "{…}"),
|
||||||
|
(str(b_preview)[:API_LOG_BODY_MAX] if b_preview else None))
|
||||||
|
_log_curl(method, url, headers, b_preview)
|
||||||
|
|
||||||
if API_DEBUG:
|
try:
|
||||||
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(
|
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:
|
try:
|
||||||
candidates: List[tuple[str, str]] = []
|
data = resp.json() if "application/json" in content_type else {}
|
||||||
if API_FALLBACK_OPENAPI_ON_404:
|
except ValueError:
|
||||||
detected = _detect_api_base_from_openapi()
|
data = {}
|
||||||
if detected:
|
|
||||||
candidates.append((detected.rstrip('/'), 'OPENAPI(FAILOVER)'))
|
|
||||||
candidates.append(('http://localhost:8080', 'DEFAULT(FAILOVER)'))
|
|
||||||
|
|
||||||
for cand_base, label in candidates:
|
if API_DEBUG:
|
||||||
if not cand_base or cand_base == _API_BASE_CACHE:
|
b_preview = data
|
||||||
continue
|
logger.info("API[req_id=%s] RESPONSE %s %sms ct=%s headers=%s body=%s",
|
||||||
if API_DEBUG:
|
rid, resp.status_code, int(resp.elapsed.total_seconds()*1000),
|
||||||
logger.warning("API[req_id=%s] 404 on base %s → switch API base to %s and retry",
|
content_type, (dict(resp.headers) if API_LOG_HEADERS else "{…}"),
|
||||||
req_id, _API_BASE_CACHE, cand_base)
|
(str(b_preview)[:API_LOG_BODY_MAX] if b_preview else None))
|
||||||
_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 и повтор
|
# авто-ретрай на 401: пробуем refresh
|
||||||
refresh_token = request.session.get('refresh_token') or request.COOKIES.get('refresh_token')
|
if resp.status_code == 401 and (request.session.get("refresh_token") or request.COOKIES.get("refresh_token")):
|
||||||
if resp.status_code == 401 and refresh_token:
|
|
||||||
if API_DEBUG:
|
if API_DEBUG:
|
||||||
logger.info("API[req_id=%s] 401 → try refresh token", req_id)
|
logger.info("API[req_id=%s] 401 → try refresh token", rid)
|
||||||
try:
|
try:
|
||||||
refresh_url = _url(EP('AUTH_REFRESH_PATH'))
|
refresh_json = {"refresh_token": request.session.get("refresh_token") or request.COOKIES.get("refresh_token")}
|
||||||
refresh_body = {'refresh_token': refresh_token}
|
r = requests.post(_url(EP("AUTH_REFRESH_PATH")), json=refresh_json, timeout=float(getattr(settings, "API_TIMEOUT", 6.0)))
|
||||||
logger.info("API[req_id=%s] REFRESH POST %s body=%s", req_id, refresh_url, _sanitize(refresh_body))
|
if r.status_code == 200:
|
||||||
refresh_resp = requests.post(refresh_url, json=refresh_body, timeout=float(getattr(settings, 'API_TIMEOUT', 8.0)))
|
tokens = r.json()
|
||||||
if refresh_resp.status_code == 200:
|
request.session["access_token"] = tokens.get("access_token")
|
||||||
try:
|
request.session["refresh_token"] = tokens.get("refresh_token")
|
||||||
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
|
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
|
|
||||||
|
|||||||
1
ui/templatetags/__init__.py
Normal file
1
ui/templatetags/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# required for Django to discover custom template tags
|
||||||
20
ui/templatetags/ui_extras.py
Normal file
20
ui/templatetags/ui_extras.py
Normal 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()
|
||||||
21
ui/urls.py
21
ui/urls.py
@@ -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'),
|
|
||||||
]
|
]
|
||||||
|
|||||||
522
ui/views.py
522
ui/views.py
@@ -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)
|
try:
|
||||||
|
return reverse(n)
|
||||||
def _jwt_exp_seconds(token: Optional[str], default_sec: int = 12 * 3600) -> int:
|
except NoReverseMatch:
|
||||||
"""
|
continue
|
||||||
Пытаемся вытащить exp из JWT и посчитать max_age для куки.
|
# last resort:
|
||||||
Если не получилось — даём дефолт (12 часов).
|
|
||||||
"""
|
|
||||||
if not token or token.count(".") != 2:
|
|
||||||
return default_sec
|
|
||||||
try:
|
try:
|
||||||
payload_b64 = token.split(".")[1]
|
return reverse("ui:index")
|
||||||
payload_b64 += "=" * (-len(payload_b64) % 4)
|
except NoReverseMatch:
|
||||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode()).decode("utf-8"))
|
return "/"
|
||||||
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:
|
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 (Admin‑only) ----------------
|
|
||||||
|
|
||||||
@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):
|
||||||
"""
|
|
||||||
Детальная карточка пользователя — тоже ADMIN‑only.
|
|
||||||
"""
|
|
||||||
if not (request.session.get("access_token") or request.COOKIES.get("access_token")):
|
|
||||||
return redirect("login")
|
|
||||||
if not _is_admin(request):
|
|
||||||
messages.info(request, "Детали пользователей доступны только администраторам.")
|
|
||||||
return redirect("cabinet")
|
|
||||||
|
|
||||||
try:
|
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 (создать/заполнить свой профиль)
|
|
||||||
"""
|
|
||||||
if not (request.session.get("access_token") or request.COOKIES.get("access_token")):
|
|
||||||
messages.info(request, "Для доступа к кабинету войдите в систему")
|
|
||||||
return redirect("login")
|
|
||||||
|
|
||||||
if request.method == "POST":
|
profile = None
|
||||||
gender = (request.POST.get("gender") or "").strip()
|
has_profile = False
|
||||||
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:
|
try:
|
||||||
profile = api.get_my_profile(request)
|
profile = api.get_my_profile(request)
|
||||||
# шапка кабинета — имя из сессии (или email)
|
has_profile = bool(profile)
|
||||||
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:
|
except PermissionDenied:
|
||||||
messages.error(request, "Сессия истекла, войдите снова")
|
messages.error(request, "Сессия истекла, войдите снова")
|
||||||
return redirect("login")
|
return redirect(_reverse_first("login", "ui: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(_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:
|
||||||
|
messages.error(request, "Сессия истекла, войдите снова")
|
||||||
|
return redirect(_reverse_first("login", "ui: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
|
@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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user