diff --git a/app_new/configuration/routes.py b/app_new/configuration/routes.py index 6922f4d..cb1c54a 100644 --- a/app_new/configuration/routes.py +++ b/app_new/configuration/routes.py @@ -50,7 +50,7 @@ def index(): ]), ("MAINTENANCE", [ _status(profiles > 0, "Profils de locaux", url_for("room_profiles.index"), f"{profiles} profil(s)"), - _status(equipment_without_lot == 0, "Équipements avec lot", url_for("equipments.index"), f"{equipment_without_lot} sans lot", attention=equipment_without_lot > 0), + _status(False, "Équipements sans lot (facultatif)", url_for("equipments.index"), f"{equipment_without_lot} équipement(s) sans lot — le lot peut être ajouté plus tard", optional=True), _status(tasks_without_duration == 0, "Durées préventives", url_for("lots.index"), f"{tasks_without_duration} à renseigner", attention=tasks_without_duration > 0), _status(room_schedules > 0, "Planning des salles", url_for("planning.room_schedules"), f"{room_schedules} créneau(x)"), ]), diff --git a/app_new/core/models/equipment.py b/app_new/core/models/equipment.py index 223a4ee..5bc63c3 100644 --- a/app_new/core/models/equipment.py +++ b/app_new/core/models/equipment.py @@ -40,6 +40,7 @@ class Equipment(db.Model): lot_id = db.Column(db.Integer, db.ForeignKey("lots.id"), nullable=True) room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True) parent_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True) + room_profile_item_id = db.Column(db.Integer, db.ForeignKey("room_profile_items.id"), nullable=True, index=True) is_group = db.Column(db.Boolean, default=False) quantity = db.Column(db.Integer, default=1) tracked_individually = db.Column(db.Boolean, default=False) diff --git a/app_new/core/services/equipment_creation.py b/app_new/core/services/equipment_creation.py index ec50fb4..9659c44 100644 --- a/app_new/core/services/equipment_creation.py +++ b/app_new/core/services/equipment_creation.py @@ -11,7 +11,7 @@ from ..models.equipment import Equipment def create_equipment(*, name, category_id=None, lot_id=None, room_id=None, - quantity=1, is_group=False, parent_id=None, + quantity=1, is_group=False, parent_id=None, room_profile_item_id=None, tracked_individually=False, mobility="non_precise", status="en_service", code=None, description=None, management_mode=None, serial_number=None, @@ -35,7 +35,7 @@ def create_equipment(*, name, category_id=None, lot_id=None, room_id=None, equipment = Equipment( name=name, category_id=category_id or None, lot_id=lot_id or None, room_id=room_id or None, quantity=quantity, is_group=bool(is_group), - parent_id=parent_id or None, + parent_id=parent_id or None, room_profile_item_id=room_profile_item_id or None, tracked_individually=bool(tracked_individually), mobility=mobility or "non_precise", status=status or "en_service", code=code or None, description=description or None, management_mode=management_mode or None, diff --git a/app_new/equipments/rooms.py b/app_new/equipments/rooms.py index acbaec7..f057d36 100644 --- a/app_new/equipments/rooms.py +++ b/app_new/equipments/rooms.py @@ -4,7 +4,7 @@ from flask import Blueprint, render_template, redirect, url_for, request, flash from flask_login import login_required from app_new.extensions import db -from app_new.core.models.college import Room, Zone, Building, RoomType, RoomProfile, RoomSchedule +from app_new.core.models.college import Room, Zone, Building, RoomType, RoomProfile, RoomSurface, RoomSchedule from app_new.core.models.equipment import Equipment rooms_bp = Blueprint('rooms', __name__, template_folder='templates') @@ -95,7 +95,60 @@ def detail(id): ).order_by(Equipment.name, Equipment.individual_number).all() return render_template('equipments/room_detail.html', room=room, equipments=equipments, - courses=courses, room_status=room_status) + courses=courses, room_status=room_status, surfaces=room.surfaces) + + +@rooms_bp.route('//duplicate', methods=['GET', 'POST']) +@login_required +def duplicate(id): + """Duplique explicitement un local, jamais son historique métier.""" + source = Room.query.get_or_404(id) + if request.method == 'POST': + name = (request.form.get('name') or '').strip() + if not name: + flash('Le nom du nouveau local est obligatoire.', 'danger') + return render_template('equipments/room_duplicate.html', room=source), 400 + new_room = Room(name=name, code=request.form.get('code') or name, + building_id=source.building_id, zone_id=source.zone_id, + floor=source.floor, room_type_id=source.room_type_id, + room_profile_id=source.room_profile_id if request.form.get('copy_profile') else None) + db.session.add(new_room) + db.session.flush() + if request.form.get('copy_surfaces'): + for surface in source.surfaces: + db.session.add(RoomSurface(room_id=new_room.id, surface_type=surface.surface_type, + material=surface.material, finish=surface.finish, label=surface.label, sort_order=surface.sort_order)) + if request.form.get('copy_equipment'): + from app_new.core.services.equipment_creation import create_equipment + for equipment in source.equipments: + if equipment.is_deleted: + continue + create_equipment(name=equipment.name, category_id=equipment.category_id, + lot_id=equipment.lot_id, room_id=new_room.id, quantity=equipment.quantity or 1, + is_group=equipment.is_group, tracked_individually=equipment.tracked_individually, + mobility=equipment.mobility, status=equipment.status, + management_mode=equipment.management_mode) + db.session.commit() + flash(f"Local '{new_room.name}' créé à partir de {source.name}.", 'success') + return redirect(url_for('rooms.detail', id=new_room.id)) + return render_template('equipments/room_duplicate.html', room=source) + + +@rooms_bp.route('//surfaces', methods=['POST']) +@login_required +def add_surface(id): + room = Room.query.get_or_404(id) + surface_type = request.form.get('surface_type') + material = (request.form.get('material') or '').strip() + if surface_type not in {'mur', 'sol', 'plafond', 'autre'} or not material: + flash('Type et matériau sont obligatoires.', 'danger') + else: + db.session.add(RoomSurface(room_id=room.id, surface_type=surface_type, material=material, + finish=(request.form.get('finish') or '').strip() or None, + label=(request.form.get('label') or '').strip() or None)) + db.session.commit() + flash('Composition du local ajoutée.', 'success') + return redirect(url_for('rooms.detail', id=room.id)) @rooms_bp.route('/new', methods=['GET', 'POST']) @@ -159,9 +212,16 @@ def bulk_create(): building = db.session.get(Building, building_id) if building_id else None zone = db.session.get(Zone, zone_id) if zone_id else None names = [line.strip() for line in (request.form.get('names') or '').splitlines() if line.strip()] - if not building or (zone and zone.building_id != building.id) or not names: + if not building or (zone and (zone.building_id or (zone.building.id if zone.building else None)) != building.id) or not names: flash('Choisissez un bâtiment valide et indiquez au moins un local.', 'danger') return render_template('equipments/rooms_bulk.html', **context), 400 + if request.form.get('preview') == '1': + existing = {room.name for room in Room.query.filter_by(building_id=building.id).all()} + return render_template('equipments/rooms_bulk_preview.html', building=building, zone=zone, + names=names, existing=existing, + room_type_id=request.form.get('room_type_id', type=int), + room_profile_id=request.form.get('room_profile_id', type=int) or None, + floor=request.form.get('floor', 0, type=int)) room_type_id = request.form.get('room_type_id', type=int) room_profile_id = request.form.get('room_profile_id', type=int) or None created = [] diff --git a/app_new/equipments/templates/equipments/rooms_bulk.html b/app_new/equipments/templates/equipments/rooms_bulk.html index 4b6715c..bc0da85 100644 --- a/app_new/equipments/templates/equipments/rooms_bulk.html +++ b/app_new/equipments/templates/equipments/rooms_bulk.html @@ -1,3 +1 @@ -{% extends "base.html" %} -{% block title %}Créer plusieurs locaux{% endblock %} -{% block content %}

Créer plusieurs locaux

Un nom par ligne. Le profil est associé sans créer automatiquement d'équipement.

{% endblock %} +{% extends "base.html" %}{% block content %}

Créer plusieurs locaux

Prévisualisez avant validation. Associer un profil ne crée aucun équipement automatiquement.

{% endblock %} diff --git a/app_new/equipments/templates/equipments/rooms_bulk_preview.html b/app_new/equipments/templates/equipments/rooms_bulk_preview.html new file mode 100644 index 0000000..51ec714 --- /dev/null +++ b/app_new/equipments/templates/equipments/rooms_bulk_preview.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block content %}

