api refactoring

This commit is contained in:
2025-08-12 20:19:14 +09:00
parent 95bef94c53
commit 73607dc6d6

453
changes.patch Normal file
View File

@@ -0,0 +1,453 @@
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"),
]