diff --git a/.importlinter b/.importlinter index d964dc3c..b74d0022 100644 --- a/.importlinter +++ b/.importlinter @@ -15,6 +15,7 @@ source_modules = modules.placement modules.directory modules.accesscontrol + modules.leave forbidden_modules = modules.placement.models modules.placement.services @@ -25,6 +26,11 @@ forbidden_modules = modules.directory.services modules.directory.api modules.accesscontrol.models + modules.leave.models + modules.leave.services + modules.leave.selectors + modules.leave.api + modules.leave.domain # DIRECT imports only. contracts.py legitimately imports its OWN module's # models and services, so following the chain transitively would make this # contract impossible to satisfy. What we forbid is a module reaching straight @@ -38,6 +44,7 @@ type = forbidden source_modules = # Every module's domain, not just the first one written. modules.placement.domain + modules.leave.domain forbidden_modules = django rest_framework @@ -66,3 +73,13 @@ layers = modules.placement.selectors modules.placement.models modules.placement.domain + +[importlinter:contract:leave-layers] +name = Layering inside leave +type = layers +layers = + modules.leave.api + modules.leave.services + modules.leave.selectors + modules.leave.models + modules.leave.domain diff --git a/ELM_specs/01_ELM_Problem_Statement_v1.4_FINAL.docx b/ELM_specs/01_ELM_Problem_Statement_v1.4_FINAL.docx new file mode 100644 index 00000000..4fbac1e2 Binary files /dev/null and b/ELM_specs/01_ELM_Problem_Statement_v1.4_FINAL.docx differ diff --git a/ELM_specs/02_ELM_Functional_Requirement_Specification_v1.5_FINAL.xlsx b/ELM_specs/02_ELM_Functional_Requirement_Specification_v1.5_FINAL.xlsx new file mode 100644 index 00000000..cdf1174f Binary files /dev/null and b/ELM_specs/02_ELM_Functional_Requirement_Specification_v1.5_FINAL.xlsx differ diff --git a/ELM_specs/03_ELM_Use_Case_Specifications_v1.4_FINAL.docx b/ELM_specs/03_ELM_Use_Case_Specifications_v1.4_FINAL.docx new file mode 100644 index 00000000..49678c35 Binary files /dev/null and b/ELM_specs/03_ELM_Use_Case_Specifications_v1.4_FINAL.docx differ diff --git a/ELM_specs/04_ELM_Business_Rule_Specifications_v1.4_FINAL.docx b/ELM_specs/04_ELM_Business_Rule_Specifications_v1.4_FINAL.docx new file mode 100644 index 00000000..b6fe6332 Binary files /dev/null and b/ELM_specs/04_ELM_Business_Rule_Specifications_v1.4_FINAL.docx differ diff --git a/ELM_specs/06_ELM_Traceability_Matrix_v1.5_FINAL.xlsx b/ELM_specs/06_ELM_Traceability_Matrix_v1.5_FINAL.xlsx new file mode 100644 index 00000000..62c46d0d Binary files /dev/null and b/ELM_specs/06_ELM_Traceability_Matrix_v1.5_FINAL.xlsx differ diff --git a/ELM_specs/06_ELM_Workflow_Specifications_v1.6_FINAL.docx b/ELM_specs/06_ELM_Workflow_Specifications_v1.6_FINAL.docx new file mode 100644 index 00000000..a611202e Binary files /dev/null and b/ELM_specs/06_ELM_Workflow_Specifications_v1.6_FINAL.docx differ 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/types.ts b/client/src/modules/leave/api/types.ts new file mode 100644 index 00000000..c8b91ee2 --- /dev/null +++ b/client/src/modules/leave/api/types.ts @@ -0,0 +1,190 @@ +export interface Page { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +export type LeaveCategory = "cl" | "rh" | "scl" | "el" | "col" | "vl"; +export type LeaveKind = "new" | "cancellation" | "extension" | "offline"; +export type LeaveStatus = + | "awaiting_substitute_response" + | "applicant_action_required" + | "awaiting_unit_head_review" + | "awaiting_establishment_routing" + | "awaiting_final_sanction_decision" + | "awaiting_director_self_sanction" + | "approved" + | "rejected" + | "withdrawn" + | "cancelled"; + +export interface LeaveRequest { + id: number; + user_id: number; + applicant_name?: string; + kind: LeaveKind; + source: string; + category: LeaveCategory; + status: LeaveStatus; + start_date: string; + end_date: string; + half_day: string; + purpose: string; + evidence_note: string; + station_leave: boolean; + station_place: string; + station_reason: string; + chargeable_days: string; + year: number; + base_request: number | null; + substitute_user_id: number | null; + substitute_name?: string; + substitute_status: string; + unit_head_user_id: number | null; + unit_head_outcome: string; + unit_head_note: string; + establishment_user_id: number | null; + authority_user_id: number | null; + is_director_self_sanction: boolean; + decision: string; + decided_at: string | null; + decided_by_user_id: number | null; + decision_note: string; + sanction_reference: string; + task_class: string; + task_user_id: number | null; + task_assigned_at: string | null; + reminder_sent_at: string | null; + escalated_at: string | null; + actual_end_date: string | null; + admin_note: string; + allowed_actions: string[]; + is_pending: boolean; + pending_resumption: LeaveResumption | null; + created_at: string; + updated_at: string; +} + +export interface LeaveResumption { + id: number; + request: number; + actual_date: string; + details: string; + status: string; + approved_end_date: string; + adjustment_days: string; + reviewed_by_user_id: number | null; + reviewed_at: string | null; + review_note: string; + created_at: string; +} + +export interface LeaveEvent { + id: number; + from_status: string; + to_status: string; + kind: string; + actor_user_id: number | null; + actor_label: string; + note: string; + at: string; +} + +export interface LeaveBalance { + id: number; + user_id: number; + category: LeaveCategory; + year: number; + carried_forward: string; + credited: string; + used: string; + adjusted: string; + available: number; +} + +export interface LeaveNotification { + id: number; + kind: string; + message: string; + request: number | null; + read_at: string | null; + created_at: string; +} + +export interface LeavePolicy { + id: number; + cl_annual: string; + rh_annual: string; + scl_annual: string; + el_annual_nonfaculty: string; + el_annual_faculty: string; + col_annual: string; + vl_annual_faculty: string; + vl_to_el_numerator: number; + vl_to_el_denominator: number; + el_carry_forward: boolean; + col_carry_forward: boolean; + substitute_required_categories: LeaveCategory[]; + scl_evidence_required: boolean; + col_evidence_required: boolean; + year_end_month: number; + year_end_day: number; + updated_at: string; +} + +export interface SubmitLeaveRequest { + category: LeaveCategory; + start_date: string; + end_date: string; + half_day?: string; + purpose?: string; + evidence_note?: string; + station_leave?: boolean; + station_place?: string; + station_reason?: string; + substitute_user_id?: number | null; +} + +export interface ExtensionRequest { + new_end_date: string; + reason?: string; + substitute_user_id?: number | null; +} + +export interface CancellationRequest { + reason?: string; +} + +export interface ReviewRequest { + outcome: "approve" | "reject" | "recommended" | "not_recommended"; + note?: string; + reference?: string; +} + +export interface SanctionRequest { + outcome: "approve" | "reject"; + note?: string; + reference?: string; +} + +export interface SubstituteResponse { + response: "accept" | "decline"; +} + +export interface ResumptionRequest { + actual_date: string; + details?: string; +} + +export interface ResumptionReviewRequest { + note?: string; +} + +export interface EmployeeSearchResult { + user_id: number; + username: string; + display_name: string; + kind: string; + department: string; +} \ No newline at end of file diff --git a/config/celery.py b/config/celery.py index ad15f06b..c2b5ee67 100644 --- a/config/celery.py +++ b/config/celery.py @@ -2,6 +2,12 @@ from celery import Celery +from modules.leave.schedule import ( + BEAT_SCHEDULE as LEAVE_SCHEDULE, +) +from modules.leave.schedule import ( + TASK_ROUTES as LEAVE_ROUTES, +) from modules.placement.schedule import ( BEAT_SCHEDULE as PLACEMENT_SCHEDULE, ) @@ -15,5 +21,5 @@ app.autodiscover_tasks() # Each module owns its timers; this only merges them, so modules stay removable. -app.conf.beat_schedule = {**PLACEMENT_SCHEDULE} -app.conf.task_routes = {**PLACEMENT_ROUTES} +app.conf.beat_schedule = {**PLACEMENT_SCHEDULE, **LEAVE_SCHEDULE} +app.conf.task_routes = {**PLACEMENT_ROUTES, **LEAVE_ROUTES} diff --git a/config/settings/base.py b/config/settings/base.py index 0dc7ada1..8ba46554 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -90,6 +90,7 @@ def env_list(name, default=None): # See docs/03-platform/module-authoring-guide.md to add one. DOMAIN_MODULES = [ "modules.placement", + "modules.leave", ] INSTALLED_APPS = (DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/config/settings/test.py b/config/settings/test.py index a4f06fd8..05f006d9 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -8,3 +8,18 @@ PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "test"}} + +# CI provides a real Postgres (see .github/workflows/ci.yml, which exports +# DB_PASSWORD). A lab machine may have no server at all, and a test suite +# you cannot run is not a test suite: with no credentials configured, fall +# back to SQLite. Nothing in the suite depends on server-side Postgres +# features (there is no contrib.postgres type anywhere in the codebase), and +# FUSION_TEST_ENGINE=postgresql forces the real database when one exists. +if env("FUSION_TEST_ENGINE", "postgresql") == "postgresql" and not env("DB_PASSWORD"): + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "test_db.sqlite3", + "OPTIONS": {"timeout": 20}, + } + } diff --git a/config/urls.py b/config/urls.py index 33f7835b..91a4dc53 100644 --- a/config/urls.py +++ b/config/urls.py @@ -16,6 +16,7 @@ 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/v1/leave/", include("modules.leave.api.urls")), path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="docs"), ] diff --git a/devtools/management/commands/seed_demo.py b/devtools/management/commands/seed_demo.py index 49f1b3d0..ab788ffa 100644 --- a/devtools/management/commands/seed_demo.py +++ b/devtools/management/commands/seed_demo.py @@ -11,6 +11,15 @@ from django.utils import timezone from modules.directory.models import UserRef +from modules.leave.models import ( + AuthorityAssignment, + Holiday, + HolidayCalendar, + LeavePolicy, + OrganizationalUnit, + SlaThreshold, + VacationPeriod, +) from modules.placement.models import Application, Company, JobPosting @@ -24,6 +33,10 @@ def handle(self, *args, **opts): (1002, "Bo Li", "student", "CSE"), (1003, "Chandra Rao", "student", "ECE"), (9001, "Dr. Meera Nair", "faculty", ""), + (9002, "Ravi Kumar", "staff", ""), + (9003, "Dr. Sunil Joshi", "faculty", ""), + (9004, "Kavita Rao", "staff", ""), + (9005, "Dr. Anil Sharma", "faculty", ""), ]: UserRef.objects.update_or_create( user_id=uid, @@ -55,6 +68,61 @@ def handle(self, *args, **opts): "eligibility_snapshot": {"is_eligible": True}, "applied_at": timezone.now()}) + # -- leave: the configuration a fresh install needs before the first + # application can route (policy and calendars are data, not code). + LeavePolicy.objects.get_or_create(id=1) + cse, _ = OrganizationalUnit.objects.update_or_create( + code="CSE", defaults={"name": "Computer Science & Engineering", + "kind": "department", "head_user_id": 9001}) + ece, _ = OrganizationalUnit.objects.update_or_create( + code="ECE", defaults={"name": "Electronics & Communication", + "kind": "department", "head_user_id": 9003}) + for role, uid, unit in [ + ("establishment", 9002, None), ("dean", 9003, None), + ("registrar", 9004, None), ("director", 9005, None), + ]: + AuthorityAssignment.objects.update_or_create( + role_code=role, org_unit=unit, + defaults={"user_id": uid, "categories": []}) + AuthorityAssignment.objects.update_or_create( + role_code="establishment", org_unit=cse, + defaults={"user_id": 9002, "categories": []}) + AuthorityAssignment.objects.update_or_create( + role_code="establishment", org_unit=ece, + defaults={"user_id": 9004, "categories": []}) + + calendar, _ = HolidayCalendar.objects.update_or_create( + title="Institute Calendar 2026", academic_year="2026-27", + defaults={"status": "published", + "published_at": timezone.now(), + "published_by_user_id": 9004}) + for day, name, kind in [ + ("2026-08-15", "Independence Day", "closed"), + ("2026-10-02", "Gandhi Jayanti", "closed"), + ("2026-11-08", "Diwali", "restricted"), + ("2026-03-04", "Holi", "restricted"), + ]: + Holiday.objects.update_or_create( + calendar=calendar, date=day, + defaults={"name": name, "kind": kind}) + VacationPeriod.objects.update_or_create( + name="Winter Vacation", academic_year="2026-27", + defaults={"start_date": "2026-12-21", "end_date": "2027-01-01", + "source_ref": "Academic Calendar 2026-27"}) + + for task_class, reminder, escalation in [ + ("substitute_response", 24, 72), + ("unit_head_review", 48, 120), + ("establishment_routing", 48, 120), + ("final_sanction", 72, 168), + ("director_sanction", 72, 168), + ("resumption_review", 72, 168), + ]: + SlaThreshold.objects.update_or_create( + task_class=task_class, + defaults={"reminder_hours": reminder, + "escalation_hours": escalation, + "escalation_user_id": None}) self.stdout.write(self.style.SUCCESS("demo data loaded")) diff --git a/modules/leave/__init__.py b/modules/leave/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/leave/api/__init__.py b/modules/leave/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/leave/api/serializers.py b/modules/leave/api/serializers.py new file mode 100644 index 00000000..fcc95607 --- /dev/null +++ b/modules/leave/api/serializers.py @@ -0,0 +1,344 @@ +"""Serializers. Thin, and every write serializer is an explicit allow-list. + +`fields = "__all__"` is never used here — it is exactly how `user_id`, +`status` or `chargeable_days` ends up writable by whoever posts the right +JSON. +""" +from __future__ import annotations + +from rest_framework import serializers + +from modules.leave.domain.categories import CATEGORIES, HALF_DAY_CHOICES +from modules.leave.models import ( + AuthorityAssignment, + LeaveBalance, + LeaveNotification, + LeavePolicy, + LeaveRequest, + LeaveRequestEvent, + LeaveResumption, + OrganizationalUnit, + SlaThreshold, +) +from modules.leave.selectors import actions + +CATEGORY_CHOICES = [(c, c) for c in CATEGORIES] + + +# -- requests --------------------------------------------------------------- +class LeaveEventSerializer(serializers.ModelSerializer): + class Meta: + model = LeaveRequestEvent + fields = ("id", "from_status", "to_status", "kind", "actor_user_id", + "actor_label", "note", "at") + read_only_fields = fields + + +class LeaveResumptionSerializer(serializers.ModelSerializer): + adjustment_days = serializers.SerializerMethodField() + + class Meta: + model = LeaveResumption + fields = ("id", "request", "actual_date", "details", "status", + "approved_end_date", "adjustment_days", + "reviewed_by_user_id", "reviewed_at", "review_note", + "created_at") + read_only_fields = fields + + def get_adjustment_days(self, obj) -> str: + # "-1", not "-1.00": a day count reads as days, not as money. + from decimal import Decimal + value = Decimal(str(obj.adjustment_days)) + return str(int(value)) if value == value.to_integral_value() \ + else str(value) + + +class LeaveRequestSerializer(serializers.ModelSerializer): + """One file as any actor sees it. Names come batched through the view's + context, so a list costs one directory call, not one per row.""" + + applicant_name = serializers.SerializerMethodField() + substitute_name = serializers.SerializerMethodField() + allowed_actions = serializers.SerializerMethodField() + is_pending = serializers.BooleanField(read_only=True) + pending_resumption = serializers.SerializerMethodField() + + class Meta: + model = LeaveRequest + fields = ( + "id", "user_id", "applicant_name", "kind", "source", "category", + "status", + "start_date", "end_date", "half_day", "purpose", "evidence_note", + "station_leave", "station_place", "station_reason", + "chargeable_days", "year", "base_request", + "substitute_user_id", "substitute_name", "substitute_status", + "unit_head_user_id", "unit_head_outcome", "unit_head_note", + "establishment_user_id", "authority_user_id", + "is_director_self_sanction", + "decision", "decided_at", "decided_by_user_id", "decision_note", + "sanction_reference", + "task_class", "task_user_id", "task_assigned_at", + "reminder_sent_at", "escalated_at", + "actual_end_date", "admin_note", + "allowed_actions", "is_pending", "pending_resumption", + "created_at", "updated_at", + ) + read_only_fields = fields + + def _people(self) -> dict: + return self.context.get("people", {}) + + def get_applicant_name(self, obj) -> str: + person = self._people().get(obj.user_id) + return person.display_name if person else "" + + def get_substitute_name(self, obj) -> str: + person = self._people().get(obj.substitute_user_id) + return person.display_name if person else "" + + def get_allowed_actions(self, obj) -> list[str]: + actor = self.context.get("actor") + return actions.allowed_actions(obj, actor) + + def get_pending_resumption(self, obj): + row = obj.resumptions.filter( + status=LeaveResumption.PENDING).order_by("-created_at").first() + if row is None: + return None + return LeaveResumptionSerializer(row).data + + +class SubmitSerializer(serializers.Serializer): + category = serializers.ChoiceField(choices=CATEGORY_CHOICES) + start_date = serializers.DateField() + end_date = serializers.DateField() + half_day = serializers.ChoiceField(choices=list(HALF_DAY_CHOICES), + default="none") + purpose = serializers.CharField(required=False, allow_blank=True, + default="") + evidence_note = serializers.CharField(required=False, allow_blank=True, + default="") + station_leave = serializers.BooleanField(required=False, default=False) + station_place = serializers.CharField(required=False, allow_blank=True, + default="") + station_reason = serializers.CharField(required=False, allow_blank=True, + default="") + substitute_user_id = serializers.IntegerField(required=False, + allow_null=True, + default=None) + + +class ReasonSerializer(serializers.Serializer): + reason = serializers.CharField(required=False, allow_blank=True, + default="") + + +class ExtensionSerializer(serializers.Serializer): + new_end_date = serializers.DateField() + reason = serializers.CharField(required=False, allow_blank=True, + default="") + substitute_user_id = serializers.IntegerField(required=False, + allow_null=True, + default=None) + + +class ResumptionFileSerializer(serializers.Serializer): + actual_date = serializers.DateField() + details = serializers.CharField(required=False, allow_blank=True, + default="") + + +class ReviewSerializer(serializers.Serializer): + """Unit Head review: both vocabularies, narrowed by the service to what + this desk and category actually accept.""" + outcome = serializers.ChoiceField(choices=( + ("approve", "approve"), ("reject", "reject"), + ("recommended", "recommended"), ("not_recommended", "not_recommended"))) + note = serializers.CharField(required=False, allow_blank=True, default="") + reference = serializers.CharField(required=False, allow_blank=True, + default="") + + +class SanctionSerializer(serializers.Serializer): + outcome = serializers.ChoiceField(choices=( + ("approve", "approve"), ("reject", "reject"))) + note = serializers.CharField(required=False, allow_blank=True, default="") + reference = serializers.CharField(required=False, allow_blank=True, + default="") + + +class NoteSerializer(serializers.Serializer): + note = serializers.CharField(required=False, allow_blank=True, default="") + + +class SubstituteRespondSerializer(serializers.Serializer): + response = serializers.ChoiceField(choices=( + ("accept", "accept"), ("decline", "decline"))) + + +class NominateSerializer(serializers.Serializer): + substitute_user_id = serializers.IntegerField() + + +class ResumptionReviewSerializer(serializers.Serializer): + note = serializers.CharField(required=False, allow_blank=True, default="") + + +# -- balances, notifications ------------------------------------------------ +class BalanceSerializer(serializers.ModelSerializer): + available = serializers.SerializerMethodField() + + class Meta: + model = LeaveBalance + fields = ("id", "user_id", "category", "year", "carried_forward", + "credited", "used", "adjusted", "available") + read_only_fields = fields + + def get_available(self, obj) -> float: + return float(obj.available) + + +class NotificationSerializer(serializers.ModelSerializer): + class Meta: + model = LeaveNotification + fields = ("id", "kind", "message", "request", "read_at", "created_at") + read_only_fields = fields + + +class MarkReadSerializer(serializers.Serializer): + ids = serializers.ListField(child=serializers.IntegerField(), + required=False, default=list) + + +# -- administration --------------------------------------------------------- +class PolicySerializer(serializers.ModelSerializer): + class Meta: + model = LeavePolicy + fields = ("id", "cl_annual", "rh_annual", "scl_annual", + "el_annual_nonfaculty", "el_annual_faculty", "col_annual", + "vl_annual_faculty", "vl_to_el_numerator", + "vl_to_el_denominator", "el_carry_forward", + "col_carry_forward", "substitute_required_categories", + "scl_evidence_required", "col_evidence_required", + "year_end_month", "year_end_day", "updated_at") + read_only_fields = ("id", "updated_at") + + +class HolidayItemSerializer(serializers.Serializer): + date = serializers.DateField() + name = serializers.CharField(max_length=120, required=False, + allow_blank=True, default="") + kind = serializers.ChoiceField(choices=(("closed", "closed"), + ("restricted", "restricted")), + default="closed") + + +class CalendarWriteSerializer(serializers.Serializer): + title = serializers.CharField(max_length=120) + academic_year = serializers.CharField(max_length=20, required=False, + allow_blank=True, default="") + holidays = HolidayItemSerializer(many=True, required=False, + default=list) + publish = serializers.BooleanField(required=False, default=False) + + +class VacationWriteSerializer(serializers.Serializer): + id = serializers.IntegerField(required=False, allow_null=True, + default=None) + name = serializers.CharField(max_length=120) + academic_year = serializers.CharField(max_length=20, required=False, + allow_blank=True, default="") + start_date = serializers.DateField() + end_date = serializers.DateField() + source_ref = serializers.CharField(max_length=200, required=False, + allow_blank=True, default="") + + +class OrgUnitSerializer(serializers.ModelSerializer): + class Meta: + model = OrganizationalUnit + fields = ("id", "code", "name", "kind", "head_user_id", "created_at") + read_only_fields = ("id", "created_at") + + +class OrgUnitWriteSerializer(serializers.Serializer): + id = serializers.IntegerField(required=False, allow_null=True, + default=None) + code = serializers.CharField(max_length=80) + name = serializers.CharField(max_length=160, required=False, + allow_blank=True, default="") + kind = serializers.ChoiceField(choices=(("department", "department"), + ("section", "section"), + ("office", "office")), + default="department") + head_user_id = serializers.IntegerField(required=False, allow_null=True, + default=None) + + +class AuthoritySerializer(serializers.ModelSerializer): + class Meta: + model = AuthorityAssignment + fields = ("id", "role_code", "user_id", "org_unit", "categories") + read_only_fields = ("id",) + + +class AuthorityWriteSerializer(serializers.Serializer): + id = serializers.IntegerField(required=False, allow_null=True, + default=None) + role_code = serializers.ChoiceField(choices=( + ("establishment", "establishment"), ("dean", "dean"), + ("registrar", "registrar"), ("director", "director"))) + user_id = serializers.IntegerField() + org_unit_id = serializers.IntegerField(required=False, allow_null=True, + default=None) + categories = serializers.ListField(child=serializers.CharField(), + required=False, default=list) + + +class SlaThresholdSerializer(serializers.ModelSerializer): + class Meta: + model = SlaThreshold + fields = ("id", "task_class", "reminder_hours", "escalation_hours", + "escalation_user_id") + read_only_fields = ("id",) + + +class SlaWriteItemSerializer(serializers.Serializer): + task_class = serializers.ChoiceField(choices=SlaThreshold.TASK_CLASS) + reminder_hours = serializers.IntegerField(required=False, allow_null=True, + default=None) + escalation_hours = serializers.IntegerField(required=False, + allow_null=True, default=None) + escalation_user_id = serializers.IntegerField(required=False, + allow_null=True, + default=None) + + +class SlaWriteSerializer(serializers.Serializer): + thresholds = SlaWriteItemSerializer(many=True) + + +class OfflineLeaveSerializer(serializers.Serializer): + user_id = serializers.IntegerField() + category = serializers.ChoiceField(choices=CATEGORY_CHOICES) + start_date = serializers.DateField() + end_date = serializers.DateField() + half_day = serializers.ChoiceField(choices=list(HALF_DAY_CHOICES), + default="none") + purpose = serializers.CharField(required=False, allow_blank=True, + default="") + sanction_reference = serializers.CharField(max_length=200, allow_blank=True) + note = serializers.CharField(required=False, allow_blank=True, default="") + + +class AdjustBalanceSerializer(serializers.Serializer): + user_id = serializers.IntegerField() + category = serializers.ChoiceField(choices=CATEGORY_CHOICES) + year = serializers.IntegerField() + days = serializers.DecimalField(max_digits=6, decimal_places=2) + note = serializers.CharField(max_length=300, required=False, + allow_blank=True, default="") + + +class AnnotateSerializer(serializers.Serializer): + note = serializers.CharField(max_length=1000) diff --git a/modules/leave/api/urls.py b/modules/leave/api/urls.py new file mode 100644 index 00000000..9a4b3686 --- /dev/null +++ b/modules/leave/api/urls.py @@ -0,0 +1,70 @@ +from django.urls import path + +from modules.leave.api import views as v + +urlpatterns = [ + # applications + path("requests", v.RequestListView.as_view(), name="leave-requests"), + path("requests/", v.RequestDetailView.as_view(), + name="leave-request"), + path("requests//history", v.RequestHistoryView.as_view(), + name="leave-request-history"), + path("requests//withdraw", v.RequestWithdrawView.as_view(), + name="leave-request-withdraw"), + path("requests//cancellation", v.RequestCancellationView.as_view(), + name="leave-request-cancellation"), + path("requests//extension", v.RequestExtensionView.as_view(), + name="leave-request-extension"), + path("requests//resumption", v.RequestResumptionView.as_view(), + name="leave-request-resumption"), + path("requests//substitute", + v.RequestSubstituteNominateView.as_view(), + name="leave-request-substitute"), + path("requests//substitute/respond", + v.SubstituteRespondView.as_view(), + name="leave-substitute-respond"), + path("requests//review", v.RequestReviewView.as_view(), + name="leave-request-review"), + path("requests//route", v.RequestRouteView.as_view(), + name="leave-request-route"), + path("requests//sanction", v.RequestSanctionView.as_view(), + name="leave-request-sanction"), + path("requests//annotate", v.RequestAnnotateView.as_view(), + name="leave-request-annotate"), + path("resumptions//review", v.ResumptionReviewView.as_view(), + name="leave-resumption-review"), + + # day-to-day + path("tasks", v.TaskListView.as_view(), name="leave-tasks"), + path("balances", v.BalanceListView.as_view(), name="leave-balances"), + path("balances/ledger", v.BalanceLedgerView.as_view(), + name="leave-balance-ledger"), + path("notifications", v.NotificationListView.as_view(), + name="leave-notifications"), + path("notifications/read", v.NotificationReadView.as_view(), + name="leave-notifications-read"), + path("calendar", v.CalendarView.as_view(), name="leave-calendar"), + path("policy", v.PolicyView.as_view(), name="leave-policy"), + path("employees", v.EmployeeSearchView.as_view(), name="leave-employees"), + + # administration + path("calendars", v.CalendarListView.as_view(), name="leave-calendars"), + path("calendars//publish", v.CalendarPublishView.as_view(), + name="leave-calendar-publish"), + path("vacation-periods", v.VacationListView.as_view(), + name="leave-vacation-periods"), + path("org-units", v.OrgUnitListView.as_view(), name="leave-org-units"), + path("org-units/", v.OrgUnitDetailView.as_view(), + name="leave-org-unit"), + path("authorities", v.AuthorityListView.as_view(), + name="leave-authorities"), + path("authorities/", v.AuthorityDetailView.as_view(), + name="leave-authority"), + path("sla", v.SlaView.as_view(), name="leave-sla"), + path("admin/offline-leave", v.OfflineLeaveView.as_view(), + name="leave-offline"), + path("admin/balances/adjust", v.AdjustBalanceView.as_view(), + name="leave-adjust"), + path("admin/year-end", v.YearEndStatusView.as_view(), + name="leave-year-end-status"), +] diff --git a/modules/leave/api/views.py b/modules/leave/api/views.py new file mode 100644 index 00000000..c617090b --- /dev/null +++ b/modules/leave/api/views.py @@ -0,0 +1,528 @@ +"""HTTP layer: parse, scope, delegate, serialise. + +Reads take their queryset from `selectors.scoping`, so authorisation is +never re-derived per endpoint: a row you may not see is a 404, not a 403. +Writes go straight to the services, which own permission, identity and the +state machine — the view only parses and translates. +""" +from __future__ import annotations + +from rest_framework import generics, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from core.api.exceptions import BadRequestError +from fusion_auth.permissions import HasModuleGrant, HasPermission +from modules.directory import contracts as directory +from modules.leave.api import serializers as s +from modules.leave.models import LeaveYearRun +from modules.leave.selectors import scoping +from modules.leave.services import ( + applications, + authz, + balances, + calendar as calendar_service, + decisions, + hierarchy, + notifications, + policy as policy_service, + records, +) +from modules.leave.services.policy import EDITABLE + +MODULE = HasModuleGrant("leave")() + + +def _perm(*codes, require_all: bool = False): + """An instance, because `get_permissions` must hand DRF instances — + placement gets that for free via `permission_classes`.""" + return HasPermission(*codes, require_all=require_all)() + +P_VIEW_SELF = "leave.request.view_self" +P_VIEW = "leave.request.view" +P_CREATE = "leave.request.create" +P_BALANCE_VIEW = "leave.balance.view" +P_TYPE_MANAGE = "leave.type.manage" +P_CALENDAR_MANAGE = "leave.calendar.manage" +P_RECORD_MANAGE = "leave.record.manage" +P_SLA_MANAGE = "leave.sla.manage" + + +def _actor(request): + return getattr(request, "principal", None) + + +def _int_param(params, name): + raw = params.get(name) + if raw in (None, ""): + return None + try: + return int(raw) + except (TypeError, ValueError) as exc: + raise BadRequestError(f"{name} must be a number.", + code="invalid_filter") from exc + + +def _people_for(*requests_rows) -> dict: + ids: set[int] = set() + for row in requests_rows: + if row is None: + continue + ids.add(row.user_id) + if row.substitute_user_id: + ids.add(row.substitute_user_id) + return directory.get_users(sorted(ids)) + + +def _detail(obj, request, code=status.HTTP_200_OK) -> Response: + data = s.LeaveRequestSerializer( + obj, context={"actor": _actor(request), + "people": _people_for(obj)}).data + return Response(data, status=code) + + +def _list_payload(qs, request) -> Response: + rows = list(qs[:200]) + ids: set[int] = set() + for row in rows: + ids.add(row.user_id) + if row.substitute_user_id: + ids.add(row.substitute_user_id) + data = s.LeaveRequestSerializer( + rows, many=True, + context={"actor": _actor(request), + "people": directory.get_users(sorted(ids))}).data + return Response({"count": qs.count(), "results": data}) + + +# -- requests --------------------------------------------------------------- +class RequestListView(generics.GenericAPIView): + """GET: scoped list. POST: a new application (EL-UC-001).""" + + serializer_class = s.LeaveRequestSerializer + + def get_permissions(self): + if self.request.method == "POST": + return [MODULE, _perm(P_CREATE)] + return [MODULE, _perm(P_VIEW_SELF, P_VIEW)] + + def get_queryset(self): + actor = _actor(self.request) + qs = scoping.requests_for(actor) + params = self.request.query_params + if params.get("mine"): + qs = qs.filter(user_id=getattr(actor, "user_id", None)) + for field in ("category", "status"): + if params.get(field): + qs = qs.filter(**{field: params[field]}) + year = _int_param(params, "year") + if year is not None: + qs = qs.filter(year=year) + user_id = _int_param(params, "user_id") + if user_id is not None: + qs = qs.filter(user_id=user_id) + return qs.order_by("-created_at") + + def get(self, request, *args, **kwargs): + return _list_payload(self.get_queryset(), request) + + def post(self, request, *args, **kwargs): + payload = s.SubmitSerializer(data=request.data) + payload.is_valid(raise_exception=True) + obj = applications.submit(actor=_actor(request), + **dict(payload.validated_data)) + return _detail(obj, request, status.HTTP_201_CREATED) + + +class RequestDetailView(APIView): + def get(self, request, pk): + obj = scoping.request_or_404(_actor(request), pk) + return _detail(obj, request) + + +class RequestHistoryView(APIView): + def get(self, request, pk): + obj = scoping.request_or_404(_actor(request), pk) + rows = obj.events.all() + return Response(s.LeaveEventSerializer(rows, many=True).data) + + +class RequestWithdrawView(APIView): + def post(self, request, pk): + obj = scoping.request_for_action(pk) + return _detail(applications.withdraw(_actor(request), obj), request) + + +class RequestCancellationView(APIView): + def post(self, request, pk): + obj = scoping.request_for_action(pk) + payload = s.ReasonSerializer(data=request.data) + payload.is_valid(raise_exception=True) + derived = applications.request_cancellation( + _actor(request), obj, **dict(payload.validated_data)) + return _detail(derived, request, status.HTTP_201_CREATED) + + +class RequestExtensionView(APIView): + def post(self, request, pk): + obj = scoping.request_for_action(pk) + payload = s.ExtensionSerializer(data=request.data) + payload.is_valid(raise_exception=True) + derived = applications.request_extension( + _actor(request), obj, **dict(payload.validated_data)) + return _detail(derived, request, status.HTTP_201_CREATED) + + +class RequestResumptionView(APIView): + def post(self, request, pk): + obj = scoping.request_for_action(pk) + payload = s.ResumptionFileSerializer(data=request.data) + payload.is_valid(raise_exception=True) + resumption = applications.submit_resumption( + _actor(request), obj, **dict(payload.validated_data)) + return Response(s.LeaveResumptionSerializer(resumption).data, + status=status.HTTP_201_CREATED) + + +class RequestSubstituteNominateView(APIView): + def post(self, request, pk): + obj = scoping.request_for_action(pk) + payload = s.NominateSerializer(data=request.data) + payload.is_valid(raise_exception=True) + out = applications.nominate_substitute( + _actor(request), obj, + substitute_user_id=payload.validated_data["substitute_user_id"]) + return _detail(out, request) + + +class SubstituteRespondView(APIView): + def post(self, request, pk): + obj = scoping.request_for_action(pk) + payload = s.SubstituteRespondSerializer(data=request.data) + payload.is_valid(raise_exception=True) + out = decisions.substitute_respond( + _actor(request), obj, **dict(payload.validated_data)) + return _detail(out, request) + + +class RequestReviewView(APIView): + def post(self, request, pk): + obj = scoping.request_for_action(pk) + payload = s.ReviewSerializer(data=request.data) + payload.is_valid(raise_exception=True) + out = decisions.unit_head_review(_actor(request), obj, + **dict(payload.validated_data)) + return _detail(out, request) + + +class RequestRouteView(APIView): + def post(self, request, pk): + obj = scoping.request_for_action(pk) + payload = s.NoteSerializer(data=request.data) + payload.is_valid(raise_exception=True) + out = decisions.establishment_route(_actor(request), obj, + **dict(payload.validated_data)) + return _detail(out, request) + + +class RequestSanctionView(APIView): + def post(self, request, pk): + obj = scoping.request_for_action(pk) + payload = s.SanctionSerializer(data=request.data) + payload.is_valid(raise_exception=True) + out = decisions.sanction(_actor(request), obj, + **dict(payload.validated_data)) + return _detail(out, request) + + +class RequestAnnotateView(APIView): + def post(self, request, pk): + obj = scoping.request_for_action(pk) + payload = s.AnnotateSerializer(data=request.data) + payload.is_valid(raise_exception=True) + out = records.annotate(_actor(request), obj, + note=payload.validated_data["note"]) + return _detail(out, request) + + +class ResumptionReviewView(APIView): + def post(self, request, pk): + from modules.leave.models import LeaveResumption + + actor = _actor(request) + resumption = LeaveResumption.objects.filter(pk=pk).first() + if resumption is None: + from core.api.exceptions import NotFoundError + raise NotFoundError("Resumption not found.", code="not_found") + # Scope through the request the resumption belongs to, so a foreign + # id is a 404 before any permission question arises. + scoping.request_or_404(actor, resumption.request_id) + payload = s.ResumptionReviewSerializer(data=request.data) + payload.is_valid(raise_exception=True) + out = decisions.review_resumption(actor, resumption, + **dict(payload.validated_data)) + return Response(s.LeaveResumptionSerializer(out).data) + + +# -- tasks, balances, notifications ---------------------------------------- +class TaskListView(APIView): + def get(self, request): + return _list_payload(scoping.tasks_for(_actor(request)), request) + + +class BalanceListView(APIView): + def get(self, request): + actor = _actor(request) + target = _int_param(request.query_params, "user_id") + if target is not None and target != getattr(actor, "user_id", None): + authz.require(actor, P_BALANCE_VIEW) + year = _int_param(request.query_params, "year") + year = year if year is not None else policy_service.current_leave_year() + person_id = target if target is not None else getattr(actor, "user_id", + None) + if person_id is None: + return Response({"count": 0, "results": []}) + balances.ensure_year(person_id, year) + rows = scoping.balances_rows_for(actor, target).filter(year=year) + return Response({"count": rows.count(), + "results": s.BalanceSerializer(rows, many=True).data, + "year": year}) + + +class BalanceLedgerView(APIView): + def get(self, request): + actor = _actor(request) + target = _int_param(request.query_params, "user_id") + if target is not None and target != getattr(actor, "user_id", None): + authz.require(actor, P_BALANCE_VIEW) + person_id = target if target is not None else getattr(actor, "user_id", + None) + if person_id is None: + return Response({"count": 0, "results": []}) + from modules.leave.models import LeaveAccountTransaction + rows = (LeaveAccountTransaction.objects + .filter(user_id=person_id).order_by("-at")[:200]) + data = [{"id": r.id, "category": r.category, "year": r.year, + "kind": r.kind, "days": r.days, "request": r.request_id, + "note": r.note, "at": r.at} for r in rows] + return Response({"count": len(data), "results": data}) + + +class NotificationListView(APIView): + def get(self, request): + rows = scoping.notifications_for(_actor(request))[:50] + return Response(s.NotificationSerializer(rows, many=True).data) + + +class NotificationReadView(APIView): + def post(self, request): + payload = s.MarkReadSerializer(data=request.data) + payload.is_valid(raise_exception=True) + updated = notifications.mark_read( + _actor(request), payload.validated_data.get("ids") or None) + return Response({"marked": updated}) + + +# -- calendars, policy, administration ------------------------------------- +class CalendarView(APIView): + def get(self, request): + authz.require(request.principal, P_VIEW_SELF) + return Response(calendar_service.calendar_payload()) + + +class CalendarListView(generics.GenericAPIView): + serializer_class = s.CalendarWriteSerializer + + def get_permissions(self): + if self.request.method == "POST": + return [MODULE, _perm(P_CALENDAR_MANAGE)] + return [MODULE, _perm(P_CALENDAR_MANAGE)] + + def get(self, request): + from modules.leave.models import HolidayCalendar + rows = HolidayCalendar.objects.all()[:50] + data = [{"id": c.id, "title": c.title, "academic_year": c.academic_year, + "status": c.status, "published_at": c.published_at, + "holidays": c.holidays.count()} for c in rows] + return Response(data) + + def post(self, request): + payload = s.CalendarWriteSerializer(data=request.data) + payload.is_valid(raise_exception=True) + calendar = calendar_service.create_calendar( + _actor(request), **dict(payload.validated_data)) + return Response({"id": calendar.id, "status": calendar.status}, + status=status.HTTP_201_CREATED) + + +class CalendarPublishView(APIView): + def post(self, request, pk): + calendar = calendar_service.publish_calendar(_actor(request), pk) + return Response({"id": calendar.id, "status": calendar.status}) + + +class VacationListView(generics.GenericAPIView): + serializer_class = s.VacationWriteSerializer + + def get_permissions(self): + if self.request.method == "POST": + return [MODULE, _perm(P_CALENDAR_MANAGE)] + return [MODULE, _perm(P_VIEW_SELF)] + + def get(self, request): + from modules.leave.models import VacationPeriod + rows = VacationPeriod.objects.all() + data = [{"id": v.id, "name": v.name, "academic_year": v.academic_year, + "start_date": v.start_date, "end_date": v.end_date, + "source_ref": v.source_ref} for v in rows] + return Response(data) + + def post(self, request): + payload = s.VacationWriteSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = calendar_service.upsert_vacation(_actor(request), + dict(payload.validated_data)) + return Response({"id": row.id}, status=status.HTTP_201_CREATED) + + +class PolicyView(APIView): + def get(self, request): + authz.require(request.principal, P_VIEW_SELF) + return Response(s.PolicySerializer(policy_service.current_policy()).data) + + def put(self, request): + payload = s.PolicySerializer(data=request.data, partial=True) + payload.is_valid(raise_exception=True) + data = {k: v for k, v in payload.validated_data.items() + if k in EDITABLE} + policy = policy_service.update_policy(_actor(request), data) + return Response(s.PolicySerializer(policy).data) + + +class OrgUnitListView(generics.GenericAPIView): + serializer_class = s.OrgUnitSerializer + + def get_permissions(self): + if self.request.method == "POST": + return [MODULE, _perm(P_TYPE_MANAGE)] + return [MODULE, _perm(P_VIEW_SELF)] + + def get(self, request): + from modules.leave.models import OrganizationalUnit + rows = OrganizationalUnit.objects.all() + return Response(s.OrgUnitSerializer(rows, many=True).data) + + def post(self, request): + payload = s.OrgUnitWriteSerializer(data=request.data) + payload.is_valid(raise_exception=True) + unit = hierarchy.configure_unit(_actor(request), + dict(payload.validated_data)) + return Response(s.OrgUnitSerializer(unit).data, + status=status.HTTP_201_CREATED) + + +class OrgUnitDetailView(APIView): + def put(self, request, pk): + payload = s.OrgUnitWriteSerializer(data=request.data) + payload.is_valid(raise_exception=True) + data = dict(payload.validated_data) + data["id"] = pk + unit = hierarchy.configure_unit(_actor(request), data) + return Response(s.OrgUnitSerializer(unit).data) + + +class AuthorityListView(generics.GenericAPIView): + serializer_class = s.AuthoritySerializer + + def get_permissions(self): + if self.request.method == "POST": + return [MODULE, _perm(P_TYPE_MANAGE)] + return [MODULE, _perm(P_VIEW_SELF)] + + def get(self, request): + from modules.leave.models import AuthorityAssignment + rows = AuthorityAssignment.objects.all() + return Response(s.AuthoritySerializer(rows, many=True).data) + + def post(self, request): + payload = s.AuthorityWriteSerializer(data=request.data) + payload.is_valid(raise_exception=True) + row = hierarchy.configure_authority(_actor(request), + dict(payload.validated_data)) + return Response(s.AuthoritySerializer(row).data, + status=status.HTTP_201_CREATED) + + +class AuthorityDetailView(APIView): + def delete(self, request, pk): + hierarchy.remove_authority(_actor(request), pk) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class SlaView(APIView): + def get(self, request): + from modules.leave.models import SlaThreshold + rows = SlaThreshold.objects.all().order_by("task_class") + return Response(s.SlaThresholdSerializer(rows, many=True).data) + + def put(self, request): + authz.require(request.principal, P_SLA_MANAGE) + payload = s.SlaWriteSerializer(data=request.data) + payload.is_valid(raise_exception=True) + from modules.leave.models import SlaThreshold + out = [] + for item in payload.validated_data["thresholds"]: + row, _ = SlaThreshold.objects.update_or_create( + task_class=item["task_class"], + defaults={"reminder_hours": item["reminder_hours"], + "escalation_hours": item["escalation_hours"], + "escalation_user_id": item["escalation_user_id"]}) + out.append(row) + return Response(s.SlaThresholdSerializer(out, many=True).data) + + +class EmployeeSearchView(APIView): + """Picker for substitutes and assignments — directory search behind this + module's own gates, never a raw cross-module call from the client.""" + + def get_permissions(self): + return [MODULE, _perm(P_CREATE, P_RECORD_MANAGE)] + + def get(self, request): + q = request.query_params.get("q", "").strip() + kind = request.query_params.get("kind") or None + rows = directory.search(q=q, kind=kind, limit=25) + return Response([ + {"user_id": p.user_id, "username": p.username, + "display_name": p.display_name, "kind": p.kind, + "department": p.department} + for p in rows + ]) + + +class OfflineLeaveView(APIView): + def post(self, request): + payload = s.OfflineLeaveSerializer(data=request.data) + payload.is_valid(raise_exception=True) + obj = applications.record_offline(_actor(request), + **dict(payload.validated_data)) + return _detail(obj, request, status.HTTP_201_CREATED) + + +class AdjustBalanceView(APIView): + def post(self, request): + payload = s.AdjustBalanceSerializer(data=request.data) + payload.is_valid(raise_exception=True) + txn = records.adjust_balance(_actor(request), + **dict(payload.validated_data)) + return Response({"id": txn.id, "category": txn.category, + "year": txn.year, "days": txn.days, + "kind": txn.kind}, + status=status.HTTP_201_CREATED) + + +class YearEndStatusView(APIView): + def get(self, request): + rows = LeaveYearRun.objects.all()[:5] + return Response([{"year": r.year, "processed_at": r.processed_at, + "report": r.report} for r in rows]) diff --git a/modules/leave/apps.py b/modules/leave/apps.py new file mode 100644 index 00000000..c6ab111a --- /dev/null +++ b/modules/leave/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LeaveConfig(AppConfig): + name = "modules.leave" + label = "leave" diff --git a/modules/leave/contracts.py b/modules/leave/contracts.py new file mode 100644 index 00000000..b586cfc6 --- /dev/null +++ b/modules/leave/contracts.py @@ -0,0 +1,71 @@ +"""leave's public surface. Plural by signature. + +Other modules ask whether someone is away; nobody asks for one person at a +time — a timetabler checks a panel. +""" +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import date + +from django.db.models.functions import Coalesce + +from modules.leave.domain.states import APPROVED + + +@dataclass(frozen=True) +class LeaveStatusDTO: + user_id: int + on_leave: bool + until: date | None + categories: tuple[str, ...] + request_ids: tuple[int, ...] + + +def get_leave_status(user_ids: Sequence[int], + on_date: date) -> dict[int, LeaveStatusDTO]: + """Who is away on `on_date`: approved leave spanning that date, using + the actual return date where a resumption review recorded one. + + A missing user is absent from the mapping — the same visible, testable + case directory exposes, rather than a silent default. + """ + ids = {int(i) for i in user_ids if i is not None} + if not ids: + return {} + + from modules.leave.models import LeaveRequest + + rows = (LeaveRequest.objects + .filter(user_id__in=ids, status=APPROVED, + start_date__lte=on_date) + .annotate(effective_end=Coalesce("actual_end_date", "end_date")) + .filter(effective_end__gte=on_date) + .order_by("start_date")) + + found: dict[int, tuple[set[str], date, list[int]]] = {} + for row in rows: + categories, until, req_ids = found.get( + row.user_id, (set(), on_date, [])) + categories.add(row.category) + until = max(until, row.effective_end) + req_ids.append(row.id) + found[row.user_id] = (categories, until, req_ids) + + return { + uid: LeaveStatusDTO( + user_id=uid, + on_leave=uid in found, + until=found[uid][1] if uid in found else None, + categories=tuple(sorted(found[uid][0])) if uid in found else (), + request_ids=tuple(found[uid][2]) if uid in found else (), + ) + for uid in ids + } + + +def on_leave_ids(user_ids: Sequence[int], on_date: date) -> set[int]: + """The filter everyone actually wants: which of these people are away.""" + return {uid for uid, dto in get_leave_status(user_ids, on_date).items() + if dto.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..37ced62f --- /dev/null +++ b/modules/leave/domain/categories.py @@ -0,0 +1,70 @@ +"""Leave categories and the policy-shaped facts that belong to each one. + +Pure constants and predicates — the shape of the ELM baseline's seven leave +categories (PS §2–4). Everything here is data a rule needs, never a rule +itself; the rules live in the other domain modules and the services. +""" +from __future__ import annotations + +CL = "cl" +RH = "rh" +SCL = "scl" +EL = "el" +COL = "col" +VL = "vl" + +CATEGORIES: tuple[str, ...] = (CL, RH, SCL, EL, COL, VL) + +CATEGORY_LABELS: dict[str, str] = { + CL: "Casual Leave", + RH: "Restricted Holiday", + SCL: "Special Casual Leave", + EL: "Earned Leave", + COL: "Commuted Leave", + VL: "Vacation Leave", +} + +#: BR-EL-011: only these count continuously across weekends and closed +#: holidays inside the interval. CL, SCL and RH are counted on working days. +CONTINUOUS_CATEGORIES: frozenset[str] = frozenset({EL, COL, VL}) + +#: BR-EL-010: only CL supports first-half / second-half requests, and an +#: approved half day consumes 0.5 day. +HALF_DAY_CATEGORIES: frozenset[str] = frozenset({CL}) + +#: Year-end outcomes (PS §3, BR-EL-002…009). EL and COL carry forward; +#: CL, RH and SCL lapse; faculty VL converts to EL at the policy ratio and +#: itself does not survive the boundary. +CARRY_FORWARD_CATEGORIES: frozenset[str] = frozenset({EL, COL}) +LAPSING_CATEGORIES: frozenset[str] = frozenset({CL, RH, SCL}) +CONVERTING_CATEGORIES: frozenset[str] = frozenset({VL}) + +#: BR-EL-025 / CFR-EL-020: extension and resumption (CFR-EL-021) apply only +#: to these categories. CL/RH/SCL have no extension or formal resumption in +#: this baseline. +EXTENDABLE_CATEGORIES: frozenset[str] = frozenset({EL, COL, VL}) +RESUMABLE_CATEGORIES: frozenset[str] = frozenset({EL, COL, VL}) + +#: BR-EL-006: VL is faculty-only and only during Academic Calendar vacation +#: periods. A non-faculty VL request is not merely unusual, it is invalid. +FACULTY_ONLY_CATEGORIES: frozenset[str] = frozenset({VL}) + +HALF_FIRST = "first" +HALF_SECOND = "second" +HALF_NONE = "none" +HALF_DAY_CHOICES: tuple[str, ...] = (HALF_NONE, HALF_FIRST, HALF_SECOND) + + +def is_continuous(category: str) -> bool: + return category in CONTINUOUS_CATEGORIES + + +def label_for(category: str) -> str: + return CATEGORY_LABELS.get(category, category) + + +def half_day_factor(half_day: str, category: str) -> float: + """0.5 for a half-day CL, 1.0 otherwise (BR-EL-010).""" + if category in HALF_DAY_CATEGORIES and half_day in (HALF_FIRST, HALF_SECOND): + return 0.5 + return 1.0 diff --git a/modules/leave/domain/counting.py b/modules/leave/domain/counting.py new file mode 100644 index 00000000..268fe6fa --- /dev/null +++ b/modules/leave/domain/counting.py @@ -0,0 +1,67 @@ +"""Chargeable-day counting (BR-EL-010, BR-EL-011, CFR-EL-010, CFR-EL-003.3). + +Pure functions over dates. The closed-holiday set is injected rather than +looked up, so the rule is testable without a calendar row and the service +remains the only place that knows where holidays come from. +""" +from __future__ import annotations + +from datetime import date, timedelta +from decimal import Decimal + +from modules.leave.domain.categories import ( + CONTINUOUS_CATEGORIES, + HALF_FIRST, + HALF_SECOND, + is_continuous, +) + +DAY = Decimal("1") +HALF = Decimal("0.5") + + +class CountingError(ValueError): + """The interval itself is malformed (end before start).""" + + +def daterange(start: date, end: date) -> list[date]: + if end < start: + raise CountingError("end date is before start date") + return [start + timedelta(days=i) for i in range((end - start).days + 1)] + + +def chargeable_days( + *, + category: str, + start: date, + end: date, + half_day: str = "none", + closed_holidays: frozenset[date] | set[date] = frozenset(), +) -> Decimal: + """Days this request consumes, per the category's counting rule. + + For EL, COL and VL every day in the interval counts — weekends and closed + holidays inside a continuous absence are still absence (BR-EL-011). For + CL, SCL and RH only days that are neither a weekend nor a closed holiday + count. A half-day CL on a single date is exactly 0.5 (BR-EL-010); + a half-day value on a multi-day CL interval is a caller error. + """ + if category in CONTINUOUS_CATEGORIES: + span = Decimal(len(daterange(start, end))) + return span + + days = Decimal(0) + for d in daterange(start, end): + if d.weekday() >= 5 or d in closed_holidays: + continue + days += DAY + + if half_day in (HALF_FIRST, HALF_SECOND): + # A half-day request spans one date. 0.5 only if that date counts at + # all — a half-day CL on a closed holiday consumes nothing. + return HALF if days >= 1 else Decimal(0) + return days + + +def is_continuous_category(category: str) -> bool: + return is_continuous(category) diff --git a/modules/leave/domain/states.py b/modules/leave/domain/states.py new file mode 100644 index 00000000..a98c1c2f --- /dev/null +++ b/modules/leave/domain/states.py @@ -0,0 +1,174 @@ +"""The leave-request state machine (CW-EL-01…04, the authoritative STTs). + +Pure Python, no Django. The STT in the workflow specification is the source: +every status below appears there, and every transition carries the actor kind +and the permission code a service must enforce. Guards are string names so +this module never has to know what a balance or a calendar is. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +# -- statuses (workflow state names, snake_cased) -------------------------- +AWAITING_SUBSTITUTE = "awaiting_substitute_response" +APPLICANT_ACTION = "applicant_action_required" +AWAITING_UH_REVIEW = "awaiting_unit_head_review" +AWAITING_ESTABLISHMENT = "awaiting_establishment_routing" +AWAITING_FINAL = "awaiting_final_sanction_decision" +AWAITING_DIRECTOR = "awaiting_director_self_sanction" + +APPROVED = "approved" +REJECTED = "rejected" +WITHDRAWN = "withdrawn" +CANCELLED = "cancelled" # a base request after its cancellation is approved + +STATUS_CHOICES: list[tuple[str, str]] = [ + (AWAITING_SUBSTITUTE, "Awaiting substitute response"), + (APPLICANT_ACTION, "Applicant action required"), + (AWAITING_UH_REVIEW, "Awaiting Unit Head review"), + (AWAITING_ESTABLISHMENT, "Awaiting establishment routing"), + (AWAITING_FINAL, "Awaiting final sanction decision"), + (AWAITING_DIRECTOR, "Awaiting Director self-sanction"), + (APPROVED, "Approved"), + (REJECTED, "Rejected"), + (WITHDRAWN, "Withdrawn"), + (CANCELLED, "Cancelled"), +] + +#: Still in flight: withdrawal is available, tasks are actionable, SLA +#: thresholds apply (BR-EL-022, BR-EL-032/033). +PENDING: frozenset[str] = frozenset({ + AWAITING_SUBSTITUTE, APPLICANT_ACTION, AWAITING_UH_REVIEW, + AWAITING_ESTABLISHMENT, AWAITING_FINAL, AWAITING_DIRECTOR, +}) + +TERMINAL: frozenset[str] = frozenset({APPROVED, REJECTED, WITHDRAWN, CANCELLED}) + +# -- actor kinds ----------------------------------------------------------- +EMPLOYEE = "employee" +SUBSTITUTE = "substitute" +UNIT_HEAD = "unit_head" +ESTABLISHMENT = "establishment" +AUTHORITY = "authority" +SYSTEM = "system" + +# -- outbound transitions --------------------------------------------------- +# Withdrawal (EL-UC-007) is cross-cutting across every pending state and is +# declared once below rather than repeated per state. + + +class InvalidTransition(Exception): + def __init__(self, frm: str, to: str): + super().__init__(f"Cannot move a leave request from {frm!r} to {to!r}.") + self.frm, self.to = frm, to + + +class ActorNotAllowed(InvalidTransition): + def __init__(self, actor_kind: str, frm: str, to: str): + super().__init__(frm, to) + self.actor_kind = actor_kind + self.args = (f"A {actor_kind} cannot move a leave request from " + f"{frm!r} to {to!r}.") + + +@dataclass(frozen=True) +class Transition: + frm: str + to: str + actor: str + permission: str + #: Substitute consent gate (BR-EL-015): routing cannot continue until the + #: nominated substitute accepts. Decline parks the request with the + #: applicant (EL-UC-019 / EL-UC-007 recovery). + guards: tuple[str, ...] = field(default_factory=tuple) + + +_TRANSITIONS: tuple[Transition, ...] = ( + # EL-UC-002 — substitute responds. + Transition(AWAITING_SUBSTITUTE, AWAITING_UH_REVIEW, SUBSTITUTE, + "leave.substitute.respond", ("substitute_accepted",)), + Transition(AWAITING_SUBSTITUTE, AWAITING_DIRECTOR, SUBSTITUTE, + "leave.substitute.respond", ("substitute_accepted", + "applicant_is_director")), + # Self-approval guard: when the applicant *is* their unit's head, the + # file skips their own desk and enters at establishment after consent. + Transition(AWAITING_SUBSTITUTE, AWAITING_ESTABLISHMENT, SUBSTITUTE, + "leave.substitute.respond", ("substitute_accepted", + "applicant_is_unit_head")), + Transition(AWAITING_SUBSTITUTE, APPLICANT_ACTION, SUBSTITUTE, + "leave.substitute.respond", ("substitute_declined",)), + + # EL-UC-019 — applicant replaces a declined substitute. + Transition(APPLICANT_ACTION, AWAITING_SUBSTITUTE, EMPLOYEE, + "leave.request.create", ("replacement_nominated",)), + + # EL-UC-003 — Unit Head review. + # CL/RH (and their cancellations) are Unit-Head-final (BR-EL-016). + Transition(AWAITING_UH_REVIEW, APPROVED, UNIT_HEAD, + "leave.request.approve", ("unit_head_final", "outcome_approve")), + Transition(AWAITING_UH_REVIEW, REJECTED, UNIT_HEAD, + "leave.request.approve", ("unit_head_final", "outcome_reject")), + # Higher-sanction (SCL/EL/COL/VL, extensions, higher cancellations): both + # recommendation outcomes continue; Not Recommended is NOT final + # (BR-EL-017). + Transition(AWAITING_UH_REVIEW, AWAITING_ESTABLISHMENT, UNIT_HEAD, + "leave.request.approve", ("higher_sanction", + "establishment_configured")), + Transition(AWAITING_UH_REVIEW, AWAITING_FINAL, UNIT_HEAD, + "leave.request.approve", ("higher_sanction", + "no_establishment_step")), + + # EL-UC-004 — Establishment verifies and forwards (BR-EL-019). + Transition(AWAITING_ESTABLISHMENT, AWAITING_FINAL, ESTABLISHMENT, + "leave.request.route", ()), + + # EL-UC-005 — final sanction, including Director self-sanction (BR-EL-020). + Transition(AWAITING_FINAL, APPROVED, AUTHORITY, + "leave.request.sanction", ("outcome_approve",)), + Transition(AWAITING_FINAL, REJECTED, AUTHORITY, + "leave.request.sanction", ("outcome_reject",)), + Transition(AWAITING_DIRECTOR, APPROVED, AUTHORITY, + "leave.request.sanction", ("applicant_is_director", + "outcome_approve")), + Transition(AWAITING_DIRECTOR, REJECTED, AUTHORITY, + "leave.request.sanction", ("applicant_is_director", + "outcome_reject")), +) + +#: Withdrawal from every pending pre-final state (BR-EL-022, CFR-EL-018). +#: Immediate: no approval of the withdrawal itself is required. +WITHDRAWABLE: frozenset[str] = PENDING + +_INDEX: dict[tuple[str, str], list[Transition]] = {} +for _t in _TRANSITIONS: + _INDEX.setdefault((_t.frm, _t.to), []).append(_t) + + +def candidates(frm: str, to: str) -> list[Transition]: + return list(_INDEX.get((frm, to), ())) + + +def resolve(frm: str, to: str, actor_kind: str) -> Transition: + """The one transition matching this move and actor, or an exception. + + Withdrawal is resolved by the service (its permission is the applicant's + own `leave.request.delete`), so it is not in this table. + """ + for t in candidates(frm, to): + if t.actor == actor_kind: + return t + if candidates(frm, to): + raise ActorNotAllowed(actor_kind, frm, to) + raise InvalidTransition(frm, to) + + +def is_legal(frm: str, to: str, actor_kind: str | None = None) -> bool: + opts = candidates(frm, to) + if actor_kind is None: + return bool(opts) + return any(t.actor == actor_kind for t in opts) + + +def allowed_targets(frm: str, actor_kind: str) -> list[str]: + return sorted({t.to for t in _TRANSITIONS + if t.frm == frm and t.actor == actor_kind}) diff --git a/modules/leave/domain/validation.py b/modules/leave/domain/validation.py new file mode 100644 index 00000000..ad55ba16 --- /dev/null +++ b/modules/leave/domain/validation.py @@ -0,0 +1,150 @@ +"""Eligibility, calendar and overlap validation (BR-EL-001…014, CFR-EL-003). + +Pure functions over dates and plain data. Calendar rows and balances are +injected, so every rule here is testable with no database and the service +stays the only place that knows where those come from. +""" +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from modules.leave.domain.categories import ( + CL, + FACULTY_ONLY_CATEGORIES, + HALF_DAY_CATEGORIES, + HALF_FIRST, + HALF_SECOND, +) +from modules.leave.domain.counting import daterange + + +class ValidationError(Exception): + """A rule refused the request. `code` is machine-readable for the API.""" + + def __init__(self, message: str, *, code: str = "invalid_request"): + super().__init__(message) + self.message = message + self.code = code + + +def check_category_applicable(*, category: str, is_faculty: bool) -> None: + """BR-EL-001 / BR-EL-006: category applicability for this employee.""" + if category in FACULTY_ONLY_CATEGORIES and not is_faculty: + raise ValidationError( + "Vacation Leave is available to faculty only.", + code="category_not_applicable") + + +def check_interval(*, start: date, end: date, half_day: str, + category: str) -> None: + if end < start: + raise ValidationError("The end date is before the start date.", + code="invalid_interval") + if half_day not in (HALF_FIRST, HALF_SECOND, "none"): + raise ValidationError("Unknown half-day value.", code="invalid_half_day") + if half_day != "none": + if category not in HALF_DAY_CATEGORIES: + raise ValidationError( + "Half-day leave is supported only for Casual Leave.", + code="half_day_not_supported") + if start != end: + raise ValidationError( + "A half-day leave must be a single day.", + code="half_day_must_be_single_day") + + +def check_rh_calendar(*, start: date, end: date, + restricted_dates: set[date]) -> None: + """BR-EL-003 / CFR-EL-005.2: every requested RH date must be an + applicable Restricted Holiday in the currently effective published + holiday/RH calendar (BR-EL-031).""" + for d in daterange(start, end): + if d not in restricted_dates: + raise ValidationError( + f"{d.isoformat()} is not a Restricted Holiday in the " + f"currently effective published calendar.", + code="rh_not_applicable") + + +def check_vl_vacation_period(*, start: date, end: date, + vacation_periods: list[tuple[date, date]]) -> None: + """BR-EL-006 / CFR-EL-008.3: VL may be availed only during an applicable + vacation period supplied through the Academic Calendar (EXT-01).""" + for d in daterange(start, end): + if not any(v_start <= d <= v_end for v_start, v_end in vacation_periods): + raise ValidationError( + "Vacation Leave may only be taken during a published " + f"vacation period; {d.isoformat()} is outside every one.", + code="vl_outside_vacation") + + +class Interval: + """One existing leave interval, for overlap checks.""" + + __slots__ = ("start", "end", "half_day", "category") + + def __init__(self, start: date, end: date, half_day: str, category: str): + self.start, self.end = start, end + self.half_day, self.category = half_day, category + + def dates(self) -> list[date]: + return daterange(self.start, self.end) + + +def _overlaps(a: Interval, b: Interval) -> bool: + if a.end < b.start or b.end < a.start: + return False + # BR-EL-012: first-half and second-half CL on the same date occupy + # different halves and are therefore NOT overlapping. + same_day = a.start == a.end == b.start == b.end + if (same_day and a.category == CL and b.category == CL + and a.half_day in (HALF_FIRST, HALF_SECOND) + and b.half_day in (HALF_FIRST, HALF_SECOND) + and a.half_day != b.half_day): + return False + return True + + +def intervals_overlap(a: Interval, b: Interval) -> bool: + return _overlaps(a, b) + + +def check_overlap(*, start: date, end: date, half_day: str, category: str, + existing: list[Interval]) -> None: + """BR-EL-012 / CFR-EL-003.4: a new request may not impermissibly overlap + the employee's active pending or approved leave.""" + new = Interval(start, end, half_day, category) + for other in existing: + if _overlaps(new, other): + raise ValidationError( + "This request overlaps existing pending or approved leave " + f"from {other.start.isoformat()} to {other.end.isoformat()}.", + code="overlapping_leave") + + +def check_balance(*, available: Decimal, required: Decimal, + category: str) -> None: + """BR-EL-002…009: insufficient entitlement/balance blocks progression.""" + if required <= 0: + raise ValidationError( + "This request consumes no leave days; check the dates.", + code="zero_chargeable_days") + if available < required: + raise ValidationError( + f"Insufficient {category.upper()} balance: {available} day(s) " + f"available, {required} requested.", + code="insufficient_balance") + + +def check_substitute(*, nominee: int, applicant: int, + employee_ids: set[int]) -> None: + """BR-EL-014 / CFR-EL-011: another employee, not the applicant, who is a + member of the directory. Their own overlapping leave is checked by the + caller, which owns the responsibility period's dates.""" + if nominee == applicant: + raise ValidationError("The substitute cannot be the applicant.", + code="self_substitute") + if nominee not in employee_ids: + raise ValidationError("The substitute must be an employee.", + code="substitute_not_employee") diff --git a/modules/leave/domain/year_end.py b/modules/leave/domain/year_end.py new file mode 100644 index 00000000..afdd494d --- /dev/null +++ b/modules/leave/domain/year_end.py @@ -0,0 +1,106 @@ +"""Year-end lapse, carry-forward and VL conversion (PS §3, BR-EL-002…009). + +Pure planning: given balances and a policy, decide the ledger entries. The +service (services/yearend.py) owns persistence, locking and idempotency. +""" +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from modules.leave.domain.categories import ( + CARRY_FORWARD_CATEGORIES, + CONVERTING_CATEGORIES, + LAPSING_CATEGORIES, + COL, + EL, +) + +@dataclass(frozen=True) +class LedgerEntry: + """One balance mutation for the year-end run. + + `direction` is applied by the service: `debit` reduces the balance + (lapse / conversion out), `credit` increases it (carry-forward into the + new year / conversion into EL). + """ + user_id: int + category: str + year: int + kind: str + days: Decimal + direction: str # "debit" | "credit" + + +@dataclass(frozen=True) +class YearEndPlan: + year: int + entries: tuple[LedgerEntry, ...] + exceptions: tuple[str, ...] + + +def plan_year_end( + *, + year: int, + balances: list[tuple[int, str, Decimal]], # (user_id, category, available) + is_faculty: dict[int, bool], + vl_to_el_numerator: Decimal, + vl_to_el_denominator: Decimal, + carry_forward_enabled: dict[str, bool], +) -> YearEndPlan: + """BR-EL-002/003/004 lapse, BR-EL-005/006 convert, BR-EL-007/008 carry. + + Idempotency is the caller's problem: this function is a pure projection + of "balances as they stand now". + """ + entries: list[LedgerEntry] = [] + exceptions: list[str] = [] + next_year = year + 1 + + for user_id, category, available in balances: + if available <= 0: + continue + + if category in LAPSING_CATEGORIES: + # BR-EL-002/003/004: CL, RH and SCL lapse outright. + entries.append(LedgerEntry(user_id, category, year, + "year_end_lapse", -available, "debit")) + + elif category in CONVERTING_CATEGORIES: + if not is_faculty.get(user_id): + # VL is faculty-only; a non-faculty VL balance lapses + # rather than converts (BR-EL-006 scope). + entries.append(LedgerEntry(user_id, category, year, + "year_end_lapse", -available, + "debit")) + continue + # BR-EL-005: at year end, 2 VL : 1 EL (policy ratio). + el_days = (available * vl_to_el_denominator + / vl_to_el_numerator) + el_days = el_days.quantize(Decimal("0.25")) + entries.append(LedgerEntry(user_id, category, year, + "vl_to_el_conversion", -available, + "debit")) + entries.append(LedgerEntry(user_id, EL, next_year, + "vl_to_el_conversion", el_days, + "credit")) + + elif category in CARRY_FORWARD_CATEGORIES: + if not carry_forward_enabled.get(category, True): + entries.append(LedgerEntry(user_id, category, year, + "year_end_lapse", -available, + "debit")) + continue + # BR-EL-007/008: EL and COL carry forward as-is. Two entries: the + # destination year receives it, the expiring year loses it. + entries.append(LedgerEntry(user_id, category, next_year, + "carry_forward", available, "credit")) + entries.append(LedgerEntry(user_id, category, year, + "carry_forward", -available, "debit")) + + else: + exceptions.append( + f"{category} for user {user_id} has no year-end rule") + + return YearEndPlan(year=year, entries=tuple(entries), + exceptions=tuple(exceptions)) diff --git a/modules/leave/management/__init__.py b/modules/leave/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/leave/management/commands/__init__.py b/modules/leave/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/leave/management/commands/leave_sla_sweep.py b/modules/leave/management/commands/leave_sla_sweep.py new file mode 100644 index 00000000..6c0c42d0 --- /dev/null +++ b/modules/leave/management/commands/leave_sla_sweep.py @@ -0,0 +1,14 @@ +"""Run one SLA sweep from the shell, for hosts where the beat timer is not +the delivery mechanism (see schedule.py for what the timer runs).""" +from django.core.management.base import BaseCommand + +from modules.leave.services import sla + + +class Command(BaseCommand): + help = "Remind and escalate overdue leave tasks once." + + def handle(self, *args, **options): + result = sla.sweep() + self.stdout.write(self.style.SUCCESS( + f"reminded {result['reminded']}, escalated {result['escalated']}")) diff --git a/modules/leave/management/commands/leave_year_end.py b/modules/leave/management/commands/leave_year_end.py new file mode 100644 index 00000000..8abe3b9d --- /dev/null +++ b/modules/leave/management/commands/leave_year_end.py @@ -0,0 +1,28 @@ +"""Run the leave-year boundary from the shell (the beat timer calls the +same service; this is the manual/audited path). + + manage.py leave_year_end [--year 2026] +""" +from django.core.management.base import BaseCommand, CommandError + +from modules.leave.services import policy as policy_service +from modules.leave.services import yearend + + +class Command(BaseCommand): + help = "Lapse, convert and carry leave balances across a year boundary." + + def add_arguments(self, parser): + parser.add_argument("--year", type=int, default=None, + help="Leave year to close (default: last year).") + + def handle(self, *args, **options): + year = options["year"] + if year is None: + year = policy_service.current_leave_year() - 1 + try: + result = yearend.run_year_end(year) + except Exception as exc: # ConflictError for an already-run year + raise CommandError(str(exc)) from exc + self.stdout.write(self.style.SUCCESS( + f"leave year {year}: {result}")) diff --git a/modules/leave/migrations/0001_initial.py b/modules/leave/migrations/0001_initial.py new file mode 100644 index 00000000..b06415e8 --- /dev/null +++ b/modules/leave/migrations/0001_initial.py @@ -0,0 +1,323 @@ +# Generated by Django 5.2.18 on 2026-10-12 12:07 + +import django.db.models.deletion +import modules.leave.models.policy +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='HolidayCalendar', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=120)), + ('academic_year', models.CharField(blank=True, max_length=20)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=12)), + ('published_at', models.DateTimeField(blank=True, null=True)), + ('published_by_user_id', models.IntegerField(blank=True, null=True)), + ], + options={ + 'db_table': 'leave_holiday_calendar', + 'ordering': ['-published_at', '-created_at'], + }, + ), + migrations.CreateModel( + name='LeavePolicy', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.PositiveSmallIntegerField(default=1, primary_key=True, serialize=False)), + ('cl_annual', models.DecimalField(decimal_places=2, default=8, max_digits=5)), + ('rh_annual', models.DecimalField(decimal_places=2, default=2, max_digits=5)), + ('scl_annual', models.DecimalField(decimal_places=2, default=15, max_digits=5)), + ('el_annual_nonfaculty', models.DecimalField(decimal_places=2, default=30, max_digits=5)), + ('el_annual_faculty', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('col_annual', models.DecimalField(decimal_places=2, default=20, max_digits=5)), + ('vl_annual_faculty', models.DecimalField(decimal_places=2, default=60, max_digits=5)), + ('vl_to_el_numerator', models.PositiveSmallIntegerField(default=2)), + ('vl_to_el_denominator', models.PositiveSmallIntegerField(default=1)), + ('el_carry_forward', models.BooleanField(default=True)), + ('col_carry_forward', models.BooleanField(default=True)), + ('substitute_required_categories', models.JSONField(blank=True, default=modules.leave.models.policy._substitute_defaults)), + ('scl_evidence_required', models.BooleanField(default=False)), + ('col_evidence_required', models.BooleanField(default=False)), + ('year_end_month', models.PositiveSmallIntegerField(default=12)), + ('year_end_day', models.PositiveSmallIntegerField(default=31)), + ], + options={ + 'db_table': 'leave_policy', + }, + ), + migrations.CreateModel( + name='LeaveYearRun', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('year', models.PositiveSmallIntegerField(unique=True)), + ('processed_at', models.DateTimeField(auto_now_add=True)), + ('report', models.JSONField(blank=True, default=dict)), + ], + options={ + 'db_table': 'leave_year_run', + 'ordering': ['-year'], + }, + ), + migrations.CreateModel( + name='OrganizationalUnit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('code', models.CharField(max_length=80, unique=True)), + ('name', models.CharField(max_length=160)), + ('kind', models.CharField(choices=[('department', 'Department'), ('section', 'Section'), ('office', 'Office')], default='department', max_length=12)), + ('head_user_id', models.IntegerField(blank=True, db_index=True, null=True)), + ], + options={ + 'db_table': 'leave_organizational_unit', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='SlaThreshold', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('task_class', models.CharField(choices=[('substitute_response', 'Substitute response'), ('unit_head_review', 'Unit Head review'), ('establishment_routing', 'Establishment routing'), ('final_sanction', 'Final sanction'), ('director_sanction', 'Director self-sanction'), ('resumption_review', 'Resumption review')], max_length=32, unique=True)), + ('reminder_hours', models.PositiveIntegerField(blank=True, null=True)), + ('escalation_hours', models.PositiveIntegerField(blank=True, null=True)), + ('escalation_user_id', models.IntegerField(blank=True, null=True)), + ], + options={ + 'db_table': 'leave_sla_threshold', + }, + ), + migrations.CreateModel( + name='LeaveBalance', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user_id', models.IntegerField(db_index=True)), + ('category', models.CharField(max_length=8)), + ('year', models.PositiveSmallIntegerField()), + ('carried_forward', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('credited', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('used', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('adjusted', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ], + options={ + 'db_table': 'leave_balance', + 'constraints': [models.UniqueConstraint(fields=('user_id', 'category', 'year'), name='leave_balance_unique')], + }, + ), + migrations.CreateModel( + name='LeaveRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user_id', models.IntegerField(db_index=True)), + ('source', models.CharField(default='elm', max_length=8)), + ('kind', models.CharField(choices=[('new', 'Leave application'), ('cancellation', 'Cancellation'), ('extension', 'Extension'), ('offline', 'Offline record')], default='new', max_length=16)), + ('category', models.CharField(max_length=8)), + ('status', models.CharField(choices=[('awaiting_substitute_response', 'Awaiting substitute response'), ('applicant_action_required', 'Applicant action required'), ('awaiting_unit_head_review', 'Awaiting Unit Head review'), ('awaiting_establishment_routing', 'Awaiting establishment routing'), ('awaiting_final_sanction_decision', 'Awaiting final sanction decision'), ('awaiting_director_self_sanction', 'Awaiting Director self-sanction'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('withdrawn', 'Withdrawn'), ('cancelled', 'Cancelled')], max_length=40)), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('half_day', models.CharField(default='none', max_length=8)), + ('purpose', models.TextField(blank=True)), + ('evidence_note', models.TextField(blank=True)), + ('station_leave', models.BooleanField(default=False)), + ('station_place', models.CharField(blank=True, max_length=160)), + ('station_reason', models.CharField(blank=True, max_length=400)), + ('chargeable_days', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('year', models.PositiveSmallIntegerField(db_index=True)), + ('substitute_user_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('substitute_status', models.CharField(default='none', max_length=12)), + ('unit_head_user_id', models.IntegerField(blank=True, null=True)), + ('unit_head_outcome', models.CharField(blank=True, max_length=20)), + ('unit_head_at', models.DateTimeField(blank=True, null=True)), + ('unit_head_note', models.TextField(blank=True)), + ('establishment_user_id', models.IntegerField(blank=True, null=True)), + ('establishment_at', models.DateTimeField(blank=True, null=True)), + ('establishment_note', models.TextField(blank=True)), + ('authority_user_id', models.IntegerField(blank=True, null=True)), + ('is_director_self_sanction', models.BooleanField(default=False)), + ('decision', models.CharField(blank=True, max_length=8)), + ('decided_at', models.DateTimeField(blank=True, null=True)), + ('decided_by_user_id', models.IntegerField(blank=True, null=True)), + ('decision_note', models.TextField(blank=True)), + ('sanction_reference', models.CharField(blank=True, max_length=200)), + ('task_class', models.CharField(blank=True, max_length=32)), + ('task_user_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('task_assigned_at', models.DateTimeField(blank=True, null=True)), + ('reminder_sent_at', models.DateTimeField(blank=True, null=True)), + ('escalated_at', models.DateTimeField(blank=True, null=True)), + ('actual_end_date', models.DateField(blank=True, null=True)), + ('admin_note', models.TextField(blank=True)), + ('base_request', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='derived_requests', to='leave.leaverequest')), + ], + options={ + 'db_table': 'leave_request', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='LeaveNotification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user_id', models.IntegerField(db_index=True)), + ('kind', models.CharField(max_length=32)), + ('message', models.TextField()), + ('read_at', models.DateTimeField(blank=True, null=True)), + ('request', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='leave.leaverequest')), + ], + options={ + 'db_table': 'leave_notification', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='LeaveAccountTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user_id', models.IntegerField(db_index=True)), + ('category', models.CharField(max_length=8)), + ('year', models.PositiveSmallIntegerField()), + ('kind', models.CharField(choices=[('credit', 'Credit'), ('deduction', 'Deduction'), ('cancellation_restoration', 'Cancellation restoration'), ('extension_deduction', 'Extension deduction'), ('refund', 'Refund'), ('admin_adjustment', 'Admin adjustment'), ('carry_forward', 'Carry forward'), ('vl_to_el_conversion', 'VL to EL conversion'), ('year_end_lapse', 'Year-end lapse')], max_length=32)), + ('days', models.DecimalField(decimal_places=2, max_digits=6)), + ('note', models.CharField(blank=True, max_length=300)), + ('actor_user_id', models.IntegerField(blank=True, null=True)), + ('at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('request', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to='leave.leaverequest')), + ], + options={ + 'db_table': 'leave_account_transaction', + 'ordering': ['-at'], + }, + ), + migrations.CreateModel( + name='LeaveRequestEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('from_status', models.CharField(blank=True, max_length=40)), + ('to_status', models.CharField(blank=True, max_length=40)), + ('kind', models.CharField(choices=[('submitted', 'Submitted'), ('status_change', 'Status change'), ('substitute_response', 'Substitute response'), ('assignment', 'Task assignment'), ('decision', 'Decision'), ('balance_effect', 'Balance effect'), ('sla_reminder', 'SLA reminder'), ('sla_escalation', 'SLA escalation'), ('note', 'Note')], max_length=24)), + ('actor_user_id', models.IntegerField(blank=True, null=True)), + ('actor_label', models.CharField(blank=True, max_length=120)), + ('note', models.TextField(blank=True)), + ('at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='leave.leaverequest')), + ], + options={ + 'db_table': 'leave_request_event', + 'ordering': ['at'], + }, + ), + migrations.CreateModel( + name='LeaveResumption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('actual_date', models.DateField()), + ('details', models.TextField(blank=True)), + ('status', models.CharField(choices=[('pending_review', 'Pending review'), ('reviewed_no_adjustment', 'Reviewed: as is'), ('reviewed_early', 'Reviewed: returned early'), ('reviewed_late', 'Reviewed: returned late')], default='pending_review', max_length=32)), + ('approved_end_date', models.DateField()), + ('adjustment_days', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('reviewed_by_user_id', models.IntegerField(blank=True, null=True)), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('review_note', models.TextField(blank=True)), + ('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resumptions', to='leave.leaverequest')), + ], + options={ + 'db_table': 'leave_resumption', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='AuthorityAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('role_code', models.CharField(choices=[('establishment', 'Establishment Section'), ('dean', 'Dean'), ('registrar', 'Registrar'), ('director', 'Director')], max_length=20)), + ('user_id', models.IntegerField(db_index=True)), + ('categories', models.JSONField(blank=True, default=list)), + ('org_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='authorities', to='leave.organizationalunit')), + ], + options={ + 'db_table': 'leave_authority_assignment', + }, + ), + migrations.CreateModel( + name='VacationPeriod', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=120)), + ('academic_year', models.CharField(blank=True, max_length=20)), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('source_ref', models.CharField(blank=True, max_length=200)), + ], + options={ + 'db_table': 'leave_vacation_period', + 'ordering': ['start_date'], + 'constraints': [models.CheckConstraint(condition=models.Q(('end_date__gte', models.F('start_date'))), name='leave_vacation_interval_valid')], + }, + ), + migrations.CreateModel( + name='Holiday', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('date', models.DateField()), + ('name', models.CharField(max_length=120)), + ('kind', models.CharField(choices=[('closed', 'Closed day'), ('restricted', 'Restricted Holiday')], default='closed', max_length=12)), + ('calendar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='holidays', to='leave.holidaycalendar')), + ], + options={ + 'db_table': 'leave_holiday', + 'ordering': ['date'], + 'constraints': [models.UniqueConstraint(fields=('calendar', 'date'), name='leave_holiday_unique')], + }, + ), + migrations.AddIndex( + model_name='leaverequest', + index=models.Index(fields=['user_id', 'status'], name='leave_reque_user_id_598dad_idx'), + ), + migrations.AddIndex( + model_name='leaverequest', + index=models.Index(fields=['status', 'task_class'], name='leave_reque_status_ce163e_idx'), + ), + migrations.AddIndex( + model_name='leaverequest', + index=models.Index(fields=['category', 'year'], name='leave_reque_categor_b7b8ac_idx'), + ), + migrations.AddConstraint( + model_name='leaverequest', + constraint=models.CheckConstraint(condition=models.Q(('end_date__gte', models.F('start_date'))), name='leave_request_interval_valid'), + ), + migrations.AddConstraint( + model_name='authorityassignment', + constraint=models.UniqueConstraint(fields=('role_code', 'org_unit'), name='leave_authority_role_unit_unique'), + ), + ] diff --git a/modules/leave/migrations/__init__.py b/modules/leave/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/leave/models/__init__.py b/modules/leave/models/__init__.py new file mode 100644 index 00000000..c4c4ed4d --- /dev/null +++ b/modules/leave/models/__init__.py @@ -0,0 +1,40 @@ +"""Leave domain tables, split by aggregate.""" +from modules.leave.models.accounts import ( + LeaveAccountTransaction, + LeaveBalance, + LeaveYearRun, +) +from modules.leave.models.calendar import ( + Holiday, + HolidayCalendar, + VacationPeriod, +) +from modules.leave.models.hierarchy import ( + AuthorityAssignment, + OrganizationalUnit, + SlaThreshold, +) +from modules.leave.models.policy import LeavePolicy +from modules.leave.models.requests import ( + LeaveNotification, + LeaveRequest, + LeaveRequestEvent, + LeaveResumption, +) + +__all__ = [ + "AuthorityAssignment", + "Holiday", + "HolidayCalendar", + "LeaveAccountTransaction", + "LeaveBalance", + "LeaveNotification", + "LeavePolicy", + "LeaveRequest", + "LeaveRequestEvent", + "LeaveResumption", + "LeaveYearRun", + "OrganizationalUnit", + "SlaThreshold", + "VacationPeriod", +] diff --git a/modules/leave/models/accounts.py b/modules/leave/models/accounts.py new file mode 100644 index 00000000..5b3bc109 --- /dev/null +++ b/modules/leave/models/accounts.py @@ -0,0 +1,103 @@ +"""Balances, the append-only account ledger, and year-end run records. + +Balances are materialised rows (BR-EL-002…009, CFR-EL-003.5) so a deduction +can be made under `select_for_update` inside the approval transaction; +the ledger is the immutable evidence behind every number. +""" +from __future__ import annotations + +from django.db import models + +from core.db.mixins import TimeStampedModel + + +class LeaveBalance(TimeStampedModel): + """One employee's balance in one category for one leave year. + + available = carried_forward + credited + adjusted - used + """ + + user_id = models.IntegerField(db_index=True) + category = models.CharField(max_length=8) + year = models.PositiveSmallIntegerField() + + carried_forward = models.DecimalField(max_digits=6, decimal_places=2, + default=0) # from prior year + credited = models.DecimalField(max_digits=6, decimal_places=2, + default=0) # annual entitlement + used = models.DecimalField(max_digits=6, decimal_places=2, default=0) + adjusted = models.DecimalField(max_digits=6, decimal_places=2, + default=0) # signed: admin, lapse + + class Meta: + db_table = "leave_balance" + constraints = [ + models.UniqueConstraint(fields=["user_id", "category", "year"], + name="leave_balance_unique"), + ] + + def __str__(self) -> str: + return f"user {self.user_id} {self.category.upper()} {self.year}" + + @property + def available(self) -> float: + from decimal import Decimal + total = (self.carried_forward + self.credited + self.adjusted + - self.used) + return float(total) + + +class LeaveAccountTransaction(TimeStampedModel): + """One immutable balance mutation (CFR-EL-003.5): approvals, cancellations, + extensions, resumption refunds, admin adjustments, year-end outcomes.""" + + CREDIT = "credit" # annual entitlement / carry-in + DEDUCTION = "deduction" # approved leave consumed + CANCELLATION_RESTORATION = "cancellation_restoration" + EXTENSION_DEDUCTION = "extension_deduction" + REFUND = "refund" # resumption: returned early + ADMIN_ADJUSTMENT = "admin_adjustment" + CARRY_FORWARD = "carry_forward" + VL_TO_EL_CONVERSION = "vl_to_el_conversion" + YEAR_END_LAPSE = "year_end_lapse" + KIND = [(CREDIT, "Credit"), (DEDUCTION, "Deduction"), + (CANCELLATION_RESTORATION, "Cancellation restoration"), + (EXTENSION_DEDUCTION, "Extension deduction"), + (REFUND, "Refund"), (ADMIN_ADJUSTMENT, "Admin adjustment"), + (CARRY_FORWARD, "Carry forward"), + (VL_TO_EL_CONVERSION, "VL to EL conversion"), + (YEAR_END_LAPSE, "Year-end lapse")] + + user_id = models.IntegerField(db_index=True) + category = models.CharField(max_length=8) + year = models.PositiveSmallIntegerField() + kind = models.CharField(max_length=32, choices=KIND) + days = models.DecimalField(max_digits=6, decimal_places=2) # signed + request = models.ForeignKey("LeaveRequest", null=True, blank=True, + on_delete=models.SET_NULL, + related_name="transactions") + note = models.CharField(max_length=300, blank=True) + actor_user_id = models.IntegerField(null=True, blank=True) + at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + db_table = "leave_account_transaction" + ordering = ["-at"] + + def __str__(self) -> str: + return f"{self.kind} {self.days} {self.category.upper()}" + + +class LeaveYearRun(TimeStampedModel): + """Idempotency marker for the year-end sweep: one row per leave year.""" + + year = models.PositiveSmallIntegerField(unique=True) + processed_at = models.DateTimeField(auto_now_add=True) + report = models.JSONField(default=dict, blank=True) + + class Meta: + db_table = "leave_year_run" + ordering = ["-year"] + + def __str__(self) -> str: + return f"year-end {self.year}" diff --git a/modules/leave/models/calendar.py b/modules/leave/models/calendar.py new file mode 100644 index 00000000..7301b26c --- /dev/null +++ b/modules/leave/models/calendar.py @@ -0,0 +1,76 @@ +"""Holiday / Restricted-Holiday calendars and Academic Calendar vacations. + +BR-EL-031: a calendar is versioned draft → published; only the currently +effective *published* calendar validates RH dates (CFR-EL-005.2). Vacation +periods come from the Academic Calendar (EXT-01) and gate VL (BR-EL-006). +""" +from __future__ import annotations + +from django.db import models + +from core.db.mixins import TimeStampedModel + + +class HolidayCalendar(TimeStampedModel): + DRAFT = "draft" + PUBLISHED = "published" + STATUS = [(DRAFT, "Draft"), (PUBLISHED, "Published")] + + title = models.CharField(max_length=120) + academic_year = models.CharField(max_length=20, blank=True) # "2026-27" + status = models.CharField(max_length=12, choices=STATUS, default=DRAFT) + published_at = models.DateTimeField(null=True, blank=True) + published_by_user_id = models.IntegerField(null=True, blank=True) + + class Meta: + db_table = "leave_holiday_calendar" + ordering = ["-published_at", "-created_at"] + + def __str__(self) -> str: + return f"{self.title} ({self.status})" + + +class Holiday(TimeStampedModel): + CLOSED = "closed" # institute closed: CL/SCL/RH do not count it + RESTRICTED = "restricted" # Restricted Holiday: the only RH-valid dates + + KIND = [(CLOSED, "Closed day"), (RESTRICTED, "Restricted Holiday")] + + calendar = models.ForeignKey(HolidayCalendar, on_delete=models.CASCADE, + related_name="holidays") + date = models.DateField() + name = models.CharField(max_length=120) + kind = models.CharField(max_length=12, choices=KIND, default=CLOSED) + + class Meta: + db_table = "leave_holiday" + ordering = ["date"] + constraints = [ + models.UniqueConstraint(fields=["calendar", "date"], + name="leave_holiday_unique"), + ] + + def __str__(self) -> str: + return f"{self.date.isoformat()} {self.name}" + + +class VacationPeriod(TimeStampedModel): + """EXT-01 input: an Academic Calendar window inside which VL may be taken.""" + + name = models.CharField(max_length=120) + academic_year = models.CharField(max_length=20, blank=True) + start_date = models.DateField() + end_date = models.DateField() + source_ref = models.CharField(max_length=200, blank=True) # calendar doc + + class Meta: + db_table = "leave_vacation_period" + ordering = ["start_date"] + constraints = [ + models.CheckConstraint( + condition=models.Q(end_date__gte=models.F("start_date")), + name="leave_vacation_interval_valid"), + ] + + def __str__(self) -> str: + return f"{self.name} {self.start_date}…{self.end_date}" diff --git a/modules/leave/models/hierarchy.py b/modules/leave/models/hierarchy.py new file mode 100644 index 00000000..41ab0c4e --- /dev/null +++ b/modules/leave/models/hierarchy.py @@ -0,0 +1,108 @@ +"""Who reviews, routes and sanctions: org units, authority assignments, SLAs. + +The routing hierarchy (BR-EL-016…020) is configured here rather than read +from the directory: the directory publishes who people are, not which desk +reviews a leave file. `user_id` fields are plain integers — the IAM owns +identity. +""" +from __future__ import annotations + +from django.db import models + +from core.db.mixins import TimeStampedModel + + +class OrganizationalUnit(TimeStampedModel): + """A department / section whose head is the Unit Head for its staff. + + `code` matches the `department` string on directory users so a request + can find its unit without a cross-boundary foreign key. + """ + + DEPARTMENT = "department" + SECTION = "section" + OFFICE = "office" + KIND = [(DEPARTMENT, "Department"), (SECTION, "Section"), + (OFFICE, "Office")] + + code = models.CharField(max_length=80, unique=True) + name = models.CharField(max_length=160) + kind = models.CharField(max_length=12, choices=KIND, default=DEPARTMENT) + head_user_id = models.IntegerField(null=True, blank=True, db_index=True) + + class Meta: + db_table = "leave_organizational_unit" + ordering = ["name"] + + def __str__(self) -> str: + return self.name + + +class AuthorityAssignment(TimeStampedModel): + """Which user currently holds a sanctioning desk. + + The establishment step (role_code=establishment) may be assigned per org + unit or institute-wide (org_unit is null). The final authority roles are + checked in order dean → registrar → director, org-specific before + institute-wide, and the first whose `categories` include the request's + category wins (BR-EL-018/019, config-driven hierarchy). + """ + + ESTABLISHMENT = "establishment" + DEAN = "dean" + REGISTRAR = "registrar" + DIRECTOR = "director" + ROLE = [(ESTABLISHMENT, "Establishment Section"), (DEAN, "Dean"), + (REGISTRAR, "Registrar"), (DIRECTOR, "Director")] + + role_code = models.CharField(max_length=20, choices=ROLE) + user_id = models.IntegerField(db_index=True) + org_unit = models.ForeignKey(OrganizationalUnit, null=True, blank=True, + on_delete=models.CASCADE, + related_name="authorities") + #: Categories this desk sanctions; empty means every category. + categories = models.JSONField(default=list, blank=True) + + class Meta: + db_table = "leave_authority_assignment" + constraints = [ + models.UniqueConstraint( + fields=["role_code", "org_unit"], + name="leave_authority_role_unit_unique"), + ] + + def __str__(self) -> str: + return f"{self.get_role_code_display()} → {self.user_id}" + + +class SlaThreshold(TimeStampedModel): + """How long a task sits before the reminder, and before escalation + (BR-EL-032…034). One row per task class.""" + + SUBSTITUTE_RESPONSE = "substitute_response" + UNIT_HEAD_REVIEW = "unit_head_review" + ESTABLISHMENT_ROUTING = "establishment_routing" + FINAL_SANCTION = "final_sanction" + DIRECTOR_SANCTION = "director_sanction" + RESUMPTION_REVIEW = "resumption_review" + TASK_CLASS = [ + (SUBSTITUTE_RESPONSE, "Substitute response"), + (UNIT_HEAD_REVIEW, "Unit Head review"), + (ESTABLISHMENT_ROUTING, "Establishment routing"), + (FINAL_SANCTION, "Final sanction"), + (DIRECTOR_SANCTION, "Director self-sanction"), + (RESUMPTION_REVIEW, "Resumption review"), + ] + + task_class = models.CharField(max_length=32, choices=TASK_CLASS, + unique=True) + reminder_hours = models.PositiveIntegerField(null=True, blank=True) + escalation_hours = models.PositiveIntegerField(null=True, blank=True) + #: Whom the escalation notifies; null falls back to the task holder. + escalation_user_id = models.IntegerField(null=True, blank=True) + + class Meta: + db_table = "leave_sla_threshold" + + def __str__(self) -> str: + return self.task_class diff --git a/modules/leave/models/policy.py b/modules/leave/models/policy.py new file mode 100644 index 00000000..9230afac --- /dev/null +++ b/modules/leave/models/policy.py @@ -0,0 +1,62 @@ +"""Leave policy: the one row every entitlement and gate reads. + +BR-EL-030 gives this table no draft/publish lifecycle (unlike the holiday +calendar): editing the policy is immediate and fully audited by the admin +action itself. Defaults mirror the ELM baseline §2–4. +""" +from __future__ import annotations + +from django.db import models + +from core.db.mixins import TimeStampedModel + + +def _substitute_defaults() -> list[str]: + """BR-EL-014: EL, COL and VL carry a responsibility that needs covering.""" + return ["el", "col", "vl"] + + +class LeavePolicy(TimeStampedModel): + """Singleton (primary key is always 1); services use `get_or_create`.""" + + id = models.PositiveSmallIntegerField(primary_key=True, default=1) + + # Annual entitlements (baseline §2). EL for faculty comes from + # vacation conversion instead of an annual credit (BR-EL-005). + cl_annual = models.DecimalField(max_digits=5, decimal_places=2, default=8) + rh_annual = models.DecimalField(max_digits=5, decimal_places=2, default=2) + scl_annual = models.DecimalField(max_digits=5, decimal_places=2, default=15) + el_annual_nonfaculty = models.DecimalField( + max_digits=5, decimal_places=2, default=30) + el_annual_faculty = models.DecimalField( + max_digits=5, decimal_places=2, default=0) + col_annual = models.DecimalField(max_digits=5, decimal_places=2, default=20) + vl_annual_faculty = models.DecimalField( + max_digits=5, decimal_places=2, default=60) + + # BR-EL-005: 2 VL convert into 1 EL at year end. + vl_to_el_numerator = models.PositiveSmallIntegerField(default=2) + vl_to_el_denominator = models.PositiveSmallIntegerField(default=1) + + # BR-EL-007/008: which categories survive the boundary as themselves. + el_carry_forward = models.BooleanField(default=True) + col_carry_forward = models.BooleanField(default=True) + + # BR-EL-014/015: categories whose routing is gated on substitute consent. + substitute_required_categories = models.JSONField( + default=_substitute_defaults, blank=True) + + # SCL and COL evidence (PS §3): the clerk asks, the applicant supplies. + scl_evidence_required = models.BooleanField(default=False) + col_evidence_required = models.BooleanField(default=False) + + # The year boundary: leave "year" labels end on this month/day + # (default 31 Dec, so 2026-12-31 leave belongs to leave-year 2026). + year_end_month = models.PositiveSmallIntegerField(default=12) + year_end_day = models.PositiveSmallIntegerField(default=31) + + class Meta: + db_table = "leave_policy" + + def __str__(self) -> str: + return "Leave policy" diff --git a/modules/leave/models/requests.py b/modules/leave/models/requests.py new file mode 100644 index 00000000..669e302d --- /dev/null +++ b/modules/leave/models/requests.py @@ -0,0 +1,207 @@ +"""The leave request aggregate: the request itself, its audit trail, its +resumption, and the applicant's notifications. + +One row serves all three intents (new / cancellation / extension) because +the workflow, SLA and audit are shared; `kind` says which. There is no +"draft" state — a request is born in its first workflow state (CFR-EL-003). +""" +from __future__ import annotations + +from django.db import models + +from core.db.mixins import TimeStampedModel, UserScopedModel +from modules.leave.domain.states import STATUS_CHOICES + + +class LeaveRequest(UserScopedModel, TimeStampedModel): + """One leave application, cancellation, extension or offline record. + + `user_id` is the applicant. People are referenced by plain integers + throughout — there is no user table here (see docs/01 data ownership). + """ + + NEW = "new" + CANCELLATION = "cancellation" + EXTENSION = "extension" + OFFLINE = "offline" + KIND = [(NEW, "Leave application"), (CANCELLATION, "Cancellation"), + (EXTENSION, "Extension"), (OFFLINE, "Offline record")] + + source = models.CharField(max_length=8, default="elm") + kind = models.CharField(max_length=16, choices=KIND, default=NEW) + category = models.CharField(max_length=8) # domain.categories + status = models.CharField(max_length=40, choices=STATUS_CHOICES) + + start_date = models.DateField() + end_date = models.DateField() + half_day = models.CharField(max_length=8, default="none") + + purpose = models.TextField(blank=True) + evidence_note = models.TextField(blank=True) + + # Station leave (CFR-EL-015): captured inside the same application. + station_leave = models.BooleanField(default=False) + station_place = models.CharField(max_length=160, blank=True) + station_reason = models.CharField(max_length=400, blank=True) + + chargeable_days = models.DecimalField(max_digits=6, decimal_places=2, + default=0) + year = models.PositiveSmallIntegerField(db_index=True) # leave year + + # Cancellation/extension point at the original request. + base_request = models.ForeignKey("self", null=True, blank=True, + on_delete=models.SET_NULL, + related_name="derived_requests") + + # Substitute consent gate (BR-EL-014/015). + substitute_user_id = models.IntegerField(null=True, blank=True, + db_index=True) + substitute_status = models.CharField(max_length=12, default="none") + # none | pending | accepted | declined + + # Reviewer states, filled as the file passes each desk. + unit_head_user_id = models.IntegerField(null=True, blank=True) + unit_head_outcome = models.CharField(max_length=20, blank=True) + # approve | reject | recommended | not_recommended + unit_head_at = models.DateTimeField(null=True, blank=True) + unit_head_note = models.TextField(blank=True) + + establishment_user_id = models.IntegerField(null=True, blank=True) + establishment_at = models.DateTimeField(null=True, blank=True) + establishment_note = models.TextField(blank=True) + + authority_user_id = models.IntegerField(null=True, blank=True) + is_director_self_sanction = models.BooleanField(default=False) + + decision = models.CharField(max_length=8, blank=True) # approve | reject + decided_at = models.DateTimeField(null=True, blank=True) + decided_by_user_id = models.IntegerField(null=True, blank=True) + decision_note = models.TextField(blank=True) + + # Offline records (BR-EL-029) carry their paper reference; EL-UC-006 + # references may be attached to any decision. + sanction_reference = models.CharField(max_length=200, blank=True) + + # Open task for SLA (BR-EL-032/033); blank once the file moves on. + task_class = models.CharField(max_length=32, blank=True) + task_user_id = models.IntegerField(null=True, blank=True, db_index=True) + task_assigned_at = models.DateTimeField(null=True, blank=True) + reminder_sent_at = models.DateTimeField(null=True, blank=True) + escalated_at = models.DateTimeField(null=True, blank=True) + + #: Set when a resumption review records the true last day (BR-EL-026). + actual_end_date = models.DateField(null=True, blank=True) + #: Free-text annotation for record maintenance (never the sanction path). + admin_note = models.TextField(blank=True) + + class Meta: + db_table = "leave_request" + ordering = ["-created_at"] + constraints = [ + models.CheckConstraint( + condition=models.Q(end_date__gte=models.F("start_date")), + name="leave_request_interval_valid"), + ] + indexes = [ + models.Index(fields=["user_id", "status"]), + models.Index(fields=["status", "task_class"]), + models.Index(fields=["category", "year"]), + ] + + def __str__(self) -> str: + return f"{self.category.upper()} {self.start_date}…{self.end_date}" + + @property + def is_pending(self) -> bool: + from modules.leave.domain.states import PENDING + return self.status in PENDING + + +class LeaveRequestEvent(models.Model): + """Append-only audit line (CFR-EL-003.6, BR-EL-035).""" + + SUBMITTED = "submitted" + STATUS_CHANGE = "status_change" + SUBSTITUTE_RESPONSE = "substitute_response" + ASSIGNMENT = "assignment" + DECISION = "decision" + BALANCE_EFFECT = "balance_effect" + SLA_REMINDER = "sla_reminder" + SLA_ESCALATION = "sla_escalation" + NOTE = "note" + + KIND = [(SUBMITTED, "Submitted"), (STATUS_CHANGE, "Status change"), + (SUBSTITUTE_RESPONSE, "Substitute response"), + (ASSIGNMENT, "Task assignment"), (DECISION, "Decision"), + (BALANCE_EFFECT, "Balance effect"), (SLA_REMINDER, "SLA reminder"), + (SLA_ESCALATION, "SLA escalation"), (NOTE, "Note")] + + request = models.ForeignKey(LeaveRequest, on_delete=models.CASCADE, + related_name="events") + from_status = models.CharField(max_length=40, blank=True) + to_status = models.CharField(max_length=40, blank=True) + kind = models.CharField(max_length=24, choices=KIND) + actor_user_id = models.IntegerField(null=True, blank=True) + actor_label = models.CharField(max_length=120, blank=True) + note = models.TextField(blank=True) + at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + db_table = "leave_request_event" + ordering = ["at"] + + def __str__(self) -> str: + return f"{self.request_id}: {self.kind} @{self.at:%Y-%m-%d %H:%M}" + + +class LeaveResumption(TimeStampedModel): + """BR-EL-026 / CFR-EL-019: employee reports actual return; the authority + reviews and the balance follows the actual period, not the approved one.""" + + PENDING = "pending_review" + NO_ADJUSTMENT = "reviewed_no_adjustment" + EARLY = "reviewed_early" + LATE = "reviewed_late" + STATUS = [(PENDING, "Pending review"), (NO_ADJUSTMENT, "Reviewed: as is"), + (EARLY, "Reviewed: returned early"), + (LATE, "Reviewed: returned late")] + + request = models.ForeignKey(LeaveRequest, on_delete=models.CASCADE, + related_name="resumptions") + actual_date = models.DateField() + details = models.TextField(blank=True) + status = models.CharField(max_length=32, choices=STATUS, default=PENDING) + #: Snapshot so a review can compare approved vs actual. + approved_end_date = models.DateField() + adjustment_days = models.DecimalField(max_digits=6, decimal_places=2, + default=0) + reviewed_by_user_id = models.IntegerField(null=True, blank=True) + reviewed_at = models.DateTimeField(null=True, blank=True) + review_note = models.TextField(blank=True) + + class Meta: + db_table = "leave_resumption" + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"resumption of #{self.request_id}: {self.actual_date}" + + +class LeaveNotification(TimeStampedModel): + """In-app notification written by the ELM itself (BR-EL-035); the + outbox/email transport is out of scope for this baseline.""" + + user_id = models.IntegerField(db_index=True) # recipient + request = models.ForeignKey(LeaveRequest, null=True, blank=True, + on_delete=models.CASCADE, + related_name="notifications") + kind = models.CharField(max_length=32) # assigned | ... + message = models.TextField() + read_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "leave_notification" + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"#{self.user_id} {self.kind}" diff --git a/modules/leave/registry.py b/modules/leave/registry.py new file mode 100644 index 00000000..38186f1f --- /dev/null +++ b/modules/leave/registry.py @@ -0,0 +1,91 @@ +"""What the server tells the shell about this module (ADR-0010). + +Nav items carry `required_permission` and the server filters before sending — +the client does zero filtering, so a link a role cannot use never reaches the +browser at all. +""" +MODULE = { + "code": "leave", "label": "Leave Management", "icon": "FaCalendarAlt", + "base_path": "/leave", "nav_section": "Leave", "sort_order": 40, + "status": "active", +} + +NAV_ITEMS = [ + # -- employees: themselves first ---------------------------------------- + {"code": "leave.mine", "label": "My Leave", + "icon": "FaCalendarAlt", "to": "/leave/mine", + "required_permission": "leave.request.view_self", "sort_order": 10}, + {"code": "leave.balances", "label": "Balances", + "icon": "FaLayerGroup", "to": "/leave/balances", + "required_permission": "leave.request.view_self", "sort_order": 20}, + {"code": "leave.tasks", "label": "Leave Tasks", + "icon": "FaCheckDouble", "to": "/leave/tasks", + "required_permission": "leave.substitute.respond", "sort_order": 30}, + + # -- reviewers and authorities ----------------------------------------- + {"code": "leave.requests", "label": "All Requests", + "icon": "FaClipboardList", "to": "/leave/requests", + "required_permission": "leave.request.view", "sort_order": 40}, + {"code": "leave.admin", "label": "Administration", + "icon": "FaCog", "to": "/leave/admin", + "required_permission": "leave.type.manage", "sort_order": 50}, +] + +#: Every permission this module recognises; ROLE_GRANTS below says who holds each. +PERMISSIONS = [ + ("leave.request.view_self", "View your own leave requests and balances"), + ("leave.request.create", "Apply for leave and nominate a substitute"), + ("leave.request.delete", "Withdraw your own pending request"), + ("leave.request.view", "View leave requests, within scope"), + ("leave.request.approve", "Unit Head: approve or recommend a request"), + ("leave.request.route", "Establishment: verify and forward a request"), + ("leave.request.sanction", "Give the final sanction on a request"), + ("leave.substitute.respond", "Accept or decline a substitute charge"), + ("leave.balance.view", "View any employee's leave balance"), + ("leave.balance.update", "Adjust a leave balance (maintenance)"), + ("leave.type.manage", "Configure leave types and entitlement rules"), + ("leave.calendar.manage", "Maintain holiday, RH and vacation calendars"), + ("leave.record.manage", "Enter offline leave and annotate records"), + ("leave.sla.manage", "Configure review SLA thresholds and escalation"), +] + +#: Performed by a service or a scheduled task, so no designation holds them. +SYSTEM_PERMISSIONS = [] + +#: Everyone who can be away holds the employee set. +_SELF = [ + "leave.request.view_self", + "leave.request.create", + "leave.request.delete", + "leave.substitute.respond", +] + +_UNIT_HEAD = [*_SELF, "leave.request.view", "leave.request.approve"] + +_ESTABLISHMENT = [*_SELF, "leave.request.view", "leave.request.route"] + +_AUTHORITY = [*_SELF, "leave.request.view", "leave.request.sanction"] + +_ADMIN = [ + "leave.request.view", + "leave.balance.view", + "leave.balance.update", + "leave.type.manage", + "leave.calendar.manage", + "leave.record.manage", + "leave.sla.manage", +] + +#: Keyed by the designation name as it exists in globals_designation. +#: The office roles below are the ELM baseline's sanctioning hierarchy +#: (BR-EL-016…020); `faculty` and `staff` are the basic kinds. +ROLE_GRANTS = { + "faculty": _SELF, + "staff": _SELF, + "Unit Head": _UNIT_HEAD, + "Establishment Section": _ESTABLISHMENT, + "Dean Academic": _AUTHORITY, + "Registrar": _AUTHORITY, + "Director": _AUTHORITY, + "Leave Administrator": _ADMIN, +} diff --git a/modules/leave/schedule.py b/modules/leave/schedule.py new file mode 100644 index 00000000..783b0281 --- /dev/null +++ b/modules/leave/schedule.py @@ -0,0 +1,27 @@ +"""What this module needs run on a timer. + +Tasks are named by string, not imported, so config assembles the schedule +without importing anything out of the module. +""" +from celery.schedules import crontab + +#: Which worker pool each task lands on; without it everything queues on default. +TASK_ROUTES = { + "leave.sweep_sla": {"queue": "notifications"}, +} + +BEAT_SCHEDULE = { + # BR-EL-032/033: pending files are reminded and escalated on the clock. + "leave.sweep-sla": { + "task": "leave.sweep_sla", + "schedule": 900.0, # 15 minutes + "options": {"expires": 840}, + }, + # The boundary only bites once a year, but it must not be missed: + # checked daily and a no-op once the year's marker row exists. + "leave.year-end": { + "task": "leave.run_year_end", + "schedule": crontab(hour=1, minute=0), + "options": {"expires": 21600}, + }, +} 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/actions.py b/modules/leave/selectors/actions.py new file mode 100644 index 00000000..24851369 --- /dev/null +++ b/modules/leave/selectors/actions.py @@ -0,0 +1,63 @@ +"""Which buttons this actor may see on this file (display only). + +Each action's endpoint re-checks its own permission and identity — this +module authorises nothing, it just keeps the UI from offering a click that +would 403. Same doctrine as the scoping selectors. +""" +from __future__ import annotations + +from modules.leave.domain import states as st +from modules.leave.domain.categories import ( + EXTENDABLE_CATEGORIES, + RESUMABLE_CATEGORIES, +) +from modules.leave.models import LeaveRequest, LeaveResumption + + +def allowed_actions(request: LeaveRequest, actor) -> list[str]: + uid = getattr(actor, "user_id", None) + if uid is None: + return ["history", "print"] + + is_applicant = request.user_id == uid + actions: list[str] = [] + + if is_applicant and request.status in st.PENDING: + actions.append("withdraw") + # The file, not the viewer: a declined substitute parks it with the + # applicant, and everyone looking at it sees what it now waits for. + if request.status == st.APPLICANT_ACTION: + actions.append("nominate_substitute") + if (is_applicant and request.status == st.APPROVED + and request.kind == LeaveRequest.NEW): + actions.append("cancel") + if request.category in EXTENDABLE_CATEGORIES: + actions.append("extend") + if (request.category in RESUMABLE_CATEGORIES + and not request.resumptions + .filter(status=LeaveResumption.PENDING).exists()): + actions.append("resumption") + + if (request.substitute_user_id == uid + and request.status == st.AWAITING_SUBSTITUTE): + actions.append("respond_substitute") + if (request.unit_head_user_id == uid + and request.status == st.AWAITING_UH_REVIEW): + actions.append("review") + if (request.establishment_user_id == uid + and request.status == st.AWAITING_ESTABLISHMENT): + actions.append("route") + if request.status == st.AWAITING_DIRECTOR and is_applicant: + actions.append("sanction") + elif (request.status == st.AWAITING_FINAL + and request.authority_user_id == uid): + actions.append("sanction") + + pending_resumption = (request.resumptions + .filter(status=LeaveResumption.PENDING).first()) + if (pending_resumption is not None + and uid in (request.authority_user_id, request.unit_head_user_id)): + actions.append("review_resumption") + + actions += ["history", "print"] + return actions diff --git a/modules/leave/selectors/scoping.py b/modules/leave/selectors/scoping.py new file mode 100644 index 00000000..43cde710 --- /dev/null +++ b/modules/leave/selectors/scoping.py @@ -0,0 +1,104 @@ +"""Role scope, decided in one place. + +Every list and detail view starts from a queryset built here. Ownership is +enforced by narrowing, never by fetching a row and then checking it — so a +row you may not see is a 404, not a 403. A 403 would confirm it exists. + +Holding `leave.request.view` widens the list to every request; everyone else +sees their own plus the files where they hold a desk (their substitute turn, +their assigned task, their unit's review). +""" +from __future__ import annotations + +from django.db.models import Q, QuerySet + +from core.api.exceptions import NotFoundError +from modules.leave.domain import states as st +from modules.leave.models import LeaveBalance, LeaveNotification, LeaveRequest + +P_VIEW_ALL = "leave.request.view" +P_VIEW_SELF = "leave.request.view_self" +P_VIEW_BALANCES = "leave.balance.view" + + +def _has(actor, *codes: str) -> bool: + check = getattr(actor, "has_permission", None) + return bool(check) and any(check(c) for c in codes) + + +def _own(actor) -> int | None: + """None means no identity to scope by, which resolves to an empty + queryset. Deny is the only safe reading of "who is asking?" being + unanswerable.""" + return getattr(actor, "user_id", None) + + +def _involved(actor) -> Q: + uid = _own(actor) + return (Q(user_id=uid) + | Q(substitute_user_id=uid) + | Q(task_user_id=uid) + | Q(unit_head_user_id=uid) + | Q(establishment_user_id=uid) + | Q(authority_user_id=uid) + | Q(decided_by_user_id=uid)) + + +def requests_for(actor) -> QuerySet[LeaveRequest]: + qs = LeaveRequest.objects.all() + if _has(actor, P_VIEW_ALL): + return qs + if _own(actor) is None: + return qs.none() + return qs.filter(_involved(actor)).distinct() + + +def request_or_404(actor, pk: int) -> LeaveRequest: + row = requests_for(actor).filter(pk=pk).first() + if row is None: + raise NotFoundError("Leave request not found.", code="not_found") + return row + + +def request_for_action(pk: int) -> LeaveRequest: + """A row an actor wants to *act* on. + + Reads stay scoped (a stranger gets 404), but an action loads by id alone: + the service's own identity gate then answers 403 "assigned to someone + else", which is the honest answer when the file exists and is simply not + yours — and never leaks anything a scoped read would not. + """ + row = LeaveRequest.objects.filter(pk=pk).first() + if row is None: + raise NotFoundError("Leave request not found.", code="not_found") + return row + + +def tasks_for(actor) -> QuerySet[LeaveRequest]: + """Files waiting on *this* actor: their assigned task, their substitute + turn, or their own action to take.""" + uid = _own(actor) + if uid is None: + return LeaveRequest.objects.none() + return (LeaveRequest.objects + .filter(status__in=st.PENDING) + .filter(Q(task_user_id=uid) + | Q(substitute_user_id=uid, + status=st.AWAITING_SUBSTITUTE) + | Q(user_id=uid, status=st.APPLICANT_ACTION)) + .distinct()) + + +def balances_rows_for(actor, user_id: int | None = None + ) -> QuerySet[LeaveBalance]: + target = user_id if user_id is not None else _own(actor) + if target is None: + return LeaveBalance.objects.none() + return LeaveBalance.objects.filter(user_id=target) + + +def notifications_for(actor) -> QuerySet[LeaveNotification]: + uid = _own(actor) + if uid is None: + return LeaveNotification.objects.none() + return LeaveNotification.objects.filter(user_id=uid) diff --git a/modules/leave/services/__init__.py b/modules/leave/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/leave/services/applications.py b/modules/leave/services/applications.py new file mode 100644 index 00000000..52f66669 --- /dev/null +++ b/modules/leave/services/applications.py @@ -0,0 +1,425 @@ +"""Everything an applicant does before a decision (EL-UC-001, 006, 007, +011, 019; BR-EL-010…014, 022, 029). + +Each function owns one rule cluster and speaks the shared envelope through +`validated`; status movement, audit and task hand-off all go via workflow. +""" +from __future__ import annotations + +from datetime import timedelta + +from django.db import transaction +from django.utils import timezone + +from core.api.exceptions import BadRequestError, ConflictError +from modules.directory import contracts as directory +from modules.leave.domain import counting, states as st, validation as v +from modules.leave.domain.categories import ( + CATEGORIES, + EXTENDABLE_CATEGORIES, + RESUMABLE_CATEGORIES, + label_for, +) +from modules.leave.models import ( + LeaveRequest, + LeaveRequestEvent, + LeaveResumption, + SlaThreshold, +) +from modules.leave.services import ( + authz, + balances, + calendar as calendar_service, + hierarchy, + notifications, + policy as policy_service, + workflow, +) +from modules.leave.services.errors import validated + +BUSY_STATUSES = [*st.PENDING, st.APPROVED] + + +def _intervals(user_id: int, *, statuses: list[str] | None = None + ) -> list[v.Interval]: + rows = LeaveRequest.objects.filter( + user_id=user_id, status__in=statuses or BUSY_STATUSES) + return [v.Interval(r.start_date, r.end_date, r.half_day, r.category) + for r in rows] + + +def _validate_substitute(*, nominee, applicant: int, start, end) -> None: + if nominee is None: + raise v.ValidationError( + "This leave needs responsibility coverage: nominate a substitute.", + code="substitute_required") + v.check_substitute(nominee=nominee, applicant=applicant, + employee_ids=directory.held_employee_ids()) + probe = v.Interval(start, end, "none", "el") + for other in _intervals(nominee): + if v.intervals_overlap(probe, other): + raise v.ValidationError( + "The nominated substitute is already on leave during that " + "period.", code="substitute_busy") + + +def _routing(*, department: str, applicant_id: int, needs_substitute: bool + ) -> tuple[str, int | None, int | None, bool]: + """Where a submitted file starts: the substitute gate first (BR-EL-015 — + routing cannot continue until consent), then the Director's own file + self-sanctions (BR-EL-020), otherwise Unit Head review. An applicant who + *is* their unit's head never waits on their own desk — their file enters + at establishment instead (self-approval guard).""" + director = hierarchy.is_director(applicant_id) + if director: + return (st.AWAITING_SUBSTITUTE if needs_substitute + else st.AWAITING_DIRECTOR), None, None, True + head = hierarchy.require_unit_head(department) + if head == applicant_id: + establishment = hierarchy.establishment_assignee(department) + if establishment is None: + from core.api.exceptions import ConflictError + raise ConflictError( + "You are your unit's head, and no establishment desk is " + "configured to carry your file; ask an administrator to " + "assign one.", code="no_establishment_step") + return (st.AWAITING_SUBSTITUTE if needs_substitute + else st.AWAITING_ESTABLISHMENT), head, establishment, False + return (st.AWAITING_SUBSTITUTE if needs_substitute + else st.AWAITING_UH_REVIEW), head, None, False + + +def _calendar_checks(category: str, start, end) -> tuple[object, set]: + """RH and VL are only valid against the published calendars (BR-EL-003, + BR-EL-006, BR-EL-031). Returns (calendar, closed_dates) for counting.""" + calendar = calendar_service.effective_calendar() + if category == "rh": + restricted = calendar_service.restricted_dates(calendar) + if not restricted: + raise ConflictError( + "No published Restricted Holiday calendar is in effect yet.", + code="no_rh_calendar") + v.check_rh_calendar(start=start, end=end, restricted_dates=restricted) + elif category == "vl": + windows = calendar_service.vacation_windows() + if not windows: + raise ConflictError( + "No vacation period has been published from the Academic " + "Calendar yet.", code="no_vacation") + v.check_vl_vacation_period(start=start, end=end, + vacation_periods=windows) + return calendar, calendar_service.closed_dates(calendar) + + +@validated +@transaction.atomic +def submit(*, actor, category: str, start_date, end_date, + half_day: str = "none", purpose: str = "", + evidence_note: str = "", station_leave: bool = False, + station_place: str = "", station_reason: str = "", + substitute_user_id=None) -> LeaveRequest: + """EL-UC-001. Born in its first workflow state — there is no draft.""" + authz.require_employee(actor) + authz.require(actor, "leave.request.create") + if category not in CATEGORIES: + raise BadRequestError(f"Unknown leave category {category!r}.", + code="unknown_category") + faculty = policy_service.is_faculty(actor.kind) + v.check_category_applicable(category=category, is_faculty=faculty) + v.check_interval(start=start_date, end=end_date, half_day=half_day, + category=category) + + policy = policy_service.current_policy() + calendar, closed = _calendar_checks(category, start_date, end_date) + days = counting.chargeable_days( + category=category, start=start_date, end=end_date, half_day=half_day, + closed_holidays=closed) + year = policy_service.leave_year_of(policy, start_date) + + held = balances.available(actor.user_id, category, year, kind=actor.kind) + v.check_balance(available=held, required=days, category=category) + v.check_overlap(start=start_date, end=end_date, half_day=half_day, + category=category, existing=_intervals(actor.user_id)) + + needs_substitute = policy_service.substitute_required(policy, category) + if needs_substitute: + _validate_substitute(nominee=substitute_user_id, + applicant=actor.user_id, + start=start_date, end=end_date) + + status, unit_head, establishment, director = _routing( + department=hierarchy.department_of(actor.user_id), + applicant_id=actor.user_id, needs_substitute=needs_substitute) + + request = LeaveRequest.objects.create( + user_id=actor.user_id, kind=LeaveRequest.NEW, source="elm", + category=category, status=status, + start_date=start_date, end_date=end_date, half_day=half_day, + purpose=purpose, evidence_note=evidence_note, + station_leave=station_leave, station_place=station_place, + station_reason=station_reason, + chargeable_days=days, year=year, + substitute_user_id=substitute_user_id if needs_substitute else None, + substitute_status="pending" if needs_substitute else "none", + unit_head_user_id=unit_head, + establishment_user_id=establishment, + is_director_self_sanction=director, + ) + workflow.record_event(request, LeaveRequestEvent.SUBMITTED, actor=actor, + note=f"{label_for(category)} " + f"{start_date.isoformat()}…{end_date.isoformat()}" + f" ({days} day(s))") + workflow.assign_task(request, actor=actor) + return request + + +@transaction.atomic +def withdraw(actor, request: LeaveRequest) -> LeaveRequest: + """BR-EL-022 / CFR-EL-018: immediate, applicant-only, pending-only.""" + authz.require(actor, "leave.request.delete") + authz.require_applicant(actor, request) + if request.status not in st.PENDING: + raise ConflictError("Only a pending request can be withdrawn.", + code="not_pending") + holder, frm = request.task_user_id, request.status + request.status = st.WITHDRAWN + request.save(update_fields=["status", "updated_at"]) + workflow.record_event(request, LeaveRequestEvent.STATUS_CHANGE, frm=frm, + to=st.WITHDRAWN, actor=actor, + note="withdrawn by applicant") + workflow.close_task(request) + notifications.notify(holder, "withdrawn", + "A leave request you were handling has been " + "withdrawn.", request) + notifications.notify(request.user_id, "withdrawn", + "Your leave request has been withdrawn.", request) + return request + + +@validated +@transaction.atomic +def nominate_substitute(actor, request: LeaveRequest, + substitute_user_id: int) -> LeaveRequest: + """EL-UC-019: replace a declined nominee (or change one still pending).""" + authz.require(actor, "leave.request.create") + authz.require_applicant(actor, request) + if request.status not in (st.APPLICANT_ACTION, st.AWAITING_SUBSTITUTE): + raise ConflictError( + "A substitute can only be nominated while one is being sought.", + code="substitute_not_open") + _validate_substitute(nominee=substitute_user_id, + applicant=request.user_id, + start=request.start_date, end=request.end_date) + request.substitute_user_id = substitute_user_id + request.substitute_status = "pending" + request.save(update_fields=["substitute_user_id", "substitute_status", + "updated_at"]) + if request.status == st.APPLICANT_ACTION: + workflow.move(request, st.AWAITING_SUBSTITUTE, actor=actor, + actor_kind=st.EMPLOYEE, + permission="leave.request.create", + guards=("replacement_nominated",), + note=f"nominee → {substitute_user_id}") + else: + workflow.assign_task(request, actor=actor, note="nominee replaced") + return request + + +@validated +@transaction.atomic +def request_cancellation(actor, base: LeaveRequest, *, + reason: str = "") -> LeaveRequest: + """EL-UC-006: a separate request row; the approved leave stands until + this one is sanctioned (which then restores the balance).""" + authz.require(actor, "leave.request.create") + authz.require_applicant(actor, base) + if base.status != st.APPROVED or base.kind != LeaveRequest.NEW: + raise ConflictError( + "Only an approved leave application can be cancelled.", + code="not_cancellable") + department = hierarchy.department_of(actor.user_id) + status, unit_head, establishment, director = _routing( + department=department, applicant_id=actor.user_id, + needs_substitute=False) + derived = LeaveRequest.objects.create( + user_id=actor.user_id, kind=LeaveRequest.CANCELLATION, source="elm", + category=base.category, status=status, + start_date=base.start_date, end_date=base.end_date, + half_day=base.half_day, purpose=reason, + chargeable_days=base.chargeable_days, year=base.year, + base_request=base, unit_head_user_id=unit_head, + establishment_user_id=establishment, + is_director_self_sanction=director, + ) + workflow.record_event(derived, LeaveRequestEvent.SUBMITTED, actor=actor, + note=f"cancellation of #{base.id}") + workflow.assign_task(derived, actor=actor) + notifications.notify( + base.user_id, "cancellation_requested", + "Your cancellation request is now in review; the leave stands until " + "it is sanctioned.", derived) + return derived + + +@validated +@transaction.atomic +def request_extension(actor, base: LeaveRequest, *, new_end_date, + reason: str = "", substitute_user_id=None + ) -> LeaveRequest: + """EL-UC-008: only the incremental period is charged and checked.""" + authz.require(actor, "leave.request.create") + authz.require_applicant(actor, base) + if base.status != st.APPROVED or base.kind != LeaveRequest.NEW: + raise ConflictError("Only an approved leave can be extended.", + code="not_approved") + if base.category not in EXTENDABLE_CATEGORIES: + raise BadRequestError( + f"{label_for(base.category)} cannot be extended; apply for fresh " + "leave instead.", code="not_extendable") + if new_end_date <= base.end_date: + raise v.ValidationError( + "An extension must end after the current leave ends.", + code="invalid_interval") + + start = base.end_date + timedelta(days=1) + policy = policy_service.current_policy() + _, closed = _calendar_checks(base.category, start, new_end_date) + days = counting.chargeable_days( + category=base.category, start=start, end=new_end_date, + half_day="none", closed_holidays=closed) + + held = balances.available(actor.user_id, base.category, base.year, + kind=actor.kind) + v.check_balance(available=held, required=days, category=base.category) + v.check_overlap(start=start, end=new_end_date, half_day="none", + category=base.category, + existing=_intervals(actor.user_id)) + + needs_substitute = policy_service.substitute_required(policy, + base.category) + if needs_substitute: + _validate_substitute(nominee=substitute_user_id, + applicant=actor.user_id, + start=start, end=new_end_date) + + status, unit_head, establishment, director = _routing( + department=hierarchy.department_of(actor.user_id), + applicant_id=actor.user_id, needs_substitute=needs_substitute) + derived = LeaveRequest.objects.create( + user_id=actor.user_id, kind=LeaveRequest.EXTENSION, source="elm", + category=base.category, status=status, + start_date=start, end_date=new_end_date, half_day="none", + purpose=reason, chargeable_days=days, year=base.year, + base_request=base, unit_head_user_id=unit_head, + establishment_user_id=establishment, + is_director_self_sanction=director, + substitute_user_id=substitute_user_id if needs_substitute else None, + substitute_status="pending" if needs_substitute else "none", + ) + workflow.record_event(derived, LeaveRequestEvent.SUBMITTED, actor=actor, + note=f"extension of #{base.id} through " + f"{new_end_date.isoformat()} ({days} day(s))") + workflow.assign_task(derived, actor=actor) + return derived + + +@validated +@transaction.atomic +def submit_resumption(actor, base: LeaveRequest, *, actual_date, + details: str = "") -> LeaveResumption: + """EL-UC-011: the employee reports the real return; the authority's + review adjusts the balance to match (BR-EL-026).""" + authz.require(actor, "leave.request.create") + authz.require_applicant(actor, base) + if base.status != st.APPROVED or base.kind != LeaveRequest.NEW: + raise ConflictError( + "A resumption can only be filed against an approved leave.", + code="not_approved") + if base.category not in RESUMABLE_CATEGORIES: + raise BadRequestError( + f"{label_for(base.category)} has no formal resumption.", + code="not_resumable") + if actual_date < base.start_date: + raise v.ValidationError( + "The actual return cannot be before the leave began.", + code="invalid_interval") + if base.resumptions.filter(status=LeaveResumption.PENDING).exists(): + raise ConflictError("A resumption for this leave is already awaiting " + "review.", code="resumption_pending") + + approved_end = base.actual_end_date or base.end_date + resumption = LeaveResumption.objects.create( + request=base, actual_date=actual_date, details=details, + approved_end_date=approved_end) + reviewer = base.authority_user_id or base.unit_head_user_id + base.task_class = SlaThreshold.RESUMPTION_REVIEW + base.task_user_id = reviewer + base.task_assigned_at = timezone.now() + base.reminder_sent_at = None + base.escalated_at = None + base.save(update_fields=["task_class", "task_user_id", "task_assigned_at", + "reminder_sent_at", "escalated_at", "updated_at"]) + workflow.record_event(base, LeaveRequestEvent.NOTE, actor=actor, + note=f"resumption filed: actual {actual_date.isoformat()}") + notifications.notify(reviewer, "assigned", + "A resumption report awaits your review.", base) + return resumption + + +@validated +@transaction.atomic +def record_offline(actor, *, user_id: int, category: str, start_date, + end_date, half_day: str = "none", purpose: str = "", + sanction_reference: str, note: str = "") -> LeaveRequest: + """BR-EL-029: recorded straight to approved with its paper reference — + never a second workflow, never a silent balance change.""" + authz.require(actor, "leave.record.manage") + if category not in CATEGORIES: + raise BadRequestError(f"Unknown leave category {category!r}.", + code="unknown_category") + if not str(sanction_reference).strip(): + raise v.ValidationError( + "An offline record needs its paper reference (sanction order " + "number or letter).", code="reference_required") + person = directory.get_users([user_id]).get(user_id) + if person is None or person.kind not in ("faculty", "staff"): + raise BadRequestError( + "Offline leave can only be recorded for an employee.", + code="not_employee") + v.check_interval(start=start_date, end=end_date, half_day=half_day, + category=category) + + policy = policy_service.current_policy() + _, closed = _calendar_checks(category, start_date, end_date) + days = counting.chargeable_days( + category=category, start=start_date, end=end_date, half_day=half_day, + closed_holidays=closed) + year = policy_service.leave_year_of(policy, start_date) + + held = balances.available(user_id, category, year, kind=person.kind) + v.check_balance(available=held, required=days, category=category) + v.check_overlap(start=start_date, end=end_date, half_day=half_day, + category=category, existing=_intervals(user_id)) + + request = LeaveRequest.objects.create( + user_id=user_id, kind=LeaveRequest.OFFLINE, source="offline", + category=category, status=st.APPROVED, + start_date=start_date, end_date=end_date, half_day=half_day, + purpose=purpose, chargeable_days=days, year=year, + sanction_reference=sanction_reference.strip(), + admin_note=note, + ) + balances.deduct(user_id, category, year, days, request=request, + note=f"offline record {request.sanction_reference}", + actor_user_id=getattr(actor, "user_id", None)) + workflow.record_event(request, LeaveRequestEvent.DECISION, actor=actor, + note=f"offline entry, reference " + f"{request.sanction_reference}") + workflow.record_event(request, LeaveRequestEvent.BALANCE_EFFECT, + actor=actor, + note=f"-{days} {category.upper()}") + notifications.notify(user_id, "offline_recorded", + f"An offline {label_for(category)} " + f"({days} day(s)) was recorded for you.", + request) + return request diff --git a/modules/leave/services/authz.py b/modules/leave/services/authz.py new file mode 100644 index 00000000..1221e762 --- /dev/null +++ b/modules/leave/services/authz.py @@ -0,0 +1,44 @@ +"""Write authority, stated once. Every service write path calls it. + +Reads are scoped by `selectors.scoping` (a row you may not see is a 404); +this module is the 403: you can see it, but you may not do that to it. +""" +from __future__ import annotations + +from core.api.exceptions import PermissionDeniedError + + +def require(actor, *codes: str) -> None: + """Admit the actor, or raise PermissionDeniedError.""" + if actor is None: + raise PermissionDeniedError("Authentication required.", + code="not_authenticated") + check = getattr(actor, "has_permission", None) + if check is None or not any(check(c) for c in codes): + raise PermissionDeniedError( + f"This action needs {' or '.join(codes)}.", + code="permission_denied") + + +def require_employee(actor) -> None: + """Only faculty and staff may apply or respond as a substitute; students + hold no leave identity at all (PS §5, BR-EL-001).""" + require(actor, "leave.request.view_self") + if getattr(actor, "kind", None) not in ("faculty", "staff"): + raise PermissionDeniedError("Leave is available to employees only.", + code="permission_denied") + + +def require_applicant(actor, request) -> None: + if getattr(actor, "user_id", None) != request.user_id: + raise PermissionDeniedError( + "Only the applicant may act on their own request.", + code="permission_denied") + + +def require_user(actor, user_id: int) -> None: + """Identity gate for a step assigned to a specific desk.""" + if getattr(actor, "user_id", None) != user_id: + raise PermissionDeniedError( + "This request is assigned to someone else.", + code="permission_denied") diff --git a/modules/leave/services/balances.py b/modules/leave/services/balances.py new file mode 100644 index 00000000..f494a824 --- /dev/null +++ b/modules/leave/services/balances.py @@ -0,0 +1,153 @@ +"""Balance reads and the ledger (BR-EL-002…009, CFR-EL-003.5). + +Rows are materialised so an approval can lock one with `select_for_update` +inside its own transaction; the account transaction is the immutable evidence +behind every number on the balance screen. +""" +from __future__ import annotations + +from decimal import Decimal + +from django.db import transaction + +from modules.directory import contracts as directory +from modules.leave.domain.categories import CATEGORIES, VL +from modules.leave.models import LeaveAccountTransaction, LeaveBalance +from modules.leave.services import authz +from modules.leave.services.policy import ( + current_leave_year, + current_policy, + entitlement, + is_faculty, +) + +#: Which balance column each ledger kind moves. Signs live in the `days` +#: argument, so every kind has exactly one home. +FIELD_FOR_KIND = { + LeaveAccountTransaction.CREDIT: "credited", + LeaveAccountTransaction.DEDUCTION: "used", + LeaveAccountTransaction.EXTENSION_DEDUCTION: "used", + LeaveAccountTransaction.CANCELLATION_RESTORATION: "used", + LeaveAccountTransaction.REFUND: "used", + LeaveAccountTransaction.ADMIN_ADJUSTMENT: "adjusted", + LeaveAccountTransaction.CARRY_FORWARD: "carried_forward", + LeaveAccountTransaction.VL_TO_EL_CONVERSION: "adjusted", + LeaveAccountTransaction.YEAR_END_LAPSE: "adjusted", +} + + +def _kind_of(user_id: int) -> str: + users = directory.get_users([user_id]) + return users[user_id].kind if user_id in users else "staff" + + +def _credit_open_year(row: LeaveBalance) -> None: + """Top `credited` up to this year's entitlement while the year is open. + + Idempotent: it only ever raises `credited` toward the entitlement, so a + policy change applies prospectively without re-crediting conversions or + adjustments (which live in other columns). + """ + policy = current_policy() + if row.year > current_leave_year(policy): + return + faculty = is_faculty(_kind_of(row.user_id)) + entitled = entitlement(policy, row.category, faculty) + if row.credited < entitled: + row.credited = entitled + row.save(update_fields=["credited", "updated_at"]) + + +def ensure_year(user_id: int, year: int, *, kind: str | None = None, + credit: bool = True) -> dict[str, LeaveBalance]: + """Materialise every applicable category row for this year. + + Staff get no VL track (BR-EL-006); everyone else gets all six so the + balance screen shows zeroes rather than holes. `credit=False` materialises + without topping up to entitlement — the year-end sweep processes the + balances as they stand and must not invent a year's credit on the way out. + """ + faculty = is_faculty(kind if kind is not None else _kind_of(user_id)) + rows: dict[str, LeaveBalance] = {} + for category in CATEGORIES: + if category == VL and not faculty: + continue + row, _ = LeaveBalance.objects.get_or_create( + user_id=user_id, category=category, year=year) + if credit: + _credit_open_year(row) + rows[category] = row + return rows + + +def available(user_id: int, category: str, year: int, + *, kind: str | None = None) -> Decimal: + rows = ensure_year(user_id, year, kind=kind) + row = rows.get(category) + if row is None: + return Decimal(0) + return row.carried_forward + row.credited + row.adjusted - row.used + + +@transaction.atomic +def post(user_id: int, category: str, year: int, kind: str, days: Decimal, + *, request=None, note: str = "", + actor_user_id: int | None = None, + credit: bool = True) -> LeaveAccountTransaction: + """One ledger entry under a row lock: the balance moves and the evidence + is written in the same transaction, or neither happens. + + `credit=False` skips the entitlement top-up — the year-end sweep posts + against balances as they stand, and topping up mid-sweep would grant a + year's credit on the way out of it.""" + row, _ = (LeaveBalance.objects.select_for_update() + .get_or_create(user_id=user_id, category=category, year=year)) + if credit: + _credit_open_year(row) + field = FIELD_FOR_KIND[kind] + setattr(row, field, getattr(row, field) + Decimal(days)) + row.save(update_fields=[field, "updated_at"]) + return LeaveAccountTransaction.objects.create( + user_id=user_id, category=category, year=year, kind=kind, + days=Decimal(days), request=request, note=note[:300], + actor_user_id=actor_user_id) + + +def deduct(user_id: int, category: str, year: int, days, *, request=None, + note: str = "", actor_user_id: int | None = None): + """Approved leave consumes balance (positive days).""" + return post(user_id, category, year, + LeaveAccountTransaction.DEDUCTION, Decimal(days), + request=request, note=note, actor_user_id=actor_user_id) + + +def restore(user_id: int, category: str, year: int, days, *, request=None, + note: str = "", actor_user_id: int | None = None): + """A cancelled or short-returned leave gives days back (negative).""" + return post(user_id, category, year, + LeaveAccountTransaction.CANCELLATION_RESTORATION, + -abs(Decimal(days)), request=request, note=note, + actor_user_id=actor_user_id) + + +def refund(user_id: int, category: str, year: int, days, *, request=None, + note: str = "", actor_user_id: int | None = None): + """Resumption review found fewer days taken than approved.""" + return post(user_id, category, year, LeaveAccountTransaction.REFUND, + -abs(Decimal(days)), request=request, note=note, + actor_user_id=actor_user_id) + + +def adjust(actor, user_id: int, category: str, year: int, days, note: str + ) -> LeaveAccountTransaction: + """Record maintenance, never sanctioning (BR-EL-037): audited as its own + ledger kind so it can never masquerade as an approval.""" + authz.require(actor, "leave.balance.update") + days = Decimal(str(days)) + if days == 0: + from core.api.exceptions import BadRequestError + raise BadRequestError("A zero-day adjustment changes nothing.", + code="zero_adjustment") + return post(user_id, category, year, + LeaveAccountTransaction.ADMIN_ADJUSTMENT, days, + note=note, actor_user_id=getattr(actor, "user_id", None)) diff --git a/modules/leave/services/calendar.py b/modules/leave/services/calendar.py new file mode 100644 index 00000000..1483dda4 --- /dev/null +++ b/modules/leave/services/calendar.py @@ -0,0 +1,130 @@ +"""Calendar reads and configuration (BR-EL-031, CFR-EL-005.2, BR-EL-006). + +Only a *published* calendar validates anything; drafts are the clerk's +workbench. Employees read the effective one; admins write versions. +""" +from __future__ import annotations + +from datetime import date + +from django.db import transaction +from django.utils import timezone + +from core.api.exceptions import BadRequestError, ConflictError, NotFoundError +from modules.leave.models import Holiday, HolidayCalendar, VacationPeriod +from modules.leave.services import authz + + +def effective_calendar() -> HolidayCalendar | None: + return (HolidayCalendar.objects + .filter(status=HolidayCalendar.PUBLISHED) + .order_by("-published_at", "-id") + .first()) + + +def closed_dates(calendar: HolidayCalendar | None = None) -> set[date]: + calendar = calendar if calendar is not None else effective_calendar() + if calendar is None: + return set() + return set(calendar.holidays.filter(kind=Holiday.CLOSED) + .values_list("date", flat=True)) + + +def restricted_dates(calendar: HolidayCalendar | None = None) -> set[date]: + """The only dates on which an RH request is valid.""" + calendar = calendar if calendar is not None else effective_calendar() + if calendar is None: + return set() + return set(calendar.holidays.filter(kind=Holiday.RESTRICTED) + .values_list("date", flat=True)) + + +def vacation_windows() -> list[tuple[date, date]]: + return [(v.start_date, v.end_date) + for v in VacationPeriod.objects.all()] + + +@transaction.atomic +def create_calendar(actor, *, title: str, academic_year: str = "", + holidays: list[dict] | None = None, + publish: bool = False) -> HolidayCalendar: + authz.require(actor, "leave.calendar.manage") + calendar = HolidayCalendar.objects.create( + title=title, academic_year=academic_year) + Holiday.objects.bulk_create([ + Holiday(calendar=calendar, + date=date.fromisoformat(str(h["date"])), + name=h.get("name", ""), + kind=h.get("kind", Holiday.CLOSED)) + for h in (holidays or []) + ]) + if publish: + _publish(calendar, actor) + return calendar + + +@transaction.atomic +def publish_calendar(actor, pk: int) -> HolidayCalendar: + authz.require(actor, "leave.calendar.manage") + calendar = HolidayCalendar.objects.filter(pk=pk).first() + if calendar is None: + raise NotFoundError("Calendar not found.", code="not_found") + if calendar.status == HolidayCalendar.PUBLISHED: + raise ConflictError("That calendar is already published.", + code="already_published") + _publish(calendar, actor) + return calendar + + +def _publish(calendar: HolidayCalendar, actor) -> None: + calendar.status = HolidayCalendar.PUBLISHED + calendar.published_at = timezone.now() + calendar.published_by_user_id = getattr(actor, "user_id", None) + calendar.save(update_fields=["status", "published_at", + "published_by_user_id", "updated_at"]) + + +@transaction.atomic +def upsert_vacation(actor, data: dict) -> VacationPeriod: + authz.require(actor, "leave.calendar.manage") + start = date.fromisoformat(str(data["start_date"])) + end = date.fromisoformat(str(data["end_date"])) + if end < start: + raise BadRequestError("The vacation period ends before it starts.", + code="invalid_interval") + pk = data.get("id") + if pk: + period = VacationPeriod.objects.filter(pk=pk).first() + if period is None: + raise NotFoundError("Vacation period not found.", code="not_found") + for field in ("name", "academic_year", "source_ref"): + if field in data: + setattr(period, field, data[field]) + period.start_date, period.end_date = start, end + period.save() + return period + return VacationPeriod.objects.create( + name=data.get("name", ""), academic_year=data.get("academic_year", ""), + start_date=start, end_date=end, source_ref=data.get("source_ref", "")) + + +def calendar_payload() -> dict: + """What the employee-facing calendar endpoint returns.""" + calendar = effective_calendar() + vacations = VacationPeriod.objects.all() + return { + "calendar": None if calendar is None else { + "id": calendar.id, "title": calendar.title, + "academic_year": calendar.academic_year, + "published_at": calendar.published_at, + }, + "holidays": ([] if calendar is None else [ + {"date": h.date.isoformat(), "name": h.name, "kind": h.kind} + for h in calendar.holidays.all() + ]), + "vacation_periods": [ + {"id": v.id, "name": v.name, "start_date": v.start_date.isoformat(), + "end_date": v.end_date.isoformat()} + for v in vacations + ], + } diff --git a/modules/leave/services/decisions.py b/modules/leave/services/decisions.py new file mode 100644 index 00000000..6507b8a5 --- /dev/null +++ b/modules/leave/services/decisions.py @@ -0,0 +1,376 @@ +"""Every decision on a file (EL-UC-002…006, 010, 011). + +Identity, state and balance effects live together on purpose: a sanction +that forgets to move the balance is a silent lie, so `_apply_effect` is +called from exactly the two paths that approve something. +""" +from __future__ import annotations + +from django.db import transaction +from django.utils import timezone + +from core.api.exceptions import BadRequestError, ConflictError +from modules.leave.domain import states as st +from modules.leave.domain.categories import label_for +from modules.leave.models import ( + LeaveAccountTransaction, + LeaveRequest, + LeaveRequestEvent, + LeaveResumption, +) +from modules.leave.services import ( + authz, + balances, + hierarchy, + notifications, + workflow, +) +from modules.leave.services.errors import validated + +APPROVE = "approve" +REJECT = "reject" +RECOMMEND = "recommended" +NOT_RECOMMEND = "not_recommended" +#: Unit-Head-final categories (BR-EL-016): CL and RH end here. +UNIT_HEAD_FINAL = ("cl", "rh") + + +def _department(request: LeaveRequest) -> str: + return hierarchy.department_of(request.user_id) + + +def _decision_fields(request, *, outcome: str, actor, note: str, + reference: str = "") -> None: + now = timezone.now() + request.decision = outcome + request.decided_at = now + request.decided_by_user_id = getattr(actor, "user_id", None) + request.decision_note = note + if reference: + request.sanction_reference = reference + request.save(update_fields=["decision", "decided_at", "decided_by_user_id", + "decision_note", "updated_at"] + + (["sanction_reference"] if reference else [])) + + +def _apply_effect(request: LeaveRequest, actor) -> None: + """The balance follows the decision (BR-EL-004, 007, 008, 014): + + * new/extension → deduct what was charged + * cancellation → restore the original deduction and retire the base + """ + if request.kind == LeaveRequest.CANCELLATION: + base = request.base_request + if base is None: + raise ConflictError("This cancellation points at nothing.", + code="orphan_cancellation") + balances.restore(base.user_id, base.category, base.year, + base.chargeable_days, request=base, + note=f"cancellation #{request.id} of #{base.id}", + actor_user_id=getattr(actor, "user_id", None)) + workflow.record_event( + request, LeaveRequestEvent.BALANCE_EFFECT, actor=actor, + note=f"+{base.chargeable_days} {base.category.upper()} restored " + f"to {base.user_id}") + frm = base.status + base.status = st.CANCELLED + base.save(update_fields=["status", "updated_at"]) + workflow.record_event(base, LeaveRequestEvent.STATUS_CHANGE, frm=frm, + to=st.CANCELLED, actor=actor, + note=f"cancelled by #{request.id}") + workflow.close_task(base) + notifications.notify( + base.user_id, "leave_cancelled", + f"Your {label_for(base.category)} leave was cancelled; " + f"{base.chargeable_days} day(s) returned to your balance.", + base) + return + + if request.kind == LeaveRequest.EXTENSION: + base = request.base_request + if base is None: + raise ConflictError("This extension points at nothing.", + code="orphan_extension") + balances.post(base.user_id, base.category, base.year, + LeaveAccountTransaction.EXTENSION_DEDUCTION, + request.chargeable_days, request=request, + note=f"extension #{request.id}", + actor_user_id=getattr(actor, "user_id", None)) + workflow.record_event( + request, LeaveRequestEvent.BALANCE_EFFECT, actor=actor, + note=f"-{request.chargeable_days} {request.category.upper()} " + f"extension") + base.end_date = request.end_date + base.chargeable_days = base.chargeable_days + request.chargeable_days + base.save(update_fields=["end_date", "chargeable_days", "updated_at"]) + workflow.record_event(base, LeaveRequestEvent.NOTE, actor=actor, + note=f"extended through " + f"{request.end_date.isoformat()}") + return + + balances.deduct(request.user_id, request.category, request.year, + request.chargeable_days, request=request, + note=f"approved #{request.id}", + actor_user_id=getattr(actor, "user_id", None)) + workflow.record_event( + request, LeaveRequestEvent.BALANCE_EFFECT, actor=actor, + note=f"-{request.chargeable_days} {request.category.upper()}") + + +def _notify_decision(request: LeaveRequest, actor, *, approved: bool, + by: str) -> None: + verb = "approved" if approved else "rejected" + notifications.notify( + request.user_id, "decision", + f"Your {label_for(request.category)} leave " + f"({request.start_date.isoformat()}…{request.end_date.isoformat()}) " + f"was {verb} by {by}.", request) + + +@transaction.atomic +def substitute_respond(actor, request: LeaveRequest, *, + response: str) -> LeaveRequest: + """BR-EL-015: the consent gate — accept forwards, decline parks the file + with the applicant (EL-UC-002, EL-UC-019).""" + authz.require(actor, "leave.substitute.respond") + authz.require_user(actor, request.substitute_user_id) + if request.status != st.AWAITING_SUBSTITUTE: + raise ConflictError( + "This request is not awaiting a substitute response.", + code="not_awaiting_substitute") + if response not in ("accept", "decline"): + raise BadRequestError("Response must be 'accept' or 'decline'.", + code="invalid_response") + + if response == "accept": + request.substitute_status = "accepted" + request.save(update_fields=["substitute_status", "updated_at"]) + guards = ("substitute_accepted",) + if request.is_director_self_sanction: + to = st.AWAITING_DIRECTOR + guards = (*guards, "applicant_is_director") + elif request.unit_head_user_id == request.user_id: + # Self-approval guard: the applicant is their own unit's head, + # so the file continues at establishment, never at their desk. + to = st.AWAITING_ESTABLISHMENT + establishment = hierarchy.establishment_assignee( + hierarchy.department_of(request.user_id)) + if establishment is None: + from core.api.exceptions import ConflictError + raise ConflictError( + "No establishment desk is configured to carry this file.", + code="no_establishment_step") + request.establishment_user_id = establishment + request.save(update_fields=["establishment_user_id", + "updated_at"]) + guards = (*guards, "applicant_is_unit_head") + else: + to = st.AWAITING_UH_REVIEW + workflow.move(request, to, actor=actor, actor_kind=st.SUBSTITUTE, + permission="leave.substitute.respond", guards=guards, + note="substitute accepted the charge") + notifications.notify( + request.user_id, "substitute_accepted", + "Your substitute accepted the charge; your request continues in " + "review.", request) + else: + request.substitute_status = "declined" + request.save(update_fields=["substitute_status", "updated_at"]) + workflow.move(request, st.APPLICANT_ACTION, actor=actor, + actor_kind=st.SUBSTITUTE, + permission="leave.substitute.respond", + guards=("substitute_declined",), + note="substitute declined the charge") + notifications.notify( + request.user_id, "substitute_declined", + "Your substitute declined; nominate another so your request can " + "continue.", request) + return request + + +@validated +@transaction.atomic +def unit_head_review(actor, request: LeaveRequest, *, outcome: str, + note: str = "", reference: str = "") -> LeaveRequest: + """EL-UC-003 / BR-EL-016…017. CL and RH end here; higher categories are + only ever *recommended* at this desk — a recommendation is not a no.""" + authz.require(actor, "leave.request.approve") + authz.require_user(actor, request.unit_head_user_id) + if request.status != st.AWAITING_UH_REVIEW: + raise ConflictError("This request is not awaiting Unit Head review.", + code="not_awaiting_review") + now = timezone.now() + request.unit_head_at = now + request.unit_head_note = note + request.unit_head_outcome = outcome + + if request.category in UNIT_HEAD_FINAL: + if outcome not in (APPROVE, REJECT): + raise BadRequestError( + "Casual and Restricted leave take approve or reject at this " + "desk.", code="invalid_outcome") + request.save(update_fields=["unit_head_outcome", "unit_head_at", + "unit_head_note", "updated_at"]) + _decision_fields(request, outcome=outcome, actor=actor, note=note, + reference=reference) + workflow.move(request, st.APPROVED if outcome == APPROVE + else st.REJECTED, + actor=actor, actor_kind=st.UNIT_HEAD, + permission="leave.request.approve", + guards=("unit_head_final", f"outcome_{outcome}"), + note=note) + if outcome == APPROVE: + _apply_effect(request, actor) + _notify_decision(request, actor, approved=outcome == APPROVE, + by="your Unit Head") + return request + + if outcome not in (RECOMMEND, NOT_RECOMMEND): + raise BadRequestError( + "This category takes 'recommended' or 'not_recommended' at the " + "Unit Head desk.", code="invalid_outcome") + request.save(update_fields=["unit_head_outcome", "unit_head_at", + "unit_head_note", "updated_at"]) + department = _department(request) + establishment = hierarchy.establishment_assignee(department) + if establishment: + to, guards = st.AWAITING_ESTABLISHMENT, ("higher_sanction", + "establishment_configured") + request.establishment_user_id = establishment + request.save(update_fields=["establishment_user_id", "updated_at"]) + else: + to, guards = st.AWAITING_FINAL, ("higher_sanction", + "no_establishment_step") + request.authority_user_id = hierarchy.require_authority( + request.category, department) + request.save(update_fields=["authority_user_id", "updated_at"]) + workflow.move(request, to, actor=actor, actor_kind=st.UNIT_HEAD, + permission="leave.request.approve", guards=guards, + note=f"Unit Head: {outcome}") + return request + + +@transaction.atomic +def establishment_route(actor, request: LeaveRequest, *, + note: str = "") -> LeaveRequest: + """EL-UC-004 / BR-EL-019: verify the record and forward it — no + approve/reject at this desk.""" + authz.require(actor, "leave.request.route") + authz.require_user(actor, request.establishment_user_id) + if request.status != st.AWAITING_ESTABLISHMENT: + raise ConflictError("This request is not awaiting establishment " + "routing.", code="not_awaiting_establishment") + request.establishment_at = timezone.now() + request.establishment_note = note + request.authority_user_id = hierarchy.require_authority( + request.category, _department(request)) + request.save(update_fields=["establishment_at", "establishment_note", + "authority_user_id", "updated_at"]) + workflow.move(request, st.AWAITING_FINAL, actor=actor, + actor_kind=st.ESTABLISHMENT, + permission="leave.request.route", guards=(), note=note) + return request + + +@validated +@transaction.atomic +def sanction(actor, request: LeaveRequest, *, outcome: str, note: str = "", + reference: str = "") -> LeaveRequest: + """EL-UC-005 / BR-EL-020: the final word, including the Director + sanctioning their own file (BR-EL-020).""" + authz.require(actor, "leave.request.sanction") + if outcome not in (APPROVE, REJECT): + raise BadRequestError("A sanction is an approve or a reject.", + code="invalid_outcome") + + if request.status == st.AWAITING_DIRECTOR: + authz.require_applicant(actor, request) + guards = ("applicant_is_director", f"outcome_{outcome}") + by = "self-sanction (Director)" + elif request.status == st.AWAITING_FINAL: + authz.require_user(actor, request.authority_user_id) + guards = (f"outcome_{outcome}",) + by = "the sanctioning authority" + else: + raise ConflictError( + "This request is not awaiting a final sanction.", + code="not_awaiting_sanction") + + _decision_fields(request, outcome=outcome, actor=actor, note=note, + reference=reference) + workflow.move(request, st.APPROVED if outcome == APPROVE + else st.REJECTED, + actor=actor, actor_kind=st.AUTHORITY, + permission="leave.request.sanction", guards=guards, + note=note) + if outcome == APPROVE: + _apply_effect(request, actor) + _notify_decision(request, actor, approved=outcome == APPROVE, by=by) + return request + + +@transaction.atomic +def review_resumption(actor, resumption: LeaveResumption, *, + note: str = "") -> LeaveResumption: + """EL-UC-011 / BR-EL-026: the balance follows the actual period — a + refund when they returned early, extra consumption when they did not.""" + authz.require(actor, "leave.request.sanction") + request = resumption.request + if resumption.status != LeaveResumption.PENDING: + raise ConflictError("This resumption has already been reviewed.", + code="already_reviewed") + reviewer = request.authority_user_id or request.unit_head_user_id + if reviewer is None: + raise ConflictError("No reviewer is assigned to this resumption.", + code="no_reviewer") + authz.require_user(actor, reviewer) + + approved, actual = resumption.approved_end_date, resumption.actual_date + if actual == approved: + resumption.status = LeaveResumption.NO_ADJUSTMENT + resumption.adjustment_days = 0 + elif actual < approved: + days = (approved - actual).days + resumption.status = LeaveResumption.EARLY + resumption.adjustment_days = -days + balances.refund(request.user_id, request.category, request.year, days, + request=request, + note=f"resumption #{resumption.id}: {days}d early", + actor_user_id=getattr(actor, "user_id", None)) + else: + days = (actual - approved).days + resumption.status = LeaveResumption.LATE + resumption.adjustment_days = days + # An approved leave kept on with permission consumes balance; it may + # legitimately run the balance into negatives rather than refuse the + # record of what was actually taken. + balances.post(request.user_id, request.category, request.year, + LeaveAccountTransaction.EXTENSION_DEDUCTION, days, + request=request, + note=f"resumption #{resumption.id}: {days}d late", + actor_user_id=getattr(actor, "user_id", None)) + + now = timezone.now() + resumption.reviewed_by_user_id = getattr(actor, "user_id", None) + resumption.reviewed_at = now + resumption.review_note = note + resumption.save(update_fields=["status", "adjustment_days", + "reviewed_by_user_id", "reviewed_at", + "review_note", "updated_at"]) + request.actual_end_date = actual + request.save(update_fields=["actual_end_date", "updated_at"]) + workflow.record_event( + request, LeaveRequestEvent.DECISION, actor=actor, + note=f"resumption {resumption.status}: approved end {approved}, " + f"actual {actual}, adjustment {resumption.adjustment_days}") + workflow.record_event( + request, LeaveRequestEvent.BALANCE_EFFECT, actor=actor, + note=f"{resumption.adjustment_days:+} {request.category.upper()} " + f"from resumption") + workflow.close_task(request) + notifications.notify( + request.user_id, "resumption_reviewed", + f"Your resumption was reviewed ({resumption.get_status_display()}); " + f"balance adjusted by {resumption.adjustment_days:+} day(s).", + request) + return resumption diff --git a/modules/leave/services/errors.py b/modules/leave/services/errors.py new file mode 100644 index 00000000..f6aebbdf --- /dev/null +++ b/modules/leave/services/errors.py @@ -0,0 +1,47 @@ +"""Translating domain exceptions into the shared error envelope. + +The domain layer is pure Python and must stay free of DRF (importlinter), +so it raises its own `ValidationError` / state-machine exceptions. This is +the seam: every public service call that can trip a rule is wrapped in +`validated`, and every path into the state machine goes through +`resolve_or_raise` — the same shape placement uses, for the same reason. +""" +from __future__ import annotations + +from functools import wraps + +from core.api.exceptions import BadRequestError, ConflictError, PermissionDeniedError +from modules.leave.domain import states as st +from modules.leave.domain.validation import ValidationError as RuleRefused + + +def validated(fn): + """Domain rule refusals become 400s with the rule's machine-readable + code, so the client can tell 'insufficient balance' from 'overlap'.""" + @wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except RuleRefused as exc: + raise BadRequestError(exc.message, code=exc.code) from exc + return wrapper + + +def resolve_or_raise(frm: str, to: str, actor_kind: str) -> st.Transition: + """`states.resolve` with its errors translated. Every path into the + state machine must use this, or an illegal move surfaces as a 500.""" + try: + return st.resolve(frm, to, actor_kind) + except st.InvalidTransition as exc: + raise ConflictError( + f"A leave request cannot move from '{exc.frm}' to '{exc.to}'.", + code="invalid_transition", + details=[{"from": exc.frm, "to": exc.to, + "allowed": st.allowed_targets(exc.frm, actor_kind)}], + ) from exc + except st.ActorNotAllowed as exc: + raise PermissionDeniedError( + "Your role cannot make that change.", code="actor_not_allowed", + details=[{"from": exc.frm, "to": exc.to, + "actor": exc.actor_kind}], + ) from exc diff --git a/modules/leave/services/hierarchy.py b/modules/leave/services/hierarchy.py new file mode 100644 index 00000000..5f48a7c1 --- /dev/null +++ b/modules/leave/services/hierarchy.py @@ -0,0 +1,143 @@ +"""The routing hierarchy: Unit Head, establishment, final authority. + +Resolved from this module's own configuration rows (BR-EL-016…020), never +from another module's tables: the directory publishes who people are, not +which desk reviews a leave file. +""" +from __future__ import annotations + +from core.api.exceptions import ConflictError, NotFoundError +from modules.directory import contracts as directory +from modules.leave.models import AuthorityAssignment, OrganizationalUnit +from modules.leave.services import authz + +#: Order in which the final desk is tried (BR-EL-018: configured hierarchy). +_FINAL_ORDER = (AuthorityAssignment.DEAN, AuthorityAssignment.REGISTRAR, + AuthorityAssignment.DIRECTOR) + + +def department_of(user_id: int) -> str: + users = directory.get_users([user_id]) + return users[user_id].department if user_id in users else "" + + +def unit_for_department(department: str) -> OrganizationalUnit | None: + if not department: + return None + return (OrganizationalUnit.objects + .filter(code__iexact=department).first() + or OrganizationalUnit.objects + .filter(name__iexact=department).first()) + + +def unit_head_user_id(department: str) -> int | None: + unit = unit_for_department(department) + return None if unit is None else unit.head_user_id + + +def is_director(user_id: int) -> bool: + return AuthorityAssignment.objects.filter( + role_code=AuthorityAssignment.DIRECTOR, user_id=user_id).exists() + + +def establishment_assignee(department: str) -> int | None: + """The establishment desk that verifies this file: the org's own desk + first, then the institute-wide one.""" + unit = unit_for_department(department) + rows = AuthorityAssignment.objects.filter( + role_code=AuthorityAssignment.ESTABLISHMENT) + if unit is not None: + hit = rows.filter(org_unit=unit).order_by("id").first() + if hit is not None: + return hit.user_id + hit = rows.filter(org_unit__isnull=True).order_by("id").first() + return hit.user_id if hit is not None else None + + +def final_authority_for(category: str, department: str) -> int | None: + """First desk in the configured hierarchy whose categories cover this + one; org-specific assignments win over institute-wide ones.""" + unit = unit_for_department(department) + for role in _FINAL_ORDER: + rows = AuthorityAssignment.objects.filter(role_code=role) + scoped = ([rows.filter(org_unit=unit), rows.filter(org_unit__isnull=True)] + if unit is not None + else [rows.filter(org_unit__isnull=True)]) + for queryset in scoped: + for row in queryset.order_by("id"): + cats = list(row.categories or []) + if not cats or category in cats: + return row.user_id + return None + + +def require_unit_head(department: str) -> int: + """The applicant's file cannot enter review without a head to review it + (CFR-EL-003.2). Configuration, not a bug: fix it in Administration.""" + head = unit_head_user_id(department) + if head is None: + raise ConflictError( + "No Unit Head is configured for your organisational unit; ask an " + "administrator to set one before applying.", + code="no_unit_head") + return head + + +def require_authority(category: str, department: str) -> int: + authority = final_authority_for(category, department) + if authority is None: + raise ConflictError( + f"No sanctioning authority is configured for " + f"{category.upper()} leave; ask an administrator to assign one.", + code="no_authority") + return authority + + +# -- configuration (admin) ------------------------------------------------- +def configure_unit(actor, data: dict) -> OrganizationalUnit: + authz.require(actor, "leave.type.manage") + pk = data.get("id") + unit = (OrganizationalUnit.objects.filter(pk=pk).first() if pk + else OrganizationalUnit.objects.filter( + code=str(data["code"])).first()) + if unit is None: + unit = OrganizationalUnit(code=str(data["code"])) + unit.name = data.get("name", unit.name or unit.code) + unit.kind = data.get("kind", unit.kind) + if "head_user_id" in data: + unit.head_user_id = data["head_user_id"] + unit.save() + return unit + + +def configure_authority(actor, data: dict) -> AuthorityAssignment: + authz.require(actor, "leave.type.manage") + role = str(data["role_code"]) + if role not in {r for r, _ in AuthorityAssignment.ROLE}: + raise NotFoundError("Unknown authority role.", code="unknown_role") + unit = None + if data.get("org_unit_id"): + unit = OrganizationalUnit.objects.filter( + pk=data["org_unit_id"]).first() + if unit is None: + raise NotFoundError("Organisational unit not found.", + code="not_found") + pk = data.get("id") + row = AuthorityAssignment.objects.filter(pk=pk).first() if pk else None + if row is None: + row = AuthorityAssignment.objects.filter( + role_code=role, org_unit=unit).first() + if row is None: + row = AuthorityAssignment(role_code=role, org_unit=unit) + row.user_id = int(data["user_id"]) + row.categories = list(data.get("categories") or []) + row.save() + return row + + +def remove_authority(actor, pk: int) -> None: + authz.require(actor, "leave.type.manage") + deleted, _ = AuthorityAssignment.objects.filter(pk=pk).delete() + if not deleted: + raise NotFoundError("Authority assignment not found.", + code="not_found") diff --git a/modules/leave/services/notifications.py b/modules/leave/services/notifications.py new file mode 100644 index 00000000..9e6c26eb --- /dev/null +++ b/modules/leave/services/notifications.py @@ -0,0 +1,30 @@ +"""In-app notifications the ELM writes for the desks it hands work to +(BR-EL-035). Email/outbox delivery is the platform's concern, not a rule.""" +from __future__ import annotations + +from django.utils import timezone + +from modules.leave.models import LeaveNotification +from modules.leave.services import authz + + +def notify(user_id: int | None, kind: str, message: str, + request=None) -> LeaveNotification | None: + if user_id is None: + return None + return LeaveNotification.objects.create( + user_id=user_id, kind=kind, message=message, request=request) + + +def unread_for(user_id: int) -> list[LeaveNotification]: + return list(LeaveNotification.objects + .filter(user_id=user_id, read_at__isnull=True)[:200]) + + +def mark_read(actor, ids: list[int] | None = None) -> int: + authz.require(actor, "leave.request.view_self") + rows = LeaveNotification.objects.filter( + user_id=getattr(actor, "user_id", None), read_at__isnull=True) + if ids: + rows = rows.filter(pk__in=ids) + return rows.update(read_at=timezone.now()) diff --git a/modules/leave/services/policy.py b/modules/leave/services/policy.py new file mode 100644 index 00000000..b00e445d --- /dev/null +++ b/modules/leave/services/policy.py @@ -0,0 +1,84 @@ +"""Policy reads and configuration (BR-EL-001…014, BR-EL-030). + +The policy row is a singleton; entitlements, the leave-year boundary and the +substitute gate all read from here so a rule never hard-codes a number. +""" +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from django.utils import timezone + +from core.api.exceptions import BadRequestError +from modules.leave.domain.categories import CL, COL, EL, RH, SCL, VL +from modules.leave.models import LeavePolicy +from modules.leave.services import authz + +#: The only fields an admin may PUT; anything else is a 400, not silence. +EDITABLE = ( + "cl_annual", "rh_annual", "scl_annual", "el_annual_nonfaculty", + "el_annual_faculty", "col_annual", "vl_annual_faculty", + "vl_to_el_numerator", "vl_to_el_denominator", + "el_carry_forward", "col_carry_forward", + "substitute_required_categories", + "scl_evidence_required", "col_evidence_required", + "year_end_month", "year_end_day", +) + + +def current_policy() -> LeavePolicy: + policy, _ = LeavePolicy.objects.get_or_create(id=1) + return policy + + +def update_policy(actor, data: dict) -> LeavePolicy: + authz.require(actor, "leave.type.manage") + unknown = sorted(set(data) - set(EDITABLE)) + if unknown: + raise BadRequestError(f"Unknown policy field {unknown[0]!r}.", + code="unknown_field") + if int(data.get("vl_to_el_denominator", + current_policy().vl_to_el_denominator)) <= 0: + raise BadRequestError("The VL→EL denominator cannot be zero.", + code="invalid_policy") + policy = current_policy() + for field, value in data.items(): + setattr(policy, field, value) + policy.save() + return policy + + +def is_faculty(kind: str) -> bool: + return kind == "faculty" + + +def entitlement(policy: LeavePolicy, category: str, faculty: bool) -> Decimal: + """Annual entitlement for one category (PS §2–4). VL for faculty is + converted into EL at year end rather than granted as EL (BR-EL-005).""" + table = { + CL: policy.cl_annual, + RH: policy.rh_annual, + SCL: policy.scl_annual, + EL: policy.el_annual_faculty if faculty else policy.el_annual_nonfaculty, + COL: policy.col_annual, + VL: policy.vl_annual_faculty if faculty else Decimal(0), + } + return table.get(category, Decimal(0)) + + +def leave_year_of(policy: LeavePolicy, day: date) -> int: + """Which leave year `day` belongs to. A year ends on year_end_month/ + year_end_day, so leave on the boundary still belongs to the closing year.""" + boundary = (policy.year_end_month, policy.year_end_day) + return day.year + 1 if (day.month, day.day) > boundary else day.year + + +def current_leave_year(policy: LeavePolicy | None = None) -> int: + policy = policy or current_policy() + return leave_year_of(policy, timezone.localdate()) + + +def substitute_required(policy: LeavePolicy, category: str) -> bool: + """BR-EL-014: responsibility coverage for the higher categories.""" + return category in set(policy.substitute_required_categories or []) diff --git a/modules/leave/services/records.py b/modules/leave/services/records.py new file mode 100644 index 00000000..5ada3001 --- /dev/null +++ b/modules/leave/services/records.py @@ -0,0 +1,28 @@ +"""Record maintenance — the non-sanctioning write paths (BR-EL-029, 037). + +Balance adjustments and annotations are deliberately separate from the +workflow: they are auditable ledger/event entries, never approvals. +""" +from __future__ import annotations + +from django.db import transaction + +from modules.leave.models import LeaveRequest, LeaveRequestEvent +from modules.leave.services import authz, balances, workflow + + +def adjust_balance(actor, *, user_id: int, category: str, year: int, + days, note: str = ""): + """BR-EL-037: maintenance only, stamped with its own ledger kind.""" + return balances.adjust(actor, user_id=user_id, category=category, + year=year, days=days, note=note) + + +@transaction.atomic +def annotate(actor, request: LeaveRequest, note: str) -> LeaveRequest: + authz.require(actor, "leave.record.manage") + request.admin_note = note + request.save(update_fields=["admin_note", "updated_at"]) + workflow.record_event(request, LeaveRequestEvent.NOTE, actor=actor, + note=note) + return request diff --git a/modules/leave/services/sla.py b/modules/leave/services/sla.py new file mode 100644 index 00000000..317210ae --- /dev/null +++ b/modules/leave/services/sla.py @@ -0,0 +1,82 @@ +"""SLA reminders and escalations (BR-EL-032…034, EL-UC-021). + +Swept on a timer by the module's beat schedule. Only files with an open task +qualify — terminal ones have their task closed, which is what keeps the SLA +per-step rather than forever-since-submission. +""" +from __future__ import annotations + +from django.db import transaction +from django.utils import timezone + +from modules.leave.models import LeaveRequest, LeaveRequestEvent, SlaThreshold +from modules.leave.services import notifications, workflow + + +def sweep(now=None) -> dict: + now = now or timezone.now() + reminded = escalated = 0 + + with transaction.atomic(): + rows = list(LeaveRequest.objects + .select_for_update() + .exclude(task_class="") + .exclude(task_assigned_at=None)) + for request in rows: + threshold = (SlaThreshold.objects + .filter(task_class=request.task_class).first()) + if threshold is None: + continue + holder = request.task_user_id + reminder_target = holder or threshold.escalation_user_id + escalation_target = threshold.escalation_user_id or holder + if reminder_target is None and escalation_target is None: + continue # configured neither way: nothing to remind about + + age_hours = ((now - request.task_assigned_at).total_seconds() + / 3600) + saved: list[str] = [] + + if (threshold.reminder_hours and request.reminder_sent_at is None + and age_hours >= threshold.reminder_hours): + notifications.notify( + reminder_target, "sla_reminder", + f"Reminder: {request.category.upper()} leave " + f"({request.start_date.isoformat()}…) assigned to you has " + f"been waiting {int(age_hours)} hours.", request) + workflow.record_event( + request, LeaveRequestEvent.SLA_REMINDER, + note=f"after {threshold.reminder_hours}h → " + f"{reminder_target}") + request.reminder_sent_at = now + saved.append("reminder_sent_at") + reminded += 1 + + if (threshold.escalation_hours + and request.escalated_at is None + and age_hours >= threshold.escalation_hours): + notifications.notify( + escalation_target, "sla_escalation", + f"Escalated: {request.category.upper()} leave " + f"({request.start_date.isoformat()}…) has been waiting " + f"{int(age_hours)} hours.", request) + if threshold.escalation_user_id is not None: + # BR-EL-033: escalation can change routing, not merely + # notify — the new holder's clock starts now. + request.task_user_id = threshold.escalation_user_id + request.task_assigned_at = now + request.reminder_sent_at = None + saved += ["task_user_id", "task_assigned_at", + "reminder_sent_at"] + request.escalated_at = now + saved.append("escalated_at") + workflow.record_event( + request, LeaveRequestEvent.SLA_ESCALATION, + note=f"after {threshold.escalation_hours}h → " + f"{escalation_target}") + escalated += 1 + + if saved: + request.save(update_fields=[*saved, "updated_at"]) + + return {"reminded": reminded, "escalated": escalated} diff --git a/modules/leave/services/workflow.py b/modules/leave/services/workflow.py new file mode 100644 index 00000000..b53bac3e --- /dev/null +++ b/modules/leave/services/workflow.py @@ -0,0 +1,112 @@ +"""Shared workflow machinery: one place that moves a request, records the +event, and points the open task at whoever holds the next step (BR-EL-035). + +Every status change in the module goes through `move`, so the audit trail +and the task/SLA state can never drift from the status itself. +""" +from __future__ import annotations + +from django.utils import timezone + +from modules.leave.domain import states as st +from modules.leave.models import LeaveRequest, LeaveRequestEvent, SlaThreshold +from modules.leave.services import authz, notifications +from modules.leave.services.errors import resolve_or_raise + +#: status -> (SLA task class, the field holding whoever owns that step). +#: An applicant's own action (APPLICANT_ACTION) is deliberately absent: +#: there is no desk to remind, the notification already points at them. +TASK_FOR_STATUS: dict[str, tuple[str, str]] = { + st.AWAITING_SUBSTITUTE: (SlaThreshold.SUBSTITUTE_RESPONSE, + "substitute_user_id"), + st.AWAITING_UH_REVIEW: (SlaThreshold.UNIT_HEAD_REVIEW, + "unit_head_user_id"), + st.AWAITING_ESTABLISHMENT: (SlaThreshold.ESTABLISHMENT_ROUTING, + "establishment_user_id"), + st.AWAITING_FINAL: (SlaThreshold.FINAL_SANCTION, "authority_user_id"), + st.AWAITING_DIRECTOR: (SlaThreshold.DIRECTOR_SANCTION, "user_id"), +} + + +def record_event(request: LeaveRequest, kind: str, *, frm: str = "", + to: str = "", actor=None, note: str = "") -> None: + LeaveRequestEvent.objects.create( + request=request, from_status=frm, to_status=to, kind=kind, + actor_user_id=getattr(actor, "user_id", None), + actor_label=str(getattr(actor, "display_name", "") + or getattr(actor, "username", "") or ""), + note=note) + + +def close_task(request: LeaveRequest) -> None: + """Nothing to remind about once a file has no open step.""" + request.task_class = "" + request.task_user_id = None + request.task_assigned_at = None + request.reminder_sent_at = None + request.escalated_at = None + request.save(update_fields=["task_class", "task_user_id", + "task_assigned_at", "reminder_sent_at", + "escalated_at", "updated_at"]) + + +def assign_task(request: LeaveRequest, *, actor=None, note: str = "") -> int | None: + """(Re)point the open task at whoever holds this file's next step. + + Called after every status change; the old holder's reminder clock resets + with the new assignment, which is what makes the SLA per-step rather than + per-request. + """ + spec = TASK_FOR_STATUS.get(request.status) + if spec is None: + close_task(request) + return None + task_class, field = spec + holder = getattr(request, field, None) + request.task_class = task_class + request.task_user_id = holder + request.task_assigned_at = timezone.now() + request.reminder_sent_at = None + request.escalated_at = None + request.save(update_fields=["task_class", "task_user_id", + "task_assigned_at", "reminder_sent_at", + "escalated_at", "updated_at"]) + if holder is not None: + notifications.notify( + holder, "assigned", + f"A leave request ({request.category.upper()} " + f"{request.start_date.isoformat()}…{request.end_date.isoformat()}) " + f"needs your action: {task_class.replace('_', ' ')}.", + request) + record_event(request, LeaveRequestEvent.ASSIGNMENT, actor=actor, + note=note or f"task → {task_class}") + return holder + + +def move(request: LeaveRequest, to_status: str, *, actor, actor_kind: str, + permission: str, guards: tuple[str, ...] = (), + note: str = "") -> LeaveRequest: + """The one legal way to change a request's status. + + `guards` are the conditions the caller has already evaluated; the state + machine re-checks that the edge exists *with those guards*, so a service + cannot, for example, approve a Unit-Head-final file through the + establishment edge by mislabelling its outcome. + """ + transition = resolve_or_raise(request.status, to_status, actor_kind) + missing = set(transition.guards) - set(guards) + if missing: + from core.api.exceptions import ConflictError + raise ConflictError( + f"This request cannot move to '{to_status}' yet.", + code="guard_not_satisfied", + details=[{"from": request.status, "to": to_status, + "unsatisfied": sorted(missing)}]) + authz.require(actor, permission) + frm = request.status + request.status = to_status + request.save(update_fields=["status", "updated_at"]) + record_event(request, LeaveRequestEvent.STATUS_CHANGE, frm=frm, + to=to_status, actor=actor, note=note) + assign_task(request, actor=actor) + return request diff --git a/modules/leave/services/yearend.py b/modules/leave/services/yearend.py new file mode 100644 index 00000000..4830db11 --- /dev/null +++ b/modules/leave/services/yearend.py @@ -0,0 +1,68 @@ +"""The leave-year boundary sweep (PS §3, BR-EL-002…009). + +Lapse what does not survive (CL/RH/SCL), convert faculty VL into EL at the +policy ratio, carry EL and COL forward — once per year, proven by a +`LeaveYearRun` row so a second sweep is a no-op rather than a double debit. +""" +from __future__ import annotations + +from django.db import IntegrityError, transaction + +from core.api.exceptions import ConflictError +from modules.directory import contracts as directory +from modules.leave.domain import year_end as plan_domain +from modules.leave.domain.categories import COL, EL +from modules.leave.models import LeaveBalance, LeaveYearRun +from modules.leave.services import balances as balances_service +from modules.leave.services.policy import current_policy + + +def run_year_end(year: int) -> dict: + policy = current_policy() + with transaction.atomic(): + existing = (LeaveYearRun.objects.select_for_update() + .filter(year=year).first()) + if existing is not None: + return {"already_run": True, **(existing.report or {})} + + # Everyone gets their expiring year materialised first, or a faculty + # member who never touched VL would silently lose the conversion. + is_faculty: dict[int, bool] = {} + for person in directory.get_employees(): + is_faculty[person.user_id] = person.kind == "faculty" + balances_service.ensure_year(person.user_id, year, + kind=person.kind, credit=False) + + snapshot = [ + (user_id, category, carried + credited + adjusted - used) + for user_id, category, carried, credited, adjusted, used + in LeaveBalance.objects.filter(year=year).values_list( + "user_id", "category", "carried_forward", "credited", + "adjusted", "used") + ] + plan = plan_domain.plan_year_end( + year=year, balances=snapshot, is_faculty=is_faculty, + vl_to_el_numerator=policy.vl_to_el_numerator, + vl_to_el_denominator=policy.vl_to_el_denominator, + carry_forward_enabled={EL: policy.el_carry_forward, + COL: policy.col_carry_forward}, + ) + + posted = 0 + for entry in plan.entries: + balances_service.post(entry.user_id, entry.category, entry.year, + entry.kind, entry.days, + note=f"year-end {year}", credit=False) + posted += 1 + + report = {"entries": posted, "balances": len(snapshot), + "exceptions": list(plan.exceptions)} + try: + LeaveYearRun.objects.create(year=year, report=report) + except IntegrityError as exc: + # A concurrent sweep won the marker; this one's effects roll back + # with the transaction, leaving exactly one processed boundary. + raise ConflictError( + f"Leave year {year} has already been processed.", + code="already_run") from exc + return {"already_run": False, **report} diff --git a/modules/leave/tasks.py b/modules/leave/tasks.py new file mode 100644 index 00000000..043fea1e --- /dev/null +++ b/modules/leave/tasks.py @@ -0,0 +1,32 @@ +"""Celery tasks. Thin: they exist so the beat schedule has something to +call, and every rule lives in the services they delegate to.""" +from __future__ import annotations + +import logging + +from celery import shared_task + +log = logging.getLogger("fusion.leave") + + +@shared_task(name="leave.sweep_sla") +def sweep_sla() -> dict: + from modules.leave.services import sla + result = sla.sweep() + if result["reminded"] or result["escalated"]: + log.info("leave SLA sweep: %s", result) + return result + + +@shared_task(name="leave.run_year_end") +def run_year_end() -> dict: + """Processes the leave year that just closed; a no-op once marked, so a + daily timer is safe even though the boundary bites once a year.""" + from modules.leave.services import policy as policy_service + from modules.leave.services import yearend + + closing = policy_service.current_leave_year() - 1 + result = yearend.run_year_end(closing) + if not result.get("already_run"): + log.info("leave year-end %s: %s", closing, result) + return result diff --git a/modules/leave/tests/__init__.py b/modules/leave/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/leave/tests/conftest.py b/modules/leave/tests/conftest.py new file mode 100644 index 00000000..179a08ac --- /dev/null +++ b/modules/leave/tests/conftest.py @@ -0,0 +1,119 @@ +"""Shared ELM fixtures: the desks of the routing hierarchy, a published +calendar, and a client factory that logs in as any of them.""" +from __future__ import annotations + +from datetime import date + +import pytest +from django.utils import timezone +from rest_framework.test import APIClient + +from conftest import make_session +from modules.directory.models import UserRef +from modules.leave.models import ( + AuthorityAssignment, + Holiday, + HolidayCalendar, + LeavePolicy, + OrganizationalUnit, + SlaThreshold, +) + +APPLICANT = 9100 +UNIT_HEAD = 9001 +ESTABLISHMENT = 9002 +DEAN = 9003 +REGISTRAR = 9004 +DIRECTOR = 9005 +SUBSTITUTE = 9101 +OTHER = 9199 +UNPLACED = 9102 # staff whose department maps to no org unit + +SELF = ("leave.request.view_self", "leave.request.create", + "leave.request.delete", "leave.substitute.respond") +UH = (*SELF, "leave.request.view", "leave.request.approve") +EST = (*SELF, "leave.request.view", "leave.request.route") +AUTH = (*SELF, "leave.request.view", "leave.request.sanction") +ADMIN = ("leave.request.view", "leave.balance.view", "leave.balance.update", + "leave.type.manage", "leave.calendar.manage", + "leave.record.manage", "leave.sla.manage") + + +def _user(uid: int, name: str, kind: str, department: str = "") -> None: + UserRef.objects.update_or_create( + user_id=uid, + defaults={"username": f"u{uid}", "display_name": name, "kind": kind, + "department": department}) + + +@pytest.fixture +def desks(): + """The eight people a routing hierarchy needs, plus config.""" + _user(APPLICANT, "Asha Verma", "staff", "CSE") + _user(UNIT_HEAD, "Head of CSE", "faculty", "CSE") + _user(ESTABLISHMENT, "Establishment Clerk", "staff", "") + _user(DEAN, "Dean Academic", "faculty", "") + _user(REGISTRAR, "Registrar Office", "staff", "") + _user(DIRECTOR, "The Director", "faculty", "") + _user(SUBSTITUTE, "Suresh Patil", "staff", "CSE") + _user(OTHER, "Other Person", "staff", "ECE") + _user(UNPLACED, "No Unit Here", "staff", "NOWHERE") + + LeavePolicy.objects.get_or_create(id=1) + cse = OrganizationalUnit.objects.create( + code="CSE", name="Computer Science & Engineering", + head_user_id=UNIT_HEAD) + OrganizationalUnit.objects.create(code="ECE", name="ECE", + head_user_id=OTHER) + for role, uid in [("establishment", ESTABLISHMENT), ("dean", DEAN), + ("registrar", REGISTRAR), ("director", DIRECTOR)]: + AuthorityAssignment.objects.create(role_code=role, user_id=uid) + + calendar = HolidayCalendar.objects.create( + title="2026", academic_year="2026-27", + status=HolidayCalendar.PUBLISHED, published_at=timezone.now(), + published_by_user_id=REGISTRAR) + Holiday.objects.create(calendar=calendar, date=date(2026, 11, 8), + name="Diwali (Sunday)", kind="restricted") + Holiday.objects.create(calendar=calendar, date=date(2026, 10, 21), + name="Mid Autumn", kind="restricted") + Holiday.objects.create(calendar=calendar, date=date(2026, 8, 15), + name="Independence Day", kind="closed") + + for task_class in ("substitute_response", "unit_head_review", + "establishment_routing", "final_sanction", + "director_sanction", "resumption_review"): + SlaThreshold.objects.get_or_create( + task_class=task_class, + defaults={"reminder_hours": 24, "escalation_hours": 48, + "escalation_user_id": DEAN}) + return {"calendar": calendar, "cse": cse} + + +@pytest.fixture +def client(stub_iam): + """client(user_id, permissions=...) → an authenticated APIClient. + + Each caller gets its own token mapping to its own session, so several + desks can be logged in at once — a single shared session would make + every earlier client silently act as whoever logged in last.""" + fake = stub_iam() + tokens: dict = {} + + def _resolve(*, auth_token=None, cookies=None): + return tokens.get(auth_token) + + fake.resolve_session = _resolve + + def _make(user_id: int, kind: str = "staff", permissions=(), + modules=("leave",)): + token = f"tok-{user_id}-{len(tokens)}" + tokens[token] = make_session( + user_id=user_id, kind=kind, + permissions=tuple(permissions), + modules=tuple(modules), + roles=(kind,), active_role=kind) + c = APIClient() + c.credentials(HTTP_AUTHORIZATION=f"Token {token}") + return c + return _make diff --git a/modules/leave/tests/test_api.py b/modules/leave/tests/test_api.py new file mode 100644 index 00000000..a3a07d1c --- /dev/null +++ b/modules/leave/tests/test_api.py @@ -0,0 +1,469 @@ +"""The workflow, driven through the HTTP layer end to end (EL-UC-001…011). + +Scoping is asserted by 404, permission by 403/400, rules by the status and +balance the API actually reports — the same seams production relies on. +""" +from __future__ import annotations + +from datetime import date, timedelta +from decimal import Decimal + +import pytest +from django.utils import timezone + +from modules.leave.models import ( + LeaveBalance, + LeaveNotification, + LeaveRequest, +) +from modules.leave.tests.conftest import ( + ADMIN, + APPLICANT, + AUTH, + DEAN, + DIRECTOR, + ESTABLISHMENT, + EST, + OTHER, + SELF, + SUBSTITUTE, + UH, + UNIT_HEAD, + UNPLACED, +) + +pytestmark = pytest.mark.django_db + + +def _submit(c, **overrides): + payload = {"category": "cl", "start_date": "2026-10-20", + "end_date": "2026-10-22", "purpose": "home work"} + payload.update(overrides) + return c.post("/api/v1/leave/requests", payload, format="json") + + +# -- EL-UC-001: apply -------------------------------------------------------- +def test_cl_application_reaches_the_unit_head_desk(stub_iam, desks, client): + c = client(APPLICANT, permissions=SELF) + body = _submit(c).json() + assert body["status"] == "awaiting_unit_head_review" + assert body["chargeable_days"] == "3.00" # Tue–Thu, all working days + assert "withdraw" in body["allowed_actions"] + assert body["unit_head_user_id"] is not None + + +def test_apply_without_create_permission_is_403(stub_iam, desks, client): + c = client(APPLICANT, permissions=("leave.request.view_self",)) + assert _submit(c).status_code == 403 + + +def test_insufficient_balance_is_a_400_with_a_code(stub_iam, desks, client): + c = client(APPLICANT, permissions=SELF) + r = _submit(c, start_date="2026-10-01", end_date="2026-10-30") + assert r.status_code == 400 + assert r.json()["error"]["code"] == "insufficient_balance" + + +def test_half_day_cl_is_half_a_day(stub_iam, desks, client): + c = client(APPLICANT, permissions=SELF) + body = _submit(c, start_date="2026-10-20", end_date="2026-10-20", + half_day="first").json() + assert body["chargeable_days"] == "0.50" + + +def test_rh_needs_a_published_restricted_day(stub_iam, desks, client): + c = client(APPLICANT, permissions=SELF) + bad = _submit(c, category="rh", start_date="2026-10-20", + end_date="2026-10-20") + assert bad.status_code == 400 + assert bad.json()["error"]["code"] == "rh_not_applicable" + good = _submit(c, category="rh", start_date="2026-10-21", + end_date="2026-10-21") + assert good.status_code == 201 + assert good.json()["chargeable_days"] == "1.00" + + +def test_vl_is_faculty_only(stub_iam, desks, client): + c = client(APPLICANT, permissions=SELF) # staff + r = _submit(c, category="vl", start_date="2026-10-20", + end_date="2026-10-20", substitute_user_id=SUBSTITUTE) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "category_not_applicable" + + +def test_no_unit_head_configured_is_a_409(stub_iam, desks, client): + c = client(UNPLACED, permissions=SELF) + r = _submit(c) + assert r.status_code == 409 + assert r.json()["error"]["code"] == "no_unit_head" + + +# -- EL-UC-003: Unit Head ---------------------------------------------------- +def test_unit_head_approves_cl_and_the_balance_follows(stub_iam, desks, client): + pk = _submit(client(APPLICANT, permissions=SELF)).json()["id"] + uh = client(UNIT_HEAD, kind="faculty", permissions=UH) + body = uh.post(f"/api/v1/leave/requests/{pk}/review", + {"outcome": "approve", "note": "ok"}, format="json").json() + assert body["status"] == "approved" + assert body["decision"] == "approve" + row = LeaveBalance.objects.get(user_id=APPLICANT, category="cl", year=2026) + assert row.used == Decimal("3.00") + assert row.available == 5.00 # 8 credited − 3 used + + +def test_only_the_assigned_unit_head_may_review(stub_iam, desks, client): + pk = _submit(client(APPLICANT, permissions=SELF)).json()["id"] + other_head = client(OTHER, permissions=UH) + r = other_head.post(f"/api/v1/leave/requests/{pk}/review", + {"outcome": "approve"}, format="json") + assert r.status_code == 403 + + +def test_not_recommended_is_not_final_for_higher_categories( + stub_iam, desks, client): + """BR-EL-017: the Unit Head's 'no' forwards, it does not kill the file.""" + c = client(APPLICANT, permissions=SELF) + pk = _submit(c, category="el", start_date="2026-10-26", + end_date="2026-10-28", + substitute_user_id=SUBSTITUTE).json()["id"] + sub = client(SUBSTITUTE, permissions=SELF) + sub.post(f"/api/v1/leave/requests/{pk}/substitute/respond", + {"response": "accept"}, format="json") + uh = client(UNIT_HEAD, kind="faculty", permissions=UH) + body = uh.post(f"/api/v1/leave/requests/{pk}/review", + {"outcome": "not_recommended"}, + format="json").json() + assert body["status"] == "awaiting_establishment_routing" + assert body["unit_head_outcome"] == "not_recommended" + + +# -- EL-UC-002/019: substitute consent -------------------------------------- +def test_el_needs_a_substitute_and_the_gate_blocks(stub_iam, desks, client): + c = client(APPLICANT, permissions=SELF) + r = _submit(c, category="el", start_date="2026-10-26", + end_date="2026-10-28") + assert r.status_code == 400 + assert r.json()["error"]["code"] == "substitute_required" + + body = _submit(c, category="el", start_date="2026-10-26", + end_date="2026-10-28", + substitute_user_id=SUBSTITUTE).json() + assert body["status"] == "awaiting_substitute_response" + + # The gate holds: a review attempt is refused while consent is pending. + uh = client(UNIT_HEAD, kind="faculty", permissions=UH) + r = uh.post(f"/api/v1/leave/requests/{body['id']}/review", + {"outcome": "approve"}, format="json") + assert r.status_code == 409 + + +def test_decline_parks_the_file_and_applicant_replaces( + stub_iam, desks, client): + c = client(APPLICANT, permissions=SELF) + pk = _submit(c, category="el", start_date="2026-10-26", + end_date="2026-10-28", + substitute_user_id=SUBSTITUTE).json()["id"] + sub = client(SUBSTITUTE, permissions=SELF) + body = sub.post(f"/api/v1/leave/requests/{pk}/substitute/respond", + {"response": "decline"}, format="json").json() + assert body["status"] == "applicant_action_required" + assert body["substitute_status"] == "declined" + assert "nominate_substitute" in body["allowed_actions"] + + # EL-UC-019: the applicant nominates someone else, back to the gate. + body = c.post(f"/api/v1/leave/requests/{pk}/substitute", + {"substitute_user_id": OTHER}, format="json").json() + assert body["status"] == "awaiting_substitute_response" + assert body["substitute_user_id"] == OTHER + + +def test_substitute_cannot_respond_for_someone_elses_file( + stub_iam, desks, client): + pk = _submit(client(APPLICANT, permissions=SELF), + category="el", start_date="2026-10-26", end_date="2026-10-28", + substitute_user_id=SUBSTITUTE).json()["id"] + intruder = client(OTHER, permissions=SELF) + r = intruder.post(f"/api/v1/leave/requests/{pk}/substitute/respond", + {"response": "accept"}, format="json") + assert r.status_code == 403 + + +# -- EL-UC-004/005: establishment and final sanction ------------------------ +def test_higher_category_walks_the_whole_hierarchy(stub_iam, desks, client): + c = client(APPLICANT, permissions=SELF) + pk = _submit(c, category="el", start_date="2026-10-26", + end_date="2026-10-28", + substitute_user_id=SUBSTITUTE).json()["id"] + client(SUBSTITUTE, permissions=SELF).post( + f"/api/v1/leave/requests/{pk}/substitute/respond", + {"response": "accept"}, format="json") + uh = client(UNIT_HEAD, kind="faculty", permissions=UH) + assert uh.post(f"/api/v1/leave/requests/{pk}/review", + {"outcome": "recommended"}, format="json").json()[ + "status"] == "awaiting_establishment_routing" + + est = client(ESTABLISHMENT, permissions=EST) + body = est.post(f"/api/v1/leave/requests/{pk}/route", + {"note": "record verified"}, format="json").json() + assert body["status"] == "awaiting_final_sanction_decision" + assert body["authority_user_id"] == DEAN + + dean = client(DEAN, kind="faculty", permissions=AUTH) + body = dean.post(f"/api/v1/leave/requests/{pk}/sanction", + {"outcome": "approve", "reference": "EST/2026/9"}, + format="json").json() + assert body["status"] == "approved" + row = LeaveBalance.objects.get(user_id=APPLICANT, category="el", year=2026) + assert row.used == Decimal("3.00") + + +def test_wrong_authority_cannot_sanction(stub_iam, desks, client): + pk = _submit(client(APPLICANT, permissions=SELF), + category="el", start_date="2026-10-26", end_date="2026-10-28", + substitute_user_id=SUBSTITUTE).json()["id"] + client(SUBSTITUTE, permissions=SELF).post( + f"/api/v1/leave/requests/{pk}/substitute/respond", + {"response": "accept"}, format="json") + client(UNIT_HEAD, kind="faculty", permissions=UH).post( + f"/api/v1/leave/requests/{pk}/review", {"outcome": "recommended"}, + format="json") + client(ESTABLISHMENT, permissions=EST).post( + f"/api/v1/leave/requests/{pk}/route", {}, format="json") + # The Director holds `sanction` but is not this file's authority. + r = client(DIRECTOR, kind="faculty", permissions=AUTH).post( + f"/api/v1/leave/requests/{pk}/sanction", {"outcome": "approve"}, + format="json") + assert r.status_code == 403 + + +# -- BR-EL-020: Director self-sanction -------------------------------------- +def test_director_sanctions_their_own_file(stub_iam, desks, client): + body = _submit(client(DIRECTOR, kind="faculty", permissions=SELF), + start_date="2026-10-26", end_date="2026-10-27").json() + assert body["status"] == "awaiting_director_self_sanction" + assert body["is_director_self_sanction"] is True + + body = client(DIRECTOR, kind="faculty", permissions=AUTH).post( + f"/api/v1/leave/requests/{body['id']}/sanction", + {"outcome": "approve", "note": "self"}, format="json").json() + assert body["status"] == "approved" + + +# -- BR-EL self-approval guard: a Unit Head's own file ------------------------ +def test_unit_heads_own_file_skips_their_desk(stub_iam, desks, client): + body = _submit(client(UNIT_HEAD, kind="faculty", permissions=SELF), + start_date="2026-10-26", end_date="2026-10-27").json() + assert body["status"] == "awaiting_establishment_routing" + assert body["establishment_user_id"] == ESTABLISHMENT + + +# -- BR-EL-022: withdrawal --------------------------------------------------- +def test_withdraw_is_immediate_and_scoped(stub_iam, desks, client): + pk = _submit(client(APPLICANT, permissions=SELF)).json()["id"] + body = client(APPLICANT, permissions=SELF).post( + f"/api/v1/leave/requests/{pk}/withdraw", format="json").json() + assert body["status"] == "withdrawn" + # A withdrawn file consumes nothing. + assert not LeaveBalance.objects.filter(user_id=APPLICANT, + used__gt=0).exists() + # Someone uninvolved cannot even see it existed. + r = client(OTHER, permissions=SELF).get( + f"/api/v1/leave/requests/{pk}") + assert r.status_code == 404 + + +def test_cannot_withdraw_an_approved_leave(stub_iam, desks, client): + pk = _submit(client(APPLICANT, permissions=SELF)).json()["id"] + client(UNIT_HEAD, kind="faculty", permissions=UH).post( + f"/api/v1/leave/requests/{pk}/review", {"outcome": "approve"}, + format="json") + r = client(APPLICANT, permissions=SELF).post( + f"/api/v1/leave/requests/{pk}/withdraw", format="json") + assert r.status_code == 409 + + +# -- EL-UC-006/008/011: cancellation, extension, resumption ------------------ +def _approve_cl(client) -> int: + pk = _submit(client(APPLICANT, permissions=SELF)).json()["id"] + client(UNIT_HEAD, kind="faculty", permissions=UH).post( + f"/api/v1/leave/requests/{pk}/review", {"outcome": "approve"}, + format="json") + return pk + + +def test_cancellation_restores_the_balance(stub_iam, desks, client): + base_pk = _approve_cl(client) + derived = client(APPLICANT, permissions=SELF).post( + f"/api/v1/leave/requests/{base_pk}/cancellation", + {"reason": "plans changed"}, format="json").json() + assert derived["kind"] == "cancellation" + assert derived["status"] == "awaiting_unit_head_review" + + client(UNIT_HEAD, kind="faculty", permissions=UH).post( + f"/api/v1/leave/requests/{derived['id']}/review", + {"outcome": "approve"}, format="json") + base = LeaveRequest.objects.get(pk=base_pk) + assert base.status == "cancelled" + row = LeaveBalance.objects.get(user_id=APPLICANT, category="cl", year=2026) + assert row.used == Decimal("0.00") + + +def test_extension_charges_only_the_increment(stub_iam, desks, client): + c = client(APPLICANT, permissions=SELF) + pk = _submit(c, category="el", start_date="2026-10-26", + end_date="2026-10-28", + substitute_user_id=SUBSTITUTE).json()["id"] + client(SUBSTITUTE, permissions=SELF).post( + f"/api/v1/leave/requests/{pk}/substitute/respond", + {"response": "accept"}, format="json") + client(UNIT_HEAD, kind="faculty", permissions=UH).post( + f"/api/v1/leave/requests/{pk}/review", {"outcome": "recommended"}, + format="json") + client(ESTABLISHMENT, permissions=EST).post( + f"/api/v1/leave/requests/{pk}/route", {}, format="json") + client(DEAN, kind="faculty", permissions=AUTH).post( + f"/api/v1/leave/requests/{pk}/sanction", {"outcome": "approve"}, + format="json") + + derived = c.post(f"/api/v1/leave/requests/{pk}/extension", + {"new_end_date": "2026-10-30", + "substitute_user_id": SUBSTITUTE, + "reason": "still unwell"}, format="json").json() + assert derived["kind"] == "extension" + assert derived["start_date"] == "2026-10-29" # day after current end + assert derived["chargeable_days"] == "2.00" + + client(SUBSTITUTE, permissions=SELF).post( + f"/api/v1/leave/requests/{derived['id']}/substitute/respond", + {"response": "accept"}, format="json") + client(UNIT_HEAD, kind="faculty", permissions=UH).post( + f"/api/v1/leave/requests/{derived['id']}/review", + {"outcome": "recommended"}, format="json") + client(ESTABLISHMENT, permissions=EST).post( + f"/api/v1/leave/requests/{derived['id']}/route", {}, format="json") + client(DEAN, kind="faculty", permissions=AUTH).post( + f"/api/v1/leave/requests/{derived['id']}/sanction", + {"outcome": "approve"}, format="json") + + base = LeaveRequest.objects.get(pk=pk) + assert base.end_date == date(2026, 10, 30) + assert base.chargeable_days == Decimal("5.00") + row = LeaveBalance.objects.get(user_id=APPLICANT, category="el", year=2026) + assert row.used == Decimal("5.00") + + +def test_resumption_refund_after_early_return(stub_iam, desks, client): + c = client(APPLICANT, permissions=SELF) + pk = _submit(c, category="el", start_date="2026-10-26", + end_date="2026-10-28", + substitute_user_id=SUBSTITUTE).json()["id"] + client(SUBSTITUTE, permissions=SELF).post( + f"/api/v1/leave/requests/{pk}/substitute/respond", + {"response": "accept"}, format="json") + client(UNIT_HEAD, kind="faculty", permissions=UH).post( + f"/api/v1/leave/requests/{pk}/review", {"outcome": "recommended"}, + format="json") + client(ESTABLISHMENT, permissions=EST).post( + f"/api/v1/leave/requests/{pk}/route", {}, format="json") + client(DEAN, kind="faculty", permissions=AUTH).post( + f"/api/v1/leave/requests/{pk}/sanction", {"outcome": "approve"}, + format="json") + + resumption = c.post(f"/api/v1/leave/requests/{pk}/resumption", + {"actual_date": "2026-10-27", + "details": "joined early"}, format="json").json() + assert resumption["status"] == "pending_review" + assert resumption["approved_end_date"] == "2026-10-28" + + reviewed = client(DEAN, kind="faculty", permissions=AUTH).post( + f"/api/v1/leave/resumptions/{resumption['id']}/review", + {"note": "noted"}, format="json").json() + assert reviewed["status"] == "reviewed_early" + assert reviewed["adjustment_days"] == "-1" + row = LeaveBalance.objects.get(user_id=APPLICANT, category="el", year=2026) + assert row.used == Decimal("2.00") + + +# -- scoping and permissions ------------------------------------------------- +def test_sees_only_own_requests_without_the_view_permission( + stub_iam, desks, client): + _submit(client(APPLICANT, permissions=SELF)) + rows = client(OTHER, permissions=SELF).get( + "/api/v1/leave/requests").json()["results"] + assert rows == [] + rows = client(DEAN, kind="faculty", + permissions=AUTH).get( + "/api/v1/leave/requests").json()["results"] + assert len(rows) == 1 # AUTH includes view + + +def test_cross_user_balance_needs_the_scope_permission(stub_iam, desks, client): + _submit(client(APPLICANT, permissions=SELF)) + r = client(OTHER, permissions=SELF).get( + f"/api/v1/leave/balances?user_id={APPLICANT}") + assert r.status_code == 403 + r = client(OTHER, permissions=ADMIN).get( + f"/api/v1/leave/balances?user_id={APPLICANT}") + assert r.status_code == 200 + assert {b["category"] for b in r.json()["results"]} >= {"cl", "el"} + + +def test_own_balance_is_readable_by_employees(stub_iam, desks, client): + body = client(APPLICANT, permissions=SELF).get( + "/api/v1/leave/balances?year=2026").json() + cl = next(b for b in body["results"] if b["category"] == "cl") + assert cl["available"] == 8.0 + # Staff have no VL track (BR-EL-006). + assert all(b["category"] != "vl" for b in body["results"]) + + +# -- BR-EL-029: offline records --------------------------------------------- +def test_offline_record_is_approved_with_its_reference(stub_iam, desks, client): + admin = client(ESTABLISHMENT, permissions=ADMIN) + body = admin.post("/api/v1/leave/admin/offline-leave", + {"user_id": APPLICANT, "category": "cl", + "start_date": "2026-10-05", "end_date": "2026-10-06", + "sanction_reference": "ORD-77/2026"}, + format="json").json() + assert body["status"] == "approved" + assert body["source"] == "offline" + assert body["sanction_reference"] == "ORD-77/2026" + row = LeaveBalance.objects.get(user_id=APPLICANT, category="cl", year=2026) + assert row.used == Decimal("2.00") + + +def test_offline_record_without_a_reference_is_refused( + stub_iam, desks, client): + admin = client(ESTABLISHMENT, permissions=ADMIN) + r = admin.post("/api/v1/leave/admin/offline-leave", + {"user_id": APPLICANT, "category": "cl", + "start_date": "2026-10-05", "end_date": "2026-10-06", + "sanction_reference": " "}, format="json") + assert r.status_code == 400 + assert r.json()["error"]["code"] == "reference_required" + + +# -- BR-EL-035: tasks and notifications ------------------------------------- +def test_assignment_notifies_the_next_desk(stub_iam, desks, client): + _submit(client(APPLICANT, permissions=SELF)) + rows = LeaveNotification.objects.filter(user_id=9001, kind="assigned") + assert rows.exists() + assert "needs your action" in rows.first().message + + +def test_tasks_page_lists_what_is_waiting_on_me(stub_iam, desks, client): + _submit(client(APPLICANT, permissions=SELF)) + rows = client(UNIT_HEAD, kind="faculty", + permissions=UH).get("/api/v1/leave/tasks").json()["results"] + assert len(rows) == 1 + assert rows[0]["status"] == "awaiting_unit_head_review" + + +# -- administration ---------------------------------------------------------- +def test_policy_put_needs_its_permission(stub_iam, desks, client): + r = client(APPLICANT, permissions=SELF).put( + "/api/v1/leave/policy", {"cl_annual": "10"}, format="json") + assert r.status_code == 403 + body = client(OTHER, permissions=ADMIN).put( + "/api/v1/leave/policy", {"cl_annual": "10"}, format="json").json() + assert body["cl_annual"] == "10.00" diff --git a/modules/leave/tests/test_contracts.py b/modules/leave/tests/test_contracts.py new file mode 100644 index 00000000..f884daee --- /dev/null +++ b/modules/leave/tests/test_contracts.py @@ -0,0 +1,53 @@ +"""The contract other modules see (plural, no singular lookup).""" +from __future__ import annotations + +from datetime import date + +import pytest + +from modules.leave import contracts +from modules.leave.models import LeaveRequest + +pytestmark = pytest.mark.django_db + + +def test_get_leave_status_is_plural_and_uses_the_actual_return( + stub_iam, desks): + LeaveRequest.objects.create( + user_id=9100, kind="new", source="elm", category="el", + status="approved", start_date=date(2026, 10, 20), + end_date=date(2026, 10, 24), year=2026, chargeable_days=5) + # A resumption review moved the real last day earlier. + LeaveRequest.objects.create( + user_id=9001, kind="new", source="elm", category="cl", + status="approved", start_date=date(2026, 10, 21), + end_date=date(2026, 10, 23), actual_end_date=date(2026, 10, 21), + year=2026, chargeable_days=1) + + status = contracts.get_leave_status([9100, 9001, 9999], + date(2026, 10, 21)) + assert status[9100].on_leave is True + assert status[9100].until == date(2026, 10, 24) + assert status[9100].categories == ("el",) + # On the 22nd the second person is NOT away: actual return was the 21st. + status = contracts.get_leave_status([9100, 9001], date(2026, 10, 22)) + assert status[9001].on_leave is False + assert status[9100].on_leave is True + + +def test_rejected_and_pending_do_not_count(stub_iam, desks): + LeaveRequest.objects.create( + user_id=9100, kind="new", source="elm", category="cl", + status="rejected", start_date=date(2026, 10, 20), + end_date=date(2026, 10, 24), year=2026, chargeable_days=5) + LeaveRequest.objects.create( + user_id=9101, kind="new", source="elm", category="cl", + status="awaiting_unit_head_review", start_date=date(2026, 10, 20), + end_date=date(2026, 10, 24), year=2026, chargeable_days=5) + status = contracts.get_leave_status([9100, 9101], date(2026, 10, 21)) + assert status[9100].on_leave is False + assert status[9101].on_leave is False + + +def test_empty_input_is_empty_output(stub_iam, desks): + assert contracts.get_leave_status([], date(2026, 10, 21)) == {} diff --git a/modules/leave/tests/test_domain.py b/modules/leave/tests/test_domain.py new file mode 100644 index 00000000..d71b5e96 --- /dev/null +++ b/modules/leave/tests/test_domain.py @@ -0,0 +1,171 @@ +"""Pure-domain rules: counting, overlap, the state machine, year-end plans. + +No database, no HTTP — these are the rules everything else delegates to. +""" +from datetime import date +from decimal import Decimal + +import pytest + +from modules.leave.domain import counting, states as st, validation as v +from modules.leave.domain import year_end as ye + + +# -- counting --------------------------------------------------------------- +def test_continuous_categories_count_every_day(): + # 2026-01-01 is a Thursday; span includes Sat 3 and Sun 4. + days = counting.chargeable_days( + category="el", start=date(2026, 1, 1), end=date(2026, 1, 4)) + assert days == Decimal(4) + + +def test_cl_skips_weekends_and_closed_holidays(): + # Wed 2026-01-07 … Tue 2026-01-13; Sat 10 and Sun 11 are weekends, + # Mon 12 is a closed holiday. + days = counting.chargeable_days( + category="cl", start=date(2026, 1, 7), end=date(2026, 1, 13), + closed_holidays={date(2026, 1, 12)}) + assert days == Decimal(4) # Wed, Thu, Fri, Tue + + +def test_half_day_cl_is_half(): + days = counting.chargeable_days( + category="cl", start=date(2026, 1, 7), end=date(2026, 1, 7), + half_day="first") + assert days == Decimal("0.5") + + +def test_half_day_el_is_not_a_thing(): + # The service refuses it; the counter simply does not halve EL. + days = counting.chargeable_days( + category="el", start=date(2026, 1, 7), end=date(2026, 1, 7), + half_day="first") + assert days == Decimal(1) + + +# -- validation ------------------------------------------------------------- +def test_half_day_must_be_single_day(): + with pytest.raises(v.ValidationError) as exc: + v.check_interval(start=date(2026, 1, 7), end=date(2026, 1, 8), + half_day="first", category="cl") + assert exc.value.code == "half_day_must_be_single_day" + + +def test_half_day_only_for_cl(): + with pytest.raises(v.ValidationError) as exc: + v.check_interval(start=date(2026, 1, 7), end=date(2026, 1, 7), + half_day="second", category="el") + assert exc.value.code == "half_day_not_supported" + + +def test_rh_must_hit_a_published_restricted_day(): + with pytest.raises(v.ValidationError) as exc: + v.check_rh_calendar(start=date(2026, 1, 7), end=date(2026, 1, 7), + restricted_dates={date(2026, 11, 8)}) + assert exc.value.code == "rh_not_applicable" + v.check_rh_calendar(start=date(2026, 1, 7), end=date(2026, 1, 7), + restricted_dates={date(2026, 1, 7)}) + + +def test_vl_only_inside_a_vacation_period(): + with pytest.raises(v.ValidationError) as exc: + v.check_vl_vacation_period(start=date(2026, 1, 7), end=date(2026, 1, 7), + vacation_periods=[(date(2026, 12, 21), + date(2027, 1, 1))]) + assert exc.value.code == "vl_outside_vacation" + + +def test_same_day_half_day_cls_do_not_overlap(): + # BR-EL-012: opposite halves of the same date coexist. + v.check_overlap(start=date(2026, 1, 7), end=date(2026, 1, 7), + half_day="first", category="cl", + existing=[v.Interval(date(2026, 1, 7), date(2026, 1, 7), + "second", "cl")]) + # ... but same-half on the same date does. + with pytest.raises(v.ValidationError) as exc: + v.check_overlap(start=date(2026, 1, 7), end=date(2026, 1, 7), + half_day="first", category="cl", + existing=[v.Interval(date(2026, 1, 7), date(2026, 1, 7), + "first", "cl")]) + assert exc.value.code == "overlapping_leave" + + +def test_balance_shortfall_is_refused(): + with pytest.raises(v.ValidationError) as exc: + v.check_balance(available=Decimal(2), required=Decimal(3), + category="cl") + assert exc.value.code == "insufficient_balance" + + +def test_substitute_must_be_someone_else_in_the_directory(): + with pytest.raises(v.ValidationError) as exc: + v.check_substitute(nominee=1, applicant=1, employee_ids={1, 2}) + assert exc.value.code == "self_substitute" + with pytest.raises(v.ValidationError) as exc: + v.check_substitute(nominee=9, applicant=1, employee_ids={1, 2}) + assert exc.value.code == "substitute_not_employee" + + +# -- state machine ---------------------------------------------------------- +def test_unit_head_final_edges_only(): + t = st.resolve(st.AWAITING_UH_REVIEW, st.APPROVED, st.UNIT_HEAD) + assert "unit_head_final" in t.guards + with pytest.raises(st.InvalidTransition): + st.resolve(st.AWAITING_UH_REVIEW, st.AWAITING_FINAL, st.SUBSTITUTE) + + +def test_substitute_cannot_sanction(): + with pytest.raises(st.ActorNotAllowed): + st.resolve(st.AWAITING_FINAL, st.APPROVED, st.SUBSTITUTE) + + +def test_withdrawable_is_exactly_the_pending_states(): + assert st.WITHDRAWABLE == st.PENDING + assert st.APPROVED not in st.WITHDRAWABLE + assert st.CANCELLED not in st.WITHDRAWABLE + + +# -- year-end plan ---------------------------------------------------------- +def test_lapse_convert_carry(): + plan = ye.plan_year_end( + year=2026, + balances=[(1, "cl", Decimal(5)), (1, "vl", Decimal(60)), + (1, "el", Decimal(10)), (2, "col", Decimal(4)), + (2, "scl", Decimal(1.5))], + is_faculty={1: True, 2: False}, + vl_to_el_numerator=Decimal(2), vl_to_el_denominator=Decimal(1), + carry_forward_enabled={"el": True, "col": True}, + ) + kinds = {(e.user_id, e.category, e.year, e.kind) for e in plan.entries} + assert (1, "cl", 2026, "year_end_lapse") in kinds + assert (1, "vl", 2026, "vl_to_el_conversion") in kinds + assert (1, "el", 2027, "vl_to_el_conversion") in kinds + assert (1, "el", 2027, "carry_forward") in kinds + assert (1, "el", 2026, "carry_forward") in kinds # source debited too + assert (2, "col", 2027, "carry_forward") in kinds + assert (2, "scl", 2026, "year_end_lapse") in kinds + el_in = [e for e in plan.entries + if e.user_id == 1 and e.category == "el" and e.year == 2027 + and e.kind == "vl_to_el_conversion"] + assert el_in[0].days == Decimal("30.00") + + +def test_faculty_vl_converts_staff_vl_lapses(): + plan = ye.plan_year_end( + year=2026, balances=[(1, "vl", Decimal(10)), (2, "vl", Decimal(10))], + is_faculty={1: True, 2: False}, + vl_to_el_numerator=Decimal(2), vl_to_el_denominator=Decimal(1), + carry_forward_enabled={}, + ) + dest = [e for e in plan.entries if e.year == 2027] + assert len(dest) == 1 and dest[0].user_id == 1 + lapses = [e for e in plan.entries if e.kind == "year_end_lapse"] + assert [e.user_id for e in lapses] == [2] + + +def test_zero_balances_plan_nothing(): + plan = ye.plan_year_end( + year=2026, balances=[(1, "cl", Decimal(0))], is_faculty={1: False}, + vl_to_el_numerator=Decimal(2), vl_to_el_denominator=Decimal(1), + carry_forward_enabled={}) + assert plan.entries == () diff --git a/modules/leave/tests/test_sla.py b/modules/leave/tests/test_sla.py new file mode 100644 index 00000000..1eece529 --- /dev/null +++ b/modules/leave/tests/test_sla.py @@ -0,0 +1,73 @@ +"""SLA reminders and escalations (BR-EL-032…034).""" +from __future__ import annotations + +from datetime import timedelta + +import pytest +from django.utils import timezone + +from modules.leave.models import LeaveNotification, SlaThreshold +from modules.leave.services import sla +from modules.leave.tests.conftest import ( + APPLICANT, + DEAN, + SELF, + SUBSTITUTE, + UH, + UNIT_HEAD, +) + +pytestmark = pytest.mark.django_db + + +def _pending_file(client, **overrides): + payload = {"category": "el", "start_date": "2026-10-26", + "end_date": "2026-10-28", + "substitute_user_id": SUBSTITUTE} + payload.update(overrides) + c = client(APPLICANT, permissions=SELF) + return c.post("/api/v1/leave/requests", payload, format="json").json() + + +def test_reminder_fires_once_then_escalation(stub_iam, desks, client): + pk = _pending_file(client)["id"] + now = timezone.now() + + # 25h in: the reminder threshold (24h) has passed, escalation (48h) has not. + result = sla.sweep(now=now + timedelta(hours=25)) + assert result == {"reminded": 1, "escalated": 0} + # CFR-EL-031: the responsible user — the substitute holding the task. + assert LeaveNotification.objects.filter( + user_id=SUBSTITUTE, kind="sla_reminder").exists() + + # A second sweep at the same instant must not double-notify. + assert sla.sweep(now=now + timedelta(hours=25))["reminded"] == 0 + + # 50h in: escalation, and BR-EL-033 re-routes the file to the + # configured higher authority (DEAN on the seeded threshold). + result = sla.sweep(now=now + timedelta(hours=50)) + assert result["escalated"] == 1 + assert LeaveNotification.objects.filter( + user_id=DEAN, kind="sla_escalation").exists() + from modules.leave.models import LeaveRequest + request = LeaveRequest.objects.get(pk=pk) + assert request.task_user_id == DEAN + assert request.escalated_at is not None + + +def test_terminal_files_are_never_swept(stub_iam, desks, client): + pk = _pending_file(client)["id"] + client(SUBSTITUTE, permissions=SELF).post( + f"/api/v1/leave/requests/{pk}/substitute/respond", + {"response": "accept"}, format="json") + # Now on the Unit Head's desk with a fresh clock; the substitute's + # closed task must not re-fire anything. + result = sla.sweep(now=timezone.now() + timedelta(hours=1)) + assert result == {"reminded": 0, "escalated": 0} + + +def test_unconfigured_task_class_is_skipped(stub_iam, desks, client): + SlaThreshold.objects.all().delete() + _pending_file(client) + assert sla.sweep(now=timezone.now() + timedelta(days=30)) == { + "reminded": 0, "escalated": 0} diff --git a/modules/leave/tests/test_year_end.py b/modules/leave/tests/test_year_end.py new file mode 100644 index 00000000..3c5fa13c --- /dev/null +++ b/modules/leave/tests/test_year_end.py @@ -0,0 +1,72 @@ +"""The boundary sweep: lapse, convert, carry — and never twice.""" +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from modules.leave.models import LeaveBalance, LeaveYearRun +from modules.leave.services import yearend +from modules.leave.tests.conftest import APPLICANT, DIRECTOR, UNIT_HEAD + +pytestmark = pytest.mark.django_db + + +def _balance(uid, category, year, **kw): + return LeaveBalance.objects.create( + user_id=uid, category=category, year=year, + credited=Decimal(kw.get("credited", 0)), + used=Decimal(kw.get("used", 0)), + carried_forward=Decimal(kw.get("carried", 0))) + + +def _available(uid, category, year) -> Decimal: + # Materialise-on-read, exactly as the production balance screen does: + # a hole shows up as zero, not as a missing row. + row, _ = LeaveBalance.objects.get_or_create( + user_id=uid, category=category, year=year) + return row.carried_forward + row.credited + row.adjusted - row.used + + +def test_year_end_lapse_convert_carry(stub_iam, desks): + # UNIT_HEAD is faculty: their VL converts; their CL and SCL lapse; + # their EL carries as itself. + _balance(UNIT_HEAD, "cl", 2026, credited="5") + _balance(UNIT_HEAD, "scl", 2026, credited="1.5") + _balance(UNIT_HEAD, "vl", 2026, credited="60", used="50") + _balance(UNIT_HEAD, "el", 2026, credited="10") + # APPLICANT is staff: staff VL lapses rather than converting. + _balance(APPLICANT, "vl", 2026, credited="4") + + report = yearend.run_year_end(2026) + assert report["already_run"] is False + + assert _available(UNIT_HEAD, "cl", 2026) == 0 + assert _available(UNIT_HEAD, "scl", 2026) == 0 + assert _available(UNIT_HEAD, "vl", 2026) == 0 + assert _available(UNIT_HEAD, "el", 2026) == 0 # carried out + # 10 unused VL → 5 EL conversion; plus the 10 EL that carried as itself. + assert _available(UNIT_HEAD, "el", 2027) == Decimal("15.00") + assert _available(APPLICANT, "vl", 2026) == 0 + assert _available(APPLICANT, "el", 2027) == 0 # nothing converted + + +def test_year_end_is_idempotent(stub_iam, desks): + _balance(UNIT_HEAD, "cl", 2026, credited="5") + first = yearend.run_year_end(2026) + second = yearend.run_year_end(2026) + assert first["already_run"] is False + assert second["already_run"] is True + assert second["entries"] == first["entries"] + # The lapse happened exactly once. + assert LeaveYearRun.objects.count() == 1 + assert _available(UNIT_HEAD, "cl", 2026) == 0 + + +def test_directors_vl_also_converts(stub_iam, desks): + _balance(DIRECTOR, "vl", 2026, credited="60", used="60") + yearend.run_year_end(2026) + # 60 taken, 0 left: nothing to convert, nothing to carry. + assert _available(DIRECTOR, "vl", 2026) == 0 + assert not LeaveBalance.objects.filter(user_id=DIRECTOR, year=2027, + category="el").exists() diff --git a/modules/placement/migrations/0009_remove_profiledocument_profile_and_more.py b/modules/placement/migrations/0009_remove_profiledocument_profile_and_more.py index 2dcd582d..489e1836 100644 --- a/modules/placement/migrations/0009_remove_profiledocument_profile_and_more.py +++ b/modules/placement/migrations/0009_remove_profiledocument_profile_and_more.py @@ -14,9 +14,26 @@ class Migration(migrations.Migration): model_name='profiledocument', name='profile', ), - migrations.RemoveIndex( - model_name='profiledocument', - name='document_profile_idx', + migrations.SeparateDatabaseAndState( + # Postgres drops the index with the column; SQLite rebuilds the + # table and the bare DROP INDEX would then fail. IF EXISTS makes + # this a no-op on either backend. + database_operations=[ + migrations.RunSQL( + sql='DROP INDEX IF EXISTS "document_profile_idx";', + reverse_sql=( + 'CREATE INDEX "document_profile_idx" ' + 'ON "placement_profile_document" ' + '("profile_id", "kind", "is_active");' + ), + ), + ], + state_operations=[ + migrations.RemoveIndex( + model_name='profiledocument', + name='document_profile_idx', + ), + ], ), migrations.RemoveField( model_name='application',