main features

This commit is contained in:
2025-08-10 17:28:38 +09:00
parent 3e4a21d5b1
commit 95bef94c53
30 changed files with 4246 additions and 1066 deletions

636
ui/api.py
View File

@@ -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'... <truncated {len(s)-limit} chars>')
def _build_curl(method: str, url: str, headers: Dict[str, str],
params: Optional[dict], json_body: Optional[dict], data: Optional[dict]) -> str:
parts = [f"curl -X '{method.upper()}' \\\n '{url}'"]
for k, v in headers.items():
if k.lower() == 'authorization':
v = 'Bearer ***'
parts.append(f" -H '{k}: {v}'")
if json_body is not None:
parts.append(" -H 'Content-Type: application/json'")
try:
body_str = json.dumps(_sanitize(json_body), ensure_ascii=False)
except Exception:
body_str = str(_sanitize(json_body))
parts.append(f" -d '{body_str}'")
elif data is not None:
try:
body_str = json.dumps(_sanitize(data), ensure_ascii=False)
except Exception:
body_str = str(_sanitize(data))
parts.append(f" --data-raw '{body_str}'")
return " \\\n".join(parts)
# ===== Пути эндпоинтов (как в swagger) =====
EP_DEFAULTS: Dict[str, str] = {
# Auth
'AUTH_REGISTER_PATH': '/auth/v1/register',
'AUTH_TOKEN_PATH': '/auth/v1/token',
'AUTH_REFRESH_PATH': '/auth/v1/refresh',
'ME_PATH': '/auth/v1/me',
'USERS_LIST_PATH': '/auth/v1/users',
'USER_DETAIL_PATH': '/auth/v1/users/{user_id}',
# Profiles
'PROFILE_ME_PATH': '/profiles/v1/profiles/me',
'PROFILES_CREATE_PATH': '/profiles/v1/profiles',
'PROFILE_PHOTO_UPLOAD_PATH': '/profiles/v1/profiles/me/photo',
'PROFILE_PHOTO_DELETE_PATH': '/profiles/v1/profiles/me/photo',
# Pairs
'PAIRS_PATH': '/match/v1/pairs',
'PAIR_DETAIL_PATH': '/match/v1/pairs/{pair_id}',
'PAIR_ACCEPT_PATH': '/match/v1/pairs/{pair_id}/accept',
'PAIR_REJECT_PATH': '/match/v1/pairs/{pair_id}/reject',
# Chat
'ROOMS_PATH': '/chat/v1/rooms',
'ROOM_DETAIL_PATH': '/chat/v1/rooms/{room_id}',
'ROOM_MESSAGES_PATH': '/chat/v1/rooms/{room_id}/messages',
# Payments
'INVOICES_PATH': '/payments/v1/invoices',
'INVOICE_DETAIL_PATH': '/payments/v1/invoices/{inv_id}',
'INVOICE_MARK_PAID_PATH': '/payments/v1/invoices/{inv_id}/mark-paid',
# 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_<KEY>
override = _from_settings_or_env(f"API_{key}", None)
path = (override or EP_DEFAULTS[key]).format(**fmt)
if not path.startswith("/"):
path = "/" + path
return path
# ===== База 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
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

View File

@@ -0,0 +1 @@
# required for Django to discover custom template tags

View File

@@ -0,0 +1,20 @@
import hashlib
from django import template
register = template.Library()
@register.filter
def gravatar_url(email: str, size: int = 96) -> str:
"""Return gravatar URL for given email (or identicon)."""
if not email:
email = ""
em = (email or "").strip().lower().encode("utf-8")
h = hashlib.md5(em).hexdigest()
# identicon fallback to have a nice default
return f"https://www.gravatar.com/avatar/{h}?d=identicon&s={int(size)}"
@register.filter
def initial(s: str) -> str:
if not s:
return "?"
return str(s).strip()[:1].upper()

View File

@@ -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/<uuid:pk>/", views.profile_detail, name="profile_detail"),
path("profiles/<uuid:pk>/like/", views.like_profile, name="like_profile"),
# Регистрация и авторизация
path('register/', views.register_view, name='register'),
path('login/', views.login_view, name='login'),
path('logout/', views.logout_view, name='logout'),
]

View File

@@ -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 (Adminonly) ----------------
# === 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):
"""
Детальная карточка пользователя — тоже ADMINonly.
"""
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")
return redirect(_reverse_first("cabinet", "ui:cabinet"))