Enhance shift constraints validation and error handling in forms and templates

This commit is contained in:
Ross
2025-12-17 15:35:20 +00:00
parent 32886da33d
commit ba154f680a
3 changed files with 100 additions and 15 deletions
@@ -22,6 +22,7 @@ function _close_shift_modal_targets(){
<div class="box" id="constraints-editor">
<h4 class="title is-6">Shift constraints</h4>
<p class="help">Add constraint entries. The form stores a JSON list; this editor helps build that list.</p>
<div id="constraint-errors" class="notification is-danger" style="display:none; margin-bottom:0.5em;"></div>
<div id="constraint-list"></div>
<div style="margin-top:0.5em;">
<button type="button" class="button is-small" id="add-constraint">Add constraint</button>
@@ -45,7 +46,7 @@ function _close_shift_modal_targets(){
{name: "weeks", label: "Weeks (comma-separated integers)", type: "list_int"},
{name: "start_date", label: "Start date", type: "date"},
{name: "end_date", label: "End date", type: "date"},
{name: "days", label: "Days (integer)", type: "int"},
{name: "days", label: "Duration (days)", type: "int", required: true},
{name: "ignore_shifts", label: "Ignore shifts (comma-separated)", type: "list"},
{name: "exclude_days", label: "Exclude days (comma-separated)", type: "list"},
{name: "allow_self", label: "Allow self", type: "bool"}
@@ -55,7 +56,7 @@ function _close_shift_modal_targets(){
{name: "weeks", label: "Weeks (comma-separated integers)", type: "list_int"},
{name: "start_date", label: "Start date", type: "date"},
{name: "end_date", label: "End date", type: "date"},
{name: "days", label: "Days (integer)", type: "int"},
{name: "days", label: "Duration (days)", type: "int", required: true},
{name: "ignore_shifts", label: "Ignore shifts (comma-separated)", type: "list"},
{name: "exclude_days", label: "Exclude days (comma-separated)", type: "list"},
{name: "allow_self", label: "Allow self", type: "bool"}
@@ -70,8 +71,31 @@ function _close_shift_modal_targets(){
const textarea = document.getElementById('id_constraints');
const listEl = document.getElementById('constraint-list');
const errorsEl = document.getElementById('constraint-errors');
const addBtn = document.getElementById('add-constraint');
function validateConstraintsArray(data){
const msgs = [];
if(!Array.isArray(data)) return ["Constraints must be a JSON list"];
data.forEach((c, idx)=>{
if(!c || typeof c !== 'object'){
msgs.push(`Constraint #${idx+1} must be an object`);
return;
}
const name = c.name || c.type || null;
if(!name){ msgs.push(`Constraint #${idx+1} missing 'name'`); return; }
if(name==='pre' || name==='post'){
const opts = c.options || {};
// days (duration) is required
const daysVal = (c.days!==undefined && c.days!==null) ? c.days : (opts.days!==undefined && opts.days!==null ? opts.days : null);
if(daysVal===null){
msgs.push(`Constraint #${idx+1} (pre/post) requires 'Duration (days)' to be set`);
}
}
});
return msgs;
}
function parseInitial(){
let val = textarea.value || '';
if(!val.trim()) return [];
@@ -106,6 +130,12 @@ function _close_shift_modal_targets(){
}catch(e){ /* ignore */ }
}
syncTextarea();
// validate and show errors
try{
const v = validateConstraintsArray(data);
if(v && v.length){ errorsEl.style.display='block'; errorsEl.innerHTML = v.map(m=>`<div>${m}</div>`).join(''); }
else { errorsEl.style.display='none'; errorsEl.innerHTML=''; }
}catch(e){ /* ignore */ }
}
function renderConstraintRow(c, idx){
+20
View File
@@ -663,6 +663,26 @@ class ShiftForm(forms.Form):
parsed = json.loads(val)
if not isinstance(parsed, list):
raise forms.ValidationError("Constraints must be a JSON list of constraint objects")
# Validate each constraint object for basic correctness
errors = []
for i, c in enumerate(parsed):
if not isinstance(c, dict):
errors.append(f"Constraint #{i+1} must be an object")
continue
name = c.get('name') or c.get('type')
if not name:
errors.append(f"Constraint #{i+1} missing 'name'")
continue
if name in ('pre', 'post'):
opts = c.get('options', {}) or {}
# days is now required: check top-level or under options
days_val = c.get('days') if c.get('days') is not None else opts.get('days')
if days_val is None:
errors.append(
f"Constraint #{i+1} ('{name}') requires a 'days' duration (number of days the constraint applies for)."
)
if errors:
raise forms.ValidationError(errors)
return parsed
except forms.ValidationError:
raise
+48 -13
View File
@@ -68,9 +68,21 @@ SHIFT_BOUNDS = {
#]
class BaseShiftConstraint(BaseModel):
weeks: Optional[List[int]] = None
start_date: Optional[datetime.date] = None
end_date: Optional[datetime.date] = None
weeks: Optional[List[int]] = Field(
None,
description=(
"List of week numbers when this constraint is active. "
"These are selectors that limit WHEN the constraint applies; they must not be used together with start_date/end_date."
),
)
start_date: Optional[datetime.date] = Field(
None,
description="Inclusive start date for the period when this constraint is active (cannot be used with `weeks`).",
)
end_date: Optional[datetime.date] = Field(
None,
description="Inclusive end date for the period when this constraint is active (cannot be used with `weeks`).",
)
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
@@ -90,18 +102,41 @@ class NightConstraint(BaseShiftConstraint):
options: Any = None
class PreShiftConstraint(BaseShiftConstraint):
days: Optional[int] = None
ignore_shifts: List[str] = []
exclude_days: List[str] = []
exclude_dates: List[datetime.date] = []
allow_self: bool = True
"""Pre-shift constraint.
Semantics:
- `weeks`, `start_date`, `end_date` (inherited) are selectors that determine WHEN the constraint is active (which weeks or date window).
- `days` is a DURATION: the number of days the constraint applies for (e.g. 3 means apply the constraint for 3 days relative to the shift). This field is required.
- `ignore_shifts`, `exclude_days`, `exclude_dates` further refine which shifts/dates are considered.
"""
days: int = Field(
...,
description=(
"Duration in days: the number of days the constraint applies for. "
"This is different from selector fields (weeks/start_date/end_date) which select WHEN the constraint is active."
),
)
ignore_shifts: List[str] = Field(default_factory=list, description="List of shift names to ignore when applying this constraint")
exclude_days: List[str] = Field(default_factory=list, description="Weekday names to exclude (e.g. ['Mon','Tue'])")
exclude_dates: List[datetime.date] = Field(default_factory=list, description="Specific dates to exclude from the constraint")
allow_self: bool = Field(True, description="Allow the same worker to satisfy this constraint (default: True)")
class PostShiftConstraint(BaseShiftConstraint):
days: Optional[int] = None
ignore_shifts: List[str] = []
exclude_days: List[str] = []
exclude_dates: List[datetime.date] = []
allow_self: bool = True
"""Post-shift constraint.
See PreShiftConstraint docs: selectors (weeks/start_date/end_date) choose WHEN the constraint is active; `days` is a duration and is required.
"""
days: int = Field(
...,
description=(
"Duration in days: the number of days the constraint applies for after the shift. "
"Different from selector fields which choose which weeks/dates the constraint is active on."
),
)
ignore_shifts: List[str] = Field(default_factory=list, description="List of shift names to ignore when applying this constraint")
exclude_days: List[str] = Field(default_factory=list, description="Weekday names to exclude (e.g. ['Mon','Tue'])")
exclude_dates: List[datetime.date] = Field(default_factory=list, description="Specific dates to exclude from the constraint")
allow_self: bool = Field(True, description="Allow the same worker to satisfy this constraint (default: True)")
class BalanceAcrossGroupsConstraint(BaseShiftConstraint):
""""""