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
    Objectif 10 %
    +
    +
    Temps de prévention
    {% for x in logs %}{% endfor %}
    {{ x.work_date|date_fmt }}{{ x.activity }}{{ x.duration_minutes }} min
    +
    Agents et habilitations ({{ staff|length }})
    +
    DUERP — risques
    {% for x in risks %}{% endfor %}
    {{ x.work_unit }}{{ x.hazard }}{{ x.score }}
    +
    Plan d’actions
    {% for x in actions %}{% endfor %}
    {{ x.title }}{{ x.owner or '—' }}{{ x.due_date|date_fmt }}{{ x.status }}
    +
    Registre sécurité / incidents / exercices
    {% for x in entries %}{% endfor %}
    {{ x.entry_date|date_fmt }}{{ x.entry_type }}{{ x.title }}{{ x.location }}
    +
    {% 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