Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ source_modules =
modules.placement
modules.directory
modules.accesscontrol
modules.leave
forbidden_modules =
modules.placement.models
modules.placement.services
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
5 changes: 5 additions & 0 deletions client/src/app/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ export const MODULE_REGISTRY: Record<string, ModuleManifest> = {
basePath: "/placement",
load: () => import("../modules/placement/routes"),
},
leave: {
code: "leave",
basePath: "/leave",
load: () => import("../modules/leave/routes"),
},
};
190 changes: 190 additions & 0 deletions client/src/modules/leave/api/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
export interface Page<T> {
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;
}
10 changes: 8 additions & 2 deletions config/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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}
1 change: 1 addition & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions config/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
}
1 change: 1 addition & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
68 changes: 68 additions & 0 deletions devtools/management/commands/seed_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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,
Expand Down Expand Up @@ -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"))
Empty file added modules/leave/__init__.py
Empty file.
Empty file added modules/leave/api/__init__.py
Empty file.
Loading