feat(patrimoine): ajouter profils de locaux progressifs

This commit is contained in:
root 2026-08-23 16:45:41 +00:00
parent 330c4ba929
commit b202cca760
19 changed files with 489 additions and 7 deletions

View file

@ -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)

View file

@ -0,0 +1 @@
"""Centre de configuration permanent."""

View file

@ -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)

View file

@ -0,0 +1,5 @@
{% extends 'base.html' %}
{% block title %}Centre de configuration{% endblock %}
{% block content %}
<div class="container-fluid py-3"><div class="d-flex justify-content-between align-items-center mb-3"><div><h1 class="h3 mb-1">Centre de configuration</h1><p class="text-muted mb-0">Un état permanent de ce qui est prêt, incomplet ou facultatif.</p></div><span class="badge text-bg-light">Configuration progressive</span></div><div class="row g-4">{% for name, items in sections %}<section class="col-md-6 col-xl-4"><div class="card h-100"><div class="card-header"><strong>{{ name }}</strong></div><div class="list-group list-group-flush">{% for item in items %}<div class="list-group-item d-flex justify-content-between gap-2 align-items-start"><div><div>{% if item.state == 'Configuré' %}✓{% elif item.state == 'Attention' %}⚠{% elif item.state == 'Facultatif' %}○{% else %}✕{% endif %} <strong>{{ item.label }}</strong></div><small class="text-muted">{{ item.state }}{% if item.detail %} · {{ item.detail }}{% endif %}</small></div><a class="btn btn-sm btn-outline-secondary" href="{{ item.url }}">Ouvrir</a></div>{% endfor %}</div></div></section>{% endfor %}</div></div>
{% endblock %}

View file

@ -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"}

View file

@ -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',

View file

@ -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"<RoomType {self.name}>"
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"<RoomProfile {self.name}>"
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"

View file

@ -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()
]

View file

@ -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()

View file

@ -1,5 +1,3 @@
{% extends "base.html" %}
{% block title %}Créer plusieurs locaux{% endblock %}
{% block content %}<div class="container-fluid" style="max-width:900px"><h1 class="h3">Créer plusieurs locaux</h1><p class="text-muted">Un nom par ligne. Le profil de local reste modifiable ensuite ; aucun équipement n'est imposé.</p><form method="post" class="card"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card-body row g-3"><div class="col-md-6"><label class="form-label">Bâtiment</label><select class="form-select" name="building_id" required><option value="">Choisir</option>{% for building in buildings %}<option value="{{ building.id }}">{{ building.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Zone</label><select class="form-select" name="zone_id"><option value="">Aucune zone</option>{% for zone in zones %}<option value="{{ zone.id }}">{{ zone.building.name }} — {{ zone.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Profil / type de local</label><select class="form-select" name="room_type_id"><option value="">À préciser plus tard</option>{% for room_type in room_types %}<option value="{{ room_type.id }}">{{ room_type.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Étage</label><input class="form-control" type="number" name="floor" value="0"></div><div class="col-12"><label class="form-label">Noms des locaux</label><textarea class="form-control" name="names" rows="8" required placeholder="B101
B102
B103"></textarea></div></div><div class="card-footer"><a class="btn btn-outline-secondary" href="{{ url_for('rooms.index') }}">Annuler</a> <button class="btn btn-primary">Créer les locaux</button></div></form></div>{% endblock %}
{% block content %}<div class="container-fluid" style="max-width:900px"><h1 class="h3">Créer plusieurs locaux</h1><p class="text-muted">Un nom par ligne. Le profil est associé sans créer automatiquement d'équipement.</p><form method="post" class="card"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card-body row g-3"><div class="col-md-6"><label class="form-label">Bâtiment</label><select class="form-select" name="building_id" required><option value="">Choisir</option>{% for building in buildings %}<option value="{{ building.id }}">{{ building.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Zone</label><select class="form-select" name="zone_id"><option value="">Aucune zone</option>{% for zone in zones %}<option value="{{ zone.id }}">{{ zone.building.name }} — {{ zone.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Type structurel de local</label><select class="form-select" name="room_type_id"><option value="">À préciser plus tard</option>{% for room_type in room_types %}<option value="{{ room_type.id }}">{{ room_type.name }}</option>{% endfor %}</select></div><div class="col-md-6"><label class="form-label">Profil de propositions</label><select class="form-select" name="room_profile_id"><option value="">Aucun</option>{% for profile in room_profiles %}<option value="{{ profile.id }}">{{ profile.name }}</option>{% endfor %}</select></div><div class="col-md-4"><label class="form-label">Étage</label><input class="form-control" type="number" name="floor" value="0"></div><div class="col-12"><label class="form-label">Noms des locaux</label><textarea class="form-control" name="names" rows="8" required placeholder="B101, B102, B103"></textarea></div></div><div class="card-footer"><a class="btn btn-outline-secondary" href="{{ url_for('rooms.index') }}">Annuler</a> <button class="btn btn-primary">Créer les locaux</button></div></form></div>{% endblock %}

