IT Assets: add Maintenance page with modal form

New database table: maintenance_records
- Fields: asset, start_date, end_date, status (open/in_progress/closed),
  initial_diagnosis, action_taken, action_result

New routes (/maintenance/):
- List with status + asset filters and pagination
- POST /new — create record (editor+); auto-sets asset status to 'maintenance'
- GET /<id>/edit — opens list page with modal pre-filled
- POST /<id>/update — save edits; auto-clears 'maintenance' status on close
- POST /<id>/delete — remove record

UI:
- Bootstrap modal form shared for add and edit
- Clicking any table row opens the modal pre-filled with that row's data
- Asset field locked in edit mode (cannot change asset)
- Delete button per row (with confirmation)
- Sidebar: 'Maintenance' link added under Hardware section
This commit is contained in:
ske087
2026-07-08 22:56:21 +03:00
parent 95ea6a3a35
commit 55a088eff4
7 changed files with 581 additions and 3 deletions
+3 -1
View File
@@ -26,7 +26,7 @@ def create_app(config_name='default'):
login_manager.init_app(app)
# Import models so Flask-Migrate detects them
from app.models import admin_user, user, asset, assignment, paperwork, audit_log, document_template # noqa: F401
from app.models import admin_user, user, asset, assignment, paperwork, audit_log, document_template, maintenance # noqa: F401
# Register blueprints
from app.routes.auth import bp as auth_bp
@@ -39,6 +39,7 @@ def create_app(config_name='default'):
from app.routes.settings import bp as settings_bp
from app.routes.doc_templates import bp as doc_templates_bp
from app.routes.internal import bp as internal_bp
from app.routes.maintenance import bp as maintenance_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
@@ -50,6 +51,7 @@ def create_app(config_name='default'):
app.register_blueprint(settings_bp)
app.register_blueprint(doc_templates_bp)
app.register_blueprint(internal_bp)
app.register_blueprint(maintenance_bp)
# Inject common template variables
from datetime import datetime, date
+2 -1
View File
@@ -5,5 +5,6 @@ from app.models.assignment import Assignment
from app.models.paperwork import Paperwork
from app.models.audit_log import AuditLog
from app.models.compliance_check import ComplianceCheck
from app.models.maintenance import MaintenanceRecord
__all__ = ['AdminUser', 'User', 'Asset', 'Assignment', 'Paperwork', 'AuditLog', 'ComplianceCheck']
__all__ = ['AdminUser', 'User', 'Asset', 'Assignment', 'Paperwork', 'AuditLog', 'ComplianceCheck', 'MaintenanceRecord']
@@ -0,0 +1,41 @@
from datetime import datetime
from app.extensions import db
MAINTENANCE_STATUSES = [
('open', 'Open / Diagnosed'),
('in_progress', 'In Progress'),
('closed', 'Closed'),
]
class MaintenanceRecord(db.Model):
"""A maintenance or service event performed on a single asset."""
__tablename__ = 'maintenance_records'
id = db.Column(db.Integer, primary_key=True)
asset_id = db.Column(db.Integer, db.ForeignKey('assets.id'), nullable=False)
start_date = db.Column(db.Date, nullable=False)
end_date = db.Column(db.Date, nullable=True)
status = db.Column(db.String(20), nullable=False, default='open')
initial_diagnosis = db.Column(db.Text, nullable=True)
action_taken = db.Column(db.Text, nullable=True)
action_result = db.Column(db.Text, nullable=True)
created_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
asset = db.relationship('Asset', backref=db.backref('maintenance_records', lazy='dynamic'))
created_by = db.relationship('AdminUser', foreign_keys=[created_by_id])
@property
def is_open(self):
return self.status != 'closed'
def __repr__(self):
return f'<MaintenanceRecord asset={self.asset_id} status={self.status}>'
+2 -1
View File
@@ -7,8 +7,9 @@ from app.routes.paperwork import bp as paperwork_bp
from app.routes.audit import bp as audit_bp
from app.routes.settings import bp as settings_bp
from app.routes.internal import bp as internal_bp
from app.routes.maintenance import bp as maintenance_bp
__all__ = [
'auth_bp', 'dashboard_bp', 'users_bp', 'assets_bp',
'assignments_bp', 'paperwork_bp', 'audit_bp', 'settings_bp', 'internal_bp',
'assignments_bp', 'paperwork_bp', 'audit_bp', 'settings_bp', 'internal_bp', 'maintenance_bp',
]
@@ -0,0 +1,202 @@
import json
from datetime import date, datetime
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify)
from flask_login import login_required, current_user
from app.extensions import db
from app.models.maintenance import MaintenanceRecord, MAINTENANCE_STATUSES
from app.models.asset import Asset, ASSET_STATUSES
from app.models.audit_log import AuditLog
from app.utils.decorators import editor_required
bp = Blueprint('maintenance', __name__, url_prefix='/maintenance')
def _parse_date(value):
if not value:
return None
if isinstance(value, date):
return value
try:
return date.fromisoformat(value.strip())
except (ValueError, AttributeError):
return None
def _log(action, record_id, description, old=None, new=None):
entry = AuditLog(
table_name='maintenance_records',
record_id=record_id,
action=action,
old_values=json.dumps(old) if old else None,
new_values=json.dumps(new) if new else None,
performed_by_id=current_user.id,
ip_address=request.remote_addr,
description=description,
)
db.session.add(entry)
# ── List ─────────────────────────────────────────────────────────────────────
@bp.route('/')
@login_required
def index():
status_filter = request.args.get('status', '')
asset_filter = request.args.get('asset_id', type=int)
page = request.args.get('page', 1, type=int)
query = MaintenanceRecord.query.join(MaintenanceRecord.asset)
if status_filter:
query = query.filter(MaintenanceRecord.status == status_filter)
if asset_filter:
query = query.filter(MaintenanceRecord.asset_id == asset_filter)
pagination = query.order_by(MaintenanceRecord.start_date.desc()).paginate(
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
)
# Assets available for the "add record" dropdown
all_assets = Asset.query.order_by(Asset.serial_number).all()
return render_template(
'maintenance/index.html',
pagination=pagination,
status_filter=status_filter,
asset_filter=asset_filter,
statuses=MAINTENANCE_STATUSES,
all_assets=all_assets,
edit_record=None,
)
# ── Create ───────────────────────────────────────────────────────────────────
@bp.route('/new', methods=['POST'])
@login_required
@editor_required
def create():
asset_id = request.form.get('asset_id', type=int)
asset = Asset.query.get(asset_id) if asset_id else None
if not asset:
flash('Please select a valid asset.', 'danger')
return redirect(url_for('maintenance.index'))
start = _parse_date(request.form.get('start_date'))
if not start:
flash('Start date is required.', 'danger')
return redirect(url_for('maintenance.index'))
record = MaintenanceRecord(
asset_id = asset.id,
start_date = start,
end_date = _parse_date(request.form.get('end_date')),
status = request.form.get('status', 'open'),
initial_diagnosis = request.form.get('initial_diagnosis', '').strip() or None,
action_taken = request.form.get('action_taken', '').strip() or None,
action_result = request.form.get('action_result', '').strip() or None,
created_by_id = current_user.id,
)
db.session.add(record)
# Auto-set asset status to 'maintenance' when a new open record is created
if record.status in ('open', 'in_progress') and asset.status != 'maintenance':
asset.status = 'maintenance'
db.session.flush()
_log('create', record.id,
f'Created maintenance record for asset SN={asset.serial_number}',
new={'asset_id': asset.id, 'status': record.status, 'start_date': str(start)})
db.session.commit()
flash(f'Maintenance record added for {asset.serial_number}.', 'success')
return redirect(url_for('maintenance.index'))
# ── Edit (GET — opens modal pre-filled) ─────────────────────────────────────
@bp.route('/<int:record_id>/edit')
@login_required
@editor_required
def edit(record_id):
record = MaintenanceRecord.query.get_or_404(record_id)
all_assets = Asset.query.order_by(Asset.serial_number).all()
status_filter = request.args.get('status', '')
asset_filter = request.args.get('asset_id', type=int)
page = request.args.get('page', 1, type=int)
query = MaintenanceRecord.query.join(MaintenanceRecord.asset)
if status_filter:
query = query.filter(MaintenanceRecord.status == status_filter)
if asset_filter:
query = query.filter(MaintenanceRecord.asset_id == asset_filter)
pagination = query.order_by(MaintenanceRecord.start_date.desc()).paginate(
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
)
return render_template(
'maintenance/index.html',
pagination=pagination,
status_filter=status_filter,
asset_filter=asset_filter,
statuses=MAINTENANCE_STATUSES,
all_assets=all_assets,
edit_record=record,
)
# ── Update ───────────────────────────────────────────────────────────────────
@bp.route('/<int:record_id>/update', methods=['POST'])
@login_required
@editor_required
def update(record_id):
record = MaintenanceRecord.query.get_or_404(record_id)
old = {
'status': record.status,
'start_date': str(record.start_date),
'end_date': str(record.end_date) if record.end_date else None,
'initial_diagnosis': record.initial_diagnosis,
'action_taken': record.action_taken,
'action_result': record.action_result,
}
new_status = request.form.get('status', record.status)
record.start_date = _parse_date(request.form.get('start_date')) or record.start_date
record.end_date = _parse_date(request.form.get('end_date'))
record.status = new_status
record.initial_diagnosis = request.form.get('initial_diagnosis', '').strip() or None
record.action_taken = request.form.get('action_taken', '').strip() or None
record.action_result = request.form.get('action_result', '').strip() or None
# If closed, clear asset's maintenance status (if no other open records)
if new_status == 'closed' and record.asset:
other_open = MaintenanceRecord.query.filter(
MaintenanceRecord.asset_id == record.asset_id,
MaintenanceRecord.status != 'closed',
MaintenanceRecord.id != record.id,
).first()
if not other_open and record.asset.status == 'maintenance':
record.asset.status = 'available'
_log('update', record.id,
f'Updated maintenance record id={record_id}', old=old,
new={'status': record.status, 'end_date': str(record.end_date) if record.end_date else None})
db.session.commit()
flash('Maintenance record updated.', 'success')
return redirect(url_for('maintenance.index'))
# ── Delete ───────────────────────────────────────────────────────────────────
@bp.route('/<int:record_id>/delete', methods=['POST'])
@login_required
@editor_required
def delete(record_id):
record = MaintenanceRecord.query.get_or_404(record_id)
asset_sn = record.asset.serial_number if record.asset else '?'
_log('delete', record.id, f'Deleted maintenance record for asset SN={asset_sn}')
db.session.delete(record)
db.session.commit()
flash('Maintenance record deleted.', 'success')
return redirect(url_for('maintenance.index'))
@@ -139,6 +139,10 @@
class="nav-link {% if request.blueprint == 'assets' %}active{% endif %}">
<i class="bi bi-laptop"></i> Assets
</a>
<a href="{{ url_for('maintenance.index') }}"
class="nav-link {% if request.blueprint == 'maintenance' %}active{% endif %}">
<i class="bi bi-wrench-adjustable"></i> Maintenance
</a>
{% if current_user.is_editor %}
<a href="{{ url_for('assets.create') }}"
class="nav-link {% if request.endpoint == 'assets.create' %}active{% endif %}">
@@ -0,0 +1,327 @@
{% extends 'base.html' %}
{% block title %}Maintenance IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item active">Maintenance</li>
{% endblock %}
{% block extra_head %}
<style>
.status-badge-open { background: #ffc107; color: #000; }
.status-badge-in_progress { background: #0d6efd; color: #fff; }
.status-badge-closed { background: #198754; color: #fff; }
tr.clickable-row { cursor: pointer; }
tr.clickable-row:hover td { background: #f0f5ff; }
</style>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1><i class="bi bi-wrench-adjustable me-2"></i>Maintenance</h1>
{% if current_user.is_editor %}
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#maintenanceModal"
id="btnAddRecord">
<i class="bi bi-plus-circle me-1"></i>Add Record
</button>
{% endif %}
</div>
<!-- Filters -->
<form method="GET" class="row g-2 mb-3">
<div class="col-auto">
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All statuses</option>
{% for val, label in statuses %}
<option value="{{ val }}" {% if status_filter == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<select name="asset_id" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All assets</option>
{% for a in all_assets %}
<option value="{{ a.id }}" {% if asset_filter == a.id %}selected{% endif %}>
{{ a.serial_number }}{% if a.brand %} {{ a.brand }}{% endif %}{% if a.model %} {{ a.model }}{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<a href="{{ url_for('maintenance.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
</form>
<!-- Table -->
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Asset</th>
<th>Start Date</th>
<th>End Date</th>
<th>Status</th>
<th>Initial Diagnosis</th>
<th>Action Taken</th>
<th>Result</th>
{% if current_user.is_editor %}<th></th>{% endif %}
</tr>
</thead>
<tbody>
{% for r in pagination.items %}
<tr class="{% if current_user.is_editor %}clickable-row{% endif %}"
{% if current_user.is_editor %}
data-bs-toggle="modal" data-bs-target="#maintenanceModal"
data-id="{{ r.id }}"
data-asset="{{ r.asset_id }}"
data-start="{{ r.start_date.isoformat() if r.start_date else '' }}"
data-end="{{ r.end_date.isoformat() if r.end_date else '' }}"
data-status="{{ r.status }}"
data-diagnosis="{{ r.initial_diagnosis or '' }}"
data-action="{{ r.action_taken or '' }}"
data-result="{{ r.action_result or '' }}"
{% endif %}>
<td>
<a href="{{ url_for('assets.detail', asset_id=r.asset.id) }}" onclick="event.stopPropagation()">
<strong>{{ r.asset.serial_number }}</strong>
</a>
{% if r.asset.brand %}<br><small class="text-muted">{{ r.asset.brand }} {{ r.asset.model or '' }}</small>{% endif %}
</td>
<td>{{ r.start_date.strftime('%d/%m/%Y') if r.start_date else '—' }}</td>
<td>{{ r.end_date.strftime('%d/%m/%Y') if r.end_date else '—' }}</td>
<td>
<span class="badge status-badge-{{ r.status }}">
{% for val, label in statuses %}{% if val == r.status %}{{ label }}{% endif %}{% endfor %}
</span>
</td>
<td>
<span title="{{ r.initial_diagnosis or '' }}">
{{ (r.initial_diagnosis or '—')[:60] }}{% if r.initial_diagnosis and r.initial_diagnosis|length > 60 %}…{% endif %}
</span>
</td>
<td>
<span title="{{ r.action_taken or '' }}">
{{ (r.action_taken or '—')[:60] }}{% if r.action_taken and r.action_taken|length > 60 %}…{% endif %}
</span>
</td>
<td>
<span title="{{ r.action_result or '' }}">
{{ (r.action_result or '—')[:60] }}{% if r.action_result and r.action_result|length > 60 %}…{% endif %}
</span>
</td>
{% if current_user.is_editor %}
<td onclick="event.stopPropagation()">
<form method="POST" action="{{ url_for('maintenance.delete', record_id=r.id) }}"
onsubmit="return confirm('Delete this maintenance record?')">
<button type="submit" class="btn btn-sm btn-outline-danger py-0 px-2">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
{% endif %}
</tr>
{% else %}
<tr>
<td colspan="{{ 8 if current_user.is_editor else 7 }}" class="text-center text-muted py-4">
No maintenance records found.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
<small class="text-muted">
Showing {{ pagination.first }}{{ pagination.last }} of {{ pagination.total }}
</small>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('maintenance.index', page=pagination.prev_num, status=status_filter, asset_id=asset_filter) }}"></a>
</li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('maintenance.index', page=p, status=status_filter, asset_id=asset_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('maintenance.index', page=pagination.next_num, status=status_filter, asset_id=asset_filter) }}"></a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
<!-- ═══ Maintenance Record Modal (Add / Edit) ═══════════════════════════════ -->
{% if current_user.is_editor %}
<div class="modal fade" id="maintenanceModal" tabindex="-1" aria-labelledby="maintenanceModalLabel">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="maintenanceModalLabel">
<i class="bi bi-wrench-adjustable me-2"></i>
<span id="modalTitleText">Add Maintenance Record</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="maintenanceForm" method="POST" action="{{ url_for('maintenance.create') }}">
<div class="modal-body">
<!-- Asset -->
<div class="mb-3">
<label class="form-label fw-semibold">Asset <span class="text-danger">*</span></label>
<select name="asset_id" id="fieldAsset" class="form-select" required>
<option value="">— select asset —</option>
{% for a in all_assets %}
<option value="{{ a.id }}">
{{ a.serial_number }}{% if a.brand %} {{ a.brand }}{% endif %}{% if a.model %} {{ a.model }}{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="row g-3 mb-3">
<!-- Start date -->
<div class="col-md-4">
<label class="form-label fw-semibold">Start Date <span class="text-danger">*</span></label>
<input type="date" name="start_date" id="fieldStart" class="form-control" required>
</div>
<!-- End date -->
<div class="col-md-4">
<label class="form-label fw-semibold">End Date</label>
<input type="date" name="end_date" id="fieldEnd" class="form-control">
</div>
<!-- Status -->
<div class="col-md-4">
<label class="form-label fw-semibold">Status</label>
<select name="status" id="fieldStatus" class="form-select">
{% for val, label in statuses %}
<option value="{{ val }}">{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Initial diagnosis -->
<div class="mb-3">
<label class="form-label fw-semibold">Initial Diagnosis</label>
<textarea name="initial_diagnosis" id="fieldDiagnosis" class="form-control" rows="3"
placeholder="Describe the fault or reason for maintenance…"></textarea>
</div>
<!-- Action taken -->
<div class="mb-3">
<label class="form-label fw-semibold">Action Taken</label>
<textarea name="action_taken" id="fieldAction" class="form-control" rows="3"
placeholder="Describe the work performed…"></textarea>
</div>
<!-- Finished action result -->
<div class="mb-3">
<label class="form-label fw-semibold">Finished Action Result</label>
<textarea name="action_result" id="fieldResult" class="form-control" rows="3"
placeholder="Outcome, parts replaced, verified status…"></textarea>
</div>
</div><!-- /.modal-body -->
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-floppy me-1"></i><span id="submitBtnText">Save Record</span>
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
{% if current_user.is_editor %}
<script>
const modal = document.getElementById('maintenanceModal');
const form = document.getElementById('maintenanceForm');
const titleText = document.getElementById('modalTitleText');
const submitText = document.getElementById('submitBtnText');
const createUrl = "{{ url_for('maintenance.create') }}";
const updateUrl = (id) => `/maintenance/${id}/update`;
// ── Open for EDIT when a table row is clicked ────────────────────────────────
document.querySelectorAll('tr[data-id]').forEach(row => {
row.addEventListener('click', () => {
const id = row.dataset.id;
titleText.textContent = 'Edit Maintenance Record';
submitText.textContent = 'Update Record';
form.action = updateUrl(id);
document.getElementById('fieldAsset').value = row.dataset.asset;
document.getElementById('fieldStart').value = row.dataset.start;
document.getElementById('fieldEnd').value = row.dataset.end;
document.getElementById('fieldStatus').value = row.dataset.status;
document.getElementById('fieldDiagnosis').value= row.dataset.diagnosis;
document.getElementById('fieldAction').value = row.dataset.action;
document.getElementById('fieldResult').value = row.dataset.result;
// Lock asset field in edit mode (asset cannot change)
document.getElementById('fieldAsset').disabled = true;
});
});
// ── Reset to ADD mode when modal is opened via the + button ─────────────────
document.getElementById('btnAddRecord').addEventListener('click', () => {
titleText.textContent = 'Add Maintenance Record';
submitText.textContent = 'Save Record';
form.action = createUrl;
form.reset();
document.getElementById('fieldAsset').disabled = false;
// Default start date to today
document.getElementById('fieldStart').value = new Date().toISOString().split('T')[0];
});
// ── Re-enable asset select before submit (disabled fields are not posted) ────
form.addEventListener('submit', () => {
document.getElementById('fieldAsset').disabled = false;
});
// ── If page loaded with edit_record (server-side edit link), auto-open modal ─
{% if edit_record %}
const editRow = document.querySelector('tr[data-id="{{ edit_record.id }}"]');
if (editRow) editRow.click();
else {
// Fallback: populate manually
titleText.textContent = 'Edit Maintenance Record';
submitText.textContent = 'Update Record';
form.action = updateUrl({{ edit_record.id }});
document.getElementById('fieldAsset').value = '{{ edit_record.asset_id }}';
document.getElementById('fieldStart').value = '{{ edit_record.start_date.isoformat() if edit_record.start_date else "" }}';
document.getElementById('fieldEnd').value = '{{ edit_record.end_date.isoformat() if edit_record.end_date else "" }}';
document.getElementById('fieldStatus').value = '{{ edit_record.status }}';
document.getElementById('fieldDiagnosis').value= {{ edit_record.initial_diagnosis | tojson }};
document.getElementById('fieldAction').value = {{ edit_record.action_taken | tojson }};
document.getElementById('fieldResult').value = {{ edit_record.action_result | tojson }};
document.getElementById('fieldAsset').disabled = true;
new bootstrap.Modal(document.getElementById('maintenanceModal')).show();
}
{% endif %}
</script>
{% endif %}
{% endblock %}