Bläddra i källkod

feat: destinations multiples par job (many-to-many)

Remplace la relation exclusive destination_id / remote_instance_id par une
table job_destinations (job_id, dest_type, dest_id) permettant d'associer
autant de destinations SSH et d'instances fédérées que nécessaire à un job.

- Migration automatique des données existantes au démarrage
- Formulaire : checkboxes multi-sélection à la place du select unique
- Dashboard : badges multiples par job
- Historique : bouton "Transférer" par destination SSH
- Export/import config : format liste destination_names / remote_instance_names
  (compat ancien format destination_name / remote_instance_name)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cedric Hansen 1 månad sedan
förälder
incheckning
d2653f5209

+ 9 - 11
sources/blueprints/jobs.py

@@ -13,7 +13,7 @@ from flask import (
     url_for,
 )
 
-from db import db, Job, Run, Destination, RemoteInstance
+from db import db, Job, Run, Destination, RemoteInstance, JobDestination
 from helpers import read_archive_info, get_ynh_apps
 
 bp = Blueprint("jobs", __name__)
@@ -407,7 +407,7 @@ def _save_job(job):
         db.session.add(job)
 
     from scheduler import schedule_job, remove_job
-    transfer_target = f.get("transfer_target", "").strip()
+    transfer_targets = f.getlist("transfer_targets")
     retention_mode = f.get("retention_mode", "count")
     if retention_mode == "gfs":
         gfs_cfg = {
@@ -431,15 +431,13 @@ def _save_job(job):
     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:"):
-        job.destination_id = int(transfer_target[5:])
-        job.remote_instance_id = None
-    elif transfer_target.startswith("inst:"):
-        job.destination_id = None
-        job.remote_instance_id = int(transfer_target[5:])
-    else:
-        job.destination_id = None
-        job.remote_instance_id = None
+    job.job_destinations = [
+        JobDestination(dest_type="ssh", dest_id=int(t[5:]))
+        if t.startswith("dest:") else
+        JobDestination(dest_type="instance", dest_id=int(t[5:]))
+        for t in transfer_targets
+        if t.startswith("dest:") or t.startswith("inst:")
+    ]
     job.updated_at = datetime.utcnow()
     db.session.commit()
 

+ 20 - 8
sources/blueprints/settings.py

@@ -89,8 +89,8 @@ def export_config():
 
     jobs_data = []
     for j in Job.query.order_by(Job.name).all():
-        dest_name = j.destination.name if j.destination_id and j.destination else None
-        inst_name = j.remote_instance.name if j.remote_instance_id and j.remote_instance else None
+        dest_names = [jd.label for jd in j.job_destinations if jd.dest_type == "ssh"]
+        inst_names = [jd.label for jd in j.job_destinations if jd.dest_type == "instance"]
         jobs_data.append({
             "name": j.name,
             "type": j.type,
@@ -101,8 +101,8 @@ def export_config():
             "retention_gfs_config": j.retention_gfs_config,
             "enabled": j.enabled,
             "core_only": j.core_only,
-            "destination_name": dest_name,
-            "remote_instance_name": inst_name,
+            "destination_names": dest_names,
+            "remote_instance_names": inst_names,
         })
 
     dest_data = []
@@ -227,10 +227,22 @@ def import_config():
         job.core_only = bool(j_data.get("core_only", False))
         job.updated_at = datetime.utcnow()
 
-        dest_name = j_data.get("destination_name")
-        inst_name = j_data.get("remote_instance_name")
-        job.destination_id = dest_by_name[dest_name].id if dest_name and dest_name in dest_by_name else None
-        job.remote_instance_id = inst_by_name[inst_name].id if inst_name and inst_name in inst_by_name else None
+        # Compat ancien format (destination_name / remote_instance_name) et nouveau (listes)
+        dest_names = j_data.get("destination_names") or (
+            [j_data["destination_name"]] if j_data.get("destination_name") else []
+        )
+        inst_names = j_data.get("remote_instance_names") or (
+            [j_data["remote_instance_name"]] if j_data.get("remote_instance_name") else []
+        )
+        from db import JobDestination
+        new_jds = []
+        for dname in dest_names:
+            if dname and dname in dest_by_name:
+                new_jds.append(JobDestination(dest_type="ssh", dest_id=dest_by_name[dname].id))
+        for iname in inst_names:
+            if iname and iname in inst_by_name:
+                new_jds.append(JobDestination(dest_type="instance", dest_id=inst_by_name[iname].id))
+        job.job_destinations = new_jds
 
         counts["jobs"] += 1
     db.session.flush()

+ 31 - 5
sources/db.py

@@ -32,8 +32,6 @@ class Destination(db.Model):
     enabled = db.Column(db.Boolean, default=True)
     created_at = db.Column(db.DateTime, default=datetime.utcnow)
 
-    jobs = db.relationship("Job", backref="destination", lazy=True)
-
     @property
     def remote_str(self):
         return f"{self.user}@{self.host}:{self.remote_path}"
@@ -52,13 +50,12 @@ class Job(db.Model):
     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)
