From 95bef94c53b04199efd6107c22ba7ef15209932c Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Sun, 10 Aug 2025 17:28:38 +0900 Subject: [PATCH] main features --- .../templates/ui/cabinet.html | 192 +++ .../templates/ui/components/like_button.html | 17 + .../like_button_login_required.html | 3 + .../templates/ui/profile_detail.html | 84 ++ .../templates/ui/profiles_list.html | 195 ++++ .patch_backup_20250810_160643/ui/api.py | 527 +++++++++ .patch_backup_20250810_160643/ui/urls.py | 20 + .patch_backup_20250810_160643/ui/views.py | 492 ++++++++ .../templates/ui/cabinet.html | 204 ++++ .../templates/ui/index.html | 31 + .../templates/ui/login.html | 27 + .../templates/ui/profile_detail.html | 66 ++ .../templates/ui/profiles_list.html | 194 ++++ .../templates/ui/register.html | 24 + .patch_backup_20250810_164608/ui/urls.py | 23 + .patch_backup_20250810_164608/ui/views.py | 337 ++++++ scripts/patch_frontend.sh | 1031 +++++++++++++++++ templates/ui/cabinet.html | 194 ++-- templates/ui/components/like_button.html | 22 +- .../like_button_login_required.html | 4 +- templates/ui/index.html | 67 +- templates/ui/login.html | 68 +- templates/ui/profile_detail.html | 112 +- templates/ui/profiles_list.html | 110 +- templates/ui/register.html | 68 +- ui/api.py | 636 ++++------ ui/templatetags/__init__.py | 1 + ui/templatetags/ui_extras.py | 20 + ui/urls.py | 21 +- ui/views.py | 522 +++------ 30 files changed, 4246 insertions(+), 1066 deletions(-) create mode 100644 .patch_backup_20250810_160643/templates/ui/cabinet.html create mode 100644 .patch_backup_20250810_160643/templates/ui/components/like_button.html create mode 100644 .patch_backup_20250810_160643/templates/ui/components/like_button_login_required.html create mode 100644 .patch_backup_20250810_160643/templates/ui/profile_detail.html create mode 100644 .patch_backup_20250810_160643/templates/ui/profiles_list.html create mode 100644 .patch_backup_20250810_160643/ui/api.py create mode 100644 .patch_backup_20250810_160643/ui/urls.py create mode 100644 .patch_backup_20250810_160643/ui/views.py create mode 100644 .patch_backup_20250810_164608/templates/ui/cabinet.html create mode 100644 .patch_backup_20250810_164608/templates/ui/index.html create mode 100644 .patch_backup_20250810_164608/templates/ui/login.html create mode 100644 .patch_backup_20250810_164608/templates/ui/profile_detail.html create mode 100644 .patch_backup_20250810_164608/templates/ui/profiles_list.html create mode 100644 .patch_backup_20250810_164608/templates/ui/register.html create mode 100644 .patch_backup_20250810_164608/ui/urls.py create mode 100644 .patch_backup_20250810_164608/ui/views.py create mode 100755 scripts/patch_frontend.sh create mode 100644 ui/templatetags/__init__.py create mode 100644 ui/templatetags/ui_extras.py diff --git a/.patch_backup_20250810_160643/templates/ui/cabinet.html b/.patch_backup_20250810_160643/templates/ui/cabinet.html new file mode 100644 index 0000000..5d4a4bb --- /dev/null +++ b/.patch_backup_20250810_160643/templates/ui/cabinet.html @@ -0,0 +1,192 @@ +{% load static %} + + + + + Кабинет + + + + + + +
+
+ {% with header_name=header_name|default:request.session.user_full_name|default:request.session.user_email %} + Здравствуйте, {{ header_name }}! + {% endwith %} +
+ +
+ +
+ + {% if messages %} + + {% endif %} + +
+

Кабинет

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

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

+
+
Имя
+
{{ request.session.user_full_name|default:"—" }}
+ +
Email
+
{{ request.session.user_email|default:"—" }}
+ +
Роль
+
{{ request.session.user_role|default:"—" }}
+ +
ID пользователя
+
{{ request.session.user_id|default:"—" }}
+
+
+ + +
+

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

+ + {% if has_profile and profile %} +
+
Пол
+
{{ 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 }}
+
+ +
+ Показать сырой JSON профиля +
{{ profile|safe }}
+
+ + {% else %} +

Профиль ещё не создан. Заполните форму ниже.

+ {% endif %} +
+
+ + {% if not has_profile or not profile %} + +
+

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

