Files
evo-sync/web/routes/catalog.py
mguschin 9aeef73b10 feat: release v1.8.0 — connections dashboard, VK OAuth, sync config, catalog browser
- Connections dashboard with add/remove flow and background health checks
- VK OAuth connection with profile info and health monitoring
- Sync configuration page with master toggle and filter summary
- Catalog browser with store/group/product tables, filter management, CSV export
- Alembic migrations for all new tables
- run/read_config.py for shell sync script DB integration
- CHANGELOG.md updated for v1.8.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:08:19 +03:00

310 lines
9.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import csv
import io
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.database import get_db
from web.evotor_api import refresh_catalog_cache
from web.models import User, EvotorConnection, SyncConfig, SyncFilter, CachedStore, CachedGroup, CachedProduct
router = APIRouter(prefix="/catalog")
templates = Jinja2Templates(directory="web/templates")
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first()
if not config:
config = SyncConfig(user_id=user_id, is_enabled=False)
db.add(config)
db.commit()
db.refresh(config)
return config
def _filter_map(config: SyncConfig) -> dict:
"""Returns {entity_id: filter_mode} for quick lookup."""
return {f.entity_id: f.filter_mode for f in config.filters}
def _filter_label(mode: str | None) -> str:
if mode == "include":
return "include"
if mode == "exclude":
return "exclude"
return "none"
@router.get("")
async def catalog_stores(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if not evotor:
return templates.TemplateResponse("catalog_stores.html", {
"request": request, "user": user,
"evotor": None, "stores": [], "filter_map": {}, "fetched_at": None,
})
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
# Auto-refresh if cache is empty
if not stores:
await refresh_catalog_cache(user.id, evotor.access_token, db)
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
fetched_at = stores[0].fetched_at if stores else None
return templates.TemplateResponse("catalog_stores.html", {
"request": request,
"user": user,
"evotor": evotor,
"stores": stores,
"filter_map": fmap,
"fetched_at": fetched_at,
})
@router.get("/groups")
def catalog_groups(
request: Request,
store_id: str,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
store = db.query(CachedStore).filter(
CachedStore.user_id == user.id,
CachedStore.evotor_id == store_id,
).first()
if not store:
return RedirectResponse("/catalog", 303)
groups = db.query(CachedGroup).filter(
CachedGroup.user_id == user.id,
CachedGroup.store_evotor_id == store_id,
).order_by(CachedGroup.name).all()
# Count products per group
product_counts = {}
for g in groups:
product_counts[g.evotor_id] = db.query(CachedProduct).filter(
CachedProduct.user_id == user.id,
CachedProduct.group_evotor_id == g.evotor_id,
).count()
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
return templates.TemplateResponse("catalog_groups.html", {
"request": request,
"user": user,
"store": store,
"groups": groups,
"product_counts": product_counts,
"filter_map": fmap,
})
@router.get("/products")
def catalog_products(
request: Request,
store_id: str,
group_id: str | None = None,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
store = db.query(CachedStore).filter(
CachedStore.user_id == user.id,
CachedStore.evotor_id == store_id,
).first()
if not store:
return RedirectResponse("/catalog", 303)
group = None
query = db.query(CachedProduct).filter(
CachedProduct.user_id == user.id,
CachedProduct.store_evotor_id == store_id,
)
if group_id:
group = db.query(CachedGroup).filter(
CachedGroup.user_id == user.id,
CachedGroup.evotor_id == group_id,
).first()
query = query.filter(CachedProduct.group_evotor_id == group_id)
products = query.order_by(CachedProduct.name).all()
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
return templates.TemplateResponse("catalog_products.html", {
"request": request,
"user": user,
"store": store,
"group": group,
"products": products,
"filter_map": fmap,
})
@router.post("/filter")
async def catalog_filter(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
form = await request.form()
entity_type = form.get("entity_type")
entity_id = form.get("entity_id")
entity_name = form.get("entity_name")
filter_mode = form.get("filter_mode") # "include", "exclude", "none"
parent_entity_id = form.get("parent_entity_id") or None
redirect_to = form.get("redirect_to", "/catalog")
config = _get_or_create_sync_config(db, user.id)
existing = db.query(SyncFilter).filter(
SyncFilter.sync_config_id == config.id,
SyncFilter.entity_type == entity_type,
SyncFilter.entity_id == entity_id,
).first()
if filter_mode == "none":
if existing:
db.delete(existing)
elif existing:
existing.filter_mode = filter_mode
existing.entity_name = entity_name
else:
db.add(SyncFilter(
sync_config_id=config.id,
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
filter_mode=filter_mode,
parent_entity_id=parent_entity_id,
))
db.commit()
return RedirectResponse(redirect_to, 303)
@router.post("/refresh")
async def catalog_refresh(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if evotor:
await refresh_catalog_cache(user.id, evotor.access_token, db)
return RedirectResponse("/catalog", 303)
@router.get("/export")
def catalog_export(
request: Request,
type: str,
store_id: str | None = None,
group_id: str | None = None,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
def filter_label(eid):
m = fmap.get(eid)
if m == "include":
return "Включено"
if m == "exclude":
return "Исключено"
return "Нет правила"
output = io.StringIO()
output.write("\ufeff") # UTF-8 BOM for Excel
writer = csv.writer(output)
from datetime import date
today = date.today().strftime("%Y%m%d")
if type == "stores":
writer.writerow(["Название", "Адрес", "ID", "Фильтр"])
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
for s in stores:
writer.writerow([s.name, s.address or "", s.evotor_id, filter_label(s.evotor_id)])
filename = f"stores_{today}.csv"
elif type == "groups":
writer.writerow(["Магазин", "Название", "ID", "Фильтр"])
q = db.query(CachedGroup, CachedStore).join(
CachedStore,
(CachedStore.evotor_id == CachedGroup.store_evotor_id) & (CachedStore.user_id == user.id)
).filter(CachedGroup.user_id == user.id)
if store_id:
q = q.filter(CachedGroup.store_evotor_id == store_id)
for g, s in q.order_by(CachedGroup.name).all():
writer.writerow([s.name, g.name, g.evotor_id, filter_label(g.evotor_id)])
filename = f"groups_{today}.csv"
else: # products
writer.writerow(["Магазин", "Группа", "Название", "Артикул", "Цена", "Количество", "Ед. измерения", "В продаже", "ID", "Фильтр"])
q = db.query(CachedProduct, CachedStore, CachedGroup).join(
CachedStore,
(CachedStore.evotor_id == CachedProduct.store_evotor_id) & (CachedStore.user_id == user.id)
).outerjoin(
CachedGroup,
(CachedGroup.evotor_id == CachedProduct.group_evotor_id) & (CachedGroup.user_id == user.id)
).filter(CachedProduct.user_id == user.id)
if store_id:
q = q.filter(CachedProduct.store_evotor_id == store_id)
if group_id:
q = q.filter(CachedProduct.group_evotor_id == group_id)
for p, s, g in q.order_by(CachedProduct.name).all():
writer.writerow([
s.name,
g.name if g else "",
p.name,
p.article_number or "",
p.price or "",
p.quantity or "",
p.measure_name or "",
"Да" if p.allow_to_sell else ("Нет" if p.allow_to_sell is not None else ""),
p.evotor_id,
filter_label(p.evotor_id),
])
filename = f"products_{today}.csv"
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)