Refactor backend to SQLModel; reset schema; add Company OS endpoints

This commit is contained in:
Abhimanyu Saharan
2026-02-01 23:16:56 +05:30
parent b37e7dd841
commit aa6b0c807b
56 changed files with 867 additions and 450 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.db.session import get_session
from app.models.activity import Activity
router = APIRouter(prefix="/activities", tags=["activities"])
@router.get("")
def list_activities(limit: int = 50, session: Session = Depends(get_session)):
items = session.exec(select(Activity).order_by(Activity.id.desc()).limit(max(1, min(limit, 200)))).all()
out = []
for a in items:
out.append(
{
"id": a.id,
"actor_employee_id": a.actor_employee_id,
"entity_type": a.entity_type,
"entity_id": a.entity_id,
"verb": a.verb,
"payload": json.loads(a.payload_json) if a.payload_json else None,
"created_at": a.created_at,
}
)
return out

61
backend/app/api/hr.py Normal file
View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.api.utils import log_activity
from app.db.session import get_session
from app.models.hr import EmploymentAction, HeadcountRequest
from app.schemas.hr import EmploymentActionCreate, HeadcountRequestCreate, HeadcountRequestUpdate
router = APIRouter(prefix="/hr", tags=["hr"])
@router.get("/headcount", response_model=list[HeadcountRequest])
def list_headcount_requests(session: Session = Depends(get_session)):
return session.exec(select(HeadcountRequest).order_by(HeadcountRequest.id.desc())).all()
@router.post("/headcount", response_model=HeadcountRequest)
def create_headcount_request(payload: HeadcountRequestCreate, session: Session = Depends(get_session)):
req = HeadcountRequest(**payload.model_dump())
session.add(req)
session.commit()
session.refresh(req)
log_activity(session, actor_employee_id=req.requested_by_manager_id, entity_type="headcount_request", entity_id=req.id, verb="submitted")
session.commit()
return req
@router.patch("/headcount/{request_id}", response_model=HeadcountRequest)
def update_headcount_request(request_id: int, payload: HeadcountRequestUpdate, session: Session = Depends(get_session)):
req = session.get(HeadcountRequest, request_id)
if not req:
raise HTTPException(status_code=404, detail="Request not found")
data = payload.model_dump(exclude_unset=True)
for k, v in data.items():
setattr(req, k, v)
session.add(req)
session.commit()
session.refresh(req)
log_activity(session, actor_employee_id=req.requested_by_manager_id, entity_type="headcount_request", entity_id=req.id, verb="updated", payload=data)
session.commit()
return req
@router.get("/actions", response_model=list[EmploymentAction])
def list_employment_actions(session: Session = Depends(get_session)):
return session.exec(select(EmploymentAction).order_by(EmploymentAction.id.desc())).all()
@router.post("/actions", response_model=EmploymentAction)
def create_employment_action(payload: EmploymentActionCreate, session: Session = Depends(get_session)):
action = EmploymentAction(**payload.model_dump())
session.add(action)
session.commit()
session.refresh(action)
log_activity(session, actor_employee_id=action.issued_by_employee_id, entity_type="employment_action", entity_id=action.id, verb=action.action_type, payload={"employee_id": action.employee_id})
session.commit()
return action

79
backend/app/api/org.py Normal file
View File

@@ -0,0 +1,79 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.api.utils import log_activity
from app.db.session import get_session
from app.models.org import Department, Employee
from app.schemas.org import DepartmentCreate, DepartmentUpdate, EmployeeCreate, EmployeeUpdate
router = APIRouter(tags=["org"])
@router.get("/departments", response_model=list[Department])
def list_departments(session: Session = Depends(get_session)):
return session.exec(select(Department).order_by(Department.name.asc())).all()
@router.post("/departments", response_model=Department)
def create_department(payload: DepartmentCreate, session: Session = Depends(get_session)):
dept = Department(name=payload.name, head_employee_id=payload.head_employee_id)
session.add(dept)
session.commit()
session.refresh(dept)
log_activity(session, actor_employee_id=None, entity_type="department", entity_id=dept.id, verb="created", payload={"name": dept.name})
session.commit()
return dept
@router.patch("/departments/{department_id}", response_model=Department)
def update_department(department_id: int, payload: DepartmentUpdate, session: Session = Depends(get_session)):
dept = session.get(Department, department_id)
if not dept:
raise HTTPException(status_code=404, detail="Department not found")
data = payload.model_dump(exclude_unset=True)
for k, v in data.items():
setattr(dept, k, v)
session.add(dept)
session.commit()
session.refresh(dept)
log_activity(session, actor_employee_id=None, entity_type="department", entity_id=dept.id, verb="updated", payload=data)
session.commit()
return dept
@router.get("/employees", response_model=list[Employee])
def list_employees(session: Session = Depends(get_session)):
return session.exec(select(Employee).order_by(Employee.id.asc())).all()
@router.post("/employees", response_model=Employee)
def create_employee(payload: EmployeeCreate, session: Session = Depends(get_session)):
emp = Employee(**payload.model_dump())
session.add(emp)
session.commit()
session.refresh(emp)
log_activity(session, actor_employee_id=None, entity_type="employee", entity_id=emp.id, verb="created", payload={"name": emp.name, "type": emp.employee_type})
session.commit()
return emp
@router.patch("/employees/{employee_id}", response_model=Employee)
def update_employee(employee_id: int, payload: EmployeeUpdate, session: Session = Depends(get_session)):
emp = session.get(Employee, employee_id)
if not emp:
raise HTTPException(status_code=404, detail="Employee not found")
data = payload.model_dump(exclude_unset=True)
for k, v in data.items():
setattr(emp, k, v)
session.add(emp)
session.commit()
session.refresh(emp)
log_activity(session, actor_employee_id=None, entity_type="employee", entity_id=emp.id, verb="updated", payload=data)
session.commit()
return emp

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.api.utils import log_activity
from app.db.session import get_session
from app.models.projects import Project
from app.schemas.projects import ProjectCreate, ProjectUpdate
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("", response_model=list[Project])
def list_projects(session: Session = Depends(get_session)):
return session.exec(select(Project).order_by(Project.name.asc())).all()
@router.post("", response_model=Project)
def create_project(payload: ProjectCreate, session: Session = Depends(get_session)):
proj = Project(**payload.model_dump())
session.add(proj)
session.commit()
session.refresh(proj)
log_activity(session, actor_employee_id=None, entity_type="project", entity_id=proj.id, verb="created", payload={"name": proj.name})
session.commit()
return proj
@router.patch("/{project_id}", response_model=Project)
def update_project(project_id: int, payload: ProjectUpdate, session: Session = Depends(get_session)):
proj = session.get(Project, project_id)
if not proj:
raise HTTPException(status_code=404, detail="Project not found")
data = payload.model_dump(exclude_unset=True)
for k, v in data.items():
setattr(proj, k, v)
session.add(proj)
session.commit()
session.refresh(proj)
log_activity(session, actor_employee_id=None, entity_type="project", entity_id=proj.id, verb="updated", payload=data)
session.commit()
return proj

View File

