Pārlūkot izejas kodu

feat: rétention GFS (Grandfather-Father-Son)

Ajoute la politique de rétention GFS avec 3 niveaux configurables :
Fils (journaliers), Père (hebdomadaires), Grand-Père (mensuels).
Migration SQLite automatique au démarrage pour la colonne retention_gfs_config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cedric Hansen 1 mēnesi atpakaļ
vecāks
revīzija
3fe195c960
5 mainītis faili ar 158 papildinājumiem un 28 dzēšanām
  1. 8 0
      sources/app.py
  2. 16 2
      sources/blueprints/jobs.py
  3. 1 0
      sources/db.py
  4. 60 0
      sources/retention.py
  5. 73 26
      sources/templates/job_form.html

+ 8 - 0
sources/app.py

@@ -64,6 +64,14 @@ def _inject_globals():
 # --- Démarrage ---------------------------------------------------------------
 
 with app.app_context():
+    from sqlalchemy import inspect, text
+    _insp = inspect(db.engine)
+    if "jobs" in _insp.get_table_names():
+        _cols = [c["name"] for c in _insp.get_columns("jobs")]
+        if "retention_gfs_config" not in _cols:
+            with db.engine.connect() as _conn:
+                _conn.execute(text("ALTER TABLE jobs ADD COLUMN retention_gfs_config TEXT"))
+                _conn.commit()
     db.create_all()
     init_scheduler(app)
     for _job in Job.query.filter_by(enabled=True).all():

+ 16 - 2
sources/blueprints/jobs.py

@@ -399,13 +399,27 @@ def _save_job(job):
 
     from scheduler import schedule_job, remove_job
     transfer_target = f.get("transfer_target", "").strip()
+    retention_mode = f.get("retention_mode", "count")
+    if retention_mode == "gfs":
+        gfs_cfg = {
+            "daily":   max(1, int(f.get("gfs_daily",   7))),
+            "weekly":  max(1, int(f.get("gfs_weekly",  4))),
+            "monthly": max(1, int(f.get("gfs_monthly", 12))),
+        }
+        retention_value = 0
+        gfs_config_json = json.dumps(gfs_cfg)
+    else:
+        gfs_config_json = None
+        retention_value = int(f.get("retention_value", 2))
+
     job.name = name
     job.type = job_type
     job.config_json = json.dumps(cfg)
     cron_raw = (f.get("cron_expr") or "").strip()
     job.cron_expr = cron_raw  # "" = manuel (NOT NULL compatible avec le schéma existant)
-    job.retention_mode = f.get("retention_mode", "count")
-    job.retention_value = int(f.get("retention_value", 2))
+    job.retention_mode = retention_mode
+    job.retention_value = retention_value
+    job.retention_gfs_config = gfs_config_json
     job.enabled = f.get("enabled") == "1"
     job.core_only = cfg.get("core_only", False)
     if transfer_target.startswith("dest:"):

+ 1 - 0
sources/db.py

@@ -49,6 +49,7 @@ class Job(db.Model):
     cron_expr = db.Column(db.Text, nullable=False, default="")  # "" = déclenchement manuel uniquement
     retention_mode = db.Column(db.Text, nullable=False)  # count|daily|gfs
     retention_value = db.Column(db.Integer, nullable=False)
+    retention_gfs_config = db.Column(db.Text, nullable=True)  # JSON {"daily":N,"weekly":M,"monthly":P}
     enabled = db.Column(db.Boolean, default=True)
     core_only = db.Column(db.Boolean, default=False)
     destination_id = db.Column(db.Integer, db.ForeignKey("destinations.id"), nullable=True)

+ 60 - 0
sources/retention.py

@@ -5,11 +5,15 @@ from datetime import datetime, timedelta
 
 def apply_retention(job, new_archive_name, backup_dir):
     """Applique la politique de rétention après une sauvegarde réussie."""
+    import json as _json
     archives = _list_archives_for_job(job, backup_dir)
     if job.retention_mode == "count":
         to_delete = _retention_count(archives, job.retention_value)
     elif job.retention_mode == "daily":
         to_delete = _retention_daily(archives, job.retention_value)
+    elif job.retention_mode == "gfs":
+        cfg = _json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
+        to_delete = _retention_gfs(archives, cfg)
     else:
         return []
 
