Fiabilise l'import initial du patrimoine
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-14 18:20:16 +00:00
parent d84e29f814
commit a0e8d3003d
4 changed files with 555 additions and 1174 deletions

View file

@ -11,9 +11,11 @@ from flask_login import login_required, current_user
from werkzeug.security import generate_password_hash
from ...extensions import db, csrf
from ..models.user import User
from ..models.college import College, Building
from ..models.college import College, Building, Zone, Room
from ..models.equipment import EquipmentCategory
from ..models.maintenance import Lot
from ..models.planning import WorkSchedule, CollegeClosure
from ..setup_catalog import EQUIPMENT_CATEGORIES, LOTS, grouped_catalog
setup_wizard_bp = Blueprint('setup_wizard', __name__, url_prefix='/setup-wizard')
@ -53,14 +55,8 @@ def protect_initial_setup():
def load_lots_data():
"""Charge les données des lots depuis le fichier JSON."""
json_path = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'lots_categories_tasks.json')
try:
with open(json_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"Erreur chargement lots: {e}")
return {}
"""Retourne le catalogue embarqué issu du dump SQL de référence."""
return grouped_catalog()
class SetupProgress(db.Model):
@ -68,7 +64,8 @@ class SetupProgress(db.Model):
__tablename__ = 'setup_progress'
id = db.Column(db.Integer, primary_key=True)
step = db.Column(db.Integer, default=1)
# Numéro de la dernière étape validée. Zéro signifie « aucune ».
step = db.Column(db.Integer, default=0)
admin_username = db.Column(db.String(100), nullable=True)
college_name = db.Column(db.String(200), nullable=True)
college_address = db.Column(db.String(255), nullable=True)
@ -128,6 +125,26 @@ class SetupProgress(db.Model):
def to_dict(self):
"""Convertit en dictionnaire pour le template."""
buildings = [
{'id': item.id, 'name': item.name, 'description': item.description or ''}
for item in Building.query.order_by(Building.name, Building.id).all()
]
zones = [
{'id': item.id, 'name': item.name, 'building_id': item.building_id}
for item in Zone.query.order_by(Zone.building_id, Zone.name, Zone.id).all()
]
rooms = [
{
'id': item.id,
'name': item.name,
'code': '' if item.code in (None, 'None') else item.code,
'building_id': item.building_id,
'zone_id': item.zone_id,
'floor': item.floor or 0,
'room_type_id': item.room_type_id,
}
for item in Room.query.order_by(Room.building_id, Room.name, Room.id).all()
]
return {
'step': self.step,
'data': {
@ -138,7 +155,9 @@ class SetupProgress(db.Model):
'zip': self.college_zip or '',
'city': self.college_city or '',
},
'buildings': self.get_buildings(),
'buildings': buildings,
'zones': zones,
'rooms': rooms,
'lots': self.get_lots(),
'categories': json.loads(self.categories_json) if self.categories_json else [],
'vacances_zone': self.vacances_zone or '',
@ -185,7 +204,9 @@ def api_save_step(step):
return jsonify({'error': 'Setup déjà terminé'}), 403
try:
data = request.get_json()
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'Corps JSON invalide'}), 400
progress = SetupProgress.get()
if step == 1:
@ -263,43 +284,153 @@ def api_save_step(step):
elif step == 3:
# Étape 3 : Bâtiments
buildings = data.get('buildings', [])
# Supprimer les anciens bâtiments
Building.query.delete()
if not isinstance(buildings, list) or not buildings:
return jsonify({'error': 'Ajoutez au moins un bâtiment'}), 400
saved = []
for b in buildings:
building = Building(
name=b.get('name'),
code=b.get('code', ''),
floors=b.get('floors', 1)
)
db.session.add(building)
progress.set_buildings(buildings)
progress.step = 4
if not isinstance(b, dict):
return jsonify({'error': 'Bâtiment invalide'}), 400
name = str(b.get('name') or '').strip()
if not name:
return jsonify({'error': 'Le nom de chaque bâtiment est requis'}), 400
building_id = b.get('id')
building = db.session.get(Building, building_id) if building_id else None
if building is None:
building = Building.query.filter(db.func.lower(Building.name) == name.lower()).first()
if building is None:
building = Building(name=name)
db.session.add(building)
db.session.flush()
building.name = name
building.description = str(b.get('description') or '').strip()
saved.append({'id': building.id, 'name': building.name, 'description': building.description or ''})
progress.set_buildings(saved)
progress.step = max(progress.step or 0, 3)
db.session.commit()
return jsonify({'success': True, 'step': 4})
return jsonify({'success': True, 'step': 3, 'buildings': saved})
elif step == 4:
# Étape 4 : Lots/Catégories
categories = data.get('categories', [])
# Créer les lots sélectionnés
for cat in categories:
lot = Lot.query.filter_by(name=cat).first()
if not lot:
lot = Lot(name=cat, description=f'Lot {cat}')
db.session.add(lot)
progress.categories_json = json.dumps(categories)
progress.step = 5
# Étape 4 : Zones. Les absences du formulaire ne suppriment rien.
zones = data.get('zones', [])
if not isinstance(zones, list):
return jsonify({'error': 'Liste de zones invalide'}), 400
saved = []
for item in zones:
if not isinstance(item, dict):
return jsonify({'error': 'Zone invalide'}), 400
name = str(item.get('name') or '').strip()
try:
building_id = int(item.get('building_id'))
except (TypeError, ValueError):
return jsonify({'error': f'Bâtiment invalide pour la zone {name or "sans nom"}'}), 400
if not name or db.session.get(Building, building_id) is None:
return jsonify({'error': 'Chaque zone doit avoir un nom et un bâtiment existant'}), 400
zone_id = item.get('id')
zone = db.session.get(Zone, zone_id) if zone_id else None
if zone is None:
zone = Zone.query.filter(
Zone.building_id == building_id,
db.func.lower(Zone.name) == name.lower(),
).first()
if zone is None:
zone = Zone(name=name, building_id=building_id)
db.session.add(zone)
db.session.flush()
zone.name = name
zone.building_id = building_id
saved.append({'id': zone.id, 'name': zone.name, 'building_id': zone.building_id})
progress.step = max(progress.step or 0, 4)
db.session.commit()
return jsonify({'success': True, 'step': 5})
return jsonify({'success': True, 'step': 4, 'zones': saved})
elif step == 5:
# Étape 5 : Vacances et horaires
# Étape 5 : Salles. Mise à jour/création uniquement.
rooms = data.get('rooms', [])
if not isinstance(rooms, list):
return jsonify({'error': 'Liste de salles invalide'}), 400
saved = []
for item in rooms:
if not isinstance(item, dict):
return jsonify({'error': 'Salle invalide'}), 400
name = str(item.get('name') or '').strip()
try:
building_id = int(item.get('building_id'))
floor = int(item.get('floor') or 0)
except (TypeError, ValueError):
return jsonify({'error': f'Bâtiment ou étage invalide pour {name or "la salle"}'}), 400
building = db.session.get(Building, building_id)
if not name or building is None:
return jsonify({'error': 'Chaque salle doit avoir un nom et un bâtiment existant'}), 400
zone_id = item.get('zone_id') or None
if zone_id is not None:
try:
zone_id = int(zone_id)
except (TypeError, ValueError):
return jsonify({'error': f'Zone invalide pour {name}'}), 400
zone = db.session.get(Zone, zone_id)
if zone is None or zone.building_id != building_id:
return jsonify({'error': f'La zone de {name} ne correspond pas à son bâtiment'}), 400
room_id = item.get('id')
room = db.session.get(Room, room_id) if room_id else None
if room is None:
room = Room.query.filter(
Room.building_id == building_id,
db.func.lower(Room.name) == name.lower(),
).first()
if room is None:
room = Room(name=name, building_id=building_id)
db.session.add(room)
db.session.flush()
room.name = name
room.code = str(item.get('code') or '').strip() or None
room.building_id = building_id
room.zone_id = zone_id
room.floor = floor
saved.append({
'id': room.id, 'name': room.name, 'code': room.code or '',
'building_id': room.building_id, 'zone_id': room.zone_id, 'floor': room.floor,
})
progress.step = max(progress.step or 0, 5)
db.session.commit()
return jsonify({'success': True, 'step': 5, 'rooms': saved})
elif step == 6:
# Étape 6 : catalogue fixe des catégories et lots du dump SQL.
categories_by_name = {}
for name in EQUIPMENT_CATEGORIES:
category = EquipmentCategory.query.filter(
db.func.lower(EquipmentCategory.name) == name.lower()
).first()
if category is None:
category = EquipmentCategory(name=name)
db.session.add(category)
db.session.flush()
categories_by_name[name] = category
for lot_name, category_name in LOTS:
lot = Lot.query.filter(db.func.lower(Lot.name) == lot_name.lower()).first()
if lot is None:
lot = Lot(name=lot_name)
db.session.add(lot)
lot.category_id = categories_by_name[category_name].id
progress.categories_json = json.dumps(list(EQUIPMENT_CATEGORIES), ensure_ascii=False)
progress.set_lots([name for name, _category in LOTS])
progress.step = max(progress.step or 0, 6)
db.session.commit()
return jsonify({
'success': True, 'step': 6,
'categories_count': len(EQUIPMENT_CATEGORIES), 'lots_count': len(LOTS),
})
elif step == 7:
# Étape 7 : Vacances et horaires
zone = data.get('zone', 'A')
annee = data.get('annee', 2024)
import_vacances = data.get('import_vacances', False)
@ -315,11 +446,22 @@ def api_save_step(step):
'custom_closures': custom_closures,
'personal_leaves': personal_leaves
})
progress.step = 6
progress.step = max(progress.step or 0, 7)
db.session.commit()
return jsonify({'success': True, 'step': 6})
return jsonify({'success': True, 'step': 7})
elif step in (8, 9):
# Intégrations facultatives : le paramétrage complet reste dans leurs pages dédiées.
progress.step = max(progress.step or 0, step)
db.session.commit()
return jsonify({'success': True, 'step': step, 'skipped': True})
elif step == 10:
progress.step = max(progress.step or 0, 10)
db.session.commit()
return jsonify({'success': True, 'step': 10})
else:
return jsonify({'error': 'Étape invalide'}), 400
@ -341,7 +483,7 @@ def api_complete():
admin = User.query.filter_by(role='admin', is_active=True).first()
if not admin:
return jsonify({'error': 'Un administrateur actif est requis'}), 400
if progress.step < 6:
if progress.step < 10:
return jsonify({'error': 'Toutes les étapes doivent être terminées'}), 400
# Créer le fichier .setup_complete

View file

@ -0,0 +1,87 @@
"""Catalogue patrimonial livré avec l'assistant de première installation.
Ces valeurs reprennent les catégories et les lots du dump SQL de référence.
Les noms sont les clés fonctionnelles utilisées pour une synchronisation idempotente :
le wizard ne supprime jamais une ligne déjà présente dans la base.
"""
EQUIPMENT_CATEGORIES = (
"Chauffage", "Courants faibles", "Gros œuvre", "Revêtement de façades",
"Menuiseries extérieures", "Cloisons", "Menuiseries intérieures", "Métallerie",
"Faux-plafonds", "Revêtements de sols souples", "Peinture", "Signalétique",
"Ventilation", "Plomberie", "Traitement de l'eau", "Gaz", "Courants forts",
"Portes et portails", "Ascenseur", "Réseaux enterrés", "Voirie", "Espaces verts",
"Clôtures", "Equipements sportifs", "Occultations", "Eau chaude sanitaire",
"Jeux d'enfants", "Couverture et étanchéité", "Désenfumage", "Flocages",
"Charpente", "Paratonnerre", "Revêtements de sols durs", "Revêtements de sols",
"Divers", "Mobilier",
)
LOTS = (
("Local chaufferie : ventilations haute et basse, extincteur, bloc autonome, conduit de fumées, siphon de sol, porte d'accès", "Chauffage"),
("Chaudières et équipements (armoire électrique, vannes, panoplie, vase d'expansion, pot à boues, …)", "Chauffage"),
("Réseaux de distribution et radiateurs", "Chauffage"),
("Chaudières dans logements", "Chauffage"), ("Régulation", "Chauffage"),
("Système de sécurité incendie, détection, centrale de mise en sécurité, équipement d'alarme", "Courants faibles"),
("Déclencheur manuel", "Courants faibles"), ("Autocommutateur", "Courants faibles"),
("Alarme technique", "Courants faibles"), ("Caméras", "Courants faibles"),
("Sonorisation - horloge", "Courants faibles"),
("Vidéoprojecteurs fixes (TICE salles de classes)", "Courants faibles"),
("Alarme anti-agression - Protection des travailleurs isolés (PTI)", "Courants faibles"),
("Alarme anti-intrusion", "Courants faibles"), ("Interphonie - Contrôle d'accès", "Courants faibles"),
("Vide sanitaire", "Gros œuvre"), ("Structure béton : poteaux, voiles, planchers, poutres", "Gros œuvre"),
("Structure béton : poteaux, voiles, planchers, poutres, balcons, consoles, appuis de fenêtre, escaliers béton", "Gros œuvre"),
("Bardages", "Revêtement de façades"), ("Enduits de façade", "Revêtement de façades"),
("Portes extérieures", "Menuiseries extérieures"), ("Fenêtres", "Menuiseries extérieures"),
("Joints de vitrages et joints entre ouvrants et dormants", "Menuiseries extérieures"),
("Cloisons intérieures", "Cloisons"), ("Portes coupe-feu", "Menuiseries intérieures"),
("Portes de distribution simples", "Menuiseries intérieures"),
("Escaliers, mains courantes, garde-corps, ouvrages de métallerie", "Métallerie"),
("Rideaux et grilles métalliques (manuels ou motorisés)", "Métallerie"),
("Faux plafonds minéraux ou métalliques", "Faux-plafonds"), ("Faux plafonds métalliques", "Faux-plafonds"),
("Sols souples linoléum, PVC, sols coulés", "Revêtements de sols souples"), ("Peintures", "Peinture"),
("Signalétique de sécurité et d'orientation", "Signalétique"), ("Groupes froids", "Ventilation"),
("Centrales de traitement d'air (CTA)", "Ventilation"), ("Climatiseur split", "Ventilation"),
("Extracteurs, gaines, clapets coupe-feu", "Ventilation"), ("Surpresseur d'eau", "Plomberie"),
("Canalisations d'eau froide", "Plomberie"), ("Equipement sanitaires terminaux", "Plomberie"),
("Robinetterie", "Plomberie"), ("Compteurs d'arrivée d'eau, gaz", "Plomberie"),
("Extincteurs", "Plomberie"), ("Disconnecteurs", "Plomberie"),
("RIA, colonnes sèches et humides", "Plomberie"), ("Adoucisseurs", "Traitement de l'eau"),
("Alimentation, détente et distribution gaz", "Gaz"), ("Onduleur", "Courants forts"),
("Local transformateur privatif et local TGBT", "Courants forts"), ("Armoires divisionnaires", "Courants forts"),
("Eclairage de sécurité", "Courants forts"), ("Compteurs électrique", "Courants forts"),
("Eclairage", "Courants forts"), ("Eclairage extérieur", "Courants forts"),
("courants forts Eclairage", "Courants forts"), ("courants forts Eclairage extérieur", "Courants forts"),
("courants forts Eclairage de sécurité", "Courants forts"),
("Portes automatiques - portes basculantes", "Portes et portails"), ("Portail électrique", "Portes et portails"),
("Ascenseur", "Ascenseur"), ("Réseaux enterrés, regards de visite, caniveaux et avaloirs", "Réseaux enterrés"),
("Séparateur à graisses à fécules, à hydrocarbures", "Réseaux enterrés"),
("Pompes de relevage des eaux usées ou des eaux pluviales", "Réseaux enterrés"),
("Voirie, chaussées", "Voirie"), ("Voirie, dallages", "Voirie"), ("Parking enherbé", "Espaces verts"),
("Pelouses", "Espaces verts"), ("Arbres de haute tige", "Espaces verts"),
("Arbustes et haies (à hauteur d'homme)", "Espaces verts"), ("Clôtures", "Clôtures"),
("Buts de football, volley, paniers de basket", "Equipements sportifs"), ("Murs d'escalade", "Equipements sportifs"),
("Stores et volets roulants", "Occultations"),
("Production d'eau chaude (chaudière, ballon)", "Eau chaude sanitaire"),
("Distribution d'eau chaude", "Eau chaude sanitaire"), ("Toboggans, structures, jeux divers", "Jeux d'enfants"),
("Toiture terrasse traditionnelle ou végétalisée", "Couverture et étanchéité"),
("Toiture à pente", "Couverture et étanchéité"), ("Descentes d'eaux pluviales (EP)", "Couverture et étanchéité"),
("Trappes, lanterneaux, skydômes, verrières (installations qui n'assurent pas le désenfumage)", "Couverture et étanchéité"),
("Echelles, garde aux corps, protections pour interventions ultérieures en toiture", "Couverture et étanchéité"),
("Ligne de vie", "Couverture et étanchéité"),
("Exutoires de désenfumage et mécanisme d'ouverture", "Désenfumage"),
("Flocages et peintures intumescentes", "Flocages"), ("Charpente bois", "Charpente"),
("Charpente métallique", "Charpente"),
("Paratonnerres et paratonnerres à dispositifs d'amorçage (PDA)", "Paratonnerre"),
("Sols durs et faïences murales", "Revêtements de sols durs"),
("souples Sols souples linoléum, PVC, sols coulés", "Revêtements de sols"),
("Informatique", "Divers"), ("Mobilier", "Mobilier"),
)
def grouped_catalog():
"""Format compact destiné à l'aperçu du wizard."""
grouped = {category: [] for category in EQUIPMENT_CATEGORIES}
for lot_name, category_name in LOTS:
grouped[category_name].append(lot_name)
return grouped

