Validate checkpoint B UI workflows
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
535458d3c4
commit
31434c5ca2
8 changed files with 158 additions and 18 deletions
|
|
@ -37,7 +37,8 @@ def index():
|
||||||
technicians = User.query.filter_by(is_active=True).all()
|
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()}
|
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()
|
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()
|
room_schedules = RoomSchedule.query.count()
|
||||||
sections = [
|
sections = [
|
||||||
("ESSENTIEL", [
|
("ESSENTIEL", [
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,26 @@ def delete_item(profile_id, item_id):
|
||||||
return redirect(url_for("room_profiles.edit", id=profile_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"])
|
@room_profiles_bp.route("/<int:id>/preview", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def preview(id):
|
def preview(id):
|
||||||
|
|
@ -151,7 +171,7 @@ def add_surface(id):
|
||||||
profile = RoomProfile.query.get_or_404(id)
|
profile = RoomProfile.query.get_or_404(id)
|
||||||
surface_type = request.form.get("surface_type")
|
surface_type = request.form.get("surface_type")
|
||||||
material = (request.form.get("material") or "").strip()
|
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")
|
flash("Type et matériau de surface obligatoires.", "danger")
|
||||||
return redirect(url_for("room_profiles.edit", id=id))
|
return redirect(url_for("room_profiles.edit", id=id))
|
||||||
db.session.add(RoomSurface(profile_id=profile.id, surface_type=surface_type, material=material,
|
db.session.add(RoomSurface(profile_id=profile.id, surface_type=surface_type, material=material,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}{{ title }}{% endblock %}
|
{% block title %}{{ title }}{% endblock %}
|
||||||
{% block content %}
|
{% 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>
|
<div class="container-fluid py-3" style="max-width:1100px">
|
||||||
<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>
|
<h1 class="h3">{{ title }}</h1><p class="text-muted">Un profil propose des équipements et ouvrages. Rien n’est créé avant validation.</p>
|
||||||
{% 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>
|
<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 l’application</a></div></div></div></div>
|
||||||
|
{% endif %}</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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('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('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.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('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><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>
|
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>
|
||||||
|
|
@ -260,6 +262,8 @@
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-dark" style="background:#34495e;">
|
<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('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('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><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>
|
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
**DATE/COMMIT** : 2026-08-23 / checkpoint B.
|
||||||
|
|
||||||
|
## D-008 — Centre de configuration sans faux état d’erreur
|
||||||
|
|
||||||
|
**CONTEXTE** : le lot d’un é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 l’inventaire 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
|
## D-000 — Checkpoints indépendants
|
||||||
|
|
||||||
**CONTEXTE** : le chantier couvre plusieurs domaines avec migrations et risques différents.
|
**CONTEXTE** : le chantier couvre plusieurs domaines avec migrations et risques différents.
|
||||||
|
|
|
||||||
|
|
@ -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] création en masse avec prévisualisation et duplication contrôlée ;
|
||||||
- [x] setup Essentiel/Maintenance/Avancé affiché et Centre permanent ;
|
- [x] setup Essentiel/Maintenance/Avancé affiché et Centre permanent ;
|
||||||
- [x] logements existants conservés et non obligatoires ;
|
- [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
|
#### 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] états calculés « Configuré », « Incomplet », « À configurer », « Facultatif », « Attention » avec liens d'action ;
|
||||||
- [x] setup progressif : Essentiel, Maintenance/Patrimoine, Avancé facultatif ;
|
- [x] setup progressif : Essentiel, Maintenance/Patrimoine, Avancé facultatif ;
|
||||||
- [x] horaires de travail rattachés explicitement à l'utilisateur/technicien ; aucun horaire fictif ;
|
- [x] horaires de travail rattachés explicitement à l'utilisateur/technicien ; aucun horaire fictif ;
|
||||||
- [ ] Site → Building → Zone → Room et HousingUnit réutilisés ; logements facultatifs ;
|
- [x] Site → Building → Zone → Room et HousingUnit réutilisés ; logements facultatifs ;
|
||||||
- [ ] TaskExecutionSegment et propriétés interruptible/splittable conservés sans régression.
|
- [x] TaskExecutionSegment et propriétés interruptible/splittable conservés sans régression.
|
||||||
|
|
||||||
### C — Compteurs, consommations, contrats → 0.2.0-dev.2
|
### C — Compteurs, consommations, contrats → 0.2.0-dev.2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
CURRENT VERSION: 0.2.0-dev.1
|
CURRENT VERSION: 0.2.0-dev.1
|
||||||
CURRENT CHECKPOINT: B — finalisation workflow profils, bulk, duplication et horaires
|
CURRENT CHECKPOINT: B — validation fonctionnelle finale
|
||||||
CURRENT COMMIT: c7c5cf2 (workflow B profils/bulk/duplication/horaires)
|
CURRENT COMMIT: à committer (Centre de configuration, menu, tests HTTP B)
|
||||||
CURRENT MIGRATION: m8b9c0d1e2f3 (appliquée Docker)
|
CURRENT MIGRATION: m8b9c0d1e2f3 (appliquée Docker)
|
||||||
DOCKER VERSION: gmao-college:0.2.0-dev.1 (reconstruite, healthy, MariaDB connectée)
|
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 FULL TEST: 166 passed, 1 failed, 1 error — uniquement lookback/configuration GMAO historique et son teardown
|
||||||
LAST TARGETED TEST: 3 passed (RoomProfile, bulk 200, service commun)
|
LAST TARGETED TEST: 11 passed (navigation, profil HTTP, bulk 200, horaires UI, Centre de configuration)
|
||||||
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
|
BLOCKERS: navigateur Chromium disponible mais aucune session admin locale documentée ; validation responsive visuelle non exécutée
|
||||||
NEXT ACTION: validation manuelle authentifiée des écrans B, puis décider si le checkpoint est assez démontré pour dev.2
|
NEXT ACTION: committer/pousser, puis maintenir dev.1 tant que la session navigateur admin locale n’est pas démontrée
|
||||||
|
|
||||||
## Avancement
|
## 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] 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] tests ciblés B et pytest complet sans nouvelle régression ;
|
||||||
- [x] rebuild Docker et application des migrations ;
|
- [x] rebuild Docker et application des migrations ;
|
||||||
- [ ] validation UI réelle et setup Essentiel/Maintenance/Avancé ;
|
- [x] validation HTTP authentifiée des profils : création UI, preview, application, réapplication, modification sans mutation ;
|
||||||
- [ ] profils complets avec prévisualisation UI démontrée ;
|
- [x] validation HTTP authentifiée des horaires de deux techniciens ;
|
||||||
- [ ] charge 200 locaux ;
|
- [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`.
|
- [ ] checkpoint B validé et version `0.2.0-dev.2`.
|
||||||
|
|
||||||
## Dernière reprise B
|
## 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`.
|
- 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.
|
- 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 n’a été trouvé dans les procédures du dépôt ; aucune session de production n’a é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é.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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.models.equipment import Equipment
|
||||||
from app_new.core.services.equipment_creation import create_equipment
|
from app_new.core.services.equipment_creation import create_equipment
|
||||||
from app_new.core.models.equipment import EquipmentCategory
|
from app_new.core.models.equipment import EquipmentCategory
|
||||||
|
from app_new.core.models.planning import WorkSchedule
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
def test_room_profile_is_editable_and_application_uses_shared_service(authenticated_client, app):
|
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
|
# The isolated fixture is torn down at session end; this assertion is
|
||||||
# deliberately kept as a measurable smoke benchmark.
|
# deliberately kept as a measurable smoke benchmark.
|
||||||
assert elapsed < 10
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue