Merge pull request #14 from abhi1693/jarvis/hr-data-model

HR data model: link onboarding/headcount to employees + idempotent actions
This commit is contained in:
Abhimanyu Saharan
2026-02-02 14:39:29 +05:30
committed by GitHub
4 changed files with 106 additions and 5 deletions

View File

@@ -0,0 +1,48 @@
"""hr data model links (onboarding.employee_id, headcount fulfillment, employment action idempotency)
Revision ID: 2b8d1e2c0d01
Revises: 9d3d9b9c1a23
Create Date: 2026-02-02 09:05:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "2b8d1e2c0d01"
down_revision = "9d3d9b9c1a23"
branch_labels = None
depends_on = None
def upgrade() -> None:
# headcount_requests fulfillment fields
op.add_column("headcount_requests", sa.Column("fulfilled_employee_id", sa.Integer(), nullable=True))
op.add_column("headcount_requests", sa.Column("fulfilled_onboarding_id", sa.Integer(), nullable=True))
op.add_column("headcount_requests", sa.Column("fulfilled_at", sa.DateTime(), nullable=True))
op.create_foreign_key("fk_headcount_fulfilled_employee", "headcount_requests", "employees", ["fulfilled_employee_id"], ["id"])
op.create_foreign_key("fk_headcount_fulfilled_onboarding", "headcount_requests", "agent_onboardings", ["fulfilled_onboarding_id"], ["id"])
# employment_actions idempotency key
op.add_column("employment_actions", sa.Column("idempotency_key", sa.String(), nullable=True))
op.create_unique_constraint("uq_employment_actions_idempotency_key", "employment_actions", ["idempotency_key"])
op.create_index("ix_employment_actions_idempotency_key", "employment_actions", ["idempotency_key"])
# agent_onboardings employee link
op.add_column("agent_onboardings", sa.Column("employee_id", sa.Integer(), nullable=True))
op.create_foreign_key("fk_agent_onboardings_employee", "agent_onboardings", "employees", ["employee_id"], ["id"])
def downgrade() -> None:
op.drop_constraint("fk_agent_onboardings_employee", "agent_onboardings", type_="foreignkey")
op.drop_column("agent_onboardings", "employee_id")
op.drop_index("ix_employment_actions_idempotency_key", table_name="employment_actions")
op.drop_constraint("uq_employment_actions_idempotency_key", "employment_actions", type_="unique")
op.drop_column("employment_actions", "idempotency_key")
op.drop_constraint("fk_headcount_fulfilled_onboarding", "headcount_requests", type_="foreignkey")
op.drop_constraint("fk_headcount_fulfilled_employee", "headcount_requests", type_="foreignkey")
op.drop_column("headcount_requests", "fulfilled_at")
op.drop_column("headcount_requests", "fulfilled_onboarding_id")
op.drop_column("headcount_requests", "fulfilled_employee_id")

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select from sqlmodel import Session, select
from sqlalchemy.exc import IntegrityError
from app.api.utils import log_activity, get_actor_employee_id from app.api.utils import log_activity, get_actor_employee_id
from app.db.session import get_session from app.db.session import get_session
@@ -34,6 +35,8 @@ def update_headcount_request(request_id: int, payload: HeadcountRequestUpdate, s
raise HTTPException(status_code=404, detail="Request not found") raise HTTPException(status_code=404, detail="Request not found")
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
if data.get("status") == "fulfilled" and getattr(req, "fulfilled_at", None) is None:
req.fulfilled_at = datetime.utcnow()
for k, v in data.items(): for k, v in data.items():
setattr(req, k, v) setattr(req, k, v)
@@ -51,14 +54,46 @@ def list_employment_actions(session: Session = Depends(get_session)):
@router.post("/actions", response_model=EmploymentAction) @router.post("/actions", response_model=EmploymentAction)
def create_employment_action(payload: EmploymentActionCreate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): def create_employment_action(
payload: EmploymentActionCreate,
session: Session = Depends(get_session),
actor_employee_id: int = Depends(get_actor_employee_id),
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
):
# Prefer explicit payload key; header can supply one for retry-safety.
if payload.idempotency_key is None and idempotency_key is not None:
payload = EmploymentActionCreate(**{**payload.model_dump(), "idempotency_key": idempotency_key})
if payload.idempotency_key:
existing = session.exec(select(EmploymentAction).where(EmploymentAction.idempotency_key == payload.idempotency_key)).first()
if existing:
return existing
action = EmploymentAction(**payload.model_dump()) action = EmploymentAction(**payload.model_dump())
session.add(action) session.add(action)
session.commit()
try:
session.flush()
log_activity(
session,
actor_employee_id=actor_employee_id,
entity_type="employment_action",
entity_id=action.id,
verb=action.action_type,
payload={"employee_id": action.employee_id},
)
session.commit()
except IntegrityError:
session.rollback()
# if unique constraint on idempotency_key raced
if payload.idempotency_key:
existing = session.exec(select(EmploymentAction).where(EmploymentAction.idempotency_key == payload.idempotency_key)).first()
if existing:
return existing
raise HTTPException(status_code=409, detail="Employment action violates constraints")
session.refresh(action) session.refresh(action)
log_activity(session, actor_employee_id=actor_employee_id, entity_type="employment_action", entity_id=action.id, verb=action.action_type, payload={"employee_id": action.employee_id}) return EmploymentAction.model_validate(action)
session.commit()
return action
@router.get("/onboarding", response_model=list[AgentOnboarding]) @router.get("/onboarding", response_model=list[AgentOnboarding])
def list_agent_onboarding(session: Session = Depends(get_session)): def list_agent_onboarding(session: Session = Depends(get_session)):
@@ -83,6 +118,8 @@ def update_agent_onboarding(onboarding_id: int, payload: AgentOnboardingUpdate,
raise HTTPException(status_code=404, detail="Onboarding record not found") raise HTTPException(status_code=404, detail="Onboarding record not found")
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
if data.get("status") == "fulfilled" and getattr(req, "fulfilled_at", None) is None:
req.fulfilled_at = datetime.utcnow()
for k, v in data.items(): for k, v in data.items():
setattr(item, k, v) setattr(item, k, v)
from datetime import datetime from datetime import datetime

View File

@@ -19,6 +19,11 @@ class HeadcountRequest(SQLModel, table=True):
justification: str | None = None justification: str | None = None
status: str = Field(default="submitted") status: str = Field(default="submitted")
# fulfillment linkage (optional)
fulfilled_employee_id: int | None = Field(default=None, foreign_key="employees.id")
fulfilled_onboarding_id: int | None = Field(default=None, foreign_key="agent_onboardings.id")
fulfilled_at: datetime | None = None
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
@@ -32,6 +37,9 @@ class EmploymentAction(SQLModel, table=True):
action_type: str # praise|warning|pip|termination action_type: str # praise|warning|pip|termination
notes: str | None = None notes: str | None = None
# Optional idempotency key to prevent duplicates on retries
idempotency_key: str | None = Field(default=None, index=True, unique=True)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
@@ -46,6 +54,9 @@ class AgentOnboarding(SQLModel, table=True):
tools_json: str | None = None tools_json: str | None = None
owner_hr_id: int | None = Field(default=None, foreign_key="employees.id") owner_hr_id: int | None = Field(default=None, foreign_key="employees.id")
# Link to the employee record once created
employee_id: int | None = Field(default=None, foreign_key="employees.id")
status: str = Field(default="planned") # planned|spawning|spawned|verified|blocked status: str = Field(default="planned") # planned|spawning|spawned|verified|blocked
spawned_agent_id: str | None = None spawned_agent_id: str | None = None
session_key: str | None = None session_key: str | None = None

View File

@@ -15,6 +15,8 @@ class HeadcountRequestCreate(SQLModel):
class HeadcountRequestUpdate(SQLModel): class HeadcountRequestUpdate(SQLModel):
status: str | None = None status: str | None = None
justification: str | None = None justification: str | None = None
fulfilled_employee_id: int | None = None
fulfilled_onboarding_id: int | None = None
class EmploymentActionCreate(SQLModel): class EmploymentActionCreate(SQLModel):
@@ -22,6 +24,7 @@ class EmploymentActionCreate(SQLModel):
issued_by_employee_id: int issued_by_employee_id: int
action_type: str action_type: str
notes: str | None = None notes: str | None = None
idempotency_key: str | None = None
class AgentOnboardingCreate(SQLModel): class AgentOnboardingCreate(SQLModel):
@@ -31,6 +34,7 @@ class AgentOnboardingCreate(SQLModel):
cron_interval_ms: int | None = None cron_interval_ms: int | None = None
tools_json: str | None = None tools_json: str | None = None
owner_hr_id: int | None = None owner_hr_id: int | None = None
employee_id: int | None = None
status: str = "planned" status: str = "planned"
spawned_agent_id: str | None = None spawned_agent_id: str | None = None
session_key: str | None = None session_key: str | None = None
@@ -44,6 +48,7 @@ class AgentOnboardingUpdate(SQLModel):
cron_interval_ms: int | None = None cron_interval_ms: int | None = None
tools_json: str | None = None tools_json: str | None = None
owner_hr_id: int | None = None owner_hr_id: int | None = None
employee_id: int | None = None
status: str | None = None status: str | None = None
spawned_agent_id: str | None = None spawned_agent_id: str | None = None
session_key: str | None = None session_key: str | None = None