`).join(''); }
+ else { errorsEl.style.display='none'; errorsEl.innerHTML=''; }
+ }catch(e){ /* ignore */ }
}
function renderConstraintRow(c, idx){
diff --git a/djangorota/rota/forms.py b/djangorota/rota/forms.py
index 129aa92..e7c965c 100644
--- a/djangorota/rota/forms.py
+++ b/djangorota/rota/forms.py
@@ -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
diff --git a/rota_generator/shifts.py b/rota_generator/shifts.py
index 69381ec..a6392cb 100644
--- a/rota_generator/shifts.py
+++ b/rota_generator/shifts.py
@@ -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):
""""""