From d8475ba9c711758ea63f950601aea570b9aa7a0d Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 4 Mar 2026 12:41:37 -0500 Subject: [PATCH] Mar 04 2026: Implement customer's view functionalities - Phase 1 --- app/models/__init__.py | 1 + app/models/facility.py | 4 +++ app/models/project.py | 63 +++++++++++++++++++++++++++++++++++++++++ app/models/user.py | 7 +++-- app/utils/decorators.py | 28 +++++++++++++++++- app/utils/forms.py | 6 +++- 6 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 app/models/project.py diff --git a/app/models/__init__.py b/app/models/__init__.py index 4ef9753..c82256d 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -3,3 +3,4 @@ from app.models.facility import Facility, Area from app.models.inspection import (InspectionTemplate, ChecklistItem, Inspection, InspectionResult) from app.models.issue import Issue +from app.models.project import Project, CustomerAssignment diff --git a/app/models/facility.py b/app/models/facility.py index 0f3c3f2..75d901b 100644 --- a/app/models/facility.py +++ b/app/models/facility.py @@ -11,6 +11,10 @@ class Facility(db.Model): contact_phone = db.Column(db.String(20)) active = db.Column(db.Boolean, default=True) + # Phase 1: link facility to a project (nullable for backward compatibility) + project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='SET NULL'), + nullable=True, index=True) + # Relationships areas = db.relationship('Area', backref='facility', lazy='dynamic') inspections = db.relationship('Inspection', backref='facility', lazy='dynamic') diff --git a/app/models/project.py b/app/models/project.py new file mode 100644 index 0000000..61a43d2 --- /dev/null +++ b/app/models/project.py @@ -0,0 +1,63 @@ +from app import db +from app.utils.time_utils import now_eastern + + +class Project(db.Model): + """Top-level grouping that contains one or more Facilities. + + Each project may have an assigned project_manager (User) and one or more + customer users linked via CustomerAssignment. + """ + __tablename__ = 'projects' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text) + project_manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + + # Relationships + project_manager = db.relationship('User', foreign_keys=[project_manager_id], + backref='managed_projects') + facilities = db.relationship('Facility', backref='project', lazy='dynamic') + customer_assignments = db.relationship('CustomerAssignment', back_populates='project', + cascade='all, delete-orphan', lazy='dynamic') + + def __repr__(self): + return f'' + + +class CustomerAssignment(db.Model): + """Links a customer-role User to a Project and/or a specific Facility. + + - project_id only → customer can view all facilities within that project + - project_id + facility_id → customer is scoped to that specific facility + """ + __tablename__ = 'customer_assignments' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='CASCADE'), + nullable=False, index=True) + facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id', ondelete='CASCADE'), + nullable=True, index=True) + created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + + __table_args__ = ( + db.UniqueConstraint('user_id', 'project_id', 'facility_id', + name='uq_customer_assignment'), + ) + + # Relationships + user = db.relationship('User', foreign_keys=[user_id], + backref=db.backref('customer_assignments', lazy='dynamic')) + project = db.relationship('Project', foreign_keys=[project_id], + back_populates='customer_assignments') + facility = db.relationship('Facility', foreign_keys=[facility_id], + backref=db.backref('customer_assignments', lazy='dynamic')) + + def __repr__(self): + return (f'') diff --git a/app/models/user.py b/app/models/user.py index 37856d4..838ae78 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -14,7 +14,10 @@ class User(UserMixin, db.Model): 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) + role = db.Column( + db.Enum('admin', 'supervisor', 'inspector', 'project_manager', 'customer'), + nullable=False + ) created_at = db.Column(db.DateTime, default=now_eastern) active = db.Column(db.Boolean, default=True, nullable=False) @@ -35,4 +38,4 @@ class User(UserMixin, db.Model): return check_password_hash(self.password_hash, password) def __repr__(self): - return f'' \ No newline at end of file + return f'' diff --git a/app/utils/decorators.py b/app/utils/decorators.py index 6316439..939e01f 100644 --- a/app/utils/decorators.py +++ b/app/utils/decorators.py @@ -18,4 +18,30 @@ def supervisor_required(f): flash('Supervisor access required.', 'danger') return redirect(url_for('dashboard.index')) return f(*args, **kwargs) - return decorated_function \ No newline at end of file + return decorated_function + +def project_manager_required(f): + """Grants access to admin, supervisor, and project_manager roles.""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role not in [ + 'admin', 'supervisor', 'project_manager' + ]: + flash('Project Manager access required.', 'danger') + return redirect(url_for('dashboard.index')) + return f(*args, **kwargs) + return decorated_function + +def customer_required(f): + """Restricts access to customer-role users only. + + Internal staff (admin, supervisor, inspector, project_manager) should + never be routed through customer-scoped views — use their own routes. + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role != 'customer': + flash('Customer portal access required.', 'danger') + return redirect(url_for('dashboard.index')) + return f(*args, **kwargs) + return decorated_function diff --git a/app/utils/forms.py b/app/utils/forms.py index 23eceac..7ca6881 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -49,7 +49,11 @@ class UserForm(FlaskForm): password = PasswordField('Password', validators=[Optional(), Length(min=6, max=100)]) confirm_password = PasswordField('Confirm Password', validators=[Optional(), EqualTo('password')]) role = SelectField('Role', choices=[ - ('admin', 'Administrator'), ('supervisor', 'Supervisor'), ('inspector', 'Inspector') + ('admin', 'Administrator'), + ('supervisor', 'Supervisor'), + ('inspector', 'Inspector'), + ('project_manager', 'Project Manager'), + ('customer', 'Customer'), ], validators=[DataRequired()]) def __init__(self, user=None, *args, **kwargs):