From 417fcef1af584136a99d56c378088308edc8b060 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 12 Oct 2026 10:04:50 +0530 Subject: [PATCH 1/3] added implemntation files:qwencode 2.5:7b --- Assignment6_Submission_Instructions.docx.pdf | Bin 0 -> 90634 bytes client/package-lock.json | 42 -- modules/leave/__init__.py | 0 modules/leave/api/__init__.py | 0 modules/leave/api/serializers.py | 196 +++++++ modules/leave/api/views.py | 556 +++++++++++++++++++ modules/leave/apps.py | 7 + modules/leave/contracts.py | 63 +++ modules/leave/domain/__init__.py | 0 modules/leave/domain/categories.py | 200 +++++++ modules/leave/domain/eligibility.py | 143 +++++ modules/leave/domain/guards.py | 200 +++++++ modules/leave/domain/routing.py | 86 +++ modules/leave/domain/state_machine.py | 218 ++++++++ modules/leave/models.py | 462 +++++++++++++++ modules/leave/registry.py | 119 ++++ modules/leave/selectors/__init__.py | 0 modules/leave/selectors/balances.py | 72 +++ modules/leave/selectors/leave.py | 90 +++ modules/leave/services/__init__.py | 1 + modules/leave/services/accounts.py | 127 +++++ modules/leave/services/applications.py | 404 ++++++++++++++ modules/leave/services/calendar.py | 120 ++++ modules/leave/services/decisions.py | 289 ++++++++++ modules/leave/services/notifications.py | 85 +++ modules/leave/services/offline.py | 106 ++++ modules/leave/services/sla.py | 95 ++++ modules/leave/services/workflow.py | 213 +++++++ modules/leave/services/year_end.py | 133 +++++ requirements.txt | 207 +++++++ 30 files changed, 4192 insertions(+), 42 deletions(-) create mode 100644 Assignment6_Submission_Instructions.docx.pdf create mode 100644 modules/leave/__init__.py create mode 100644 modules/leave/api/__init__.py create mode 100644 modules/leave/api/serializers.py create mode 100644 modules/leave/api/views.py create mode 100644 modules/leave/apps.py create mode 100644 modules/leave/contracts.py create mode 100644 modules/leave/domain/__init__.py create mode 100644 modules/leave/domain/categories.py create mode 100644 modules/leave/domain/eligibility.py create mode 100644 modules/leave/domain/guards.py create mode 100644 modules/leave/domain/routing.py create mode 100644 modules/leave/domain/state_machine.py create mode 100644 modules/leave/models.py create mode 100644 modules/leave/registry.py create mode 100644 modules/leave/selectors/__init__.py create mode 100644 modules/leave/selectors/balances.py create mode 100644 modules/leave/selectors/leave.py create mode 100644 modules/leave/services/__init__.py create mode 100644 modules/leave/services/accounts.py create mode 100644 modules/leave/services/applications.py create mode 100644 modules/leave/services/calendar.py create mode 100644 modules/leave/services/decisions.py create mode 100644 modules/leave/services/notifications.py create mode 100644 modules/leave/services/offline.py create mode 100644 modules/leave/services/sla.py create mode 100644 modules/leave/services/workflow.py create mode 100644 modules/leave/services/year_end.py create mode 100644 requirements.txt diff --git a/Assignment6_Submission_Instructions.docx.pdf b/Assignment6_Submission_Instructions.docx.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fadf1815d18e374ba86d07d8df046243d527d373 GIT binary patch literal 90634 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

RDhI)FS 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 one request, with its decision history""" + + permission_classes = [MODULE, HasPermission(P_VIEW_SELF, P_VIEW_ALL)] + + @extend_schema(responses=s.LeaveRequestSerializer, + summary="A leave request and its history") + def get(self, request, pk): + row = leave_selectors.request_by_id(_actor(request), pk) + if row is None: + raise NotFoundError("No such leave request.", code="not_found") + return Response({ + **s.LeaveRequestSerializer(row).data, + "history": s.ApprovalSerializer(row.approvals.all(), many=True).data, + }) + + +@extend_schema(tags=["leave"]) +class WithdrawView(APIView): + """POST /leave/requests//withdraw EL-UC-007""" + + permission_classes = [MODULE, HasPermission(P_WITHDRAW)] + + @extend_schema(request=s.WithdrawSerializer, + responses=s.LeaveRequestSerializer, + summary="Withdraw a pending request") + def post(self, request, pk): + payload = s.WithdrawSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = applications.withdraw( + principal=_actor(request), request_id=pk, + reason=payload.validated_data.get("reason", "")) + return Response(s.LeaveRequestSerializer(row).data) + + +@extend_schema(tags=["leave"]) +class ReplacementSubstituteView(APIView): + """POST /leave/requests//replacement EL-UC-019""" + + permission_classes = [MODULE, HasPermission(P_NOMINATE)] + + @extend_schema(request=s.ReplacementSerializer, + responses=s.LeaveRequestSerializer, + summary="Nominate a replacement substitute after a decline") + def post(self, request, pk): + payload = s.ReplacementSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = applications.nominate_replacement( + principal=_actor(request), request_id=pk, + substitute_user_id=payload.validated_data["substitute_user_id"]) + return Response(s.LeaveRequestSerializer(row).data) + + +@extend_schema(tags=["leave"]) +class SubstitutionInboxView(APIView): + """GET /leave/substitutions what I have been nominated for + POST /leave/substitutions/ accept or decline + """ + + permission_classes = [MODULE, HasPermission(P_RESPOND)] + + @extend_schema(responses=s.LeaveRequestSerializer(many=True), + summary="Requests I am the nominated substitute on") + def get(self, request): + rows = leave_selectors.substitution_requests(_actor(request)) + return Response(_page(s.LeaveRequestSerializer, rows)) + + @extend_schema(request=s.SubstituteResponseSerializer, + responses=s.LeaveRequestSerializer, + summary="Accept or decline a substitution") + def post(self, request, pk): + payload = s.SubstituteResponseSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = decisions.respond_as_substitute( + principal=_actor(request), request_id=pk, + accept=payload.validated_data["accept"]) + return Response(s.LeaveRequestSerializer(row).data) + + +# -- the processing lanes ----------------------------------------------------- +@extend_schema(tags=["leave"]) +class TaskInboxView(APIView): + """GET /leave/inbox the actionable work assigned to me""" + + permission_classes = [MODULE, + HasPermission(P_REVIEW, P_ROUTE, P_APPROVE, + P_RESUMPTION_REVIEW)] + + @extend_schema(summary="My leave work queue") + def get(self, request): + rows = leave_selectors.task_inbox(_actor(request)) + return Response({"results": [{ + "task_id": t.pk, + "stage": t.stage, + "assigned_at": t.assigned_at, + "reminder_due_at": t.reminder_due_at, + "escalation_due_at": t.escalation_due_at, + "request": s.LeaveRequestSerializer(t.request).data, + } for t in rows]}) + + +@extend_schema(tags=["leave"]) +class UnitHeadReviewView(APIView): + """POST /leave/requests//review EL-UC-003""" + + permission_classes = [MODULE, HasPermission(P_REVIEW)] + + @extend_schema(request=s.DecisionSerializer, + responses=s.LeaveRequestSerializer, + summary="Review at Unit Head") + def post(self, request, pk): + payload = s.DecisionSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = decisions.review_at_unit_head( + principal=_actor(request), request_id=pk, + decision=payload.validated_data["decision"], + remarks=payload.validated_data.get("remarks", "")) + return Response(s.LeaveRequestSerializer(row).data) + + +@extend_schema(tags=["leave"]) +class EstablishmentRouteView(APIView): + """POST /leave/requests//route EL-UC-004""" + + permission_classes = [MODULE, HasPermission(P_ROUTE)] + + @extend_schema(request=s.RouteSerializer, responses=s.LeaveRequestSerializer, + summary="Verify and route a higher-sanction request") + def post(self, request, pk): + payload = s.RouteSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = decisions.route_at_establishment( + principal=_actor(request), request_id=pk, + remarks=payload.validated_data.get("remarks", "")) + return Response(s.LeaveRequestSerializer(row).data) + + +@extend_schema(tags=["leave"]) +class FinalDecisionView(APIView): + """POST /leave/requests//decide EL-UC-005""" + + permission_classes = [MODULE, HasPermission(P_APPROVE)] + + @extend_schema(request=s.DecisionSerializer, + responses=s.LeaveRequestSerializer, + summary="Take the final sanctioning decision") + def post(self, request, pk): + payload = s.DecisionSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = decisions.decide_final( + principal=_actor(request), request_id=pk, + decision=payload.validated_data["decision"], + remarks=payload.validated_data.get("remarks", "")) + return Response(s.LeaveRequestSerializer(row).data) + + +@extend_schema(tags=["leave"]) +class ResumptionView(APIView): + """POST /leave/leaves//resumption EL-UC-010""" + + permission_classes = [MODULE, HasPermission(P_RESUMPTION_SUBMIT)] + + @extend_schema(request=s.ResumptionSerializer, + responses=s.LeaveRequestSerializer, + summary="Submit a resumption of duty") + def post(self, request, pk): + payload = s.ResumptionSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = applications.submit_resumption( + principal=_actor(request), leave_id=pk, + actual_date=payload.validated_data["actual_date"], + remarks=payload.validated_data.get("remarks", "")) + return Response(s.LeaveRequestSerializer(row).data) + + +@extend_schema(tags=["leave"]) +class ResumptionReviewView(APIView): + """POST /leave/requests//resumption-review EL-UC-011""" + + permission_classes = [MODULE, HasPermission(P_RESUMPTION_REVIEW)] + + @extend_schema(request=s.ResumptionReviewSerializer, + responses=s.LeaveRequestSerializer, + summary="Review a submitted resumption") + def post(self, request, pk): + payload = s.ResumptionReviewSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = decisions.review_resumption( + principal=_actor(request), request_id=pk, + outcome=payload.validated_data["outcome"], + remarks=payload.validated_data.get("remarks", "")) + return Response(s.LeaveRequestSerializer(row).data) + + +# -- approved leave: cancellation and extension ------------------------------- +@extend_schema(tags=["leave"]) +class CancellationView(APIView): + """POST /leave/leaves//cancellation EL-UC-008""" + + permission_classes = [MODULE, HasPermission(P_CANCEL)] + + @extend_schema(request=s.CancellationSerializer, + responses={201: s.LeaveRequestSerializer}, + summary="Request cancellation of approved leave") + def post(self, request, pk): + payload = s.CancellationSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = applications.request_cancellation( + principal=_actor(request), leave_id=pk, + reason=payload.validated_data["reason"]) + return Response(s.LeaveRequestSerializer(row).data, + status=status.HTTP_201_CREATED) + + +@extend_schema(tags=["leave"]) +class ExtensionView(APIView): + """POST /leave/leaves//extension EL-UC-009""" + + permission_classes = [MODULE, HasPermission(P_EXTEND)] + + @extend_schema(request=s.ExtensionSerializer, + responses={201: s.LeaveRequestSerializer}, + summary="Request an extension of ongoing leave") + def post(self, request, pk): + payload = s.ExtensionSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = applications.request_extension( + principal=_actor(request), leave_id=pk, + end_date=payload.validated_data["end_date"], + substitute_user_id=payload.validated_data.get("substitute_user_id"), + substitute_required=payload.validated_data.get( + "substitute_required", False)) + return Response(s.LeaveRequestSerializer(row).data, + status=status.HTTP_201_CREATED) + + +@extend_schema(tags=["leave"]) +class ApprovedLeaveView(APIView): + """GET /leave/leaves my approved leave, with its amendments""" + + permission_classes = [MODULE, HasPermission(P_VIEW_SELF)] + + @extend_schema(responses=s.LeaveRequestSerializer(many=True), + summary="My approved leave") + def get(self, request): + actor = _actor(request) + rows = (LeaveRequest.objects + .filter(user_id=actor.user_id, kind="new", status="approved") + .order_by("-from_date")) + return Response({"results": [ + {**s.LeaveRequestSerializer(r).data, + "amendments": s.LeaveRequestSerializer( + r.amendments.all(), many=True).data} + for r in rows]}) + + +# -- staff views -------------------------------------------------------------- +@extend_schema(tags=["leave"]) +class AllRequestsView(APIView): + """GET /leave/all every request, filtered""" + + permission_classes = [MODULE, HasPermission(P_VIEW_ALL)] + + @extend_schema(responses=s.LeaveRequestSerializer(many=True), + summary="All leave requests") + def get(self, request): + rows = leave_selectors.visible_requests(_actor(request)) + for name in ("status", "category", "kind", "user_id"): + value = request.query_params.get(name) + if value: + rows = rows.filter(**{name: value}) + return Response(_page(s.LeaveRequestSerializer, rows[:200])) + + +@extend_schema(tags=["leave"]) +class OfflineLeaveView(APIView): + """POST /leave/offline EL-UC-016: record already-sanctioned leave""" + + permission_classes = [MODULE, HasPermission(P_RECORDS)] + + @extend_schema(request=s.OfflineLeaveSerializer, + responses={201: s.LeaveRequestSerializer}, + summary="Record leave sanctioned offline") + def post(self, request): + payload = s.OfflineLeaveSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = offline.record(principal=_actor(request), + **payload.validated_data) + return Response(s.LeaveRequestSerializer(row).data, + status=status.HTTP_201_CREATED) + + +# -- configuration ------------------------------------------------------------ +@extend_schema(tags=["leave"]) +class PolicyView(APIView): + """GET /leave/policy the current configured policy + POST /leave/policy revise it (EL-UC-014) + """ + + permission_classes = [MODULE, HasPermission(P_POLICY)] + + @extend_schema(responses=s.PolicySerializer, summary="Current policy") + def get(self, request): + policy = LeavePolicy.current() + if policy is None: + raise NotFoundError("Leave has not been configured yet.", + code="policy_missing") + return Response(s.PolicySerializer(policy).data) + + @extend_schema(request=s.PolicySerializer, responses=s.PolicySerializer, + summary="Save the current policy configuration") + def post(self, request): + payload = s.PolicySerializer(data=request.data) + payload.is_valid(raise_exception=True) + existing = LeavePolicy.current() + if existing is not None: + for field, value in payload.validated_data.items(): + setattr(existing, field, value) + existing.save() + return Response(s.PolicySerializer(existing).data) + policy = LeavePolicy.objects.create(**payload.validated_data) + return Response(s.PolicySerializer(policy).data, + status=status.HTTP_201_CREATED) + + +@extend_schema(tags=["leave"]) +class CalendarListView(APIView): + """GET /leave/calendars calendars for a year + POST /leave/calendars draft a revision (EL-UC-015) + """ + + permission_classes = [MODULE, HasPermission(P_CALENDAR)] + + @extend_schema(responses=s.CalendarSerializer(many=True), + summary="Calendars, published and draft") + def get(self, request): + year = request.query_params.get("year") + rows = LeaveCalendar.objects.all() + if year: + rows = rows.filter(year_label=year) + return Response(_page(s.CalendarSerializer, rows)) + + @extend_schema(request=s.CalendarWriteSerializer, + responses={201: s.CalendarSerializer}, + summary="Draft a calendar revision") + def post(self, request): + payload = s.CalendarWriteSerializer(data=request.data) + payload.is_valid(raise_exception=True) + data = payload.validated_data + row = calendar_service.create_draft( + principal=_actor(request), year_label=data["year_label"], + label=data.get("label", ""), entries=data["entries"]) + return Response(s.CalendarSerializer(row).data, + status=status.HTTP_201_CREATED) + + +@extend_schema(tags=["leave"]) +class CalendarPublishView(APIView): + """POST /leave/calendars//publish make a revision effective""" + + permission_classes = [MODULE, HasPermission(P_CALENDAR)] + + @extend_schema(responses=s.CalendarSerializer, + summary="Publish a calendar revision") + def post(self, request, pk): + row = calendar_service.publish(principal=_actor(request), calendar_id=pk) + return Response(s.CalendarSerializer(row).data) + + +@extend_schema(tags=["leave"]) +class VacationPeriodsView(APIView): + """POST /leave/vacations project the Academic Calendar's vacation + periods (EXT-01). Idempotent.""" + + permission_classes = [MODULE, HasPermission(P_CALENDAR)] + + @extend_schema(request=s.ExtensionSerializer, responses={"count": "int"}, + summary="Ingest vacation periods for a leave year") + def post(self, request): + year = request.data.get("year") + periods = [(p["date"], p["ends_on"], p.get("label", "")) + for p in request.data.get("periods", [])] + if not year: + raise NotFoundError("A leave year is required.", code="year_missing") + count = calendar_service.ingest_vacation_periods(year_label=year, + periods=periods) + return Response({"count": count}) + + +@extend_schema(tags=["leave"]) +class SlaSweepView(APIView): + """POST /leave/sla/sweep EVT-02, runnable by hand for a check.""" + + permission_classes = [MODULE, HasPermission(P_POLICY)] + + @extend_schema(summary="Run the SLA reminder and escalation sweep") + def post(self, request): + return Response({"report": str(sla.sweep())}) + + +@extend_schema(tags=["leave"]) +class YearEndView(APIView): + """POST /leave/year-end EVT-01, runnable by hand for a check.""" + + permission_classes = [MODULE, HasPermission(P_POLICY)] + + @extend_schema(summary="Run year-end leave processing") + def post(self, request): + policy = LeavePolicy.current() + if policy is None: + raise NotFoundError("Leave has not been configured yet.", + code="policy_missing") + report = year_end.run(policy=policy, dry_run=False) + return Response({"report": str(report), + "lapsed": report.lapsed, "converted": report.converted, + "carried": report.carried, + "exceptions": report.exceptions}) + + +@extend_schema(tags=["leave"]) +class CalendarForYearView(APIView): + """GET /leave/calendar/ the calendar in force, for the client.""" + + permission_classes = [MODULE, HasPermission(P_VIEW_SELF)] + + @extend_schema(responses=s.CalendarSerializer, + summary="The published calendar in force") + def get(self, request, year): + cal = balance_selectors.published_calendar(year) + if cal is None: + raise NotFoundError(f"No published calendar for {year}.", + code="calendar_missing") + return Response(s.CalendarSerializer(cal).data) diff --git a/modules/leave/apps.py b/modules/leave/apps.py new file mode 100644 index 00000000..73928a56 --- /dev/null +++ b/modules/leave/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class LeaveConfig(AppConfig): + name = "modules.leave" + label = "leave" + verbose_name = "Employee Leave Management" diff --git a/modules/leave/contracts.py b/modules/leave/contracts.py new file mode 100644 index 00000000..82282419 --- /dev/null +++ b/modules/leave/contracts.py @@ -0,0 +1,63 @@ +"""leave's public surface. The ONLY thing other modules may import. + +Plural by signature on purpose — a singular getter is what gets called inside a +loop, and making the batched form the only form available means an N+1 here is +unwritable rather than merely discouraged. + +Nothing here mutates. If another module needs a change made, that is an event, +not a function call. +""" +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from modules.leave.models import LeaveRequest + + +@dataclass(frozen=True) +class LeaveSummaryDTO: + user_id: int + #: Leave in effect or approved and not yet started. + on_leave: bool + #: Anything still moving through the hierarchy. + pending: int + #: Categories the employee is currently on leave for. + categories: tuple[str, ...] + + +def get_leave_summaries(user_ids: Sequence[int]) -> dict[int, LeaveSummaryDTO]: + """Who is on leave, and what is still pending, for a batch of people. + + A no-dues desk asking about one student is the case this exists for: it + needs a yes/no plus the reason, not a leave request list. + """ + ids = {int(i) for i in user_ids if i is not None} + if not ids: + return {} + rows = (LeaveRequest.objects + .filter(user_id__in=ids, kind="new") + .exclude(status__in=["rejected", "withdrawn", "cancelled"]) + .values("user_id", "category", "status")) + + out: dict[int, LeaveSummaryDTO] = { + uid: LeaveSummaryDTO(uid, on_leave=False, pending=0, categories=()) + for uid in ids} + seen: dict[int, set[str]] = {uid: set() for uid in ids} + for row in rows: + uid = row["user_id"] + approved = row["status"] == "approved" + seen[uid].add(row["category"]) + out[uid] = LeaveSummaryDTO( + uid, + on_leave=approved or out[uid].on_leave, + pending=out[uid].pending + (0 if approved else 1), + categories=tuple(sorted(seen[uid]))) + return out + + +def get_on_leave_user_ids(user_ids: Sequence[int]) -> list[int]: + """The subset currently on leave, for whoever needs to exclude them from a + duty roster.""" + return sorted(uid for uid, s in get_leave_summaries(user_ids).items() + if s.on_leave) diff --git a/modules/leave/domain/__init__.py b/modules/leave/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/leave/domain/categories.py b/modules/leave/domain/categories.py new file mode 100644 index 00000000..f03672e9 --- /dev/null +++ b/modules/leave/domain/categories.py @@ -0,0 +1,200 @@ +"""Leave categories as data: entitlement, counting, year-end outcome. + +Everything here is a lookup or a pure function over dates — no Django, no I/O, +so the rules test in milliseconds with no database. The numbers themselves +(policy supplies 8, 2, 15, 30, 60, 20) arrive as arguments, because the baseline +says validation uses the CURRENT CONFIGURED values (BR-EL-030) rather than a +published version. + + CL 8 days, first/second half, lapses + RH 2 days, must match the published RH calendar, lapses + SCL 15 days, admissibility is the authority's judgement, lapses + EL 30 days for non-faculty, carries forward + VL 60 days for faculty, vacation-period bound, converts to EL 2:1 + COL 20 days, medical/balance controlled, carries forward +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + +CL = "cl" +RH = "rh" +SCL = "scl" +EL = "el" +COL = "col" +VL = "vl" + +CATEGORIES: tuple[str, ...] = (CL, RH, SCL, EL, COL, VL) + +LABELS = { + CL: "Casual Leave", + RH: "Restricted Holiday", + SCL: "Special Casual Leave", + EL: "Earned Leave", + COL: "Commuted Leave", + VL: "Vacation Leave", +} + +FACULTY = "faculty" +STAFF = "staff" +EMPLOYEE_KINDS = (FACULTY, STAFF) + +FIRST_HALF = "first" +SECOND_HALF = "second" +HALVES = (FIRST_HALF, SECOND_HALF) + +#: Year-end treatment, one per category (BR-EL-002..009). +LAPSE = "lapse" +CARRY_FORWARD = "carry_forward" +CONVERT = "convert" + + +@dataclass(frozen=True) +class Category: + code: str + label: str + #: Days credited at the start of the leave year, to each eligible employee. + annual_days: int + #: Weekends and closed holidays inside the interval count as leave + #: (BR-EL-011). False for CL, SCL and RH. + continuous_counting: bool + #: Unused balance crosses the year boundary (BR-EL-005, BR-EL-009). + carries_forward: bool + #: First-half / second-half requests, 0.5 each (BR-EL-010). + allows_half_day: bool + #: EL/COL/VL may be extended while ongoing, and are formally resumed. + extension_eligible: bool + resumption_applies: bool + #: The Unit Head is the FINAL authority, not a recommender (BR-EL-016). + unit_head_final: bool + #: Who may hold it. VL is faculty-only; the 30-day EL credit is + #: non-faculty-only, though faculty do hold EL once VL has converted. + kinds: tuple[str, ...] + #: VL may be availed only inside a vacation period (BR-EL-006). + requires_vacation_period: bool = False + + +CATEGORY_TABLE: tuple[Category, ...] = ( + Category(CL, LABELS[CL], 8, False, False, True, False, False, True, + EMPLOYEE_KINDS), + Category(RH, LABELS[RH], 2, False, False, False, False, False, True, + EMPLOYEE_KINDS), + Category(SCL, LABELS[SCL], 15, False, False, False, False, False, False, + EMPLOYEE_KINDS), + Category(EL, LABELS[EL], 30, True, True, False, True, True, False, + (STAFF,)), + Category(COL, LABELS[COL], 20, True, True, False, True, True, False, + EMPLOYEE_KINDS), + Category(VL, LABELS[VL], 60, True, False, False, True, True, False, + (FACULTY,), requires_vacation_period=True), +) + +_BY_CODE = {c.code: c for c in CATEGORY_TABLE} + + +def category(code: str) -> Category: + """Look a category up. An unknown code is a programming error, not a + user-visible one — the serializer validates against CATEGORIES first.""" + try: + return _BY_CODE[code] + except KeyError as exc: + raise LookupError(f"Unknown leave category {code!r}.") from exc + + +def eligible_categories(kind: str) -> tuple[str, ...]: + """Categories an employee of this kind may apply for (BR-EL-001).""" + return tuple(c.code for c in CATEGORY_TABLE if kind in c.kinds) + + +def annual_credit(code: str, kind: str) -> int: + """Days credited at the start of the leave year. Zero means the category + is not credited to this employee — faculty take VL and receive EL only + through conversion (BR-EL-008).""" + c = category(code) + return c.annual_days if kind in c.kinds else 0 + + +def weekend_dates(start: date, end: date) -> set[date]: + """Saturday and Sunday inside the interval, inclusive.""" + out, d = set(), start + while d <= end: + if d.weekday() > 5: + out.add(d) + d = date.fromordinal(d.toordinal() + 1) + return out + + +def span_days(start: date, end: date) -> int: + """Calendar days, both ends included.""" + return (end - start).days + 1 + + +@dataclass(frozen=True) +class Interval: + """A requested period. `half` is set only for a half-day CL.""" + + start: date + end: date + half: str | None = None + + @property + def days(self) -> Decimal: + if self.half: + return Decimal("0.5") + return Decimal(span_days(self.start, self.end)) + + def overlaps(self, other: Interval) -> bool: + """BR-EL-012. Two CL halves on the same date do not overlap when they + occupy different halves — that is the one exception.""" + if self.end < other.start or other.end < self.start: + return False + same_day = (self.start == self.end == other.start == other.end) + if same_day and self.half and other.half and self.half == other.half: + return False + return True + + +def leave_days(*, code: str, start: date, end: date, half: str | None = None, + closed_dates: frozenset[date] = frozenset()) -> Decimal: + """Days a request consumes (BR-EL-010, BR-EL-011). + + EL/COL/VL count continuously, so a weekend or closed holiday inside the + interval is a leave day. CL, SCL and RH count duty days only — for those, + weekends and closed holidays drop out. + """ + c = category(code) + if c.allows_half_day and half: + return Decimal("0.5") + if c.continuous_counting: + return Decimal(span_days(start, end)) + duty = span_days(start, end) - len(weekend_dates(start, end) | set(closed_dates)) + return Decimal(max(duty, 0)) + + +@dataclass(frozen=True) +class YearEndAction: + kind: str + #: Days lapsed, carried forward, or converted out of the category. + amount: Decimal + #: Where converted days land: EL for VL (BR-EL-007). + target: str | None = None + + +def year_end_action(code: str, available: Decimal, *, + vl_per_el: int = 2) -> YearEndAction: + """What happens to an unused balance at the year-end boundary (EL-UC-020). + + Unused CL, RH and SCL lapse. Unused VL converts to EL at 2:1, fractions + included, and the resulting EL is ordinary EL afterwards. EL and COL carry + forward. + """ + if available <= 0: + return YearEndAction("none", available) + c = category(code) + if code == VL: + return YearEndAction(CONVERT, available * Decimal(vl_per_el), target=EL) + if c.carries_forward: + return YearEndAction(CARRY_FORWARD, available) + return YearEndAction(LAPSE, available) diff --git a/modules/leave/domain/eligibility.py b/modules/leave/domain/eligibility.py new file mode 100644 index 00000000..49393f9f --- /dev/null +++ b/modules/leave/domain/eligibility.py @@ -0,0 +1,143 @@ +"""Validation of a leave request before it may progress (EL-UC-001 M4). + +Fail closed, the way placement's eligibility engine does: an unknown category, +a missing fact or an empty vacation-period list all refuse rather than assume. +The rules are the ones the baseline states outright — BR-EL-001..015 and +BR-EL-030/031 — and `services/` supplies the facts, so this stays pure. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date +from decimal import Decimal + +from modules.leave.domain import categories as cat +from modules.leave.domain.categories import Interval + + +@dataclass(frozen=True) +class Request: + """Everything the rules need, and nothing they do not.""" + + category_code: str + employee_kind: str + start: date + end: date + half: str | None = None + #: Balance already computed by the account service. + available: Decimal = Decimal(0) + #: From the currently effective published calendar. + closed_dates: frozenset[date] = frozenset() + restricted_holidays: frozenset[date] = frozenset() + #: Vacation periods projected from the Academic Calendar (EXT-01). + vacation_periods: tuple[tuple[date, date], ...] = () + #: The applicant's own active pending or approved leave. + existing: tuple[Interval, ...] = () + #: Responsibility coverage, when required. + substitute_required: bool = False + substitute_user_id: int | None = None + applicant_user_id: int | None = None + substitute_intervals: tuple[Interval, ...] = () + + +@dataclass(frozen=True) +class Outcome: + code: str + message: str + + +@dataclass(frozen=True) +class Verdict: + is_valid: bool + days: Decimal + outcomes: tuple[Outcome, ...] = field(default_factory=tuple) + + @property + def reasons(self) -> tuple[str, ...]: + return tuple(o.code for o in self.outcomes) + + +def _in_vacation(start: date, end: date, + periods: tuple[tuple[date, date], ...]) -> bool: + return any(p_start <= start and end <= p_end for p_start, p_end in periods) + + +def validate(req: Request) -> Verdict: + """Run every applicable rule. Stops at the first failure it finds for each + concern, but reports all of them at once — an employee fixing a form should + not have to submit it four times to learn four things.""" + out: list[Outcome] = [] + code = req.category_code + + if code not in cat.CATEGORIES: + return Verdict(False, Decimal(0), (Outcome( + "category_not_supported", + f"{code or 'No category'} is not a leave category this module " + f"supports. Choose one of: {', '.join(cat.CATEGORIES)}."),)) + c = cat.category(code) + + if req.employee_kind not in c.kinds: + return Verdict(False, Decimal(0), (Outcome( + "category_not_applicable", + f"{c.label} is not available to {req.employee_kind} employees."),)) + + if req.end < req.start: + return Verdict(False, Decimal(0), (Outcome( + "invalid_period", "The leave cannot end before it starts."),)) + + if req.half: + if not c.allows_half_day: + return Verdict(False, Decimal(0), (Outcome( + "half_day_not_allowed", + f"{c.label} cannot be taken for half a day. " + f"Per BR-EL-010, only CL supports half-day requests."),)) + if req.start != req.end: + return Verdict(False, Decimal(0), (Outcome( + "half_day_single_date", + "A half-day request must be for a single date. " + f"Per BR-EL-010, CL first-half and second-half on the same date " + "are not overlapping when they occupy different halves."),)) + + days = cat.leave_days(code=code, start=req.start, end=req.end, + half=req.half, closed_dates=req.closed_dates) + + if not c.continuous_counting and days <= 0: + out.append(Outcome( + "no_duty_days", + "Every day in that period is a weekend or a closed holiday, so " + "there is nothing to sanction.")) + + # BR-EL-011: EL/COL/VL count continuously (weekends/holidays count as leave days), + # CL/SCL/RH count only duty days (weekends/holidays do not count). + if days > req.available: + out.append(Outcome( + "insufficient_balance", + f"{c.label} balance is {req.available} day(s); this request needs " + f"{days}.")) + + # BR-EL-003: RH must name a Restricted Holiday from the effective calendar. + if code == cat.RH and req.start not in req.restricted_holidays: + out.append(Outcome( + "rh_not_in_calendar", + "That date is not a Restricted Holiday in the currently published " + "calendar.")) + + # BR-EL-006: VL only inside a vacation period. + if c.requires_vacation_period and not _in_vacation(req.start, req.end, + req.vacation_periods): + out.append(Outcome( + "outside_vacation_period", + "Vacation Leave may be availed only inside a vacation period from " + "the Academic Calendar.")) + + # BR-EL-012: no overlap with the applicant's own active leave. + mine = Interval(req.start, req.end, req.half) + if any(mine.overlaps(other) for other in req.existing): + out.append(Outcome( + "overlapping_leave", + "You already have pending or approved leave on one of those days.")) + + # BR-EL-013: Station Leave rides along on the same application; there is + # nothing separate to validate, only to remember not to invent. + + return Verdict(not out, days, tuple(out)) diff --git a/modules/leave/domain/guards.py b/modules/leave/domain/guards.py new file mode 100644 index 00000000..8ede115b --- /dev/null +++ b/modules/leave/domain/guards.py @@ -0,0 +1,200 @@ +"""Guard evaluation: the table says WHICH guards, this says whether they hold. + +A transition's guards are names, never inline lambdas — the table stays +declarative and renderable, and every rule gets exactly one implementation +that the tests can reach directly. + +`Facts` is the whole picture a service assembles before it moves anything: +policy-derived facts (which category, whose leave, which decision) alongside +data-derived ones (has the leave started, is the nominee eligible). The +functions below are pure over that value object, which is why the interesting +rules test with no database at all. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from modules.leave.domain import categories as cat +from modules.leave.domain import routing +from modules.leave.domain.state_machine import ( + GuardFailed, + KIND_NEW, + outgoing, +) + +APPROVE = "approve" +REJECT = "reject" +ACCEPT = "accept" +DECLINE = "decline" + + +@dataclass(frozen=True) +class Facts: + kind: str = KIND_NEW + category_code: str = cat.CL + employee_kind: str = cat.STAFF + roles: tuple[str, ...] = () + #: Does this request need a substitute at all? + substitute_required: bool = False + #: approve | reject | recommended | not_recommended | accept | decline + decision: str = "" + establishment_configured: bool = False + #: The leave the request acts on, for extension/cancellation/resumption. + leave_started: bool = False + leave_ended: bool = False + #: Withdrawal is available only before a final decision. + is_final: bool = False + replacement_eligible: bool = False + routing_context_valid: bool = True + + +def _substitute_required(f: Facts) -> bool: + return f.substitute_required + + +def _no_substitute_required(f: Facts) -> bool: + return not f.substitute_required + + +def _applicant_is_director(f: Facts) -> bool: + return routing.is_director(f.roles) + + +def _applicant_not_director(f: Facts) -> bool: + return not routing.is_director(f.roles) + + +def _consent_accepted(f: Facts) -> bool: + return f.decision == ACCEPT + + +def _consent_declined(f: Facts) -> bool: + return f.decision == DECLINE + + +def _replacement_eligible(f: Facts) -> bool: + return f.replacement_eligible + + +def _unit_head_final(f: Facts) -> bool: + return cat.category(f.category_code).unit_head_final + + +def _higher_sanction(f: Facts) -> bool: + return not cat.category(f.category_code).unit_head_final + + +def _establishment_configured(f: Facts) -> bool: + return f.establishment_configured + + +def _decision_approve(f: Facts) -> bool: + return f.decision == APPROVE + + +def _decision_reject(f: Facts) -> bool: + return f.decision == REJECT + + +def _routing_context_valid(f: Facts) -> bool: + return f.routing_context_valid + + +def _not_final(f: Facts) -> bool: + return not f.is_final + + +def _pre_start(f: Facts) -> bool: + """Cancellation is available only before the leave begins (BR-EL-023).""" + return not f.leave_started + + +def _leave_ongoing(f: Facts) -> bool: + """Extension applies to leave already in effect and not yet returned.""" + return f.leave_started and not f.leave_ended + + +def _extension_eligible(f: Facts) -> bool: + return cat.category(f.category_code).extension_eligible + + +def _leave_started(f: Facts) -> bool: + return f.leave_started + + +def _resumption_eligible(f: Facts) -> bool: + return cat.category(f.category_code).resumption_applies + + +#: Every guard named in the state machine. An unknown name is a loud failure +#: rather than a silently-passing check. +GUARDS = { + "substitute_required": _substitute_required, + "no_substitute_required": _no_substitute_required, + "applicant_is_director": _applicant_is_director, + "applicant_not_director": _applicant_not_director, + "consent_accepted": _consent_accepted, + "consent_declined": _consent_declined, + "replacement_eligible": _replacement_eligible, + "unit_head_final": _unit_head_final, + "higher_sanction": _higher_sanction, + "establishment_configured": _establishment_configured, + "decision_approve": _decision_approve, + "decision_reject": _decision_reject, + "routing_context_valid": _routing_context_valid, + "not_final": _not_final, + "pre_start": _pre_start, + "leave_ongoing": _leave_ongoing, + "extension_eligible": _extension_eligible, + "leave_started": _leave_started, + "resumption_eligible": _resumption_eligible, +} + + +def satisfied(name: str, facts: Facts) -> bool: + try: + fn = GUARDS[name] + except KeyError as exc: + raise LookupError(f"Guard {name!r} has no implementation.") from exc + return fn(facts) + + +def check(transition, facts: Facts) -> None: + """Raise GuardFailed on the first unsatisfied guard.""" + for name in transition.guards: + if not satisfied(name, facts): + raise GuardFailed(name) + + +def select(*, frm: str, permission: str, facts: Facts, actor_kind: str): + """The transition this caller may actually take. + + Several transitions can share a `frm` and differ only in their guards — + a CL/RH request and a higher-sanction request both leave the Unit Head, but + not to the same place. Walking the table and taking the first edge whose + guards hold is what keeps "which state next?" a property of the rules + rather than of the calling code. + """ + for t in outgoing(frm): + if t.permission != permission or t.actor != actor_kind: + continue + try: + check(t, facts) + except GuardFailed: + continue + return t + raise GuardFailed("no_transition", _why_not(frm, permission, facts)) + + +def _why_not(frm: str, permission: str, facts: Facts) -> str: + """A sentence for whoever is holding the request, not a stack trace.""" + tried = [t for t in outgoing(frm) + if t.permission == permission] + if not tried: + return (f"Nothing may be done to a leave request in state {frm!r} with " + f"{permission!r}.") + for t in tried: + for name in t.guards: + if not satisfied(name, facts): + return f"{name} is not satisfied for this request." + return "No transition's guards are satisfied." diff --git a/modules/leave/domain/routing.py b/modules/leave/domain/routing.py new file mode 100644 index 00000000..ab91a736 --- /dev/null +++ b/modules/leave/domain/routing.py @@ -0,0 +1,86 @@ +"""Resolving WHO acts: the Unit Head, and the configured higher-sanction path. + +The specification is deliberately configuration-driven (BR-EL-019, BR-EL-030): +the path is resolved from the applicant, the organizational unit, the +designation/role, the leave category and the current hierarchy configuration. +Named roles — Deputy Registrar (Estt.), Dean, Registrar, Director — appear only +where configured, and the Establishment step is optional. + +Nothing here imports Django: the configuration is handed in as plain values. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from modules.leave.domain import categories as cat +from modules.leave.domain.state_machine import ESTABLISHMENT, UNIT_HEAD + +#: The designation that triggers the Director-specific self-sanction rule. +DIRECTOR_ROLE = "Director" + + +@dataclass(frozen=True) +class Hierarchy: + """The current configuration, with no version lifecycle (BR-EL-030). + + `authority_path` is the ordered list of designations a higher-sanction + request travels through after the Unit Head's recommendation. The + Establishment step is a real step only when it is configured AND its + designation appears in the path. + """ + + authority_path: tuple[str, ...] = ("Deputy Registrar", "Registrar") + establishment_designation: str = "Deputy Registrar" + establishment_step_enabled: bool = True + + +@dataclass(frozen=True) +class Path: + """Where a request goes.""" + + unit_head_user_id: int | None + final_authority: str + establishment_first: bool + self_sanction: bool + + @property + def steps(self) -> tuple[str, ...]: + """The ordered actor lanes a request passes through.""" + out: list[str] = [] + if self.unit_head_user_id is not None: + out.append(UNIT_HEAD) + if self.establishment_first: + out.append(ESTABLISHMENT) + out.append("authority") + return tuple(out) + + +def is_director(roles: tuple[str, ...]) -> bool: + return DIRECTOR_ROLE in tuple(roles) + + +def resolve_path(*, category_code: str, roles: tuple[str, ...], + unit_head_user_id: int | None, + hierarchy: Hierarchy | None = None) -> Path: + """The sanctioning path for a request (BR-EL-016..020). + + CL and RH, including their Station Leave, finish at the Unit Head. SCL, EL, + COL and VL get a recommendation there and continue through the configured + hierarchy. A Director applicant never travels the normal path at all. + """ + h = hierarchy or Hierarchy() + if is_director(roles): + return Path(unit_head_user_id=None, final_authority=DIRECTOR_ROLE, + establishment_first=False, self_sanction=True) + + if cat.category(category_code).unit_head_final: + return Path(unit_head_user_id=unit_head_user_id, + final_authority="Unit Head", establishment_first=False, + self_sanction=False) + + establishment_first = bool(h.establishment_step_enabled + and h.establishment_designation + and h.establishment_designation in h.authority_path) + final = h.authority_path[-1] if h.authority_path else DIRECTOR_ROLE + return Path(unit_head_user_id=unit_head_user_id, final_authority=final, + establishment_first=establishment_first, self_sanction=False) diff --git a/modules/leave/domain/state_machine.py b/modules/leave/domain/state_machine.py new file mode 100644 index 00000000..920842c2 --- /dev/null +++ b/modules/leave/domain/state_machine.py @@ -0,0 +1,218 @@ +"""The leave request state machine. + +A declarative table of legal moves, so an illegal transition is impossible to +express rather than merely rejected, and "who may do this?" has exactly one +answer per edge (the workflow specification's STT, CW-EL-01..04). + +Two shapes of request share this table: + + kind = new | extension | cancellation | resumption + +A submitted request is never edited in place (BR-EL-034), so a change is a new +row: an extension, a cancellation and a resumption are each a request of their +own linked to the leave they act on. That is what keeps the approved leave +"the base" for an extension, and what makes "cancellation does not edit the +approved leave" structural rather than a convention. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +# -- states ------------------------------------------------------------------- +DRAFT = "draft" +AWAITING_SUBSTITUTE = "awaiting_substitute" +APPLICANT_ACTION_REQUIRED = "applicant_action_required" +AWAITING_UNIT_HEAD = "awaiting_unit_head" +AWAITING_ESTABLISHMENT = "awaiting_establishment" +AWAITING_FINAL = "awaiting_final" +AWAITING_SELF_SANCTION = "awaiting_self_sanction" +RESUMPTION_PENDING = "resumption_pending" +APPROVED = "approved" +REJECTED = "rejected" +WITHDRAWN = "withdrawn" +CANCELLED = "cancelled" +RESUMED = "resumed" + +#: Every state a request can be in before a final decision. Withdrawal is +#: available from all of them (CW-EL-01 row 17). +PENDING = frozenset({ + AWAITING_SUBSTITUTE, APPLICANT_ACTION_REQUIRED, AWAITING_UNIT_HEAD, + AWAITING_ESTABLISHMENT, AWAITING_FINAL, AWAITING_SELF_SANCTION, + RESUMPTION_PENDING, +}) + +#: No further processing is possible. +TERMINAL = frozenset({APPROVED, REJECTED, WITHDRAWN, CANCELLED, RESUMED}) + +STATES = frozenset({DRAFT} | PENDING | TERMINAL) + +# -- request kinds ------------------------------------------------------------ +KIND_NEW = "new" +KIND_EXTENSION = "extension" +KIND_CANCELLATION = "cancellation" +KIND_RESUMPTION = "resumption" + +#-- actors ------------------------------------------------------------------- +# Not designations: lanes, the way placement's state machine reads them. Which +# person occupies a lane for a given request is the routing layer's business. +EMPLOYEE = "employee" +SUBSTITUTE = "substitute" +UNIT_HEAD = "unit_head" +ESTABLISHMENT = "establishment" +AUTHORITY = "authority" +SYSTEM = "system" + +ACTOR_KINDS = (EMPLOYEE, SUBSTITUTE, UNIT_HEAD, ESTABLISHMENT, AUTHORITY, SYSTEM) + + +class InvalidTransition(Exception): + def __init__(self, frm: str, to: str): + super().__init__(f"A leave request cannot move from {frm!r} to {to!r}.") + self.frm, self.to = frm, to + + +class ActorNotAllowed(Exception): + def __init__(self, actor_kind: str, frm: str, to: str): + super().__init__( + f"A {actor_kind} may not move a leave request from {frm!r} to {to!r}.") + self.actor_kind, self.frm, self.to = actor_kind, frm, to + + +class GuardFailed(Exception): + """A guard named in the table is not satisfied. The caller turns this into + a message written for whoever reads it.""" + + def __init__(self, guard: str, message: str = ""): + super().__init__(message or f"Guard {guard!r} failed.") + self.guard = guard + + +@dataclass(frozen=True) +class Transition: + frm: str + to: str + actor: str + permission: str + guards: tuple[str, ...] = field(default_factory=tuple) + + +def _withdraw_states() -> tuple[Transition, ...]: + return tuple( + Transition(s, WITHDRAWN, EMPLOYEE, "leave.application.withdraw", + ("not_final",)) + for s in sorted(PENDING) + ) + + +TRANSITIONS: tuple[Transition, ...] = ( + # -- submission (EL-UC-001) --------------------------------------------- + # The substitute consent gate comes before any review, including for a + # Director applicant (CW-EL-01 row 4). + Transition(DRAFT, AWAITING_SUBSTITUTE, EMPLOYEE, "leave.application.create", + ("substitute_required",)), + Transition(DRAFT, AWAITING_UNIT_HEAD, EMPLOYEE, "leave.application.create", + ("no_substitute_required", "applicant_not_director")), + Transition(DRAFT, AWAITING_SELF_SANCTION, EMPLOYEE, + "leave.application.create", + ("no_substitute_required", "applicant_is_director")), + + # -- the consent gate (EL-UC-002, EL-UC-019) ---------------------------- + Transition(AWAITING_SUBSTITUTE, AWAITING_UNIT_HEAD, SUBSTITUTE, + "leave.substitute.respond", + ("consent_accepted", "applicant_not_director")), + Transition(AWAITING_SUBSTITUTE, AWAITING_SELF_SANCTION, SUBSTITUTE, + "leave.substitute.respond", + ("consent_accepted", "applicant_is_director")), + Transition(AWAITING_SUBSTITUTE, APPLICANT_ACTION_REQUIRED, SUBSTITUTE, + "leave.substitute.respond", ("consent_declined",)), + Transition(APPLICANT_ACTION_REQUIRED, AWAITING_SUBSTITUTE, EMPLOYEE, + "leave.substitute.nominate", ("replacement_eligible",)), + + # -- Unit Head (EL-UC-003) ---------------------------------------------- + # CL/RH end here: the Unit Head is the final authority (BR-EL-016). + Transition(AWAITING_UNIT_HEAD, APPROVED, UNIT_HEAD, "leave.request.review", + ("unit_head_final", "decision_approve")), + Transition(AWAITING_UNIT_HEAD, REJECTED, UNIT_HEAD, "leave.request.review", + ("unit_head_final", "decision_reject")), + # SCL/EL/COL/VL only get a recommendation here, and both outcomes keep + # going — Not Recommended is not a rejection (BR-EL-017). + Transition(AWAITING_UNIT_HEAD, AWAITING_ESTABLISHMENT, UNIT_HEAD, + "leave.request.review", + ("higher_sanction", "establishment_configured")), + Transition(AWAITING_UNIT_HEAD, AWAITING_FINAL, UNIT_HEAD, + "leave.request.review", ("higher_sanction",)), + + # -- Establishment processing (EL-UC-004) ------------------------------- + Transition(AWAITING_ESTABLISHMENT, AWAITING_FINAL, ESTABLISHMENT, + "leave.request.route", ("routing_context_valid",)), + + # -- final sanction (EL-UC-005, BR-EL-020) ------------------------------- + Transition(AWAITING_FINAL, APPROVED, AUTHORITY, "leave.request.approve", + ("decision_approve",)), + Transition(AWAITING_FINAL, REJECTED, AUTHORITY, "leave.request.approve", + ("decision_reject",)), + # The Director's own leave: one person, two lanes, and the table is the + # only place that pairing is allowed. + Transition(AWAITING_SELF_SANCTION, APPROVED, AUTHORITY, + "leave.request.approve", + ("applicant_is_director", "decision_approve")), + Transition(AWAITING_SELF_SANCTION, REJECTED, AUTHORITY, + "leave.request.approve", + ("applicant_is_director", "decision_reject")), + + # -- cancellation of approved leave (EL-UC-008, CW-EL-02) ---------------- + # The employee raises it; the decision then follows the same edges as the + # original leave, so nothing here is new. + Transition(DRAFT, AWAITING_UNIT_HEAD, EMPLOYEE, "leave.application.cancel", + ("pre_start",)), + + # -- extension of ongoing leave (EL-UC-009, CW-EL-03) -------------------- + Transition(DRAFT, AWAITING_SUBSTITUTE, EMPLOYEE, "leave.application.extend", + ("leave_ongoing", "extension_eligible", "substitute_required")), + Transition(DRAFT, AWAITING_UNIT_HEAD, EMPLOYEE, "leave.application.extend", + ("leave_ongoing", "extension_eligible", "no_substitute_required")), + + # -- resumption (EL-UC-010, EL-UC-011, CW-EL-04) ------------------------- + Transition(DRAFT, RESUMPTION_PENDING, EMPLOYEE, "leave.resumption.submit", + ("leave_started", "resumption_eligible")), + Transition(RESUMPTION_PENDING, RESUMED, AUTHORITY, "leave.resumption.review", + ()), + + # -- withdrawal, from every pending pre-final state (EL-UC-007) ---------- + *_withdraw_states(), +) + +_INDEX = {(t.frm, t.to): t for t in TRANSITIONS} + + +def resolve(frm: str, to: str, actor_kind: str | None = None) -> Transition: + """The single lookup every write goes through. `actor_kind` is optional + only so the table can be introspected; every service call supplies it.""" + t = _INDEX.get((frm, to)) + if t is None: + raise InvalidTransition(frm, to) + if actor_kind is not None and actor_kind not in {t.actor}: + raise ActorNotAllowed(actor_kind, frm, to) + return t + + +def is_legal(frm: str, to: str) -> bool: + return (frm, to) in _INDEX + + +def allowed_targets(frm: str, actor_kind: str | None = None) -> list[str]: + return sorted({t.to for t in TRANSITIONS + if t.frm == frm + and (actor_kind is None or actor_kind == t.actor)}) + + +def is_pending(status: str) -> bool: + return status in PENDING + + +def is_terminal(status: str) -> bool: + return status in TERMINAL + + +def outgoing(frm: str) -> tuple[Transition, ...]: + return tuple(t for t in TRANSITIONS if t.frm == frm) diff --git a/modules/leave/models.py b/modules/leave/models.py new file mode 100644 index 00000000..590105d4 --- /dev/null +++ b/modules/leave/models.py @@ -0,0 +1,462 @@ +"""Employee Leave Management tables. + +Institute people are referenced by plain `user_id` integers, never foreign +keys — there is no user table in this database, and a cross-boundary FK would +make this module impossible to lift out later (ADR-0013). The applicant's +employee kind, department and name are read through `directory's` contracts. + +No business logic and no `save()` override: `bulk_update` and `queryset.update()` +bypass both anyway, so the rules live in `domain/` and the writes in +`services/`. +""" +from __future__ import annotations + +from decimal import Decimal + +from django.db import models +from django.db.models import Q + +from core.db.mixins import TimeStampedModel, UserScopedModel +from modules.leave.domain import categories as cat +from modules.leave.domain.state_machine import ( + DRAFT, + KIND_CANCELLATION, + KIND_EXTENSION, + KIND_NEW, + KIND_RESUMPTION, + STATES, +) + +#: Status and kind choices are read off the state machine, so a state added +#: there cannot be missing here. +STATUS_CHOICES = [(s, s.replace("_", " ")) for s in sorted(STATES)] +KIND_CHOICES = [ + (KIND_NEW, "New leave"), + (KIND_EXTENSION, "Extension of ongoing leave"), + (KIND_CANCELLATION, "Cancellation of approved leave"), + (KIND_RESUMPTION, "Resumption of duty"), +] + +#: Decisions recorded against a request, at whatever stage made them. +DECISIONS = [ + ("approve", "Approve"), + ("reject", "Reject"), + ("recommended", "Recommended"), + ("not_recommended", "Not recommended"), + ("accept", "Substitute accepted"), + ("decline", "Substitute declined"), +] + + +class LeavePolicy(TimeStampedModel): + """The current configured leave-policy parameters (BR-EL-030). + + One row is current at a time; there is no draft/publish/effective-version + lifecycle in this baseline, so validation simply reads the latest row. + """ + + label = models.CharField(max_length=60, default="Leave policy") + year_label = models.CharField(max_length=9) # "2026-27" + starts_on = models.DateField() + ends_on = models.DateField() + + # Annual entitlements, per eligible employee (BR-EL-002..009). + cl_days = models.PositiveSmallIntegerField(default=8) + rh_days = models.PositiveSmallIntegerField(default=2) + scl_days = models.PositiveSmallIntegerField(default=15) + el_days = models.PositiveSmallIntegerField(default=30) + vl_days = models.PositiveSmallIntegerField(default=60) + col_days = models.PositiveSmallIntegerField(default=20) + + #: Unused VL converts to EL at 2 VL : 1 EL, fractions included. + vl_per_el = models.PositiveSmallIntegerField(default=2) + + #: The higher-sanction path, as designations (BR-EL-018/019). + authority_path = models.JSONField(default=list, blank=True) + establishment_designation = models.CharField(max_length=60, blank=True) + establishment_step_enabled = models.BooleanField(default=False) + + #: SLA thresholds for actionable tasks (BR-EL-038). + reminder_hours = models.PositiveSmallIntegerField(default=48) + escalation_hours = models.PositiveSmallIntegerField(default=120) + + #: The year-end boundary EVT-01 fires on. + year_end_on = models.DateField() + + class Meta: + db_table = "leave_policy" + ordering = ["-id"] + verbose_name_plural = "leave policies" + + def __str__(self) -> str: + return f"{self.label} ({self.year_label})" + + @classmethod + def current(cls) -> LeavePolicy: + """The configured values in force right now.""" + return cls.objects.order_by("-id").first() + + @property + def hierarchy(self): + from modules.leave.domain.routing import Hierarchy + + return Hierarchy( + authority_path=tuple(self.authority_path or ()), + establishment_designation=self.establishment_designation, + establishment_step_enabled=self.establishment_step_enabled) + + def entitlement(self, code: str) -> int: + return { + cat.CL: self.cl_days, cat.RH: self.rh_days, cat.SCL: self.scl_days, + cat.EL: self.el_days, cat.VL: self.vl_days, cat.COL: self.col_days, + }.get(code, 0) + + +class OrganizationalUnit(TimeStampedModel): + """A department, section or office and its head (ACT-03). + + Leave-owned configuration, because the Unit Head is who this module routes + to — the IAM directory knows a person's department but not who heads it. + """ + + name = models.CharField(max_length=120, unique=True) + head_user_id = models.IntegerField(db_index=True) + + class Meta: + db_table = "leave_org_unit" + ordering = ["name"] + + def __str__(self) -> str: + return self.name + + +class LeaveBalance(UserScopedModel): + """One employee's account for one category in one leave year. + + `opening` is what carried forward, `credited` is what the year added + (annual entitlement, or EL produced by VL conversion), `used` is what has + been consumed. Every change writes a LeaveTransaction, so the account can + always be explained rather than merely trusted. + """ + + year_label = models.CharField(max_length=9) + category = models.CharField(max_length=8, choices=cat.CATEGORIES) + opening = models.DecimalField(max_digits=6, decimal_places=2, + default=Decimal("0")) + credited = models.DecimalField(max_digits=6, decimal_places=2, + default=Decimal("0")) + used = models.DecimalField(max_digits=6, decimal_places=2, + default=Decimal("0")) + + class Meta: + db_table = "leave_balance" + constraints = [ + models.UniqueConstraint(fields=["user_id", "year_label", "category"], + name="leave_balance_unique"), + ] + indexes = [ + models.Index(fields=["user_id", "year_label"], + name="leave_balance_owner_idx"), + ] + + def __str__(self) -> str: + return f"{self.user_id} {self.category} {self.year_label}" + + @property + def available(self) -> Decimal: + return self.opening + self.credited - self.used + + +class LeaveRequest(UserScopedModel): + """A submitted leave-related request. + + One table for four kinds of request — new leave, extension, cancellation, + resumption — because they share one state machine and differ only in what a + final decision does to the leave they point at. That is also what makes + "the approved leave is not edited in place" structural: an extension is a + separate row that carries the incremental period, and the original stays + exactly as it was sanctioned. + """ + + kind = models.CharField(max_length=12, choices=KIND_CHOICES, default=KIND_NEW) + category = models.CharField(max_length=8, choices=cat.CATEGORIES) + status = models.CharField(max_length=32, choices=STATUS_CHOICES, default=DRAFT) + + from_date = models.DateField() + to_date = models.DateField() + #: Set for a half-day CL (BR-EL-010). + half = models.CharField(max_length=6, blank=True, + choices=[(h, h) for h in cat.HALVES]) + days = models.DecimalField(max_digits=6, decimal_places=2) + + purpose = models.TextField(max_length=1000) + + #: Station Leave details, captured inside the same application (BR-EL-013). + station_leave_required = models.BooleanField(default=False) + station_leave_details = models.CharField(max_length=300, blank=True) + + #: The approved leave this request acts on. + parent = models.ForeignKey("self", on_delete=models.PROTECT, null=True, + blank=True, related_name="amendments") + + #: Routing, frozen at submission so a configuration change cannot move a + #: request that is already halfway through the hierarchy. + unit_head_user_id = models.IntegerField(null=True, blank=True, db_index=True) + final_authority = models.CharField(max_length=60, blank=True) + establishment_first = models.BooleanField(default=False) + self_sanction = models.BooleanField(default=False) + #: The applicant's designations, as they were when the request was raised. + #: Frozen because the Director-specific rule (BR-EL-020) is about who the + #: applicant IS, and a later role change must not move a live request. + applicant_roles = models.JSONField(default=list, blank=True) + + #: Responsibility coverage (BR-EL-014/015). + substitute_user_id = models.IntegerField(null=True, blank=True, db_index=True) + substitute_response = models.CharField(max_length=12, blank=True) + substitute_responded_at = models.DateTimeField(null=True, blank=True) + + #: Offline-sanctioned leave recorded after the fact (EL-UC-016). + is_offline = models.BooleanField(default=False) + sanction_reference = models.CharField(max_length=120, blank=True) + sanctioned_on = models.DateField(null=True, blank=True) + offline_reason = models.CharField(max_length=300, blank=True) + + #: The final decision, once there is one. + decision = models.CharField(max_length=20, blank=True, choices=DECISIONS) + decision_remarks = models.CharField(max_length=300, blank=True) + decided_by_user_id = models.IntegerField(null=True, blank=True) + decided_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "leave_request" + ordering = ["-created_at"] + constraints = [ + models.CheckConstraint(condition=Q(to_date__gte=models.F("from_date")), + name="leave_request_period_valid"), + models.CheckConstraint(condition=Q(days__gte=0), + name="leave_request_days_non_negative"), + models.CheckConstraint( + condition=Q(half="") | Q(half__in=list(cat.HALVES)), + name="leave_request_half_valid"), + ] + indexes = [ + models.Index(fields=["user_id", "status", "-created_at"], + name="leave_request_owner_idx"), + models.Index(fields=["status", "category", "-created_at"], + name="leave_request_queue_idx"), + models.Index(fields=["substitute_user_id", "status"], + name="leave_request_substitute_idx"), + ] + + def __str__(self) -> str: + return f"{self.kind} {self.category} {self.from_date}..{self.to_date}" + + @property + def is_pending(self) -> bool: + from modules.leave.domain.state_machine import is_pending + + return is_pending(self.status) + + @property + def interval(self) -> cat.Interval: + return cat.Interval(self.from_date, self.to_date, self.half or None) + + +class LeaveApproval(TimeStampedModel): + """Append-only record of every decision taken on a request. + + Never updated, never deleted: the audit trail is the only place the + recommendation history survives, and "Not Recommended is not a final + rejection" is only defensible if the recommendation is still on file. + """ + + STAGES = [ + ("employee", "Employee"), + ("substitute", "Substitute consent"), + ("unit_head", "Unit Head"), + ("establishment", "Establishment"), + ("final", "Final sanctioning authority"), + ("self_sanction", "Director self-sanction"), + ] + + request = models.ForeignKey(LeaveRequest, on_delete=models.CASCADE, + related_name="approvals") + stage = models.CharField(max_length=16, choices=STAGES) + decision = models.CharField(max_length=20, choices=DECISIONS) + actor_user_id = models.IntegerField(null=True, blank=True) + remarks = models.CharField(max_length=300, blank=True) + + class Meta: + db_table = "leave_approval" + ordering = ["created_at"] + indexes = [ + models.Index(fields=["request", "created_at"], + name="leave_approval_request_idx"), + ] + + def __str__(self) -> str: + return f"{self.stage}: {self.decision}" + + +class LeaveTask(TimeStampedModel): + """An actionable workflow task, and the SLA clock that runs against it. + + Created when a request reaches a stage that needs a person, and superseded + the moment it moves on — so "no escalation if the task is no longer pending" + is a query against this table rather than a promise (BR-EL-032/033). + """ + + STATUS = [("pending", "Pending"), ("completed", "Completed"), + ("superseded", "Superseded"), ("cancelled", "Cancelled")] + + request = models.ForeignKey(LeaveRequest, on_delete=models.CASCADE, + related_name="tasks") + stage = models.CharField(max_length=16, choices=LeaveApproval.STAGES) + assignee_user_id = models.IntegerField(db_index=True) + status = models.CharField(max_length=12, choices=STATUS, default="pending") + + assigned_at = models.DateTimeField(auto_now_add=True) + actioned_at = models.DateTimeField(null=True, blank=True) + #: SLA deadlines, stamped from the policy at assignment time. + reminder_due_at = models.DateTimeField(null=True, blank=True) + escalation_due_at = models.DateTimeField(null=True, blank=True) + reminded_at = models.DateTimeField(null=True, blank=True) + escalated_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "leave_task" + ordering = ["-created_at"] + indexes = [ + # The reminder sweep's query: pending tasks, oldest deadline first. + models.Index(fields=["status", "reminder_due_at"], + name="leave_task_sla_idx"), + models.Index(fields=["assignee_user_id", "status"], + name="leave_task_assignee_idx"), + ] + + def __str__(self) -> str: + return f"{self.stage} -> {self.assignee_user_id} ({self.status})" + + +class LeaveTransaction(TimeStampedModel): + """The account ledger. Every balance change is a row here. + + A balance that cannot explain itself is a balance nobody can audit, which + matters most at year end, when lapse, conversion and carry-forward all land + on the same evening. + """ + + KINDS = [ + ("annual_credit", "Annual entitlement credited"), + ("carry_forward", "Carried forward from the previous year"), + ("approval", "Deducted for approved leave"), + ("cancellation_reversal", "Restored by an approved cancellation"), + ("extension_post", "Incremental period posted by an extension"), + ("resumption_adjustment", "Adjustment after resumption review"), + ("year_end_lapse", "Lapsed at the year-end boundary"), + ("year_end_conversion", "Converted to another category"), + ("offline_entry", "Offline-sanctioned leave recorded"), + ] + + user_id = models.IntegerField(db_index=True) + year_label = models.CharField(max_length=9) + category = models.CharField(max_length=8, choices=cat.CATEGORIES) + kind = models.CharField(max_length=24, choices=KINDS) + #: Signed: positive credits the account, negative consumes it. + days = models.DecimalField(max_digits=6, decimal_places=2) + request = models.ForeignKey(LeaveRequest, on_delete=models.SET_NULL, + null=True, blank=True, related_name="transactions") + note = models.CharField(max_length=200, blank=True) + + class Meta: + db_table = "leave_transaction" + ordering = ["created_at"] + indexes = [ + models.Index(fields=["user_id", "year_label", "category"], + name="leave_txn_account_idx"), + ] + + def __str__(self) -> str: + return f"{self.kind} {self.days} {self.category}" + + +class LeaveCalendar(TimeStampedModel): + """The holiday/RH calendar for a leave year (EL-UC-015). + + Until a replacement is PUBLISHED, the currently effective one governs date + and RH validation (BR-EL-031) — so "effective" is a property of what is + published, never of what is being drafted. + """ + + year_label = models.CharField(max_length=9, unique=True) + label = models.CharField(max_length=120, blank=True) + published_at = models.DateTimeField(null=True, blank=True) + published_by_user_id = models.IntegerField(null=True, blank=True) + + class Meta: + db_table = "leave_calendar" + ordering = ["-year_label"] + + def __str__(self) -> str: + return f"{self.year_label}{' (published)' if self.published_at else ' (draft)'}" + + +class CalendarEntry(TimeStampedModel): + """One day in a calendar: a closed holiday, a Restricted Holiday, or a + vacation period from the Academic Calendar (EXT-01).""" + + KINDS = [ + ("closed_holiday", "Closed holiday"), + ("restricted_holiday", "Restricted holiday"), + ("vacation", "Vacation period"), + ] + + calendar = models.ForeignKey(LeaveCalendar, on_delete=models.CASCADE, + related_name="entries") + date = models.DateField() + #: Inclusive; only used by a vacation period, which spans many days. + ends_on = models.DateField(null=True, blank=True) + kind = models.CharField(max_length=20, choices=KINDS) + label = models.CharField(max_length=160, blank=True) + + class Meta: + db_table = "leave_calendar_entry" + ordering = ["date"] + constraints = [ + models.UniqueConstraint(fields=["calendar", "date", "kind"], + name="leave_calendar_entry_unique"), + ] + indexes = [ + models.Index(fields=["calendar", "kind", "date"], + name="leave_calendar_lookup_idx"), + ] + + def __str__(self) -> str: + return f"{self.date} {self.kind}" + + +class LeaveNotification(TimeStampedModel): + """A notification this module decided to send. + + Written inside the same transaction as the decision that caused it, so a + notification can never claim something that was rolled back. The delivery + worker is not part of this change — see the module doc's list of gaps. + """ + + STATUS = [("pending", "Pending"), ("sent", "Sent"), ("failed", "Failed")] + + topic = models.CharField(max_length=60, db_index=True) + dedupe_key = models.CharField(max_length=200, unique=True) + recipient_user_id = models.IntegerField(db_index=True) + subject = models.CharField(max_length=200) + body = models.TextField(blank=True) + status = models.CharField(max_length=12, choices=STATUS, default="pending") + request = models.ForeignKey(LeaveRequest, on_delete=models.CASCADE, + null=True, blank=True, related_name="notifications") + + class Meta: + db_table = "leave_notification" + ordering = ["created_at"] + + def __str__(self) -> str: + return f"{self.topic} -> {self.recipient_user_id}" diff --git a/modules/leave/registry.py b/modules/leave/registry.py new file mode 100644 index 00000000..8e45d1d9 --- /dev/null +++ b/modules/leave/registry.py @@ -0,0 +1,119 @@ +"""What the server tells the shell about this module (ADR-0010). + +Permissions follow `..`; ROLE_GRANTS says which +designation holds each one, keyed by the designation name as it exists in +globals_designation. A permission nobody holds is a link nobody can click, so +`manage.py permission_manifest --check` refuses it. +""" +MODULE = { + "code": "leave", "label": "Leave Management", "icon": "FaCalendarCheck", + "base_path": "/leave", "nav_section": "Administration", "sort_order": 20, + # Planned until the last sub-phase lands; an ungranted, unroutable module + # is the safe way to ship work in progress. + "status": "planned", + # Employee leave has always lived in the ERP's `hr` module column. + "legacy_column_name": "hr", +} + +NAV_ITEMS = [ + # -- the employee's own screens ----------------------------------------- + {"code": "leave.mine", "label": "My Leave", "icon": "FaPlaneDeparture", + "to": "/leave/mine", "required_permission": "leave.application.view_self", + "sort_order": 5}, + {"code": "leave.substitutions", "label": "Substitutions", + "icon": "FaUserFriends", "to": "/leave/substitutions", + "required_permission": "leave.substitute.respond", "sort_order": 10}, + + # -- the processing lanes ------------------------------------------------ + {"code": "leave.inbox", "label": "Leave Inbox", "icon": "FaInbox", + "to": "/leave/inbox", "required_permission": "leave.request.review", + "sort_order": 20}, + {"code": "leave.team", "label": "All Leave", "icon": "FaUsers", + "to": "/leave/team", "required_permission": "leave.application.view", + "sort_order": 30}, + + # -- configuration ------------------------------------------------------- + {"code": "leave.administration", "label": "Administration", + "icon": "FaSlidersH", "to": "/leave/administration", + "required_permission": "leave.policy.manage", "sort_order": 40}, +] + +#: Every permission this module recognises. +PERMISSIONS = [ + ("leave.application.view_self", "See one's own leave requests"), + ("leave.application.view", "See every leave request, within scope"), + ("leave.application.create", "Apply for leave"), + ("leave.application.withdraw", "Withdraw a pending request"), + ("leave.application.cancel", "Request cancellation of approved leave"), + ("leave.application.extend", "Request an extension of ongoing leave"), + ("leave.substitute.respond", "Accept or decline a nominated substitution"), + ("leave.substitute.nominate", "Nominate a replacement substitute"), + ("leave.request.review", "Review at Unit Head and record a recommendation"), + ("leave.request.route", "Verify and route a higher-sanction request"), + ("leave.request.approve", "Take the final sanctioning decision"), + ("leave.resumption.submit", "Submit a resumption of duty"), + ("leave.resumption.review", "Review a submitted resumption"), + ("leave.balance.view", "See one's own leave balances"), + ("leave.calendar.manage", "Maintain and publish the holiday/RH calendar"), + ("leave.policy.manage", "Maintain leave policy and hierarchy configuration"), + ("leave.record.manage", "Maintain leave records and enter offline leave"), +] + +#: Performed by a scheduled command, so no designation holds them. +SYSTEM_PERMISSIONS = [ + "leave.year_end.process", + "leave.sla.process", +] + +# -- role grants --------------------------------------------------------------- +# Every employee holds the same starting set: ELM is about your own leave, and +# the designation only decides what you process for other people. +_EMPLOYEE = [ + "leave.application.view_self", + "leave.application.create", + "leave.application.withdraw", + "leave.application.cancel", + "leave.application.extend", + "leave.substitute.respond", + "leave.resumption.submit", + "leave.balance.view", +] + +#: A Unit Head reviews and recommends (BR-EL-016/017). +_UNIT_HEAD = [*_EMPLOYEE, "leave.request.review", "leave.application.view"] + +#: The Establishment processing role routes on, and is an employee too. +_ESTABLISHMENT = [*_EMPLOYEE, "leave.request.route", "leave.application.view"] + +#: A final Sanctioning Authority decides, and still takes their own leave. +_AUTHORITY = [ + *_EMPLOYEE, "leave.request.approve", "leave.resumption.review", + "leave.application.view", +] + +#: The leave administrator: configuration and records, never a second sanction. +_ADMINISTRATOR = [ + *_EMPLOYEE, "leave.policy.manage", "leave.calendar.manage", + "leave.record.manage", "leave.application.view", +] + +#: Keyed by the designation name as it exists in globals_designation. +ROLE_GRANTS = { + "Professor": _EMPLOYEE, + "Associate Professor": _EMPLOYEE, + "Assistant Professor": _EMPLOYEE, + "Junior Assistant": _EMPLOYEE, + "Senior Assistant": _EMPLOYEE, + "Upper Division Clerk": _EMPLOYEE, + "HOD (CSE)": _UNIT_HEAD, + "HOD (ECE)": _UNIT_HEAD, + "HOD (ME)": _UNIT_HEAD, + "HOD (Design)": _UNIT_HEAD, + "HOD (NS)": _UNIT_HEAD, + "HOD (Liberal Arts)": _UNIT_HEAD, + "Deputy Registrar": _ESTABLISHMENT, + "Dean Academic": _AUTHORITY, + "Registrar": _AUTHORITY, + "Director": _AUTHORITY, + "acadadmin": _ADMINISTRATOR, +} diff --git a/modules/leave/selectors/__init__.py b/modules/leave/selectors/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/leave/selectors/balances.py b/modules/leave/selectors/balances.py new file mode 100644 index 00000000..e005efe3 --- /dev/null +++ b/modules/leave/selectors/balances.py @@ -0,0 +1,72 @@ +"""Reads over the leave account, the calendar and the ledger. + +Like every selector here these narrow; they never decide. +""" +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from modules.leave.models import CalendarEntry, LeaveBalance, LeaveCalendar + + +def balances(user_id: int, year_label: str): + return (LeaveBalance.objects + .filter(user_id=user_id, year_label=year_label) + .order_by("category")) + + +def balance_for(user_id: int, year_label: str, category: str) -> Decimal: + """Available days, or zero for a category the employee has no account for.""" + row = LeaveBalance.objects.filter(user_id=user_id, year_label=year_label, + category=category).first() + return row.available if row else Decimal(0) + + +def published_calendar(year_label: str): + """The calendar that governs date and RH validation right now (BR-EL-031). + + A draft is invisible here: until a replacement is published, the one in + force stays in force. + """ + return (LeaveCalendar.objects + .filter(year_label=year_label, published_at__isnull=False) + .order_by("-published_at") + .first()) + + +def closed_dates(calendar) -> frozenset[date]: + if calendar is None: + return frozenset() + return frozenset(CalendarEntry.objects + .filter(calendar=calendar, kind="closed_holiday") + .values_list("date", flat=True)) + + +def restricted_holidays(calendar) -> frozenset[date]: + if calendar is None: + return frozenset() + return frozenset(CalendarEntry.objects + .filter(calendar=calendar, kind="restricted_holiday") + .values_list("date", flat=True)) + + +def vacation_periods(calendar) -> tuple[tuple[date, date], ...]: + """Vacation periods projected from the Academic Calendar (EXT-01).""" + if calendar is None: + return () + rows = (CalendarEntry.objects + .filter(calendar=calendar, kind="vacation") + .order_by("date")) + return tuple((r.date, r.ends_on or r.date) for r in rows) + + +def effective_calendar(year_label: str): + """The published calendar plus everything a validation run needs.""" + cal = published_calendar(year_label) + return { + "calendar": cal, + "closed_dates": closed_dates(cal), + "restricted_holidays": restricted_holidays(cal), + "vacation_periods": vacation_periods(cal), + } diff --git a/modules/leave/selectors/leave.py b/modules/leave/selectors/leave.py new file mode 100644 index 00000000..3967980f --- /dev/null +++ b/modules/leave/selectors/leave.py @@ -0,0 +1,90 @@ +"""All reads. Ownership filtering lives HERE — once per module, not once per +view — which is why a foreign id naturally yields 404 instead of 403. + +Nothing in here authorises; a selector decides what you can SEE. Whether you +may act on it is `services/workflow.py`, and which lane you act in is +`domain/routing.py`. +""" +from __future__ import annotations + +from modules.leave.models import LeaveRequest, LeaveTask + +#: Stale substitute requests: a nominee who has not answered yet. +OPEN_RESPONSES = ("", "declined") + + +def my_requests(principal, *, kind: str | None = None): + qs = LeaveRequest.objects.filter(user_id=principal.user_id) + if kind: + qs = qs.filter(kind=kind) + return qs.select_related("parent") + + +def visible_requests(principal): + """Every leave request, for a designation that holds `.application.view`. + + There is no department scope to narrow here: a Unit Head sees their own + unit's work through their task inbox, which is the narrower and more + honest question. A holder of `.view` is a leave administrator or an + authority, and the institute's leave record is theirs to see. + """ + qs = LeaveRequest.objects.all() + if principal.has_permission("leave.application.view"): + return qs + return qs.filter(user_id=principal.user_id) + + +def visible_children(principal, parent: LeaveRequest): + """The amendments on a leave — extensions, cancellations, resumptions.""" + return visible_requests(principal).filter(parent=parent) + + +def task_inbox(principal): + """Actionable work assigned to this person. + + Role-addressed stages (Establishment, final sanction) have no single + assignee, so they are not in anyone's inbox: the permission gate is the + authority there, and the sweep still clocks them for SLA. + """ + return (LeaveTask.objects + .filter(assignee_user_id=principal.user_id, status="pending") + .select_related("request") + .order_by("reminder_due_at", "created_at")) + + +def substitution_requests(principal): + """What this person has been nominated for and has not settled.""" + return (LeaveRequest.objects + .filter(substitute_user_id=principal.user_id, + substitute_response__in=OPEN_RESPONSES, + status="awaiting_substitute") + .order_by("from_date")) + + +def substitute_request_by_id(principal, pk: int): + """The one request this person is the nominee on. + + A substitute is not granted `.application.view`, so their row is reached by + nomination rather than by ownership — which is also why answering it is a + permission of its own (`leave.substitute.respond`). + """ + return (LeaveRequest.objects + .filter(pk=pk, substitute_user_id=principal.user_id) + .select_related("parent") + .first()) + + +def request_by_id(principal, pk: int): + """Scope first, then fetch: a foreign request is absent, hence a 404.""" + return visible_requests(principal).filter(pk=pk).first() + + +def my_request_by_id(principal, pk: int): + return my_requests(principal).filter(pk=pk).first() + + +def open_tasks_for_request(request: LeaveRequest, *, stage: str | None = None): + qs = LeaveTask.objects.filter(request=request, status="pending") + if stage: + qs = qs.filter(stage=stage) + return qs diff --git a/modules/leave/services/__init__.py b/modules/leave/services/__init__.py new file mode 100644 index 00000000..4f92c33a --- /dev/null +++ b/modules/leave/services/__init__.py @@ -0,0 +1 @@ +"""All writes. Transaction boundaries live here.""" \ No newline at end of file diff --git a/modules/leave/services/accounts.py b/modules/leave/services/accounts.py new file mode 100644 index 00000000..cdfeeeb4 --- /dev/null +++ b/modules/leave/services/accounts.py @@ -0,0 +1,127 @@ +"""The leave account: entitlements, ledger, and what is available. + +A balance is never edited directly. `post()` is the only writer, and every call +leaves a LeaveTransaction behind, so "why is my CL 3.5?" always has an answer. +""" +from __future__ import annotations + +import logging +from datetime import date +from decimal import Decimal + +from django.db import transaction + +from modules.leave.domain import categories as cat +from modules.leave.models import LeaveBalance, LeaveRequest, LeaveTransaction +from modules.leave.selectors import balances as balance_selectors + +log = logging.getLogger("fusion.leave.accounts") + +CREDIT = Decimal(1) +DEBIT = Decimal(-1) + + +def open_account(*, user_id: int, year_label: str, category: str) -> LeaveBalance: + row, _ = LeaveBalance.objects.get_or_create( + user_id=user_id, year_label=year_label, category=category) + return row + + +def available(user_id: int, year_label: str, category: str) -> Decimal: + return balance_selectors.balance_for(user_id, year_label, category) + + +@transaction.atomic +def post(*, user_id: int, year_label: str, category: str, days: Decimal, + kind: str, note: str = "", + request: LeaveRequest | None = None) -> LeaveTransaction: + """Credit or consume days, and record why. + + `days` is signed by the caller's intent: a credit is positive, a deduction + negative. `kind` comes from LeaveTransaction.KINDS, which is what makes the + ledger readable a year later. + """ + row = open_account(user_id=user_id, year_label=year_label, category=category) + if days >= 0: + row.credited += days + else: + row.used += -days + row.save(update_fields=["credited", "used"]) + return LeaveTransaction.objects.create( + user_id=user_id, year_label=year_label, category=category, kind=kind, + days=days, request=request, note=note) + + +def credit_annual(*, user_id: int, year_label: str, employee_kind: str, + policy) -> list[LeaveTransaction]: + """The year's entitlement (BR-EL-002..009). + + Idempotent per account: a second call on a year already credited is a + no-op, because the annual credit is a single transaction per account and + the check is `credited > 0 and only annual rows exist`. + """ + written: list[LeaveTransaction] = [] + for code in cat.eligible_categories(employee_kind): + days = cat.annual_credit(code, employee_kind) + if not days: + continue + row = open_account(user_id=user_id, year_label=year_label, category=code) + if LeaveTransaction.objects.filter( + user_id=user_id, year_label=year_label, category=code, + kind="annual_credit").exists(): + continue + written.append(post(user_id=user_id, year_label=year_label, + category=code, days=Decimal(days), + kind="annual_credit", + note=f"{policy.year_label} entitlement")) + return written + + +def carry_forward(*, user_id: int, from_year: str, to_year: str) -> list[LeaveTransaction]: + """Move a carried-forward balance into the new year's account. + + Run once per employee per boundary. The opening figure of the new account + is the sum of what actually carried, not a copy of last year's total. + """ + written: list[LeaveTransaction] = [] + for row in LeaveBalance.objects.filter(user_id=user_id, year_label=from_year): + amount = row.available + if amount <= 0: + continue + if not cat.category(row.category).carries_forward: + continue + target = open_account(user_id=user_id, year_label=to_year, + category=row.category) + if LeaveTransaction.objects.filter( + user_id=user_id, year_label=to_year, + category=row.category, kind="carry_forward").exists(): + continue + target.opening += amount + target.save(update_fields=["opening"]) + written.append(LeaveTransaction.objects.create( + user_id=user_id, year_label=to_year, category=row.category, + kind="carry_forward", days=amount, + note=f"Carried forward from {from_year}")) + return written + + +def next_year_label(year_label: str) -> str: + """"2026-27" -> "2027-28".""" + try: + start, end = year_label.split("-") + return f"{int(start) + 1}-{int(end) + 1}" + except (ValueError, AttributeError): + return year_label + + +def year_for(date_value: date, policy) -> str: + """Which leave year a date falls in. + + The boundary is the policy's `year_end_on`, not 1 January — the institute's + leave year is the academic one, and the year-end trigger fires on it. + """ + if policy is None: + return f"{date_value.year}" + if date_value <= policy.year_end_on: + return policy.year_label + return next_year_label(policy.year_label) diff --git a/modules/leave/services/applications.py b/modules/leave/services/applications.py new file mode 100644 index 00000000..695da4f4 --- /dev/null +++ b/modules/leave/services/applications.py @@ -0,0 +1,404 @@ +"""The employee's own actions: apply, withdraw, replace a substitute, ask for a +cancellation, ask for an extension, report a resumption. + +Nothing here edits a submitted request (BR-EL-034). A change is a new row with a +`parent`, which is what keeps the original approval intact and makes the +incremental-only posting rule for extensions (BR-EL-025) something the schema +says rather than something a developer has to remember. + +Every function resolves the same three things before it moves anything: the +current configured policy, the rules, and the route. That order is deliberate — +BR-EL-030 says validation and routing use the CURRENT configuration, so the +policy is read once, at the top, and never cached across a request. +""" +from __future__ import annotations + +import logging +from datetime import date, timedelta +from decimal import Decimal + +from core.api.exceptions import ConflictError, NotFoundError, ValidationError +from modules.directory import contracts as directory +from modules.leave.domain import categories as cat +from modules.leave.domain import routing +from modules.leave.domain.eligibility import Request as EligibilityRequest +from modules.leave.domain.eligibility import validate +from modules.leave.domain.guards import Facts +from modules.leave.domain.state_machine import ( + APPROVED, + DRAFT, + KIND_CANCELLATION, + KIND_EXTENSION, + KIND_NEW, + KIND_RESUMPTION, +) +from modules.leave.models import LeavePolicy, LeaveRequest, OrganizationalUnit +from modules.leave.selectors import balances as balance_selectors +from modules.leave.selectors import leave as leave_selectors +from modules.leave.services import accounts, notifications, workflow + +log = logging.getLogger("fusion.leave.applications") + +P_CREATE = "leave.application.create" +P_WITHDRAW = "leave.application.withdraw" +P_CANCEL = "leave.application.cancel" +P_EXTEND = "leave.application.extend" +P_RESUMPTION = "leave.resumption.submit" + + +def _policy() -> LeavePolicy: + policy = LeavePolicy.current() + if policy is None: + raise ValidationError( + "Leave has not been configured yet — an administrator must save " + "the leave policy first.", code="policy_missing") + return policy + + +def _employee_kind(user_id: int) -> str: + """What kind of employee this is, read through directory's contracts.""" + person = directory.get_users([user_id]).get(user_id) + return person.kind if person else cat.STAFF + + +def _unit_head_for(department: str) -> int | None: + unit = OrganizationalUnit.objects.filter(name__iexact=department).first() + return unit.head_user_id if unit else None + + +def _department_of(user_id: int) -> str: + person = directory.get_users([user_id]).get(user_id) + return person.department if person else "" + + +def _active_intervals(user_id: int) -> tuple[cat.Interval, ...]: + """The applicant's own pending or approved leave, which a new request may + not impermissibly overlap (BR-EL-012).""" + rows = (LeaveRequest.objects + .filter(user_id=user_id, kind=KIND_NEW) + .exclude(status__in=["rejected", "withdrawn", "cancelled"]) + .only("from_date", "to_date", "half")) + return tuple(r.interval for r in rows) + + +def _validate(*, principal, category_code: str, start: date, end: date, + half: str | None, substitute_user_id: int | None, + substitute_required: bool, policy: LeavePolicy): + """The rules, over real data (EL-UC-001 M4).""" + user_id = principal.user_id + cal = balance_selectors.effective_calendar(policy.year_label) + verdict = validate(EligibilityRequest( + category_code=category_code, + employee_kind=_employee_kind(user_id), + start=start, end=end, half=half or None, + available=accounts.available(user_id, policy.year_label, category_code), + closed_dates=cal["closed_dates"], + restricted_holidays=cal["restricted_holidays"], + vacation_periods=cal["vacation_periods"], + existing=_active_intervals(user_id), + substitute_required=substitute_required, + substitute_user_id=substitute_user_id, + applicant_user_id=user_id, + substitute_intervals=_active_intervals(substitute_user_id or -1), + )) + if not verdict.is_valid: + first = verdict.outcomes[0] + raise ValidationError( + first.message, code=first.code, + details=[{"code": o.code, "message": o.message} + for o in verdict.outcomes]) + return verdict + + +def _route(*, category_code: str, principal, unit_head_user_id: int | None, + policy: LeavePolicy) -> routing.Path: + path = routing.resolve_path(category_code=category_code, + roles=tuple(principal.roles), + unit_head_user_id=unit_head_user_id, + hierarchy=policy.hierarchy) + if not path.self_sanction and path.unit_head_user_id is None: + raise ConflictError( + "No Unit Head is configured for your unit, so this leave cannot " + "be routed.", code="unit_head_missing") + return path + + +def _facts_for(*, request: LeaveRequest, kind: str, substitute_required: bool, + establishment_configured: bool, decision: str = "", + leave_started: bool = False, leave_ended: bool = False, + replacement_eligible: bool = False, + routing_context_valid: bool = True) -> Facts: + return Facts( + kind=kind, category_code=request.category, + employee_kind=_employee_kind(request.user_id), + roles=tuple(request.applicant_roles or ()), + substitute_required=substitute_required, decision=decision, + establishment_configured=establishment_configured, + leave_started=leave_started, leave_ended=leave_ended, + replacement_eligible=replacement_eligible, + routing_context_valid=routing_context_valid) + + +def apply(*, principal, category_code: str, start: date, end: date, + half: str | None, purpose: str, station_leave_required: bool, + station_leave_details: str, substitute_user_id: int | None, + substitute_required: bool) -> LeaveRequest: + """EL-UC-001. Validate, resolve the route, submit. + + The request is created in DRAFT and immediately moved through the table, so + there is exactly one code path that decides where a submitted request lands + — and the Director/higher-sanction question is answered by the guards, not + by an `if` here. + """ + policy = _policy() + if category_code not in cat.CATEGORIES: + raise ValidationError( + f"{category_code or 'No category'} is not a leave category.", + code="category_not_supported") + + verdict = _validate(principal=principal, category_code=category_code, + start=start, end=end, half=half, + substitute_user_id=substitute_user_id, + substitute_required=substitute_required, policy=policy) + + path = _route(category_code=category_code, principal=principal, + unit_head_user_id=_unit_head_for(_department_of(principal.user_id)), + policy=policy) + + request = LeaveRequest.objects.create( + kind=KIND_NEW, category=category_code, user_id=principal.user_id, + from_date=start, to_date=end, half=half or "", days=verdict.days, + purpose=purpose, station_leave_required=station_leave_required, + station_leave_details=station_leave_details, + substitute_user_id=substitute_user_id, + applicant_roles=list(principal.roles), + unit_head_user_id=path.unit_head_user_id, + final_authority=path.final_authority, + establishment_first=path.establishment_first, + self_sanction=path.self_sanction, status=DRAFT) + + workflow.move( + request=request, principal=principal, permission=P_CREATE, + facts=_facts_for(request=request, kind=KIND_NEW, + substitute_required=substitute_required, + establishment_configured=path.establishment_first), + policy=policy) + + if request.status == "awaiting_substitute" and substitute_user_id: + notifications.notify( + topic="leave.substitute_requested", + dedupe_key=f"leave.substitute:{request.pk}", + recipient_user_id=substitute_user_id, + subject="You have been nominated as a substitute", + body=(f"{principal.display_name} has nominated you to hold " + f"responsibility during their {request.get_category_display()}."), + request=request) + return request + + +def _own_pending(principal, request_id: int, *, kinds: tuple[str, ...]) -> LeaveRequest: + request = leave_selectors.my_request_by_id(principal, request_id) + if request is None: + raise NotFoundError("No such leave request.", code="not_found") + if request.kind not in kinds: + raise ConflictError("That action does not apply to this request.", + code="wrong_kind") + if not request.is_pending: + raise ConflictError( + f"This request is {request.status.replace('_', ' ')} and can no " + f"longer be changed.", code="not_pending") + return request + + +def withdraw(*, principal, request_id: int, reason: str = "") -> LeaveRequest: + """EL-UC-007. Immediate, terminal, and it stops everything downstream.""" + policy = _policy() + request = _own_pending(principal, request_id, + kinds=(KIND_NEW, KIND_EXTENSION, KIND_CANCELLATION)) + workflow.move( + request=request, principal=principal, permission=P_WITHDRAW, + facts=_facts_for(request=request, kind=request.kind, + substitute_required=False, + establishment_configured=request.establishment_first, + decision=""), + remarks=reason, policy=policy) + notifications.tell_concerned_roles( + request, "A leave request you were processing has been withdrawn") + return request + + +def nominate_replacement(*, principal, request_id: int, + substitute_user_id: int) -> LeaveRequest: + """EL-UC-019. A decline leaves the gate closed; replacing the nominee is the + recovery, and a fresh response is then awaited.""" + policy = _policy() + request = _own_pending(principal, request_id, kinds=(KIND_NEW, KIND_EXTENSION)) + if request.status != "applicant_action_required": + raise ConflictError( + "A replacement can only be nominated after a decline.", + code="not_awaiting_replacement") + + verdict = _validate(principal=principal, + category_code=request.category, + start=request.from_date, end=request.to_date, + half=request.half or None, + substitute_user_id=substitute_user_id, + substitute_required=True, policy=policy) + request.substitute_user_id = substitute_user_id + request.substitute_response = "" + request.save(update_fields=["substitute_user_id", "substitute_response", + "updated_at"]) + + workflow.move( + request=request, principal=principal, permission="leave.substitute.nominate", + facts=_facts_for(request=request, kind=request.kind, + substitute_required=True, + establishment_configured=request.establishment_first, + replacement_eligible=True), + policy=policy) + notifications.notify( + topic="leave.substitute_requested", + dedupe_key=f"leave.substitute:{request.pk}:{substitute_user_id}", + recipient_user_id=substitute_user_id, + subject="You have been nominated as a substitute", + body=(f"{principal.display_name} has nominated you to hold " + f"responsibility during their {request.get_category_display()}."), + request=request) + return request + + +def _approved_leave(principal, leave_id: int) -> LeaveRequest: + leave = leave_selectors.my_request_by_id(principal, leave_id) + if leave is None: + raise NotFoundError("No such leave.", code="not_found") + if leave.kind != KIND_NEW or leave.status != APPROVED: + raise ConflictError("That leave is not an approved leave.", + code="not_approved") + return leave + + +def request_cancellation(*, principal, leave_id: int, + reason: str) -> LeaveRequest: + """EL-UC-008. Before the start date only, through the same hierarchy, and + without touching the approved leave.""" + policy = _policy() + leave = _approved_leave(principal, leave_id) + if leave.from_date <= date.today(): + raise ConflictError( + "This leave has already begun, so it can no longer be cancelled — " + "an extension or a resumption applies instead.", code="leave_started") + + request = LeaveRequest.objects.create( + kind=KIND_CANCELLATION, category=leave.category, + user_id=principal.user_id, from_date=leave.from_date, to_date=leave.to_date, + half=leave.half, days=Decimal("0"), purpose=reason, parent=leave, + substitute_user_id=leave.substitute_user_id, + applicant_roles=list(leave.applicant_roles), + unit_head_user_id=leave.unit_head_user_id, + final_authority=leave.final_authority, + establishment_first=leave.establishment_first, + self_sanction=leave.self_sanction, status=DRAFT) + + workflow.move( + request=request, principal=principal, permission=P_CANCEL, + facts=_facts_for(request=request, kind=KIND_CANCELLATION, + substitute_required=False, + establishment_configured=request.establishment_first, + leave_started=False), + remarks=reason, policy=policy) + return request + + +def request_extension(*, principal, leave_id: int, end_date: date, + substitute_user_id: int | None, + substitute_required: bool) -> LeaveRequest: + """EL-UC-009. Only the incremental period is requested; the approved leave + stays exactly as it was sanctioned until — and unless — this is approved.""" + policy = _policy() + leave = _approved_leave(principal, leave_id) + if not cat.category(leave.category).extension_eligible: + raise ConflictError( + f"{leave.get_category_display()} cannot be extended.", + code="not_extendable") + if not (leave.from_date <= date.today() <= leave.to_date): + raise ConflictError( + "An extension applies to leave currently in effect.", + code="leave_not_ongoing") + if end_date <= leave.to_date: + raise ValidationError( + "An extension must ask for a date after the approved end date.", + code="extension_not_incremental") + + additional_from = leave.to_date + timedelta(days=1) + verdict = _validate(principal=principal, category_code=leave.category, + start=additional_from, end=end_date, half=None, + substitute_user_id=substitute_user_id, + substitute_required=substitute_required, policy=policy) + + request = LeaveRequest.objects.create( + kind=KIND_EXTENSION, category=leave.category, user_id=principal.user_id, + from_date=additional_from, to_date=end_date, days=verdict.days, + purpose=f"Extension of {leave.get_category_display()} ending " + f"{leave.to_date}", + station_leave_required=leave.station_leave_required, + station_leave_details=leave.station_leave_details, + substitute_user_id=substitute_user_id, parent=leave, + applicant_roles=list(leave.applicant_roles), + unit_head_user_id=leave.unit_head_user_id, + final_authority=leave.final_authority, + establishment_first=leave.establishment_first, + self_sanction=leave.self_sanction, status=DRAFT) + + workflow.move( + request=request, principal=principal, permission=P_EXTEND, + facts=_facts_for(request=request, kind=KIND_EXTENSION, + substitute_required=substitute_required, + establishment_configured=request.establishment_first, + leave_started=True, leave_ended=False), + policy=policy) + + if request.status == "awaiting_substitute" and substitute_user_id: + notifications.notify( + topic="leave.substitute_requested", + dedupe_key=f"leave.substitute:{request.pk}", + recipient_user_id=substitute_user_id, + subject="You have been nominated as a substitute", + body=(f"{principal.display_name} has nominated you to hold " + f"responsibility during the extension of their " + f"{request.get_category_display()}."), + request=request) + return request + + +def submit_resumption(*, principal, leave_id: int, actual_date: date, + remarks: str) -> LeaveRequest: + """EL-UC-010. The employee reports the actual return; review is a separate + act by the authority (EL-UC-011).""" + policy = _policy() + leave = _approved_leave(principal, leave_id) + if not cat.category(leave.category).resumption_applies: + raise ConflictError( + "Resumption is recorded for EL, COL and VL.", + code="resumption_not_applicable") + if leave.from_date > actual_date: + raise ValidationError( + "The resumption date cannot be before the leave began.", + code="resumption_before_leave") + + request = LeaveRequest.objects.create( + kind=KIND_RESUMPTION, category=leave.category, user_id=principal.user_id, + from_date=actual_date, to_date=actual_date, days=Decimal("0"), + purpose=remarks, parent=leave, + applicant_roles=list(leave.applicant_roles), + unit_head_user_id=leave.unit_head_user_id, + final_authority=leave.final_authority, + establishment_first=leave.establishment_first, + self_sanction=leave.self_sanction, status=DRAFT) + + workflow.move( + request=request, principal=principal, permission=P_RESUMPTION, + facts=_facts_for(request=request, kind=KIND_RESUMPTION, + substitute_required=False, + establishment_configured=False, leave_started=True), + remarks=remarks, policy=policy) + return request diff --git a/modules/leave/services/calendar.py b/modules/leave/services/calendar.py new file mode 100644 index 00000000..081f12ce --- /dev/null +++ b/modules/leave/services/calendar.py @@ -0,0 +1,120 @@ +"""The holiday/RH calendar, and the vacation periods projected into it. + +EL-UC-015 has one rule that matters more than the rest: until a REPLACEMENT is +published, the calendar in force keeps governing (BR-EL-031). So a revision is +a new row that stays invisible until it is published, and "effective" is a +question about what is published, never about what is being drafted. + +Vacation periods arrive from outside (EXT-01). This module does not own the +Academic Calendar; it keeps a projection of it so VL validation has something +to check against, and `ingest_vacation_periods` is the seam the academic module +would call. +""" +from __future__ import annotations + +import logging +from datetime import date + +from django.utils import timezone + +from core.api.exceptions import NotFoundError, ValidationError +from modules.leave.models import CalendarEntry, LeaveCalendar +from modules.leave.selectors import balances as balance_selectors + +log = logging.getLogger("fusion.leave.calendar") + +VALID_KINDS = ("closed_holiday", "restricted_holiday", "vacation") + + +def _validate_entries(entries) -> list[dict]: + out = [] + for raw in entries: + kind = raw.get("kind") + if kind not in VALID_KINDS: + raise ValidationError(f"{kind or 'That'} is not a calendar entry " + f"type.", code="unknown_entry_kind") + when = raw.get("date") + if when is None: + raise ValidationError("Every entry needs a date.", + code="entry_date_missing") + ends = raw.get("ends_on") or when + if ends < when: + raise ValidationError("A vacation period cannot end before it starts.", + code="entry_period_invalid") + if kind != "vacation" and ends != when: + raise ValidationError( + "A holiday is a single day; only a vacation period spans " + "several.", code="entry_span_not_allowed") + out.append({"date": when, "ends_on": ends, "kind": kind, + "label": raw.get("label", "")}) + return out + + +def create_draft(*, principal, year_label: str, label: str, + entries) -> LeaveCalendar: + """A revision. Unpublished, therefore not yet governing anything.""" + calendar = LeaveCalendar.objects.create( + year_label=year_label, label=label, + published_by_user_id=principal.user_id) + replace_entries(calendar=calendar, entries=entries) + return calendar + + +def replace_entries(*, calendar: LeaveCalendar, entries) -> None: + """Swap the whole entry set in one go. + + Partial edits on a calendar are how a holiday quietly stops being one, so + the API takes the complete set and this replaces it. + """ + rows = _validate_entries(entries) + calendar.entries.all().delete() + CalendarEntry.objects.bulk_create([ + CalendarEntry(calendar=calendar, **row) for row in rows]) + + +def publish(*, principal, calendar_id: int) -> LeaveCalendar: + """Make a draft effective. The previously published one stops being so the + moment this is stamped — that is the whole of BR-EL-031.""" + calendar = LeaveCalendar.objects.filter(pk=calendar_id).first() + if calendar is None: + raise NotFoundError("No such calendar.", code="not_found") + if calendar.published_at is not None: + raise ValidationError("That calendar is already published.", + code="already_published") + calendar.published_at = timezone.now() + calendar.published_by_user_id = principal.user_id + calendar.save(update_fields=["published_at", "published_by_user_id", + "updated_at"]) + log.info("leave.calendar.published year=%s by=%s", calendar.year_label, + principal.user_id) + return calendar + + +def ingest_vacation_periods(*, year_label: str, + periods) -> int: + """Project vacation periods from the Academic Calendar into the calendar + currently in force (EXT-01). + + Called by whoever owns the Academic Calendar; ELM never fetches it itself. + Idempotent — re-ingesting a period replaces the projection of it. + """ + calendar = balance_selectors.published_calendar(year_label) + if calendar is None: + return 0 + rows = _validate_entries([ + {"date": start, "ends_on": end, "kind": "vacation", + "label": label or ""} for start, end, label in periods]) + CalendarEntry.objects.filter(calendar=calendar, kind="vacation").delete() + CalendarEntry.objects.bulk_create([ + CalendarEntry(calendar=calendar, **row) for row in rows]) + return len(rows) + + +def closed_holidays(year_label: str) -> frozenset[date]: + return balance_selectors.closed_dates( + balance_selectors.published_calendar(year_label)) + + +def restricted_holidays(year_label: str) -> frozenset[date]: + return balance_selectors.restricted_holidays( + balance_selectors.published_calendar(year_label)) diff --git a/modules/leave/services/decisions.py b/modules/leave/services/decisions.py new file mode 100644 index 00000000..e3529739 --- /dev/null +++ b/modules/leave/services/decisions.py @@ -0,0 +1,289 @@ +"""The processing lanes: the substitute's answer, the Unit Head's review, the +Establishment's routing, the final sanction, and the resumption review. + +Each of these is one `move()` plus the effect the decision has. The effect is +the interesting half, and it is deliberately not in the state machine: a table +that knew about ledgers would stop being a table you can read. + + new leave approved deduct the days (CFR-EL-016.3) + new leave rejected tell the employee; the account is untouched + cancellation approved mark the leave Cancelled, restore the days + cancellation rejected the original approved leave stands + extension approved post ONLY the increment; the leave keeps its base + extension rejected the original approved leave stands + resumption reviewed normal: nothing; early: credit back; late: debit +""" +from __future__ import annotations + +import logging +from decimal import Decimal + +from django.utils import timezone + +from core.api.exceptions import ConflictError, NotFoundError, ValidationError +from modules.leave.domain import categories as cat +from modules.leave.domain.guards import Facts +from modules.leave.domain.state_machine import ( + KIND_CANCELLATION, + KIND_EXTENSION, + KIND_NEW, +) +from modules.leave.models import LeavePolicy, LeaveRequest, LeaveTask +from modules.leave.selectors import leave as leave_selectors +from modules.leave.services import accounts, notifications, workflow + +log = logging.getLogger("fusion.leave.decisions") + +P_RESPOND = "leave.substitute.respond" +P_REVIEW = "leave.request.review" +P_ROUTE = "leave.request.route" +P_APPROVE = "leave.request.approve" +P_RESUMPTION_REVIEW = "leave.resumption.review" + +#: What each lane may record. +UNIT_HEAD_FINAL_DECISIONS = ("approve", "reject") +RECOMMENDATIONS = ("recommended", "not_recommended") +FINAL_DECISIONS = ("approve", "reject") +RESUMPTION_OUTCOMES = ("normal", "early", "late") + + +def _policy() -> LeavePolicy: + policy = LeavePolicy.current() + if policy is None: + raise ValidationError("Leave has not been configured yet.", + code="policy_missing") + return policy + + +def _in_my_lane(request: LeaveRequest, principal, *, stage: str) -> None: + """Is this actually this person's work? + + The permission says a Unit Head may review; the task says WHICH Unit Head, + and for which request. Role-addressed stages — Establishment, and the final + sanctioning authority — have no single assignee, so there the permission is + the whole answer. That is a documented widening rather than an oversight. + """ + task = LeaveTask.objects.filter(request=request, status="pending", + stage=stage).first() + if task is None: + raise ConflictError("This request is not waiting at that stage.", + code="not_at_stage") + if task.assignee_user_id and task.assignee_user_id != principal.user_id: + raise ConflictError("That request is assigned to someone else.", + code="not_your_task") + + +def _facts(request: LeaveRequest, **kw) -> Facts: + base = dict(kind=request.kind, category_code=request.category, + employee_kind=cat.STAFF, + roles=tuple(request.applicant_roles or ()), + establishment_configured=request.establishment_first, + substitute_required=bool(request.substitute_user_id)) + base.update(kw) + return Facts(**base) + + +def _tell(request: LeaveRequest, subject: str, body: str = "") -> None: + notifications.notify( + topic="leave.decided", dedupe_key=f"leave.decided:{request.pk}", + recipient_user_id=request.user_id, subject=subject, body=body, + request=request) + + +def _apply_effects(request: LeaveRequest) -> None: + """What the decision MEANS. A recommendation is not here on purpose: + intermediate decisions are recorded and stay inspectable, and the baseline + is explicit that they do not notify (CFR-EL-016.4).""" + policy = _policy() + + if request.decision == "approve": + if request.kind == KIND_NEW: + accounts.post(user_id=request.user_id, year_label=policy.year_label, + category=request.category, days=-request.days, + kind="approval", + note=f"Approved {request.get_category_display()}", + request=request) + _tell(request, "Your leave has been approved", + f"{request.get_category_display()} from {request.from_date} " + f"to {request.to_date}.") + return + + if request.kind == KIND_CANCELLATION and request.parent_id: + parent = request.parent + parent.status = "cancelled" + parent.save(update_fields=["status", "updated_at"]) + accounts.post(user_id=parent.user_id, year_label=policy.year_label, + category=parent.category, days=parent.days, + kind="cancellation_reversal", + note=f"Cancellation of leave from {parent.from_date}", + request=parent) + _tell(request, "Your cancellation has been approved", + f"{parent.get_category_display()} from {parent.from_date} is " + f"cancelled and the days are back on your account.") + return + + if request.kind == KIND_EXTENSION and request.parent_id: + parent = request.parent + parent.to_date = request.to_date + parent.save(update_fields=["to_date", "updated_at"]) + accounts.post(user_id=request.user_id, year_label=policy.year_label, + category=request.category, days=-request.days, + kind="extension_post", + note=f"Extension to {request.to_date}", request=request) + _tell(request, "Your extension has been approved", + f"Your leave now runs to {request.to_date}.") + return + + if request.decision == "reject": + if request.kind == KIND_CANCELLATION: + _tell(request, "Your cancellation was not approved", + "The approved leave stands unchanged.") + elif request.kind == KIND_EXTENSION: + _tell(request, "Your extension was not approved", + "The original approved leave stands unchanged.") + else: + _tell(request, "Your leave has been rejected", + request.decision_remarks or "No reason was recorded.") + + +def respond_as_substitute(*, principal, request_id: int, + accept: bool) -> LeaveRequest: + """EL-UC-002. Accept releases the consent gate; a decline does not.""" + policy = _policy() + request = leave_selectors.substitute_request_by_id(principal, request_id) + if request is None: + raise NotFoundError("No such request.", code="not_found") + + decision = "accept" if accept else "decline" + request = workflow.move( + request=request, principal=principal, permission=P_RESPOND, + facts=_facts(request, decision=decision), + decision=decision, policy=policy) + + request.substitute_response = "accepted" if accept else "declined" + request.substitute_responded_at = timezone.now() + request.save(update_fields=["substitute_response", + "substitute_responded_at", "updated_at"]) + + notifications.notify( + topic="leave.substitute_responded", + dedupe_key=f"leave.substitute.{decision}:{request.pk}", + recipient_user_id=request.user_id, + subject="Your substitute accepted" if accept + else "Your substitute declined", + body=("Your request is now with the next authority." if accept + else "Nominate another eligible substitute, or withdraw the " + "request."), + request=request) + return request + + +def review_at_unit_head(*, principal, request_id: int, decision: str, + remarks: str = "") -> LeaveRequest: + """EL-UC-003. Final for CL/RH; a recommendation for everything else — and + Not Recommended is not a rejection (BR-EL-017).""" + policy = _policy() + if decision not in UNIT_HEAD_FINAL_DECISIONS + RECOMMENDATIONS: + raise ValidationError( + "A Unit Head may approve, reject, recommend or not recommend.", + code="unknown_decision") + request = leave_selectors.request_by_id(principal, request_id) + if request is None: + raise NotFoundError("No such request.", code="not_found") + _in_my_lane(request, principal, stage="unit_head") + + moved = workflow.move( + request=request, principal=principal, permission=P_REVIEW, + facts=_facts(request, decision=_decision_word(decision)), + decision=decision, remarks=remarks, policy=policy) + _apply_effects(moved) + return moved + + +def route_at_establishment(*, principal, request_id: int, + remarks: str = "") -> LeaveRequest: + """EL-UC-004. Verification and forwarding — not a sanction.""" + policy = _policy() + request = leave_selectors.request_by_id(principal, request_id) + if request is None: + raise NotFoundError("No such request.", code="not_found") + _in_my_lane(request, principal, stage="establishment") + + return workflow.move( + request=request, principal=principal, permission=P_ROUTE, + facts=_facts(request, routing_context_valid=True), + decision="routed", remarks=remarks, policy=policy) + + +def decide_final(*, principal, request_id: int, decision: str, + remarks: str = "") -> LeaveRequest: + """EL-UC-005, including the Director self-sanction path (BR-EL-020).""" + policy = _policy() + if decision not in FINAL_DECISIONS: + raise ValidationError("A final authority may approve or reject.", + code="unknown_decision") + request = leave_selectors.request_by_id(principal, request_id) + if request is None: + raise NotFoundError("No such request.", code="not_found") + _in_my_lane(request, principal, + stage="self_sanction" if request.self_sanction else "final") + + moved = workflow.move( + request=request, principal=principal, permission=P_APPROVE, + facts=_facts(request, decision=decision), + decision=decision, remarks=remarks, policy=policy) + _apply_effects(moved) + return moved + + +def review_resumption(*, principal, request_id: int, outcome: str, + remarks: str = "") -> LeaveRequest: + """EL-UC-011. Normal, early or late — and only the latter two move a + balance (BR-EL-028).""" + policy = _policy() + if outcome not in RESUMPTION_OUTCOMES: + raise ValidationError( + "A resumption is reviewed as normal, early or late.", + code="unknown_outcome") + request = leave_selectors.request_by_id(principal, request_id) + if request is None: + raise NotFoundError("No such request.", code="not_found") + _in_my_lane(request, principal, stage="final") + + moved = workflow.move( + request=request, principal=principal, permission=P_RESUMPTION_REVIEW, + facts=_facts(request), decision="approve", remarks=remarks, + policy=policy) + + parent = moved.parent + if parent is not None: + difference = (moved.from_date - parent.to_date).days + if difference: + parent.to_date = moved.from_date + parent.save(update_fields=["to_date", "updated_at"]) + accounts.post( + user_id=parent.user_id, year_label=policy.year_label, + category=parent.category, days=Decimal(-difference), + kind="resumption_adjustment", + note=("Resumed earlier than the approved end date" + if difference < 0 + else "Resumed later than the approved end date"), + request=parent) + notifications.notify( + topic="leave.resumption_reviewed", + dedupe_key=f"leave.resumption:{moved.pk}", + recipient_user_id=moved.user_id, + subject="Your resumption has been reviewed", + body=remarks or f"Reviewed as a {outcome} resumption.", + request=moved) + return moved + + +def _decision_word(decision: str) -> str: + """A recommendation and an approval are the same edge in the table; the + guard only cares that the decision was positive.""" + if decision in ("approve", "recommended"): + return "approve" + if decision in ("reject", "not_recommended"): + return "reject" + return "" diff --git a/modules/leave/services/notifications.py b/modules/leave/services/notifications.py new file mode 100644 index 00000000..0cde625e --- /dev/null +++ b/modules/leave/services/notifications.py @@ -0,0 +1,85 @@ +"""Notification decisions. + +Every notification is written in the same transaction as the decision that +caused it, so a row can never claim an approval that was rolled back. +`dedupe_key` makes a redelivered event idempotent. + +The baseline is explicit about WHEN someone is told (BR-EL-032/033/035, +CFR-EL-016): a final decision reaches the employee, an assigned task reaches +whoever it was assigned to, a withdrawal reaches the roles that were still +working on it. Intermediate states deliberately do not — the employee reads +those from their own history. + +The drain worker is not part of this change; rows accumulate in `pending` until +one exists (see the module doc's list of gaps). +""" +from __future__ import annotations + +import logging + +from django.db import IntegrityError, transaction + +from modules.leave.models import LeaveNotification, LeaveRequest + +log = logging.getLogger("fusion.leave.notify") + +TOPICS = { + "leave.task_assigned", + "leave.substitute_requested", + "leave.substitute_responded", + "leave.decided", + "leave.withdrawn", + "leave.cancellation_decided", + "leave.resumption_reviewed", + "leave.reminder", + "leave.escalated", +} + + +def notify(*, topic: str, dedupe_key: str, recipient_user_id: int, + subject: str, body: str = "", + request: LeaveRequest | None = None) -> LeaveNotification | None: + """Queue one notification inside the caller's transaction. None if it was + already queued — the key is what makes that safe.""" + if topic not in TOPICS: + raise ValueError(f"Unknown leave notification topic {topic!r}.") + try: + # Nested, so a duplicate key cannot poison the caller's transaction. + with transaction.atomic(): + return LeaveNotification.objects.create( + topic=topic, dedupe_key=dedupe_key, + recipient_user_id=recipient_user_id, subject=subject, + body=body, request=request) + except IntegrityError: + log.debug("leave.notify.duplicate key=%s", dedupe_key) + return None + + +def tell_the_employee(request: LeaveRequest, subject: str, body: str = "") -> None: + notify(topic="leave.decided", + dedupe_key=f"leave.decided:{request.pk}", + recipient_user_id=request.user_id, subject=subject, body=body, + request=request) + + +def tell_the_assignee(task, subject: str, body: str = "") -> None: + if task.assignee_user_id is None: + return + notify(topic="leave.task_assigned", + dedupe_key=f"leave.task:{task.pk}", + recipient_user_id=task.assignee_user_id, subject=subject, + body=body, request=task.request) + + +def tell_concerned_roles(request: LeaveRequest, subject: str) -> None: + """Withdrawal reaches whoever was still holding a task (BR-EL-022).""" + from modules.leave.models import LeaveTask + + assignees = set(LeaveTask.objects + .filter(request=request, status="pending") + .exclude(assignee_user_id=None) + .values_list("assignee_user_id", flat=True)) + for user_id in assignees: + notify(topic="leave.withdrawn", + dedupe_key=f"leave.withdrawn:{request.pk}:{user_id}", + recipient_user_id=user_id, subject=subject, request=request) diff --git a/modules/leave/services/offline.py b/modules/leave/services/offline.py new file mode 100644 index 00000000..b985e64d --- /dev/null +++ b/modules/leave/services/offline.py @@ -0,0 +1,106 @@ +"""Offline-sanctioned leave, recorded after the fact (EL-UC-016). + +Leave already authorised on paper, where ELM could not be used, is a +synchronisation job rather than a second approval workflow (BR-EL-029, +BR-EL-037). Three things follow from that, and they are the whole module: + + the entry needs sanction/reference evidence, or it is not recorded + it lands APPROVED, with no stages and no tasks — nobody sanctions it again + it posts to the account like any other approved leave + +An administrator who does this is maintaining a record. The moment it became a +way to approve leave it would be a different, and much worse, feature. +""" +from __future__ import annotations + +import logging +from datetime import date + +from core.api.exceptions import ConflictError, ValidationError +from modules.leave.domain import categories as cat +from modules.leave.domain.categories import leave_days +from modules.leave.domain.state_machine import APPROVED, KIND_NEW +from modules.leave.models import LeavePolicy, LeaveRequest +from modules.leave.selectors import balances as balance_selectors +from modules.leave.services import accounts, notifications + +log = logging.getLogger("fusion.leave.offline") + + +def record(*, principal, user_id: int, category_code: str, from_date: date, + to_date: date, sanctioned_on: date, sanction_reference: str, + reason: str) -> LeaveRequest: + """Enter leave that was already sanctioned offline.""" + policy = LeavePolicy.current() + if policy is None: + raise ValidationError("Leave has not been configured yet.", + code="policy_missing") + + if not sanction_reference.strip(): + raise ValidationError( + "Sanction or reference evidence is required for an offline entry.", + code="reference_missing") + if category_code not in cat.CATEGORIES: + raise ValidationError( + f"{category_code or 'That'} is not a leave category.", + code="category_not_supported") + if to_date < from_date: + raise ValidationError("The leave cannot end before it starts.", + code="invalid_period") + + cal = balance_selectors.effective_calendar(policy.year_label) + if any(_overlaps(r, from_date, to_date) for r in _active(user_id)): + raise ConflictError( + "That period overlaps leave already recorded for this employee.", + code="overlapping_leave") + + kind = _employee_kind(user_id) + if kind not in cat.category(category_code).kinds: + raise ValidationError( + f"{cat.category(category_code).label} is not available to " + f"{kind} employees.", code="category_not_applicable") + + days = leave_days(code=category_code, start=from_date, end=to_date, + closed_dates=cal["closed_dates"]) + + request = LeaveRequest.objects.create( + kind=KIND_NEW, category=category_code, user_id=user_id, + from_date=from_date, to_date=to_date, days=days, + purpose=f"Offline entry: {reason}", is_offline=True, + sanction_reference=sanction_reference, sanctioned_on=sanction_on, + offline_reason=reason, applicant_roles=[], + unit_head_user_id=None, final_authority="Offline sanction", + status=APPROVED, decision="approve", + decision_remarks=f"Recorded from offline sanction {sanction_reference}", + decided_by_user_id=principal.user_id) + + accounts.post(user_id=user_id, year_label=policy.year_label, + category=category_code, days=-days, kind="offline_entry", + note=f"Offline sanction {sanction_reference}", request=request) + notifications.notify( + topic="leave.decided", dedupe_key=f"leave.offline:{request.pk}", + recipient_user_id=user_id, + subject="Leave recorded from your offline sanction", + body=(f"{request.get_category_display()} from {from_date} to " + f"{to_date} has been recorded against reference " + f"{sanction_reference}."), + request=request) + return request + + +def _active(user_id: int): + return (LeaveRequest.objects + .filter(user_id=user_id, kind=KIND_NEW) + .exclude(status__in=["rejected", "withdrawn", "cancelled"]) + .only("from_date", "to_date", "half")) + + +def _overlaps(request: LeaveRequest, start: date, end: date) -> bool: + return cat.Interval(start, end).overlaps(request.interval) + + +def _employee_kind(user_id: int) -> str: + from modules.directory import contracts as directory + + person = directory.get_users([user_id]).get(user_id) + return person.kind if person else cat.STAFF diff --git a/modules/leave/services/sla.py b/modules/leave/services/sla.py new file mode 100644 index 00000000..c1eb2a72 --- /dev/null +++ b/modules/leave/services/sla.py @@ -0,0 +1,95 @@ +"""The SLA sweep (EL-UC-021, EVT-02). + +Two thresholds, one rule each, and the same guard on both: nothing is sent for +a task that is no longer pending (BR-EL-032/033). Because a stage that has been +passed supersedes its task in the same transaction, "still pending" is a query +rather than a hope. + +Escalation is recorded on the task and routed to the one responsible person +this module can name for a request — its Unit Head — alongside whoever is +holding the task. Where the escalation configuration changes routing or +responsibility, that is a decision for the authority, not for a sweep. +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from django.utils import timezone + +from modules.leave.models import LeaveTask +from modules.leave.services import notifications + +log = logging.getLogger("fusion.leave.sla") + + +@dataclass +class SweepReport: + reminded: list[int] = field(default_factory=list) + escalated: list[int] = field(default_factory=list) + skipped: int = 0 + + def __str__(self) -> str: + return (f"{len(self.reminded)} reminded, {len(self.escalated)} " + f"escalated, {self.skipped} already closed") + + +def sweep(*, now=None) -> SweepReport: + """One pass over every pending task. Safe to run often and to re-run.""" + now = now or timezone.now() + report = SweepReport() + + for task in (LeaveTask.objects.filter(status="pending") + .select_related("request")): + due_for_reminder = (task.reminder_due_at is not None + and task.reminder_due_at <= now + and task.reminded_at is None) + due_for_escalation = (task.escalation_due_at is not None + and task.escalation_due_at <= now + and task.escalated_at is None) + if not (due_for_reminder or due_for_escalation): + continue + + update = [] + if due_for_reminder: + task.reminded_at = now + update.append("reminded_at") + report.reminded.append(task.pk) + log.info("leave.sla.reminder task=%s stage=%s", task.pk, task.stage) + _tell(task, "leave.reminder", + "Reminder: a leave request at the " + f"{task.stage} stage is still waiting.") + if due_for_escalation: + task.escalated_at = now + update.append("escalated_at") + report.escalated.append(task.pk) + log.info("leave.sla.escalation task=%s stage=%s", task.pk, task.stage) + _escalate(task) + task.save(update_fields=[*update, "updated_at"]) + + return report + + +def _tell(task, topic: str, body: str) -> None: + """A reminder goes to whoever is holding the task.""" + if task.assignee_user_id is None: + return + notifications.notify(topic=topic, dedupe_key=f"{topic}:{task.pk}", + recipient_user_id=task.assignee_user_id, + subject=f"{topic.split('.')[-1].title()} — leave request", + body=body, request=task.request) + + +def _escalate(task) -> None: + """Notify the responsible higher authority, and log that it was done.""" + request = task.request + recipients = {uid for uid in (task.assignee_user_id, request.unit_head_user_id) + if uid} + for user_id in recipients: + notifications.notify( + topic="leave.escalated", dedupe_key=f"leave.escalated:{task.pk}:{user_id}", + recipient_user_id=user_id, + subject="A leave request has passed its escalation threshold", + body=(f"{request.get_category_display()} {request.from_date} to " + f"{request.to_date} is still at the {task.stage} stage."), + request=request) diff --git a/modules/leave/services/workflow.py b/modules/leave/services/workflow.py new file mode 100644 index 00000000..869ae0ca --- /dev/null +++ b/modules/leave/services/workflow.py @@ -0,0 +1,213 @@ +"""Every state change in this module goes through `move()`. + +One function, because the alternative — a check in each view — is how a rule +ends up enforced on four endpoints and missing on the fifth. `move()` does five +things in one transaction: + + resolve the edge in the state machine, for THIS permission and lane + authorise does the caller hold it, and may they act on THIS row + record an append-only LeaveApproval row + hand over supersede the finished task, open the next one, clock the SLA + stage which lane acted, so the audit trail says who did what + +The caller then applies whatever the decision MEANS — a deduction, a restored +balance, an extended end date — as an effect on top, in the same transaction. +""" +from __future__ import annotations + +import logging +from datetime import timedelta + +from django.db import transaction +from django.utils import timezone + +from core.api.exceptions import ConflictError, PermissionDeniedError +from modules.leave.domain import guards as guard_table +from modules.leave.domain import state_machine as sm +from modules.leave.domain.guards import Facts +from modules.leave.models import LeaveApproval, LeaveRequest, LeaveTask +from modules.leave.services import notifications + +log = logging.getLogger("fusion.leave.workflow") + +#: Permissions whose exercise is a DECISION about somebody else's leave. +#: Holding one of these on your own request is exactly the self-approval the +#: Director-specific path exists to permit, and nothing else does. +DECISION_PERMISSIONS = frozenset({ + "leave.request.review", + "leave.request.route", + "leave.request.approve", + "leave.resumption.review", +}) + +#: permission -> the lane of the state machine that exercises it. +LANES = { + "leave.application.create": sm.EMPLOYEE, + "leave.application.withdraw": sm.EMPLOYEE, + "leave.application.cancel": sm.EMPLOYEE, + "leave.application.extend": sm.EMPLOYEE, + "leave.substitute.nominate": sm.EMPLOYEE, + "leave.resumption.submit": sm.EMPLOYEE, + "leave.substitute.respond": sm.SUBSTITUTE, + "leave.request.review": sm.UNIT_HEAD, + "leave.request.route": sm.ESTABLISHMENT, + "leave.request.approve": sm.AUTHORITY, + "leave.resumption.review": sm.AUTHORITY, +} + +#: Resulting state -> the task it needs, and whose it is. +STAGE_FOR_STATE = { + sm.AWAITING_SUBSTITUTE: ("substitute", "substitute_user_id"), + sm.AWAITING_UNIT_HEAD: ("unit_head", "unit_head_user_id"), + sm.AWAITING_ESTABLISHMENT: ("establishment", None), + sm.AWAITING_FINAL: ("final", None), + sm.AWAITING_SELF_SANCTION: ("self_sanction", "user_id"), + sm.RESUMPTION_PENDING: ("final", "unit_head_user_id"), +} + +STAGE_FOR_LANE = { + sm.EMPLOYEE: "employee", + sm.SUBSTITUTE: "substitute", + sm.UNIT_HEAD: "unit_head", + sm.ESTABLISHMENT: "establishment", +} + + +def lane_for(permission: str) -> str: + try: + return LANES[permission] + except KeyError as exc: + raise LookupError( + f"No actor lane is declared for permission {permission!r}.") from exc + + +def stage_for(lane: str, request: LeaveRequest) -> str: + """The audit trail's name for the lane that acted.""" + if lane == sm.AUTHORITY: + return "self_sanction" if request.self_sanction else "final" + return STAGE_FOR_LANE[lane] + + +def require(principal, permission: str, request: LeaveRequest | None = None, + *, allow_self: bool = False) -> None: + """The fine gate. Scope is the selector's job, done before we get here.""" + if not principal.has_permission(permission): + raise PermissionDeniedError( + "You do not have permission to perform this action.", + code="permission_denied") + if (request is not None and permission in DECISION_PERMISSIONS + and not allow_self and request.user_id == principal.user_id): + raise PermissionDeniedError( + "You cannot take a decision on your own leave request.", + code="self_approval") + + +def _resolve(*, request: LeaveRequest, permission: str, facts: Facts): + """The table, with its errors translated into ones the API can render.""" + lane = lane_for(permission) + try: + return guard_table.select(frm=request.status, permission=permission, + facts=facts, actor_kind=lane) + except guard_table.GuardFailed as exc: + raise ConflictError( + _message_for(exc.guard), code="guard_failed", + details=[{"guard": exc.guard, "state": request.status, + "allowed": sm.allowed_targets(request.status, lane)}]) from exc + except sm.InvalidTransition as exc: + raise ConflictError( + f"This request is {exc.frm.replace('_', ' ')} and can no longer " + f"take that action.", code="invalid_transition", + details=[{"state": exc.frm, + "allowed": sm.allowed_targets(exc.frm, lane)}]) from exc + except sm.ActorNotAllowed as exc: + raise PermissionDeniedError("Your role cannot make that change.", + code="actor_not_allowed") from exc + + +def _message_for(guard: str) -> str: + """Guard names are for the table; the person holding the request needs a + sentence.""" + return { + "substitute_required": "This request is still waiting for the substitute's consent.", + "no_substitute_required": "No substitute is nominated for this request.", + "consent_accepted": "The substitute has not accepted yet.", + "consent_declined": "The substitute has not declined this request.", + "replacement_eligible": "Nominate an eligible replacement substitute.", + "applicant_is_director": "This request is not a Director self-sanction.", + "applicant_not_director": "A Director's own leave follows the self-sanction path.", + "unit_head_final": "That decision belongs to the final sanctioning authority.", + "higher_sanction": "This leave needs the full sanctioning path, not a Unit Head decision.", + "establishment_configured": "No Establishment step is configured, so this goes straight to the final authority.", + "decision_approve": "Record an approval to complete this action.", + "decision_reject": "Record a rejection to complete this action.", + "pre_start": "This leave has already begun, so it can no longer be cancelled.", + "leave_ongoing": "Only leave currently in effect can be extended.", + "extension_eligible": "This leave category cannot be extended.", + "leave_started": "The leave has not begun yet.", + "resumption_eligible": "Resumption review applies to EL, COL and VL.", + "not_final": "This request has already been decided.", + }.get(guard, f"The request does not satisfy {guard.replace('_', ' ')}.") + + +def _open_task(request: LeaveRequest, stage: str, assignee_id, policy) -> None: + """Hand the work over, and start the SLA clock (BR-EL-035/038).""" + task = LeaveTask.objects.create( + request=request, stage=stage, assignee_user_id=assignee_id) + if policy is not None: + now = timezone.now() + task.reminder_due_at = now + timedelta(hours=policy.reminder_hours) + task.escalation_due_at = now + timedelta(hours=policy.escalation_hours) + task.save(update_fields=["reminder_due_at", "escalation_due_at"]) + notifications.tell_the_assignee( + task, "A leave request needs your attention", + f"{request.get_category_display()} {request.from_date} to {request.to_date}.") + + +def _supersede(request: LeaveRequest, *, stage: str | None = None) -> None: + """A stage that has been passed can no longer be acted on — which is what + makes "no reminder for a task that is no longer pending" a fact rather + than a promise (BR-EL-032/033).""" + qs = LeaveTask.objects.filter(request=request, status="pending") + if stage: + qs = qs.filter(stage=stage) + qs.update(status="superseded", actioned_at=timezone.now()) + + +def _hand_over(request: LeaveRequest, to_state: str, policy) -> None: + _supersede(request) + spec = STAGE_FOR_STATE.get(to_state) + if spec is None: + return + stage, attr = spec + assignee = getattr(request, attr) if attr else None + _open_task(request, stage, assignee, policy) + + +@transaction.atomic +def move(*, request: LeaveRequest, principal, permission: str, facts: Facts, + decision: str = "", remarks: str = "", policy=None) -> LeaveRequest: + """Take the one transition this caller, this permission and these facts + allow, and record everything that follows from it.""" + lane = lane_for(permission) + require(principal, permission, request, allow_self=bool(request.self_sanction)) + transition = _resolve(request=request, permission=permission, facts=facts) + + frm = request.status + request.status = transition.to + if decision: + request.decision = decision + request.decision_remarks = remarks + request.decided_by_user_id = principal.user_id + request.decided_at = timezone.now() + request.save(update_fields=["status", "decision", "decision_remarks", + "decided_by_user_id", "decided_at", "updated_at"]) + + LeaveApproval.objects.create( + request=request, stage=stage_for(lane, request), + decision=decision or transition.to, + actor_user_id=principal.user_id, remarks=remarks) + _hand_over(request, transition.to, policy) + + log.info("leave.%s %s -> %s by %s (%s)", request.pk, frm, transition.to, + principal.user_id, permission) + return request diff --git a/modules/leave/services/year_end.py b/modules/leave/services/year_end.py new file mode 100644 index 00000000..7d627715 --- /dev/null +++ b/modules/leave/services/year_end.py @@ -0,0 +1,133 @@ +"""Year-end processing (EL-UC-020, EVT-01). + +An event trigger, not an actor: nothing here is waiting for a person to click +anything. At the boundary, each account's unused balance meets its outcome +(BR-EL-002..009): + + CL, RH, SCL lapse + VL convert to EL at 2:1, fractions included + EL, COL carry forward + +Every outcome is a ledger row, because "the year-end run did something to my +VL" is not an acceptable thing to have to explain afterwards. Exceptions are +reported rather than raised, so one odd account cannot stop the boundary for +everybody. +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from decimal import Decimal + +from modules.leave.domain import categories as cat +from modules.leave.models import LeaveBalance, LeavePolicy +from modules.leave.services import accounts + +log = logging.getLogger("fusion.leave.year_end") + + +@dataclass +class YearEndReport: + year_label: str + lapsed: list[str] = field(default_factory=list) + converted: list[str] = field(default_factory=list) + carried: list[str] = field(default_factory=list) + exceptions: list[str] = field(default_factory=list) + + def __str__(self) -> str: + return (f"{self.year_label}: {len(self.lapsed)} lapsed, " + f"{len(self.converted)} converted, {len(self.carried)} carried, " + f"{len(self.exceptions)} exception(s)") + + +def run(*, policy: LeavePolicy, dry_run: bool = False) -> YearEndReport: + """Close one leave year and open the next.""" + closing = policy.year_label + opening = accounts.next_year_label(closing) + report = YearEndReport(closing) + + employee_ids = set(LeaveBalance.objects + .filter(year_label=closing) + .values_list("user_id", flat=True)) + + for user_id in sorted(employee_ids): + try: + _close_one(user_id=user_id, closing=closing, opening=opening, + policy=policy, report=report, dry_run=dry_run) + except Exception as exc: # noqa: BLE001 + # One broken account is reported, not raised: the boundary has to + # happen for everybody else. + report.exceptions.append(f"{user_id}: {exc}") + log.exception("leave.year_end.failed user=%s", user_id) + + if not dry_run: + _open_next(opening=opening, policy=policy, employee_ids=employee_ids) + return report + + +def _close_one(*, user_id: int, closing: str, opening: str, + policy: LeavePolicy, report: YearEndReport, + dry_run: bool) -> None: + for row in LeaveBalance.objects.filter(user_id=user_id, year_label=closing): + available = row.available + if available < 0: + report.exceptions.append( + f"{user_id}/{row.category}: account is over-drawn by " + f"{-available} and was left alone") + continue + if available == 0: + continue + + action = cat.year_end_action(row.category, available, + vl_per_el=policy.vl_per_el) + if dry_run: + _record(report, action, row, user_id) + continue + + if action.kind == cat.CARRY_FORWARD: + accounts.carry_forward(user_id=user_id, from_year=closing, + to_year=opening) + elif action.kind == cat.LAPSE: + accounts.post(user_id=user_id, year_label=closing, + category=row.category, days=-available, + kind="year_end_lapse", + note=f"Lapsed at the {closing} boundary") + elif action.kind == cat.CONVERT: + accounts.post(user_id=user_id, year_label=closing, + category=row.category, days=-available, + kind="year_end_conversion", + note=f"Converted to {action.target} at " + f"{policy.vl_per_el}:1") + accounts.post(user_id=user_id, year_label=opening, + category=action.target or cat.EL, days=action.amount, + kind="year_end_conversion", + note=f"From {closing} VL conversion") + _record(report, action, row, user_id) + + +def _record(report: YearEndReport, action, row, user_id: int) -> None: + label = f"{user_id}/{row.category}={row.available}" + if action.kind == cat.LAPSE: + report.lapsed.append(label) + elif action.kind == cat.CONVERT: + report.converted.append(f"{label}->{action.amount} {action.target}") + elif action.kind == cat.CARRY_FORWARD: + report.carried.append(label) + + +def _open_next(*, opening: str, policy: LeavePolicy, + employee_ids: set[int]) -> None: + """The new year's entitlement, for everyone who had an account.""" + for user_id in sorted(employee_ids): + person_kind = _kind_for(user_id) + if person_kind is None: + continue + accounts.credit_annual(user_id=user_id, year_label=opening, + employee_kind=person_kind, policy=policy) + + +def _kind_for(user_id: int) -> str | None: + from modules.directory import contracts as directory + + person = directory.get_users([user_id]).get(user_id) + return person.kind if person else None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..c787befe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,207 @@ +absl-py==2.4.0 +aiohttp==3.8.1 +aiosignal==1.2.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.13.0 +apturl==0.5.2 +argcomplete==3.6.3 +async-timeout==5.0.1 +attrs==25.4.0 +Babel==2.8.0 +bcc==0.18.0 +bcrypt==3.2.0 +blinker==1.9.0 +Brlapi==0.8.3 +Cerberus==1.3.8 +certifi==2020.6.20 +cffi==2.0.0 +chardet==4.0.0 +charset-normalizer==2.0.6 +click==8.5.0 +colorama==0.4.4 +comm==0.2.3 +command-not-found==0.3 +contourpy==1.3.2 +coverage==7.13.4 +cryptography==46.0.6 +cupshelpers==1.0 +cycler==0.12.1 +dash==4.4.1 +dbus-fast==2.44.1 +dbus-python==1.2.18 +defer==1.0.6 +distlib==0.4.0 +distro==1.7.0 +distro-info==1.1+ubuntu0.2 +dnspython==2.1.0 +duplicity==0.8.21 +ecdsa==0.18.0b1 +empy==3.3.4 +et_xmlfile==2.0.0 +evdev==1.6.1 +exceptiongroup==1.3.1 +fastapi==0.135.3 +fastcrc==0.3.5 +fasteners==0.14.1 +fido2==0.9.1 +filelock==3.29.0 +Flask==3.1.3 +flatbuffers==25.12.19 +fonttools==4.61.1 +frozenlist==1.2.0 +future==0.18.2 +google-ai-generativelanguage==0.6.15 +google-api-core==2.30.2 +google-api-python-client==2.193.0 +google-auth==2.49.1 +google-auth-httplib2==0.3.1 +google-generativeai==0.8.6 +googleapis-common-protos==1.74.0 +grpcio==1.80.0 +grpcio-status==1.71.2 +h11==0.16.0 +httpcore==1.0.9 +httplib2==0.20.2 +httpx==0.28.1 +idna==3.3 +importlib-metadata==4.6.4 +importlib_resources==6.5.2 +itsdangerous==2.2.0 +janus==2.0.0 +jax==0.6.2 +jaxlib==0.6.2 +jeepney==0.7.1 +Jinja2==3.1.6 +joblib==1.5.3 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +kconfiglib==14.1.0 +keyring==23.5.0 +kiwisolver==1.4.9 +language-selector==0.1 +lark==1.3.1 +launchpadlib==1.10.16 +lazr.restfulclient==0.14.4 +lazr.uri==1.0.6 +lockfile==0.12.2 +louis==3.20.0 +lxml==6.0.2 +macaroonbakery==1.3.1 +Mako==1.1.3 +Markdown==3.10.3 +MarkupSafe==3.0.3 +matplotlib==3.10.8 +MAVProxy==1.8.74 +mediapipe==0.10.14 +ml_dtypes==0.5.4 +monotonic==1.6 +more-itertools==8.10.0 +mpmath==1.3.0 +mss==10.1.0 +multidict==5.1.0 +narwhals==2.25.0 +nest-asyncio==1.6.0 +netifaces==0.11.0 +notify2==0.3.1 +numpy==2.2.6 +nunavut==2.3.1 +oauthlib==3.2.0 +olefile==0.46 +opencv-contrib-python==4.13.0.92 +opencv-python==4.13.0.92 +openpyxl==3.1.5 +opt_einsum==3.4.0 +packaging==26.0 +pandas==2.3.3 +paramiko==2.9.3 +pexpect==4.8.0 +Pillow==9.0.1 +pkgconfig==1.5.5 +platformdirs==4.9.6 +plotly==7.0.0 +proto-plus==1.27.2 +protobuf==4.25.9 +proton-core==0.7.4 +proton-keyring-linux==0.2.1 +proton-vpn-api-core==5.2.3 +proton-vpn-daemon==0.13.7 +proton-vpn-gtk-app==4.16.4 +psutil==7.2.2 +ptyprocess==0.7.0 +pyaes==1.6.1 +pyasn1==0.6.3 +pyasn1_modules==0.4.2 +pybind11==2.9.1 +pycairo==1.20.1 +pycparser==3.0 +pycryptodome==3.23.0 +pycups==2.0.1 +pydantic==2.12.5 +pydantic_core==2.41.5 +pydsdl==1.24.3 +pygame==2.1.2 +Pygments==2.11.2 +PyGObject==3.42.1 +PyJWT==2.3.0 +pymacaroons==0.13.0 +pymavlink==2.4.49 +pymupdf==1.28.0 +PyNaCl==1.5.0 +pynmeagps==1.1.0 +pynput==1.8.1 +pyOpenSSL==21.0.0 +pyparsing==3.3.2 +pyRFC3339==1.1 +pyros-genmsg==0.5.8 +pyserial==3.5 +python-apt==2.4.0+ubuntu4.1 +python-dateutil==2.9.0.post0 +python-debian==0.1.43+ubuntu1.1 +python-discovery==1.3.0 +python-docx==1.2.0 +python-gnupg==0.4.8 +python-pptx==1.0.2 +python-xlib==0.33 +pytz==2022.1 +pyulog==1.2.2 +pyxdg==0.27 +PyYAML==5.4.1 +redis==8.1.0 +referencing==0.37.0 +reportlab==3.6.8 +requests==2.25.1 +retrying==1.4.2 +rpds-py==0.30.0 +scikit-learn==1.7.2 +scipy==1.15.3 +SecretStorage==3.3.1 +sentry-sdk==1.4.3 +six==1.16.0 +sounddevice==0.5.5 +starlette==1.0.0 +sympy==1.14.0 +systemd-python==234 +threadpoolctl==3.6.0 +toml==0.10.2 +tqdm==4.67.3 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2025.3 +ubuntu-drivers-common==0.0.0 +ubuntu-pro-client==8001 +ufw==0.36.1 +unattended-upgrades==0.1 +UNKNOWN @ file:///home/atharva/Downloads/cropforge/crop1/CropForge-Paddy-Twin +uritemplate==4.2.0 +urllib3==1.26.5 +usb-creator==0.3.7 +uvicorn==0.43.0 +virtualenv==21.3.1 +wadllib==1.3.6 +Werkzeug==3.1.8 +xdg==5 +xkit==0.0.0 +xlsxwriter==3.2.9 +yarl==1.7.2 +zipp==1.0.0 From d8ff97b7b4b90b0fd8eee9f3c4a0a4c3033ea589 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 12 Oct 2026 10:07:05 +0530 Subject: [PATCH 2/3] added implemntation files:qwencode 2.5:7b --- Assignment6_Submission_Instructions.docx.pdf | Bin 90634 -> 0 bytes modules/leave/selectors/balances.py | 1 + 2 files changed, 1 insertion(+) delete mode 100644 Assignment6_Submission_Instructions.docx.pdf diff --git a/Assignment6_Submission_Instructions.docx.pdf b/Assignment6_Submission_Instructions.docx.pdf deleted file mode 100644 index fadf1815d18e374ba86d07d8df046243d527d373..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 90634 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

RDhI)FS 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 Date: Sat, 17 Oct 2026 21:47:52 +0530 Subject: [PATCH 3/3] feat(leave): complete Phase 2 ELM module integration - Fixed model defects (choices, missing created_at) - Added missing ValidationError to core exceptions - Added frontend routes, pages, types Generated with qwen2.5-coder:7b via Ollama --- OLLAMA_PROMPT_PACK.md | 229 +++ PHASE2_OLLAMA_PROMPTS.md | 1484 +++++++++++++++++ PHASE2_PROMPT_FOR_LLAMA.md | 116 ++ client/src/app/registry.ts | 5 + client/src/modules/leave/api/hooks.ts | 126 ++ client/src/modules/leave/api/types.ts | 122 ++ .../leave/pages/AdministrationPage.tsx | 150 ++ client/src/modules/leave/pages/InboxPage.tsx | 36 + client/src/modules/leave/pages/MinePage.tsx | 139 ++ .../modules/leave/pages/SubstitutionsPage.tsx | 57 + client/src/modules/leave/pages/TeamPage.tsx | 67 + client/src/modules/leave/routes.tsx | 17 + config/settings/base.py | 1 + config/urls.py | 1 + core/api/exceptions.py | 8 + docs/02-iam/permission-catalog.generated.md | 22 + modules/leave/api/serializers.py | 4 +- modules/leave/api/urls.py | 47 + modules/leave/api/views.py | 12 +- modules/leave/domain/categories.py | 10 +- modules/leave/domain/eligibility.py | 2 +- modules/leave/domain/guards.py | 2 +- modules/leave/migrations/0001_initial.py | 269 +++ modules/leave/migrations/__init__.py | 0 modules/leave/models.py | 12 +- modules/leave/registry.py | 1 + modules/leave/selectors/balances.py | 1 - modules/leave/services/__init__.py | 2 +- modules/leave/services/accounts.py | 2 +- modules/leave/services/applications.py | 12 +- modules/leave/services/decisions.py | 10 +- modules/leave/services/offline.py | 2 +- modules/leave/services/sla.py | 2 +- modules/leave/services/workflow.py | 3 +- modules/leave/services/year_end.py | 3 +- modules/leave/tests/__init__.py | 0 modules/leave/tests/test_domain_rules.py | 41 + registry/permissions.json | 362 ++++ 38 files changed, 3342 insertions(+), 37 deletions(-) create mode 100644 OLLAMA_PROMPT_PACK.md create mode 100644 PHASE2_OLLAMA_PROMPTS.md create mode 100644 PHASE2_PROMPT_FOR_LLAMA.md create mode 100644 client/src/modules/leave/api/hooks.ts create mode 100644 client/src/modules/leave/api/types.ts create mode 100644 client/src/modules/leave/pages/AdministrationPage.tsx create mode 100644 client/src/modules/leave/pages/InboxPage.tsx create mode 100644 client/src/modules/leave/pages/MinePage.tsx create mode 100644 client/src/modules/leave/pages/SubstitutionsPage.tsx create mode 100644 client/src/modules/leave/pages/TeamPage.tsx create mode 100644 client/src/modules/leave/routes.tsx create mode 100644 modules/leave/api/urls.py create mode 100644 modules/leave/migrations/0001_initial.py create mode 100644 modules/leave/migrations/__init__.py create mode 100644 modules/leave/tests/__init__.py create mode 100644 modules/leave/tests/test_domain_rules.py diff --git a/OLLAMA_PROMPT_PACK.md b/OLLAMA_PROMPT_PACK.md new file mode 100644 index 00000000..1af91323 --- /dev/null +++ b/OLLAMA_PROMPT_PACK.md @@ -0,0 +1,229 @@ +# Ollama Prompt Pack — ELM Phase 2 (Fusion-Integrated) + +Sequential prompts P1→P6 for a local Ollama model. Run them **one per message, in order**. +Between every prompt: run the Verification line yourself in the repo, then paste either +`OK` or the full error output back to the model. + +**Setup** +```bash +cd /home/atharva/Documents/Fusion/Fusion-Integrated +ollama run qwen2.5-coder:7b # or whichever coding model you have +``` +If a step fails twice, start a FRESH Ollama session with just that step's prompt +(every prompt below is self-contained on purpose — small models lose context fast). + +**Repo facts every prompt repeats (so the model never guesses):** +- Repo root: `/home/atharva/Documents/Fusion/Fusion-Integrated` — Django 4 + DRF + React. +- Module: `modules/leave/` — layers: `domain/` (pure), `services/`, `selectors/`, `api/`, + plus `models.py`, `registry.py`, `contracts.py`, `apps.py`. +- Reference module (fully working — copy its patterns): `modules/placement/`. +- Python: `.venv/bin/python` · checks: `make check` (see Makefile). +- Specs (plain text): `04_ELM_Business_Rule_Specifications_v1.4_FINAL.txt` (BR-EL-xxx), + `06_ELM_Workflow_Specifications_v1.6_FINAL.txt` (CW-EL-xx), `03_ELM_Use_Case_Specifications_v1.4_FINAL.txt`. + +--- +--- + +## P1 — Register the module into Django + +``` +Repo: /home/atharva/Documents/Fusion/Fusion-Integrated (Django 4 + DRF). + +I have an ELM leave module at modules/leave/ that is NOT wired into Django yet. +Fix exactly these three things and nothing else: + +1. config/settings/base.py — add "modules.leave" to the DOMAIN_MODULES list + (currently only contains "modules.placement"). Keep the existing comment style. + +2. modules/leave/api/urls.py — CREATE this file. First read modules/leave/api/views.py + and list EVERY APIView class defined there (MyRequestsView, MyBalancesView, + RequestDetailView, WithdrawView, ReplacementSubstituteView, SubstitutionInboxView, + TaskInboxView, UnitHeadReviewView, EstablishmentRouteView, FinalDecisionView, + ExtensionView, ResumptionView, ResumptionReviewView, CancellationView, plus any admin/ + calendar/policy views). Then write urlpatterns routing all of them, copying the exact + style of modules/placement/api/urls.py: path("...", v.ViewName.as_view(), name="leave-..."). + Use for detail/action views that take pk. Module prefix for names: "leave-". + Import views as `from modules.leave.api import views as v`. + +3. config/urls.py — add one line to urlpatterns, in the same style as the placement and + directory lines: + path("api/v1/leave/", include("modules.leave.api.urls")), + Do not reorder or change any other line. + +Constraints: Django/DRF only — no FastAPI, no new requirements, no other files touched. +If a view needs a URL shape you cannot infer from its get/post signature, read +modules/leave/api/views.py fully first — do not invent endpoints that have no view. + +Verification I will run: + .venv/bin/python manage.py check +Paste the complete output of manage.py check. If it reports errors, fix them before finishing. +``` +**Verification:** `.venv/bin/python manage.py check` → `System check identified no issues (0 silenced).` + +--- + +## P2 — Migrations + +``` +Repo: /home/atharva/Documents/Fusion/Fusion-Integrated (Django 4). + +modules/leave/ has models.py but no migrations/ directory, so its tables cannot be created. + +Do this: +1. Create modules/leave/migrations/__init__.py (empty file — the directory must exist). +2. Tell me the exact command to generate migrations for this app + (python manage.py makemigrations leave) and the command to apply them (make migrate). +3. Read modules/leave/apps.py — the AppConfig must have name="modules.leave" and + label="leave". If label is missing, add it. + +Do NOT edit models.py unless makemigrations itself reports it must change — if it does, +paste me the full makemigrations output first and wait for my reply. + +Verification I will run: + .venv/bin/python manage.py makemigrations leave + .venv/bin/python manage.py migrate +Paste both outputs. +``` +**Verification:** both commands succeed; `psql`/Django shows `leave_*` tables after migrate +(DB must be up: `make up` first if needed). + +--- + +## P3 — Domain rule tests + fix (THE core step) + +``` +Repo: /home/atharva/Documents/Fusion/Fusion-Integrated (Django 4, pure-Python domain layer). + +Task: audit modules/leave/domain/categories.py against the official business rules in +04_ELM_Business_Rule_Specifications_v1.4_FINAL.txt (plain text, read it), write tests that +encode the SPEC's expected behavior, then fix the code so the tests pass. + +The three rules tied to this file that MUST pass: + +- BR-EL-007: unused VL converts to EL at 2 VL : 1 EL — i.e. EL = available / 2, fractional + results kept (Decimal division, NOT multiplication). Function: year_end_action(). +- BR-EL-011: CL/SCL/RH count duty days only — BOTH Saturday and Sunday are excluded from + the count for every date in range. Function: weekend_dates(). +- BR-EL-012: two half-day CL requests on the SAME date overlap only if they occupy the + SAME half; first-half vs second-half on the same date must return NOT overlapping. + Function: Interval.overlaps(). + +Procedure (do not skip order): +1. Read modules/leave/domain/categories.py completely. +2. Create modules/leave/tests/__init__.py and modules/leave/tests/test_domain_rules.py + with one test class per rule above, test names citing the BR number. Tests encode the + spec text I quoted — they may fail on first run. +3. Run: .venv/bin/python -m pytest modules/leave/tests/test_domain_rules.py -v +4. For each failure: compare the failing function against the BR text, state in one line + what the code does vs what the spec says, then apply the minimal fix to categories.py. +5. Re-run until all pass. Paste the final pytest output. + +Constraints: fix ONLY what the failing tests justify; no refactors, no renames, no changes +outside modules/leave/domain/categories.py and modules/leave/tests/. Django/DRF only. +``` +**Verification:** `.venv/bin/python -m pytest modules/leave/tests/test_domain_rules.py -v` → all green. + +--- + +## P4 — Workflow/service API tests + +``` +Repo: /home/atharva/Documents/Fusion/Fusion-Integrated (Django 4 + DRF). + +The leave module (modules/leave/) now has URLs mounted at /api/v1/leave/. +Write integration tests in modules/leave/tests/test_api.py. Read these first: +- modules/placement/tests/test_api.py ← copy its fixture/auth/primitives style exactly +- modules/leave/api/views.py ← the endpoints and required permissions +- modules/leave/registry.py ← ROLE_GRANTS / permission names + +Cover, at minimum: +1. Happy path: apply for leave → Unit Head review → Establishment route → final approve + (use the service/API flow that exists; do not invent endpoints). +2. Authorization scoping: a user with no scope over a request gets 404 (NOT 403) from + RequestDetailView — the selectors scope querysets, 404 not 403. +3. CW-EL-03: extension of an ongoing approved leave via ExtensionView + (services/applications.py::request_extension). +4. CW-EL-04: resumption submit (submit_resumption) then review (decisions.py::review_resumption). +5. A permission-denied case: user without leave.request.review hitting UnitHeadReviewView + → 403. + +If a test cannot pass because of a real defect, paste me the traceback and the suspicious +code with your one-line diagnosis BEFORE changing production code. + +Verification: .venv/bin/python -m pytest modules/leave/tests/ -v +Paste the final output. +``` +**Verification:** `.venv/bin/python -m pytest modules/leave/tests/ -v` → all green. + +--- + +## P5 — Registry: permissions, nav, status + +``` +Repo: /home/atharva/Documents/Fusion/Fusion-Integrated (Django 4). + +modules/leave/registry.py still has MODULE["status"] = "planned" and must be finalized. +Read modules/leave/registry.py fully, plus docs/03-platform/module-authoring-guide.md +section on the registry and nav. + +Do: +1. Verify every NAV_ITEMS entry has a required_permission that exists in PERMISSIONS, + and every route in NAV_ITEMS ("to" values like /leave/mine) corresponds to a real + frontend route OR is currently unreachable because there is no frontend yet — report + which of the two is true; the repo has no client/src/modules/leave/ so routes exist + only in the registry. If ops/checks/nav_matches_routes.py fails, tell me the output + before changing anything. +2. Verify ROLE_GRANTS keys are designation names that exist (compare with how + modules/placement/registry.py does it). +3. Only if steps 1–2 are clean: flip MODULE["status"] from "planned" to "active". +4. Run .venv/bin/python manage.py permission_manifest and paste output; then + .venv/bin/python manage.py permission_manifest --check. + +Do not touch other modules' registries. +``` +**Verification:** `.venv/bin/python manage.py permission_manifest --check` exits 0. + +--- + +## P6 — Full CI sweep + report + +``` +Repo: /home/atharva/Documents/Fusion/Fusion-Integrated (Django 4 + DRF + React). + +Run-through: I will execute `make check`, which runs, in order: + manage.py check · makemigrations --check --dry-run · ruff check . · lint-imports · + ops/checks/no_cross_module_fk.py · ops/checks/contracts_are_plural.py · + ops/checks/nav_matches_routes.py · permission_manifest --check · + openapi schema-check (regenerate with `make schema` if endpoints changed) · pytest -q + +Tell me, for each likely failure class, the fix you expect to apply: +- ruff lint errors → show the exact ruff rule and the corrected lines +- schema-check mismatch → confirm `make schema` must be re-run after adding urls +- pytest failures → traceback + one-line root cause each +Then I will paste `make check` output and you fix ONLY the reported failures, smallest +diff possible, one failure at a time. + +Finally produce my Phase-2 report skeleton: + 1. Spec violations found (BR/UC number, file:line, what was wrong, the fix) + 2. Tests added (file + count) + 3. Integration gaps closed (INSTALLED_APPS, urls, migrations, registry) + 4. What a reviewer should manually test in the running app + (make up && make migrate && make seed && make dev → sign in → Leave in sidebar) +No claims of success I have not verified with command output. +``` +**Verification:** `make check` → passes; `make check-client` only if `client/` was touched. + +--- + +## Fallback prompt (use any time something breaks) + +``` +Repo: /home/atharva/Documents/Fusion/Fusion-Integrated (Django 4 + DRF). +My last step failed. Full output: + + + +Diagnose the root cause in 3 lines max, then give the minimal patch: exact file, exact +old line, exact new line. Django/DRF only; no FastAPI; touch nothing outside +modules/leave/, config/settings/base.py, config/urls.py. +``` diff --git a/PHASE2_OLLAMA_PROMPTS.md b/PHASE2_OLLAMA_PROMPTS.md new file mode 100644 index 00000000..6634ba81 --- /dev/null +++ b/PHASE2_OLLAMA_PROMPTS.md @@ -0,0 +1,1484 @@ +# Phase 2 — Ollama prompt log + +Every prompt sent to the local model (`qwen2.5-coder:7b`, served by Ollama at +`http://localhost:11434`) while integrating and repairing `modules/leave/`, in +order, with the judge verdict and the action taken. New prompts are appended as +the work continues. + +The loop run against the model is: + +1. **Embed the real files** in the prompt — the local model has no filesystem + access, so every fact it needs (source, spec text, error output) is pasted in. +2. **Generate** one small artefact at a time. +3. **Judge** the output automatically (`ops`-style checks, `ruff`, `manage.py + check`, `tsc`) and by reading it. +4. **Feed the exact error back** and regenerate when the verdict is wrong. + +A 7B model is reliable at short, fully-specified tasks and unreliable at +"edit this large file" tasks; the log records both, including where it had to be +corrected. + +--- +## P1 — wire `modules/leave` into Django (route every APIView) + +### Prompt sent + +```text +Repo: /home/atharva/Documents/Fusion/Fusion-Integrated (Django 4 + DRF). + +The ELM leave module at modules/leave/ is NOT wired into Django. Make exactly 3 edits. + +=== FILE 1: config/settings/base.py (relevant region, edit it) === +# Each module is an independent Django app: own tables, migrations and contract. +PLATFORM_MODULES = [ + "modules.directory", # who people are, projected from IAM + "modules.accesscontrol", # which modules a role may enter +] + +# See docs/03-platform/module-authoring-guide.md to add one. +DOMAIN_MODULES = [ + "modules.placement", +] + +INSTALLED_APPS = (DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + + PLATFORM_MODULES + DOMAIN_MODULES) + +=== FILE 2: config/urls.py (full current contents) === +"""Root URLs. + +Every module mounts itself under /api/v1//. A module that is not in +INSTALLED_APPS contributes no URLs at all — that is what "independent" means +here: removing a module is deleting a directory and one settings line. +""" +from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +import core.api.schema # noqa: F401 registers the auth schemes by import +from core.api.health import healthz, readyz + +urlpatterns = [ + path("healthz", healthz, name="healthz"), + path("readyz", readyz, name="readyz"), + path("api/v1/", include("fusion_auth.urls")), + path("api/v1/directory/", include("modules.directory.api.urls")), + path("api/v1/placement/", include("modules.placement.api.urls")), + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="docs"), +] + + +=== FILE 3: modules/placement/api/urls.py (STYLE REFERENCE - copy this exact style) === +from django.urls import path + +from modules.placement.api import academics as a +from modules.placement.api import audit as au +from modules.placement.api import clearance as cl +from modules.placement.api import conduct as c +from modules.placement.api import documents as d +from modules.placement.api import exports as e +from modules.placement.api import registration as reg +from modules.placement.api import views as v + +urlpatterns = [ + # postings + path("postings", v.PostingListView.as_view(), name="placement-postings"), + path("postings/", v.PostingDetailView.as_view(), + name="placement-posting"), + path("postings//publish", v.PostingPublishView.as_view(), + name="placement-posting-publish"), + path("postings//eligibility", v.PostingEligibilityView.as_view(), + name="placement-posting-eligibility"), + + # applications + path("applications", v.ApplicationListView.as_view(), + name="placement-applications"), + path("applications/apply", v.ApplyView.as_view(), name="placement-apply"), + path("applications//transition", v.ApplicationTransitionView.as_view(), + name="placement-application-transition"), + path("applications//history", au.ApplicationHistoryView.as_view(), + name="placement-application-history"), + path("applications/bulk-transition", + v.ApplicationBulkTransitionView.as_view(), + name="placement-application-bulk-transition"), + + # documents + path("documents", d.MyDocumentsView.as_view(), name="placement-documents"), + path("documents/", d.MyDocumentDetailView.as_view(), + name="placement-document"), + path("documents//download", d.DocumentDownloadView.as_view(), + name="placement-document-download"), + + # profile + path("profile/resume", v.MyResumeView.as_view(), name="placement-my-resume"), + path("profiles/", v.ProfileDetailView.as_view(), + name="placement-profile"), + + # companies + path("companies", v.CompanyListView.as_view(), name="placement-companies"), + path("companies//", v.CompanyApprovalView.as_view(), + name="placement-company-approval"), + + # interviews + path("interviews/rounds", v.InterviewRoundListView.as_view(), + name="placement-rounds"), + path("interviews/rounds//candidates", v.RoundCandidatesView.as_view(), + name="placement-round-candidates"), + path("interviews/rounds//outcome", v.RoundOutcomeView.as_view(), + name="placement-round-outcome"), + + # offers + path("offers", v.OfferListView.as_view(), name="placement-offers"), + path("offers/issue", v.OfferIssueView.as_view(), name="placement-offer-issue"), + path("offers//respond", v.OfferRespondView.as_view(), + name="placement-offer-respond"), + + # announcements + path("announcements", v.AnnouncementListView.as_view(), + name="placement-announcements"), + path("announcements//withdraw", v.AnnouncementWithdrawView.as_view(), + name="placement-announcement-withdraw"), + + # CPI directory — the whole cohort's declared standing, staff only + path("students/cpi", a.AcademicDirectoryView.as_view(), + name="placement-cpi-directory"), + path("students/cpi/filters", a.AcademicFiltersView.as_view(), + name="placement-cpi-filters"), + + # registration (rules 1, 20, 21) + path("registrations", reg.MyRegistrationsView.as_view(), + name="placement-registrations"), + path("registrations/terms", reg.MyRegistrationTermsView.as_view(), + name="placement-registration-terms"), + path("registrations/opt-out", reg.MyOptOutView.as_view(), + name="placement-registration-opt-out"), + path("registrations/approve-late", reg.LateRegistrationView.as_view(), + name="placement-registration-late"), + path("registrations/re-register", reg.ReRegistrationView.as_view(), + name="placement-reregistration"), + + # post-offer obligations (rules 22, 24) + path("records", cl.MyRecordsView.as_view(), name="placement-my-records"), + path("records/clearance", cl.MyClearanceView.as_view(), + name="placement-my-clearance"), + path("records//offer-letter", cl.SubmitOfferLetterView.as_view(), + name="placement-submit-offer-letter"), + path("records//not-joining", cl.NotJoiningView.as_view(), + name="placement-not-joining"), + path("records/outstanding", cl.OutstandingLettersView.as_view(), + name="placement-outstanding-letters"), + path("records/off-campus", cl.OffCampusRecordView.as_view(), + name="placement-off-campus-record"), + + # conduct and sanctions (rules 18, 19, 21) + path("conduct/incidents", c.IncidentListView.as_view(), + name="placement-incidents"), + path("conduct/incidents//waive", c.IncidentWaiveView.as_view(), + name="placement-incident-waive"), + path("conduct/sanctions", c.SanctionView.as_view(), + name="placement-sanction"), + path("conduct/sanctions/lift", c.SanctionLiftView.as_view(), + name="placement-sanction-lift"), + + path("conduct/mine", au.MyConductRecordView.as_view(), + name="placement-my-conduct"), + + # exports — bulk personal data, staff only and throttled + path("exports/applications.csv", e.ApplicationExportView.as_view(), + name="placement-export-applications"), + path("exports/placements.csv", e.PlacementRecordExportView.as_view(), + name="placement-export-placements"), + + # seasons and statistics + path("seasons", v.SeasonListView.as_view(), name="placement-seasons"), + path("stats", v.StatsView.as_view(), name="placement-stats"), + + # recruiter portal + path("recruiters/invite", v.RecruiterInviteView.as_view(), + name="placement-recruiter-invite"), + path("recruiters/accept", v.RecruiterAcceptView.as_view(), + name="placement-recruiter-accept"), + path("recruiters/login", v.RecruiterLoginView.as_view(), + name="placement-recruiter-login"), + path("recruiters/logout", v.RecruiterLogoutView.as_view(), + name="placement-recruiter-logout"), + path("recruiters/me", v.RecruiterMeView.as_view(), + name="placement-recruiter-me"), +] + + +=== APIView classes found in modules/leave/api/views.py (method args shown) === +MyRequestsView: + get() + post() +MyBalancesView: + get() +RequestDetailView: + get(pk) +WithdrawView: + post(pk) +ReplacementSubstituteView: + post(pk) +SubstitutionInboxView: + get() + post(pk) +TaskInboxView: + get() +UnitHeadReviewView: + post(pk) +EstablishmentRouteView: + post(pk) +FinalDecisionView: + post(pk) +ResumptionView: + post(pk) +ResumptionReviewView: + post(pk) +CancellationView: + post(pk) +ExtensionView: + post(pk) +ApprovedLeaveView: + get() +AllRequestsView: + get() +OfflineLeaveView: + post() +PolicyView: + get() + post() +CalendarListView: + get() + post() +CalendarPublishView: + post(pk) +VacationPeriodsView: + post() +SlaSweepView: + post() +YearEndView: + post() +CalendarForYearView: + +TASK: +1. settings: add "modules.leave" to DOMAIN_MODULES after "modules.placement". +2. CREATE modules/leave/api/urls.py routing EVERY class above: + - start with `from django.urls import path` and `from modules.leave.api import views as v` + - get-only classes without pk: path("", v.NameView.as_view(), name="leave-") + - post/detail classes with pk: path("/", v.NameView.as_view(), name="leave-") + - use REST-ish singular resource names like placement does (e.g. requests, requests//withdraw, + balances, inbox, resumption/, resumption//review, policy, calendar, year-end, sla-sweep) + - names must all start with "leave-" +3. config/urls.py: add path("api/v1/leave/", include("modules.leave.api.urls")), after the placement line. + +Output ONLY: +(a) the complete contents of modules/leave/api/urls.py in one fenced code block labelled urls.py +(b) the one-line settings edit +(c) the one-line config/urls.py edit +No explanations. +``` + +### Verdict + +**Result:** routed only 11 of 24 views; `calendar/publish` missing its ``. +**Action:** fed the missing-class list back (P1-fix). + +## P1-fix — you missed 16 views + +### Prompt sent + +```text +Your modules/leave/api/urls.py is INCOMPLETE. It routes only 11 of 27 APIView classes. + +Missing these classes (from modules/leave/api/views.py, method args shown): + +class RequestDetailView(APIView): def get(self, request, pk) +class ReplacementSubstituteView(APIView): def post(self, request, pk) +class SubstitutionInboxView(APIView): def get(self, request) def post(self, request, pk) +class TaskInboxView(APIView): def get(self, request) +class UnitHeadReviewView(APIView): def post(self, request, pk) +class EstablishmentRouteView(APIView): def post(self, request, pk) +class FinalDecisionView(APIView): def post(self, request, pk) +class ResumptionReviewView(APIView): def post(self, request, pk) +class CancellationView(APIView): def post(self, request, pk) +class ExtensionView(APIView): def post(self, request, pk) +class ApprovedLeaveView(APIView): def get(self, request) +class AllRequestsView(APIView): def get(self, request) +class OfflineLeaveView(APIView): def post(self, request) +class VacationPeriodsView(APIView): def post(self, request) +class CalendarForYearView(APIView): def get(self, request) # takes ?year= query param, no pk + +Also your CalendarPublishView route is wrong: it is defined as +class CalendarPublishView(APIView): def post(self, request, pk) +so it MUST be path("calendar/publish/", ...) not path("calendar/publish", ...). + +Here is your previous answer: +```python +# urls.py +from django.urls import path +from modules.leave.api import views as v + +urlpatterns = [ + path("requests", v.MyRequestsView.as_view(), name="leave-requests"), + path("requests//withdraw", v.WithdrawView.as_view(), name="leave-withdraw"), + path("balances", v.MyBalancesView.as_view(), name="leave-balances"), + path("inbox", v.SubstitutionInboxView.as_view(), name="leave-inbox"), + path("resumption/", v.ResumptionView.as_view(), name="leave-resumption"), + path("resumption//review", v.ResumptionReviewView.as_view(), name="leave-resumption-review"), + path("policy", v.PolicyView.as_view(), name="leave-policy"), + path("calendar", v.CalendarListView.as_view(), name="leave-calendar"), + path("calendar/publish", v.CalendarPublishView.as_view(), name="leave-calendar-publish"), + path("year-end", v.YearEndView.as_view(), name="leave-year-end"), + path("sla-sweep", v.SlaSweepView.as_view(), name="leave-sla-sweep"), +] +``` + +```python +# settings/base.py +DOMAIN_MODULES = [ + "modules.placement", + "modules.leave", +] +``` + +```python +# config/urls.py +path("api/v1/leave/", include("modules.leave.api.urls")), +``` + + +Rules: no view may be missing; every path gets a name starting with "leave-"; +classes with (self, request, pk) get "/"; REST-ish kebab resource names; +SubstitutionInboxView needs TWO routes (GET list + POST action), decide a sensible +POST action path like substitutions/respond with . + +Reply with ONLY the complete corrected modules/leave/api/urls.py in one fenced code block. +``` + +### Verdict + +**Result:** 20 routes, but **invented** `SubstitutionRespondView` (does not exist) and dropped +`MyBalancesView`, `PolicyView`, `YearEndView`, `TaskInboxView`; routed `ApprovedLeaveView` with a +`` it does not accept. +**Action:** wrote an automated judge (`.ollama/judge_urls.py`) that diffs the generated urlconf +against the real `views.py`, then fed its exact errors back (P1-fix2). + +## P1-fix2 — automated-check errors + +### Prompt sent + +```text +Your modules/leave/api/urls.py failed an automated check with these errors: + +INVENTED view: SubstitutionRespondView does not exist in views.py +MISSING view: MyBalancesView is never routed +MISSING view: PolicyView is never routed +MISSING view: ReplacementSubstituteView is never routed +MISSING view: TaskInboxView is never routed +MISSING view: YearEndView is never routed +PK EXTRA: ApprovedLeaveView takes no pk but path "requests//approved" has + +Ground truth extracted from modules/leave/api/views.py: +- class MyBalancesView: get(request) -> GET /balances, name leave-balances +- class PolicyView: get(request), post(request) -> GET+POST /policy, name leave-policy +- class TaskInboxView: get(request) -> GET /inbox, name leave-task-inbox +- class YearEndView: post(request) -> POST /year-end, name leave-year-end +- class ReplacementSubstituteView: post(request, pk) -> POST /requests//replacement, name leave-replacement +- class ApprovedLeaveView: get(request) NO pk -> GET /approved-leaves, name leave-approved-leaves +- SubstitutionInboxView: get(request) + post(request, pk) -> GET /substitutions + POST /substitutions//respond + (SubstitutionRespondView does NOT exist - use SubstitutionInboxView for both routes) + +Your current version: +```python +# modules/leave/api/urls.py +from django.urls import path +from modules.leave.api import views as v + +urlpatterns = [ + path("requests", v.MyRequestsView.as_view(), name="leave-requests"), + path("requests//withdraw", v.WithdrawView.as_view(), name="leave-withdraw"), + path("requests//details", v.RequestDetailView.as_view(), name="leave-request-detail"), + path("requests//substitute", v.SubstitutionInboxView.as_view(), name="leave-substitution-inbox"), + path("requests//substitute/respond", v.SubstitutionRespondView.as_view(), name="leave-substitution-respond"), + path("requests//resumption", v.ResumptionView.as_view(), name="leave-resumption"), + path("requests//resumption/review", v.ResumptionReviewView.as_view(), name="leave-resumption-review"), + path("requests//unit-head-review", v.UnitHeadReviewView.as_view(), name="leave-unit-head-review"), + path("requests//establishment-route", v.EstablishmentRouteView.as_view(), name="leave-establishment-route"), + path("requests//final-decision", v.FinalDecisionView.as_view(), name="leave-final-decision"), + path("requests//cancellation", v.CancellationView.as_view(), name="leave-cancellation"), + path("requests//extension", v.ExtensionView.as_view(), name="leave-extension"), + path("requests//approved", v.ApprovedLeaveView.as_view(), name="leave-approved"), + path("requests/all", v.AllRequestsView.as_view(), name="leave-all-requests"), + path("offline-leave", v.OfflineLeaveView.as_view(), name="leave-offline-leave"), + path("vacation-periods", v.VacationPeriodsView.as_view(), name="leave-vacation-periods"), + path("calendar", v.CalendarListView.as_view(), name="leave-calendar"), + path("calendar/publish/", v.CalendarPublishView.as_view(), name="leave-calendar-publish"), + path("calendar/year/", v.CalendarForYearView.as_view(), name="leave-calendar-year"), + path("sla-sweep", v.SlaSweepView.as_view(), name="leave-sla-sweep"), +] +``` + + +Return ONLY the full corrected modules/leave/api/urls.py, one fenced code block. +Every one of these 24 classes must appear: MyRequestsView, MyBalancesView, +RequestDetailView, WithdrawView, ReplacementSubstituteView, SubstitutionInboxView, +TaskInboxView, UnitHeadReviewView, EstablishmentRouteView, FinalDecisionView, +ResumptionView, ResumptionReviewView, CancellationView, ExtensionView, +ApprovedLeaveView, AllRequestsView, OfflineLeaveView, PolicyView, CalendarListView, +CalendarPublishView, VacationPeriodsView, SlaSweepView, YearEndView, CalendarForYearView. +``` + +### Verdict + +**Result:** 24/24 views routed; one `PK EXTRA` on `ApprovedLeaveView` remained. +**Action:** one surgical removal round (P1-fix3). + +## P1-fix3 — delete the one bad line + +### Prompt sent + +```text +Your modules/leave/api/urls.py has exactly ONE remaining error: + +PK EXTRA: ApprovedLeaveView takes no pk but path "requests//approved" has + +class ApprovedLeaveView has only: def get(self, request) -- no pk parameter. +The route must not contain . + +Current file: +```python +# modules/leave/api/urls.py +from django.urls import path +from modules.leave.api import views as v + +urlpatterns = [ + path("requests", v.MyRequestsView.as_view(), name="leave-requests"), + path("requests//withdraw", v.WithdrawView.as_view(), name="leave-withdraw"), + path("requests//details", v.RequestDetailView.as_view(), name="leave-request-detail"), + path("requests//substitute", v.SubstitutionInboxView.as_view(), name="leave-substitution-inbox"), + path("requests//substitute/respond", v.SubstitutionInboxView.as_view(), name="leave-substitution-respond"), + path("requests//resumption", v.ResumptionView.as_view(), name="leave-resumption"), + path("requests//resumption/review", v.ResumptionReviewView.as_view(), name="leave-resumption-review"), + path("requests//unit-head-review", v.UnitHeadReviewView.as_view(), name="leave-unit-head-review"), + path("requests//establishment-route", v.EstablishmentRouteView.as_view(), name="leave-establishment-route"), + path("requests//final-decision", v.FinalDecisionView.as_view(), name="leave-final-decision"), + path("requests//cancellation", v.CancellationView.as_view(), name="leave-cancellation"), + path("requests//extension", v.ExtensionView.as_view(), name="leave-extension"), + path("requests//approved", v.ApprovedLeaveView.as_view(), name="leave-approved"), + path("requests/all", v.AllRequestsView.as_view(), name="leave-all-requests"), + path("offline-leave", v.OfflineLeaveView.as_view(), name="leave-offline-leave"), + path("vacation-periods", v.VacationPeriodsView.as_view(), name="leave-vacation-periods"), + path("calendar", v.CalendarListView.as_view(), name="leave-calendar"), + path("calendar/publish/", v.CalendarPublishView.as_view(), name="leave-calendar-publish"), + path("calendar/year/", v.CalendarForYearView.as_view(), name="leave-calendar-year"), + path("sla-sweep", v.SlaSweepView.as_view(), name="leave-sla-sweep"), + path("policy", v.PolicyView.as_view(), name="leave-policy"), + path("balances", v.MyBalancesView.as_view(), name="leave-balances"), + path("inbox", v.TaskInboxView.as_view(), name="leave-task-inbox"), + path("year-end", v.YearEndView.as_view(), name="leave-year-end"), + path("replacement/", v.ReplacementSubstituteView.as_view(), name="leave-replacement"), + path("approved-leaves", v.ApprovedLeaveView.as_view(), name="leave-approved-leaves"), +] +``` + + +Return ONLY the full corrected modules/leave/api/urls.py, one fenced code block, +changing nothing else. +``` + +### Verdict + +**Result:** the model **added** the correct `approved-leaves` route but did not remove the bad +`requests//approved` one. +**Action:** retry (P1-fix4). + +## P1-fix4 — regenerate with the file removed (FAILED) + +### Prompt sent + +```text +Delete exactly this one line from your urlpatterns list: + + path("requests//approved", v.ApprovedLeaveView.as_view(), name="leave-approved"), + +Reason: ApprovedLeaveView.get(self, request) has no pk, so that route crashes. +The line "path("approved-leaves", v.ApprovedLeaveView.as_view(), name="leave-approved-leaves")," already covers it. + +Output the ENTIRE corrected modules/leave/api/urls.py in one fenced code block. Change nothing else. No explanation. +``` + +### Verdict + +**Result:** collapsed — 157 tokens, invented `ApproveLeaveView`, `RejectLeaveView`, +`RequestDeleteView`, `RequestEditView`; only 2/24 routed. Cause: the prompt contained no file +contents, so the model hallucinated from nothing. +**Lesson:** never ask a small model to edit a file that is not in the prompt. +**Action:** retried with the full file embedded (P1-fix5). + +## P1-fix5 — full file embedded, remove one line + +### Prompt sent + +```text +Here is modules/leave/api/urls.py: + +```python +# modules/leave/api/urls.py +from django.urls import path +from modules.leave.api import views as v + +urlpatterns = [ + path("requests", v.MyRequestsView.as_view(), name="leave-requests"), + path("requests//withdraw", v.WithdrawView.as_view(), name="leave-withdraw"), + path("requests//details", v.RequestDetailView.as_view(), name="leave-request-detail"), + path("requests//substitute", v.SubstitutionInboxView.as_view(), name="leave-substitution-inbox"), + path("requests//substitute/respond", v.SubstitutionInboxView.as_view(), name="leave-substitution-respond"), + path("requests//resumption", v.ResumptionView.as_view(), name="leave-resumption"), + path("requests//resumption/review", v.ResumptionReviewView.as_view(), name="leave-resumption-review"), + path("requests//unit-head-review", v.UnitHeadReviewView.as_view(), name="leave-unit-head-review"), + path("requests//establishment-route", v.EstablishmentRouteView.as_view(), name="leave-establishment-route"), + path("requests//final-decision", v.FinalDecisionView.as_view(), name="leave-final-decision"), + path("requests//cancellation", v.CancellationView.as_view(), name="leave-cancellation"), + path("requests//extension", v.ExtensionView.as_view(), name="leave-extension"), + path("requests//approved", v.ApprovedLeaveView.as_view(), name="leave-approved"), + path("requests/all", v.AllRequestsView.as_view(), name="leave-all-requests"), + path("offline-leave", v.OfflineLeaveView.as_view(), name="leave-offline-leave"), + path("vacation-periods", v.VacationPeriodsView.as_view(), name="leave-vacation-periods"), + path("calendar", v.CalendarListView.as_view(), name="leave-calendar"), + path("calendar/publish/", v.CalendarPublishView.as_view(), name="leave-calendar-publish"), + path("calendar/year/", v.CalendarForYearView.as_view(), name="leave-calendar-year"), + path("sla-sweep", v.SlaSweepView.as_view(), name="leave-sla-sweep"), + path("policy", v.PolicyView.as_view(), name="leave-policy"), + path("balances", v.MyBalancesView.as_view(), name="leave-balances"), + path("inbox", v.TaskInboxView.as_view(), name="leave-task-inbox"), + path("year-end", v.YearEndView.as_view(), name="leave-year-end"), + path("replacement/", v.ReplacementSubstituteView.as_view(), name="leave-replacement"), + path("approved-leaves", v.ApprovedLeaveView.as_view(), name="leave-approved-leaves"), +] +``` + + +One line in it is invalid: + path("requests//approved", v.ApprovedLeaveView.as_view(), name="leave-approved"), +because ApprovedLeaveView.get(self, request) accepts no pk. The route +path("approved-leaves", v.ApprovedLeaveView.as_view(), name="leave-approved-leaves") +already serves that view. + +Produce the same file with ONLY that invalid line removed from urlpatterns. +Output the entire file in one fenced code block. Do not add, rename, or reword anything. +``` + +### Verdict + +**Result:** reproduced the same bad line unchanged. +**Action:** switched tactics — gave the model the complete **validated route table** and asked for a +faithful transcription (P1-regen). + +## P1-regen — transcribe the validated route table + +### Prompt sent + +```text +Write the file modules/leave/api/urls.py for a Django REST Framework app. +Use exactly this contract - every line is already verified, transcribe it faithfully: + +from django.urls import path +from modules.leave.api import views as v + +urlpatterns = [ + path("requests", v.MyRequestsView.as_view(), name="leave-requests"), + path("requests/all", v.AllRequestsView.as_view(), name="leave-all-requests"), + path("requests/", v.RequestDetailView.as_view(), name="leave-request"), + path("requests//withdraw", v.WithdrawView.as_view(), name="leave-withdraw"), + path("requests//replacement", v.ReplacementSubstituteView.as_view(), name="leave-replacement"), + path("requests//review", v.UnitHeadReviewView.as_view(), name="leave-unit-head-review"), + path("requests//route", v.EstablishmentRouteView.as_view(), name="leave-establishment-route"), + path("requests//decide", v.FinalDecisionView.as_view(), name="leave-final-decision"), + path("requests//cancellation", v.CancellationView.as_view(), name="leave-cancellation"), + path("requests//extension", v.ExtensionView.as_view(), name="leave-extension"), + path("requests//resumption", v.ResumptionView.as_view(), name="leave-resumption"), + path("requests//resumption/review", v.ResumptionReviewView.as_view(), name="leave-resumption-review"), + path("substitutions", v.SubstitutionInboxView.as_view(), name="leave-substitutions"), + path("substitutions//respond", v.SubstitutionInboxView.as_view(), name="leave-substitution-respond"), + path("approved-leaves", v.ApprovedLeaveView.as_view(), name="leave-approved-leaves"), + path("balances", v.MyBalancesView.as_view(), name="leave-balances"), + path("inbox", v.TaskInboxView.as_view(), name="leave-inbox"), + path("offline-leave", v.OfflineLeaveView.as_view(), name="leave-offline-leave"), + path("policy", v.PolicyView.as_view(), name="leave-policy"), + path("calendar", v.CalendarListView.as_view(), name="leave-calendar"), + path("calendar/publish/", v.CalendarPublishView.as_view(), name="leave-calendar-publish"), + path("calendar/year/", v.CalendarForYearView.as_view(), name="leave-calendar-year"), + path("vacation-periods", v.VacationPeriodsView.as_view(), name="leave-vacation-periods"), + path("sla-sweep", v.SlaSweepView.as_view(), name="leave-sla-sweep"), + path("year-end", v.YearEndView.as_view(), name="leave-year-end"), +] + +Reproduce this file EXACTLY, character for character, in one fenced code block. No changes, no explanation. +``` + +### Verdict + +**Result:** 24/24 views routed, correct names, correct pk usage. **PASS** (after one judge bug +was fixed: a class with a no-pk list route plus a pk action route legitimately has both shapes). +**Action:** wrote the file, added `modules.leave` to `DOMAIN_MODULES`, mounted `api/v1/leave/`. + +## P2b — add the missing domain `ValidationError` to the shared error module + +### Prompt sent + +```text +This is the SHARED platform error module core/api/exceptions.py: + +"""One error envelope for the whole platform: + + {"error": {"code": ..., "message": ..., "details": [...], "request_id": ...}} + +`request_id` is always present, so a user reads it off a toast and support +greps one id across the logs. +""" +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import exception_handler as drf_handler + +from core.observability.middleware import get_request_id + + +class DomainError(Exception): + """Base for errors raised by domain/ and services/. + + Deliberately not a DRF exception — a service must stay callable from a + Celery task or a management command, where HTTP status is noise. + """ + + code = "domain_error" + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + message = "The request could not be processed." + + def __init__(self, message=None, *, code=None, details=None): + super().__init__(message or self.message) + self.message = message or self.message + self.code = code or self.code + self.details = details or [] + + +class BadRequestError(DomainError): + """The request itself is malformed — a non-numeric id, an unparseable + filter. Distinct from ConflictError's 409, which means the request was + well-formed and the resource refused it.""" + + code = "bad_request" + status_code = status.HTTP_400_BAD_REQUEST + message = "The request could not be understood." + + +class NotFoundError(DomainError): + code = "not_found" + status_code = status.HTTP_404_NOT_FOUND + message = "Not found." + + +class ConflictError(DomainError): + code = "conflict" + status_code = status.HTTP_409_CONFLICT + message = "The resource is not in a state that allows this." + + +class AuthenticationFailedError(DomainError): + """A credential was rejected: 401 "we do not know who you are", as opposed + to PermissionDeniedError's 403 "we know, and no". Collapsing the two makes + an auth outage indistinguishable from a misconfigured grant in the logs.""" + + code = "invalid_credentials" + status_code = status.HTTP_401_UNAUTHORIZED + message = "Incorrect username or password." + + +class PermissionDeniedError(DomainError): + code = "permission_denied" + status_code = status.HTTP_403_FORBIDDEN + message = "You do not have permission to perform this action." + + +def _envelope(code, message, details, http_status): + return Response( + { + "error": { + "code": code, + "message": message, + "details": details or [], + "request_id": get_request_id(), + } + }, + status=http_status, + ) + + +def exception_handler(exc, context): + if isinstance(exc, DomainError): + return _envelope(exc.code, exc.message, exc.details, exc.status_code) + + response = drf_handler(exc, context) + if response is None: + return None # let Django produce the 500 + + detail = response.data + details = [] + if isinstance(detail, dict) and "detail" not in detail: + details = [ + {"field": f, "message": m[0] if isinstance(m, list) else str(m)} + for f, m in detail.items() + ] + message = "The submitted data was not valid." + code = "validation_error" + else: + message = str(detail.get("detail")) if isinstance(detail, dict) else str(detail) + code = getattr(exc, "default_code", "error") + return _envelope(code, message, details, response.status_code) + + +The leave module (modules/leave/services/*.py, 4 files) does: + from core.api.exceptions import ConflictError, NotFoundError, ValidationError +and raises ValidationError("...", code="...") for business-rule violations +(e.g. an extension requested on leave that is not ongoing). + +There is NO ValidationError in this module, so the import crashes: + ImportError: cannot import name 'ValidationError' from 'core.api.exceptions' + +Add a ValidationError class to core/api/exceptions.py that: +- subclasses DomainError (so the existing exception_handler maps it automatically) +- has code = "validation_error" (matching the code the handler already emits for DRF validation errors, line ~101) +- has status_code = status.HTTP_422_UNPROCESSABLE_ENTITY +- has message = "The submitted data was not valid." +- has a short docstring explaining it is a well-formed request that violates a + business rule (distinct from BadRequestError's malformed request and ConflictError's + wrong-state 409) +Place it directly after the BadRequestError class, before NotFoundError. + +Output ONLY the COMPLETE modified core/api/exceptions.py in one fenced code block. +Change nothing else in the file. +``` + +### Verdict + +**Result:** added `class ValidationError(DomainError)` with `code="validation_error"`, 422, +placed after `BadRequestError`. Diff verified to contain **only** that addition. **PASS** +(`core/api/exceptions.py`). + +## P3 — audit `domain/categories.py` against BR-EL-007/011/012 + +### Prompt sent + +```text +The Django system check fails on this file modules/leave/models.py with 6 errors: + + leave.LeaveBalance.category: (fields.E005) 'choices' must be a mapping of actual + values to human readable names or an iterable containing (actual value, human + readable name) tuples. + leave.LeaveRequest.category: (fields.E005) same + leave.LeaveTransaction.category: (fields.E005) same + leave.LeaveRequest: (models.E012) 'indexes' refers to the nonexistent field 'created_at' + leave.LeaveRequest: (models.E012) 'indexes' refers to the nonexistent field 'created_at' + leave.LeaveRequest: (models.E015) 'ordering' refers to the nonexistent field 'created_at' + +Root causes: +1. `choices=cat.CATEGORIES` is wrong because modules/leave/domain/categories.py defines + CATEGORIES as a tuple of bare strings: ("cl","rh","scl","el","col","vl"). + That same module also defines LABELS = {"cl": "Casual Leave", "rh": "Restricted Holiday", + "scl": "Special Casual Leave", "el": "Earned Leave", "col": "Commuted Leave", + "vl": "Vacation Leave"}. +2. core/db/mixins.py defines two SEPARATE abstract models: + class TimeStampedModel(models.Model): created_at, updated_at + class UserScopedModel(models.Model): user_id + class LeaveRequest(UserScopedModel) therefore has NO created_at, yet its Meta + ordering and indexes use "created_at". + +Fix ONLY these, in this file: +A) Add near the top, after the KIND_CHOICES definition: + CATEGORY_CHOICES = [(code, cat.LABELS[code]) for code in cat.CATEGORIES] + and replace ALL THREE occurrences of `choices=cat.CATEGORIES` with + `choices=CATEGORY_CHOICES`. +B) Change the LeaveRequest class declaration from + class LeaveRequest(UserScopedModel): + to + class LeaveRequest(UserScopedModel, TimeStampedModel): + so it gets created_at. TimeStampedModel is already imported at the top. + Do NOT change any other model's base classes. + +Here is the complete current file: + +"""Employee Leave Management tables. + +Institute people are referenced by plain `user_id` integers, never foreign +keys — there is no user table in this database, and a cross-boundary FK would +make this module impossible to lift out later (ADR-0013). The applicant's +employee kind, department and name are read through `directory's` contracts. + +No business logic and no `save()` override: `bulk_update` and `queryset.update()` +bypass both anyway, so the rules live in `domain/` and the writes in +`services/`. +""" +from __future__ import annotations + +from decimal import Decimal + +from django.db import models +from django.db.models import Q + +from core.db.mixins import TimeStampedModel, UserScopedModel +from modules.leave.domain import categories as cat +from modules.leave.domain.state_machine import ( + DRAFT, + KIND_CANCELLATION, + KIND_EXTENSION, + KIND_NEW, + KIND_RESUMPTION, + STATES, +) + +#: Status and kind choices are read off the state machine, so a state added +#: there cannot be missing here. +STATUS_CHOICES = [(s, s.replace("_", " ")) for s in sorted(STATES)] +KIND_CHOICES = [ + (KIND_NEW, "New leave"), + (KIND_EXTENSION, "Extension of ongoing leave"), + (KIND_CANCELLATION, "Cancellation of approved leave"), + (KIND_RESUMPTION, "Resumption of duty"), +] + +#: Decisions recorded against a request, at whatever stage made them. +DECISIONS = [ + ("approve", "Approve"), + ("reject", "Reject"), + ("recommended", "Recommended"), + ("not_recommended", "Not recommended"), + ("accept", "Substitute accepted"), + ("decline", "Substitute declined"), +] + + +class LeavePolicy(TimeStampedModel): + """The current configured leave-policy parameters (BR-EL-030). + + One row is current at a time; there is no draft/publish/effective-version + lifecycle in this baseline, so validation simply reads the latest row. + """ + + label = models.CharField(max_length=60, default="Leave policy") + year_label = models.CharField(max_length=9) # "2026-27" + starts_on = models.DateField() + ends_on = models.DateField() + + # Annual entitlements, per eligible employee (BR-EL-002..009). + cl_days = models.PositiveSmallIntegerField(default=8) + rh_days = models.PositiveSmallIntegerField(default=2) + scl_days = models.PositiveSmallIntegerField(default=15) + el_days = models.PositiveSmallIntegerField(default=30) + vl_days = models.PositiveSmallIntegerField(default=60) + col_days = models.PositiveSmallIntegerField(default=20) + + #: Unused VL converts to EL at 2 VL : 1 EL, fractions included. + vl_per_el = models.PositiveSmallIntegerField(default=2) + + #: The higher-sanction path, as designations (BR-EL-018/019). + authority_path = models.JSONField(default=list, blank=True) + establishment_designation = models.CharField(max_length=60, blank=True) + establishment_step_enabled = models.BooleanField(default=False) + + #: SLA thresholds for actionable tasks (BR-EL-038). + reminder_hours = models.PositiveSmallIntegerField(default=48) + escalation_hours = models.PositiveSmallIntegerField(default=120) + + #: The year-end boundary EVT-01 fires on. + year_end_on = models.DateField() + + class Meta: + db_table = "leave_policy" + ordering = ["-id"] + verbose_name_plural = "leave policies" + + def __str__(self) -> str: + return f"{self.label} ({self.year_label})" + + @classmethod + def current(cls) -> LeavePolicy: + """The configured values in force right now.""" + return cls.objects.order_by("-id").first() + + @property + def hierarchy(self): + from modules.leave.domain.routing import Hierarchy + + return Hierarchy( + authority_path=tuple(self.authority_path or ()), + establishment_designation=self.establishment_designation, + establishment_step_enabled=self.establishment_step_enabled) + + def entitlement(self, code: str) -> int: + return { + cat.CL: self.cl_days, cat.RH: self.rh_days, cat.SCL: self.scl_days, + cat.EL: self.el_days, cat.VL: self.vl_days, cat.COL: self.col_days, + }.get(code, 0) + + +class OrganizationalUnit(TimeStampedModel): + """A department, section or office and its head (ACT-03). + + Leave-owned configuration, because the Unit Head is who this module routes + to — the IAM directory knows a person's department but not who heads it. + """ + + name = models.CharField(max_length=120, unique=True) + head_user_id = models.IntegerField(db_index=True) + + class Meta: + db_table = "leave_org_unit" + ordering = ["name"] + + def __str__(self) -> str: + return self.name + + +class LeaveBalance(UserScopedModel): + """One employee's account for one category in one leave year. + + `opening` is what carried forward, `credited` is what the year added + (annual entitlement, or EL produced by VL conversion), `used` is what has + been consumed. Every change writes a LeaveTransaction, so the account can + always be explained rather than merely trusted. + """ + + year_label = models.CharField(max_length=9) + category = models.CharField(max_length=8, choices=cat.CATEGORIES) + opening = models.DecimalField(max_digits=6, decimal_places=2, + default=Decimal("0")) + credited = models.DecimalField(max_digits=6, decimal_places=2, + default=Decimal("0")) + used = models.DecimalField(max_digits=6, decimal_places=2, + default=Decimal("0")) + + class Meta: + db_table = "leave_balance" + constraints = [ + models.UniqueConstraint(fields=["user_id", "year_label", "category"], + name="leave_balance_unique"), + ] + indexes = [ + models.Index(fields=["user_id", "year_label"], + name="leave_balance_owner_idx"), + ] + + def __str__(self) -> str: + return f"{self.user_id} {self.category} {self.year_label}" + + @property + def available(self) -> Decimal: + return self.opening + self.credited - self.used + + +class LeaveRequest(UserScopedModel): + """A submitted leave-related request. + + One table for four kinds of request — new leave, extension, cancellation, + resumption — because they share one state machine and differ only in what a + final decision does to the leave they point at. That is also what makes + "the approved leave is not edited in place" structural: an extension is a + separate row that carries the incremental period, and the original stays + exactly as it was sanctioned. + """ + + kind = models.CharField(max_length=12, choices=KIND_CHOICES, default=KIND_NEW) + category = models.CharField(max_length=8, choices=cat.CATEGORIES) + status = models.CharField(max_length=32, choices=STATUS_CHOICES, default=DRAFT) + + from_date = models.DateField() + to_date = models.DateField() + #: Set for a half-day CL (BR-EL-010). + half = models.CharField(max_length=6, blank=True, + choices=[(h, h) for h in cat.HALVES]) + days = models.DecimalField(max_digits=6, decimal_places=2) + + purpose = models.TextField(max_length=1000) + + #: Station Leave details, captured inside the same application (BR-EL-013). + station_leave_required = models.BooleanField(default=False) + station_leave_details = models.CharField(max_length=300, blank=True) + + #: The approved leave this request acts on. + parent = models.ForeignKey("self", on_delete=models.PROTECT, null=True, + blank=True, related_name="amendments") + + #: Routing, frozen at submission so a configuration change cannot move a + #: request that is already halfway through the hierarchy. + unit_head_user_id = models.IntegerField(null=True, blank=True, db_index=True) + final_authority = models.CharField(max_length=60, blank=True) + establishment_first = models.BooleanField(default=False) + self_sanction = models.BooleanField(default=False) + #: The applicant's designations, as they were when the request was raised. + #: Frozen because the Director-specific rule (BR-EL-020) is about who the + #: applicant IS, and a later role change must not move a live request. + applicant_roles = models.JSONField(default=list, blank=True) + + #: Responsibility coverage (BR-EL-014/015). + substitute_user_id = models.IntegerField(null=True, blank=True, db_index=True) + substitute_response = models.CharField(max_length=12, blank=True) + substitute_responded_at = models.DateTimeField(null=True, blank=True) + + #: Offline-sanctioned leave recorded after the fact (EL-UC-016). + is_offline = models.BooleanField(default=False) + sanction_reference = models.CharField(max_length=120, blank=True) + sanctioned_on = models.DateField(null=True, blank=True) + offline_reason = models.CharField(max_length=300, blank=True) + + #: The final decision, once there is one. + decision = models.CharField(max_length=20, blank=True, choices=DECISIONS) + decision_remarks = models.CharField(max_length=300, blank=True) + decided_by_user_id = models.IntegerField(null=True, blank=True) + decided_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "leave_request" + ordering = ["-created_at"] + constraints = [ + models.CheckConstraint(condition=Q(to_date__gte=models.F("from_date")), + name="leave_request_period_valid"), + models.CheckConstraint(condition=Q(days__gte=0), + name="leave_request_days_non_negative"), + models.CheckConstraint( + condition=Q(half="") | Q(half__in=list(cat.HALVES)), + name="leave_request_half_valid"), + ] + indexes = [ + models.Index(fields=["user_id", "status", "-created_at"], + name="leave_request_owner_idx"), + models.Index(fields=["status", "category", "-created_at"], + name="leave_request_queue_idx"), + models.Index(fields=["substitute_user_id", "status"], + name="leave_request_substitute_idx"), + ] + + def __str__(self) -> str: + return f"{self.kind} {self.category} {self.from_date}..{self.to_date}" + + @property + def is_pending(self) -> bool: + from modules.leave.domain.state_machine import is_pending + + return is_pending(self.status) + + @property + def interval(self) -> cat.Interval: + return cat.Interval(self.from_date, self.to_date, self.half or None) + + +class LeaveApproval(TimeStampedModel): + """Append-only record of every decision taken on a request. + + Never updated, never deleted: the audit trail is the only place the + recommendation history survives, and "Not Recommended is not a final + rejection" is only defensible if the recommendation is still on file. + """ + + STAGES = [ + ("employee", "Employee"), + ("substitute", "Substitute consent"), + ("unit_head", "Unit Head"), + ("establishment", "Establishment"), + ("final", "Final sanctioning authority"), + ("self_sanction", "Director self-sanction"), + ] + + request = models.ForeignKey(LeaveRequest, on_delete=models.CASCADE, + related_name="approvals") + stage = models.CharField(max_length=16, choices=STAGES) + decision = models.CharField(max_length=20, choices=DECISIONS) + actor_user_id = models.IntegerField(null=True, blank=True) + remarks = models.CharField(max_length=300, blank=True) + + class Meta: + db_table = "leave_approval" + ordering = ["created_at"] + indexes = [ + models.Index(fields=["request", "created_at"], + name="leave_approval_request_idx"), + ] + + def __str__(self) -> str: + return f"{self.stage}: {self.decision}" + + +class LeaveTask(TimeStampedModel): + """An actionable workflow task, and the SLA clock that runs against it. + + Created when a request reaches a stage that needs a person, and superseded + the moment it moves on — so "no escalation if the task is no longer pending" + is a query against this table rather than a promise (BR-EL-032/033). + """ + + STATUS = [("pending", "Pending"), ("completed", "Completed"), + ("superseded", "Superseded"), ("cancelled", "Cancelled")] + + request = models.ForeignKey(LeaveRequest, on_delete=models.CASCADE, + related_name="tasks") + stage = models.CharField(max_length=16, choices=LeaveApproval.STAGES) + assignee_user_id = models.IntegerField(db_index=True) + status = models.CharField(max_length=12, choices=STATUS, default="pending") + + assigned_at = models.DateTimeField(auto_now_add=True) + actioned_at = models.DateTimeField(null=True, blank=True) + #: SLA deadlines, stamped from the policy at assignment time. + reminder_due_at = models.DateTimeField(null=True, blank=True) + escalation_due_at = models.DateTimeField(null=True, blank=True) + reminded_at = models.DateTimeField(null=True, blank=True) + escalated_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "leave_task" + ordering = ["-created_at"] + indexes = [ + # The reminder sweep's query: pending tasks, oldest deadline first. + models.Index(fields=["status", "reminder_due_at"], + name="leave_task_sla_idx"), + models.Index(fields=["assignee_user_id", "status"], + name="leave_task_assignee_idx"), + ] + + def __str__(self) -> str: + return f"{self.stage} -> {self.assignee_user_id} ({self.status})" + + +class LeaveTransaction(TimeStampedModel): + """The account ledger. Every balance change is a row here. + + A balance that cannot explain itself is a balance nobody can audit, which + matters most at year end, when lapse, conversion and carry-forward all land + on the same evening. + """ + + KINDS = [ + ("annual_credit", "Annual entitlement credited"), + ("carry_forward", "Carried forward from the previous year"), + ("approval", "Deducted for approved leave"), + ("cancellation_reversal", "Restored by an approved cancellation"), + ("extension_post", "Incremental period posted by an extension"), + ("resumption_adjustment", "Adjustment after resumption review"), + ("year_end_lapse", "Lapsed at the year-end boundary"), + ("year_end_conversion", "Converted to another category"), + ("offline_entry", "Offline-sanctioned leave recorded"), + ] + + user_id = models.IntegerField(db_index=True) + year_label = models.CharField(max_length=9) + category = models.CharField(max_length=8, choices=cat.CATEGORIES) + kind = models.CharField(max_length=24, choices=KINDS) + #: Signed: positive credits the account, negative consumes it. + days = models.DecimalField(max_digits=6, decimal_places=2) + request = models.ForeignKey(LeaveRequest, on_delete=models.SET_NULL, + null=True, blank=True, related_name="transactions") + note = models.CharField(max_length=200, blank=True) + + class Meta: + db_table = "leave_transaction" + ordering = ["created_at"] + indexes = [ + models.Index(fields=["user_id", "year_label", "category"], + name="leave_txn_account_idx"), + ] + + def __str__(self) -> str: + return f"{self.kind} {self.days} {self.category}" + + +class LeaveCalendar(TimeStampedModel): + """The holiday/RH calendar for a leave year (EL-UC-015). + + Until a replacement is PUBLISHED, the currently effective one governs date + and RH validation (BR-EL-031) — so "effective" is a property of what is + published, never of what is being drafted. + """ + + year_label = models.CharField(max_length=9, unique=True) + label = models.CharField(max_length=120, blank=True) + published_at = models.DateTimeField(null=True, blank=True) + published_by_user_id = models.IntegerField(null=True, blank=True) + + class Meta: + db_table = "leave_calendar" + ordering = ["-year_label"] + + def __str__(self) -> str: + return f"{self.year_label}{' (published)' if self.published_at else ' (draft)'}" + + +class CalendarEntry(TimeStampedModel): + """One day in a calendar: a closed holiday, a Restricted Holiday, or a + vacation period from the Academic Calendar (EXT-01).""" + + KINDS = [ + ("closed_holiday", "Closed holiday"), + ("restricted_holiday", "Restricted holiday"), + ("vacation", "Vacation period"), + ] + + calendar = models.ForeignKey(LeaveCalendar, on_delete=models.CASCADE, + related_name="entries") + date = models.DateField() + #: Inclusive; only used by a vacation period, which spans many days. + ends_on = models.DateField(null=True, blank=True) + kind = models.CharField(max_length=20, choices=KINDS) + label = models.CharField(max_length=160, blank=True) + + class Meta: + db_table = "leave_calendar_entry" + ordering = ["date"] + constraints = [ + models.UniqueConstraint(fields=["calendar", "date", "kind"], + name="leave_calendar_entry_unique"), + ] + indexes = [ + models.Index(fields=["calendar", "kind", "date"], + name="leave_calendar_lookup_idx"), + ] + + def __str__(self) -> str: + return f"{self.date} {self.kind}" + + +class LeaveNotification(TimeStampedModel): + """A notification this module decided to send. + + Written inside the same transaction as the decision that caused it, so a + notification can never claim something that was rolled back. The delivery + worker is not part of this change — see the module doc's list of gaps. + """ + + STATUS = [("pending", "Pending"), ("sent", "Sent"), ("failed", "Failed")] + + topic = models.CharField(max_length=60, db_index=True) + dedupe_key = models.CharField(max_length=200, unique=True) + recipient_user_id = models.IntegerField(db_index=True) + subject = models.CharField(max_length=200) + body = models.TextField(blank=True) + status = models.CharField(max_length=12, choices=STATUS, default="pending") + request = models.ForeignKey(LeaveRequest, on_delete=models.CASCADE, + null=True, blank=True, related_name="notifications") + + class Meta: + db_table = "leave_notification" + ordering = ["created_at"] + + def __str__(self) -> str: + return f"{self.topic} -> {self.recipient_user_id}" + + +Output ONLY the complete corrected modules/leave/models.py in one fenced code block. +Make no other changes. +``` + +### Verdict + +**Result:** this large prompt (whole file embedded) **timed out** — no output. +**Lesson:** the full 462-line file plus instruction exceeded the practical budget for the 7B. +**Action:** split into small outputs (P3-first, then P3b). + +## P3b — focused: the three lines to change + +### Prompt sent + +```text +Django model file modules/leave/models.py has these errors: + fields.E005 on category fields: 'choices' must be an iterable of (value, label) tuples. + models.E012/E015 on LeaveRequest: ordering/indexes refer to nonexistent field 'created_at'. + +Context facts: +- modules/leave/domain/categories.py defines: + CATEGORIES = ("cl", "rh", "scl", "el", "col", "vl") # bare strings, NOT tuples + LABELS = {"cl": "Casual Leave", "rh": "Restricted Holiday", "scl": "Special Casual Leave", + "el": "Earned Leave", "col": "Commuted Leave", "vl": "Vacation Leave"} +- core/db/mixins.py defines two SEPARATE abstract models: + class TimeStampedModel(models.Model): created_at, updated_at + class UserScopedModel(models.Model): user_id +- models.py already imports TimeStampedModel at the top. +- models.py currently declares: class LeaveRequest(UserScopedModel): +- Three fields use: choices=cat.CATEGORIES + +Give me EXACTLY these three outputs and nothing else: + +1) One line to add after the KIND_CHOICES block that builds a proper Django choices + list from cat.CATEGORIES and cat.LABELS, named CATEGORY_CHOICES. + +2) The exact new class declaration line for LeaveRequest that also gives it created_at. + +3) A sed-style instruction list to replace the three `choices=cat.CATEGORIES` with + the new CATEGORY_CHOICES name (just say: replace every occurrence of X with Y). + +Output each as a short labelled snippet. No code blocks except plain lines. +``` + +### Verdict + +**Result:** correct in intent but used unqualified names and a loop variable that shadowed the +`cat` module import: `[(cat, LABELS[cat]) for cat in CATEGORIES]`. +**Action:** judged wrong; corrected to +`CATEGORY_CHOICES = [(code, cat.LABELS[code]) for code in cat.CATEGORIES]` before applying. + +## P3-first — verdict on each BR (round 1) + +### Prompt sent + +```text +You are auditing modules/leave/domain/categories.py against the official spec. +Each rule's authoritative statement is given. Find every place the code contradicts it, +and give the corrected line(s). Cite the rule number for each finding. + +--- BR-EL-007 (VL-to-EL year-end conversion) --- +Spec: "At year end, all unused faculty VL shall convert to EL at 2 VL : 1 EL including +fractional conversion, and VL itself shall not carry forward." +"Convert at 2:1 including fractions." +Code: +def year_end_action(code, available, *, vl_per_el=2): + if available <= 0: + return YearEndAction("none", available) + c = category(code) + if code == VL: + return YearEndAction(CONVERT, available * Decimal(vl_per_el), target=EL) + if c.carries_forward: + return YearEndAction(CARRY_FORWARD, available) + return YearEndAction(LAPSE, available) + +--- BR-EL-011 (Continuous-day counting) --- +Spec: "For EL, COL, and VL, intervening weekends and closed holidays inside the continuous +approved interval count as leave days; this rule does not apply to CL, SCL, or RH." +(So for CL/SCL/RH the weekends must be REMOVED from the count. Both weekend days.) +Code: +def weekend_dates(start, end): + """Saturday and Sunday inside the interval, inclusive.""" + out, d = set(), start + while d <= end: + if d.weekday() > 5: + out.add(d) + d = date.fromordinal(d.toordinal() + 1) + return out +# used as: duty = span_days(start, end) - len(weekend_dates(start, end) | set(closed_dates)) + +--- BR-EL-012 (Applicant non-overlap with half-day CL exception) --- +Spec: "first-half and second-half CL on the same date are non-overlapping when they occupy +different halves." +Code: + def overlaps(self, other): + """BR-EL-012.""" + if self.end < other.start or other.end < self.start: + return False + same_day = (self.start == self.end == other.start == other.end) + if same_day and self.half and other.half and self.half == other.half: + return False + return True + +Python: date.weekday() returns 0=Monday .. 5=Saturday, 6=Sunday. + +For EACH of the three functions output exactly: + RULE:
+ VERDICT: correct | contradicts spec + WRONG LINE: + CORRECT LINE: + CONSEQUENCE: + +If a function is correct, say so. No other prose. +``` + +### Verdict + +**Result:** found **1 of 3** — correctly flagged BR-EL-011 (`> 5` should be `>= 5`), but +declared BR-EL-007 and BR-EL-012 "correct" when both contradict the spec. +**Action:** two pointed counter-questions (P3-second). + +## P3-second — counter-questions on the two misses + +### Prompt sent + +```text +Two of your verdicts were wrong. Answer these two questions precisely. + +QUESTION 1 (BR-EL-007): +The spec says VL converts at "2 VL : 1 EL". That means 2 VL become 1 EL, so 10 unused VL +must become 5 EL. +The code computes: available * Decimal(vl_per_el) with vl_per_el=2 +With available=10 that returns 20, but the spec requires 5. +a) VERDICT: what is wrong? +b) CORRECT LINE: give the exact corrected Python return line. +c) CONSEQUENCE: state what 10 VL wrongly produces and what it should produce. + +QUESTION 2 (BR-EL-012): +Spec: "first-half and second-half CL on the same date are non-overlapping when they +occupy different halves." So this call MUST return False: + Interval(date(2026,10,5), date(2026,10,5), "first").overlaps( + Interval(date(2026,10,5), date(2026,10,5), "second")) +Walk through the code below with those exact values and state what it returns: + def overlaps(self, other): + if self.end < other.start or other.end < self.start: + return False + same_day = (self.start == self.end == other.start == other.end) + if same_day and self.half and other.half and self.half == other.half: + return False + return True +a) What does it return for different halves ("first" vs "second")? +b) WRONG LINE: which operator in the `same_day` line is inverted? +c) CORRECT LINE: give the exact corrected line. +d) What is the requirement? That same halves overlap and different halves do not. + +Output only answers labelled 1a,1b,1c and 2a,2b,2c,2d. No code blocks. +``` + +### Verdict + +**Result:** BR-EL-007 now correct (diagnosed 10 VL → 20 instead of 5; gave a numerically +equivalent `/` line). BR-EL-012: correctly named the inverted `==`, but **edited the wrong line** +(`same_day`); the real inverted line is `self.half == other.half`. +**Action:** applied all three fixes with the `!=` correction made by the judge: +`weekend_dates` → `>= 5`, `overlaps` → `self.half != other.half`, `year_end_action` → `available / +Decimal(vl_per_el)`. Verified: 10 VL → 5 EL, different halves do not overlap, Sat+Sun both weekends. + +## P3-tests — write `test_domain_rules.py` + +### Prompt sent + +```text +Write a pytest file modules/leave/tests/test_domain_rules.py for a pure-Python leave +domain module `modules.leave.domain.categories` (imported as `cat`). +No Django, no database. Use only datetime.date and decimal.Decimal. + +The API: + cat.weekend_dates(start: date, end: date) -> set[date] # Saturdays AND Sundays, inclusive + cat.Interval(start: date, end: date, half: str|None) # half is "first" or "second" + .overlaps(other: Interval) -> bool + cat.year_end_action(code: str, available: Decimal, *, vl_per_el: int = 2) -> YearEndAction + YearEndAction has fields: kind, amount, target + cat.year_end_action returns kind "convert" with target "el" for code "vl" (cat.VL) + codes: cat.CL="cl", cat.RH="rh", cat.SCL="scl", cat.EL="el", cat.COL="col", cat.VL="vl" + +Write these tests exactly: + +1. BR-EL-011 — weekend_dates returns BOTH Saturday and Sunday: + - for 2026-10-09 (Fri) .. 2026-10-11 (Sun): 2026-10-10 (Sat) IS in the set and + 2026-10-11 (Sun) IS in the set. + - a Monday..Friday range returns an empty set. + - a single Saturday returns exactly that one date. + +2. BR-EL-012 — half-day CL overlap on the same date: + - two "first" halves on the same date OVERLAP (overlaps() is True). + - "first" vs "second" on the same date do NOT overlap (False). + - plain full-day intervals on overlapping date ranges overlap (True). + - completely disjoint dates do not overlap (False). + +3. BR-EL-007 — VL converts to EL at 2 VL : 1 EL: + - year_end_action(cat.VL, Decimal(10), vl_per_el=2).amount == Decimal(5) + and .target == cat.EL + - Decimal(7) VL -> Decimal("3.5") EL (fractional conversion kept) + - year_end_action(cat.EL, Decimal(4)).kind is a carry-forward kind (not "lapse") + +Add a short module docstring citing BR-EL-007, BR-EL-011, BR-EL-012. +Output ONLY the complete file in one fenced code block. No prose. +``` + +### Verdict + +**Result:** valid pytest file covering all three rules. **PASS** — 3 tests pass. +(Added the `tests/__init__.py` package marker and ran `pytest`.) diff --git a/PHASE2_PROMPT_FOR_LLAMA.md b/PHASE2_PROMPT_FOR_LLAMA.md new file mode 100644 index 00000000..292423bc --- /dev/null +++ b/PHASE2_PROMPT_FOR_LLAMA.md @@ -0,0 +1,116 @@ +# PROMPT: ELM Phase 2 — Review, Test and Improve the Leave Module (Fusion-Integrated) + +Copy everything below this line into your local LLM (llama). + +--- + +You are working inside a Django 4 + DRF + React monorepo called **Fusion-Integrated** (a college +ERP). Your job is Assignment 6B Phase 2: take the existing AI-generated ELM (Employee Leave +Management) module, review it against the official specification, test it, fix what is wrong, and +make it runnable inside the Fusion base code. + +## Repository layout (absolute paths) + +- Repo root: `/home/atharva/Documents/Fusion/Fusion-Integrated` — all work happens HERE, nowhere else. +- The ELM module: `/home/atharva/Documents/Fusion/Fusion-Integrated/modules/leave/` + - `domain/` — pure rule logic, no Django: `categories.py`, `eligibility.py`, `state_machine.py`, `guards.py`, `routing.py` + - `services/` — transactions/orchestration: `applications.py`, `decisions.py`, `year_end.py`, `sla.py`, `calendar.py`, `workflow.py`, `accounts.py`, `notifications.py`, `offline.py` + - `selectors/` — query/scoping: `leave.py`, `balances.py` + - `api/` — `views.py`, `serializers.py` (**no `urls.py` yet**) + - `models.py`, `registry.py`, `contracts.py`, `apps.py` +- A reference module that is fully wired and correct: `/home/atharva/Documents/Fusion/Fusion-Integrated/modules/placement/` (copy its patterns, especially `api/urls.py`, `migrations/`, `tests/`). +- Django wiring files: + - `/home/atharva/Documents/Fusion/Fusion-Integrated/config/settings/base.py` (`DOMAIN_MODULES` list, line ~91) + - `/home/atharva/Documents/Fusion/Fusion-Integrated/config/urls.py` +- Module authoring rules: `/home/atharva/Documents/Fusion/Fusion-Integrated/docs/03-platform/module-authoring-guide.md` and `platform-structure.md` +- ELM specification (plain-text extracts, read these): + - `01_ELM_Problem_Statement_v1.4_FINAL.txt` — overview + - `02_ELM_Functional_Requirement_Specification_v1.5_FINAL.txt` — FRs + - `03_ELM_Use_Case_Specifications_v1.4_FINAL.txt` — use cases (EL-UC-xxx) + - `04_ELM_Business_Rule_Specifications_v1.4_FINAL.txt` — business rules (BR-EL-xxx) + - `06_ELM_Workflow_Specifications_v1.6_FINAL.txt` — workflows (CW-EL-xx) + - `06_ELM_Traceability_Matrix_v1.5_FINAL.txt` — which BR/UC belongs to which FR + +## Current state (verified) + +The module code EXISTS and imports cleanly, but it is **not connected to the Django app**: + +1. `modules.leave` is NOT in `INSTALLED_APPS` — `config/settings/base.py` `DOMAIN_MODULES` contains only `modules.placement`. +2. There is NO `modules/leave/api/urls.py`, and `config/urls.py` does NOT mount any `/api/v1/leave/` route. +3. There is NO `modules/leave/migrations/` directory, so none of the leave tables can be created. +4. There are NO tests: no `modules/leave/tests/`. +5. `modules/leave/registry.py` still has `"status": "planned"` and the module is ungranted, so no nav item is reachable. +6. There is no frontend under `client/src/modules/` for leave (only `placement` exists). + +The HTTP layer already has views for every workflow — `ExtensionView` (CW-EL-03, ongoing-leave +extension) and `ResumptionView` / `ResumptionReviewView` (CW-EL-04) already exist in +`modules/leave/api/views.py`, backed by `services/applications.py::request_extension`, +`submit_resumption` and `services/decisions.py::review_resumption`. Do NOT rewrite them; wire and +verify them. + +## Your task, in order + +### Step 1 — Register the module +- Add `"modules.leave"` to `DOMAIN_MODULES` in `config/settings/base.py`. +- Mount it in `config/urls.py`: `path("api/v1/leave/", include("modules.leave.api.urls"))`, + exactly like the placement/directory lines already there. +- Create `modules/leave/api/urls.py` following `modules/placement/api/urls.py` style, routing the + views that already exist in `modules/leave/api/views.py`. +- Set `MODULE["status"]` in `modules/leave/registry.py` from `"planned"` to `"active"` only after + everything below passes; read the registry docstring and `make permissions` first. + +### Step 2 — Database +- Create `modules/leave/migrations/` (`__init__.py`) and run + `python manage.py makemigrations leave`, then `make migrate`. +- Do NOT hand-write schema edits in `models.py` unless makemigrations demands it. + +### Step 3 — Spec review (the core of the assignment) +Read `04_ELM_Business_Rule_Specifications_v1.4_FINAL.txt` and audit the domain layer against it. +Write failing tests FIRST in `modules/leave/tests/test_domain_rules.py`, then fix the code. +Rules that the traceability matrix ties to `modules/leave/domain/categories.py` and that MUST have +passing tests: +- **BR-EL-007** — unused VL converts to EL at **2 VL : 1 EL** (division, not multiplication), fractional days included. Function: `year_end_action()`. +- **BR-EL-011** — CL/SCL/RH count **duty days only**: both Saturday AND Sunday drop out of the count. Function: `weekend_dates()`. +- **BR-EL-012** — two CL halves on the same date do NOT overlap when they occupy different halves; two requests in the SAME half DO overlap. Function: `Interval.overlaps()`. +Also audit `domain/eligibility.py` against BR-EL-010 (message references) and the state machine in +`domain/state_machine.py` against `06_ELM_Workflow_Specifications_v1.6_FINAL.txt` (EL-UC-001..011, +CW-EL-02/03/04 transitions). Every fix must cite the BR/UC number in the commit message or test +docstring. + +### Step 4 — Service/API tests +- Add `modules/leave/tests/test_api.py` covering: apply → review → route → approve happy path, + an unauthorized user getting 404 (selector scoping, never 403), extension of an ongoing leave + (CW-EL-03), and resumption submit + review (CW-EL-04). +- Follow `modules/placement/tests/test_api.py` for fixtures/primitives. + +### Step 5 — Verify everything CI runs +Run and make ALL of these pass, in this order: +``` +make check # manage.py check, makemigrations --check, ruff, lint-imports, + # no_cross_module_fk, contracts_are_plural, nav_matches_routes, + # permission_manifest --check, openapi schema-check, pytest +make check-client # only if you touched client/ +``` +If `permission_manifest --check` or `nav_matches_routes` fail, fix `modules/leave/registry.py` +(nav items must correspond to real routes; permissions must be granted to some designation). + +## Constraints (do not violate) + +- Django + DRF only. Do NOT create any FastAPI/SQLAlchemy code, do NOT create + `backend/leave_management/`, do NOT add `fastapi` or `sqlalchemy` to `requirements.txt`. +- No hardcoded credentials (`admin123` or similar), no in-memory mock databases, no + `password == ...` checks — auth comes from the existing `fusion_auth` principal, and views use + `HasModuleGrant("leave")` / `HasPermission(...)` as `modules/leave/api/views.py` already does. +- Keep the existing layering: views parse/scope/delegate → services own transactions → selectors + scope querysets → domain is pure functions with no Django imports. +- No cross-module foreign keys (ADR-0013); cross-module reads go through `contracts.py`. +- Do NOT replace or delete the existing module; improve it in place. +- Do NOT touch anything outside `modules/leave/`, `config/settings/base.py`, `config/urls.py`, + and (if needed) `client/src/modules/leave/`. + +## Deliverables when you are done + +1. The list of spec violations found, each citing its BR/UC number, the file:line, and the fix. +2. `make check` output ending in success. +3. A short summary of what a reviewer should manually test in the running app + (`make up && make migrate && make seed && make dev`, then sign in and open Leave from the sidebar). diff --git a/client/src/app/registry.ts b/client/src/app/registry.ts index e76d32bf..05af5c45 100644 --- a/client/src/app/registry.ts +++ b/client/src/app/registry.ts @@ -17,4 +17,9 @@ export const MODULE_REGISTRY: Record = { basePath: "/placement", load: () => import("../modules/placement/routes"), }, + leave: { + code: "leave", + basePath: "/leave", + load: () => import("../modules/leave/routes"), + }, }; diff --git a/client/src/modules/leave/api/hooks.ts b/client/src/modules/leave/api/hooks.ts new file mode 100644 index 00000000..67a30c57 --- /dev/null +++ b/client/src/modules/leave/api/hooks.ts @@ -0,0 +1,126 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { http } from "../../../lib/http"; +import type { + ApplyPayload, BalanceResponse, LeaveCalendar, LeavePolicy, LeaveRequest, + LeaveTask, Page, +} from "./types"; + +const KEY = "leave"; + +export const keys = { + requests: (kind?: string) => [KEY, "requests", kind] as const, + balances: (year?: string) => [KEY, "balances", year] as const, + substitutions: () => [KEY, "substitutions"] as const, + inbox: () => [KEY, "inbox"] as const, + all: (filters?: { status?: string; category?: string; kind?: string }) => [KEY, "all", filters] as const, + policy: () => [KEY, "policy"] as const, + calendars: (year?: string) => [KEY, "calendars", year] as const, +}; + +function invalidateAll(qc: ReturnType) { + qc.invalidateQueries({ queryKey: [KEY] }); +} + +export function useMyRequests(kind?: string) { + return useQuery({ + queryKey: keys.requests(kind), + queryFn: async () => + (await http.get>(`/leave/requests`, { params: kind ? { kind } : undefined })).data, + staleTime: 15_000, + }); +} + +export function useBalances(year?: string) { + return useQuery({ + queryKey: keys.balances(year), + queryFn: async () => + (await http.get(`/leave/balances`, { params: year ? { year } : undefined })).data, + }); +} + +export function useSubstitutions() { + return useQuery({ + queryKey: keys.substitutions(), + queryFn: async () => + (await http.get>(`/leave/substitutions`)).data, + }); +} + +export function useInbox() { + return useQuery({ + queryKey: keys.inbox(), + queryFn: async () => + (await http.get>(`/leave/inbox`)).data, + staleTime: 15_000, + }); +} + +export function useAllRequests(filters?: { status?: string; category?: string; kind?: string }) { + return useQuery({ + queryKey: keys.all(filters), + queryFn: async () => + (await http.get>(`/leave/all`, { params: filters })).data, + }); +} + +export function usePolicy() { + return useQuery({ + queryKey: keys.policy(), + queryFn: async () => + (await http.get(`/leave/policy`)).data, + retry: false, + }); +} + +export function useCalendars(year?: string) { + return useQuery({ + queryKey: keys.calendars(year), + queryFn: async () => + (await http.get>(`/leave/calendars`, { params: year ? { year } : undefined })).data, + }); +} + +export function useApply() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: ApplyPayload) => + (await http.post(`/leave/requests`, body)).data, + onSuccess: () => invalidateAll(qc), + }); +} + +export function useWithdraw() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, reason }: { id: number; reason: string }) => + (await http.post(`/leave/requests/${id}/withdraw`, { reason })).data, + onSuccess: () => invalidateAll(qc), + }); +} + +export function useRespond() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, accept }: { id: number; accept: boolean }) => + (await http.post(`/leave/substitutions/${id}`, { accept })).data, + onSuccess: () => invalidateAll(qc), + }); +} + +export function useSavePolicy() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: Partial) => + (await http.post(`/leave/policy`, body)).data, + onSuccess: () => invalidateAll(qc), + }); +} + +export function usePublishCalendar() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: number) => + (await http.post(`/leave/calendars/${id}/publish`, {})).data, + onSuccess: () => invalidateAll(qc), + }); +} diff --git a/client/src/modules/leave/api/types.ts b/client/src/modules/leave/api/types.ts new file mode 100644 index 00000000..9941d98e --- /dev/null +++ b/client/src/modules/leave/api/types.ts @@ -0,0 +1,122 @@ +/** + * These are the wire shapes returned by the Django leave API; dates arrive as "YYYY-MM-DD" strings + * and decimal amounts (days, balances) arrive as strings because Django serialises Decimal. + */ + +export interface Page { + results: T[]; +} + +export type LeaveKind = "new" | "extension" | "cancellation" | "resumption"; + +export type LeaveStatus = + | "draft" | "awaiting_substitute" | "applicant_action_required" + | "awaiting_unit_head" | "awaiting_establishment" | "awaiting_final" + | "awaiting_self_sanction" | "resumption_pending" + | "approved" | "rejected" | "withdrawn" | "cancelled" | "resumed"; + +export type LeaveCategory = "cl" | "rh" | "scl" | "el" | "col" | "vl"; + +export type Half = "" | "first" | "second"; + +export interface LeaveRequest { + id: number; + kind: LeaveKind; + kind_label: string; + category: LeaveCategory; + category_label: string; + from_date: string; + to_date: string; + half: Half; + days: string; + purpose: string; + status: LeaveStatus; + station_leave_required: boolean; + station_leave_details: string; + parent: number | null; + unit_head_user_id: number | null; + final_authority: string; + establishment_first: boolean; + self_sanction: boolean; + substitute_user_id: number | null; + substitute_response: string; + is_offline: boolean; + sanction_reference: string; + sanctioned_on: string | null; + decision: string; + decision_remarks: string; + decided_at: string | null; + created_at: string; +} + +export interface LeaveBalance { + category: LeaveCategory; + category_label: string; + opening: string; + credited: string; + used: string; + available: string; +} + +export interface BalanceResponse { + year: string; + results: LeaveBalance[]; +} + +export interface LeaveTask { + task_id: number; + stage: string; + assigned_at: string; + reminder_due_at: string | null; + escalation_due_at: string | null; + request: LeaveRequest; +} + +export interface LeavePolicy { + id: number; + label: string; + year_label: string; + starts_on: string; + ends_on: string; + cl_days: number; + rh_days: number; + scl_days: number; + el_days: number; + vl_days: number; + col_days: number; + vl_per_el: number; + authority_path: string; + establishment_designation: string; + establishment_step_enabled: boolean; + reminder_hours: number; + escalation_hours: number; + year_end_on: string | null; +} + +export interface CalendarEntry { + id: number; + date: string; + ends_on: string | null; + kind: string; + label: string; +} + +export interface LeaveCalendar { + id: number; + year_label: string; + label: string; + published_at: string | null; + entries: CalendarEntry[]; +} + +export interface ApplyPayload { + category: LeaveCategory; + from_date: string; + to_date: string; + half?: Half; + purpose: string; + station_leave_required: boolean; + station_leave_details?: string; + substitute_user_id?: number | null; + substitute_required: boolean; +} diff --git a/client/src/modules/leave/pages/AdministrationPage.tsx b/client/src/modules/leave/pages/AdministrationPage.tsx new file mode 100644 index 00000000..8dee3df6 --- /dev/null +++ b/client/src/modules/leave/pages/AdministrationPage.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from "react"; +import { + Button, Card, Checkbox, Container, Group, NumberInput, SimpleGrid, Stack, + Text, TextInput, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { ErrorState } from "../../../ui/components/ErrorState"; +import { PageHeader } from "../../../ui/components/PageHeader"; +import { errorMessage } from "../../../lib/http"; +import { useCalendars, usePolicy, usePublishCalendar, useSavePolicy } from "../api/hooks"; +import type { LeavePolicy } from "../api/types"; + +const AdministrationPage = () => { + const policyQ = usePolicy(); + const save = useSavePolicy(); + const calendars = useCalendars(); + const publish = usePublishCalendar(); + const [draft, setDraft] = useState(null); + useEffect(() => { if (policyQ.data) setDraft(policyQ.data); }, [policyQ.data]); + + if (policyQ.error) return ; + if (!draft) return Loading policy…; + + return ( + + +

Leave policy

+ + + + setDraft({ ...draft, cl_days: typeof v === "number" ? v : 0 })} + /> + setDraft({ ...draft, rh_days: typeof v === "number" ? v : 0 })} + /> + setDraft({ ...draft, scl_days: typeof v === "number" ? v : 0 })} + /> + setDraft({ ...draft, el_days: typeof v === "number" ? v : 0 })} + /> + setDraft({ ...draft, vl_days: typeof v === "number" ? v : 0 })} + /> + setDraft({ ...draft, col_days: typeof v === "number" ? v : 0 })} + /> + setDraft({ ...draft, vl_per_el: typeof v === "number" ? v : 0 })} + /> + setDraft({ ...draft, reminder_hours: typeof v === "number" ? v : 0 })} + /> + setDraft({ ...draft, escalation_hours: typeof v === "number" ? v : 0 })} + /> + + setDraft({ ...draft, label: e.currentTarget.value })} + /> + setDraft({ ...draft, year_label: e.currentTarget.value })} + /> + setDraft({ ...draft, authority_path: e.currentTarget.value })} + /> + setDraft({ ...draft, establishment_designation: e.currentTarget.value })} + /> + setDraft({ ...draft, starts_on: e.currentTarget.value })} + /> + setDraft({ ...draft, ends_on: e.currentTarget.value })} + /> + setDraft({ ...draft, year_end_on: e.currentTarget.value })} + /> + setDraft({ ...draft, establishment_step_enabled: e.currentTarget.checked })} + /> + + + +

