diff --git a/Dockerfile b/Dockerfile
index cd16467..fbffbc6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -67,6 +67,7 @@ COPY --chown=1000:1000 migrations /app/migrations
COPY --chown=1000:1000 VERSION /app/VERSION
COPY --chown=1000:1000 wsgi.py run_app_new.py /app/
COPY --chown=1000:1000 gmao_watchdog.py ent_watchdog.py pronote_watchdog.py watchdog_dnd.py /app/
+COPY --chown=1000:1000 watchdog_gate.py /app/watchdog_gate.py
COPY --chown=1000:1000 docker/backup.py docker/backup_loop.py /app/
# Tracabilite du build (surchargeable avec --build-arg GIT_VERSION=...)
diff --git a/app_new/ai_config/routes.py b/app_new/ai_config/routes.py
index daf72f4..6a9f472 100644
--- a/app_new/ai_config/routes.py
+++ b/app_new/ai_config/routes.py
@@ -59,7 +59,8 @@ def index():
has_key=bool(api_key),
model=model,
model_status=model_status,
- model_error=model_error)
+ model_error=model_error,
+ human_approval=True)
@ai_config_bp.route('/save', methods=['POST'])
@@ -68,7 +69,7 @@ def save():
"""Sauvegarde la configuration IA."""
# Cle API
api_key = request.form.get('api_key', '').strip()
- if api_key and api_key != '***' and not api_key.startswith('***'):
+ if api_key and '...' not in api_key and api_key != '***' and not api_key.startswith('***'):
AppSettings.set('openrouter_api_key', api_key,
description='Cle API OpenRouter pour l\'interpretation des emails',
is_encrypted=True)
@@ -78,6 +79,8 @@ def save():
if model:
AppSettings.set('openrouter_model', model,
description='Modele OpenRouter pour l\'interpretation IA')
+ AppSettings.set('ai_requires_human_approval', 'true',
+ description='Validation humaine obligatoire avant action métier')
flash('Configuration IA sauvegardee.', 'success')
return redirect(url_for('ai_config.index'))
@@ -155,4 +158,4 @@ def api_status():
else:
return jsonify({'status': 'error', 'model': model, 'message': f'HTTP {resp.status_code}'})
except Exception as e:
- return jsonify({'status': 'error', 'model': model, 'message': str(e)[:100]})
\ No newline at end of file
+ return jsonify({'status': 'error', 'model': model, 'message': str(e)[:100]})
diff --git a/app_new/ai_config/templates/ai_config/index.html b/app_new/ai_config/templates/ai_config/index.html
index 168c482..d1b9e02 100644
--- a/app_new/ai_config/templates/ai_config/index.html
+++ b/app_new/ai_config/templates/ai_config/index.html
@@ -11,6 +11,7 @@
Configuration IA (OpenRouter)
+ Validation humaine obligatoire : l’IA produit uniquement des propositions. Elle ne crée, ne modifie et ne clôture jamais automatiquement une intervention.
@@ -129,4 +130,4 @@ document.getElementById('btn-test-model').addEventListener('click', async functi
btn.innerHTML = original;
});
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/app_new/core/models/settings.py b/app_new/core/models/settings.py
index 63935e7..b03805a 100644
--- a/app_new/core/models/settings.py
+++ b/app_new/core/models/settings.py
@@ -3,6 +3,12 @@ Modèles pour les paramètres de l'application
"""
from datetime import datetime, timezone
from app_new.extensions import db
+import os
+
+def _cipher():
+ from cryptography.fernet import Fernet
+ key = os.environ.get('OUTLOOK_ENCRYPTION_KEY')
+ return Fernet(key.encode()) if key else None
class AppSettings(db.Model):
@@ -22,22 +28,37 @@ class AppSettings(db.Model):
"""Récupère un paramètre par sa clé."""
setting = AppSettings.query.filter_by(key=key).first()
if setting:
+ if setting.is_encrypted and setting.value and setting.value.startswith('enc:'):
+ cipher = _cipher()
+ if not cipher:
+ return default
+ try:
+ return cipher.decrypt(setting.value[4:].encode()).decode()
+ except Exception:
+ return default
return setting.value
return default
@staticmethod
def set(key, value, description=None, is_encrypted=False):
"""Définit un paramètre."""
+ stored_value = value
+ if is_encrypted and value:
+ cipher = _cipher()
+ if not cipher:
+ raise RuntimeError("OUTLOOK_ENCRYPTION_KEY est requise pour stocker ce secret.")
+ stored_value = 'enc:' + cipher.encrypt(value.encode()).decode()
setting = AppSettings.query.filter_by(key=key).first()
if setting:
- setting.value = value
+ setting.value = stored_value
+ setting.is_encrypted = is_encrypted
setting.updated_at = datetime.now(timezone.utc)
if description:
setting.description = description
else:
setting = AppSettings(
key=key,
- value=value,
+ value=stored_value,
description=description,
is_encrypted=is_encrypted
)
@@ -61,4 +82,4 @@ class AppSettings(db.Model):
)
def __repr__(self):
- return f'
'
\ No newline at end of file
+ return f''
diff --git a/app_new/gmao_config/routes.py b/app_new/gmao_config/routes.py
index f0b7d9b..e286210 100644
--- a/app_new/gmao_config/routes.py
+++ b/app_new/gmao_config/routes.py
@@ -26,7 +26,7 @@ def index():
outlook_accounts = OutlookAccount.query.filter_by(user_id=current_user.id).all()
# Recuperer la cle API et le modele OpenRouter
- openrouter_key = AppSettings.get('openrouter_api_key') or ''
+ openrouter_key = '••••••••' if AppSettings.get('openrouter_api_key') else ''
openrouter_model = AppSettings.get('openrouter_model', 'poolside/laguna-s-2.1:free')
ollama_model = AppSettings.get('ollama_model', 'gemma3:4b-it')
@@ -80,8 +80,8 @@ def save():
# Configuration IA
from app_new.core.models.settings import AppSettings
api_key = request.form.get('openrouter_api_key', '').strip()
- if api_key:
- AppSettings.set('openrouter_api_key', api_key)
+ if api_key and api_key != '••••••••':
+ AppSettings.set('openrouter_api_key', api_key, is_encrypted=True)
model = request.form.get('openrouter_model', '').strip()
if model:
AppSettings.set('openrouter_model', model)
@@ -150,4 +150,4 @@ def test():
'success': True,
'analysis': result,
'context_summary': context.get_full_context()[:200] + '...' if context.get_full_context() else 'Vide'
- })
\ No newline at end of file
+ })
diff --git a/app_new/logs/routes.py b/app_new/logs/routes.py
index 86513bd..29c7dfe 100644
--- a/app_new/logs/routes.py
+++ b/app_new/logs/routes.py
@@ -1,6 +1,8 @@
"""Logs centralises des watchdogs."""
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for
from flask_login import login_required
+from datetime import datetime, timezone, timedelta
+from app_new.core.models.settings import AppSettings
from sqlalchemy import text
from app_new.extensions import db
from app_new.core.models.maintenance import WatchdogLog
@@ -34,7 +36,8 @@ def index():
return render_template('logs/index.html',
pagination=pagination,
watchdogs=watchdogs,
- filters={'level': level, 'watchdog': watchdog})
+ filters={'level': level, 'watchdog': watchdog},
+ retention_days=int(AppSettings.get('watchdog_log_retention_days', '90')))
@logs_bp.route('/api/recent')
@@ -69,3 +72,15 @@ def clear_watchdog_logs():
db.session.commit()
flash('Les journaux des watchdogs ont été vidés.', 'success')
return redirect(url_for('logs.index'))
+
+
+@logs_bp.route('/retention', methods=['POST'])
+@system_admin_required
+def apply_retention():
+ days = max(1, min(request.form.get('days', type=int) or 90, 3650))
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
+ deleted = WatchdogLog.query.filter(WatchdogLog.created_at < cutoff).delete(synchronize_session=False)
+ db.session.commit()
+ AppSettings.set('watchdog_log_retention_days', str(days), 'Durée de conservation des logs watchdog')
+ flash(f'Rétention fixée à {days} jours ; {deleted} ancien(s) journal(aux) supprimé(s).', 'success')
+ return redirect(url_for('logs.index'))
diff --git a/app_new/logs/templates/logs/index.html b/app_new/logs/templates/logs/index.html
index e9fcbea..f439165 100644
--- a/app_new/logs/templates/logs/index.html
+++ b/app_new/logs/templates/logs/index.html
@@ -7,6 +7,7 @@
API JSON
{% if current_user.is_admin() %}
+