api refactoring
This commit is contained in:
453
changes.patch
Normal file
453
changes.patch
Normal 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"),
|
||||
]
|
||||
Reference in New Issue
Block a user