Ir al contenido

API — Contratos de Endpoints HTTP

SSOT for all HTTP communication between Angular 21 and Django DRF. Every endpoint here is a contract; if implementation differs, this file wins ([[adr-005-api-contract-ssot|ADR-005]]).

All cross-cutting rules — URL structure, pagination (page_size default 25 / max 100), HTTP methods, naming, dates, IDs, auth & cookies, throttling, CSRF, and the error/status-code format — live in [[api-conventions]] and are not restated per endpoint. This file lists only what is specific to each endpoint.


Health check endpoint. No authentication required. Use to verify API is alive and database connectivity.

Response 200 OK:

{
"status": "healthy",
"database": "connected",
"timestamp": "2026-03-13T14:30:45.123456Z",
"version": "0.1.0"
}
FieldTypeNotes
statusstring"healthy" or "unhealthy"
databasestring"connected" or "unreachable"
timestampstringISO 8601 timestamp
versionstringOnly included when authenticated or DEBUG=True

Response 503 Service Unavailable (database unreachable):

{
"status": "unhealthy",
"database": "unreachable"
}

Base path: /api/v1/auth/.

Authenticate user. Returns user profile in response body. Tokens are set as httpOnly cookies — not in the response body.

Throttle: login scope (5 requests/hour per IP).

Request:

{
"email": "[email protected]",
"password": "secret"
}

Response 200 OK:

{
"user": {
"sub": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]",
"given_name": "John",
"family_name": "Doe",
"role": "VIEWER",
"email_verified": true,
"is_staff": false
}
}

Set-Cookie headers (httpOnly, not visible to JS):

Set-Cookie: access_token=<jwt>; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600
Set-Cookie: refresh_token=<jwt>; HttpOnly; SameSite=Lax; Path=/api/v1/auth/token/refresh/; Max-Age=604800

Response 400 Bad Request:

{
"non_field_errors": ["Invalid email or password."]
}

Response 429 Too Many Requests:

{
"detail": "Request was throttled. Expected available in 720 seconds."
}

Blacklists the refresh token and clears both auth cookies server-side. Requires authentication (valid access_token cookie).

Response 204 No Content (empty body, cookies cleared via Set-Cookie with Max-Age=0)


Reads the refresh_token cookie and issues a new access_token cookie. ROTATE_REFRESH_TOKENS=True — also issues a new refresh_token cookie and blacklists the old one. No request body required.

Throttle: token_refresh scope (20 requests/hour).

Response 200 OK:

{
"message": "Token refreshed"
}

Response 401 Unauthorized (no or invalid refresh cookie):

{
"detail": "Refresh token not found."
}

Retrieve authenticated user’s profile. Auth via access_token cookie. Response includes Cache-Control: no-store.

Response 200 OK (UserSerializer):

{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]",
"given_name": "John",
"family_name": "Doe",
"role": "VIEWER",
"email_verified": true,
"is_staff": false
}
FieldTypeSourceNotes
subUUIDUser.subOIDC claim — Cognito-compatible
emailstringUser.emailUnique, used as username
given_namestringUser.given_nameOIDC claim
family_namestringUser.family_nameOIDC claim
rolestringUser.roleEnum: ADMIN, MANAGER, SUPERVISOR, VIEWER, EMPLOYEE
email_verifiedbooleanUser.email_verifiedOIDC claim
is_staffbooleanUser.is_staffDjango admin access

Response 401 Unauthorized (missing/invalid cookie):

{
"detail": "Authentication credentials were not provided."
}

Deactivates the authenticated user’s account (GDPR Art. 17 — Right to erasure). Sets is_active=False. Clears auth cookies.

Soft-delete retention: This endpoint deactivates the account (is_active=False) but does not destroy the user record or any associated data. User data (profile, assignments, audit logs) is retained in the database and remains accessible to administrators for recovery purposes. A future hard-delete sweep may be scheduled per the project’s data retention policy.

Response 204 No Content (empty body, cookies cleared)

Response 401 Unauthorized:

{
"detail": "Authentication credentials were not provided."
}

Exports the authenticated user’s personal data as JSON (GDPR Art. 20 — Data portability). Returns Content-Disposition: attachment; filename="my-data.json". Response includes Cache-Control: no-store.

Response 200 OK:

{
"email": "[email protected]",
"given_name": "John",
"family_name": "Doe",
"role": "VIEWER",
"date_joined": "2026-01-15T10:30:00+00:00",
"last_login": "2026-03-14T08:15:30+00:00"
}

Response 401 Unauthorized:

{
"detail": "Authentication credentials were not provided."
}

All employee endpoints require authentication. Base path: /api/v1/employees/

List employees (paginated).

Query parameters: (plus standard page, page_size, ordering — see [[api-conventions]])

ParamTypeDescription
statusstringFilter by FSM state: ONBOARDING, ACTIVE, PROPOSAL_PENDING, ON_LEAVE, DEACTIVATED, TERMINATED
searchstringFull-text on first_name, last_name, employee_number (icontains)
orderingstringDefault: last_name

Response 200 OK (EmployeeListSerializer):

FieldTypeSourceNotes
idUUIDEmployee.idPrimary key
employee_numberstringEmployee.employee_numberUnique
first_namestringEmployee.first_name
last_namestringEmployee.last_name
emailstringEmployee.email
statusstringEmployee.statusFSM state (see below)
hire_datedate | nullEmployee.hire_date
photostring | nullEmployee.photoAvatar URL/path
created_atdatetimeEmployee.created_at

Create a new employee (EmployeeWriteSerializer). Status defaults to ONBOARDING.

Request:

