Replace the entire Python/FastAPI backend with a Node.js/TypeScript stack: - Framework: Hono + @hono/node-server - Templates: Nunjucks (.njk) replacing Jinja2 (.html) - ORM: Drizzle ORM with mysql2 (same MariaDB schema, no migrations needed) - Sessions: hono-sessions with CookieStore - CSS: Pico CSS v2 replacing Bootstrap 5 (Bootstrap Icons CDN kept) - Dev: tsx watch; Prod: tsc + node dist/index.js Original Python app preserved in web-python/ as backup. Updated Dockerfile.web and docker-compose.yml for Node.js deployment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
309 lines
9.8 KiB
Python
309 lines
9.8 KiB
Python
import csv
|
||
import io
|
||
|
||
from fastapi import APIRouter, Request, Depends
|
||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||
from web.templates_env import templates
|
||
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")
|
||
|
||
|
||
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}"},
|
||
)
|