-    remote_instance_id = db.Column(db.Integer, db.ForeignKey("remote_instances.id"), nullable=True)
-    remote_instance = db.relationship("RemoteInstance", foreign_keys=[remote_instance_id], lazy="joined")
     created_at = db.Column(db.DateTime, default=datetime.utcnow)
     updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
 
     runs = db.relationship("Run", backref="job", lazy=True, cascade="all, delete-orphan")
+    job_destinations = db.relationship("JobDestination", backref="job", lazy="joined",
+                                       cascade="all, delete-orphan", order_by="JobDestination.id")
 
     def next_run(self):
         from scheduler import get_next_run
@@ -149,6 +146,35 @@ class RemoteRun(db.Model):
         return _size_human(self.last_size_bytes)
 
 
+class JobDestination(db.Model):
+    __tablename__ = "job_destinations"
+
+    id = db.Column(db.Integer, primary_key=True)
+    job_id = db.Column(db.Integer, db.ForeignKey("jobs.id"), nullable=False)
+    dest_type = db.Column(db.Text, nullable=False)  # 'ssh' | 'instance'
+    dest_id = db.Column(db.Integer, nullable=False)
+
+    @property
+    def resolved(self):
+        if self.dest_type == "ssh":
+            return db.session.get(Destination, self.dest_id)
+        if self.dest_type == "instance":
+            return db.session.get(RemoteInstance, self.dest_id)
+        return None
+
+    @property
+    def label(self):
+        obj = self.resolved
+        return obj.name if obj else f"#{self.dest_id}"
+
+    @property
+    def detail(self):
+        obj = self.resolved
+        if obj is None:
+            return ""
+        return obj.remote_str if self.dest_type == "ssh" else obj.url
+
+
 class Upload(db.Model):
     __tablename__ = "uploads"
 

+ 30 - 0
sources/init_db.py

@@ -33,6 +33,36 @@ if os.path.exists(db_path):
             _conn.execute(sql)
             _conn.commit()
             print(f"Migration : colonne {col} ajoutée à jobs.")
