From b202cca76028e5112393d5224e7e39e7de14c418 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Aug 2026 16:45:41 +0000 Subject: [PATCH] feat(patrimoine): ajouter profils de locaux progressifs --- app_new/__init__.py | 6 + app_new/configuration/__init__.py | 1 + app_new/configuration/routes.py | 69 ++++++++ .../templates/configuration/index.html | 5 + app_new/core/authorization.py | 2 +- app_new/core/models/__init__.py | 4 +- app_new/core/models/college.py | 64 ++++++++ app_new/core/routes/setup_wizard.py | 1 + app_new/equipments/rooms.py | 7 +- .../templates/equipments/rooms_bulk.html | 4 +- app_new/room_profiles/__init__.py | 1 + app_new/room_profiles/routes.py | 155 ++++++++++++++++++ .../templates/room_profiles/form.html | 7 + .../templates/room_profiles/index.html | 8 + .../templates/room_profiles/preview.html | 5 + app_new/templates/equipments/room_form.html | 10 ++ .../versions/k6f7a8b9c0d1_room_profiles.py | 69 ++++++++ .../l7a8b9c0d1e2_assign_room_profiles.py | 20 +++ .../test_room_profiles_checkpoint_b.py | 58 +++++++ 19 files changed, 489 insertions(+), 7 deletions(-) create mode 100644 app_new/configuration/__init__.py create mode 100644 app_new/configuration/routes.py create mode 100644 app_new/configuration/templates/configuration/index.html create mode 100644 app_new/room_profiles/__init__.py create mode 100644 app_new/room_profiles/routes.py create mode 100644 app_new/room_profiles/templates/room_profiles/form.html create mode 100644 app_new/room_profiles/templates/room_profiles/index.html create mode 100644 app_new/room_profiles/templates/room_profiles/preview.html create mode 100644 migrations/versions/k6f7a8b9c0d1_room_profiles.py create mode 100644 migrations/versions/l7a8b9c0d1e2_assign_room_profiles.py create mode 100644 tests/integration/test_room_profiles_checkpoint_b.py diff --git a/app_new/__init__.py b/app_new/__init__.py index fe53efe..f9449f2 100644 --- a/app_new/__init__.py +++ b/app_new/__init__.py @@ -154,12 +154,18 @@ def create_app(config_name='default'): from .room_types.routes import room_types_bp app.register_blueprint(room_types_bp, url_prefix='/room-types') + + from .room_profiles.routes import room_profiles_bp + app.register_blueprint(room_profiles_bp, url_prefix='/room-profiles') from .documents.routes import documents_bp app.register_blueprint(documents_bp, url_prefix='/documents') from .gmao_config.routes import gmao_config_bp app.register_blueprint(gmao_config_bp, url_prefix='/gmao-config') + + from .configuration.routes import configuration_bp + app.register_blueprint(configuration_bp, url_prefix='/configuration') from .wizard.routes import wizard_bp app.register_blueprint(wizard_bp) diff --git a/app_new/configuration/__init__.py b/app_new/configuration/__init__.py new file mode 100644 index 0000000..57775d8 --- /dev/null +++ b/app_new/configuration/__init__.py @@ -0,0 +1 @@ +"""Centre de configuration permanent.""" diff --git a/app_new/configuration/routes.py b/app_new/configuration/routes.py new file mode 100644 index 0000000..6922f4d --- /dev/null +++ b/app_new/configuration/routes.py @@ -0,0 +1,69 @@ +from flask import Blueprint, render_template +from flask_login import login_required + +from app_new.core.models.college import Site, Building, Zone, Room, RoomProfile, RoomSchedule +from app_new.core.models.equipment import Equipment +from app_new.core.models.maintenance import LotTask +from app_new.core.models.user import User +from app_new.core.models.planning import WorkSchedule +from app_new.contracts.models import Contract + +configuration_bp = Blueprint("configuration", __name__, template_folder="templates") + + +def _status(ok, label, url, detail=None, optional=False, attention=False): + if optional and not ok: + state = "Facultatif" + elif attention: + state = "Attention" + elif ok: + state = "Configuré" + else: + state = "À configurer" + return {"label": label, "state": state, "ok": ok, "url": url, "detail": detail or ""} + + +@configuration_bp.route("/") +@login_required +def index(): + from flask import url_for + sites = Site.query.count() + buildings = Building.query.count() + zones = Zone.query.count() + rooms = Room.query.count() + profiles = RoomProfile.query.count() + unprofiled = Room.query.filter(Room.room_type_id.is_(None)).count() + equipment_without_lot = Equipment.query.filter(Equipment.is_deleted.is_(False), Equipment.lot_id.is_(None)).count() + technicians = User.query.filter_by(is_active=True).all() + scheduled_users = {row[0] for row in WorkSchedule.query.filter_by(is_active=True).with_entities(WorkSchedule.user_id).all()} + tasks_without_duration = LotTask.query.filter(LotTask.is_active.is_(True), (LotTask.duree_minutes.is_(None) | (LotTask.duree_minutes <= 0))).count() + contracts_without_due = Contract.query.filter(Contract.is_active.is_(True), Contract.end_date.is_(None)).count() if hasattr(Contract, "end_date") else 0 + room_schedules = RoomSchedule.query.count() + sections = [ + ("ESSENTIEL", [ + _status(User.query.filter_by(is_active=True).count() > 0, "Administrateur / utilisateurs", url_for("admin.users")), + _status(sites > 0, "Établissement / site", url_for("buildings.index"), f"{sites} site(s)"), + _status(buildings > 0, "Bâtiments", url_for("buildings.index"), f"{buildings} bâtiment(s)"), + _status(zones > 0, "Zones", url_for("zones.index"), f"{zones} zone(s)"), + _status(rooms > 0, "Locaux / emplacements", url_for("rooms.index"), f"{rooms} local(aux)"), + _status(len(scheduled_users) >= len(technicians) and bool(technicians), "Horaires techniciens", url_for("planning.work_schedules"), f"{len(technicians) - len(scheduled_users)} sans horaires" if technicians else "Aucun technicien actif", attention=bool(technicians) and len(scheduled_users) < len(technicians)), + ]), + ("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(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)"), + ]), + ("PATRIMOINE", [ + _status(unprofiled == 0, "Locaux avec profil structurel", url_for("rooms.index"), f"{unprofiled} sans type", attention=unprofiled > 0), + _status(True, "Logements de fonction", url_for("housing.index"), "Facultatif", optional=True), + ]), + ("ENTREPRISES", [_status(contracts_without_due == 0, "Contrats avec échéance", url_for("companies.index"), f"{contracts_without_due} sans échéance", attention=contracts_without_due > 0)]), + ("COMPTEURS", [_status(False, "Compteurs", url_for("meters.index"), "Non configuré", optional=True)]), + ("CARTOGRAPHIE TECHNIQUE", [_status(False, "Relations techniques", url_for("equipments.index"), "Facultatif", optional=True)]), + ("INTÉGRATIONS", [ + _status(False, "Pronote", url_for("pronote.index"), "Non configuré", optional=True), + _status(False, "ENT / Outlook / Yeastar / IA", url_for("admin.settings"), "Facultatif", optional=True), + ]), + ] + return render_template("configuration/index.html", sections=sections) diff --git a/app_new/configuration/templates/configuration/index.html b/app_new/configuration/templates/configuration/index.html new file mode 100644 index 0000000..2c83efd --- /dev/null +++ b/app_new/configuration/templates/configuration/index.html @@ -0,0 +1,5 @@ +{% extends 'base.html' %} +{% block title %}Centre de configuration{% endblock %} +{% block content %} +