@@ -82,6 +86,10 @@ def apply_remote_retention(job, client):
         to_delete = _retention_count(matching, job.retention_value)
     elif job.retention_mode == "daily":
         to_delete = _retention_daily(matching, job.retention_value)
+    elif job.retention_mode == "gfs":
+        import json as _json
+        cfg = _json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
+        to_delete = _retention_gfs(matching, cfg)
     else:
         return []
 
@@ -127,3 +135,55 @@ def _retention_daily(archives, days):
         else:
             seen_dates.add(date_key)
     return to_delete
+
+
+def _retention_gfs(archives, config):
+    """Politique Grandfather-Father-Son.
+
+    config: {"daily": N, "weekly": M, "monthly": P}
+    - Fils (daily)      : conserve les N archives les plus récentes
+    - Père (weekly)     : conserve 1 archive par semaine sur M semaines
+    - Grand-Père (monthly): conserve 1 archive par mois sur P mois
+    Une archive peut satisfaire plusieurs catégories simultanément.
+    """
+    daily_keep = int(config.get("daily", 7))
+    weekly_keep = int(config.get("weekly", 4))
+    monthly_keep = int(config.get("monthly", 12))
+
+    dated = []
+    for archive in archives:
+        d = _extract_date(archive)
+        if d != datetime.min:
+            dated.append((d, archive))
+
+    if not dated:
+        return []
+
+    # Trier du plus récent au plus ancien
+    dated.sort(key=lambda x: x[0], reverse=True)
+
+    keepers = set()
+
+    # Fils : N archives les plus récentes
+    for _, archive in dated[:daily_keep]:
+        keepers.add(archive)
+
+    # Père : 1 archive par semaine (la plus récente de chaque semaine), M semaines
+    seen_weeks = {}
+    for d, archive in dated:
+        wk = (d.isocalendar()[0], d.isocalendar()[1])
+        if wk not in seen_weeks:
+            seen_weeks[wk] = archive  # premier = plus récent de la semaine
+    for wk in sorted(seen_weeks, reverse=True)[:weekly_keep]:
+        keepers.add(seen_weeks[wk])
+
+    # Grand-Père : 1 archive par mois (la plus récente du mois), P mois
+    seen_months = {}
+    for d, archive in dated:
+        mk = (d.year, d.month)
+        if mk not in seen_months:
+            seen_months[mk] = archive
+    for mk in sorted(seen_months, reverse=True)[:monthly_keep]:
+        keepers.add(seen_months[mk])
+
+    return [archive for _, archive in dated if archive not in keepers]

+ 73 - 26
sources/templates/job_form.html