{
"employee_number": "EMP-001",
"first_name": "María",
"last_name": "García",
"email": "[email protected]",
"document_number": "30123456",
"hire_date": "2026-03-17"
}

Response 201 Created — returns EmployeeDetailSerializer.


Retrieve employee detail (EmployeeDetailSerializer). Includes current_proposal when status is PROPOSAL_PENDING.

Response 200 OK:

FieldTypeSourceNotes
idUUIDEmployee.idPrimary key
employee_numberstringEmployee.employee_numberUnique
first_namestringEmployee.first_name
last_namestringEmployee.last_name
emailstringEmployee.email
document_numberstringEmployee.document_number
statusstringEmployee.statusFSM state
date_of_birthdate | nullEmployee.date_of_birth
hire_datedate | nullEmployee.hire_date
termination_datedate | nullEmployee.termination_dateSet by terminate()
leave_started_atdatetime | nullEmployee.leave_started_atSet by go_on_leave()
current_proposalobject | nullComputedSee below
photostring | nullEmployee.photoAvatar URL/path
created_atdatetimeEmployee.created_at
updated_atdatetimeEmployee.updated_at

current_proposal (only when status == PROPOSAL_PENDING):

{
"proposal_type": "ASSIGNMENT",
"notes": "Reassign to ER unit",
"expires_at": "2026-03-24T14:30:00Z",
"previous_status": "ACTIVE"
}
FieldTypeNotes
proposal_typestringEnum: ASSIGNMENT, TRANSFER
notesstringContext provided by the proposer
expires_atdatetime | nullAuto-expiry timestamp (set by propose())
previous_statusstringFSM state before proposal (used for reject/cancel rollback)

Partial update (EmployeeWriteSerializer). Cannot modify status (FSM-controlled).

Writable fields: employee_number, first_name, last_name, email, document_number, date_of_birth, hire_date, photo

Response 200 OK — returns EmployeeDetailSerializer.


Response 405 Method Not Allowed. Hard delete is not supported. Use FSM transitions to manage the employee lifecycle (terminate/, rehire/, etc.).


All FSM endpoints are POST, return full employee detail (EmployeeDetailSerializer) on success, or 409 Conflict if the transition is not allowed from the current state.

States: ONBOARDING (initial) → ACTIVEPROPOSAL_PENDING / ON_LEAVE / DEACTIVATEDTERMINATEDONBOARDING (rehire cycle).

EndpointFromToActorBody
POST /{id}/activate/ONBOARDINGACTIVEADMIN{reason?}
POST /{id}/propose/ACTIVE, ON_LEAVEPROPOSAL_PENDINGMANAGER{proposal_type, notes?, expires_in_days?}
POST /{id}/accept-proposal/PROPOSAL_PENDING(previous)EMPLOYEE{reason?}
POST /{id}/reject-proposal/PROPOSAL_PENDING(previous)EMPLOYEE{reason?}
POST /{id}/cancel-proposal/PROPOSAL_PENDING(previous)MANAGER{reason?}
POST /{id}/force-accept-proposal/PROPOSAL_PENDING(previous)SUPERUSER{reason?}
POST /{id}/go-on-leave/ACTIVEON_LEAVEEMPLOYEE{reason?}
POST /{id}/return-from-leave/ON_LEAVEACTIVEEMPLOYEE{reason?}
POST /{id}/deactivate/ACTIVEDEACTIVATEDADMIN{reason?}
POST /{id}/reactivate/DEACTIVATEDACTIVEADMIN{reason?}
POST /{id}/terminate/ACTIVE, ON_LEAVE, DEACTIVATEDTERMINATEDADMIN{reason?}
POST /{id}/rehire/TERMINATEDONBOARDINGADMIN{reason?}

proposal_type enum: ASSIGNMENT, TRANSFER

Error 409 Conflict:

{
"detail": "Transition \"activate\" not allowed from state \"ACTIVE\"."
}

Audit log for this employee (paginated, TransitionLogSerializer).

Response 200 OK:

FieldTypeSource
idintegerEmployeeTransitionLog.id
from_statusstringEmployeeTransitionLog.from_status
to_statusstringEmployeeTransitionLog.to_status
transitionstringEmployeeTransitionLog.transition
actorUUID | nullEmployeeTransitionLog.actor_id
actor_emailstring | nullComputed from actor.email
reasonstringEmployeeTransitionLog.reason
metadataobjectEmployeeTransitionLog.metadata
created_atdatetimeEmployeeTransitionLog.created_at

GET /api/v1/employees/{id}/available-transitions/

Sección titulada «GET /api/v1/employees/{id}/available-transitions/»

List transitions available from the current state.

Response 200 OK:

{
"status": "ACTIVE",
"transitions": ["propose", "go_on_leave", "terminate"]
}

Audit Log — Transition History (Historial)

Sección titulada «Audit Log — Transition History (Historial)»

Global audit log served by apps/audit_log. Role-scoped: EMPLOYEE users see only their own records (matched by email); all other roles see all records. Covers two log types:

  1. FSM Transition Logs — Employee and Assignment state transitions (EmployeeTransitionLog, AssignmentTransitionLog).
  2. Tag Change Logs — Employee tag lifecycle events (TagChangeLog): ASSIGNED, REMOVED, REVOKED, EXPIRED, MODIFIED.

List all transitions (paginated). Supports query param filters.

Query Parameters:

ParamTypeDescription
searchstringSearch by employee full name, employee number, or actor full name (icontains)
transitionstringFilter by transition name (e.g. activate, terminate)
from_statusstringFilter by origin status
to_statusstringFilter by target status
employeeUUIDFilter by employee ID
date_fromdateFilter created_at >= date (YYYY-MM-DD)
date_todateFilter created_at <= date (YYYY-MM-DD)
exclude_admin_actorsstringtrue or 1 to hide transitions performed by ADMIN-role users

