- /patching/config-exclusions: exclusions iTop par serveur + bulk + push iTop
- /quickwin/config: liste globale reboot packages (au lieu de per-server)
- /patching/correspondance: builder mark PROD/NON-PROD + bulk change env/app
+ auto-detect par nomenclature + exclut stock/obsolete
- /patching/validations: workflow post-patching (en_attente/OK/KO/force)
validator obligatoire depuis contacts iTop
- /patching/validations/history/{id}: historique par serveur
- Auto creation patch_validation apres status='patched' dans QuickWin
- check_prod_validations: banniere rouge sur quickwin detail si non-prod non valides
- Menu: Correspondance sous Serveurs, Config exclusions+Validations sous Patching
- Colonne Equivalent(s) sur /servers + section Correspondance sur detail
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
973 lines
43 KiB
Python
973 lines
43 KiB
Python
"""Router QuickWin — Campagnes patching rapide avec exclusions par serveur"""
|
|
import json
|
|
from datetime import datetime
|
|
from sqlalchemy import text
|
|
from fastapi import APIRouter, Request, Depends, Query, Form
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, StreamingResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
|
|
from ..services.quickwin_service import (
|
|
get_server_configs, upsert_server_config, delete_server_config,
|
|
get_eligible_servers, list_runs, get_run, get_run_entries,
|
|
create_run, delete_run, update_entry_field,
|
|
can_start_prod, check_prod_validations, get_run_stats, inject_yum_history,
|
|
advance_run_status, get_step_stats, mark_snapshot, mark_all_snapshots,
|
|
build_yum_commands, get_available_servers, get_available_filters,
|
|
add_entries_to_run, remove_entries_from_run,
|
|
get_campaign_scope, apply_scope,
|
|
get_correspondance, get_available_prod_entries,
|
|
compute_correspondance, set_prod_pair, clear_all_pairs,
|
|
DEFAULT_GENERAL_EXCLUDES,
|
|
)
|
|
from ..services.quickwin_log_service import get_logs, get_log_stats, clear_logs
|
|
from ..config import APP_NAME
|
|
|
|
router = APIRouter()
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
|
|
@router.get("/quickwin", response_class=HTMLResponse)
|
|
async def quickwin_page(request: Request, db=Depends(get_db)):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"):
|
|
return RedirectResponse(url="/dashboard")
|
|
|
|
runs = list_runs(db)
|
|
configs = get_server_configs(db)
|
|
now = datetime.now()
|
|
|
|
ctx = base_context(request, db, user)
|
|
ctx.update({
|
|
"app_name": APP_NAME,
|
|
"runs": runs,
|
|
"configs": configs,
|
|
"config_count": len(configs),
|
|
"current_week": now.isocalendar()[1],
|
|
"current_year": now.isocalendar()[0],
|
|
"can_create": can_edit(perms, "campaigns"),
|
|
"msg": request.query_params.get("msg"),
|
|
})
|
|
return templates.TemplateResponse("quickwin.html", ctx)
|
|
|
|
|
|
# -- Config exclusions par serveur --
|
|
|
|
DEFAULT_REBOOT_PACKAGES = (
|
|
"kernel* glibc* systemd* dbus* polkit* linux-firmware* microcode_ctl* "
|
|
"tuned* dracut* grub2* kexec-tools* libselinux* selinux-policy* shim* "
|
|
"mokutil* net-snmp* NetworkManager* network-scripts* nss* openssl-libs*"
|
|
)
|
|
|
|
|
|
@router.get("/quickwin/config", response_class=HTMLResponse)
|
|
async def quickwin_config_page(request: Request, db=Depends(get_db)):
|
|
"""Page d'édition de la liste globale des packages qui nécessitent un reboot.
|
|
Cette liste est utilisée par QuickWin (en plus des exclusions iTop par serveur)."""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
|
return RedirectResponse(url="/dashboard")
|
|
|
|
from ..services.secrets_service import get_secret
|
|
current = get_secret(db, "patching_reboot_packages") or DEFAULT_REBOOT_PACKAGES
|
|
|
|
ctx = base_context(request, db, user)
|
|
ctx.update({
|
|
"app_name": APP_NAME,
|
|
"reboot_packages": current,
|
|
"default_packages": DEFAULT_REBOOT_PACKAGES,
|
|
"msg": request.query_params.get("msg"),
|
|
})
|
|
return templates.TemplateResponse("quickwin_config.html", ctx)
|
|
|
|
|
|
@router.post("/quickwin/config/save")
|
|
async def quickwin_config_save(request: Request, db=Depends(get_db),
|
|
reboot_packages: str = Form("")):
|
|
"""Sauvegarde la liste globale des packages nécessitant un reboot."""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
|
return RedirectResponse(url="/dashboard")
|
|
from ..services.secrets_service import set_secret
|
|
set_secret(db, "patching_reboot_packages", reboot_packages.strip(),
|
|
"Packages nécessitant un reboot (QuickWin)")
|
|
return RedirectResponse(url="/quickwin/config?msg=saved", status_code=303)
|
|
|
|
|
|
# -- Runs QuickWin --
|
|
|
|
@router.post("/quickwin/create")
|
|
async def quickwin_create(request: Request, db=Depends(get_db),
|
|
label: str = Form(""),
|
|
week_number: int = Form(0),
|
|
year: int = Form(0),
|
|
server_ids: str = Form(""),
|
|
notes: str = Form("")):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns"):
|
|
return RedirectResponse(url="/quickwin")
|
|
|
|
if not label:
|
|
label = f"Quick Win S{week_number:02d} {year}"
|
|
|
|
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
|
|
if not ids:
|
|
# Prendre tous les serveurs eligibles (linux, production, secops)
|
|
eligible = get_eligible_servers(db)
|
|
ids = [s.id for s in eligible]
|
|
|
|
if not ids:
|
|
return RedirectResponse(url="/quickwin?msg=no_servers", status_code=303)
|
|
|
|
try:
|
|
run_id = create_run(db, year, week_number, label, user.get("uid"), ids, notes)
|
|
return RedirectResponse(url=f"/quickwin/{run_id}", status_code=303)
|
|
except Exception as e:
|
|
db.rollback()
|
|
return RedirectResponse(url=f"/quickwin?msg=error", status_code=303)
|
|
|
|
|
|
@router.get("/quickwin/correspondance", response_class=HTMLResponse)
|
|
async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db)):
|
|
"""Redirige vers la correspondance de la derniere campagne active"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
|
return RedirectResponse(url="/dashboard")
|
|
runs = list_runs(db)
|
|
if not runs:
|
|
return RedirectResponse(url="/quickwin?msg=no_run")
|
|
return RedirectResponse(url=f"/quickwin/{runs[0].id}/correspondance")
|
|
|
|
|
|
@router.get("/quickwin/{run_id}", response_class=HTMLResponse)
|
|
async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
|
|
search: str = Query(""),
|
|
status: str = Query(""),
|
|
domain: str = Query(""),
|
|
prereq_filter: str = Query(""),
|
|
snap_filter: str = Query(""),
|
|
hp_page: int = Query(1),
|
|
p_page: int = Query(1),
|
|
per_page: int = Query(14),
|
|
add_search: str = Query(""),
|
|
add_domains: str = Query(""),
|
|
add_envs: str = Query(""),
|
|
add_zones: str = Query(""),
|
|
show_add: int = Query(0)):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
|
|
run = get_run(db, run_id)
|
|
if not run:
|
|
return RedirectResponse(url="/quickwin")
|
|
|
|
entries = get_run_entries(db, run_id)
|
|
stats = get_run_stats(db, run_id)
|
|
prod_ok = can_start_prod(db, run_id)
|
|
validations_ok, validations_blockers = check_prod_validations(db, run_id)
|
|
step_stats_hp = get_step_stats(db, run_id, "hprod")
|
|
step_stats_pr = get_step_stats(db, run_id, "prod")
|
|
|
|
hprod_all = [e for e in entries if e.branch == "hprod" and e.status != "excluded"]
|
|
prod_all = [e for e in entries if e.branch == "prod" and e.status != "excluded"]
|
|
|
|
# Filtres
|
|
def apply_filters(lst):
|
|
filtered = lst
|
|
if search:
|
|
filtered = [e for e in filtered if search.lower() in e.hostname.lower()]
|
|
if status:
|
|
filtered = [e for e in filtered if e.status == status]
|
|
if domain:
|
|
filtered = [e for e in filtered if e.domaine == domain]
|
|
if prereq_filter == "ok":
|
|
filtered = [e for e in filtered if e.prereq_ok is True]
|
|
elif prereq_filter == "ko":
|
|
filtered = [e for e in filtered if e.prereq_ok is False]
|
|
elif prereq_filter == "pending":
|
|
filtered = [e for e in filtered if e.prereq_ok is None]
|
|
if snap_filter == "ok":
|
|
filtered = [e for e in filtered if e.snap_done is True]
|
|
elif snap_filter == "pending":
|
|
filtered = [e for e in filtered if not e.snap_done]
|
|
return filtered
|
|
|
|
hprod = apply_filters(hprod_all)
|
|
prod = apply_filters(prod_all)
|
|
|
|
# Pagination
|
|
per_page = max(5, min(per_page, 100))
|
|
|
|
hp_total = len(hprod)
|
|
hp_total_pages = max(1, (hp_total + per_page - 1) // per_page)
|
|
hp_page = max(1, min(hp_page, hp_total_pages))
|
|
hp_start = (hp_page - 1) * per_page
|
|
hprod_page = hprod[hp_start:hp_start + per_page]
|
|
|
|
p_total = len(prod)
|
|
p_total_pages = max(1, (p_total + per_page - 1) // per_page)
|
|
p_page = max(1, min(p_page, p_total_pages))
|
|
p_start = (p_page - 1) * per_page
|
|
prod_page = prod[p_start:p_start + per_page]
|
|
|
|
# Campaign scope (domains/zones in this run)
|
|
scope = get_campaign_scope(db, run_id) if run.status in ("draft", "prereq") else {}
|
|
|
|
ctx = base_context(request, db, user)
|
|
ctx.update({
|
|
"app_name": APP_NAME,
|
|
"run": run, "entries": entries, "stats": stats,
|
|
"hprod": hprod_page, "prod": prod_page,
|
|
"hprod_total": hp_total, "prod_total": p_total,
|
|
"hp_page": hp_page, "hp_total_pages": hp_total_pages,
|
|
"p_page": p_page, "p_total_pages": p_total_pages,
|
|
"per_page": per_page,
|
|
"prod_ok": prod_ok,
|
|
"validations_ok": validations_ok, "validations_blockers": validations_blockers,
|
|
"step_hp": step_stats_hp, "step_pr": step_stats_pr,
|
|
"scope": scope,
|
|
"filters": {"search": search, "status": status, "domain": domain,
|
|
"prereq": prereq_filter, "snap": snap_filter},
|
|
"msg": request.query_params.get("msg"),
|
|
})
|
|
return templates.TemplateResponse("quickwin_detail.html", ctx)
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/delete")
|
|
async def quickwin_delete(request: Request, run_id: int, db=Depends(get_db)):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns"):
|
|
return RedirectResponse(url="/quickwin")
|
|
delete_run(db, run_id)
|
|
return RedirectResponse(url="/quickwin?msg=deleted", status_code=303)
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/apply-scope")
|
|
async def quickwin_apply_scope(request: Request, run_id: int, db=Depends(get_db),
|
|
scope_domains: str = Form(""), scope_zones: str = Form("")):
|
|
"""Applique le perimetre: domaines + zones selectionnes = inclus, reste = excluded"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
|
return RedirectResponse(url=f"/quickwin/{run_id}")
|
|
domains = [d for d in scope_domains.split(",") if d.strip()] or None
|
|
zones = [z for z in scope_zones.split(",") if z.strip()] or None
|
|
included, excluded = apply_scope(db, run_id, keep_domains=domains, keep_zones=zones, user=user)
|
|
return RedirectResponse(url=f"/quickwin/{run_id}?msg=scope_{included}in_{excluded}ex", status_code=303)
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/add-servers")
|
|
async def quickwin_add_servers(request: Request, run_id: int, db=Depends(get_db),
|
|
server_ids: str = Form("")):
|
|
"""Ajoute des serveurs au run (depuis le panneau d'ajout)"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
|
return RedirectResponse(url=f"/quickwin/{run_id}")
|
|
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
|
|
added = add_entries_to_run(db, run_id, ids, user=user) if ids else 0
|
|
return RedirectResponse(url=f"/quickwin/{run_id}?msg=added_{added}", status_code=303)
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/add-filtered")
|
|
async def quickwin_add_filtered(request: Request, run_id: int, db=Depends(get_db),
|
|
add_domains: str = Form(""), add_envs: str = Form(""),
|
|
add_zones: str = Form(""), add_search: str = Form("")):
|
|
"""Ajoute tous les serveurs matchant les filtres multi-select"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
|
return RedirectResponse(url=f"/quickwin/{run_id}")
|
|
domains = [d for d in add_domains.split(",") if d.strip()] or None
|
|
envs = [e for e in add_envs.split(",") if e.strip()] or None
|
|
zones = [z for z in add_zones.split(",") if z.strip()] or None
|
|
servers = get_available_servers(db, run_id, search=add_search, domains=domains, envs=envs, zones=zones)
|
|
ids = [s.id for s in servers]
|
|
added = add_entries_to_run(db, run_id, ids, user=user) if ids else 0
|
|
return RedirectResponse(url=f"/quickwin/{run_id}?msg=added_{added}", status_code=303)
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/remove-entries")
|
|
async def quickwin_remove_entries(request: Request, run_id: int, db=Depends(get_db),
|
|
entry_ids: str = Form("")):
|
|
"""Supprime des entries du run"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
|
return RedirectResponse(url=f"/quickwin/{run_id}")
|
|
ids = [int(x) for x in entry_ids.split(",") if x.strip().isdigit()]
|
|
removed = remove_entries_from_run(db, run_id, ids, user=user) if ids else 0
|
|
return RedirectResponse(url=f"/quickwin/{run_id}?msg=removed_{removed}", status_code=303)
|
|
|
|
|
|
# -- Workflow steps --
|
|
|
|
STEPS = ["draft", "prereq", "snapshot", "patching", "result", "completed"]
|
|
|
|
@router.post("/quickwin/{run_id}/advance")
|
|
async def quickwin_advance(request: Request, run_id: int, db=Depends(get_db),
|
|
target: str = Form("")):
|
|
"""Avance le run vers l'etape suivante"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
|
return RedirectResponse(url=f"/quickwin/{run_id}")
|
|
if target in STEPS:
|
|
advance_run_status(db, run_id, target, user=user)
|
|
return RedirectResponse(url=f"/quickwin/{run_id}?msg=step_{target}", status_code=303)
|
|
|
|
|
|
@router.get("/quickwin/{run_id}/prereq-stream")
|
|
async def quickwin_prereq_stream(request: Request, run_id: int, db=Depends(get_db),
|
|
branch: str = Query("hprod")):
|
|
"""SSE: streame les resultats prereq serveur par serveur"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return StreamingResponse(iter([]), media_type="text/event-stream")
|
|
|
|
def event_generator():
|
|
from ..services.quickwin_prereq_service import check_server_prereqs
|
|
from ..services.quickwin_log_service import log_info, log_success, log_error
|
|
by = user.get("display_name", user.get("username", "")) if user else ""
|
|
|
|
entries = db.execute(text("""
|
|
SELECT qe.id, s.hostname, s.domain_ltd, s.ssh_method,
|
|
e.code as env_code
|
|
FROM quickwin_entries qe
|
|
JOIN servers s ON qe.server_id = s.id
|
|
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
|
LEFT JOIN environments e ON de.environment_id = e.id
|
|
WHERE qe.run_id = :rid AND qe.branch = :br
|
|
AND qe.status NOT IN ('excluded','skipped')
|
|
ORDER BY s.hostname
|
|
"""), {"rid": run_id, "br": branch}).fetchall()
|
|
|
|
total = len(entries)
|
|
log_info(db, run_id, "prereq",
|
|
f"Lancement check prerequis {branch} ({total} serveurs)", created_by=by)
|
|
db.commit()
|
|
|
|
yield f"data: {json.dumps({'type':'start','total':total,'branch':branch})}\n\n"
|
|
|
|
ok_count = 0
|
|
ko_count = 0
|
|
for idx, e in enumerate(entries):
|
|
yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'status':'checking'})}\n\n"
|
|
try:
|
|
r = check_server_prereqs(
|
|
hostname=e.hostname, db=db,
|
|
domain_ltd=e.domain_ltd, env_code=e.env_code,
|
|
ssh_method=e.ssh_method or "ssh_key",
|
|
)
|
|
except Exception as ex:
|
|
r = {"dns_ok": False, "ssh_ok": False, "satellite_ok": False,
|
|
"disk_ok": False, "detail": str(ex), "skip": True}
|
|
|
|
prereq_ok = (r.get("dns_ok", False) and r.get("ssh_ok", False)
|
|
and r.get("satellite_ok", False) and r.get("disk_ok", True))
|
|
db.execute(text("""
|
|
UPDATE quickwin_entries SET
|
|
prereq_ok = :ok, prereq_ssh = :ssh, prereq_satellite = :sat, prereq_disk = :disk,
|
|
prereq_detail = :detail, prereq_date = now(), updated_at = now()
|
|
WHERE id = :id
|
|
"""), {
|
|
"id": e.id, "ok": prereq_ok,
|
|
"ssh": r.get("ssh_ok", False), "sat": r.get("satellite_ok", False),
|
|
"disk": r.get("disk_ok", True), "detail": r.get("detail", ""),
|
|
})
|
|
|
|
detail_str = r.get("detail", "")
|
|
if prereq_ok:
|
|
ok_count += 1
|
|
log_success(db, run_id, "prereq", f"OK: {e.hostname}",
|
|
detail=detail_str, entry_id=e.id, hostname=e.hostname)
|
|
else:
|
|
ko_count += 1
|
|
log_error(db, run_id, "prereq", f"KO: {e.hostname}",
|
|
detail=detail_str, entry_id=e.id, hostname=e.hostname)
|
|
db.commit()
|
|
|
|
result_data = {
|
|
'type': 'result', 'idx': idx+1, 'total': total,
|
|
'hostname': e.hostname, 'ok': prereq_ok,
|
|
'dns': r.get('dns_ok', False), 'ssh': r.get('ssh_ok', False),
|
|
'sat': r.get('satellite_ok', False), 'disk': r.get('disk_ok', True),
|
|
'fqdn': r.get('fqdn', ''), 'detail': detail_str[:200],
|
|
}
|
|
yield f"data: {json.dumps(result_data)}\n\n"
|
|
|
|
log_info(db, run_id, "prereq",
|
|
f"Fin check {branch}: {ok_count} OK, {ko_count} KO sur {total}", created_by=by)
|
|
db.commit()
|
|
|
|
yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':total})}\n\n"
|
|
|
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/snapshot/mark")
|
|
async def quickwin_snapshot_mark(request: Request, run_id: int, db=Depends(get_db),
|
|
entry_id: int = Form(0), done: str = Form("true")):
|
|
"""Marque un serveur comme snapshot fait/pas fait"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
if entry_id:
|
|
mark_snapshot(db, entry_id, done == "true")
|
|
return RedirectResponse(url=f"/quickwin/{run_id}?msg=snap_marked", status_code=303)
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/snapshot/mark-all")
|
|
async def quickwin_snapshot_mark_all(request: Request, run_id: int, db=Depends(get_db),
|
|
branch: str = Form("hprod")):
|
|
"""Marque tous les serveurs d'une branche comme snapshot fait"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
|
return RedirectResponse(url=f"/quickwin/{run_id}")
|
|
mark_all_snapshots(db, run_id, branch, True)
|
|
return RedirectResponse(url=f"/quickwin/{run_id}?msg=snap_all", status_code=303)
|
|
|
|
|
|
@router.get("/quickwin/{run_id}/snapshot-stream")
|
|
async def quickwin_snapshot_stream(request: Request, run_id: int, db=Depends(get_db),
|
|
branch: str = Query("hprod")):
|
|
"""SSE: prend les snapshots VM serveur par serveur"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return StreamingResponse(iter([]), media_type="text/event-stream")
|
|
|
|
def event_generator():
|
|
from ..services.quickwin_snapshot_service import snapshot_server
|
|
from ..services.quickwin_log_service import log_info, log_success, log_error, log_warn
|
|
|
|
by = user.get("display_name", user.get("username", "")) if user else ""
|
|
|
|
entries = db.execute(text("""
|
|
SELECT qe.id, s.hostname, s.machine_type, s.vcenter_vm_name, qe.branch
|
|
FROM quickwin_entries qe
|
|
JOIN servers s ON qe.server_id = s.id
|
|
WHERE qe.run_id = :rid AND qe.branch = :br
|
|
AND qe.status NOT IN ('excluded','skipped')
|
|
ORDER BY s.hostname
|
|
"""), {"rid": run_id, "br": branch}).fetchall()
|
|
|
|
total = len(entries)
|
|
physical_entries = [e for e in entries if e.machine_type == 'physical']
|
|
vm_entries = [e for e in entries if e.machine_type != 'physical']
|
|
physical_count = len(physical_entries)
|
|
|
|
log_info(db, run_id, "snapshot",
|
|
f"Lancement snapshots {branch} ({len(vm_entries)} VMs, {physical_count} physiques ignores)",
|
|
created_by=by)
|
|
db.commit()
|
|
|
|
yield f"data: {json.dumps({'type':'start','total':total,'vms':len(vm_entries),'physical':physical_count,'branch':branch})}\n\n"
|
|
|
|
# Marquer les physiques comme snap_done (pas de snap necessaire)
|
|
for e in physical_entries:
|
|
db.execute(text("""
|
|
UPDATE quickwin_entries SET snap_done = true, snap_detail = 'Serveur physique - pas de snapshot VM',
|
|
updated_at = now() WHERE id = :id
|
|
"""), {"id": e.id})
|
|
log_warn(db, run_id, "snapshot",
|
|
f"Physique ignore: {e.hostname} (verifier backup Commvault)",
|
|
entry_id=e.id, hostname=e.hostname, created_by=by)
|
|
if physical_entries:
|
|
db.commit()
|
|
|
|
ok_count = 0
|
|
ko_count = 0
|
|
snap_name = f"QW_{run_id}_{branch}_{__import__('datetime').datetime.now().strftime('%Y%m%d_%H%M')}"
|
|
|
|
for idx, e in enumerate(vm_entries):
|
|
yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':len(vm_entries),'hostname':e.hostname,'status':'connecting'})}\n\n"
|
|
|
|
r = snapshot_server(
|
|
hostname=e.hostname,
|
|
vm_name=e.vcenter_vm_name,
|
|
branch=e.branch,
|
|
db=db,
|
|
snap_name=snap_name,
|
|
)
|
|
|
|
snap_ok = r.get("ok", False)
|
|
detail = r.get("detail", "")
|
|
vcenter = r.get("vcenter", "")
|
|
|
|
db.execute(text("""
|
|
UPDATE quickwin_entries SET
|
|
snap_done = :done, snap_detail = :detail, updated_at = now()
|
|
WHERE id = :id
|
|
"""), {"id": e.id, "done": snap_ok, "detail": f"[{vcenter}] {detail}" if vcenter else detail})
|
|
|
|
if snap_ok:
|
|
ok_count += 1
|
|
log_success(db, run_id, "snapshot", f"OK: {e.hostname} ({vcenter})",
|
|
detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
|
|
else:
|
|
ko_count += 1
|
|
log_error(db, run_id, "snapshot", f"KO: {e.hostname}",
|
|
detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
|
|
db.commit()
|
|
|
|
result_data = {
|
|
'type': 'result', 'idx': idx+1, 'total': len(vm_entries),
|
|
'hostname': e.hostname, 'ok': snap_ok,
|
|
'vcenter': vcenter, 'detail': detail[:200],
|
|
}
|
|
yield f"data: {json.dumps(result_data)}\n\n"
|
|
|
|
log_info(db, run_id, "snapshot",
|
|
f"Fin snapshots {branch}: {ok_count} OK, {ko_count} KO sur {len(vm_entries)} VMs",
|
|
created_by=by)
|
|
db.commit()
|
|
|
|
yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':len(vm_entries),'physical':physical_count})}\n\n"
|
|
|
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/build-commands")
|
|
async def quickwin_build_commands(request: Request, run_id: int, db=Depends(get_db),
|
|
branch: str = Form("hprod")):
|
|
"""Construit les commandes yum pour une branche"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
commands = build_yum_commands(db, run_id, branch)
|
|
return RedirectResponse(url=f"/quickwin/{run_id}?msg=commands_{len(commands)}&show_cmds={branch}", status_code=303)
|
|
|
|
|
|
@router.get("/api/quickwin/{run_id}/commands/{branch}")
|
|
async def quickwin_get_commands(request: Request, run_id: int, branch: str, db=Depends(get_db)):
|
|
"""Retourne les commandes generees pour une branche"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return JSONResponse({"error": "unauthorized"}, 401)
|
|
entries = db.execute(text("""
|
|
SELECT qe.id, s.hostname, qe.patch_command, s.ssh_method, s.domain_ltd,
|
|
e.code as env_code
|
|
FROM quickwin_entries qe
|
|
JOIN servers s ON qe.server_id = s.id
|
|
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
|
LEFT JOIN environments e ON de.environment_id = e.id
|
|
WHERE qe.run_id = :rid AND qe.branch = :br
|
|
AND qe.status NOT IN ('excluded','skipped')
|
|
AND qe.patch_command IS NOT NULL AND qe.patch_command != ''
|
|
ORDER BY s.hostname
|
|
"""), {"rid": run_id, "br": branch}).fetchall()
|
|
return JSONResponse([
|
|
{"id": e.id, "hostname": e.hostname, "command": e.patch_command}
|
|
for e in entries
|
|
])
|
|
|
|
|
|
@router.get("/quickwin/{run_id}/patch-stream")
|
|
async def quickwin_patch_stream(request: Request, run_id: int, db=Depends(get_db),
|
|
branch: str = Query("hprod")):
|
|
"""SSE: execute les commandes yum serveur par serveur via SSH"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return StreamingResponse(iter([]), media_type="text/event-stream")
|
|
|
|
def event_generator():
|
|
from ..services.quickwin_prereq_service import _get_secret, _resolve_fqdn, _get_ssh_key_path, SSH_TIMEOUT, PSMP_HOST, CYBR_USER, TARGET_USER
|
|
from ..services.quickwin_log_service import log_info, log_success, log_error
|
|
import paramiko
|
|
|
|
by = user.get("display_name", user.get("username", "")) if user else ""
|
|
|
|
entries = db.execute(text("""
|
|
SELECT qe.id, s.hostname, qe.patch_command, s.ssh_method, s.domain_ltd,
|
|
e.code as env_code
|
|
FROM quickwin_entries qe
|
|
JOIN servers s ON qe.server_id = s.id
|
|
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
|
LEFT JOIN environments e ON de.environment_id = e.id
|
|
WHERE qe.run_id = :rid AND qe.branch = :br
|
|
AND qe.status NOT IN ('excluded','skipped')
|
|
AND qe.patch_command IS NOT NULL AND qe.patch_command != ''
|
|
ORDER BY s.hostname
|
|
"""), {"rid": run_id, "br": branch}).fetchall()
|
|
|
|
total = len(entries)
|
|
log_info(db, run_id, "patching",
|
|
f"Lancement patching {branch} ({total} serveurs)", created_by=by)
|
|
db.commit()
|
|
|
|
yield f"data: {json.dumps({'type':'start','total':total,'branch':branch})}\n\n"
|
|
|
|
ok_count = 0
|
|
ko_count = 0
|
|
|
|
for idx, e in enumerate(entries):
|
|
yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'command':e.patch_command,'status':'connecting'})}\n\n"
|
|
|
|
# Resoudre FQDN
|
|
fqdn, _dns_err = _resolve_fqdn(e.hostname, e.domain_ltd, e.env_code)
|
|
if not fqdn:
|
|
fqdn = e.hostname
|
|
|
|
# Connexion SSH
|
|
client = None
|
|
ssh_err = None
|
|
try:
|
|
ssh_method = e.ssh_method or "ssh_key"
|
|
if ssh_method == "ssh_key":
|
|
client = paramiko.SSHClient()
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
key_path = _get_ssh_key_path(db)
|
|
if not key_path:
|
|
raise Exception("Cle SSH non configuree (ssh_key_file dans Secrets)")
|
|
client.connect(fqdn, username="root", key_filename=key_path,
|
|
timeout=SSH_TIMEOUT, allow_agent=False, look_for_keys=False)
|
|
else:
|
|
password = _get_secret(db, "ssh_pwd_default_pass")
|
|
transport = paramiko.Transport((PSMP_HOST, 22))
|
|
psmp_user = f"{CYBR_USER}@{TARGET_USER}@{fqdn}"
|
|
transport.connect(username=psmp_user, password=password or "")
|
|
transport.set_keepalive(30)
|
|
client = paramiko.SSHClient()
|
|
client._transport = transport
|
|
except Exception as ex:
|
|
ssh_err = str(ex)
|
|
|
|
if ssh_err or not client:
|
|
ko_count += 1
|
|
detail = f"SSH echoue: {ssh_err or 'connexion impossible'}"
|
|
db.execute(text("""
|
|
UPDATE quickwin_entries SET status='failed', patch_output=:out,
|
|
patch_date=now(), updated_at=now() WHERE id=:id
|
|
"""), {"id": e.id, "out": detail})
|
|
log_error(db, run_id, "patching", f"KO SSH: {e.hostname}",
|
|
detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
|
|
db.commit()
|
|
yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':False,'detail':detail[:300]})}\n\n"
|
|
continue
|
|
|
|
# Executer la commande
|
|
try:
|
|
yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'status':'executing','command':e.patch_command})}\n\n"
|
|
|
|
stdin, stdout, stderr = client.exec_command(e.patch_command, timeout=600)
|
|
output = stdout.read().decode('utf-8', errors='replace')
|
|
err_output = stderr.read().decode('utf-8', errors='replace')
|
|
exit_code = stdout.channel.recv_exit_status()
|
|
|
|
full_output = output
|
|
if err_output:
|
|
full_output += "\n--- STDERR ---\n" + err_output
|
|
|
|
# Parser la sortie yum/dnf
|
|
pkg_count = 0
|
|
packages = ""
|
|
summary_lines = []
|
|
upgrade_keywords = ("Updating", "Installing", "Upgrading",
|
|
"Mise à niveau", "Installation de", "Mise à jour")
|
|
result_keywords = ("Updated:", "Installed:", "Upgraded:",
|
|
"Mis à jour", "Installé", "Mis à niveau")
|
|
skip_keywords = ("vérification", "métadonnées", "Dernière",
|
|
"===", "---", "Dépendances résolues",
|
|
"Paquet ", "Architecture", "Version",
|
|
"Dépôt", "Taille")
|
|
|
|
for line in output.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
if any(stripped.startswith(k) for k in result_keywords):
|
|
packages += stripped + "\n"
|
|
if any(stripped.startswith(k) for k in upgrade_keywords):
|
|
pkg_count += 1
|
|
|
|
# Construire un résumé pour le terminal SSE
|
|
for line in output.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
if any(k in stripped for k in skip_keywords):
|
|
continue
|
|
summary_lines.append(stripped)
|
|
|
|
# Garder les lignes utiles (fin = résumé transaction)
|
|
if len(summary_lines) > 15:
|
|
summary_lines = summary_lines[-15:]
|
|
summary = "\n".join(summary_lines)
|
|
|
|
# Rien à faire ?
|
|
nothing = any(k in output for k in ("Rien à faire", "Nothing to do", "No packages marked"))
|
|
if nothing and pkg_count == 0:
|
|
summary = "Rien à faire — tous les paquets sont à jour."
|
|
|
|
reboot = "reboot" in output.lower() or "kernel" in output.lower()
|
|
|
|
if exit_code == 0:
|
|
ok_count += 1
|
|
status = "patched"
|
|
log_success(db, run_id, "patching",
|
|
f"OK: {e.hostname} ({pkg_count} paquets)",
|
|
detail=full_output[:500], entry_id=e.id,
|
|
hostname=e.hostname, created_by=by)
|
|
else:
|
|
ko_count += 1
|
|
status = "failed"
|
|
log_error(db, run_id, "patching",
|
|
f"KO: {e.hostname} (exit {exit_code})",
|
|
detail=full_output[:500], entry_id=e.id,
|
|
hostname=e.hostname, created_by=by)
|
|
|
|
db.execute(text("""
|
|
UPDATE quickwin_entries SET status=:st, patch_output=:out,
|
|
patch_packages_count=:pc, patch_packages=:pp,
|
|
reboot_required=:rb, patch_date=now(), updated_at=now()
|
|
WHERE id=:id
|
|
"""), {"id": e.id, "st": status, "out": full_output[:5000],
|
|
"pc": pkg_count, "pp": packages[:2000], "rb": reboot})
|
|
db.commit()
|
|
|
|
yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':exit_code==0,'exit_code':exit_code,'packages':pkg_count,'reboot':reboot,'detail':summary[:1000]})}\n\n"
|
|
|
|
except Exception as ex:
|
|
ko_count += 1
|
|
detail = f"Erreur execution: {ex}"
|
|
db.execute(text("""
|
|
UPDATE quickwin_entries SET status='failed', patch_output=:out,
|
|
patch_date=now(), updated_at=now() WHERE id=:id
|
|
"""), {"id": e.id, "out": detail})
|
|
log_error(db, run_id, "patching", f"KO: {e.hostname}",
|
|
detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
|
|
db.commit()
|
|
yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':False,'detail':detail[:300]})}\n\n"
|
|
finally:
|
|
try:
|
|
client.close()
|
|
except Exception:
|
|
pass
|
|
|
|
log_info(db, run_id, "patching",
|
|
f"Fin patching {branch}: {ok_count} OK, {ko_count} KO sur {total}", created_by=by)
|
|
db.commit()
|
|
yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':total})}\n\n"
|
|
|
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/mark-patched")
|
|
async def quickwin_mark_patched(request: Request, run_id: int, db=Depends(get_db),
|
|
entry_id: int = Form(0),
|
|
patch_status: str = Form("patched"),
|
|
packages_count: int = Form(0),
|
|
reboot_required: str = Form("false"),
|
|
patch_output: str = Form("")):
|
|
"""Marque un serveur comme patche ou echoue"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
if entry_id:
|
|
from sqlalchemy import text
|
|
reboot = reboot_required == "true"
|
|
db.execute(text("""
|
|
UPDATE quickwin_entries SET status = :st, patch_packages_count = :pc,
|
|
reboot_required = :rb, patch_output = :po, patch_date = now(), updated_at = now()
|
|
WHERE id = :id
|
|
"""), {"id": entry_id, "st": patch_status, "pc": packages_count,
|
|
"rb": reboot, "po": patch_output})
|
|
db.commit()
|
|
return RedirectResponse(url=f"/quickwin/{run_id}?msg=marked", status_code=303)
|
|
|
|
|
|
# -- Logs --
|
|
|
|
@router.get("/quickwin/{run_id}/logs", response_class=HTMLResponse)
|
|
async def quickwin_logs_page(request: Request, run_id: int, db=Depends(get_db),
|
|
level: str = Query(""),
|
|
step: str = Query(""),
|
|
hostname: str = Query("")):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
run = get_run(db, run_id)
|
|
if not run:
|
|
return RedirectResponse(url="/quickwin")
|
|
logs = get_logs(db, run_id, level=level or None, step=step or None,
|
|
hostname=hostname or None)
|
|
log_stats = get_log_stats(db, run_id)
|
|
stats_dict = {r.level: r.cnt for r in log_stats}
|
|
ctx = base_context(request, db, user)
|
|
ctx.update({
|
|
"app_name": APP_NAME, "run": run, "logs": logs,
|
|
"log_stats": stats_dict,
|
|
"total_logs": sum(stats_dict.values()),
|
|
"filters": {"level": level, "step": step, "hostname": hostname},
|
|
"msg": request.query_params.get("msg"),
|
|
})
|
|
return templates.TemplateResponse("quickwin_logs.html", ctx)
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/logs/clear")
|
|
async def quickwin_logs_clear(request: Request, run_id: int, db=Depends(get_db),
|
|
clear_step: str = Form("")):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
|
return RedirectResponse(url=f"/quickwin/{run_id}/logs")
|
|
clear_logs(db, run_id, step=clear_step or None)
|
|
return RedirectResponse(url=f"/quickwin/{run_id}/logs?msg=cleared", status_code=303)
|
|
|
|
|
|
# -- API JSON --
|
|
|
|
@router.post("/api/quickwin/entry/update")
|
|
async def quickwin_entry_update(request: Request, db=Depends(get_db)):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return JSONResponse({"error": "unauthorized"}, 401)
|
|
body = await request.json()
|
|
entry_id = body.get("id")
|
|
field = body.get("field")
|
|
value = body.get("value")
|
|
if not entry_id or not field:
|
|
return JSONResponse({"error": "id and field required"}, 400)
|
|
ok = update_entry_field(db, entry_id, field, value)
|
|
return JSONResponse({"ok": ok})
|
|
|
|
|
|
@router.post("/api/quickwin/inject-yum-history")
|
|
async def quickwin_inject_yum(request: Request, db=Depends(get_db)):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return JSONResponse({"error": "unauthorized"}, 401)
|
|
body = await request.json()
|
|
if not isinstance(body, list):
|
|
return JSONResponse({"error": "expected list"}, 400)
|
|
updated, inserted = inject_yum_history(db, body)
|
|
return JSONResponse({"ok": True, "updated": updated, "inserted": inserted})
|
|
|
|
|
|
@router.get("/api/quickwin/prod-check/{run_id}")
|
|
async def quickwin_prod_check(request: Request, run_id: int, db=Depends(get_db)):
|
|
"""Verifie si le prod peut demarrer (tous hprod termines)"""
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return JSONResponse({"error": "unauthorized"}, 401)
|
|
ok = can_start_prod(db, run_id)
|
|
return JSONResponse({"can_start_prod": ok})
|
|
|
|
|
|
# ========== CORRESPONDANCE HPROD ↔ PROD ==========
|
|
|
|
@router.get("/quickwin/{run_id}/correspondance")
|
|
async def quickwin_correspondance_page(request: Request, run_id: int, db=Depends(get_db),
|
|
search: str = Query(""), pair_filter: str = Query(""),
|
|
env_filter: str = Query(""), domain_filter: str = Query(""),
|
|
page: int = Query(1), per_page: int = Query(50)):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
run = get_run(db, run_id)
|
|
if not run:
|
|
return RedirectResponse(url="/quickwin")
|
|
pairs = get_correspondance(db, run_id, search=search or None,
|
|
pair_filter=pair_filter or None, env_filter=env_filter or None,
|
|
domain_filter=domain_filter or None)
|
|
available = get_available_prod_entries(db, run_id)
|
|
matched = sum(1 for p in pairs if p["is_matched"])
|
|
unmatched = sum(1 for p in pairs if not p["is_matched"])
|
|
anomalies = sum(1 for p in pairs if p["is_anomaly"])
|
|
|
|
# Get unfiltered totals for KPIs
|
|
all_pairs = get_correspondance(db, run_id) if (search or pair_filter or env_filter or domain_filter) else pairs
|
|
# Extract domain list for filter dropdown
|
|
domains_in_run = sorted(set(p["hprod_domaine"] for p in all_pairs if p["hprod_domaine"]))
|
|
total = len(all_pairs)
|
|
total_matched = sum(1 for p in all_pairs if p["is_matched"])
|
|
total_unmatched = sum(1 for p in all_pairs if not p["is_matched"])
|
|
total_anomalies = sum(1 for p in all_pairs if p["is_anomaly"])
|
|
|
|
# Pagination
|
|
per_page = max(10, min(per_page, 200))
|
|
total_filtered = len(pairs)
|
|
total_pages = max(1, (total_filtered + per_page - 1) // per_page)
|
|
page = max(1, min(page, total_pages))
|
|
start = (page - 1) * per_page
|
|
pairs_page = pairs[start:start + per_page]
|
|
|
|
ctx = base_context(request, db, user)
|
|
ctx.update({
|
|
"app_name": APP_NAME, "run": run, "pairs": pairs_page, "available": available,
|
|
"stats": {"total": total, "matched": total_matched, "unmatched": total_unmatched, "anomalies": total_anomalies},
|
|
"filters": {"search": search, "pair_filter": pair_filter, "env_filter": env_filter, "domain_filter": domain_filter},
|
|
"domains_in_run": domains_in_run,
|
|
"page": page, "per_page": per_page, "total_pages": total_pages, "total_filtered": total_filtered,
|
|
"msg": request.query_params.get("msg"),
|
|
})
|
|
return templates.TemplateResponse("quickwin_correspondance.html", ctx)
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/correspondance/auto")
|
|
async def quickwin_correspondance_auto(request: Request, run_id: int, db=Depends(get_db)):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
perms = get_user_perms(db, user)
|
|
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
|
return RedirectResponse(url=f"/quickwin/{run_id}/correspondance")
|
|
m, u, a = compute_correspondance(db, run_id, user=user)
|
|
return RedirectResponse(url=f"/quickwin/{run_id}/correspondance?msg=auto&am={m}&au={u}&aa={a}", status_code=303)
|
|
|
|
|
|
@router.post("/quickwin/{run_id}/correspondance/clear-all")
|
|
async def quickwin_correspondance_clear(request: Request, run_id: int, db=Depends(get_db)):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return RedirectResponse(url="/login")
|
|
clear_all_pairs(db, run_id)
|
|
return RedirectResponse(url=f"/quickwin/{run_id}/correspondance?msg=cleared", status_code=303)
|
|
|
|
|
|
@router.post("/api/quickwin/correspondance/set-pair")
|
|
async def quickwin_set_pair_api(request: Request, db=Depends(get_db)):
|
|
user = get_current_user(request)
|
|
if not user:
|
|
return JSONResponse({"error": "unauthorized"}, 401)
|
|
body = await request.json()
|
|
hprod_id = body.get("hprod_id")
|
|
prod_id = body.get("prod_id") # 0 or null to clear
|
|
if not hprod_id:
|
|
return JSONResponse({"error": "missing hprod_id"}, 400)
|
|
set_prod_pair(db, hprod_id, prod_id if prod_id else None)
|
|
return JSONResponse({"ok": True})
|