Gère le patrimoine mobile et son cycle de vie
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-14 19:16:01 +00:00
parent 7d8d67a9d2
commit 82c2683c4a
13 changed files with 515 additions and 148 deletions

View file

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

View file

@ -30,6 +30,19 @@ class College(db.Model):
return f"<College {self.name}>" return f"<College {self.name}>"
class Site(db.Model):
"""Site physique : collège, logements de fonction, annexe, etc."""
__tablename__ = "sites"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False, unique=True)
site_type = db.Column(db.String(40), nullable=False, default="college")
address = 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))
buildings = db.relationship("Building", back_populates="site", lazy="dynamic")
class Building(db.Model): class Building(db.Model):
"""Bâtiment du collège.""" """Bâtiment du collège."""
__tablename__ = "buildings" __tablename__ = "buildings"
@ -37,10 +50,12 @@ class Building(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False) name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
site_id = db.Column(db.Integer, db.ForeignKey("sites.id"), nullable=True, index=True)
# Relations - utiliser overlaps pour éviter warning SQLAlchemy # Relations - utiliser overlaps pour éviter warning SQLAlchemy
rooms = db.relationship("Room", overlaps="building", lazy="dynamic") rooms = db.relationship("Room", overlaps="building", lazy="dynamic")
zones = db.relationship("Zone", back_populates="building", lazy="dynamic") zones = db.relationship("Zone", back_populates="building", lazy="dynamic")
site = db.relationship("Site", back_populates="buildings")
@property @property
def room_count(self): def room_count(self):

View file

@ -45,6 +45,20 @@ class Equipment(db.Model):
position_y = db.Column(db.Float, nullable=True) position_y = db.Column(db.Float, nullable=True)
install_date = db.Column(db.Date, nullable=True) install_date = db.Column(db.Date, nullable=True)
warranty_end = db.Column(db.Date, nullable=True) warranty_end = db.Column(db.Date, nullable=True)
mobility = db.Column(db.String(20), nullable=False, default="non_precise")
serial_number = db.Column(db.String(120), nullable=True, index=True)
manufacturer = db.Column(db.String(120), nullable=True)
model_reference = db.Column(db.String(120), nullable=True)
supplier_id = db.Column(db.Integer, db.ForeignKey("companies.id"), nullable=True)
supplier_reference = db.Column(db.String(120), nullable=True)
purchase_price = db.Column(db.Numeric(12, 2), nullable=True)
purchase_date = db.Column(db.Date, nullable=True)
lifecycle_status = db.Column(db.String(30), nullable=False, default="en_service")
unavailable_since = db.Column(db.DateTime, nullable=True)
planned_disposal_date = db.Column(db.Date, nullable=True)
disposal_date = db.Column(db.Date, nullable=True)
disposal_reason = db.Column(db.String(500), nullable=True)
recurrence_monitoring = db.Column(db.Boolean, nullable=False, default=False)
is_deleted = db.Column(db.Boolean, default=False, nullable=False) is_deleted = db.Column(db.Boolean, default=False, nullable=False)
deleted_at = db.Column(db.DateTime, nullable=True) deleted_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
@ -59,6 +73,7 @@ class Equipment(db.Model):
documents = db.relationship("EquipmentDocument", back_populates="equipment") documents = db.relationship("EquipmentDocument", back_populates="equipment")
consumables = db.relationship("EquipmentConsumable", back_populates="equipment", lazy="dynamic") consumables = db.relationship("EquipmentConsumable", back_populates="equipment", lazy="dynamic")
room_history = db.relationship("EquipmentRoomHistory", backref="equipment", order_by="desc(EquipmentRoomHistory.changed_at)") room_history = db.relationship("EquipmentRoomHistory", backref="equipment", order_by="desc(EquipmentRoomHistory.changed_at)")
supplier = db.relationship("Company", foreign_keys=[supplier_id])
@property @property
def effective_lot_id(self): def effective_lot_id(self):
@ -158,7 +173,11 @@ class Equipment(db.Model):
@property @property
def needs_deep_work(self): def needs_deep_work(self):
"""Nécessite une intervention profonde.""" """Nécessite une intervention profonde."""
return False return bool(self.recurrence_monitoring and self.curative_count >= 3)
@property
def is_mobile(self):
return self.mobility == "mobile"
@property @property
def children_count(self): def children_count(self):
@ -301,6 +320,25 @@ class EquipmentRoomHistory(db.Model):
return f"<EquipmentRoomHistory {self.equipment_id}>" return f"<EquipmentRoomHistory {self.equipment_id}>"
class EquipmentQuantityMovement(db.Model):
"""Mouvement d'une quantité entre deux salles ou groupes."""
__tablename__ = "equipment_quantity_movements"
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
root_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=False, index=True)
source_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True)
target_equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id"), nullable=True)
source_room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=True)
target_room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), nullable=False)
quantity = db.Column(db.Integer, nullable=False)
reason = db.Column(db.String(255), nullable=True)
moved_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
moved_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
source_room = db.relationship("Room", foreign_keys=[source_room_id])
target_room = db.relationship("Room", foreign_keys=[target_room_id])
class RoomConstraint(db.Model): class RoomConstraint(db.Model):
"""Contrainte d'indisponibilite d'une salle (cours PRONOTE, horaire special, etc.).""" """Contrainte d'indisponibilite d'une salle (cours PRONOTE, horaire special, etc.)."""
__tablename__ = "room_constraints" __tablename__ = "room_constraints"

View file

@ -3,7 +3,7 @@ from flask import Blueprint, render_template, redirect, url_for, request, flash,
from flask_login import login_required from flask_login import login_required
from app_new.extensions import db from app_new.extensions import db
from app_new.core.models.college import Building, Zone, Room from app_new.core.models.college import Site, Building, Zone, Room
buildings_bp = Blueprint('buildings', __name__, template_folder='templates') buildings_bp = Blueprint('buildings', __name__, template_folder='templates')
@ -13,7 +13,22 @@ buildings_bp = Blueprint('buildings', __name__, template_folder='templates')
def index(): def index():
"""Liste des bâtiments.""" """Liste des bâtiments."""
buildings = Building.query.order_by(Building.name).all() buildings = Building.query.order_by(Building.name).all()
return render_template('equipments/buildings.html', buildings=buildings) return render_template('equipments/buildings.html', buildings=buildings, sites=Site.query.order_by(Site.name).all())
@buildings_bp.route('/sites', methods=['POST'])
@login_required
def create_site():
name = (request.form.get('name') or '').strip()
if not name:
flash('Le nom du site est obligatoire.', 'danger')
elif Site.query.filter_by(name=name).first():
flash('Ce site existe déjà.', 'warning')
else:
db.session.add(Site(name=name, site_type=request.form.get('site_type', 'college'), address=(request.form.get('address') or '').strip() or None))
db.session.commit()
flash(f"Site '{name}' créé.", 'success')
return redirect(url_for('buildings.index'))
@buildings_bp.route('/new', methods=['GET', 'POST']) @buildings_bp.route('/new', methods=['GET', 'POST'])
@ -25,14 +40,14 @@ def create():
description = (request.form.get('description') or '').strip() description = (request.form.get('description') or '').strip()
if not name: if not name:
flash('Le nom du bâtiment est requis.', 'danger') flash('Le nom du bâtiment est requis.', 'danger')
return render_template('equipments/building_form.html', title='Nouveau bâtiment'), 400 return render_template('equipments/building_form.html', title='Nouveau bâtiment', sites=Site.query.order_by(Site.name).all()), 400
building = Building(name=name, description=description) building = Building(name=name, description=description, site_id=request.form.get('site_id', type=int))
db.session.add(building) db.session.add(building)
db.session.commit() db.session.commit()
flash(f"Bâtiment '{building.name}' créé.", 'success') flash(f"Bâtiment '{building.name}' créé.", 'success')
return redirect(url_for('buildings.index'), code=303) return redirect(url_for('buildings.index'), code=303)
return render_template('equipments/building_form.html', title='Nouveau bâtiment') return render_template('equipments/building_form.html', title='Nouveau bâtiment', sites=Site.query.order_by(Site.name).all())
@buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST']) @buildings_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@ -50,9 +65,10 @@ def edit(id):
return render_template('equipments/building_form.html', return render_template('equipments/building_form.html',
title='Modifier le bâtiment', title='Modifier le bâtiment',
building=building, building=building,
zones=zones), 400 zones=zones, sites=Site.query.order_by(Site.name).all()), 400
building.name = name building.name = name
building.description = description building.description = description
building.site_id = request.form.get('site_id', type=int)
db.session.commit() db.session.commit()
flash(f"Bâtiment '{building.name}' mis à jour.", 'success') flash(f"Bâtiment '{building.name}' mis à jour.", 'success')
return redirect(url_for('buildings.index'), code=303) return redirect(url_for('buildings.index'), code=303)
@ -61,7 +77,7 @@ def edit(id):
return render_template('equipments/building_form.html', return render_template('equipments/building_form.html',
title='Modifier le bâtiment', title='Modifier le bâtiment',
building=building, building=building,
zones=zones) zones=zones, sites=Site.query.order_by(Site.name).all())
@buildings_bp.route('/<int:id>/zones', methods=['POST']) @buildings_bp.route('/<int:id>/zones', methods=['POST'])

View file

@ -3,7 +3,7 @@ from flask import Blueprint, render_template, redirect, url_for, request, flash,
from flask_login import login_required, current_user from flask_login import login_required, current_user
from datetime import datetime from datetime import datetime
from app_new.extensions import db from app_new.extensions import db
from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction, EquipmentQuantityMovement
from ..core.models.college import Room, Building, RoomType, Zone from ..core.models.college import Room, Building, RoomType, Zone
from ..core.models.maintenance import Lot, Intervention from ..core.models.maintenance import Lot, Intervention
from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable, ScheduledTask from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable, ScheduledTask
@ -13,6 +13,10 @@ def _empty_to_none(v):
"""Convertit une chaîne vide en None.""" """Convertit une chaîne vide en None."""
return None if v == '' else v return None if v == '' else v
def _date_or_none(value):
return datetime.strptime(value, '%Y-%m-%d').date() if value else None
main_bp = Blueprint("equipments", __name__, template_folder='templates') main_bp = Blueprint("equipments", __name__, template_folder='templates')
""" """
@ -23,7 +27,7 @@ from flask import Blueprint, render_template, redirect, url_for, request, flash,
from flask_login import login_required, current_user from flask_login import login_required, current_user
from datetime import datetime from datetime import datetime
from app_new.extensions import db from app_new.extensions import db
from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction from ..core.models.equipment import Equipment, EquipmentCategory, EquipmentDocument, EquipmentRoomHistory, EquipmentRestriction, EquipmentQuantityMovement
from ..core.models.college import Room, Building, RoomType, Zone from ..core.models.college import Room, Building, RoomType, Zone
from ..core.models.maintenance import Lot, Intervention from ..core.models.maintenance import Lot, Intervention
from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable from ..core.models.planning import Meter, MeterReading, Consumable, EquipmentConsumable
@ -216,7 +220,16 @@ def create():
status=request.form.get('status', 'en_service'), status=request.form.get('status', 'en_service'),
is_group=request.form.get('is_group') == '1', is_group=request.form.get('is_group') == '1',
quantity=int(request.form.get('quantity', 1) or 1), quantity=int(request.form.get('quantity', 1) or 1),
parent_id=parent_id parent_id=parent_id,
mobility=request.form.get('mobility', 'non_precise'),
serial_number=_empty_to_none(request.form.get('serial_number', '').strip()),
manufacturer=_empty_to_none(request.form.get('manufacturer', '').strip()),
model_reference=_empty_to_none(request.form.get('model_reference', '').strip()),
supplier_id=request.form.get('supplier_id') or None,
supplier_reference=_empty_to_none(request.form.get('supplier_reference', '').strip()),
purchase_price=request.form.get('purchase_price') or None,
purchase_date=_date_or_none(request.form.get('purchase_date')),
recurrence_monitoring=request.form.get('recurrence_monitoring') == '1',
) )
db.session.add(equipment) db.session.add(equipment)
db.session.commit() db.session.commit()
@ -233,6 +246,8 @@ def create():
buildings = Building.query.order_by(Building.name).all() buildings = Building.query.order_by(Building.name).all()
parent_equipments = Equipment.query.filter(Equipment.is_group == True, Equipment.parent_id == None).order_by(Equipment.name).all() parent_equipments = Equipment.query.filter(Equipment.is_group == True, Equipment.parent_id == None).order_by(Equipment.name).all()
lots = Lot.query.order_by(Lot.name).all() lots = Lot.query.order_by(Lot.name).all()
from ..core.models.company import Company
companies = Company.query.order_by(Company.name).all()
return render_template('equipments/form.html', return render_template('equipments/form.html',
categories=categories, categories=categories,
@ -241,6 +256,7 @@ def create():
parent_equipments=parent_equipments, parent_equipments=parent_equipments,
lots=lots, lots=lots,
statuses=EQUIPMENT_STATUSES, statuses=EQUIPMENT_STATUSES,
companies=companies,
preselected_parent_id=preselected_parent_id) preselected_parent_id=preselected_parent_id)
@ -249,7 +265,13 @@ def create():
def detail(id): def detail(id):
"""Détail d'un équipement.""" """Détail d'un équipement."""
equipment = Equipment.query.get_or_404(id) equipment = Equipment.query.get_or_404(id)
return render_template('equipments/detail.html', equipment=equipment) root_id = equipment.parent_id or equipment.id
if equipment.parent and equipment.parent.parent_id:
root_id = equipment.parent.parent_id
quantity_movements = EquipmentQuantityMovement.query.filter_by(
root_equipment_id=root_id
).order_by(EquipmentQuantityMovement.moved_at.desc()).limit(50).all()
return render_template('equipments/detail.html', equipment=equipment, quantity_movements=quantity_movements)
@main_bp.route('/<int:id>/edit', methods=['GET', 'POST']) @main_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@ -270,6 +292,15 @@ def edit(id):
equipment.is_group = bool(request.form.get('is_group')) equipment.is_group = bool(request.form.get('is_group'))
equipment.tracked_individually = bool(request.form.get('tracked_individually')) equipment.tracked_individually = bool(request.form.get('tracked_individually'))
equipment.position = _empty_to_none(request.form.get('position')) equipment.position = _empty_to_none(request.form.get('position'))
equipment.mobility = request.form.get('mobility', 'non_precise')
equipment.serial_number = _empty_to_none(request.form.get('serial_number', '').strip())
equipment.manufacturer = _empty_to_none(request.form.get('manufacturer', '').strip())
equipment.model_reference = _empty_to_none(request.form.get('model_reference', '').strip())
equipment.supplier_id = request.form.get('supplier_id') or None
equipment.supplier_reference = _empty_to_none(request.form.get('supplier_reference', '').strip())
equipment.purchase_price = request.form.get('purchase_price') or None
equipment.purchase_date = _date_or_none(request.form.get('purchase_date'))
equipment.recurrence_monitoring = bool(request.form.get('recurrence_monitoring'))
# Position X/Y # Position X/Y
pos_x = request.form.get('position_x') pos_x = request.form.get('position_x')
@ -302,6 +333,8 @@ def edit(id):
buildings = Building.query.order_by(Building.name).all() buildings = Building.query.order_by(Building.name).all()
parent_equipments = Equipment.query.filter(Equipment.id != id).order_by(Equipment.name).all() parent_equipments = Equipment.query.filter(Equipment.id != id).order_by(Equipment.name).all()
lots = Lot.query.order_by(Lot.name).all() lots = Lot.query.order_by(Lot.name).all()
from ..core.models.company import Company
companies = Company.query.order_by(Company.name).all()
# Charger les relations # Charger les relations
meters = Meter.query.filter_by(equipment_id=id).all() meters = Meter.query.filter_by(equipment_id=id).all()
@ -316,6 +349,7 @@ def edit(id):
buildings=buildings, buildings=buildings,
parent_equipments=parent_equipments, parent_equipments=parent_equipments,
lots=lots, lots=lots,
companies=companies,
meters=meters, meters=meters,
equipment_consumables=equipment_consumables, equipment_consumables=equipment_consumables,
all_consumables=all_consumables, all_consumables=all_consumables,
@ -443,6 +477,9 @@ def mark_as_trash(id):
"""Marquer un équipement comme à jeter.""" """Marquer un équipement comme à jeter."""
equipment = Equipment.query.get_or_404(id) equipment = Equipment.query.get_or_404(id)
equipment.status = 'a_jeter' equipment.status = 'a_jeter'
equipment.lifecycle_status = 'mise_au_rebut_planifiee'
equipment.planned_disposal_date = _date_or_none(request.form.get('planned_disposal_date'))
equipment.disposal_reason = _empty_to_none(request.form.get('reason', '').strip())
db.session.commit() db.session.commit()
flash(f"Équipement '{equipment.name}' marqué comme à jeter.", 'warning') flash(f"Équipement '{equipment.name}' marqué comme à jeter.", 'warning')
return redirect(url_for('equipments.detail', id=equipment.id)) return redirect(url_for('equipments.detail', id=equipment.id))
@ -458,6 +495,8 @@ def cancel_trash(id):
return redirect(url_for('equipments.detail', id=equipment.id)) return redirect(url_for('equipments.detail', id=equipment.id))
equipment.status = 'en_service' equipment.status = 'en_service'
equipment.lifecycle_status = 'en_service'
equipment.planned_disposal_date = None
db.session.commit() db.session.commit()
flash(f"Équipement '{equipment.name}' remis en service.", 'success') flash(f"Équipement '{equipment.name}' remis en service.", 'success')
return redirect(url_for('equipments.detail', id=equipment.id)) return redirect(url_for('equipments.detail', id=equipment.id))
@ -469,6 +508,8 @@ def confirm_trashed(id):
"""Confirmer qu'un équipement a été jeté.""" """Confirmer qu'un équipement a été jeté."""
equipment = Equipment.query.get_or_404(id) equipment = Equipment.query.get_or_404(id)
equipment.status = 'jete' equipment.status = 'jete'
equipment.lifecycle_status = 'jete'
equipment.disposal_date = datetime.now().date()
equipment.room_id = None equipment.room_id = None
db.session.commit() db.session.commit()
flash(f"Équipement '{equipment.name}' marqué comme jeté.", 'success') flash(f"Équipement '{equipment.name}' marqué comme jeté.", 'success')
@ -659,33 +700,118 @@ def init_scheduled_tasks(id):
@main_bp.route('/<int:id>/move', methods=['GET', 'POST']) @main_bp.route('/<int:id>/move', methods=['GET', 'POST'])
@login_required @login_required
def move_to_room(id): def move_to_room(id):
"""Déplacer un équipement vers une autre salle.""" """Déplace une unité ou une quantité, sans casser les totaux des groupes."""
from ..core.models.college import Building, Room
from ..core.models.equipment import EquipmentRoomHistory
equipment = Equipment.query.get_or_404(id) equipment = Equipment.query.get_or_404(id)
if equipment.mobility == 'fixe':
flash("Cet équipement est fixe. Sa relocalisation nécessite une modification de sa fiche.", 'danger')
return redirect(url_for('equipments.detail', id=id))
if request.method == 'POST': if request.method == 'POST':
new_room_id = request.form.get('room_id') target_room_id = request.form.get('room_id', type=int)
if new_room_id: target_room = db.session.get(Room, target_room_id) if target_room_id else None
old_room_id = equipment.room_id if target_room is None:
flash('La salle cible est invalide.', 'danger')
return redirect(url_for('equipments.move_to_room', id=id))
reason = _empty_to_none((request.form.get('reason') or '').strip())
# Créer l'historique du déplacement if equipment.is_group:
if old_room_id != int(new_room_id): quantity = request.form.get('quantity', 1, type=int)
history = EquipmentRoomHistory( if quantity < 1:
equipment_id=equipment.id, flash('La quantité doit être positive.', 'danger')
old_room_id=old_room_id, return redirect(url_for('equipments.move_to_room', id=id))
new_room_id=new_room_id
root = equipment.parent if equipment.parent_id else equipment
if equipment.parent_id:
available = equipment.unindividualized_quantity
else:
allocated = sum(child.quantity or 0 for child in equipment.children if child.is_group)
available = max((equipment.quantity or 0) - allocated, 0)
if quantity > available:
flash(f'Seulement {available} unité(s) non individualisée(s) sont déplaçables.', 'danger')
return redirect(url_for('equipments.move_to_room', id=id))
target = Equipment.query.filter_by(
parent_id=root.id, room_id=target_room.id, is_group=True, is_deleted=False
).first()
if target is None:
target = Equipment(
name=equipment.name if equipment.parent_id else root.name,
parent_id=root.id, room_id=target_room.id, is_group=True,
quantity=0, status='en_service', mobility=root.mobility,
) )
db.session.add(history) db.session.add(target)
db.session.flush()
if equipment.parent_id and target.id == equipment.id:
flash('La salle cible est déjà la salle source.', 'warning')
return redirect(url_for('equipments.move_to_room', id=id))
equipment.room_id = new_room_id source_room_id = equipment.room_id if equipment.parent_id else None
if equipment.parent_id:
equipment.quantity -= quantity
target.quantity = (target.quantity or 0) + quantity
db.session.add(EquipmentQuantityMovement(
root_equipment_id=root.id, source_equipment_id=equipment.id,
target_equipment_id=target.id, source_room_id=source_room_id,
target_room_id=target_room.id, quantity=quantity, reason=reason,
moved_by_id=current_user.id,
))
db.session.commit() db.session.commit()
flash(f"Équipement déplacé.", 'success') flash(f'{quantity} unité(s) déplacée(s) vers {target_room.name}.', 'success')
return redirect(url_for('equipments.detail', id=root.id))
old_room_id = equipment.effective_room.id if equipment.effective_room else None
if old_room_id == target_room.id:
flash('Cet équipement est déjà dans cette salle.', 'warning')
return redirect(url_for('equipments.move_to_room', id=id))
equipment.room_id = target_room.id
if equipment.parent_id and equipment.parent and equipment.parent.parent_id:
root = equipment.parent.parent
target_group = Equipment.query.filter_by(
parent_id=root.id, room_id=target_room.id, is_group=True, is_deleted=False
).first()
if target_group:
equipment.parent_id = target_group.id
db.session.add(EquipmentRoomHistory(
equipment_id=equipment.id, old_room_id=old_room_id,
new_room_id=target_room.id, changed_by_id=current_user.id, reason=reason,
))
db.session.commit()
flash('Équipement déplacé.', 'success')
return redirect(url_for('equipments.detail', id=equipment.id)) return redirect(url_for('equipments.detail', id=equipment.id))
buildings = Building.query.order_by(Building.name).all() rooms = Room.query.join(Building).order_by(Building.name, Room.name).all()
return render_template('equipments/move.html', equipment=equipment, buildings=buildings) return render_template(
'equipments/move.html', equipment=equipment, rooms=rooms,
current_room=equipment.effective_room,
max_quantity=(equipment.unindividualized_quantity if equipment.parent_id else max(
(equipment.quantity or 0) - sum(c.quantity or 0 for c in equipment.children if c.is_group), 0
)) if equipment.is_group else 1,
)
@main_bp.route('/<int:id>/repair/start', methods=['POST'])
@login_required
def start_repair(id):
equipment = Equipment.query.get_or_404(id)
equipment.lifecycle_status = 'en_reparation'
equipment.status = 'en_maintenance'
equipment.unavailable_since = datetime.now()
db.session.commit()
flash('Équipement retiré provisoirement pour réparation.', 'warning')
return redirect(url_for('equipments.detail', id=id))
@main_bp.route('/<int:id>/repair/complete', methods=['POST'])
@login_required
def complete_repair(id):
equipment = Equipment.query.get_or_404(id)
equipment.lifecycle_status = 'en_service'
equipment.status = 'en_service'
equipment.unavailable_since = None
db.session.commit()
flash('Réparation terminée, équipement remis en service.', 'success')
return redirect(url_for('equipments.detail', id=id))
# === Gestion des Zones === # === Gestion des Zones ===

View file

@ -11,6 +11,10 @@
<label class="form-label">Nom *</label> <label class="form-label">Nom *</label>
<input type="text" name="name" class="form-control" value="{{ building.name if building else '' }}" required> <input type="text" name="name" class="form-control" value="{{ building.name if building else '' }}" required>
</div> </div>
<div class="mb-3">
<label class="form-label" for="site_id">Site</label>
<select id="site_id" name="site_id" class="form-select"><option value="">— Non renseigné —</option>{% for site in sites %}<option value="{{ site.id }}" {% if building and building.site_id == site.id %}selected{% endif %}>{{ site.name }}</option>{% endfor %}</select>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="3">{{ building.description if building else '' }}</textarea> <textarea name="description" class="form-control" rows="3">{{ building.description if building else '' }}</textarea>

View file

@ -7,12 +7,23 @@
<a href="{{ url_for('buildings.create') }}" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Nouveau bâtiment</a> <a href="{{ url_for('buildings.create') }}" class="btn btn-primary"><i class="bi bi-plus-lg"></i> Nouveau bâtiment</a>
</div> </div>
<div class="card mb-4"><div class="card-header"><i class="bi bi-geo"></i> Sites</div><div class="card-body">
<div class="d-flex flex-wrap gap-2 mb-3">{% for site in sites %}<span class="badge text-bg-light border">{{ site.name }} · {{ site.site_type|replace('_',' ') }}</span>{% else %}<span class="text-muted">Aucun site défini.</span>{% endfor %}</div>
<form method="post" action="{{ url_for('buildings.create_site') }}" class="row g-2">
<div class="col-md-4"><input class="form-control" name="name" placeholder="Ex. Collège principal" required></div>
<div class="col-md-3"><select class="form-select" name="site_type"><option value="college">Collège</option><option value="logements">Logements de fonction</option><option value="annexe">Annexe</option><option value="autre">Autre</option></select></div>
<div class="col-md-3"><input class="form-control" name="address" placeholder="Adresse (facultatif)"></div>
<div class="col-md-2"><button class="btn btn-outline-primary w-100" type="submit">Ajouter</button></div>
</form>
</div></div>
<div class="row"> <div class="row">
{% for building in buildings %} {% for building in buildings %}
<div class="col-md-6 col-lg-4 mb-3"> <div class="col-md-6 col-lg-4 mb-3">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">{{ building.name }}</h5> <h5 class="card-title">{{ building.name }}</h5>
<div class="small text-muted mb-2"><i class="bi bi-geo-alt"></i> {{ building.site.name if building.site else 'Site non renseigné' }}</div>
<p class="card-text text-muted">{{ building.description or '' }}</p> <p class="card-text text-muted">{{ building.description or '' }}</p>
<p><span class="badge bg-secondary">{{ building.rooms.count() }} salle(s)</span></p> <p><span class="badge bg-secondary">{{ building.rooms.count() }} salle(s)</span></p>
<div class="btn-group"> <div class="btn-group">

