Track lot presence and missing equipment

This commit is contained in:
root 2026-08-14 17:21:03 +00:00
parent 89c7845f5b
commit 86bed6ff56
7 changed files with 265 additions and 16 deletions

View file

@ -339,11 +339,28 @@ class Lot(db.Model):
name = db.Column(db.String(500), nullable=False) name = db.Column(db.String(500), nullable=False)
company_id = db.Column(db.Integer, db.ForeignKey("companies.id"), nullable=True) company_id = db.Column(db.Integer, db.ForeignKey("companies.id"), nullable=True)
category_id = db.Column(db.Integer, db.ForeignKey("equipment_categories.id"), nullable=True) category_id = db.Column(db.Integer, db.ForeignKey("equipment_categories.id"), nullable=True)
is_present = db.Column(db.Boolean, nullable=False, default=True)
absence_reason = db.Column(db.String(500), nullable=True)
# Relations # Relations
category = db.relationship("EquipmentCategory", foreign_keys=[category_id]) category = db.relationship("EquipmentCategory", foreign_keys=[category_id])
tasks = db.relationship("LotTask", backref="lot") tasks = db.relationship("LotTask", backref="lot")
@property
def active_equipment_count(self):
"""Equipements relies directement au lot et encore dans le patrimoine."""
from .equipment import Equipment
return Equipment.query.filter(
Equipment.lot_id == self.id,
Equipment.is_deleted.is_(False),
db.or_(Equipment.status.is_(None), Equipment.status != 'jete'),
).count()
@property
def needs_equipment_attention(self):
"""Un lot present sans equipement doit etre signale."""
return self.is_present and self.active_equipment_count == 0
def __repr__(self): def __repr__(self):
return f"<Lot {self.name}>" return f"<Lot {self.name}>"

View file

@ -7,6 +7,7 @@ from flask_login import login_required
from app_new.extensions import db from app_new.extensions import db
from ..core.models.maintenance import Lot, LotTask from ..core.models.maintenance import Lot, LotTask
from ..core.models.company import Company from ..core.models.company import Company
from ..core.models.equipment import Equipment
lots_bp = Blueprint('lots', __name__) lots_bp = Blueprint('lots', __name__)
@ -14,27 +15,78 @@ lots_bp = Blueprint('lots', __name__)
@login_required @login_required
def index(): def index():
"""Liste des lots.""" """Liste des lots."""
lots = Lot.query.order_by(Lot.name).all() page = request.args.get('page', 1, type=int)
return render_template('lots/index.html', lots=lots) search = (request.args.get('search') or '').strip()
company_id = request.args.get('company_id', type=int)
presence = request.args.get('presence', '')
query = Lot.query
if search:
query = query.filter(Lot.name.ilike(f'%{search}%'))
if company_id:
query = query.filter(Lot.company_id == company_id)
if presence == 'present':
query = query.filter(Lot.is_present.is_(True))
elif presence == 'absent':
query = query.filter(Lot.is_present.is_(False))
lots = query.order_by(Lot.name).paginate(page=page, per_page=50, error_out=False)
companies = Company.query.order_by(Company.name).all()
return render_template('lots/index.html', lots=lots, companies=companies)
@lots_bp.route('/<int:id>') @lots_bp.route('/<int:id>')
@login_required @login_required
def detail(id): def detail(id):
"""Détail d'un lot.""" """Détail d'un lot."""
from app_new.core.models.equipment import Equipment
lot = Lot.query.get_or_404(id) lot = Lot.query.get_or_404(id)
# Récupérer les équipements de ce lot # Récupérer les équipements de ce lot
equipments = Equipment.query.filter_by(lot_id=id).order_by(Equipment.name).all() equipments = Equipment.query.filter_by(lot_id=id).order_by(Equipment.name).all()
return render_template('lots/detail.html', lot=lot, equipments=equipments) return render_template('lots/detail.html', lot=lot, equipments=equipments)
@lots_bp.route('/<int:id>/edit') @lots_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit(id): def edit(id):
"""Modifier un lot (formulaire).""" """Modifier un lot (formulaire)."""
lot = Lot.query.get_or_404(id) lot = Lot.query.get_or_404(id)
# TODO: formulaire d'édition if request.method == 'POST':
flash("Fonctionnalité en cours de développement", "info") lot.company_id = request.form.get('company_id', type=int)
return redirect(url_for('lots.detail', id=id)) lot.absence_reason = (request.form.get('absence_reason') or '').strip() or None
db.session.commit()
flash('Lot modifié.', 'success')
return redirect(url_for('lots.detail', id=id))
companies = Company.query.order_by(Company.name).all()
return render_template('lots/edit.html', lot=lot, companies=companies)
@lots_bp.route('/<int:id>/presence', methods=['POST'])
@login_required
def set_presence(id):
"""Marquer un lot comme présent ou non présent dans l'établissement."""
lot = Lot.query.get_or_404(id)
action = request.form.get('action')
if action == 'absent':
if lot.active_equipment_count:
flash(
f"Impossible de marquer ce lot non présent : "
f"{lot.active_equipment_count} équipement(s) y sont encore rattachés.",
'danger',
)
else:
lot.is_present = False
lot.absence_reason = (request.form.get('absence_reason') or '').strip() or None
db.session.commit()
flash(f"Le lot « {lot.name} » est marqué non présent.", 'success')
elif action == 'present':
lot.is_present = True
lot.absence_reason = None
db.session.commit()
flash(f"Le lot « {lot.name} » est marqué présent.", 'success')
else:
flash('Action de présence invalide.', 'danger')
return redirect(request.referrer or url_for('lots.detail', id=id))
@lots_bp.route('/<int:id>/delete', methods=['POST']) @lots_bp.route('/<int:id>/delete', methods=['POST'])
@login_required @login_required

View file

@ -30,6 +30,18 @@
<div class="row"> <div class="row">
<div class="col-lg-8"> <div class="col-lg-8">
{% if lot.needs_equipment_attention %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Lot présent sans équipement.</strong> Rattachez au moins un équipement ou marquez ce lot comme non présent.
</div>
{% elif not lot.is_present %}
<div class="alert alert-secondary">
<i class="bi bi-dash-circle me-1"></i>
<strong>Ce lot nest pas présent dans létablissement.</strong>
{% if lot.absence_reason %}<div class="mt-1">{{ lot.absence_reason }}</div>{% endif %}
</div>
{% endif %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Informations</h5> <h5 class="mb-0">Informations</h5>
@ -39,6 +51,12 @@
<dt class="col-sm-4">Nom</dt> <dt class="col-sm-4">Nom</dt>
<dd class="col-sm-8">{{ lot.name }}</dd> <dd class="col-sm-8">{{ lot.name }}</dd>
<dt class="col-sm-4">Présence</dt>
<dd class="col-sm-8">
{% if lot.is_present %}<span class="badge bg-success">Présent</span>
{% else %}<span class="badge bg-secondary">Non présent</span>{% endif %}
</dd>
{% if lot.description %} {% if lot.description %}
<dt class="col-sm-4">Description</dt> <dt class="col-sm-4">Description</dt>
<dd class="col-sm-8">{{ lot.description }}</dd> <dd class="col-sm-8">{{ lot.description }}</dd>
@ -67,6 +85,28 @@
</div> </div>
</div> </div>
{% if not lot.active_equipment_count %}
<div class="card mb-4">
<div class="card-header">Statut du lot</div>
<div class="card-body">
<form method="POST" action="{{ url_for('lots.set_presence', id=lot.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if lot.is_present %}
<input type="hidden" name="action" value="absent">
<div class="mb-3">
<label class="form-label" for="absence_reason">Raison facultative</label>
<input class="form-control" id="absence_reason" name="absence_reason" placeholder="Ex. aucun ascenseur dans létablissement">
</div>
<button class="btn btn-outline-secondary">Marquer le lot comme non présent</button>
{% else %}
<input type="hidden" name="action" value="present">
<button class="btn btn-outline-success">Marquer le lot comme présent</button>
{% endif %}
</form>
</div>
</div>
{% endif %}
{# Équipements du lot #} {# Équipements du lot #}
{% if equipments %} {% if equipments %}
<div class="card mb-4"> <div class="card mb-4">

View file

@ -20,6 +20,14 @@
<input type="text" class="form-control" value="{{ lot.name }}" readonly> <input type="text" class="form-control" value="{{ lot.name }}" readonly>
</div> </div>
{% if not lot.is_present %}
<div class="mb-3">
<label for="absence_reason" class="form-label">Raison de labsence</label>
<input type="text" name="absence_reason" id="absence_reason" class="form-control"
value="{{ lot.absence_reason or '' }}">
</div>
{% endif %}
<div class="mb-3"> <div class="mb-3">
<label for="company_id" class="form-label">Entreprise prestataire</label> <label for="company_id" class="form-label">Entreprise prestataire</label>
<select name="company_id" id="company_id" class="form-select"> <select name="company_id" id="company_id" class="form-select">

View file

@ -13,10 +13,10 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-body"> <div class="card-body">
<form method="get" class="row g-3"> <form method="get" class="row g-3">
<div class="col-md-6"> <div class="col-md-4">
<input type="text" name="search" class="form-control" placeholder="Rechercher un lot..." value="{{ request.args.get('search', '') }}"> <input type="text" name="search" class="form-control" placeholder="Rechercher un lot..." value="{{ request.args.get('search', '') }}">
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<select name="company_id" class="form-select"> <select name="company_id" class="form-select">
<option value="">Toutes les entreprises</option> <option value="">Toutes les entreprises</option>
{% for company in companies %} {% for company in companies %}
@ -26,6 +26,13 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-md-3">
<select name="presence" class="form-select">
<option value="">Tous les statuts</option>
<option value="present" {% if request.args.get('presence') == 'present' %}selected{% endif %}>Présents</option>
<option value="absent" {% if request.args.get('presence') == 'absent' %}selected{% endif %}>Non présents</option>
</select>
</div>
<div class="col-md-2"> <div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">Filtrer</button> <button type="submit" class="btn btn-primary w-100">Filtrer</button>
</div> </div>
@ -43,16 +50,34 @@
<thead> <thead>
<tr> <tr>
<th>Lot</th> <th>Lot</th>
<th>Catégories</th> <th>Présence</th>
<th>Équipements</th>
<th>Tâches</th>
<th>Entreprise</th> <th>Entreprise</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for lot in lots %} {% for lot in lots %}
<tr> <tr class="{% if lot.needs_equipment_attention %}table-warning{% elif not lot.is_present %}table-secondary{% endif %}">
<td><strong>{{ lot.name }}</strong></td> <td><strong>{{ lot.name }}</strong></td>
<td><span class="badge bg-info">{{ lot.tasks|length }} tâche(s)</span></td> <td>
{% if lot.is_present %}
<span class="badge bg-success">Présent</span>
{% else %}
<span class="badge bg-secondary">Non présent</span>
{% endif %}
</td>
<td>
{% if lot.active_equipment_count %}
<span class="badge bg-primary">{{ lot.active_equipment_count }} équipement(s)</span>
{% elif lot.is_present %}
<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>Aucun équipement</span>
{% else %}
<span class="text-muted">Sans objet</span>
{% endif %}
</td>
<td><span class="badge bg-info text-dark">{{ lot.tasks|length }} tâche(s)</span></td>
<td> <td>
{% if lot.company %} {% if lot.company %}
<span class="badge bg-success">{{ lot.company.name }}</span> <span class="badge bg-success">{{ lot.company.name }}</span>
@ -67,6 +92,19 @@
<a href="{{ url_for('lots.edit', id=lot.id) }}" class="btn btn-sm btn-outline-warning"> <a href="{{ url_for('lots.edit', id=lot.id) }}" class="btn btn-sm btn-outline-warning">
<i class="bi bi-pencil"></i> Modifier <i class="bi bi-pencil"></i> Modifier
</a> </a>
{% if not lot.is_present %}
<form method="POST" action="{{ url_for('lots.set_presence', id=lot.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="present">
<button class="btn btn-sm btn-outline-success"><i class="bi bi-check-circle"></i> Marquer présent</button>
</form>
{% elif not lot.active_equipment_count %}
<form method="POST" action="{{ url_for('lots.set_presence', id=lot.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="absent">
<button class="btn btn-sm btn-outline-secondary"><i class="bi bi-dash-circle"></i> Non présent</button>
</form>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -74,6 +112,19 @@
</table> </table>
</div> </div>
</div> </div>
{% if lots.pages > 1 %}
<nav class="mt-3" aria-label="Pagination des lots">
<ul class="pagination justify-content-center">
<li class="page-item {% if not lots.has_prev %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('lots.index', page=lots.prev_num, search=request.args.get('search'), company_id=request.args.get('company_id'), presence=request.args.get('presence')) }}">Précédent</a>
</li>
<li class="page-item disabled"><span class="page-link">Page {{ lots.page }} / {{ lots.pages }}</span></li>
<li class="page-item {% if not lots.has_next %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('lots.index', page=lots.next_num, search=request.args.get('search'), company_id=request.args.get('company_id'), presence=request.args.get('presence')) }}">Suivant</a>
</li>
</ul>
</nav>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,29 @@
"""Add explicit lot presence status.
Revision ID: c8d2e3f4a5b6
Revises: b7a1c2d3e4f5
Create Date: 2026-08-14
"""
from alembic import op
import sqlalchemy as sa
revision = "c8d2e3f4a5b6"
down_revision = "b7a1c2d3e4f5"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"lots",
sa.Column("is_present", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column("lots", sa.Column("absence_reason", sa.String(length=500), nullable=True))
op.alter_column("lots", "is_present", server_default=None)
def downgrade():
op.drop_column("lots", "absence_reason")
op.drop_column("lots", "is_present")

View file

@ -0,0 +1,52 @@
from app_new.extensions import db
from app_new.core.models.equipment import Equipment
from app_new.core.models.maintenance import Lot
from app_new.lots.routes import index, set_presence
def test_empty_lot_is_visible_and_can_be_marked_absent(app):
with app.app_context():
lot = Lot(name="Ascenseurs")
db.session.add(lot)
db.session.commit()
lot_id = lot.id
with app.test_request_context('/lots/'):
rendered = index.__wrapped__()
assert 'Aucun équipement' in rendered
with app.test_request_context(
f'/lots/{lot_id}/presence',
method='POST',
data={'action': 'absent', 'absence_reason': 'Aucun ascenseur dans le college'},
):
response = set_presence.__wrapped__(lot_id)
assert response.status_code == 302
lot = db.session.get(Lot, lot_id)
assert lot.is_present is False
assert lot.absence_reason == 'Aucun ascenseur dans le college'
assert lot.needs_equipment_attention is False
def test_lot_with_equipment_cannot_be_marked_absent(app):
with app.app_context():
lot = Lot(name="Electricite")
equipment = Equipment(
name="Luminaires A01",
is_group=True,
quantity=10,
lot=lot,
status='en_service',
)
db.session.add_all([lot, equipment])
db.session.commit()
lot_id = lot.id
with app.test_request_context(
f'/lots/{lot_id}/presence',
method='POST',
data={'action': 'absent'},
):
response = set_presence.__wrapped__(lot_id)
assert response.status_code == 302
assert db.session.get(Lot, lot_id).is_present is True