Merge branch 'main' of https://gitea.ngodanguyen.tech/nngo/LT_Janitorial_Quality_Control
This commit is contained in:
+124
-3
@@ -13,6 +13,7 @@ PATCH /api/v1/inspections/<inspection_id>
|
||||
GET /api/v1/inspections
|
||||
Returns the authenticated inspector's own inspection history.
|
||||
Supports ?limit=N&offset=N&facility_id=N&status=completed
|
||||
&follow_up_required=true
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -95,6 +96,58 @@ def _parse_datetime(value):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_schedule(schedule_id, user):
|
||||
"""Resolve a client-supplied scheduled_inspection_id, or None.
|
||||
|
||||
The iPad sends this when the inspector taps Start on a scheduled row;
|
||||
without it the inspection lands unlinked and the schedule is never fulfilled
|
||||
(no "Scheduled" badge, and the dashboard banner never clears).
|
||||
|
||||
NON-BLOCKING BY DESIGN. A bad link drops the link and logs a warning — it
|
||||
never fails the submission. The app is offline-first, so a schedule can
|
||||
legitimately be deleted or reassigned while a completed inspection sits in
|
||||
the outbox for days; erroring here would retry-fail that inspection and
|
||||
strand the inspector's work (and its photos) permanently. A missed fulfil is
|
||||
recoverable from the web UI; a stranded submission is not.
|
||||
|
||||
The ownership check still matters: accepting a foreign link would let one
|
||||
inspector fulfil another's schedule. So the link is refused — but the
|
||||
inspection itself is still accepted.
|
||||
"""
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
|
||||
sched = db.session.get(ScheduledInspection, schedule_id)
|
||||
if sched is None:
|
||||
logger.warning('API INSPECTIONS | unknown scheduled_inspection_id=%s from user=%s '
|
||||
'— submitting unlinked', schedule_id, user.username)
|
||||
return None
|
||||
if user.role == 'inspector' and sched.inspector_id != user.id:
|
||||
logger.warning('API INSPECTIONS | scheduled_inspection_id=%s not assigned to user=%s '
|
||||
'— submitting unlinked', schedule_id, user.username)
|
||||
return None
|
||||
return sched
|
||||
|
||||
|
||||
def _fulfill_schedule(inspection):
|
||||
"""Roll the originating schedule forward / deactivate it. Caller commits.
|
||||
|
||||
Mirrors the web execute route: one-time schedules deactivate (so the
|
||||
dashboard banner, which filters on active, disappears), recurring ones
|
||||
advance to their next occurrence and reset the reminder flags.
|
||||
"""
|
||||
if not inspection.scheduled_inspection_id:
|
||||
return
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
|
||||
sched = db.session.get(ScheduledInspection, inspection.scheduled_inspection_id)
|
||||
if sched is None:
|
||||
return
|
||||
sched.fulfill()
|
||||
logger.info('API INSPECTIONS | schedule fulfilled | schedule=%s | inspection=%s | next=%s',
|
||||
sched.id, inspection.id,
|
||||
sched.next_due_date if sched.active else 'deactivated')
|
||||
|
||||
|
||||
def _media(key):
|
||||
"""Absolute display URL for a storage key (presigned on R2, absolute-static
|
||||
on local). '' for falsy keys. Used for iPad image rendering."""
|
||||
@@ -157,6 +210,10 @@ def _inspection_payload(inspection):
|
||||
'follow_up_required': inspection.follow_up_required,
|
||||
'follow_up_note': inspection.follow_up_note,
|
||||
'parent_inspection_id': inspection.parent_inspection_id,
|
||||
# Set when this inspection was started from a ScheduledInspection —
|
||||
# drives the "Scheduled" badge on the web list and lets the iPad show
|
||||
# the same marker in history.
|
||||
'scheduled_inspection_id': inspection.scheduled_inspection_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +236,13 @@ def list_inspections():
|
||||
status str filter by status (completed, in_progress, flagged)
|
||||
from_date str ISO date (YYYY-MM-DD) — include inspections on/after this date
|
||||
to_date str ISO date (YYYY-MM-DD) — include inspections on/before this date
|
||||
follow_up_required
|
||||
str "true"/"1" — only inspections a director has flagged as
|
||||
needing a follow-up and that no re-inspection has answered
|
||||
yet (flagged + completed + no child), matching what
|
||||
"Follow-up" means on the web. Drives the iPad's FOLLOW-UP
|
||||
REQUESTED card, so it must return the complete outstanding
|
||||
set, not just the recent page the history list shows.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
@@ -215,6 +279,24 @@ def list_inspections():
|
||||
if status:
|
||||
query = query.filter(Inspection.status == status)
|
||||
|
||||
if request.args.get('follow_up_required', '').lower() in ('true', '1'):
|
||||
# Must mean exactly what "Follow-up" means everywhere on the web
|
||||
# (inspections.list / reports status_filter == 'follow_up'): flagged,
|
||||
# completed, and not yet answered by a linked re-inspection.
|
||||
#
|
||||
# The ~follow_ups.any() clause is the one that matters. The web execute
|
||||
# route never clears follow_up_required on the parent — it only stops
|
||||
# listing it once a child exists — so filtering on the flag alone would
|
||||
# return follow-ups that were already satisfied on the web, forever.
|
||||
# On the iPad those rows are undismissable: pull_follow_up_requests()
|
||||
# keeps receiving them and update(from:) resets fulfilledLocally, so the
|
||||
# FOLLOW-UP REQUESTED card would never clear. (The mobile POST path does
|
||||
# clear the parent flag, so only web-completed re-inspections stick.)
|
||||
query = query.filter(
|
||||
Inspection.follow_up_required.is_(True),
|
||||
Inspection.status == 'completed',
|
||||
).filter(~Inspection.follow_ups.any())
|
||||
|
||||
from_date_str = request.args.get('from_date')
|
||||
if from_date_str:
|
||||
try:
|
||||
@@ -333,6 +415,25 @@ def create_inspection():
|
||||
if parent is None:
|
||||
return api_error('Parent inspection not found', 404)
|
||||
|
||||
# ── Optional schedule link (started from a ScheduledInspection) ───────
|
||||
scheduled_inspection_id = None
|
||||
if data.get('scheduled_inspection_id'):
|
||||
sched = _resolve_schedule(data['scheduled_inspection_id'], user)
|
||||
scheduled_inspection_id = sched.id if sched else None
|
||||
|
||||
# phase45 — inherit the follow-up link from the schedule when the
|
||||
# client did not send one. A schedule created by "Schedule Follow-up"
|
||||
# knows which inspection it answers, so the link should not depend on
|
||||
# the client remembering to pass it: an older build, or a draft resumed
|
||||
# after the cached row was refreshed, would otherwise submit a plain
|
||||
# inspection and leave the parent flagged forever. Never overrides an
|
||||
# explicit parent_inspection_id.
|
||||
if not parent_inspection_id and sched and sched.parent_inspection_id:
|
||||
parent_inspection_id = sched.parent_inspection_id
|
||||
logger.info('API INSPECTIONS | parent inherited from schedule | '
|
||||
'schedule=%s | parent=%s | user=%s',
|
||||
sched.id, parent_inspection_id, user.username)
|
||||
|
||||
# ── Score calculation ─────────────────────────────────────────────────
|
||||
overall_score = data.get('overall_score')
|
||||
if overall_score is None and status == 'completed':
|
||||
@@ -383,11 +484,19 @@ def create_inspection():
|
||||
parent_inspection_id = parent_inspection_id,
|
||||
submit_latitude = submit_latitude,
|
||||
submit_longitude = submit_longitude,
|
||||
scheduled_inspection_id = scheduled_inspection_id,
|
||||
)
|
||||
|
||||
db.session.add(inspection)
|
||||
db.session.flush()
|
||||
|
||||
# ── Fulfil the originating schedule ───────────────────────────────────
|
||||
# Staged into the same atomic commit as the inspection, mirroring the web
|
||||
# execute route. Without this the schedule stays active: the dashboard
|
||||
# banner and the iPad "Scheduled" section never clear.
|
||||
if status == 'completed':
|
||||
_fulfill_schedule(inspection)
|
||||
|
||||
# ── Auto-clear follow-up flag on parent ───────────────────────────────
|
||||
# When a completed re-inspection arrives that links to a parent, clear
|
||||
# follow_up_required on the parent automatically. This mirrors the web
|
||||
@@ -512,6 +621,13 @@ def update_inspection(inspection_id):
|
||||
|
||||
prev_status = inspection.status
|
||||
|
||||
# Allow the link to be set/corrected on PATCH too — the iPad may create the
|
||||
# inspection as a draft first and only attach the schedule on submit.
|
||||
if data.get('scheduled_inspection_id'):
|
||||
sched = _resolve_schedule(data['scheduled_inspection_id'], user)
|
||||
if sched is not None:
|
||||
inspection.scheduled_inspection_id = sched.id
|
||||
|
||||
if 'status' in data:
|
||||
inspection.status = data['status']
|
||||
|
||||
@@ -527,12 +643,17 @@ def update_inspection(inspection_id):
|
||||
elif data.get('status') == 'completed' and not inspection.completed_at:
|
||||
inspection.completed_at = now_eastern()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Notify when a draft transitions to completed — mirrors the POST handler.
|
||||
transitioning_to_complete = (
|
||||
data.get('status') == 'completed' and prev_status != 'completed'
|
||||
)
|
||||
# Fulfil the schedule on the draft → completed transition only, so a later
|
||||
# PATCH on an already-completed inspection can't roll it forward twice.
|
||||
if transitioning_to_complete:
|
||||
_fulfill_schedule(inspection)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Notify when a draft transitions to completed — mirrors the POST handler.
|
||||
if transitioning_to_complete:
|
||||
score_val = inspection.overall_score
|
||||
score_display = f'{score_val:.1f}%' if score_val is not None else 'N/A'
|
||||
|
||||
@@ -17,12 +17,16 @@ app/models/scheduled_inspection.py for the full lifecycle.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
from app import db
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
from app.models.inspection import Inspection
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.scope import get_inspector_scope
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,9 +46,25 @@ def _scheduled_payload(s):
|
||||
'inspector_id': s.inspector_id,
|
||||
'frequency': s.frequency,
|
||||
'frequency_label': s.frequency_label,
|
||||
# phase43 recurrence detail. `recurrence_label` is the display string
|
||||
# ("Weekly · Mon, Wed, Fri"); the raw fields let the iPad render its own.
|
||||
'recurrence_label': s.recurrence_label,
|
||||
'weekdays': s.weekday_list,
|
||||
'month_mode': s.month_mode,
|
||||
'day_of_month': s.day_of_month,
|
||||
'nth_week': s.nth_week,
|
||||
'nth_weekday': s.nth_weekday,
|
||||
'next_due_date': s.next_due_date.isoformat() if s.next_due_date else None,
|
||||
# phase44. Additive: the iPad decodes explicit CodingKeys, so a build
|
||||
# that predates this key ignores it rather than failing to decode.
|
||||
'end_date': s.end_date.isoformat() if s.end_date else None,
|
||||
'is_overdue': s.is_overdue(),
|
||||
'notes': s.notes or None,
|
||||
# phase45. Set when this schedule is a planned follow-up of a specific
|
||||
# inspection; the iPad carries it onto the inspection it starts so the
|
||||
# run lands as a linked re-inspection. Additive — older builds decode
|
||||
# explicit CodingKeys and ignore it.
|
||||
'parent_inspection_id': s.parent_inspection_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -102,3 +122,118 @@ def list_scheduled():
|
||||
|
||||
return api_ok({'scheduled': payload, 'total': total,
|
||||
'limit': limit, 'offset': offset})
|
||||
|
||||
|
||||
# ── Create a scheduled follow-up (phase45) ────────────────────────────────────
|
||||
|
||||
@bp.route('/scheduled-inspections/follow-up', methods=['POST'])
|
||||
@jwt_required
|
||||
def create_follow_up():
|
||||
"""
|
||||
Plan a follow-up re-inspection of a completed inspection for a later date.
|
||||
|
||||
Backs "Schedule Follow-up" in the iPad's inspection history detail, the
|
||||
deferred twin of "Re-inspect Now". Creates a one-time schedule carrying
|
||||
`parent_inspection_id`, so the inspection eventually started from it is a
|
||||
true linked re-inspection.
|
||||
|
||||
Deliberately narrow: this is not a general schedule-creation endpoint. The
|
||||
facility, template and assignee are all derived from the parent inspection
|
||||
rather than taken from the client, so a follow-up can only ever target the
|
||||
thing it is a follow-up of. Recurring schedules stay web-only
|
||||
(`@project_manager_required`).
|
||||
|
||||
Request body
|
||||
------------
|
||||
parent_inspection_id int required — the completed inspection to follow up
|
||||
due_date str required — ISO date (YYYY-MM-DD), today or later
|
||||
notes str optional — what the follow-up should address
|
||||
|
||||
Response 200/201
|
||||
----------------
|
||||
{ "ok": true, "data": { "scheduled": {...}, "created": true } }
|
||||
"""
|
||||
user = g.api_user
|
||||
|
||||
# Auditor is read-only everywhere else; keep it that way here.
|
||||
if user.role not in {'admin', 'director', 'inspector', 'project_manager'}:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
|
||||
parent_id = body.get('parent_inspection_id')
|
||||
if not isinstance(parent_id, int):
|
||||
return api_error('parent_inspection_id is required', 400)
|
||||
|
||||
parent = db.session.get(Inspection, parent_id)
|
||||
if parent is None:
|
||||
return api_error('Inspection not found', 404)
|
||||
|
||||
# An inspector may only schedule a follow-up of their own work, and only
|
||||
# within their assigned contracts — the same two gates the rest of the
|
||||
# mobile API applies. Managers are unrestricted, matching the web.
|
||||
if user.role == 'inspector':
|
||||
if parent.inspector_id != user.id:
|
||||
return api_error('Access denied', 403)
|
||||
fids = get_inspector_scope(user)
|
||||
if not fids or parent.facility_id not in fids:
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
# A follow-up only makes sense once there is something to follow up on.
|
||||
if parent.status != 'completed':
|
||||
return api_error('Only a completed inspection can have a follow-up '
|
||||
'scheduled', 400)
|
||||
|
||||
due_raw = (body.get('due_date') or '').strip()
|
||||
try:
|
||||
due_date = datetime.strptime(due_raw, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return api_error('due_date must be an ISO date (YYYY-MM-DD)', 400)
|
||||
|
||||
# Today is allowed — "later today" is a legitimate plan; yesterday is not.
|
||||
if due_date < now_eastern().date():
|
||||
return api_error('due_date cannot be in the past', 400)
|
||||
|
||||
notes = (body.get('notes') or '').strip() or None
|
||||
|
||||
# Idempotent: the iPad may retry a request whose response was lost, and a
|
||||
# second identical schedule would put a duplicate row in the inspector's
|
||||
# Scheduled list with no way to tell them apart. Reuse the existing active
|
||||
# follow-up for this parent instead, updating the date they just picked.
|
||||
existing = (ScheduledInspection.query
|
||||
.filter_by(parent_inspection_id=parent.id, active=True)
|
||||
.order_by(ScheduledInspection.id.desc())
|
||||
.first())
|
||||
if existing is not None:
|
||||
existing.next_due_date = due_date
|
||||
if notes:
|
||||
existing.notes = notes
|
||||
db.session.commit()
|
||||
logger.info('API SCHEDULED | follow-up updated | schedule=%s | '
|
||||
'parent=%s | due=%s | user=%s',
|
||||
existing.id, parent.id, due_date, user.username)
|
||||
return api_ok({'scheduled': _scheduled_payload(existing),
|
||||
'created': False})
|
||||
|
||||
sched = ScheduledInspection(
|
||||
facility_id = parent.facility_id,
|
||||
template_id = parent.template_id,
|
||||
# Assign to whoever performed the original — they are the one being
|
||||
# asked to put it right. Falls back to the caller when the parent has
|
||||
# no inspector (its account was deleted).
|
||||
inspector_id = parent.inspector_id or user.id,
|
||||
frequency = 'once',
|
||||
next_due_date = due_date,
|
||||
active = True,
|
||||
notes = notes,
|
||||
parent_inspection_id = parent.id,
|
||||
created_by = user.id,
|
||||
)
|
||||
db.session.add(sched)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('API SCHEDULED | follow-up created | schedule=%s | parent=%s | '
|
||||
'facility=%s | due=%s | user=%s',
|
||||
sched.id, parent.id, parent.facility_id, due_date, user.username)
|
||||
|
||||
return api_ok({'scheduled': _scheduled_payload(sched), 'created': True}, 201)
|
||||
|
||||
@@ -81,6 +81,14 @@ class Inspection(db.Model):
|
||||
)
|
||||
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
|
||||
follow_up_note = db.Column(db.Text, nullable=True)
|
||||
# Who asked for the follow-up (phase46). NULL for legacy rows flagged before
|
||||
# the column existed. Matters because customers can now raise the request
|
||||
# themselves — staff need to see at a glance that the client is waiting on
|
||||
# this one, not another internal reviewer.
|
||||
follow_up_requested_by = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
|
||||
)
|
||||
follow_up_requested_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
||||
issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
|
||||
@@ -88,6 +96,10 @@ class Inspection(db.Model):
|
||||
# None for ad-hoc/manual inspections or if the schedule was later deleted.
|
||||
scheduled_inspection = db.relationship('ScheduledInspection',
|
||||
foreign_keys=[scheduled_inspection_id])
|
||||
# The user who requested the follow-up (phase46) — a customer or a manager.
|
||||
# Explicit foreign_keys: `inspector_id` also points at users.
|
||||
follow_up_requester = db.relationship('User',
|
||||
foreign_keys=[follow_up_requested_by])
|
||||
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
|
||||
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all
|
||||
# overdue to admin/director). Phase 36.
|
||||
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection'
|
||||
|
||||
# Fired when someone asks for a follow-up re-inspection of a completed
|
||||
# inspection. Raised by admin/director from the inspection page and — since
|
||||
# phase46 — by CUSTOMERS for their own facilities. Phase 46.
|
||||
EVENT_FOLLOWUP_REQUESTED = 'followup_requested'
|
||||
|
||||
ALL_EVENT_TYPES = {
|
||||
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
||||
EVENT_ISSUE_STATUS: 'Issue status changed',
|
||||
@@ -49,6 +54,7 @@ ALL_EVENT_TYPES = {
|
||||
EVENT_SCORE_ALERT: 'Facility score trend alert (significant drop detected)',
|
||||
# Scheduled inspection reminders (due/advance/overdue)
|
||||
EVENT_SCHEDULED_INSPECTION: 'Scheduled inspection reminders (due / overdue)',
|
||||
EVENT_FOLLOWUP_REQUESTED: 'Follow-up re-inspection requested',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ issue_flagged : admin ✓ director ✓ inspector ✗ pm ✗ cust
|
||||
issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
|
||||
issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
|
||||
verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
|
||||
followup_requested : admin ✓ director ✓ inspector ✗ pm ✓ customer ✗ (inspection's own inspector implicit)
|
||||
sla_alert : admin ✓ director ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit)
|
||||
score_alert : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗ (facility score drop cron)
|
||||
"""
|
||||
@@ -63,6 +64,7 @@ MATRIX_EVENTS = {
|
||||
'issue_created': 'Issue created (standalone)',
|
||||
'issue_updated_customer': 'Issue updated (customer)',
|
||||
'verification_requested': 'Verification requested',
|
||||
'followup_requested': 'Follow-up requested (incl. by customer)',
|
||||
'sla_alert': 'SLA at-risk / breached',
|
||||
'score_alert': 'Facility score trend alert (significant drop)',
|
||||
}
|
||||
@@ -147,6 +149,16 @@ MATRIX_DEFAULTS = {
|
||||
('verification_requested', 'project_manager'): False,
|
||||
('verification_requested', 'customer'): False,
|
||||
('verification_requested', 'custom'): False,
|
||||
# followup_requested — a customer (or manager) asks for a re-inspection.
|
||||
# On for the roles who action it; the inspection's own inspector is
|
||||
# notified directly by the route, so the inspector column stays off to
|
||||
# avoid alerting the whole inspector pool.
|
||||
('followup_requested', 'admin'): True,
|
||||
('followup_requested', 'director'): True,
|
||||
('followup_requested', 'inspector'): False,
|
||||
('followup_requested', 'project_manager'): True,
|
||||
('followup_requested', 'customer'): False,
|
||||
('followup_requested', 'custom'): False,
|
||||
# sla_alert (assignee + followers always notified implicitly)
|
||||
('sla_alert', 'admin'): True,
|
||||
('sla_alert', 'director'): False,
|
||||
|
||||
@@ -17,15 +17,64 @@ POST /scheduled-inspections/run?token=DIGEST_SECRET:
|
||||
- overdue alert to admin/director once the due date passes uncompleted
|
||||
The *_notified flags make each of those fire at most once per occurrence and
|
||||
reset when a recurring schedule rolls forward.
|
||||
|
||||
Two dates, deliberately distinct (phase44):
|
||||
next_due_date — mutable state. The next occurrence. Rewritten by fulfill()
|
||||
after every completed inspection.
|
||||
end_date — fixed boundary. The last date an occurrence may fall on,
|
||||
set by the manager and never rewritten. NULL = forever.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly')
|
||||
|
||||
# Monthly recurrence styles (phase43). Stored as VARCHAR, not ENUM, so adding a
|
||||
# style later needs no 3-step MySQL ENUM dance (CLAUDE.md rule 3).
|
||||
MONTH_MODE_DAY = 'day_of_month' # "the 15th of every month"
|
||||
MONTH_MODE_NTH = 'nth_weekday' # "the 2nd Tuesday of every month"
|
||||
|
||||
# Python weekday numbering: Monday=0 … Sunday=6 (matches date.weekday()).
|
||||
WEEKDAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday')
|
||||
WEEKDAY_ABBREV = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
|
||||
|
||||
# nth_week: 1–4 are literal, 5 means "5th (or last if the month is short)",
|
||||
# -1 means "last" explicitly.
|
||||
NTH_WEEK_LABELS = {1: '1st', 2: '2nd', 3: '3rd', 4: '4th', 5: '5th', -1: 'Last'}
|
||||
|
||||
|
||||
def _last_day_of(year, month):
|
||||
return calendar.monthrange(year, month)[1]
|
||||
|
||||
|
||||
def _shift_month(year, month, n=1):
|
||||
"""Return (year, month) shifted by *n* months."""
|
||||
idx = year * 12 + (month - 1) + n
|
||||
return idx // 12, idx % 12 + 1
|
||||
|
||||
|
||||
def _nth_weekday_of(year, month, weekday, nth):
|
||||
"""Date of the *nth* *weekday* in a month.
|
||||
|
||||
``nth == -1`` means the last one. A requested 5th occurrence that does not
|
||||
exist falls back to the 4th, so every month yields a valid date.
|
||||
"""
|
||||
last = _last_day_of(year, month)
|
||||
if nth == -1:
|
||||
d = date(year, month, last)
|
||||
return d - timedelta(days=(d.weekday() - weekday) % 7)
|
||||
first = date(year, month, 1)
|
||||
day = 1 + ((weekday - first.weekday()) % 7) + (nth - 1) * 7
|
||||
while day > last:
|
||||
day -= 7
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
class ScheduledInspection(db.Model):
|
||||
__tablename__ = 'scheduled_inspections'
|
||||
@@ -42,9 +91,40 @@ class ScheduledInspection(db.Model):
|
||||
nullable=True, index=True)
|
||||
frequency = db.Column(db.Enum(*FREQUENCY_CHOICES), nullable=False, default='once')
|
||||
next_due_date = db.Column(db.Date, nullable=False, index=True)
|
||||
# Fixed boundary set by the manager, never rewritten by the app — unlike
|
||||
# next_due_date, which fulfill() advances after every completed inspection.
|
||||
# NULL = repeat indefinitely. Only meaningful for recurring schedules; the
|
||||
# create/edit routes force it to NULL when frequency == 'once'.
|
||||
end_date = db.Column(db.Date, nullable=True)
|
||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# ── Follow-up link (phase45) ─────────────────────────────────────────────
|
||||
# Set when this schedule was created as a follow-up of a specific completed
|
||||
# inspection ("Schedule Follow-up" in the iPad's history detail). The
|
||||
# inspection eventually started from this schedule inherits it as its
|
||||
# parent_inspection_id, so it lands as a true linked re-inspection —
|
||||
# pre-filled from the parent and clearing the parent's follow_up_required on
|
||||
# submit. NULL = an ordinary schedule, which is what every pre-phase45 row
|
||||
# is.
|
||||
parent_inspection_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('inspections.id', ondelete='SET NULL'),
|
||||
nullable=True, index=True,
|
||||
)
|
||||
|
||||
# ── Recurrence detail (phase43) ──────────────────────────────────────────
|
||||
# weekly : CSV of Python weekday ints, e.g. '0,2,4' = Mon/Wed/Fri.
|
||||
# NULL/empty falls back to the legacy "every 7 days" behaviour.
|
||||
# monthly : month_mode picks which pair of columns applies —
|
||||
# MONTH_MODE_DAY → day_of_month; MONTH_MODE_NTH → nth_week + nth_weekday.
|
||||
# NULL falls back to the legacy "same day next month" behaviour.
|
||||
weekdays = db.Column(db.String(20), nullable=True)
|
||||
month_mode = db.Column(db.String(20), nullable=True)
|
||||
day_of_month = db.Column(db.SmallInteger, nullable=True)
|
||||
nth_week = db.Column(db.SmallInteger, nullable=True)
|
||||
nth_weekday = db.Column(db.SmallInteger, nullable=True)
|
||||
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
@@ -60,6 +140,13 @@ class ScheduledInspection(db.Model):
|
||||
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
|
||||
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
# Explicit foreign_keys is required, not optional: inspections and
|
||||
# scheduled_inspections now reference each other (Inspection
|
||||
# .scheduled_inspection_id points here, parent_inspection_id points back),
|
||||
# so SQLAlchemy cannot infer the join for either side. Inspection
|
||||
# .scheduled_inspection is already declared the same way.
|
||||
parent_inspection = db.relationship('Inspection',
|
||||
foreign_keys=[parent_inspection_id])
|
||||
|
||||
FREQUENCY_LABELS = {
|
||||
'once': 'One-time',
|
||||
@@ -72,42 +159,166 @@ class ScheduledInspection(db.Model):
|
||||
def frequency_label(self):
|
||||
return self.FREQUENCY_LABELS.get(self.frequency, self.frequency)
|
||||
|
||||
# ── Recurrence accessors ─────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def weekday_list(self):
|
||||
"""Selected weekdays as a sorted list of ints (Mon=0). [] if unset."""
|
||||
if not self.weekdays:
|
||||
return []
|
||||
out = set()
|
||||
for part in str(self.weekdays).split(','):
|
||||
part = part.strip()
|
||||
if part.lstrip('-').isdigit() and 0 <= int(part) <= 6:
|
||||
out.add(int(part))
|
||||
return sorted(out)
|
||||
|
||||
def set_weekdays(self, values):
|
||||
"""Store an iterable of weekday ints as the CSV column (None if empty)."""
|
||||
clean = sorted({int(v) for v in (values or []) if 0 <= int(v) <= 6})
|
||||
self.weekdays = ','.join(str(v) for v in clean) or None
|
||||
|
||||
@property
|
||||
def recurrence_label(self):
|
||||
"""Human summary of the recurrence rule, e.g. 'Weekly · Mon, Wed, Fri'."""
|
||||
base = self.frequency_label
|
||||
if self.frequency == 'weekly':
|
||||
days = self.weekday_list
|
||||
if days:
|
||||
return f"{base} · {', '.join(WEEKDAY_ABBREV[d] for d in days)}"
|
||||
elif self.frequency == 'monthly':
|
||||
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
|
||||
nth = NTH_WEEK_LABELS.get(self.nth_week, str(self.nth_week))
|
||||
return f'{base} · {nth} {WEEKDAY_NAMES[self.nth_weekday]}'
|
||||
if self.day_of_month:
|
||||
return f'{base} · day {self.day_of_month}'
|
||||
return base
|
||||
|
||||
# ── Date arithmetic ──────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _add_interval(d, frequency):
|
||||
"""Return d advanced by one interval of the given frequency."""
|
||||
"""Return d advanced by one plain interval of *frequency*.
|
||||
|
||||
Fallback used when no day-of-week / day-of-month detail is configured
|
||||
(legacy phase36 rows). Prefer :meth:`next_occurrence_after`.
|
||||
"""
|
||||
if frequency == 'daily':
|
||||
return d + timedelta(days=1)
|
||||
if frequency == 'weekly':
|
||||
return d + timedelta(weeks=1)
|
||||
if frequency == 'monthly':
|
||||
# Add ~1 month by stepping 28–31 days to the same day-of-month where possible.
|
||||
month = d.month + 1
|
||||
year = d.year + (1 if month > 12 else 0)
|
||||
month = 1 if month > 12 else month
|
||||
day = min(d.day, 28) # clamp to avoid invalid dates (e.g. Feb 30)
|
||||
return d.replace(year=year, month=month, day=day)
|
||||
year, month = _shift_month(d.year, d.month, 1)
|
||||
return date(year, month, min(d.day, _last_day_of(year, month)))
|
||||
return d # 'once' has no next interval
|
||||
|
||||
def next_occurrence_after(self, d):
|
||||
"""First occurrence strictly after date *d*, honouring the day rules."""
|
||||
if self.frequency == 'weekly':
|
||||
days = self.weekday_list
|
||||
if days:
|
||||
for step in range(1, 8):
|
||||
cand = d + timedelta(days=step)
|
||||
if cand.weekday() in days:
|
||||
return cand
|
||||
elif self.frequency == 'monthly':
|
||||
year, month = _shift_month(d.year, d.month, 1)
|
||||
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
|
||||
return _nth_weekday_of(year, month, self.nth_weekday, self.nth_week)
|
||||
if self.day_of_month:
|
||||
return date(year, month, min(self.day_of_month, _last_day_of(year, month)))
|
||||
return self._add_interval(d, self.frequency)
|
||||
|
||||
def align_due_date(self, d):
|
||||
"""Snap *d* forward to the first date on/after it that fits the rule.
|
||||
|
||||
Lets a manager pick any start date and still get, say, Mon/Wed/Fri:
|
||||
picking a Tuesday for a Mon/Wed/Fri schedule yields that Wednesday.
|
||||
"""
|
||||
if self.frequency == 'weekly':
|
||||
days = self.weekday_list
|
||||
if days:
|
||||
for step in range(0, 7):
|
||||
cand = d + timedelta(days=step)
|
||||
if cand.weekday() in days:
|
||||
return cand
|
||||
elif self.frequency == 'monthly':
|
||||
if self.month_mode == MONTH_MODE_NTH and self.nth_week and self.nth_weekday is not None:
|
||||
cand = _nth_weekday_of(d.year, d.month, self.nth_weekday, self.nth_week)
|
||||
elif self.day_of_month:
|
||||
cand = date(d.year, d.month,
|
||||
min(self.day_of_month, _last_day_of(d.year, d.month)))
|
||||
else:
|
||||
return d
|
||||
if cand < d:
|
||||
return self.next_occurrence_after(cand)
|
||||
return cand
|
||||
return d
|
||||
|
||||
def is_overdue(self, today=None):
|
||||
today = today or now_eastern().date()
|
||||
return self.active and self.next_due_date < today
|
||||
|
||||
# ── End-date boundary (phase44) ──────────────────────────────────────────
|
||||
|
||||
def is_within_end_date(self, d):
|
||||
"""True if date *d* is on or before the end date (inclusive).
|
||||
|
||||
No end date means the schedule repeats indefinitely, so every date
|
||||
qualifies.
|
||||
"""
|
||||
return self.end_date is None or d <= self.end_date
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""True once the end date has passed.
|
||||
|
||||
Independent of `active`: a schedule can be inactive because it expired
|
||||
or because a manager switched it off, and the list view distinguishes
|
||||
the two. Compare against the *end date* rather than `next_due_date`,
|
||||
which may have been advanced past the boundary by fulfill().
|
||||
"""
|
||||
if self.end_date is None:
|
||||
return False
|
||||
return self.end_date < now_eastern().date()
|
||||
|
||||
def expire_if_past_end_date(self, today=None):
|
||||
"""Deactivate a schedule whose end date has passed. Caller commits.
|
||||
|
||||
Returns True if this call changed anything. Needed because a schedule
|
||||
can reach its end date *without ever being completed* — fulfill() never
|
||||
runs, so the boundary would otherwise be checked nowhere and the cron
|
||||
would keep firing overdue alerts forever. Called from run_reminders().
|
||||
"""
|
||||
today = today or now_eastern().date()
|
||||
if self.active and self.end_date is not None and self.end_date < today:
|
||||
self.active = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def fulfill(self):
|
||||
"""Mark this occurrence complete. One-time schedules deactivate;
|
||||
recurring ones roll their due date forward past today and reset the
|
||||
reminder flags. Caller commits."""
|
||||
reminder flags. A recurring schedule whose next occurrence would fall
|
||||
past its end date deactivates instead. Caller commits."""
|
||||
self.last_completed_at = now_eastern()
|
||||
if self.frequency == 'once':
|
||||
self.active = False
|
||||
return
|
||||
# Recurring: advance until the next due date is in the future.
|
||||
today = now_eastern().date()
|
||||
nxt = self._add_interval(self.next_due_date, self.frequency)
|
||||
nxt = self.next_occurrence_after(self.next_due_date)
|
||||
guard = 0
|
||||
while nxt <= today and guard < 400:
|
||||
nxt = self._add_interval(nxt, self.frequency)
|
||||
nxt = self.next_occurrence_after(nxt)
|
||||
guard += 1
|
||||
self.next_due_date = nxt
|
||||
# Past the manager's boundary: this was the last occurrence. next_due_date
|
||||
# is left at the computed value rather than clamped, so the row still
|
||||
# shows which occurrence it stopped before.
|
||||
if not self.is_within_end_date(nxt):
|
||||
self.active = False
|
||||
return
|
||||
self.advance_notified = False
|
||||
self.due_notified = False
|
||||
self.overdue_notified = False
|
||||
|
||||
+6
-1
@@ -34,7 +34,12 @@ class User(UserMixin, db.Model):
|
||||
set_password_token_expires = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
|
||||
# Explicit foreign_keys: inspections now has a SECOND FK to users
|
||||
# (follow_up_requested_by, phase46), so the join is otherwise ambiguous.
|
||||
# This relationship means "inspections I performed" — inspector_id only.
|
||||
inspections = db.relationship('Inspection', backref='inspector',
|
||||
lazy='dynamic',
|
||||
foreign_keys='Inspection.inspector_id')
|
||||
|
||||
# ── Flask-Login integration ────────────────────────────────────────────
|
||||
# Override UserMixin.is_active so that disabled accounts are rejected
|
||||
|
||||
@@ -361,8 +361,10 @@ def index():
|
||||
# ── Scheduled inspections (phase36): upcoming / overdue ──────────────
|
||||
sched_upcoming = []
|
||||
sched_overdue_count = 0
|
||||
sched_open_inspections = {}
|
||||
if not is_customer:
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
from app.routes.scheduled_inspections import _open_inspection_ids
|
||||
_today = now.date()
|
||||
_sq = ScheduledInspection.query.filter_by(active=True)
|
||||
if is_inspector:
|
||||
@@ -374,11 +376,14 @@ def index():
|
||||
s for s in _all_sched
|
||||
if _today <= s.next_due_date <= _today + timedelta(days=7)
|
||||
][:8]
|
||||
# Offer Continue (not a duplicate Start) where one is already underway.
|
||||
sched_open_inspections = _open_inspection_ids(sched_upcoming)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
sched_upcoming = sched_upcoming,
|
||||
sched_overdue_count = sched_overdue_count,
|
||||
sched_open_inspections = sched_open_inspections,
|
||||
submitted_this_week = submitted_this_week,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
|
||||
+71
-16
@@ -21,6 +21,7 @@ from app.utils.notifications import notify, notify_customers_for_facility, notif
|
||||
from app.models.notification import (
|
||||
EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED,
|
||||
EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED,
|
||||
EVENT_FOLLOWUP_REQUESTED,
|
||||
)
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||
from app.utils.scope import get_customer_scope, get_inspector_scope
|
||||
@@ -1215,29 +1216,61 @@ def export_pdf(inspection_id):
|
||||
|
||||
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
|
||||
@login_required
|
||||
@supervisor_required
|
||||
def flag_followup(inspection_id):
|
||||
"""Mark an inspection as requiring a follow-up re-inspection."""
|
||||
"""Mark an inspection as requiring a follow-up re-inspection.
|
||||
|
||||
Open to admin/director AND to customers for their own facilities — a client
|
||||
unhappy with a result can ask for a re-inspection directly rather than
|
||||
going through support. Every other role is refused.
|
||||
|
||||
Customers may only *request*: they cannot clear the flag (see
|
||||
clear_followup, still admin/director) nor run the re-inspection itself.
|
||||
"""
|
||||
inspection = db.session.get(Inspection, inspection_id)
|
||||
if inspection is None:
|
||||
abort(404)
|
||||
|
||||
is_customer = current_user.role == 'customer'
|
||||
if is_customer:
|
||||
# Same facility scope as view() — a customer must not be able to reach
|
||||
# another client's inspection with a crafted POST.
|
||||
if inspection.facility_id not in (get_customer_scope(current_user) or []):
|
||||
abort(403)
|
||||
# Nothing to follow up on until the inspection has been submitted.
|
||||
if inspection.status != 'completed':
|
||||
flash('You can only request a follow-up on a completed inspection.', 'warning')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
# Don't let a repeat request overwrite the note/attribution of a pending
|
||||
# one — the flag is already raised and staff are already on it.
|
||||
if inspection.follow_up_required:
|
||||
flash('A follow-up has already been requested for this inspection.', 'info')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
elif current_user.role not in ('admin', 'director'):
|
||||
abort(403)
|
||||
|
||||
note = request.form.get('follow_up_note', '').strip() or None
|
||||
|
||||
inspection.follow_up_required = True
|
||||
inspection.follow_up_note = note
|
||||
inspection.follow_up_required = True
|
||||
inspection.follow_up_note = note
|
||||
inspection.follow_up_requested_by = current_user.id
|
||||
inspection.follow_up_requested_at = now_eastern()
|
||||
db.session.commit()
|
||||
|
||||
# Notify the original inspector so they see it on the iPad
|
||||
note_suffix = f' Note: {note}' if note else ''
|
||||
who = (f'The customer ({current_user.display_name})' if is_customer
|
||||
else current_user.display_name)
|
||||
body = (
|
||||
f'{who} has requested a follow-up re-inspection '
|
||||
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
|
||||
)
|
||||
|
||||
# Notify the original inspector so they see it on the iPad.
|
||||
inspector = db.session.get(User, inspection.inspector_id)
|
||||
if inspector and inspector.id != current_user.id:
|
||||
note_suffix = f' Note: {note}' if note else ''
|
||||
notify(
|
||||
recipient = inspector,
|
||||
title = f'Follow-Up Required: Inspection #{inspection_id}',
|
||||
body = (
|
||||
f'{current_user.username} has requested a follow-up re-inspection '
|
||||
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
|
||||
),
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=inspection_id),
|
||||
inspection_id = inspection_id,
|
||||
event_type = EVENT_INSPECTION_DONE,
|
||||
@@ -1245,14 +1278,34 @@ def flag_followup(inspection_id):
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
# Route to the staff who action follow-ups. Going through notify_by_matrix
|
||||
# rather than notifying managers directly keeps recipients admin-configurable
|
||||
# and lets per-contract recipients fire too (rule 73). This matters most for
|
||||
# a customer request: without it only the inspector would hear about it and
|
||||
# nobody would be accountable for scheduling the re-inspection.
|
||||
notify_by_matrix(
|
||||
event_type = EVENT_FOLLOWUP_REQUESTED,
|
||||
title = f'Follow-Up Requested: Inspection #{inspection_id}',
|
||||
body = body,
|
||||
link = url_for('inspections.view', inspection_id=inspection_id),
|
||||
inspection_id = inspection_id,
|
||||
facility_id = inspection.facility_id,
|
||||
exclude_user_ids = {current_user.id,
|
||||
inspector.id if inspector else None} - {None},
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
current_app.logger.info(
|
||||
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r',
|
||||
inspection_id, current_user.username, note,
|
||||
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s (%s) | note=%r',
|
||||
inspection_id, current_user.username, current_user.role, note,
|
||||
)
|
||||
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
f'follow_up_required=True; note={note!r}')
|
||||
flash('Follow-up inspection required flag set.', 'warning')
|
||||
f'follow_up_required=True; by_role={current_user.role}; note={note!r}')
|
||||
if is_customer:
|
||||
flash('Follow-up re-inspection requested. The team has been notified.', 'success')
|
||||
else:
|
||||
flash('Follow-up inspection required flag set.', 'warning')
|
||||
return redirect(url_for('inspections.view', inspection_id=inspection_id))
|
||||
|
||||
|
||||
@@ -1264,8 +1317,10 @@ def clear_followup(inspection_id):
|
||||
inspection = db.session.get(Inspection, inspection_id)
|
||||
if inspection is None:
|
||||
abort(404)
|
||||
inspection.follow_up_required = False
|
||||
inspection.follow_up_note = None
|
||||
inspection.follow_up_required = False
|
||||
inspection.follow_up_note = None
|
||||
inspection.follow_up_requested_by = None
|
||||
inspection.follow_up_requested_at = None
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
|
||||
@@ -22,7 +22,8 @@ from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
from app.models.scheduled_inspection import (ScheduledInspection,
|
||||
MONTH_MODE_DAY, MONTH_MODE_NTH)
|
||||
from app.models.facility import Facility
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.project import Project
|
||||
@@ -79,6 +80,72 @@ def _notify_assignee(sched, reassigned=False):
|
||||
)
|
||||
|
||||
|
||||
def _apply_recurrence(sched, form):
|
||||
"""Copy the recurrence block for the chosen frequency onto *sched* and
|
||||
clear the blocks that no longer apply, then snap next_due_date onto the
|
||||
rule. Keeping the unused columns NULL means `recurrence_label` and the
|
||||
date math never read stale settings after a frequency change."""
|
||||
sched.frequency = form.frequency.data
|
||||
|
||||
if sched.frequency == 'weekly':
|
||||
sched.set_weekdays(form.weekdays.data)
|
||||
else:
|
||||
sched.weekdays = None
|
||||
|
||||
if sched.frequency == 'monthly':
|
||||
sched.month_mode = form.month_mode.data or MONTH_MODE_DAY
|
||||
if sched.month_mode == MONTH_MODE_NTH:
|
||||
sched.day_of_month = None
|
||||
sched.nth_week = form.nth_week.data
|
||||
sched.nth_weekday = form.nth_weekday.data
|
||||
else:
|
||||
sched.day_of_month = form.day_of_month.data
|
||||
sched.nth_week = None
|
||||
sched.nth_weekday = None
|
||||
else:
|
||||
sched.month_mode = sched.day_of_month = None
|
||||
sched.nth_week = sched.nth_weekday = None
|
||||
|
||||
# End date (phase44) — a boundary, not a cadence setting. A one-time
|
||||
# schedule has none: it ends by deactivating when it is completed.
|
||||
sched.end_date = form.end_date.data if sched.frequency != 'once' else None
|
||||
|
||||
# Snap the picked date forward onto the first matching occurrence.
|
||||
sched.next_due_date = sched.align_due_date(form.next_due_date.data)
|
||||
|
||||
|
||||
def _reject_if_past_end_date(sched, form):
|
||||
"""True (and a form error set) if the aligned first occurrence falls past
|
||||
the end date.
|
||||
|
||||
The form already rejects an end date earlier than the *picked* due date, but
|
||||
align_due_date() can push that date forward onto the recurrence rule — pick
|
||||
a Tuesday for a Mon/Wed/Fri schedule and the first occurrence is Wednesday.
|
||||
Without this check that combination would save as active with no occurrence
|
||||
it is ever allowed to run.
|
||||
"""
|
||||
if sched.is_within_end_date(sched.next_due_date):
|
||||
return False
|
||||
form.end_date.errors.append(
|
||||
f'With this recurrence the first occurrence falls on '
|
||||
f'{sched.next_due_date:%b %d, %Y}, after the end date.')
|
||||
return True
|
||||
|
||||
|
||||
def _open_inspection_ids(schedules):
|
||||
"""{schedule_id: inspection_id} for schedules with an inspection already
|
||||
in progress, so the UI offers Continue instead of a duplicate Start."""
|
||||
ids = [s.id for s in schedules if s.id]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (Inspection.query
|
||||
.filter(Inspection.scheduled_inspection_id.in_(ids),
|
||||
Inspection.status == 'in_progress')
|
||||
.order_by(Inspection.id.desc())
|
||||
.all())
|
||||
return {r.scheduled_inspection_id: r.id for r in rows}
|
||||
|
||||
|
||||
def _selected_project_id(form):
|
||||
"""Contract of the submitted facility (for restoring the selector on
|
||||
re-render), or None."""
|
||||
@@ -110,7 +177,8 @@ def index():
|
||||
).all()
|
||||
|
||||
return render_template('scheduled_inspections/list.html',
|
||||
schedules=schedules, today=today)
|
||||
schedules=schedules, today=today,
|
||||
open_inspections=_open_inspection_ids(schedules))
|
||||
|
||||
|
||||
# ── Create ──────────────────────────────────────────────────────────────────
|
||||
@@ -121,6 +189,10 @@ def index():
|
||||
def create():
|
||||
form = ScheduledInspectionForm()
|
||||
_populate_choices(form)
|
||||
# On a new schedule this date IS the start; on edit it is whatever the next
|
||||
# occurrence happens to be. One field, two meanings — so the label follows
|
||||
# the context instead of saying both at once.
|
||||
form.next_due_date.label.text = 'Start Date'
|
||||
if not form.next_due_date.data:
|
||||
form.next_due_date.data = now_eastern().date()
|
||||
|
||||
@@ -129,17 +201,25 @@ def create():
|
||||
facility_id = form.facility_id.data,
|
||||
template_id = form.template_id.data,
|
||||
inspector_id = form.inspector_id.data,
|
||||
frequency = form.frequency.data,
|
||||
next_due_date = form.next_due_date.data,
|
||||
notes = (form.notes.data or '').strip() or None,
|
||||
active = form.active.data,
|
||||
created_by = current_user.id,
|
||||
)
|
||||
_apply_recurrence(sched, form)
|
||||
if _reject_if_past_end_date(sched, form):
|
||||
# sched was never added to the session — nothing to roll back.
|
||||
return render_template('scheduled_inspections/form.html',
|
||||
form=form, title='New Scheduled Inspection',
|
||||
projects=_active_contracts(),
|
||||
selected_project_id=_selected_project_id(form))
|
||||
db.session.add(sched)
|
||||
db.session.commit()
|
||||
log_action(ACTION_CREATE, 'ScheduledInspection', sched.id,
|
||||
f'{sched.template.name} @ {sched.facility.name}',
|
||||
f'freq={sched.frequency}; due={sched.next_due_date}; inspector={sched.inspector_id}')
|
||||
f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
|
||||
f'end={sched.end_date or "—"}; '
|
||||
f'inspector={sched.inspector_id}')
|
||||
logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id)
|
||||
|
||||
# Notify the assigned inspector immediately.
|
||||
@@ -166,20 +246,36 @@ def edit(schedule_id):
|
||||
abort(404)
|
||||
form = ScheduledInspectionForm(obj=sched)
|
||||
_populate_choices(form)
|
||||
form.next_due_date.label.text = 'Next Due Date'
|
||||
if request.method == 'GET':
|
||||
# obj= copies the raw CSV column into a multi-select field; hand it the
|
||||
# parsed int list instead so the checkboxes pre-tick correctly.
|
||||
form.weekdays.data = sched.weekday_list
|
||||
form.month_mode.data = sched.month_mode or MONTH_MODE_DAY
|
||||
|
||||
if form.validate_on_submit():
|
||||
old_inspector_id = sched.inspector_id
|
||||
sched.facility_id = form.facility_id.data
|
||||
sched.template_id = form.template_id.data
|
||||
sched.inspector_id = form.inspector_id.data
|
||||
sched.frequency = form.frequency.data
|
||||
sched.next_due_date = form.next_due_date.data
|
||||
sched.notes = (form.notes.data or '').strip() or None
|
||||
sched.active = form.active.data
|
||||
_apply_recurrence(sched, form)
|
||||
if _reject_if_past_end_date(sched, form):
|
||||
# sched is a persistent object and has already been mutated — discard
|
||||
# those pending changes before re-rendering so nothing leaks out on
|
||||
# the next flush.
|
||||
db.session.rollback()
|
||||
return render_template('scheduled_inspections/form.html',
|
||||
form=form, title='Edit Scheduled Inspection',
|
||||
schedule=sched, projects=_active_contracts(),
|
||||
selected_project_id=_selected_project_id(form))
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
|
||||
f'{sched.template.name} @ {sched.facility.name}',
|
||||
f'freq={sched.frequency}; due={sched.next_due_date}; active={sched.active}')
|
||||
f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
|
||||
f'end={sched.end_date or "—"}; '
|
||||
f'active={sched.active}')
|
||||
|
||||
# Notify the inspector if the assignment changed to them.
|
||||
if sched.active and sched.inspector_id and sched.inspector_id != old_inspector_id:
|
||||
@@ -242,6 +338,16 @@ def start(schedule_id):
|
||||
flash('The template for this schedule has no form fields yet.', 'warning')
|
||||
return redirect(url_for('scheduled_inspections.index'))
|
||||
|
||||
# Already started but not submitted? Resume it rather than opening a second
|
||||
# inspection against the same occurrence.
|
||||
existing = (Inspection.query
|
||||
.filter_by(scheduled_inspection_id=sched.id, status='in_progress')
|
||||
.order_by(Inspection.id.desc())
|
||||
.first())
|
||||
if existing is not None:
|
||||
flash('Resuming the inspection you already started for this schedule.', 'info')
|
||||
return redirect(url_for('inspections.execute', inspection_id=existing.id))
|
||||
|
||||
inspection = Inspection(
|
||||
template_id = sched.template_id,
|
||||
facility_id = sched.facility_id,
|
||||
@@ -250,6 +356,12 @@ def start(schedule_id):
|
||||
inspection_date = now_eastern(),
|
||||
status = 'in_progress',
|
||||
scheduled_inspection_id = sched.id,
|
||||
# phase45 — a schedule created by "Schedule Follow-up" carries the
|
||||
# inspection it is a follow-up of. Inheriting it here is what makes the
|
||||
# run a real linked re-inspection: execute() pre-fills from the parent
|
||||
# and submit clears the parent's follow_up_required. NULL for ordinary
|
||||
# schedules, which is every pre-phase45 row.
|
||||
parent_inspection_id = sched.parent_inspection_id,
|
||||
)
|
||||
db.session.add(inspection)
|
||||
db.session.commit()
|
||||
@@ -271,10 +383,23 @@ def run_reminders():
|
||||
abort(403)
|
||||
|
||||
today = now_eastern().date()
|
||||
sent = {'advance': 0, 'due': 0, 'overdue': 0}
|
||||
sent = {'advance': 0, 'due': 0, 'overdue': 0, 'expired': 0}
|
||||
|
||||
schedules = ScheduledInspection.query.filter_by(active=True).all()
|
||||
|
||||
# Expire schedules past their end date BEFORE any reminder work (phase44).
|
||||
# fulfill() closes out a schedule that reaches its boundary by being
|
||||
# completed; this covers the one that reaches it without ever being done —
|
||||
# otherwise it stays active and re-alerts as overdue indefinitely.
|
||||
live = []
|
||||
for s in schedules:
|
||||
if s.expire_if_past_end_date(today):
|
||||
sent['expired'] += 1
|
||||
logger.info('SCHED INSP | expired | id=%s | end=%s', s.id, s.end_date)
|
||||
else:
|
||||
live.append(s)
|
||||
schedules = live
|
||||
|
||||
# Cache admin/director recipients for overdue alerts
|
||||
managers = User.query.filter(
|
||||
User.role.in_(['admin', 'director']), User.active == True # noqa: E712
|
||||
@@ -333,6 +458,6 @@ def run_reminders():
|
||||
sent['overdue'] += 1
|
||||
|
||||
db.session.commit()
|
||||
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s',
|
||||
sent['advance'], sent['due'], sent['overdue'])
|
||||
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s expired=%s',
|
||||
sent['advance'], sent['due'], sent['overdue'], sent['expired'])
|
||||
return {'ok': True, 'sent': sent}, 200
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr><th>Facility</th><th>Template</th><th>Inspector</th><th>Due</th><th></th></tr>
|
||||
<tr><th>Facility</th><th>Template</th><th>Inspector</th><th>Repeats</th><th>Due</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in sched_upcoming %}
|
||||
@@ -56,13 +56,21 @@
|
||||
<td>{{ s.facility.name if s.facility else '—' }}</td>
|
||||
<td class="small">{{ s.template.name if s.template else '—' }}</td>
|
||||
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||
<td class="small text-muted">{{ s.recurrence_label }}</td>
|
||||
<td class="small">{{ s.next_due_date.strftime('%b %d') }}</td>
|
||||
<td class="text-end">
|
||||
{# Start is shown only to the assignee — the inspection is theirs to do. #}
|
||||
{% if s.inspector_id and s.inspector_id == current_user.id %}
|
||||
{% set open_id = sched_open_inspections.get(s.id) %}
|
||||
{% if open_id %}
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
|
||||
class="btn btn-sm btn-warning py-0" title="You already started this — resume it">
|
||||
<i class="bi bi-pencil-square"></i> Continue</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -263,6 +263,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Instructions from the schedule (phase36) ──
|
||||
Only rendered when this inspection was started from a ScheduledInspection
|
||||
that carries instructions. `scheduled_inspection` is NULL for ad-hoc work
|
||||
and for schedules deleted after the inspection was started, so both the
|
||||
relationship and the text are guarded. #}
|
||||
{% if inspection.scheduled_inspection and inspection.scheduled_inspection.notes %}
|
||||
<div style="background:#eef2ff;border:1px solid #c7d2fe;border-left:4px solid #6366f1;
|
||||
padding:.9rem 1.1rem;margin-top:.85rem;border-radius:8px;">
|
||||
<div class="fw-semibold mb-1" style="color:#3730a3;font-size:.9rem;">
|
||||
<i class="bi bi-info-circle-fill"></i> Instructions for this inspection
|
||||
</div>
|
||||
<div style="white-space:pre-wrap;color:#1e1b4b;font-size:.9rem;">{{ inspection.scheduled_inspection.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Form body ── #}
|
||||
<div class="insp-body">
|
||||
{% if form_fields %}
|
||||
|
||||
@@ -356,6 +356,16 @@
|
||||
<i class="bi bi-arrow-repeat"></i> Re-inspect
|
||||
</a>
|
||||
{% endif %}
|
||||
{# Customers may REQUEST a follow-up on their own completed inspections;
|
||||
only admin/director can clear one. #}
|
||||
{% if current_user.role == 'customer' and inspection.status == 'completed'
|
||||
and not inspection.follow_up_required %}
|
||||
<button type="button" class="btn btn-sm btn-outline-warning"
|
||||
data-bs-toggle="modal" data-bs-target="#followupModal"
|
||||
title="Ask the team to re-inspect this facility">
|
||||
<i class="bi bi-flag"></i> Request Follow-up
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin','director'] %}
|
||||
{% if not inspection.follow_up_required %}
|
||||
<button type="button" class="btn btn-sm btn-outline-warning"
|
||||
@@ -388,13 +398,27 @@
|
||||
<i class="bi bi-flag-fill mt-1"></i>
|
||||
<div>
|
||||
<strong>Follow-up Inspection Required</strong>
|
||||
{% if inspection.follow_up_requester %}
|
||||
<span class="badge {{ 'bg-info text-dark' if inspection.follow_up_requester.role == 'customer' else 'bg-secondary' }} ms-1">
|
||||
{{ 'Requested by customer' if inspection.follow_up_requester.role == 'customer' else 'Requested by staff' }}:
|
||||
{{ inspection.follow_up_requester.display_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if inspection.follow_up_requested_at %}
|
||||
<span class="small text-muted ms-1">{{ inspection.follow_up_requested_at.strftime('%b %d, %Y %I:%M %p') }}</span>
|
||||
{% endif %}
|
||||
{% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %}
|
||||
{# Re-inspection is staff work — reinspect() already refuses customers. #}
|
||||
{% if current_user.role != 'customer' %}
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
|
||||
class="btn btn-sm btn-warning">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="small mt-1">The team has been notified and will schedule the re-inspection.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -886,19 +910,31 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
|
||||
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
{% set is_cust = current_user.role == 'customer' %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-flag me-2"></i>Flag Follow-up Required</h5>
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-flag me-2"></i>{{ 'Request a Follow-up Inspection' if is_cust else 'Flag Follow-up Required' }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label fw-semibold">Reason / Notes <span class="text-muted small">(optional)</span></label>
|
||||
{% if is_cust %}
|
||||
<p class="small text-muted">
|
||||
Ask the team to re-inspect this facility. Your request is sent to the
|
||||
inspector and management right away.
|
||||
</p>
|
||||
{% endif %}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ 'What still needs attention?' if is_cust else 'Reason / Notes' }}
|
||||
<span class="text-muted small">(optional)</span>
|
||||
</label>
|
||||
<textarea name="follow_up_note" class="form-control" rows="3"
|
||||
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-flag me-1"></i>Flag Follow-up
|
||||
<i class="bi bi-flag me-1"></i>{{ 'Send Request' if is_cust else 'Flag Follow-up' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,12 +49,93 @@
|
||||
{{ form.next_due_date.label(class="form-label fw-semibold") }}
|
||||
{{ form.next_due_date(class="form-control", type="date") }}
|
||||
{% for e in form.next_due_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
<div class="form-text">
|
||||
Snapped forward to the first matching day.
|
||||
Advances automatically after each completed inspection.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── End date (phase44) ──
|
||||
Hidden for one-time schedules, which end by deactivating when
|
||||
completed. syncFrequency() toggles it; the route forces the column
|
||||
to NULL when frequency == 'once', so a stale DOM value cannot
|
||||
survive a frequency change. #}
|
||||
<div class="row" id="end_date_row" hidden>
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.end_date.label(class="form-label fw-semibold") }}
|
||||
{{ form.end_date(class="form-control", type="date") }}
|
||||
{% for e in form.end_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
<div class="form-text">
|
||||
Optional. The last date this schedule may run — leave blank to repeat indefinitely.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Weekly: which days of the week ──────────────────────────── #}
|
||||
<div class="mb-3 p-3 rounded bg-light border" id="weekly_block" hidden>
|
||||
<label class="form-label fw-semibold d-block">{{ form.weekdays.label.text }}</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
{% for value, label in form.weekdays.choices %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="weekdays"
|
||||
id="weekday_{{ value }}" value="{{ value }}"
|
||||
{% if form.weekdays.data and value in form.weekdays.data %}checked{% endif %}>
|
||||
<label class="form-check-label" for="weekday_{{ value }}">{{ label[:3] }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for e in form.weekdays.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
<div class="form-text mb-0">
|
||||
Pick every day the inspection recurs — e.g. Mon, Wed, Fri gives three
|
||||
inspections a week. The due date rolls to the next selected day each
|
||||
time one is submitted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Monthly: day-of-month OR nth weekday ────────────────────── #}
|
||||
<div class="mb-3 p-3 rounded bg-light border" id="monthly_block" hidden>
|
||||
<label class="form-label fw-semibold d-block">{{ form.month_mode.label.text }}</label>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="month_mode"
|
||||
id="month_mode_day" value="day_of_month"
|
||||
{% if form.month_mode.data != 'nth_weekday' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="month_mode_day">On a day of the month</label>
|
||||
</div>
|
||||
<div class="ms-4 mb-2" id="dom_row">
|
||||
<div class="input-group input-group-sm" style="max-width:16rem;">
|
||||
<span class="input-group-text">Day</span>
|
||||
{{ form.day_of_month(class="form-control", type="number", min=1, max=31,
|
||||
placeholder="15") }}
|
||||
</div>
|
||||
{% for e in form.day_of_month.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
<div class="form-text mb-0">Months without that day use their last day.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="month_mode"
|
||||
id="month_mode_nth" value="nth_weekday"
|
||||
{% if form.month_mode.data == 'nth_weekday' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="month_mode_nth">On a weekday of the month</label>
|
||||
</div>
|
||||
<div class="ms-4" id="nth_row">
|
||||
<div class="d-flex gap-2 flex-wrap" style="max-width:24rem;">
|
||||
{{ form.nth_week(class="form-select form-select-sm", style="max-width:7rem;") }}
|
||||
{{ form.nth_weekday(class="form-select form-select-sm", style="max-width:11rem;") }}
|
||||
</div>
|
||||
{% for e in form.nth_week.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
<div class="form-text mb-0">e.g. the 2nd Tuesday of every month.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.notes.label(class="form-label fw-semibold") }}
|
||||
{{ form.notes(class="form-control", rows=2, placeholder="Optional instructions for the inspector…") }}
|
||||
{{ form.notes(class="form-control", rows=3, placeholder="e.g. Front lobby carpet needs extra attention. Check loading dock after 3 PM — key is at the front desk.") }}
|
||||
<div class="form-text">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Shown to the assigned inspector when they open this inspection, on the web and on the iPad.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
@@ -129,5 +210,43 @@
|
||||
setPlaceholder();
|
||||
}
|
||||
}());
|
||||
|
||||
// Recurrence blocks: only the one matching the chosen frequency is shown.
|
||||
// The server clears the columns for the hidden blocks on save, so stale values
|
||||
// left in the DOM never take effect.
|
||||
(function () {
|
||||
'use strict';
|
||||
var freq = document.getElementById('frequency') ||
|
||||
document.querySelector('[name="frequency"]');
|
||||
var weekly = document.getElementById('weekly_block');
|
||||
var monthly = document.getElementById('monthly_block');
|
||||
var endRow = document.getElementById('end_date_row');
|
||||
if (!freq || !weekly || !monthly) { return; }
|
||||
|
||||
var domRadio = document.getElementById('month_mode_day');
|
||||
var nthRadio = document.getElementById('month_mode_nth');
|
||||
var domRow = document.getElementById('dom_row');
|
||||
var nthRow = document.getElementById('nth_row');
|
||||
|
||||
function syncMonthMode() {
|
||||
var useNth = nthRadio && nthRadio.checked;
|
||||
domRow.style.opacity = useNth ? '.45' : '1';
|
||||
nthRow.style.opacity = useNth ? '1' : '.45';
|
||||
}
|
||||
|
||||
function syncFrequency() {
|
||||
weekly.hidden = freq.value !== 'weekly';
|
||||
monthly.hidden = freq.value !== 'monthly';
|
||||
// End date is a recurring-only concept.
|
||||
if (endRow) { endRow.hidden = freq.value === 'once'; }
|
||||
syncMonthMode();
|
||||
}
|
||||
|
||||
freq.addEventListener('change', syncFrequency);
|
||||
[domRadio, nthRadio].forEach(function (r) {
|
||||
if (r) { r.addEventListener('change', syncMonthMode); }
|
||||
});
|
||||
syncFrequency();
|
||||
}());
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<th>Inspector</th>
|
||||
<th>Frequency</th>
|
||||
<th>Next Due</th>
|
||||
<th>Ends</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end"></th>
|
||||
</tr>
|
||||
@@ -43,7 +44,7 @@
|
||||
<td><strong>{{ s.facility.name if s.facility else '—' }}</strong></td>
|
||||
<td>{{ s.template.name if s.template else '—' }}</td>
|
||||
<td>{{ s.inspector.display_name if s.inspector else '— Unassigned —' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ s.frequency_label }}</span></td>
|
||||
<td><span class="badge bg-secondary">{{ s.recurrence_label }}</span></td>
|
||||
<td>
|
||||
{{ s.next_due_date.strftime('%b %d, %Y') }}
|
||||
{% if overdue %}
|
||||
@@ -52,9 +53,22 @@
|
||||
<span class="badge bg-warning text-dark ms-1">Due soon</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{% if s.frequency == 'once' %}
|
||||
<span class="text-muted">—</span>
|
||||
{% elif s.end_date %}
|
||||
{{ s.end_date.strftime('%b %d, %Y') }}
|
||||
{% else %}
|
||||
<span class="text-muted" title="Repeats indefinitely">No end</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{# Three states, not two: "Ended" distinguishes a schedule that ran
|
||||
its course from one a manager switched off. #}
|
||||
{% if s.active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% elif s.is_expired %}
|
||||
<span class="badge bg-dark" title="Passed its end date">Ended</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
@@ -62,11 +76,19 @@
|
||||
<td class="text-end text-nowrap">
|
||||
{# Start is shown only to the assignee — the inspection is theirs to do. #}
|
||||
{% if s.active and s.inspector_id and s.inspector_id == current_user.id %}
|
||||
{% set open_id = open_inspections.get(s.id) %}
|
||||
{% if open_id %}
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
|
||||
class="btn btn-sm btn-warning" title="You already started this — resume it">
|
||||
<i class="bi bi-pencil-square"></i> Continue
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-success" title="Start this inspection">
|
||||
<i class="bi bi-play-fill"></i> Start
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||
<a href="{{ url_for('scheduled_inspections.edit', schedule_id=s.id) }}"
|
||||
class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
|
||||
|
||||
+68
-3
@@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileAllowed, MultipleFileField
|
||||
from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
|
||||
DecimalField, BooleanField, IntegerField, HiddenField,
|
||||
RadioField, DateField)
|
||||
RadioField, DateField, SelectMultipleField)
|
||||
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
|
||||
Optional, NumberRange, ValidationError)
|
||||
from app.models.user import User
|
||||
@@ -338,10 +338,75 @@ class ScheduledInspectionForm(FlaskForm):
|
||||
('once', 'One-time'), ('daily', 'Daily'),
|
||||
('weekly', 'Weekly'), ('monthly', 'Monthly'),
|
||||
], validators=[DataRequired()])
|
||||
next_due_date = DateField('Due Date', validators=[DataRequired()])
|
||||
notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)])
|
||||
next_due_date = DateField('Start / Due Date', validators=[DataRequired()])
|
||||
# Label is overridden per context in routes/scheduled_inspections.py:
|
||||
# "Start Date" when creating, "Next Due Date" when editing. The default
|
||||
# above is only a fallback.
|
||||
end_date = DateField('End Date', validators=[Optional()])
|
||||
# UI label only. The field name, the ScheduledInspection.notes attribute and
|
||||
# the scheduled_inspections.notes column all stay `notes` — renaming any of
|
||||
# them would break the API payload key the iPad decodes.
|
||||
notes = TextAreaField('Instructions', validators=[Optional(), Length(max=1000)])
|
||||
active = BooleanField('Active', default=True)
|
||||
|
||||
# ── Recurrence detail (phase43) ──────────────────────────────────────────
|
||||
# Only the block matching `frequency` is required; the rest is ignored and
|
||||
# cleared on save. Shown/hidden client-side, enforced in validate() below.
|
||||
weekdays = SelectMultipleField(
|
||||
'Days of the Week', coerce=int, validators=[Optional()],
|
||||
choices=[(i, n) for i, n in enumerate(
|
||||
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday'])],
|
||||
)
|
||||
month_mode = SelectField('Monthly Rule', validators=[Optional()], choices=[
|
||||
('day_of_month', 'On a day of the month'),
|
||||
('nth_weekday', 'On a weekday of the month'),
|
||||
], default='day_of_month')
|
||||
day_of_month = IntegerField(
|
||||
'Day of Month', validators=[Optional(), NumberRange(min=1, max=31)])
|
||||
nth_week = SelectField('Week', coerce=int, validators=[Optional()], choices=[
|
||||
(1, '1st'), (2, '2nd'), (3, '3rd'), (4, '4th'), (5, '5th'), (-1, 'Last'),
|
||||
], default=1)
|
||||
nth_weekday = SelectField(
|
||||
'Weekday', coerce=int, validators=[Optional()],
|
||||
choices=[(i, n) for i, n in enumerate(
|
||||
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday'])],
|
||||
default=0,
|
||||
)
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
"""Conditionally require the recurrence block for the chosen frequency."""
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
ok = True
|
||||
if self.frequency.data == 'weekly' and not self.weekdays.data:
|
||||
self.weekdays.errors.append('Pick at least one day of the week.')
|
||||
ok = False
|
||||
elif self.frequency.data == 'monthly':
|
||||
if self.month_mode.data == 'nth_weekday':
|
||||
if not self.nth_week.data or self.nth_weekday.data is None:
|
||||
self.nth_week.errors.append('Choose which weekday of the month.')
|
||||
ok = False
|
||||
elif not self.day_of_month.data:
|
||||
self.day_of_month.errors.append('Enter a day of the month (1–31).')
|
||||
ok = False
|
||||
|
||||
# End date (phase44). Only meaningful for recurring schedules — a
|
||||
# one-time schedule ends by deactivating when it is completed. Rejecting
|
||||
# an end date before the due date here is what makes the "already past
|
||||
# its boundary on save" case unreachable in the routes.
|
||||
if self.end_date.data:
|
||||
if self.frequency.data == 'once':
|
||||
self.end_date.errors.append(
|
||||
'A one-time schedule has no end date — it closes when completed.')
|
||||
ok = False
|
||||
elif self.next_due_date.data and self.end_date.data < self.next_due_date.data:
|
||||
self.end_date.errors.append(
|
||||
'End date must be on or after the due date.')
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
# ── Support Knowledge Base (phase38) ─────────────────────────────────────────
|
||||
|
||||
|
||||
+21
-17
@@ -216,40 +216,44 @@ def _draw_overlay(img, lines):
|
||||
width, height = img.size
|
||||
# Scale everything off the short edge so portrait and landscape match.
|
||||
base = min(width, height)
|
||||
font_size = max(14, int(base * 0.035))
|
||||
pad = max(8, int(base * 0.018))
|
||||
font_size = max(11, int(base * 0.020))
|
||||
pad = max(6, int(base * 0.012))
|
||||
font = _load_font(font_size)
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
measure = ImageDraw.Draw(img)
|
||||
|
||||
# Measure the block.
|
||||
heights, widths = [], []
|
||||
for line in lines:
|
||||
box = draw.textbbox((0, 0), line, font=font)
|
||||
box = measure.textbbox((0, 0), line, font=font)
|
||||
widths.append(box[2] - box[0])
|
||||
heights.append(box[3] - box[1])
|
||||
line_gap = max(2, int(font_size * 0.25))
|
||||
text_h = sum(heights) + line_gap * (len(lines) - 1)
|
||||
bar_h = text_h + pad * 2
|
||||
|
||||
# Translucent black bar, composited so it works on RGB too.
|
||||
bar = Image.new('RGBA', (width, bar_h), (0, 0, 0, 150))
|
||||
# Draw the whole overlay on a transparent layer so both the bar AND the
|
||||
# text carry alpha, then composite once. Keeps the photo readable through
|
||||
# the stamp instead of masking it behind a solid strip.
|
||||
layer = Image.new('RGBA', (width, bar_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
draw.rectangle((0, 0, width, bar_h), fill=(0, 0, 0, 80))
|
||||
|
||||
y = pad
|
||||
for line, h in zip(lines, heights):
|
||||
# Faint dark outline still keeps the text legible over a bright photo.
|
||||
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||
draw.text((pad + dx, y + dy), line, font=font, fill=(0, 0, 0, 90))
|
||||
draw.text((pad, y), line, font=font, fill=(255, 255, 255, 165))
|
||||
y += h + line_gap
|
||||
|
||||
if img.mode == 'RGBA':
|
||||
img.alpha_composite(bar, (0, height - bar_h))
|
||||
img.alpha_composite(layer, (0, height - bar_h))
|
||||
else:
|
||||
img.paste(Image.alpha_composite(
|
||||
img.crop((0, height - bar_h, width, height)).convert('RGBA'), bar
|
||||
img.crop((0, height - bar_h, width, height)).convert('RGBA'), layer
|
||||
).convert('RGB'), (0, height - bar_h))
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
y = height - bar_h + pad
|
||||
for line, h in zip(lines, heights):
|
||||
# Thin dark outline keeps the text legible over a bright photo.
|
||||
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||
draw.text((pad + dx, y + dy), line, font=font, fill=(0, 0, 0))
|
||||
draw.text((pad, y), line, font=font, fill=(255, 255, 255))
|
||||
y += h + line_gap
|
||||
|
||||
return img
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user