Track lot presence and missing equipment
This commit is contained in:
parent
89c7845f5b
commit
86bed6ff56
7 changed files with 265 additions and 16 deletions
|
|
@ -339,10 +339,27 @@ class Lot(db.Model):
|
|||
name = db.Column(db.String(500), nullable=False)
|
||||
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)
|
||||
is_present = db.Column(db.Boolean, nullable=False, default=True)
|
||||
absence_reason = db.Column(db.String(500), nullable=True)
|
||||
|
||||
# Relations
|
||||
category = db.relationship("EquipmentCategory", foreign_keys=[category_id])
|
||||
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):
|
||||
return f"<Lot {self.name}>"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from flask_login import login_required
|
|||
from app_new.extensions import db
|
||||
from ..core.models.maintenance import Lot, LotTask
|
||||
from ..core.models.company import Company
|
||||
from ..core.models.equipment import Equipment
|
||||
|
||||
lots_bp = Blueprint('lots', __name__)
|
||||
|
||||
|
|
@ -14,27 +15,78 @@ lots_bp = Blueprint('lots', __name__)
|
|||
@login_required
|
||||
def index():
|
||||
"""Liste des lots."""
|
||||
lots = Lot.query.order_by(Lot.name).all()
|
||||
return render_template('lots/index.html', lots=lots)
|
||||
page = request.args.get('page', 1, type=int)
|
||||
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>')
|
||||
@login_required
|
||||
def detail(id):
|
||||
"""Détail d'un lot."""
|
||||
from app_new.core.models.equipment import Equipment
|
||||
lot = Lot.query.get_or_404(id)
|
||||
# Récupérer les équipements de ce lot
|
||||
equipments = Equipment.query.filter_by(lot_id=id).order_by(Equipment.name).all()
|
||||
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
|
||||
def edit(id):
|
||||
"""Modifier un lot (formulaire)."""
|
||||
lot = Lot.query.get_or_404(id)
|
||||
# TODO: formulaire d'édition
|
||||
flash("Fonctionnalité en cours de développement", "info")
|
||||
return redirect(url_for('lots.detail', id=id))
|
||||
if request.method == 'POST':
|
||||
lot.company_id = request.form.get('company_id', type=int)
|
||||
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'])
|
||||
@login_required
|
||||
|
|
@ -43,4 +95,4 @@ def delete(id):
|
|||
lot = Lot.query.get_or_404(id)
|
||||
# TODO: suppression avec déplacement vers corbeille
|
||||
flash("Fonctionnalité en cours de développement", "info")
|
||||
return redirect(url_for('lots.index'))
|
||||
return redirect(url_for('lots.index'))
|
||||
|
|
|
|||
|
|
@ -30,6 +30,18 @@
|
|||
|
||||
<div class="row">
|
||||
<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 n’est 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-header">
|
||||
<h5 class="mb-0">Informations</h5>
|
||||
|
|
@ -38,6 +50,12 @@
|
|||
<dl class="row">
|
||||
<dt class="col-sm-4">Nom</dt>
|
||||
<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 %}
|
||||
<dt class="col-sm-4">Description</dt>
|
||||
|
|
@ -66,6 +84,28 @@
|
|||
</dl>
|
||||
</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 #}
|
||||
{% if equipments %}
|
||||
|
|
@ -176,4 +216,4 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@
|
|||
<label class="form-label">Nom du Lot</label>
|
||||
<input type="text" class="form-control" value="{{ lot.name }}" readonly>
|
||||
</div>
|
||||
|
||||
{% if not lot.is_present %}
|
||||
<div class="mb-3">
|
||||
<label for="absence_reason" class="form-label">Raison de l’absence</label>
|
||||
<input type="text" name="absence_reason" id="absence_reason" class="form-control"
|
||||
value="{{ lot.absence_reason or '' }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="company_id" class="form-label">Entreprise prestataire</label>
|
||||
|
|
@ -40,4 +48,4 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@
|
|||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<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', '') }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<select name="company_id" class="form-select">
|
||||
<option value="">Toutes les entreprises</option>
|
||||
{% for company in companies %}
|
||||
|
|
@ -26,6 +26,13 @@
|
|||
{% endfor %}
|
||||
</select>
|
||||
</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">
|
||||
<button type="submit" class="btn btn-primary w-100">Filtrer</button>
|
||||
</div>
|
||||
|
|
@ -43,16 +50,34 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Lot</th>
|
||||
<th>Catégories</th>
|
||||
<th>Présence</th>
|
||||
<th>Équipements</th>
|
||||
<th>Tâches</th>
|
||||
<th>Entreprise</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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><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>
|
||||
{% if lot.company %}
|
||||
<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">
|
||||
<i class="bi bi-pencil"></i> Modifier
|
||||
</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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
|
@ -74,6 +112,19 @@
|
|||
</table>
|
||||
</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>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
29
migrations/versions/c8d2e3f4a5b6_add_lot_presence.py
Normal file
29
migrations/versions/c8d2e3f4a5b6_add_lot_presence.py
Normal 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")
|
||||
52
tests/integration/test_lot_presence.py
Normal file
52
tests/integration/test_lot_presence.py
Normal 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
|
||||
Loading…
Reference in a new issue