View file

@ -48,16 +48,31 @@
<a href="{{ url_for('interventions.create') }}?equipment_id={{ equipment.id }}" class="btn btn-danger"> <a href="{{ url_for('interventions.create') }}?equipment_id={{ equipment.id }}" class="btn btn-danger">
<i class="bi bi-wrench"></i> Intervention <i class="bi bi-wrench"></i> Intervention
</a> </a>
{% if equipment.mobility != 'fixe' %}<a href="{{ url_for('equipments.move_to_room', id=equipment.id) }}" class="btn btn-outline-secondary"><i class="bi bi-arrow-left-right"></i> Déplacer</a>{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Alertes --> <!-- Alertes -->
{% if equipment.lifecycle_status == 'en_reparation' %}
<div class="alert alert-warning d-flex justify-content-between align-items-center"><span><i class="bi bi-tools"></i> Retiré provisoirement pour réparation depuis {{ equipment.unavailable_since|datetime_fmt }}.</span><form method="post" action="{{ url_for('equipments.complete_repair', id=equipment.id) }}"><button class="btn btn-success btn-sm" type="submit">Réparation terminée</button></form></div>
{% elif equipment.status != 'jete' %}
<div class="d-flex gap-2 mb-3">
<form method="post" action="{{ url_for('equipments.start_repair', id=equipment.id) }}"><button class="btn btn-outline-warning btn-sm" type="submit"><i class="bi bi-tools"></i> Retirer pour réparation</button></form>
<form method="post" action="{{ url_for('equipments.mark_as_trash', id=equipment.id) }}" class="row g-2">
<div class="col-auto"><input class="form-control form-control-sm" type="date" name="planned_disposal_date" title="Date prévue"></div>
<div class="col-auto"><input class="form-control form-control-sm" name="reason" placeholder="Motif de mise au rebut"></div>
<div class="col-auto"><button class="btn btn-outline-danger btn-sm" type="submit">Prévoir la mise au rebut</button></div>
</form>
</div>
{% endif %}
{% if equipment.status == 'a_jeter' %} {% if equipment.status == 'a_jeter' %}
<div class="alert alert-danger d-flex justify-content-between align-items-center"> <div class="alert alert-danger d-flex justify-content-between align-items-center">
<div> <div>
<i class="bi bi-exclamation-triangle me-2"></i> <i class="bi bi-exclamation-triangle me-2"></i>
<strong>Cet équipement est marqué comme à jeter.</strong> <strong>Cet équipement est marqué comme à jeter.</strong>
{% if equipment.planned_disposal_date %}<span>Date prévue : {{ equipment.planned_disposal_date|date_fmt }}.</span>{% endif %}
{% if equipment.disposal_reason %}<span>Motif : {{ equipment.disposal_reason }}.</span>{% endif %}
</div> </div>
<div> <div>
<form action="{{ url_for('equipments.confirm_trashed', id=equipment.id) }}" method="POST" style="display:inline;"> <form action="{{ url_for('equipments.confirm_trashed', id=equipment.id) }}" method="POST" style="display:inline;">
@ -99,6 +114,10 @@
<th style="width: 30%;">Statut</th> <th style="width: 30%;">Statut</th>
<td><span class="badge {{ equipment.status|status_badge }}">{{ equipment.status|format_status }}</span></td> <td><span class="badge {{ equipment.status|status_badge }}">{{ equipment.status|format_status }}</span></td>
</tr> </tr>
<tr><th>Mobilité</th><td>{{ equipment.mobility|replace('_', ' ')|title }}</td></tr>
{% if equipment.serial_number %}<tr><th>Numéro de série</th><td><code>{{ equipment.serial_number }}</code></td></tr>{% endif %}
{% if equipment.manufacturer or equipment.model_reference %}<tr><th>Fabricant / modèle</th><td>{{ equipment.manufacturer or '—' }} / {{ equipment.model_reference or '—' }}</td></tr>{% endif %}
{% if equipment.supplier %}<tr><th>Fournisseur</th><td><a href="{{ url_for('companies.detail', id=equipment.supplier.id) }}">{{ equipment.supplier.name }}</a></td></tr>{% endif %}
{% if equipment.is_group %} {% if equipment.is_group %}
<tr> <tr>
@ -203,6 +222,10 @@
</div> </div>
</div> </div>
{% if quantity_movements %}
<div class="card mb-3"><div class="card-header"><i class="bi bi-arrow-left-right"></i> Mouvements quantitatifs</div><div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Date</th><th>Origine</th><th>Destination</th><th>Quantité</th><th>Motif</th></tr></thead><tbody>{% for movement in quantity_movements %}<tr><td>{{ movement.moved_at|datetime_fmt }}</td><td>{{ movement.source_room.full_name if movement.source_room else 'Stock non localisé' }}</td><td>{{ movement.target_room.full_name }}</td><td><strong>{{ movement.quantity }}</strong></td><td>{{ movement.reason or '—' }}</td></tr>{% endfor %}</tbody></table></div></div>
{% endif %}
<!-- Compteurs --> <!-- Compteurs -->
{% set meters_list = equipment.meters.all() %} {% set meters_list = equipment.meters.all() %}
{% if meters_list %} {% if meters_list %}

View file

@ -176,6 +176,18 @@
</div> </div>
</div> </div>
<div class="card mb-3"><div class="card-header"><i class="bi bi-wrench-adjustable"></i> Données techniques</div><div class="card-body row g-3">
<div class="col-md-4"><label class="form-label" for="mobility">Mobilité</label><select class="form-select" id="mobility" name="mobility">{% for value, label in [('non_precise','À préciser'),('mobile','Mobile'),('fixe','Fixe')] %}<option value="{{ value }}" {% if equipment.mobility == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div class="col-md-4"><label class="form-label" for="serial_number">N° de série</label><input class="form-control" id="serial_number" name="serial_number" value="{{ equipment.serial_number or '' }}"></div>
<div class="col-md-4"><label class="form-label" for="manufacturer">Fabricant</label><input class="form-control" id="manufacturer" name="manufacturer" value="{{ equipment.manufacturer or '' }}"></div>
<div class="col-md-4"><label class="form-label" for="model_reference">Modèle</label><input class="form-control" id="model_reference" name="model_reference" value="{{ equipment.model_reference or '' }}"></div>
<div class="col-md-4"><label class="form-label" for="supplier_id">Fournisseur</label><select class="form-select" id="supplier_id" name="supplier_id"><option value=""></option>{% for company in companies %}<option value="{{ company.id }}" {% if equipment.supplier_id == company.id %}selected{% endif %}>{{ company.name }}</option>{% endfor %}</select></div>
<div class="col-md-4"><label class="form-label" for="supplier_reference">Référence fournisseur</label><input class="form-control" id="supplier_reference" name="supplier_reference" value="{{ equipment.supplier_reference or '' }}"></div>
<div class="col-md-4"><label class="form-label" for="purchase_price">Prix d'achat</label><input class="form-control" id="purchase_price" name="purchase_price" type="number" min="0" step="0.01" value="{{ equipment.purchase_price or '' }}"></div>
<div class="col-md-4"><label class="form-label" for="purchase_date">Date d'achat</label><input class="form-control" id="purchase_date" name="purchase_date" type="date" value="{{ equipment.purchase_date.strftime('%Y-%m-%d') if equipment.purchase_date else '' }}"></div>
<div class="col-md-4 d-flex align-items-end"><div class="form-check mb-2"><input class="form-check-input" id="recurrence_monitoring" name="recurrence_monitoring" type="checkbox" {% if equipment.recurrence_monitoring %}checked{% endif %}><label class="form-check-label" for="recurrence_monitoring">Surveiller les récurrences</label></div></div>
</div></div>
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<i class="bi bi-geo me-1"></i>Position <i class="bi bi-geo me-1"></i>Position

View file

@ -155,6 +155,21 @@
</div> </div>
</div> </div>
<div class="card mb-3">
<div class="card-header"><i class="bi bi-wrench-adjustable"></i> Suivi technique et mobilité</div>
<div class="card-body row g-3">
<div class="col-md-4"><label class="form-label" for="mobility">Mobilité</label><select class="form-select" id="mobility" name="mobility"><option value="non_precise">À préciser</option><option value="mobile">Mobile</option><option value="fixe">Fixe</option></select></div>
<div class="col-md-4"><label class="form-label" for="serial_number">Numéro de série</label><input class="form-control" id="serial_number" name="serial_number"></div>
<div class="col-md-4"><label class="form-label" for="manufacturer">Fabricant</label><input class="form-control" id="manufacturer" name="manufacturer"></div>
<div class="col-md-4"><label class="form-label" for="model_reference">Modèle</label><input class="form-control" id="model_reference" name="model_reference"></div>
<div class="col-md-4"><label class="form-label" for="supplier_id">Fournisseur</label><select class="form-select" id="supplier_id" name="supplier_id"><option value=""></option>{% for company in companies %}<option value="{{ company.id }}">{{ company.name }}</option>{% endfor %}</select></div>
<div class="col-md-4"><label class="form-label" for="supplier_reference">Référence fournisseur</label><input class="form-control" id="supplier_reference" name="supplier_reference"></div>
<div class="col-md-4"><label class="form-label" for="purchase_price">Prix d'achat informatif</label><input class="form-control" id="purchase_price" name="purchase_price" type="number" min="0" step="0.01"></div>
<div class="col-md-4"><label class="form-label" for="purchase_date">Date d'achat</label><input class="form-control" id="purchase_date" name="purchase_date" type="date"></div>
<div class="col-md-4 d-flex align-items-end"><div class="form-check mb-2"><input class="form-check-input" id="recurrence_monitoring" name="recurrence_monitoring" value="1" type="checkbox"><label class="form-check-label" for="recurrence_monitoring">Alerter après 3 pannes récurrentes</label></div></div>
</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="description" class="form-label">Description</label> <label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="3">{{ equipment.description if equipment else '' }}</textarea> <textarea class="form-control" id="description" name="description" rows="3">{{ equipment.description if equipment else '' }}</textarea>

View file

@ -1,109 +1,34 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Déplacer l'équipement — GMAO Collège{% endblock %} {% block title %}Déplacer {{ equipment.name }} — GMAO{% endblock %}
{% block content %} {% block content %}
<h1 class="mb-4"> <div class="container-fluid" style="max-width: 850px">
<i class="bi bi-arrow-left-right"></i> Déplacer l'équipement <h1 class="h3 mb-4"><i class="bi bi-arrow-left-right"></i> Déplacer {{ equipment.name }}</h1>
</h1> <div class="card"><div class="card-body">
<dl class="row">
<div class="card"> <dt class="col-sm-4">Salle actuelle</dt><dd class="col-sm-8">{{ current_room.full_name if current_room else 'Stock non localisé' }}</dd>
<div class="card-body"> <dt class="col-sm-4">Mobilité</dt><dd class="col-sm-8">{{ equipment.mobility|replace('_', ' ')|title }}</dd>
{% if equipment.is_group %}<dt class="col-sm-4">Quantité disponible</dt><dd class="col-sm-8"><strong>{{ max_quantity }}</strong> non individualisée(s)</dd>{% endif %}
</dl>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if equipment.is_group %}
<div class="mb-3"> <div class="mb-3">
<strong>{{ equipment.name }}</strong> <label for="quantity" class="form-label">Quantité à déplacer</label>
{% if equipment.code %}<span class="text-muted">({{ equipment.code }})</span>{% endif %} <input id="quantity" name="quantity" type="number" class="form-control" min="1" max="{{ max_quantity }}" value="1" required>
<div class="form-text">Les unités déjà individualisées doivent être déplacées depuis leur propre fiche.</div>
{% if equipment.is_individual and equipment.parent %} </div>
<br><small class="text-muted">Élément n°{{ equipment.individual_number }} du groupe « <a href="{{ url_for('equipments.detail', id=equipment.parent_id) }}">{{ equipment.parent.name }}</a> »</small>
{% endif %} {% endif %}
</div>
<form method="POST">
{{ csrf_token() if csrf_token is defined }}
{% if equipment.is_individual and equipment.parent %}
{# Élément individuel : doit choisir un groupe cible #}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
Les éléments individuels doivent être déplacés vers un groupe du même type dans une autre salle.
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Salle actuelle</label> <label for="room_id" class="form-label">Salle cible</label>
<input type="text" class="form-control" <select id="room_id" name="room_id" class="form-select" required>
value="{{ current_room.building.name ~ ' > ' ~ current_room.name if current_room else 'Non définie' }}" <option value="">— Sélectionner —</option>
disabled> {% for room in rooms %}<option value="{{ room.id }}">{{ room.full_name }}</option>{% endfor %}
</div>
<div class="mb-3">
<label for="group_id" class="form-label">Groupe cible</label>
<select class="form-select" id="group_id" name="group_id" required>
<option value="">— Sélectionner un groupe —</option>
{% for group in target_groups %}
{% set group_room = group.effective_room %}
<option value="{{ group.id }}" data-room-id="{{ group_room.id if group_room else '' }}">
{{ group.name }}
{% if group_room %}
({{ group_room.building.name }} > {{ group_room.name }})
{% endif %}
— {{ group.quantity }} unités
</option>
{% endfor %}
</select>
{% if not target_groups %}
<small class="text-danger">Aucun groupe du même type ({{ equipment.parent.name }}) disponible dans d'autres salles.</small>
{% endif %}
</div>
<input type="hidden" name="room_id" id="room_id" value="">
{% else %}
{# Équipement standard ou groupe #}
<div class="mb-3">
<label for="room_id" class="form-label">Nouvelle salle</label>
<select class="form-select" id="room_id" name="room_id">
<option value="">— Aucune salle —</option>
{% for room in rooms %}
<option value="{{ room.id }}"
{% if current_room and room.id == current_room.id %}selected{% endif %}>
{{ room.building.name }} > {{ room.name }}
</option>
{% endfor %}
</select> </select>
</div> </div>
<div class="mb-3"><label for="reason" class="form-label">Motif</label><input id="reason" name="reason" class="form-control" maxlength="255"></div>
<div class="mb-3"> <button class="btn btn-primary" type="submit" {% if equipment.is_group and max_quantity < 1 %}disabled{% endif %}><i class="bi bi-check-lg"></i> Confirmer le déplacement</button>
<label class="form-label">Salle actuelle</label> <a href="{{ url_for('equipments.detail', id=equipment.id) }}" class="btn btn-outline-secondary">Annuler</a>
<input type="text" class="form-control"
value="{{ current_room.building.name ~ ' > ' ~ current_room.name if current_room else 'Non définie' }}"
disabled>
</div>
{% endif %}
<div class="mt-4">
<button type="submit" class="btn btn-primary" {% if equipment.is_individual and not target_groups %}disabled{% endif %}>
<i class="bi bi-check-lg"></i> Déplacer
</button>
<a href="{{ url_for('equipments.detail', id=equipment.id) }}" class="btn btn-outline-secondary">
Annuler
</a>
</div>
</form> </form>
</div></div>
</div> </div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const groupSelect = document.getElementById('group_id');
const roomInput = document.getElementById('room_id');
if (groupSelect && roomInput) {
groupSelect.addEventListener('change', function() {
const selected = this.options[this.selectedIndex];
const roomId = selected.dataset.roomId || '';
roomInput.value = roomId;
});
}
});
</script>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,89 @@
"""Ajoute sites, données techniques et mouvements quantitatifs.
Revision ID: c4d8e9f0a1b2
Revises: b3c7d8e9f0a1
Create Date: 2026-08-14
"""
from alembic import op
import sqlalchemy as sa
revision = "c4d8e9f0a1b2"
down_revision = "b3c7d8e9f0a1"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"sites",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("site_type", sa.String(length=40), nullable=False),
sa.Column("address", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("name"),
)
op.add_column("buildings", sa.Column("site_id", sa.Integer(), nullable=True))
op.create_foreign_key("fk_buildings_site", "buildings", "sites", ["site_id"], ["id"])
op.create_index("ix_buildings_site_id", "buildings", ["site_id"])
equipment_columns = (
sa.Column("mobility", sa.String(length=20), nullable=False, server_default="non_precise"),
sa.Column("serial_number", sa.String(length=120), nullable=True),
sa.Column("manufacturer", sa.String(length=120), nullable=True),
sa.Column("model_reference", sa.String(length=120), nullable=True),
sa.Column("supplier_id", sa.Integer(), nullable=True),
sa.Column("supplier_reference", sa.String(length=120), nullable=True),
sa.Column("purchase_price", sa.Numeric(12, 2), nullable=True),
sa.Column("purchase_date", sa.Date(), nullable=True),
sa.Column("lifecycle_status", sa.String(length=30), nullable=False, server_default="en_service"),
sa.Column("unavailable_since", sa.DateTime(), nullable=True),
sa.Column("planned_disposal_date", sa.Date(), nullable=True),
sa.Column("disposal_date", sa.Date(), nullable=True),
sa.Column("disposal_reason", sa.String(length=500), nullable=True),
sa.Column("recurrence_monitoring", sa.Boolean(), nullable=False, server_default=sa.false()),
)
for column in equipment_columns:
op.add_column("equipments", column)
op.create_foreign_key("fk_equipments_supplier", "equipments", "companies", ["supplier_id"], ["id"])
op.create_index("ix_equipments_serial_number", "equipments", ["serial_number"])
op.create_table(
"equipment_quantity_movements",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("root_equipment_id", sa.Integer(), nullable=False),
sa.Column("source_equipment_id", sa.Integer(), nullable=True),
sa.Column("target_equipment_id", sa.Integer(), nullable=True),
sa.Column("source_room_id", sa.Integer(), nullable=True),
sa.Column("target_room_id", sa.Integer(), nullable=False),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column("reason", sa.String(length=255), nullable=True),
sa.Column("moved_by_id", sa.Integer(), nullable=True),
sa.Column("moved_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["root_equipment_id"], ["equipments.id"]),
sa.ForeignKeyConstraint(["source_equipment_id"], ["equipments.id"]),
sa.ForeignKeyConstraint(["target_equipment_id"], ["equipments.id"]),
sa.ForeignKeyConstraint(["source_room_id"], ["rooms.id"]),
sa.ForeignKeyConstraint(["target_room_id"], ["rooms.id"]),
sa.ForeignKeyConstraint(["moved_by_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.CheckConstraint("quantity > 0", name="ck_equipment_quantity_movement_positive"),
)
op.create_index("ix_equipment_quantity_movements_root_equipment_id", "equipment_quantity_movements", ["root_equipment_id"])
def downgrade():
op.drop_table("equipment_quantity_movements")
op.drop_index("ix_equipments_serial_number", table_name="equipments")
op.drop_constraint("fk_equipments_supplier", "equipments", type_="foreignkey")
for name in (
"recurrence_monitoring", "disposal_reason", "disposal_date", "planned_disposal_date",
"unavailable_since", "lifecycle_status", "purchase_date", "purchase_price",
"supplier_reference", "supplier_id", "model_reference", "manufacturer", "serial_number", "mobility",
):
op.drop_column("equipments", name)
op.drop_index("ix_buildings_site_id", table_name="buildings")
op.drop_constraint("fk_buildings_site", "buildings", type_="foreignkey")
op.drop_column("buildings", "site_id")
op.drop_table("sites")

View file

@ -0,0 +1,93 @@
from app_new import db
from app_new.core.models.college import Building, Room, Site
from app_new.core.models.equipment import Equipment, EquipmentQuantityMovement
from app_new.core.models.user import User
from app_new.equipments.main import complete_repair, move_to_room, start_repair
from flask_login import login_user
def _rooms(app):
with app.app_context():
building = Building(name='Bâtiment transfert')
db.session.add(building)
db.session.flush()
source = Room(name='Salle source', building_id=building.id)
target = Room(name='Salle cible', building_id=building.id)
db.session.add_all([source, target])
db.session.commit()
return source.id, target.id
def test_partial_group_transfer_updates_both_rooms(authenticated_client, app):
source_room_id, target_room_id = _rooms(app)
with app.app_context():
root = Equipment(name='Chaises', is_group=True, quantity=30, mobility='mobile')
db.session.add(root)
db.session.flush()
source = Equipment(
name='Chaises', parent_id=root.id, room_id=source_room_id,
is_group=True, quantity=10, mobility='mobile',
)
db.session.add(source)
db.session.commit()
source_id, root_id = source.id, root.id
assert db.session.get(Room, target_room_id) is not None
assert db.session.get(Equipment, source_id) is not None
admin = User.query.filter_by(role='admin').first()
with app.test_request_context(method='POST', data={'room_id': target_room_id, 'quantity': 4, 'reason': 'Réorganisation'}):
login_user(admin)
response = move_to_room.__wrapped__(source_id)
assert response.status_code == 302
with app.app_context():
source = db.session.get(Equipment, source_id)
target = Equipment.query.filter_by(parent_id=root_id, room_id=target_room_id).one()
movement = EquipmentQuantityMovement.query.filter_by(root_equipment_id=root_id).one()
assert source.quantity == 6
assert target.quantity == 4
assert movement.quantity == 4
def test_fixed_equipment_cannot_be_moved(authenticated_client, app):
source_room_id, target_room_id = _rooms(app)
with app.app_context():
equipment = Equipment(name='Luminaire fixe', room_id=source_room_id, mobility='fixe')
db.session.add(equipment)
db.session.commit()
equipment_id = equipment.id
admin = User.query.filter_by(role='admin').first()
with app.test_request_context(method='POST', data={'room_id': target_room_id}):
login_user(admin)
response = move_to_room.__wrapped__(equipment_id)
assert response.status_code == 302
with app.app_context():
assert db.session.get(Equipment, equipment_id).room_id == source_room_id
def test_repair_workflow(authenticated_client, app):
with app.app_context():
equipment = Equipment(name='Photocopieur', mobility='mobile')
db.session.add(equipment)
db.session.commit()
equipment_id = equipment.id
admin = User.query.filter_by(role='admin').first()
with app.test_request_context(method='POST'):
login_user(admin)
assert start_repair.__wrapped__(equipment_id).status_code == 302
assert db.session.get(Equipment, equipment_id).lifecycle_status == 'en_reparation'
with app.test_request_context(method='POST'):
login_user(admin)
assert complete_repair.__wrapped__(equipment_id).status_code == 302
equipment = db.session.get(Equipment, equipment_id)
assert equipment.lifecycle_status == 'en_service'
assert equipment.unavailable_since is None
def test_site_can_represent_staff_housing(authenticated_client, app):
response = authenticated_client.post('/equipments/buildings/sites', data={
'name': 'Logements de fonction', 'site_type': 'logements',
})
assert response.status_code == 302
with app.app_context():
assert Site.query.filter_by(name='Logements de fonction', site_type='logements').one()