diff --git a/.importlinter b/.importlinter index d964dc3c..1eea34fb 100644 --- a/.importlinter +++ b/.importlinter @@ -15,7 +15,11 @@ source_modules = modules.placement modules.directory modules.accesscontrol + modules.elm forbidden_modules = + modules.elm.models + modules.elm.services + modules.elm.api modules.placement.models modules.placement.services modules.placement.selectors @@ -66,3 +70,11 @@ layers = modules.placement.selectors modules.placement.models modules.placement.domain + +[importlinter:contract:elm-layers] +name = Layering inside elm +type = layers +layers = + modules.elm.api + modules.elm.services + modules.elm.models diff --git a/config/settings/base.py b/config/settings/base.py index 0dc7ada1..2e1ec6b5 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -90,6 +90,8 @@ def env_list(name, default=None): # See docs/03-platform/module-authoring-guide.md to add one. DOMAIN_MODULES = [ "modules.placement", + "modules.elm", + ] INSTALLED_APPS = (DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/config/urls.py b/config/urls.py index 33f7835b..de504d92 100644 --- a/config/urls.py +++ b/config/urls.py @@ -16,6 +16,8 @@ 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/elm/", include("modules.elm.api.urls")), + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="docs"), ] diff --git a/docs/02-iam/permission-catalog.generated.md b/docs/02-iam/permission-catalog.generated.md index 6d748531..c198a634 100644 --- a/docs/02-iam/permission-catalog.generated.md +++ b/docs/02-iam/permission-catalog.generated.md @@ -13,6 +13,15 @@ For the intended taxonomy of modules not yet built, see |---|---|---| | `directory.user.search` | Search the institute directory | `Dean Academic`, `acadadmin`, `placement_chairman`, `placement_coordinator`, `placement_officer` | +## `elm` + +| Code | What it allows | Held by | +|---|---|---| +| `elm.leave.view` | See leave categories and own balances | `Dean`, `Deputy Registrar`, `Director`, `HOD`, `Registrar`, `faculty`, `staff` | +| `elm.leave.view_self` | See own leave applications | `Dean`, `Deputy Registrar`, `Director`, `HOD`, `Registrar`, `faculty`, `staff` | +| `elm.leave.create` | Apply for leave | `Dean`, `Deputy Registrar`, `Director`, `HOD`, `Registrar`, `faculty`, `staff` | +| `elm.leave.approve` | Review, recommend or sanction leave requests | `Dean`, `Deputy Registrar`, `Director`, `HOD`, `Registrar` | + ## `placement_cell` | Code | What it allows | Held by | diff --git a/modules/elm/__init__.py b/modules/elm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/elm/api/__init__.py b/modules/elm/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/elm/api/serializers.py b/modules/elm/api/serializers.py new file mode 100644 index 00000000..9afba410 --- /dev/null +++ b/modules/elm/api/serializers.py @@ -0,0 +1,47 @@ +from rest_framework import serializers + +from modules.elm.models.leave_application import LeaveApplication +from modules.elm.models.leave_types import LeaveBalance, LeaveType + + +class LeaveTypeSerializer(serializers.ModelSerializer): + class Meta: + model = LeaveType + fields = "__all__" + + +class LeaveBalanceSerializer(serializers.ModelSerializer): + class Meta: + model = LeaveBalance + fields = "__all__" + + +class LeaveApplicationSerializer(serializers.ModelSerializer): + leave_type_name = serializers.CharField(source="leave_type.name", read_only=True) + + class Meta: + model = LeaveApplication + fields = [ + "id", + "user_id", + "leave_type", + "leave_type_name", + "start_date", + "end_date", + "purpose", + "station_leave_details", + "substitute_id", + "status", + "decision_maker_id", + "decision_date", + "created_at", + "updated_at", + "unit_head_recommendation", + ] + read_only_fields = [ + "user_id", + "status", + "decision_maker_id", + "decision_date", + "unit_head_recommendation", + ] diff --git a/modules/elm/api/urls.py b/modules/elm/api/urls.py new file mode 100644 index 00000000..76f413d7 --- /dev/null +++ b/modules/elm/api/urls.py @@ -0,0 +1,20 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework.urls import path as drf_path + +from modules.elm.api.views import ( + LeaveApplicationViewSet, + LeaveBalanceViewSet, + LeaveTypeViewSet, + record_decision, +) + +router = DefaultRouter() +router.register(r"leave-types", LeaveTypeViewSet, basename="leave-types") +router.register(r"leave-balances", LeaveBalanceViewSet, basename="leave-balances") +router.register(r"leave-applications", LeaveApplicationViewSet, basename="leave-applications") + +urlpatterns = [ + path("", include(router.urls)), + drf_path("record-decision/", record_decision, name="record-decision"), +] diff --git a/modules/elm/api/views.py b/modules/elm/api/views.py new file mode 100644 index 00000000..6a08c856 --- /dev/null +++ b/modules/elm/api/views.py @@ -0,0 +1,122 @@ +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers, status, viewsets +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from fusion_auth.permissions import HasModuleGrant, HasPermission +from modules.elm.api.serializers import ( + LeaveApplicationSerializer, + LeaveBalanceSerializer, + LeaveTypeSerializer, +) +from modules.elm.domain.state_machine import InvalidTransition, initial_state +from modules.elm.models.leave_application import LeaveApplication +from modules.elm.models.leave_types import LeaveBalance, LeaveType +from modules.elm.services.leave_service import LeaveService +from modules.elm.services.workflow_service import WorkflowService + + +class LeaveTypeViewSet(viewsets.ReadOnlyModelViewSet): + queryset = LeaveType.objects.all() + serializer_class = LeaveTypeSerializer + permission_classes = [HasModuleGrant("elm"), HasPermission("elm.leave.view")] + + +class LeaveBalanceViewSet(viewsets.ReadOnlyModelViewSet): + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return LeaveBalance.objects.none() + user_id = self.request.principal.user_id + return LeaveBalance.objects.filter(user_id=user_id) + + serializer_class = LeaveBalanceSerializer + permission_classes = [HasModuleGrant("elm"), HasPermission("elm.leave.view")] + + +class LeaveApplicationViewSet(viewsets.ModelViewSet): + queryset = LeaveApplication.objects.all() + serializer_class = LeaveApplicationSerializer + http_method_names = ["get", "post", "head", "options"] + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return LeaveApplication.objects.none() + user_id = self.request.principal.user_id + return LeaveApplication.objects.filter(user_id=user_id) + + def perform_create(self, serializer): + serializer.save( + user_id=self.request.principal.user_id, + status=initial_state( + substitute_required=bool(serializer.validated_data.get("substitute_id")), + applicant_is_director="Director" in self.request.principal.roles, + ), + ) + + def create(self, request, *args, **kwargs): + data = request.data + leave_type_code = data.get("leave_type") + start_date = data.get("start_date") + end_date = data.get("end_date") + purpose = data.get("purpose") + + validation = LeaveService.validate_application( + self.request.principal.user_id, leave_type_code, start_date, end_date, purpose + ) + + if not validation["valid"]: + return Response({"error": validation["reason"]}, status=status.HTTP_400_BAD_REQUEST) + + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get_permissions(self): + if self.action in ["list", "retrieve"]: + permission_classes = [HasModuleGrant("elm")(), HasPermission("elm.leave.view_self")()] + elif self.action in ["create"]: + permission_classes = [HasModuleGrant("elm")(), HasPermission("elm.leave.create")()] + else: + permission_classes = [HasModuleGrant("elm")()] + return [permission for permission in permission_classes if permission] + + +@extend_schema( + request=inline_serializer( + name="RecordDecisionRequest", + fields={ + "application_id": serializers.IntegerField(), + "action": serializers.ChoiceField( + choices=["approve", "reject", "recommend", "not_recommend", "route"] + ), + }, + ), + responses=LeaveApplicationSerializer, +) +@api_view(["POST"]) +def record_decision(request): + + if not request.principal.has_module("elm") or not request.principal.has_permission( + "elm.leave.approve" + ): + return Response({"error": "Unauthorized"}, status=status.HTTP_403_FORBIDDEN) + + application_id = request.data.get("application_id") + action = request.data.get("action") + decision_maker_id = request.principal.user_id + + application = get_object_or_404(LeaveApplication, id=application_id) + + if application.user_id == decision_maker_id: + return Response( + {"error": "Cannot approve your own application."}, status=status.HTTP_403_FORBIDDEN + ) + + try: + WorkflowService.process_decision(application, action, decision_maker_id) + except InvalidTransition as exc: + return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + return Response(LeaveApplicationSerializer(application).data) diff --git a/modules/elm/apps.py b/modules/elm/apps.py new file mode 100644 index 00000000..60eca705 --- /dev/null +++ b/modules/elm/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class ElmConfig(AppConfig): + name = "modules.elm" + label = "elm" + + def ready(self): + pass diff --git a/modules/elm/domain/__init__.py b/modules/elm/domain/__init__.py new file mode 100644 index 00000000..aa0bca0b --- /dev/null +++ b/modules/elm/domain/__init__.py @@ -0,0 +1,29 @@ +from .state_machine import ( + APPLICANT_ACTION_REQUIRED, + APPROVED, + AWAITING_DIRECTOR_SELF_SANCTION, + AWAITING_ESTABLISHMENT_ROUTING, + AWAITING_FINAL_SANCTION, + AWAITING_SUBSTITUTE_RESPONSE, + AWAITING_UNIT_HEAD_REVIEW, + REJECTED, + WITHDRAWN, + InvalidTransition, + initial_state, + transition, +) + +__all__ = [ + "APPLICANT_ACTION_REQUIRED", + "APPROVED", + "AWAITING_DIRECTOR_SELF_SANCTION", + "AWAITING_ESTABLISHMENT_ROUTING", + "AWAITING_FINAL_SANCTION", + "AWAITING_SUBSTITUTE_RESPONSE", + "AWAITING_UNIT_HEAD_REVIEW", + "REJECTED", + "WITHDRAWN", + "InvalidTransition", + "initial_state", + "transition", +] diff --git a/modules/elm/domain/state_machine.py b/modules/elm/domain/state_machine.py new file mode 100644 index 00000000..ad2ceb89 --- /dev/null +++ b/modules/elm/domain/state_machine.py @@ -0,0 +1,86 @@ +class InvalidTransition(Exception): + pass + + +# State Constants +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 = "AWAITING_FINAL_SANCTION" +AWAITING_DIRECTOR_SELF_SANCTION = "AWAITING_DIRECTOR_SELF_SANCTION" +APPROVED = "APPROVED" +REJECTED = "REJECTED" +WITHDRAWN = "WITHDRAWN" + +PENDING_STATES = { + AWAITING_SUBSTITUTE_RESPONSE, + APPLICANT_ACTION_REQUIRED, + AWAITING_UNIT_HEAD_REVIEW, + AWAITING_ESTABLISHMENT_ROUTING, + AWAITING_FINAL_SANCTION, + AWAITING_DIRECTOR_SELF_SANCTION, +} + +UNIT_HEAD_FINAL_CATEGORIES = {"CL", "RH"} + + +def initial_state(substitute_required: bool, applicant_is_director: bool) -> str: + if substitute_required: + return AWAITING_SUBSTITUTE_RESPONSE + if applicant_is_director: + return AWAITING_DIRECTOR_SELF_SANCTION + return AWAITING_UNIT_HEAD_REVIEW + + +def transition( + state: str, + action: str, + *, + category: str | None = None, + applicant_is_director: bool = False, + establishment_configured: bool = False, +) -> str: + if action == "withdraw": + if state in PENDING_STATES: + return WITHDRAWN + raise InvalidTransition(f"Cannot withdraw from state {state}") + + if state == AWAITING_SUBSTITUTE_RESPONSE: + if action == "substitute_accept": + return ( + AWAITING_DIRECTOR_SELF_SANCTION + if applicant_is_director + else AWAITING_UNIT_HEAD_REVIEW + ) + if action == "substitute_decline": + return APPLICANT_ACTION_REQUIRED + + if state == APPLICANT_ACTION_REQUIRED and action == "nominate_replacement": + return AWAITING_SUBSTITUTE_RESPONSE + + if state == AWAITING_UNIT_HEAD_REVIEW: + if action in ("approve", "reject"): + if category in UNIT_HEAD_FINAL_CATEGORIES: + return APPROVED if action == "approve" else REJECTED + raise InvalidTransition(f"Unit head cannot approve/reject category {category}") + if action in ("recommend", "not_recommend"): + if category not in UNIT_HEAD_FINAL_CATEGORIES: + return ( + AWAITING_ESTABLISHMENT_ROUTING + if establishment_configured + else AWAITING_FINAL_SANCTION + ) + + raise InvalidTransition(f"Unit head cannot recommend/not_recommend category {category}") + + if state == AWAITING_ESTABLISHMENT_ROUTING and action == "route": + return AWAITING_FINAL_SANCTION + + if state in (AWAITING_FINAL_SANCTION, AWAITING_DIRECTOR_SELF_SANCTION) and action in ( + "approve", + "reject", + ): + return APPROVED if action == "approve" else REJECTED + + raise InvalidTransition(f"Invalid transition from {state} with action {action}") diff --git a/modules/elm/migrations/0001_initial.py b/modules/elm/migrations/0001_initial.py new file mode 100644 index 00000000..96f1c74a --- /dev/null +++ b/modules/elm/migrations/0001_initial.py @@ -0,0 +1,69 @@ +# Generated by Django 5.2.18 on 2026-10-30 02:23 + +import django.db.models.deletion +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')), + ('date', models.DateField(unique=True)), + ('is_holiday', models.BooleanField(default=False)), + ('rh_type', models.CharField(blank=True, max_length=20, null=True)), + ], + ), + migrations.CreateModel( + name='LeaveType', + fields=[ + ('code', models.CharField(max_length=10, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=50)), + ('annual_entitlement', models.DecimalField(decimal_places=2, max_digits=5)), + ('carry_forward', models.BooleanField(default=False)), + ('continuous_counting', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='LeaveApplication', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField()), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('purpose', models.TextField()), + ('station_leave_details', models.JSONField(blank=True, default=dict)), + ('substitute_id', models.IntegerField(blank=True, null=True)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('RECOMMENDED', 'Recommended'), ('NOT_RECOMMENDED', 'Not Recommended'), ('APPROVED', 'Approved'), ('REJECTED', 'Rejected'), ('WITHDRAWN', 'Withdrawn'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('decision_maker_id', models.IntegerField(blank=True, null=True)), + ('decision_date', models.DateTimeField(blank=True, null=True)), + ('leave_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='elm.leavetype')), + ], + options={ + 'verbose_name': 'Leave Application', + 'db_table': 'elm_leave_application', + }, + ), + migrations.CreateModel( + name='LeaveBalance', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField()), + ('year', models.IntegerField()), + ('balance', models.DecimalField(decimal_places=2, max_digits=5)), + ('leave_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='elm.leavetype')), + ], + options={ + 'unique_together': {('user_id', 'leave_type', 'year')}, + }, + ), + ] diff --git a/modules/elm/migrations/0002_leaveapplication_unit_head_recommendation_and_more.py b/modules/elm/migrations/0002_leaveapplication_unit_head_recommendation_and_more.py new file mode 100644 index 00000000..ced64e8e --- /dev/null +++ b/modules/elm/migrations/0002_leaveapplication_unit_head_recommendation_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.18 on 2026-10-30 04:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('elm', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='leaveapplication', + name='unit_head_recommendation', + field=models.CharField(blank=True, default='', max_length=20), + ), + migrations.AlterField( + model_name='leaveapplication', + name='status', + field=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', 'Awaiting Final Sanction'), ('AWAITING_DIRECTOR_SELF_SANCTION', 'Awaiting Director Self Sanction'), ('APPROVED', 'Approved'), ('REJECTED', 'Rejected'), ('WITHDRAWN', 'Withdrawn')], default='AWAITING_UNIT_HEAD_REVIEW', max_length=40), + ), + ] diff --git a/modules/elm/migrations/0003_alter_holidaycalendar_rh_type.py b/modules/elm/migrations/0003_alter_holidaycalendar_rh_type.py new file mode 100644 index 00000000..2f64c326 --- /dev/null +++ b/modules/elm/migrations/0003_alter_holidaycalendar_rh_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.18 on 2026-10-30 05:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('elm', '0002_leaveapplication_unit_head_recommendation_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='holidaycalendar', + name='rh_type', + field=models.CharField(blank=True, default='', max_length=20), + ), + ] diff --git a/modules/elm/migrations/__init__.py b/modules/elm/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/elm/models/__init__.py b/modules/elm/models/__init__.py new file mode 100644 index 00000000..9f544a8d --- /dev/null +++ b/modules/elm/models/__init__.py @@ -0,0 +1,9 @@ +from .leave_application import LeaveApplication +from .leave_types import HolidayCalendar, LeaveBalance, LeaveType + +__all__ = [ + "HolidayCalendar", + "LeaveApplication", + "LeaveBalance", + "LeaveType", +] diff --git a/modules/elm/models/leave_application.py b/modules/elm/models/leave_application.py new file mode 100644 index 00000000..05db32be --- /dev/null +++ b/modules/elm/models/leave_application.py @@ -0,0 +1,54 @@ +from django.db import models + +from modules.elm.domain import ( + APPLICANT_ACTION_REQUIRED, + APPROVED, + AWAITING_DIRECTOR_SELF_SANCTION, + AWAITING_ESTABLISHMENT_ROUTING, + AWAITING_FINAL_SANCTION, + AWAITING_SUBSTITUTE_RESPONSE, + AWAITING_UNIT_HEAD_REVIEW, + REJECTED, + WITHDRAWN, +) + +from .leave_types import LeaveType + + +class LeaveApplication(models.Model): + STATUS_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, "Awaiting Final Sanction"), + (AWAITING_DIRECTOR_SELF_SANCTION, "Awaiting Director Self Sanction"), + (APPROVED, "Approved"), + (REJECTED, "Rejected"), + (WITHDRAWN, "Withdrawn"), + ] + + user_id = models.IntegerField() + leave_type = models.ForeignKey(LeaveType, on_delete=models.CASCADE) + start_date = models.DateField() + end_date = models.DateField() + purpose = models.TextField() + station_leave_details = models.JSONField(default=dict, blank=True) + substitute_id = models.IntegerField(null=True, blank=True) + status = models.CharField( + max_length=40, default=AWAITING_UNIT_HEAD_REVIEW, choices=STATUS_CHOICES + ) + unit_head_recommendation = models.CharField(max_length=20, blank=True, default="") + + # Metadata + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + decision_maker_id = models.IntegerField(null=True, blank=True) + decision_date = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = "Leave Application" + db_table = "elm_leave_application" + + def __str__(self): + return f"LeaveApp-{self.id} (User {self.user_id})" diff --git a/modules/elm/models/leave_types.py b/modules/elm/models/leave_types.py new file mode 100644 index 00000000..4cdfc9dd --- /dev/null +++ b/modules/elm/models/leave_types.py @@ -0,0 +1,34 @@ +from django.db import models + + +class LeaveType(models.Model): + code = models.CharField(max_length=10, primary_key=True) # CL, RH, SCL, EL, COL, VL + name = models.CharField(max_length=50) + annual_entitlement = models.DecimalField(max_digits=5, decimal_places=2) + carry_forward = models.BooleanField(default=False) + continuous_counting = models.BooleanField(default=False) + + def __str__(self): + return f"{self.code} - {self.name}" + + +class LeaveBalance(models.Model): + user_id = models.IntegerField() # Maps to Principal.user_id + leave_type = models.ForeignKey(LeaveType, on_delete=models.CASCADE) + year = models.IntegerField() + balance = models.DecimalField(max_digits=5, decimal_places=2) + + class Meta: + unique_together = ("user_id", "leave_type", "year") + + def __str__(self): + return f"User {self.user_id} - {self.leave_type.code} - {self.year}: {self.balance}" + + +class HolidayCalendar(models.Model): + date = models.DateField(unique=True) + is_holiday = models.BooleanField(default=False) + rh_type = models.CharField(max_length=20, blank=True, default="") # e.g., 'RH', 'PUBLIC' + + def __str__(self): + return f"{self.date} - {self.is_holiday} ({self.rh_type})" diff --git a/modules/elm/registry.py b/modules/elm/registry.py new file mode 100644 index 00000000..54d66dcd --- /dev/null +++ b/modules/elm/registry.py @@ -0,0 +1,43 @@ +"""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": "elm", + "label": "Leave Management", + "icon": "CalendarCheck", + "base_path": "/elm", + "nav_section": "Leave", + "sort_order": 20, + "status": "planned", +} + +NAV_ITEMS = [] + +#: Every permission this module recognises; ROLE_GRANTS below says who holds each. +PERMISSIONS = [ + ("elm.leave.view", "See leave categories and own balances"), + ("elm.leave.view_self", "See own leave applications"), + ("elm.leave.create", "Apply for leave"), + ("elm.leave.approve", "Review, recommend or sanction leave requests"), +] + +#: Performed by a service or a scheduled task, so no designation holds them. +SYSTEM_PERMISSIONS = [] + +_RESIDENT = ["elm.leave.view", "elm.leave.view_self", "elm.leave.create"] +_WARDEN = [*_RESIDENT, "elm.leave.approve"] + +#: Keyed by the designation name as it exists in globals_designation. +ROLE_GRANTS = { + "faculty": _RESIDENT, + "staff": _RESIDENT, + "HOD": _WARDEN, + "Deputy Registrar": _WARDEN, + "Dean": _WARDEN, + "Registrar": _WARDEN, + "Director": _WARDEN, +} diff --git a/modules/elm/services/__init__.py b/modules/elm/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/elm/services/leave_service.py b/modules/elm/services/leave_service.py new file mode 100644 index 00000000..8cbc740a --- /dev/null +++ b/modules/elm/services/leave_service.py @@ -0,0 +1,66 @@ +from datetime import timedelta +from decimal import Decimal + +from modules.elm.models.leave_application import LeaveApplication +from modules.elm.models.leave_types import HolidayCalendar, LeaveBalance, LeaveType + + +class LeaveService: + @staticmethod + def validate_application( + user_id: int, leave_type_code: str, start_date, end_date, purpose: str + ) -> dict: + leave_type = LeaveType.objects.get(code=leave_type_code) + balance = LeaveBalance.objects.filter(user_id=user_id, leave_type=leave_type).first() + + if not balance or balance.balance < 1.0: + return {"valid": False, "reason": "Insufficient balance"} + + if start_date > end_date: + return {"valid": False, "reason": "Start date must be before end date"} + + # RH Check + if leave_type_code == "RH": + holiday = HolidayCalendar.objects.filter(date=start_date).first() + if not holiday or not holiday.is_holiday or holiday.rh_type != "RH": + return {"valid": False, "reason": "Start date is not a Restricted Holiday"} + + # Check overlap (excluding half-day CL exception) + overlapping = ( + LeaveApplication.objects.filter( + user_id=user_id, + status__in=["PENDING", "APPROVED"], + start_date__lte=end_date, + end_date__gte=start_date, + ) + .exclude(leave_type__code="CL", start_date=start_date, end_date=end_date) + .exists() + ) + + if overlapping: + return {"valid": False, "reason": "Overlapping leave detected"} + + return {"valid": True} + + @staticmethod + def calculate_chargeable_days(start_date, end_date, leave_type_code: str) -> Decimal: + leave_type = LeaveType.objects.get(code=leave_type_code) + + total_days = (end_date - start_date).days + 1 + + if not leave_type.continuous_counting: + # Non-continuous: only count business days + # Simplified: excluding weekends + holidays + chargeable = 0 + current_date = start_date + while current_date <= end_date: + is_weekend = current_date.weekday() >= 5 + holiday_entry = HolidayCalendar.objects.filter(date=current_date).first() + is_holiday = holiday_entry and holiday_entry.is_holiday + + if not is_weekend and not is_holiday: + chargeable += 1 + current_date += timedelta(days=1) + return Decimal(chargeable) + # Continuous counting includes weekends and holidays + return Decimal(total_days) diff --git a/modules/elm/services/workflow_service.py b/modules/elm/services/workflow_service.py new file mode 100644 index 00000000..00190374 --- /dev/null +++ b/modules/elm/services/workflow_service.py @@ -0,0 +1,52 @@ +from django.utils import timezone + +from modules.elm.domain import ( + APPROVED, + REJECTED, + transition, +) +from modules.elm.models.leave_application import LeaveApplication + + +class WorkflowService: + @staticmethod + def process_decision( + application: LeaveApplication, + action: str, + actor_id: int, + *, + applicant_is_director: bool = False, + establishment_configured: bool = False, + ) -> LeaveApplication: + """ + Records a decision and updates the application status based on the state machine. + """ + new_state = transition( + application.status, + action, + category=application.leave_type.code, + applicant_is_director=applicant_is_director, + establishment_configured=establishment_configured, + ) + + if action in ("recommend", "not_recommend"): + application.unit_head_recommendation = ( + "RECOMMENDED" if action == "recommend" else "NOT_RECOMMENDED" + ) + + application.status = new_state + + if new_state in (APPROVED, REJECTED): + application.decision_maker_id = actor_id + application.decision_date = timezone.now() + + application.save() + return application + + @staticmethod + def check_sla_and_notify(application: LeaveApplication) -> None: + """ + Checks if an application has exceeded any SLA thresholds and + triggers notifications/escalations. + """ + # Placeholder for SLA logic diff --git a/modules/elm/tests/__init__.py b/modules/elm/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/elm/tests/test_api_access.py b/modules/elm/tests/test_api_access.py new file mode 100644 index 00000000..8cf23ae2 --- /dev/null +++ b/modules/elm/tests/test_api_access.py @@ -0,0 +1,198 @@ +import pytest +from rest_framework.test import APIClient + +from conftest import make_session +from modules.elm.models.leave_application import LeaveApplication +from modules.elm.models.leave_types import LeaveBalance, LeaveType + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def leave_type_cl(): + return LeaveType.objects.create(code="CL", name="Casual Leave", annual_entitlement=8) + + +@pytest.fixture +def leave_balance_user7(leave_type_cl): + LeaveBalance.objects.create(user_id=7, leave_type=leave_type_cl, year=2026, balance=8) + + +def test_no_module_grant_403(stub_iam): + stub_iam( + make_session(user_id=7, kind="faculty", modules=(), permissions=("elm.leave.view_self",)) + ) + c = APIClient() + c.credentials(HTTP_AUTHORIZATION="Token t") + r = c.get("/api/v1/elm/leave-applications/") + assert r.status_code == 403 + + +def test_user_sees_only_own_applications(stub_iam, leave_type_cl): + # User 500's application + LeaveApplication.objects.create( + user_id=500, + leave_type=leave_type_cl, + status="AWAITING_UNIT_HEAD_REVIEW", + start_date="2026-11-01", + end_date="2026-11-02", + purpose="Test", + ) + # User 7's application + LeaveApplication.objects.create( + user_id=7, + leave_type=leave_type_cl, + status="AWAITING_UNIT_HEAD_REVIEW", + start_date="2026-11-01", + end_date="2026-11-02", + purpose="Test", + ) + stub_iam( + make_session( + user_id=7, + kind="faculty", + modules=("elm",), + permissions=("elm.leave.view_self", "elm.leave.view"), + ) + ) + + c = APIClient() + c.credentials(HTTP_AUTHORIZATION="Token t") + r = c.get("/api/v1/elm/leave-applications/") + results = r.json()["results"] + assert len(results) == 1 + assert results[0]["user_id"] == 7 + + +def test_leave_types_post_delete_405(stub_iam): + stub_iam( + make_session( + user_id=7, + kind="faculty", + modules=("elm",), + permissions=("elm.leave.view_self", "elm.leave.view"), + ) + ) + c = APIClient() + c.credentials(HTTP_AUTHORIZATION="Token t") + + r_post = c.post( + "/api/v1/elm/leave-types/", + {"code": "NEW", "name": "New", "annual_entitlement": 5}, + format="json", + ) + assert r_post.status_code == 405 + + r_delete = c.delete("/api/v1/elm/leave-types/1/") + assert r_delete.status_code == 405 + + +def test_leave_applications_put_patch_delete_405(stub_iam): + stub_iam( + make_session( + user_id=7, kind="faculty", modules=("elm",), permissions=("elm.leave.view_self",) + ) + ) + c = APIClient() + c.credentials(HTTP_AUTHORIZATION="Token t") + + # Test PUT + r_put = c.put("/api/v1/elm/leave-applications/1/", {"status": "APPROVED"}, format="json") + assert r_put.status_code == 405 + + # Test PATCH + r_patch = c.patch("/api/v1/elm/leave-applications/1/", {"status": "APPROVED"}, format="json") + assert r_patch.status_code == 405 + + # Test DELETE + r_delete = c.delete("/api/v1/elm/leave-applications/1/") + assert r_delete.status_code == 405 + + +def test_apply_leave_success(stub_iam, leave_type_cl, leave_balance_user7): + stub_iam( + make_session( + user_id=7, + kind="faculty", + modules=("elm",), + permissions=("elm.leave.view_self", "elm.leave.create"), + ) + ) + c = APIClient() + c.credentials(HTTP_AUTHORIZATION="Token t") + + r = c.post( + "/api/v1/elm/leave-applications/", + { + "leave_type": "CL", + "start_date": "2026-11-02", + "end_date": "2026-11-02", + "purpose": "Test", + "status": "APPROVED", + "user_id": 999, + }, + format="json", + ) + + assert r.status_code == 201 + app_id = r.json()["id"] + app = LeaveApplication.objects.get(id=app_id) + assert app.user_id == 7 + assert app.status == "AWAITING_UNIT_HEAD_REVIEW" + + +def test_record_decision_rules(stub_iam, leave_type_cl): + # Setup + app = LeaveApplication.objects.create( + user_id=500, + leave_type=leave_type_cl, + status="AWAITING_UNIT_HEAD_REVIEW", + start_date="2026-11-01", + end_date="2026-11-02", + purpose="Test", + ) + + # 1. No permission + stub_iam( + make_session( + user_id=7, kind="faculty", modules=("elm",), permissions=("elm.leave.view_self",) + ) + ) + c = APIClient() + c.credentials(HTTP_AUTHORIZATION="Token t") + r = c.post( + "/api/v1/elm/record-decision/", + {"application_id": app.id, "action": "approve"}, + format="json", + ) + assert r.status_code == 403 + + # 2. Approver is applicant + stub_iam( + make_session( + user_id=500, kind="faculty", modules=("elm",), permissions=("elm.leave.approve",) + ) + ) + c.credentials(HTTP_AUTHORIZATION="Token t") + r = c.post( + "/api/v1/elm/record-decision/", + {"application_id": app.id, "action": "approve"}, + format="json", + ) + assert r.status_code == 403 + + # 3. Success + stub_iam( + make_session( + user_id=7, kind="faculty", modules=("elm",), permissions=("elm.leave.approve",) + ) + ) + c.credentials(HTTP_AUTHORIZATION="Token t") + r = c.post( + "/api/v1/elm/record-decision/", + {"application_id": app.id, "action": "approve"}, + format="json", + ) + assert r.status_code == 200 + app.refresh_from_db() + assert app.status == "APPROVED" diff --git a/modules/elm/tests/test_state_machine.py b/modules/elm/tests/test_state_machine.py new file mode 100644 index 00000000..277ba881 --- /dev/null +++ b/modules/elm/tests/test_state_machine.py @@ -0,0 +1,99 @@ +import unittest + +from modules.elm.domain import ( + APPLICANT_ACTION_REQUIRED, + APPROVED, + AWAITING_DIRECTOR_SELF_SANCTION, + AWAITING_ESTABLISHMENT_ROUTING, + AWAITING_FINAL_SANCTION, + AWAITING_SUBSTITUTE_RESPONSE, + AWAITING_UNIT_HEAD_REVIEW, + REJECTED, + WITHDRAWN, + InvalidTransition, + initial_state, + transition, +) + + +class TestStateMachine(unittest.TestCase): + def test_initial_state(self): + self.assertEqual(initial_state(True, False), AWAITING_SUBSTITUTE_RESPONSE) + self.assertEqual(initial_state(False, True), AWAITING_DIRECTOR_SELF_SANCTION) + self.assertEqual(initial_state(False, False), AWAITING_UNIT_HEAD_REVIEW) + + def test_substitute_accept(self): + self.assertEqual( + transition( + AWAITING_SUBSTITUTE_RESPONSE, "substitute_accept", applicant_is_director=True + ), + AWAITING_DIRECTOR_SELF_SANCTION, + ) + self.assertEqual( + transition( + AWAITING_SUBSTITUTE_RESPONSE, "substitute_accept", applicant_is_director=False + ), + AWAITING_UNIT_HEAD_REVIEW, + ) + + def test_substitute_decline(self): + self.assertEqual( + transition(AWAITING_SUBSTITUTE_RESPONSE, "substitute_decline"), + APPLICANT_ACTION_REQUIRED, + ) + + def test_nominate_replacement(self): + self.assertEqual( + transition(APPLICANT_ACTION_REQUIRED, "nominate_replacement"), + AWAITING_SUBSTITUTE_RESPONSE, + ) + + def test_unit_head_approve_reject(self): + self.assertEqual(transition(AWAITING_UNIT_HEAD_REVIEW, "approve", category="CL"), APPROVED) + self.assertEqual(transition(AWAITING_UNIT_HEAD_REVIEW, "reject", category="RH"), REJECTED) + with self.assertRaises(InvalidTransition): + transition(AWAITING_UNIT_HEAD_REVIEW, "approve", category="EL") + + def test_unit_head_recommend(self): + self.assertEqual( + transition( + AWAITING_UNIT_HEAD_REVIEW, "recommend", category="EL", establishment_configured=True + ), + AWAITING_ESTABLISHMENT_ROUTING, + ) + self.assertEqual( + transition( + AWAITING_UNIT_HEAD_REVIEW, + "recommend", + category="EL", + establishment_configured=False, + ), + AWAITING_FINAL_SANCTION, + ) + with self.assertRaises(InvalidTransition): + transition(AWAITING_UNIT_HEAD_REVIEW, "recommend", category="CL") + + def test_route(self): + self.assertEqual( + transition(AWAITING_ESTABLISHMENT_ROUTING, "route"), AWAITING_FINAL_SANCTION + ) + + def test_final_sanction_approve_reject(self): + self.assertEqual(transition(AWAITING_FINAL_SANCTION, "approve"), APPROVED) + self.assertEqual(transition(AWAITING_DIRECTOR_SELF_SANCTION, "reject"), REJECTED) + + def test_withdraw(self): + self.assertEqual(transition(AWAITING_UNIT_HEAD_REVIEW, "withdraw"), WITHDRAWN) + self.assertEqual(transition(AWAITING_FINAL_SANCTION, "withdraw"), WITHDRAWN) + + def test_invalid_transitions(self): + with self.assertRaises(InvalidTransition): + transition(APPROVED, "approve") + with self.assertRaises(InvalidTransition): + transition(WITHDRAWN, "withdraw") + with self.assertRaises(InvalidTransition): + transition(AWAITING_UNIT_HEAD_REVIEW, "route") + + +if __name__ == "__main__": + unittest.main() diff --git a/openapi/fusion-integrated.v1.yaml b/openapi/fusion-integrated.v1.yaml index 850dd24a..57dfc90c 100644 --- a/openapi/fusion-integrated.v1.yaml +++ b/openapi/fusion-integrated.v1.yaml @@ -63,6 +63,201 @@ paths: schema: $ref: '#/components/schemas/UserSearchResult' description: '' + /api/v1/elm/leave-applications/: + get: + operationId: v1_elm_leave_applications_list + parameters: + - name: cursor + required: false + in: query + description: The pagination cursor value. + schema: + type: string + - name: limit + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + tags: + - v1 + security: + - iamSession: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedLeaveApplicationList' + description: '' + post: + operationId: v1_elm_leave_applications_create + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveApplication' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LeaveApplication' + multipart/form-data: + schema: + $ref: '#/components/schemas/LeaveApplication' + required: true + security: + - iamSession: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveApplication' + description: '' + /api/v1/elm/leave-applications/{id}/: + get: + operationId: v1_elm_leave_applications_retrieve + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Leave Application. + required: true + tags: + - v1 + security: + - iamSession: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveApplication' + description: '' + /api/v1/elm/leave-balances/: + get: + operationId: v1_elm_leave_balances_list + parameters: + - name: cursor + required: false + in: query + description: The pagination cursor value. + schema: + type: string + - name: limit + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + tags: + - v1 + security: + - iamSession: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedLeaveBalanceList' + description: '' + /api/v1/elm/leave-balances/{id}/: + get: + operationId: v1_elm_leave_balances_retrieve + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this leave balance. + required: true + tags: + - v1 + security: + - iamSession: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveBalance' + description: '' + /api/v1/elm/leave-types/: + get: + operationId: v1_elm_leave_types_list + parameters: + - name: cursor + required: false + in: query + description: The pagination cursor value. + schema: + type: string + - name: limit + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + tags: + - v1 + security: + - iamSession: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedLeaveTypeList' + description: '' + /api/v1/elm/leave-types/{code}/: + get: + operationId: v1_elm_leave_types_retrieve + parameters: + - in: path + name: code + schema: + type: string + description: A unique value identifying this leave type. + required: true + tags: + - v1 + security: + - iamSession: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveType' + description: '' + /api/v1/elm/record-decision/: + post: + operationId: v1_elm_record_decision_create + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RecordDecisionRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/RecordDecisionRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/RecordDecisionRequest' + required: true + security: + - iamSession: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/LeaveApplication' + description: '' /api/v1/me: get: operationId: v1_me_retrieve @@ -1763,6 +1958,20 @@ components: - semester - semester_type - user_id + ActionEnum: + enum: + - approve + - reject + - recommend + - not_recommend + - route + type: string + description: |- + * `approve` - approve + * `reject` - reject + * `recommend` - recommend + * `not_recommend` - not_recommend + * `route` - route AddCandidates: type: object properties: @@ -2637,6 +2846,141 @@ components: * `in_progress` - Selection in progress * `completed` - Completed * `cancelled` - Cancelled + LeaveApplication: + type: object + properties: + id: + type: integer + readOnly: true + user_id: + type: integer + readOnly: true + leave_type: + type: string + leave_type_name: + type: string + readOnly: true + start_date: + type: string + format: date + end_date: + type: string + format: date + purpose: + type: string + station_leave_details: {} + substitute_id: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + status: + allOf: + - $ref: '#/components/schemas/LeaveApplicationStatusEnum' + readOnly: true + decision_maker_id: + type: integer + readOnly: true + nullable: true + decision_date: + type: string + format: date-time + readOnly: true + nullable: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + unit_head_recommendation: + type: string + readOnly: true + required: + - created_at + - decision_date + - decision_maker_id + - end_date + - id + - leave_type + - leave_type_name + - purpose + - start_date + - status + - unit_head_recommendation + - updated_at + - user_id + LeaveApplicationStatusEnum: + enum: + - AWAITING_SUBSTITUTE_RESPONSE + - APPLICANT_ACTION_REQUIRED + - AWAITING_UNIT_HEAD_REVIEW + - AWAITING_ESTABLISHMENT_ROUTING + - AWAITING_FINAL_SANCTION + - AWAITING_DIRECTOR_SELF_SANCTION + - APPROVED + - REJECTED + - WITHDRAWN + type: string + description: |- + * `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` - Awaiting Final Sanction + * `AWAITING_DIRECTOR_SELF_SANCTION` - Awaiting Director Self Sanction + * `APPROVED` - Approved + * `REJECTED` - Rejected + * `WITHDRAWN` - Withdrawn + LeaveBalance: + type: object + properties: + id: + type: integer + readOnly: true + user_id: + type: integer + maximum: 2147483647 + minimum: -2147483648 + year: + type: integer + maximum: 2147483647 + minimum: -2147483648 + balance: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + leave_type: + type: string + required: + - balance + - id + - leave_type + - user_id + - year + LeaveType: + type: object + properties: + code: + type: string + maxLength: 10 + name: + type: string + maxLength: 50 + annual_entitlement: + type: string + format: decimal + pattern: ^-?\d{0,3}(?:\.\d{0,2})?$ + carry_forward: + type: boolean + continuous_counting: + type: boolean + required: + - annual_entitlement + - code + - name LiftSanction: type: object properties: @@ -2981,6 +3325,63 @@ components: type: array items: $ref: '#/components/schemas/JobPosting' + PaginatedLeaveApplicationList: + type: object + required: + - results + properties: + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?cursor=cD00ODY%3D" + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?cursor=cj0xJnA9NDg3 + results: + type: array + items: + $ref: '#/components/schemas/LeaveApplication' + PaginatedLeaveBalanceList: + type: object + required: + - results + properties: + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?cursor=cD00ODY%3D" + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?cursor=cj0xJnA9NDg3 + results: + type: array + items: + $ref: '#/components/schemas/LeaveBalance' + PaginatedLeaveTypeList: + type: object + required: + - results + properties: + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?cursor=cD00ODY%3D" + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?cursor=cj0xJnA9NDg3 + results: + type: array + items: + $ref: '#/components/schemas/LeaveType' PaginatedOfferList: type: object required: @@ -3220,6 +3621,16 @@ components: - message - rule - sanction + RecordDecisionRequest: + type: object + properties: + application_id: + type: integer + action: + $ref: '#/components/schemas/ActionEnum' + required: + - action + - application_id RecordIncident: type: object properties: diff --git a/registry/permissions.json b/registry/permissions.json index 213c8f24..5ba84569 100644 --- a/registry/permissions.json +++ b/registry/permissions.json @@ -34,6 +34,88 @@ "scope_permissions": [], "system_permissions": [] }, + "elm": { + "grants": { + "Dean": [ + "elm.leave.approve", + "elm.leave.create", + "elm.leave.view", + "elm.leave.view_self" + ], + "Deputy Registrar": [ + "elm.leave.approve", + "elm.leave.create", + "elm.leave.view", + "elm.leave.view_self" + ], + "Director": [ + "elm.leave.approve", + "elm.leave.create", + "elm.leave.view", + "elm.leave.view_self" + ], + "HOD": [ + "elm.leave.approve", + "elm.leave.create", + "elm.leave.view", + "elm.leave.view_self" + ], + "Registrar": [ + "elm.leave.approve", + "elm.leave.create", + "elm.leave.view", + "elm.leave.view_self" + ], + "faculty": [ + "elm.leave.create", + "elm.leave.view", + "elm.leave.view_self" + ], + "staff": [ + "elm.leave.create", + "elm.leave.view", + "elm.leave.view_self" + ] + }, + "module_grants": [ + "Dean", + "Deputy Registrar", + "Director", + "HOD", + "Registrar", + "faculty", + "staff" + ], + "nav": { + "base_path": "/elm", + "icon": "CalendarCheck", + "items": [], + "label": "Leave Management", + "nav_section": "Leave", + "sort_order": 20, + "status": "planned" + }, + "permissions": [ + { + "code": "elm.leave.view", + "label": "See leave categories and own balances" + }, + { + "code": "elm.leave.view_self", + "label": "See own leave applications" + }, + { + "code": "elm.leave.create", + "label": "Apply for leave" + }, + { + "code": "elm.leave.approve", + "label": "Review, recommend or sanction leave requests" + } + ], + "scope_permissions": [], + "system_permissions": [] + }, "placement_cell": { "grants": { "Dean Academic": [