mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat(web,docs): visual summaries (curve, pips/sources incl. 'C', non‑land sources), tooltip copy, favicon; diagnostics (/healthz, request‑id, global handlers); fetches excluded, basics CSV fallback, list highlight polish; README/DOCKER/release-notes/CHANGELOG updated
This commit is contained in:
parent
625f6abb13
commit
8d1f6a8ac4
27 changed files with 1704 additions and 154 deletions
|
|
@ -15,9 +15,28 @@ router = APIRouter(prefix="/build")
|
|||
async def build_index(request: Request) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
# Determine last step (fallback heuristics if not set)
|
||||
last_step = sess.get("last_step")
|
||||
if not last_step:
|
||||
if sess.get("build_ctx"):
|
||||
last_step = 5
|
||||
elif sess.get("ideals"):
|
||||
last_step = 4
|
||||
elif sess.get("bracket"):
|
||||
last_step = 3
|
||||
elif sess.get("commander"):
|
||||
last_step = 2
|
||||
else:
|
||||
last_step = 1
|
||||
resp = templates.TemplateResponse(
|
||||
"build/index.html",
|
||||
{"request": request, "sid": sid, "commander": sess.get("commander"), "tags": sess.get("tags", [])},
|
||||
{
|
||||
"request": request,
|
||||
"sid": sid,
|
||||
"commander": sess.get("commander"),
|
||||
"tags": sess.get("tags", []),
|
||||
"last_step": last_step,
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
|
@ -25,7 +44,12 @@ async def build_index(request: Request) -> HTMLResponse:
|
|||
|
||||
@router.get("/step1", response_class=HTMLResponse)
|
||||
async def build_step1(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 1
|
||||
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/step1", response_class=HTMLResponse)
|
||||
|
|
@ -45,7 +69,10 @@ async def build_step1_search(
|
|||
top_name = candidates[0][0]
|
||||
res = orch.commander_select(top_name)
|
||||
if res.get("ok"):
|
||||
return templates.TemplateResponse(
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 2
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step2.html",
|
||||
{
|
||||
"request": request,
|
||||
|
|
@ -56,7 +83,12 @@ async def build_step1_search(
|
|||
"brackets": orch.bracket_options(),
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 1
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step1.html",
|
||||
{
|
||||
"request": request,
|
||||
|
|
@ -67,24 +99,39 @@ async def build_step1_search(
|
|||
"count": len(candidates) if candidates else 0,
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/step1/inspect", response_class=HTMLResponse)
|
||||
async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 1
|
||||
info = orch.commander_inspect(name)
|
||||
return templates.TemplateResponse(
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step1.html",
|
||||
{"request": request, "inspect": info, "selected": name, "tags": orch.tags_for_commander(name)},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/step1/confirm", response_class=HTMLResponse)
|
||||
async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||
res = orch.commander_select(name)
|
||||
if not res.get("ok"):
|
||||
return templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name})
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 1
|
||||
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name})
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
# Proceed to step2 placeholder
|
||||
return templates.TemplateResponse(
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 2
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step2.html",
|
||||
{
|
||||
"request": request,
|
||||
|
|
@ -95,19 +142,24 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
|
|||
"brackets": orch.bracket_options(),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/step2", response_class=HTMLResponse)
|
||||
async def build_step2_get(request: Request) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 2
|
||||
commander = sess.get("commander")
|
||||
if not commander:
|
||||
# Fallback to step1 if no commander in session
|
||||
return templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
|
||||
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
tags = orch.tags_for_commander(commander)
|
||||
selected = sess.get("tags", [])
|
||||
return templates.TemplateResponse(
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step2.html",
|
||||
{
|
||||
"request": request,
|
||||
|
|
@ -123,6 +175,8 @@ async def build_step2_get(request: Request) -> HTMLResponse:
|
|||
"tag_mode": sess.get("tag_mode", "AND"),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/step2", response_class=HTMLResponse)
|
||||
|
|
@ -138,7 +192,10 @@ async def build_step2_submit(
|
|||
# Validate primary tag selection if tags are available
|
||||
available_tags = orch.tags_for_commander(commander)
|
||||
if available_tags and not (primary_tag and primary_tag.strip()):
|
||||
return templates.TemplateResponse(
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 2
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step2.html",
|
||||
{
|
||||
"request": request,
|
||||
|
|
@ -155,6 +212,8 @@ async def build_step2_submit(
|
|||
"tag_mode": (tag_mode or "AND"),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
# Save selection to session (basic MVP; real build will use this later)
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
|
@ -164,7 +223,8 @@ async def build_step2_submit(
|
|||
sess["tag_mode"] = (tag_mode or "AND").upper()
|
||||
sess["bracket"] = int(bracket)
|
||||
# Proceed to Step 3 placeholder for now
|
||||
return templates.TemplateResponse(
|
||||
sess["last_step"] = 3
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step3.html",
|
||||
{
|
||||
"request": request,
|
||||
|
|
@ -176,6 +236,8 @@ async def build_step2_submit(
|
|||
"values": orch.ideal_defaults(),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/step3", response_class=HTMLResponse)
|
||||
|
|
@ -220,7 +282,8 @@ async def build_step3_submit(
|
|||
if errors:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
return templates.TemplateResponse(
|
||||
sess["last_step"] = 3
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step3.html",
|
||||
{
|
||||
"request": request,
|
||||
|
|
@ -233,6 +296,8 @@ async def build_step3_submit(
|
|||
"bracket": sess.get("bracket"),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
# Save to session
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
|
|
@ -240,7 +305,8 @@ async def build_step3_submit(
|
|||
sess["ideals"] = submitted
|
||||
|
||||
# Proceed to review (Step 4)
|
||||
return templates.TemplateResponse(
|
||||
sess["last_step"] = 4
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step4.html",
|
||||
{
|
||||
"request": request,
|
||||
|
|
@ -249,12 +315,15 @@ async def build_step3_submit(
|
|||
"commander": sess.get("commander"),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/step3", response_class=HTMLResponse)
|
||||
async def build_step3_get(request: Request) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 3
|
||||
defaults = orch.ideal_defaults()
|
||||
values = sess.get("ideals") or defaults
|
||||
resp = templates.TemplateResponse(
|
||||
|
|
@ -277,6 +346,7 @@ async def build_step3_get(request: Request) -> HTMLResponse:
|
|||
async def build_step4_get(request: Request) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 4
|
||||
labels = orch.ideal_labels()
|
||||
values = sess.get("ideals") or orch.ideal_defaults()
|
||||
commander = sess.get("commander")
|
||||
|
|
@ -302,6 +372,7 @@ async def build_toggle_owned_review(
|
|||
"""Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4."""
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 4
|
||||
only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False
|
||||
pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False
|
||||
sess["use_owned_only"] = only_val
|
||||
|
|
@ -329,6 +400,7 @@ async def build_toggle_owned_review(
|
|||
async def build_step5_get(request: Request) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
sess["last_step"] = 5
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step5.html",
|
||||
{
|
||||
|
|
@ -344,6 +416,12 @@ async def build_step5_get(request: Request) -> HTMLResponse:
|
|||
"stage_label": None,
|
||||
"log": None,
|
||||
"added_cards": [],
|
||||
"i": None,
|
||||
"n": None,
|
||||
"total_cards": None,
|
||||
"added_total": 0,
|
||||
"show_skipped": False,
|
||||
"skipped": False,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
},
|
||||
)
|
||||
|
|
@ -383,10 +461,12 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
prefer_owned=prefer,
|
||||
owned_names=owned_names,
|
||||
)
|
||||
show_skipped = True if (request.query_params.get('show_skipped') == '1' or (await request.form().get('show_skipped', None) == '1') if hasattr(request, 'form') else False) else False
|
||||
# Read show_skipped from either query or form safely
|
||||
show_skipped = True if (request.query_params.get('show_skipped') == '1') else False
|
||||
try:
|
||||
form = await request.form()
|
||||
show_skipped = True if (form.get('show_skipped') == '1') else show_skipped
|
||||
if form and form.get('show_skipped') == '1':
|
||||
show_skipped = True
|
||||
except Exception:
|
||||
pass
|
||||
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
|
||||
|
|
@ -400,6 +480,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
csv_path = res.get("csv_path") if res.get("done") else None
|
||||
txt_path = res.get("txt_path") if res.get("done") else None
|
||||
summary = res.get("summary") if res.get("done") else None
|
||||
total_cards = res.get("total_cards")
|
||||
added_total = res.get("added_total")
|
||||
sess["last_step"] = 5
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step5.html",
|
||||
{
|
||||
|
|
@ -422,6 +505,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
|
|||
"summary": summary,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"show_skipped": show_skipped,
|
||||
"total_cards": total_cards,
|
||||
"added_total": added_total,
|
||||
"skipped": bool(res.get("skipped")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -474,6 +560,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
csv_path = res.get("csv_path") if res.get("done") else None
|
||||
txt_path = res.get("txt_path") if res.get("done") else None
|
||||
summary = res.get("summary") if res.get("done") else None
|
||||
total_cards = res.get("total_cards")
|
||||
added_total = res.get("added_total")
|
||||
sess["last_step"] = 5
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step5.html",
|
||||
{
|
||||
|
|
@ -496,6 +585,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
|
|||
"summary": summary,
|
||||
"game_changers": bc.GAME_CHANGERS,
|
||||
"show_skipped": show_skipped,
|
||||
"total_cards": total_cards,
|
||||
"added_total": added_total,
|
||||
"skipped": bool(res.get("skipped")),
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
@ -554,6 +646,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
|
|||
csv_path = res.get("csv_path") if res.get("done") else None
|
||||
txt_path = res.get("txt_path") if res.get("done") else None
|
||||
summary = res.get("summary") if res.get("done") else None
|
||||
sess["last_step"] = 5
|
||||
resp = templates.TemplateResponse(
|
||||
"build/_step5.html",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ def _build_owned_context(request: Request, notice: str | None = None, error: str
|
|||
"""
|
||||
# Read enriched data from the store (fast path; avoids per-request CSV parsing)
|
||||
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
|
||||
added_at_map = store.get_added_at_map()
|
||||
user_tags_map = store.get_user_tags_map()
|
||||
# Default sort by name (case-insensitive)
|
||||
names_sorted = sorted(names, key=lambda s: s.lower())
|
||||
# Build filter option sets
|
||||
|
|
@ -95,6 +97,8 @@ def _build_owned_context(request: Request, notice: str | None = None, error: str
|
|||
"all_tags": all_tags,
|
||||
"all_colors": all_colors,
|
||||
"color_combos": combos,
|
||||
"added_at_map": added_at_map,
|
||||
"user_tags_map": user_tags_map,
|
||||
}
|
||||
if notice:
|
||||
ctx["notice"] = notice
|
||||
|
|
@ -139,6 +143,85 @@ async def owned_clear(request: Request) -> HTMLResponse:
|
|||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/remove", response_class=HTMLResponse)
|
||||
async def owned_remove(request: Request) -> HTMLResponse:
|
||||
"""Remove a set of names provided as JSON or form data under 'names'."""
|
||||
try:
|
||||
names: list[str] = []
|
||||
# Try JSON first
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
elif isinstance(payload, list):
|
||||
names = [str(x) for x in payload]
|
||||
except Exception:
|
||||
# Fallback to form field 'names' as comma-separated
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
removed, total = store.remove_names(names)
|
||||
notice = f"Removed {removed} name(s). Total: {total}."
|
||||
ctx = _build_owned_context(request, notice=notice)
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
except Exception as e:
|
||||
ctx = _build_owned_context(request, error=f"Remove failed: {e}")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/tag/add", response_class=HTMLResponse)
|
||||
async def owned_tag_add(request: Request) -> HTMLResponse:
|
||||
try:
|
||||
names: list[str] = []
|
||||
tag: str = ""
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
tag = str(payload.get("tag") or "").strip()
|
||||
except Exception:
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
tag = str(form.get("tag") or "").strip()
|
||||
updated = store.add_user_tag(names, tag)
|
||||
notice = f"Added tag '{tag}' to {updated} name(s)."
|
||||
ctx = _build_owned_context(request, notice=notice)
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
except Exception as e:
|
||||
ctx = _build_owned_context(request, error=f"Tag add failed: {e}")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/tag/remove", response_class=HTMLResponse)
|
||||
async def owned_tag_remove(request: Request) -> HTMLResponse:
|
||||
try:
|
||||
names: list[str] = []
|
||||
tag: str = ""
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
tag = str(payload.get("tag") or "").strip()
|
||||
except Exception:
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
tag = str(form.get("tag") or "").strip()
|
||||
updated = store.remove_user_tag(names, tag)
|
||||
notice = f"Removed tag '{tag}' from {updated} name(s)."
|
||||
ctx = _build_owned_context(request, notice=notice)
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
except Exception as e:
|
||||
ctx = _build_owned_context(request, error=f"Tag remove failed: {e}")
|
||||
return templates.TemplateResponse("owned/index.html", ctx)
|
||||
|
||||
|
||||
# Legacy /owned/use route removed; owned-only toggle now lives on the Builder Review step.
|
||||
|
||||
|
||||
|
|
@ -177,3 +260,69 @@ async def owned_export_csv() -> Response:
|
|||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=owned_cards.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export-visible")
|
||||
async def owned_export_visible_txt(request: Request) -> Response:
|
||||
"""Download the provided names (visible subset) as TXT."""
|
||||
try:
|
||||
names: list[str] = []
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
elif isinstance(payload, list):
|
||||
names = [str(x) for x in payload]
|
||||
except Exception:
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
# Stable case-insensitive sort
|
||||
lines = "\n".join(sorted((names or []), key=lambda s: s.lower()))
|
||||
return Response(
|
||||
content=lines + ("\n" if lines else ""),
|
||||
media_type="text/plain; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=owned_visible.txt"},
|
||||
)
|
||||
except Exception:
|
||||
# On error return empty file
|
||||
return Response(content="", media_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
@router.post("/export-visible.csv")
|
||||
async def owned_export_visible_csv(request: Request) -> Response:
|
||||
"""Download the provided names (visible subset) with enrichment as CSV."""
|
||||
try:
|
||||
names: list[str] = []
|
||||
try:
|
||||
payload = await request.json()
|
||||
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
|
||||
names = [str(x) for x in payload.get("names")]
|
||||
elif isinstance(payload, list):
|
||||
names = [str(x) for x in payload]
|
||||
except Exception:
|
||||
form = await request.form()
|
||||
raw = form.get("names") or ""
|
||||
if raw:
|
||||
names = [s.strip() for s in str(raw).split(',') if s.strip()]
|
||||
# Build CSV using current enrichment
|
||||
all_names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
|
||||
import csv
|
||||
from io import StringIO
|
||||
buf = StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["Name", "Type", "Colors", "Tags"])
|
||||
for n in sorted((names or []), key=lambda s: s.lower()):
|
||||
tline = type_by_name.get(n, "")
|
||||
cols = ''.join(colors_by_name.get(n, []) or [])
|
||||
tags = '|'.join(tags_by_name.get(n, []) or [])
|
||||
writer.writerow([n, tline, cols, tags])
|
||||
content = buf.getvalue()
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=owned_visible.csv"},
|
||||
)
|
||||
except Exception:
|
||||
return Response(content="", media_type="text/csv; charset=utf-8")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue