diff --git a/app_new/core/routes/setup_wizard.py b/app_new/core/routes/setup_wizard.py index fc25426..0101241 100644 --- a/app_new/core/routes/setup_wizard.py +++ b/app_new/core/routes/setup_wizard.py @@ -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 diff --git a/app_new/core/setup_catalog.py b/app_new/core/setup_catalog.py new file mode 100644 index 0000000..0587abb --- /dev/null +++ b/app_new/core/setup_catalog.py @@ -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 diff --git a/app_new/templates/setup_wizard/index.html b/app_new/templates/setup_wizard/index.html index 8174779..89ebd41 100644 --- a/app_new/templates/setup_wizard/index.html +++ b/app_new/templates/setup_wizard/index.html @@ -3,1146 +3,215 @@ {% block content %}
Les bâtiments existants sont chargés et mis à jour. Retirer une ligne de l’écran ne supprime jamais le bâtiment ni ses locaux.
+ + +Chaque zone appartient à un bâtiment. Les zones déjà importées sont conservées.
+ + +Une salle peut être affectée à une zone, avec son étage et son code. Les salles existantes ne sont jamais supprimées par le wizard.
+| Nom | Code | Bâtiment | Zone | Étage |
|---|