diff --git a/app/__init__.py b/app/__init__.py index def312e..417a3ed 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,6 +6,12 @@ from flask_mail import Mail from config import config import os +import app +from app.routes import auth, dashboard, inspections, templates, reports, facilities + +# Add to blueprint registration +app.register_blueprint(facilities.bp) + # Initialize extensions db = SQLAlchemy() login_manager = LoginManager() diff --git a/app/routes/auth.py b/app/routes/auth.py index 82c69d3..a6b9d76 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,30 +1,101 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request -from flask_login import login_user, logout_user, login_required +from flask_login import login_user, logout_user, login_required, current_user from app import db from app.models.user import User +from app.utils.forms import LoginForm, UserForm +from app.utils.decorators import admin_required bp = Blueprint('auth', __name__, url_prefix='/auth') @bp.route('/login', methods=['GET', 'POST']) def login(): - if request.method == 'POST': - username = request.form.get('username') - password = request.form.get('password') - - user = User.query.filter_by(username=username).first() - - if user and user.check_password(password): + if current_user.is_authenticated: + return redirect(url_for('dashboard.index')) + + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(username=form.username.data).first() + + if user and user.check_password(form.password.data): login_user(user) next_page = request.args.get('next') + flash(f'Welcome back, {user.username}!', 'success') return redirect(next_page or url_for('dashboard.index')) else: - flash('Invalid username or password', 'danger') - - return render_template('login.html') + flash('Invalid credentials. Please try again.', 'danger') + + return render_template('auth/login.html', form=form) @bp.route('/logout') @login_required def logout(): logout_user() - flash('You have been logged out successfully', 'success') + flash('Successfully logged out.', 'success') return redirect(url_for('auth.login')) + +@bp.route('/users') +@login_required +@admin_required +def list_users(): + users = User.query.order_by(User.created_at.desc()).all() + return render_template('auth/users.html', users=users) + +@bp.route('/users/new', methods=['GET', 'POST']) +@login_required +@admin_required +def create_user(): + form = UserForm() + + if form.validate_on_submit(): + user = User( + username=form.username.data, + email=form.email.data, + role=form.role.data + ) + user.set_password(form.password.data) + + db.session.add(user) + db.session.commit() + + flash(f'User {user.username} created successfully.', 'success') + return redirect(url_for('auth.list_users')) + + return render_template('auth/user_form.html', form=form, title='Create User') + +@bp.route('/users//edit', methods=['GET', 'POST']) +@login_required +@admin_required +def edit_user(user_id): + user = User.query.get_or_404(user_id) + form = UserForm(user=user, obj=user) + + if form.validate_on_submit(): + user.username = form.username.data + user.email = form.email.data + user.role = form.role.data + + if form.password.data: + user.set_password(form.password.data) + + db.session.commit() + flash(f'User {user.username} updated successfully.', 'success') + return redirect(url_for('auth.list_users')) + + return render_template('auth/user_form.html', form=form, user=user, title='Edit User') + +@bp.route('/users//delete', methods=['POST']) +@login_required +@admin_required +def delete_user(user_id): + user = User.query.get_or_404(user_id) + + if user.id == current_user.id: + flash('Cannot delete your own account.', 'danger') + return redirect(url_for('auth.list_users')) + + username = user.username + db.session.delete(user) + db.session.commit() + + flash(f'User {username} deleted successfully.', 'success') + return redirect(url_for('auth.list_users')) \ No newline at end of file diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 8191acb..81d76e2 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -1,5 +1,11 @@ from flask import Blueprint, render_template from flask_login import login_required, current_user +from app.models.inspection import Inspection, InspectionTemplate +from app.models.facility import Facility +from app.models.issue import Issue +from app.models.user import User +from sqlalchemy import func +from datetime import datetime, timedelta bp = Blueprint('dashboard', __name__) @@ -7,4 +13,80 @@ bp = Blueprint('dashboard', __name__) @bp.route('/dashboard') @login_required def index(): - return render_template('dashboard.html', user=current_user) + # Get today's date range + today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + today_end = today_start + timedelta(days=1) + + # Statistics for today + if current_user.role == 'inspector': + today_inspections = Inspection.query.filter( + Inspection.inspector_id == current_user.id, + Inspection.inspection_date >= today_start, + Inspection.inspection_date < today_end + ).count() + + completed_today = Inspection.query.filter( + Inspection.inspector_id == current_user.id, + Inspection.status == 'completed', + Inspection.inspection_date >= today_start, + Inspection.inspection_date < today_end + ).count() + + open_issues = Issue.query.join(Inspection).filter( + Inspection.inspector_id == current_user.id, + Issue.status.in_(['open', 'in_progress']) + ).count() + + else: + today_inspections = Inspection.query.filter( + Inspection.inspection_date >= today_start, + Inspection.inspection_date < today_end + ).count() + + completed_today = Inspection.query.filter( + Inspection.status == 'completed', + Inspection.inspection_date >= today_start, + Inspection.inspection_date < today_end + ).count() + + open_issues = Issue.query.filter( + Issue.status.in_(['open', 'in_progress']) + ).count() + + # Calculate average score (last 30 days) + thirty_days_ago = datetime.utcnow() - timedelta(days=30) + avg_score_query = Inspection.query.filter( + Inspection.status == 'completed', + Inspection.overall_score.isnot(None), + Inspection.inspection_date >= thirty_days_ago + ) + + if current_user.role == 'inspector': + avg_score_query = avg_score_query.filter(Inspection.inspector_id == current_user.id) + + avg_score = db.session.query(func.avg(Inspection.overall_score)).filter( + Inspection.id.in_([i.id for i in avg_score_query.all()]) + ).scalar() + + # Recent inspections + recent_inspections_query = Inspection.query.order_by(Inspection.inspection_date.desc()).limit(5) + if current_user.role == 'inspector': + recent_inspections_query = recent_inspections_query.filter(Inspection.inspector_id == current_user.id) + + recent_inspections = recent_inspections_query.all() + + # System statistics (admin/supervisor only) + total_facilities = Facility.query.filter_by(active=True).count() if current_user.role in ['admin', 'supervisor'] else 0 + total_templates = InspectionTemplate.query.count() if current_user.role in ['admin', 'supervisor'] else 0 + total_users = User.query.count() if current_user.role == 'admin' else 0 + + return render_template('dashboard.html', + today_inspections=today_inspections, + completed_today=completed_today, + open_issues=open_issues, + avg_score=round(avg_score, 2) if avg_score else None, + recent_inspections=recent_inspections, + total_facilities=total_facilities, + total_templates=total_templates, + total_users=total_users + ) \ No newline at end of file diff --git a/app/routes/facilities.py b/app/routes/facilities.py new file mode 100644 index 0000000..80e67b9 --- /dev/null +++ b/app/routes/facilities.py @@ -0,0 +1,149 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask_login import login_required +from app import db +from app.models.facility import Facility, Area +from app.utils.forms import FacilityForm, AreaForm +from app.utils.decorators import supervisor_required + +bp = Blueprint('facilities', __name__, url_prefix='/facilities') + +@bp.route('/') +@login_required +def list_facilities(): + facilities = Facility.query.order_by(Facility.name).all() + return render_template('facilities/list.html', facilities=facilities) + +@bp.route('/new', methods=['GET', 'POST']) +@login_required +@supervisor_required +def create_facility(): + form = FacilityForm() + + if form.validate_on_submit(): + facility = Facility( + name=form.name.data, + address=form.address.data, + contact_person=form.contact_person.data, + contact_phone=form.contact_phone.data, + active=form.active.data + ) + + db.session.add(facility) + db.session.commit() + + flash(f'Facility "{facility.name}" created successfully.', 'success') + return redirect(url_for('facilities.view_facility', facility_id=facility.id)) + + return render_template('facilities/form.html', form=form, title='Create Facility') + +@bp.route('/') +@login_required +def view_facility(facility_id): + facility = Facility.query.get_or_404(facility_id) + areas = facility.areas.order_by(Area.name).all() + return render_template('facilities/view.html', facility=facility, areas=areas) + +@bp.route('//edit', methods=['GET', 'POST']) +@login_required +@supervisor_required +def edit_facility(facility_id): + facility = Facility.query.get_or_404(facility_id) + form = FacilityForm(obj=facility) + + if form.validate_on_submit(): + facility.name = form.name.data + facility.address = form.address.data + facility.contact_person = form.contact_person.data + facility.contact_phone = form.contact_phone.data + facility.active = form.active.data + + db.session.commit() + flash(f'Facility "{facility.name}" updated successfully.', 'success') + return redirect(url_for('facilities.view_facility', facility_id=facility.id)) + + return render_template('facilities/form.html', form=form, facility=facility, title='Edit Facility') + +@bp.route('//delete', methods=['POST']) +@login_required +@supervisor_required +def delete_facility(facility_id): + facility = Facility.query.get_or_404(facility_id) + + # Check if facility has inspections + if facility.inspections.count() > 0: + flash('Cannot delete facility with existing inspections.', 'danger') + return redirect(url_for('facilities.view_facility', facility_id=facility.id)) + + facility_name = facility.name + db.session.delete(facility) + db.session.commit() + + flash(f'Facility "{facility_name}" deleted successfully.', 'success') + return redirect(url_for('facilities.list_facilities')) + +# Area Management Routes + +@bp.route('//areas/new', methods=['GET', 'POST']) +@login_required +@supervisor_required +def create_area(facility_id): + facility = Facility.query.get_or_404(facility_id) + form = AreaForm() + form.facility_id.choices = [(facility.id, facility.name)] + form.facility_id.data = facility.id + + if form.validate_on_submit(): + area = Area( + name=form.name.data, + area_type=form.area_type.data, + facility_id=facility.id + ) + + db.session.add(area) + db.session.commit() + + flash(f'Area "{area.name}" created successfully.', 'success') + return redirect(url_for('facilities.view_facility', facility_id=facility.id)) + + return render_template('facilities/area_form.html', form=form, facility=facility, title='Create Area') + +@bp.route('/areas//edit', methods=['GET', 'POST']) +@login_required +@supervisor_required +def edit_area(area_id): + area = Area.query.get_or_404(area_id) + form = AreaForm(obj=area) + + # Populate facility choices + facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() + form.facility_id.choices = [(f.id, f.name) for f in facilities] + + if form.validate_on_submit(): + area.name = form.name.data + area.area_type = form.area_type.data + area.facility_id = form.facility_id.data + + db.session.commit() + flash(f'Area "{area.name}" updated successfully.', 'success') + return redirect(url_for('facilities.view_facility', facility_id=area.facility_id)) + + return render_template('facilities/area_form.html', form=form, area=area, facility=area.facility, title='Edit Area') + +@bp.route('/areas//delete', methods=['POST']) +@login_required +@supervisor_required +def delete_area(area_id): + area = Area.query.get_or_404(area_id) + facility_id = area.facility_id + + # Check if area has inspections + if area.inspections.count() > 0: + flash('Cannot delete area with existing inspections.', 'danger') + return redirect(url_for('facilities.view_facility', facility_id=facility_id)) + + area_name = area.name + db.session.delete(area) + db.session.commit() + + flash(f'Area "{area_name}" deleted successfully.', 'success') + return redirect(url_for('facilities.view_facility', facility_id=facility_id)) \ No newline at end of file diff --git a/app/routes/templates.py b/app/routes/templates.py index 684136b..af8197b 100644 --- a/app/routes/templates.py +++ b/app/routes/templates.py @@ -1,7 +1,170 @@ -from flask import Blueprint +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify +from flask_login import login_required, current_user +from app import db +from app.models.inspection import InspectionTemplate, ChecklistItem +from app.utils.forms import InspectionTemplateForm, ChecklistItemForm +from app.utils.decorators import supervisor_required bp = Blueprint('templates', __name__, url_prefix='/templates') @bp.route('/') +@login_required def index(): - return "Templates module - Coming in Phase 2" + templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all() + return render_template('templates/list.html', templates=templates) + +@bp.route('/new', methods=['GET', 'POST']) +@login_required +@supervisor_required +def create_template(): + form = InspectionTemplateForm() + + if form.validate_on_submit(): + template = InspectionTemplate( + name=form.name.data, + description=form.description.data, + frequency=form.frequency.data, + created_by=current_user.id + ) + + db.session.add(template) + db.session.commit() + + flash(f'Template "{template.name}" created successfully.', 'success') + return redirect(url_for('templates.edit_template', template_id=template.id)) + + return render_template('templates/form.html', form=form, title='Create Inspection Template') + +@bp.route('/') +@login_required +def view_template(template_id): + template = InspectionTemplate.query.get_or_404(template_id) + checklist_items = template.checklist_items.order_by(ChecklistItem.display_order, ChecklistItem.category).all() + + # Group items by category + items_by_category = {} + for item in checklist_items: + category = item.category or 'General' + if category not in items_by_category: + items_by_category[category] = [] + items_by_category[category].append(item) + + return render_template('templates/view.html', template=template, items_by_category=items_by_category) + +@bp.route('//edit', methods=['GET', 'POST']) +@login_required +@supervisor_required +def edit_template(template_id): + template = InspectionTemplate.query.get_or_404(template_id) + form = InspectionTemplateForm(obj=template) + + if form.validate_on_submit(): + template.name = form.name.data + template.description = form.description.data + template.frequency = form.frequency.data + + db.session.commit() + flash(f'Template "{template.name}" updated successfully.', 'success') + return redirect(url_for('templates.view_template', template_id=template.id)) + + checklist_items = template.checklist_items.order_by(ChecklistItem.display_order, ChecklistItem.category).all() + + return render_template('templates/edit.html', form=form, template=template, checklist_items=checklist_items) + +@bp.route('//delete', methods=['POST']) +@login_required +@supervisor_required +def delete_template(template_id): + template = InspectionTemplate.query.get_or_404(template_id) + + # Check if template has inspections + if template.inspections.count() > 0: + flash('Cannot delete template with existing inspections.', 'danger') + return redirect(url_for('templates.view_template', template_id=template.id)) + + template_name = template.name + db.session.delete(template) + db.session.commit() + + flash(f'Template "{template_name}" deleted successfully.', 'success') + return redirect(url_for('templates.index')) + +# Checklist Item Management + +@bp.route('//items/new', methods=['GET', 'POST']) +@login_required +@supervisor_required +def create_checklist_item(template_id): + template = InspectionTemplate.query.get_or_404(template_id) + form = ChecklistItemForm() + + if form.validate_on_submit(): + # Get the highest display order + max_order = db.session.query(db.func.max(ChecklistItem.display_order))\ + .filter_by(template_id=template.id).scalar() or 0 + + item = ChecklistItem( + template_id=template.id, + category=form.category.data, + item_description=form.item_description.data, + scoring_type=form.scoring_type.data, + weight=form.weight.data, + requires_photo=form.requires_photo.data, + display_order=max_order + 1 + ) + + db.session.add(item) + db.session.commit() + + flash('Checklist item added successfully.', 'success') + return redirect(url_for('templates.edit_template', template_id=template.id)) + + return render_template('templates/item_form.html', form=form, template=template, title='Add Checklist Item') + +@bp.route('/items//edit', methods=['GET', 'POST']) +@login_required +@supervisor_required +def edit_checklist_item(item_id): + item = ChecklistItem.query.get_or_404(item_id) + form = ChecklistItemForm(obj=item) + + if form.validate_on_submit(): + item.category = form.category.data + item.item_description = form.item_description.data + item.scoring_type = form.scoring_type.data + item.weight = form.weight.data + item.requires_photo = form.requires_photo.data + + db.session.commit() + flash('Checklist item updated successfully.', 'success') + return redirect(url_for('templates.edit_template', template_id=item.template_id)) + + return render_template('templates/item_form.html', form=form, item=item, template=item.template, title='Edit Checklist Item') + +@bp.route('/items//delete', methods=['POST']) +@login_required +@supervisor_required +def delete_checklist_item(item_id): + item = ChecklistItem.query.get_or_404(item_id) + template_id = item.template_id + + db.session.delete(item) + db.session.commit() + + flash('Checklist item deleted successfully.', 'success') + return redirect(url_for('templates.edit_template', template_id=template_id)) + +@bp.route('//items/reorder', methods=['POST']) +@login_required +@supervisor_required +def reorder_items(template_id): + template = InspectionTemplate.query.get_or_404(template_id) + item_order = request.json.get('item_order', []) + + for index, item_id in enumerate(item_order): + item = ChecklistItem.query.get(item_id) + if item and item.template_id == template.id: + item.display_order = index + + db.session.commit() + return jsonify({'success': True}) \ No newline at end of file diff --git a/app/templates/auth/user_form.html b/app/templates/auth/user_form.html new file mode 100644 index 0000000..ae342f3 --- /dev/null +++ b/app/templates/auth/user_form.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+

{{ title }}

+
+
+
+ {{ form.hidden_tag() }} + +
+
+ {{ form.username.label(class="form-label") }} + {{ form.username(class="form-control") }} + {% if form.username.errors %} +
+ {% for error in form.username.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control") }} + {% if form.email.errors %} +
+ {% for error in form.email.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+
+ +
+
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }} + {% if form.password.errors %} +
+ {% for error in form.password.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+ +
+ {{ form.confirm_password.label(class="form-label") }} + {{ form.confirm_password(class="form-control") }} + {% if form.confirm_password.errors %} +
+ {% for error in form.confirm_password.errors %}{{ error }}{% endfor %} +
+ {% endif %} +
+
+ +
+ {{ form.role.label(class="form-label") }} + {{ form.role(class="form-select") }} +
+ +
+ + + Cancel + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/users.html b/app/templates/auth/users.html new file mode 100644 index 0000000..7eb9644 --- /dev/null +++ b/app/templates/auth/users.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} + +{% block title %}User Management{% endblock %} + +{% block content %} +
+
+

User Management

+
+ +
+ +
+
+
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% endfor %} + +
UsernameEmailRoleCreatedActions
{{ user.username }}{{ user.email }} + + {{ user.role|title }} + + {{ user.created_at.strftime('%Y-%m-%d') }} + + + + {% if user.id != current_user.id %} +
+ +
+ {% endif %} +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index cbe6720..78dda0c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -24,14 +24,22 @@ Dashboard + + {% if current_user.role == 'admin' %} + + {% endif %}