1032 lines
43 KiB
Bash
Executable File
1032 lines
43 KiB
Bash
Executable File
#!/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"
|