diff --git a/app_new/__init__.py b/app_new/__init__.py
index 802ca9f..ed77873 100644
--- a/app_new/__init__.py
+++ b/app_new/__init__.py
@@ -136,6 +136,8 @@ def create_app(config_name='default'):
from .housing import housing_bp
app.register_blueprint(housing_bp, url_prefix='/housing')
+ from .prevention import prevention_bp
+ app.register_blueprint(prevention_bp, url_prefix='/prevention')
# Blueprints des intégrations
from .pronote.routes import pronote_bp
diff --git a/app_new/core/authorization.py b/app_new/core/authorization.py
index 06d113a..f366862 100644
--- a/app_new/core/authorization.py
+++ b/app_new/core/authorization.py
@@ -74,7 +74,7 @@ PATRIMOINE_BLUEPRINTS = {
PLANNING_BLUEPRINTS = {"planning", "scheduler", "interventions_planning"}
STOCK_BLUEPRINTS = {"parts", "meters"}
CONTRACT_BLUEPRINTS = {"companies", "contracts", "services"}
-PREVENTION_BLUEPRINTS = {"trainings", "constraints"}
+PREVENTION_BLUEPRINTS = {"trainings", "constraints", "prevention"}
def canonical_role(role):
diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py
index caacf77..f177740 100644
--- a/app_new/core/models/__init__.py
+++ b/app_new/core/models/__init__.py
@@ -18,6 +18,7 @@ from .planning import (
)
from .settings import AppSettings
from .audit import AuditLog
+from .prevention import PreventionWorkLog, StaffAuthorization, RiskAssessment, PreventionAction, SafetyRegisterEntry
__all__ = [
'User', 'Staff',
@@ -31,4 +32,5 @@ __all__ = [
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask',
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
'AppSettings', 'AuditLog',
+ 'PreventionWorkLog', 'StaffAuthorization', 'RiskAssessment', 'PreventionAction', 'SafetyRegisterEntry',
]
diff --git a/app_new/core/models/prevention.py b/app_new/core/models/prevention.py
new file mode 100644
index 0000000..ee41a42
--- /dev/null
+++ b/app_new/core/models/prevention.py
@@ -0,0 +1,69 @@
+"""Registre de prévention, DUERP et suivi du temps dédié."""
+from datetime import datetime, timezone
+from app_new.extensions import db
+
+
+class PreventionWorkLog(db.Model):
+ __tablename__ = "prevention_work_logs"
+ id = db.Column(db.Integer, primary_key=True)
+ user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
+ work_date = db.Column(db.Date, nullable=False, index=True)
+ duration_minutes = db.Column(db.Integer, nullable=False)
+ activity = db.Column(db.String(255), nullable=False)
+ notes = db.Column(db.Text)
+ created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
+ user = db.relationship("User")
+
+
+class StaffAuthorization(db.Model):
+ __tablename__ = "staff_authorizations"
+ id = db.Column(db.Integer, primary_key=True)
+ staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=False, index=True)
+ name = db.Column(db.String(160), nullable=False)
+ issued_on = db.Column(db.Date)
+ expires_on = db.Column(db.Date, index=True)
+ issuer = db.Column(db.String(160))
+ notes = db.Column(db.Text)
+ staff = db.relationship("Staff", backref=db.backref("authorizations", cascade="all, delete-orphan"))
+
+
+class RiskAssessment(db.Model):
+ __tablename__ = "risk_assessments"
+ id = db.Column(db.Integer, primary_key=True)
+ work_unit = db.Column(db.String(160), nullable=False)
+ hazard = db.Column(db.String(255), nullable=False)
+ exposed_people = db.Column(db.String(255))
+ severity = db.Column(db.Integer, nullable=False, default=1)
+ probability = db.Column(db.Integer, nullable=False, default=1)
+ control_measures = db.Column(db.Text)
+ reviewed_on = db.Column(db.Date)
+ is_active = db.Column(db.Boolean, nullable=False, default=True)
+ @property
+ def score(self): return self.severity * self.probability
+
+
+class PreventionAction(db.Model):
+ __tablename__ = "prevention_actions"
+ id = db.Column(db.Integer, primary_key=True)
+ title = db.Column(db.String(255), nullable=False)
+ source_type = db.Column(db.String(40), nullable=False, default="observation")
+ risk_id = db.Column(db.Integer, db.ForeignKey("risk_assessments.id"))
+ priority = db.Column(db.String(20), nullable=False, default="normale")
+ owner = db.Column(db.String(160))
+ due_date = db.Column(db.Date, index=True)
+ status = db.Column(db.String(30), nullable=False, default="a_faire")
+ evidence = db.Column(db.Text)
+ risk = db.relationship("RiskAssessment", backref="actions")
+
+
+class SafetyRegisterEntry(db.Model):
+ __tablename__ = "safety_register_entries"
+ id = db.Column(db.Integer, primary_key=True)
+ entry_date = db.Column(db.Date, nullable=False, index=True)
+ entry_type = db.Column(db.String(50), nullable=False)
+ title = db.Column(db.String(255), nullable=False)
+ details = db.Column(db.Text)
+ location = db.Column(db.String(255))
+ follow_up = db.Column(db.Text)
+ created_by_id = db.Column(db.Integer, db.ForeignKey("users.id"))
+ created_by = db.relationship("User")
diff --git a/app_new/prevention/__init__.py b/app_new/prevention/__init__.py
new file mode 100644
index 0000000..2dd7a3e
--- /dev/null
+++ b/app_new/prevention/__init__.py
@@ -0,0 +1,2 @@
+from .routes import prevention_bp
+__all__ = ["prevention_bp"]
diff --git a/app_new/prevention/routes.py b/app_new/prevention/routes.py
new file mode 100644
index 0000000..5fb103e
--- /dev/null
+++ b/app_new/prevention/routes.py
@@ -0,0 +1,54 @@
+from datetime import date, datetime, timedelta
+from flask import Blueprint, flash, redirect, render_template, request, url_for
+from flask_login import current_user, login_required
+from sqlalchemy import func
+from app_new.extensions import db
+from app_new.core.models.user import Staff
+from app_new.core.models.prevention import PreventionWorkLog, StaffAuthorization, RiskAssessment, PreventionAction, SafetyRegisterEntry
+
+prevention_bp = Blueprint("prevention", __name__)
+def _date(value): return datetime.strptime(value, "%Y-%m-%d").date() if value else None
+
+@prevention_bp.route("/")
+@login_required
+def index():
+ monday = date.today() - timedelta(days=date.today().weekday())
+ minutes = db.session.query(func.coalesce(func.sum(PreventionWorkLog.duration_minutes), 0)).filter(PreventionWorkLog.user_id == current_user.id, PreventionWorkLog.work_date >= monday).scalar()
+ target = 210 # 10 % d'une semaine de 35 h, paramétrable à l'étape suivante.
+ return render_template("prevention/index.html", staff=Staff.query.filter_by(is_active=True).order_by(Staff.last_name).all(), logs=PreventionWorkLog.query.order_by(PreventionWorkLog.work_date.desc()).limit(20).all(), risks=RiskAssessment.query.filter_by(is_active=True).all(), actions=PreventionAction.query.order_by(PreventionAction.due_date).all(), entries=SafetyRegisterEntry.query.order_by(SafetyRegisterEntry.entry_date.desc()).limit(20).all(), minutes=minutes, target=target)
+
+@prevention_bp.post("/staff")
+@login_required
+def add_staff():
+ db.session.add(Staff(first_name=request.form.get("first_name", "").strip(), last_name=request.form.get("last_name", "").strip(), function=request.form.get("function")))
+ db.session.commit(); flash("Agent ajouté.", "success"); return redirect(url_for("prevention.index"))
+
+@prevention_bp.post("/time")
+@login_required
+def add_time():
+ db.session.add(PreventionWorkLog(user_id=current_user.id, work_date=_date(request.form.get("work_date")) or date.today(), duration_minutes=max(1, request.form.get("duration_minutes", type=int) or 1), activity=request.form.get("activity", "").strip(), notes=request.form.get("notes")))
+ db.session.commit(); flash("Temps de prévention enregistré.", "success"); return redirect(url_for("prevention.index"))
+
+@prevention_bp.post("/authorization")
+@login_required
+def add_authorization():
+ db.session.add(StaffAuthorization(staff_id=request.form.get("staff_id", type=int), name=request.form.get("name", "").strip(), issued_on=_date(request.form.get("issued_on")), expires_on=_date(request.form.get("expires_on")), issuer=request.form.get("issuer")))
+ db.session.commit(); flash("Habilitation enregistrée.", "success"); return redirect(url_for("prevention.index"))
+
+@prevention_bp.post("/risk")
+@login_required
+def add_risk():
+ db.session.add(RiskAssessment(work_unit=request.form.get("work_unit", "").strip(), hazard=request.form.get("hazard", "").strip(), exposed_people=request.form.get("exposed_people"), severity=request.form.get("severity", type=int) or 1, probability=request.form.get("probability", type=int) or 1, control_measures=request.form.get("control_measures"), reviewed_on=date.today()))
+ db.session.commit(); flash("Risque ajouté au DUERP.", "success"); return redirect(url_for("prevention.index"))
+
+@prevention_bp.post("/action")
+@login_required
+def add_action():
+ db.session.add(PreventionAction(title=request.form.get("title", "").strip(), source_type=request.form.get("source_type", "observation"), risk_id=request.form.get("risk_id", type=int), priority=request.form.get("priority", "normale"), owner=request.form.get("owner"), due_date=_date(request.form.get("due_date"))))
+ db.session.commit(); flash("Action créée.", "success"); return redirect(url_for("prevention.index"))
+
+@prevention_bp.post("/register")
+@login_required
+def add_register():
+ db.session.add(SafetyRegisterEntry(entry_date=_date(request.form.get("entry_date")) or date.today(), entry_type=request.form.get("entry_type", "observation"), title=request.form.get("title", "").strip(), details=request.form.get("details"), location=request.form.get("location"), created_by_id=current_user.id))
+ db.session.commit(); flash("Entrée ajoutée au registre.", "success"); return redirect(url_for("prevention.index"))
diff --git a/app_new/templates/base.html b/app_new/templates/base.html
index e7010bb..ac252b6 100644
--- a/app_new/templates/base.html
+++ b/app_new/templates/base.html
@@ -192,6 +192,7 @@
Tâches administratives
Règles d'accès
+ Prévention / DUERP
Formations
Mes formations
diff --git a/app_new/templates/prevention/index.html b/app_new/templates/prevention/index.html
new file mode 100644
index 0000000..27ff338
--- /dev/null
+++ b/app_new/templates/prevention/index.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}{% block title %}Prévention — GMAO{% endblock %}{% block content %}
+ Assistant de prévention
Agents, temps dédié, DUERP, habilitations et plan d’actions.
{{ (minutes / 60)|round(1) }} h / {{ (target / 60)|round(1) }} h
+
+
{% for x in logs %}| {{ x.work_date|date_fmt }} | {{ x.activity }} | {{ x.duration_minutes }} min |
{% endfor %}
+
+
{% for x in risks %}| {{ x.work_unit }} | {{ x.hazard }} | {{ x.score }} |
{% endfor %}
+
{% for x in actions %}| {{ x.title }} | {{ x.owner or '—' }} | {{ x.due_date|date_fmt }} | {{ x.status }} |
{% endfor %}
+
{% for x in entries %}| {{ x.entry_date|date_fmt }} | {{ x.entry_type }} | {{ x.title }} | {{ x.location }} |
{% endfor %}
+
{% endblock %}
diff --git a/migrations/versions/f7a1b2c3d4e5_add_prevention_registers.py b/migrations/versions/f7a1b2c3d4e5_add_prevention_registers.py
new file mode 100644
index 0000000..84d2e8a
--- /dev/null
+++ b/migrations/versions/f7a1b2c3d4e5_add_prevention_registers.py
@@ -0,0 +1,16 @@
+"""Ajoute les registres de prévention et le DUERP.
+
+Revision ID: f7a1b2c3d4e5
+Revises: e6f0a1b2c3d4
+"""
+from alembic import op
+import sqlalchemy as sa
+revision="f7a1b2c3d4e5"; down_revision="e6f0a1b2c3d4"; branch_labels=None; depends_on=None
+def upgrade():
+ op.create_table("prevention_work_logs",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("user_id",sa.Integer(),sa.ForeignKey("users.id"),nullable=False),sa.Column("work_date",sa.Date(),nullable=False),sa.Column("duration_minutes",sa.Integer(),nullable=False),sa.Column("activity",sa.String(255),nullable=False),sa.Column("notes",sa.Text()),sa.Column("created_at",sa.DateTime())); op.create_index("ix_prevention_work_logs_user_id","prevention_work_logs",["user_id"]); op.create_index("ix_prevention_work_logs_work_date","prevention_work_logs",["work_date"])
+ op.create_table("staff_authorizations",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("staff_id",sa.Integer(),sa.ForeignKey("staff.id"),nullable=False),sa.Column("name",sa.String(160),nullable=False),sa.Column("issued_on",sa.Date()),sa.Column("expires_on",sa.Date()),sa.Column("issuer",sa.String(160)),sa.Column("notes",sa.Text())); op.create_index("ix_staff_authorizations_staff_id","staff_authorizations",["staff_id"]); op.create_index("ix_staff_authorizations_expires_on","staff_authorizations",["expires_on"])
+ op.create_table("risk_assessments",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("work_unit",sa.String(160),nullable=False),sa.Column("hazard",sa.String(255),nullable=False),sa.Column("exposed_people",sa.String(255)),sa.Column("severity",sa.Integer(),nullable=False),sa.Column("probability",sa.Integer(),nullable=False),sa.Column("control_measures",sa.Text()),sa.Column("reviewed_on",sa.Date()),sa.Column("is_active",sa.Boolean(),nullable=False))
+ op.create_table("prevention_actions",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("title",sa.String(255),nullable=False),sa.Column("source_type",sa.String(40),nullable=False),sa.Column("risk_id",sa.Integer(),sa.ForeignKey("risk_assessments.id")),sa.Column("priority",sa.String(20),nullable=False),sa.Column("owner",sa.String(160)),sa.Column("due_date",sa.Date()),sa.Column("status",sa.String(30),nullable=False),sa.Column("evidence",sa.Text())); op.create_index("ix_prevention_actions_due_date","prevention_actions",["due_date"])
+ op.create_table("safety_register_entries",sa.Column("id",sa.Integer(),primary_key=True),sa.Column("entry_date",sa.Date(),nullable=False),sa.Column("entry_type",sa.String(50),nullable=False),sa.Column("title",sa.String(255),nullable=False),sa.Column("details",sa.Text()),sa.Column("location",sa.String(255)),sa.Column("follow_up",sa.Text()),sa.Column("created_by_id",sa.Integer(),sa.ForeignKey("users.id"))); op.create_index("ix_safety_register_entries_entry_date","safety_register_entries",["entry_date"])
+def downgrade():
+ for table in ("safety_register_entries","prevention_actions","risk_assessments","staff_authorizations","prevention_work_logs"): op.drop_table(table)
diff --git a/tests/integration/test_prevention.py b/tests/integration/test_prevention.py
new file mode 100644
index 0000000..f087b23
--- /dev/null
+++ b/tests/integration/test_prevention.py
@@ -0,0 +1,31 @@
+from datetime import date
+from uuid import uuid4
+from flask_login import login_user
+from app_new.extensions import db
+from app_new.core.models.prevention import PreventionAction, PreventionWorkLog, RiskAssessment
+from app_new.core.models.user import User
+from app_new.prevention.routes import add_risk, add_time, index
+
+
+def test_prevention_dashboard_tracks_time_and_duerp(app):
+ with app.app_context():
+ user = User(username=f"prev-{uuid4().hex}", full_name="Prévention", role="assistant_prevention", password_hash="x")
+ db.session.add(user); db.session.commit()
+ with app.test_request_context(method="POST", data={"work_date": date.today().isoformat(), "duration_minutes": "90", "activity": "Visite sécurité"}):
+ login_user(user); assert add_time.__wrapped__().status_code == 302
+ with app.test_request_context(method="POST", data={"work_unit": "Entretien", "hazard": "Chute", "severity": "4", "probability": "3"}):
+ login_user(user); assert add_risk.__wrapped__().status_code == 302
+ assert PreventionWorkLog.query.filter_by(user_id=user.id).one().duration_minutes == 90
+ assert RiskAssessment.query.filter_by(work_unit="Entretien").one().score == 12
+ with app.test_request_context():
+ login_user(user); html = index.__wrapped__()
+ assert "Objectif 10 %" in html and "Visite sécurité" in html
+
+
+def test_prevention_action_can_reference_a_risk(app):
+ with app.app_context():
+ risk = RiskAssessment(work_unit="Cuisine", hazard="Brûlure", severity=3, probability=2)
+ db.session.add(risk); db.session.flush()
+ action = PreventionAction(title="Installer une protection", risk=risk, owner="Gestionnaire")
+ db.session.add(action); db.session.commit()
+ assert action.risk.score == 6