File diff suppressed because it is too large Load diff

View file

@ -46,3 +46,86 @@ def test_completed_setup_rejects_mutation(client, monkeypatch, tmp_path):
'password': 'a-strong-password',
})
assert response.status_code == 403
def _authorize_setup(client, monkeypatch, tmp_path):
from app_new.core.routes import setup_wizard
monkeypatch.setattr(setup_wizard, 'DATA_DIR', str(tmp_path))
token = 'test-setup-token-with-at-least-32-characters'
response = client.get(f'/setup-wizard/?token={token}')
assert response.status_code == 302
def test_setup_updates_buildings_without_deleting_existing(client, app, monkeypatch, tmp_path):
from app_new import db
from app_new.core.models.college import Building
_authorize_setup(client, monkeypatch, tmp_path)
with app.app_context():
existing = Building(name='Bâtiment conservé', description='Ancienne description')
untouched = Building(name='Bâtiment hors formulaire')
db.session.add_all([existing, untouched])
db.session.commit()
existing_id, untouched_id = existing.id, untouched.id
response = client.post('/setup-wizard/api/step/3', json={'buildings': [
{'id': existing_id, 'name': 'Bâtiment conservé', 'description': 'Mise à jour'},
{'name': 'Nouveau bâtiment', 'description': ''},
]})
assert response.status_code == 200
with app.app_context():
assert db.session.get(Building, existing_id).description == 'Mise à jour'
assert db.session.get(Building, untouched_id) is not None
assert Building.query.filter_by(name='Nouveau bâtiment').count() == 1
def test_setup_creates_zones_and_rooms_with_valid_links(client, app, monkeypatch, tmp_path):
from app_new import db
from app_new.core.models.college import Building, Room, Zone
_authorize_setup(client, monkeypatch, tmp_path)
with app.app_context():
building = Building(name='Bâtiment relations wizard')
db.session.add(building)
db.session.commit()
building_id = building.id
zone_response = client.post('/setup-wizard/api/step/4', json={'zones': [
{'name': 'Zone wizard', 'building_id': building_id},
]})
assert zone_response.status_code == 200
zone_id = zone_response.get_json()['zones'][0]['id']
room_response = client.post('/setup-wizard/api/step/5', json={'rooms': [
{'name': 'Salle wizard', 'code': 'W01', 'building_id': building_id, 'zone_id': zone_id, 'floor': 1},
]})
assert room_response.status_code == 200
with app.app_context():
zone = db.session.get(Zone, zone_id)
room = Room.query.filter_by(name='Salle wizard').one()
assert zone.building_id == building_id
assert room.building_id == building_id
assert room.zone_id == zone_id
assert room.floor == 1
def test_setup_imports_embedded_lot_catalog_idempotently(client, app, monkeypatch, tmp_path):
from app_new.core.models.equipment import EquipmentCategory
from app_new.core.models.maintenance import Lot
from app_new.core.setup_catalog import EQUIPMENT_CATEGORIES, LOTS
_authorize_setup(client, monkeypatch, tmp_path)
first = client.post('/setup-wizard/api/step/6', json={})
second = client.post('/setup-wizard/api/step/6', json={})
assert first.status_code == second.status_code == 200
assert first.get_json()['categories_count'] == len(EQUIPMENT_CATEGORIES)
assert first.get_json()['lots_count'] == len(LOTS)
with app.app_context():
for name in EQUIPMENT_CATEGORIES:
assert EquipmentCategory.query.filter_by(name=name).count() == 1
for name, category_name in LOTS:
lot = Lot.query.filter_by(name=name).one()
assert lot.category.name == category_name