Centre de configuration

Un état permanent de ce qui est prêt, incomplet ou facultatif.

Configuration progressive
{% for name, items in sections %}
{{ name }}
{% for item in items %}
{% if item.state == 'Configuré' %}✓{% elif item.state == 'Attention' %}⚠{% elif item.state == 'Facultatif' %}○{% else %}✕{% endif %} {{ item.label }}
{{ item.state }}{% if item.detail %} · {{ item.detail }}{% endif %}
Ouvrir
{% endfor %}
{% endfor %}
+{% endblock %} diff --git a/app_new/core/authorization.py b/app_new/core/authorization.py index d395df0..6f97196 100644 --- a/app_new/core/authorization.py +++ b/app_new/core/authorization.py @@ -99,7 +99,7 @@ ADMIN_BLUEPRINTS = { } PATRIMOINE_BLUEPRINTS = { "equipments", "equipments_meters", "equipments_documents", "equipments_restrictions", "housing", - "equipments_scheduled", "buildings", "zones", "rooms", "room_types", "wizard", "lots", + "equipments_scheduled", "buildings", "zones", "rooms", "room_types", "room_profiles", "configuration", "wizard", "lots", } PLANNING_BLUEPRINTS = {"planning", "scheduler", "interventions_planning"} STOCK_BLUEPRINTS = {"parts", "meters", "cleaning"} diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index f9fa507..77c939b 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -3,7 +3,7 @@ Core Models - GMAO Collège Import tous les modèles du core """ from .user import User, Staff -from .college import College, Site, Building, HousingUnit, HousingOccupancy, Room, RoomType, RoomSchedule, Zone +from .college import College, Site, Building, HousingUnit, HousingOccupancy, Room, RoomType, RoomProfile, RoomProfileItem, RoomSurface, RoomSchedule, Zone from .equipment import EquipmentCategory, Equipment, EquipmentDocument, EquipmentRoomHistory, EquipmentQuantityMovement, EquipmentLifecycleEvent from .maintenance import ( Intervention, StatusChange, InterventionComment, InterventionDocument, @@ -30,7 +30,7 @@ from .rbac import Role, Permission, UserRole, RolePermission, UserPermission __all__ = [ 'User', 'Staff', - 'College', 'Site', 'Building', 'HousingUnit', 'HousingOccupancy', 'Room', 'RoomType', 'RoomSchedule', 'Zone', + 'College', 'Site', 'Building', 'HousingUnit', 'HousingOccupancy', 'Room', 'RoomType', 'RoomProfile', 'RoomProfileItem', 'RoomSurface', 'RoomSchedule', 'Zone', 'EquipmentCategory', 'Equipment', 'EquipmentDocument', 'EquipmentRoomHistory', 'EquipmentQuantityMovement', 'EquipmentLifecycleEvent', 'Intervention', 'StatusChange', 'InterventionComment', 'InterventionDocument', 'Lot', 'LotTask', 'LotService', 'WorkRequest', 'WorkRequestQuote', 'WorkRequestEvent', diff --git a/app_new/core/models/college.py b/app_new/core/models/college.py index c9b3473..18b097c 100644 --- a/app_new/core/models/college.py +++ b/app_new/core/models/college.py @@ -151,6 +151,7 @@ class Room(db.Model): floor = db.Column(db.Integer, default=0) building_id = db.Column(db.Integer, db.ForeignKey("buildings.id"), nullable=False) room_type_id = db.Column(db.Integer, db.ForeignKey("room_types.id"), nullable=True) + room_profile_id = db.Column(db.Integer, db.ForeignKey("room_profiles.id"), nullable=True, index=True) housing_unit_id = db.Column(db.Integer, db.ForeignKey("housing_units.id"), nullable=True, index=True) # Libelle historique conserve pour les imports anterieurs a zone_id. legacy_zone_name = db.Column("zone", db.String(100), nullable=True) @@ -159,6 +160,7 @@ class Room(db.Model): building = db.relationship("Building", overlaps="rooms") zone = db.relationship("Zone", back_populates="rooms") room_type = db.relationship("RoomType", back_populates="rooms") + room_profile = db.relationship("RoomProfile", backref=db.backref("rooms", lazy="dynamic")) equipments = db.relationship("Equipment", back_populates="room") housing_unit = db.relationship("HousingUnit", back_populates="rooms") @@ -191,6 +193,68 @@ class RoomType(db.Model): return f"" +class RoomProfile(db.Model): + """Modèle de proposition pour préconfigurer un local. + + ``RoomType`` reste un classement structurel (notamment utilisé par les + emplois du temps). Un profil est une proposition éditable qui ne crée + aucun équipement tant que l'utilisateur n'a pas validé l'application. + """ + __tablename__ = "room_profiles" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + description = db.Column(db.Text, nullable=True) + room_type_id = db.Column(db.Integer, db.ForeignKey("room_types.id"), nullable=True, index=True) + is_active = db.Column(db.Boolean, nullable=False, default=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + room_type = db.relationship("RoomType", backref=db.backref("profiles", lazy="dynamic")) + items = db.relationship("RoomProfileItem", back_populates="profile", cascade="all, delete-orphan", + order_by="RoomProfileItem.sort_order, RoomProfileItem.id") + surfaces = db.relationship("RoomSurface", back_populates="profile", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + +class RoomProfileItem(db.Model): + """Une proposition d'équipement dans un profil de local.""" + __tablename__ = "room_profile_items" + + id = db.Column(db.Integer, primary_key=True) + profile_id = db.Column(db.Integer, db.ForeignKey("room_profiles.id", ondelete="CASCADE"), nullable=False, index=True) + label = db.Column(db.String(200), nullable=False) + category_id = db.Column(db.Integer, db.ForeignKey("equipment_categories.id"), nullable=True, index=True) + lot_id = db.Column(db.Integer, db.ForeignKey("lots.id"), nullable=True, index=True) + quantity = db.Column(db.Integer, nullable=False, default=1) + is_group = db.Column(db.Boolean, nullable=False, default=False) + enabled_by_default = db.Column(db.Boolean, nullable=False, default=True) + sort_order = db.Column(db.Integer, nullable=False, default=0) + + profile = db.relationship("RoomProfile", back_populates="items") + category = db.relationship("EquipmentCategory") + lot = db.relationship("Lot") + + +class RoomSurface(db.Model): + """Composition facultative d'une surface réelle ou proposée.""" + __tablename__ = "room_surfaces" + + id = db.Column(db.Integer, primary_key=True) + room_id = db.Column(db.Integer, db.ForeignKey("rooms.id", ondelete="CASCADE"), nullable=True, index=True) + profile_id = db.Column(db.Integer, db.ForeignKey("room_profiles.id", ondelete="CASCADE"), nullable=True, index=True) + surface_type = db.Column(db.String(30), nullable=False) # mur, sol, plafond + material = db.Column(db.String(80), nullable=False) + finish = db.Column(db.String(80), nullable=True) + label = db.Column(db.String(120), nullable=True) + sort_order = db.Column(db.Integer, nullable=False, default=0) + + room = db.relationship("Room", backref=db.backref("surfaces", cascade="all, delete-orphan")) + profile = db.relationship("RoomProfile", back_populates="surfaces") + + class RoomSchedule(db.Model): """Emploi du temps d'une salle.""" __tablename__ = "room_schedules" diff --git a/app_new/core/routes/setup_wizard.py b/app_new/core/routes/setup_wizard.py index c364d0a..eeeaa28 100644 --- a/app_new/core/routes/setup_wizard.py +++ b/app_new/core/routes/setup_wizard.py @@ -149,6 +149,7 @@ class SetupProgress(db.Model): 'zone_id': item.zone_id, 'floor': item.floor or 0, 'room_type_id': item.room_type_id, + 'room_profile_id': item.room_profile_id, } for item in Room.query.order_by(Room.building_id, Room.name, Room.id).all() ] diff --git a/app_new/equipments/rooms.py b/app_new/equipments/rooms.py index df8eebe..acbaec7 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, RoomSchedule +from app_new.core.models.college import Room, Zone, Building, RoomType, RoomProfile, RoomSchedule from app_new.core.models.equipment import Equipment rooms_bp = Blueprint('rooms', __name__, template_folder='templates') @@ -22,6 +22,7 @@ def _room_form_context(room=None): 'buildings': Building.query.order_by(Building.name).all(), 'zones': Zone.query.join(Building).order_by(Building.name, Zone.name).all(), 'room_types': RoomType.query.order_by(RoomType.name).all(), + 'room_profiles': RoomProfile.query.order_by(RoomProfile.name).all(), } @@ -126,6 +127,7 @@ def create(): zone_id=zone_id, building_id=building.id, 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) ) db.session.add(room) @@ -161,6 +163,7 @@ def bulk_create(): flash('Choisissez un bâtiment valide et indiquez au moins un local.', 'danger') return render_template('equipments/rooms_bulk.html', **context), 400 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 = [] existing = {room.name for room in Room.query.filter_by(building_id=building.id).all()} for name in names: @@ -169,6 +172,7 @@ def bulk_create(): created.append(Room(name=name, code=name, building_id=building.id, zone_id=zone.id if zone else None, room_type_id=room_type_id, + room_profile_id=room_profile_id, floor=request.form.get('floor', 0, type=int))) existing.add(name) db.session.add_all(created) @@ -201,6 +205,7 @@ def edit(id): room.zone_id = zone_id room.building_id = building.id room.room_type_id = request.form.get('room_type_id', type=int) + room.room_profile_id = request.form.get('room_profile_id', type=int) or None room.floor = request.form.get('floor', 0, type=int) try: db.session.commit() diff --git a/app_new/equipments/templates/equipments/rooms_bulk.html b/app_new/equipments/templates/equipments/rooms_bulk.html index a2de07b..4b6715c 100644 --- a/app_new/equipments/templates/equipments/rooms_bulk.html +++ b/app_new/equipments/templates/equipments/rooms_bulk.html @@ -1,5 +1,3 @@ {% extends "base.html" %} {% block title %}Créer plusieurs locaux{% endblock %} -{% block content %}

Créer plusieurs locaux

Un nom par ligne. Le profil de local reste modifiable ensuite ; aucun équipement n'est imposé.

{% endblock %} +{% block content %}

Créer plusieurs locaux

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

{% endblock %} diff --git a/app_new/room_profiles/__init__.py b/app_new/room_profiles/__init__.py new file mode 100644 index 0000000..544e3cf --- /dev/null +++ b/app_new/room_profiles/__init__.py @@ -0,0 +1 @@ +"""Profils de locaux : propositions validables d'équipements et d'ouvrages.""" diff --git a/app_new/room_profiles/routes.py b/app_new/room_profiles/routes.py new file mode 100644 index 0000000..829f1c8 --- /dev/null +++ b/app_new/room_profiles/routes.py @@ -0,0 +1,155 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash +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.maintenance import Lot +from app_new.core.services.equipment_creation import create_equipment + +room_profiles_bp = Blueprint("room_profiles", __name__, template_folder="templates") + + +def _context(profile=None): + return { + "profile": profile, + "room_types": RoomType.query.order_by(RoomType.name).all(), + "categories": EquipmentCategory.query.order_by(EquipmentCategory.name).all(), + "lots": Lot.query.order_by(Lot.name).all(), + } + + +@room_profiles_bp.route("/") +@login_required +def index(): + return render_template("room_profiles/index.html", profiles=RoomProfile.query.order_by(RoomProfile.name).all()) + + +@room_profiles_bp.route("/new", methods=["GET", "POST"]) +@login_required +def new(): + if request.method == "POST": + name = (request.form.get("name") or "").strip() + if not name: + flash("Le nom du profil est obligatoire.", "danger") + return render_template("room_profiles/form.html", title="Nouveau profil", **_context()), 400 + if RoomProfile.query.filter(db.func.lower(RoomProfile.name) == name.lower()).first(): + flash("Un profil porte déjà ce nom.", "danger") + return render_template("room_profiles/form.html", title="Nouveau profil", **_context()), 400 + profile = RoomProfile( + name=name, + description=(request.form.get("description") or "").strip() or None, + room_type_id=request.form.get("room_type_id", type=int) or None, + ) + db.session.add(profile) + db.session.commit() + flash("Profil créé. Ajoutez ses propositions puis prévisualisez avant toute création.", "success") + return redirect(url_for("room_profiles.edit", id=profile.id)) + return render_template("room_profiles/form.html", title="Nouveau profil", **_context()) + + +@room_profiles_bp.route("//edit", methods=["GET", "POST"]) +@login_required +def edit(id): + profile = RoomProfile.query.get_or_404(id) + if request.method == "POST": + profile.name = (request.form.get("name") or "").strip() + profile.description = (request.form.get("description") or "").strip() or None + profile.room_type_id = request.form.get("room_type_id", type=int) or None + if not profile.name: + flash("Le nom du profil est obligatoire.", "danger") + return render_template("room_profiles/form.html", title="Modifier le profil", **_context(profile)), 400 + db.session.commit() + flash("Profil mis à jour.", "success") + return redirect(url_for("room_profiles.edit", id=profile.id)) + return render_template("room_profiles/form.html", title="Modifier le profil", **_context(profile)) + + +@room_profiles_bp.route("//items", methods=["POST"]) +@login_required +def add_item(id): + profile = RoomProfile.query.get_or_404(id) + label = (request.form.get("label") or "").strip() + quantity = request.form.get("quantity", 1, type=int) + if not label or not quantity or quantity < 1: + flash("Libellé et quantité positive sont obligatoires.", "danger") + return redirect(url_for("room_profiles.edit", id=id)) + item = RoomProfileItem( + profile_id=profile.id, + label=label, + category_id=request.form.get("category_id", type=int) or None, + lot_id=request.form.get("lot_id", type=int) or None, + quantity=quantity, + is_group=request.form.get("is_group") == "1", + enabled_by_default=request.form.get("enabled_by_default") == "1", + sort_order=request.form.get("sort_order", 0, type=int), + ) + db.session.add(item) + db.session.commit() + flash("Proposition ajoutée au profil.", "success") + return redirect(url_for("room_profiles.edit", id=id)) + + +@room_profiles_bp.route("//items//delete", methods=["POST"]) +@login_required +def delete_item(profile_id, item_id): + item = RoomProfileItem.query.filter_by(id=item_id, profile_id=profile_id).first_or_404() + db.session.delete(item) + db.session.commit() + flash("Proposition retirée du profil.", "success") + return redirect(url_for("room_profiles.edit", id=profile_id)) + + +@room_profiles_bp.route("//preview", methods=["GET", "POST"]) +@login_required +def preview(id): + profile = RoomProfile.query.get(id) + if profile is None: + from flask import abort + abort(404) + 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] + 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) + created = 0 + for room in rooms: + for item in items: + if item.id not in selected: + continue + quantity = request.form.get(f"quantity_{item.id}", item.quantity, type=int) + if quantity < 1: + continue + create_equipment( + name=item.label, + category_id=item.category_id, + lot_id=item.lot_id, + room_id=room.id, + quantity=quantity, + is_group=item.is_group, + ) + 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) + + +@room_profiles_bp.route("//surfaces", methods=["POST"]) +@login_required +def add_surface(id): + profile = RoomProfile.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"} or not material: + flash("Type et matériau de surface obligatoires.", "danger") + return redirect(url_for("room_profiles.edit", id=id)) + db.session.add(RoomSurface(profile_id=profile.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 proposée ajoutée.", "success") + return redirect(url_for("room_profiles.edit", id=id)) diff --git a/app_new/room_profiles/templates/room_profiles/form.html b/app_new/room_profiles/templates/room_profiles/form.html new file mode 100644 index 0000000..489c83a --- /dev/null +++ b/app_new/room_profiles/templates/room_profiles/form.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% block title %}{{ title }}{% endblock %} +{% block content %} +

{{ title }}

Un profil propose des équipements et des ouvrages. Rien n'est créé avant validation dans la prévisualisation.

+
+{% if profile %}
Équipements proposés
{% for item in profile.items %}{% else %}{% endfor %}
PropositionQtéMode
{{ item.label }}{% if item.category %} · {{ item.category.name }}{% endif %}{{ item.quantity }}{{ 'Groupe' if item.is_group else 'Individuel' }}
Aucune proposition.
Composition facultative

Application

Sélectionnez des locaux puis validez chaque proposition dans l'écran de prévisualisation.

Prévisualiser l'application
{% endif %}
+{% endblock %} diff --git a/app_new/room_profiles/templates/room_profiles/index.html b/app_new/room_profiles/templates/room_profiles/index.html new file mode 100644 index 0000000..9ac591f --- /dev/null +++ b/app_new/room_profiles/templates/room_profiles/index.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %} +{% block title %}Profils de locaux{% endblock %} +{% block content %} +
+

Profils de locaux

Des propositions réutilisables, jamais des créations automatiques.

Nouveau profil
+
{% for profile in profiles %}

{{ profile.name }}

{{ profile.description or 'Aucune description.' }}

{{ profile.items|length }} proposition(s){% if profile.room_type %} · {{ profile.room_type.name }}{% endif %}
{% else %}
Aucun profil. Vous pouvez créer des locaux sans profil et compléter plus tard.
{% endfor %}
+
+{% endblock %} diff --git a/app_new/room_profiles/templates/room_profiles/preview.html b/app_new/room_profiles/templates/room_profiles/preview.html new file mode 100644 index 0000000..09cdd6f --- /dev/null +++ b/app_new/room_profiles/templates/room_profiles/preview.html @@ -0,0 +1,5 @@ +{% extends 'base.html' %} +{% block title %}Prévisualiser un profil{% endblock %} +{% 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
+{% endblock %} diff --git a/app_new/templates/equipments/room_form.html b/app_new/templates/equipments/room_form.html index 120346a..5902e5a 100644 --- a/app_new/templates/equipments/room_form.html +++ b/app_new/templates/equipments/room_form.html @@ -54,6 +54,16 @@ {% endfor %} +
+ + + Le profil ne crée aucun équipement automatiquement. +
diff --git a/migrations/versions/k6f7a8b9c0d1_room_profiles.py b/migrations/versions/k6f7a8b9c0d1_room_profiles.py new file mode 100644 index 0000000..9663cfc --- /dev/null +++ b/migrations/versions/k6f7a8b9c0d1_room_profiles.py @@ -0,0 +1,69 @@ +"""Checkpoint B: profils de locaux et compositions facultatives.""" +from alembic import op +import sqlalchemy as sa + +revision = "k6f7a8b9c0d1" +down_revision = "j5e6f7a8b9c0" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "room_profiles", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(120), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("room_type_id", sa.Integer(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["room_type_id"], ["room_types.id"]), + sa.UniqueConstraint("name", name="uq_room_profiles_name"), + ) + op.create_index("ix_room_profiles_room_type_id", "room_profiles", ["room_type_id"]) + op.create_table( + "room_profile_items", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("profile_id", sa.Integer(), nullable=False), + sa.Column("label", sa.String(200), nullable=False), + sa.Column("category_id", sa.Integer(), nullable=True), + sa.Column("lot_id", sa.Integer(), nullable=True), + sa.Column("quantity", sa.Integer(), nullable=False, server_default="1"), + sa.Column("is_group", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("enabled_by_default", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.ForeignKeyConstraint(["profile_id"], ["room_profiles.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["category_id"], ["equipment_categories.id"]), + sa.ForeignKeyConstraint(["lot_id"], ["lots.id"]), + ) + op.create_index("ix_room_profile_items_profile_id", "room_profile_items", ["profile_id"]) + op.create_index("ix_room_profile_items_category_id", "room_profile_items", ["category_id"]) + op.create_index("ix_room_profile_items_lot_id", "room_profile_items", ["lot_id"]) + op.create_table( + "room_surfaces", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("room_id", sa.Integer(), nullable=True), + sa.Column("profile_id", sa.Integer(), nullable=True), + sa.Column("surface_type", sa.String(30), nullable=False), + sa.Column("material", sa.String(80), nullable=False), + sa.Column("finish", sa.String(80), nullable=True), + sa.Column("label", sa.String(120), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.ForeignKeyConstraint(["room_id"], ["rooms.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["profile_id"], ["room_profiles.id"], ondelete="CASCADE"), + ) + op.create_index("ix_room_surfaces_room_id", "room_surfaces", ["room_id"]) + op.create_index("ix_room_surfaces_profile_id", "room_surfaces", ["profile_id"]) + + +def downgrade(): + op.drop_index("ix_room_surfaces_profile_id", table_name="room_surfaces") + op.drop_index("ix_room_surfaces_room_id", table_name="room_surfaces") + op.drop_table("room_surfaces") + op.drop_index("ix_room_profile_items_lot_id", table_name="room_profile_items") + op.drop_index("ix_room_profile_items_category_id", table_name="room_profile_items") + op.drop_index("ix_room_profile_items_profile_id", table_name="room_profile_items") + op.drop_table("room_profile_items") + op.drop_index("ix_room_profiles_room_type_id", table_name="room_profiles") + op.drop_table("room_profiles") diff --git a/migrations/versions/l7a8b9c0d1e2_assign_room_profiles.py b/migrations/versions/l7a8b9c0d1e2_assign_room_profiles.py new file mode 100644 index 0000000..8f1f148 --- /dev/null +++ b/migrations/versions/l7a8b9c0d1e2_assign_room_profiles.py @@ -0,0 +1,20 @@ +"""Checkpoint B: associer un profil validé à un local.""" +from alembic import op +import sqlalchemy as sa + +revision = "l7a8b9c0d1e2" +down_revision = "k6f7a8b9c0d1" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("rooms", sa.Column("room_profile_id", sa.Integer(), nullable=True)) + op.create_foreign_key("fk_rooms_room_profile_id", "rooms", "room_profiles", ["room_profile_id"], ["id"]) + op.create_index("ix_rooms_room_profile_id", "rooms", ["room_profile_id"]) + + +def downgrade(): + op.drop_index("ix_rooms_room_profile_id", table_name="rooms") + op.drop_constraint("fk_rooms_room_profile_id", "rooms", type_="foreignkey") + op.drop_column("rooms", "room_profile_id") diff --git a/tests/integration/test_room_profiles_checkpoint_b.py b/tests/integration/test_room_profiles_checkpoint_b.py new file mode 100644 index 0000000..26263e2 --- /dev/null +++ b/tests/integration/test_room_profiles_checkpoint_b.py @@ -0,0 +1,58 @@ +"""Couverture métier du checkpoint B : profils sans création implicite.""" +from app_new import db +from app_new.core.models.college import Building, Zone, Room, RoomProfile, RoomProfileItem +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 + + +def test_room_profile_is_editable_and_application_uses_shared_service(authenticated_client, app): + with app.app_context(): + building = Building(name="TEST_UI_020_B_BUILDING") + db.session.add(building) + db.session.flush() + zone = Zone(name="TEST_UI_020_B_ZONE", building_id=building.id) + category = EquipmentCategory(name="TEST_UI_020_B_LUMINAIRE") + db.session.add_all([zone, category]) + db.session.flush() + room = Room(name="TEST_UI_020_B101", code="TEST_UI_020_B101", building_id=building.id, zone_id=zone.id) + db.session.add(room) + db.session.flush() + profile = RoomProfile(name="TEST_UI_020_B_CLASSE", description="Profil de validation") + db.session.add(profile) + db.session.flush() + item = RoomProfileItem(profile_id=profile.id, label="Luminaire", category_id=category.id, quantity=10, is_group=True) + db.session.add(item) + db.session.commit() + profile_id, room_id, item_id = profile.id, room.id, item.id + assert RoomProfile.query.get(profile_id) is not None + + assert Equipment.query.filter_by(room_id=room_id).count() == 0 + with app.app_context(): + item = RoomProfileItem.query.get(item_id) + created = create_equipment(name=item.label, category_id=item.category_id, + room_id=room_id, quantity=4, is_group=item.is_group, + create_schedule=False) + db.session.commit() + assert created.id is not None + assert Equipment.query.filter_by(room_id=room_id).count() == 1 + assert Equipment.query.filter_by(room_id=room_id).one().quantity == 4 + + +def test_bulk_room_can_reference_profile_without_creating_equipment(authenticated_client, app): + with app.app_context(): + building = Building(name="TEST_UI_020_B_BULK_BUILDING") + zone = Zone(name="TEST_UI_020_B_BULK_ZONE", building=building) + profile = RoomProfile(name="TEST_UI_020_B_BULK_PROFILE") + db.session.add_all([building, zone, profile]) + db.session.commit() + building_id, zone_id, profile_id = building.id, zone.id, profile.id + with app.app_context(): + rooms = [Room(name=name, code=name, building_id=building_id, zone_id=zone_id, + room_profile_id=profile_id, floor=1) + for name in ("TEST_UI_020_B101", "TEST_UI_020_B102")] + db.session.add_all(rooms) + db.session.commit() + 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