@@ -1,64 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db.session import SessionLocal
from app.models.task import Task
from app.schemas.task import TaskCreate, TaskOut, TaskUpdate
router = APIRouter(prefix="/tasks", tags=["tasks"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("", response_model=list[TaskOut])
def list_tasks(db: Session = Depends(get_db)):
return db.query(Task).order_by(Task.id.desc()).all()
@router.post("", response_model=TaskOut)
def create_task(payload: TaskCreate, db: Session = Depends(get_db)):
task = Task(
title=payload.title,
description=payload.description,
status=payload.status,
assignee=payload.assignee,
)
db.add(task)
db.commit()
db.refresh(task)
return task
@router.patch("/{task_id}", response_model=TaskOut)
def update_task(task_id: int, payload: TaskUpdate, db: Session = Depends(get_db)):
task = db.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
data = payload.model_dump(exclude_unset=True)
for k, v in data.items():
setattr(task, k, v)
db.add(task)
db.commit()
db.refresh(task)
return task
@router.delete("/{task_id}")
def delete_task(task_id: int, db: Session = Depends(get_db)):
task = db.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
db.delete(task)
db.commit()
return {"ok": True}

28
backend/app/api/utils.py Normal file
View File

@@ -0,0 +1,28 @@
from __future__ import annotations
import json
from typing import Any
from sqlmodel import Session
from app.models.activity import Activity
def log_activity(
session: Session,
*,
actor_employee_id: int | None,
entity_type: str,
entity_id: int | None,
verb: str,
payload: dict[str, Any] | None = None,
) -> None:
session.add(
Activity(
actor_employee_id=actor_employee_id,
entity_type=entity_type,
entity_id=entity_id,
verb=verb,
payload_json=json.dumps(payload) if payload is not None else None,
)
)

80
backend/app/api/work.py Normal file
View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.api.utils import log_activity
from app.db.session import get_session
from app.models.work import Task, TaskComment
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate
router = APIRouter(tags=["work"])
@router.get("/tasks", response_model=list[Task])
def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)):
stmt = select(Task).order_by(Task.id.asc())
if project_id is not None:
stmt = stmt.where(Task.project_id == project_id)
return session.exec(stmt).all()
@router.post("/tasks", response_model=Task)
def create_task(payload: TaskCreate, session: Session = Depends(get_session)):
task = Task(**payload.model_dump())
task.updated_at = datetime.utcnow()
session.add(task)
session.commit()
session.refresh(task)
log_activity(session, actor_employee_id=task.created_by_employee_id, entity_type="task", entity_id=task.id, verb="created", payload={"project_id": task.project_id, "title": task.title})
session.commit()
return task
@router.patch("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, payload: TaskUpdate, session: Session = Depends(get_session)):
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
data = payload.model_dump(exclude_unset=True)
for k, v in data.items():
setattr(task, k, v)
task.updated_at = datetime.utcnow()
session.add(task)
session.commit()
session.refresh(task)
log_activity(session, actor_employee_id=None, entity_type="task", entity_id=task.id, verb="updated", payload=data)
session.commit()
return task
@router.delete("/tasks/{task_id}")
def delete_task(task_id: int, session: Session = Depends(get_session)):
task = session.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
session.delete(task)
session.commit()
log_activity(session, actor_employee_id=None, entity_type="task", entity_id=task_id, verb="deleted")
session.commit()
return {"ok": True}
@router.get("/task-comments", response_model=list[TaskComment])
def list_task_comments(task_id: int, session: Session = Depends(get_session)):
return session.exec(select(TaskComment).where(TaskComment.task_id == task_id).order_by(TaskComment.id.asc())).all()
@router.post("/task-comments", response_model=TaskComment)
def create_task_comment(payload: TaskCommentCreate, session: Session = Depends(get_session)):
c = TaskComment(**payload.model_dump())
session.add(c)
session.commit()
session.refresh(c)
log_activity(session, actor_employee_id=c.author_employee_id, entity_type="task", entity_id=c.task_id, verb="commented")
session.commit()
return c

View File

@@ -1,5 +0,0 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@@ -1,9 +1,16 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlmodel import Session, SQLModel, create_engine
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
engine = create_engine(settings.database_url, echo=False)
def init_db() -> None:
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session

View File

@@ -3,10 +3,15 @@ from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.tasks import router as tasks_router
from app.api.activities import router as activities_router
from app.api.hr import router as hr_router
from app.api.org import router as org_router
from app.api.projects import router as projects_router
from app.api.work import router as work_router
from app.core.config import settings
from app.db.session import init_db
app = FastAPI(title="OpenClaw Agency API", version="0.1.0")
app = FastAPI(title="OpenClaw Agency API", version="0.3.0")
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
if origins:
@@ -14,11 +19,21 @@ if origins:
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"] ,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(tasks_router)
@app.on_event("startup")
def on_startup() -> None:
init_db()
app.include_router(org_router)
app.include_router(projects_router)
app.include_router(work_router)
app.include_router(hr_router)
app.include_router(activities_router)
@app.get("/health")

View File

@@ -1,2 +1,17 @@
# Import models here so Alembic can discover them
from .task import Task # noqa: F401
from app.models.org import Department, Employee
from app.models.projects import Project, ProjectMember
from app.models.work import Task, TaskComment
from app.models.hr import HeadcountRequest, EmploymentAction
from app.models.activity import Activity
__all__ = [
"Department",
"Employee",
"Project",
"ProjectMember",
"Task",
"TaskComment",
"HeadcountRequest",
"EmploymentAction",
"Activity",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime
from sqlmodel import Field, SQLModel
class Activity(SQLModel, table=True):
__tablename__ = "activities"
id: int | None = Field(default=None, primary_key=True)
actor_employee_id: int | None = Field(default=None, foreign_key="employees.id")
entity_type: str
entity_id: int | None = None
verb: str
payload_json: str | None = None
created_at: datetime = Field(default_factory=datetime.utcnow)

35
backend/app/models/hr.py Normal file
View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import datetime
from sqlmodel import Field, SQLModel
class HeadcountRequest(SQLModel, table=True):
__tablename__ = "headcount_requests"
id: int | None = Field(default=None, primary_key=True)
department_id: int = Field(foreign_key="departments.id")
requested_by_manager_id: int = Field(foreign_key="employees.id")
role_title: str
employee_type: str # human | agent
quantity: int = Field(default=1)
justification: str | None = None
status: str = Field(default="submitted")
created_at: datetime = Field(default_factory=datetime.utcnow)
class EmploymentAction(SQLModel, table=True):
__tablename__ = "employment_actions"
id: int | None = Field(default=None, primary_key=True)
employee_id: int = Field(foreign_key="employees.id")
issued_by_employee_id: int = Field(foreign_key="employees.id")
action_type: str # praise|warning|pip|termination
notes: str | None = None
created_at: datetime = Field(default_factory=datetime.utcnow)

27
backend/app/models/org.py Normal file
View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from typing import Optional
from sqlmodel import Field, SQLModel
class Department(SQLModel, table=True):
__tablename__ = "departments"
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True, unique=True)
head_employee_id: int | None = Field(default=None, foreign_key="employees.id")
class Employee(SQLModel, table=True):
__tablename__ = "employees"
id: int | None = Field(default=None, primary_key=True)
name: str
employee_type: str # human | agent
department_id: int | None = Field(default=None, foreign_key="departments.id")
manager_id: int | None = Field(default=None, foreign_key="employees.id")
title: str | None = None
status: str = Field(default="active")

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from sqlmodel import Field, SQLModel
class Project(SQLModel, table=True):
__tablename__ = "projects"
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True, unique=True)
status: str = Field(default="active")
class ProjectMember(SQLModel, table=True):
__tablename__ = "project_members"
id: int | None = Field(default=None, primary_key=True)
project_id: int = Field(foreign_key="projects.id")
employee_id: int = Field(foreign_key="employees.id")
role: str | None = None