Response 200 OK:

{
"count": 42,
"next": "http://localhost:8000/api/v1/transitions/?page=2",
"previous": null,
"results": [
{
"id": 1,
"employee": "550e8400-e29b-41d4-a716-446655440000",
"employee_name": "Perez, Juan",
"employee_number": "EMP-001",
"from_status": "ONBOARDING",
"to_status": "ACTIVE",
"transition": "activate",
"actor": "660e8400-e29b-41d4-a716-446655440001",
"actor_name": "Boss, Admin",
"reason": "Onboarding complete",
"metadata": {},
"created_at": "2026-03-18T10:30:00Z"
}
]
}
FieldTypeNotes
idintegerPrimary key
employeeUUIDEmployee FK
employee_namestring”LastName, FirstName”
employee_numberstringUnique employee identifier
from_statusstringOrigin FSM state
to_statusstringTarget FSM state
transitionstringTransition name
actorUUID | nullUser who triggered the transition
actor_namestring”FamilyName, GivenName”, email fallback, or “Sistema”
reasonstringReason for transition
metadataobjectExtra context set by the view before transition (e.g. proposal details)
created_atdatetimeWhen the transition occurred

Retrieve a single transition log entry.

Response 200 OK — same shape as a single result above.

Response 404 Not Found.

List all tag change log entries (paginated). Same architectural pattern as /api/v1/transitions/: append-only, read-only viewset in apps/audit_log.

Query Parameters:

ParamTypeDescription
searchstringSearch by employee full name, employee number, or tag name (icontains)
employeeUUIDFilter by employee ID
tagUUIDFilter by TagCatalog ID
actionstringFilter by action: ASSIGNED, REMOVED, REVOKED, EXPIRED, MODIFIED
date_fromdateFilter created_at >= date (YYYY-MM-DD)
date_todateFilter created_at <= date (YYYY-MM-DD)

Response 200 OK:

{
"count": 15,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"employee": "550e8400-e29b-41d4-a716-446655440000",
"employee_name": "Perez, Juan",
"employee_number": "EMP-001",
"tag": "770e8400-e29b-41d4-a716-446655440002",
"tag_name": "Guardia 12h",
"tag_category": "CONTRACT",
"action": "ASSIGNED",
"actor": "660e8400-e29b-41d4-a716-446655440001",
"actor_name": "Boss, Admin",
"reason": "",
"created_at": "2026-03-18T10:30:00Z"
}
]
}
FieldTypeNotes
idintegerPrimary key
employeeUUIDEmployee FK
employee_namestring”LastName, FirstName”
employee_numberstringUnique employee identifier
tagUUIDTagCatalog FK
tag_namestringComputed from tag (short label)
tag_display_namestringComputed from tag (long human-readable form)
tag_categorystringComputed from tag: CONTRACT, QUALIFICATION, EXCEPTION, CERTIFICATION
actionstringEnum: ASSIGNED, REMOVED, REVOKED, EXPIRED, MODIFIED
actorUUID | nullUser who triggered the change, or null for system (e.g. EXPIRED by cron)
actor_namestring”FamilyName, GivenName”, email fallback, or “Sistema”
reasonstringOptional reason for the change
created_atdatetimeWhen the change occurred

Retrieve a single tag change log entry.

Response 200 OK — same shape as a single result above.

Response 404 Not Found.


All assignment endpoints require authentication. Base path: /api/v1/assignments/

Assignments are the bridge between Employees (supply) and Positions (demand). An assignment says: “Employee X covers Y hours of Position Z.” Only ACTIVE assignments contribute to coverage and hours calculations.

List assignments (paginated).

Query Parameters:

ParamTypeDescription
employeeUUIDFilter by employee ID
position_idUUIDFilter by position UUID
statusstringFilter by status (ACTIVE, SUSPENDED, ENDED)
employee__employee_numberstringFilter by employee number (exact match)
searchstringSearch by employee name or number (icontains)

Response 200 OK (AssignmentListSerializer):

FieldTypeSourceNotes
idUUIDAssignment.idPrimary key
employeeUUIDAssignment.employee_idEmployee FK
employee_namestringComputed”LastName, FirstName”
position_idUUIDAssignment.position_idPosition UUID
position_titlestringComputedTitle of the assigned position
org_unit_namestringComputedName of the org unit containing the position
effective_hoursdecimal(6,2)Assignment.effective_hoursHours this assignment covers
is_reinforcementbooleanAssignment.is_reinforcementtrue if this assignment adds reinforcement hours above the position’s base demand
statusstringAssignment.statusFSM state (see below)
effective_datedate | nullAssignment.effective_dateDate assignment becomes effective
end_datedate | nullAssignment.end_dateSet by end() transition
created_atdatetimeAssignment.created_at

Create a new assignment (AssignmentWriteSerializer). Status defaults to ACTIVE.

Request:

{
"employee": "550e8400-e29b-41d4-a716-446655440000",
"position_id": "660e8400-e29b-41d4-a716-446655440001",
"effective_hours": "8.00",
"effective_date": "2026-03-17",
"notes": "Primary assignment",
"is_reinforcement": false
}

Writable fields: employee, position_id, effective_hours, effective_date, notes, is_reinforcement

Validation rules:

  • effective_hours must be > 0.
  • Employee must be in ACTIVE or ON_LEAVE status. Other statuses are rejected.
  • One active assignment per employee-position pair (unique constraint).