@@ -240,31 +240,59 @@
     </div>
 
     {# ── Rétention ── #}
+    {% set gfs_cfg = (job.retention_gfs_config | fromjson) if job and job.retention_gfs_config else {} %}
     <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
       <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Rétention</h2>
-      <div class="grid grid-cols-2 gap-4">
-        <div>
-          <label class="block text-sm font-medium text-gray-700 mb-1">Mode</label>
-          <select name="retention_mode"
-                  class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
-            {% for val, label in [('count','Garder les N dernières archives'),
-                                  ('daily','1 archive par jour sur N jours glissants'),
-                                  ('gfs','Grand-Père-Fils (non disponible)')] %}
-              <option value="{{ val }}"
-                {% if job and job.retention_mode == val %}selected{% endif %}
-                {% if val == 'gfs' %}disabled class="text-gray-400"{% endif %}>
-                {{ label }}
-              </option>
-            {% endfor %}
-          </select>
-        </div>
-        <div>
-          <label class="block text-sm font-medium text-gray-700 mb-1">Valeur (N)</label>
-          <input type="number" name="retention_value" min="1" max="365" required
-                 value="{{ job.retention_value if job else 7 }}"
-                 class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+      <div>
+        <label class="block text-sm font-medium text-gray-700 mb-1">Mode</label>
+        <select name="retention_mode" id="retention_mode"
+                class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+          {% for val, label in [('count','Garder les N dernières archives'),
+                                ('daily','1 archive par jour sur N jours glissants'),
+                                ('gfs','Grand-Père-Père-Fils (GFS)')] %}
+            <option value="{{ val }}"
+              {% if job and job.retention_mode == val %}selected{% endif %}>
+              {{ label }}
+            </option>
+          {% endfor %}
+        </select>
+      </div>
+
+      {# Mode count / daily : un seul champ N #}
+      <div id="retention-simple">
+        <label class="block text-sm font-medium text-gray-700 mb-1">Valeur (N)</label>
+        <input type="number" name="retention_value" id="retention_value" min="1" max="365"
+               value="{{ job.retention_value if job and job.retention_mode != 'gfs' else 7 }}"
+               class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+      </div>
+
+      {# Mode GFS : trois champs Fils / Père / Grand-Père #}
+      <div id="retention-gfs" class="hidden space-y-3">
+        <div class="grid grid-cols-3 gap-4">
+          <div>
+            <label class="block text-sm font-medium text-gray-700 mb-1">Fils (journaliers)</label>
+            <input type="number" name="gfs_daily" min="1" max="31"
+                   value="{{ gfs_cfg.get('daily', 7) }}"
+                   class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+            <p class="text-xs text-gray-400 mt-1">dernières archives</p>
+          </div>
+          <div>
+            <label class="block text-sm font-medium text-gray-700 mb-1">Père (hebdos)</label>
+            <input type="number" name="gfs_weekly" min="1" max="52"
+                   value="{{ gfs_cfg.get('weekly', 4) }}"
+                   class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+            <p class="text-xs text-gray-400 mt-1">semaines glissantes</p>
+          </div>
+          <div>
+            <label class="block text-sm font-medium text-gray-700 mb-1">Grand-Père (mensuels)</label>
+            <input type="number" name="gfs_monthly" min="1" max="60"
+                   value="{{ gfs_cfg.get('monthly', 12) }}"
+                   class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
+            <p class="text-xs text-gray-400 mt-1">mois glissants</p>
+          </div>
         </div>
       </div>
+
       <p id="retention-help" class="text-xs text-gray-400"></p>
     </div>
 
@@ -329,7 +357,7 @@
   const retentionHelp = {
     count: "Ex : N=7 → conserve les 7 dernières archives, supprime les plus anciennes.",
     daily: "Ex : N=30 → conserve 1 archive par jour sur les 30 derniers jours. Les doublons du même jour sont supprimés.",
-    gfs:   "Mode non encore disponible."
+    gfs:   "Ex : Fils=7, Père=4, GP=12 → 7 archives récentes + 1 par semaine sur 4 semaines + 1 par mois sur 12 mois. Une archive peut couvrir plusieurs niveaux."
   };
 
   const dbCache = {};
@@ -382,8 +410,27 @@
     if (type === 'mysql' || type === 'postgresql') loadDatabases(type);
   }
 
-  function updateRetentionHelp() {
-    const mode = document.querySelector('[name=retention_mode]').value;
+  function updateRetentionMode() {
+    const mode = document.getElementById('retention_mode').value;
+    const simple = document.getElementById('retention-simple');
+    const gfsBlock = document.getElementById('retention-gfs');
+    const simpleInput = document.getElementById('retention_value');
+    const gfsInputs = gfsBlock.querySelectorAll('input');
+
+    if (mode === 'gfs') {
+      simple.classList.add('hidden');
+      simpleInput.disabled = true;
+      simpleInput.required = false;
+      gfsBlock.classList.remove('hidden');
+      gfsInputs.forEach(i => { i.disabled = false; i.required = true; });
+    } else {
+      simple.classList.remove('hidden');
+      simpleInput.disabled = false;
+      simpleInput.required = true;
+      gfsBlock.classList.add('hidden');
+      gfsInputs.forEach(i => { i.disabled = true; i.required = false; });
+    }
+
     document.getElementById('retention-help').textContent = retentionHelp[mode] || '';
   }
 
@@ -424,10 +471,10 @@
   const srcPath = document.querySelector('[name=source_path]');
   if (srcPath) srcPath.addEventListener('input', suggestName);
 
-  document.querySelector('[name=retention_mode]').addEventListener('change', updateRetentionHelp);
+  document.getElementById('retention_mode').addEventListener('change', updateRetentionMode);
   showTypeConfig();
   suggestName(); // pré-remplit le nom si champ vide à l'ouverture du formulaire
-  updateRetentionHelp();
+  updateRetentionMode();
 
   // Toggle planification cron
   const scheduledChk = document.getElementById('scheduled');