View File

@@ -1,28 +0,0 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.sql import func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# kanban columns: todo | doing | done
status: Mapped[str] = mapped_column(String(32), nullable=False, default="todo")
# simple attribution (no auth)
assignee: Mapped[str | None] = mapped_column(String(120), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import datetime
from sqlmodel import Field, SQLModel
class Task(SQLModel, table=True):
__tablename__ = "tasks"
id: int | None = Field(default=None, primary_key=True)
project_id: int = Field(foreign_key="projects.id", index=True)
title: str
description: str | None = None
status: str = Field(default="backlog", index=True)
assignee_employee_id: int | None = Field(default=None, foreign_key="employees.id")
reviewer_employee_id: int | None = Field(default=None, foreign_key="employees.id")
created_by_employee_id: int | None = Field(default=None, foreign_key="employees.id")
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class TaskComment(SQLModel, table=True):
__tablename__ = "task_comments"
id: int | None = Field(default=None, primary_key=True)
task_id: int = Field(foreign_key="tasks.id", index=True)
author_employee_id: int | None = Field(default=None, foreign_key="employees.id")
body: str
created_at: datetime = Field(default_factory=datetime.utcnow)

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
backend/app/schemas/hr.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from sqlmodel import SQLModel
class HeadcountRequestCreate(SQLModel):
department_id: int
requested_by_manager_id: int
role_title: str
employee_type: str
quantity: int = 1
justification: str | None = None
class HeadcountRequestUpdate(SQLModel):
status: str | None = None
justification: str | None = None
class EmploymentActionCreate(SQLModel):
employee_id: int
issued_by_employee_id: int
action_type: str
notes: str | None = None

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from sqlmodel import SQLModel
class DepartmentCreate(SQLModel):
name: str
head_employee_id: int | None = None
class DepartmentUpdate(SQLModel):
name: str | None = None
head_employee_id: int | None = None
class EmployeeCreate(SQLModel):
name: str
employee_type: str
department_id: int | None = None
manager_id: int | None = None
title: str | None = None
status: str = "active"
class EmployeeUpdate(SQLModel):
name: str | None = None
employee_type: str | None = None
department_id: int | None = None
manager_id: int | None = None
title: str | None = None
status: str | None = None

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from sqlmodel import SQLModel
class ProjectCreate(SQLModel):
name: str
status: str = "active"
class ProjectUpdate(SQLModel):
name: str | None = None
status: str | None = None

View File

@@ -1,35 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
TaskStatus = Literal["todo", "doing", "done"]
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str | None = None
status: TaskStatus = "todo"
assignee: str | None = None
class TaskUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = None
status: TaskStatus | None = None
assignee: str | None = None
class TaskOut(BaseModel):
id: int
title: str
description: str | None
status: TaskStatus
assignee: str | None
created_at: datetime
updated_at: datetime | None
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from sqlmodel import SQLModel
class TaskCreate(SQLModel):
project_id: int
title: str
description: str | None = None
status: str = "backlog"
assignee_employee_id: int | None = None
reviewer_employee_id: int | None = None
created_by_employee_id: int | None = None
class TaskUpdate(SQLModel):
title: str | None = None
description: str | None = None
status: str | None = None
assignee_employee_id: int | None = None
reviewer_employee_id: int | None = None
class TaskCommentCreate(SQLModel):
task_id: int
author_employee_id: int | None = None
body: str