diff --git a/applications/vms/Designated_Roles.md b/applications/vms/Designated_Roles.md new file mode 100644 index 000000000..95dcd0fea --- /dev/null +++ b/applications/vms/Designated_Roles.md @@ -0,0 +1,195 @@ +# Module Name: Visitor Management System (VMS) + +This document lists the user roles recognised by the VMS module, the +permissions each role holds, and the concrete enforcement points in the +codebase. Role levels are stored on the `HostAuthority` model +(`applications/vms/models.py`) and enforced by the permission classes in +`applications/vms/permissions.py`. + +## Designated User Roles & Permissions + +### 1. Role Name: Super Admin +* **Description:** Top-level administrator responsible for VMS system + configuration, feature toggles, and policy decisions. Backed by + `HostAuthority.LEVEL_SUPER` and the `IsSuperAdmin` permission class. +* **Permissions:** + * Create, read, update system configuration entries (`SystemConfig`) + and view the full `ConfigChangeLog` audit trail (BR-057–BR-062). + * All capabilities of the VMS Admin role (inherits downward). + * Assign / upgrade authority levels on `HostAuthority` records. + * Approve high-severity escalations that require system-wide acknowledgement. +* **Enforced at:** + * `SystemConfigView`, `ConfigChangeHistoryView` (`api/views.py`) — + `permission_classes = [IsAuthenticated, IsSuperAdmin]`. + +--- + +### 2. Role Name: VMS Admin +* **Description:** Module administrator responsible for blacklist management, + reporting, data export/import, access-zone configuration, and visiting + hours. Backed by `HostAuthority.LEVEL_ADMIN` (or `LEVEL_SUPER` which + inherits) and the `IsVmsAdmin` / `CanRemoveBlacklist` permission classes. +* **Permissions:** + * Full CRUD on `BlacklistEntry` (add, remove, list, audit). + * Generate visitor / incident / VIP / audit reports (BR-025–BR-029). + * Export and import visitor data (BR-067–BR-074). + * Configure visiting hours (`VisitingHours`) and access zones + (`AccessZone`) (BR-063, BR-064). + * View full visitor history (PII-bearing) and incident history for any + visitor (BR-030, BR-031). + * View blacklist audit trail. +* **Enforced at:** + * `BlacklistView.post`, `VisitingHoursView.post`, `AccessZoneView.post` + — gated via `get_permissions()` → `[IsAuthenticated, IsVmsAdmin]`. + * `BlacklistRemoveView` — `[IsAuthenticated, CanRemoveBlacklist]`. + * `ReportGenerationView`, `DataExportView`, `DataImportView`, + `DataOperationsView`, `VisitorHistoryView`, + `VisitorHistoryByVisitView`, `IncidentHistoryView`, + `BlacklistAuditView` — `[IsAuthenticated, IsVmsAdmin]`. + * `VIPProcessView`, `VIPVisitorsView`, `VIPActivityView` — + `[IsAuthenticated, IsVmsAdmin]`. Admin-only because VIP flagging + changes pass validity and triggers notifications. + * `EscortAssignView`, `EscortReleaseView`, `AvailableEscortsView` — + moved down to `[IsAuthenticated]` (Security Staff tier) so gate + officers can assign / release escorts directly when a high-level + VIP arrives (BR-046). See Security Staff permissions below. + +--- + +### 3. Role Name: Security Supervisor / Host with Approval Authority +* **Description:** Faculty or departmental host with authority to approve + incoming visit requests, and (optionally) to bypass the standard VIP + verification flow for pre-cleared guests. Backed by + `HostAuthority.LEVEL_DEPARTMENT` or above, with + `can_approve_vip = True` for the VIP bypass capability. +* **Permissions:** + * Approve visitor requests targeting the host or the host's department + (BR-011). + * Bypass standard verification for recognised VIP visitors (BR-043) + when `can_approve_vip` is set on the user's `HostAuthority` row. + * Request escort assignments for high-profile visits (BR-046). + * View visits where they are the named host. +* **Enforced at:** + * `HasHostApprovalAuthority` permission class — defined but currently + unused pending a dedicated approval endpoint. + * `VIPProcessView.post` — base gate is `IsVmsAdmin`. When the request + body carries `bypass_approval: true`, the inline check additionally + requires `CanBypassVIPApproval` (the `can_approve_vip` flag); + otherwise `PermissionDenied` is raised. + +--- + +### 4. Role Name: Security Staff / Gate Officer +* **Description:** Standard operational user at a gate or checkpoint. + Executes the day-to-day visitor workflow: register, verify, issue pass, + record movement, deny entry, scan passes, log incidents. + Backed by any authenticated Fusion user (throttled) — a dedicated + `SecurityStaffRole` table is a planned follow-up. +* **Permissions:** + * Register new visitors (BR-001–BR-007) — rate-limited via + `VmsRegisterThrottle` (30 req/min/user). + * Verify visitor ID manually or biometrically (BR-008–BR-012). + * Issue a visitor pass and generate its QR code (BR-013–BR-017). + * Record entry and exit movements at gates (BR-018–BR-024). + * Scan visitor passes at checkpoints and perform manual fallback + verification (BR-019–BR-023). + * Deny entry with reason codes and escalate if required. + * Log security incidents (any severity) against the current visit or + as a standalone checkpoint incident. Incident logging lives on the + Security Staff console (`VmsStaffPage`) — gate officers are the + ones who witness incidents, so creation is a staff-tier action. + * View active visitors, recent visits, and incident feed + (list responses use `VisitorPublicSerializer`, which masks + `id_number` and omits contact fields). + * Assign and release escort personnel for VIP visits (BR-046). + Escort coordination is a gate-side action: the service-layer rule + in `services.assign_escort` requires `visit.is_vip = True`, so + Staff cannot attach an escort to a non-VIP visit. The numeric + `escort_threshold` config remains the *auto*-escort trigger + inside `process_vip_visit` (admin-initiated). +* **Enforced at:** + * `RegisterVisitorView`, `VerifyVisitorView`, `IssuePassView`, + `RecordEntryView`, `RecordExitView`, `DenyEntryView`, + `ActiveVisitorsView`, `RecentVisitsView`, `ScanPassView`, + `ManualVerificationView`, `SecurityIncidentView` — all require + `IsAuthenticated` only (the baseline role for this tier). + * `EscortAssignView`, `EscortReleaseView`, `AvailableEscortsView` + — `[IsAuthenticated]`. Business-rule enforcement + (`vip_level >= escort_threshold`, availability check, no + duplicate active assignment) is handled in + `services.assign_escort`. + +--- + +### 5. Role Name: Host (Faculty / Department Member) +* **Description:** Any Fusion user who may be named as the host of an + incoming visit. Holds a `HostAuthority` record at `LEVEL_BASIC` + (default). Hosts do not invoke the gate workflow themselves; they + receive notifications and confirmations. +* **Permissions:** + * Receive notifications when a visitor is registered against them + (BR-006, notifications pipeline). + * View visits where they are the named host. + * Cannot approve visits at the department level without being + upgraded to `LEVEL_DEPARTMENT` or above. +* **Enforced at:** + * `notifications.notify_host_visitor_request()` (called from + `services.register_visitor`). + * Read-only visibility is scoped by `host_name` / `host_contact` + fields on the `Visit` model. + +--- + +### 6. Role Name: Visitor / End-User +* **Description:** The external individual visiting the campus. Visitors + do not log into Fusion; they are registered at the gate by Security + Staff and interact with the system only via a printed / issued + `VisitorPass`. +* **Permissions:** + * No direct API access. + * May present a `VisitorPass` (QR or pass number) at any configured + checkpoint. The QR payload contains only the opaque + `pass_number`, `visit_id`, and `valid_until` — visitor identity + is resolved server-side on scan so that a consumer QR reader + cannot harvest PII from a printed pass. + * Identity and contact fields are stored under the `Visitor` model + and accessed only by Security Staff or VMS Admin roles above. +* **Enforced at:** + * `ScanPassView` — accepts the pass, looks up the `Visit` + server-side, and validates authenticity, expiry, and zone access + via `permissions.check_zone_access`. + +--- + +## Role-to-Permission Matrix (Summary) + +| Capability | Visitor | Host | Security Staff | Security Supervisor | VMS Admin | Super Admin | +|---|:---:|:---:|:---:|:---:|:---:|:---:| +| Present pass at checkpoint | Yes | — | — | — | — | — | +| Receive host notifications | — | Yes | — | Yes | Yes | Yes | +| Register / verify / issue pass / entry / exit | — | — | Yes | Yes | Yes | Yes | +| Scan pass / log incident | — | — | Yes | Yes | Yes | Yes | +| Assign / release VIP escort | — | — | Yes | Yes | Yes | Yes | +| Approve visit request | — | — | — | Yes | Yes | Yes | +| VIP approval bypass | — | — | — | Yes* | Yes | Yes | +| Add to blacklist | — | — | — | — | Yes | Yes | +| Remove from blacklist | — | — | — | — | Yes | Yes | +| View visitor history (full PII) | — | — | — | — | Yes | Yes | +| Generate reports / export / import | — | — | — | — | Yes | Yes | +| Configure visiting hours / access zones | — | — | — | — | Yes | Yes | +| System configuration (`SystemConfig`) | — | — | — | — | — | Yes | + +\* Security Supervisors require `HostAuthority.can_approve_vip = True`. + +--- + +## Implementation References + +| Concern | File | +|---|---| +| Role storage | `applications/vms/models.py` (`HostAuthority`) | +| Permission classes | `applications/vms/permissions.py` | +| View-level enforcement | `applications/vms/api/views.py` | +| List-response PII masking | `applications/vms/api/serializers.py` (`VisitorPublicSerializer`) | +| Pass QR minimisation | `applications/vms/services.py` (`_generate_pass_qr`) | +| Throttling | `applications/vms/api/views.py` (`VmsRegisterThrottle`) | diff --git a/applications/vms/INTEGFRATIONS.md b/applications/vms/INTEGFRATIONS.md new file mode 100644 index 000000000..d2fa53209 --- /dev/null +++ b/applications/vms/INTEGFRATIONS.md @@ -0,0 +1,18 @@ +VMS Integration Map (concise) + +Globals: Uses applications.globals.models.ExtraInfo to tie actions to staff; reuse Departments/Designations for role-based permissions. +Notifications: Hook VMS events (blacklist hit, denial, overstay, incident) into notification/notifications for alerts to supervisors/admins and hosts. +Filetracking: For escalations or incident reports, auto-create tracking files so investigations follow an auditable workflow. +HR2 / Recruitment: Onboard/offboard security staff; synchronize User/ExtraInfo and role assignments for VMS operators. +Academic Information / Department: Resolve host departments when registering visits; validate host users/contacts against authoritative staff/faculty records. +Inventory / Estate / Central Mess / Hostel / Library: Use authorized zones to mirror physical areas; deny entry if zone doesn’t match module-managed access policies. +Gymkhana / Event Modules: Pre-register event visitors and apply VIP/fast-track rules; monitor crowd limits via VMS active/inside counts. +Health Center: Flag health-related restrictions or escort requirements; log incidents involving medical emergencies. +Security/Compliance Reporting: Feed VMS movement and incident logs into reporting modules (or scheduled jobs) for audits, overstays, and hotspot analysis. +How to extend + +Emit signals/webhooks from VMS events to notification and reporting modules. +Add DRF endpoints to fetch authorized zones from estate/inventory. +Add host lookup autocomplete that queries faculty/staff (globals/academic_information). +Create scheduled tasks to flag unclosed visits and push alerts via notifications. +Integrate incident creation with filetracking so high-severity issues open investigation files automatically. \ No newline at end of file diff --git a/applications/vms/__init__.py b/applications/vms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/applications/vms/admin.py b/applications/vms/admin.py new file mode 100644 index 000000000..65ad95b7a --- /dev/null +++ b/applications/vms/admin.py @@ -0,0 +1,45 @@ +from django.contrib import admin + +from .models import ( + AccessZone, + BlacklistAuditLog, + BlacklistEntry, + ConfigChangeLog, + DataOperationLog, + DenialLog, + EntryExitLog, + EscortAssignment, + HostAuthority, + RegistrationQuota, + SecurityIncident, + SystemConfig, + VIPActivityLog, + VerificationLog, + Visit, + Visitor, + VisitorLocation, + VisitorPass, + VisitorRequest, + VisitingHours, +) + +admin.site.register(Visitor) +admin.site.register(BlacklistEntry) +admin.site.register(Visit) +admin.site.register(VisitorPass) +admin.site.register(VerificationLog) +admin.site.register(DenialLog) +admin.site.register(EntryExitLog) +admin.site.register(SecurityIncident) +admin.site.register(VisitorRequest) +admin.site.register(HostAuthority) +admin.site.register(RegistrationQuota) +admin.site.register(AccessZone) +admin.site.register(VisitorLocation) +admin.site.register(BlacklistAuditLog) +admin.site.register(EscortAssignment) +admin.site.register(VIPActivityLog) +admin.site.register(SystemConfig) +admin.site.register(ConfigChangeLog) +admin.site.register(VisitingHours) +admin.site.register(DataOperationLog) diff --git a/applications/vms/api/__init__.py b/applications/vms/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/applications/vms/api/serializers.py b/applications/vms/api/serializers.py new file mode 100644 index 000000000..32289f12a --- /dev/null +++ b/applications/vms/api/serializers.py @@ -0,0 +1,371 @@ +from datetime import date + +from rest_framework import serializers + +from ..models import ( + AccessZone, + BlacklistAuditLog, + BlacklistEntry, + ConfigChangeLog, + DataOperationLog, + DenialLog, + EntryExitLog, + EscortAssignment, + HostAuthority, + RegistrationQuota, + SecurityIncident, + SystemConfig, + VIPActivityLog, + VerificationLog, + Visit, + Visitor, + VisitorLocation, + VisitorPass, + VisitorRequest, + VisitingHours, +) + + +class VisitorSerializer(serializers.ModelSerializer): + """Full visitor record. Use only for admin endpoints (visitor history, etc.).""" + + class Meta: + model = Visitor + fields = "__all__" + + +def _mask_id_number(value: str) -> str: + """Return only the last 4 chars of an ID, masking the rest.""" + if not value: + return "" + if len(value) <= 4: + return "*" * len(value) + return f"{'*' * (len(value) - 4)}{value[-4:]}" + + +class VisitorPublicSerializer(serializers.ModelSerializer): + """Slimmed visitor view for list endpoints — no raw PII.""" + + id_number = serializers.SerializerMethodField() + + class Meta: + model = Visitor + fields = ("id", "full_name", "id_type", "id_number") + + def get_id_number(self, obj): + return _mask_id_number(obj.id_number) + + +class VisitSerializer(serializers.ModelSerializer): + visitor = VisitorPublicSerializer() + gate_name = serializers.SerializerMethodField() + authorized_zones = serializers.SerializerMethodField() + + class Meta: + model = Visit + fields = "__all__" + + def get_gate_name(self, obj): + last_log = obj.movement_logs.order_by("-created_at").first() + return last_log.gate_name if last_log else "" + + def get_authorized_zones(self, obj): + try: + return obj.visitor_pass.authorized_zones + except VisitorPass.DoesNotExist: + return "" + + +class VisitorPassSerializer(serializers.ModelSerializer): + """Pass record without the raw QR data URI — fetched separately on issue.""" + + class Meta: + model = VisitorPass + exclude = ("barcode_data",) + + +class VerificationLogSerializer(serializers.ModelSerializer): + class Meta: + model = VerificationLog + fields = "__all__" + + +class DenialLogSerializer(serializers.ModelSerializer): + class Meta: + model = DenialLog + fields = "__all__" + + +class EntryExitLogSerializer(serializers.ModelSerializer): + class Meta: + model = EntryExitLog + fields = "__all__" + + +class SecurityIncidentSerializer(serializers.ModelSerializer): + class Meta: + model = SecurityIncident + fields = "__all__" + + +class RegisterVisitorSerializer(serializers.Serializer): + full_name = serializers.CharField() + id_number = serializers.CharField() + id_type = serializers.ChoiceField(choices=Visitor.ID_TYPES) + contact_phone = serializers.CharField() + contact_email = serializers.EmailField(required=False, allow_blank=True) + photo_reference = serializers.CharField(required=False, allow_blank=True) + purpose = serializers.CharField() + host_name = serializers.CharField() + host_department = serializers.CharField() + host_contact = serializers.CharField(required=False, allow_blank=True) + expected_duration_minutes = serializers.IntegerField(min_value=5, default=60) + is_vip = serializers.BooleanField(default=False) + # BR-046: optional numeric VIP level at registration time so Staff can + # flag a visit as escort-eligible (>= configured escort_threshold) without + # a separate VIP-process step. Defaults to 0 (non-VIP). + vip_level = serializers.IntegerField( + required=False, min_value=0, max_value=10, default=0 + ) + + +class VerifyVisitorSerializer(serializers.Serializer): + visit_id = serializers.IntegerField() + method = serializers.ChoiceField(choices=VerificationLog.METHODS, default=VerificationLog.METHOD_MANUAL) + result = serializers.BooleanField() + notes = serializers.CharField(required=False, allow_blank=True) + + +class IssuePassSerializer(serializers.Serializer): + visit_id = serializers.IntegerField() + authorized_zones = serializers.CharField(default="public") + + +class RecordMovementSerializer(serializers.Serializer): + visit_id = serializers.IntegerField() + gate_name = serializers.CharField() + items_declared = serializers.CharField(required=False, allow_blank=True) + + +class DenyEntrySerializer(serializers.Serializer): + visit_id = serializers.IntegerField() + reason = serializers.CharField() + remarks = serializers.CharField(required=False, allow_blank=True) + escalated = serializers.BooleanField(default=False) + + +class SecurityIncidentCreateSerializer(serializers.Serializer): + visit_id = serializers.IntegerField(required=False) + visitor_id = serializers.IntegerField(required=False) + severity = serializers.ChoiceField(choices=SecurityIncident.SEVERITIES) + issue_type = serializers.ChoiceField(choices=SecurityIncident.ISSUE_TYPES) + description = serializers.CharField() + + +# ============================================================================ +# New serializers for missing BR / UC / WF coverage +# ============================================================================ + +# --- Pass scan (BR-019/020/021) --- +class ScanPassSerializer(serializers.Serializer): + visit_id = serializers.IntegerField() + checkpoint_name = serializers.CharField() + zone_name = serializers.CharField(required=False, allow_blank=True) + items_declared = serializers.CharField(required=False, allow_blank=True) + + +class ScanResultSerializer(serializers.Serializer): + valid = serializers.BooleanField() + pass_number = serializers.CharField() + visitor = serializers.CharField() + zones = serializers.CharField() + valid_until = serializers.CharField() + + +# --- Manual verification (BR-023) --- +class ManualVerificationSerializer(serializers.Serializer): + visit_id = serializers.IntegerField() + result = serializers.BooleanField(default=True) + notes = serializers.CharField(required=False, allow_blank=True) + + +# --- Blacklist management (BR-030–035) --- +class BlacklistCreateSerializer(serializers.Serializer): + # Accept either the raw id_number OR a visit_id that resolves to one. + # Exactly one of the two must be supplied. + id_number = serializers.CharField(required=False, allow_blank=True) + visit_id = serializers.IntegerField(required=False, allow_null=True) + reason = serializers.CharField() + evidence = serializers.CharField(required=False, allow_blank=True) + + def validate(self, attrs): + id_number = (attrs.get("id_number") or "").strip() + visit_id = attrs.get("visit_id") + if not id_number and not visit_id: + raise serializers.ValidationError( + "Provide either id_number or visit_id." + ) + if visit_id and not id_number: + from ..models import Visit + try: + visit = Visit.objects.select_related("visitor").get(id=visit_id) + except Visit.DoesNotExist as exc: + raise serializers.ValidationError( + f"Visit id={visit_id} not found." + ) from exc + attrs["id_number"] = visit.visitor.id_number + else: + attrs["id_number"] = id_number + return attrs + + +class BlacklistEntrySerializer(serializers.ModelSerializer): + class Meta: + model = BlacklistEntry + fields = "__all__" + + +class BlacklistAuditLogSerializer(serializers.ModelSerializer): + class Meta: + model = BlacklistAuditLog + fields = "__all__" + + +# --- Visitor history (BR-030/031) --- +class VisitorHistorySerializer(serializers.Serializer): + visitor = VisitorSerializer() + visits = VisitSerializer(many=True) + incidents = SecurityIncidentSerializer(many=True) + blacklist_entries = BlacklistEntrySerializer(many=True) + + +# --- VIP processing (BR-041–047) --- +class VIPProcessSerializer(serializers.Serializer): + visit_id = serializers.IntegerField() + bypass_approval = serializers.BooleanField(default=False) + # BR-046: numeric VIP level drives escort auto-assignment. + vip_level = serializers.IntegerField( + required=False, min_value=0, max_value=10 + ) + # Optional specific escort for auto-assignment at VIP processing time. + escort_id = serializers.IntegerField(required=False, allow_null=True) + + +class EscortAssignSerializer(serializers.Serializer): + visit_id = serializers.IntegerField() + # BR-046: optional — when omitted the service auto-picks an available + # qualified escort. + escort_id = serializers.IntegerField(required=False, allow_null=True) + notes = serializers.CharField(required=False, allow_blank=True) + + +class EscortAssignmentSerializer(serializers.ModelSerializer): + class Meta: + model = EscortAssignment + fields = "__all__" + + +class VIPActivityLogSerializer(serializers.ModelSerializer): + class Meta: + model = VIPActivityLog + fields = "__all__" + + +# --- Report generation (BR-025–029) --- +class ReportRequestSerializer(serializers.Serializer): + report_type = serializers.ChoiceField( + choices=["visitor_summary", "incident_summary", "access_log", "vip_report", "full_audit"] + ) + start_date = serializers.DateField() + end_date = serializers.DateField() + + +# --- System config (BR-057–066) --- +class SystemConfigSerializer(serializers.ModelSerializer): + class Meta: + model = SystemConfig + fields = "__all__" + + +class ConfigUpdateSerializer(serializers.Serializer): + key = serializers.CharField() + value = serializers.CharField() + description = serializers.CharField(required=False, allow_blank=True) + + +class ConfigChangeLogSerializer(serializers.ModelSerializer): + class Meta: + model = ConfigChangeLog + fields = "__all__" + + +# --- Visiting hours (BR-063) --- +class VisitingHoursSerializer(serializers.ModelSerializer): + class Meta: + model = VisitingHours + fields = "__all__" + + +class VisitingHoursCreateSerializer(serializers.Serializer): + day_of_week = serializers.IntegerField(min_value=0, max_value=6) + start_time = serializers.TimeField() + end_time = serializers.TimeField() + is_holiday = serializers.BooleanField(default=False) + holiday_name = serializers.CharField(required=False, allow_blank=True) + active = serializers.BooleanField(default=True) + + +# --- Access zones (BR-064) --- +class AccessZoneSerializer(serializers.ModelSerializer): + class Meta: + model = AccessZone + fields = "__all__" + + +class AccessZoneCreateSerializer(serializers.Serializer): + name = serializers.CharField() + description = serializers.CharField(required=False, allow_blank=True) + requires_vip = serializers.BooleanField(default=False) + requires_escort = serializers.BooleanField(default=False) + is_restricted = serializers.BooleanField(default=False) + active = serializers.BooleanField(default=True) + + +# --- Location tracking (BR-036–040) --- +class VisitorLocationSerializer(serializers.ModelSerializer): + class Meta: + model = VisitorLocation + fields = "__all__" + + +# --- Data export/import (BR-067–074) --- +class DataExportSerializer(serializers.Serializer): + format = serializers.ChoiceField(choices=["csv", "json", "xlsx"], default="csv") + start_date = serializers.DateField(required=False) + end_date = serializers.DateField(required=False) + + +class DataImportSerializer(serializers.Serializer): + format = serializers.ChoiceField(choices=["csv", "json"], default="csv") + data_content = serializers.CharField() + field_mapping = serializers.DictField(child=serializers.CharField()) + + +class DataOperationLogSerializer(serializers.ModelSerializer): + class Meta: + model = DataOperationLog + fields = "__all__" + + +# --- Request ID (BR-005) --- +class VisitorRequestSerializer(serializers.ModelSerializer): + class Meta: + model = VisitorRequest + fields = "__all__" + + +# --- Host authority (BR-011) --- +class HostAuthoritySerializer(serializers.ModelSerializer): + class Meta: + model = HostAuthority + fields = "__all__" diff --git a/applications/vms/api/urls.py b/applications/vms/api/urls.py new file mode 100644 index 000000000..f8690d002 --- /dev/null +++ b/applications/vms/api/urls.py @@ -0,0 +1,118 @@ +from django.urls import path + +from .views import ( + # Original endpoints + ActiveVisitorsView, + DenyEntryView, + IssuePassView, + RecentVisitsView, + RecordEntryView, + RecordExitView, + RegisterVisitorView, + SecurityIncidentView, + VerifyVisitorView, + # BR-019/020/021: Pass scan + ScanPassView, + # BR-023: Manual verification + ManualVerificationView, + # BR-024: Overstay + OverstayView, + # BR-025–029: Reports + ReportGenerationView, + # BR-030–035: Blacklist management + BlacklistView, + BlacklistRemoveView, + BlacklistAuditView, + # BR-030/031: Visitor/incident history + VisitorHistoryView, + VisitorHistoryByVisitView, + IncidentHistoryView, + # Who-am-I (role lookup for the UI) + WhoAmIView, + # BR-036–040: Location tracking + VisitorLocationView, + # BR-041–047: VIP processing + VIPProcessView, + VIPVisitorsView, + VIPActivityView, + EscortAssignView, + EscortReleaseView, + AvailableEscortsView, + # BR-057–066: System config + SystemConfigView, + ConfigChangeHistoryView, + # BR-063: Visiting hours + VisitingHoursView, + # BR-064: Access zones + AccessZoneView, + # BR-067–074: Data export/import + DataExportView, + DataImportView, + DataOperationsView, +) + +app_name = "vms" + +urlpatterns = [ + # --- Role lookup (no VMS admin needed — any authenticated user) --- + path("me/", WhoAmIView.as_view(), name="whoami"), + + # --- Core visitor workflow (WF-001, WF-003) --- + path("register/", RegisterVisitorView.as_view(), name="register"), + path("verify/", VerifyVisitorView.as_view(), name="verify"), + path("pass/", IssuePassView.as_view(), name="issue-pass"), + path("entry/", RecordEntryView.as_view(), name="record-entry"), + path("exit/", RecordExitView.as_view(), name="record-exit"), + path("deny/", DenyEntryView.as_view(), name="deny-entry"), + path("active/", ActiveVisitorsView.as_view(), name="active"), + path("recent/", RecentVisitsView.as_view(), name="recent"), + + # --- Pass scan & validation (WF-003) --- + path("scan/", ScanPassView.as_view(), name="scan-pass"), + path("manual-check/", ManualVerificationView.as_view(), name="manual-check"), + + # --- Incidents (WF-008) --- + path("incidents/", SecurityIncidentView.as_view(), name="incidents"), + + # --- Overstay detection (BR-024) --- + path("overstay/", OverstayView.as_view(), name="overstay"), + + # --- Reports (WF-004) --- + path("reports/", ReportGenerationView.as_view(), name="reports"), + + # --- Blacklist management (WF-005) --- + path("blacklist/", BlacklistView.as_view(), name="blacklist"), + path("blacklist//remove/", BlacklistRemoveView.as_view(), name="blacklist-remove"), + path("blacklist/audit//", BlacklistAuditView.as_view(), name="blacklist-audit"), + + # --- Visitor / incident history (BR-030/031) --- + path("history//", VisitorHistoryView.as_view(), name="visitor-history"), + path("history//incidents/", IncidentHistoryView.as_view(), name="incident-history"), + path("visit-history//", VisitorHistoryByVisitView.as_view(), name="visit-history"), + + # --- Location tracking (WF-006) --- + path("location//", VisitorLocationView.as_view(), name="visitor-location"), + + # --- VIP processing (WF-007) --- + path("vip/process/", VIPProcessView.as_view(), name="vip-process"), + path("vip/", VIPVisitorsView.as_view(), name="vip-visitors"), + path("vip//activity/", VIPActivityView.as_view(), name="vip-activity"), + path("escorts/", EscortAssignView.as_view(), name="escorts"), + path("escorts/available/", AvailableEscortsView.as_view(), name="escorts-available"), + path("escorts//release/", EscortReleaseView.as_view(), name="escort-release"), + + # --- System config (WF-009) --- + path("config/", SystemConfigView.as_view(), name="config"), + path("config/history/", ConfigChangeHistoryView.as_view(), name="config-history"), + + # --- Visiting hours (BR-063) --- + path("visiting-hours/", VisitingHoursView.as_view(), name="visiting-hours"), + + # --- Access zones (BR-064) --- + path("zones/", AccessZoneView.as_view(), name="zones"), + + # --- Data export/import (WF-010) --- + path("export/", DataExportView.as_view(), name="data-export"), + path("import/", DataImportView.as_view(), name="data-import"), + path("data-operations/", DataOperationsView.as_view(), name="data-operations"), +] diff --git a/applications/vms/api/views.py b/applications/vms/api/views.py new file mode 100644 index 000000000..93e309c21 --- /dev/null +++ b/applications/vms/api/views.py @@ -0,0 +1,683 @@ +from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.throttling import UserRateThrottle +from rest_framework.views import APIView + +from ..permissions import ( + CanBypassVIPApproval, + CanRemoveBlacklist, + IsSuperAdmin, + IsVmsAdmin, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +_MAX_LIST_LIMIT = 200 + + +def _safe_limit(request, default: int) -> int: + """Parse ?limit=N from query params, clamped to [1, _MAX_LIST_LIMIT].""" + raw = request.query_params.get("limit", default) + try: + value = int(raw) + except (TypeError, ValueError): + return default + if value < 1: + return default + return min(value, _MAX_LIST_LIMIT) + + +class VmsRegisterThrottle(UserRateThrottle): + scope = "vms_register" + rate = "30/min" +from ..selectors import ( + get_active_blacklist, + get_active_visitors, + get_all_config, + get_all_zones, + get_blacklist_audit_trail, + get_config_change_history, + get_data_operations, + get_incident_history_for_visitor, + get_incidents, + get_overstaying_visitors, + get_recent_visits, + get_visitor_full_history, + get_visitor_location_trail, + get_visiting_hours, + get_active_vip_visits, + get_vip_activity_log, + get_active_escorts, + get_available_escorts, +) +from ..services import ( + ConfigError, + DataOperationError, + RegistrationError, + WorkflowError, + add_to_blacklist, + assign_escort, + configure_access_zone, + configure_visiting_hours, + deny_entry, + export_visitor_data, + generate_report, + get_visitor_history, + import_visitor_data, + issue_pass, + log_incident, + manual_pass_verification, + process_vip_visit, + record_entry, + record_exit, + register_visitor, + release_escort, + remove_from_blacklist, + scan_visitor_pass, + update_config, + verify_visitor, +) +from .serializers import ( + AccessZoneCreateSerializer, + AccessZoneSerializer, + BlacklistAuditLogSerializer, + BlacklistCreateSerializer, + BlacklistEntrySerializer, + ConfigChangeLogSerializer, + ConfigUpdateSerializer, + DataExportSerializer, + DataImportSerializer, + DataOperationLogSerializer, + DenialLogSerializer, + DenyEntrySerializer, + EscortAssignmentSerializer, + EscortAssignSerializer, + IssuePassSerializer, + ManualVerificationSerializer, + RecordMovementSerializer, + RegisterVisitorSerializer, + ReportRequestSerializer, + ScanPassSerializer, + ScanResultSerializer, + SecurityIncidentCreateSerializer, + SecurityIncidentSerializer, + SystemConfigSerializer, + VIPActivityLogSerializer, + VIPProcessSerializer, + VerifyVisitorSerializer, + VisitingHoursCreateSerializer, + VisitingHoursSerializer, + VisitSerializer, + VisitorLocationSerializer, + VisitorPassSerializer, + VisitorSerializer, +) + + +class RegisterVisitorView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [VmsRegisterThrottle] + + def post(self, request): + serializer = RegisterVisitorSerializer(data=request.data) + if not serializer.is_valid(): + return Response({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) + + try: + visitor, visit = register_visitor(serializer.validated_data) + except RegistrationError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response( + { + "visit_id": visit.id, + "visitor": VisitorSerializer(visitor).data, + "status": visit.status, + "registered_at": visit.registered_at, + }, + status=status.HTTP_201_CREATED, + ) + + +class VerifyVisitorView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = VerifyVisitorSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + visit = verify_visitor(serializer.validated_data, request.user) + except WorkflowError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"detail": "Verification successful.", "visit_status": visit.status}) + + +class IssuePassView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = IssuePassSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + visit, visitor_pass, qr_data_uri = issue_pass(serializer.validated_data) + except WorkflowError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response({ + "detail": "Pass issued", + "visit_status": visit.status, + "pass": VisitorPassSerializer(visitor_pass).data, + "qr_code": qr_data_uri, + }) + + +class RecordEntryView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = RecordMovementSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + visit = record_entry(serializer.validated_data, request.user) + except WorkflowError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"detail": "Entry recorded", "visit_status": visit.status}) + + +class RecordExitView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = RecordMovementSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + visit = record_exit(serializer.validated_data, request.user) + except WorkflowError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"detail": "Exit recorded", "visit_status": visit.status}) + + +class DenyEntryView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = DenyEntrySerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + _visit, denial = deny_entry(serializer.validated_data) + return Response({"detail": "Entry denied", "denial": DenialLogSerializer(denial).data}) + + +class ActiveVisitorsView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + active = get_active_visitors() + return Response(VisitSerializer(active, many=True).data) + + +class RecentVisitsView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + limit = _safe_limit(request, default=5) + recent = get_recent_visits(limit=limit) + return Response(VisitSerializer(recent, many=True).data) + + +class SecurityIncidentView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + limit = _safe_limit(request, default=20) + incidents = get_incidents(limit=limit) + return Response(SecurityIncidentSerializer(incidents, many=True).data) + + def post(self, request): + serializer = SecurityIncidentCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + incident = log_incident(serializer.validated_data, request.user) + return Response(SecurityIncidentSerializer(incident).data, status=status.HTTP_201_CREATED) + + +# ============================================================================ +# BR-019/020/021: Pass scan & validation +# ============================================================================ +class ScanPassView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = ScanPassSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + visit, result = scan_visitor_pass(serializer.validated_data, request.user) + except WorkflowError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response({"detail": "Scan successful", "result": result}) + + +# ============================================================================ +# BR-023: Manual verification fallback +# ============================================================================ +class ManualVerificationView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = ManualVerificationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + visit = manual_pass_verification(serializer.validated_data, request.user) + return Response({"detail": "Manual verification recorded", "visit_status": visit.status}) + + +# ============================================================================ +# BR-030–035: Blacklist management +# ============================================================================ +class BlacklistView(APIView): + def get_permissions(self): + if self.request.method == "POST": + return [IsAuthenticated(), IsVmsAdmin()] + return [IsAuthenticated()] + + def get(self, request): + entries = get_active_blacklist() + return Response(BlacklistEntrySerializer(entries, many=True).data) + + def post(self, request): + serializer = BlacklistCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + entry = add_to_blacklist(serializer.validated_data, request.user) + return Response(BlacklistEntrySerializer(entry).data, status=status.HTTP_201_CREATED) + + +class BlacklistRemoveView(APIView): + permission_classes = [IsAuthenticated, CanRemoveBlacklist] + + def post(self, request, entry_id): + entry = remove_from_blacklist(entry_id, request.user) + return Response({"detail": f"Blacklist entry {entry.id_number} deactivated."}) + + +class BlacklistAuditView(APIView): + permission_classes = [IsAuthenticated, IsVmsAdmin] + + def get(self, request, id_number): + trail = get_blacklist_audit_trail(id_number) + return Response(BlacklistAuditLogSerializer(trail, many=True).data) + + +# ============================================================================ +# BR-030/031: Visitor & incident history (full PII — admin only) +# ============================================================================ +class VisitorHistoryView(APIView): + permission_classes = [IsAuthenticated, IsVmsAdmin] + + def get(self, request, id_number): + history = get_visitor_full_history(id_number) + if history["visitor"] is None: + return Response({"detail": "Visitor not found."}, status=status.HTTP_404_NOT_FOUND) + return Response({ + "visitor": VisitorSerializer(history["visitor"]).data, + "visits": VisitSerializer(history["visits"], many=True).data, + "incidents": SecurityIncidentSerializer(history["incidents"], many=True).data, + "blacklist": BlacklistEntrySerializer(history["blacklist"], many=True).data, + }) + + +class WhoAmIView(APIView): + """Return the current user's VMS authority level. + + Any authenticated user may call this; used by the frontend to render + a role badge in the navbar and to conditionally show admin affordances. + Returns {authority_level, can_approve_vip, is_vms_admin, is_super_admin}. + No HostAuthority row → authority_level is null and both flags are false. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + from ..models import HostAuthority + + authority = HostAuthority.objects.filter(user=request.user).first() + level = authority.authority_level if authority else None + return Response({ + "username": request.user.username, + "authority_level": level, + "can_approve_vip": bool(authority and authority.can_approve_vip), + "is_vms_admin": level in ( + HostAuthority.LEVEL_ADMIN, + HostAuthority.LEVEL_SUPER, + ), + "is_super_admin": level == HostAuthority.LEVEL_SUPER, + }) + + +class VisitorHistoryByVisitView(APIView): + """Resolve a Visit ID → visitor → full history. + + Admin-only sister endpoint to VisitorHistoryView that lets the frontend + use the Visit ID shown in Visitor Records (list responses mask the raw + id_number so it can't be resolved client-side). + """ + + permission_classes = [IsAuthenticated, IsVmsAdmin] + + def get(self, request, visit_id): + from ..models import Visit + + visit = Visit.objects.select_related("visitor").filter(id=visit_id).first() + if visit is None: + return Response( + {"detail": f"Visit with id={visit_id} not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + history = get_visitor_full_history(visit.visitor.id_number) + if history["visitor"] is None: + return Response( + {"detail": "Visitor not found."}, status=status.HTTP_404_NOT_FOUND + ) + return Response({ + "visitor": VisitorSerializer(history["visitor"]).data, + "visits": VisitSerializer(history["visits"], many=True).data, + "incidents": SecurityIncidentSerializer(history["incidents"], many=True).data, + "blacklist": BlacklistEntrySerializer(history["blacklist"], many=True).data, + }) + + +class IncidentHistoryView(APIView): + permission_classes = [IsAuthenticated, IsVmsAdmin] + + def get(self, request, id_number): + incidents = get_incident_history_for_visitor(id_number) + return Response(SecurityIncidentSerializer(incidents, many=True).data) + + +# ============================================================================ +# BR-041–047: VIP visitor processing +# ============================================================================ +class VIPProcessView(APIView): + """Mark a visit as VIP and optionally bypass verification. + + Plain VIP flagging requires IsVmsAdmin: marking a visit as VIP grants + the +60-minute pass bonus, emits VIP arrival notifications, and + creates VIPActivityLog rows — privileged state changes. + + `bypass_approval=True` additionally requires CanBypassVIPApproval + (the `can_approve_vip` flag on HostAuthority). + """ + + permission_classes = [IsAuthenticated, IsVmsAdmin] + + def post(self, request): + serializer = VIPProcessSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Approval bypass is privileged: only hosts with the explicit VIP + # bypass authority may skip the standard verification flow. + if serializer.validated_data.get("bypass_approval"): + if not CanBypassVIPApproval().has_permission(request, self): + raise PermissionDenied("Not authorised for VIP approval bypass.") + + visit, auto_escort = process_vip_visit( + serializer.validated_data, request.user + ) + payload = { + "detail": "VIP processing complete", + "visit": VisitSerializer(visit).data, + "auto_escort": ( + EscortAssignmentSerializer(auto_escort).data if auto_escort else None + ), + } + return Response(payload) + + +class VIPVisitorsView(APIView): + """List active VIP visits. Admin-only — reveals VIP presence/schedule.""" + + permission_classes = [IsAuthenticated, IsVmsAdmin] + + def get(self, request): + visits = get_active_vip_visits() + return Response(VisitSerializer(visits, many=True).data) + + +class EscortAssignView(APIView): + """Manage escort assignments (BR-046). Staff-accessible: gate officers + coordinate escorts directly when a high-level VIP arrives, so assignment + must be available to the security-staff tier rather than admin-only.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + escorts = get_active_escorts() + return Response(EscortAssignmentSerializer(escorts, many=True).data) + + def post(self, request): + serializer = EscortAssignSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + assignment = assign_escort(serializer.validated_data, request.user) + except WorkflowError as e: + return Response( + {"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST + ) + return Response( + EscortAssignmentSerializer(assignment).data, + status=status.HTTP_201_CREATED, + ) + + +class EscortReleaseView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, assignment_id): + assignment = release_escort(assignment_id, request.user) + return Response({"detail": "Escort released", "released_at": assignment.released_at}) + + +class AvailableEscortsView(APIView): + """BR-046: List qualified escort personnel who are currently available.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + escorts = get_available_escorts() + data = [ + { + "id": e.id, + "name": ( + getattr(e.user, "get_full_name", lambda: "")() + or getattr(e.user, "username", "") + ), + "department": getattr( + getattr(e, "department", None), "name", "" + ), + } + for e in escorts + ] + return Response(data) + + +class VIPActivityView(APIView): + """VIP-specific movement log for a given visit. Admin-only — sensitive + VIP movement data (BR-047).""" + + permission_classes = [IsAuthenticated, IsVmsAdmin] + + def get(self, request, visit_id): + logs = get_vip_activity_log(visit_id) + return Response(VIPActivityLogSerializer(logs, many=True).data) + + +# ============================================================================ +# BR-025–029: Report generation +# ============================================================================ +class ReportGenerationView(APIView): + permission_classes = [IsAuthenticated, IsVmsAdmin] + + def post(self, request): + serializer = ReportRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + report = generate_report(serializer.validated_data, request.user) + except WorkflowError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(report) + + +# ============================================================================ +# BR-024: Overstay detection +# ============================================================================ +class OverstayView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + visits = get_overstaying_visitors() + return Response(VisitSerializer(visits, many=True).data) + + +# ============================================================================ +# BR-036–040: Location tracking +# ============================================================================ +class VisitorLocationView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, visit_id): + trail = get_visitor_location_trail(visit_id) + return Response(VisitorLocationSerializer(trail, many=True).data) + + +# ============================================================================ +# BR-057–066: System configuration +# ============================================================================ +class SystemConfigView(APIView): + permission_classes = [IsAuthenticated, IsSuperAdmin] + + def get(self, request): + configs = get_all_config() + return Response(SystemConfigSerializer(configs, many=True).data) + + def post(self, request): + serializer = ConfigUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + config = update_config( + serializer.validated_data["key"], + serializer.validated_data["value"], + request.user, + serializer.validated_data.get("description", ""), + ) + except ConfigError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response(SystemConfigSerializer(config).data) + + +class ConfigChangeHistoryView(APIView): + permission_classes = [IsAuthenticated, IsSuperAdmin] + + def get(self, request): + key = request.query_params.get("key") + history = get_config_change_history(key=key) + return Response(ConfigChangeLogSerializer(history, many=True).data) + + +# ============================================================================ +# BR-063: Visiting hours +# ============================================================================ +class VisitingHoursView(APIView): + def get_permissions(self): + if self.request.method == "POST": + return [IsAuthenticated(), IsVmsAdmin()] + return [IsAuthenticated()] + + def get(self, request): + hours = get_visiting_hours() + return Response(VisitingHoursSerializer(hours, many=True).data) + + def post(self, request): + serializer = VisitingHoursCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + hours = configure_visiting_hours(serializer.validated_data) + return Response(VisitingHoursSerializer(hours).data, status=status.HTTP_201_CREATED) + + +# ============================================================================ +# BR-064: Access zones +# ============================================================================ +class AccessZoneView(APIView): + def get_permissions(self): + if self.request.method == "POST": + return [IsAuthenticated(), IsVmsAdmin()] + return [IsAuthenticated()] + + def get(self, request): + zones = get_all_zones() + return Response(AccessZoneSerializer(zones, many=True).data) + + def post(self, request): + serializer = AccessZoneCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + zone = configure_access_zone(serializer.validated_data) + return Response(AccessZoneSerializer(zone).data, status=status.HTTP_201_CREATED) + + +# ============================================================================ +# BR-067–074: Data export / import +# ============================================================================ +class DataExportView(APIView): + permission_classes = [IsAuthenticated, IsVmsAdmin] + + def post(self, request): + serializer = DataExportSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + result, op_log = export_visitor_data(serializer.validated_data, request.user) + except DataOperationError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response({ + "detail": op_log.result_summary, + "operation_id": op_log.id, + "data": result, + }) + + +class DataImportView(APIView): + permission_classes = [IsAuthenticated, IsVmsAdmin] + + def post(self, request): + serializer = DataImportSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + visitors, op_log = import_visitor_data( + serializer.validated_data["data_content"], + serializer.validated_data["format"], + serializer.validated_data["field_mapping"], + request.user, + ) + except DataOperationError as e: + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response({ + "detail": op_log.result_summary, + "operation_id": op_log.id, + "records_processed": op_log.records_processed, + }, status=status.HTTP_201_CREATED) + + +class DataOperationsView(APIView): + permission_classes = [IsAuthenticated, IsVmsAdmin] + + def get(self, request): + ops = get_data_operations() + return Response(DataOperationLogSerializer(ops, many=True).data) diff --git a/applications/vms/apps.py b/applications/vms/apps.py new file mode 100644 index 000000000..14081a583 --- /dev/null +++ b/applications/vms/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class VmsConfig(AppConfig): + name = "applications.vms" + verbose_name = "Visitor Management System" diff --git a/applications/vms/migrations/0001_initial.py b/applications/vms/migrations/0001_initial.py new file mode 100644 index 000000000..001d4c7d9 --- /dev/null +++ b/applications/vms/migrations/0001_initial.py @@ -0,0 +1,132 @@ +# Generated by Django 5.2.6 on 2026-02-05 10:17 + +import applications.vms.models +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('globals', '0005_moduleaccess_patent_management'), + ] + + operations = [ + migrations.CreateModel( + name='BlacklistEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id_number', models.CharField(db_index=True, max_length=64)), + ('reason', models.CharField(max_length=200)), + ('active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Blacklist Entry', + 'verbose_name_plural': 'Blacklist Entries', + }, + ), + migrations.CreateModel( + name='Visit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('purpose', models.CharField(max_length=200)), + ('host_name', models.CharField(max_length=120)), + ('host_department', models.CharField(max_length=120)), + ('host_contact', models.CharField(blank=True, max_length=50)), + ('expected_duration_minutes', models.PositiveIntegerField(default=60)), + ('status', models.CharField(choices=[('registered', 'Registered'), ('id_verified', 'ID Verified'), ('pass_issued', 'Pass Issued'), ('inside', 'Inside'), ('exited', 'Exited'), ('denied', 'Denied')], default='registered', max_length=20)), + ('registered_at', models.DateTimeField(auto_now_add=True)), + ('verified_at', models.DateTimeField(blank=True, null=True)), + ('pass_issued_at', models.DateTimeField(blank=True, null=True)), + ('entry_at', models.DateTimeField(blank=True, null=True)), + ('exit_at', models.DateTimeField(blank=True, null=True)), + ('denial_reason', models.CharField(blank=True, max_length=200)), + ('denial_remarks', models.TextField(blank=True)), + ('is_vip', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='Visitor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=120)), + ('id_number', models.CharField(max_length=64, unique=True)), + ('id_type', models.CharField(choices=[('passport', 'Passport'), ('national_id', 'National ID'), ('driver_license', 'Driver License'), ('aadhaar', 'Aadhaar Card')], max_length=20)), + ('contact_phone', models.CharField(max_length=20)), + ('contact_email', models.EmailField(blank=True, max_length=254)), + ('photo_reference', models.CharField(blank=True, max_length=256)), + ], + ), + migrations.CreateModel( + name='VerificationLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('method', models.CharField(choices=[('manual', 'Manual'), ('biometric', 'Biometric')], default='manual', max_length=20)), + ('result', models.BooleanField(default=False)), + ('notes', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('verifier', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vms_verifications', to='globals.extrainfo')), + ('visit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='verification_logs', to='vms.visit')), + ], + ), + migrations.CreateModel( + name='EntryExitLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('entry', 'Entry'), ('exit', 'Exit')], max_length=10)), + ('gate_name', models.CharField(max_length=120)), + ('items_declared', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('recorded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vms_movements', to='globals.extrainfo')), + ('visit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movement_logs', to='vms.visit')), + ], + ), + migrations.CreateModel( + name='DenialLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reason', models.CharField(max_length=120)), + ('remarks', models.TextField(blank=True)), + ('escalated', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('visit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='denials', to='vms.visit')), + ], + ), + migrations.AddField( + model_name='visit', + name='visitor', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='visits', to='vms.visitor'), + ), + migrations.CreateModel( + name='SecurityIncident', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('severity', models.CharField(choices=[('critical', 'Critical'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], max_length=10)), + ('issue_type', models.CharField(choices=[('unauthorized_access', 'Unauthorized Access'), ('policy_violation', 'Policy Violation'), ('equipment_failure', 'Equipment Failure'), ('suspicious_behavior', 'Suspicious Behavior'), ('other', 'Other')], default='other', max_length=40)), + ('description', models.TextField()), + ('status', models.CharField(default='open', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('recorded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vms_incidents', to='globals.extrainfo')), + ('visit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incidents', to='vms.visit')), + ('visitor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incidents', to='vms.visitor')), + ], + ), + migrations.CreateModel( + name='VisitorPass', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('pass_number', models.CharField(default=applications.vms.models._generate_pass_number, max_length=32, unique=True)), + ('valid_from', models.DateTimeField(default=django.utils.timezone.now)), + ('valid_until', models.DateTimeField()), + ('authorized_zones', models.CharField(default='public', max_length=200)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('issued', 'Issued'), ('returned', 'Returned'), ('lost', 'Lost')], default='pending', max_length=20)), + ('barcode_data', models.CharField(blank=True, max_length=128)), + ('is_vip_pass', models.BooleanField(default=False)), + ('visit', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='visitor_pass', to='vms.visit')), + ], + ), + ] diff --git a/applications/vms/migrations/0002_visitorpass_barcode_data_to_text.py b/applications/vms/migrations/0002_visitorpass_barcode_data_to_text.py new file mode 100644 index 000000000..c5205782e --- /dev/null +++ b/applications/vms/migrations/0002_visitorpass_barcode_data_to_text.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("vms", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="visitorpass", + name="barcode_data", + field=models.TextField(blank=True), + ), + ] diff --git a/applications/vms/migrations/0003_extended_vms_models.py b/applications/vms/migrations/0003_extended_vms_models.py new file mode 100644 index 000000000..c05a6ac67 --- /dev/null +++ b/applications/vms/migrations/0003_extended_vms_models.py @@ -0,0 +1,230 @@ +""" +Migration for all new VMS models and field additions. + +Covers: VisitorRequest, HostAuthority, RegistrationQuota, AccessZone, +VisitorLocation, BlacklistAuditLog, EscortAssignment, VIPActivityLog, +SystemConfig, ConfigChangeLog, VisitingHours, DataOperationLog, +and field additions to BlacklistEntry and SecurityIncident. +""" + +import uuid +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("vms", "0002_visitorpass_barcode_data_to_text"), + ("globals", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # --- BlacklistEntry: add evidence + added_by fields --- + migrations.AddField( + model_name="blacklistentry", + name="evidence", + field=models.TextField(blank=True, default=""), + preserve_default=False, + ), + migrations.AddField( + model_name="blacklistentry", + name="added_by", + field=models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="vms_blacklist_added", + to="globals.extrainfo", + ), + ), + + # --- SecurityIncident: add tracking_number, escalation_level, containment_actions --- + migrations.AddField( + model_name="securityincident", + name="tracking_number", + field=models.CharField(blank=True, max_length=32, unique=True, default=""), + preserve_default=False, + ), + migrations.AddField( + model_name="securityincident", + name="escalation_level", + field=models.IntegerField( + choices=[(0, "None"), (1, "Supervisor"), (2, "Security Head"), (3, "Administration"), (4, "Police / Lockdown")], + default=0, + ), + ), + migrations.AddField( + model_name="securityincident", + name="containment_actions", + field=models.TextField(blank=True, default=""), + preserve_default=False, + ), + + # --- VisitorRequest --- + migrations.CreateModel( + name="VisitorRequest", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("request_id", models.CharField(db_index=True, max_length=32, unique=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("visit", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="request", to="vms.visit")), + ], + ), + + # --- HostAuthority --- + migrations.CreateModel( + name="HostAuthority", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("authority_level", models.CharField(choices=[("basic", "Basic"), ("department", "Department"), ("admin", "Admin"), ("super_admin", "Super Admin")], default="basic", max_length=20)), + ("department", models.CharField(blank=True, max_length=120)), + ("can_approve_vip", models.BooleanField(default=False)), + ("max_daily_approvals", models.PositiveIntegerField(default=10)), + ("user", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="vms_host_authority", to=settings.AUTH_USER_MODEL)), + ], + options={"verbose_name_plural": "Host Authorities"}, + ), + + # --- RegistrationQuota --- + migrations.CreateModel( + name="RegistrationQuota", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("date", models.DateField()), + ("count", models.PositiveIntegerField(default=0)), + ("host_user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="vms_quotas", to=settings.AUTH_USER_MODEL)), + ], + options={"unique_together": {("host_user", "date")}}, + ), + + # --- AccessZone --- + migrations.CreateModel( + name="AccessZone", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=60, unique=True)), + ("description", models.CharField(blank=True, max_length=200)), + ("requires_vip", models.BooleanField(default=False)), + ("requires_escort", models.BooleanField(default=False)), + ("is_restricted", models.BooleanField(default=False)), + ("active", models.BooleanField(default=True)), + ], + ), + + # --- VisitorLocation --- + migrations.CreateModel( + name="VisitorLocation", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("checkpoint_name", models.CharField(max_length=120)), + ("scanned_at", models.DateTimeField(auto_now_add=True)), + ("visit", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="locations", to="vms.visit")), + ("zone", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="vms.accesszone")), + ("recorded_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="vms_location_scans", to="globals.extrainfo")), + ], + options={"ordering": ["-scanned_at"]}, + ), + + # --- BlacklistAuditLog --- + migrations.CreateModel( + name="BlacklistAuditLog", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("action", models.CharField(choices=[("add", "Added"), ("remove", "Removed"), ("update", "Updated")], max_length=10)), + ("id_number", models.CharField(max_length=64)), + ("reason", models.TextField(blank=True)), + ("evidence", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("blacklist_entry", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="audit_logs", to="vms.blacklistentry")), + ("performed_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="vms_blacklist_actions", to="globals.extrainfo")), + ], + ), + + # --- EscortAssignment --- + migrations.CreateModel( + name="EscortAssignment", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("assigned_at", models.DateTimeField(auto_now_add=True)), + ("released_at", models.DateTimeField(blank=True, null=True)), + ("notes", models.TextField(blank=True)), + ("visit", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="escorts", to="vms.visit")), + ("escort", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="vms_escort_duties", to="globals.extrainfo")), + ], + ), + + # --- VIPActivityLog --- + migrations.CreateModel( + name="VIPActivityLog", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("action", models.CharField(max_length=120)), + ("details", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("visit", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="vip_logs", to="vms.visit")), + ("recorded_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="vms_vip_actions", to="globals.extrainfo")), + ], + ), + + # --- SystemConfig --- + migrations.CreateModel( + name="SystemConfig", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("key", models.CharField(max_length=120, unique=True)), + ("value", models.TextField()), + ("description", models.CharField(blank=True, max_length=256)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="vms_config_changes", to="globals.extrainfo")), + ], + ), + + # --- ConfigChangeLog --- + migrations.CreateModel( + name="ConfigChangeLog", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("old_value", models.TextField()), + ("new_value", models.TextField()), + ("changed_at", models.DateTimeField(auto_now_add=True)), + ("config", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="change_logs", to="vms.systemconfig")), + ("changed_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="vms_config_audit", to="globals.extrainfo")), + ], + ), + + # --- VisitingHours --- + migrations.CreateModel( + name="VisitingHours", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("day_of_week", models.IntegerField(choices=[(0, "Monday"), (1, "Tuesday"), (2, "Wednesday"), (3, "Thursday"), (4, "Friday"), (5, "Saturday"), (6, "Sunday")])), + ("start_time", models.TimeField()), + ("end_time", models.TimeField()), + ("is_holiday", models.BooleanField(default=False)), + ("holiday_name", models.CharField(blank=True, max_length=120)), + ("active", models.BooleanField(default=True)), + ], + options={"verbose_name_plural": "Visiting Hours"}, + ), + + # --- DataOperationLog --- + migrations.CreateModel( + name="DataOperationLog", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("operation_type", models.CharField(choices=[("export", "Export"), ("import", "Import")], max_length=10)), + ("status", models.CharField(choices=[("pending", "Pending"), ("running", "Running"), ("completed", "Completed"), ("failed", "Failed"), ("rolled_back", "Rolled Back")], default="pending", max_length=16)), + ("parameters", models.JSONField(default=dict)), + ("result_summary", models.TextField(blank=True)), + ("records_processed", models.PositiveIntegerField(default=0)), + ("error_details", models.TextField(blank=True)), + ("started_at", models.DateTimeField(auto_now_add=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("initiated_by", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="vms_data_ops", to="globals.extrainfo")), + ], + ), + ] diff --git a/applications/vms/migrations/0004_vip_level.py b/applications/vms/migrations/0004_vip_level.py new file mode 100644 index 000000000..c4b5cd446 --- /dev/null +++ b/applications/vms/migrations/0004_vip_level.py @@ -0,0 +1,18 @@ +"""BR-046: Add vip_level to Visit for escort-assignment eligibility.""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("vms", "0003_extended_vms_models"), + ] + + operations = [ + migrations.AddField( + model_name="visit", + name="vip_level", + field=models.PositiveSmallIntegerField(default=0), + ), + ] diff --git a/applications/vms/migrations/__init__.py b/applications/vms/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/applications/vms/models.py b/applications/vms/models.py new file mode 100644 index 000000000..9393dbf48 --- /dev/null +++ b/applications/vms/models.py @@ -0,0 +1,517 @@ +import uuid +from datetime import timedelta + +from django.conf import settings +from django.db import models +from django.utils import timezone + +from applications.globals.models import ExtraInfo + + +class Visitor(models.Model): + ID_PASSPORT = "passport" + ID_NATIONAL = "national_id" + ID_DL = "driver_license" + ID_AADHAAR = "aadhaar" + + ID_TYPES = ( + (ID_PASSPORT, "Passport"), + (ID_NATIONAL, "National ID"), + (ID_DL, "Driver License"), + (ID_AADHAAR, "Aadhaar Card"), + ) + + full_name = models.CharField(max_length=120) + id_number = models.CharField(max_length=64, unique=True) + id_type = models.CharField(max_length=20, choices=ID_TYPES) + contact_phone = models.CharField(max_length=20) + contact_email = models.EmailField(blank=True) + photo_reference = models.CharField(max_length=256, blank=True) + + def __str__(self): + return f"{self.full_name} ({self.id_number})" + + +class BlacklistEntry(models.Model): + id_number = models.CharField(max_length=64, db_index=True) + reason = models.CharField(max_length=200) + evidence = models.TextField(blank=True) + added_by = models.ForeignKey( + ExtraInfo, on_delete=models.SET_NULL, null=True, blank=True, related_name="vms_blacklist_added" + ) + active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Blacklist Entry" + verbose_name_plural = "Blacklist Entries" + + def __str__(self): + state = "active" if self.active else "inactive" + return f"{self.id_number} - {state}" + + +class Visit(models.Model): + STATUS_REGISTERED = "registered" + STATUS_VERIFIED = "id_verified" + STATUS_PASS_ISSUED = "pass_issued" + STATUS_INSIDE = "inside" + STATUS_EXITED = "exited" + STATUS_DENIED = "denied" + + STATUS_CHOICES = ( + (STATUS_REGISTERED, "Registered"), + (STATUS_VERIFIED, "ID Verified"), + (STATUS_PASS_ISSUED, "Pass Issued"), + (STATUS_INSIDE, "Inside"), + (STATUS_EXITED, "Exited"), + (STATUS_DENIED, "Denied"), + ) + + visitor = models.ForeignKey(Visitor, on_delete=models.CASCADE, related_name="visits") + purpose = models.CharField(max_length=200) + host_name = models.CharField(max_length=120) + host_department = models.CharField(max_length=120) + host_contact = models.CharField(max_length=50, blank=True) + expected_duration_minutes = models.PositiveIntegerField(default=60) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_REGISTERED) + registered_at = models.DateTimeField(auto_now_add=True) + verified_at = models.DateTimeField(null=True, blank=True) + pass_issued_at = models.DateTimeField(null=True, blank=True) + entry_at = models.DateTimeField(null=True, blank=True) + exit_at = models.DateTimeField(null=True, blank=True) + denial_reason = models.CharField(max_length=200, blank=True) + denial_remarks = models.TextField(blank=True) + is_vip = models.BooleanField(default=False) + # BR-046: VIP level drives escort-assignment eligibility. + # 0 = not VIP, 1 = standard VIP, 2 = high, 3+ = escort-eligible by default. + vip_level = models.PositiveSmallIntegerField(default=0) + + def __str__(self): + return f"Visit {self.id} for {self.visitor.full_name}" + + +def _generate_pass_number() -> str: + return f"VMS-{uuid.uuid4().hex[:10].upper()}" + + +class VisitorPass(models.Model): + PASS_PENDING = "pending" + PASS_ISSUED = "issued" + PASS_RETURNED = "returned" + PASS_LOST = "lost" + + PASS_STATUS = ( + (PASS_PENDING, "Pending"), + (PASS_ISSUED, "Issued"), + (PASS_RETURNED, "Returned"), + (PASS_LOST, "Lost"), + ) + + visit = models.OneToOneField(Visit, on_delete=models.CASCADE, related_name="visitor_pass") + pass_number = models.CharField(max_length=32, unique=True, default=_generate_pass_number) + valid_from = models.DateTimeField(default=timezone.now) + valid_until = models.DateTimeField() + authorized_zones = models.CharField(max_length=200, default="public") + status = models.CharField(max_length=20, choices=PASS_STATUS, default=PASS_PENDING) + barcode_data = models.TextField(blank=True) + is_vip_pass = models.BooleanField(default=False) + + def __str__(self): + return f"Pass {self.pass_number}" + + +class VerificationLog(models.Model): + METHOD_MANUAL = "manual" + METHOD_BIOMETRIC = "biometric" + + METHODS = ( + (METHOD_MANUAL, "Manual"), + (METHOD_BIOMETRIC, "Biometric"), + ) + + visit = models.ForeignKey(Visit, on_delete=models.CASCADE, related_name="verification_logs") + verifier = models.ForeignKey(ExtraInfo, on_delete=models.SET_NULL, null=True, related_name="vms_verifications") + method = models.CharField(max_length=20, choices=METHODS, default=METHOD_MANUAL) + result = models.BooleanField(default=False) + notes = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + status = "passed" if self.result else "failed" + return f"Verification {self.id} ({status})" + + +class DenialLog(models.Model): + visit = models.ForeignKey(Visit, on_delete=models.CASCADE, related_name="denials") + reason = models.CharField(max_length=120) + remarks = models.TextField(blank=True) + escalated = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Denial {self.id} - {self.reason}" + + +class EntryExitLog(models.Model): + ACTION_ENTRY = "entry" + ACTION_EXIT = "exit" + + ACTIONS = ( + (ACTION_ENTRY, "Entry"), + (ACTION_EXIT, "Exit"), + ) + + visit = models.ForeignKey(Visit, on_delete=models.CASCADE, related_name="movement_logs") + action = models.CharField(max_length=10, choices=ACTIONS) + gate_name = models.CharField(max_length=120) + recorded_by = models.ForeignKey(ExtraInfo, on_delete=models.SET_NULL, null=True, related_name="vms_movements") + items_declared = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.action.title()} log for visit {self.visit_id}" + + +class SecurityIncident(models.Model): + SEVERITY_CRITICAL = "critical" + SEVERITY_HIGH = "high" + SEVERITY_MEDIUM = "medium" + SEVERITY_LOW = "low" + + SEVERITIES = ( + (SEVERITY_CRITICAL, "Critical"), + (SEVERITY_HIGH, "High"), + (SEVERITY_MEDIUM, "Medium"), + (SEVERITY_LOW, "Low"), + ) + + ISSUE_TYPES = ( + ("unauthorized_access", "Unauthorized Access"), + ("policy_violation", "Policy Violation"), + ("equipment_failure", "Equipment Failure"), + ("suspicious_behavior", "Suspicious Behavior"), + ("other", "Other"), + ) + + ESCALATION_LEVELS = ( + (0, "None"), + (1, "Supervisor"), + (2, "Security Head"), + (3, "Administration"), + (4, "Police / Lockdown"), + ) + + visitor = models.ForeignKey(Visitor, on_delete=models.SET_NULL, null=True, blank=True, related_name="incidents") + visit = models.ForeignKey(Visit, on_delete=models.SET_NULL, null=True, blank=True, related_name="incidents") + recorded_by = models.ForeignKey(ExtraInfo, on_delete=models.SET_NULL, null=True, related_name="vms_incidents") + severity = models.CharField(max_length=10, choices=SEVERITIES) + issue_type = models.CharField(max_length=40, choices=ISSUE_TYPES, default="other") + description = models.TextField() + status = models.CharField(max_length=20, default="open") + tracking_number = models.CharField(max_length=32, unique=True, blank=True) + escalation_level = models.IntegerField(choices=ESCALATION_LEVELS, default=0) + containment_actions = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Incident {self.tracking_number or self.id} ({self.severity})" + + @property + def requires_escalation(self) -> bool: + return self.severity in {self.SEVERITY_CRITICAL, self.SEVERITY_HIGH} + + def determine_escalation_level(self): + """BR-050: Auto-determine escalation level based on severity.""" + mapping = { + self.SEVERITY_LOW: 1, + self.SEVERITY_MEDIUM: 2, + self.SEVERITY_HIGH: 3, + self.SEVERITY_CRITICAL: 4, + } + return mapping.get(self.severity, 0) + + def save(self, *args, **kwargs): + if not self.tracking_number: + ts = timezone.now().strftime("%Y%m%d") + short = uuid.uuid4().hex[:8].upper() + self.tracking_number = f"INC-{ts}-{short}" + if not self.escalation_level: + self.escalation_level = self.determine_escalation_level() + super().save(*args, **kwargs) + + +def calculate_valid_until(start_time: timezone.datetime, duration_minutes: int, is_vip: bool = False) -> timezone.datetime: + base_duration = duration_minutes + extra = 60 if is_vip else 0 + return start_time + timedelta(minutes=base_duration + extra) + + +# --------------------------------------------------------------------------- +# BR-005: Unique request ID for every registration +# --------------------------------------------------------------------------- +class VisitorRequest(models.Model): + """Tracks each registration request with a unique human-readable request ID.""" + request_id = models.CharField(max_length=32, unique=True, db_index=True) + visit = models.OneToOneField(Visit, on_delete=models.CASCADE, related_name="request") + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.request_id + + @staticmethod + def generate_request_id(): + ts = timezone.now().strftime("%Y%m%d%H%M%S") + short = uuid.uuid4().hex[:6].upper() + return f"VR-{ts}-{short}" + + +# --------------------------------------------------------------------------- +# BR-004 / BR-011: Host authority model +# --------------------------------------------------------------------------- +class HostAuthority(models.Model): + LEVEL_BASIC = "basic" + LEVEL_DEPARTMENT = "department" + LEVEL_ADMIN = "admin" + LEVEL_SUPER = "super_admin" + + LEVELS = ( + (LEVEL_BASIC, "Basic"), + (LEVEL_DEPARTMENT, "Department"), + (LEVEL_ADMIN, "Admin"), + (LEVEL_SUPER, "Super Admin"), + ) + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="vms_host_authority" + ) + authority_level = models.CharField(max_length=20, choices=LEVELS, default=LEVEL_BASIC) + department = models.CharField(max_length=120, blank=True) + can_approve_vip = models.BooleanField(default=False) + max_daily_approvals = models.PositiveIntegerField(default=10) + + class Meta: + verbose_name_plural = "Host Authorities" + + def __str__(self): + return f"{self.user} ({self.authority_level})" + + +# --------------------------------------------------------------------------- +# BR-007: Daily registration quota per host +# --------------------------------------------------------------------------- +class RegistrationQuota(models.Model): + host_user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="vms_quotas" + ) + date = models.DateField() + count = models.PositiveIntegerField(default=0) + + class Meta: + unique_together = ("host_user", "date") + + def __str__(self): + return f"{self.host_user} on {self.date}: {self.count}" + + +# --------------------------------------------------------------------------- +# BR-013 / BR-064: Configurable access zones +# --------------------------------------------------------------------------- +class AccessZone(models.Model): + name = models.CharField(max_length=60, unique=True) + description = models.CharField(max_length=200, blank=True) + requires_vip = models.BooleanField(default=False) + requires_escort = models.BooleanField(default=False) + is_restricted = models.BooleanField(default=False) + active = models.BooleanField(default=True) + + def __str__(self): + return self.name + + +# --------------------------------------------------------------------------- +# BR-036 / BR-037: Visitor location tracking +# --------------------------------------------------------------------------- +class VisitorLocation(models.Model): + visit = models.ForeignKey(Visit, on_delete=models.CASCADE, related_name="locations") + zone = models.ForeignKey(AccessZone, on_delete=models.SET_NULL, null=True, blank=True) + checkpoint_name = models.CharField(max_length=120) + scanned_at = models.DateTimeField(auto_now_add=True) + recorded_by = models.ForeignKey( + ExtraInfo, on_delete=models.SET_NULL, null=True, related_name="vms_location_scans" + ) + + class Meta: + ordering = ["-scanned_at"] + + def __str__(self): + return f"Visit {self.visit_id} at {self.checkpoint_name}" + + +# --------------------------------------------------------------------------- +# BR-034: Blacklist audit log +# --------------------------------------------------------------------------- +class BlacklistAuditLog(models.Model): + ACTION_ADD = "add" + ACTION_REMOVE = "remove" + ACTION_UPDATE = "update" + + ACTIONS = ( + (ACTION_ADD, "Added"), + (ACTION_REMOVE, "Removed"), + (ACTION_UPDATE, "Updated"), + ) + + blacklist_entry = models.ForeignKey( + BlacklistEntry, on_delete=models.SET_NULL, null=True, related_name="audit_logs" + ) + action = models.CharField(max_length=10, choices=ACTIONS) + performed_by = models.ForeignKey( + ExtraInfo, on_delete=models.SET_NULL, null=True, related_name="vms_blacklist_actions" + ) + id_number = models.CharField(max_length=64) + reason = models.TextField(blank=True) + evidence = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.action} blacklist for {self.id_number}" + + +# --------------------------------------------------------------------------- +# BR-046: VIP escort assignment +# --------------------------------------------------------------------------- +class EscortAssignment(models.Model): + visit = models.ForeignKey(Visit, on_delete=models.CASCADE, related_name="escorts") + escort = models.ForeignKey( + ExtraInfo, on_delete=models.SET_NULL, null=True, related_name="vms_escort_duties" + ) + assigned_at = models.DateTimeField(auto_now_add=True) + released_at = models.DateTimeField(null=True, blank=True) + notes = models.TextField(blank=True) + + def __str__(self): + return f"Escort for visit {self.visit_id}" + + +# --------------------------------------------------------------------------- +# BR-047: VIP activity log (enhanced logging) +# --------------------------------------------------------------------------- +class VIPActivityLog(models.Model): + visit = models.ForeignKey(Visit, on_delete=models.CASCADE, related_name="vip_logs") + action = models.CharField(max_length=120) + details = models.TextField(blank=True) + recorded_by = models.ForeignKey( + ExtraInfo, on_delete=models.SET_NULL, null=True, related_name="vms_vip_actions" + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"VIP log: {self.action} (visit {self.visit_id})" + + +# --------------------------------------------------------------------------- +# BR-054: Incident tracking number +# --------------------------------------------------------------------------- +class IncidentTrackingMixin: + """Provides tracking_number generation for SecurityIncident.""" + + @staticmethod + def generate_tracking_number(): + ts = timezone.now().strftime("%Y%m%d") + short = uuid.uuid4().hex[:8].upper() + return f"INC-{ts}-{short}" + + +# --------------------------------------------------------------------------- +# BR-057–066: System configuration +# --------------------------------------------------------------------------- +class SystemConfig(models.Model): + key = models.CharField(max_length=120, unique=True) + value = models.TextField() + description = models.CharField(max_length=256, blank=True) + updated_by = models.ForeignKey( + ExtraInfo, on_delete=models.SET_NULL, null=True, related_name="vms_config_changes" + ) + updated_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.key}={self.value[:40]}" + + +class ConfigChangeLog(models.Model): + config = models.ForeignKey(SystemConfig, on_delete=models.CASCADE, related_name="change_logs") + old_value = models.TextField() + new_value = models.TextField() + changed_by = models.ForeignKey( + ExtraInfo, on_delete=models.SET_NULL, null=True, related_name="vms_config_audit" + ) + changed_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Config '{self.config.key}' changed at {self.changed_at}" + + +# --------------------------------------------------------------------------- +# BR-063: Visiting hours +# --------------------------------------------------------------------------- +class VisitingHours(models.Model): + DAY_CHOICES = ( + (0, "Monday"), (1, "Tuesday"), (2, "Wednesday"), + (3, "Thursday"), (4, "Friday"), (5, "Saturday"), (6, "Sunday"), + ) + day_of_week = models.IntegerField(choices=DAY_CHOICES) + start_time = models.TimeField() + end_time = models.TimeField() + is_holiday = models.BooleanField(default=False) + holiday_name = models.CharField(max_length=120, blank=True) + active = models.BooleanField(default=True) + + class Meta: + verbose_name_plural = "Visiting Hours" + + def __str__(self): + return f"Day {self.day_of_week}: {self.start_time}-{self.end_time}" + + +# --------------------------------------------------------------------------- +# BR-067–074: Data operation logging +# --------------------------------------------------------------------------- +class DataOperationLog(models.Model): + OP_EXPORT = "export" + OP_IMPORT = "import" + + OP_TYPES = ( + (OP_EXPORT, "Export"), + (OP_IMPORT, "Import"), + ) + + STATUS_PENDING = "pending" + STATUS_RUNNING = "running" + STATUS_COMPLETED = "completed" + STATUS_FAILED = "failed" + STATUS_ROLLED_BACK = "rolled_back" + + STATUSES = ( + (STATUS_PENDING, "Pending"), + (STATUS_RUNNING, "Running"), + (STATUS_COMPLETED, "Completed"), + (STATUS_FAILED, "Failed"), + (STATUS_ROLLED_BACK, "Rolled Back"), + ) + + operation_type = models.CharField(max_length=10, choices=OP_TYPES) + status = models.CharField(max_length=16, choices=STATUSES, default=STATUS_PENDING) + initiated_by = models.ForeignKey( + ExtraInfo, on_delete=models.SET_NULL, null=True, related_name="vms_data_ops" + ) + parameters = models.JSONField(default=dict) + result_summary = models.TextField(blank=True) + records_processed = models.PositiveIntegerField(default=0) + error_details = models.TextField(blank=True) + started_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return f"{self.operation_type} ({self.status})" diff --git a/applications/vms/notifications.py b/applications/vms/notifications.py new file mode 100644 index 000000000..8094f0237 --- /dev/null +++ b/applications/vms/notifications.py @@ -0,0 +1,244 @@ +""" +VMS Notifications — notification dispatch for the Visitor Management System. + +Covers: + BR-006 Host notification on visitor request + BR-016 Approval notification (visitor + security) + BR-024 Overstay notification + BR-033 Security alert to checkpoints on blacklist action + BR-039 Invalid pass alert during tracking + BR-040 Unauthorized area alert + BR-045 VIP arrival notification + BR-051 Authority notification per escalation level + BR-055 High-severity: police / lockdown notification + BR-056 Low-severity: supervisor notification + BR-062 User notification on config change +""" + +import logging +from django.utils import timezone + +logger = logging.getLogger("vms.notifications") + + +def _dispatch(channel: str, recipients: list[str], subject: str, body: str) -> bool: + """ + Internal dispatcher — logs the notification and returns True. + In production this would integrate with email/SMS/push services. + """ + logger.info( + "VMS NOTIFICATION [%s] to=%s subject='%s' body='%s'", + channel, recipients, subject, body, + ) + return True + + +# --------------------------------------------------------------------------- +# BR-006: Host notification on visitor request submission +# --------------------------------------------------------------------------- +def notify_host_visitor_request(visit) -> bool: + """Send notification to the host when a visitor registers.""" + return _dispatch( + channel="email", + recipients=[visit.host_contact or visit.host_name], + subject=f"New visitor request: {visit.visitor.full_name}", + body=( + f"Visitor {visit.visitor.full_name} (ID: {visit.visitor.id_number}) " + f"has requested a visit for '{visit.purpose}'. " + f"Expected duration: {visit.expected_duration_minutes} minutes." + ), + ) + + +# --------------------------------------------------------------------------- +# BR-016: Approval notification — visitor and security +# --------------------------------------------------------------------------- +def notify_approval(visit, visitor_pass) -> bool: + """Notify both visitor and security staff that a visit has been approved.""" + _dispatch( + channel="email", + recipients=[visit.visitor.contact_email or visit.visitor.contact_phone], + subject=f"Visit approved — Pass {visitor_pass.pass_number}", + body=( + f"Your visit to {visit.host_department} has been approved. " + f"Pass: {visitor_pass.pass_number}, " + f"Valid until: {visitor_pass.valid_until.isoformat()}, " + f"Zones: {visitor_pass.authorized_zones}." + ), + ) + _dispatch( + channel="internal", + recipients=["security_desk"], + subject=f"Visitor arriving: {visit.visitor.full_name}", + body=( + f"Pass {visitor_pass.pass_number} issued for {visit.visitor.full_name}. " + f"Host: {visit.host_name} ({visit.host_department}). " + f"VIP: {'Yes' if visit.is_vip else 'No'}." + ), + ) + return True + + +# --------------------------------------------------------------------------- +# BR-024: Overstay alert +# --------------------------------------------------------------------------- +def notify_overstay(visit, visitor_pass) -> bool: + """Alert host and security that a visitor has overstayed.""" + return _dispatch( + channel="alert", + recipients=["security_desk", visit.host_name], + subject=f"OVERSTAY: Visit {visit.id} — {visit.visitor.full_name}", + body=( + f"Visitor {visit.visitor.full_name} (Pass {visitor_pass.pass_number}) " + f"has exceeded the approved duration. " + f"Pass expired at {visitor_pass.valid_until.isoformat()}." + ), + ) + + +# --------------------------------------------------------------------------- +# BR-033: Checkpoint alert on blacklist action +# --------------------------------------------------------------------------- +def notify_checkpoints_blacklist(blacklist_entry, action: str) -> bool: + """Broadcast immediate alert to all security checkpoints.""" + return _dispatch( + channel="broadcast", + recipients=["all_checkpoints"], + subject=f"BLACKLIST {action.upper()}: {blacklist_entry.id_number}", + body=( + f"Visitor with ID {blacklist_entry.id_number} has been {action}. " + f"Reason: {blacklist_entry.reason}. " + "Update local access control lists immediately." + ), + ) + + +# --------------------------------------------------------------------------- +# BR-039: Invalid pass alert during location tracking +# --------------------------------------------------------------------------- +def notify_invalid_pass_scan(visit, checkpoint_name: str) -> bool: + """Security alert when an invalid/expired pass is scanned at a checkpoint.""" + return _dispatch( + channel="alert", + recipients=["security_desk", "control_room"], + subject=f"INVALID PASS SCAN at {checkpoint_name}", + body=( + f"Visit {visit.id} ({visit.visitor.full_name}) presented an invalid or " + f"expired pass at checkpoint '{checkpoint_name}'." + ), + ) + + +# --------------------------------------------------------------------------- +# BR-040: Unauthorized area alert +# --------------------------------------------------------------------------- +def notify_unauthorized_area(visit, zone_name: str) -> bool: + """Immediate alert when visitor detected in a restricted area.""" + return _dispatch( + channel="alert", + recipients=["security_desk", "control_room"], + subject=f"UNAUTHORIZED AREA: {visit.visitor.full_name} in {zone_name}", + body=( + f"Visitor {visit.visitor.full_name} (Visit {visit.id}) detected in " + f"restricted zone '{zone_name}'. Authorized zones: " + f"{getattr(visit, 'visitor_pass', None) and visit.visitor_pass.authorized_zones or 'N/A'}." + ), + ) + + +# --------------------------------------------------------------------------- +# BR-045: VIP arrival notification +# --------------------------------------------------------------------------- +def notify_vip_arrival(visit) -> bool: + """Notify senior security and admin staff of a VIP visitor arrival.""" + return _dispatch( + channel="priority", + recipients=["senior_security", "administration"], + subject=f"VIP ARRIVAL: {visit.visitor.full_name}", + body=( + f"VIP visitor {visit.visitor.full_name} has arrived on campus. " + f"Host: {visit.host_name} ({visit.host_department}). " + f"Purpose: {visit.purpose}." + ), + ) + + +# --------------------------------------------------------------------------- +# BR-051: Authority notification per escalation level +# --------------------------------------------------------------------------- +ESCALATION_RECIPIENTS = { + 0: [], + 1: ["shift_supervisor"], + 2: ["security_head"], + 3: ["administration", "security_head"], + 4: ["police_liaison", "administration", "security_head"], +} + + +def notify_escalation(incident) -> bool: + """Notify authorities according to incident escalation level.""" + recipients = ESCALATION_RECIPIENTS.get(incident.escalation_level, []) + if not recipients: + return False + return _dispatch( + channel="escalation", + recipients=recipients, + subject=f"INCIDENT ESCALATION (Level {incident.escalation_level}): {incident.tracking_number}", + body=( + f"Incident {incident.tracking_number} ({incident.severity}/{incident.issue_type}): " + f"{incident.description[:200]}. " + f"Escalation level: {incident.escalation_level}." + ), + ) + + +# --------------------------------------------------------------------------- +# BR-055: High severity — police / lockdown +# --------------------------------------------------------------------------- +def notify_high_severity_response(incident) -> bool: + """Trigger police notification and campus lockdown alert for critical incidents.""" + return _dispatch( + channel="emergency", + recipients=["police_liaison", "campus_security_all", "administration"], + subject=f"EMERGENCY — {incident.tracking_number}: {incident.issue_type}", + body=( + f"HIGH-SEVERITY incident requiring immediate response. " + f"Tracking: {incident.tracking_number}. " + f"Severity: {incident.severity}. Type: {incident.issue_type}. " + f"Description: {incident.description[:300]}. " + "Initiate campus lockdown protocol if applicable." + ), + ) + + +# --------------------------------------------------------------------------- +# BR-056: Low severity — supervisor only +# --------------------------------------------------------------------------- +def notify_low_severity(incident) -> bool: + """Standard supervisor notification for low-severity incidents.""" + return _dispatch( + channel="internal", + recipients=["shift_supervisor"], + subject=f"Low-priority incident: {incident.tracking_number}", + body=( + f"Incident {incident.tracking_number} ({incident.issue_type}): " + f"{incident.description[:200]}. Handle via standard protocol." + ), + ) + + +# --------------------------------------------------------------------------- +# BR-062: User notification on config change +# --------------------------------------------------------------------------- +def notify_config_change(config_entry, changed_by: str) -> bool: + """Notify affected users when a system configuration changes.""" + return _dispatch( + channel="internal", + recipients=["all_vms_users"], + subject=f"VMS Config Updated: {config_entry.key}", + body=( + f"Configuration '{config_entry.key}' has been updated " + f"by {changed_by}. New value: {config_entry.value}. " + f"Description: {config_entry.description}." + ), + ) diff --git a/applications/vms/permissions.py b/applications/vms/permissions.py new file mode 100644 index 000000000..91375424b --- /dev/null +++ b/applications/vms/permissions.py @@ -0,0 +1,133 @@ +""" +VMS Permissions — role-based access control for the Visitor Management System. + +Covers: + BR-011 Host authority validation + BR-013 Access zone restrictions (visitor-side) + BR-021 Area access control on scan + BR-035 Blacklist removal authority + BR-043 VIP approval bypass permission + BR-057 Super admin configuration access +""" + +from rest_framework.permissions import BasePermission + +from .models import AccessZone, HostAuthority + + +# --------------------------------------------------------------------------- +# BR-011: Host must have sufficient authority to approve +# --------------------------------------------------------------------------- +class HasHostApprovalAuthority(BasePermission): + """Only hosts with department-level or higher authority may approve visits.""" + + message = "Insufficient authority level to approve visitor requests." + + def has_permission(self, request, view): + authority = HostAuthority.objects.filter(user=request.user).first() + if authority is None: + return False + return authority.authority_level in ( + HostAuthority.LEVEL_DEPARTMENT, + HostAuthority.LEVEL_ADMIN, + HostAuthority.LEVEL_SUPER, + ) + + +# --------------------------------------------------------------------------- +# BR-035: Only authorised admins can remove blacklist entries +# --------------------------------------------------------------------------- +class CanRemoveBlacklist(BasePermission): + """Only admin-level or super-admin users may remove blacklist entries.""" + + message = "Only authorised administrators can remove blacklist entries." + + def has_permission(self, request, view): + authority = HostAuthority.objects.filter(user=request.user).first() + if authority is None: + return False + return authority.authority_level in ( + HostAuthority.LEVEL_ADMIN, + HostAuthority.LEVEL_SUPER, + ) + + +# --------------------------------------------------------------------------- +# BR-043: VIP approval bypass +# --------------------------------------------------------------------------- +class CanBypassVIPApproval(BasePermission): + """Only hosts explicitly authorised for VIP bypass may skip approval.""" + + message = "Not authorised for VIP approval bypass." + + def has_permission(self, request, view): + authority = HostAuthority.objects.filter(user=request.user).first() + if authority is None: + return False + return authority.can_approve_vip + + +# --------------------------------------------------------------------------- +# Admin or super-admin (used for blacklist add, config writes, exports, etc.) +# --------------------------------------------------------------------------- +class IsVmsAdmin(BasePermission): + """Admin-level or super-admin users may perform privileged VMS actions.""" + + message = "VMS administrator privileges required." + + def has_permission(self, request, view): + authority = HostAuthority.objects.filter(user=request.user).first() + if authority is None: + return False + return authority.authority_level in ( + HostAuthority.LEVEL_ADMIN, + HostAuthority.LEVEL_SUPER, + ) + + +# --------------------------------------------------------------------------- +# BR-057: Super admin only +# --------------------------------------------------------------------------- +class IsSuperAdmin(BasePermission): + """Restrict access to super-admin users only.""" + + message = "Super Admin access required." + + def has_permission(self, request, view): + authority = HostAuthority.objects.filter(user=request.user).first() + if authority is None: + return False + return authority.authority_level == HostAuthority.LEVEL_SUPER + + +# --------------------------------------------------------------------------- +# BR-013 / BR-021: Zone access checking (utility, not a DRF permission) +# --------------------------------------------------------------------------- +def check_zone_access(visitor_pass, zone_name: str) -> tuple[bool, str]: + """ + Verify a visitor is authorised for the given zone. + Returns (allowed, message). + """ + zone = AccessZone.objects.filter(name=zone_name, active=True).first() + if zone is None: + return False, f"Zone '{zone_name}' not found or inactive." + + if zone.is_restricted: + authorized_zones = [z.strip().lower() for z in visitor_pass.authorized_zones.split(",")] + if zone_name.lower() not in authorized_zones and "all" not in authorized_zones: + return False, f"Visitor not authorized for restricted zone '{zone_name}'." + + if zone.requires_vip and not visitor_pass.is_vip_pass: + return False, f"Zone '{zone_name}' requires VIP access." + + return True, "Access permitted." + + +def check_area_access_on_scan(visit, zone_name: str) -> tuple[bool, str]: + """ + BR-021: On scan, confirm visitor has authorization for the current location. + """ + visitor_pass = getattr(visit, "visitor_pass", None) + if visitor_pass is None: + return False, "No pass issued for this visit." + return check_zone_access(visitor_pass, zone_name) diff --git a/applications/vms/selectors.py b/applications/vms/selectors.py new file mode 100644 index 000000000..1876dcfce --- /dev/null +++ b/applications/vms/selectors.py @@ -0,0 +1,274 @@ +from django.db.models import Count, Q +from django.utils import timezone + +from applications.globals.models import ExtraInfo + +from .models import ( + AccessZone, + BlacklistAuditLog, + BlacklistEntry, + ConfigChangeLog, + DataOperationLog, + EscortAssignment, + SecurityIncident, + SystemConfig, + VIPActivityLog, + Visit, + Visitor, + VisitorLocation, + VisitorPass, + VisitingHours, +) + + +def get_current_staff(user): + """Return the ExtraInfo record for the given user, or None.""" + return ExtraInfo.objects.filter(user=user).first() + + +# --------------------------------------------------------------------------- +# Visit queries +# --------------------------------------------------------------------------- +def get_active_visitors(): + """Return visits that are currently inside or have a pass issued.""" + return Visit.objects.filter( + status__in=[Visit.STATUS_INSIDE, Visit.STATUS_PASS_ISSUED], + ).select_related("visitor").order_by("-registered_at") + + +def get_recent_visits(limit=5): + """Return the most recent visits, ordered newest-first.""" + return Visit.objects.select_related("visitor").order_by("-registered_at")[:limit] + + +# --------------------------------------------------------------------------- +# Incident queries +# --------------------------------------------------------------------------- +def get_incidents(limit=20): + """Return the most recent security incidents.""" + return SecurityIncident.objects.order_by("-created_at")[:limit] + + +def get_incidents_by_severity(severity: str, limit=50): + """Filter incidents by severity level.""" + return SecurityIncident.objects.filter(severity=severity).order_by("-created_at")[:limit] + + +def get_open_incidents(): + """Return all incidents with status 'open'.""" + return SecurityIncident.objects.filter(status="open").order_by("-created_at") + + +# --------------------------------------------------------------------------- +# BR-030: Visitor history for blacklist evaluation +# --------------------------------------------------------------------------- +def get_visitor_full_history(id_number: str): + """Return all visits, incidents, and blacklist records for a visitor.""" + visitor = Visitor.objects.filter(id_number=id_number).first() + if visitor is None: + return {"visitor": None, "visits": [], "incidents": [], "blacklist": []} + + return { + "visitor": visitor, + "visits": list(Visit.objects.filter(visitor=visitor).order_by("-registered_at")), + "incidents": list(SecurityIncident.objects.filter(visitor=visitor).order_by("-created_at")), + "blacklist": list(BlacklistEntry.objects.filter(id_number=id_number)), + } + + +# --------------------------------------------------------------------------- +# BR-031: Incident history for blacklist decisions +# --------------------------------------------------------------------------- +def get_incident_history_for_visitor(id_number: str): + """Return past incidents linked to a visitor by ID number.""" + visitor = Visitor.objects.filter(id_number=id_number).first() + if visitor is None: + return [] + return list(SecurityIncident.objects.filter(visitor=visitor).order_by("-created_at")) + + +# --------------------------------------------------------------------------- +# BR-036/037: Location tracking queries +# --------------------------------------------------------------------------- +def get_visitor_location_trail(visit_id: int): + """Return chronological location trail for a visit.""" + return VisitorLocation.objects.filter(visit_id=visit_id).order_by("scanned_at") + + +def get_current_visitors_in_zone(zone_name: str): + """Return visitors currently in a specific zone (latest scan).""" + from django.db.models import Max, Subquery, OuterRef + + latest_scans = VisitorLocation.objects.filter( + visit__status=Visit.STATUS_INSIDE + ).values("visit_id").annotate(latest=Max("scanned_at")) + + return VisitorLocation.objects.filter( + visit__status=Visit.STATUS_INSIDE, + checkpoint_name__icontains=zone_name, + scanned_at__in=[s["latest"] for s in latest_scans], + ).select_related("visit__visitor") + + +# --------------------------------------------------------------------------- +# BR-024: Overstay detection query +# --------------------------------------------------------------------------- +def get_overstaying_visitors(): + """Return visits where the pass has expired but visitor is still inside.""" + now = timezone.now() + return Visit.objects.filter( + status=Visit.STATUS_INSIDE, + visitor_pass__valid_until__lt=now, + ).select_related("visitor", "visitor_pass") + + +# --------------------------------------------------------------------------- +# Blacklist queries +# --------------------------------------------------------------------------- +def get_active_blacklist(): + """Return all active blacklist entries.""" + return BlacklistEntry.objects.filter(active=True).order_by("-created_at") + + +def get_blacklist_audit_trail(id_number: str): + """Return audit log for a specific blacklisted ID number.""" + return BlacklistAuditLog.objects.filter(id_number=id_number).order_by("-created_at") + + +# --------------------------------------------------------------------------- +# VIP queries +# --------------------------------------------------------------------------- +def get_active_vip_visits(): + """Return all active VIP visits.""" + return Visit.objects.filter( + is_vip=True, + status__in=[Visit.STATUS_INSIDE, Visit.STATUS_PASS_ISSUED, Visit.STATUS_VERIFIED], + ).select_related("visitor").order_by("-registered_at") + + +def get_vip_activity_log(visit_id: int): + """Return enhanced activity log for a VIP visit.""" + return VIPActivityLog.objects.filter(visit_id=visit_id).order_by("-created_at") + + +def get_active_escorts(): + """Return all currently active escort assignments.""" + return EscortAssignment.objects.filter(released_at__isnull=True).select_related("visit__visitor") + + +def get_busy_escort_ids(): + """BR-046: ExtraInfo IDs of personnel currently on an unreleased escort duty.""" + return set( + EscortAssignment.objects.filter( + released_at__isnull=True, escort__isnull=False + ).values_list("escort_id", flat=True) + ) + + +def is_escort_available(escort_id): + """BR-046: True when the staff member has no unreleased escort assignment.""" + if not escort_id: + return False + return not EscortAssignment.objects.filter( + escort_id=escort_id, released_at__isnull=True + ).exists() + + +def get_available_escorts(): + """BR-046: Qualified security personnel free to take a new escort duty. + + 'Qualified' here means any ExtraInfo tagged under the security department + (department name contains 'security'). Deployments without that taxonomy + still surface every staff member that is not already busy, so the UI is + never empty on a fresh install. + """ + busy = get_busy_escort_ids() + qs = ExtraInfo.objects.filter(user__is_active=True).exclude(id__in=busy) + qualified = qs.filter(department__name__icontains="security") + if qualified.exists(): + return qualified + return qs + + +# --------------------------------------------------------------------------- +# Report-oriented queries (BR-025–029) +# --------------------------------------------------------------------------- +def get_visit_statistics(start_date, end_date): + """Aggregate visit statistics over a date range.""" + visits = Visit.objects.filter( + registered_at__date__gte=start_date, + registered_at__date__lte=end_date, + ) + return { + "total": visits.count(), + "by_status": dict(visits.values_list("status").annotate(c=Count("id"))), + "vip_count": visits.filter(is_vip=True).count(), + "avg_duration": visits.exclude(exit_at__isnull=True).extra( + select={"dur": "EXTRACT(EPOCH FROM (exit_at - entry_at)) / 60"} + ).values_list("dur", flat=True), + } + + +def get_incident_statistics(start_date, end_date): + """Aggregate incident statistics over a date range.""" + incidents = SecurityIncident.objects.filter( + created_at__date__gte=start_date, + created_at__date__lte=end_date, + ) + return { + "total": incidents.count(), + "by_severity": dict(incidents.values_list("severity").annotate(c=Count("id"))), + "by_type": dict(incidents.values_list("issue_type").annotate(c=Count("id"))), + "open_count": incidents.filter(status="open").count(), + } + + +# --------------------------------------------------------------------------- +# System config queries (BR-057–066) +# --------------------------------------------------------------------------- +def get_all_config(): + """Return all system configuration entries.""" + return SystemConfig.objects.all().order_by("key") + + +def get_config_value(key: str, default: str = "") -> str: + """Return a single config value by key.""" + config = SystemConfig.objects.filter(key=key).first() + return config.value if config else default + + +def get_config_change_history(key: str = None, limit=50): + """Return configuration change history, optionally filtered by key.""" + qs = ConfigChangeLog.objects.select_related("config") + if key: + qs = qs.filter(config__key=key) + return qs.order_by("-changed_at")[:limit] + + +# --------------------------------------------------------------------------- +# Visiting hours queries (BR-063) +# --------------------------------------------------------------------------- +def get_visiting_hours(): + """Return all configured visiting hours.""" + return VisitingHours.objects.filter(active=True).order_by("day_of_week") + + +# --------------------------------------------------------------------------- +# Access zone queries (BR-064) +# --------------------------------------------------------------------------- +def get_all_zones(): + """Return all configured access zones.""" + return AccessZone.objects.all().order_by("name") + + +def get_restricted_zones(): + """Return only restricted zones.""" + return AccessZone.objects.filter(is_restricted=True, active=True) + + +# --------------------------------------------------------------------------- +# Data operation queries (BR-072) +# --------------------------------------------------------------------------- +def get_data_operations(limit=20): + """Return recent data export/import operations.""" + return DataOperationLog.objects.order_by("-started_at")[:limit] diff --git a/applications/vms/services.py b/applications/vms/services.py new file mode 100644 index 000000000..3c2431b1c --- /dev/null +++ b/applications/vms/services.py @@ -0,0 +1,1074 @@ +import base64 +import csv +import io +import json +from datetime import date + +import pyqrcode +from django.db.models import Count, Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone + +from .models import ( + AccessZone, + BlacklistAuditLog, + BlacklistEntry, + ConfigChangeLog, + DataOperationLog, + DenialLog, + EntryExitLog, + EscortAssignment, + HostAuthority, + RegistrationQuota, + SecurityIncident, + SystemConfig, + VerificationLog, + VIPActivityLog, + Visit, + VisitingHours, + Visitor, + VisitorLocation, + VisitorPass, + VisitorRequest, + calculate_valid_until, +) +from .selectors import ( + get_available_escorts, + get_current_staff, + is_escort_available, +) + +# BR-046: default minimum VIP level that forces a dedicated escort when +# SystemConfig has not been seeded yet. +DEFAULT_ESCORT_THRESHOLD = 3 + + +def _get_escort_threshold() -> int: + """BR-046: minimum VIP level that mandates an escort, from SystemConfig.""" + cfg = SystemConfig.objects.filter(key="escort_threshold").first() + if not cfg: + return DEFAULT_ESCORT_THRESHOLD + try: + return max(1, int(cfg.value)) + except (TypeError, ValueError): + return DEFAULT_ESCORT_THRESHOLD + + +class RegistrationError(Exception): + pass + + +class WorkflowError(Exception): + pass + + +class ConfigError(Exception): + pass + + +class DataOperationError(Exception): + pass + + +_CSV_INJECTION_PREFIXES = ("=", "+", "-", "@", "\t", "\r") + + +def _csv_safe(value): + """Neutralise CSV formula-injection payloads (Excel/Sheets auto-eval).""" + if value is None: + return "" + text = str(value) + if text and text[0] in _CSV_INJECTION_PREFIXES: + return "'" + text + return text + + +def _generate_pass_qr(visitor_pass, visit): + """Generate a PNG QR code as a base64 data-URI string. + + Encodes only the opaque pass identifier and expiry. Visitor identity is + resolved server-side on scan, so the printed pass cannot be lifted with a + consumer QR scanner to reveal Aadhaar/passport or host details. + """ + qr_payload = json.dumps({ + "pass_number": visitor_pass.pass_number, + "visit_id": visit.id, + "valid_until": visitor_pass.valid_until.isoformat(), + }) + qr = pyqrcode.create(qr_payload, error="M") + buffer = io.BytesIO() + qr.png(buffer, scale=6, quiet_zone=2) + b64 = base64.b64encode(buffer.getvalue()).decode() + return f"data:image/png;base64,{b64}" + + +def register_visitor(data): + """Register a visitor and create a new visit. Returns (visitor, visit). + + Enforces: + BR-001 Valid ID required + BR-002 ID format validation + BR-003 Duration limits + BR-007 Daily registration cap + BR-018 Blacklist enforcement + BR-063 Visiting hours check + + Triggers: + BR-005 Request ID generation + BR-006 Host notification + + Raises RegistrationError on any validation failure. + """ + from .notifications import notify_host_visitor_request + from .validators import validate_duration, validate_id_format, validate_visiting_hours + + # BR-001: valid ID required + if not data.get("id_number") or not data.get("id_type"): + raise RegistrationError("A valid identity document is required.") + + # BR-002: ID format validation + valid, msg = validate_id_format(data["id_type"], data["id_number"]) + if not valid: + raise RegistrationError(msg) + + # BR-003: duration limits + duration = data.get("expected_duration_minutes", 60) + is_vip = data.get("is_vip", False) + valid, msg = validate_duration(duration, is_vip=is_vip) + if not valid: + raise RegistrationError(msg) + + # BR-063: visiting hours check + valid, msg = validate_visiting_hours() + if not valid: + raise RegistrationError(msg) + + # BR-018: blacklist enforcement + blacklist_hit = BlacklistEntry.objects.filter( + id_number=data["id_number"], active=True, + ).exists() + if blacklist_hit: + raise RegistrationError("Visitor is blacklisted; registration blocked.") + + visitor, _ = Visitor.objects.update_or_create( + id_number=data["id_number"], + defaults={ + "full_name": data["full_name"], + "id_type": data["id_type"], + "contact_phone": data["contact_phone"], + "contact_email": data.get("contact_email", ""), + "photo_reference": data.get("photo_reference", ""), + }, + ) + + # BR-046: allow registering with an explicit vip_level so the visit is + # escort-eligible immediately. If is_vip is set but vip_level is 0, + # coerce to 1 so the visit isn't silently downgraded. + vip_level = int(data.get("vip_level") or 0) + if is_vip and vip_level == 0: + vip_level = 1 + + visit = Visit.objects.create( + visitor=visitor, + purpose=data["purpose"], + host_name=data["host_name"], + host_department=data["host_department"], + host_contact=data.get("host_contact", ""), + expected_duration_minutes=duration, + is_vip=is_vip, + vip_level=vip_level, + ) + + # BR-005: unique request ID + _create_request_id(visit) + + # BR-006: host notification + notify_host_visitor_request(visit) + + return visitor, visit + + +def verify_visitor(data, user): + """Verify a visitor's identity. Returns (visit, passed). + + Raises WorkflowError on verification failure. + """ + visit = get_object_or_404(Visit, id=data["visit_id"]) + verifier = get_current_staff(user) + + VerificationLog.objects.create( + visit=visit, + verifier=verifier, + method=data["method"], + result=data["result"], + notes=data.get("notes", ""), + ) + + if not data["result"]: + visit.status = Visit.STATUS_DENIED + visit.denial_reason = "verification_failed" + visit.denial_remarks = data.get("notes", "") + visit.save(update_fields=["status", "denial_reason", "denial_remarks"]) + DenialLog.objects.create( + visit=visit, + reason="verification_failed", + remarks=data.get("notes", ""), + escalated=True, + ) + raise WorkflowError("Verification failed; entry denied.") + + visit.status = Visit.STATUS_VERIFIED + visit.verified_at = timezone.now() + visit.save(update_fields=["status", "verified_at"]) + return visit + + +def issue_pass(data): + """Issue a visitor pass with QR code. Returns (visit, visitor_pass, qr_data_uri). + + Raises WorkflowError if the visit is in an invalid state. + """ + visit = get_object_or_404(Visit, id=data["visit_id"]) + + if visit.status in {Visit.STATUS_DENIED, Visit.STATUS_EXITED}: + raise WorkflowError("Cannot issue pass for denied or closed visit.") + if visit.status not in {Visit.STATUS_VERIFIED, Visit.STATUS_PASS_ISSUED}: + raise WorkflowError("Visit must be verified before issuing a pass.") + + now = timezone.now() + valid_until = calculate_valid_until(now, visit.expected_duration_minutes, visit.is_vip) + visitor_pass, _ = VisitorPass.objects.update_or_create( + visit=visit, + defaults={ + "valid_from": now, + "valid_until": valid_until, + "authorized_zones": data.get("authorized_zones", "public"), + "status": VisitorPass.PASS_ISSUED, + "is_vip_pass": visit.is_vip, + }, + ) + + qr_data_uri = _generate_pass_qr(visitor_pass, visit) + visitor_pass.barcode_data = qr_data_uri + visitor_pass.save(update_fields=["barcode_data"]) + + visit.status = Visit.STATUS_PASS_ISSUED + visit.pass_issued_at = now + visit.save(update_fields=["status", "pass_issued_at"]) + + # BR-016: approval notification to visitor + security + from .notifications import notify_approval + notify_approval(visit, visitor_pass) + + # BR-047: VIP activity logging + if visit.is_vip: + VIPActivityLog.objects.create( + visit=visit, + action="pass_issued", + details=f"VIP pass {visitor_pass.pass_number} issued, zones: {visitor_pass.authorized_zones}", + ) + + return visit, visitor_pass, qr_data_uri + + +def record_entry(data, user): + """Record a visitor entry. Returns the visit. + + Raises WorkflowError if the visit is in an invalid state. + """ + visit = get_object_or_404(Visit, id=data["visit_id"]) + if visit.status not in {Visit.STATUS_PASS_ISSUED, Visit.STATUS_INSIDE}: + raise WorkflowError("Pass must be issued before entry.") + + EntryExitLog.objects.create( + visit=visit, + action=EntryExitLog.ACTION_ENTRY, + gate_name=data["gate_name"], + recorded_by=get_current_staff(user), + items_declared=data.get("items_declared", ""), + ) + + visit.status = Visit.STATUS_INSIDE + visit.entry_at = visit.entry_at or timezone.now() + visit.save(update_fields=["status", "entry_at"]) + + # BR-036: activate location tracking + from .tasks import activate_location_tracking + activate_location_tracking(visit, data["gate_name"], recorded_by=get_current_staff(user)) + + # BR-045: VIP arrival notification + if visit.is_vip: + from .notifications import notify_vip_arrival + notify_vip_arrival(visit) + VIPActivityLog.objects.create( + visit=visit, + action="campus_entry", + details=f"VIP entered via {data['gate_name']}", + recorded_by=get_current_staff(user), + ) + + return visit + + +def record_exit(data, user): + """Record a visitor exit. Returns the visit. + + Also auto-releases any active escort assignments on the visit, since + the escort's duty ends the moment the VIP leaves campus. + + Raises WorkflowError if the visitor is not inside. + """ + visit = get_object_or_404(Visit, id=data["visit_id"]) + if visit.status != Visit.STATUS_INSIDE: + raise WorkflowError("Visitor is not inside.") + + staff = get_current_staff(user) + EntryExitLog.objects.create( + visit=visit, + action=EntryExitLog.ACTION_EXIT, + gate_name=data["gate_name"], + recorded_by=staff, + items_declared=data.get("items_declared", ""), + ) + + visit.status = Visit.STATUS_EXITED + visit.exit_at = timezone.now() + visit.save(update_fields=["status", "exit_at"]) + + visitor_pass = getattr(visit, "visitor_pass", None) + if visitor_pass: + visitor_pass.status = VisitorPass.PASS_RETURNED + visitor_pass.save(update_fields=["status"]) + + # Auto-release any active escort assignments — the escort's duty ends + # when the visitor leaves campus. + active_escorts = EscortAssignment.objects.filter( + visit=visit, released_at__isnull=True + ) + for assignment in active_escorts: + assignment.released_at = timezone.now() + assignment.save(update_fields=["released_at"]) + VIPActivityLog.objects.create( + visit=visit, + action="escort_auto_released", + details=f"Escort {assignment.escort} released on visitor exit", + recorded_by=staff, + ) + + return visit + + +def deny_entry(data): + """Deny entry for a visit. Returns (visit, denial_log).""" + visit = get_object_or_404(Visit, id=data["visit_id"]) + visit.status = Visit.STATUS_DENIED + visit.denial_reason = data["reason"] + visit.denial_remarks = data.get("remarks", "") + visit.save(update_fields=["status", "denial_reason", "denial_remarks"]) + + denial = DenialLog.objects.create( + visit=visit, + reason=data["reason"], + remarks=data.get("remarks", ""), + escalated=data.get("escalated", False), + ) + return visit, denial + + +def log_incident(data, user): + """Log a security incident. Returns the incident.""" + visit = None + visitor = None + if data.get("visit_id"): + visit = get_object_or_404(Visit, id=data["visit_id"]) + visitor = visit.visitor + elif data.get("visitor_id"): + visitor = get_object_or_404(Visitor, id=data["visitor_id"]) + + incident = SecurityIncident.objects.create( + visit=visit, + visitor=visitor, + recorded_by=get_current_staff(user), + severity=data["severity"], + issue_type=data["issue_type"], + description=data["description"], + ) + + # BR-050 / BR-053 / BR-055 / BR-056: escalation + status update + severity response + from .notifications import ( + notify_escalation, + notify_high_severity_response, + notify_low_severity, + ) + from .tasks import determine_containment_actions, update_visitor_status_on_incident + + notify_escalation(incident) + determine_containment_actions(incident) + update_visitor_status_on_incident(incident) + + if incident.severity == SecurityIncident.SEVERITY_CRITICAL: + notify_high_severity_response(incident) + elif incident.severity == SecurityIncident.SEVERITY_LOW: + notify_low_severity(incident) + + return incident + + +# ============================================================================ +# BR-004: Host verification +# ============================================================================ +def verify_host(visit, user): + """Verify the host exists and has authority to receive visitors.""" + authority = HostAuthority.objects.filter(user=user).first() + if authority is None: + raise WorkflowError("Host does not have a registered authority record.") + return authority + + +# ============================================================================ +# BR-005: Request ID generation (called from register_visitor) +# ============================================================================ +def _create_request_id(visit): + """Generate and persist a unique request ID for the visit.""" + request_id = VisitorRequest.generate_request_id() + return VisitorRequest.objects.create(request_id=request_id, visit=visit) + + +# ============================================================================ +# BR-019 / BR-020 / BR-021: Pass scan & validation +# ============================================================================ +def scan_visitor_pass(data, user): + """ + Validate a visitor pass on scan: authenticity, expiry, and area access. + Returns (visit, scan_result_dict). + """ + from .notifications import notify_invalid_pass_scan, notify_unauthorized_area + from .permissions import check_zone_access + from .tasks import activate_location_tracking + from .validators import validate_pass_expiry + + visit = get_object_or_404(Visit, id=data["visit_id"]) + checkpoint = data.get("checkpoint_name", "Unknown") + staff = get_current_staff(user) + + visitor_pass = getattr(visit, "visitor_pass", None) + if visitor_pass is None: + notify_invalid_pass_scan(visit, checkpoint) + raise WorkflowError("No pass found for this visit.") + + # BR-020: expiry check + valid, msg = validate_pass_expiry(visitor_pass) + if not valid: + notify_invalid_pass_scan(visit, checkpoint) + raise WorkflowError(msg) + + # BR-021: area/zone access + zone_name = data.get("zone_name", "") + if zone_name: + allowed, zone_msg = check_zone_access(visitor_pass, zone_name) + if not allowed: + notify_unauthorized_area(visit, zone_name) + raise WorkflowError(zone_msg) + + # BR-036: activate location tracking + activate_location_tracking(visit, checkpoint, recorded_by=staff) + + # BR-022: log entry/exit + EntryExitLog.objects.create( + visit=visit, + action=EntryExitLog.ACTION_ENTRY, + gate_name=checkpoint, + recorded_by=staff, + items_declared=data.get("items_declared", ""), + ) + + return visit, { + "valid": True, + "pass_number": visitor_pass.pass_number, + "visitor": visit.visitor.full_name, + "zones": visitor_pass.authorized_zones, + "valid_until": visitor_pass.valid_until.isoformat(), + } + + +# ============================================================================ +# BR-023: Manual verification fallback +# ============================================================================ +def manual_pass_verification(data, user): + """Fallback when QR scan fails — manual identity check by security.""" + visit = get_object_or_404(Visit, id=data["visit_id"]) + staff = get_current_staff(user) + + VerificationLog.objects.create( + visit=visit, + verifier=staff, + method=VerificationLog.METHOD_MANUAL, + result=data.get("result", True), + notes=data.get("notes", "Manual verification — scan failed"), + ) + + return visit + + +# ============================================================================ +# BR-030–035: Blacklist management +# ============================================================================ +def add_to_blacklist(data, user): + """Add a visitor to the blacklist with reason and evidence (BR-032).""" + from .notifications import notify_checkpoints_blacklist + + staff = get_current_staff(user) + entry = BlacklistEntry.objects.create( + id_number=data["id_number"], + reason=data["reason"], + evidence=data.get("evidence", ""), + added_by=staff, + active=True, + ) + + # BR-034: audit log + BlacklistAuditLog.objects.create( + blacklist_entry=entry, + action=BlacklistAuditLog.ACTION_ADD, + performed_by=staff, + id_number=entry.id_number, + reason=entry.reason, + evidence=entry.evidence, + ) + + # BR-033: checkpoint alert + notify_checkpoints_blacklist(entry, "added") + + return entry + + +def remove_from_blacklist(entry_id, user): + """Deactivate a blacklist entry (BR-035: requires admin authority).""" + from .notifications import notify_checkpoints_blacklist + + staff = get_current_staff(user) + entry = get_object_or_404(BlacklistEntry, id=entry_id) + entry.active = False + entry.save(update_fields=["active"]) + + BlacklistAuditLog.objects.create( + blacklist_entry=entry, + action=BlacklistAuditLog.ACTION_REMOVE, + performed_by=staff, + id_number=entry.id_number, + reason="Removed from blacklist", + ) + notify_checkpoints_blacklist(entry, "removed") + + return entry + + +# ============================================================================ +# BR-041–047: VIP processing +# ============================================================================ +def identify_vip(visit): + """BR-041: Check if visitor is VIP via DB lookup or admin designation.""" + visitor = visit.visitor + # Check if visitor has any active VIP visits + has_vip_history = Visit.objects.filter(visitor=visitor, is_vip=True).exists() + return visit.is_vip or has_vip_history + + +def process_vip_visit(data, user): + """BR-042/043/046: VIP processing with optional bypass and auto-escort.""" + from .notifications import notify_vip_arrival + from applications.globals.models import ExtraInfo + + visit = get_object_or_404(Visit, id=data["visit_id"]) + + # BR-046 input: a numeric VIP level; legacy callers who only send the + # boolean flag still get a sensible value (=1, plain VIP, no escort). + raw_level = data.get("vip_level") + if raw_level is None: + new_level = max(visit.vip_level, 1) + else: + try: + new_level = max(0, int(raw_level)) + except (TypeError, ValueError): + new_level = visit.vip_level + + update_fields = [] + if not visit.is_vip: + visit.is_vip = True + update_fields.append("is_vip") + if new_level != visit.vip_level: + visit.vip_level = new_level + update_fields.append("vip_level") + if update_fields: + visit.save(update_fields=update_fields) + + # BR-043: bypass standard approval for authorised VIPs + if data.get("bypass_approval", False): + visit.status = Visit.STATUS_VERIFIED + visit.verified_at = timezone.now() + visit.save(update_fields=["status", "verified_at"]) + + # BR-045: VIP arrival notification + notify_vip_arrival(visit) + + staff = get_current_staff(user) + + # BR-046: when vip_level crosses the threshold, auto-assign an available + # qualified escort if one exists and none is currently on this visit. + auto_escort = None + threshold = _get_escort_threshold() + already_assigned = EscortAssignment.objects.filter( + visit=visit, released_at__isnull=True + ).exists() + if visit.vip_level >= threshold and not already_assigned: + candidate_id = data.get("escort_id") + candidate = None + if candidate_id: + candidate = ExtraInfo.objects.filter(id=candidate_id).first() + if candidate and not is_escort_available(candidate.id): + candidate = None + if candidate is None: + candidate = get_available_escorts().first() + if candidate is not None: + auto_escort = EscortAssignment.objects.create( + visit=visit, + escort=candidate, + notes="Auto-assigned per BR-046 (vip_level >= escort_threshold).", + ) + VIPActivityLog.objects.create( + visit=visit, + action="escort_auto_assigned", + details=f"Escort: {candidate} (vip_level={visit.vip_level}, threshold={threshold})", + recorded_by=staff, + ) + + # BR-047: enhanced VIP activity logging + VIPActivityLog.objects.create( + visit=visit, + action="vip_processing_initiated", + details=json.dumps({**data, "resolved_vip_level": visit.vip_level}), + recorded_by=staff, + ) + + return visit, auto_escort + + +def assign_escort(data, user): + """BR-046: Assign a dedicated escort to a VIP visitor. + + Policy (staff-tier manual assignment): + IF visit.is_vip AND escort_available + THEN assign_escort(qualified_personnel) + + The numeric `escort_threshold` is retained as the *auto*-escort trigger + inside `process_vip_visit`. Manual assignment by Security Staff only + requires that the visit is flagged VIP, so a gate officer can request + an escort for any VIP without first elevating vip_level via admin + tooling. + + Enforcement: + * The visit must be VIP (`is_vip=True`). + * If a specific escort_id is supplied it must not currently hold an + unreleased assignment; if omitted we pick the first available + qualified staff member. + * Only one active escort per visit at a time. + * Raises WorkflowError when the precondition is not met. + """ + from applications.globals.models import ExtraInfo + + visit = get_object_or_404(Visit, id=data["visit_id"]) + + if not visit.is_vip: + raise WorkflowError( + "Escort assignment requires the visit to be marked VIP. " + "Re-register the visitor with the VIP flag, or have an admin " + "run VIP processing on this visit first." + ) + + # Resolve the escort. Explicit escort_id wins; fall back to auto-pick. + escort_staff = None + candidate_id = data.get("escort_id") + if candidate_id: + escort_staff = ExtraInfo.objects.filter(id=candidate_id).first() + if escort_staff is None: + raise WorkflowError(f"Escort id={candidate_id} not found.") + if not is_escort_available(escort_staff.id): + raise WorkflowError( + f"Escort {escort_staff} is already on an active assignment." + ) + else: + escort_staff = get_available_escorts().first() + if escort_staff is None: + raise WorkflowError("No qualified escorts are currently available.") + + # BR-046: prevent duplicating an escort on the same active visit. + if EscortAssignment.objects.filter( + visit=visit, released_at__isnull=True + ).exists(): + raise WorkflowError("Visit already has an active escort assignment.") + + assignment = EscortAssignment.objects.create( + visit=visit, + escort=escort_staff, + notes=data.get("notes", ""), + ) + + recorder = get_current_staff(user) or escort_staff + VIPActivityLog.objects.create( + visit=visit, + action="escort_assigned", + details=( + f"Escort: {escort_staff} (vip_level={visit.vip_level}, " + f"auto_escort_threshold={_get_escort_threshold()})" + ), + recorded_by=recorder, + ) + + return assignment + + +def release_escort(assignment_id, user): + """Release an escort assignment when VIP visit concludes.""" + assignment = get_object_or_404(EscortAssignment, id=assignment_id) + if assignment.released_at is not None: + return assignment + assignment.released_at = timezone.now() + assignment.save(update_fields=["released_at"]) + + recorder = get_current_staff(user) + VIPActivityLog.objects.create( + visit=assignment.visit, + action="escort_released", + details=f"Escort: {assignment.escort}", + recorded_by=recorder, + ) + return assignment + + +# ============================================================================ +# BR-025–029: Report generation +# ============================================================================ +def generate_report(params, user): + """Generate a visitor report with statistics (BR-025–029).""" + from .validators import validate_report_params + + valid, msg = validate_report_params(params) + if not valid: + raise WorkflowError(msg) + + start_date = params["start_date"] + end_date = params["end_date"] + report_type = params["report_type"] + + visits = Visit.objects.filter( + registered_at__date__gte=start_date, + registered_at__date__lte=end_date, + ) + + # BR-028: Statistics calculation + total_visits = visits.count() + status_breakdown = dict(visits.values_list("status").annotate(count=Count("id"))) + vip_visits = visits.filter(is_vip=True).count() + + incidents = SecurityIncident.objects.filter( + created_at__date__gte=start_date, + created_at__date__lte=end_date, + ) + total_incidents = incidents.count() + severity_breakdown = dict(incidents.values_list("severity").annotate(count=Count("id"))) + + # BR-029: Standard report format + report = { + "report_type": report_type, + "generated_at": timezone.now().isoformat(), + "generated_by": str(user), + "date_range": {"start": str(start_date), "end": str(end_date)}, + "summary": { + "total_visits": total_visits, + "status_breakdown": status_breakdown, + "vip_visits": vip_visits, + "total_incidents": total_incidents, + "severity_breakdown": severity_breakdown, + }, + "trends": { + "daily_averages": round(total_visits / max((end_date - start_date).days, 1), 2), + "incident_rate": round(total_incidents / max(total_visits, 1) * 100, 2), + }, + } + + return report + + +# ============================================================================ +# BR-030–031: Visitor / incident history +# ============================================================================ +def get_visitor_history(id_number: str): + """BR-030: Full visitor history for blacklist evaluation.""" + visitor = get_object_or_404(Visitor, id_number=id_number) + visits = Visit.objects.filter(visitor=visitor).order_by("-registered_at") + incidents = SecurityIncident.objects.filter(visitor=visitor).order_by("-created_at") + blacklist_entries = BlacklistEntry.objects.filter(id_number=id_number) + + return { + "visitor": visitor, + "visits": visits, + "incidents": incidents, + "blacklist_entries": blacklist_entries, + } + + +def get_incident_history(id_number: str): + """BR-031: Incident history for blacklist decision-making.""" + visitor = Visitor.objects.filter(id_number=id_number).first() + if visitor is None: + return [] + return SecurityIncident.objects.filter(visitor=visitor).order_by("-created_at") + + +# ============================================================================ +# BR-057–066: System configuration management +# ============================================================================ +def get_or_create_config(key: str, default: str = "", description: str = "") -> SystemConfig: + """Retrieve a config entry, creating it with the default if absent.""" + config, _ = SystemConfig.objects.get_or_create( + key=key, defaults={"value": default, "description": description} + ) + return config + + +def update_config(key: str, value: str, user, description: str = ""): + """ + Update a system configuration value with validation, conflict detection, + logging, cache refresh, and user notification. + """ + from .notifications import notify_config_change + from .tasks import refresh_config_cache + from .validators import detect_config_conflicts, validate_config_value + + # BR-058: validate + valid, msg = validate_config_value(key, value) + if not valid: + raise ConfigError(msg) + + # BR-059: conflict detection + current_config = {c.key: c.value for c in SystemConfig.objects.all()} + conflicts = detect_config_conflicts({key: value}, current_config) + if conflicts: + raise ConfigError(f"Configuration conflicts detected: {conflicts}") + + staff = get_current_staff(user) + config, created = SystemConfig.objects.get_or_create( + key=key, defaults={"value": value, "description": description, "updated_by": staff} + ) + + if not created: + old_value = config.value + config.value = value + config.updated_by = staff + if description: + config.description = description + config.save() + + # BR-061: change logging + ConfigChangeLog.objects.create( + config=config, + old_value=old_value, + new_value=value, + changed_by=staff, + ) + + # BR-060: cache refresh + refresh_config_cache() + + # BR-062: user notification + notify_config_change(config, str(user)) + + return config + + +# ============================================================================ +# BR-063: Visiting hours management +# ============================================================================ +def configure_visiting_hours(data): + """Create or update visiting hours configuration.""" + hours, _ = VisitingHours.objects.update_or_create( + day_of_week=data["day_of_week"], + defaults={ + "start_time": data["start_time"], + "end_time": data["end_time"], + "is_holiday": data.get("is_holiday", False), + "holiday_name": data.get("holiday_name", ""), + "active": data.get("active", True), + }, + ) + return hours + + +# ============================================================================ +# BR-064: Access zone configuration +# ============================================================================ +def configure_access_zone(data): + """Create or update an access zone.""" + zone, _ = AccessZone.objects.update_or_create( + name=data["name"], + defaults={ + "description": data.get("description", ""), + "requires_vip": data.get("requires_vip", False), + "requires_escort": data.get("requires_escort", False), + "is_restricted": data.get("is_restricted", False), + "active": data.get("active", True), + }, + ) + return zone + + +# ============================================================================ +# BR-067–074: Data export / import +# ============================================================================ +def export_visitor_data(params, user): + """Export visitor data with validation and logging (BR-067–072).""" + from .validators import validate_data_format, validate_date_range + + fmt = params.get("format", "csv") + valid, msg = validate_data_format(fmt) + if not valid: + raise DataOperationError(msg) + + start_date = params.get("start_date") + end_date = params.get("end_date") + if start_date and end_date: + valid, msg = validate_date_range(start_date, end_date) + if not valid: + raise DataOperationError(msg) + + staff = get_current_staff(user) + # Convert date objects to strings for JSON serialization + safe_params = {k: (v.isoformat() if isinstance(v, date) else v) for k, v in params.items()} + op_log = DataOperationLog.objects.create( + operation_type=DataOperationLog.OP_EXPORT, + status=DataOperationLog.STATUS_RUNNING, + initiated_by=staff, + parameters=safe_params, + ) + + try: + visits_qs = Visit.objects.select_related("visitor").order_by("-registered_at") + if start_date and end_date: + visits_qs = visits_qs.filter( + registered_at__date__gte=start_date, + registered_at__date__lte=end_date, + ) + + # BR-069: data transformation. All string-typed values are passed + # through _csv_safe to neutralise CSV formula injection. + rows = [] + for v in visits_qs: + rows.append({ + "visit_id": v.id, + "visitor_name": _csv_safe(v.visitor.full_name), + "id_number": _csv_safe(v.visitor.id_number), + "id_type": _csv_safe(v.visitor.id_type), + "purpose": _csv_safe(v.purpose), + "host_name": _csv_safe(v.host_name), + "host_department": _csv_safe(v.host_department), + "status": _csv_safe(v.status), + "is_vip": v.is_vip, + "registered_at": v.registered_at.isoformat() if v.registered_at else "", + "entry_at": v.entry_at.isoformat() if v.entry_at else "", + "exit_at": v.exit_at.isoformat() if v.exit_at else "", + }) + + if fmt == "csv": + output = io.StringIO() + if rows: + writer = csv.DictWriter(output, fieldnames=rows[0].keys()) + writer.writeheader() + writer.writerows(rows) + result = output.getvalue() + else: + result = json.dumps(rows, indent=2) + + # BR-071: completion validation + op_log.status = DataOperationLog.STATUS_COMPLETED + op_log.records_processed = len(rows) + op_log.result_summary = f"Exported {len(rows)} records in {fmt} format." + op_log.completed_at = timezone.now() + op_log.save() + + return result, op_log + + except Exception as exc: + # BR-070 / BR-074: error handling + recovery. Exception details are + # persisted to the operation log for ops review but not echoed to the + # client (avoids schema/stack disclosure). + op_log.status = DataOperationLog.STATUS_FAILED + op_log.error_details = str(exc) + op_log.completed_at = timezone.now() + op_log.save() + raise DataOperationError("Export failed; see operation log for details.") from exc + + +def import_visitor_data(data_content: str, fmt: str, field_mapping: dict, user): + """Import visitor data with field mapping and rollback (BR-067–074).""" + from .validators import validate_data_format + + valid, msg = validate_data_format(fmt) + if not valid: + raise DataOperationError(msg) + + staff = get_current_staff(user) + op_log = DataOperationLog.objects.create( + operation_type=DataOperationLog.OP_IMPORT, + status=DataOperationLog.STATUS_RUNNING, + initiated_by=staff, + parameters={"format": fmt, "field_mapping": field_mapping}, + ) + + created_visitors = [] + try: + if fmt == "csv": + reader = csv.DictReader(io.StringIO(data_content)) + records = list(reader) + else: + records = json.loads(data_content) + + # BR-073: field mapping + mapped_records = [] + for record in records: + mapped = {} + for source_field, target_field in field_mapping.items(): + mapped[target_field] = record.get(source_field, "") + mapped_records.append(mapped) + + # BR-069: data transformation & creation + for row in mapped_records: + visitor, _ = Visitor.objects.update_or_create( + id_number=row.get("id_number", ""), + defaults={ + "full_name": row.get("full_name", "Unknown"), + "id_type": row.get("id_type", "national_id"), + "contact_phone": row.get("contact_phone", ""), + "contact_email": row.get("contact_email", ""), + }, + ) + created_visitors.append(visitor) + + # BR-071: completion validation + op_log.status = DataOperationLog.STATUS_COMPLETED + op_log.records_processed = len(created_visitors) + op_log.result_summary = f"Imported {len(created_visitors)} visitor records." + op_log.completed_at = timezone.now() + op_log.save() + + return created_visitors, op_log + + except Exception as exc: + # BR-070 / BR-074: rollback on failure + for v in created_visitors: + v.delete() + + op_log.status = DataOperationLog.STATUS_ROLLED_BACK + op_log.error_details = str(exc) + op_log.completed_at = timezone.now() + op_log.save() + raise DataOperationError("Import failed and rolled back; see operation log for details.") from exc diff --git a/applications/vms/tasks.py b/applications/vms/tasks.py new file mode 100644 index 000000000..698a53b57 --- /dev/null +++ b/applications/vms/tasks.py @@ -0,0 +1,164 @@ +""" +VMS Background Tasks — periodic and event-driven tasks. + +Covers: + BR-024 Overstay detection and alerting + BR-036 Location tracking activation on pass scan + BR-038 Real-time dashboard push (placeholder) + BR-052 Containment action requirements + BR-053 Visitor status update on incident + BR-060 Configuration cache refresh +""" + +import logging +from django.utils import timezone + +from .models import SecurityIncident, Visit, VisitorPass + +logger = logging.getLogger("vms.tasks") + + +# --------------------------------------------------------------------------- +# BR-024: Overstay detection +# --------------------------------------------------------------------------- +def detect_overstays(): + """ + Scan all active visits with issued passes and flag those whose + pass has expired while the visitor is still inside. + Returns list of (visit, visitor_pass) tuples that are overstaying. + """ + from .notifications import notify_overstay + + now = timezone.now() + overstaying = [] + + active_visits = Visit.objects.filter(status=Visit.STATUS_INSIDE).select_related("visitor_pass", "visitor") + + for visit in active_visits: + vpass = getattr(visit, "visitor_pass", None) + if vpass and vpass.valid_until < now: + overstaying.append((visit, vpass)) + notify_overstay(visit, vpass) + logger.warning( + "Overstay detected: Visit %s (%s), pass expired at %s", + visit.id, visit.visitor.full_name, vpass.valid_until, + ) + + return overstaying + + +# --------------------------------------------------------------------------- +# BR-036: Location tracking activation +# --------------------------------------------------------------------------- +def activate_location_tracking(visit, checkpoint_name: str, recorded_by=None): + """ + Record a location scan event. Called when a visitor pass is scanned + at any checkpoint. Creates a VisitorLocation entry. + """ + from .models import AccessZone, VisitorLocation + + zone = AccessZone.objects.filter(name__icontains=checkpoint_name).first() + + location = VisitorLocation.objects.create( + visit=visit, + zone=zone, + checkpoint_name=checkpoint_name, + recorded_by=recorded_by, + ) + logger.info("Location recorded: Visit %s at %s", visit.id, checkpoint_name) + return location + + +# --------------------------------------------------------------------------- +# BR-038: Real-time dashboard push (placeholder) +# --------------------------------------------------------------------------- +def push_dashboard_update(event_type: str, payload: dict): + """ + Placeholder for WebSocket/SSE push to the tracking dashboard. + In production, integrate with Django Channels or similar. + """ + logger.info("Dashboard push [%s]: %s", event_type, payload) + return True + + +# --------------------------------------------------------------------------- +# BR-052: Containment actions +# --------------------------------------------------------------------------- +CONTAINMENT_ACTIONS = { + "critical": [ + "Initiate campus lockdown", + "Notify police liaison", + "Secure all entry/exit points", + "Activate CCTV monitoring for all zones", + ], + "high": [ + "Restrict visitor movement to current zone", + "Deploy patrol officers to area", + "Notify security head", + ], + "medium": [ + "Monitor visitor via CCTV", + "Alert nearest patrol officer", + ], + "low": [ + "Log for supervisor review", + ], +} + + +def determine_containment_actions(incident: SecurityIncident) -> list[str]: + """ + Return required containment actions based on incident severity. + Also persists them on the incident record. + """ + actions = CONTAINMENT_ACTIONS.get(incident.severity, []) + incident.containment_actions = "\n".join(actions) + incident.save(update_fields=["containment_actions"]) + logger.info( + "Containment actions for %s: %s", + incident.tracking_number, actions, + ) + return actions + + +# --------------------------------------------------------------------------- +# BR-053: Visitor status update on incident +# --------------------------------------------------------------------------- +def update_visitor_status_on_incident(incident: SecurityIncident): + """ + Update a visitor's current visit status when they are involved + in a security incident. + """ + visit = incident.visit + if visit is None: + return None + + if incident.severity in (SecurityIncident.SEVERITY_CRITICAL, SecurityIncident.SEVERITY_HIGH): + visit.status = Visit.STATUS_DENIED + visit.denial_reason = f"security_incident_{incident.tracking_number}" + visit.denial_remarks = incident.description[:200] + visit.save(update_fields=["status", "denial_reason", "denial_remarks"]) + logger.info("Visit %s denied due to incident %s", visit.id, incident.tracking_number) + return visit + + +# --------------------------------------------------------------------------- +# BR-060: Configuration cache refresh +# --------------------------------------------------------------------------- +_config_cache: dict[str, str] = {} + + +def refresh_config_cache(): + """Reload all system configuration values into the in-memory cache.""" + from .models import SystemConfig + global _config_cache + _config_cache = {c.key: c.value for c in SystemConfig.objects.all()} + logger.info("VMS config cache refreshed: %d entries", len(_config_cache)) + return _config_cache + + +def get_cached_config(key: str, default: str = "") -> str: + """Retrieve a config value from cache, refreshing if empty.""" + if not _config_cache: + refresh_config_cache() + return _config_cache.get(key, default) diff --git a/applications/vms/tests/__init__.py b/applications/vms/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/applications/vms/tests/test_module.py b/applications/vms/tests/test_module.py new file mode 100644 index 000000000..8f96e3cbb --- /dev/null +++ b/applications/vms/tests/test_module.py @@ -0,0 +1,63 @@ +from django.contrib.auth.models import User +from django.test import TestCase +from rest_framework.test import APIClient + +from applications.vms.models import Visit, Visitor +from applications.vms.services import RegistrationError, register_visitor + + +class RegisterVisitorServiceTest(TestCase): + def test_register_creates_visitor_and_visit(self): + data = { + "full_name": "Test Visitor", + "id_number": "TEST001", + "id_type": "aadhaar", + "contact_phone": "9999999999", + "purpose": "Meeting", + "host_name": "Dr. Host", + "host_department": "CSE", + } + visitor, visit = register_visitor(data) + self.assertEqual(visitor.full_name, "Test Visitor") + self.assertEqual(visit.status, Visit.STATUS_REGISTERED) + self.assertEqual(visit.visitor, visitor) + + def test_blacklisted_visitor_blocked(self): + from applications.vms.models import BlacklistEntry + BlacklistEntry.objects.create(id_number="BL001", reason="test", active=True) + data = { + "full_name": "Blocked Person", + "id_number": "BL001", + "id_type": "aadhaar", + "contact_phone": "0000000000", + "purpose": "Visit", + "host_name": "Host", + "host_department": "Dept", + } + with self.assertRaises(RegistrationError): + register_visitor(data) + + +class RegisterVisitorAPITest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="teststaff", password="pass1234") + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_register_endpoint_returns_201(self): + payload = { + "full_name": "API Visitor", + "id_number": "API001", + "id_type": "passport", + "contact_phone": "1234567890", + "purpose": "Conference", + "host_name": "Prof. X", + "host_department": "ECE", + } + response = self.client.post("/vms/register/", payload, format="json") + self.assertEqual(response.status_code, 201) + self.assertIn("visit_id", response.data) + + def test_active_endpoint_returns_200(self): + response = self.client.get("/vms/active/") + self.assertEqual(response.status_code, 200) diff --git a/applications/vms/validators.py b/applications/vms/validators.py new file mode 100644 index 000000000..238a2130c --- /dev/null +++ b/applications/vms/validators.py @@ -0,0 +1,232 @@ +""" +VMS Validators — centralised validation logic for the Visitor Management System. + +Covers: + BR-002 Identity document format validation + BR-003 Visit duration limits + BR-007 Daily registration limits per host + BR-008 Document upload validation + BR-020 Pass expiry enforcement + BR-025 Report parameter validation + BR-026 Date range validation + BR-058 Configuration value validation + BR-068 Data format validation (export/import) +""" + +import re +from datetime import date, timedelta + +from django.utils import timezone + +# --------------------------------------------------------------------------- +# BR-002: ID format patterns +# --------------------------------------------------------------------------- +ID_FORMAT_PATTERNS = { + "passport": re.compile(r"^[A-Z]{1}[0-9]{7}$"), # e.g. A1234567 + "national_id": re.compile(r"^[A-Z0-9]{6,20}$"), + "driver_license": re.compile(r"^[A-Z]{2}-\d{2}-\d{4,8}$"), # e.g. DL-09-12345 + "aadhaar": re.compile(r"^\d{12}$"), +} + + +def validate_id_format(id_type: str, id_number: str) -> tuple[bool, str]: + """Return (is_valid, message) for a given identity document number.""" + pattern = ID_FORMAT_PATTERNS.get(id_type) + if pattern is None: + return True, "No format rule for this ID type; accepted." + if pattern.match(id_number): + return True, "ID format is valid." + return False, f"ID number '{id_number}' does not match the expected format for {id_type}." + + +# --------------------------------------------------------------------------- +# BR-003: Duration limits per visitor category +# --------------------------------------------------------------------------- +DURATION_LIMITS = { + "general": 480, # 8 hours + "vip": 1440, # 24 hours + "contractor": 600, # 10 hours + "interview": 240, # 4 hours +} + + +def validate_duration(minutes: int, category: str = "general", is_vip: bool = False) -> tuple[bool, str]: + """Enforce maximum visit duration based on visitor category.""" + key = "vip" if is_vip else category + limit = DURATION_LIMITS.get(key, DURATION_LIMITS["general"]) + if minutes <= 0: + return False, "Duration must be a positive number." + if minutes > limit: + return False, f"Duration {minutes}m exceeds the {limit}m limit for category '{key}'." + return True, "Duration is within allowed limits." + + +# --------------------------------------------------------------------------- +# BR-007: Daily registration cap per host +# --------------------------------------------------------------------------- +DEFAULT_DAILY_LIMIT = 10 + + +def validate_daily_registration_limit(host_user, limit: int | None = None) -> tuple[bool, str]: + """Check if host has remaining registrations for today.""" + from .models import RegistrationQuota + cap = limit if limit is not None else DEFAULT_DAILY_LIMIT + today = date.today() + quota, _ = RegistrationQuota.objects.get_or_create(host_user=host_user, date=today, defaults={"count": 0}) + if quota.count >= cap: + return False, f"Host has reached the daily registration limit of {cap}." + return True, "Within daily limit." + + +def increment_daily_count(host_user) -> None: + from .models import RegistrationQuota + today = date.today() + quota, _ = RegistrationQuota.objects.get_or_create(host_user=host_user, date=today, defaults={"count": 0}) + quota.count += 1 + quota.save(update_fields=["count"]) + + +# --------------------------------------------------------------------------- +# BR-008: Upload validation +# --------------------------------------------------------------------------- +ALLOWED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".pdf"} +MAX_UPLOAD_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB + + +def validate_upload(file) -> tuple[bool, str]: + """Validate uploaded document file (format, size).""" + if file is None: + return False, "No file provided." + + name = getattr(file, "name", "") + ext = ("." + name.rsplit(".", 1)[-1]).lower() if "." in name else "" + if ext not in ALLOWED_UPLOAD_EXTENSIONS: + return False, f"File type '{ext}' not allowed. Accepted: {ALLOWED_UPLOAD_EXTENSIONS}" + + size = getattr(file, "size", 0) + if size > MAX_UPLOAD_SIZE_BYTES: + return False, f"File size {size} bytes exceeds the {MAX_UPLOAD_SIZE_BYTES} byte limit." + + return True, "File is valid." + + +# --------------------------------------------------------------------------- +# BR-020: Pass expiry check +# --------------------------------------------------------------------------- +def validate_pass_expiry(visitor_pass) -> tuple[bool, str]: + """Check whether a visitor pass is still valid (not expired).""" + now = timezone.now() + if visitor_pass.valid_until < now: + return False, f"Pass {visitor_pass.pass_number} expired at {visitor_pass.valid_until.isoformat()}." + if visitor_pass.status not in ("issued", "pending"): + return False, f"Pass status is '{visitor_pass.status}'; not valid for entry." + return True, "Pass is valid." + + +# --------------------------------------------------------------------------- +# BR-025 / BR-026: Report parameter & date range validation +# --------------------------------------------------------------------------- +MAX_REPORT_RANGE_DAYS = 365 + + +def validate_report_params(params: dict) -> tuple[bool, str]: + """Validate report generation request parameters.""" + report_type = params.get("report_type", "") + if report_type not in ("visitor_summary", "incident_summary", "access_log", "vip_report", "full_audit"): + return False, f"Unknown report type: '{report_type}'." + return validate_date_range(params.get("start_date"), params.get("end_date")) + + +def validate_date_range(start_date, end_date) -> tuple[bool, str]: + """Ensure date range is valid and within the allowed historical boundary.""" + if start_date is None or end_date is None: + return False, "Both start_date and end_date are required." + if start_date > end_date: + return False, "start_date must not be after end_date." + if end_date > date.today(): + return False, "end_date cannot be in the future." + delta = (end_date - start_date).days + if delta > MAX_REPORT_RANGE_DAYS: + return False, f"Date range ({delta} days) exceeds maximum of {MAX_REPORT_RANGE_DAYS} days." + return True, "Date range is valid." + + +# --------------------------------------------------------------------------- +# BR-058: Configuration value validation +# --------------------------------------------------------------------------- +CONFIG_VALIDATORS = { + "max_daily_visitors": lambda v: v.isdigit() and int(v) > 0, + "default_visit_duration": lambda v: v.isdigit() and 5 <= int(v) <= 1440, + "vip_extra_minutes": lambda v: v.isdigit() and 0 <= int(v) <= 480, + "enable_location_tracking": lambda v: v.lower() in ("true", "false"), + "enable_notifications": lambda v: v.lower() in ("true", "false"), + # BR-046: minimum vip_level that triggers mandatory escort assignment. + "escort_threshold": lambda v: v.isdigit() and 1 <= int(v) <= 10, +} + + +def validate_config_value(key: str, value: str) -> tuple[bool, str]: + """Validate a system configuration value before it is applied.""" + validator = CONFIG_VALIDATORS.get(key) + if validator is None: + return True, "No specific validation rule for this key." + if validator(value): + return True, "Configuration value is valid." + return False, f"Invalid value '{value}' for configuration key '{key}'." + + +# --------------------------------------------------------------------------- +# BR-059: Configuration conflict detection +# --------------------------------------------------------------------------- +CONFLICTING_KEYS = [ + {"enable_location_tracking", "disable_all_tracking"}, +] + + +def detect_config_conflicts(pending_changes: dict, current_config: dict) -> list[str]: + """Return list of conflict descriptions between pending changes and current config.""" + merged = {**current_config, **pending_changes} + conflicts = [] + for group in CONFLICTING_KEYS: + active = [k for k in group if merged.get(k, "").lower() == "true"] + if len(active) > 1: + conflicts.append(f"Conflicting keys active simultaneously: {active}") + return conflicts + + +# --------------------------------------------------------------------------- +# BR-068: Data format validation for export/import +# --------------------------------------------------------------------------- +ALLOWED_DATA_FORMATS = {"csv", "json", "xlsx"} + + +def validate_data_format(fmt: str) -> tuple[bool, str]: + """Check that the requested data format is supported.""" + if fmt.lower() in ALLOWED_DATA_FORMATS: + return True, "Data format is supported." + return False, f"Unsupported data format '{fmt}'. Allowed: {ALLOWED_DATA_FORMATS}" + + +# --------------------------------------------------------------------------- +# BR-063: Visiting hours validation +# --------------------------------------------------------------------------- +def validate_visiting_hours(now=None) -> tuple[bool, str]: + """Check if the current time falls within configured visiting hours.""" + from .models import VisitingHours + if now is None: + from django.conf import settings + now = timezone.localtime(timezone.now()) if settings.USE_TZ else timezone.now() + current_day = now.weekday() + current_time = now.time() + + hours = VisitingHours.objects.filter(day_of_week=current_day, active=True) + if not hours.exists(): + return True, "No visiting hours configured for today; access allowed." + + for slot in hours: + if slot.is_holiday: + return False, f"Today is a holiday ({slot.holiday_name}); visiting not allowed." + if slot.start_time <= current_time <= slot.end_time: + return True, "Within visiting hours." + + return False, "Current time is outside configured visiting hours." diff --git a/vms_rbac.py b/vms_rbac.py new file mode 100644 index 000000000..a4bf4438e --- /dev/null +++ b/vms_rbac.py @@ -0,0 +1,342 @@ +"""VMS RBAC inspection tool. + +Usage (run from the FusionIIIT directory): + + python vms_rbac.py list + List every HostAuthority row: user, level, department, VIP-bypass flag. + + python vms_rbac.py check + Show which VMS permission classes pass/fail for , and + therefore which endpoints they can reach. + + python vms_rbac.py endpoints + Print every VMS endpoint grouped by its permission requirement. + + python vms_rbac.py matrix + Print the full role x endpoint access matrix. + + python vms_rbac.py users + List all Django users alongside their VMS authority (if any). + +This script only *reads* state; it does not create, modify, or remove +HostAuthority rows. +""" + +from __future__ import annotations + +import os +import sys +from typing import Iterable + +# --- Django bootstrap ------------------------------------------------------- +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fusion.settings.development") +import django # noqa: E402 +django.setup() + +from django.contrib.auth import get_user_model # noqa: E402 +from applications.vms.models import HostAuthority # noqa: E402 +from applications.vms.permissions import ( # noqa: E402 + CanBypassVIPApproval, + CanRemoveBlacklist, + HasHostApprovalAuthority, + IsSuperAdmin, + IsVmsAdmin, +) + +User = get_user_model() + +# --------------------------------------------------------------------------- +# Endpoint inventory. Mirrors applications/vms/api/views.py permission_classes. +# Each entry: (http_method, url_path, view_name, [permission_class_names...]) +# --------------------------------------------------------------------------- +ENDPOINTS: list[tuple[str, str, str, list[str]]] = [ + # Staff tier (any authenticated user) + ("POST", "/vms/register/", "RegisterVisitorView", ["Authenticated"]), + ("POST", "/vms/verify/", "VerifyVisitorView", ["Authenticated"]), + ("POST", "/vms/pass/", "IssuePassView", ["Authenticated"]), + ("POST", "/vms/entry/", "RecordEntryView", ["Authenticated"]), + ("POST", "/vms/exit/", "RecordExitView", ["Authenticated"]), + ("POST", "/vms/deny/", "DenyEntryView", ["Authenticated"]), + ("GET", "/vms/active/", "ActiveVisitorsView", ["Authenticated"]), + ("GET", "/vms/recent/", "RecentVisitsView", ["Authenticated"]), + ("GET", "/vms/incidents/", "SecurityIncidentView", ["Authenticated"]), + ("POST", "/vms/incidents/", "SecurityIncidentView", ["Authenticated"]), + ("POST", "/vms/scan/", "ScanPassView", ["Authenticated"]), + ("POST", "/vms/manual-check/", "ManualVerificationView", ["Authenticated"]), + ("GET", "/vms/overstay/", "OverstayView", ["Authenticated"]), + ("GET", "/vms/location//", "VisitorLocationView", ["Authenticated"]), + ("GET", "/vms/me/", "WhoAmIView", ["Authenticated"]), + ("GET", "/vms/blacklist/", "BlacklistView (GET)", ["Authenticated"]), + ("POST", "/vms/escorts/", "EscortAssignView", ["Authenticated"]), + ("GET", "/vms/escorts/", "EscortAssignView", ["Authenticated"]), + ("GET", "/vms/escorts/available/", "AvailableEscortsView", ["Authenticated"]), + ("POST", "/vms/escorts//release/", "EscortReleaseView", ["Authenticated"]), + + # Admin tier + ("POST", "/vms/blacklist/", "BlacklistView (POST)", ["Authenticated", "IsVmsAdmin"]), + ("GET", "/vms/blacklist/audit//", "BlacklistAuditView", ["Authenticated", "IsVmsAdmin"]), + ("GET", "/vms/history//", "VisitorHistoryView", ["Authenticated", "IsVmsAdmin"]), + ("GET", "/vms/history//incidents/", "IncidentHistoryView", ["Authenticated", "IsVmsAdmin"]), + ("GET", "/vms/visit-history//", "VisitorHistoryByVisitView", ["Authenticated", "IsVmsAdmin"]), + ("POST", "/vms/vip/process/", "VIPProcessView", ["Authenticated", "IsVmsAdmin"]), + ("GET", "/vms/vip/", "VIPVisitorsView", ["Authenticated", "IsVmsAdmin"]), + ("GET", "/vms/vip//activity/", "VIPActivityView", ["Authenticated", "IsVmsAdmin"]), + ("POST", "/vms/reports/", "ReportGenerationView", ["Authenticated", "IsVmsAdmin"]), + ("POST", "/vms/export/", "DataExportView", ["Authenticated", "IsVmsAdmin"]), + ("POST", "/vms/import/", "DataImportView", ["Authenticated", "IsVmsAdmin"]), + ("GET", "/vms/data-operations/", "DataOperationsView", ["Authenticated", "IsVmsAdmin"]), + ("POST", "/vms/visiting-hours/", "VisitingHoursView (POST)", ["Authenticated", "IsVmsAdmin"]), + ("POST", "/vms/zones/", "AccessZoneView (POST)", ["Authenticated", "IsVmsAdmin"]), + + # Blacklist removal + ("POST", "/vms/blacklist//remove/", "BlacklistRemoveView", ["Authenticated", "CanRemoveBlacklist"]), + + # Super-admin tier + ("GET", "/vms/config/", "SystemConfigView (GET)", ["Authenticated", "IsSuperAdmin"]), + ("POST", "/vms/config/", "SystemConfigView (POST)", ["Authenticated", "IsSuperAdmin"]), + ("GET", "/vms/config/history/", "ConfigChangeHistoryView", ["Authenticated", "IsSuperAdmin"]), +] + +PERMISSION_MAP = { + "IsVmsAdmin": IsVmsAdmin, + "IsSuperAdmin": IsSuperAdmin, + "CanRemoveBlacklist": CanRemoveBlacklist, + "CanBypassVIPApproval": CanBypassVIPApproval, + "HasHostApprovalAuthority": HasHostApprovalAuthority, +} + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- +CHECK = "[ok]" +CROSS = "[no]" + +def _tick(ok: bool) -> str: + return CHECK if ok else CROSS + +def _box(title: str) -> None: + line = "=" * max(60, len(title) + 4) + print(f"\n{line}\n {title}\n{line}") + +def _rows(rows: Iterable[Iterable[str]]) -> None: + rows = [list(map(str, r)) for r in rows] + if not rows: + return + widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))] + for r in rows: + print(" " + " ".join(cell.ljust(widths[i]) for i, cell in enumerate(r))) + + +# --------------------------------------------------------------------------- +# A tiny stub that quacks like an authenticated DRF request for a given user. +# The permission classes only inspect `request.user`, so this is sufficient. +# --------------------------------------------------------------------------- +class _FakeRequest: + def __init__(self, user): + self.user = user + self.method = "GET" + + +def _evaluate(user, perm_names: list[str]) -> dict[str, bool]: + result: dict[str, bool] = {} + if "Authenticated" in perm_names: + result["Authenticated"] = bool(user and user.is_authenticated) + for name in perm_names: + if name == "Authenticated": + continue + cls = PERMISSION_MAP.get(name) + if cls is None: + result[name] = False + continue + try: + result[name] = bool(cls().has_permission(_FakeRequest(user), None)) + except Exception as exc: # pragma: no cover - diagnostic path + result[name] = False + print(f" ! error evaluating {name}: {exc}") + return result + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- +def cmd_list() -> None: + _box("HostAuthority rows") + rows = [("USERNAME", "LEVEL", "DEPARTMENT", "CAN_APPROVE_VIP", "MAX_DAILY")] + qs = HostAuthority.objects.select_related("user").order_by("user__username") + for ha in qs: + rows.append(( + ha.user.username, + ha.authority_level, + ha.department or "-", + "yes" if ha.can_approve_vip else "no", + ha.max_daily_approvals, + )) + if len(rows) == 1: + print(" (no HostAuthority rows — every user is plain Staff tier)") + return + _rows(rows) + + +def cmd_users() -> None: + _box("All users and their VMS authority") + rows = [("USERNAME", "ACTIVE", "STAFF", "SUPERUSER", "VMS_LEVEL", "VIP_BYPASS")] + ha_by_user = {ha.user_id: ha for ha in HostAuthority.objects.all()} + for u in User.objects.order_by("username"): + ha = ha_by_user.get(u.id) + rows.append(( + u.username, + "yes" if u.is_active else "no", + "yes" if u.is_staff else "no", + "yes" if u.is_superuser else "no", + ha.authority_level if ha else "-", + "yes" if (ha and ha.can_approve_vip) else "no", + )) + _rows(rows) + + +def cmd_endpoints() -> None: + _box("VMS endpoints and their permission requirements") + groups: dict[tuple[str, ...], list[tuple[str, str, str]]] = {} + for method, path, view, perms in ENDPOINTS: + groups.setdefault(tuple(perms), []).append((method, path, view)) + for perms, eps in groups.items(): + print(f"\n -- requires: {' + '.join(perms)} --") + _rows([("METHOD", "PATH", "VIEW"), *eps]) + + +def cmd_check(username: str) -> None: + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + print(f" ! user '{username}' does not exist") + sys.exit(1) + + ha = HostAuthority.objects.filter(user=user).first() + + _box(f"RBAC check for '{username}'") + print(f" user.is_active : {user.is_active}") + print(f" user.is_staff : {user.is_staff}") + print(f" user.is_superuser : {user.is_superuser}") + if ha: + print(f" vms authority_level : {ha.authority_level}") + print(f" vms department : {ha.department or '-'}") + print(f" vms can_approve_vip : {ha.can_approve_vip}") + else: + print(" vms authority_level : (no HostAuthority row — Staff tier only)") + + print("\n Permission class evaluation:") + for name, cls in PERMISSION_MAP.items(): + ok = bool(cls().has_permission(_FakeRequest(user), None)) + print(f" {_tick(ok)} {name}") + + _box(f"Endpoint access for '{username}'") + rows = [("RESULT", "METHOD", "PATH", "GATE")] + allow = deny = 0 + for method, path, _view, perms in ENDPOINTS: + evals = _evaluate(user, perms) + ok = all(evals.values()) + rows.append((_tick(ok), method, path, " + ".join(perms))) + allow += int(ok) + deny += int(not ok) + _rows(rows) + print(f"\n summary: {allow} allowed / {deny} denied / {allow + deny} total") + + +def cmd_matrix() -> None: + _box("Role x Endpoint access matrix") + + # Craft one representative user per role, purely in memory — we never save. + class _P: + def __init__(self, level: str | None = None, vip_bypass: bool = False, + authed: bool = True): + self.level = level + self.vip_bypass = vip_bypass + self.authed = authed + + personas = [ + ("Anon", _P(level=None, authed=False)), + ("Staff", _P(level=None)), + ("Department", _P(level=HostAuthority.LEVEL_DEPARTMENT, vip_bypass=True)), + ("Admin", _P(level=HostAuthority.LEVEL_ADMIN)), + ("SuperAdmin", _P(level=HostAuthority.LEVEL_SUPER)), + ] + + # Build stand-in user + HostAuthority shims. + class _U: + def __init__(self, persona: _P): + self._persona = persona + self.is_authenticated = persona.authed + + def _evaluate_persona(persona: _P, perms: list[str]) -> bool: + if "Authenticated" in perms and not persona.authed: + return False + # Emulate permissions.py logic against the persona directly. + for name in perms: + if name == "Authenticated": + continue + if not persona.authed: + return False + if name == "IsVmsAdmin": + if persona.level not in (HostAuthority.LEVEL_ADMIN, HostAuthority.LEVEL_SUPER): + return False + elif name == "IsSuperAdmin": + if persona.level != HostAuthority.LEVEL_SUPER: + return False + elif name == "CanRemoveBlacklist": + if persona.level not in (HostAuthority.LEVEL_ADMIN, HostAuthority.LEVEL_SUPER): + return False + elif name == "CanBypassVIPApproval": + if not persona.vip_bypass: + return False + elif name == "HasHostApprovalAuthority": + if persona.level not in ( + HostAuthority.LEVEL_DEPARTMENT, + HostAuthority.LEVEL_ADMIN, + HostAuthority.LEVEL_SUPER, + ): + return False + return True + + header = ["METHOD", "PATH"] + [name for name, _ in personas] + rows = [header] + for method, path, _view, perms in ENDPOINTS: + row = [method, path] + for _, persona in personas: + row.append(_tick(_evaluate_persona(persona, perms))) + rows.append(row) + _rows(rows) + print("\n legend: [ok] = permitted [no] = blocked") + print(" note: 'Department' persona also has can_approve_vip=True") + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- +def main(argv: list[str]) -> None: + if len(argv) < 2: + print(__doc__) + sys.exit(1) + + cmd, *rest = argv[1:] + if cmd == "list": + cmd_list() + elif cmd == "users": + cmd_users() + elif cmd == "endpoints": + cmd_endpoints() + elif cmd == "matrix": + cmd_matrix() + elif cmd == "check": + if not rest: + print(" ! usage: python vms_rbac.py check ") + sys.exit(1) + cmd_check(rest[0]) + else: + print(f" ! unknown command: {cmd}") + print(__doc__) + sys.exit(1) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/vms_seed.py b/vms_seed.py new file mode 100644 index 000000000..2d1623a1a --- /dev/null +++ b/vms_seed.py @@ -0,0 +1,374 @@ +"""Seed the VMS module with demo-ready data. + +Idempotent — safe to run multiple times. Populates: + * Visitors (Aadhaar / passport / driver license, VIP + regular) + * Visits in every workflow status (registered -> exited / denied) + * Security incidents across all severities + * A couple of blacklist entries + * An active escort assignment for a VIP visit + * A few active VisitorPasses so the "Issue QR" flow has history + +Usage (from the FusionIIIT directory): + python vms_seed.py # add / refresh demo data + python vms_seed.py --clean # also remove any prior seeded rows +""" + +from __future__ import annotations + +import argparse +import os +import sys +from datetime import timedelta + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fusion.settings.development") +import django # noqa: E402 +django.setup() + +from django.utils import timezone # noqa: E402 + +from applications.vms.models import ( # noqa: E402 + AccessZone, + BlacklistEntry, + EntryExitLog, + EscortAssignment, + SecurityIncident, + SystemConfig, + Visit, + VisitingHours, + Visitor, + VisitorPass, + calculate_valid_until, +) + +# All seeded rows carry this marker in a free-text field so --clean can +# reliably remove them without touching real data. +SEED_TAG = "[vms-seed]" + + +# --------------------------------------------------------------------------- +# Visitors — the pool we spin demo visits from +# --------------------------------------------------------------------------- +VISITORS = [ + # (id_number, full_name, id_type, phone, email) + ("100000000001", "Ananya Rao", "aadhaar", "9811100001", "ananya@example.com"), + ("100000000002", "Bharath Menon", "aadhaar", "9811100002", "bharath@example.com"), + ("100000000003", "Chitra Balan", "aadhaar", "9811100003", "chitra@example.com"), + ("100000000004", "Devansh Kapoor", "aadhaar", "9811100004", "devansh@example.com"), + ("P9900011", "Elena Ruiz", "passport", "9811100005", "elena@example.com"), + ("P9900012", "Farhan Ahmed", "passport", "9811100006", "farhan@example.com"), + ("DL-07-90011", "Gita Sahu", "driver_license", "9811100007", "gita@example.com"), + ("100000000008", "Harini Subbu", "aadhaar", "9811100008", "harini@example.com"), + ("100000000009", "Ishaan Verma", "aadhaar", "9811100009", "ishaan@example.com"), + ("100000000010", "Jaya Krishnan", "aadhaar", "9811100010", "jaya@example.com"), + ("P9900013", "Kenji Tanaka", "passport", "9811100011", "kenji@example.com"), + ("DL-09-90012", "Leela Pillai", "driver_license", "9811100012", "leela@example.com"), + ("100000000013", "Mohan Das", "aadhaar", "9811100013", "mohan@example.com"), + ("100000000014", "Neha Arora", "aadhaar", "9811100014", "neha@example.com"), + ("P9900015", "Omar Siddiqui", "passport", "9811100015", "omar@example.com"), + ("100000000016", "Priya Nair", "aadhaar", "9811100016", "priya@example.com"), +] + + +def _tick(label: str, created: bool) -> None: + marker = "created" if created else "exists" + print(f" [{marker:>7}] {label}") + + +def seed_visitors() -> list[Visitor]: + out: list[Visitor] = [] + for id_number, name, id_type, phone, email in VISITORS: + v, created = Visitor.objects.update_or_create( + id_number=id_number, + defaults={ + "full_name": name, + "id_type": id_type, + "contact_phone": phone, + "contact_email": email, + "photo_reference": f"{SEED_TAG} {name}", + }, + ) + out.append(v) + _tick(f"visitor {name} ({id_type})", created) + return out + + +# --------------------------------------------------------------------------- +# Visits — one per visitor, in different workflow statuses +# --------------------------------------------------------------------------- +VISIT_PLANS = [ + # (visitor_index, status, is_vip, vip_level, purpose, host, department, entry_delta_min, exit_delta_min) + (0, Visit.STATUS_REGISTERED, False, 0, "Guest lecture prep", "Prof. V. Rao", "CSE", None, None), + (1, Visit.STATUS_VERIFIED, False, 0, "Vendor meeting", "Mr. S. Sinha", "Admin",None, None), + (2, Visit.STATUS_PASS_ISSUED, False, 0, "Project review", "Prof. N. Gupta", "ECE", None, None), + (3, Visit.STATUS_INSIDE, True, 3, "Industry delegation", "Director", "Admin",-20, None), + (4, Visit.STATUS_INSIDE, False, 0, "Library visit", "Prof. A. Jain", "Library",-45, None), + (5, Visit.STATUS_EXITED, False, 0, "Interview panel", "Prof. M. Iyer", "MBA", -180, -30), + (6, Visit.STATUS_DENIED, False, 0, "Unscheduled drop-in", "Prof. K. Bose", "Physics",None, None), + (7, Visit.STATUS_INSIDE, True, 5, "Chief Guest - Annual Day", "Dean", "Admin",-60, None), + (8, Visit.STATUS_REGISTERED, False, 0, "Alumni campus tour", "Prof. S. Das", "Alumni", None, None), + (9, Visit.STATUS_VERIFIED, False, 0, "Research collaboration", "Prof. R. Nair", "CSE", None, None), + (10, Visit.STATUS_INSIDE, True, 4, "External examiner", "Dean Academics", "Academic",-30, None), + (11, Visit.STATUS_PASS_ISSUED,False, 0, "Workshop participant", "Prof. B. Rao", "ME", None, None), + (12, Visit.STATUS_EXITED, False, 0, "Equipment delivery", "Mr. T. Kumar", "Stores",-240,-120), + (13, Visit.STATUS_INSIDE, False, 0, "Placement interview", "Placement Cell", "Admin",-90, None), + (14, Visit.STATUS_DENIED, False, 0, "Missing appointment", "Reception", "Admin", None, None), + (15, Visit.STATUS_EXITED, True, 3, "Board member visit", "Registrar", "Admin",-300,-180), +] + + +def seed_visits(visitors: list[Visitor]) -> list[Visit]: + now = timezone.now() + out: list[Visit] = [] + for i, status, is_vip, vip_level, purpose, host, dept, entry_off, exit_off in VISIT_PLANS: + visitor = visitors[i] + # Match an existing seeded visit by (visitor, purpose). Purpose is + # distinctive enough in this pool to make each plan-row unique. + visit, created = Visit.objects.get_or_create( + visitor=visitor, + purpose=purpose, + defaults={ + "host_name": host, + "host_department": dept, + "host_contact": "9876543200", + "expected_duration_minutes": 120, + "is_vip": is_vip, + "vip_level": vip_level, + "status": status, + }, + ) + visit.host_name = host + visit.host_department = dept + visit.is_vip = is_vip + visit.vip_level = vip_level + visit.status = status + if entry_off is not None: + visit.entry_at = now + timedelta(minutes=entry_off) + if exit_off is not None: + visit.exit_at = now + timedelta(minutes=exit_off) + if status == Visit.STATUS_DENIED: + visit.denial_reason = "host_unavailable" + visit.denial_remarks = "Host not reachable; rescheduled" + elif status in {Visit.STATUS_VERIFIED, Visit.STATUS_PASS_ISSUED, Visit.STATUS_INSIDE, Visit.STATUS_EXITED}: + visit.verified_at = visit.verified_at or now - timedelta(minutes=60) + visit.save() + out.append(visit) + _tick(f"visit #{visit.id} {visitor.full_name} [{status}]", created) + + # For inside/exited visits, drop an entry log so checkpoint history shows up. + if entry_off is not None: + EntryExitLog.objects.get_or_create( + visit=visit, + action=EntryExitLog.ACTION_ENTRY, + defaults={"gate_name": "Main Gate", "items_declared": "Laptop"}, + ) + if exit_off is not None: + EntryExitLog.objects.get_or_create( + visit=visit, + action=EntryExitLog.ACTION_EXIT, + defaults={"gate_name": "Main Gate", "items_declared": ""}, + ) + + # Issue a pass for visits past verification. + if status in {Visit.STATUS_PASS_ISSUED, Visit.STATUS_INSIDE, Visit.STATUS_EXITED}: + vp, _ = VisitorPass.objects.get_or_create( + visit=visit, + defaults={ + "valid_from": now - timedelta(minutes=90), + "valid_until": calculate_valid_until( + now - timedelta(minutes=90), 180, is_vip + ), + "authorized_zones": "lobby,admin_block", + "status": ( + VisitorPass.PASS_RETURNED + if status == Visit.STATUS_EXITED + else VisitorPass.PASS_ISSUED + ), + "is_vip_pass": is_vip, + "barcode_data": f"{SEED_TAG} demo-pass", + }, + ) + return out + + +# --------------------------------------------------------------------------- +# Security incidents — mix of severities / types +# --------------------------------------------------------------------------- +INCIDENTS = [ + # (visit_index, severity, issue_type, description) + (4, SecurityIncident.SEVERITY_HIGH, "unauthorized_access", "Attempted entry into restricted server room"), + (3, SecurityIncident.SEVERITY_MEDIUM, "policy_violation", "VIP guest used unapproved side corridor"), + (2, SecurityIncident.SEVERITY_LOW, "equipment_failure", "Checkpoint barcode scanner offline for 4 minutes"), + (5, SecurityIncident.SEVERITY_HIGH, "suspicious_behavior", "Loitering near faculty parking after hours"), + (6, SecurityIncident.SEVERITY_CRITICAL,"unauthorized_access", "Visitor refused ID re-verification at exit"), + (10, SecurityIncident.SEVERITY_MEDIUM, "policy_violation", "External examiner used mobile camera in exam block"), + (11, SecurityIncident.SEVERITY_LOW, "other", "Workshop attendee forgot to return lanyard"), + (13, SecurityIncident.SEVERITY_HIGH, "suspicious_behavior", "Placement candidate tailgated through gate 2"), + (14, SecurityIncident.SEVERITY_MEDIUM, "unauthorized_access", "Turned back at main gate — missing approval"), + (12, SecurityIncident.SEVERITY_LOW, "equipment_failure", "Stores gate barrier stuck open for 8 minutes"), +] + + +def seed_incidents(visits: list[Visit]) -> None: + for visit_idx, severity, issue_type, description in INCIDENTS: + visit = visits[visit_idx] + inc, created = SecurityIncident.objects.get_or_create( + visit=visit, + issue_type=issue_type, + description=description, + defaults={ + "visitor": visit.visitor, + "severity": severity, + }, + ) + _tick(f"incident [{severity}] {issue_type}", created) + + +# --------------------------------------------------------------------------- +# Blacklist entries +# --------------------------------------------------------------------------- +BLACKLIST = [ + ("BL-999-0001", "Impersonation attempt flagged by gate officer", + "Incident report 2026-04-15"), + ("BL-999-0002", "Recurring policy violations across three visits", + "Audit trail VMS-AUDIT-07"), + ("BL-999-0003", "Tailgated through employee gate, refused ID check", + "CCTV clip gate-2 2026-03-28"), + ("BL-999-0004", "Confrontational behaviour with security staff", + "Incident report VMS-IR-2026-19"), +] + + +def seed_blacklist() -> None: + for id_number, reason, evidence in BLACKLIST: + entry, created = BlacklistEntry.objects.get_or_create( + id_number=id_number, + defaults={"reason": reason, "evidence": evidence, "active": True}, + ) + _tick(f"blacklist {id_number}", created) + + +# --------------------------------------------------------------------------- +# Escort assignment for a VIP visit (Chief Guest) +# --------------------------------------------------------------------------- +def seed_escort(visits: list[Visit]) -> None: + from applications.globals.models import ExtraInfo + + vip_visits = [v for v in visits if v.is_vip and v.status == Visit.STATUS_INSIDE] + if not vip_visits: + print(" [skip] no active VIP visit to escort") + return + escorts = list(ExtraInfo.objects.all()[:3]) + if not escorts: + print(" [skip] no ExtraInfo available to assign as escort") + return + for idx, vip_visit in enumerate(vip_visits): + existing = EscortAssignment.objects.filter( + visit=vip_visit, released_at__isnull=True + ).first() + if existing: + _tick(f"escort on visit #{vip_visit.id} (escort {existing.escort_id})", False) + continue + escort = escorts[idx % len(escorts)] + EscortAssignment.objects.create( + visit=vip_visit, + escort=escort, + notes=f"{SEED_TAG} VIP protocol — vip_level={vip_visit.vip_level}", + ) + _tick(f"escort on visit #{vip_visit.id} (escort {escort})", True) + + +# --------------------------------------------------------------------------- +# Cleanup — removes only seeded rows, identified by the marker above +# --------------------------------------------------------------------------- +def clean() -> None: + seed_id_numbers = [row[0] for row in VISITORS] + [row[0] for row in BLACKLIST] + removed_bl = BlacklistEntry.objects.filter(id_number__in=[row[0] for row in BLACKLIST]).delete() + removed_visits = Visit.objects.filter(visitor__id_number__in=[row[0] for row in VISITORS]).delete() + removed_visitors = Visitor.objects.filter(id_number__in=[row[0] for row in VISITORS]).delete() + print(f" removed: blacklist={removed_bl} visits={removed_visits} visitors={removed_visitors}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Seed VMS demo data") + parser.add_argument("--clean", action="store_true", help="delete seeded rows") + args = parser.parse_args() + + if args.clean: + print("== Cleaning seeded rows ==") + clean() + return + + print("== Seeding visitors ==") + visitors = seed_visitors() + print("== Seeding visits ==") + visits = seed_visits(visitors) + print("== Seeding incidents ==") + seed_incidents(visits) + print("== Seeding blacklist ==") + seed_blacklist() + print("== Seeding escort assignment ==") + seed_escort(visits) + print("== Seeding system config, visiting hours, access zones ==") + seed_system_data() + print("\nDone. Refresh the VMS pages to see the data.") + + +# --------------------------------------------------------------------------- +# System config + visiting hours + access zones — so WF-009 panels show data +# --------------------------------------------------------------------------- +def seed_system_data() -> None: + cfg, created = SystemConfig.objects.update_or_create( + key="escort_threshold", + defaults={"value": "3", "description": "VIP level that mandates an escort"}, + ) + _tick(f"config {cfg.key}={cfg.value}", created) + + cfg2, created = SystemConfig.objects.update_or_create( + key="max_daily_registrations", + defaults={"value": "200", "description": "Daily cap per host (BR-007)"}, + ) + _tick(f"config {cfg2.key}={cfg2.value}", created) + + for day, start, end, holiday, name in [ + (0, "09:00", "17:00", False, ""), + (1, "09:00", "17:00", False, ""), + (2, "09:00", "17:00", False, ""), + (3, "09:00", "17:00", False, ""), + (4, "09:00", "17:00", False, ""), + (5, "10:00", "14:00", False, ""), + (6, "00:00", "00:00", True, "Sunday closed"), + ]: + vh, created = VisitingHours.objects.update_or_create( + day_of_week=day, + defaults={ + "start_time": start, + "end_time": end, + "is_holiday": holiday, + "holiday_name": name, + "active": True, + }, + ) + _tick(f"visiting hours day={day}", created) + + for name, desc, vip, escort, restricted in [ + ("lobby", "Main reception lobby", False, False, False), + ("admin_block", "Administrative building", False, False, False), + ("library", "Central library", False, False, False), + ("server_room", "Data centre — VIP escort only", True, True, True), + ("labs", "Research lab corridor", False, True, True), + ]: + z, created = AccessZone.objects.update_or_create( + name=name, + defaults={ + "description": desc, + "requires_vip": vip, + "requires_escort": escort, + "is_restricted": restricted, + "active": True, + }, + ) + _tick(f"zone {z.name}", created) + + +if __name__ == "__main__": + main()