Files
marriage_frontend/changes.patch
2025-08-12 20:19:14 +09:00

454 lines
20 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters

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

diff --git a/ui/api.py b/ui/api.py
index 3b3d9a1..5a8bc3f 100644
--- a/ui/api.py
+++ b/ui/api.py
@@ -1,10 +1,13 @@
import logging
import os
-from typing import Any, Dict, Optional, Tuple, List, IO
+from typing import Any, Dict, Optional, Tuple, List, IO
import requests
+import mimetypes
+import unicodedata
+import re
from django.conf import settings
from django.core.exceptions import PermissionDenied
logger = logging.getLogger(__name__)
class ApiError(Exception):
@@ -14,6 +17,34 @@ class ApiError(Exception):
self.status = status
self.payload = payload or {}
+def _get_setting(name: str, default: str = "") -> str:
+ return getattr(settings, name, "") or os.environ.get(name, "") or default
+
+def _api_base() -> str:
+ base = _get_setting("API_BASE_URL", "http://localhost:8080").rstrip("/")
+ return base
+
+def _safe_filename(name: str, default_ext: str = ".jpg") -> str:
+ """
+ Нормализуем имя файла до ASCII, чтобы избежать падений бекенда на не-ASCII filename.
+ """
+ name = name or f"photo{default_ext}"
+ base, ext = os.path.splitext(name)
+ # нормализуем и выкидываем не-ascii
+ base_ascii = unicodedata.normalize("NFKD", base).encode("ascii", "ignore").decode("ascii") or "photo"
+ # оставим алфанум/._-
+ base_ascii = re.sub(r"[^A-Za-z0-9._-]", "_", base_ascii)
+ ext = ext if ext else default_ext
+ if not ext.startswith("."):
+ ext = "." + ext
+ ext = re.sub(r"[^A-Za-z0-9._-]", "", ext)
+ return (base_ascii or "photo") + (ext or default_ext)
+
+def _guess_mime(filename: str, fallback: str = "application/octet-stream") -> str:
+ mt, _ = mimetypes.guess_type(filename)
+ return mt or fallback
+
+
def _headers(request, *, for_files: bool = False, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""
Если for_files=True, убираем Content-Type, чтобы requests сам проставил multipart/form-data.
"""
headers = {
'Accept': 'application/json',
}
if not for_files:
headers['Content-Type'] = 'application/json'
# токены и из cookies, и из session
- token = (
- request.COOKIES.get('access_token')
- or request.session.get('access_token')
- or (request.session.get('auth') or {}).get('access_token')
- )
+ token = (
+ (request.COOKIES.get('access_token') if hasattr(request, "COOKIES") else None)
+ or request.session.get('access_token')
+ or (request.session.get('auth') or {}).get('access_token')
+ )
if token:
headers['Authorization'] = f'Bearer {token}'
- api_key = getattr(settings, 'API_KEY', '') or os.environ.get('API_KEY', '')
+ api_key = _get_setting('API_KEY', '')
if api_key:
headers['X-API-Key'] = api_key
if extra:
headers.update(extra)
return headers
-def _url(path: str) -> str:
- base = settings.API_BASE_URL.rstrip('/')
- path = path if path.startswith('/') else '/' + path
- return base + path
+def _url(path: str) -> str:
+ path = path if path.startswith('/') else '/' + path
+ return _api_base() + path
def request_api(
request,
method: str,
path: str,
*,
params: Optional[dict] = None,
json: Optional[dict] = None,
- files: Optional[dict] = None
+ files: Optional[dict] = None,
+ auth_token: Optional[str] = None, # <— можно принудительно задать Authorization
) -> Tuple[int, Any]:
url = _url(path)
- # Включённый подробный лог согласно .env
- api_debug = bool(int(getattr(settings, "API_DEBUG", 1)))
+ # Включённый подробный лог согласно .env
+ api_debug = bool(int(_get_setting("API_DEBUG", "1")))
+ log_headers = bool(int(_get_setting("API_LOG_HEADERS", "1")))
+ log_body_max = int(_get_setting("API_LOG_BODY_MAX", "2000"))
req_id = os.urandom(4).hex()
try:
- hdrs = _headers(request, for_files=bool(files))
+ extra_hdr = {'Authorization': f'Bearer {auth_token}'} if auth_token else None
+ hdrs = _headers(request, for_files=bool(files), extra=extra_hdr)
if files and 'Content-Type' in hdrs:
# гарантия: multipart должен ставить requests
hdrs.pop('Content-Type', None)
if api_debug:
- safe_body = "***" if (files or (json and "password" in (json or {}))) else json
- logger.info("API[req_id=%s] REQUEST %s %s params=%s headers=%s body=%s",
- req_id, method.upper(), url, params, {k: ('***' if k=='Authorization' else v) for k,v in hdrs.items()}, safe_body)
+ safe_body = "***" if (files or (json and "password" in (json or {}))) else json
+ files_meta = None
+ if files:
+ files_meta = {
+ k: {
+ "filename": (v[0] if isinstance(v, (list, tuple)) and len(v) >= 1 else ""),
+ "content_type": (v[2] if isinstance(v, (list, tuple)) and len(v) >= 3 else ""),
+ "size": (getattr(v[1], "size", None) if isinstance(v, (list, tuple)) and len(v) >= 2 else None),
+ }
+ for k, v in files.items()
+ }
+ logger.info(
+ "API[req_id=%s] REQUEST %s %s params=%s headers=%s body=%s files=%s",
+ req_id, method.upper(), url, params,
+ ({k: ('***' if k=='Authorization' else v) for k,v in hdrs.items()} if log_headers else "{hidden}"),
+ safe_body, files_meta
+ )
resp = requests.request(
method=method.upper(),
url=url,
headers=hdrs,
params=params,
json=(None if files else json), # при files json не отправляем
files=files,
- timeout=settings.API_TIMEOUT,
+ timeout=float(_get_setting("API_TIMEOUT", "6.0")),
)
except requests.RequestException as e:
logger.exception('API network error: %s', e)
raise ApiError(0, f'Network unavailable or timeout when accessing API ({e})')
content_type = resp.headers.get('Content-Type', '')
try:
data = resp.json() if 'application/json' in content_type else {}
except ValueError:
data = {}
+ # дополнительно снимем текст тела (например, при 500 text/plain)
+ text_body = ""
+ try:
+ if not data:
+ text_body = (resp.text or "")[:log_body_max]
+ except Exception:
+ text_body = ""
- if api_debug:
- logger.info("API[req_id=%s] RESPONSE %s %sms ct=%s headers=%s body=%s",
- req_id, resp.status_code, getattr(resp, 'elapsed', None), content_type,
- dict(list(resp.headers.items())[:10]), ('***' if 'access_token' in str(data) else data))
+ if api_debug:
+ logger.info(
+ "API[req_id=%s] RESPONSE %s %sms ct=%s headers=%s body=%s text=%s",
+ req_id, resp.status_code, getattr(resp, 'elapsed', None), content_type,
+ (dict(list(resp.headers.items())[:10]) if log_headers else "{hidden}"),
+ ('***' if 'access_token' in str(data) else data),
+ text_body
+ )
# Попытка refresh, если есть refresh_token
if resp.status_code == 401:
refresh_token = (
- request.COOKIES.get('refresh_token')
+ (request.COOKIES.get('refresh_token') if hasattr(request, "COOKIES") else None)
or request.session.get('refresh_token')
or (request.session.get('auth') or {}).get('refresh_token')
)
@@ -76,13 +137,15 @@ def request_api(
content_type = resp.headers.get('Content-Type', '')
try:
data = resp.json() if 'application/json' in content_type else {}
except ValueError:
data = {}
+ if not data:
+ text_body = (resp.text or "")[:log_body_max]
# если не вышло — пойдём ниже по обработке статуса
except requests.RequestException as e:
logger.exception('Refresh token error: %s', e)
raise ApiError(401, f'Token refresh failed ({e})')
if not (200 <= resp.status_code < 300):
- msg = (data.get('detail') or data.get('message') or f'API error: {resp.status_code}')
+ msg = (data.get('detail') or data.get('message') or text_body or f'API error: {resp.status_code}')
if resp.status_code in (401, 403):
raise PermissionDenied(msg)
raise ApiError(resp.status_code, msg, data)
return resp.status_code, data
@@ -90,6 +153,7 @@ def request_api(
# ----- HIGH-LEVEL HELPERS -----
def get_my_profile(request) -> Dict[str, Any]:
_, data = request_api(request, 'GET', '/profiles/v1/profiles/me')
return data # ProfileOut
@@ -101,10 +165,41 @@ def create_my_profile(request, gender: str, city: str, languages: List[str], interests: List[str]) -> Dict[str, Any]:
_, data = request_api(request, 'POST', '/profiles/v1/profiles', json=payload)
return data # ProfileOut
+def get_current_user_with_token(request, token: str) -> Dict[str, Any]:
+ """
+ Получить /auth/v1/me, принудительно подставляя только что полученный access_token.
+ Нельзя полагаться на cookies в том же запросе после логина.
+ """
+ _, data = request_api(request, 'GET', '/auth/v1/me', auth_token=token)
+ return data # UserRead
+
def upload_my_profile_photo(request, file_obj: IO[bytes]) -> Dict[str, Any]:
"""
POST /profiles/v1/profiles/me/photo (multipart/form-data, field: file)
- Возвращает ProfileOut с photo_url. (см. OpenAPI)
+ Возвращает ProfileOut с photo_url. (фактический контракт шлюза)
"""
- filename = getattr(file_obj, 'name', 'photo.jpg')
- content_type = getattr(file_obj, 'content_type', 'application/octet-stream')
- files = {'file': (filename, file_obj, content_type)}
+ # нормализуем имя и тип
+ raw_name = getattr(file_obj, 'name', 'photo.jpg')
+ safe_name = _safe_filename(raw_name, ".jpg")
+ content_type = getattr(file_obj, 'content_type', '') or _guess_mime(safe_name, 'image/jpeg')
+ # на всякий случай выставим указатель в начало
+ try:
+ file_obj.seek(0)
+ except Exception:
+ pass
+ files = {'file': (safe_name, file_obj, content_type)}
_, data = request_api(request, 'POST', '/profiles/v1/profiles/me/photo', files=files)
return data
@@ -113,6 +208,40 @@ def upload_my_profile_photo(request, file_obj: IO[bytes]) -> Dict[str, Any]:
def register_user(request, email: str, password: str, full_name: Optional[str] = None, role: str = 'CLIENT') -> Dict[str, Any]:
json_body = {'email': email, 'password': password, 'full_name': full_name, 'role': role}
_, data = request_api(request, 'POST', '/auth/v1/register', json=json_body)
return data # Returns UserRead
def login(request, email: str, password: str) -> Dict[str, Any]:
json_body = {'email': email, 'password': password}
_, data = request_api(request, 'POST', '/auth/v1/token', json=json_body)
return data # Returns TokenPair
diff --git a/ui/views.py b/ui/views.py
index 9f42bd1..8a9b1c2 100644
--- a/ui/views.py
+++ b/ui/views.py
@@ -1,12 +1,14 @@
from typing import List, Dict, Any, Optional
from django.http import Http404, HttpResponse, JsonResponse
from django.shortcuts import render, redirect
from django.views.decorators.http import require_http_methods, require_POST
from django.contrib import messages
from django.db.models import Q
+from django.conf import settings
from . import api
from .api import ApiError
from django.core.exceptions import PermissionDenied
@@ -88,14 +90,14 @@ def profile_list(request):
try:
- data = api.list_users(request, offset=offset, limit=limit)
+ data = api.list_users(request, offset=offset, limit=limit)
profiles = data if isinstance(data, list) else data.get("items", [])
except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова")
- return redirect("cabinet")
+ return redirect("ui:cabinet")
except ApiError as e:
error = str(e)
ctx = {
"profiles": cards,
"filters": filters,
"count": len(cards),
"error": error,
}
return render(request, "ui/profiles_list.html", ctx)
@@ -150,22 +152,63 @@ 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:
- data = api.login(request, email, password)
- # Expected: {'access_token': '...', 'user': {...}}
- auth = {"access_token": data.get("access_token"), "user": data.get("user")}
- if not auth["access_token"]:
- raise ApiError(0, "API не вернул access_token")
- request.session["auth"] = auth
- request.session.modified = True
- messages.success(request, "Вы успешно вошли")
- next_url = request.GET.get("next") or "profiles"
- return redirect(next_url)
- except ApiError as e:
- messages.error(request, f"Ошибка входа: {e.args[0]}")
- return render(request, "ui/login.html", {})
+ 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:
+ toks = api.login(request, email, password)
+ access = toks.get("access_token")
+ refresh = toks.get("refresh_token")
+ if not access or not refresh:
+ raise ApiError(0, "Auth error: API не вернул токены")
+
+ # ВАЖНО: сразу получаем /me, принудительно подставив свежий access
+ me = api.get_current_user_with_token(request, access)
+
+ # сохраняем в session
+ request.session["access_token"] = access
+ request.session["refresh_token"] = refresh
+ request.session["user_id"] = me.get("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")
+ request.session["auth"] = {"access_token": access, "refresh_token": refresh, "user": me}
+ request.session.modified = True
+
+ # редирект на нужную страницу
+ next_name = request.GET.get("next")
+ if not next_name:
+ next_name = "ui:profiles" if me.get("role") == "ADMIN" else "ui:cabinet"
+ resp = redirect(next_name)
+
+ # выставим cookies для последующих запросов
+ cookie_kwargs = {
+ "httponly": True,
+ "secure": bool(getattr(settings, "SESSION_COOKIE_SECURE", False)),
+ "samesite": getattr(settings, "CSRF_COOKIE_SAMESITE", "Lax") or "Lax",
+ "max_age": 60 * 60 * 24 * 7, # 7 дней
+ }
+ resp.set_cookie("access_token", access, **cookie_kwargs)
+ resp.set_cookie("refresh_token", refresh, **cookie_kwargs)
+
+ messages.success(request, "Вы успешно вошли")
+ return resp
+ except PermissionDenied as e:
+ messages.error(request, f"Доступ запрещён: {e}")
+ except ApiError as e:
+ messages.error(request, f"Ошибка входа: {e}")
+ return render(request, "ui/login.html", {})
@require_POST
def logout_view(request):
try:
api.logout(request)
finally:
request.session.pop("auth", None)
request.session.modified = True
messages.info(request, "Вы вышли из аккаунта")
- return redirect("ui:index")
+ return redirect("ui:index")
@@ -180,6 +223,57 @@ def logout_view(request):
return redirect("ui:index")
@require_POST
def cabinet_upload_photo(request):
"""
Загрузка фото: валидируем тип/размер заранее, отправляем multipart c безопасным именем.
"""
# проверка авторизации
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')
+ return redirect('ui:login')
file_obj = request.FILES.get('file') or request.FILES.get('photo')
if not file_obj:
messages.error(request, "Не выбрано фото")
- return redirect('ui:cabinet')
+ return redirect('ui:cabinet')
# валидации (можно вынести в настройки)
allowed = {'image/jpeg', 'image/png', 'image/webp'}
max_mb = getattr(settings, "UPLOAD_MAX_MB", 10)
max_bytes = max_mb * 1024 * 1024
ctype = (getattr(file_obj, 'content_type', '') or '').lower()
size = getattr(file_obj, 'size', 0)
if ctype and ctype not in allowed:
messages.error(request, f"Недопустимый тип файла: {ctype}. Разрешено: JPEG/PNG/WebP")
- return redirect('ui:cabinet')
+ return redirect('ui:cabinet')
if size and size > max_bytes:
messages.error(request, f"Файл слишком большой ({size//1024} КБ). Максимум {max_mb} МБ")
- return redirect('ui:cabinet')
+ return redirect('ui:cabinet')
try:
prof = api.upload_my_profile_photo(request, file_obj)
if prof and prof.get("photo_url"):
messages.success(request, "Фото успешно обновлено")
else:
messages.info(request, "Фото загружено, но сервер не вернул ссылку. Обновите страницу позже.")
except PermissionDenied:
messages.error(request, "Сессия истекла, войдите снова")
- return redirect('ui:login')
+ return redirect('ui:login')
except ApiError as e:
# покажем текст с бэка, если был (мы его теперь вытаскиваем из resp.text)
messages.error(request, f"Не удалось загрузить фото: {e.payload.get('detail') or e.args[0]}")
- return redirect('ui:cabinet')
+ return redirect('ui:cabinet')
diff --git a/ui/urls.py b/ui/urls.py
index 8b8e71b..36a3b88 100644
--- a/ui/urls.py
+++ b/ui/urls.py
@@ -1,16 +1,17 @@
from django.urls import path
from . import views
app_name = "ui"
urlpatterns = [
path("", views.index, name="index"),
path("login/", views.login_view, name="login"),
path("logout/", views.logout_view, name="logout"),
path("register/", views.register_view, name="register"),
path("cabinet/", views.cabinet_view, name="cabinet"),
+ path("cabinet/photo/", views.cabinet_upload_photo, name="cabinet_upload_photo"),
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"),
]