Response 201 Created — returns AssignmentDetailSerializer.

Response 400 Bad Request (validation failure):

{
"employee": ["Cannot assign an employee in ONBOARDING state. Only ACTIVE and ON_LEAVE employees can receive assignments."]
}

Retrieve assignment detail (AssignmentDetailSerializer).

Response 200 OK:

FieldTypeSourceNotes
idUUIDAssignment.idPrimary key
employeeUUIDAssignment.employee_idEmployee FK
employee_namestringComputed”LastName, FirstName”
position_idUUIDAssignment.position_idPosition UUID
effective_hoursdecimal(6,2)Assignment.effective_hours
is_reinforcementbooleanAssignment.is_reinforcementtrue if this assignment adds reinforcement hours above the position’s base demand
statusstringAssignment.statusFSM state
effective_datedate | nullAssignment.effective_date
end_datedate | nullAssignment.end_dateSet by end()
end_reasonstringAssignment.end_reasonSet by end()
suspended_atdatetime | nullAssignment.suspended_atSet by suspend()
notesstringAssignment.notes
created_atdatetimeAssignment.created_at
updated_atdatetimeAssignment.updated_at

Partial update (AssignmentWriteSerializer). Cannot modify status (FSM-controlled).

Writable fields: employee, position_id, effective_hours, effective_date, notes

Response 200 OK — returns AssignmentDetailSerializer.


Response 405 Method Not Allowed. Hard delete is not supported. Use the end/ FSM transition to close an assignment.


All FSM endpoints are POST, return full assignment detail (AssignmentDetailSerializer) on success, or 409 Conflict if the transition is not allowed from the current state.

States: ACTIVE (initial) → SUSPENDEDACTIVE (resume) | ACTIVE / SUSPENDEDENDED (terminal).

AssignmentStatus enum: ACTIVE, SUSPENDED, ENDED

EndpointFromToBody
POST /{id}/suspend/ACTIVESUSPENDED{reason?}
POST /{id}/resume/SUSPENDEDACTIVE{reason?}
POST /{id}/end/ACTIVE, SUSPENDEDENDED{end_reason?, notes?}

EndReason enum: RESIGNATION, TRANSFER, POSITION_CLOSED, TERMINATED, OTHER

Error 409 Conflict:

{
"detail": "Transition \"suspend\" not allowed from state \"ENDED\"."
}

GET /api/v1/assignments/{id}/available-transitions/

Sección titulada «GET /api/v1/assignments/{id}/available-transitions/»

List transitions available from the current state.

Response 200 OK:

{
"status": "ACTIVE",
"transitions": ["suspend", "end"]
}

Audit log for this assignment (paginated, AssignmentTransitionLogSerializer).

Response 200 OK:

FieldTypeSource
idintegerAssignmentTransitionLog.id
from_statusstringAssignmentTransitionLog.from_status
to_statusstringAssignmentTransitionLog.to_status
transitionstringAssignmentTransitionLog.transition
actorUUID | nullAssignmentTransitionLog.actor_id
actor_emailstring | nullComputed from actor.email
reasonstringAssignmentTransitionLog.reason
metadataobjectAssignmentTransitionLog.metadata
created_atdatetimeAssignmentTransitionLog.created_at

Validation-only endpoint: evaluates all enabled business rules against the proposed assignment without persisting. The employee’s hours balance is already visible on the Roster detail card — this endpoint does NOT recalculate hours.

Request:

{
"employee": "550e8400-e29b-41d4-a716-446655440000",
"position_id": "660e8400-e29b-41d4-a716-446655440001",
"effective_hours": "8.00"
}

Response 200 OK:

{
"assignment": {
"employee": "550e8400-e29b-41d4-a716-446655440000",
"employee_name": "García, María",
"position_id": "660e8400-e29b-41d4-a716-446655440001",
"effective_hours": "8.00"
},
"is_valid": true,
"violations": {
"blocking": [],
"warnings": [
{
"rule_code": "COVERAGE_EXCEEDED",
"message": "Puesto quedaría en excedente de horas"
}
],
"info": []
}
}
FieldTypeNotes
assignment.employeeUUIDEmployee ID
assignment.employee_namestring”LastName, FirstName”
assignment.position_idUUIDPosition UUID
assignment.effective_hoursdecimalRequested hours
is_validbooleanfalse if any blocking violations
violations.blockingarrayBLOCKING violations — prevent the assignment
violations.warningsarrayWARNING violations — allow with advisory
violations.infoarrayINFO violations — audit trail only

Violation object shape:

{
"rule_code": "MAX_WEEKLY_HOURS",
"message": "Total semanal sería 68h, excede el tope de 60h"
}

Rule codes (from apps/business_rules, see Business Rules section):

CodeSeverityWhen it fires
EMPLOYEE_TERMINATEDBLOCKINGEmployee status is TERMINATED
DUPLICATE_ASSIGNMENTBLOCKINGActive assignment already exists for (employee, position)
MAX_WEEKLY_HOURSBLOCKINGSUM(active assignments) + effective_hours > 60h threshold
TAG_REQUIREMENT_MISMATCHBLOCKING*Employee lacks required tags for this position. Response includes missing_tags array. *Default BLOCKING; admin-configurable to WARNING via business rules panel.
MAX_CONSECUTIVE_SHIFTSWARNINGEmployee approaching consecutive-day limit
COVERAGE_EXCEEDEDWARNINGPosition would become OVER_COVERED
CONTRACT_NEAR_EXPIRYINFOEmployee’s contract expires within 30 days

