feat: API request/response logging with admin log viewer

- Add api_logs table (migration 0007) and ApiLog model
- Add web/lib/api_logger.py — httpx wrapper that records every outbound call
- Wire api_logger into vk_sync, vk_catalog, and connections test endpoints
- Add /admin/logs page with filters (service, method, status, time range, URL search) and expandable request/response detail
- Add "Логи" nav link for admin users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-05-12 22:00:14 +03:00
parent cad0b10fbb
commit 9960d760a0
10 changed files with 392 additions and 21 deletions

View File

@@ -10,9 +10,9 @@ import os
from datetime import datetime, timezone
from decimal import Decimal
import httpx
from celery import shared_task
import web.lib.api_logger as api_logger
from web.config import settings
from web.database import SessionLocal
from web.models.connections import (
@@ -63,14 +63,14 @@ def _name_for_vk(name: str) -> str:
return name.replace(";", ",")
def _vk_post(method: str, data: dict, token: str) -> dict:
def _vk_post(method: str, data: dict, token: str, user_id: int | None = None) -> dict:
data = {**data, "access_token": token, "v": settings.VK_API_VERSION}
r = httpx.post(f"{VK_API}/{method}", data=data, timeout=30)
r = api_logger.post(f"{VK_API}/{method}", user_id=user_id, data=data, timeout=30)
r.raise_for_status()
return r.json()
def _upload_photo(token: str, group_id: str) -> str | None:
def _upload_photo(token: str, group_id: str, user_id: int | None = None) -> str | None:
"""Upload the default product photo and return photo_id, or None on failure."""
photo_path = settings.VK_DEFAULT_PHOTO_PATH
if not os.path.exists(photo_path):
@@ -78,7 +78,7 @@ def _upload_photo(token: str, group_id: str) -> str | None:
return None
try:
# Step 1: get upload URL
resp = _vk_post("market.getProductPhotoUploadServer", {"group_id": group_id}, token)
resp = _vk_post("market.getProductPhotoUploadServer", {"group_id": group_id}, token, user_id=user_id)
if "error" in resp:
logger.warning("getProductPhotoUploadServer error: %s", resp["error"])
return None
@@ -86,12 +86,12 @@ def _upload_photo(token: str, group_id: str) -> str | None:
# Step 2: upload file
with open(photo_path, "rb") as f:
up = httpx.post(upload_url, files={"file": f}, timeout=30)
up = api_logger.post(upload_url, user_id=user_id, files={"file": f}, timeout=30)
up.raise_for_status()
upload_obj = up.text
# Step 3: save
resp2 = _vk_post("market.saveProductPhoto", {"upload_response": upload_obj}, token)
resp2 = _vk_post("market.saveProductPhoto", {"upload_response": upload_obj}, token, user_id=user_id)
if "error" in resp2:
logger.warning("saveProductPhoto error: %s", resp2["error"])
return None
@@ -104,7 +104,7 @@ def _upload_photo(token: str, group_id: str) -> str | None:
def _get_photo_id(user_id: int, token: str, group_id: str) -> str | None:
"""Upload photo once per sync run per user, cache the result."""
if user_id not in _PHOTO_CACHE:
_PHOTO_CACHE[user_id] = _upload_photo(token, group_id)
_PHOTO_CACHE[user_id] = _upload_photo(token, group_id, user_id=user_id)
return _PHOTO_CACHE[user_id]
@@ -142,7 +142,7 @@ def _ensure_album(db, user_id: int, vk_group_id: str, group_name: str, token: st
resp = _vk_post("market.addAlbum", {
"owner_id": f"-{vk_group_id}",
"title": group_name,
}, token)
}, token, user_id=user_id)
if "error" in resp:
logger.warning("market.addAlbum error for '%s': %s", group_name, resp["error"])
return None
@@ -212,7 +212,7 @@ def _sync_product(
"category_id": settings.VK_CATEGORY_ID,
"price": price_kopecks,
"stock_amount": stock,
}, token)
}, token, user_id=user_id)
if "error" in resp:
logger.warning("market.edit error product=%s: %s", product.evotor_id, resp["error"])
return
@@ -223,12 +223,12 @@ def _sync_product(
"owner_id": owner_id,
"item_id": product.vk_product_id,
"album_ids": old_album_id,
}, token)
}, token, user_id=user_id)
resp_album = _vk_post("market.addToAlbum", {
"owner_id": owner_id,
"item_ids": product.vk_product_id,
"album_ids": album_id,
}, token)
}, token, user_id=user_id)
if "error" in resp_album:
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp_album["error"])
else:
@@ -255,7 +255,7 @@ def _sync_product(
"price": price_kopecks,
"main_photo_id": photo_id,
"stock_amount": stock,
}, token)
}, token, user_id=user_id)
if "error" in resp:
logger.warning("market.add error product=%s: %s", product.evotor_id, resp["error"])
return
@@ -269,7 +269,7 @@ def _sync_product(
"owner_id": owner_id,
"item_ids": vk_item_id,
"album_ids": album_id,
}, token)
}, token, user_id=user_id)
if "error" in resp2:
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp2["error"])
@@ -310,7 +310,7 @@ def _delete_orphans(db, user_id, vk_group_id, owned_ids, token, results):
resp = _vk_post("market.delete", {
"owner_id": owner_id,
"item_id": vk_p.vk_product_id,
}, token)
}, token, user_id=user_id)
if "error" in resp:
logger.warning("market.delete error id=%s: %s", vk_p.vk_product_id, resp["error"])
results["errors"] += 1