Files
marriage_frontend/scripts/patch_frontend.sh
2025-08-10 17:28:38 +09:00

1032 lines
43 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env bash
set -Eeuo pipefail
# Patch: fix NoReverseMatch by using namespaced urls (ui:*),
# add robust reverse helper in views, update templates nav/links, and
# keep token refresh flow. Based on OpenAPI & audit logs.
# Sources: OpenAPI spec & audit summary. :contentReference[oaicite:0]{index=0} :contentReference[oaicite:1]{index=1}
ROOT="$(pwd)"
TS="$(date +%Y%m%d_%H%M%S)"
BK=".patch_backup_${TS}"
mkdir -p "$BK"
save() {
local path="$1"; shift
mkdir -p "$(dirname "$path")"
if [[ -f "$path" ]]; then
mkdir -p "$BK/$(dirname "$path")"
cp -a "$path" "$BK/$path"
fi
cat > "$path" <<'EOF'
'"$@"'
EOF
}
write() {
local path="$1"; shift
mkdir -p "$(dirname "$path")"
if [[ -f "$path" ]]; then
mkdir -p "$BK/$(dirname "$path")"
cp -a "$path" "$BK/$path"
fi
cat > "$path"
}
echo "==> Applying patch (backup in $BK)"
# --- ui/urls.py: ensure app_name and route names are stable -------------------
write ui/urls.py <<'PY'
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"),
]
PY
# --- ui/views.py: add reverse helper and use everywhere -----------------------
# keep existing content if close to previous patch, but enforce helper usage
write ui/views.py <<'PY'
from __future__ import annotations
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.urls import reverse, NoReverseMatch
from . import api
from .api import ApiError
# --- Reverse helper -----------------------------------------------------------
def _reverse_first(*names: str) -> str:
"""Return the first successfully reversed URL among candidates, else '/'."""
for n in names:
try:
return reverse(n)
except NoReverseMatch:
continue
# last resort:
try:
return reverse("ui:index")
except NoReverseMatch:
return "/"
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]):
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]:
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": "",
"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(_reverse_first("login", "ui:login"))
if not _is_admin(request):
messages.info(request, "Каталог доступен только администраторам. Перенаправляем в Кабинет.")
return redirect(_reverse_first("cabinet", "ui: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 []
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(_reverse_first("cabinet", "ui: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):
try:
user = api.get_user(request, str(pk))
except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова")
return redirect(_reverse_first("login", "ui:login"))
except ApiError as e:
if e.status == 404:
raise Http404("Пользователь не найден")
messages.error(request, str(e))
return redirect(_reverse_first("profiles", "ui: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)
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"])
def cabinet_view(request):
if not _is_logged(request):
messages.info(request, "Войдите, чтобы открыть кабинет")
return redirect(_reverse_first("login", "ui:login"))
profile = None
has_profile = False
try:
profile = api.get_my_profile(request)
has_profile = bool(profile)
except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова")
return redirect(_reverse_first("login", "ui:login"))
except ApiError as e:
if e.status != 404:
messages.error(request, f"Ошибка чтения профиля: {e}")
if request.method == "POST":
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
def cabinet_upload_photo(request):
if not _is_logged(request):
messages.info(request, "Войдите, чтобы загрузить фото")
return redirect(_reverse_first("login", "ui:login"))
f = request.FILES.get("photo")
if not f:
messages.error(request, "Файл не выбран")
return redirect(_reverse_first("cabinet", "ui:cabinet"))
if f.size and f.size > 5 * 1024 * 1024:
messages.error(request, "Файл слишком большой (макс. 5 МБ)")
return redirect(_reverse_first("cabinet", "ui:cabinet"))
try:
api.upload_my_photo(request, f)
messages.success(request, "Фото обновлено")
except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова")
return redirect(_reverse_first("login", "ui:login"))
except ApiError as e:
if e.status in (404, 405):
messages.error(request, "Бэкенд пока не поддерживает загрузку фото.")
else:
messages.error(request, f"Ошибка загрузки: {e}")
return redirect(_reverse_first("cabinet", "ui:cabinet"))
@require_POST
def cabinet_delete_photo(request):
if not _is_logged(request):
messages.info(request, "Войдите, чтобы удалить фото")
return redirect(_reverse_first("login", "ui:login"))
try:
api.delete_my_photo(request)
messages.success(request, "Фото удалено")
except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова")
return redirect(_reverse_first("login", "ui:login"))
except ApiError as e:
if e.status in (404, 405):
messages.error(request, "Удаление фото не поддерживается бэкендом.")
else:
messages.error(request, f"Ошибка удаления: {e}")
return redirect(_reverse_first("cabinet", "ui:cabinet"))
PY
# --- templates: switch to namespaced urls (ui:*) ------------------------------
# profiles_list.html
write templates/ui/profiles_list.html <<'HTML'
{% 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 'ui:index' %}">Главная</a>
<a href="{% url 'ui:cabinet' %}">Кабинет</a>
<a href="{% url 'ui:profiles' %}">Каталог</a>
<a href="{% url 'ui: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 'ui: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 'ui: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 'ui: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>
HTML
# profile_detail.html
write templates/ui/profile_detail.html <<'HTML'
{% 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; }
.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 'ui:profiles' %}">← Каталог</a>
<a href="{% url 'ui:cabinet' %}">Кабинет</a>
<a href="{% url 'ui: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 'ui: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>
HTML
# cabinet.html (nav urls to ui:*)
write templates/ui/cabinet.html <<'HTML'
{% 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 'ui:index' %}">Главная</a>
<a href="{% url 'ui:cabinet' %}">Кабинет</a>
<a href="{% url 'ui:profiles' %}">Каталог</a>
<a href="{% url 'ui: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 'ui: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 'ui: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 'ui: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 'ui:cabinet' %}">
{% csrf_token %}
<input type="hidden" name="action" value="create_profile">
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:16px;">
<div>
<label for="gender">Пол</label>
<select id="gender" name="gender" required>
<option value="">— выберите —</option>
<option value="male">Мужской</option>
<option value="female">Женский</option>
<option value="other">Другое</option>
</select>
</div>
<div>
<label for="city">Город</label>
<input id="city" name="city" type="text" required placeholder="Москва">
</div>
</div>
<div>
<label for="languages">Языки</label>
<input id="languages" name="languages" type="text" placeholder="ru,en">
<small class="muted">Несколько — через запятую</small>
</div>
<div>
<label for="interests">Интересы</label>
<input id="interests" name="interests" type="text" placeholder="music,travel">
<small class="muted">Несколько — через запятую</small>
</div>
<div class="btnrow">
<button class="btn btn-primary" type="submit">Создать профиль</button>
<a class="btn btn-outline" href="{% url 'ui:cabinet' %}">Сбросить</a>
</div>
</form>
</section>
{% else %}
<section class="card" style="margin-top:16px;">
<h2 class="muted" style="margin-top:0;">Данные профиля</h2>
<dl>
<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>
HTML
# login.html (ensure register url is namespaced)
write templates/ui/login.html <<'HTML'
{% 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; }
.wrap { max-width:420px; margin:60px auto; background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; }
.form { display:grid; gap:12px; }
.form input { border:1px solid #d1d5db; border-radius:8px; padding:10px 12px; font:inherit; }
.btn { padding:10px 14px; border-radius:10px; background:#2563eb; color:#fff; border:none; cursor:pointer; font-weight:600; }
.muted { color:#6b7280; }
a { color:#2563eb; text-decoration:none; }
</style>
</head>
<body>
<div class="wrap">
<h1>Вход</h1>
{% if messages %}
<ul>
{% for message in messages %}
<li class="{{ message.tags }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<form class="form" method="post" action="{% url 'ui:login' %}">
{% csrf_token %}
<input type="email" name="email" placeholder="Email">
<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>
HTML
# register.html
write templates/ui/register.html <<'HTML'
{% 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; }
.wrap { max-width:520px; margin:60px auto; background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; }
.form { display:grid; gap:12px; }
.form input { border:1px solid #d1d5db; border-radius:8px; padding:10px 12px; font:inherit; }
.btn { padding:10px 14px; border-radius:10px; background:#2563eb; color:#fff; border:none; cursor:pointer; font-weight:600; }
.muted { color:#6b7280; }
a { color:#2563eb; text-decoration:none; }
</style>
</head>
<body>
<div class="wrap">
<h1>Регистрация</h1>
{% if messages %}
<ul>
{% for message in messages %}
<li class="{{ message.tags }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<form class="form" method="post" action="{% url 'ui:register' %}">
{% csrf_token %}
<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>
HTML
# index.html (nav to ui:*)
write templates/ui/index.html <<'HTML'
{% 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; }
header { background:#111827; color:#fff; padding:14px 18px; display:flex; gap:14px; }
header a { color:#cfe3ff; text-decoration:none; }
.container { max-width:900px; margin:24px auto; padding:0 16px; }
.btn { padding:10px 14px; border-radius:10px; background:#2563eb; color:#fff; border:none; text-decoration:none; }
</style>
</head>
<body>
<header>
<div style="flex:1 1 auto;">Agency Frontend</div>
<nav>
<a href="{% url 'ui:index' %}">Главная</a>
<a href="{% url 'ui:cabinet' %}">Кабинет</a>
<a href="{% url 'ui:profiles' %}">Каталог</a>
<a href="{% url 'ui:login' %}">Войти</a>
</nav>
</header>
<main class="container">
<h1>Добро пожаловать</h1>
<p>Это фронтенд для API брачного агентства.</p>
<p>
<a class="btn" href="{% url 'ui:login' %}">Войти</a>
<a class="btn" style="background:#10b981;" href="{% url 'ui:register' %}">Регистрация</a>
</p>
</main>
</body>
</html>
HTML
echo "==> Patch complete. Restart Django and test:"
echo " - /login -> вход; ADMIN после входа -> /profiles (ui:profiles)"
echo " - /profiles -> список (если роль ADMIN, при 401 пытаемся refresh, иначе редирект в ui:cabinet)"
echo " - /cabinet -> личный кабинет"
echo
echo "Backup created at: $BK"