patchcenter/app/services/campaign_service.py
Khalid MOUTAOUAKIL 8277653c43 PatchCenter v2.0 — Initial commit
Modules: Dashboard, Serveurs, Campagnes, Planning, Specifiques, Settings, Users
Stack: FastAPI + Jinja2 + HTMX + Alpine.js + TailwindCSS + PostgreSQL
Features: Qualys sync, prereqs auto, planning annuel, server specifics, role-based access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:00:12 +02:00

288 lines
12 KiB
Python

"""Service campagnes — logique metier patching"""
from datetime import datetime
from sqlalchemy import text
def list_campaigns(db, year=None, status=None):
where = ["1=1"]
params = {}
if year:
where.append("c.year = :year"); params["year"] = year
if status:
where.append("c.status = :status"); params["status"] = status
wc = " AND ".join(where)
return db.execute(text(f"""
SELECT c.*, u.display_name as created_by_name,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id) as session_count,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'patched') as patched_count,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'failed') as failed_count,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'pending') as pending_count,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'excluded') as excluded_count
FROM campaigns c
LEFT JOIN users u ON c.created_by = u.id
WHERE {wc} ORDER BY c.year DESC, c.week_code DESC
"""), params).fetchall()
def get_campaign(db, campaign_id):
return db.execute(text("""
SELECT c.*, u.display_name as created_by_name
FROM campaigns c LEFT JOIN users u ON c.created_by = u.id
WHERE c.id = :id
"""), {"id": campaign_id}).fetchone()
def get_campaign_sessions(db, campaign_id):
return db.execute(text("""
SELECT ps.*, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier,
s.etat, s.ssh_method, s.licence_support, s.machine_type,
d.name as domaine, e.name as environnement,
u.display_name as intervenant_name
FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN users u ON ps.intervenant_id = u.id
WHERE ps.campaign_id = :cid
ORDER BY CASE ps.status
WHEN 'in_progress' THEN 1
WHEN 'pending' THEN 2
WHEN 'prereq_ok' THEN 3
WHEN 'patched' THEN 4
WHEN 'failed' THEN 5
WHEN 'reported' THEN 6
WHEN 'excluded' THEN 7
WHEN 'cancelled' THEN 8
ELSE 9 END, s.hostname
"""), {"cid": campaign_id}).fetchall()
def get_campaign_stats(db, campaign_id):
return db.execute(text("""
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'patched') as patched,
COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) FILTER (WHERE status = 'pending') as pending,
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
COUNT(*) FILTER (WHERE status = 'skipped') as skipped,
COUNT(*) FILTER (WHERE status = 'excluded') as excluded,
COUNT(*) FILTER (WHERE status = 'reported') as reported,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled
FROM patch_sessions WHERE campaign_id = :cid
"""), {"cid": campaign_id}).fetchone()
def get_planning_for_week(db, year, week_number):
"""Retourne les entrees planning pour une semaine donnee"""
return db.execute(text("""
SELECT pp.*, d.name as domain_name
FROM patch_planning pp
LEFT JOIN domains d ON pp.domain_code = d.code
WHERE pp.year = :y AND pp.week_number = :wn AND pp.status = 'open'
ORDER BY pp.domain_code
"""), {"y": year, "wn": week_number}).fetchall()
def get_servers_for_planning(db, year, week_number):
"""Retourne les serveurs a proposer pour une semaine du planning.
Inclut les domaines planifies + DMZ (toujours inclus)."""
planning = get_planning_for_week(db, year, week_number)
if not planning:
return [], []
# Construire les filtres domaine/env depuis le planning
domain_envs = []
for p in planning:
if p.domain_code == 'DMZ':
continue # DMZ traite separement
if p.env_scope == 'prod':
domain_envs.append(("d.code = :dc_{0} AND e.name = 'Production'".format(len(domain_envs)), p.domain_code))
elif p.env_scope == 'hprod':
domain_envs.append(("d.code = :dc_{0} AND e.name != 'Production'".format(len(domain_envs)), p.domain_code))
elif p.env_scope == 'prod_pilot':
domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code))
else: # all
domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code))
if not domain_envs:
return [], planning
# Construire la clause OR
or_clauses = []
params = {}
for i, (clause, dc) in enumerate(domain_envs):
or_clauses.append(clause)
params[f"dc_{i}"] = dc
# Toujours inclure DMZ
or_clauses.append("d.code = 'DMZ'")
where = f"""
s.etat = 'en_production'
AND s.patch_os_owner = 'secops'
AND s.licence_support IN ('active', 'els')
AND ({' OR '.join(or_clauses)})
"""
servers = db.execute(text(f"""
SELECT s.id, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier,
s.licence_support, s.ssh_method, s.machine_type,
d.name as domaine, d.code as domain_code, e.name as environnement
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE {where}
ORDER BY d.name, e.name, s.hostname
"""), params).fetchall()
return servers, planning
def create_campaign_from_planning(db, year, week_number, label, user_id, excluded_ids=None):
"""Cree une campagne depuis le planning avec exclusions"""
servers, planning = get_servers_for_planning(db, year, week_number)
if not servers:
return None
wc = f"S{week_number:02d}"
# Dates de la semaine
p = planning[0] if planning else None
ds = p.week_start if p else None
de = p.week_end if p else None
row = db.execute(text("""
INSERT INTO campaigns (week_code, year, label, status, date_start, date_end, created_by)
VALUES (:wc, :y, :label, 'draft', :ds, :de, :uid)
RETURNING id
"""), {"wc": wc, "y": year, "label": label, "ds": ds, "de": de, "uid": user_id}).fetchone()
cid = row.id
excluded = set(excluded_ids or [])
for s in servers:
status = 'excluded' if s.id in excluded else 'pending'
db.execute(text("""
INSERT INTO patch_sessions (campaign_id, server_id, status)
VALUES (:cid, :sid, :st)
ON CONFLICT (campaign_id, server_id) DO NOTHING
"""), {"cid": cid, "sid": s.id, "st": status})
# Update total
count = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status != 'excluded'"
), {"cid": cid}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": count, "cid": cid})
db.commit()
return cid
def exclude_session(db, session_id, reason, detail, username):
"""Exclut un serveur d'une campagne avec motif"""
db.execute(text("""
UPDATE patch_sessions SET
status = 'excluded', exclusion_reason = :reason,
exclusion_detail = :detail, excluded_by = :by,
excluded_at = now()
WHERE id = :id
"""), {"id": session_id, "reason": reason, "detail": detail, "by": username})
# Recalculer total
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
if row:
count = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status NOT IN ('excluded','cancelled')"
), {"cid": row.campaign_id}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": count, "cid": row.campaign_id})
db.commit()
def restore_session(db, session_id):
"""Restaure un serveur exclu"""
db.execute(text("""
UPDATE patch_sessions SET
status = 'pending', exclusion_reason = NULL,
exclusion_detail = NULL, excluded_by = NULL, excluded_at = NULL
WHERE id = :id
"""), {"id": session_id})
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
if row:
count = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status NOT IN ('excluded','cancelled')"
), {"cid": row.campaign_id}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": count, "cid": row.campaign_id})
db.commit()
def validate_prereq(db, session_id, ssh, satellite, rollback, rollback_justif, username):
"""Valide les prereqs d'un serveur dans une campagne"""
db.execute(text("""
UPDATE patch_sessions SET
prereq_ssh = :ssh, prereq_satellite = :sat,
rollback_method = :rb, rollback_justif = :rbj,
prereq_validated = CASE WHEN :ssh = 'ok' AND :sat = 'ok' AND :rb IS NOT NULL THEN true ELSE false END,
prereq_validated_by = :by, prereq_validated_at = now(), prereq_date = now()
WHERE id = :id
"""), {"id": session_id, "ssh": ssh, "sat": satellite,
"rb": rollback or None, "rbj": rollback_justif or None, "by": username})
db.commit()
def bulk_auto_exclude_failed_prereqs(db, campaign_id, username):
"""Exclut automatiquement les serveurs qui n'ont pas passe les prereqs"""
failed = db.execute(text("""
SELECT id FROM patch_sessions
WHERE campaign_id = :cid AND status = 'pending'
AND prereq_validated = false
AND prereq_date IS NOT NULL
AND (prereq_ssh = 'ko' OR prereq_satellite = 'ko' OR rollback_method IS NULL)
"""), {"cid": campaign_id}).fetchall()
count = 0
for r in failed:
exclude_session(db, r.id, "creneau_inadequat", "Prereqs non valides — report auto", username)
count += 1
return count
def get_prereq_stats(db, campaign_id):
"""Stats prereqs d'une campagne"""
return db.execute(text("""
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as total_pending,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_validated = true) as prereq_ok,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_validated = false AND prereq_date IS NOT NULL) as prereq_ko,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_date IS NULL) as prereq_todo,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_ssh = 'ok') as ssh_ok,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_satellite = 'ok') as sat_ok,
COUNT(*) FILTER (WHERE status = 'pending' AND rollback_method IS NOT NULL) as rollback_ok,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_disk_ok = true) as disk_ok
FROM patch_sessions WHERE campaign_id = :cid
"""), {"cid": campaign_id}).fetchone()
def can_plan_campaign(db, campaign_id):
"""Verifie si la campagne peut passer en 'planned' (tous les prereqs pending valides)"""
pending_not_validated = db.execute(text("""
SELECT COUNT(*) FROM patch_sessions
WHERE campaign_id = :cid AND status = 'pending' AND prereq_validated = false
"""), {"cid": campaign_id}).scalar()
return pending_not_validated == 0
def update_campaign_status(db, campaign_id, new_status):
db.execute(text("UPDATE campaigns SET status = :s WHERE id = :id"),
{"s": new_status, "id": campaign_id})
db.commit()
def get_reference_data(db):
domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall()
envs = db.execute(text("SELECT code, name FROM environments ORDER BY display_order")).fetchall()
return domains, envs