#!/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//", views.profile_detail, name="profile_detail"), path("profiles//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 %} Каталог анкет
Каталог анкет (ADMIN)
{% if messages %}
    {% for message in messages %}
  • {{ message }}
  • {% endfor %}
{% endif %}
{% if error %}
  • {{ error }}
  • {% endif %}
    {% for p in profiles %}
    {% if p.email %} {% else %} {{ p.name|initial }} {% endif %}
    {{ p.name }}
    {{ p.email }}
    {% csrf_token %} {% include "ui/components/like_button.html" with profile_id=p.id liked=p.liked %}
    {{ p.verified|yesno:"ACTIVE,INACTIVE" }}
    {{ p.role|default:"USER" }}
    ID: {{ p.id }}
    {% empty %}
    Ничего не найдено. Попробуйте изменить фильтры.
    {% endfor %}
    HTML # profile_detail.html write templates/ui/profile_detail.html <<'HTML' {% load static ui_extras %} Анкета пользователя
    Карточка пользователя (ADMIN)
    {% if profile.email %} {% else %} {{ profile.name|initial }} {% endif %}
    {{ profile.name }}
    ID: {{ profile.id }}
    Email: {{ profile.email|default:"—" }}
    Роль: {{ profile.role|default:"CLIENT" }}
    {% csrf_token %} {% include "ui/components/like_button.html" with profile_id=profile.id liked=liked %}
    HTML # cabinet.html (nav urls to ui:*) write templates/ui/cabinet.html <<'HTML' {% load static ui_extras %} Кабинет
    Здравствуйте, {{ request.session.user_full_name|default:request.session.user_email }}!
    {% if messages %}
      {% for message in messages %}
    • {{ message }}
    • {% endfor %}
    {% endif %}

    Кабинет

    {% if has_profile %} профиль создан {% else %} профиль ещё не создан {% endif %}

    Данные аккаунта

    {% if request.session.user_email %} {% else %} {{ request.session.user_full_name|default:request.session.user_email|initial }} {% endif %}
    {{ request.session.user_full_name|default:"Без имени" }}
    {{ request.session.user_email }}
    {{ request.session.user_role|default:"CLIENT" }}
    {% csrf_token %}

    Фото профиля

    {% if profile and profile.photo_url %} {% else %} {% if request.session.user_email %} {% else %} {{ request.session.user_full_name|default:request.session.user_email|initial }} {% endif %} {% endif %}
    {% csrf_token %}
    {% csrf_token %}

    Если сервер не поддерживает загрузку, вы увидите соответствующее сообщение. Пока используется Gravatar/инициалы.

    {% if not has_profile or not profile %}

    Создать профиль

    {% csrf_token %}
    Несколько — через запятую
    Несколько — через запятую
    Сбросить
    {% else %}

    Данные профиля

    Пол
    {{ profile.gender|default:"—" }}
    Город
    {{ profile.city|default:"—" }}
    Языки
    {% if profile.languages %} {% for lang in profile.languages %}{{ lang }}{% endfor %} {% else %} — {% endif %}
    Интересы
    {% if profile.interests %} {% for it in profile.interests %}{{ it }}{% endfor %} {% else %} — {% endif %}
    ID профиля
    {{ profile.id }}
    ID пользователя
    {{ profile.user_id }}

    Редактирование полей профиля появится, как только сервер добавит PATCH /profiles/v1/profiles/me.

    {% endif %}
    HTML # login.html (ensure register url is namespaced) write templates/ui/login.html <<'HTML' {% load static %} Вход

    Вход

    {% if messages %}
      {% for message in messages %}
    • {{ message }}
    • {% endfor %}
    {% endif %}
    {% csrf_token %}

    Нет аккаунта? Зарегистрируйтесь

    HTML # register.html write templates/ui/register.html <<'HTML' {% load static %} Регистрация

    Регистрация

    {% if messages %}
      {% for message in messages %}
    • {{ message }}
    • {% endfor %}
    {% endif %}
    {% csrf_token %}

    Уже есть аккаунт? Войти

    HTML # index.html (nav to ui:*) write templates/ui/index.html <<'HTML' {% load static %} Главная
    Agency Frontend

    Добро пожаловать

    Это фронтенд для API брачного агентства.

    Войти Регистрация

    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"