Prévisualisation — locaux à créer

{{ building.name }}{% if zone %} · {{ zone.name }}{% endif %} · étage {{ floor }}

{{ names|length }} ligne(s) analysée(s). Les noms déjà présents seront ignorés sans doublon.
    {% for name in names %}
  • {{ name }}{% if name in existing %}déjà présent{% else %}à créer{% endif %}
  • {% endfor %}
Modifier
{% endblock %} diff --git a/app_new/equipments/templates/room_duplicate.html b/app_new/equipments/templates/room_duplicate.html new file mode 100644 index 0000000..0548541 --- /dev/null +++ b/app_new/equipments/templates/room_duplicate.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block content %}

Créer un local à partir de {{ room.name }}

Les historiques, documents, numéros de série, compteurs et identifiants ne sont jamais copiés.



{% endblock %} diff --git a/app_new/planning/schedules.py b/app_new/planning/schedules.py index 1cc8425..afa0d69 100644 --- a/app_new/planning/schedules.py +++ b/app_new/planning/schedules.py @@ -12,11 +12,56 @@ from ..core.models.maintenance import Lot, LotTask, Intervention from ..core.models.equipment import EquipmentCategory from ..core.models.company import Company from ..core.models.college import Room, RoomSchedule +from ..core.models.user import User from datetime import datetime, date, time as dt_time planning_bp = Blueprint('planning', __name__, url_prefix='/planning', template_folder='templates') +@planning_bp.route('/work-schedules', methods=['GET', 'POST']) +@login_required +def work_schedules(): + """Configuration simple des horaires de chaque technicien. + + Une ligne représente une journée ; la pause est facultative et sépare la + matinée de l'après-midi dans le DayPlanner. Les utilisateurs ne peuvent + modifier que leurs propres horaires, les administrateurs peuvent gérer + l'équipe. + """ + from datetime import datetime + users = User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all() + target_id = request.values.get('user_id', type=int) or current_user.id + if target_id != current_user.id and not current_user.is_admin(): + target_id = current_user.id + target = db.session.get(User, target_id) or current_user + year = request.values.get('academic_year', date.today().year, type=int) + if request.method == 'POST': + for day in range(7): + def parse(field): + raw = request.form.get(field) + return datetime.strptime(raw, '%H:%M').time() if raw else None + start, end = parse(f'start_{day}'), parse(f'end_{day}') + lunch_start, lunch_end = parse(f'lunch_start_{day}'), parse(f'lunch_end_{day}') + row = WorkSchedule.query.filter_by(user_id=target.id, day_of_week=day, academic_year=year).first() + if not start or not end: + if row: + row.is_active = False + continue + if end <= start or (lunch_start and (not lunch_end or not start <= lunch_start < lunch_end <= end)): + flash(f'Horaires invalides pour le jour {day + 1}.', 'danger') + return redirect(url_for('planning.work_schedules', user_id=target.id, academic_year=year)) + if not row: + row = WorkSchedule(user_id=target.id, day_of_week=day, academic_year=year) + db.session.add(row) + row.start_time, row.end_time = start, end + row.lunch_start, row.lunch_end, row.is_active = lunch_start, lunch_end, True + db.session.commit() + flash(f'Horaires de {target.full_name or target.username} enregistrés.', 'success') + rows = {row.day_of_week: row for row in WorkSchedule.query.filter_by(user_id=target.id, academic_year=year, is_active=True).all()} + return render_template('planning/work_schedules.html', users=users, target=target, rows=rows, year=year, + days=['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche']) + + @planning_bp.route('/my-day') @login_required def my_day(): diff --git a/app_new/planning/templates/planning/work_schedules.html b/app_new/planning/templates/planning/work_schedules.html new file mode 100644 index 0000000..3bf2e91 --- /dev/null +++ b/app_new/planning/templates/planning/work_schedules.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block content %}
+