+ +
+ {% csrf_token %} +
+
+ + +
+ +
+ + +
+
+ +
+ + + Несколько — через запятую: ru,en +
+ +
+ + + Несколько — через запятую: music,travel +
+ +
+ + Сбросить +
+
+
+ {% endif %} + +
+ + + diff --git a/.patch_backup_20250810_160643/templates/ui/components/like_button.html b/.patch_backup_20250810_160643/templates/ui/components/like_button.html new file mode 100644 index 0000000..34c9d76 --- /dev/null +++ b/.patch_backup_20250810_160643/templates/ui/components/like_button.html @@ -0,0 +1,17 @@ +{# expects: profile_id, liked: bool #} +
+ {% csrf_token %} + {% if liked %} + + {% else %} + + {% endif %} +
diff --git a/.patch_backup_20250810_160643/templates/ui/components/like_button_login_required.html b/.patch_backup_20250810_160643/templates/ui/components/like_button_login_required.html new file mode 100644 index 0000000..09b5307 --- /dev/null +++ b/.patch_backup_20250810_160643/templates/ui/components/like_button_login_required.html @@ -0,0 +1,3 @@ + + Войти, чтобы добавить + diff --git a/.patch_backup_20250810_160643/templates/ui/profile_detail.html b/.patch_backup_20250810_160643/templates/ui/profile_detail.html new file mode 100644 index 0000000..20faeec --- /dev/null +++ b/.patch_backup_20250810_160643/templates/ui/profile_detail.html @@ -0,0 +1,84 @@ +{% load static %} + + + + + Анкета пользователя + + + + + + +
+
Карточка пользователя (ADMIN)
+ +
+ +
+ +
+
+
+ {% if profile.photo %} + + {% else %} + {{ profile.name|first|upper }} + {% endif %} +
+
+
{{ profile.name }}
+ +
+
+
+ {% csrf_token %} + {% include "ui/components/like_button.html" with profile_id=profile.id liked=liked %} +
+
+
+
+ +
+

Профиль

+
+
Пол
{{ 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 %} +
+
+
+ +
+ + diff --git a/.patch_backup_20250810_160643/templates/ui/profiles_list.html b/.patch_backup_20250810_160643/templates/ui/profiles_list.html new file mode 100644 index 0000000..ac435ba --- /dev/null +++ b/.patch_backup_20250810_160643/templates/ui/profiles_list.html @@ -0,0 +1,195 @@ +{% load static %} + + + + + Каталог анкет + + + + + + +
+
+ Каталог анкет (ADMIN) +
+ +
+ +
+ + {% if messages %} + + {% endif %} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + + + {% if error %} +
  • {{ error }}
  • + {% endif %} + +
    + {% for p in profiles %} +
    +
    +
    +
    {{ p.name }}
    + +
    +
    +
    + {% 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" }}
    + +
    + + +
    + {% empty %} +
    +
    Ничего не найдено. Попробуйте изменить фильтры.
    +
    + {% endfor %} +
    + + + +
    + + + diff --git a/.patch_backup_20250810_160643/ui/api.py b/.patch_backup_20250810_160643/ui/api.py new file mode 100644 index 0000000..6ec01f3 --- /dev/null +++ b/.patch_backup_20250810_160643/ui/api.py @@ -0,0 +1,527 @@ +import logging +import os +import os.path +import json +import time +import uuid +from typing import Any, Dict, Optional, Tuple, List, Union + +import requests +from django.conf import settings +from django.core.exceptions import PermissionDenied + +logger = logging.getLogger(__name__) + +# ===== Логирование / флаги ===== +API_DEBUG = os.environ.get('API_DEBUG', '1') == '1' +API_LOG_BODY_MAX = int(os.environ.get('API_LOG_BODY_MAX', '2000')) +API_LOG_HEADERS = os.environ.get('API_LOG_HEADERS', '1') == '1' +API_LOG_CURL = os.environ.get('API_LOG_CURL', '0') == '1' + +# Переключение базы при 404: сначала servers[0].url из openapi.json, потом жёстко http://localhost:8080 +API_FALLBACK_OPENAPI_ON_404 = os.environ.get('API_FALLBACK_OPENAPI_ON_404', '1') == '1' + +SENSITIVE_KEYS = {'password', 'refresh_token', 'access_token', 'authorization', 'token', 'api_key'} + + +def _sanitize(obj: Any) -> Any: + try: + if isinstance(obj, dict): + return {k: ('***' if isinstance(k, str) and k.lower() in SENSITIVE_KEYS else _sanitize(v)) + for k, v in obj.items()} + if isinstance(obj, list): + return [_sanitize(x) for x in obj] + return obj + except Exception: + return obj + + +def _shorten(s: str, limit: int) -> str: + if s is None: + return '' + return s if len(s) <= limit else (s[:limit] + f'... ') + + +def _build_curl(method: str, url: str, headers: Dict[str, str], + params: Optional[dict], json_body: Optional[dict], data: Optional[dict]) -> str: + parts = [f"curl -X '{method.upper()}' \\\n '{url}'"] + for k, v in headers.items(): + if k.lower() == 'authorization': + v = 'Bearer ***' + parts.append(f" -H '{k}: {v}'") + if json_body is not None: + parts.append(" -H 'Content-Type: application/json'") + try: + body_str = json.dumps(_sanitize(json_body), ensure_ascii=False) + except Exception: + body_str = str(_sanitize(json_body)) + parts.append(f" -d '{body_str}'") + elif data is not None: + try: + body_str = json.dumps(_sanitize(data), ensure_ascii=False) + except Exception: + body_str = str(_sanitize(data)) + parts.append(f" --data-raw '{body_str}'") + return " \\\n".join(parts) + + +# ===== Пути эндпоинтов (как в swagger) ===== +EP_DEFAULTS: Dict[str, str] = { + # Auth + 'AUTH_REGISTER_PATH': '/auth/v1/register', + 'AUTH_TOKEN_PATH': '/auth/v1/token', + 'AUTH_REFRESH_PATH': '/auth/v1/refresh', + 'ME_PATH': '/auth/v1/me', + 'USERS_LIST_PATH': '/auth/v1/users', + 'USER_DETAIL_PATH': '/auth/v1/users/{user_id}', + + # Profiles + 'PROFILE_ME_PATH': '/profiles/v1/profiles/me', + 'PROFILES_CREATE_PATH': '/profiles/v1/profiles', + 'PROFILE_PHOTO_UPLOAD_PATH': '/profiles/v1/profiles/me/photo', + 'PROFILE_PHOTO_DELETE_PATH': '/profiles/v1/profiles/me/photo', + + # Pairs + 'PAIRS_PATH': '/match/v1/pairs', + 'PAIR_DETAIL_PATH': '/match/v1/pairs/{pair_id}', + 'PAIR_ACCEPT_PATH': '/match/v1/pairs/{pair_id}/accept', + 'PAIR_REJECT_PATH': '/match/v1/pairs/{pair_id}/reject', + + # Chat + 'ROOMS_PATH': '/chat/v1/rooms', + 'ROOM_DETAIL_PATH': '/chat/v1/rooms/{room_id}', + 'ROOM_MESSAGES_PATH': '/chat/v1/rooms/{room_id}/messages', + + # Payments + 'INVOICES_PATH': '/payments/v1/invoices', + 'INVOICE_DETAIL_PATH': '/payments/v1/invoices/{inv_id}', + 'INVOICE_MARK_PAID_PATH': '/payments/v1/invoices/{inv_id}/mark-paid', +} + +def EP(key: str) -> str: + return os.environ.get(f'API_{key}', EP_DEFAULTS[key]) + + +# ===== База API с авто‑детектом ===== +_API_BASE_CACHE: Optional[str] = None +_API_LAST_SELECT_SRC = 'DEFAULT' # для логов + +def _detect_api_base_from_openapi() -> Optional[str]: + """ + Берём servers[0].url из openapi.json (по схеме — http://localhost:8080). + """ + candidates = [ + os.environ.get('API_SPEC_PATH'), + getattr(settings, 'API_SPEC_PATH', None), + os.path.join(getattr(settings, 'BASE_DIR', ''), 'openapi.json') if getattr(settings, 'BASE_DIR', None) else None, + os.path.join(getattr(settings, 'BASE_DIR', ''), 'agency', 'openapi.json') if getattr(settings, 'BASE_DIR', None) else None, + '/mnt/data/openapi.json', + ] + for p in candidates: + if p and os.path.isfile(p): + try: + with open(p, 'r', encoding='utf-8') as f: + spec = json.load(f) + servers = spec.get('servers') or [] + if servers and isinstance(servers[0], dict): + url = servers[0].get('url') + if url: + return url + except Exception as e: + if API_DEBUG: + logger.debug('API: cannot read OpenAPI from %s: %s', p, e) + return None + + +def _get_api_base_url() -> str: + """ + Источники (по приоритету): + 1) ENV/Settings: API_BASE_URL, затем BASE_URL + 2) servers[0].url из openapi.json + 3) 'http://localhost:8080' + """ + global _API_BASE_CACHE, _API_LAST_SELECT_SRC + if _API_BASE_CACHE: + return _API_BASE_CACHE + + base = (os.environ.get('API_BASE_URL') or getattr(settings, 'API_BASE_URL', '') + or os.environ.get('BASE_URL') or getattr(settings, 'BASE_URL', '')) + if base: + _API_BASE_CACHE = base.rstrip('/') + _API_LAST_SELECT_SRC = 'ENV/SETTINGS' + else: + detected = _detect_api_base_from_openapi() + if detected: + _API_BASE_CACHE = detected.rstrip('/') + _API_LAST_SELECT_SRC = 'OPENAPI' + else: + _API_BASE_CACHE = 'http://localhost:8080' + _API_LAST_SELECT_SRC = 'HARDCODED' + + if API_DEBUG: + logger.info("API base selected [%s]: %s", _API_LAST_SELECT_SRC, _API_BASE_CACHE) + return _API_BASE_CACHE + + +class ApiError(Exception): + def __init__(self, status: int, message: str = 'API error', payload: Optional[dict] = None, req_id: Optional[str] = None): + if req_id and message and 'req_id=' not in message: + message = f"{message} (req_id={req_id})" + super().__init__(message) + self.status = status + self.payload = payload or {} + self.req_id = req_id + + +def _base_headers(request, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """ + Достаём токен сначала из сессии, затем из куки — чтобы «куки‑режим» работал без доп. настроек. + """ + headers: Dict[str, str] = {'Accept': 'application/json'} + token = request.session.get('access_token') or request.COOKIES.get('access_token') + if token: + headers['Authorization'] = f'Bearer {token}' + api_key = getattr(settings, 'API_KEY', '') or os.environ.get('API_KEY', '') + if api_key: + headers['X-API-Key'] = api_key + if extra: + headers.update(extra) + return headers + + +def _url(path: str) -> str: + base = _get_api_base_url() + path = path if path.startswith('/') else '/' + path + return base + path + + +def request_api( + request, + method: str, + path: str, + *, + params: Optional[dict] = None, + json: Optional[dict] = None, + files: Optional[dict] = None, + data: Optional[dict] = None, +) -> Tuple[int, Any]: + """ + Универсальный HTTP-вызов (упрощённая версия): + - токен/ключ в заголовках (токен берём из сессии или куки), + - auto-refresh при 401 (refresh берём тоже из сессии или куки), + - 404 → переключаем базу: servers[0].url из openapi.json → 'http://localhost:8080', + - подробные логи; БЕЗ «retry со слэшем». + """ + global _API_BASE_CACHE, _API_LAST_SELECT_SRC + + req_id = uuid.uuid4().hex[:8] + base_before = _get_api_base_url() + url = _url(path) + + def _do(_url: str): + headers = _base_headers(request) + if json is not None and files is None and data is None: + headers['Content-Type'] = 'application/json' + + if API_DEBUG: + log_headers = _sanitize(headers) if API_LOG_HEADERS else {} + log_body = _sanitize(json if json is not None else data) + if API_LOG_CURL: + try: + curl = _build_curl(method, _url, headers, params, json, data) + logger.debug("API[req_id=%s] cURL:\n%s", req_id, curl) + except Exception: + pass + logger.info( + "API[req_id=%s] REQUEST %s %s params=%s headers=%s body=%s", + req_id, method.upper(), _url, _sanitize(params), log_headers, log_body + ) + + t0 = time.time() + resp = requests.request( + method=method.upper(), + url=_url, + headers=headers, + params=params, + json=json, + data=data, + files=files, + timeout=float(getattr(settings, 'API_TIMEOUT', 8.0)), + ) + dt = int((time.time() - t0) * 1000) + + content_type = resp.headers.get('Content-Type', '') + try: + payload = resp.json() if 'application/json' in content_type else {} + except ValueError: + payload = {} + + if API_DEBUG: + body_str = "" + try: + body_str = json.dumps(_sanitize(payload), ensure_ascii=False) + except Exception: + body_str = str(_sanitize(payload)) + headers_out = _sanitize(dict(resp.headers)) if API_LOG_HEADERS else {} + logger.info( + "API[req_id=%s] RESPONSE %s %sms ct=%s headers=%s body=%s", + req_id, resp.status_code, dt, content_type, headers_out, _shorten(body_str, API_LOG_BODY_MAX) + ) + + return resp, payload + + # 1) Первый запрос + try: + resp, payload = _do(url) + except requests.RequestException as e: + logger.exception('API[req_id=%s] network error: %s', req_id, e) + raise ApiError(0, f'Network unavailable or timeout when accessing API ({e})', req_id=req_id) + + # 2) 404 → переключаем базу (openapi → 8080) и повторяем + if resp.status_code == 404: + candidates: List[tuple[str, str]] = [] + if API_FALLBACK_OPENAPI_ON_404: + detected = _detect_api_base_from_openapi() + if detected: + candidates.append((detected.rstrip('/'), 'OPENAPI(FAILOVER)')) + candidates.append(('http://localhost:8080', 'DEFAULT(FAILOVER)')) + + for cand_base, label in candidates: + if not cand_base or cand_base == _API_BASE_CACHE: + continue + if API_DEBUG: + logger.warning("API[req_id=%s] 404 on base %s → switch API base to %s and retry", + req_id, _API_BASE_CACHE, cand_base) + _API_BASE_CACHE = cand_base + _API_LAST_SELECT_SRC = label + try: + resp, payload = _do(_url(path)) + if resp.status_code != 404: + break + except requests.RequestException: + continue + + # 3) 401 → refresh и повтор + refresh_token = request.session.get('refresh_token') or request.COOKIES.get('refresh_token') + if resp.status_code == 401 and refresh_token: + if API_DEBUG: + logger.info("API[req_id=%s] 401 → try refresh token", req_id) + try: + refresh_url = _url(EP('AUTH_REFRESH_PATH')) + refresh_body = {'refresh_token': refresh_token} + logger.info("API[req_id=%s] REFRESH POST %s body=%s", req_id, refresh_url, _sanitize(refresh_body)) + refresh_resp = requests.post(refresh_url, json=refresh_body, timeout=float(getattr(settings, 'API_TIMEOUT', 8.0))) + if refresh_resp.status_code == 200: + try: + rj = refresh_resp.json() + except ValueError: + rj = {} + if rj.get('access_token'): + request.session['access_token'] = rj['access_token'] + if rj.get('refresh_token'): + request.session['refresh_token'] = rj['refresh_token'] + request.session.modified = True + if API_DEBUG: + logger.info("API[req_id=%s] REFRESH OK → retry original request", req_id) + resp, payload = _do(_url(path)) + else: + logger.warning("API[req_id=%s] REFRESH failed: %s", req_id, refresh_resp.status_code) + except requests.RequestException as e: + logger.exception('API[req_id=%s] Refresh token network error: %s', req_id, e) + raise ApiError(401, f'Token refresh failed ({e})', req_id=req_id) + + # 4) Ошибки + if not (200 <= resp.status_code < 300): + msg = None + if isinstance(payload, dict): + msg = payload.get('detail') or payload.get('message') + msg = msg or f'API error: {resp.status_code}' + if resp.status_code in (401, 403): + # PermissionDenied обрабатываем во view (не всегда это «выйти и войти заново») + raise PermissionDenied(f"{msg} (req_id={req_id})") + raise ApiError(resp.status_code, msg, payload if isinstance(payload, dict) else {}, req_id=req_id) + + # 5) База сменилась — отметим + base_after = _get_api_base_url() + if API_DEBUG and base_before != base_after: + logger.warning("API[req_id=%s] BASE SWITCHED: %s → %s", req_id, base_before, base_after) + + return resp.status_code, payload + + +# ========================== +# AUTH +# ========================== + +def register_user(request, email: str, password: str, full_name: Optional[str] = None, role: str = 'CLIENT') -> Dict[str, Any]: + body = {'email': email, 'password': password, 'full_name': full_name, 'role': role} + _, data = request_api(request, 'POST', EP('AUTH_REGISTER_PATH'), json=body) + return data # UserRead + +def login(request, email: str, password: str) -> Dict[str, Any]: + body = {'email': email, 'password': password} + _, data = request_api(request, 'POST', EP('AUTH_TOKEN_PATH'), json=body) + return data # TokenPair + +def get_current_user(request) -> Dict[str, Any]: + _, data = request_api(request, 'GET', EP('ME_PATH')) + return data # UserRead + +def list_users(request, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]: + params = {'offset': offset, 'limit': limit} + _, data = request_api(request, 'GET', EP('USERS_LIST_PATH'), params=params) + return data + +def get_user(request, user_id: str) -> Dict[str, Any]: + path = EP('USER_DETAIL_PATH').format(user_id=user_id) + _, data = request_api(request, 'GET', path) + return data + +def update_user(request, user_id: str, **fields) -> Dict[str, Any]: + path = EP('USER_DETAIL_PATH').format(user_id=user_id) + _, data = request_api(request, 'PATCH', path, json=fields) + return data + +def delete_user(request, user_id: str) -> None: + path = EP('USER_DETAIL_PATH').format(user_id=user_id) + request_api(request, 'DELETE', path) + + +# ========================== +# PROFILES +# ========================== + +def get_my_profile(request) -> Dict[str, Any]: + _, data = request_api(request, 'GET', EP('PROFILE_ME_PATH')) + return data # ProfileOut + +def create_my_profile(request, gender: str, city: str, languages: List[str], interests: List[str]) -> Dict[str, Any]: + body = {'gender': gender, 'city': city, 'languages': languages, 'interests': interests} + _, data = request_api(request, 'POST', EP('PROFILES_CREATE_PATH'), json=body) + return data # ProfileOut + + +# ========================== +# PAIRS +# ========================== + +def create_pair(request, user_id_a: str, user_id_b: str, score: Optional[float] = None, notes: Optional[str] = None) -> Dict[str, Any]: + body = {'user_id_a': user_id_a, 'user_id_b': user_id_b, 'score': score, 'notes': notes} + _, data = request_api(request, 'POST', EP('PAIRS_PATH'), json=body) + return data # PairRead + +def list_pairs(request, for_user_id: Optional[str] = None, status: Optional[str] = None, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]: + params = {'for_user_id': for_user_id, 'status': status, 'offset': offset, 'limit': limit} + params = {k: v for k, v in params.items() if v is not None} + _, data = request_api(request, 'GET', EP('PAIRS_PATH'), params=params) + return data + +def get_pair(request, pair_id: str) -> Dict[str, Any]: + path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id) + _, data = request_api(request, 'GET', path) + return data + +def update_pair(request, pair_id: str, **fields) -> Dict[str, Any]: + path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id) + _, data = request_api(request, 'PATCH', path, json=fields) + return data + +def delete_pair(request, pair_id: str) -> None: + path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id) + request_api(request, 'DELETE', path) + +def accept_pair(request, pair_id: str) -> Dict[str, Any]: + path = EP('PAIR_ACCEPT_PATH').format(pair_id=pair_id) + _, data = request_api(request, 'POST', path) + return data + +def reject_pair(request, pair_id: str) -> Dict[str, Any]: + path = EP('PAIR_REJECT_PATH').format(pair_id=pair_id) + _, data = request_api(request, 'POST', path) + return data + + +# ========================== +# CHAT +# ========================== + +def my_rooms(request) -> Union[List[Dict[str, Any]], Dict[str, Any]]: + _, data = request_api(request, 'GET', EP('ROOMS_PATH')) + return data + +def get_room(request, room_id: str) -> Dict[str, Any]: + path = EP('ROOM_DETAIL_PATH').format(room_id=room_id) + _, data = request_api(request, 'GET', path) + return data + +def create_room(request, title: Optional[str] = None, participants: List[str] = []) -> Dict[str, Any]: + body = {'title': title, 'participants': participants} + _, data = request_api(request, 'POST', EP('ROOMS_PATH'), json=body) + return data + +def send_message(request, room_id: str, content: str) -> Dict[str, Any]: + body = {'content': content} + path = EP('ROOM_MESSAGES_PATH').format(room_id=room_id) + _, data = request_api(request, 'POST', path, json=body) + return data + +def list_messages(request, room_id: str, offset: int = 0, limit: int = 100) -> Union[List[Dict[str, Any]], Dict[str, Any]]: + params = {'offset': offset, 'limit': limit} + path = EP('ROOM_MESSAGES_PATH').format(room_id=room_id) + _, data = request_api(request, 'GET', path, params=params) + return data + + +# ========================== +# PAYMENTS +# ========================== + +def create_invoice(request, client_id: str, amount: float, currency: str, description: Optional[str] = None) -> Dict[str, Any]: + body = {'client_id': client_id, 'amount': amount, 'currency': currency, 'description': description} + _, data = request_api(request, 'POST', EP('INVOICES_PATH'), json=body) + return data + +def list_invoices(request, client_id: Optional[str] = None, status: Optional[str] = None, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]: + params = {'client_id': client_id, 'status': status, 'offset': offset, 'limit': limit} + params = {k: v for k, v in params.items() if v is not None} + _, data = request_api(request, 'GET', EP('INVOICES_PATH'), params=params) + return data + +def get_invoice(request, inv_id: str) -> Dict[str, Any]: + path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id) + _, data = request_api(request, 'GET', path) + return data + +def update_invoice(request, inv_id: str, **fields) -> Dict[str, Any]: + path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id) + _, data = request_api(request, 'PATCH', path, json=fields) + return data + +def delete_invoice(request, inv_id: str) -> None: + path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id) + request_api(request, 'DELETE', path) + +def mark_invoice_paid(request, inv_id: str) -> Dict[str, Any]: + path = EP('INVOICE_MARK_PAID_PATH').format(inv_id=inv_id) + _, data = request_api(request, 'POST', path) + return data + +def upload_my_photo(request, file_obj) -> Dict[str, Any]: + """ + Отправляет multipart/form-data на бекенд для загрузки фото профиля. + Ожидаем, что сервер примет поле 'file' и вернёт обновлённый профиль или {photo_url: "..."}. + """ + path = EP('PROFILE_PHOTO_UPLOAD_PATH') + filename = getattr(file_obj, 'name', 'photo.jpg') + content_type = getattr(file_obj, 'content_type', 'application/octet-stream') + files = {'file': (filename, file_obj, content_type)} + _, data = request_api(request, 'POST', path, files=files) + return data + +def delete_my_photo(request) -> Dict[str, Any]: + """ + Удаляет фото профиля (если сервер поддерживает DELETE на том же пути). + """ + path = EP('PROFILE_PHOTO_DELETE_PATH') + _, data = request_api(request, 'DELETE', path) + return data \ No newline at end of file diff --git a/.patch_backup_20250810_160643/ui/urls.py b/.patch_backup_20250810_160643/ui/urls.py new file mode 100644 index 0000000..b37920b --- /dev/null +++ b/.patch_backup_20250810_160643/ui/urls.py @@ -0,0 +1,20 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.index, name='index'), + # Кабинет + path("cabinet/", views.cabinet_view, name="cabinet"), + path("cabinet/photo/upload/", views.cabinet_upload_photo, name="cabinet_upload_photo"), + path("cabinet/photo/delete/", views.cabinet_delete_photo, name="cabinet_delete_photo"), + + # Каталог + path("profiles/", views.profile_list, name="profiles"), + path("profiles//", views.profile_detail, name="profile_detail"), + path("profiles//like/", views.like_profile, name="like_profile"), + + # Регистрация и авторизация + path('register/', views.register_view, name='register'), + path('login/', views.login_view, name='login'), + path('logout/', views.logout_view, name='logout'), +] diff --git a/.patch_backup_20250810_160643/ui/views.py b/.patch_backup_20250810_160643/ui/views.py new file mode 100644 index 0000000..ea90500 --- /dev/null +++ b/.patch_backup_20250810_160643/ui/views.py @@ -0,0 +1,492 @@ +import base64 +import json +import time +from typing import List, Dict, Any, Optional + +from django.http import Http404, HttpResponse +from django.shortcuts import render, redirect +from django.views.decorators.http import require_http_methods, require_POST +from django.contrib import messages +from django.core.exceptions import PermissionDenied +from django.conf import settings +from django.views.decorators.csrf import csrf_exempt + +from . import api +from .api import ApiError + + +# -------- helpers (cookies/JWT) -------- + +def _cookie_secure() -> bool: + # в dev можно False, в prod обязательно True + return not getattr(settings, "DEBUG", True) + +def _jwt_exp_seconds(token: Optional[str], default_sec: int = 12 * 3600) -> int: + """ + Пытаемся вытащить exp из JWT и посчитать max_age для куки. + Если не получилось — даём дефолт (12 часов). + """ + if not token or token.count(".") != 2: + return default_sec + try: + payload_b64 = token.split(".")[1] + payload_b64 += "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode()).decode("utf-8")) + exp = int(payload.get("exp", 0)) + now = int(time.time()) + if exp > now: + # добавим небольшой «запас» (минус 60 сек) + return max(60, exp - now - 60) + return default_sec + except Exception: + return default_sec + +def _set_auth_cookies(resp: HttpResponse, access_token: Optional[str], refresh_token: Optional[str]) -> None: + """ + Кладём токены в HttpOnly cookies с корректным сроком жизни. + """ + if access_token: + resp.set_cookie( + "access_token", + access_token, + max_age=_jwt_exp_seconds(access_token), + httponly=True, + samesite="Lax", + secure=_cookie_secure(), + path="/", + ) + if refresh_token: + resp.set_cookie( + "refresh_token", + refresh_token, + max_age=_jwt_exp_seconds(refresh_token, default_sec=7 * 24 * 3600), + httponly=True, + samesite="Lax", + secure=_cookie_secure(), + path="/", + ) + +def _clear_auth_cookies(resp: HttpResponse) -> None: + resp.delete_cookie("access_token", path="/") + resp.delete_cookie("refresh_token", path="/") + + +# -------- UI helpers -------- + +def index(request): + return render(request, "ui/index.html", {}) + +def _auth_required_partial(request) -> HttpResponse: + # Мини‑partial под HTMX для кнопки "в избранное", если нет логина + return render(request, "ui/components/like_button_login_required.html", {}) + +def _is_admin(request) -> bool: + return (request.session.get("user_role") or "").upper() == "ADMIN" + +def _user_to_profile_stub(u: Dict[str, Any]) -> Dict[str, Any]: + name = u.get("full_name") or u.get("email") or "Без имени" + return { + "id": u.get("id"), + "name": name, + "email": u.get("email") or "", + "role": (u.get("role") or "").upper() or "CLIENT", + "verified": u.get("is_active", False), + # ↓ ключевые правки — чтобы шаблон не генерил src="None" + "age": None, + "city": None, + "about": "", + "photo": "", # было: None + "interests": [], + "liked": False, + } + +def _format_validation(payload: Optional[dict]) -> Optional[str]: + """Сборка сообщений 422 ValidationError в одну строку.""" + if not payload: + return None + det = payload.get("detail") + if isinstance(det, list): + msgs = [] + for item in det: + msg = item.get("msg") + loc = item.get("loc") + if isinstance(loc, list) and loc: + field = ".".join(str(x) for x in loc if isinstance(x, (str, int))) + if field and field not in ("body",): + msgs.append(f"{field}: {msg}") + else: + msgs.append(msg) + elif msg: + msgs.append(msg) + return "; ".join(m for m in msgs if m) + return payload.get("message") or payload.get("detail") + + +# ---------------- Auth ---------------- + +@require_http_methods(["GET", "POST"]) +def login_view(request): + if request.method == "POST": + email = (request.POST.get("email") or "").strip() + password = (request.POST.get("password") or "").strip() + if not email or not password: + messages.error(request, "Укажите email и пароль") + else: + try: + token_pair = api.login(request, email, password) # POST /auth/v1/token + access = token_pair.get("access_token") + refresh = token_pair.get("refresh_token") or token_pair.get("refresh") + if not access: + raise ApiError(0, "API не вернул access_token") + + # Пока храним и в сессии, и в куки (куки — для твоей задачи; сессия — на случай refresh внутри запроса) + request.session["access_token"] = access + if refresh: + request.session["refresh_token"] = refresh + + me = api.get_current_user(request) # GET /auth/v1/me + request.session["user_id"] = me.get("id") or me.get("user_id") + request.session["user_email"] = me.get("email") + request.session["user_full_name"] = me.get("full_name") or me.get("email") + request.session["user_role"] = me.get("role") or "CLIENT" + request.session.modified = True + + user_label = request.session.get("user_full_name") or request.session.get("user_email") or "пользователь" + messages.success(request, f"Здравствуйте, {user_label}!") + + next_url = request.GET.get("next") + if not next_url: + next_url = "profiles" if _is_admin(request) else "cabinet" + + resp = redirect(next_url) + _set_auth_cookies(resp, access, refresh) + return resp + + except PermissionDenied: + messages.error(request, "Неверные учётные данные") + except ApiError as e: + messages.error(request, f"Ошибка входа: {e}") + + return render(request, "ui/login.html", {}) + + +@require_http_methods(["GET", "POST"]) +def register_view(request): + """ + Регистрация → авто‑логин → установка токенов в cookies → редирект. + """ + if request.method == "POST": + email = (request.POST.get("email") or "").strip() + password = (request.POST.get("password") or "").strip() + full_name = (request.POST.get("full_name") or "").strip() or None + + if not email or not password: + messages.error(request, "Укажите email и пароль") + else: + try: + api.register_user(request, email=email, password=password, full_name=full_name, role='CLIENT') + token_pair = api.login(request, email=email, password=password) + access = token_pair.get("access_token") + refresh = token_pair.get("refresh_token") or token_pair.get("refresh") + if not access: + raise ApiError(0, "API не вернул access_token после регистрации") + + request.session["access_token"] = access + if refresh: + request.session["refresh_token"] = refresh + + me = api.get_current_user(request) + request.session["user_id"] = me.get("id") or me.get("user_id") + request.session["user_email"] = me.get("email") + request.session["user_full_name"] = me.get("full_name") or me.get("email") + request.session["user_role"] = me.get("role") or "CLIENT" + request.session.modified = True + + messages.success(request, "Регистрация успешна! Вы вошли в систему.") + next_url = request.GET.get("next") + if not next_url: + next_url = "profiles" if _is_admin(request) else "cabinet" + resp = redirect(next_url) + _set_auth_cookies(resp, access, refresh) + return resp + + except ApiError as e: + payload = getattr(e, "payload", None) + if payload and isinstance(payload, dict): + nice = _format_validation(payload) + messages.error(request, nice or f"Ошибка регистрации: {e}") + else: + messages.error(request, f"Ошибка регистрации: {e}") + except PermissionDenied: + messages.info(request, "Регистрация прошла, войдите под своим email/паролем") + return redirect("login") + + return render(request, "ui/register.html", {}) + + +@require_http_methods(["POST", "GET"]) +def logout_view(request): + for key in ("access_token", "refresh_token", "user_id", "user_email", "user_full_name", "user_role", "likes"): + request.session.pop(key, None) + request.session.modified = True + messages.info(request, "Вы вышли из аккаунта") + resp = redirect("index") + _clear_auth_cookies(resp) + return resp + + +# ---------------- Catalog (Admin‑only) ---------------- + +@require_http_methods(["GET"]) +def profile_list(request): + """ + Каталог анкет (по сути — пользователей) с фильтрами и пагинацией. + Доступно только ADMIN (по API /auth/v1/users; у клиента прав нет). + Фильтры клиентские: q (имя/email), role, active, email_domain; сортировка; page/limit. + """ + if not (request.session.get("access_token") or request.COOKIES.get("access_token")): + messages.info(request, "Войдите, чтобы открыть каталог") + return redirect("login") + + if not _is_admin(request): + messages.info(request, "Каталог доступен только администраторам. Перенаправляем в Кабинет.") + return redirect("cabinet") + + # --- читаем query-параметры --- + q = (request.GET.get("q") or "").strip().lower() + role = (request.GET.get("role") or "").strip().upper() # CLIENT|ADMIN|"" (любой) + active = request.GET.get("active") # "1"|"0"|None + email_domain = (request.GET.get("domain") or "").strip().lower().lstrip("@") + + # сортировка: name, name_desc, email, email_desc (по умолчанию name) + sort = (request.GET.get("sort") or "name").strip().lower() + page = max(1, int(request.GET.get("page") or 1)) + limit = min(max(1, int(request.GET.get("limit") or 20)), 200) # API максимум 200, см. спеки :contentReference[oaicite:1]{index=1} + offset = (page - 1) * limit + + error: Optional[str] = None + profiles: List[Dict[str, Any]] = [] + page_info = {"page": page, "limit": limit, "has_prev": page > 1, "has_next": False} + + try: + # Серверная пагинация есть, фильтров — нет (кроме offset/limit) → забираем страницу + data = api.list_users(request, offset=offset, limit=limit) # GET /auth/v1/users :contentReference[oaicite:2]{index=2} + users: List[Dict[str, Any]] = (data.get("items") if isinstance(data, dict) else data) or [] + + # --- клиентская фильтрация --- + def keep(u: Dict[str, Any]) -> bool: + if q: + fn = (u.get("full_name") or "").lower() + em = (u.get("email") or "").lower() + if q not in fn and q not in em: + return False + if role and (u.get("role") or "").upper() != role: + return False + if active in ("1", "0"): + is_act = bool(u.get("is_active")) + if (active == "1" and not is_act) or (active == "0" and is_act): + return False + if email_domain: + em = (u.get("email") or "").lower() + dom = em.split("@")[-1] if "@" in em else "" + if dom != email_domain: + return False + return True + + users = [u for u in users if keep(u)] + + # --- сортировка --- + def key_name(u): return (u.get("full_name") or u.get("email") or "").lower() + def key_email(u): return (u.get("email") or "").lower() + if sort == "name": + users.sort(key=key_name) + elif sort == "name_desc": + users.sort(key=key_name, reverse=True) + elif sort == "email": + users.sort(key=key_email) + elif sort == "email_desc": + users.sort(key=key_email, reverse=True) + + # Преобразуем в «анкеты» + profiles = [_user_to_profile_stub(u) for u in users] + + # отметка лайков (локальная сессия) + liked_ids = set(request.session.get("likes", [])) + for p in profiles: + p["liked"] = p.get("id") in liked_ids + + # has_next — на глаз: если сервер отдал ровно limit без наших фильтров, + # считаем, что следующая страница потенциально есть + page_info["has_next"] = (len(users) == limit and not (q or role or active in ("1","0") or email_domain)) + # (если включены клиентские фильтры — не знаем полный объём; оставим conservative False) + + except PermissionDenied as e: + messages.error(request, f"Нет доступа к каталогу: {e}") + return redirect("cabinet") + except ApiError as e: + error = str(e) + + ctx = { + "profiles": profiles, + "filters": { + "q": (request.GET.get("q") or "").strip(), + "role": role, + "active": (active or ""), + "domain": (request.GET.get("domain") or "").strip(), + "sort": sort, + "page": page, + "limit": limit, + }, + "count": len(profiles), + "page": page_info, + "error": error, + "page_sizes": [10, 20, 50, 100, 200], + } + return render(request, "ui/profiles_list.html", ctx) + +@require_http_methods(["GET"]) +def profile_detail(request, pk: str): + """ + Детальная карточка пользователя — тоже ADMIN‑only. + """ + if not (request.session.get("access_token") or request.COOKIES.get("access_token")): + return redirect("login") + if not _is_admin(request): + messages.info(request, "Детали пользователей доступны только администраторам.") + return redirect("cabinet") + + try: + user = api.get_user(request, user_id=str(pk)) + except PermissionDenied as e: + messages.error(request, f"Нет доступа: {e}") + return redirect("cabinet") + except ApiError as e: + if e.status == 404: + raise Http404("Пользователь не найден") + messages.error(request, str(e)) + return redirect("profiles") + + profile = _user_to_profile_stub(user) + liked_ids = set(request.session.get("likes", [])) + liked = profile.get("id") in liked_ids + return render(request, "ui/profile_detail.html", {"profile": profile, "liked": liked}) + + +@require_POST +def like_profile(request, pk: str): + """ + Локальный «лайк» (для демо). Не требует API. + """ + if not (request.session.get("access_token") or request.COOKIES.get("access_token")): + return _auth_required_partial(request) + + likes = set(request.session.get("likes", [])) + if str(pk) in likes: + likes.remove(str(pk)) + liked = False + else: + likes.add(str(pk)) + liked = True + request.session["likes"] = list(likes) + request.session.modified = True + + return render(request, "ui/components/like_button.html", {"profile_id": pk, "liked": liked}) + + +# ---------------- Кабинет: мой профиль ---------------- + +@require_http_methods(["GET", "POST"]) +def cabinet_view(request): + """ + Мой профиль: + - GET: /profiles/v1/profiles/me; если 404 — пустая форма создания + - POST: /profiles/v1/profiles (создать/заполнить свой профиль) + """ + if not (request.session.get("access_token") or request.COOKIES.get("access_token")): + messages.info(request, "Для доступа к кабинету войдите в систему") + return redirect("login") + + if request.method == "POST": + gender = (request.POST.get("gender") or "").strip() + city = (request.POST.get("city") or "").strip() + languages = [s.strip() for s in (request.POST.get("languages") or "").split(",") if s.strip()] + interests = [s.strip() for s in (request.POST.get("interests") or "").split(",") if s.strip()] + if not gender or not city: + messages.error(request, "Укажите пол и город") + else: + try: + profile = api.create_my_profile(request, gender=gender, city=city, languages=languages, interests=interests) + messages.success(request, "Профиль создан") + return render(request, "ui/cabinet.html", {"profile": profile, "has_profile": True}) + except PermissionDenied: + messages.error(request, "Сессия истекла, войдите снова") + return redirect("login") + except ApiError as e: + payload = getattr(e, "payload", None) + nice = _format_validation(payload) if isinstance(payload, dict) else None + messages.error(request, nice or f"Ошибка сохранения профиля: {e}") + + # GET + try: + profile = api.get_my_profile(request) + # шапка кабинета — имя из сессии (или email) + header_name = request.session.get("user_full_name") or request.session.get("user_email") or "" + return render(request, "ui/cabinet.html", {"profile": profile, "has_profile": True, "header_name": header_name}) + except ApiError as e: + if e.status == 404: + header_name = request.session.get("user_full_name") or request.session.get("user_email") or "" + return render(request, "ui/cabinet.html", {"profile": None, "has_profile": False, "header_name": header_name}) + messages.error(request, f"Ошибка загрузки профиля: {e}") + return render(request, "ui/cabinet.html", {"profile": None, "has_profile": False}) + except PermissionDenied: + messages.error(request, "Сессия истекла, войдите снова") + return redirect("login") + + +@require_POST +def cabinet_upload_photo(request): + # Требуется логин + if not (request.session.get("access_token") or request.COOKIES.get("access_token")): + messages.info(request, "Войдите, чтобы загрузить фото") + return redirect("login") + + f = request.FILES.get("photo") + if not f: + messages.error(request, "Файл не выбран") + return redirect("cabinet") + if f.size and f.size > 5 * 1024 * 1024: + messages.error(request, "Файл слишком большой (макс. 5 МБ)") + return redirect("cabinet") + + try: + api.upload_my_photo(request, f) + messages.success(request, "Фото обновлено") + except PermissionDenied: + messages.error(request, "Сессия истекла, войдите снова") + return redirect("login") + except ApiError as e: + if e.status in (404, 405): + messages.error(request, "Бэкенд пока не поддерживает загрузку фото (нет эндпоинта).") + else: + messages.error(request, f"Ошибка загрузки: {e}") + return redirect("cabinet") + + +@require_POST +def cabinet_delete_photo(request): + if not (request.session.get("access_token") or request.COOKIES.get("access_token")): + messages.info(request, "Войдите, чтобы удалить фото") + return redirect("login") + + try: + api.delete_my_photo(request) + messages.success(request, "Фото удалено") + except PermissionDenied: + messages.error(request, "Сессия истекла, войдите снова") + return redirect("login") + except ApiError as e: + if e.status in (404, 405): + messages.error(request, "Удаление фото не поддерживается бэкендом.") + else: + messages.error(request, f"Ошибка удаления: {e}") + return redirect("cabinet") \ No newline at end of file diff --git a/.patch_backup_20250810_164608/templates/ui/cabinet.html b/.patch_backup_20250810_164608/templates/ui/cabinet.html new file mode 100644 index 0000000..708c35e --- /dev/null +++ b/.patch_backup_20250810_164608/templates/ui/cabinet.html @@ -0,0 +1,204 @@ +{% 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 %} + +
    + + diff --git a/.patch_backup_20250810_164608/templates/ui/index.html b/.patch_backup_20250810_164608/templates/ui/index.html new file mode 100644 index 0000000..1adf362 --- /dev/null +++ b/.patch_backup_20250810_164608/templates/ui/index.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} +{% block title %}Главная — MatchAgency{% endblock %} +{% block content %} +
    +
    +

    Подбор идеальных пар под ключ

    +

    Фронтенд полностью на API: ни одной локальной таблицы.

    +
    + Смотреть анкеты + {% if not api_user %} + Войти + {% endif %} +
    +
    +
    +
    + + + + + + +
    +
    +
    +{% endblock %} diff --git a/.patch_backup_20250810_164608/templates/ui/login.html b/.patch_backup_20250810_164608/templates/ui/login.html new file mode 100644 index 0000000..c53d140 --- /dev/null +++ b/.patch_backup_20250810_164608/templates/ui/login.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} +{% block title %}Вход — MatchAgency{% endblock %} +{% block content %} +
    +

    Вход

    +
    + {% csrf_token %} +
    + + +
    +
    + + +
    +
    + + +
    + {% if error_message %} +

    {{ error_message }}

    + {% endif %} +

    Нет аккаунта? Зарегистрироваться

    + +
    +
    +{% endblock %} diff --git a/.patch_backup_20250810_164608/templates/ui/profile_detail.html b/.patch_backup_20250810_164608/templates/ui/profile_detail.html new file mode 100644 index 0000000..56470f2 --- /dev/null +++ b/.patch_backup_20250810_164608/templates/ui/profile_detail.html @@ -0,0 +1,66 @@ +{% 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 %} +
    +
    +
    +
    + +
    + + diff --git a/.patch_backup_20250810_164608/templates/ui/profiles_list.html b/.patch_backup_20250810_164608/templates/ui/profiles_list.html new file mode 100644 index 0000000..fb4074c --- /dev/null +++ b/.patch_backup_20250810_164608/templates/ui/profiles_list.html @@ -0,0 +1,194 @@ +{% 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 %} +
    + + + +
    + + diff --git a/.patch_backup_20250810_164608/templates/ui/register.html b/.patch_backup_20250810_164608/templates/ui/register.html new file mode 100644 index 0000000..a012534 --- /dev/null +++ b/.patch_backup_20250810_164608/templates/ui/register.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block title %}Регистрация — MatchAgency{% endblock %} +{% block content %} +
    +

    Регистрация

    +
    + {% csrf_token %} +
    + + +
    +
    + + +
    +
    + + +
    + +
    +

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

    +
    +{% endblock %} diff --git a/.patch_backup_20250810_164608/ui/urls.py b/.patch_backup_20250810_164608/ui/urls.py new file mode 100644 index 0000000..f23663c --- /dev/null +++ b/.patch_backup_20250810_164608/ui/urls.py @@ -0,0 +1,23 @@ +from django.urls import path +from . import views + +app_name = "ui" + +urlpatterns = [ + path("", views.index, name="index"), + + # auth + path("login/", views.login_view, name="login"), + path("register/", views.register_view, name="register"), + path("logout/", views.logout_view, name="logout"), + + # cabinet + path("cabinet/", views.cabinet_view, name="cabinet"), + path("cabinet/photo/upload/", views.cabinet_upload_photo, name="cabinet_upload_photo"), + path("cabinet/photo/delete/", views.cabinet_delete_photo, name="cabinet_delete_photo"), + + # admin catalog (users ≈ анкеты) + path("profiles/", views.profile_list, name="profiles"), + path("profiles//", views.profile_detail, name="profile_detail"), + path("profiles//like/", views.like_profile, name="like_profile"), +] diff --git a/.patch_backup_20250810_164608/ui/views.py b/.patch_backup_20250810_164608/ui/views.py new file mode 100644 index 0000000..a209ed7 --- /dev/null +++ b/.patch_backup_20250810_164608/ui/views.py @@ -0,0 +1,337 @@ +from __future__ import annotations +from typing import List, Dict, Any, Optional +import uuid as _uuid + +from django.http import Http404, HttpResponse +from django.shortcuts import render, redirect +from django.views.decorators.http import require_http_methods, require_POST +from django.contrib import messages +from django.core.exceptions import PermissionDenied + +from . import api +from .api import ApiError + +# === helpers ================================================================== + +def _is_logged(request) -> bool: + return bool(request.session.get("access_token") or request.COOKIES.get("access_token")) + +def _is_admin(request) -> bool: + return (request.session.get("user_role") or "").upper() == "ADMIN" + +def _set_tokens_and_user(request, tokens: Dict[str, Any], me: Dict[str, Any]): + # tokens + request.session["access_token"] = tokens.get("access_token") + request.session["refresh_token"] = tokens.get("refresh_token") + # user + request.session["user_id"] = me.get("id") + request.session["user_email"] = me.get("email") + request.session["user_full_name"] = me.get("full_name") + request.session["user_role"] = me.get("role") + request.session.modified = True + +def _user_to_profile_stub(u: Dict[str, Any]) -> Dict[str, Any]: + name = u.get("full_name") or u.get("email") or "Без имени" + return { + "id": u.get("id"), + "name": name, + "email": u.get("email") or "", + "role": (u.get("role") or "").upper() or "CLIENT", + "verified": bool(u.get("is_active", False)), + "age": None, + "city": None, + "about": "", + "photo": "", # важно: не None → не будет src="None" + "interests": [], + "liked": False, + } + +# === public pages ============================================================== + +def index(request): + return render(request, "ui/index.html", {}) + +# === catalog (admin) ========================================================== + +@require_http_methods(["GET"]) +def profile_list(request): + if not _is_logged(request): + messages.info(request, "Войдите, чтобы открыть каталог") + return redirect("login") + if not _is_admin(request): + messages.info(request, "Каталог доступен только администраторам. Перенаправляем в Кабинет.") + return redirect("cabinet") + + q = (request.GET.get("q") or "").strip().lower() + role = (request.GET.get("role") or "").strip().upper() + active = request.GET.get("active") + email_domain = (request.GET.get("domain") or "").strip().lower().lstrip("@") + sort = (request.GET.get("sort") or "name").strip().lower() + page = max(1, int(request.GET.get("page") or 1)) + limit = min(max(1, int(request.GET.get("limit") or 20)), 200) + offset = (page - 1) * limit + + error: Optional[str] = None + profiles: List[Dict[str, Any]] = [] + page_info = {"page": page, "limit": limit, "has_prev": page > 1, "has_next": False} + + try: + data = api.list_users(request, offset=offset, limit=limit) + users: List[Dict[str, Any]] = (data.get("items") if isinstance(data, dict) else data) or [] + + # client-side filters + def keep(u: Dict[str, Any]) -> bool: + fn = (u.get("full_name") or "") + em = (u.get("email") or "") + if q and (q not in fn.lower() and q not in em.lower()): + return False + if role and (u.get("role") or "").upper() != role: + return False + if active in ("1", "0"): + is_act = bool(u.get("is_active", False)) + if (active == "1" and not is_act) or (active == "0" and is_act): + return False + if email_domain: + dom = em.lower().split("@")[-1] if "@" in em else "" + if dom != email_domain: + return False + return True + + users = [u for u in users if keep(u)] + + def key_name(u): return (u.get("full_name") or u.get("email") or "").lower() + def key_email(u): return (u.get("email") or "").lower() + if sort == "name": + users.sort(key=key_name) + elif sort == "name_desc": + users.sort(key=key_name, reverse=True) + elif sort == "email": + users.sort(key=key_email) + elif sort == "email_desc": + users.sort(key=key_email, reverse=True) + + profiles = [_user_to_profile_stub(u) for u in users] + liked_ids = set(request.session.get("likes", [])) + for p in profiles: + p["liked"] = p.get("id") in liked_ids + + page_info["has_next"] = (len(users) == limit and not (q or role or active in ("1","0") or email_domain)) + + except PermissionDenied as e: + messages.error(request, f"Нет доступа к каталогу: {e}") + return redirect("cabinet") + except ApiError as e: + error = str(e) + + ctx = { + "profiles": profiles, + "filters": { + "q": (request.GET.get("q") or "").strip(), + "role": role, + "active": (active or ""), + "domain": (request.GET.get("domain") or "").strip(), + "sort": sort, + "page": page, + "limit": limit, + }, + "count": len(profiles), + "page": page_info, + "error": error, + "page_sizes": [10, 20, 50, 100, 200], + } + return render(request, "ui/profiles_list.html", ctx) + +@require_http_methods(["GET"]) +def profile_detail(request, pk): + # pk — uuid в маршруте; приводим к строке + try: + user = api.get_user(request, str(pk)) + except PermissionDenied: + messages.error(request, "Сессия истекла, войдите снова") + return redirect("login") + except ApiError as e: + if e.status == 404: + raise Http404("Пользователь не найден") + messages.error(request, str(e)) + return redirect("profiles") + + stub = _user_to_profile_stub(user) + liked_ids = set(request.session.get("likes", [])) + liked = stub["id"] in liked_ids + return render(request, "ui/profile_detail.html", {"profile": stub, "liked": liked}) + +@require_POST +def like_profile(request, pk): + # простая реализация лайков в сессии + likes = set(request.session.get("likes", [])) + pk_str = str(pk) + if pk_str in likes: + likes.remove(pk_str); liked = False + else: + likes.add(pk_str); liked = True + request.session["likes"] = list(likes) + request.session.modified = True + return render(request, "ui/components/like_button.html", {"profile_id": pk_str, "liked": liked}) + +# === auth ===================================================================== + +@require_http_methods(["GET", "POST"]) +def login_view(request): + if request.method == "POST": + email = (request.POST.get("email") or "").strip() + password = (request.POST.get("password") or "").strip() + if not email or not password: + messages.error(request, "Укажите email и пароль") + else: + try: + tokens = api.login(request, email, password) + me = api.get_me(request) + _set_tokens_and_user(request, tokens, me) + + # ставим httpOnly cookie для API-клиента (серверные запросы читают из сессии) + resp = redirect(request.GET.get("next") or ("profiles" if (me.get("role") == "ADMIN") else "cabinet")) + max_age = 7 * 24 * 3600 + resp.set_cookie("access_token", tokens.get("access_token"), max_age=max_age, httponly=True, samesite="Lax") + resp.set_cookie("refresh_token", tokens.get("refresh_token"), max_age=max_age, httponly=True, samesite="Lax") + messages.success(request, "Вы успешно вошли") + return resp + except PermissionDenied as e: + messages.error(request, f"Доступ запрещён: {e}") + except ApiError as e: + messages.error(request, f"Ошибка входа: {e.args[0]}") + return render(request, "ui/login.html", {}) + +@require_http_methods(["GET", "POST"]) +def register_view(request): + if request.method == "POST": + email = (request.POST.get("email") or "").strip() + password = (request.POST.get("password") or "").strip() + full_name = (request.POST.get("full_name") or "").strip() or None + if not email or not password: + messages.error(request, "Укажите email и пароль") + else: + try: + api.register_user(request, email, password, full_name, role="CLIENT") + # сразу логиним + tokens = api.login(request, email, password) + me = api.get_me(request) + _set_tokens_and_user(request, tokens, me) + resp = redirect("cabinet") + max_age = 7 * 24 * 3600 + resp.set_cookie("access_token", tokens.get("access_token"), max_age=max_age, httponly=True, samesite="Lax") + resp.set_cookie("refresh_token", tokens.get("refresh_token"), max_age=max_age, httponly=True, samesite="Lax") + messages.success(request, "Регистрация успешна") + return resp + except ApiError as e: + messages.error(request, f"Ошибка регистрации: {e.args[0]}") + return render(request, "ui/register.html", {}) + +@require_http_methods(["POST", "GET"]) +def logout_view(request): + resp = redirect("index") + # чистим куки + resp.delete_cookie("access_token") + resp.delete_cookie("refresh_token") + # и сессию + for k in ("auth", "access_token", "refresh_token", "user_id", "user_email", "user_full_name", "user_role"): + request.session.pop(k, None) + request.session.modified = True + messages.info(request, "Вы вышли из аккаунта") + return resp + +# === cabinet ================================================================== + +@require_http_methods(["GET", "POST"]) +def cabinet_view(request): + if not _is_logged(request): + messages.info(request, "Войдите, чтобы открыть кабинет") + return redirect("login") + + # запрашиваем мой профиль (может быть 404, если не создан) + profile = None + has_profile = False + try: + profile = api.get_my_profile(request) + has_profile = bool(profile) + except PermissionDenied: + messages.error(request, "Сессия истекла, войдите снова") + return redirect("login") + except ApiError as e: + if e.status != 404: + messages.error(request, f"Ошибка чтения профиля: {e}") + + if request.method == "POST": + action = (request.POST.get("action") or "").strip() + try: + if action == "create_profile": + gender = (request.POST.get("gender") or "").strip() + city = (request.POST.get("city") or "").strip() + languages = [s.strip() for s in (request.POST.get("languages") or "").split(",") if s.strip()] + interests = [s.strip() for s in (request.POST.get("interests") or "").split(",") if s.strip()] + api.create_my_profile(request, gender, city, languages, interests) + messages.success(request, "Профиль создан") + return redirect("cabinet") + elif action == "update_name": + full_name = (request.POST.get("full_name") or "").strip() + if not full_name: + messages.error(request, "Имя не может быть пустым") + else: + api.update_user_me(request, request.session.get("user_id"), full_name=full_name) + request.session["user_full_name"] = full_name + request.session.modified = True + messages.success(request, "Имя обновлено") + return redirect("cabinet") + except PermissionDenied: + messages.error(request, "Сессия истекла, войдите снова") + return redirect("login") + except ApiError as e: + messages.error(request, f"Ошибка: {e}") + + ctx = { + "has_profile": has_profile, + "profile": profile, + } + return render(request, "ui/cabinet.html", ctx) + +@require_POST +def cabinet_upload_photo(request): + if not _is_logged(request): + messages.info(request, "Войдите, чтобы загрузить фото") + return redirect("login") + f = request.FILES.get("photo") + if not f: + messages.error(request, "Файл не выбран") + return redirect("cabinet") + if f.size and f.size > 5 * 1024 * 1024: + messages.error(request, "Файл слишком большой (макс. 5 МБ)") + return redirect("cabinet") + try: + api.upload_my_photo(request, f) + messages.success(request, "Фото обновлено") + except PermissionDenied: + messages.error(request, "Сессия истекла, войдите снова") + return redirect("login") + except ApiError as e: + if e.status in (404, 405): + messages.error(request, "Бэкенд пока не поддерживает загрузку фото.") + else: + messages.error(request, f"Ошибка загрузки: {e}") + return redirect("cabinet") + +@require_POST +def cabinet_delete_photo(request): + if not _is_logged(request): + messages.info(request, "Войдите, чтобы удалить фото") + return redirect("login") + try: + api.delete_my_photo(request) + messages.success(request, "Фото удалено") + except PermissionDenied: + messages.error(request, "Сессия истекла, войдите снова") + return redirect("login") + except ApiError as e: + if e.status in (404, 405): + messages.error(request, "Удаление фото не поддерживается бэкендом.") + else: + messages.error(request, f"Ошибка удаления: {e}") + return redirect("cabinet") diff --git a/scripts/patch_frontend.sh b/scripts/patch_frontend.sh new file mode 100755 index 0000000..a656d96 --- /dev/null +++ b/scripts/patch_frontend.sh @@ -0,0 +1,1031 @@ +#!/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" diff --git a/templates/ui/cabinet.html b/templates/ui/cabinet.html index 5d4a4bb..33e05a2 100644 --- a/templates/ui/cabinet.html +++ b/templates/ui/cabinet.html @@ -1,4 +1,4 @@ -{% load static %} +{% load static ui_extras %} @@ -17,42 +17,37 @@ .card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; box-shadow: 0 1px 2px rgba(0,0,0,.03); } .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 label { font-weight:600; font-size:14px; } - .form input[type="text"], .form select, .form textarea { - width:100%; border:1px solid #d1d5db; border-radius:8px; padding:10px 12px; font:inherit; background:#fff; - } - .form small { color:#6b7280; } + .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 transparent; font-weight:600; cursor:pointer; } - .btn-primary { background:#2563eb; color:#fff; } + .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; } - dl { display:grid; grid-template-columns: 200px 1fr; gap:8px 14px; margin: 0; } - dt { font-weight:600; color:#374151; } - dd { margin:0; color:#111827; } - .pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; } - details summary { cursor:pointer; } - code, pre { background:#111827; color:#e5e7eb; padding:10px 12px; border-radius:10px; display:block; overflow:auto; }
    - {% with header_name=header_name|default:request.session.user_full_name|default:request.session.user_email %} - Здравствуйте, {{ header_name }}! - {% endwith %} + Здравствуйте, {{ request.session.user_full_name|default:request.session.user_email }}!
    @@ -75,118 +70,129 @@ {% endif %} -
    - +

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

    -
    -
    Имя
    -
    {{ request.session.user_full_name|default:"—" }}
    +
    +
    + {% 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" }}
    +
    +
    -
    Email
    -
    {{ request.session.user_email|default:"—" }}
    - -
    Роль
    -
    {{ request.session.user_role|default:"—" }}
    - -
    ID пользователя
    -
    {{ request.session.user_id|default:"—" }}
    -
    +
    + {% csrf_token %} + + + +
    + +
    +
    - -
    -

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

    - - {% if has_profile and profile %} -
    -
    Пол
    -
    {{ 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 }}
    -
    - -
    - Показать сырой JSON профиля -
    {{ profile|safe }}
    -
    - - {% else %} -

    Профиль ещё не создан. Заполните форму ниже.

    - {% endif %} -
    -
    + {% if has_profile and profile %} +
    +

    Фото профиля

    +
    +
    + {% if profile.photo_url %} + + {% elif profile.photo %} + + {% elif request.session.user_email %} + + {% else %} +
    + {% endif %} +
    +
    + {% csrf_token %} + + +
    +
    +

    Поддерживается multipart загрузка изображения в поле file. Эндпоинт: /profiles/v1/profiles/me/photo. :contentReference[oaicite:4]{index=4}

    +
    +{% endif %} {% if not has_profile or not profile %} -

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

    -
    + {% csrf_token %} -
    + + +
    - +
    - - Несколько — через запятую: ru,en + + Несколько — через запятую
    - - Несколько — через запятую: music,travel + + Несколько — через запятую
    - Сбросить + Сбросить
    + {% 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 %} - diff --git a/templates/ui/components/like_button.html b/templates/ui/components/like_button.html index 34c9d76..44bb1a9 100644 --- a/templates/ui/components/like_button.html +++ b/templates/ui/components/like_button.html @@ -1,17 +1,5 @@ -{# expects: profile_id, liked: bool #} -
    - {% csrf_token %} - {% if liked %} - - {% else %} - - {% endif %} -
    +{% if liked %} + +{% else %} + +{% endif %} diff --git a/templates/ui/components/like_button_login_required.html b/templates/ui/components/like_button_login_required.html index 09b5307..f7a0528 100644 --- a/templates/ui/components/like_button_login_required.html +++ b/templates/ui/components/like_button_login_required.html @@ -1,3 +1 @@ - - Войти, чтобы добавить - + diff --git a/templates/ui/index.html b/templates/ui/index.html index 1adf362..90c397a 100644 --- a/templates/ui/index.html +++ b/templates/ui/index.html @@ -1,31 +1,36 @@ -{% extends 'base.html' %} -{% block title %}Главная — MatchAgency{% endblock %} -{% block content %} -
    -
    -

    Подбор идеальных пар под ключ

    -

    Фронтенд полностью на API: ни одной локальной таблицы.

    -
    - Смотреть анкеты - {% if not api_user %} - Войти - {% endif %} -
    -
    -
    -
    - - - - - - -
    -
    -
    -{% endblock %} +{% load static %} + + + + + Главная + + + + + +
    +
    Agency Frontend
    + +
    +
    +

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

    +

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

    +

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

    +
    + + diff --git a/templates/ui/login.html b/templates/ui/login.html index c53d140..b90e104 100644 --- a/templates/ui/login.html +++ b/templates/ui/login.html @@ -1,27 +1,43 @@ -{% extends 'base.html' %} -{% block title %}Вход — MatchAgency{% endblock %} -{% block content %} -
    -

    Вход

    -
    - {% csrf_token %} -
    - - -
    -
    - - -
    -
    - - -
    - {% if error_message %} -

    {{ error_message }}

    +{% load static %} + + + + + Вход + + + + + +
    +

    Вход

    + + {% if messages %} +
      + {% for message in messages %} +
    • {{ message }}
    • + {% endfor %} +
    {% endif %} -

    Нет аккаунта? Зарегистрироваться

    - - -
    -{% endblock %} + +
    + {% csrf_token %} + + + +
    + +

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

    +
    + + diff --git a/templates/ui/profile_detail.html b/templates/ui/profile_detail.html index 20faeec..6258685 100644 --- a/templates/ui/profile_detail.html +++ b/templates/ui/profile_detail.html @@ -1,4 +1,4 @@ -{% load static %} +{% load static ui_extras %} @@ -10,18 +10,24 @@ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans", sans-serif; margin:0; background:#f7f7fb; color:#111; } .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; } + .container { max-width:960px; margin:24px auto; padding:0 16px; } .card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:18px; } .row { display:flex; gap:18px; align-items:center; } .grow { flex:1 1 auto; } .muted { color:#6b7280; font-size:14px; } .pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; } - .avatar { width:96px; height:96px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:32px; background:#e5e7eb; color:#374151; } - .avatar img { width:96px; height:96px; object-fit:cover; border-radius:50%; display:block; } + .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; } + + /* Детальные списки */ dl { display:grid; grid-template-columns: 220px 1fr; gap:8px 14px; margin:0; } dt { font-weight:600; color:#374151; } dd { margin:0; color:#111827; } - .btn { display:inline-block; padding:9px 12px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; text-decoration:none; color:#111; } + .pill-wrap { display:flex; flex-wrap:wrap; gap:6px; } + .grid { display:grid; gap:16px; } + .grid-2 { grid-template-columns: 1fr 1fr; } + @media (max-width: 800px) { .grid-2 { grid-template-columns: 1fr; } } @@ -29,29 +35,40 @@
    Карточка пользователя (ADMIN)
    -
    + +
    - {% if profile.photo %} + {% if profile.photo_url %} + + {% elif profile.photo %} + {% elif profile.email %} + {% else %} - {{ profile.name|first|upper }} + {{ profile.name|initial }} {% endif %}
    {{ profile.name }}
    - +
    ID пользователя: {{ profile.id }}
    + {% if profile.email %} +
    Email: {{ profile.email }}
    + {% endif %} + {% if profile.role %} +
    Роль: {{ profile.role }}
    + {% endif %}
    -
    + {% csrf_token %} {% include "ui/components/like_button.html" with profile_id=profile.id liked=liked %}
    @@ -59,24 +76,57 @@
    -
    -

    Профиль

    -
    -
    Пол
    {{ 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 %} -
    -
    + +
    + +
    +

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

    +
    +
    Имя
    {{ profile.name|default:"—" }}
    +
    Email
    {{ profile.email|default:"—" }}
    +
    Роль
    {{ profile.role|default:"—" }}
    +
    Статус
    {% if profile.verified %}ACTIVE{% else %}INACTIVE{% endif %}
    +
    ID пользователя
    {{ profile.id|default:"—" }}
    +
    +
    + + +
    +

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

    +
    +
    Пол
    {{ 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 %} +
    +
    О себе
    {{ profile.about|default:"—" }}
    +
    Возраст
    {{ profile.age|default:"—" }}
    +
    ID профиля
    {{ profile.profile_id|default:profile.id|default:"—" }}
    +
    ID пользователя (в профиле)
    {{ profile.user_id|default:"—" }}
    +
    Фото (URL)
    + {% if profile.photo_url %}{{ profile.photo_url }} + {% elif profile.photo %}{{ profile.photo }} + {% else %} — {% endif %} +
    +
    +

    + Поля профиля основаны на контракте ProfileOut (gender, city, languages, interests, id, user_id). + Если это чужой пользователь, сервер может не возвращать профиль, поэтому часть значений будет пустой. :contentReference[oaicite:1]{index=1} +

    +
    diff --git a/templates/ui/profiles_list.html b/templates/ui/profiles_list.html index ac435ba..0774358 100644 --- a/templates/ui/profiles_list.html +++ b/templates/ui/profiles_list.html @@ -1,4 +1,4 @@ -{% load static %} +{% load static ui_extras %} @@ -11,6 +11,7 @@ .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; } @@ -18,43 +19,37 @@ .messages li.info { background:#eff6ff; color:#1e40af; border:1px solid #bfdbfe; } .filters { display:grid; grid-template-columns: repeat(8, 1fr); gap:10px; background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:14px; } - .filters .full { grid-column: 1 / -1; } - .filters input[type="text"], .filters select { - width:100%; border:1px solid #d1d5db; border-radius:8px; padding:8px 10px; font:inherit; background:#fff; + .filters input[type="text"], .filters select { width:100%; border:1px solid #d1d5db; border-radius:8px; padding:8px 10px; font:inherit; background:#fff; } + + /* Фото‑карточки */ + .list { margin-top:16px; display:grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap:14px; } + .card-photo { position:relative; display:block; border-radius:14px; overflow:hidden; border:1px solid #e5e7eb; background:#e5e7eb; } + .card-photo img { width:100%; height:280px; object-fit:cover; display:block; } + .card-photo__overlay { + position:absolute; left:0; right:0; bottom:0; + padding:12px 14px; + background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.65) 90%); + color:#fff; } - .btn { display:inline-block; padding:9px 12px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; } - .btn-primary { background:#2563eb; color:#fff; border-color:#2563eb; } - - .list { margin-top:16px; display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:14px; } - .card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:14px; } - .row { display:flex; align-items:center; gap:12px; } - .grow { flex:1 1 auto; } - .muted { color:#6b7280; font-size:14px; } - .pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; } - - .meta { margin-top:8px; display:flex; flex-wrap:wrap; gap:10px 18px; } - .meta .k { color:#6b7280; } - .meta .v { color:#111827; font-weight:600; } + .card-photo__title { font-weight:700; font-size:18px; text-shadow:0 1px 2px rgba(0,0,0,.4); } .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 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 { padding:9px 12px; border-radius:10px; border:1px solid #d1d5db; background:#fff; cursor:pointer; font-weight:600; } + .muted { color:#6b7280; font-size:14px; } + .pill { display:inline-block; padding:4px 10px; border-radius:999px; background:#eef2ff; color:#3730a3; font-size:12px; margin:2px 6px 2px 0; }
    -
    - Каталог анкет (ADMIN) -
    +
    Каталог анкет (ADMIN)
    @@ -68,7 +63,8 @@ {% endif %} -
    + +
    @@ -81,25 +77,12 @@
    -
    - - -
    -
    - - -
    @@ -115,7 +98,7 @@
    - +
    @@ -139,33 +122,25 @@
  • {{ error }}
  • {% endif %} +
    {% for p in profiles %} -
    -
    -
    -
    {{ p.name }}
    - -
    - + {% empty %}
    Ничего не найдено. Попробуйте изменить фильтры.
    @@ -190,6 +165,5 @@
    - diff --git a/templates/ui/register.html b/templates/ui/register.html index a012534..987e18e 100644 --- a/templates/ui/register.html +++ b/templates/ui/register.html @@ -1,24 +1,44 @@ -{% extends 'base.html' %} -{% block title %}Регистрация — MatchAgency{% endblock %} -{% block content %} -
    -

    Регистрация

    -
    - {% csrf_token %} -
    - - -
    -
    - - -
    -
    - - -
    - -
    -

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

    -
    -{% endblock %} +{% load static %} + + + + + Регистрация + + + + + +
    +

    Регистрация

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

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

    +
    + + diff --git a/ui/api.py b/ui/api.py index 6ec01f3..ebb1dc5 100644 --- a/ui/api.py +++ b/ui/api.py @@ -1,10 +1,7 @@ import logging import os -import os.path -import json -import time import uuid -from typing import Any, Dict, Optional, Tuple, List, Union +from typing import Any, Dict, Optional, Tuple, List, Union, IO import requests from django.conf import settings @@ -12,189 +9,111 @@ from django.core.exceptions import PermissionDenied logger = logging.getLogger(__name__) -# ===== Логирование / флаги ===== -API_DEBUG = os.environ.get('API_DEBUG', '1') == '1' -API_LOG_BODY_MAX = int(os.environ.get('API_LOG_BODY_MAX', '2000')) -API_LOG_HEADERS = os.environ.get('API_LOG_HEADERS', '1') == '1' -API_LOG_CURL = os.environ.get('API_LOG_CURL', '0') == '1' +# === Конфиг лога (можно подкрутить через .env): =============================== +API_DEBUG = bool(int(os.environ.get("API_DEBUG", getattr(settings, "API_DEBUG", 1)))) +API_LOG_BODY_MAX = int(os.environ.get("API_LOG_BODY_MAX", getattr(settings, "API_LOG_BODY_MAX", 2000))) +API_LOG_HEADERS = bool(int(os.environ.get("API_LOG_HEADERS", getattr(settings, "API_LOG_HEADERS", 1)))) +API_LOG_CURL = bool(int(os.environ.get("API_LOG_CURL", getattr(settings, "API_LOG_CURL", 1)))) +API_FALLBACK_OPENAPI_ON_404 = bool(int(os.environ.get("API_FALLBACK_OPENAPI_ON_404", getattr(settings, "API_FALLBACK_OPENAPI_ON_404", 1)))) -# Переключение базы при 404: сначала servers[0].url из openapi.json, потом жёстко http://localhost:8080 -API_FALLBACK_OPENAPI_ON_404 = os.environ.get('API_FALLBACK_OPENAPI_ON_404', '1') == '1' +# === База API и пути по умолчанию (OpenAPI v1): =============================== +def _from_settings_or_env(name: str, default: Optional[str] = None) -> Optional[str]: + return getattr(settings, name, None) or os.environ.get(name, None) or default -SENSITIVE_KEYS = {'password', 'refresh_token', 'access_token', 'authorization', 'token', 'api_key'} +_API_BASE_PRIMARY = (_from_settings_or_env("API_BASE_URL", None) or "http://localhost:8080").rstrip("/") +_API_BASE_CACHE = None # текущая выбранная база +_API_LAST_SELECT_SRC = "ENV/SETTINGS" - -def _sanitize(obj: Any) -> Any: - try: - if isinstance(obj, dict): - return {k: ('***' if isinstance(k, str) and k.lower() in SENSITIVE_KEYS else _sanitize(v)) - for k, v in obj.items()} - if isinstance(obj, list): - return [_sanitize(x) for x in obj] - return obj - except Exception: - return obj - - -def _shorten(s: str, limit: int) -> str: - if s is None: - return '' - return s if len(s) <= limit else (s[:limit] + f'... ') - - -def _build_curl(method: str, url: str, headers: Dict[str, str], - params: Optional[dict], json_body: Optional[dict], data: Optional[dict]) -> str: - parts = [f"curl -X '{method.upper()}' \\\n '{url}'"] - for k, v in headers.items(): - if k.lower() == 'authorization': - v = 'Bearer ***' - parts.append(f" -H '{k}: {v}'") - if json_body is not None: - parts.append(" -H 'Content-Type: application/json'") - try: - body_str = json.dumps(_sanitize(json_body), ensure_ascii=False) - except Exception: - body_str = str(_sanitize(json_body)) - parts.append(f" -d '{body_str}'") - elif data is not None: - try: - body_str = json.dumps(_sanitize(data), ensure_ascii=False) - except Exception: - body_str = str(_sanitize(data)) - parts.append(f" --data-raw '{body_str}'") - return " \\\n".join(parts) - - -# ===== Пути эндпоинтов (как в swagger) ===== EP_DEFAULTS: Dict[str, str] = { - # Auth - 'AUTH_REGISTER_PATH': '/auth/v1/register', - 'AUTH_TOKEN_PATH': '/auth/v1/token', - 'AUTH_REFRESH_PATH': '/auth/v1/refresh', - 'ME_PATH': '/auth/v1/me', - 'USERS_LIST_PATH': '/auth/v1/users', - 'USER_DETAIL_PATH': '/auth/v1/users/{user_id}', - - # Profiles - 'PROFILE_ME_PATH': '/profiles/v1/profiles/me', - 'PROFILES_CREATE_PATH': '/profiles/v1/profiles', - 'PROFILE_PHOTO_UPLOAD_PATH': '/profiles/v1/profiles/me/photo', - 'PROFILE_PHOTO_DELETE_PATH': '/profiles/v1/profiles/me/photo', - - # Pairs - 'PAIRS_PATH': '/match/v1/pairs', - 'PAIR_DETAIL_PATH': '/match/v1/pairs/{pair_id}', - 'PAIR_ACCEPT_PATH': '/match/v1/pairs/{pair_id}/accept', - 'PAIR_REJECT_PATH': '/match/v1/pairs/{pair_id}/reject', - - # Chat - 'ROOMS_PATH': '/chat/v1/rooms', - 'ROOM_DETAIL_PATH': '/chat/v1/rooms/{room_id}', - 'ROOM_MESSAGES_PATH': '/chat/v1/rooms/{room_id}/messages', - - # Payments - 'INVOICES_PATH': '/payments/v1/invoices', - 'INVOICE_DETAIL_PATH': '/payments/v1/invoices/{inv_id}', - 'INVOICE_MARK_PAID_PATH': '/payments/v1/invoices/{inv_id}/mark-paid', + # AUTH + "AUTH_REGISTER_PATH": "/auth/v1/register", + "AUTH_TOKEN_PATH": "/auth/v1/token", + "AUTH_REFRESH_PATH": "/auth/v1/refresh", + "AUTH_ME_PATH": "/auth/v1/me", + # USERS (admin and/or owner) + "USERS_LIST_PATH": "/auth/v1/users", + "USER_DETAIL_PATH": "/auth/v1/users/{user_id}", + # PROFILES + "PROFILE_ME_PATH": "/profiles/v1/profiles/me", + "PROFILE_CREATE_PATH": "/profiles/v1/profiles", + # Optional, если сервер будет поддерживать: + "PROFILE_ME_PATCH_PATH": "", # пусто → нет на бэкенде + "PROFILE_PHOTO_UPLOAD_PATH": "", # "/profiles/v1/profiles/me/photo" + "PROFILE_PHOTO_DELETE_PATH": "", # "/profiles/v1/profiles/me/photo" } -def EP(key: str) -> str: - return os.environ.get(f'API_{key}', EP_DEFAULTS[key]) +def EP(key: str, **fmt) -> str: + # при желании можно переопределить путями в settings или ENV: API_ + override = _from_settings_or_env(f"API_{key}", None) + path = (override or EP_DEFAULTS[key]).format(**fmt) + if not path.startswith("/"): + path = "/" + path + return path - -# ===== База API с авто‑детектом ===== -_API_BASE_CACHE: Optional[str] = None -_API_LAST_SELECT_SRC = 'DEFAULT' # для логов - -def _detect_api_base_from_openapi() -> Optional[str]: - """ - Берём servers[0].url из openapi.json (по схеме — http://localhost:8080). - """ - candidates = [ - os.environ.get('API_SPEC_PATH'), - getattr(settings, 'API_SPEC_PATH', None), - os.path.join(getattr(settings, 'BASE_DIR', ''), 'openapi.json') if getattr(settings, 'BASE_DIR', None) else None, - os.path.join(getattr(settings, 'BASE_DIR', ''), 'agency', 'openapi.json') if getattr(settings, 'BASE_DIR', None) else None, - '/mnt/data/openapi.json', - ] - for p in candidates: - if p and os.path.isfile(p): - try: - with open(p, 'r', encoding='utf-8') as f: - spec = json.load(f) - servers = spec.get('servers') or [] - if servers and isinstance(servers[0], dict): - url = servers[0].get('url') - if url: - return url - except Exception as e: - if API_DEBUG: - logger.debug('API: cannot read OpenAPI from %s: %s', p, e) - return None - - -def _get_api_base_url() -> str: - """ - Источники (по приоритету): - 1) ENV/Settings: API_BASE_URL, затем BASE_URL - 2) servers[0].url из openapi.json - 3) 'http://localhost:8080' - """ +def _get_api_base() -> str: global _API_BASE_CACHE, _API_LAST_SELECT_SRC if _API_BASE_CACHE: return _API_BASE_CACHE - - base = (os.environ.get('API_BASE_URL') or getattr(settings, 'API_BASE_URL', '') - or os.environ.get('BASE_URL') or getattr(settings, 'BASE_URL', '')) - if base: - _API_BASE_CACHE = base.rstrip('/') - _API_LAST_SELECT_SRC = 'ENV/SETTINGS' - else: - detected = _detect_api_base_from_openapi() - if detected: - _API_BASE_CACHE = detected.rstrip('/') - _API_LAST_SELECT_SRC = 'OPENAPI' - else: - _API_BASE_CACHE = 'http://localhost:8080' - _API_LAST_SELECT_SRC = 'HARDCODED' - + # пробуем взять из ENV/SETTINGS + _API_BASE_CACHE = _API_BASE_PRIMARY + _API_LAST_SELECT_SRC = "ENV/SETTINGS" if API_DEBUG: logger.info("API base selected [%s]: %s", _API_LAST_SELECT_SRC, _API_BASE_CACHE) return _API_BASE_CACHE +def _set_api_base(new_base: str): + global _API_BASE_CACHE, _API_LAST_SELECT_SRC + if new_base and new_base.rstrip("/") != _API_BASE_CACHE: + old = _API_BASE_CACHE + _API_BASE_CACHE = new_base.rstrip("/") + _API_LAST_SELECT_SRC = "FALLBACK" + if API_DEBUG: + logger.warning("API BASE SWITCHED: %s → %s", old, _API_BASE_CACHE) +# === Исключение API: ========================================================== class ApiError(Exception): - def __init__(self, status: int, message: str = 'API error', payload: Optional[dict] = None, req_id: Optional[str] = None): - if req_id and message and 'req_id=' not in message: - message = f"{message} (req_id={req_id})" + def __init__(self, status: int, message: str = "API error", payload: Optional[dict] = None): super().__init__(message) self.status = status self.payload = payload or {} - self.req_id = req_id - -def _base_headers(request, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: - """ - Достаём токен сначала из сессии, затем из куки — чтобы «куки‑режим» работал без доп. настроек. - """ - headers: Dict[str, str] = {'Accept': 'application/json'} - token = request.session.get('access_token') or request.COOKIES.get('access_token') +# === Вспомогательные: ========================================================= +def _headers(request, *, extra: Optional[Dict[str, str]] = None, json_mode: bool = True) -> Dict[str, str]: + h = {"Accept": "application/json"} + if json_mode: + h["Content-Type"] = "application/json" + # Токен: из сессии или из cookie (HTTPCookie) + token = request.session.get("access_token") or request.COOKIES.get("access_token") if token: - headers['Authorization'] = f'Bearer {token}' - api_key = getattr(settings, 'API_KEY', '') or os.environ.get('API_KEY', '') + h["Authorization"] = f"Bearer {token}" + api_key = getattr(settings, "API_KEY", "") or os.environ.get("API_KEY", "") if api_key: - headers['X-API-Key'] = api_key + h["X-API-Key"] = api_key if extra: - headers.update(extra) - return headers - + h.update(extra) + return h def _url(path: str) -> str: - base = _get_api_base_url() - path = path if path.startswith('/') else '/' + path + base = _get_api_base() + path = path if path.startswith("/") else "/" + path return base + path +def _log_curl(method: str, url: str, headers: Dict[str, str], body: Optional[Union[dict, str]]): + if not API_LOG_CURL: + return + parts = ["curl", "-X", method.upper(), f"'{url}'"] + if headers: + for k, v in headers.items(): + if k.lower() == "authorization": + v = "*****" + parts += ["-H", f"'{k}: {v}'"] + if body is not None and body != {}: + import json as _json + b = body if isinstance(body, str) else _json.dumps(body, ensure_ascii=False) + parts += ["-d", f"'{b}'"] + logger.debug("CURL> %s", " ".join(parts)) +# === Базовый HTTP вызов с ретраями/refresh/fallback: ========================== def request_api( request, method: str, @@ -203,325 +122,184 @@ def request_api( params: Optional[dict] = None, json: Optional[dict] = None, files: Optional[dict] = None, - data: Optional[dict] = None, ) -> Tuple[int, Any]: - """ - Универсальный HTTP-вызов (упрощённая версия): - - токен/ключ в заголовках (токен берём из сессии или куки), - - auto-refresh при 401 (refresh берём тоже из сессии или куки), - - 404 → переключаем базу: servers[0].url из openapi.json → 'http://localhost:8080', - - подробные логи; БЕЗ «retry со слэшем». - """ - global _API_BASE_CACHE, _API_LAST_SELECT_SRC - - req_id = uuid.uuid4().hex[:8] - base_before = _get_api_base_url() + rid = uuid.uuid4().hex[:8] url = _url(path) + json_mode = files is None + headers = _headers(request, json_mode=json_mode) - def _do(_url: str): - headers = _base_headers(request) - if json is not None and files is None and data is None: - headers['Content-Type'] = 'application/json' + if API_DEBUG: + b_preview = ("***" if json and "password" in (json or {}) else json) + if isinstance(b_preview, dict) and "password" in (b_preview or {}): + b_preview = dict(b_preview); b_preview["password"] = "***" + logger.info("API[req_id=%s] REQUEST %s %s params=%s headers=%s body=%s", + rid, method.upper(), url, params, (headers if API_LOG_HEADERS else "{…}"), + (str(b_preview)[:API_LOG_BODY_MAX] if b_preview else None)) + _log_curl(method, url, headers, b_preview) - if API_DEBUG: - log_headers = _sanitize(headers) if API_LOG_HEADERS else {} - log_body = _sanitize(json if json is not None else data) - if API_LOG_CURL: - try: - curl = _build_curl(method, _url, headers, params, json, data) - logger.debug("API[req_id=%s] cURL:\n%s", req_id, curl) - except Exception: - pass - logger.info( - "API[req_id=%s] REQUEST %s %s params=%s headers=%s body=%s", - req_id, method.upper(), _url, _sanitize(params), log_headers, log_body - ) - - t0 = time.time() + try: resp = requests.request( method=method.upper(), - url=_url, + url=url, headers=headers, params=params, - json=json, - data=data, - files=files, - timeout=float(getattr(settings, 'API_TIMEOUT', 8.0)), + json=json if json_mode else None, + files=files if not json_mode else None, + timeout=float(getattr(settings, "API_TIMEOUT", os.environ.get("API_TIMEOUT", 6.0))), ) - dt = int((time.time() - t0) * 1000) - - content_type = resp.headers.get('Content-Type', '') - try: - payload = resp.json() if 'application/json' in content_type else {} - except ValueError: - payload = {} - - if API_DEBUG: - body_str = "" - try: - body_str = json.dumps(_sanitize(payload), ensure_ascii=False) - except Exception: - body_str = str(_sanitize(payload)) - headers_out = _sanitize(dict(resp.headers)) if API_LOG_HEADERS else {} - logger.info( - "API[req_id=%s] RESPONSE %s %sms ct=%s headers=%s body=%s", - req_id, resp.status_code, dt, content_type, headers_out, _shorten(body_str, API_LOG_BODY_MAX) - ) - - return resp, payload - - # 1) Первый запрос - try: - resp, payload = _do(url) except requests.RequestException as e: - logger.exception('API[req_id=%s] network error: %s', req_id, e) - raise ApiError(0, f'Network unavailable or timeout when accessing API ({e})', req_id=req_id) + logger.exception("API network error: %s", e) + raise ApiError(0, f"Network unavailable or timeout when accessing API ({e})") - # 2) 404 → переключаем базу (openapi → 8080) и повторяем - if resp.status_code == 404: - candidates: List[tuple[str, str]] = [] - if API_FALLBACK_OPENAPI_ON_404: - detected = _detect_api_base_from_openapi() - if detected: - candidates.append((detected.rstrip('/'), 'OPENAPI(FAILOVER)')) - candidates.append(('http://localhost:8080', 'DEFAULT(FAILOVER)')) + content_type = resp.headers.get("Content-Type", "") + try: + data = resp.json() if "application/json" in content_type else {} + except ValueError: + data = {} - for cand_base, label in candidates: - if not cand_base or cand_base == _API_BASE_CACHE: - continue - if API_DEBUG: - logger.warning("API[req_id=%s] 404 on base %s → switch API base to %s and retry", - req_id, _API_BASE_CACHE, cand_base) - _API_BASE_CACHE = cand_base - _API_LAST_SELECT_SRC = label - try: - resp, payload = _do(_url(path)) - if resp.status_code != 404: - break - except requests.RequestException: - continue + if API_DEBUG: + b_preview = data + logger.info("API[req_id=%s] RESPONSE %s %sms ct=%s headers=%s body=%s", + rid, resp.status_code, int(resp.elapsed.total_seconds()*1000), + content_type, (dict(resp.headers) if API_LOG_HEADERS else "{…}"), + (str(b_preview)[:API_LOG_BODY_MAX] if b_preview else None)) - # 3) 401 → refresh и повтор - refresh_token = request.session.get('refresh_token') or request.COOKIES.get('refresh_token') - if resp.status_code == 401 and refresh_token: + # авто-ретрай на 401: пробуем refresh + if resp.status_code == 401 and (request.session.get("refresh_token") or request.COOKIES.get("refresh_token")): if API_DEBUG: - logger.info("API[req_id=%s] 401 → try refresh token", req_id) + logger.info("API[req_id=%s] 401 → try refresh token", rid) try: - refresh_url = _url(EP('AUTH_REFRESH_PATH')) - refresh_body = {'refresh_token': refresh_token} - logger.info("API[req_id=%s] REFRESH POST %s body=%s", req_id, refresh_url, _sanitize(refresh_body)) - refresh_resp = requests.post(refresh_url, json=refresh_body, timeout=float(getattr(settings, 'API_TIMEOUT', 8.0))) - if refresh_resp.status_code == 200: - try: - rj = refresh_resp.json() - except ValueError: - rj = {} - if rj.get('access_token'): - request.session['access_token'] = rj['access_token'] - if rj.get('refresh_token'): - request.session['refresh_token'] = rj['refresh_token'] + refresh_json = {"refresh_token": request.session.get("refresh_token") or request.COOKIES.get("refresh_token")} + r = requests.post(_url(EP("AUTH_REFRESH_PATH")), json=refresh_json, timeout=float(getattr(settings, "API_TIMEOUT", 6.0))) + if r.status_code == 200: + tokens = r.json() + request.session["access_token"] = tokens.get("access_token") + request.session["refresh_token"] = tokens.get("refresh_token") request.session.modified = True if API_DEBUG: - logger.info("API[req_id=%s] REFRESH OK → retry original request", req_id) - resp, payload = _do(_url(path)) + logger.info("API[req_id=%s] REFRESH OK → retry original request", rid) + # повторяем исходный + headers = _headers(request, json_mode=json_mode) + resp = requests.request( + method=method.upper(), url=url, headers=headers, + params=params, json=json if json_mode else None, files=files if not json_mode else None, + timeout=float(getattr(settings, "API_TIMEOUT", 6.0)), + ) + content_type = resp.headers.get("Content-Type", "") + data = resp.json() if "application/json" in content_type else {} else: - logger.warning("API[req_id=%s] REFRESH failed: %s", req_id, refresh_resp.status_code) + if API_DEBUG: + logger.warning("API[req_id=%s] REFRESH FAIL %s body=%s", rid, r.status_code, r.text[:200]) except requests.RequestException as e: - logger.exception('API[req_id=%s] Refresh token network error: %s', req_id, e) - raise ApiError(401, f'Token refresh failed ({e})', req_id=req_id) + logger.exception("Refresh token error: %s", e) + raise ApiError(401, f"Token refresh failed ({e})") - # 4) Ошибки + # fallback базы на 404 (например, локальный gateway vs 8080) + if resp.status_code == 404 and API_FALLBACK_OPENAPI_ON_404: + # пробуем со слешем + if not url.endswith("/"): + url_slash = url + "/" + if API_DEBUG: + logger.info("API[req_id=%s] 404 → retry with trailing slash", rid) + resp2 = requests.request(method=method.upper(), url=url_slash, headers=headers, + params=params, json=json if json_mode else None, files=files if not json_mode else None, + timeout=float(getattr(settings, "API_TIMEOUT", 6.0))) + content_type2 = resp2.headers.get("Content-Type", "") + data2 = resp2.json() if "application/json" in content_type2 else {} + if resp2.status_code != 404: + resp, data, content_type = resp2, data2, content_type2 + # если всё ещё 404 и текущая база не 8080 — переключаемся и повторяем + if resp.status_code == 404 and _get_api_base() != "http://localhost:8080": + if API_DEBUG: + logger.warning("API[req_id=%s] 404 on base %s → switch API base to http://localhost:8080 and retry", + rid, _get_api_base()) + _set_api_base("http://localhost:8080") + url = _url(path) + headers = _headers(request, json_mode=json_mode) + resp = requests.request(method=method.upper(), url=url, headers=headers, + params=params, json=json if json_mode else None, files=files if not json_mode else None, + timeout=float(getattr(settings, "API_TIMEOUT", 6.0))) + content_type = resp.headers.get("Content-Type", "") + try: + data = resp.json() if "application/json" in content_type else {} + except ValueError: + data = {} + + # финальная проверка кода if not (200 <= resp.status_code < 300): - msg = None - if isinstance(payload, dict): - msg = payload.get('detail') or payload.get('message') - msg = msg or f'API error: {resp.status_code}' + msg = data.get("detail") or data.get("message") or f"API error: {resp.status_code}" if resp.status_code in (401, 403): - # PermissionDenied обрабатываем во view (не всегда это «выйти и войти заново») - raise PermissionDenied(f"{msg} (req_id={req_id})") - raise ApiError(resp.status_code, msg, payload if isinstance(payload, dict) else {}, req_id=req_id) + raise PermissionDenied(msg) + raise ApiError(resp.status_code, msg, data) - # 5) База сменилась — отметим - base_after = _get_api_base_url() - if API_DEBUG and base_before != base_after: - logger.warning("API[req_id=%s] BASE SWITCHED: %s → %s", req_id, base_before, base_after) + return resp.status_code, data - return resp.status_code, payload +# === High-level helpers в соответствии с OpenAPI v1 ========================== - -# ========================== -# AUTH -# ========================== - -def register_user(request, email: str, password: str, full_name: Optional[str] = None, role: str = 'CLIENT') -> Dict[str, Any]: - body = {'email': email, 'password': password, 'full_name': full_name, 'role': role} - _, data = request_api(request, 'POST', EP('AUTH_REGISTER_PATH'), json=body) +def register_user(request, email: str, password: str, full_name: Optional[str] = None, role: str = "CLIENT") -> Dict[str, Any]: + body = {"email": email, "password": password, "full_name": full_name, "role": role} + _, data = request_api(request, "POST", EP("AUTH_REGISTER_PATH"), json=body) return data # UserRead def login(request, email: str, password: str) -> Dict[str, Any]: - body = {'email': email, 'password': password} - _, data = request_api(request, 'POST', EP('AUTH_TOKEN_PATH'), json=body) + body = {"email": email, "password": password} + _, data = request_api(request, "POST", EP("AUTH_TOKEN_PATH"), json=body) return data # TokenPair -def get_current_user(request) -> Dict[str, Any]: - _, data = request_api(request, 'GET', EP('ME_PATH')) +def get_me(request) -> Dict[str, Any]: + _, data = request_api(request, "GET", EP("AUTH_ME_PATH")) return data # UserRead -def list_users(request, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]: - params = {'offset': offset, 'limit': limit} - _, data = request_api(request, 'GET', EP('USERS_LIST_PATH'), params=params) - return data +def list_users(request, offset: int = 0, limit: int = 50) -> List[Dict[str, Any]] | Dict[str, Any]: + params = {"offset": offset, "limit": min(max(limit, 1), 200)} + _, data = request_api(request, "GET", EP("USERS_LIST_PATH"), params=params) + return data # array[UserRead] (в схеме — массив) или {"items": [...]} если backend так возвращает def get_user(request, user_id: str) -> Dict[str, Any]: - path = EP('USER_DETAIL_PATH').format(user_id=user_id) - _, data = request_api(request, 'GET', path) - return data + _, data = request_api(request, "GET", EP("USER_DETAIL_PATH", user_id=user_id)) + return data # UserRead -def update_user(request, user_id: str, **fields) -> Dict[str, Any]: - path = EP('USER_DETAIL_PATH').format(user_id=user_id) - _, data = request_api(request, 'PATCH', path, json=fields) - return data - -def delete_user(request, user_id: str) -> None: - path = EP('USER_DETAIL_PATH').format(user_id=user_id) - request_api(request, 'DELETE', path) - - -# ========================== -# PROFILES -# ========================== +def update_user_me(request, user_id: str, *, full_name: Optional[str] = None, password: Optional[str] = None) -> Dict[str, Any]: + body: Dict[str, Any] = {} + if full_name is not None: + body["full_name"] = full_name + if password is not None: + body["password"] = password + _, data = request_api(request, "PATCH", EP("USER_DETAIL_PATH", user_id=user_id), json=body) + return data # UserRead def get_my_profile(request) -> Dict[str, Any]: - _, data = request_api(request, 'GET', EP('PROFILE_ME_PATH')) + _, data = request_api(request, "GET", EP("PROFILE_ME_PATH")) return data # ProfileOut def create_my_profile(request, gender: str, city: str, languages: List[str], interests: List[str]) -> Dict[str, Any]: - body = {'gender': gender, 'city': city, 'languages': languages, 'interests': interests} - _, data = request_api(request, 'POST', EP('PROFILES_CREATE_PATH'), json=body) + body = {"gender": gender, "city": city, "languages": languages, "interests": interests} + _, data = request_api(request, "POST", EP("PROFILE_CREATE_PATH"), json=body) return data # ProfileOut - -# ========================== -# PAIRS -# ========================== - -def create_pair(request, user_id_a: str, user_id_b: str, score: Optional[float] = None, notes: Optional[str] = None) -> Dict[str, Any]: - body = {'user_id_a': user_id_a, 'user_id_b': user_id_b, 'score': score, 'notes': notes} - _, data = request_api(request, 'POST', EP('PAIRS_PATH'), json=body) - return data # PairRead - -def list_pairs(request, for_user_id: Optional[str] = None, status: Optional[str] = None, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]: - params = {'for_user_id': for_user_id, 'status': status, 'offset': offset, 'limit': limit} - params = {k: v for k, v in params.items() if v is not None} - _, data = request_api(request, 'GET', EP('PAIRS_PATH'), params=params) +# Опционально: если сервер добавит PATCH /profiles/v1/profiles/me +def patch_my_profile(request, **fields) -> Dict[str, Any]: + patch_path = EP("PROFILE_ME_PATCH_PATH") + if not patch_path or patch_path.strip("/") == "": + raise ApiError(405, "Profile update endpoint is not available on backend") + _, data = request_api(request, "PATCH", patch_path, json=fields) return data -def get_pair(request, pair_id: str) -> Dict[str, Any]: - path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id) - _, data = request_api(request, 'GET', path) - return data - -def update_pair(request, pair_id: str, **fields) -> Dict[str, Any]: - path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id) - _, data = request_api(request, 'PATCH', path, json=fields) - return data - -def delete_pair(request, pair_id: str) -> None: - path = EP('PAIR_DETAIL_PATH').format(pair_id=pair_id) - request_api(request, 'DELETE', path) - -def accept_pair(request, pair_id: str) -> Dict[str, Any]: - path = EP('PAIR_ACCEPT_PATH').format(pair_id=pair_id) - _, data = request_api(request, 'POST', path) - return data - -def reject_pair(request, pair_id: str) -> Dict[str, Any]: - path = EP('PAIR_REJECT_PATH').format(pair_id=pair_id) - _, data = request_api(request, 'POST', path) - return data - - -# ========================== -# CHAT -# ========================== - -def my_rooms(request) -> Union[List[Dict[str, Any]], Dict[str, Any]]: - _, data = request_api(request, 'GET', EP('ROOMS_PATH')) - return data - -def get_room(request, room_id: str) -> Dict[str, Any]: - path = EP('ROOM_DETAIL_PATH').format(room_id=room_id) - _, data = request_api(request, 'GET', path) - return data - -def create_room(request, title: Optional[str] = None, participants: List[str] = []) -> Dict[str, Any]: - body = {'title': title, 'participants': participants} - _, data = request_api(request, 'POST', EP('ROOMS_PATH'), json=body) - return data - -def send_message(request, room_id: str, content: str) -> Dict[str, Any]: - body = {'content': content} - path = EP('ROOM_MESSAGES_PATH').format(room_id=room_id) - _, data = request_api(request, 'POST', path, json=body) - return data - -def list_messages(request, room_id: str, offset: int = 0, limit: int = 100) -> Union[List[Dict[str, Any]], Dict[str, Any]]: - params = {'offset': offset, 'limit': limit} - path = EP('ROOM_MESSAGES_PATH').format(room_id=room_id) - _, data = request_api(request, 'GET', path, params=params) - return data - - -# ========================== -# PAYMENTS -# ========================== - -def create_invoice(request, client_id: str, amount: float, currency: str, description: Optional[str] = None) -> Dict[str, Any]: - body = {'client_id': client_id, 'amount': amount, 'currency': currency, 'description': description} - _, data = request_api(request, 'POST', EP('INVOICES_PATH'), json=body) - return data - -def list_invoices(request, client_id: Optional[str] = None, status: Optional[str] = None, offset: int = 0, limit: int = 50) -> Union[List[Dict[str, Any]], Dict[str, Any]]: - params = {'client_id': client_id, 'status': status, 'offset': offset, 'limit': limit} - params = {k: v for k, v in params.items() if v is not None} - _, data = request_api(request, 'GET', EP('INVOICES_PATH'), params=params) - return data - -def get_invoice(request, inv_id: str) -> Dict[str, Any]: - path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id) - _, data = request_api(request, 'GET', path) - return data - -def update_invoice(request, inv_id: str, **fields) -> Dict[str, Any]: - path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id) - _, data = request_api(request, 'PATCH', path, json=fields) - return data - -def delete_invoice(request, inv_id: str) -> None: - path = EP('INVOICE_DETAIL_PATH').format(inv_id=inv_id) - request_api(request, 'DELETE', path) - -def mark_invoice_paid(request, inv_id: str) -> Dict[str, Any]: - path = EP('INVOICE_MARK_PAID_PATH').format(inv_id=inv_id) - _, data = request_api(request, 'POST', path) - return data - -def upload_my_photo(request, file_obj) -> Dict[str, Any]: +def upload_my_profile_photo(request, file_obj: IO[bytes]) -> Dict[str, Any]: """ - Отправляет multipart/form-data на бекенд для загрузки фото профиля. - Ожидаем, что сервер примет поле 'file' и вернёт обновлённый профиль или {photo_url: "..."}. + POST /profiles/v1/profiles/me/photo (multipart/form-data, field: file) + Возвращает ProfileOut с photo_url. (см. OpenAPI) :contentReference[oaicite:1]{index=1} """ - path = EP('PROFILE_PHOTO_UPLOAD_PATH') filename = getattr(file_obj, 'name', 'photo.jpg') content_type = getattr(file_obj, 'content_type', 'application/octet-stream') files = {'file': (filename, file_obj, content_type)} - _, data = request_api(request, 'POST', path, files=files) + _, data = request_api(request, 'POST', '/profiles/v1/profiles/me/photo', files=files) return data def delete_my_photo(request) -> Dict[str, Any]: - """ - Удаляет фото профиля (если сервер поддерживает DELETE на том же пути). - """ - path = EP('PROFILE_PHOTO_DELETE_PATH') - _, data = request_api(request, 'DELETE', path) - return data \ No newline at end of file + path = EP("PROFILE_PHOTO_DELETE_PATH") + if not path or path.strip("/") == "": + raise ApiError(405, "Photo delete endpoint is not available on backend") + _, data = request_api(request, "DELETE", path) + return data + diff --git a/ui/templatetags/__init__.py b/ui/templatetags/__init__.py new file mode 100644 index 0000000..249a72d --- /dev/null +++ b/ui/templatetags/__init__.py @@ -0,0 +1 @@ +# required for Django to discover custom template tags diff --git a/ui/templatetags/ui_extras.py b/ui/templatetags/ui_extras.py new file mode 100644 index 0000000..92bf784 --- /dev/null +++ b/ui/templatetags/ui_extras.py @@ -0,0 +1,20 @@ +import hashlib +from django import template + +register = template.Library() + +@register.filter +def gravatar_url(email: str, size: int = 96) -> str: + """Return gravatar URL for given email (or identicon).""" + if not email: + email = "" + em = (email or "").strip().lower().encode("utf-8") + h = hashlib.md5(em).hexdigest() + # identicon fallback to have a nice default + return f"https://www.gravatar.com/avatar/{h}?d=identicon&s={int(size)}" + +@register.filter +def initial(s: str) -> str: + if not s: + return "?" + return str(s).strip()[:1].upper() diff --git a/ui/urls.py b/ui/urls.py index b37920b..45dfc4d 100644 --- a/ui/urls.py +++ b/ui/urls.py @@ -1,20 +1,23 @@ from django.urls import path from . import views +app_name = "ui" + urlpatterns = [ - path('', views.index, name='index'), - # Кабинет + path("", views.index, name="index"), + + # auth + path("login/", views.login_view, name="login"), + path("register/", views.register_view, name="register"), + path("logout/", views.logout_view, name="logout"), + + # cabinet path("cabinet/", views.cabinet_view, name="cabinet"), - path("cabinet/photo/upload/", views.cabinet_upload_photo, name="cabinet_upload_photo"), + path("cabinet/photo/", views.cabinet_upload_photo, name="cabinet_upload_photo"), path("cabinet/photo/delete/", views.cabinet_delete_photo, name="cabinet_delete_photo"), - # Каталог + # 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"), - - # Регистрация и авторизация - path('register/', views.register_view, name='register'), - path('login/', views.login_view, name='login'), - path('logout/', views.logout_view, name='logout'), ] diff --git a/ui/views.py b/ui/views.py index ea90500..9d9802b 100644 --- a/ui/views.py +++ b/ui/views.py @@ -1,6 +1,4 @@ -import base64 -import json -import time +from __future__ import annotations from typing import List, Dict, Any, Optional from django.http import Http404, HttpResponse @@ -8,81 +6,41 @@ from django.shortcuts import render, redirect from django.views.decorators.http import require_http_methods, require_POST from django.contrib import messages from django.core.exceptions import PermissionDenied -from django.conf import settings -from django.views.decorators.csrf import csrf_exempt +from django.urls import reverse, NoReverseMatch from . import api from .api import ApiError -# -------- helpers (cookies/JWT) -------- - -def _cookie_secure() -> bool: - # в dev можно False, в prod обязательно True - return not getattr(settings, "DEBUG", True) - -def _jwt_exp_seconds(token: Optional[str], default_sec: int = 12 * 3600) -> int: - """ - Пытаемся вытащить exp из JWT и посчитать max_age для куки. - Если не получилось — даём дефолт (12 часов). - """ - if not token or token.count(".") != 2: - return default_sec +# --- 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: - payload_b64 = token.split(".")[1] - payload_b64 += "=" * (-len(payload_b64) % 4) - payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode()).decode("utf-8")) - exp = int(payload.get("exp", 0)) - now = int(time.time()) - if exp > now: - # добавим небольшой «запас» (минус 60 сек) - return max(60, exp - now - 60) - return default_sec - except Exception: - return default_sec + return reverse("ui:index") + except NoReverseMatch: + return "/" -def _set_auth_cookies(resp: HttpResponse, access_token: Optional[str], refresh_token: Optional[str]) -> None: - """ - Кладём токены в HttpOnly cookies с корректным сроком жизни. - """ - if access_token: - resp.set_cookie( - "access_token", - access_token, - max_age=_jwt_exp_seconds(access_token), - httponly=True, - samesite="Lax", - secure=_cookie_secure(), - path="/", - ) - if refresh_token: - resp.set_cookie( - "refresh_token", - refresh_token, - max_age=_jwt_exp_seconds(refresh_token, default_sec=7 * 24 * 3600), - httponly=True, - samesite="Lax", - secure=_cookie_secure(), - path="/", - ) - -def _clear_auth_cookies(resp: HttpResponse) -> None: - resp.delete_cookie("access_token", path="/") - resp.delete_cookie("refresh_token", path="/") - - -# -------- UI helpers -------- - -def index(request): - return render(request, "ui/index.html", {}) - -def _auth_required_partial(request) -> HttpResponse: - # Мини‑partial под HTMX для кнопки "в избранное", если нет логина - return render(request, "ui/components/like_button_login_required.html", {}) +def _is_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 { @@ -90,178 +48,38 @@ def _user_to_profile_stub(u: Dict[str, Any]) -> Dict[str, Any]: "name": name, "email": u.get("email") or "", "role": (u.get("role") or "").upper() or "CLIENT", - "verified": u.get("is_active", False), - # ↓ ключевые правки — чтобы шаблон не генерил src="None" + "verified": bool(u.get("is_active", False)), "age": None, "city": None, "about": "", - "photo": "", # было: None + "photo": "", "interests": [], "liked": False, } -def _format_validation(payload: Optional[dict]) -> Optional[str]: - """Сборка сообщений 422 ValidationError в одну строку.""" - if not payload: - return None - det = payload.get("detail") - if isinstance(det, list): - msgs = [] - for item in det: - msg = item.get("msg") - loc = item.get("loc") - if isinstance(loc, list) and loc: - field = ".".join(str(x) for x in loc if isinstance(x, (str, int))) - if field and field not in ("body",): - msgs.append(f"{field}: {msg}") - else: - msgs.append(msg) - elif msg: - msgs.append(msg) - return "; ".join(m for m in msgs if m) - return payload.get("message") or payload.get("detail") +# === public pages ============================================================== +def index(request): + return render(request, "ui/index.html", {}) -# ---------------- Auth ---------------- - -@require_http_methods(["GET", "POST"]) -def login_view(request): - if request.method == "POST": - email = (request.POST.get("email") or "").strip() - password = (request.POST.get("password") or "").strip() - if not email or not password: - messages.error(request, "Укажите email и пароль") - else: - try: - token_pair = api.login(request, email, password) # POST /auth/v1/token - access = token_pair.get("access_token") - refresh = token_pair.get("refresh_token") or token_pair.get("refresh") - if not access: - raise ApiError(0, "API не вернул access_token") - - # Пока храним и в сессии, и в куки (куки — для твоей задачи; сессия — на случай refresh внутри запроса) - request.session["access_token"] = access - if refresh: - request.session["refresh_token"] = refresh - - me = api.get_current_user(request) # GET /auth/v1/me - request.session["user_id"] = me.get("id") or me.get("user_id") - request.session["user_email"] = me.get("email") - request.session["user_full_name"] = me.get("full_name") or me.get("email") - request.session["user_role"] = me.get("role") or "CLIENT" - request.session.modified = True - - user_label = request.session.get("user_full_name") or request.session.get("user_email") or "пользователь" - messages.success(request, f"Здравствуйте, {user_label}!") - - next_url = request.GET.get("next") - if not next_url: - next_url = "profiles" if _is_admin(request) else "cabinet" - - resp = redirect(next_url) - _set_auth_cookies(resp, access, refresh) - return resp - - except PermissionDenied: - messages.error(request, "Неверные учётные данные") - except ApiError as e: - messages.error(request, f"Ошибка входа: {e}") - - return render(request, "ui/login.html", {}) - - -@require_http_methods(["GET", "POST"]) -def register_view(request): - """ - Регистрация → авто‑логин → установка токенов в cookies → редирект. - """ - if request.method == "POST": - email = (request.POST.get("email") or "").strip() - password = (request.POST.get("password") or "").strip() - full_name = (request.POST.get("full_name") or "").strip() or None - - if not email or not password: - messages.error(request, "Укажите email и пароль") - else: - try: - api.register_user(request, email=email, password=password, full_name=full_name, role='CLIENT') - token_pair = api.login(request, email=email, password=password) - access = token_pair.get("access_token") - refresh = token_pair.get("refresh_token") or token_pair.get("refresh") - if not access: - raise ApiError(0, "API не вернул access_token после регистрации") - - request.session["access_token"] = access - if refresh: - request.session["refresh_token"] = refresh - - me = api.get_current_user(request) - request.session["user_id"] = me.get("id") or me.get("user_id") - request.session["user_email"] = me.get("email") - request.session["user_full_name"] = me.get("full_name") or me.get("email") - request.session["user_role"] = me.get("role") or "CLIENT" - request.session.modified = True - - messages.success(request, "Регистрация успешна! Вы вошли в систему.") - next_url = request.GET.get("next") - if not next_url: - next_url = "profiles" if _is_admin(request) else "cabinet" - resp = redirect(next_url) - _set_auth_cookies(resp, access, refresh) - return resp - - except ApiError as e: - payload = getattr(e, "payload", None) - if payload and isinstance(payload, dict): - nice = _format_validation(payload) - messages.error(request, nice or f"Ошибка регистрации: {e}") - else: - messages.error(request, f"Ошибка регистрации: {e}") - except PermissionDenied: - messages.info(request, "Регистрация прошла, войдите под своим email/паролем") - return redirect("login") - - return render(request, "ui/register.html", {}) - - -@require_http_methods(["POST", "GET"]) -def logout_view(request): - for key in ("access_token", "refresh_token", "user_id", "user_email", "user_full_name", "user_role", "likes"): - request.session.pop(key, None) - request.session.modified = True - messages.info(request, "Вы вышли из аккаунта") - resp = redirect("index") - _clear_auth_cookies(resp) - return resp - - -# ---------------- Catalog (Admin‑only) ---------------- +# === catalog (admin) ========================================================== @require_http_methods(["GET"]) def profile_list(request): - """ - Каталог анкет (по сути — пользователей) с фильтрами и пагинацией. - Доступно только ADMIN (по API /auth/v1/users; у клиента прав нет). - Фильтры клиентские: q (имя/email), role, active, email_domain; сортировка; page/limit. - """ - if not (request.session.get("access_token") or request.COOKIES.get("access_token")): + if not _is_logged(request): messages.info(request, "Войдите, чтобы открыть каталог") - return redirect("login") - + return redirect(_reverse_first("login", "ui:login")) if not _is_admin(request): messages.info(request, "Каталог доступен только администраторам. Перенаправляем в Кабинет.") - return redirect("cabinet") + return redirect(_reverse_first("cabinet", "ui:cabinet")) - # --- читаем query-параметры --- q = (request.GET.get("q") or "").strip().lower() - role = (request.GET.get("role") or "").strip().upper() # CLIENT|ADMIN|"" (любой) - active = request.GET.get("active") # "1"|"0"|None + role = (request.GET.get("role") or "").strip().upper() + active = request.GET.get("active") email_domain = (request.GET.get("domain") or "").strip().lower().lstrip("@") - - # сортировка: name, name_desc, email, email_desc (по умолчанию name) sort = (request.GET.get("sort") or "name").strip().lower() page = max(1, int(request.GET.get("page") or 1)) - limit = min(max(1, int(request.GET.get("limit") or 20)), 200) # API максимум 200, см. спеки :contentReference[oaicite:1]{index=1} + limit = min(max(1, int(request.GET.get("limit") or 20)), 200) offset = (page - 1) * limit error: Optional[str] = None @@ -269,33 +87,28 @@ def profile_list(request): page_info = {"page": page, "limit": limit, "has_prev": page > 1, "has_next": False} try: - # Серверная пагинация есть, фильтров — нет (кроме offset/limit) → забираем страницу - data = api.list_users(request, offset=offset, limit=limit) # GET /auth/v1/users :contentReference[oaicite:2]{index=2} + 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: - if q: - fn = (u.get("full_name") or "").lower() - em = (u.get("email") or "").lower() - if q not in fn and q not in em: - return False + 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")) + 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: - em = (u.get("email") or "").lower() - dom = em.split("@")[-1] if "@" in em else "" + 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": @@ -307,22 +120,16 @@ def profile_list(request): elif sort == "email_desc": users.sort(key=key_email, reverse=True) - # Преобразуем в «анкеты» profiles = [_user_to_profile_stub(u) for u in users] - - # отметка лайков (локальная сессия) liked_ids = set(request.session.get("likes", [])) for p in profiles: p["liked"] = p.get("id") in liked_ids - # has_next — на глаз: если сервер отдал ровно limit без наших фильтров, - # считаем, что следующая страница потенциально есть page_info["has_next"] = (len(users) == limit and not (q or role or active in ("1","0") or email_domain)) - # (если включены клиентские фильтры — не знаем полный объём; оставим conservative False) except PermissionDenied as e: messages.error(request, f"Нет доступа к каталогу: {e}") - return redirect("cabinet") + return redirect(_reverse_first("cabinet", "ui:cabinet")) except ApiError as e: error = str(e) @@ -345,148 +152,195 @@ def profile_list(request): return render(request, "ui/profiles_list.html", ctx) @require_http_methods(["GET"]) -def profile_detail(request, pk: str): - """ - Детальная карточка пользователя — тоже ADMIN‑only. - """ - if not (request.session.get("access_token") or request.COOKIES.get("access_token")): - return redirect("login") - if not _is_admin(request): - messages.info(request, "Детали пользователей доступны только администраторам.") - return redirect("cabinet") - +def profile_detail(request, pk): try: - user = api.get_user(request, user_id=str(pk)) - except PermissionDenied as e: - messages.error(request, f"Нет доступа: {e}") - return redirect("cabinet") + 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("profiles") + return redirect(_reverse_first("profiles", "ui:profiles")) - profile = _user_to_profile_stub(user) + stub = _user_to_profile_stub(user) liked_ids = set(request.session.get("likes", [])) - liked = profile.get("id") in liked_ids - return render(request, "ui/profile_detail.html", {"profile": profile, "liked": liked}) - + liked = stub["id"] in liked_ids + return render(request, "ui/profile_detail.html", {"profile": stub, "liked": liked}) @require_POST -def like_profile(request, pk: str): - """ - Локальный «лайк» (для демо). Не требует API. - """ - if not (request.session.get("access_token") or request.COOKIES.get("access_token")): - return _auth_required_partial(request) - +def like_profile(request, pk): likes = set(request.session.get("likes", [])) - if str(pk) in likes: - likes.remove(str(pk)) - liked = False + pk_str = str(pk) + if pk_str in likes: + likes.remove(pk_str); liked = False else: - likes.add(str(pk)) - liked = True + 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}) - return render(request, "ui/components/like_button.html", {"profile_id": pk, "liked": liked}) +# === auth ===================================================================== +@require_http_methods(["GET", "POST"]) +def login_view(request): + if request.method == "POST": + email = (request.POST.get("email") or "").strip() + password = (request.POST.get("password") or "").strip() + if not email or not password: + messages.error(request, "Укажите email и пароль") + else: + try: + tokens = api.login(request, email, password) + me = api.get_me(request) + _set_tokens_and_user(request, tokens, me) -# ---------------- Кабинет: мой профиль ---------------- + next_url = request.GET.get("next") + if not next_url: + if (me.get("role") or "").upper() == "ADMIN": + next_url = _reverse_first("profiles", "ui:profiles") + else: + next_url = _reverse_first("cabinet", "ui:cabinet") + + resp = redirect(next_url) + max_age = 7 * 24 * 3600 + resp.set_cookie("access_token", tokens.get("access_token"), max_age=max_age, httponly=True, samesite="Lax") + resp.set_cookie("refresh_token", tokens.get("refresh_token"), max_age=max_age, httponly=True, samesite="Lax") + messages.success(request, "Вы успешно вошли") + return resp + except PermissionDenied as e: + messages.error(request, f"Доступ запрещён: {e}") + except ApiError as e: + messages.error(request, f"Ошибка входа: {e.args[0]}") + return render(request, "ui/login.html", {}) + +@require_http_methods(["GET", "POST"]) +def register_view(request): + if request.method == "POST": + email = (request.POST.get("email") or "").strip() + password = (request.POST.get("password") or "").strip() + full_name = (request.POST.get("full_name") or "").strip() or None + if not email or not password: + messages.error(request, "Укажите email и пароль") + else: + try: + api.register_user(request, email, password, full_name, role="CLIENT") + tokens = api.login(request, email, password) + me = api.get_me(request) + _set_tokens_and_user(request, tokens, me) + resp = redirect(_reverse_first("cabinet", "ui:cabinet")) + max_age = 7 * 24 * 3600 + resp.set_cookie("access_token", tokens.get("access_token"), max_age=max_age, httponly=True, samesite="Lax") + resp.set_cookie("refresh_token", tokens.get("refresh_token"), max_age=max_age, httponly=True, samesite="Lax") + messages.success(request, "Регистрация успешна") + return resp + except ApiError as e: + messages.error(request, f"Ошибка регистрации: {e.args[0]}") + return render(request, "ui/register.html", {}) + +@require_http_methods(["POST", "GET"]) +def logout_view(request): + resp = redirect(_reverse_first("index", "ui:index")) + resp.delete_cookie("access_token") + resp.delete_cookie("refresh_token") + for k in ("auth", "access_token", "refresh_token", "user_id", "user_email", "user_full_name", "user_role"): + request.session.pop(k, None) + request.session.modified = True + messages.info(request, "Вы вышли из аккаунта") + return resp + +# === cabinet ================================================================== @require_http_methods(["GET", "POST"]) def cabinet_view(request): - """ - Мой профиль: - - GET: /profiles/v1/profiles/me; если 404 — пустая форма создания - - POST: /profiles/v1/profiles (создать/заполнить свой профиль) - """ - if not (request.session.get("access_token") or request.COOKIES.get("access_token")): - messages.info(request, "Для доступа к кабинету войдите в систему") - return redirect("login") + if not _is_logged(request): + messages.info(request, "Войдите, чтобы открыть кабинет") + return redirect(_reverse_first("login", "ui:login")) - if request.method == "POST": - gender = (request.POST.get("gender") or "").strip() - city = (request.POST.get("city") or "").strip() - languages = [s.strip() for s in (request.POST.get("languages") or "").split(",") if s.strip()] - interests = [s.strip() for s in (request.POST.get("interests") or "").split(",") if s.strip()] - if not gender or not city: - messages.error(request, "Укажите пол и город") - else: - try: - profile = api.create_my_profile(request, gender=gender, city=city, languages=languages, interests=interests) - messages.success(request, "Профиль создан") - return render(request, "ui/cabinet.html", {"profile": profile, "has_profile": True}) - except PermissionDenied: - messages.error(request, "Сессия истекла, войдите снова") - return redirect("login") - except ApiError as e: - payload = getattr(e, "payload", None) - nice = _format_validation(payload) if isinstance(payload, dict) else None - messages.error(request, nice or f"Ошибка сохранения профиля: {e}") - - # GET + profile = None + has_profile = False try: profile = api.get_my_profile(request) - # шапка кабинета — имя из сессии (или email) - header_name = request.session.get("user_full_name") or request.session.get("user_email") or "" - return render(request, "ui/cabinet.html", {"profile": profile, "has_profile": True, "header_name": header_name}) - except ApiError as e: - if e.status == 404: - header_name = request.session.get("user_full_name") or request.session.get("user_email") or "" - return render(request, "ui/cabinet.html", {"profile": None, "has_profile": False, "header_name": header_name}) - messages.error(request, f"Ошибка загрузки профиля: {e}") - return render(request, "ui/cabinet.html", {"profile": None, "has_profile": False}) + has_profile = bool(profile) except PermissionDenied: messages.error(request, "Сессия истекла, войдите снова") - return redirect("login") + return redirect(_reverse_first("login", "ui:login")) + except ApiError as e: + if e.status != 404: + messages.error(request, f"Ошибка чтения профиля: {e}") + if request.method == "POST": + action = (request.POST.get("action") or "").strip() + try: + if action == "create_profile": + gender = (request.POST.get("gender") or "").strip() + city = (request.POST.get("city") or "").strip() + languages = [s.strip() for s in (request.POST.get("languages") or "").split(",") if s.strip()] + interests = [s.strip() for s in (request.POST.get("interests") or "").split(",") if s.strip()] + api.create_my_profile(request, gender, city, languages, interests) + messages.success(request, "Профиль создан") + return redirect(_reverse_first("cabinet", "ui:cabinet")) + elif action == "update_name": + full_name = (request.POST.get("full_name") or "").strip() + if not full_name: + messages.error(request, "Имя не может быть пустым") + else: + api.update_user_me(request, request.session.get("user_id"), full_name=full_name) + request.session["user_full_name"] = full_name + request.session.modified = True + messages.success(request, "Имя обновлено") + return redirect(_reverse_first("cabinet", "ui:cabinet")) + except PermissionDenied: + messages.error(request, "Сессия истекла, войдите снова") + return redirect(_reverse_first("login", "ui:login")) + except ApiError as e: + messages.error(request, f"Ошибка: {e}") + + ctx = { + "has_profile": has_profile, + "profile": profile, + } + return render(request, "ui/cabinet.html", ctx) @require_POST def cabinet_upload_photo(request): - # Требуется логин - if not (request.session.get("access_token") or request.COOKIES.get("access_token")): - messages.info(request, "Войдите, чтобы загрузить фото") - return redirect("login") + # Требуем авторизацию + if not (request.COOKIES.get('access_token') or request.session.get('access_token') or (request.session.get('auth') or {}).get('access_token')): + messages.error(request, "Сначала войдите") + return redirect('ui:login') - f = request.FILES.get("photo") - if not f: - messages.error(request, "Файл не выбран") - return redirect("cabinet") - if f.size and f.size > 5 * 1024 * 1024: - messages.error(request, "Файл слишком большой (макс. 5 МБ)") - return redirect("cabinet") + file_obj = request.FILES.get('file') or request.FILES.get('photo') + if not file_obj: + messages.error(request, "Не выбрано фото") + return redirect('ui:cabinet') try: - api.upload_my_photo(request, f) - messages.success(request, "Фото обновлено") + prof = api.upload_my_profile_photo(request, file_obj) + # Обновлённый профиль приходит от API (в т.ч. photo_url) + messages.success(request, "Фото успешно обновлено") except PermissionDenied: messages.error(request, "Сессия истекла, войдите снова") - return redirect("login") + return redirect('ui:login') except ApiError as e: - if e.status in (404, 405): - messages.error(request, "Бэкенд пока не поддерживает загрузку фото (нет эндпоинта).") - else: - messages.error(request, f"Ошибка загрузки: {e}") - return redirect("cabinet") + messages.error(request, f"Не удалось загрузить фото: {e.payload.get('detail') or e.args[0]}") + return redirect('ui:cabinet') @require_POST def cabinet_delete_photo(request): - if not (request.session.get("access_token") or request.COOKIES.get("access_token")): + if not _is_logged(request): messages.info(request, "Войдите, чтобы удалить фото") - return redirect("login") - + return redirect(_reverse_first("login", "ui:login")) try: api.delete_my_photo(request) messages.success(request, "Фото удалено") except PermissionDenied: messages.error(request, "Сессия истекла, войдите снова") - return redirect("login") + 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("cabinet") \ No newline at end of file + return redirect(_reverse_first("cabinet", "ui:cabinet"))