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/urls.py b/modules/elm/api/urls.py new file mode 100644 index 00000000..03497aac --- /dev/null +++ b/modules/elm/api/urls.py @@ -0,0 +1,29 @@ +"""URL patterns for Employee Leave Management.""" + +from django.urls import path + +from modules.elm.api.views import ( + DecideLeaveApplicationView, + MyLeaveApplicationsView, + SubmitLeaveApplicationView, +) + +app_name = "elm" + +urlpatterns = [ + path( + "my-leaves/", + MyLeaveApplicationsView.as_view(), + name="my-leaves", + ), + path( + "apply/", + SubmitLeaveApplicationView.as_view(), + name="apply", + ), + path( + "applications//decision/", + DecideLeaveApplicationView.as_view(), + name="decide-application", + ), +] diff --git a/modules/elm/api/views.py b/modules/elm/api/views.py new file mode 100644 index 00000000..cb944a8f --- /dev/null +++ b/modules/elm/api/views.py @@ -0,0 +1,138 @@ +"""API views for Employee Leave Management.""" + +from datetime import date +from uuid import UUID + +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from modules.elm.models import LeaveApplication +from modules.elm.services.applications import ( + LeaveApplicationError, + decide_leave, + submit_leave, +) +from modules.elm.services.authz import require + + +def serialize_application(application): + """Convert a leave application to a JSON-compatible dictionary.""" + return { + "id": str(application.id), + "employee_id": application.employee_id, + "leave_type": application.leave_type, + "start_date": application.start_date.isoformat(), + "end_date": application.end_date.isoformat(), + "reason": application.reason, + "status": application.status, + "approver_id": application.approver_id, + "decision_note": application.decision_note, + "submitted_at": ( + application.submitted_at.isoformat() + if application.submitted_at + else None + ), + "decided_at": ( + application.decided_at.isoformat() + if application.decided_at + else None + ), + } + + +def get_actor(request): + """Return the authenticated principal supplied by the platform.""" + return getattr(request, "principal", None) + + +class MyLeaveApplicationsView(APIView): + """List leave applications belonging to the current employee.""" + + def get(self, request): + actor = get_actor(request) + require(actor, "elm.application.view_self") + + employee_id = str(getattr(actor, "id", "")) + + if not employee_id: + return Response( + {"detail": "Employee identity is unavailable."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + applications = LeaveApplication.objects.filter( + employee_id=employee_id + ) + + return Response( + [serialize_application(item) for item in applications] + ) + + +class SubmitLeaveApplicationView(APIView): + """Submit a leave application.""" + + def post(self, request): + actor = get_actor(request) + require(actor, "elm.application.create") + + data = request.data + + try: + start_date = date.fromisoformat(data.get("start_date", "")) + end_date = date.fromisoformat(data.get("end_date", "")) + + application = submit_leave( + employee_id=str(getattr(actor, "id", "")), + leave_type=str(data.get("leave_type", "")), + start_date=start_date, + end_date=end_date, + reason=str(data.get("reason", "")), + ) + except (ValueError, TypeError, LeaveApplicationError) as exc: + return Response( + {"detail": str(exc)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + serialize_application(application), + status=status.HTTP_201_CREATED, + ) + + +class DecideLeaveApplicationView(APIView): + """Approve or reject a submitted leave application.""" + + def post(self, request, application_id): + actor = get_actor(request) + require(actor, "elm.application.approve") + + try: + UUID(str(application_id)) + decision = request.data.get("decision", "") + if decision not in ("approve", "reject"): + return Response( + {"detail": "Decision must be 'approve' or 'reject'."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + application = decide_leave( + application_id=application_id, + approver_id=str(getattr(actor, "id", "")), + approve=(decision == "approve"), + decision_note=str(request.data.get("decision_note", "")), + ) + except LeaveApplication.DoesNotExist: + return Response( + {"detail": "Leave application not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + except (ValueError, TypeError, LeaveApplicationError) as exc: + return Response( + {"detail": str(exc)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(serialize_application(application)) diff --git a/modules/elm/apps.py b/modules/elm/apps.py new file mode 100644 index 00000000..616e3b67 --- /dev/null +++ b/modules/elm/apps.py @@ -0,0 +1,9 @@ + +from django.apps import AppConfig + + +class ElmConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modules.elm" + label = "elm" + verbose_name = "Employee Leave Management" diff --git a/modules/elm/domain/__init__.py b/modules/elm/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/elm/domain/entities.py b/modules/elm/domain/entities.py new file mode 100644 index 00000000..c908815a --- /dev/null +++ b/modules/elm/domain/entities.py @@ -0,0 +1,42 @@ + +"""Domain types for Employee Leave Management.""" + +from dataclasses import dataclass +from datetime import date +from enum import StrEnum +from uuid import UUID + + +class LeaveStatus(StrEnum): + DRAFT = "draft" + SUBMITTED = "submitted" + PENDING_APPROVAL = "pending_approval" + APPROVED = "approved" + REJECTED = "rejected" + WITHDRAWN = "withdrawn" + CANCELLED = "cancelled" + EXTENSION_REQUESTED = "extension_requested" + RESUMED = "resumed" + + +@dataclass(frozen=True) +class LeavePeriod: + start_date: date + end_date: date + + def __post_init__(self) -> None: + if self.end_date < self.start_date: + raise ValueError("Leave end date cannot be before start date.") + + @property + def calendar_days(self) -> int: + return (self.end_date - self.start_date).days + 1 + + +@dataclass(frozen=True) +class LeaveApplicationSummary: + id: UUID + employee_id: str + leave_type: str + period: LeavePeriod + status: LeaveStatus diff --git a/modules/elm/migrations/0001_initial.py b/modules/elm/migrations/0001_initial.py new file mode 100644 index 00000000..2380678e --- /dev/null +++ b/modules/elm/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.18 on 2026-10-12 13:18 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='LeaveApplication', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('employee_id', models.CharField(db_index=True, max_length=128)), + ('leave_type', models.CharField(max_length=32)), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('reason', models.TextField()), + ('status', models.CharField(choices=[('draft', 'Draft'), ('submitted', 'Submitted'), ('pending_approval', 'Pending Approval'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('withdrawn', 'Withdrawn'), ('cancelled', 'Cancelled'), ('extension_requested', 'Extension Requested'), ('resumed', 'Resumed')], db_index=True, default='draft', max_length=32)), + ('approver_id', models.CharField(blank=True, default='', max_length=128)), + ('decision_note', models.TextField(blank=True, default='')), + ('submitted_at', models.DateTimeField(blank=True, null=True)), + ('decided_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['employee_id', 'status'], name='elm_emp_status_idx'), models.Index(fields=['start_date', 'end_date'], name='elm_leave_dates_idx')], + }, + ), + ] 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..2bc32b54 --- /dev/null +++ b/modules/elm/models/__init__.py @@ -0,0 +1,6 @@ + +"""Database models for Employee Leave Management.""" + +from .leave_application import LeaveApplication + +__all__ = ["LeaveApplication"] diff --git a/modules/elm/models/leave_application.py b/modules/elm/models/leave_application.py new file mode 100644 index 00000000..29a7e5ab --- /dev/null +++ b/modules/elm/models/leave_application.py @@ -0,0 +1,62 @@ + +"""Database model for Employee Leave Management applications.""" + +import uuid + +from django.db import models + + +class LeaveApplication(models.Model): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + SUBMITTED = "submitted", "Submitted" + PENDING_APPROVAL = "pending_approval", "Pending Approval" + APPROVED = "approved", "Approved" + REJECTED = "rejected", "Rejected" + WITHDRAWN = "withdrawn", "Withdrawn" + CANCELLED = "cancelled", "Cancelled" + EXTENSION_REQUESTED = "extension_requested", "Extension Requested" + RESUMED = "resumed", "Resumed" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + employee_id = models.CharField(max_length=128, db_index=True) + leave_type = models.CharField(max_length=32) + start_date = models.DateField() + end_date = models.DateField() + reason = models.TextField() + + status = models.CharField( + max_length=32, + choices=Status.choices, + default=Status.DRAFT, + db_index=True, + ) + + approver_id = models.CharField(max_length=128, blank=True, default="") + decision_note = models.TextField(blank=True, default="") + submitted_at = models.DateTimeField(null=True, blank=True) + decided_at = models.DateTimeField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = "elm" + ordering = ["-created_at"] + indexes = [ + models.Index( + fields=["employee_id", "status"], + name="elm_emp_status_idx", + ), + models.Index( + fields=["start_date", "end_date"], + name="elm_leave_dates_idx", + ), + ] + + def __str__(self): + return ( + f"{self.employee_id}: {self.leave_type} " + f"({self.start_date} to {self.end_date})" + ) diff --git a/modules/elm/registry.py b/modules/elm/registry.py new file mode 100644 index 00000000..16495fba --- /dev/null +++ b/modules/elm/registry.py @@ -0,0 +1,103 @@ + +"""Registry metadata for the Employee Leave Management module.""" + +MODULE = { + "code": "elm", + "label": "Employee Leave Management", + "icon": "Calendar", + "base_path": "/elm", + "nav_section": "Employee Services", + "sort_order": 40, + "status": "planned", +} + +NAV_ITEMS = [ + { + "code": "elm.my_leaves", + "label": "My Leave Applications", + "icon": "Calendar", + "to": "/elm/my-leaves", + "required_permission": "elm.application.view_self", + "sort_order": 10, + }, + { + "code": "elm.apply", + "label": "Apply for Leave", + "icon": "Plus", + "to": "/elm/apply", + "required_permission": "elm.application.create", + "sort_order": 20, + }, + { + "code": "elm.approvals", + "label": "Leave Approvals", + "icon": "ClipboardCheck", + "to": "/elm/approvals", + "required_permission": "elm.application.approve", + "sort_order": 30, + }, + { + "code": "elm.leave_calendar", + "label": "Leave Calendar", + "icon": "CalendarDays", + "to": "/elm/calendar", + "required_permission": "elm.calendar.view", + "sort_order": 40, + }, + { + "code": "elm.administration", + "label": "Leave Administration", + "icon": "Settings", + "to": "/elm/admin", + "required_permission": "elm.leave_type.manage", + "sort_order": 50, + }, +] + +PERMISSIONS = [ + ("elm.application.view_self", "View own leave applications"), + ("elm.application.create", "Submit leave applications"), + ("elm.application.withdraw", "Withdraw eligible leave applications"), + ("elm.application.view", "View leave applications for approval"), + ("elm.application.approve", "Approve or reject leave applications"), + ("elm.application.forward", "Forward leave applications"), + ("elm.application.cancel", "Cancel approved leave"), + ("elm.application.extend", "Request leave extension"), + ("elm.application.resume", "Record return from leave"), + ("elm.calendar.view", "View the leave calendar"), + ("elm.calendar.manage", "Manage leave calendar entries"), + ("elm.leave_type.manage", "Manage leave types and entitlements"), + ("elm.report.view", "View leave reports"), +] + +SYSTEM_PERMISSIONS = [] + +ROLE_GRANTS = { + "Employee": [ + "elm.application.view_self", + "elm.application.create", + "elm.application.withdraw", + "elm.application.cancel", + "elm.application.extend", + "elm.application.resume", + "elm.calendar.view", + ], + "Leave Approver": [ + "elm.application.view", + "elm.application.approve", + "elm.application.forward", + "elm.calendar.view", + ], + "Leave Administrator": [ + "elm.application.view", + "elm.application.approve", + "elm.application.forward", + "elm.application.cancel", + "elm.application.extend", + "elm.application.resume", + "elm.calendar.view", + "elm.calendar.manage", + "elm.leave_type.manage", + "elm.report.view", + ], +} diff --git a/modules/elm/selectors/__init__.py b/modules/elm/selectors/__init__.py new file mode 100644 index 00000000..e69de29b 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/applications.py b/modules/elm/services/applications.py new file mode 100644 index 00000000..439afa41 --- /dev/null +++ b/modules/elm/services/applications.py @@ -0,0 +1,87 @@ + +"""Application operations for Employee Leave Management.""" + +from django.db import transaction +from django.utils import timezone + +from modules.elm.models import LeaveApplication + + +class LeaveApplicationError(Exception): + """Raised when a leave application cannot be processed.""" + + +@transaction.atomic +def submit_leave( + *, + employee_id: str, + leave_type: str, + start_date, + end_date, + reason: str, +) -> LeaveApplication: + """Create and submit a leave application.""" + if not employee_id.strip(): + raise LeaveApplicationError("Employee ID is required.") + + if not leave_type.strip(): + raise LeaveApplicationError("Leave type is required.") + + if end_date < start_date: + raise LeaveApplicationError("End date cannot precede start date.") + + if not reason.strip(): + raise LeaveApplicationError("A reason for leave is required.") + + return LeaveApplication.objects.create( + employee_id=employee_id, + leave_type=leave_type, + start_date=start_date, + end_date=end_date, + reason=reason.strip(), + status=LeaveApplication.Status.SUBMITTED, + submitted_at=timezone.now(), + ) + + + +@transaction.atomic +def decide_leave( + *, + application_id, + approver_id: str, + approve: bool, + decision_note: str = "", +) -> LeaveApplication: + """Record an approval or rejection decision.""" + application = LeaveApplication.objects.select_for_update().get( + pk=application_id + ) + + if application.status != LeaveApplication.Status.SUBMITTED: + raise LeaveApplicationError( + "Only submitted applications can receive a decision." + ) + + if not approver_id.strip(): + raise LeaveApplicationError("Approver ID is required.") + + application.approver_id = approver_id + application.decision_note = decision_note.strip() + application.status = ( + LeaveApplication.Status.APPROVED + if approve + else LeaveApplication.Status.REJECTED + ) + application.decided_at = timezone.now() + application.save( + update_fields=[ + "approver_id", + "decision_note", + "status", + "decided_at", + "updated_at", + ] + ) + + return application diff --git a/modules/elm/services/authz.py b/modules/elm/services/authz.py new file mode 100644 index 00000000..042b48c0 --- /dev/null +++ b/modules/elm/services/authz.py @@ -0,0 +1,21 @@ + +"""Authorization helpers for Employee Leave Management.""" + +from core.api.exceptions import PermissionDeniedError + + +def require(actor, *codes: str) -> None: + """Require an authenticated actor with at least one ELM permission.""" + 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(code) for code in codes): + raise PermissionDeniedError( + f"This action requires one of: {', '.join(codes)}.", + code="permission_denied", + ) 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_applications.py b/modules/elm/tests/test_applications.py new file mode 100644 index 00000000..a05a1563 --- /dev/null +++ b/modules/elm/tests/test_applications.py @@ -0,0 +1,63 @@ +"""Tests for Employee Leave Management application services.""" + +from datetime import date + +import pytest + +from modules.elm.models import LeaveApplication +from modules.elm.services.applications import ( + LeaveApplicationError, + decide_leave, + submit_leave, +) + + +@pytest.mark.django_db +def test_submit_leave_creates_submitted_application(): + application = submit_leave( + employee_id="employee-001", + leave_type="CL", + start_date=date(2026, 11, 2), + end_date=date(2026, 11, 3), + reason="Personal work", + ) + + assert application.employee_id == "employee-001" + assert application.leave_type == "CL" + assert application.status == LeaveApplication.Status.SUBMITTED + assert application.submitted_at is not None + + +@pytest.mark.django_db +def test_submit_leave_rejects_invalid_date_range(): + with pytest.raises(LeaveApplicationError): + submit_leave( + employee_id="employee-001", + leave_type="CL", + start_date=date(2026, 11, 4), + end_date=date(2026, 11, 2), + reason="Personal work", + ) + + +@pytest.mark.django_db +def test_decide_leave_approves_submitted_application(): + application = submit_leave( + employee_id="employee-001", + leave_type="CL", + start_date=date(2026, 11, 2), + end_date=date(2026, 11, 2), + reason="Personal work", + ) + + decided = decide_leave( + application_id=application.id, + approver_id="approver-001", + approve=True, + decision_note="Approved", + ) + + assert decided.status == LeaveApplication.Status.APPROVED + assert decided.approver_id == "approver-001" + assert decided.decision_note == "Approved" + assert decided.decided_at is not None