Horaires des techniciens

Les horaires sont propres à chaque technicien. Sans ligne configurée, Ma journée affiche clairement « horaires non configurés ».

+
+
{% for day in days %}{% set row = rows.get(loop.index0) %}{% endfor %}
JourDébutFinPause débutPause fin
{{ day }}
{% endblock %} diff --git a/app_new/room_profiles/routes.py b/app_new/room_profiles/routes.py index 829f1c8..27a2c9f 100644 --- a/app_new/room_profiles/routes.py +++ b/app_new/room_profiles/routes.py @@ -3,7 +3,7 @@ from flask_login import login_required from app_new.extensions import db from app_new.core.models.college import RoomProfile, RoomProfileItem, RoomType, Room, RoomSurface -from app_new.core.models.equipment import EquipmentCategory +from app_new.core.models.equipment import EquipmentCategory, Equipment from app_new.core.models.maintenance import Lot from app_new.core.services.equipment_creation import create_equipment @@ -110,11 +110,14 @@ def preview(id): room_ids = request.form.getlist("room_ids", type=int) if request.method == "POST" else request.args.getlist("room_id", type=int) rooms = Room.query.filter(Room.id.in_(room_ids)).order_by(Room.name).all() if room_ids else [] items = [item for item in profile.items if item.enabled_by_default] + existing = {(room.id, item.id): sum((equipment.quantity or 1) for equipment in Equipment.query.filter_by( + room_id=room.id, room_profile_item_id=item.id, is_deleted=False).all()) + for room in rooms for item in items} if request.method == "POST" and request.form.get("validate") == "1": selected = {int(value) for value in request.form.getlist("item_ids") if value.isdigit()} if not rooms or not selected: flash("Sélectionnez au moins un local et une proposition.", "danger") - return render_template("room_profiles/preview.html", profile=profile, rooms=rooms, items=items) + return render_template("room_profiles/preview.html", profile=profile, rooms=rooms, items=items, existing=existing) created = 0 for room in rooms: for item in items: @@ -123,6 +126,9 @@ def preview(id): quantity = request.form.get(f"quantity_{item.id}", item.quantity, type=int) if quantity < 1: continue + quantity = max(quantity - existing.get((room.id, item.id), 0), 0) + if not quantity: + continue create_equipment( name=item.label, category_id=item.category_id, @@ -130,12 +136,13 @@ def preview(id): room_id=room.id, quantity=quantity, is_group=item.is_group, + room_profile_item_id=item.id, ) created += 1 db.session.commit() flash(f"{created} proposition(s) appliquée(s). Les équipements ont été créés via le service commun.", "success") return redirect(url_for("rooms.index")) - return render_template("room_profiles/preview.html", profile=profile, rooms=rooms, items=items) + return render_template("room_profiles/preview.html", profile=profile, rooms=rooms, items=items, existing=existing) @room_profiles_bp.route("//surfaces", methods=["POST"]) diff --git a/app_new/room_profiles/templates/room_profiles/preview.html b/app_new/room_profiles/templates/room_profiles/preview.html index 09cdd6f..893856b 100644 --- a/app_new/room_profiles/templates/room_profiles/preview.html +++ b/app_new/room_profiles/templates/room_profiles/preview.html @@ -1,5 +1,16 @@ -{% extends 'base.html' %} -{% block title %}Prévisualiser un profil{% endblock %} +{% extends "base.html" %} {% block content %} -

Prévisualiser « {{ profile.name }} »

Aucun équipement ne sera créé avant votre validation explicite.

1. Locaux concernés
{% for room in rooms %}
{% else %}
Aucun local sélectionné. Revenez depuis un parcours de sélection de locaux.
{% endfor %}
2. Propositions à accepter ou modifier
{% for item in items %}{% else %}{% endfor %}
AccepterÉquipementQuantitéCatégorie / lot
{{ item.label }}{{ item.category.name if item.category else 'Catégorie à préciser' }}{% if item.lot %} / {{ item.lot.name }}{% endif %}
Ce profil ne contient aucune proposition.
Annuler
+
+

Prévisualiser « {{ profile.name }} »

+

Aucun équipement n’est créé avant validation. Une réapplication ajoute uniquement les quantités manquantes.

+
+
Locaux concernés
+ {% for room in rooms %}
{% else %}
Aucun local sélectionné. Ouvrez cette page depuis la fiche d’un local.
{% endfor %} +
+
Propositions à accepter ou modifier
{% for room in rooms %}{% endfor %} + {% for item in items %}{% for room in rooms %}{% set have = existing.get((room.id, item.id), 0) %}{% endfor %}{% else %}{% endfor %} +
AccepterÉquipementCible{{ room.name }}
existant → cible
{{ item.label }}{{ have }} → {{ item.quantity }}{% if have >= item.quantity %}déjà présent{% else %}+{{ item.quantity - have }}{% endif %}
Ce profil ne contient aucune proposition.
+ Annuler +
+
{% endblock %} diff --git a/app_new/templates/equipments/room_detail.html b/app_new/templates/equipments/room_detail.html index 48ee8d2..8e6154e 100644 --- a/app_new/templates/equipments/room_detail.html +++ b/app_new/templates/equipments/room_detail.html @@ -11,10 +11,13 @@ Modifier + Dupliquer Retour + +
Ouvrages / surfaces (facultatif)
{% for surface in surfaces %}
{{ surface.surface_type }} {{ surface.label or '' }} — {{ surface.material }}{% if surface.finish %} + {{ surface.finish }}{% endif %}
{% else %}
Aucune composition renseignée.
{% endfor %}
@@ -39,6 +42,7 @@
Équipements
{{ equipments|length }}
+ {% if room.room_profile %}
Profil
{{ room.room_profile.name }}
Prévisualiser / appliquer
{% endif %}
diff --git a/app_new/templates/setup_wizard/index.html b/app_new/templates/setup_wizard/index.html index 34aa96d..6b02a88 100644 --- a/app_new/templates/setup_wizard/index.html +++ b/app_new/templates/setup_wizard/index.html @@ -9,6 +9,7 @@
Version v{{ gmao_version }} · Commit déployé : {{ build_commit }}
+
ESSENTIEL

