diff --git a/.gitignore b/.gitignore index b7faf40..07bb4a1 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,6 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +wsgi.py +gunicorn_config.py diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..def312e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,48 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from flask_migrate import Migrate +from flask_mail import Mail +from config import config +import os + +# Initialize extensions +db = SQLAlchemy() +login_manager = LoginManager() +migrate = Migrate() +mail = Mail() + +def create_app(config_name='default'): + app = Flask(__name__) + + # Load configuration + app.config.from_object(config[config_name]) + + # Initialize extensions with app + db.init_app(app) + login_manager.init_app(app) + migrate.init_app(app, db) + mail.init_app(app) + + # Configure login manager + login_manager.login_view = 'auth.login' + login_manager.login_message = 'Please log in to access this page.' + login_manager.login_message_category = 'info' + + # Create upload directory if it doesn't exist + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + + # Register blueprints + from app.routes import auth, dashboard, inspections, templates, reports + + app.register_blueprint(auth.bp) + app.register_blueprint(dashboard.bp) + app.register_blueprint(inspections.bp) + app.register_blueprint(templates.bp) + app.register_blueprint(reports.bp) + + # Create database tables + with app.app_context(): + db.create_all() + + return app diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..577a663 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,4 @@ +from app.models.user import User +from app.models.facility import Facility, Area +from app.models.inspection import InspectionTemplate, ChecklistItem, Inspection, InspectionResult +from app.models.issue import Issue diff --git a/app/models/facility.py b/app/models/facility.py new file mode 100644 index 0000000..0f3c3f2 --- /dev/null +++ b/app/models/facility.py @@ -0,0 +1,34 @@ +from app import db +from datetime import datetime + +class Facility(db.Model): + __tablename__ = 'facilities' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), nullable=False) + address = db.Column(db.Text) + contact_person = db.Column(db.String(100)) + contact_phone = db.Column(db.String(20)) + active = db.Column(db.Boolean, default=True) + + # Relationships + areas = db.relationship('Area', backref='facility', lazy='dynamic') + inspections = db.relationship('Inspection', backref='facility', lazy='dynamic') + + def __repr__(self): + return f'' + +class Area(db.Model): + __tablename__ = 'areas' + + id = db.Column(db.Integer, primary_key=True) + facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False) + name = db.Column(db.String(255), nullable=False) + area_type = db.Column(db.String(50)) + + # Relationships + inspections = db.relationship('Inspection', backref='area', lazy='dynamic') + issues = db.relationship('Issue', backref='area', lazy='dynamic') + + def __repr__(self): + return f'' diff --git a/app/models/inspection.py b/app/models/inspection.py new file mode 100644 index 0000000..13478e3 --- /dev/null +++ b/app/models/inspection.py @@ -0,0 +1,72 @@ +from app import db +from datetime import datetime + +class InspectionTemplate(db.Model): + __tablename__ = 'inspection_templates' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text) + frequency = db.Column(db.Enum('daily', 'weekly', 'monthly', 'quarterly')) + created_by = db.Column(db.Integer, db.ForeignKey('users.id')) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationships + checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan') + inspections = db.relationship('Inspection', backref='template', lazy='dynamic') + + def __repr__(self): + return f'' + +class ChecklistItem(db.Model): + __tablename__ = 'checklist_items' + + id = db.Column(db.Integer, primary_key=True) + template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False) + category = db.Column(db.String(100)) + item_description = db.Column(db.Text, nullable=False) + scoring_type = db.Column(db.Enum('pass_fail', 'rating_5', 'rating_10')) + weight = db.Column(db.Numeric(3, 2), default=1.00) + requires_photo = db.Column(db.Boolean, default=False) + display_order = db.Column(db.Integer) + + # Relationships + results = db.relationship('InspectionResult', backref='checklist_item', lazy='dynamic') + + def __repr__(self): + return f'' + +class Inspection(db.Model): + __tablename__ = 'inspections' + + id = db.Column(db.Integer, primary_key=True) + template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False) + facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False) + area_id = db.Column(db.Integer, db.ForeignKey('areas.id')) + inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + inspection_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + overall_score = db.Column(db.Numeric(5, 2)) + status = db.Column(db.Enum('in_progress', 'completed', 'flagged'), default='in_progress') + notes = db.Column(db.Text) + completed_at = db.Column(db.DateTime) + + # Relationships + results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan') + issues = db.relationship('Issue', backref='inspection', lazy='dynamic') + + def __repr__(self): + return f'' + +class InspectionResult(db.Model): + __tablename__ = 'inspection_results' + + id = db.Column(db.Integer, primary_key=True) + inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id'), nullable=False) + checklist_item_id = db.Column(db.Integer, db.ForeignKey('checklist_items.id'), nullable=False) + score = db.Column(db.Numeric(5, 2)) + passed = db.Column(db.Boolean) + comments = db.Column(db.Text) + photo_path = db.Column(db.String(255)) + + def __repr__(self): + return f'' diff --git a/app/models/issue.py b/app/models/issue.py new file mode 100644 index 0000000..d96f6c1 --- /dev/null +++ b/app/models/issue.py @@ -0,0 +1,19 @@ +from app import db +from datetime import datetime + +class Issue(db.Model): + __tablename__ = 'issues' + + id = db.Column(db.Integer, primary_key=True) + inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id')) + area_id = db.Column(db.Integer, db.ForeignKey('areas.id'), nullable=False) + severity = db.Column(db.Enum('low', 'medium', 'high', 'critical'), nullable=False) + description = db.Column(db.Text, nullable=False) + photo_path = db.Column(db.String(255)) + status = db.Column(db.Enum('open', 'in_progress', 'resolved'), default='open') + assigned_to = db.Column(db.Integer, db.ForeignKey('users.id')) + reported_at = db.Column(db.DateTime, default=datetime.utcnow) + resolved_at = db.Column(db.DateTime) + + def __repr__(self): + return f'' diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..747042b --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,30 @@ +from app import db, login_manager +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +from datetime import datetime + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + +class User(UserMixin, db.Model): + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(100), unique=True, nullable=False, index=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.Enum('admin', 'supervisor', 'inspector'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationships + inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic') + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def __repr__(self): + return f'' diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..82c69d3 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,30 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask_login import login_user, logout_user, login_required +from app import db +from app.models.user import User + +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): + login_user(user) + next_page = request.args.get('next') + return redirect(next_page or url_for('dashboard.index')) + else: + flash('Invalid username or password', 'danger') + + return render_template('login.html') + +@bp.route('/logout') +@login_required +def logout(): + logout_user() + flash('You have been logged out successfully', 'success') + return redirect(url_for('auth.login')) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py new file mode 100644 index 0000000..8191acb --- /dev/null +++ b/app/routes/dashboard.py @@ -0,0 +1,10 @@ +from flask import Blueprint, render_template +from flask_login import login_required, current_user + +bp = Blueprint('dashboard', __name__) + +@bp.route('/') +@bp.route('/dashboard') +@login_required +def index(): + return render_template('dashboard.html', user=current_user) diff --git a/app/routes/inspections.py b/app/routes/inspections.py new file mode 100644 index 0000000..6309720 --- /dev/null +++ b/app/routes/inspections.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +bp = Blueprint('inspections', __name__, url_prefix='/inspections') + +@bp.route('/') +def index(): + return "Inspections module - Coming in Phase 2" diff --git a/app/routes/reports.py b/app/routes/reports.py new file mode 100644 index 0000000..a1836f5 --- /dev/null +++ b/app/routes/reports.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +bp = Blueprint('reports', __name__, url_prefix='/reports') + +@bp.route('/') +def index(): + return "Reports module - Coming in Phase 2" diff --git a/app/routes/templates.py b/app/routes/templates.py new file mode 100644 index 0000000..684136b --- /dev/null +++ b/app/routes/templates.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +bp = Blueprint('templates', __name__, url_prefix='/templates') + +@bp.route('/') +def index(): + return "Templates module - Coming in Phase 2" diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..cbe6720 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,71 @@ + + + + + + {% block title %}Janitorial QC System{% endblock %} + + + {% block extra_css %}{% endblock %} + + + {% if current_user.is_authenticated %} + + {% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + {% block extra_js %}{% endblock %} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..353cb53 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Janitorial QC{% endblock %} + +{% block content %} +
+
+

Welcome, {{ current_user.username }}!

+

Role: {{ current_user.role|title }}

+
+
+ +
+
+
+
+
Today's Inspections
+

0

+
+
+
+
+
+
+
Completed
+

0

+
+
+
+
+
+
+
Issues Open
+

0

+
+
+
+
+
+
+
Avg Score
+

--

+
+
+
+
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..5586e9e --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block title %}Login - Janitorial QC{% endblock %} + +{% block content %} +
+
+
+
+

Janitorial QC System

+
+
+
+
+ + +
+
+ + +
+ +
+
+
+
+
+{% endblock %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config.py b/config.py new file mode 100644 index 0000000..d352be8 --- /dev/null +++ b/config.py @@ -0,0 +1,46 @@ +import os +from datetime import timedelta + +basedir = os.path.abspath(os.path.dirname(__file__)) + +class Config: + # Secret key for session management + SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' + + # Database configuration + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + 'mysql+pymysql://jqc_admin:Jqc4dm1n141!@localhost/janitorial_qc' + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ECHO = False + + # Upload configuration + UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads') + MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} + + # Session configuration + PERMANENT_SESSION_LIFETIME = timedelta(hours=24) + SESSION_COOKIE_SECURE = False # Change to True on production + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = 'Lax' + + # Mail configuration (configure later) + MAIL_SERVER = os.environ.get('MAIL_SERVER') + MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587) + MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1'] + MAIL_USERNAME = os.environ.get('MAIL_USERNAME') + MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') + +class DevelopmentConfig(Config): + DEBUG = True + SQLALCHEMY_ECHO = True + +class ProductionConfig(Config): + DEBUG = False + SESSION_COOKIE_SECURE = True + +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig +} diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..968e0be --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +Flask==3.0.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.1 +Flask-Mail==0.9.1 +Flask-Migrate==4.0.5 +PyMySQL==1.1.0 +cryptography==41.0.7 +python-dotenv==1.0.0 +Pillow==10.1.0 +WTForms==3.1.1 +email-validator==2.1.0 +gunicorn==21.2.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..526ecd1 --- /dev/null +++ b/run.py @@ -0,0 +1,22 @@ +from app import create_app, db +from app.models import User, Facility, Area, InspectionTemplate, ChecklistItem, Inspection, InspectionResult, Issue +import os + +app = create_app(os.getenv('FLASK_ENV') or 'default') + +@app.shell_context_processor +def make_shell_context(): + return { + 'db': db, + 'User': User, + 'Facility': Facility, + 'Area': Area, + 'InspectionTemplate': InspectionTemplate, + 'ChecklistItem': ChecklistItem, + 'Inspection': Inspection, + 'InspectionResult': InspectionResult, + 'Issue': Issue + } + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True)