+
+    # Table many-to-many job_destinations
+    tables = {r[0] for r in _conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
+    if "job_destinations" not in tables:
+        _conn.execute("""
+            CREATE TABLE job_destinations (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                job_id INTEGER NOT NULL REFERENCES jobs(id),
+                dest_type TEXT NOT NULL,
+                dest_id INTEGER NOT NULL
+            )
+        """)
+        # Migrer les données existantes depuis destination_id / remote_instance_id
+        if "destination_id" in existing_cols:
+            for job_id, dest_id, inst_id in _conn.execute(
+                "SELECT id, destination_id, remote_instance_id FROM jobs"
+            ).fetchall():
+                if dest_id:
+                    _conn.execute(
+                        "INSERT INTO job_destinations (job_id, dest_type, dest_id) VALUES (?, 'ssh', ?)",
+                        (job_id, dest_id),
+                    )
+                if inst_id:
+                    _conn.execute(
+                        "INSERT INTO job_destinations (job_id, dest_type, dest_id) VALUES (?, 'instance', ?)",
+                        (job_id, inst_id),
+                    )
+        _conn.commit()
+        print("Migration : table job_destinations créée et données migrées.")
+
     _conn.close()
 
 # Import de app après les migrations — SQLAlchemy peut désormais requêter la DB

+ 14 - 14
sources/jobs/ynh_backup.py

@@ -3,7 +3,7 @@ import os
 import subprocess
 from datetime import datetime
 
-from db import db, Job, Run, _size_human
+from db import db, Job, Run, Destination, RemoteInstance, _size_human
 
 
 BACKUP_DIR = None  # initialisé depuis app.config
@@ -53,30 +53,30 @@ def execute_job(job_id):
             run.log_text += f"\n\nRétention locale : {len(deleted)} archive(s) supprimée(s) : {', '.join(deleted)}"
             db.session.commit()
 
-        # Checkpoint 2 : transfert
-        if job.destination_id:
-            from db import Destination
-            dest = db.session.get(Destination, job.destination_id)
-            if dest and dest.enabled:
+        # Checkpoint 2 : transfert vers chaque destination
+        for jd in job.job_destinations:
+            obj = jd.resolved
+            if obj is None:
+                continue
+            if jd.dest_type == "ssh":
+                if not obj.enabled:
+                    continue
                 data_dir = current_app.config["DATA_DIR"]
-                run.log_text += f"\n\nTransfert → {dest.remote_str} : démarré…"
+                run.log_text += f"\n\nTransfert → {obj.remote_str} : démarré…"
                 db.session.commit()
                 try:
                     from jobs.transfer import transfer_archive
-                    transfer_log = transfer_archive(archive_name, dest, backup_dir, data_dir)
+                    transfer_log = transfer_archive(archive_name, obj, backup_dir, data_dir)
                     run.log_text += f"\n{transfer_log}"
                 except Exception as transfer_exc:
                     run.log_text += f"\n⚠ Transfert échoué : {transfer_exc}"
                 db.session.commit()
-        elif job.remote_instance_id:
-            from db import RemoteInstance
-            inst = db.session.get(RemoteInstance, job.remote_instance_id)
-            if inst:
-                run.log_text += f"\n\nTransfert HTTP → {inst.name} ({inst.url}) : démarré…"
+            elif jd.dest_type == "instance":
+                run.log_text += f"\n\nTransfert HTTP → {obj.name} ({obj.url}) : démarré…"
                 db.session.commit()
                 try:
                     from jobs.transfer import push_archive_to_instance
-                    transfer_log = push_archive_to_instance(archive_name, inst, backup_dir, job=job)
+                    transfer_log = push_archive_to_instance(archive_name, obj, backup_dir, job=job)
                     run.log_text += f"\n{transfer_log}"
                 except Exception as transfer_exc:
                     run.log_text += f"\n⚠ Transfert HTTP échoué : {transfer_exc}"

+ 12 - 4
sources/templates/dashboard_local.html

@@ -95,10 +95,18 @@
                 {% else %}<span class="bg-gray-100 text-gray-500 text-xs px-2 py-0.5 rounded font-sans">Manuel</span>{% endif %}
               </td>
               <td class="px-6 py-4">
-                {% if job.destination %}
-                  <span class="bg-violet-50 text-violet-700 text-xs px-2 py-0.5 rounded font-medium" title="{{ job.destination.remote_str }}">SSH · {{ job.destination.name }}</span>
-                {% elif job.remote_instance %}
-                  <span class="bg-blue-50 text-blue-700 text-xs px-2 py-0.5 rounded font-medium" title="{{ job.remote_instance.url }}">HTTP · {{ job.remote_instance.name }}</span>
+                {% if job.job_destinations %}
+                  <div class="flex flex-wrap gap-1">
+                    {% for jd in job.job_destinations %}
+                      {% set obj = jd.resolved %}{% if obj %}
+                        {% if jd.dest_type == 'ssh' %}
+                          <span class="bg-violet-50 text-violet-700 text-xs px-2 py-0.5 rounded font-medium" title="{{ obj.remote_str }}">SSH · {{ obj.name }}</span>
+                        {% else %}
+                          <span class="bg-blue-50 text-blue-700 text-xs px-2 py-0.5 rounded font-medium" title="{{ obj.url }}">HTTP · {{ obj.name }}</span>
+                        {% endif %}
+                      {% endif %}
+                    {% endfor %}
+                  </div>
                 {% else %}
                   <span class="bg-gray-100 text-gray-500 text-xs px-2 py-0.5 rounded">Local</span>
                 {% endif %}

+ 28 - 21
sources/templates/job_form.html

@@ -320,31 +320,38 @@
     {# ── Destination ── #}
     <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3">
       <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Transfert après sauvegarde</h2>
-      <select name="transfer_target"
-              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">
-        <option value="">Aucun — stockage local uniquement</option>
+      {% if destinations or remote_instances %}
+        {% set sel_dest_ids = job.job_destinations | selectattr('dest_type','eq','ssh') | map(attribute='dest_id') | list if job else [] %}
+        {% set sel_inst_ids = job.job_destinations | selectattr('dest_type','eq','instance') | map(attribute='dest_id') | list if job else [] %}
         {% if destinations %}
-          <optgroup label="SSH / rsync">
-            {% for d in destinations %}
-              <option value="dest:{{ d.id }}"
-                      {% if job and job.destination_id == d.id %}selected{% endif %}>
-                {{ d.name }} — {{ d.remote_str }}
-              </option>
-            {% endfor %}
-          </optgroup>
+          <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide">SSH / rsync</p>
+          {% for d in destinations %}
+            <div class="flex items-center gap-2">
+              <input type="checkbox" name="transfer_targets" value="dest:{{ d.id }}"
+                     id="tr_dest_{{ d.id }}"
+                     {% if d.id in sel_dest_ids %}checked{% endif %}
+                     class="rounded border-gray-300 text-blue-600">
+              <label for="tr_dest_{{ d.id }}" class="text-sm text-gray-700">
+                {{ d.name }} <span class="font-mono text-xs text-gray-400">— {{ d.remote_str }}</span>
+              </label>
+            </div>
+          {% endfor %}
         {% endif %}
         {% if remote_instances %}
-          <optgroup label="Instance fédérée (HTTP chunked)">
-            {% for inst in remote_instances %}
-              <option value="inst:{{ inst.id }}"
-                      {% if job and job.remote_instance_id == inst.id %}selected{% endif %}>
-                {{ inst.name }} — {{ inst.url }}
-              </option>
-            {% endfor %}
-          </optgroup>
+          <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide {% if destinations %}mt-2{% endif %}">Instances fédérées</p>
+          {% for inst in remote_instances %}
+            <div class="flex items-center gap-2">
+              <input type="checkbox" name="transfer_targets" value="inst:{{ inst.id }}"
+                     id="tr_inst_{{ inst.id }}"
+                     {% if inst.id in sel_inst_ids %}checked{% endif %}
+                     class="rounded border-gray-300 text-blue-600">
+              <label for="tr_inst_{{ inst.id }}" class="text-sm text-gray-700">
+                {{ inst.name }} <span class="font-mono text-xs text-gray-400">— {{ inst.url }}</span>
+              </label>
+            </div>
+          {% endfor %}
         {% endif %}
-      </select>
-      {% if not destinations and not remote_instances %}
+      {% else %}
         <p class="text-xs text-gray-400">
           Aucune destination configurée.
           <a href="{{ url_for('dest.destination_new') }}" class="text-blue-600 hover:underline">Créer une destination SSH →</a>

+ 4 - 5
sources/templates/job_history.html

@@ -90,15 +90,14 @@
                        class="text-xs text-orange-600 hover:text-orange-800 hover:underline whitespace-nowrap">
                       ↩ Restaurer
                     </a>
-                    {% set destinations = job.destination_id and [job.destination] or [] %}
-                    {% if destinations %}
+                    {% for jd in job.job_destinations if jd.dest_type == 'ssh' %}
                       <form method="post" action="{{ url_for('dest.archive_transfer', archive_name=run.archive_name) }}">
-                        <input type="hidden" name="destination_id" value="{{ job.destination_id }}">
+                        <input type="hidden" name="destination_id" value="{{ jd.dest_id }}">
                         <button type="submit" class="text-xs text-blue-600 hover:text-blue-800 hover:underline whitespace-nowrap text-left">
-                          ↑ Transférer
+                          ↑ {{ jd.label }}
                         </button>
                       </form>
-                    {% endif %}
+                    {% endfor %}
                   </div>
                 {% else %}
                   <span class="text-gray-300 text-xs">—</span>

+ 11 - 8
sources/tests/test_config_io.py

@@ -28,8 +28,8 @@ def _job_data(**overrides):
         "retention_gfs_config": None,
         "enabled": True,
         "core_only": False,
-        "destination_name": None,
-        "remote_instance_name": None,
+        "destination_names": [],
+        "remote_instance_names": [],
     }
     base.update(overrides)
     return base
@@ -92,11 +92,11 @@ class TestExportConfig:
         assert j["name"] == "Sys backup"
         assert j["type"] == "ynh_system"
         assert j["retention_value"] == 5
-        assert j["destination_name"] is None
+        assert j["destination_names"] == []
 
     def test_exporte_job_avec_destination(self, client, app):
         with app.app_context():
-            from db import db, Job, Destination
+            from db import db, Job, Destination, JobDestination
             dest = Destination(
                 name="VPS-OVH", host="vps.example.com", port=22,
                 user="backup", remote_path="/backups", enabled=True,
@@ -106,7 +106,8 @@ class TestExportConfig:
             job = Job(
                 name="Job avec dest", type="ynh_system", config_json="{}",
                 cron_expr="", retention_mode="count", retention_value=7,
-                enabled=True, core_only=False, destination_id=dest.id,
+                enabled=True, core_only=False,
+                job_destinations=[JobDestination(dest_type="ssh", dest_id=dest.id)],
             )
             db.session.add(job)
             db.session.commit()
@@ -114,7 +115,7 @@ class TestExportConfig:
         resp = client.get("/settings/export-config")
         data = resp.get_json()
         j = data["jobs"][0]
-        assert j["destination_name"] == "VPS-OVH"
+        assert j["destination_names"] == ["VPS-OVH"]
 
     def test_nom_fichier_dans_header(self, client):
         resp = client.get("/settings/export-config")
@@ -171,7 +172,7 @@ class TestImportConfig:
 
     def test_lie_job_a_destination(self, client, app):
         payload = _payload(
-            jobs=[_job_data(destination_name="VPS-OVH")],
+            jobs=[_job_data(destination_names=["VPS-OVH"])],
             destinations=[_dest_data()],
         )
         _do_import(client, payload)
@@ -180,7 +181,9 @@ class TestImportConfig:
             from db import Job, Destination
             dest = Destination.query.filter_by(name="VPS-OVH").first()
             job = Job.query.filter_by(name="Mon job").first()
-            assert job.destination_id == dest.id
+            assert len(job.job_destinations) == 1
+            assert job.job_destinations[0].dest_type == "ssh"
+            assert job.job_destinations[0].dest_id == dest.id
 
     def test_cree_instance_distante(self, client, app):
         payload = _payload(remote_instances=[{