Design note: The preview does NOT return employee_hours_before/after or position_hours_before/after. The employee’s balance (DEFICIT/BALANCED/SURPLUS) is already computed by the Hours Ledger pipeline and displayed on the employee card. The preview is a validation gate, not a calculator.


Base path: /api/v1/tags/. All endpoints require authentication. Tags are the unified primitive for contracts, certifications, exceptions, and qualifications (ADR-019).

List tag catalog entries (paginated).

Response 200 OK:

FieldTypeNotes
idUUIDPrimary key
namestringShort badge-friendly label (5-15 chars), UNIQUE. E.g. “Guardia 24h”, “Médico”, “ACLS”
display_namestringLonger human-readable form for detail views and tooltips. E.g. “Guardia Activa 24h”, “Profesional médico/a matriculado/a”
categorystringEnum: CONTRACT, QUALIFICATION, EXCEPTION, CERTIFICATION
hours_deltadecimal(6,2)Weekly hours impact. Positive = adds to pool, negative = reduces, zero = pure qualifier.
descriptionstringHuman-readable context (blank allowed)
is_activebooleanWhether this tag can be assigned to new employees/positions
created_atdatetime
updated_atdatetime

Create a tag catalog entry.

Request:

{
"name": "Guardia 24h",
"display_name": "Guardia Activa 24h",
"category": "CONTRACT",
"hours_delta": "24.00",
"description": "Bloque continuo de 24h, 1 día/semana. Distinto de Medio Tiempo 24h.",
"is_active": true
}

Response 201 Created — returns full tag object.

Retrieve a single tag catalog entry.

Partial update. Writable fields: name, display_name, category, hours_delta, description, is_active.

Response 204 No Content. Only tags with no active employee or position references can be deleted.

Response 409 Conflict (tag in use):

{
"detail": "Cannot delete tag with active references. Deactivate it instead.",
"code": "CONFLICT_HAS_DEPENDENCIES"
}

Base path: /api/v1/employee-tags/. All endpoints require authentication. Employee tags describe what an employee IS — their contracts, qualifications, certifications, and exceptions.

Stacking: An employee CAN hold multiple instances of the same tag (e.g., two “Guardia 12h” tags = 24h pool). Each instance is a separate record with its own lifecycle.

GET /api/v1/employee-tags/?employee_id={uuid}

Sección titulada «GET /api/v1/employee-tags/?employee_id={uuid}»

List employee tags (paginated). Optional employee_id filter.

Query parameters:

ParamTypeDescription
employee_idUUIDFilter by employee
categorystringFilter by tag category: CONTRACT, QUALIFICATION, EXCEPTION, CERTIFICATION
statusstringFilter by status: ACTIVE, EXPIRED, REVOKED

Response 200 OK:

FieldTypeNotes
idUUIDPrimary key
employeeUUIDEmployee FK
tagUUIDTagCatalog FK
tag_namestringComputed from tag (short label)
tag_display_namestringComputed from tag (long human-readable form)
tag_categorystringComputed from tag
hours_deltadecimal(6,2)Computed from tag
start_datedateTag effective start
end_datedate | nullNull = indefinite
statusstringEnum: ACTIVE, EXPIRED, REVOKED
created_atdatetime

Retrieve a single employee tag assignment by ID.

Response 200 OK: Same fields as the list response (see above).

Response 404 Not Found if the record does not exist.

Assign a tag to an employee.

Request:

{
"employee": "550e8400-...",
"tag": "660e8400-...",
"start_date": "2026-01-01",
"end_date": null
}

Response 201 Created. Side effect: creates TagChangeLog entry (action: ASSIGNED).

Partial update. Writable fields: start_date, end_date, status, reason.

Note: Changing the tag FK is not allowed — revoke this tag and assign a new one.

Side effects (TagChangeLog):

Field changedConditionAction logged
statusREVOKEDAnyREVOKED
statusEXPIREDAnyEXPIRED
start_date or end_datestatus unchangedMODIFIED

Each mutation creates exactly one TagChangeLog entry. Status changes take precedence over date changes in the same request (i.e., a PATCH that sets status=REVOKED and end_date=2026-03-27 logs REVOKED, not MODIFIED).

Implementation constraint: Must use instance-level .save(), not queryset .update(), to ensure post_save signals fire for audit capture (ADR-017 pattern).

Soft revoke. Sets status to REVOKED. Side effect: creates TagChangeLog entry (action: REVOKED).

Response 204 No Content.


Base path: /api/v1/position-tags/. All endpoints require authentication. Position tags declare what a position REQUIRES from employees.

GET /api/v1/position-tags/?position_id={uuid}

Sección titulada «GET /api/v1/position-tags/?position_id={uuid}»

List position tag requirements (paginated).

Query parameters:

ParamTypeDescription
position_idUUIDFilter by position

Response 200 OK:

FieldTypeNotes
idUUIDPrimary key
positionUUIDPosition FK
tagUUIDTagCatalog FK
tag_namestringComputed from tag (short label)
tag_display_namestringComputed from tag (long human-readable form)
tag_categorystringComputed from tag
is_mandatorybooleantrue = blocks assignment (default), false = warning only
created_atdatetime

Retrieve a single position tag requirement by ID.

Response 200 OK: Same fields as the list response (see above).

Response 404 Not Found if the record does not exist.

Add a tag requirement to a position.

Request:

{
"position": "660e8400-...",
"tag": "770e8400-...",
"is_mandatory": true
}

Validation: Unique constraint on (position, tag). Duplicate returns 400 Bad Request.

Response 201 Created.

Partial update of a position tag requirement. Writable fields: is_mandatory.

Note: Changing position or tag FKs is not allowed — delete this requirement and create a new one.

