From 73607dc6d63199d78ce71fb5b7e474657593e558 Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Tue, 12 Aug 2025 20:19:14 +0900 Subject: [PATCH] api refactoring --- changes.patch | 453 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 changes.patch diff --git a/changes.patch b/changes.patch new file mode 100644 index 0000000..8d8c573 --- /dev/null +++ b/changes.patch @@ -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//", views.profile_detail, name="profile_detail"), + path("profiles//like/", views.like_profile, name="like_profile"), + ]