Gère les logements de fonction et leurs pièces
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-14 21:23:33 +00:00
parent 358f946c76
commit a296b9e013
11 changed files with 198 additions and 3 deletions

View file

@ -133,6 +133,9 @@ def create_app(config_name='default'):
from .wizard.routes import wizard_bp
app.register_blueprint(wizard_bp)
from .housing import housing_bp
app.register_blueprint(housing_bp, url_prefix='/housing')
# Blueprints des intégrations
from .pronote.routes import pronote_bp

View file

@ -68,7 +68,7 @@ ADMIN_BLUEPRINTS = {
"outlook_auth", "outlook_dashboard", "outlook_pages", "outlook_sync", "ent", "pronote",
}
PATRIMOINE_BLUEPRINTS = {
"equipments", "equipments_meters", "equipments_documents", "equipments_restrictions",
"equipments", "equipments_meters", "equipments_documents", "equipments_restrictions", "housing",
"equipments_scheduled", "buildings", "zones", "rooms", "room_types", "wizard", "lots",
}
PLANNING_BLUEPRINTS = {"planning", "scheduler", "interventions_planning"}

View file

@ -3,7 +3,7 @@ Core Models - GMAO Collège
Import tous les modèles du core
"""
from .user import User, Staff
from .college import College, Site, Building, Room, RoomType, RoomSchedule, Zone
from .college import College, Site, Building, HousingUnit, Room, RoomType, RoomSchedule, Zone
from .equipment import EquipmentCategory, Equipment, EquipmentDocument, EquipmentRoomHistory, EquipmentQuantityMovement
from .maintenance import (
Intervention, StatusChange, InterventionComment, InterventionDocument,
@ -21,7 +21,7 @@ from .audit import AuditLog
__all__ = [
'User', 'Staff',
'College', 'Site', 'Building', 'Room', 'RoomType', 'RoomSchedule', 'Zone',
'College', 'Site', 'Building', 'HousingUnit', 'Room', 'RoomType', 'RoomSchedule', 'Zone',
'EquipmentCategory', 'Equipment', 'EquipmentDocument', 'EquipmentRoomHistory', 'EquipmentQuantityMovement',
'Intervention', 'StatusChange', 'InterventionComment', 'InterventionDocument',
'Lot', 'LotTask', 'LotService',

View file

@ -66,6 +66,29 @@ class Building(db.Model):
return f"<Building {self.name}>"
class HousingUnit(db.Model):
"""Logement de fonction et historique courant de son occupation."""
__tablename__ = "housing_units"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
code = db.Column(db.String(40), nullable=True, unique=True)
building_id = db.Column(db.Integer, db.ForeignKey("buildings.id"), nullable=False, index=True)
housing_type = db.Column(db.String(40), nullable=False, default="fonction")
occupancy_status = db.Column(db.String(30), nullable=False, default="vacant")
occupant_name = db.Column(db.String(150), nullable=True)
occupant_contact = db.Column(db.String(150), nullable=True)
occupancy_start = db.Column(db.Date, nullable=True)
occupancy_end = db.Column(db.Date, nullable=True)
notes = db.Column(db.Text, nullable=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
building = db.relationship("Building", backref=db.backref("housing_units", lazy="dynamic"))
rooms = db.relationship("Room", back_populates="housing_unit", lazy="dynamic")
class Zone(db.Model):
"""Zone dans un bâtiment."""
__tablename__ = "zones"
@ -100,6 +123,7 @@ class Room(db.Model):
floor = db.Column(db.Integer, default=0)
building_id = db.Column(db.Integer, db.ForeignKey("buildings.id"), nullable=False)
room_type_id = db.Column(db.Integer, db.ForeignKey("room_types.id"), nullable=True)
housing_unit_id = db.Column(db.Integer, db.ForeignKey("housing_units.id"), nullable=True, index=True)
# Libelle historique conserve pour les imports anterieurs a zone_id.
legacy_zone_name = db.Column("zone", db.String(100), nullable=True)
@ -108,6 +132,7 @@ class Room(db.Model):
zone = db.relationship("Zone", back_populates="rooms")
room_type = db.relationship("RoomType", back_populates="rooms")
equipments = db.relationship("Equipment", back_populates="room")
housing_unit = db.relationship("HousingUnit", back_populates="rooms")
@property
def full_name(self):

View file

@ -0,0 +1,3 @@
from .routes import housing_bp
__all__ = ["housing_bp"]

100
app_new/housing/routes.py Normal file
View file

@ -0,0 +1,100 @@
from datetime import datetime
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import login_required
from app_new.extensions import db
from app_new.core.models.college import Building, HousingUnit, Room, RoomType, Site, Zone
housing_bp = Blueprint("housing", __name__, template_folder="templates")
def _date(value):
return datetime.strptime(value, "%Y-%m-%d").date() if value else None
@housing_bp.route("/")
@login_required
def index():
units = HousingUnit.query.join(Building).order_by(Building.name, HousingUnit.name).all()
return render_template("housing/index.html", units=units)
@housing_bp.route("/new", methods=["GET", "POST"])
@login_required
def create():
housing_sites = Site.query.filter_by(site_type="logements", is_active=True).order_by(Site.name).all()
buildings = Building.query.order_by(Building.name).all()
if request.method == "POST":
name = (request.form.get("name") or "").strip()
building_id = request.form.get("building_id", type=int)
if not name or not building_id:
flash("Le nom et le bâtiment sont obligatoires.", "danger")
return render_template("housing/form.html", unit=None, buildings=buildings, housing_sites=housing_sites), 400
unit = HousingUnit(name=name, building_id=building_id)
_apply_form(unit)
db.session.add(unit)
db.session.commit()
flash(f"Logement « {unit.name} » créé.", "success")
return redirect(url_for("housing.detail", unit_id=unit.id))
return render_template("housing/form.html", unit=None, buildings=buildings, housing_sites=housing_sites)
@housing_bp.route("/<int:unit_id>")
@login_required
def detail(unit_id):
unit = HousingUnit.query.get_or_404(unit_id)
equipments = [equipment for room in unit.rooms for equipment in room.equipments if not equipment.is_deleted]
return render_template("housing/detail.html", unit=unit, equipments=equipments)
@housing_bp.route("/<int:unit_id>/edit", methods=["GET", "POST"])
@login_required
def edit(unit_id):
unit = HousingUnit.query.get_or_404(unit_id)
buildings = Building.query.order_by(Building.name).all()
if request.method == "POST":
_apply_form(unit)
unit.name = (request.form.get("name") or "").strip()
unit.building_id = request.form.get("building_id", type=int)
db.session.commit()
flash("Logement mis à jour.", "success")
return redirect(url_for("housing.detail", unit_id=unit.id))
return render_template("housing/form.html", unit=unit, buildings=buildings, housing_sites=[])
@housing_bp.route("/<int:unit_id>/rooms", methods=["POST"])
@login_required
def add_room(unit_id):
unit = HousingUnit.query.get_or_404(unit_id)
name = (request.form.get("name") or "").strip()
if not name:
flash("Le nom de la pièce est obligatoire.", "danger")
return redirect(url_for("housing.detail", unit_id=unit.id))
zone = Zone.query.filter_by(building_id=unit.building_id, name="Logements").first()
if zone is None:
zone = Zone(name="Logements", building_id=unit.building_id)
db.session.add(zone)
db.session.flush()
room_type = RoomType.query.filter_by(name=request.form.get("room_type_name", "Logement")).first()
room = Room(
name=name, code=(request.form.get("code") or "").strip() or None,
building_id=unit.building_id, zone_id=zone.id,
room_type_id=room_type.id if room_type else None, housing_unit_id=unit.id,
)
db.session.add(room)
db.session.commit()
flash(f"Pièce « {room.name} » ajoutée.", "success")
return redirect(url_for("housing.detail", unit_id=unit.id))
def _apply_form(unit):
unit.code = (request.form.get("code") or "").strip() or None
unit.housing_type = request.form.get("housing_type", "fonction")
unit.occupancy_status = request.form.get("occupancy_status", "vacant")
unit.occupant_name = (request.form.get("occupant_name") or "").strip() or None
unit.occupant_contact = (request.form.get("occupant_contact") or "").strip() or None
unit.occupancy_start = _date(request.form.get("occupancy_start"))
unit.occupancy_end = _date(request.form.get("occupancy_end"))
unit.notes = (request.form.get("notes") or "").strip() or None

View file

@ -211,6 +211,7 @@
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></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('rooms.index') }}"><i class="bi bi-door-open"></i> Salles</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>
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>
<li><a class="dropdown-item" href="{{ url_for('companies.index') }}"><i class="bi bi-building"></i> Entreprises</a></li>

View file

@ -0,0 +1,5 @@
{% extends "base.html" %}{% block title %}{{ unit.name }} — GMAO{% endblock %}{% block content %}
<div class="d-flex justify-content-between mb-4"><div><h1 class="h3"><i class="bi bi-house-door"></i> {{ unit.name }}</h1><div class="text-muted">{{ unit.building.site.name if unit.building.site else '' }} · {{ unit.building.name }}</div></div><div><a href="{{ url_for('housing.edit', unit_id=unit.id) }}" class="btn btn-outline-primary">Modifier</a> <a href="{{ url_for('wizard.equipment_wizard', housing_id=unit.id) }}" class="btn btn-success"><i class="bi bi-magic"></i> Inventorier</a></div></div>
<div class="row g-3"><div class="col-lg-4"><div class="card"><div class="card-header">Occupation</div><div class="card-body"><p><strong>État :</strong> {{ unit.occupancy_status|replace('_',' ')|title }}</p><p><strong>Occupant :</strong> {{ unit.occupant_name or 'Aucun' }}</p><p><strong>Contact :</strong> {{ unit.occupant_contact or '—' }}</p><p><strong>Période :</strong> {{ unit.occupancy_start|date_fmt if unit.occupancy_start else '—' }} → {{ unit.occupancy_end|date_fmt if unit.occupancy_end else '—' }}</p></div></div></div>
<div class="col-lg-8"><div class="card mb-3"><div class="card-header">Pièces</div><div class="card-body"><div class="list-group mb-3">{% for room in unit.rooms %}<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for('rooms.detail', id=room.id) }}"><span>{{ room.name }}</span><span class="badge text-bg-secondary">{{ room.equipments|length }} équipement(s)</span></a>{% else %}<div class="text-muted">Aucune pièce.</div>{% endfor %}</div><form method="post" action="{{ url_for('housing.add_room', unit_id=unit.id) }}" class="row g-2"><div class="col"><input name="name" class="form-control" placeholder="Cuisine, séjour, chambre…" required></div><div class="col-sm-3"><input name="code" class="form-control" placeholder="Code"></div><div class="col-auto"><button class="btn btn-primary">Ajouter</button></div></form></div></div><div class="card"><div class="card-header">Patrimoine du logement</div><div class="list-group list-group-flush">{% for equipment in equipments %}<a href="{{ url_for('equipments.detail', id=equipment.id) }}" class="list-group-item list-group-item-action d-flex justify-content-between"><span>{{ equipment.name }} <small class="text-muted">· {{ equipment.room.name }}</small></span><span>{{ equipment.quantity }}</span></a>{% else %}<div class="list-group-item text-muted">Aucun équipement.</div>{% endfor %}</div></div></div></div>
{% endblock %}

View file

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block title %}{{ 'Modifier' if unit else 'Nouveau' }} logement — GMAO{% endblock %}
{% block content %}<div class="container" style="max-width:900px"><h1 class="h3 mb-4">{{ 'Modifier le' if unit else 'Nouveau' }} logement</h1><form method="post" class="card"><div class="card-body row g-3">
<div class="col-md-8"><label class="form-label">Nom *</label><input name="name" class="form-control" value="{{ unit.name if unit else '' }}" placeholder="Ex. Logement 1" required></div><div class="col-md-4"><label class="form-label">Code</label><input name="code" class="form-control" value="{{ unit.code if unit and unit.code else '' }}"></div>
<div class="col-md-6"><label class="form-label">Bâtiment *</label><select name="building_id" class="form-select" required><option value=""></option>{% for building in buildings %}<option value="{{ building.id }}" {% if unit and unit.building_id == building.id %}selected{% endif %}>{{ building.site.name ~ ' · ' if building.site else '' }}{{ building.name }}</option>{% endfor %}</select></div><div class="col-md-3"><label class="form-label">Type</label><select name="housing_type" class="form-select"><option value="fonction">Fonction</option><option value="gardien">Gardien</option><option value="temporaire">Temporaire</option></select></div><div class="col-md-3"><label class="form-label">État</label><select name="occupancy_status" class="form-select">{% for value,label in [('vacant','Vacant'),('occupe','Occupé'),('travaux','En travaux'),('indisponible','Indisponible')] %}<option value="{{ value }}" {% if unit and unit.occupancy_status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="col-md-6"><label class="form-label">Occupant</label><input name="occupant_name" class="form-control" value="{{ unit.occupant_name if unit and unit.occupant_name else '' }}"></div><div class="col-md-6"><label class="form-label">Contact</label><input name="occupant_contact" class="form-control" value="{{ unit.occupant_contact if unit and unit.occupant_contact else '' }}"></div><div class="col-md-3"><label class="form-label">Début d'occupation</label><input type="date" name="occupancy_start" class="form-control" value="{{ unit.occupancy_start.isoformat() if unit and unit.occupancy_start else '' }}"></div><div class="col-md-3"><label class="form-label">Fin prévue/réelle</label><input type="date" name="occupancy_end" class="form-control" value="{{ unit.occupancy_end.isoformat() if unit and unit.occupancy_end else '' }}"></div><div class="col-md-6"><label class="form-label">Notes techniques</label><textarea name="notes" class="form-control">{{ unit.notes if unit and unit.notes else '' }}</textarea></div>
</div><div class="card-footer"><button class="btn btn-primary">Enregistrer</button> <a href="{{ url_for('housing.index') }}" class="btn btn-outline-secondary">Annuler</a></div></form></div>{% endblock %}

View file

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}Logements de fonction — GMAO{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"><div><h1 class="h3"><i class="bi bi-house-door"></i> Logements de fonction</h1><p class="text-muted mb-0">Occupation, pièces et patrimoine technique des logements.</p></div><a class="btn btn-primary" href="{{ url_for('housing.create') }}"><i class="bi bi-plus-lg"></i> Nouveau logement</a></div>
<div class="row g-3">{% for unit in units %}<div class="col-md-6 col-xl-4"><div class="card h-100"><div class="card-body"><div class="d-flex justify-content-between"><h2 class="h5">{{ unit.name }}</h2><span class="badge {% if unit.occupancy_status == 'occupe' %}text-bg-success{% elif unit.occupancy_status == 'travaux' %}text-bg-warning{% else %}text-bg-secondary{% endif %}">{{ unit.occupancy_status|replace('_',' ') }}</span></div><div class="text-muted small">{{ unit.building.site.name if unit.building.site else 'Site non renseigné' }} · {{ unit.building.name }}</div><hr><div><strong>Occupant :</strong> {{ unit.occupant_name or 'Aucun' }}</div><div><strong>Pièces :</strong> {{ unit.rooms.count() }}</div></div><div class="card-footer"><a href="{{ url_for('housing.detail', unit_id=unit.id) }}" class="btn btn-sm btn-outline-primary">Ouvrir</a></div></div></div>{% else %}<div class="col-12"><div class="alert alert-info">Aucun logement enregistré.</div></div>{% endfor %}</div>
{% endblock %}

View file

@ -0,0 +1,45 @@
"""Ajoute la gestion métier des logements de fonction.
Revision ID: d5e9f0a1b2c3
Revises: c4d8e9f0a1b2
"""
from alembic import op
import sqlalchemy as sa
revision = "d5e9f0a1b2c3"
down_revision = "c4d8e9f0a1b2"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"housing_units",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("code", sa.String(length=40), nullable=True),
sa.Column("building_id", sa.Integer(), nullable=False),
sa.Column("housing_type", sa.String(length=40), nullable=False, server_default="fonction"),
sa.Column("occupancy_status", sa.String(length=30), nullable=False, server_default="vacant"),
sa.Column("occupant_name", sa.String(length=150), nullable=True),
sa.Column("occupant_contact", sa.String(length=150), nullable=True),
sa.Column("occupancy_start", sa.Date(), nullable=True),
sa.Column("occupancy_end", sa.Date(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["building_id"], ["buildings.id"]),
sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("code"),
)
op.create_index("ix_housing_units_building_id", "housing_units", ["building_id"])
op.add_column("rooms", sa.Column("housing_unit_id", sa.Integer(), nullable=True))
op.create_foreign_key("fk_rooms_housing_unit", "rooms", "housing_units", ["housing_unit_id"], ["id"])
op.create_index("ix_rooms_housing_unit_id", "rooms", ["housing_unit_id"])
def downgrade():
op.drop_index("ix_rooms_housing_unit_id", table_name="rooms")
op.drop_constraint("fk_rooms_housing_unit", "rooms", type_="foreignkey")
op.drop_column("rooms", "housing_unit_id")
op.drop_table("housing_units")