Response 200 OK: Updated position tag object (same fields as list response).

Remove a tag requirement from a position.

Response 204 No Content.


Offer — Hours Ledger (Stateless Computation)

Sección titulada «Offer — Hours Ledger (Stateless Computation)»

The hours balance is never persisted (ADR-011, ADR-019). Every request triggers fresh computation from employee tags and assignments. Pool = max(0, Σ(active tag hours_delta)). See ADR-019.

GET /api/v1/offer/employees/{employee_id}/balance/?reference_date=YYYY-MM-DD

Sección titulada «GET /api/v1/offer/employees/{employee_id}/balance/?reference_date=YYYY-MM-DD»

Compute hours balance for a single employee. If reference_date is omitted, defaults to today.

Response 200 OK:

{
"employee_id": "550e8400-...",
"period": {
"start_date": "2026-03-16",
"end_date": "2026-03-22"
},
"pool": {
"base_hours": "40.00",
"adjustment_delta": "-10.00",
"effective_hours": "30.00"
},
"consumption": {
"assigned_hours": "32.00",
"assignment_count": 2
},
"balance": "-2.00",
"state": "DEFICIT",
"computed_at": "2026-03-18T14:30:45.123456Z",
"error": null
}
FieldTypeNotes
employee_idUUID
periodobjectISO week (Mon–Sun) resolved from reference_date
pool.base_hoursdecimalΣ(positive tag deltas) — always weekly per ADR-011
pool.adjustment_deltadecimalΣ(negative tag deltas, pro-rated for partial-week overlap)
pool.effective_hoursdecimalmax(0, base + delta)
consumption.assigned_hoursdecimalSum of ACTIVE assignment hours
consumption.assignment_countinteger
balancedecimaleffective_hours - assigned_hours
statestringDEFICIT (> 0), BALANCED (= 0), SURPLUS (< 0)
computed_atdatetimeTimestamp of computation
tagsarrayList of active tag names for this employee during the period
errorstring | nullNO_ACTIVE_TAGS if no tags with hours_delta > 0 are active for the date

Response 404 Not Found — employee does not exist.


Batch balance computation for up to 500 employees.

Request:

{
"employee_ids": ["550e8400-...", "660e8400-..."],
"reference_date": "2026-03-18"
}

Response 200 OK — array of HoursBalance objects (same shape as single balance).


POST /api/v1/offer/employees/{employee_id}/simulate/

Sección titulada «POST /api/v1/offer/employees/{employee_id}/simulate/»

What-if analysis: projected balance if additional hours were assigned. Does not modify data.

Request:

{
"additional_hours": "8.00",
"reference_date": "2026-03-18"
}

Response 200 OK:

{
"current_balance": { /* HoursBalance object */ },
"projected_balance": "-2.00",
"projected_state": "SURPLUS",
"would_exceed_pool": true
}

Response 404 Not Found — employee does not exist.


All org unit endpoints require authentication. Base path: /api/v1/org-units/

Org units define the hierarchical structure of the clinic: CLINIC → DEPARTMENT → SERVICE → UNIT. Each unit has a parent_id forming an adjacency list. Only UNIT-type nodes hold positions.

List org units (paginated).

Query parameters:

ParamTypeNotes
unit_typestringFilter by OrgUnitTypeCode: CLINIC, DEPARTMENT, SERVICE, UNIT
is_activebooleanFilter active/inactive units
parent_idUUIDFilter direct children of a parent
searchstringFull-text on name and code
orderingstringDefault: sort_order

Response 200 OK (OrgUnitListSerializer):

FieldTypeSourceNotes
idUUIDOrgUnit.idPrimary key
parent_idUUID | nullOrgUnit.parent_idNull for root (CLINIC)
unit_typestringOrgUnit.unit_typeCLINIC, DEPARTMENT, SERVICE, UNIT
codestringOrgUnit.codeHierarchical code (e.g., BIE-MED-GUA)
namestringOrgUnit.nameFull display name
short_namestringOrgUnit.short_nameAbbreviated name
is_activebooleanOrgUnit.is_activeSoft-delete flag
is_managementbooleanOrgUnit.is_managementFalse by default. Whether this UNIT is a management role. Set by admin. Only meaningful on UNIT type.
sort_orderintegerOrgUnit.sort_orderDisplay order among siblings
max_weekly_hoursdecimal | nullOrgUnit.max_weekly_hoursClinic-level override for max weekly hours (default legal cap: 60h). Only meaningful on CLINIC type.
position_countintegercomputedCount of active positions in this unit
created_atdatetimeOrgUnit.created_at
updated_atdatetimeOrgUnit.updated_at

OrgUnitTypeCode enum: CLINIC, DEPARTMENT, SERVICE, UNIT

Create a new org unit (OrgUnitWriteSerializer).

Request:

{
"parent_id": "550e8400-e29b-41d4-a716-446655440001",
"unit_type": "DEPARTMENT",
"code": "BIE-NUE",
"name": "Nuevo Departamento",
"short_name": "Nuevo",
"sort_order": 6
}

Writable fields: parent_id, unit_type, code, name, short_name, sort_order, is_active, max_weekly_hours

Validation rules:

  • code must be unique across all org units.
  • parent_id must reference an existing org unit (or null for root).
  • Only one CLINIC-type unit allowed (root node).
  • unit_type must follow nesting rules: CLINIC has no parent; DEPARTMENT under CLINIC; SERVICE under DEPARTMENT; UNIT under SERVICE or DEPARTMENT.

Response 201 Created — returns OrgUnitDetailSerializer.

