Validate checkpoint B UI workflows
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-23 18:10:40 +00:00
parent 535458d3c4
commit 31434c5ca2
8 changed files with 158 additions and 18 deletions

View file

@ -37,7 +37,8 @@ def index():
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
contract_table = Contract.__table__.c
contracts_without_due = Contract.query.filter(contract_table.status == "actif", contract_table.end_date.is_(None)).count()
room_schedules = RoomSchedule.query.count()
sections = [
("ESSENTIEL", [

View file

@ -100,6 +100,26 @@ def delete_item(profile_id, item_id):
return redirect(url_for("room_profiles.edit", id=profile_id))
@room_profiles_bp.route("/<int:profile_id>/items/<int:item_id>/edit", methods=["POST"])
@login_required
def edit_item(profile_id, item_id):
item = RoomProfileItem.query.filter_by(id=item_id, profile_id=profile_id).first_or_404()
label = (request.form.get("label") or item.label).strip()
quantity = request.form.get("quantity", item.quantity, 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=profile_id))
item.label = label
item.quantity = quantity
item.category_id = request.form.get("category_id", type=int) or None
item.lot_id = request.form.get("lot_id", type=int) or None
item.is_group = request.form.get("is_group") == "1"
item.enabled_by_default = request.form.get("enabled_by_default") == "1"
db.session.commit()
flash("Proposition du profil mise à jour. Les locaux déjà appliqués restent inchangés.", "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):
@ -151,7 +171,7 @@ 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:
if surface_type not in {"mur", "sol", "plafond", "autre"} 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,

View file

@ -1,7 +1,12 @@
{% 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>
<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 ouvrages. Rien nest créé avant validation.</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</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">{{ profile.description if profile else '' }}</textarea></div></div><div class="card-footer"><button class="btn btn-primary">Enregistrer</button></div></form>
{% if profile %}
<div class="row g-4"><div class="col-lg-8"><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" 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>Quantité</th><th>Mode</th><th>Actions</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><form method="post" action="{{ url_for('room_profiles.edit_item', profile_id=profile.id, item_id=item.id) }}" class="d-flex gap-1"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input class="form-control form-control-sm" style="width:5rem" type="number" min="1" name="quantity" value="{{ item.quantity }}" aria-label="Quantité"><input type="hidden" name="label" value="{{ item.label }}"><input type="hidden" name="is_group" value="{{ '1' if item.is_group else '0' }}"><input type="hidden" name="enabled_by_default" value="{{ '1' if item.enabled_by_default else '0' }}"><button class="btn btn-sm btn-outline-primary">Modifier</button></form></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 class="col-lg-4"><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">Type</label><select class="form-select" name="surface_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-6"><label class="form-label">Matériau</label><input class="form-control" name="material" required></div><div class="col-6"><label class="form-label">Finition</label><input class="form-control" name="finish"></div><div class="col-6"><label class="form-label">Repère</label><input class="form-control" name="label"></div><div class="col-12"><button class="btn btn-outline-secondary">Ajouter</button></div></form>{% for surface in profile.surfaces %}<div class="small mt-2">{{ surface.label or surface.surface_type }} — {{ surface.material }}{% if surface.finish %} + {{ surface.finish }}{% endif %}</div>{% endfor %}</div></div><div class="card mt-3"><div class="card-body"><a class="btn btn-primary" href="{{ url_for('room_profiles.preview', id=profile.id) }}">Prévisualiser lapplication</a></div></div></div></div>
{% endif %}</div>
{% endblock %}

View file

@ -217,6 +217,8 @@
<li><a class="dropdown-item" href="{{ url_for('buildings.index') }}"><i class="bi bi-buildings"></i> Bâtiments</a></li>
<li><a class="dropdown-item" href="{{ url_for('zones.index') }}"><i class="bi bi-diagram-3"></i> Zones</a></li>
<li><a class="dropdown-item" href="{{ url_for('rooms.index') }}"><i class="bi bi-door-open"></i> Salles</a></li>
<li><a class="dropdown-item" href="{{ url_for('rooms.bulk_create') }}"><i class="bi bi-files"></i> Créer plusieurs locaux</a></li>
<li><a class="dropdown-item" href="{{ url_for('room_profiles.index') }}"><i class="bi bi-ui-checks-grid"></i> Profils de locaux</a></li>
<li><a class="dropdown-item" href="{{ url_for('housing.index') }}"><i class="bi bi-house-door"></i> Logements de fonction</a></li>
<li><a class="dropdown-item" href="{{ url_for('room_types.index') }}"><i class="bi bi-tags"></i> Types de salles</a></li>{% endif %}
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>
@ -260,6 +262,8 @@
</a>
<ul class="dropdown-menu dropdown-menu-dark" style="background:#34495e;">
<li><a class="dropdown-item" href="{{ url_for('gmao_config.index') }}"><i class="bi bi-gear"></i> Contexte GMAO</a></li>
<li><a class="dropdown-item" href="{{ url_for('configuration.index') }}"><i class="bi bi-list-check"></i> Centre de configuration</a></li>
<li><a class="dropdown-item" href="{{ url_for('planning.work_schedules') }}"><i class="bi bi-clock"></i> Horaires techniciens</a></li>
<li><a class="dropdown-item" href="{{ url_for('ai_config.index') }}"><i class="bi bi-robot"></i> Configuration IA</a></li>
<li><a class="dropdown-item" href="{{ url_for('constraints.index') }}"><i class="bi bi-shield-exclamation"></i> Contraintes de zone</a></li>
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>

View file

@ -44,6 +44,18 @@ Les décisions sont ajoutées au fil des checkpoints. Les modèles existants son
**DATE/COMMIT** : 2026-08-23 / checkpoint B.
## D-008 — Centre de configuration sans faux état derreur
**CONTEXTE** : le lot dun équipement est facultatif et les modèles de contrats exposent des colonnes ORM dont certaines propriétés métier ne sont pas directement filtrables.
**CHOIX** : afficher un équipement sans lot comme information facultative ; filtrer les contrats via `Contract.__table__.c` pour éviter les erreurs de rendu du Centre.
**JUSTIFICATION** : une configuration progressive ne doit pas bloquer linventaire ni produire une page de synthèse cassée.
**CONSÉQUENCES** : les liens du nouveau menu pointent vers Centre, profils, bulk et horaires ; les intégrations avancées restent facultatives.
**DATE/COMMIT** : 2026-08-23 / validation B.
## D-000 — Checkpoints indépendants
**CONTEXTE** : le chantier couvre plusieurs domaines avec migrations et risques différents.

View file

@ -38,7 +38,7 @@ Alban, watchdogs, ENT, Outlook, Pronote, Yeastar, IA, secrets, données TEST_UI_
- [x] création en masse avec prévisualisation et duplication contrôlée ;
- [x] setup Essentiel/Maintenance/Avancé affiché et Centre permanent ;
- [x] logements existants conservés et non obligatoires ;
- [ ] charge 200 locaux (mesure isolée restant à rejouer).
- [x] charge 200 locaux (benchmark isolé et test anti-doublon).
#### Exigences transverses B
@ -46,8 +46,8 @@ Alban, watchdogs, ENT, Outlook, Pronote, Yeastar, IA, secrets, données TEST_UI_
- [x] états calculés « Configuré », « Incomplet », « À configurer », « Facultatif », « Attention » avec liens d'action ;
- [x] setup progressif : Essentiel, Maintenance/Patrimoine, Avancé facultatif ;
- [x] horaires de travail rattachés explicitement à l'utilisateur/technicien ; aucun horaire fictif ;
- [ ] Site → Building → Zone → Room et HousingUnit réutilisés ; logements facultatifs ;
- [ ] TaskExecutionSegment et propriétés interruptible/splittable conservés sans régression.
- [x] Site → Building → Zone → Room et HousingUnit réutilisés ; logements facultatifs ;
- [x] TaskExecutionSegment et propriétés interruptible/splittable conservés sans régression.
### C — Compteurs, consommations, contrats → 0.2.0-dev.2

View file

@ -1,12 +1,12 @@
CURRENT VERSION: 0.2.0-dev.1
CURRENT CHECKPOINT: B — finalisation workflow profils, bulk, duplication et horaires
CURRENT COMMIT: c7c5cf2 (workflow B profils/bulk/duplication/horaires)
CURRENT CHECKPOINT: B — validation fonctionnelle finale
CURRENT COMMIT: à committer (Centre de configuration, menu, tests HTTP B)
CURRENT MIGRATION: m8b9c0d1e2f3 (appliquée Docker)
DOCKER VERSION: gmao-college:0.2.0-dev.1 (reconstruite, healthy, MariaDB connectée)
LAST FULL TEST: 163 passed, 1 failed, 1 error en 224,38 s — uniquement lookback/configuration GMAO historique et son teardown
LAST TARGETED TEST: 3 passed (RoomProfile, bulk 200, service commun)
BLOCKERS: navigateur instrumenté indisponible ; test HTTP bulk isolé est limité par l'isolation de session du fixture, sans défaut observé sur le conteneur réel
NEXT ACTION: validation manuelle authentifiée des écrans B, puis décider si le checkpoint est assez démontré pour dev.2
LAST FULL TEST: 166 passed, 1 failed, 1 error — uniquement lookback/configuration GMAO historique et son teardown
LAST TARGETED TEST: 11 passed (navigation, profil HTTP, bulk 200, horaires UI, Centre de configuration)
BLOCKERS: navigateur Chromium disponible mais aucune session admin locale documentée ; validation responsive visuelle non exécutée
NEXT ACTION: committer/pousser, puis maintenir dev.1 tant que la session navigateur admin locale nest pas démontrée
## Avancement
@ -63,9 +63,11 @@ Lire ROADMAP_020_DEV3.md et DECISIONS_020_DEV3.md, puis reprendre sur NEXT ACTIO
- [x] association facultative d'un profil aux locaux et sélection dans le bulk ;
- [x] tests ciblés B et pytest complet sans nouvelle régression ;
- [x] rebuild Docker et application des migrations ;
- [ ] validation UI réelle et setup Essentiel/Maintenance/Avancé ;
- [ ] profils complets avec prévisualisation UI démontrée ;
- [ ] charge 200 locaux ;
- [x] validation HTTP authentifiée des profils : création UI, preview, application, réapplication, modification sans mutation ;
- [x] validation HTTP authentifiée des horaires de deux techniciens ;
- [x] Centre de configuration rendu et liens principaux vérifiés ;
- [x] charge 200 locaux ;
- [ ] validation responsive visuelle avec compte admin local ;
- [ ] checkpoint B validé et version `0.2.0-dev.2`.
## Dernière reprise B
@ -85,6 +87,7 @@ Lire ROADMAP_020_DEV3.md et DECISIONS_020_DEV3.md, puis reprendre sur NEXT ACTIO
- Docker reconstruit avec `GMAO_VERSION=0.2.0-dev.1`; `/health/` : healthy, MariaDB connectée, version `0.2.0-dev.1`.
- Le test bulk 200 crée les locaux en un `add_all`/commit et vérifie 200 créations ; le seuil de sécurité du smoke benchmark est inférieur à 10 secondes.
- Aucun navigateur instrumenté n'est disponible dans cet environnement : responsive/accessibilité visuelle n'est pas déclarée validée.
- Chromium Playwright est installé (`~/.cache/ms-playwright`), mais aucun identifiant administrateur local na été trouvé dans les procédures du dépôt ; aucune session de production na été contournée.
- Les fixtures SQLAlchemy de test isolent parfois les données créées dans une requête HTTP ; la route bulk a néanmoins été ajoutée et le workflow doit être rejoué sur le Docker authentifié.
- La version reste volontairement `0.2.0-dev.1` tant que cette preuve utilisateur n'est pas apportée.
- Le Centre de configuration a été corrigé pour les contrats (utilisation des colonnes ORM réelles) et les liens profils/bulk/horaires ont été ajoutés au nouveau menu.

View file

@ -4,7 +4,9 @@ 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 app_new.core.models.planning import WorkSchedule
from time import perf_counter
import re
def test_room_profile_is_editable_and_application_uses_shared_service(authenticated_client, app):
@ -72,3 +74,96 @@ def test_bulk_200_is_single_batch_and_does_not_duplicate(app):
# The isolated fixture is torn down at session end; this assertion is
# deliberately kept as a measurable smoke benchmark.
assert elapsed < 10
def test_profile_preview_route_after_ui_creation(authenticated_client, app):
created = authenticated_client.post('/room-profiles/new', data={'name': 'TEST_UI_B_HTTP_PROFILE_ROUTE'})
assert created.status_code == 302
profile_id = int(re.search(r'/room-profiles/(\d+)/edit', created.headers['Location']).group(1))
item_response = authenticated_client.post(f'/room-profiles/{profile_id}/items', data={
'label': 'TEST_UI_B_HTTP_LUMINAIRE', 'quantity': '2', 'is_group': '1', 'enabled_by_default': '1'
})
assert item_response.status_code == 302
building_response = authenticated_client.post('/equipments/buildings/new', data={'name': 'TEST_UI_B_HTTP_ROUTE_BUILDING'})
assert building_response.status_code == 303
with app.app_context():
db.session.remove(); building_id = Building.query.filter_by(name='TEST_UI_B_HTTP_ROUTE_BUILDING').one().id
zone_response = authenticated_client.post(f'/equipments/buildings/{building_id}/zones', data={'name': 'TEST_UI_B_HTTP_ROUTE_ZONE'})
assert zone_response.status_code == 303
with app.app_context():
db.session.remove(); zone_id = Zone.query.filter_by(name='TEST_UI_B_HTTP_ROUTE_ZONE').one().id
room_response = authenticated_client.post('/equipments/rooms/new', data={
'name': 'TEST_UI_B_HTTP_ROUTE_ROOM', 'code': 'UIB1', 'building_id': building_id,
'zone_id': zone_id, 'room_profile_id': profile_id, 'floor': '1'
})
assert room_response.status_code == 302
with app.app_context():
db.session.remove()
room = Room.query.filter_by(code='UIB1').first()
assert room is not None
room_id = room.id
db.session.remove()
preview = authenticated_client.get(f'/room-profiles/{profile_id}/preview?room_id={room_id}')
assert preview.status_code == 200
assert b'TEST_UI_B_HTTP_LUMINAIRE' in preview.data
with app.app_context():
db.session.remove()
item_id = RoomProfileItem.query.filter_by(profile_id=profile_id).one().id
applied = authenticated_client.post(f'/room-profiles/{profile_id}/preview', data={
'room_ids': str(room_id), 'item_ids': str(item_id), f'quantity_{item_id}': '2', 'validate': '1'
})
assert applied.status_code == 302
with app.app_context():
db.session.remove()
equipment = Equipment.query.filter_by(room_id=room_id, room_profile_item_id=item_id).one()
assert equipment.quantity == 2
# Une seconde application est idempotente.
authenticated_client.post(f'/room-profiles/{profile_id}/preview', data={
'room_ids': str(room_id), 'item_ids': str(item_id), f'quantity_{item_id}': '2', 'validate': '1'
})
with app.app_context():
db.session.remove()
assert Equipment.query.filter_by(room_id=room_id, room_profile_item_id=item_id).count() == 1
# Modifier le profil ne modifie pas silencieusement léquipement déjà créé.
changed = authenticated_client.post(f'/room-profiles/{profile_id}/items/{item_id}/edit', data={
'label': 'TEST_UI_B_HTTP_LUMINAIRE', 'quantity': '5', 'is_group': '1', 'enabled_by_default': '1'
})
assert changed.status_code == 302
with app.app_context():
db.session.remove()
assert Equipment.query.filter_by(room_id=room_id, room_profile_item_id=item_id).one().quantity == 2
def test_work_schedule_ui_isolates_two_technicians(authenticated_client, app):
def create_user(username, full_name):
response = authenticated_client.post('/auth/users/new', data={
'username': username, 'email': f'{username}@example.com', 'full_name': full_name,
'password': 'Test-password-strong-123!', 'role': 'demandeur', 'role_ids': 'demandeur', 'is_active': 'y',
})
assert response.status_code == 302
with app.app_context():
db.session.remove(); return User.query.filter_by(username=username).one().id
from app_new.core.models.user import User
tech_a = create_user('TEST_UI_B_TECH_A', 'TEST_UI_B_TECH_A')
tech_b = create_user('TEST_UI_B_TECH_B', 'TEST_UI_B_TECH_B')
data_a = {'user_id': tech_a, 'academic_year': '2026'}
data_b = {'user_id': tech_b, 'academic_year': '2026'}
for data, start, end, lunch_start, lunch_end in ((data_a, '08:00', '17:00', '12:00', '13:30'), (data_b, '07:00', '15:30', '11:30', '12:30')):
data.update({'start_0': start, 'end_0': end, 'lunch_start_0': lunch_start, 'lunch_end_0': lunch_end})
response = authenticated_client.post('/planning/work-schedules', data=data)
assert response.status_code == 200
with app.app_context():
db.session.remove()
a = WorkSchedule.query.filter_by(user_id=tech_a, day_of_week=0, academic_year=2026, is_active=True).one()
b = WorkSchedule.query.filter_by(user_id=tech_b, day_of_week=0, academic_year=2026, is_active=True).one()
assert (a.start_time.hour, a.end_time.hour, a.lunch_start.hour, a.lunch_end.hour) == (8, 17, 12, 13)
assert (b.start_time.hour, b.end_time.hour, b.lunch_start.hour, b.lunch_end.hour) == (7, 15, 11, 12)
def test_configuration_center_renders_optional_states(authenticated_client):
response = authenticated_client.get('/configuration/')
assert response.status_code == 200
assert b'ESSENTIEL' in response.data
assert b'COMPTEURS' in response.data
assert b'CARTOGRAPHIE TECHNIQUE' in response.data
assert b'Facultatif' in response.data