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:
82
web/lib/api_logger.py
Normal file
82
web/lib/api_logger.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Thin wrapper around httpx that logs every outbound API call to api_logs."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from web.database import SessionLocal
|
||||||
|
from web.models.connections import ApiLog
|
||||||
|
|
||||||
|
_MAX_BODY = 8000 # truncate stored bodies beyond this
|
||||||
|
|
||||||
|
|
||||||
|
def _service_from_url(url: str) -> str:
|
||||||
|
host = urllib.parse.urlparse(url).netloc
|
||||||
|
if "evotor" in host:
|
||||||
|
return "evotor"
|
||||||
|
if "vk.com" in host:
|
||||||
|
return "vk"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str | None) -> str | None:
|
||||||
|
if text and len(text) > _MAX_BODY:
|
||||||
|
return text[:_MAX_BODY] + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
user_id: int | None,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
request_body: str | None,
|
||||||
|
response_status: int | None,
|
||||||
|
response_body: str | None,
|
||||||
|
duration_ms: int,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
db.add(ApiLog(
|
||||||
|
user_id=user_id,
|
||||||
|
service=_service_from_url(url),
|
||||||
|
method=method.upper(),
|
||||||
|
url=url,
|
||||||
|
request_body=_truncate(request_body),
|
||||||
|
response_status=response_status,
|
||||||
|
response_body=_truncate(response_body),
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass # never let logging crash the caller
|
||||||
|
|
||||||
|
|
||||||
|
def get(url: str, *, user_id: int | None = None, **kwargs) -> httpx.Response:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
resp = httpx.get(url, **kwargs)
|
||||||
|
ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
try:
|
||||||
|
body = resp.text
|
||||||
|
except Exception:
|
||||||
|
body = None
|
||||||
|
_record(user_id, "GET", url, None, resp.status_code, body, ms)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def post(url: str, *, user_id: int | None = None, data: Any = None, json: Any = None, **kwargs) -> httpx.Response:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
resp = httpx.post(url, data=data, json=json, **kwargs)
|
||||||
|
ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
try:
|
||||||
|
req_body = resp.request.content.decode("utf-8", errors="replace") if resp.request.content else None
|
||||||
|
except Exception:
|
||||||
|
req_body = None
|
||||||
|
try:
|
||||||
|
body = resp.text
|
||||||
|
except Exception:
|
||||||
|
body = None
|
||||||
|
_record(user_id, "POST", url, req_body, resp.status_code, body, ms)
|
||||||
|
return resp
|
||||||
@@ -38,6 +38,7 @@ from web.routes.admin import router as admin_router # noqa: E402
|
|||||||
from web.routes.catalog import router as catalog_router # noqa: E402
|
from web.routes.catalog import router as catalog_router # noqa: E402
|
||||||
from web.routes.connections import router as connections_router # noqa: E402
|
from web.routes.connections import router as connections_router # noqa: E402
|
||||||
from web.routes.vk_catalog import router as vk_catalog_router # noqa: E402
|
from web.routes.vk_catalog import router as vk_catalog_router # noqa: E402
|
||||||
|
from web.routes.logs import router as logs_router # noqa: E402
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(reset_router)
|
app.include_router(reset_router)
|
||||||
@@ -48,6 +49,7 @@ app.include_router(admin_router)
|
|||||||
app.include_router(catalog_router)
|
app.include_router(catalog_router)
|
||||||
app.include_router(connections_router)
|
app.include_router(connections_router)
|
||||||
app.include_router(vk_catalog_router)
|
app.include_router(vk_catalog_router)
|
||||||
|
app.include_router(logs_router)
|
||||||
|
|
||||||
|
|
||||||
# ── Catalog redirect ─────────────────────────────────────────────────────────
|
# ── Catalog redirect ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
32
web/migrations/versions/0007_api_logs.py
Normal file
32
web/migrations/versions/0007_api_logs.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add api_logs table for request/response logging."""
|
||||||
|
revision = "0007"
|
||||||
|
down_revision = "0006"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"api_logs",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("service", sa.String(20), nullable=False),
|
||||||
|
sa.Column("method", sa.String(10), nullable=False),
|
||||||
|
sa.Column("url", sa.String(1024), nullable=False),
|
||||||
|
sa.Column("request_body", sa.Text, nullable=True),
|
||||||
|
sa.Column("response_status", sa.Integer, nullable=True),
|
||||||
|
sa.Column("response_body", sa.Text, nullable=True),
|
||||||
|
sa.Column("duration_ms", sa.Integer, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_api_logs_user_service", "api_logs", ["user_id", "service"])
|
||||||
|
op.create_index("ix_api_logs_created_at", "api_logs", ["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_api_logs_created_at", "api_logs")
|
||||||
|
op.drop_index("ix_api_logs_user_service", "api_logs")
|
||||||
|
op.drop_table("api_logs")
|
||||||
@@ -3,6 +3,7 @@ from sqlalchemy import (
|
|||||||
Numeric, String, Text, UniqueConstraint, func,
|
Numeric, String, Text, UniqueConstraint, func,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from web.database import Base
|
from web.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -175,3 +176,23 @@ class CachedProduct(Base):
|
|||||||
UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"),
|
UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"),
|
||||||
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
|
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiLog(Base):
|
||||||
|
__tablename__ = "api_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
service = Column(String(20), nullable=False) # "evotor" | "vk"
|
||||||
|
method = Column(String(10), nullable=False) # "GET" | "POST"
|
||||||
|
url = Column(String(1024), nullable=False)
|
||||||
|
request_body = Column(Text, nullable=True)
|
||||||
|
response_status = Column(Integer, nullable=True)
|
||||||
|
response_body = Column(Text, nullable=True)
|
||||||
|
duration_ms = Column(Integer, nullable=True)
|
||||||
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_api_logs_user_service", "user_id", "service"),
|
||||||
|
Index("ix_api_logs_created_at", "created_at"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import web.lib.api_logger as api_logger
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -306,8 +307,9 @@ async def connections_evotor_test(request: Request, db: Session = Depends(get_db
|
|||||||
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
|
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = httpx.get(
|
r = api_logger.get(
|
||||||
"https://api.evotor.ru/stores",
|
"https://api.evotor.ru/stores",
|
||||||
|
user_id=user.id,
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {conn.access_token}",
|
"Authorization": f"Bearer {conn.access_token}",
|
||||||
"Accept": "application/vnd.evotor.v2+json",
|
"Accept": "application/vnd.evotor.v2+json",
|
||||||
@@ -344,8 +346,9 @@ async def connections_vk_test(request: Request, db: Session = Depends(get_db)):
|
|||||||
if not conn.vk_user_id:
|
if not conn.vk_user_id:
|
||||||
return JSONResponse({"ok": False, "message": "Укажите ID сообщества для проверки подключения."})
|
return JSONResponse({"ok": False, "message": "Укажите ID сообщества для проверки подключения."})
|
||||||
|
|
||||||
r = httpx.get(
|
r = api_logger.get(
|
||||||
"https://api.vk.com/method/groups.getById",
|
"https://api.vk.com/method/groups.getById",
|
||||||
|
user_id=user.id,
|
||||||
params={
|
params={
|
||||||
"group_id": conn.vk_user_id,
|
"group_id": conn.vk_user_id,
|
||||||
"fields": "market",
|
"fields": "market",
|
||||||
|
|||||||
81
web/routes/logs.py
Normal file
81
web/routes/logs.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""API request/response log viewer (admin only)."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.auth.session import get_current_user
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models.connections import ApiLog
|
||||||
|
from web.templates_env import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
PAGE_SIZE = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _render(request, template, ctx):
|
||||||
|
return templates.TemplateResponse(template, {"request": request, **ctx})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/logs")
|
||||||
|
async def admin_logs(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: str = "",
|
||||||
|
method: str = "",
|
||||||
|
status: str = "",
|
||||||
|
q: str = "",
|
||||||
|
page: int = 1,
|
||||||
|
hours: int = 168,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
since = datetime.utcnow() - timedelta(hours=hours)
|
||||||
|
query = db.query(ApiLog).filter(ApiLog.created_at >= since)
|
||||||
|
|
||||||
|
if service:
|
||||||
|
query = query.filter(ApiLog.service == service)
|
||||||
|
if method:
|
||||||
|
query = query.filter(ApiLog.method == method)
|
||||||
|
if status:
|
||||||
|
try:
|
||||||
|
st = int(status)
|
||||||
|
query = query.filter(ApiLog.response_status == st)
|
||||||
|
except ValueError:
|
||||||
|
if status == "error":
|
||||||
|
query = query.filter(ApiLog.response_status >= 400)
|
||||||
|
elif status == "ok":
|
||||||
|
query = query.filter(ApiLog.response_status < 400)
|
||||||
|
if q:
|
||||||
|
like = f"%{q}%"
|
||||||
|
query = query.filter(
|
||||||
|
ApiLog.url.like(like) | ApiLog.response_body.like(like)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
logs = (
|
||||||
|
query.order_by(ApiLog.created_at.desc())
|
||||||
|
.offset((page - 1) * PAGE_SIZE)
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||||
|
|
||||||
|
return _render(request, "admin/logs.html", {
|
||||||
|
"user": user,
|
||||||
|
"logs": logs,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"page_size": PAGE_SIZE,
|
||||||
|
"filter_service": service,
|
||||||
|
"filter_method": method,
|
||||||
|
"filter_status": status,
|
||||||
|
"filter_q": q,
|
||||||
|
"filter_hours": hours,
|
||||||
|
})
|
||||||
@@ -5,9 +5,9 @@ for every connected user and upsert into vk_cached_* tables.
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import httpx
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
|
import web.lib.api_logger as api_logger
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.database import SessionLocal
|
from web.database import SessionLocal
|
||||||
from web.models.connections import VkCachedAlbum, VkCachedProduct, VkConnection
|
from web.models.connections import VkCachedAlbum, VkCachedProduct, VkConnection
|
||||||
@@ -21,9 +21,9 @@ def _now() -> datetime:
|
|||||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
def _vk_get(method: str, params: dict, token: str) -> dict:
|
def _vk_get(method: str, params: dict, token: str, user_id: int | None = None) -> dict:
|
||||||
params = {**params, "access_token": token, "v": settings.VK_API_VERSION}
|
params = {**params, "access_token": token, "v": settings.VK_API_VERSION}
|
||||||
r = httpx.get(f"{VK_API}/{method}", params=params, timeout=20)
|
r = api_logger.get(f"{VK_API}/{method}", user_id=user_id, params=params, timeout=20)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ def _sync_user(db, user_id: int, token: str, group_id: str) -> None:
|
|||||||
|
|
||||||
# ── albums ────────────────────────────────────────────────────────────────
|
# ── albums ────────────────────────────────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
data = _vk_get("market.getAlbums", {"owner_id": owner_id, "count": 100}, token)
|
data = _vk_get("market.getAlbums", {"owner_id": owner_id, "count": 100}, token, user_id=user_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("user=%s vk fetch albums failed: %s", user_id, e)
|
logger.warning("user=%s vk fetch albums failed: %s", user_id, e)
|
||||||
return
|
return
|
||||||
@@ -83,6 +83,7 @@ def _sync_user(db, user_id: int, token: str, group_id: str) -> None:
|
|||||||
"market.get",
|
"market.get",
|
||||||
{"owner_id": owner_id, "count": 200, "offset": offset, "extended": 1},
|
{"owner_id": owner_id, "count": 200, "offset": offset, "extended": 1},
|
||||||
token,
|
token,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("user=%s vk fetch products (extended) failed: %s", user_id, e)
|
logger.warning("user=%s vk fetch products (extended) failed: %s", user_id, e)
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import os
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import httpx
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
|
import web.lib.api_logger as api_logger
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.database import SessionLocal
|
from web.database import SessionLocal
|
||||||
from web.models.connections import (
|
from web.models.connections import (
|
||||||
@@ -63,14 +63,14 @@ def _name_for_vk(name: str) -> str:
|
|||||||
return name.replace(";", ",")
|
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}
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
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."""
|
"""Upload the default product photo and return photo_id, or None on failure."""
|
||||||
photo_path = settings.VK_DEFAULT_PHOTO_PATH
|
photo_path = settings.VK_DEFAULT_PHOTO_PATH
|
||||||
if not os.path.exists(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
|
return None
|
||||||
try:
|
try:
|
||||||
# Step 1: get upload URL
|
# 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:
|
if "error" in resp:
|
||||||
logger.warning("getProductPhotoUploadServer error: %s", resp["error"])
|
logger.warning("getProductPhotoUploadServer error: %s", resp["error"])
|
||||||
return None
|
return None
|
||||||
@@ -86,12 +86,12 @@ def _upload_photo(token: str, group_id: str) -> str | None:
|
|||||||
|
|
||||||
# Step 2: upload file
|
# Step 2: upload file
|
||||||
with open(photo_path, "rb") as f:
|
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()
|
up.raise_for_status()
|
||||||
upload_obj = up.text
|
upload_obj = up.text
|
||||||
|
|
||||||
# Step 3: save
|
# 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:
|
if "error" in resp2:
|
||||||
logger.warning("saveProductPhoto error: %s", resp2["error"])
|
logger.warning("saveProductPhoto error: %s", resp2["error"])
|
||||||
return None
|
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:
|
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."""
|
"""Upload photo once per sync run per user, cache the result."""
|
||||||
if user_id not in _PHOTO_CACHE:
|
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]
|
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", {
|
resp = _vk_post("market.addAlbum", {
|
||||||
"owner_id": f"-{vk_group_id}",
|
"owner_id": f"-{vk_group_id}",
|
||||||
"title": group_name,
|
"title": group_name,
|
||||||
}, token)
|
}, token, user_id=user_id)
|
||||||
if "error" in resp:
|
if "error" in resp:
|
||||||
logger.warning("market.addAlbum error for '%s': %s", group_name, resp["error"])
|
logger.warning("market.addAlbum error for '%s': %s", group_name, resp["error"])
|
||||||
return None
|
return None
|
||||||
@@ -212,7 +212,7 @@ def _sync_product(
|
|||||||
"category_id": settings.VK_CATEGORY_ID,
|
"category_id": settings.VK_CATEGORY_ID,
|
||||||
"price": price_kopecks,
|
"price": price_kopecks,
|
||||||
"stock_amount": stock,
|
"stock_amount": stock,
|
||||||
}, token)
|
}, token, user_id=user_id)
|
||||||
if "error" in resp:
|
if "error" in resp:
|
||||||
logger.warning("market.edit error product=%s: %s", product.evotor_id, resp["error"])
|
logger.warning("market.edit error product=%s: %s", product.evotor_id, resp["error"])
|
||||||
return
|
return
|
||||||
@@ -223,12 +223,12 @@ def _sync_product(
|
|||||||
"owner_id": owner_id,
|
"owner_id": owner_id,
|
||||||
"item_id": product.vk_product_id,
|
"item_id": product.vk_product_id,
|
||||||
"album_ids": old_album_id,
|
"album_ids": old_album_id,
|
||||||
}, token)
|
}, token, user_id=user_id)
|
||||||
resp_album = _vk_post("market.addToAlbum", {
|
resp_album = _vk_post("market.addToAlbum", {
|
||||||
"owner_id": owner_id,
|
"owner_id": owner_id,
|
||||||
"item_ids": product.vk_product_id,
|
"item_ids": product.vk_product_id,
|
||||||
"album_ids": album_id,
|
"album_ids": album_id,
|
||||||
}, token)
|
}, token, user_id=user_id)
|
||||||
if "error" in resp_album:
|
if "error" in resp_album:
|
||||||
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp_album["error"])
|
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp_album["error"])
|
||||||
else:
|
else:
|
||||||
@@ -255,7 +255,7 @@ def _sync_product(
|
|||||||
"price": price_kopecks,
|
"price": price_kopecks,
|
||||||
"main_photo_id": photo_id,
|
"main_photo_id": photo_id,
|
||||||
"stock_amount": stock,
|
"stock_amount": stock,
|
||||||
}, token)
|
}, token, user_id=user_id)
|
||||||
if "error" in resp:
|
if "error" in resp:
|
||||||
logger.warning("market.add error product=%s: %s", product.evotor_id, resp["error"])
|
logger.warning("market.add error product=%s: %s", product.evotor_id, resp["error"])
|
||||||
return
|
return
|
||||||
@@ -269,7 +269,7 @@ def _sync_product(
|
|||||||
"owner_id": owner_id,
|
"owner_id": owner_id,
|
||||||
"item_ids": vk_item_id,
|
"item_ids": vk_item_id,
|
||||||
"album_ids": album_id,
|
"album_ids": album_id,
|
||||||
}, token)
|
}, token, user_id=user_id)
|
||||||
if "error" in resp2:
|
if "error" in resp2:
|
||||||
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp2["error"])
|
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", {
|
resp = _vk_post("market.delete", {
|
||||||
"owner_id": owner_id,
|
"owner_id": owner_id,
|
||||||
"item_id": vk_p.vk_product_id,
|
"item_id": vk_p.vk_product_id,
|
||||||
}, token)
|
}, token, user_id=user_id)
|
||||||
if "error" in resp:
|
if "error" in resp:
|
||||||
logger.warning("market.delete error id=%s: %s", vk_p.vk_product_id, resp["error"])
|
logger.warning("market.delete error id=%s: %s", vk_p.vk_product_id, resp["error"])
|
||||||
results["errors"] += 1
|
results["errors"] += 1
|
||||||
|
|||||||
147
web/templates/admin/logs.html
Normal file
147
web/templates/admin/logs.html
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}API Логи — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-journal-text me-2"></i>API Логи</h1>
|
||||||
|
<span class="text-muted small">Найдено: {{ total }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── filters ── #}
|
||||||
|
<form method="get" action="/admin/logs" class="mb-3" style="display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center;">
|
||||||
|
<select name="service" style="width:auto;">
|
||||||
|
<option value="" {% if not filter_service %}selected{% endif %}>Все сервисы</option>
|
||||||
|
<option value="evotor" {% if filter_service == 'evotor' %}selected{% endif %}>Эвотор</option>
|
||||||
|
<option value="vk" {% if filter_service == 'vk' %}selected{% endif %}>ВКонтакте</option>
|
||||||
|
<option value="other" {% if filter_service == 'other' %}selected{% endif %}>Другое</option>
|
||||||
|
</select>
|
||||||
|
<select name="method" style="width:auto;">
|
||||||
|
<option value="" {% if not filter_method %}selected{% endif %}>Все методы</option>
|
||||||
|
<option value="GET" {% if filter_method == 'GET' %}selected{% endif %}>GET</option>
|
||||||
|
<option value="POST" {% if filter_method == 'POST' %}selected{% endif %}>POST</option>
|
||||||
|
</select>
|
||||||
|
<select name="status" style="width:auto;">
|
||||||
|
<option value="" {% if not filter_status %}selected{% endif %}>Любой статус</option>
|
||||||
|
<option value="ok" {% if filter_status == 'ok' %}selected{% endif %}>2xx / 3xx</option>
|
||||||
|
<option value="error" {% if filter_status == 'error' %}selected{% endif %}>4xx / 5xx</option>
|
||||||
|
<option value="200" {% if filter_status == '200' %}selected{% endif %}>200</option>
|
||||||
|
<option value="401" {% if filter_status == '401' %}selected{% endif %}>401</option>
|
||||||
|
<option value="403" {% if filter_status == '403' %}selected{% endif %}>403</option>
|
||||||
|
<option value="429" {% if filter_status == '429' %}selected{% endif %}>429</option>
|
||||||
|
<option value="500" {% if filter_status == '500' %}selected{% endif %}>500</option>
|
||||||
|
</select>
|
||||||
|
<select name="hours" style="width:auto;">
|
||||||
|
<option value="1" {% if filter_hours == 1 %}selected{% endif %}>Последний час</option>
|
||||||
|
<option value="6" {% if filter_hours == 6 %}selected{% endif %}>6 часов</option>
|
||||||
|
<option value="24" {% if filter_hours == 24 %}selected{% endif %}>24 часа</option>
|
||||||
|
<option value="168" {% if filter_hours == 168 or (not filter_hours) %}selected{% endif %}>7 дней</option>
|
||||||
|
<option value="720" {% if filter_hours == 720 %}selected{% endif %}>30 дней</option>
|
||||||
|
</select>
|
||||||
|
<input type="search" name="q" value="{{ filter_q }}" placeholder="URL или тело ответа…" style="flex:1; min-width:160px;">
|
||||||
|
<button type="submit">Применить</button>
|
||||||
|
{% if filter_service or filter_method or filter_status or filter_q or filter_hours != 24 %}
|
||||||
|
<a href="/admin/logs" role="button" class="outline secondary">Сбросить</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<article class="card" style="padding:0;">
|
||||||
|
{% if logs %}
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="align-middle" style="font-size:0.82rem;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:140px;">Время</th>
|
||||||
|
<th style="width:60px;">Сервис</th>
|
||||||
|
<th style="width:40px;">Метод</th>
|
||||||
|
<th style="width:50px;">Статус</th>
|
||||||
|
<th style="width:60px;">Мс</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th style="width:30px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
{% set is_error = log.response_status and log.response_status >= 400 %}
|
||||||
|
<tr class="{{ 'text-danger' if is_error else '' }}" style="cursor:pointer;" onclick="toggleDetail({{ log.id }})">
|
||||||
|
<td class="text-muted">{{ log.created_at | datefmt }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ 'badge-evotor' if log.service == 'evotor' else 'badge-vk' if log.service == 'vk' else '' }}">
|
||||||
|
{{ log.service }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ log.method }}</code></td>
|
||||||
|
<td>
|
||||||
|
{% if log.response_status %}
|
||||||
|
<span class="{{ 'text-danger' if is_error else 'text-muted' }}">{{ log.response_status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">{{ log.duration_ms if log.duration_ms is not none else '—' }}</td>
|
||||||
|
<td style="max-width:400px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||||
|
<span title="{{ log.url }}">{{ log.url }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted"><i class="bi bi-chevron-down"></i></td>
|
||||||
|
</tr>
|
||||||
|
<tr id="detail-{{ log.id }}" style="display:none; background:var(--pico-card-background-color);">
|
||||||
|
<td colspan="7" style="padding:0.75rem 1rem;">
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small mb-1"><strong>URL</strong></div>
|
||||||
|
<code style="word-break:break-all; font-size:0.78rem;">{{ log.url }}</code>
|
||||||
|
{% if log.request_body %}
|
||||||
|
<div class="text-muted small mt-2 mb-1"><strong>Request body</strong></div>
|
||||||
|
<pre style="font-size:0.75rem; max-height:200px; overflow:auto; margin:0; background:var(--pico-code-background-color); padding:0.5rem; border-radius:4px;">{{ log.request_body }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small mb-1"><strong>Response ({{ log.response_status }})</strong></div>
|
||||||
|
{% if log.response_body %}
|
||||||
|
<pre style="font-size:0.75rem; max-height:200px; overflow:auto; margin:0; background:var(--pico-code-background-color); padding:0.5rem; border-radius:4px;">{{ log.response_body }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── pagination ── #}
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div style="display:flex; justify-content:center; gap:0.5rem; padding:1rem;">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="?service={{ filter_service }}&method={{ filter_method }}&status={{ filter_status }}&q={{ filter_q }}&hours={{ filter_hours }}&page={{ page - 1 }}" role="button" class="outline secondary sm">← Назад</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-muted" style="line-height:2.2rem;">Стр. {{ page }} / {{ total_pages }}</span>
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="?service={{ filter_service }}&method={{ filter_method }}&status={{ filter_status }}&q={{ filter_q }}&hours={{ filter_hours }}&page={{ page + 1 }}" role="button" class="outline secondary sm">Вперёд →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-journal-x" style="font-size:2rem;"></i>
|
||||||
|
<p class="mt-2">Записей не найдено за выбранный период.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge { display:inline-block; padding:0.1rem 0.4rem; border-radius:4px; font-size:0.75rem; font-weight:600; }
|
||||||
|
.badge-evotor { background:#e8f4fd; color:#0986E2; }
|
||||||
|
.badge-vk { background:#e8f0fe; color:#3b5998; }
|
||||||
|
.text-danger { color:#dc3545; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleDetail(id) {
|
||||||
|
const row = document.getElementById('detail-' + id);
|
||||||
|
row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<li><a href="/sync">Синхронизация</a></li>
|
<li><a href="/sync">Синхронизация</a></li>
|
||||||
{% if user.role in ('admin', 'system') %}
|
{% if user.role in ('admin', 'system') %}
|
||||||
<li><a href="/admin/users"><i class="bi bi-shield-lock"></i> Админ</a></li>
|
<li><a href="/admin/users"><i class="bi bi-shield-lock"></i> Админ</a></li>
|
||||||
|
<li><a href="/admin/logs"><i class="bi bi-journal-text"></i> Логи</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a href="/profile"><i class="bi bi-person-circle"></i> Личный кабинет</a></li>
|
<li><a href="/profile"><i class="bi bi-person-circle"></i> Личный кабинет</a></li>
|
||||||
<li><a href="/logout" class="secondary">Выход</a></li>
|
<li><a href="/logout" class="secondary">Выход</a></li>
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<li><a href="/sync">Синхронизация</a></li>
|
<li><a href="/sync">Синхронизация</a></li>
|
||||||
{% if user.role in ('admin', 'system') %}
|
{% if user.role in ('admin', 'system') %}
|
||||||
<li><a href="/admin/users">Админ</a></li>
|
<li><a href="/admin/users">Админ</a></li>
|
||||||
|
<li><a href="/admin/logs">Логи</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a href="/profile">Личный кабинет</a></li>
|
<li><a href="/profile">Личный кабинет</a></li>
|
||||||
<li><a href="/logout">Выход</a></li>
|
<li><a href="/logout">Выход</a></li>
|
||||||
|
|||||||
Reference in New Issue
Block a user