Retrieve org unit detail (OrgUnitDetailSerializer).

Additional fields beyond list serializer:

FieldTypeSourceNotes
children_countintegercomputedCount of direct child units
descendant_countintegercomputedCount of all descendants
total_position_countintegercomputedPositions in this unit + all descendants

Response 200 OK — returns OrgUnitDetailSerializer.

Response 404 Not Found — org unit does not exist.

Partial update (OrgUnitWriteSerializer). Cannot modify unit_type after creation.

Writable fields: parent_id, code, name, short_name, sort_order, is_active, max_weekly_hours

Response 200 OK — returns OrgUnitDetailSerializer.

Soft-delete (sets is_active=False). Only units with no active children and no active positions can be deactivated. Records are retained for audit history (PRD §7.4).

Response 204 No Content — soft-deleted (is_active=False).

Response 409 Conflict (has active children or positions):

{
"detail": "Cannot deactivate org unit with active child units.",
"code": "CONFLICT_HAS_DEPENDENCIES"
}

or

{
"detail": "Cannot deactivate org unit with active positions.",
"code": "CONFLICT_HAS_DEPENDENCIES"
}

All position endpoints require authentication. Base path: /api/v1/demand/

Positions define the demand side: “This org unit needs N hours of this role.” Positions belong to UNIT-type org units. Coverage is computed from active assignments against the position’s required_weekly_hours.

List positions (paginated).

Query parameters:

ParamTypeNotes
org_unit_idUUIDFilter by org unit
is_activebooleanFilter active/inactive
coverage_statestringFilter by computed coverage: VACANT, PARTIAL, COVERED, OVER_COVERED
searchstringFull-text on title
eligible_for_employeeUUIDAnnotate each position with tag-based eligibility for this employee. Adds required_tags, is_eligible, missing_tags to response.

Response 200 OK (PositionListSerializer):

FieldTypeSourceNotes
idUUIDPosition.idPrimary key
org_unit_idUUIDPosition.org_unit_idParent org unit
org_unit_namestringcomputedOrg unit name (read-only)
titlestringPosition.titlePosition title (e.g., “Médico de Guardia”)
required_weekly_hoursdecimal(6,2)Position.required_weekly_hoursHours this position needs per week
is_activebooleanPosition.is_active
coverage_statestringcomputedVACANT, PARTIAL, COVERED, OVER_COVERED
assigned_hoursdecimal(6,2)computedSum of active assignment hours
assignment_countintegercomputedCount of active assignments
required_tagsarray|nullcomputedOnly when eligible_for_employee is set. Each entry: {name, category, is_mandatory}
is_eligibleboolean|nullcomputedOnly when eligible_for_employee is set. True if employee has all mandatory tags
missing_tagsarray|nullcomputedOnly when eligible_for_employee is set. List of mandatory tag names the employee lacks
notesstringPosition.notes
created_atdatetimePosition.created_at
updated_atdatetimePosition.updated_at

CoverageState enum: VACANT (0% assigned), PARTIAL (1–99%), COVERED (100%), OVER_COVERED (>100%)

Coverage computation:

coverage_ratio = assigned_hours / required_weekly_hours
VACANT: coverage_ratio == 0
PARTIAL: 0 < coverage_ratio < 1
COVERED: coverage_ratio == 1
OVER_COVERED: coverage_ratio > 1

Create a new position (PositionWriteSerializer).

Request:

{
"org_unit_id": "660e8400-e29b-41d4-a716-446655440001",
"title": "Médico de Guardia",
"required_weekly_hours": "36.00",
"notes": "Cobertura turno noche"
}

Writable fields: org_unit_id, title, required_weekly_hours, is_active, notes

Validation:

  • org_unit_id must reference an existing, active, UNIT-type org unit.
  • required_weekly_hours must be > 0.

Response 201 Created — returns PositionListSerializer.

Retrieve position detail (PositionDetailSerializer).

Additional fields beyond list serializer:

FieldTypeSourceNotes
assignmentsarraycomputedList of active assignments (AssignmentListSerializer)
required_tagsarrayPositionTagList of {id, tag_id, tag_name, tag_category, is_mandatory}

Response 200 OK — returns PositionDetailSerializer.

Partial update (PositionWriteSerializer).

Writable fields: org_unit_id, title, required_weekly_hours, is_active, notes

Response 200 OK — returns PositionDetailSerializer.

Soft-delete (sets is_active=False). Only positions with no active assignments can be deactivated. Records are retained for audit history (PRD §7.4).

Response 204 No Content — soft-deleted (is_active=False).

Response 409 Conflict (has active assignments):

{
"detail": "Cannot deactivate position with active assignments. End all assignments first.",
"code": "CONFLICT_HAS_DEPENDENCIES"
}

Clone a position within the same org unit. Creates a copy with “(copy)” suffix in title. Tag requirements (PositionTag rows) ARE copied. Assignments are NOT copied.

Request: (empty body or optional overrides)

{
"title": "Médico de Guardia (Noche)"
}

Response 201 Created — returns PositionListSerializer of the new position.

Aggregated coverage metrics grouped by org unit. Returns global totals and per-unit breakdown with position counts by coverage state, required/assigned hours, and coverage percentage. Non-paginated — returns the full set.

Response 200 OK:

