diff --git a/IT_asset_management/app/__init__.py b/IT_asset_management/app/__init__.py index 8b6a0ad..d15a431 100644 --- a/IT_asset_management/app/__init__.py +++ b/IT_asset_management/app/__init__.py @@ -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 diff --git a/IT_asset_management/app/models/__init__.py b/IT_asset_management/app/models/__init__.py index 76eb9aa..dd4350f 100644 --- a/IT_asset_management/app/models/__init__.py +++ b/IT_asset_management/app/models/__init__.py @@ -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'] diff --git a/IT_asset_management/app/models/maintenance.py b/IT_asset_management/app/models/maintenance.py new file mode 100644 index 0000000..dcaa2b5 --- /dev/null +++ b/IT_asset_management/app/models/maintenance.py @@ -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'' diff --git a/IT_asset_management/app/routes/__init__.py b/IT_asset_management/app/routes/__init__.py index 4ced1c7..21e9256 100644 --- a/IT_asset_management/app/routes/__init__.py +++ b/IT_asset_management/app/routes/__init__.py @@ -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', ] diff --git a/IT_asset_management/app/routes/maintenance.py b/IT_asset_management/app/routes/maintenance.py new file mode 100644 index 0000000..372edc8 --- /dev/null +++ b/IT_asset_management/app/routes/maintenance.py @@ -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('//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('//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('//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')) diff --git a/IT_asset_management/app/templates/base.html b/IT_asset_management/app/templates/base.html index 34df11f..69b6e39 100644 --- a/IT_asset_management/app/templates/base.html +++ b/IT_asset_management/app/templates/base.html @@ -139,6 +139,10 @@ class="nav-link {% if request.blueprint == 'assets' %}active{% endif %}"> Assets + + Maintenance + {% if current_user.is_editor %} diff --git a/IT_asset_management/app/templates/maintenance/index.html b/IT_asset_management/app/templates/maintenance/index.html new file mode 100644 index 0000000..0adf3e7 --- /dev/null +++ b/IT_asset_management/app/templates/maintenance/index.html @@ -0,0 +1,327 @@ +{% extends 'base.html' %} +{% block title %}Maintenance – IT Asset Management{% endblock %} +{% block breadcrumb %} + + +{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} + + + +
+
+ +
+
+ +
+
+ Clear +
+
+ + +
+
+ + + + + + + + + + + {% if current_user.is_editor %}{% endif %} + + + + {% for r in pagination.items %} + + + + + + + + + {% if current_user.is_editor %} + + {% endif %} + + {% else %} + + + + {% endfor %} + +
AssetStart DateEnd DateStatusInitial DiagnosisAction TakenResult
+ + {{ r.asset.serial_number }} + + {% if r.asset.brand %}
{{ r.asset.brand }} {{ r.asset.model or '' }}{% endif %} +
{{ r.start_date.strftime('%d/%m/%Y') if r.start_date else '—' }}{{ r.end_date.strftime('%d/%m/%Y') if r.end_date else '—' }} + + {% for val, label in statuses %}{% if val == r.status %}{{ label }}{% endif %}{% endfor %} + + + + {{ (r.initial_diagnosis or '—')[:60] }}{% if r.initial_diagnosis and r.initial_diagnosis|length > 60 %}…{% endif %} + + + + {{ (r.action_taken or '—')[:60] }}{% if r.action_taken and r.action_taken|length > 60 %}…{% endif %} + + + + {{ (r.action_result or '—')[:60] }}{% if r.action_result and r.action_result|length > 60 %}…{% endif %} + + +
+ +
+
+ No maintenance records found. +
+
+ + + {% if pagination.pages > 1 %} + + {% endif %} +
+ + +{% if current_user.is_editor %} + +{% endif %} +{% endblock %} + +{% block extra_js %} +{% if current_user.is_editor %} + +{% endif %} +{% endblock %}