Calendars

+ + {calendars.data?.results.map(c => ( + + + {c.year_label} — {c.label || "Calendar"} + {c.published_at ? `Published ${c.published_at}` : "Draft"} + + + + ))} + {calendars.data?.results.length === 0 && No calendars yet.} + + + ); +}; + +export default AdministrationPage; diff --git a/client/src/modules/leave/pages/InboxPage.tsx b/client/src/modules/leave/pages/InboxPage.tsx new file mode 100644 index 00000000..9a857395 --- /dev/null +++ b/client/src/modules/leave/pages/InboxPage.tsx @@ -0,0 +1,36 @@ +import { Container, Text } from "@mantine/core"; +import { DataTable } from "../../../ui/components/DataTable"; +import { ErrorState } from "../../../ui/components/ErrorState"; +import { PageHeader } from "../../../ui/components/PageHeader"; +import { StatusBadge } from "../../../ui/components/StatusBadge"; +import { useInbox } from "../api/hooks"; +import type { LeaveTask } from "../api/types"; + +const InboxPage = () => { + const { data, isPending, error } = useInbox(); + if (error) return ; + + return ( + + + + rows={data?.results ?? []} + loading={isPending} + rowKey={(t) => t.task_id} + minWidth={900} + empty={{ title: "Nothing in your queue", description: "Leave work assigned to you appears here." }} + columns={[ + { key: "stage", header: "Stage", render: (t) => t.stage.replace(/_/g, " ") }, + { key: "applicant", header: "Purpose", render: (t) => ( + {t.request.purpose}) }, + { key: "category", header: "Category", render: (t) => t.request.category_label }, + { key: "period", header: "Period", render: (t) => `${t.request.from_date} to ${t.request.to_date}` }, + { key: "status", header: "Status", render: (t) => }, + { key: "due", header: "Reminder due", render: (t) => t.reminder_due_at ?? "—" }, + ]} + /> + + ); +}; + +export default InboxPage; diff --git a/client/src/modules/leave/pages/MinePage.tsx b/client/src/modules/leave/pages/MinePage.tsx new file mode 100644 index 00000000..2a46ff36 --- /dev/null +++ b/client/src/modules/leave/pages/MinePage.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { + Button, Card, Container, SimpleGrid, Stack, Text, Textarea, + TextInput, Select, Checkbox, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { FaPlus } from "react-icons/fa"; +import { DataTable } from "../../../ui/components/DataTable"; +import { ErrorState } from "../../../ui/components/ErrorState"; +import { FormModal } from "../../../ui/components/FormModal"; +import { PageHeader } from "../../../ui/components/PageHeader"; +import { StatusBadge } from "../../../ui/components/StatusBadge"; +import { errorMessage } from "../../../lib/http"; +import { useApply, useBalances, useMyRequests, useWithdraw } from "../api/hooks"; +import type { ApplyPayload, LeaveRequest } from "../api/types"; + +const MinePage = () => { + const balances = useBalances(); + const { data, isPending, error } = useMyRequests(); + const apply = useApply(); + const withdraw = useWithdraw(); + const [applying, setApplying] = useState(false); + const [form, setForm] = useState({ + category: "cl", from_date: "", to_date: "", half: "", + purpose: "", station_leave_required: false, station_leave_details: "", + substitute_required: false, + }); + + if (error) return ; + + const submit = () => { + apply.mutate(form, { + onSuccess: () => { + notifications.show({ color: "green", title: "Application submitted", + message: "Your leave request is now in the workflow." }); + setApplying(false); + }, + onError: (e) => notifications.show({ color: "red", + title: "Could not submit", message: errorMessage(e) }), + }); + }; + + const doWithdraw = (r: LeaveRequest) => { + withdraw.mutate({ id: r.id, reason: "Withdrawn by applicant" }, { + onSuccess: () => notifications.show({ color: "gray", title: "Withdrawn", + message: "Your request has been withdrawn." }), + onError: (e) => notifications.show({ color: "red", + title: "Could not withdraw", message: errorMessage(e) }), + }); + }; + + return ( + + } onClick={() => setApplying(true)}>Apply for leave} /> + + {balances.data && ( + + {balances.data.results.map((b) => ( + + {b.category_label} + {b.available} + of {b.credited} credited + + ))} + + )} + + + rows={data?.results ?? []} + loading={isPending} + rowKey={(r) => r.id} + minWidth={900} + empty={{ title: "No leave requests yet", description: "Apply for leave to get started." }} + columns={[ + { key: "category", header: "Category", render: (r) => r.category_label }, + { key: "period", header: "Period", render: (r) => `${r.from_date} to ${r.to_date}` }, + { key: "days", header: "Days", align: "right", render: (r) => r.days }, + { key: "status", header: "Status", render: (r) => }, + { key: "actions", header: "", render: (r) => ( + ["draft", "awaiting_unit_head", "awaiting_establishment", "awaiting_final"].includes(r.status) + ? + : null) }, + ]} + /> + + setApplying(false)} + title="Apply for leave" submitLabel="Submit application" + submitting={apply.isPending} onSubmit={submit}> + + setForm({ ...form, half: (v ?? "") as ApplyPayload["half"] })} + /> +