{
"global": {
"total_positions": 42,
"covered_positions": 12,
"partial_positions": 8,
"vacant_positions": 20,
"over_covered_positions": 2,
"total_required_hours": "1680.00",
"total_assigned_hours": "840.00",
"coverage_pct": "50.00"
},
"by_unit": [
{
"org_unit_id": "uuid",
"org_unit_name": "Unidad X",
"org_unit_type": "UNIT",
"parent_id": "uuid-or-null",
"position_count": 5,
"covered": 2,
"partial": 1,
"vacant": 2,
"over_covered": 0,
"required_hours": "200.00",
"assigned_hours": "120.00",
"coverage_pct": "60.00",
"employee_breakdown": {
"active": 3,
"on_leave": 1,
"other": 0
}
}
]
}
FieldTypeNotes
global.total_positionsintegerTotal active positions across all units
global.covered_positionsintegerPositions with coverage_state = COVERED
global.partial_positionsintegerPositions with coverage_state = PARTIAL
global.vacant_positionsintegerPositions with coverage_state = VACANT
global.over_covered_positionsintegerPositions with coverage_state = OVER_COVERED
global.total_required_hoursdecimal(10,2)Sum of required_weekly_hours across all active positions
global.total_assigned_hoursdecimal(10,2)Sum of active assignment hours across all positions
global.coverage_pctdecimal(5,2)(total_assigned / total_required) * 100, or 0 if no required hours
by_unit[].org_unit_idUUIDOrg unit primary key
by_unit[].org_unit_namestringOrg unit name
by_unit[].org_unit_typestringCLINIC, DEPARTMENT, SERVICE, UNIT
by_unit[].parent_idUUID or nullParent org unit (null for root)
by_unit[].position_countintegerActive positions in this unit
by_unit[].coveredintegerCOVERED positions
by_unit[].partialintegerPARTIAL positions
by_unit[].vacantintegerVACANT positions
by_unit[].over_coveredintegerOVER_COVERED positions
by_unit[].required_hoursdecimal(10,2)Sum of required_weekly_hours for this unit
by_unit[].assigned_hoursdecimal(10,2)Sum of active assignment hours for this unit
by_unit[].coverage_pctdecimal(5,2)Unit-level coverage percentage
by_unit[].employee_breakdownobjectEmployee status counts for this unit’s active assignments
by_unit[].employee_breakdown.activeintegerEmployees with status ACTIVE
by_unit[].employee_breakdown.on_leaveintegerEmployees with status ON_LEAVE
by_unit[].employee_breakdown.otherintegerEmployees with any other status

by_unit is sorted by coverage_pct ascending (worst coverage first). Only units with at least one active position are included.

Hierarchical aggregation: The by_unit array is flat — it lists only UNIT-type nodes that hold positions. Each entry includes parent_id, allowing the frontend to reconstruct the tree hierarchy and roll up coverage from Unit → Service → Department → Clinic. The backend does not perform hierarchical aggregation; the client computes parent-level metrics by traversing parent_id chains against the org-unit tree from GET /api/v1/org-units/.

Base path: /api/v1/business-rules/. All endpoints require authentication.

Non-paginated reference list. Rules are seeded via migration and configurable via admin. The evaluation engine is separate — this endpoint exposes the catalogue only.

When rules are evaluated:

TriggerEndpointBehavior
Assignment previewPOST /api/v1/assignments/preview/All enabled rules evaluated; violations returned without persisting
Assignment creationPOST /api/v1/assignments/BLOCKING rules prevent creation (400); WARNING/INFO violations logged to metadata
Assignment updatePATCH /api/v1/assignments/{id}/Same as creation — re-evaluated on effective_hours change

Rules are not evaluated on DELETE, FSM transitions (suspend/resume/end), or read operations.

List all business rules. Non-paginated — returns the full set.

Response 200 OK:

[
{
"id": "uuid",
"code": "MAX_WEEKLY_HOURS",
"name": "Límite máximo de horas semanales",
"severity": "BLOCKING",
"threshold": "60.00",
"enabled": true,
"description": "No exceder el tope legal de horas semanales",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
]
FieldTypeNotes
idUUIDPrimary key
codestringUnique identifier (e.g., MAX_WEEKLY_HOURS)
namestringHuman-readable label
severitystringEnum: BLOCKING, WARNING, INFO (ADR-010)
thresholddecimal(10,2) | nullNumeric value for rules that need one (e.g., 60h cap, 30 days)
enabledbooleanToggle on/off without code changes
descriptionstringExplanation for control panel UI
created_atdatetime
updated_atdatetime

severity semantics (BLOCKING prevents, WARNING advises, INFO logs only) are defined in [[adr-010-rule-severity-levels|ADR-010]].

Retrieve a single business rule by UUID.

Response 200 OK — single object (same shape as list item).

Response 404 Not Found — rule does not exist.

MVP seed rules (7):

CodeSeverityThresholdDescription
MAX_WEEKLY_HOURSBLOCKING60.00No exceder el tope legal de horas semanales
DUPLICATE_ASSIGNMENTBLOCKINGNo duplicar asignación activa por empleado y puesto
EMPLOYEE_TERMINATEDBLOCKINGNo asignar empleados desvinculados
TAG_REQUIREMENT_MISMATCHBLOCKINGEmpleado carece de tags requeridos por el puesto (ADR-019 §5). Admin-configurable a WARNING.
MAX_CONSECUTIVE_SHIFTSWARNINGEmpleado acercándose al límite de días consecutivos
COVERAGE_EXCEEDEDWARNINGPuesto quedaría en excedente de horas
CONTRACT_NEAR_EXPIRYINFO30.00Contrato próximo a vencer

Scope: Health + Authentication + GDPR + Employees + Employee FSM + Audit Log (transitions, tag-change-logs) + Assignments + Assignment FSM + Hours Ledger (offer) + Tags + Employee/Position Tags + Org Units + Positions (demand) + Coverage Summary + Business Rules. Conventions: see [[api-conventions]].