Trace les écritures et fiabilise les migrations
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
05b2002d02
commit
7d8d67a9d2
13 changed files with 234 additions and 190 deletions
|
|
@ -58,6 +58,8 @@ def create_app(config_name='default'):
|
||||||
from .core.authorization import enforce_route_permission, has_permission
|
from .core.authorization import enforce_route_permission, has_permission
|
||||||
app.before_request(enforce_route_permission)
|
app.before_request(enforce_route_permission)
|
||||||
app.jinja_env.globals["has_permission"] = has_permission
|
app.jinja_env.globals["has_permission"] = has_permission
|
||||||
|
from .core.audit import record_mutation
|
||||||
|
app.after_request(record_mutation)
|
||||||
|
|
||||||
# Configuration du login manager
|
# Configuration du login manager
|
||||||
login_manager.login_view = 'auth.login'
|
login_manager.login_view = 'auth.login'
|
||||||
|
|
@ -210,10 +212,6 @@ def create_app(config_name='default'):
|
||||||
app.jinja_env.filters["text_color"] = bootstrap_text_color
|
app.jinja_env.filters["text_color"] = bootstrap_text_color
|
||||||
app.jinja_env.globals["timedelta"] = timedelta
|
app.jinja_env.globals["timedelta"] = timedelta
|
||||||
|
|
||||||
# Création des tables (fallback si migrations pas encore appliquees)
|
|
||||||
with app.app_context():
|
|
||||||
db.create_all()
|
|
||||||
|
|
||||||
# Gestionnaires d'erreurs personnalisées
|
# Gestionnaires d'erreurs personnalisées
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def not_found_error(e):
|
def not_found_error(e):
|
||||||
|
|
|
||||||
58
app_new/core/audit.py
Normal file
58
app_new/core/audit.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Capture centralisée et expurgée des écritures HTTP."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from flask import current_app, request
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from ..extensions import db
|
||||||
|
from .models.audit import AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
SENSITIVE_MARKERS = ("password", "mot_de_passe", "secret", "token", "api_key", "apikey", "pin")
|
||||||
|
|
||||||
|
|
||||||
|
def _redacted_payload():
|
||||||
|
data = request.get_json(silent=True) if request.is_json else request.form.to_dict(flat=False)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
clean = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
clean[key] = "[MASQUÉ]" if any(marker in key.lower() for marker in SENSITIVE_MARKERS) else value
|
||||||
|
encoded = json.dumps(clean, ensure_ascii=False, default=str)
|
||||||
|
return encoded[:8000]
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_from_path():
|
||||||
|
endpoint = request.endpoint or ""
|
||||||
|
blueprint = endpoint.split(".", 1)[0] or None
|
||||||
|
match = re.search(r"/(\d+)(?:/|$)", request.path)
|
||||||
|
return blueprint, match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def record_mutation(response):
|
||||||
|
if request.method in {"GET", "HEAD", "OPTIONS"} or response.status_code >= 500:
|
||||||
|
return response
|
||||||
|
entity_type, entity_id = _entity_from_path()
|
||||||
|
values = {
|
||||||
|
"user_id": current_user.id if current_user.is_authenticated else None,
|
||||||
|
"username": current_user.username if current_user.is_authenticated else "anonyme",
|
||||||
|
"action": request.method,
|
||||||
|
"endpoint": request.endpoint,
|
||||||
|
"path": request.path[:500],
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"payload": _redacted_payload(),
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"ip_address": (request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip())[:64],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
# Transaction séparée : le journal ne valide jamais par accident des
|
||||||
|
# changements laissés en attente par une vue métier.
|
||||||
|
with db.engine.begin() as connection:
|
||||||
|
connection.execute(AuditLog.__table__.insert().values(**values))
|
||||||
|
except Exception:
|
||||||
|
# Une panne du journal ne doit pas transformer une réponse métier déjà
|
||||||
|
# produite en erreur 500 ; l'erreur reste visible dans les logs serveur.
|
||||||
|
current_app.logger.exception("Impossible d'enregistrer l'opération dans le journal d'audit")
|
||||||
|
return response
|
||||||
|
|
@ -17,6 +17,7 @@ from .planning import (
|
||||||
TechnicianAvailability, AdminTask, ZoneAccessRule
|
TechnicianAvailability, AdminTask, ZoneAccessRule
|
||||||
)
|
)
|
||||||
from .settings import AppSettings
|
from .settings import AppSettings
|
||||||
|
from .audit import AuditLog
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'User', 'Staff',
|
'User', 'Staff',
|
||||||
|
|
@ -29,5 +30,5 @@ __all__ = [
|
||||||
'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
|
'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
|
||||||
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask',
|
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask',
|
||||||
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
|
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
|
||||||
'AppSettings',
|
'AppSettings', 'AuditLog',
|
||||||
]
|
]
|
||||||
|
|
|
||||||
25
app_new/core/models/audit.py
Normal file
25
app_new/core/models/audit.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""Journal append-only des opérations sensibles."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ...extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(db.Model):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||||
|
username = db.Column(db.String(80), nullable=False, default="système")
|
||||||
|
action = db.Column(db.String(16), nullable=False)
|
||||||
|
endpoint = db.Column(db.String(160), nullable=True)
|
||||||
|
path = db.Column(db.String(500), nullable=False)
|
||||||
|
entity_type = db.Column(db.String(80), nullable=True, index=True)
|
||||||
|
entity_id = db.Column(db.String(80), nullable=True, index=True)
|
||||||
|
payload = db.Column(db.Text, nullable=True)
|
||||||
|
status_code = db.Column(db.SmallInteger, nullable=False)
|
||||||
|
ip_address = db.Column(db.String(64), nullable=True)
|
||||||
|
created_at = db.Column(
|
||||||
|
db.DateTime, nullable=False, index=True,
|
||||||
|
default=lambda: datetime.now(timezone.utc).replace(tzinfo=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -135,6 +135,17 @@ def permissions():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/audit')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def audit_logs():
|
||||||
|
"""Consultation du journal immuable des écritures HTTP."""
|
||||||
|
from ..models.audit import AuditLog
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
logs = AuditLog.query.order_by(AuditLog.created_at.desc()).paginate(page=page, per_page=50)
|
||||||
|
return render_template('admin/audit_logs.html', logs=logs)
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/users/<int:id>/permissions', methods=['POST'])
|
@admin_bp.route('/users/<int:id>/permissions', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ def add_meter_reading(id):
|
||||||
meter_id = data.get('meter_id')
|
meter_id = data.get('meter_id')
|
||||||
value = data.get('value')
|
value = data.get('value')
|
||||||
|
|
||||||
meter = Meter.query.get_or_404(meter_id)
|
meter = Meter.query.filter_by(id=meter_id, equipment_id=id).first_or_404()
|
||||||
|
|
||||||
reading = MeterReading(
|
reading = MeterReading(
|
||||||
meter_id=meter_id,
|
meter_id=meter_id,
|
||||||
|
|
@ -143,7 +143,7 @@ def remove_consumable(id):
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
ec_id = data.get('ec_id')
|
ec_id = data.get('ec_id')
|
||||||
|
|
||||||
ec = EquipmentConsumable.query.get_or_404(ec_id)
|
ec = EquipmentConsumable.query.filter_by(id=ec_id, equipment_id=id).first_or_404()
|
||||||
db.session.delete(ec)
|
db.session.delete(ec)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
@ -151,4 +151,3 @@ def remove_consumable(id):
|
||||||
|
|
||||||
|
|
||||||
# ==================== DOCUMENTS ====================
|
# ==================== DOCUMENTS ====================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,10 +101,11 @@ def delete_restriction(id, restriction_id):
|
||||||
"""Supprimer une restriction."""
|
"""Supprimer une restriction."""
|
||||||
from ..core.models.equipment import EquipmentRestriction
|
from ..core.models.equipment import EquipmentRestriction
|
||||||
|
|
||||||
restriction = EquipmentRestriction.query.get_or_404(restriction_id)
|
restriction = EquipmentRestriction.query.filter_by(
|
||||||
|
id=restriction_id, equipment_id=id
|
||||||
|
).first_or_404()
|
||||||
db.session.delete(restriction)
|
db.session.delete(restriction)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Restriction supprimée', 'success')
|
flash('Restriction supprimée', 'success')
|
||||||
return redirect(url_for('equipments.restrictions', id=id))
|
return redirect(url_for('equipments.restrictions', id=id))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ def new_scheduled_task(id):
|
||||||
from ..core.models.user import User
|
from ..core.models.user import User
|
||||||
|
|
||||||
rooms = Room.query.order_by(Room.name).all()
|
rooms = Room.query.order_by(Room.name).all()
|
||||||
technicians = User.query.filter(User.role.in_(['technician', 'admin'])).all()
|
technicians = User.query.filter(User.role.in_(['technicien', 'tech', 'technician', 'responsable_gmao', 'admin'])).all()
|
||||||
companies = Company.query.order_by(Company.name).all()
|
companies = Company.query.order_by(Company.name).all()
|
||||||
return render_template('equipments/scheduled_task_form.html', equipment=equipment, rooms=rooms, technicians=technicians, companies=companies)
|
return render_template('equipments/scheduled_task_form.html', equipment=equipment, rooms=rooms, technicians=technicians, companies=companies)
|
||||||
|
|
||||||
|
|
@ -79,7 +79,7 @@ def edit_scheduled_task(id, task_id):
|
||||||
from ..core.models.college import Room
|
from ..core.models.college import Room
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
task = ScheduledTask.query.get_or_404(task_id)
|
task = ScheduledTask.query.filter_by(id=task_id, equipment_id=id).first_or_404()
|
||||||
equipment = Equipment.query.get_or_404(id)
|
equipment = Equipment.query.get_or_404(id)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
|
@ -101,7 +101,7 @@ def edit_scheduled_task(id, task_id):
|
||||||
from ..core.models.user import User
|
from ..core.models.user import User
|
||||||
|
|
||||||
rooms = Room.query.order_by(Room.name).all()
|
rooms = Room.query.order_by(Room.name).all()
|
||||||
technicians = User.query.filter(User.role.in_(['technician', 'admin'])).all()
|
technicians = User.query.filter(User.role.in_(['technicien', 'tech', 'technician', 'responsable_gmao', 'admin'])).all()
|
||||||
companies = Company.query.order_by(Company.name).all()
|
companies = Company.query.order_by(Company.name).all()
|
||||||
return render_template('equipments/scheduled_task_form.html', equipment=equipment, task=task, rooms=rooms, technicians=technicians, companies=companies)
|
return render_template('equipments/scheduled_task_form.html', equipment=equipment, task=task, rooms=rooms, technicians=technicians, companies=companies)
|
||||||
|
|
||||||
|
|
@ -112,7 +112,7 @@ def delete_scheduled_task(id, task_id):
|
||||||
"""Supprimer une tâche planifiée."""
|
"""Supprimer une tâche planifiée."""
|
||||||
from ..core.models.planning import ScheduledTask
|
from ..core.models.planning import ScheduledTask
|
||||||
|
|
||||||
task = ScheduledTask.query.get_or_404(task_id)
|
task = ScheduledTask.query.filter_by(id=task_id, equipment_id=id).first_or_404()
|
||||||
db.session.delete(task)
|
db.session.delete(task)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Tâche supprimée', 'success')
|
flash('Tâche supprimée', 'success')
|
||||||
|
|
@ -129,7 +129,7 @@ def create_intervention_from_task(id, task_id):
|
||||||
from ..core.models.user import User
|
from ..core.models.user import User
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
task = ScheduledTask.query.get_or_404(task_id)
|
task = ScheduledTask.query.filter_by(id=task_id, equipment_id=id).first_or_404()
|
||||||
equipment = Equipment.query.get_or_404(id)
|
equipment = Equipment.query.get_or_404(id)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
|
@ -160,4 +160,3 @@ def create_intervention_from_task(id, task_id):
|
||||||
rooms=rooms,
|
rooms=rooms,
|
||||||
technicians=technicians,
|
technicians=technicians,
|
||||||
companies=companies)
|
companies=companies)
|
||||||
|
|
||||||
|
|
|
||||||
22
app_new/templates/admin/audit_logs.html
Normal file
22
app_new/templates/admin/audit_logs.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Journal d'audit — GMAO{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<h1 class="h3 mb-3"><i class="bi bi-journal-lock"></i> Journal d'audit</h1>
|
||||||
|
<p class="text-muted">Écritures HTTP réussies ou refusées. Les mots de passe, jetons, clés et PIN sont masqués.</p>
|
||||||
|
<div class="table-responsive"><table class="table table-sm table-hover align-middle">
|
||||||
|
<thead><tr><th>Date</th><th>Utilisateur</th><th>Action</th><th>Route</th><th>Résultat</th><th>Données</th></tr></thead>
|
||||||
|
<tbody>{% for log in logs.items %}<tr>
|
||||||
|
<td class="text-nowrap">{{ log.created_at|datetime_fmt }}</td><td>{{ log.username }}</td>
|
||||||
|
<td><span class="badge text-bg-secondary">{{ log.action }}</span></td><td><code>{{ log.path }}</code></td>
|
||||||
|
<td><span class="badge {% if log.status_code < 400 %}text-bg-success{% else %}text-bg-warning{% endif %}">{{ log.status_code }}</span></td>
|
||||||
|
<td class="small text-break" style="max-width:30rem">{{ log.payload or '—' }}</td>
|
||||||
|
</tr>{% else %}<tr><td colspan="6" class="text-center text-muted">Aucune écriture enregistrée.</td></tr>{% endfor %}</tbody>
|
||||||
|
</table></div>
|
||||||
|
<nav><ul class="pagination">
|
||||||
|
{% if logs.has_prev %}<li class="page-item"><a class="page-link" href="{{ url_for('admin.audit_logs', page=logs.prev_num) }}">Précédent</a></li>{% endif %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">Page {{ logs.page }} / {{ logs.pages or 1 }}</span></li>
|
||||||
|
{% if logs.has_next %}<li class="page-item"><a class="page-link" href="{{ url_for('admin.audit_logs', page=logs.next_num) }}">Suivant</a></li>{% endif %}
|
||||||
|
</ul></nav>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Gestion des bases — GMAO{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<h2 class="mb-4"><i class="bi bi-database"></i> Gestion des bases de données</h2>
|
|
||||||
|
|
||||||
<!-- Actions principales -->
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="bi bi-gear"></i> Actions
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row g-3">
|
|
||||||
<!-- Créer une nouvelle base -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card h-100 border-primary">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title"><i class="bi bi-plus-circle text-primary"></i> Nouvelle base</h5>
|
|
||||||
<p class="card-text small">Créer une nouvelle base de données vide.</p>
|
|
||||||
<form method="POST" action="{{ url_for('setup_wizard.create') }}">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" class="form-control" name="name" placeholder="Nom (ex: test)" required pattern="[a-zA-Z0-9_-]+" title="Caractères alphanumériques, tirets et underscores uniquement">
|
|
||||||
<span class="input-group-text">.db</span>
|
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-plus"></i></button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sauvegarder la base actuelle -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card h-100 border-success">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title"><i class="bi bi-download text-success"></i> Sauvegarder</h5>
|
|
||||||
<p class="card-text small">Créer une copie horodatée de la base active.</p>
|
|
||||||
<form method="POST" action="{{ url_for('setup_wizard.backup') }}">
|
|
||||||
<button type="submit" class="btn btn-success btn-sm w-100">
|
|
||||||
<i class="bi bi-download"></i> Créer une sauvegarde
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recharger la base à chaud -->
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card h-100 border-info">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title"><i class="bi bi-arrow-repeat text-info"></i> Recharger</h5>
|
|
||||||
<p class="card-text small">Recharger la base de données active sans redémarrer.</p>
|
|
||||||
<form method="POST" action="{{ url_for('setup_wizard.reload_db') }}">
|
|
||||||
<button type="submit" class="btn btn-info btn-sm w-100">
|
|
||||||
<i class="bi bi-arrow-repeat"></i> Recharger la base
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Liste des bases -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<span><i class="bi bi-collection"></i> Bases de données disponibles</span>
|
|
||||||
<form method="POST" action="{{ url_for('setup_wizard.delete_db', filename='dummy') }}" onsubmit="return confirm('Vider la corbeille ?');" id="clearTrashForm">
|
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm" onclick="clearTrash()">
|
|
||||||
<i class="bi bi-trash"></i> Vider la corbeille
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
{% if databases %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover mb-0">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Fichier</th>
|
|
||||||
<th>Taille</th>
|
|
||||||
<th>Modifié le</th>
|
|
||||||
<th>Statut</th>
|
|
||||||
<th class="text-end">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for db in databases %}
|
|
||||||
<tr {% if db.is_current %}class="table-primary"{% endif %}>
|
|
||||||
<td>
|
|
||||||
<code>{{ db.filename }}</code>
|
|
||||||
{% if db.is_current %}
|
|
||||||
<span class="badge bg-primary ms-2">Active</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ (db.size / 1024) | round(1) }} Ko</td>
|
|
||||||
<td>{{ db.modified.strftime('%d/%m/%Y %H:%M') }}</td>
|
|
||||||
<td>
|
|
||||||
{% if db.is_current %}
|
|
||||||
<span class="badge bg-success"><i class="bi bi-check-circle"></i> En cours</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary">Inactive</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
{% if not db.is_current %}
|
|
||||||
<form method="POST" action="{{ url_for('setup_wizard.switch_db', filename=db.filename) }}" style="display:inline;" onsubmit="return confirm('Basculer vers cette base ?');">
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-primary" title="Activer cette base">
|
|
||||||
<i class="bi bi-arrow-repeat"></i> Activer
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<form method="POST" action="{{ url_for('setup_wizard.delete_db', filename=db.filename) }}" style="display:inline;" onsubmit="return confirm('Supprimer cette base ?');">
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Supprimer">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted small">Base active</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="p-4 text-center text-muted">
|
|
||||||
<i class="bi bi-inbox fs-1"></i>
|
|
||||||
<p>Aucune base de données trouvée.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Avertissement -->
|
|
||||||
<div class="alert alert-warning mt-4" role="alert">
|
|
||||||
<i class="bi bi-exclamation-triangle"></i> <strong>Attention :</strong>
|
|
||||||
Le basculement vers une autre base nécessite un rechargement pour prendre effet.
|
|
||||||
Les bases supprimées sont déplacées vers une corbeille.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function clearTrash() {
|
|
||||||
if (confirm('Vider la corbeille ? Cette action est irréversible.')) {
|
|
||||||
fetch('/setup-wizard/admin/clear-trash', {method: 'POST'})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
alert(data.error || 'Erreur');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -32,25 +32,58 @@ echo "MariaDB OK"
|
||||||
mkdir -p /app/data/cache /app/app_new/uploads /app/app_new/instance
|
mkdir -p /app/data/cache /app/app_new/uploads /app/app_new/instance
|
||||||
chown -R 1000:1000 /app/data /app/app_new/uploads /app/app_new/instance 2>/dev/null || true
|
chown -R 1000:1000 /app/data /app/app_new/uploads /app/app_new/instance 2>/dev/null || true
|
||||||
|
|
||||||
# Alembic
|
# Alembic : une base neuve est créée depuis les modèles, une base importée du
|
||||||
echo "Verification Alembic..."
|
# dump est rattachée au bon point historique, puis toutes les évolutions
|
||||||
python3 - <<PYEOF "${MARIADB_DATABASE:-gmao_db}"
|
# suivantes passent obligatoirement par les migrations.
|
||||||
import sys
|
echo "Application des migrations Alembic..."
|
||||||
|
ALEMBIC_STATE="$(python3 - <<'PYEOF'
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from app_new import create_app
|
from app_new import create_app
|
||||||
|
|
||||||
db_name = sys.argv[1]
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
engine = app.extensions['migrate'].db.engine
|
engine = app.extensions['migrate'].db.engine
|
||||||
inspector = inspect(engine)
|
tables = set(inspect(engine).get_table_names())
|
||||||
tables = inspector.get_table_names()
|
if 'alembic_version' in tables:
|
||||||
print(f"Tables presentes: {len(tables)}")
|
print('tracked')
|
||||||
if 'alembic_version' not in tables:
|
elif 'users' not in tables:
|
||||||
print("Note: alembic_version absent, base probablement initialisee par dump SQL")
|
print('empty')
|
||||||
|
elif 'closure_work_days' in tables:
|
||||||
|
print('imported_f1')
|
||||||
else:
|
else:
|
||||||
print("alembic_version present, migrations suivies")
|
print('imported_e0')
|
||||||
PYEOF
|
PYEOF
|
||||||
|
)"
|
||||||
|
|
||||||
|
case "$ALEMBIC_STATE" in
|
||||||
|
empty)
|
||||||
|
echo "Base vide : création du schéma courant."
|
||||||
|
python3 - <<'PYEOF'
|
||||||
|
from app_new import create_app, db
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
PYEOF
|
||||||
|
flask --app wsgi:app db stamp head
|
||||||
|
;;
|
||||||
|
imported_e0)
|
||||||
|
echo "Dump historique détecté : rattachement à e0f4a5b6c7d8."
|
||||||
|
flask --app wsgi:app db stamp e0f4a5b6c7d8
|
||||||
|
flask --app wsgi:app db upgrade
|
||||||
|
;;
|
||||||
|
imported_f1)
|
||||||
|
echo "Base importée déjà enrichie : rattachement à f1a5b6c7d8e9."
|
||||||
|
flask --app wsgi:app db stamp f1a5b6c7d8e9
|
||||||
|
flask --app wsgi:app db upgrade
|
||||||
|
;;
|
||||||
|
tracked)
|
||||||
|
flask --app wsgi:app db upgrade
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Etat Alembic inattendu: $ALEMBIC_STATE" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
echo "Lancement de l'application..."
|
echo "Lancement de l'application..."
|
||||||
|
|
||||||
|
|
|
||||||
39
migrations/versions/b3c7d8e9f0a1_add_audit_logs.py
Normal file
39
migrations/versions/b3c7d8e9f0a1_add_audit_logs.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""Ajoute le journal d'audit.
|
||||||
|
|
||||||
|
Revision ID: b3c7d8e9f0a1
|
||||||
|
Revises: a2b6c7d8e9f0
|
||||||
|
Create Date: 2026-08-14
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "b3c7d8e9f0a1"
|
||||||
|
down_revision = "a2b6c7d8e9f0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"audit_logs",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("username", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("action", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("endpoint", sa.String(length=160), nullable=True),
|
||||||
|
sa.Column("path", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("entity_id", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("payload", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status_code", sa.SmallInteger(), nullable=False),
|
||||||
|
sa.Column("ip_address", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
for column in ("user_id", "entity_type", "entity_id", "created_at"):
|
||||||
|
op.create_index(f"ix_audit_logs_{column}", "audit_logs", [column])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("audit_logs")
|
||||||
21
tests/integration/test_audit_log.py
Normal file
21
tests/integration/test_audit_log.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Le journal d'audit doit être utile sans enregistrer de secrets."""
|
||||||
|
from app_new.core.models.audit import AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_attempt_is_audited_and_password_is_redacted(client, app):
|
||||||
|
response = client.post('/auth/login', data={
|
||||||
|
'username': 'personne-inconnue',
|
||||||
|
'password': 'secret-qui-ne-doit-pas-etre-stocke',
|
||||||
|
})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
entry = AuditLog.query.filter_by(endpoint='auth.login').order_by(AuditLog.id.desc()).first()
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.action == 'POST'
|
||||||
|
assert 'secret-qui-ne-doit-pas-etre-stocke' not in entry.payload
|
||||||
|
assert '[MASQUÉ]' in entry.payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_page_is_admin_only(client, app):
|
||||||
|
assert client.get('/admin/audit').status_code == 302
|
||||||
Loading…
Reference in a new issue