Jul 16 - Fill the gaps between Single-tenant mode and Multi-tenant mode
This commit is contained in:
+1
-1
@@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_comments', __name__)
|
bp = Blueprint('api_comments', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _comment_payload(comment: IssueComment) -> dict:
|
def _comment_payload(comment: IssueComment) -> dict:
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_inspections', __name__)
|
bp = Blueprint('api_inspections', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _merge_form_data(existing: dict, incoming: dict) -> dict:
|
def _merge_form_data(existing: dict, incoming: dict) -> dict:
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_issues', __name__)
|
bp = Blueprint('api_issues', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
|
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
|
||||||
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
|
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
|
||||||
_UUID_RE = re.compile(
|
_UUID_RE = re.compile(
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
|||||||
bp = Blueprint('api_photos', __name__)
|
bp = Blueprint('api_photos', __name__)
|
||||||
|
|
||||||
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _allowed_file(filename: str) -> bool:
|
def _allowed_file(filename: str) -> bool:
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
bp = Blueprint('api_stats', __name__)
|
bp = Blueprint('api_stats', __name__)
|
||||||
|
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/stats/dashboard', methods=['GET'])
|
@bp.route('/stats/dashboard', methods=['GET'])
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
bp = Blueprint('api_templates', __name__)
|
bp = Blueprint('api_templates', __name__)
|
||||||
|
|
||||||
# Customer role cannot access template data — inspectors and above only
|
# Customer role cannot access template data — inspectors and above only
|
||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
def _template_summary_payload(template: InspectionTemplate) -> dict:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ MATRIX_ROLES = [
|
|||||||
('director', 'Director'),
|
('director', 'Director'),
|
||||||
('inspector', 'Inspector'),
|
('inspector', 'Inspector'),
|
||||||
('project_manager', 'Project Manager'),
|
('project_manager', 'Project Manager'),
|
||||||
|
('auditor', 'Auditor'),
|
||||||
('customer', 'Customer'),
|
('customer', 'Customer'),
|
||||||
('custom', 'Custom Recipients'),
|
('custom', 'Custom Recipients'),
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ class User(UserMixin, db.Model):
|
|||||||
role = db.Column(
|
role = db.Column(
|
||||||
# Phase 11 migration complete — 'supervisor' removed from both the DB
|
# Phase 11 migration complete — 'supervisor' removed from both the DB
|
||||||
# ENUM and this Python-side declaration. Director is the canonical role.
|
# ENUM and this Python-side declaration. Director is the canonical role.
|
||||||
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer'),
|
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer', 'auditor'),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
created_at = db.Column(db.DateTime, default=now_eastern)
|
created_at = db.Column(db.DateTime, default=now_eastern)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def index():
|
|||||||
is_privileged = current_user.role in ['admin', 'director']
|
is_privileged = current_user.role in ['admin', 'director']
|
||||||
is_customer = current_user.role == 'customer'
|
is_customer = current_user.role == 'customer'
|
||||||
is_project_manager = current_user.role == 'project_manager'
|
is_project_manager = current_user.role == 'project_manager'
|
||||||
|
is_auditor = current_user.role == 'auditor'
|
||||||
|
|
||||||
# Resolve facility scope
|
# Resolve facility scope
|
||||||
customer_facility_ids = get_customer_scope(current_user) # None for non-customers
|
customer_facility_ids = get_customer_scope(current_user) # None for non-customers
|
||||||
@@ -299,9 +300,9 @@ def index():
|
|||||||
unassigned_q = unassigned_q.filter(False) # not relevant for customers
|
unassigned_q = unassigned_q.filter(False) # not relevant for customers
|
||||||
unassigned_open = unassigned_q.count()
|
unassigned_open = unassigned_q.count()
|
||||||
|
|
||||||
# ── Inspector activity today (admin / director / PM only) ─────────────────
|
# ── Inspector activity today (admin / director / PM / auditor only) ───────
|
||||||
inspector_activity = []
|
inspector_activity = []
|
||||||
if is_privileged or is_project_manager:
|
if is_privileged or is_project_manager or is_auditor:
|
||||||
active_inspectors = (
|
active_inspectors = (
|
||||||
User.query
|
User.query
|
||||||
.filter_by(role='inspector', active=True)
|
.filter_by(role='inspector', active=True)
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ def execute(inspection_id):
|
|||||||
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
|
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
|
||||||
|
|
||||||
staff_for_flag_issue = User.query.filter(
|
staff_for_flag_issue = User.query.filter(
|
||||||
User.role.in_(['admin', 'director', 'inspector', 'project_manager']),
|
User.role.in_(['director', 'inspector', 'project_manager', 'auditor']),
|
||||||
User.active == True,
|
User.active == True,
|
||||||
).order_by(User.full_name, User.username).all()
|
).order_by(User.full_name, User.username).all()
|
||||||
|
|
||||||
|
|||||||
+20
-12
@@ -15,7 +15,8 @@ from app.models.notification import (
|
|||||||
EVENT_CUSTOMER_ISSUE_UPDATED,
|
EVENT_CUSTOMER_ISSUE_UPDATED,
|
||||||
)
|
)
|
||||||
from app.utils.forms import IssueForm, IssueUpdateForm
|
from app.utils.forms import IssueForm, IssueUpdateForm
|
||||||
from app.utils.decorators import supervisor_required, project_manager_required
|
from app.utils.decorators import (supervisor_required, project_manager_required,
|
||||||
|
issue_manager_required)
|
||||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||||
from app.tenancy.gates import quota_soft_check
|
from app.tenancy.gates import quota_soft_check
|
||||||
@@ -346,7 +347,7 @@ def index():
|
|||||||
|
|
||||||
# Staff for quick-assign dropdown — same roles as the full issue form
|
# Staff for quick-assign dropdown — same roles as the full issue form
|
||||||
staff = User.query.filter(
|
staff = User.query.filter(
|
||||||
User.role.in_(['admin', 'director', 'inspector']), User.active == True
|
User.role.in_(['director', 'inspector', 'auditor']), User.active == True
|
||||||
).order_by(User.username).all()
|
).order_by(User.username).all()
|
||||||
|
|
||||||
# Reporters dropdown — users who have actually filed at least one issue
|
# Reporters dropdown — users who have actually filed at least one issue
|
||||||
@@ -421,7 +422,14 @@ def view(issue_id):
|
|||||||
return redirect(url_for('issues.view', issue_id=issue_id))
|
return redirect(url_for('issues.view', issue_id=issue_id))
|
||||||
|
|
||||||
form = IssueUpdateForm(obj=issue)
|
form = IssueUpdateForm(obj=issue)
|
||||||
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
|
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
|
||||||
|
# Preserve any pre-existing assignee who is no longer in the assignable set
|
||||||
|
# (e.g. an admin assigned before admins were removed from the dropdown) so
|
||||||
|
# saving the form doesn't silently unassign them.
|
||||||
|
if issue.assigned_to and issue.assigned_to not in [u.id for u in staff]:
|
||||||
|
current_assignee = db.session.get(User, issue.assigned_to)
|
||||||
|
if current_assignee:
|
||||||
|
staff.append(current_assignee)
|
||||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
||||||
form.status.data = form.status.data or issue.status
|
form.status.data = form.status.data or issue.status
|
||||||
|
|
||||||
@@ -431,7 +439,7 @@ def view(issue_id):
|
|||||||
|
|
||||||
issue.status = form.status.data
|
issue.status = form.status.data
|
||||||
|
|
||||||
if current_user.role in ['admin', 'director']:
|
if current_user.role in ['admin', 'director', 'auditor']:
|
||||||
issue.assigned_to = form.assigned_to.data or None
|
issue.assigned_to = form.assigned_to.data or None
|
||||||
|
|
||||||
if form.status.data == 'resolved' and not issue.resolved_at:
|
if form.status.data == 'resolved' and not issue.resolved_at:
|
||||||
@@ -449,7 +457,7 @@ def view(issue_id):
|
|||||||
issue.result_notes = form.result_notes.data or None
|
issue.result_notes = form.result_notes.data or None
|
||||||
|
|
||||||
# Vendor / contractor assignment — admin, director, project_manager only
|
# Vendor / contractor assignment — admin, director, project_manager only
|
||||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
if current_user.role in ('admin', 'director', 'project_manager', 'auditor'):
|
||||||
issue.vendor_name = form.vendor_name.data.strip() or None
|
issue.vendor_name = form.vendor_name.data.strip() or None
|
||||||
issue.vendor_contact = form.vendor_contact.data.strip() or None
|
issue.vendor_contact = form.vendor_contact.data.strip() or None
|
||||||
issue.vendor_notes = form.vendor_notes.data.strip() or None
|
issue.vendor_notes = form.vendor_notes.data.strip() or None
|
||||||
@@ -695,7 +703,7 @@ def unfollow(issue_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@quota_soft_check('issues')
|
@quota_soft_check('issues')
|
||||||
def create():
|
def create():
|
||||||
if current_user.role not in ('admin', 'director', 'customer'):
|
if current_user.role not in ('admin', 'director', 'customer', 'auditor'):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
from app.models.project import Project, CustomerAssignment
|
from app.models.project import Project, CustomerAssignment
|
||||||
@@ -717,7 +725,7 @@ def create():
|
|||||||
else:
|
else:
|
||||||
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||||
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
projects = Project.query.filter_by(active=True).order_by(Project.name).all()
|
||||||
staff = User.query.filter(User.role.in_(['admin', 'director', 'inspector'])).order_by(User.username).all()
|
staff = User.query.filter(User.role.in_(['director', 'inspector', 'auditor'])).order_by(User.username).all()
|
||||||
|
|
||||||
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
form.facility_id.choices = [(f.id, f.name) for f in facilities]
|
||||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.display_name) for u in staff]
|
||||||
@@ -803,7 +811,7 @@ def create():
|
|||||||
|
|
||||||
@bp.route('/<int:issue_id>/verify', methods=['POST'])
|
@bp.route('/<int:issue_id>/verify', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@issue_manager_required
|
||||||
def verify(issue_id):
|
def verify(issue_id):
|
||||||
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
|
"""Supervisor sign-off: confirms resolution is satisfactory and closes the issue."""
|
||||||
issue = db.session.get(Issue, issue_id)
|
issue = db.session.get(Issue, issue_id)
|
||||||
@@ -837,7 +845,7 @@ def verify(issue_id):
|
|||||||
|
|
||||||
@bp.route('/bulk-verify', methods=['POST'])
|
@bp.route('/bulk-verify', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@issue_manager_required
|
||||||
def bulk_verify():
|
def bulk_verify():
|
||||||
"""Verify multiple pending-verification issues in a single action."""
|
"""Verify multiple pending-verification issues in a single action."""
|
||||||
issue_ids = request.form.getlist('issue_ids', type=int)
|
issue_ids = request.form.getlist('issue_ids', type=int)
|
||||||
@@ -884,7 +892,7 @@ def request_verification(issue_id):
|
|||||||
|
|
||||||
# Only the assignee, director, or admin can request verification
|
# Only the assignee, director, or admin can request verification
|
||||||
can_act = (
|
can_act = (
|
||||||
current_user.role in ['admin', 'director']
|
current_user.role in ['admin', 'director', 'auditor']
|
||||||
or issue.assigned_to == current_user.id
|
or issue.assigned_to == current_user.id
|
||||||
)
|
)
|
||||||
if not can_act:
|
if not can_act:
|
||||||
@@ -927,7 +935,7 @@ def request_verification(issue_id):
|
|||||||
|
|
||||||
@bp.route('/verification-queue')
|
@bp.route('/verification-queue')
|
||||||
@login_required
|
@login_required
|
||||||
@supervisor_required
|
@issue_manager_required
|
||||||
def verification_queue():
|
def verification_queue():
|
||||||
"""Supervisor queue of all issues awaiting verification, grouped by facility."""
|
"""Supervisor queue of all issues awaiting verification, grouped by facility."""
|
||||||
from app.models.facility import Facility, Area
|
from app.models.facility import Facility, Area
|
||||||
@@ -1028,7 +1036,7 @@ def delete(issue_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def quick_assign(issue_id):
|
def quick_assign(issue_id):
|
||||||
"""Inline assignee update from the issues list — returns JSON."""
|
"""Inline assignee update from the issues list — returns JSON."""
|
||||||
if current_user.role not in ('admin', 'director'):
|
if current_user.role not in ('admin', 'director', 'auditor'):
|
||||||
return jsonify({'ok': False, 'error': 'Permission denied'}), 403
|
return jsonify({'ok': False, 'error': 'Permission denied'}), 403
|
||||||
|
|
||||||
issue = db.session.get(Issue, issue_id)
|
issue = db.session.get(Issue, issue_id)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<td>{{ user.full_name or '—' }}</td>
|
<td>{{ user.full_name or '—' }}</td>
|
||||||
<td>{{ user.email }}</td>
|
<td>{{ user.email }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
|
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'auditor' %}secondary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
|
||||||
{{ user.role.replace('_',' ')|title }}
|
{{ user.role.replace('_',' ')|title }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
|
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
|
||||||
</li>
|
</li>
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
|
||||||
</li>
|
</li>
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
||||||
</li>
|
</li>
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
|
||||||
href="{{ url_for('issues.verification_queue') }}">
|
href="{{ url_for('issues.verification_queue') }}">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="row mb-3 align-items-center">
|
<div class="row mb-3 align-items-center">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2>
|
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2>
|
||||||
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
|
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'auditor' %}secondary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
|
||||||
{{ current_user.role.replace('_',' ')|title }}
|
{{ current_user.role.replace('_',' ')|title }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<h2><i class="bi bi-building"></i> Facilities</h2>
|
<h2><i class="bi bi-building"></i> Facilities</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-end">
|
<div class="col-md-6 text-end">
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<a href="{{ url_for('facilities.qr_sheet') }}" class="btn btn-outline-secondary">
|
<a href="{{ url_for('facilities.qr_sheet') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-qr-code"></i> Print QR Codes
|
<i class="bi bi-qr-code"></i> Print QR Codes
|
||||||
</a>
|
</a>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
|
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
|
||||||
{% if group.project and current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if group.project and current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
|
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
|
||||||
class="btn btn-sm btn-outline-secondary ms-2"
|
class="btn btn-sm btn-outline-secondary ms-2"
|
||||||
title="View Contract">
|
title="View Contract">
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
|
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left"></i> Back to Facilities
|
<i class="bi bi-arrow-left"></i> Back to Facilities
|
||||||
</a>
|
</a>
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer', 'auditor'] %}
|
||||||
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
|
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
|
||||||
class="btn btn-outline-info">
|
class="btn btn-outline-info">
|
||||||
<i class="bi bi-graph-up-arrow"></i> Scorecard
|
<i class="bi bi-graph-up-arrow"></i> Scorecard
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<a href="{{ url_for('facilities.qr_card', facility_id=facility.id) }}"
|
<a href="{{ url_for('facilities.qr_card', facility_id=facility.id) }}"
|
||||||
class="btn btn-outline-secondary">
|
class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-qr-code"></i> QR Code
|
<i class="bi bi-qr-code"></i> QR Code
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
|
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
|
||||||
{% if current_user.role in ['admin','director','customer'] %}
|
{% if current_user.role in ['admin','director','customer','auditor'] %}
|
||||||
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
||||||
<i class="bi bi-plus-circle"></i> Log Issue
|
<i class="bi bi-plus-circle"></i> Log Issue
|
||||||
</a>
|
</a>
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_user.role in ['admin', 'director'] and issue.status != 'resolved' %}
|
{% if current_user.role in ['admin', 'director', 'auditor'] and issue.status != 'resolved' %}
|
||||||
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
|
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
|
||||||
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
|
||||||
<option value="">— Unassigned —</option>
|
<option value="">— Unassigned —</option>
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
|
|
||||||
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
|
||||||
class="btn btn-sm btn-outline-secondary">
|
class="btn btn-sm btn-outline-secondary">
|
||||||
{% if current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
|
{% if current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||||
<i class="bi bi-pencil"></i> Edit
|
<i class="bi bi-pencil"></i> Edit
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="bi bi-eye"></i> View
|
<i class="bi bi-eye"></i> View
|
||||||
@@ -294,7 +294,7 @@
|
|||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director', 'auditor'] %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% set can_edit = current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
|
{% set can_edit = current_user.role in ['admin','director','auditor'] or issue.assigned_to == current_user.id %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
|
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
<div class="alert alert-info py-2 mb-0">
|
<div class="alert alert-info py-2 mb-0">
|
||||||
<i class="bi bi-hourglass-split me-1"></i>
|
<i class="bi bi-hourglass-split me-1"></i>
|
||||||
<strong>Awaiting director verification.</strong>
|
<strong>Awaiting director verification.</strong>
|
||||||
{% if current_user.role in ['admin','director'] %}
|
{% if current_user.role in ['admin','director','auditor'] %}
|
||||||
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
|
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@@ -363,7 +363,7 @@
|
|||||||
{{ form.status.label(class="form-label fw-semibold") }}
|
{{ form.status.label(class="form-label fw-semibold") }}
|
||||||
{{ form.status(class="form-select") }}
|
{{ form.status(class="form-select") }}
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.role in ['admin','director'] %}
|
{% if current_user.role in ['admin','director','auditor'] %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{{ form.assigned_to.label(class="form-label fw-semibold") }}
|
{{ form.assigned_to.label(class="form-label fw-semibold") }}
|
||||||
{{ form.assigned_to(class="form-select") }}
|
{{ form.assigned_to(class="form-select") }}
|
||||||
@@ -386,7 +386,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||||
<hr class="my-3">
|
<hr class="my-3">
|
||||||
<p class="fw-semibold small mb-2">
|
<p class="fw-semibold small mb-2">
|
||||||
<i class="bi bi-person-check me-1 text-secondary"></i>Handler / Ownership
|
<i class="bi bi-person-check me-1 text-secondary"></i>Handler / Ownership
|
||||||
@@ -467,7 +467,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# ── Vendor Work Orders (phase36) ───────────────────────────────────── #}
|
{# ── Vendor Work Orders (phase36) ───────────────────────────────────── #}
|
||||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||||
<div class="card shadow-sm mt-3">
|
<div class="card shadow-sm mt-3">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
<h6 class="mb-0"><i class="bi bi-send me-1"></i>Contractor Work Orders</h6>
|
<h6 class="mb-0"><i class="bi bi-send me-1"></i>Contractor Work Orders</h6>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<i class="bi bi-shield-check me-1"></i>SLA Compliance
|
<i class="bi bi-shield-check me-1"></i>SLA Compliance
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
|
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
|
||||||
href="{{ url_for('reports.followup_closure') }}">
|
href="{{ url_for('reports.followup_closure') }}">
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
{% if current_user.role in ['admin', 'director', 'project_manager', 'auditor'] %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
|
||||||
href="{{ url_for('scheduled_reports.index') }}">
|
href="{{ url_for('scheduled_reports.index') }}">
|
||||||
|
|||||||
+24
-2
@@ -54,17 +54,39 @@ def supervisor_required(f):
|
|||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
def project_manager_required(f):
|
def project_manager_required(f):
|
||||||
"""Grants access to admin, director, and project_manager roles."""
|
"""Grants access to admin, director, project_manager, and auditor roles.
|
||||||
|
|
||||||
|
Auditor mirrors Project Manager for all baseline access, so it is included
|
||||||
|
here alongside project_manager.
|
||||||
|
"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if not current_user.is_authenticated or current_user.role not in [
|
if not current_user.is_authenticated or current_user.role not in [
|
||||||
'admin', 'director', 'project_manager'
|
'admin', 'director', 'project_manager', 'auditor'
|
||||||
]:
|
]:
|
||||||
flash('Project Manager access required.', 'danger')
|
flash('Project Manager access required.', 'danger')
|
||||||
return redirect(url_for('dashboard.index'))
|
return redirect(url_for('dashboard.index'))
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
|
def issue_manager_required(f):
|
||||||
|
"""Grants access to admin, director, and auditor roles.
|
||||||
|
|
||||||
|
Used for issue-management powers that go beyond the Project Manager
|
||||||
|
baseline (verification and the verification queue). Deliberately does NOT
|
||||||
|
include project_manager, and does NOT grant issue deletion — delete stays
|
||||||
|
on @supervisor_required (admin/director only).
|
||||||
|
"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated or current_user.role not in [
|
||||||
|
'admin', 'director', 'auditor'
|
||||||
|
]:
|
||||||
|
flash('Issue management access required.', 'danger')
|
||||||
|
return redirect(url_for('dashboard.index'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
def customer_required(f):
|
def customer_required(f):
|
||||||
"""Restricts access to customer-role users only.
|
"""Restricts access to customer-role users only.
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class UserForm(FlaskForm):
|
|||||||
('director', 'Director'),
|
('director', 'Director'),
|
||||||
('inspector', 'Inspector'),
|
('inspector', 'Inspector'),
|
||||||
('project_manager', 'Project Manager'),
|
('project_manager', 'Project Manager'),
|
||||||
|
('auditor', 'Auditor'),
|
||||||
# 'customer' is intentionally excluded — customer accounts are managed via /customers
|
# 'customer' is intentionally excluded — customer accounts are managed via /customers
|
||||||
], validators=[Optional()])
|
], validators=[Optional()])
|
||||||
# NOTE: Optional() here because directors submit no role value (the field is
|
# NOTE: Optional() here because directors submit no role value (the field is
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ def notify_by_matrix(
|
|||||||
'director': 'director',
|
'director': 'director',
|
||||||
'inspector': 'inspector',
|
'inspector': 'inspector',
|
||||||
'project_manager': 'project_manager',
|
'project_manager': 'project_manager',
|
||||||
|
'auditor': 'auditor',
|
||||||
'customer': 'customer',
|
'customer': 'customer',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""phase41 — add 'auditor' role to users.role ENUM
|
||||||
|
|
||||||
|
Introduces a new staff role, Auditor, with the same access as Project Manager
|
||||||
|
plus full issue-management powers (create, assign, quick-assign, handler/vendor
|
||||||
|
triage, request-verification, verify/bulk-verify/verification-queue) — but NOT
|
||||||
|
issue deletion (that stays admin/director via @supervisor_required).
|
||||||
|
|
||||||
|
Ported from the single-tenant chain (phase40_auditor_role) and renumbered onto
|
||||||
|
the multi-tenant HEAD.
|
||||||
|
|
||||||
|
This is a pure ENUM expansion (adds a value, removes none, no data migration),
|
||||||
|
so the 3-step ENUM protocol does not apply. Re-running the same MODIFY is a
|
||||||
|
no-op — safe to re-run on every tenant DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = 'phase41_auditor_role'
|
||||||
|
down_revision = 'phase40_support_chat_kb'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
_ENUM_WITH_AUDITOR = (
|
||||||
|
"ENUM('admin','director','inspector','project_manager','customer','auditor')"
|
||||||
|
)
|
||||||
|
_ENUM_WITHOUT_AUDITOR = (
|
||||||
|
"ENUM('admin','director','inspector','project_manager','customer')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Idempotent: MODIFY to the expanded set is harmless if already applied.
|
||||||
|
op.execute(sa.text(
|
||||||
|
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITH_AUDITOR} NOT NULL"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Reassign any auditor rows before contracting the ENUM so no data is lost.
|
||||||
|
op.execute(sa.text(
|
||||||
|
"UPDATE users SET role = 'project_manager' WHERE role = 'auditor'"
|
||||||
|
))
|
||||||
|
op.execute(sa.text(
|
||||||
|
f"ALTER TABLE users MODIFY COLUMN role {_ENUM_WITHOUT_AUDITOR} NOT NULL"
|
||||||
|
))
|
||||||
Reference in New Issue
Block a user