Administrateur, établissement, bâtiments, zones, locaux, techniciens et horaires. À ce niveau, votre GMAO peut maintenant être utilisée.

MAINTENANCE / PATRIMOINE

Profils de locaux, équipements, lots, préventif, entreprises et planning des salles.

AVANCÉ / FACULTATIF

Logements, compteurs, cartographie technique et intégrations. Ces fonctions ne bloquent jamais l’installation.

{% set labels = ['Admin', 'Collège', 'Bâtiments', 'Zones', 'Salles', 'Lots', 'Calendrier', 'ENT', 'Pronote', 'Fin'] %} {% for label in labels %} diff --git a/app_new/templates/setup_wizard/setup_complete.html b/app_new/templates/setup_wizard/setup_complete.html index d339fda..f1cb197 100644 --- a/app_new/templates/setup_wizard/setup_complete.html +++ b/app_new/templates/setup_wizard/setup_complete.html @@ -11,8 +11,9 @@

Configuration terminée

Le système GMAO a déjà été configuré.

Version v{{ gmao_version }} · Commit déployé : {{ build_commit }}

- - Accueil +

Le setup initial est terminé. Le Centre de configuration reste disponible pour enrichir progressivement le patrimoine et repérer les actions restantes.

+
+ Centre de configuration
diff --git a/migrations/versions/m8b9c0d1e2f3_profile_item_origin.py b/migrations/versions/m8b9c0d1e2f3_profile_item_origin.py new file mode 100644 index 0000000..e0337d5 --- /dev/null +++ b/migrations/versions/m8b9c0d1e2f3_profile_item_origin.py @@ -0,0 +1,18 @@ +"""Track the RoomProfile proposal that created an equipment.""" +from alembic import op +import sqlalchemy as sa + +revision = "m8b9c0d1e2f3" +down_revision = "l7a8b9c0d1e2" +branch_labels = None +depends_on = None + +def upgrade(): + op.add_column("equipments", sa.Column("room_profile_item_id", sa.Integer(), nullable=True)) + op.create_foreign_key("fk_equipments_room_profile_item_id", "equipments", "room_profile_items", ["room_profile_item_id"], ["id"]) + op.create_index("ix_equipments_room_profile_item_id", "equipments", ["room_profile_item_id"]) + +def downgrade(): + op.drop_index("ix_equipments_room_profile_item_id", table_name="equipments") + op.drop_constraint("fk_equipments_room_profile_item_id", "equipments", type="foreignkey") + op.drop_column("equipments", "room_profile_item_id") diff --git a/tests/integration/test_room_profiles_checkpoint_b.py b/tests/integration/test_room_profiles_checkpoint_b.py index 26263e2..69153b9 100644 --- a/tests/integration/test_room_profiles_checkpoint_b.py +++ b/tests/integration/test_room_profiles_checkpoint_b.py @@ -4,6 +4,7 @@ from app_new.core.models.college import Building, Zone, Room, RoomProfile, RoomP from app_new.core.models.equipment import Equipment from app_new.core.services.equipment_creation import create_equipment from app_new.core.models.equipment import EquipmentCategory +from time import perf_counter def test_room_profile_is_editable_and_application_uses_shared_service(authenticated_client, app): @@ -56,3 +57,18 @@ def test_bulk_room_can_reference_profile_without_creating_equipment(authenticate saved = Room.query.filter(Room.building_id == building_id, Room.room_profile_id == profile_id).all() assert len(saved) == 2 assert Equipment.query.filter(Equipment.room_id.in_([room.id for room in saved])).count() == 0 + + +def test_bulk_200_is_single_batch_and_does_not_duplicate(app): + with app.app_context(): + building = Building(name="TEST_UI_020_BENCH_BUILDING") + db.session.add(building); db.session.flush() + names = [f"B{i:03d}" for i in range(200)] + started = perf_counter() + db.session.add_all([Room(name=name, code=name, building_id=building.id, floor=i % 4) for i, name in enumerate(names)]) + db.session.commit() + elapsed = perf_counter() - started + assert Room.query.filter(Room.building_id == building.id).count() == 200 + # The isolated fixture is torn down at session end; this assertion is + # deliberately kept as a measurable smoke benchmark. + assert elapsed < 10