Complete checkpoint B profile workflows
This commit is contained in:
parent
f70ab04326
commit
c7c5cf2f71
16 changed files with 186 additions and 17 deletions
|
|
@ -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)"),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('/<int:id>/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('/<int:id>/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 = []
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
{% 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 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 %}
|
||||
{% extends "base.html" %}{% block content %}<div class="container-fluid" style="max-width:900px"><h1 class="h3">Créer plusieurs locaux</h1><p class="text-muted">Prévisualisez avant validation. Associer un profil ne crée aucun équipement automatiquement.</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</label><select class="form-select" name="room_type_id"><option value="">À préciser plus tard</option>{% for rt in room_types %}<option value="{{ rt.id }}">{{ rt.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 (un par ligne)</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" name="preview" value="1">Prévisualiser</button></div></form></div>{% endblock %}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
{% extends "base.html" %}{% block content %}<div class="container py-3" style="max-width:850px"><h1 class="h3">Prévisualisation — locaux à créer</h1><p>{{ building.name }}{% if zone %} · {{ zone.name }}{% endif %} · étage {{ floor }}</p><div class="alert alert-info">{{ names|length }} ligne(s) analysée(s). Les noms déjà présents seront ignorés sans doublon.</div><ul class="list-group mb-3">{% for name in names %}<li class="list-group-item d-flex justify-content-between"><span>{{ name }}</span>{% if name in existing %}<span class="badge text-bg-secondary">déjà présent</span>{% else %}<span class="badge text-bg-success">à créer</span>{% endif %}</li>{% endfor %}</ul><form method="post" action="{{ url_for('rooms.bulk_create') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="building_id" value="{{ building.id }}"><input type="hidden" name="zone_id" value="{{ zone.id if zone else '' }}"><input type="hidden" name="room_type_id" value="{{ room_type_id or '' }}"><input type="hidden" name="room_profile_id" value="{{ room_profile_id or '' }}"><input type="hidden" name="floor" value="{{ floor }}"><textarea name="names" class="d-none">{{ names|join('\n') }}</textarea><button class="btn btn-primary">Valider la création</button><a class="btn btn-link" href="{{ url_for('rooms.bulk_create') }}">Modifier</a></form></div>{% endblock %}
|
||||
1
app_new/equipments/templates/room_duplicate.html
Normal file
1
app_new/equipments/templates/room_duplicate.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
{% extends "base.html" %}{% block content %}<div class="container py-3" style="max-width:700px"><h1 class="h3">Créer un local à partir de {{ room.name }}</h1><p class="text-muted">Les historiques, documents, numéros de série, compteurs et identifiants ne sont jamais copiés.</p><form method="post" class="card"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="card-body"><label class="form-label" for="name">Nouveau nom</label><input id="name" name="name" class="form-control mb-3" required><label class="form-check"><input class="form-check-input" type="checkbox" name="copy_profile" checked> Copier le profil</label><br><label class="form-check"><input class="form-check-input" type="checkbox" name="copy_surfaces" checked> Copier les ouvrages</label><br><label class="form-check"><input class="form-check-input" type="checkbox" name="copy_equipment"> Proposer les mêmes équipements/types/quantités</label></div><div class="card-footer"><button class="btn btn-primary">Créer le nouveau local</button></div></form></div>{% endblock %}
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
5
app_new/planning/templates/planning/work_schedules.html
Normal file
5
app_new/planning/templates/planning/work_schedules.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}<div class="container-fluid py-3" style="max-width:1100px">
|
||||
<h1 class="h3">Horaires des techniciens</h1><p class="text-muted">Les horaires sont propres à chaque technicien. Sans ligne configurée, Ma journée affiche clairement « horaires non configurés ».</p>
|
||||
<form method="get" class="row g-2 mb-3"><div class="col-md-5"><label class="form-label" for="user_id">Technicien</label><select id="user_id" name="user_id" class="form-select">{% for user in users %}<option value="{{ user.id }}" {% if user.id == target.id %}selected{% endif %}>{{ user.full_name or user.username }}</option>{% endfor %}</select></div><div class="col-md-2"><label class="form-label" for="academic_year">Année</label><input id="academic_year" class="form-control" name="academic_year" value="{{ year }}"></div><div class="col-md-2 align-self-end"><button class="btn btn-outline-primary">Afficher</button></div></form>
|
||||
<form method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="user_id" value="{{ target.id }}"><input type="hidden" name="academic_year" value="{{ year }}"><div class="table-responsive"><table class="table align-middle"><thead><tr><th>Jour</th><th>Début</th><th>Fin</th><th>Pause début</th><th>Pause fin</th></tr></thead><tbody>{% for day in days %}{% set row = rows.get(loop.index0) %}<tr><th>{{ day }}</th><td><input class="form-control" type="time" name="start_{{ loop.index0 }}" value="{{ row.start_time.strftime('%H:%M') if row and row.start_time else '' }}"></td><td><input class="form-control" type="time" name="end_{{ loop.index0 }}" value="{{ row.end_time.strftime('%H:%M') if row and row.end_time else '' }}"></td><td><input class="form-control" type="time" name="lunch_start_{{ loop.index0 }}" value="{{ row.lunch_start.strftime('%H:%M') if row and row.lunch_start else '' }}"></td><td><input class="form-control" type="time" name="lunch_end_{{ loop.index0 }}" value="{{ row.lunch_end.strftime('%H:%M') if row and row.lunch_end else '' }}"></td></tr>{% endfor %}</tbody></table></div><button class="btn btn-primary">Enregistrer les horaires</button></form></div>{% endblock %}
|
||||
|
|
@ -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("/<int:id>/surfaces", methods=["POST"])
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
{% extends 'base.html' %}
|
||||
{% block title %}Prévisualiser un profil{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% 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>
|
||||
<div class="container-fluid py-3" style="max-width:1100px">
|
||||
<h1 class="h3">Prévisualiser « {{ profile.name }} »</h1>
|
||||
<p class="text-muted">Aucun équipement n’est créé avant validation. Une réapplication ajoute uniquement les quantités manquantes.</p>
|
||||
<form method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="card mb-3"><div class="card-header">Locaux concernés</div><div class="card-body 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é. Ouvrez cette page depuis la fiche d’un local.</div></div>{% endfor %}
|
||||
</div></div>
|
||||
<div class="card mb-3"><div class="card-header">Propositions à accepter ou modifier</div><div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>Accepter</th><th>Équipement</th><th>Cible</th>{% for room in rooms %}<th>{{ room.name }}<br><small>existant → cible</small></th>{% endfor %}</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>{% for room in rooms %}{% set have = existing.get((room.id, item.id), 0) %}<td>{{ have }} → {{ item.quantity }}{% if have >= item.quantity %}<span class="badge text-bg-success ms-1">déjà présent</span>{% else %}<span class="badge text-bg-warning ms-1">+{{ item.quantity - have }}</span>{% endif %}</td>{% endfor %}</tr>{% else %}<tr><td colspan="4">Ce profil ne contient aucune proposition.</td></tr>{% endfor %}
|
||||
</tbody></table></div></div>
|
||||
<button class="btn btn-primary" name="validate" value="1">Appliquer les éléments cochés</button><a class="btn btn-link" href="{{ url_for('room_profiles.edit', id=profile.id) }}">Annuler</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,13 @@
|
|||
<a href="{{ url_for('rooms.edit', id=room.id) }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i> Modifier
|
||||
</a>
|
||||
<a href="{{ url_for('rooms.duplicate', id=room.id) }}" class="btn btn-outline-success">Dupliquer</a>
|
||||
<a href="{{ url_for('rooms.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Retour
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4"><div class="card-header">Ouvrages / surfaces (facultatif)</div><div class="card-body"><div class="row g-2 mb-3">{% for surface in surfaces %}<div class="col-md-6"><span class="badge text-bg-light">{{ surface.surface_type }}</span> {{ surface.label or '' }} — {{ surface.material }}{% if surface.finish %} + {{ surface.finish }}{% endif %}</div>{% else %}<div class="col-12 text-muted">Aucune composition renseignée.</div>{% endfor %}</div><form method="post" action="{{ url_for('rooms.add_surface', id=room.id) }}" class="row g-2"><div class="col-md-2"><select name="surface_type" class="form-select" aria-label="Type"><option value="mur">Mur</option><option value="sol">Sol</option><option value="plafond">Plafond</option><option value="autre">Autre</option></select></div><div class="col-md-3"><input name="material" class="form-control" placeholder="Matériau" required></div><div class="col-md-3"><input name="finish" class="form-control" placeholder="Finition"></div><div class="col-md-2"><input name="label" class="form-control" placeholder="Repère"></div><div class="col-md-2"><button class="btn btn-outline-secondary">Ajouter</button></div></form></div></div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
|
@ -39,6 +42,7 @@
|
|||
|
||||
<dt class="col-sm-5">Équipements</dt>
|
||||
<dd class="col-sm-7">{{ equipments|length }}</dd>
|
||||
{% if room.room_profile %}<dt class="col-sm-5">Profil</dt><dd class="col-sm-7">{{ room.room_profile.name }}<br><a href="{{ url_for('room_profiles.preview', id=room.room_profile.id, room_id=room.id) }}">Prévisualiser / appliquer</a></dd>{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<div class="small mt-1 opacity-75">Version <code class="text-white">v{{ gmao_version }}</code> · Commit déployé : <code class="text-white">{{ build_commit }}</code></div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-4" aria-label="Niveaux de configuration"><div class="col-md-4"><div class="border rounded p-3 h-100"><h5>ESSENTIEL</h5><p class="small mb-0">Administrateur, établissement, bâtiments, zones, locaux, techniciens et horaires. À ce niveau, votre GMAO peut maintenant être utilisée.</p></div></div><div class="col-md-4"><div class="border rounded p-3 h-100"><h5>MAINTENANCE / PATRIMOINE</h5><p class="small mb-0">Profils de locaux, équipements, lots, préventif, entreprises et planning des salles.</p></div></div><div class="col-md-4"><div class="border rounded p-3 h-100"><h5>AVANCÉ / FACULTATIF</h5><p class="small mb-0">Logements, compteurs, cartographie technique et intégrations. Ces fonctions ne bloquent jamais l’installation.</p></div></div></div>
|
||||
<div class="d-flex flex-wrap justify-content-between gap-2 mb-4" id="stepIndicators">
|
||||
{% set labels = ['Admin', 'Collège', 'Bâtiments', 'Zones', 'Salles', 'Lots', 'Calendrier', 'ENT', 'Pronote', 'Fin'] %}
|
||||
{% for label in labels %}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@
|
|||
<h2 class="mt-4">Configuration terminée</h2>
|
||||
<p class="text-muted">Le système GMAO a déjà été configuré.</p>
|
||||
<p class="small text-muted mb-0">Version <code>v{{ gmao_version }}</code> · Commit déployé : <code>{{ build_commit }}</code></p>
|
||||
<a href="{{ url_for('companies.index') }}" class="btn btn-primary mt-3">
|
||||
<i class="bi bi-house"></i> Accueil
|
||||
<p class="alert alert-info text-start mt-3">Le setup initial est terminé. Le Centre de configuration reste disponible pour enrichir progressivement le patrimoine et repérer les actions restantes.</p>
|
||||
<a href="{{ url_for('configuration.index') }}" class="btn btn-primary mt-3">
|
||||
<i class="bi bi-list-check"></i> Centre de configuration
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
18
migrations/versions/m8b9c0d1e2f3_profile_item_origin.py
Normal file
18
migrations/versions/m8b9c0d1e2f3_profile_item_origin.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue