Jul 17 - Fill the gaps between Single-tenant mode and Multi-tenant mode - MT5
This commit is contained in:
@@ -266,6 +266,7 @@ def create_app(config_name='default'):
|
|||||||
from app.api.notifications import bp as _api_notifications_bp
|
from app.api.notifications import bp as _api_notifications_bp
|
||||||
from app.api.stats import bp as _api_stats_bp
|
from app.api.stats import bp as _api_stats_bp
|
||||||
from app.api.comments import bp as _api_comments_bp
|
from app.api.comments import bp as _api_comments_bp
|
||||||
|
from app.api.scheduled import bp as _api_scheduled_bp
|
||||||
csrf.exempt(_api_auth_bp)
|
csrf.exempt(_api_auth_bp)
|
||||||
csrf.exempt(_api_facilities_bp)
|
csrf.exempt(_api_facilities_bp)
|
||||||
csrf.exempt(_api_templates_bp)
|
csrf.exempt(_api_templates_bp)
|
||||||
@@ -275,6 +276,7 @@ def create_app(config_name='default'):
|
|||||||
csrf.exempt(_api_notifications_bp)
|
csrf.exempt(_api_notifications_bp)
|
||||||
csrf.exempt(_api_stats_bp)
|
csrf.exempt(_api_stats_bp)
|
||||||
csrf.exempt(_api_comments_bp)
|
csrf.exempt(_api_comments_bp)
|
||||||
|
csrf.exempt(_api_scheduled_bp)
|
||||||
register_api(app)
|
register_api(app)
|
||||||
|
|
||||||
# ── Security response headers ─────────────────────────────────────────
|
# ── Security response headers ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ def register_api(app):
|
|||||||
from app.api.comments import bp as comments_bp
|
from app.api.comments import bp as comments_bp
|
||||||
api_bp.register_blueprint(comments_bp)
|
api_bp.register_blueprint(comments_bp)
|
||||||
|
|
||||||
|
# MT-5: Planned inspection assignments (plan-mode schedules)
|
||||||
|
from app.api.scheduled import bp as scheduled_bp
|
||||||
|
api_bp.register_blueprint(scheduled_bp)
|
||||||
|
|
||||||
# NOTE: device registration lives on the auth blueprint
|
# NOTE: device registration lives on the auth blueprint
|
||||||
# (POST /api/v1/devices/register in app/api/auth.py) and writes to the
|
# (POST /api/v1/devices/register in app/api/auth.py) and writes to the
|
||||||
# canonical api_device_tokens table (model DeviceToken). A former duplicate
|
# canonical api_device_tokens table (model DeviceToken). A former duplicate
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ bp = Blueprint('api_issues', __name__)
|
|||||||
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
_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'}
|
||||||
|
_VALID_HANDLERS = {'internal', 'facility', 'vendor'}
|
||||||
_UUID_RE = re.compile(
|
_UUID_RE = re.compile(
|
||||||
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
|
r'^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$',
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
@@ -82,6 +83,18 @@ def _issue_payload(issue):
|
|||||||
'area_name': issue.area.name if issue.area else None,
|
'area_name': issue.area.name if issue.area else None,
|
||||||
# Assigned-to display name — set when a director assigns the issue to a user.
|
# Assigned-to display name — set when a director assigns the issue to a user.
|
||||||
'assigned_to_name': issue.assigned_user.display_name if issue.assigned_user else None,
|
'assigned_to_name': issue.assigned_user.display_name if issue.assigned_user else None,
|
||||||
|
# ── Handler ("Handled By", phase39) ───────────────────────────────
|
||||||
|
# handler_type categorises WHO resolves the issue:
|
||||||
|
# internal = our staff (assigned_to) facility = facility's own staff
|
||||||
|
# vendor = external contractor
|
||||||
|
'handler_type': issue.handler_type or 'internal',
|
||||||
|
'handler_label': issue.handler_label,
|
||||||
|
'facility_handler_name': issue.facility_handler_name or None,
|
||||||
|
'facility_handler_contact': issue.facility_handler_contact or None,
|
||||||
|
'facility_handler_notes': issue.facility_handler_notes or None,
|
||||||
|
'vendor_name': issue.vendor_name or None,
|
||||||
|
'vendor_contact': issue.vendor_contact or None,
|
||||||
|
'vendor_notes': issue.vendor_notes or None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -497,3 +510,86 @@ def update_issue_result_photos(issue_id):
|
|||||||
issue.id, len(new_photos), user.username)
|
issue.id, len(new_photos), user.username)
|
||||||
|
|
||||||
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
|
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Update Issue Handler ("Handled By") ───────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/issues/<int:issue_id>/handler', methods=['PATCH'])
|
||||||
|
@jwt_required
|
||||||
|
def update_issue_handler(issue_id):
|
||||||
|
"""
|
||||||
|
Set who handles an issue ("Handled By") from the mobile app.
|
||||||
|
|
||||||
|
Unlike the web form (which limits handler edits to admin/director/PM/auditor),
|
||||||
|
the iPad allows the assigned inspector to set the handler from the field,
|
||||||
|
scoped to issues at their assigned facilities. This divergence is deliberate:
|
||||||
|
the inspector is the one standing in the building who knows whether the
|
||||||
|
facility's own staff or a vendor should take it.
|
||||||
|
|
||||||
|
Request JSON
|
||||||
|
------------
|
||||||
|
{
|
||||||
|
"handler_type": "internal" | "facility" | "vendor",
|
||||||
|
"facility_handler_name": "...", // optional (facility handler)
|
||||||
|
"facility_handler_contact": "...", // optional
|
||||||
|
"facility_handler_notes": "...", // optional
|
||||||
|
"vendor_name": "...", // optional (vendor handler)
|
||||||
|
"vendor_contact": "...", // optional
|
||||||
|
"vendor_notes": "..." // optional
|
||||||
|
}
|
||||||
|
|
||||||
|
Only keys present in the body are updated; empty strings clear a field.
|
||||||
|
handler_type is required.
|
||||||
|
|
||||||
|
Access:
|
||||||
|
- admin / director / project_manager / auditor : any issue
|
||||||
|
- inspector : only issues at their assigned facilities
|
||||||
|
- customer : denied
|
||||||
|
"""
|
||||||
|
user = g.api_user
|
||||||
|
if user.role not in _ALLOWED_ROLES:
|
||||||
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
|
issue = db.session.get(Issue, issue_id)
|
||||||
|
if issue is None:
|
||||||
|
return api_error('Issue not found', 404)
|
||||||
|
|
||||||
|
if user.role == 'inspector':
|
||||||
|
fids = get_inspector_scope(user)
|
||||||
|
facility = issue.resolved_facility
|
||||||
|
if not fids or not facility or facility.id not in fids:
|
||||||
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
handler = (data.get('handler_type') or '').strip().lower()
|
||||||
|
|
||||||
|
if handler not in _VALID_HANDLERS:
|
||||||
|
return api_error(
|
||||||
|
f'handler_type must be one of: {", ".join(sorted(_VALID_HANDLERS))}', 400
|
||||||
|
)
|
||||||
|
|
||||||
|
old_handler = issue.handler_type or 'internal'
|
||||||
|
issue.handler_type = handler
|
||||||
|
|
||||||
|
# Update only the detail fields that were supplied. Empty string clears
|
||||||
|
# the field (stored as NULL); a missing key leaves the field untouched.
|
||||||
|
_text_fields = (
|
||||||
|
'facility_handler_name', 'facility_handler_contact', 'facility_handler_notes',
|
||||||
|
'vendor_name', 'vendor_contact', 'vendor_notes',
|
||||||
|
)
|
||||||
|
for field in _text_fields:
|
||||||
|
if field in data:
|
||||||
|
val = (data.get(field) or '').strip()
|
||||||
|
setattr(issue, field, val or None)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(ACTION_UPDATE, 'Issue', issue.id,
|
||||||
|
f'handler {old_handler} → {handler}',
|
||||||
|
f'source=mobile; updated_by={user.username}')
|
||||||
|
|
||||||
|
logger.info('API ISSUES | handler_updated | issue_id=%d | %s→%s | user=%s',
|
||||||
|
issue.id, old_handler, handler, user.username)
|
||||||
|
|
||||||
|
return api_ok({'issue_id': issue.id, 'handler_type': issue.handler_type,
|
||||||
|
'handler_label': issue.handler_label})
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
app/api/scheduled.py
|
||||||
|
--------------------
|
||||||
|
Mobile API endpoint for planned inspection assignments (MT-5, phase43).
|
||||||
|
|
||||||
|
GET /api/v1/scheduled-inspections
|
||||||
|
Returns ACTIVE, PLAN-MODE schedules the caller is responsible for.
|
||||||
|
- inspector : only schedules where inspector_id == the caller
|
||||||
|
- admin / director / project_manager / auditor : all active plan schedules
|
||||||
|
Powers the "Scheduled" section on the iPad Dashboard and My Inspections
|
||||||
|
lists. The iPad taps "Start", which opens the normal new-inspection flow
|
||||||
|
with the facility + template preselected (client-side); the schedule
|
||||||
|
lifecycle (fulfil / roll-forward) continues to be driven by the web app.
|
||||||
|
|
||||||
|
Why plan-mode only
|
||||||
|
------------------
|
||||||
|
`mode='auto'` schedules materialise themselves into a real Inspection at
|
||||||
|
next_run_at, which the iPad already fetches via /api/v1/inspections. Returning
|
||||||
|
them here too would show the same work twice, and "Start" is meaningless for a
|
||||||
|
schedule that starts itself. This mirrors the web dashboard panel (MT-4).
|
||||||
|
|
||||||
|
A plan-mode schedule is a PLAN, not an inspection — see
|
||||||
|
app/models/inspection_schedule.py for the full lifecycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, request, g
|
||||||
|
from app.models.inspection_schedule import InspectionSchedule
|
||||||
|
from app.api.errors import api_ok, api_error
|
||||||
|
from app.api.decorators import jwt_required
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp = Blueprint('api_scheduled', __name__)
|
||||||
|
|
||||||
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager', 'auditor'}
|
||||||
|
|
||||||
|
|
||||||
|
def _scheduled_payload(s):
|
||||||
|
"""Serialise an InspectionSchedule to the dict returned in list responses.
|
||||||
|
|
||||||
|
`next_due_date` is the date part of next_run_at — MT reuses next_run_at as
|
||||||
|
the due datetime for both modes (see phase43).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'id': s.id,
|
||||||
|
'name': s.name,
|
||||||
|
'facility_id': s.facility_id,
|
||||||
|
'facility_name': s.facility.name if s.facility else None,
|
||||||
|
'area_id': s.area_id,
|
||||||
|
'area_name': s.area.name if s.area else None,
|
||||||
|
'template_id': s.template_id,
|
||||||
|
'template_name': s.template.name if s.template else None,
|
||||||
|
'inspector_id': s.inspector_id,
|
||||||
|
'frequency': s.frequency,
|
||||||
|
'frequency_label': s.frequency_label,
|
||||||
|
'mode': s.mode,
|
||||||
|
'next_due_date': s.next_run_at.date().isoformat() if s.next_run_at else None,
|
||||||
|
'is_overdue': s.is_overdue(),
|
||||||
|
'notes': s.notes or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── List Scheduled Inspections ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/scheduled-inspections', methods=['GET'])
|
||||||
|
@jwt_required
|
||||||
|
def list_scheduled():
|
||||||
|
"""
|
||||||
|
Return active plan-mode scheduled inspections for the authenticated user.
|
||||||
|
|
||||||
|
Query parameters
|
||||||
|
----------------
|
||||||
|
limit int Default 100, max 200.
|
||||||
|
offset int Default 0.
|
||||||
|
|
||||||
|
Response 200
|
||||||
|
------------
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"scheduled": [...],
|
||||||
|
"total": 3,
|
||||||
|
"limit": 100,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
user = g.api_user
|
||||||
|
if user.role not in _ALLOWED_ROLES:
|
||||||
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
limit = min(int(request.args.get('limit', 100)), 200)
|
||||||
|
offset = max(int(request.args.get('offset', 0)), 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return api_error('limit and offset must be integers', 400)
|
||||||
|
|
||||||
|
query = InspectionSchedule.query.filter(
|
||||||
|
InspectionSchedule.active.is_(True),
|
||||||
|
InspectionSchedule.mode == 'plan',
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.role == 'inspector':
|
||||||
|
# Inspectors only see schedules assigned directly to them.
|
||||||
|
query = query.filter(InspectionSchedule.inspector_id == user.id)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
rows = (
|
||||||
|
query
|
||||||
|
.order_by(InspectionSchedule.next_run_at.asc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = [_scheduled_payload(s) for s in rows]
|
||||||
|
|
||||||
|
logger.info('API SCHEDULED | list | user=%s | count=%d | total=%d',
|
||||||
|
user.username, len(payload), total)
|
||||||
|
|
||||||
|
return api_ok({'scheduled': payload, 'total': total,
|
||||||
|
'limit': limit, 'offset': offset})
|
||||||
@@ -116,6 +116,20 @@ class Issue(db.Model):
|
|||||||
"""Return True if the given user is currently following this issue."""
|
"""Return True if the given user is currently following this issue."""
|
||||||
return self.followers.filter_by(user_id=user.id).first() is not None
|
return self.followers.filter_by(user_id=user.id).first() is not None
|
||||||
|
|
||||||
|
# Display labels for handler_type. The web templates hardcode these inline;
|
||||||
|
# this mapping exists so the mobile API can return a human-readable label
|
||||||
|
# without the client duplicating the strings. (phase43 / MT-5)
|
||||||
|
HANDLER_LABELS = {
|
||||||
|
'internal': 'Janitorial Staff',
|
||||||
|
'facility': 'Facility Staff',
|
||||||
|
'vendor': 'External Vendor',
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def handler_label(self):
|
||||||
|
"""Human-readable label for handler_type; defaults to internal."""
|
||||||
|
return self.HANDLER_LABELS.get(self.handler_type or 'internal', 'Janitorial Staff')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resolved_facility(self):
|
def resolved_facility(self):
|
||||||
"""Returns the Facility for this issue regardless of which path was used to create it.
|
"""Returns the Facility for this issue regardless of which path was used to create it.
|
||||||
|
|||||||
Reference in New Issue
Block a user