Jul 26 - Update scheduled inspection settings (weekly/monthly)
This commit is contained in:
@@ -42,6 +42,14 @@ 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,
|
||||
'is_overdue': s.is_overdue(),
|
||||
'notes': s.notes or None,
|
||||
|
||||
@@ -19,13 +19,56 @@ The *_notified flags make each of those fire at most once per occurrence and
|
||||
reset when a recurring schedule rolls forward.
|
||||
"""
|
||||
|
||||
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'
|
||||
@@ -45,6 +88,18 @@ class ScheduledInspection(db.Model):
|
||||
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
notes = db.Column(db.Text, nullable=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)
|
||||
@@ -72,22 +127,102 @@ 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
|
||||
@@ -102,10 +237,10 @@ class ScheduledInspection(db.Model):
|
||||
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
|
||||
self.advance_notified = False
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,50 @@ 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
|
||||
|
||||
# Snap the picked date forward onto the first matching occurrence.
|
||||
sched.next_due_date = sched.align_due_date(form.next_due_date.data)
|
||||
|
||||
|
||||
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 +155,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 ──────────────────────────────────────────────────────────────────
|
||||
@@ -129,17 +175,18 @@ 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)
|
||||
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'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 +213,25 @@ def edit(schedule_id):
|
||||
abort(404)
|
||||
form = ScheduledInspectionForm(obj=sched)
|
||||
_populate_choices(form)
|
||||
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)
|
||||
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'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 +294,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,
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -49,6 +49,64 @@
|
||||
{{ 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.</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>
|
||||
|
||||
@@ -129,5 +187,40 @@
|
||||
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');
|
||||
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';
|
||||
syncMonthMode();
|
||||
}
|
||||
|
||||
freq.addEventListener('change', syncFrequency);
|
||||
[domRadio, nthRadio].forEach(function (r) {
|
||||
if (r) { r.addEventListener('change', syncMonthMode); }
|
||||
});
|
||||
syncFrequency();
|
||||
}());
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -43,7 +43,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 %}
|
||||
@@ -62,11 +62,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>
|
||||
|
||||
+46
-2
@@ -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,54 @@ class ScheduledInspectionForm(FlaskForm):
|
||||
('once', 'One-time'), ('daily', 'Daily'),
|
||||
('weekly', 'Weekly'), ('monthly', 'Monthly'),
|
||||
], validators=[DataRequired()])
|
||||
next_due_date = DateField('Due Date', validators=[DataRequired()])
|
||||
next_due_date = DateField('Start / Due Date', validators=[DataRequired()])
|
||||
notes = TextAreaField('Notes', 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
|
||||
return ok
|
||||
|
||||
|
||||
# ── Support Knowledge Base (phase38) ─────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user