View file

@ -0,0 +1 @@
"""Profils de locaux : propositions validables d'équipements et d'ouvrages."""

View file

@ -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("/<int:id>/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("/<int:id>/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("/<int:profile_id>/items/<int:item_id>/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("/<int:id>/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("/<int:id>/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))

View file

@ -0,0 +1,7 @@
{% extends 'base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container-fluid py-3" style="max-width:1100px"><h1 class="h3">{{ title }}</h1><p class="text-muted">Un profil propose des équipements et des ouvrages. Rien n'est créé avant validation dans la prévisualisation.</p>
<form method="post" class="card mb-4"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card-body row g-3"><div class="col-md-6"><label class="form-label">Nom</label><input class="form-control" name="name" value="{{ profile.name if profile else '' }}" required></div><div class="col-md-6"><label class="form-label">Type structurel de local (facultatif)</label><select class="form-select" name="room_type_id"><option value="">Aucun</option>{% for rt in room_types %}<option value="{{ rt.id }}" {% if profile and profile.room_type_id == rt.id %}selected{% endif %}>{{ rt.name }}</option>{% endfor %}</select></div><div class="col-12"><label class="form-label">Description</label><textarea class="form-control" name="description" rows="2">{{ profile.description if profile else '' }}</textarea></div></div><div class="card-footer"><button class="btn btn-primary">Enregistrer</button><a class="btn btn-link" href="{{ url_for('room_profiles.index') }}">Annuler</a></div></form>
{% if profile %}<div class="row g-4"><div class="col-lg-7"><div class="card"><div class="card-header"><strong>Équipements proposés</strong></div><div class="card-body"><form method="post" action="{{ url_for('room_profiles.add_item', id=profile.id) }}" class="row g-2 align-items-end mb-3"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col-md-4"><label class="form-label">Équipement</label><input class="form-control" name="label" placeholder="Luminaire" required></div><div class="col-md-2"><label class="form-label">Qté</label><input class="form-control" type="number" min="1" name="quantity" value="1" required></div><div class="col-md-3"><label class="form-label">Catégorie</label><select class="form-select" name="category_id"><option value="">À choisir</option>{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}</select></div><div class="col-md-3"><label class="form-label">Lot suggéré</label><select class="form-select" name="lot_id"><option value="">Aucun</option>{% for lot in lots %}<option value="{{ lot.id }}">{{ lot.name }}</option>{% endfor %}</select></div><div class="col-md-3"><label class="form-label">Mode</label><select class="form-select" name="is_group"><option value="0">Individuel</option><option value="1">Groupe</option></select></div><div class="col-md-3"><label class="form-label">Par défaut</label><select class="form-select" name="enabled_by_default"><option value="1">Oui</option><option value="0">Non</option></select></div><div class="col-md-3"><button class="btn btn-outline-primary w-100">Ajouter</button></div></form><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Proposition</th><th>Qté</th><th>Mode</th><th></th></tr></thead><tbody>{% for item in profile.items %}<tr><td>{{ item.label }}{% if item.category %}<small class="text-muted"> · {{ item.category.name }}</small>{% endif %}</td><td>{{ item.quantity }}</td><td>{{ 'Groupe' if item.is_group else 'Individuel' }}</td><td><form method="post" action="{{ url_for('room_profiles.delete_item', profile_id=profile.id, item_id=item.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-danger">Retirer</button></form></td></tr>{% else %}<tr><td colspan="4" class="text-muted">Aucune proposition.</td></tr>{% endfor %}</tbody></table></div></div></div></div><div class="col-lg-5"><div class="card"><div class="card-header"><strong>Composition facultative</strong></div><div class="card-body"><form method="post" action="{{ url_for('room_profiles.add_surface', id=profile.id) }}" class="row g-2"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col-6"><label class="form-label">Surface</label><select class="form-select" name="surface_type"><option value="mur">Mur</option><option value="sol">Sol</option><option value="plafond">Plafond</option></select></div><div class="col-6"><label class="form-label">Matériau</label><input class="form-control" name="material" placeholder="Béton" required></div><div class="col-6"><label class="form-label">Finition</label><input class="form-control" name="finish" placeholder="Peinture"></div><div class="col-6"><label class="form-label">Repère</label><input class="form-control" name="label" placeholder="Mur 1"></div><div class="col-12"><button class="btn btn-outline-secondary">Ajouter une composition</button></div></form></div></div><div class="card mt-3"><div class="card-body"><h2 class="h6">Application</h2><p class="small text-muted">Sélectionnez des locaux puis validez chaque proposition dans l'écran de prévisualisation.</p><a class="btn btn-primary" href="{{ url_for('room_profiles.preview', id=profile.id) }}">Prévisualiser l'application</a></div></div></div></div>{% endif %}</div>
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends 'base.html' %}
{% block title %}Profils de locaux{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h1 class="h3 mb-1">Profils de locaux</h1><p class="text-muted mb-0">Des propositions réutilisables, jamais des créations automatiques.</p></div><a class="btn btn-primary" href="{{ url_for('room_profiles.new') }}">Nouveau profil</a></div>
<div class="row g-3">{% for profile in profiles %}<div class="col-md-6 col-xl-4"><div class="card h-100"><div class="card-body"><h2 class="h5">{{ profile.name }}</h2><p class="text-muted">{{ profile.description or 'Aucune description.' }}</p><div class="small">{{ profile.items|length }} proposition(s){% if profile.room_type %} · {{ profile.room_type.name }}{% endif %}</div></div><div class="card-footer bg-transparent"><a class="btn btn-sm btn-outline-primary" href="{{ url_for('room_profiles.edit', id=profile.id) }}">Modifier / détailler</a></div></div></div>{% else %}<div class="col-12"><div class="alert alert-info">Aucun profil. Vous pouvez créer des locaux sans profil et compléter plus tard.</div></div>{% endfor %}</div>
</div>
{% endblock %}

View file

@ -0,0 +1,5 @@
{% extends 'base.html' %}
{% block title %}Prévisualiser un profil{% endblock %}
{% block content %}
<div class="container-fluid py-3" style="max-width:1100px"><h1 class="h3">Prévisualiser « {{ profile.name }} »</h1><p class="text-muted">Aucun équipement ne sera créé avant votre validation explicite.</p><form method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card mb-3"><div class="card-header">1. Locaux concernés</div><div class="card-body"><div class="row g-2">{% for room in rooms %}<div class="col-md-4"><label class="form-check"><input class="form-check-input" type="checkbox" name="room_ids" value="{{ room.id }}" checked><span class="form-check-label">{{ room.full_name }}</span></label></div>{% else %}<div class="col-12"><div class="alert alert-warning">Aucun local sélectionné. Revenez depuis un parcours de sélection de locaux.</div></div>{% endfor %}</div></div></div><div class="card mb-3"><div class="card-header">2. Propositions à accepter ou modifier</div><div class="card-body"><div class="table-responsive"><table class="table"><thead><tr><th>Accepter</th><th>Équipement</th><th>Quantité</th><th>Catégorie / lot</th></tr></thead><tbody>{% for item in items %}<tr><td><input class="form-check-input" type="checkbox" name="item_ids" value="{{ item.id }}" checked></td><td>{{ item.label }}</td><td><input class="form-control" type="number" min="1" name="quantity_{{ item.id }}" value="{{ item.quantity }}"></td><td>{{ item.category.name if item.category else 'Catégorie à préciser' }}{% if item.lot %} / {{ item.lot.name }}{% endif %}</td></tr>{% else %}<tr><td colspan="4">Ce profil ne contient aucune proposition.</td></tr>{% endfor %}</tbody></table></div></div></div><button class="btn btn-primary" name="validate" value="1">Valider et créer les équipements</button><a class="btn btn-link" href="{{ url_for('room_profiles.edit', id=profile.id) }}">Annuler</a></form></div>
{% endblock %}

View file

@ -54,6 +54,16 @@
{% endfor %}
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Profil de propositions</label>
<select name="room_profile_id" class="form-select">
<option value="">— Aucun —</option>
{% for profile in room_profiles %}
<option value="{{ profile.id }}" {% if form_data.get('room_profile_id', room.room_profile_id if room else '')|string == profile.id|string %}selected{% endif %}>{{ profile.name }}</option>
{% endfor %}
</select>
<small class="text-muted">Le profil ne crée aucun équipement automatiquement.</small>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Enregistrer</button>

View file

@ -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")

View file

@ -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")

View file

@ -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