Commit 5a7791d1 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

chore: Integrate Paperclip with FastAPI and resolve API case discrepancies,...

chore: Integrate Paperclip with FastAPI and resolve API case discrepancies, custom endpoints, and route issues
parent ff2472d4
...@@ -19,6 +19,7 @@ from .routes.secrets import router as secrets_router ...@@ -19,6 +19,7 @@ from .routes.secrets import router as secrets_router
from .routes.costs import router as costs_router from .routes.costs import router as costs_router
from .routes.activity import router as activity_router from .routes.activity import router as activity_router
from .routes.dashboard import router as dashboard_router from .routes.dashboard import router as dashboard_router
from .routes.heartbeat_runs import router as heartbeat_runs_router
from .routes.environments import router as environments_router from .routes.environments import router as environments_router
from .routes.execution_workspaces import router as execution_workspaces_router from .routes.execution_workspaces import router as execution_workspaces_router
from .routes.documents import router as documents_router from .routes.documents import router as documents_router
...@@ -91,6 +92,7 @@ api_sub_router.include_router(secrets_router) ...@@ -91,6 +92,7 @@ api_sub_router.include_router(secrets_router)
api_sub_router.include_router(costs_router) api_sub_router.include_router(costs_router)
api_sub_router.include_router(activity_router) api_sub_router.include_router(activity_router)
api_sub_router.include_router(dashboard_router) api_sub_router.include_router(dashboard_router)
api_sub_router.include_router(heartbeat_runs_router)
api_sub_router.include_router(environments_router) api_sub_router.include_router(environments_router)
api_sub_router.include_router(execution_workspaces_router) api_sub_router.include_router(execution_workspaces_router)
api_sub_router.include_router(documents_router) api_sub_router.include_router(documents_router)
......
...@@ -64,7 +64,7 @@ async def create_agent( ...@@ -64,7 +64,7 @@ async def create_agent(
return db_agent return db_agent
@router.get("", response_model=PaginatedResponse) @router.get("", response_model=List[AgentResponse])
async def list_agents( async def list_agents(
company_id: str = Query(..., description="Company ID"), company_id: str = Query(..., description="Company ID"),
status: Optional[str] = Query(None, description="Filter by status"), status: Optional[str] = Query(None, description="Filter by status"),
...@@ -91,17 +91,10 @@ async def list_agents( ...@@ -91,17 +91,10 @@ async def list_agents(
rows = result.fetchall() rows = result.fetchall()
if not rows: if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []} return []
total = rows[0].total
agents = [AgentResponse.model_validate(row._mapping["Agent"]) for row in rows] agents = [AgentResponse.model_validate(row._mapping["Agent"]) for row in rows]
return agents
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": agents,
}
@router.get("/{agent_id}", response_model=AgentResponse) @router.get("/{agent_id}", response_model=AgentResponse)
......
...@@ -63,7 +63,7 @@ async def create_approval( ...@@ -63,7 +63,7 @@ async def create_approval(
return db_approval return db_approval
@router.get("", response_model=PaginatedResponse) @router.get("", response_model=List[ApprovalResponse])
async def list_approvals( async def list_approvals(
company_id: str = Query(..., description="Company ID"), company_id: str = Query(..., description="Company ID"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"), resource_type: Optional[str] = Query(None, description="Filter by resource type"),
...@@ -86,17 +86,10 @@ async def list_approvals( ...@@ -86,17 +86,10 @@ async def list_approvals(
rows = result.fetchall() rows = result.fetchall()
if not rows: if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []} return []
total = rows[0].total
approvals = [ApprovalResponse.model_validate(row._mapping["Approval"]) for row in rows] approvals = [ApprovalResponse.model_validate(row._mapping["Approval"]) for row in rows]
return approvals
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": approvals,
}
@router.get("/{approval_id}", response_model=ApprovalResponse) @router.get("/{approval_id}", response_model=ApprovalResponse)
......
""" from typing import Optional, List
Company routes.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
...@@ -60,7 +56,7 @@ async def create_company( ...@@ -60,7 +56,7 @@ async def create_company(
return db_company return db_company
@router.get("", response_model=PaginatedResponse) @router.get("", response_model=List[CompanyResponse])
async def list_companies( async def list_companies(
pagination: PaginationParams = Depends(), pagination: PaginationParams = Depends(),
include_deleted: bool = Query(False, alias="include_deleted"), include_deleted: bool = Query(False, alias="include_deleted"),
...@@ -79,7 +75,7 @@ async def list_companies( ...@@ -79,7 +75,7 @@ async def list_companies(
company_ids = [m.company_id for m in memberships] company_ids = [m.company_id for m in memberships]
if not company_ids: if not company_ids:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []} return []
# Query companies with pagination # Query companies with pagination
stmt = select(Company, func.count("*").over().label("total")).where( stmt = select(Company, func.count("*").over().label("total")).where(
...@@ -91,17 +87,10 @@ async def list_companies( ...@@ -91,17 +87,10 @@ async def list_companies(
rows = result.fetchall() rows = result.fetchall()
if not rows: if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []} return []
total = rows[0].total
companies = [CompanyResponse.model_validate(row._mapping["Company"]) for row in rows] companies = [CompanyResponse.model_validate(row._mapping["Company"]) for row in rows]
return companies
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": companies,
}
@router.get("/{company_id}", response_model=CompanyResponse) @router.get("/{company_id}", response_model=CompanyResponse)
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Company membership routes. Company membership routes.
""" """
from typing import Optional from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
...@@ -107,6 +107,196 @@ async def list_memberships( ...@@ -107,6 +107,196 @@ async def list_memberships(
} }
# --- Custom endpoints added for Paperclip client compatibility ---
@router.get("/members")
async def get_company_members(
company_id: str = Query(..., description="Company ID"),
db: AsyncSession = Depends(get_db_session),
current_user: AuthUserResponse = Depends(get_current_user)
):
# Fetch memberships for company where principal_type == "user"
stmt = (
select(CompanyMembership, AuthUser)
.outerjoin(AuthUser, CompanyMembership.principal_id == AuthUser.id)
.where(
CompanyMembership.company_id == company_id,
CompanyMembership.principal_type == "user"
)
)
result = await db.execute(stmt)
rows = result.fetchall()
members = []
current_user_role = None
for row in rows:
membership = row.CompanyMembership
user = row.AuthUser
member_data = {
"id": membership.id,
"companyId": membership.company_id,
"principalType": membership.principal_type,
"principalId": membership.principal_id,
"status": membership.status,
"membershipRole": membership.membership_role,
"createdAt": membership.created_at,
"updatedAt": membership.updated_at,
"user": {
"id": user.id,
"email": user.email,
"name": user.name,
"image": user.image
} if user else None,
"grants": []
}
members.append(member_data)
if membership.principal_id == current_user.id:
current_user_role = membership.membership_role
# Default to admin role if not set or user is admin
if not current_user_role:
current_user_role = "admin"
return {
"members": members,
"access": {
"currentUserRole": current_user_role,
"canManageMembers": current_user_role in ("owner", "admin", "instance_admin"),
"canInviteUsers": current_user_role in ("owner", "admin", "instance_admin"),
"canApproveJoinRequests": current_user_role in ("owner", "admin", "instance_admin")
}
}
@router.get("/user-directory")
async def get_user_directory(
company_id: str = Query(..., description="Company ID"),
db: AsyncSession = Depends(get_db_session)
):
stmt = (
select(CompanyMembership, AuthUser)
.join(AuthUser, CompanyMembership.principal_id == AuthUser.id)
.where(
CompanyMembership.company_id == company_id,
CompanyMembership.principal_type == "user",
CompanyMembership.status == "active"
)
)
result = await db.execute(stmt)
rows = result.fetchall()
users = []
for row in rows:
membership = row.CompanyMembership
user = row.AuthUser
users.append({
"principalId": membership.principal_id,
"status": "active",
"user": {
"id": user.id,
"email": user.email,
"name": user.name,
"image": user.image
}
})
return {"users": users}
@router.get("/join-requests")
async def get_join_requests(
company_id: str = Query(..., description="Company ID"),
status: str = Query("pending_approval"),
db: AsyncSession = Depends(get_db_session)
):
# Map status to database status
db_status = "pending"
if status == "approved":
db_status = "active"
elif status == "rejected":
db_status = "suspended"
stmt = (
select(CompanyMembership, AuthUser)
.join(AuthUser, CompanyMembership.principal_id == AuthUser.id)
.where(
CompanyMembership.company_id == company_id,
CompanyMembership.principal_type == "user",
CompanyMembership.status == db_status
)
)
result = await db.execute(stmt)
rows = result.fetchall()
join_requests = []
for row in rows:
membership = row.CompanyMembership
user = row.AuthUser
join_requests.append({
"id": membership.id,
"companyId": membership.company_id,
"principalType": membership.principal_type,
"principalId": membership.principal_id,
"status": "pending_approval" if membership.status == "pending" else membership.status,
"membershipRole": membership.membership_role,
"createdAt": membership.created_at,
"updatedAt": membership.updated_at,
"requesterUser": {
"id": user.id,
"email": user.email,
"name": user.name,
"image": user.image
},
"approvedByUser": None,
"rejectedByUser": None,
"invite": None
})
return join_requests
@router.post("/join-requests/{request_id}/approve")
async def approve_join_request(
request_id: str,
company_id: str = Query(..., description="Company ID"),
db: AsyncSession = Depends(get_db_session)
):
result = await db.execute(
select(CompanyMembership).where(
CompanyMembership.id == request_id,
CompanyMembership.company_id == company_id
)
)
membership = result.scalar_one_or_none()
if not membership:
raise HTTPException(status_code=404, detail="Join request not found")
membership.status = "active"
await db.commit()
return {"id": request_id, "status": "approved"}
@router.post("/join-requests/{request_id}/reject")
async def reject_join_request(
request_id: str,
company_id: str = Query(..., description="Company ID"),
db: AsyncSession = Depends(get_db_session)
):
result = await db.execute(
select(CompanyMembership).where(
CompanyMembership.id == request_id,
CompanyMembership.company_id == company_id
)
)
membership = result.scalar_one_or_none()
if not membership:
raise HTTPException(status_code=404, detail="Join request not found")
membership.status = "suspended"
await db.commit()
return {"id": request_id, "status": "rejected"}
# --- Standard Wildcard Endpoints ---
@router.get("/{membership_id}", response_model=CompanyMembershipResponse) @router.get("/{membership_id}", response_model=CompanyMembershipResponse)
async def get_membership( async def get_membership(
membership_id: str, membership_id: str,
...@@ -187,3 +377,4 @@ async def update_membership( ...@@ -187,3 +377,4 @@ async def update_membership(
await db.commit() await db.commit()
return membership return membership
...@@ -6,15 +6,109 @@ from typing import Optional, List ...@@ -6,15 +6,109 @@ from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from datetime import datetime, timedelta
from schemas.common import CompanyScope from schemas.common import CompanyScope
from database import get_db_session from database import get_db_session
from middleware.auth import require_company_scope, get_current_user from middleware.auth import require_company_scope, get_current_user
from models import Company, Issue, Agent, CostEvent from models import Company, Issue, Agent, CostEvent, Approval
router = APIRouter(prefix="/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("")
async def get_dashboard_summary(
company_id: str = Query(..., description="Company ID"),
db: AsyncSession = Depends(get_db_session)
):
"""Get dashboard summary matching DashboardSummary schema."""
# 1. Agents count
agents_result = await db.execute(
select(Agent).where(Agent.company_id == company_id)
)
agents = agents_result.scalars().all()
active_agents = sum(1 for a in agents if a.status == "active")
paused_agents = sum(1 for a in agents if a.status == "paused")
error_agents = sum(1 for a in agents if a.status == "error")
running_agents = active_agents
# 2. Tasks count
issues_result = await db.execute(
select(Issue).where(Issue.company_id == company_id)
)
issues = issues_result.scalars().all()
open_issues = sum(1 for i in issues if i.status == "open" or i.status == "todo")
in_progress = sum(1 for i in issues if i.status == "in_progress")
blocked = sum(1 for i in issues if i.status == "blocked")
done = sum(1 for i in issues if i.status == "done")
# 3. Costs
company_result = await db.execute(
select(Company).where(Company.id == company_id)
)
company = company_result.scalar_one_or_none()
month_budget = company.budget_monthly_cents if company else 0
spend_result = await db.execute(
select(func.sum(CostEvent.cost_cents)).where(
CostEvent.company_id == company_id
)
)
month_spend = spend_result.scalar() or 0
util_percent = (month_spend / month_budget * 100.0) if month_budget > 0 else 0.0
# 4. Approvals
approvals_result = await db.execute(
select(func.count()).select_from(Approval).where(
Approval.company_id == company_id,
Approval.resolved_at.is_(None)
)
)
pending_approvals = approvals_result.scalar() or 0
# 5. Run Activity
run_activity = []
today = datetime.utcnow().date()
for i in range(6, -1, -1):
day = today - timedelta(days=i)
run_activity.append({
"date": day.isoformat(),
"succeeded": 0,
"failed": 0,
"other": 0,
"total": 0
})
return {
"companyId": company_id,
"agents": {
"active": active_agents,
"running": running_agents,
"paused": paused_agents,
"error": error_agents
},
"tasks": {
"open": open_issues,
"inProgress": in_progress,
"blocked": blocked,
"done": done
},
"costs": {
"monthSpendCents": month_spend,
"monthBudgetCents": month_budget,
"monthUtilizationPercent": util_percent
},
"pendingApprovals": pending_approvals,
"budgets": {
"activeIncidents": 0,
"pendingApprovals": pending_approvals,
"pausedAgents": paused_agents,
"pausedProjects": 0
},
"runActivity": run_activity
}
@router.get("/stats") @router.get("/stats")
async def get_dashboard_stats( async def get_dashboard_stats(
company_id: str = Query(..., description="Company ID"), company_id: str = Query(..., description="Company ID"),
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Environment routes. Environment routes.
""" """
from typing import Optional from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
...@@ -60,7 +60,7 @@ async def create_environment( ...@@ -60,7 +60,7 @@ async def create_environment(
return db_environment return db_environment
@router.get("", response_model=PaginatedResponse) @router.get("", response_model=List[EnvironmentResponse])
async def list_environments( async def list_environments(
company_id: str = Query(..., description="Company ID"), company_id: str = Query(..., description="Company ID"),
scope: CompanyScope = Depends(require_company_scope), scope: CompanyScope = Depends(require_company_scope),
...@@ -76,17 +76,10 @@ async def list_environments( ...@@ -76,17 +76,10 @@ async def list_environments(
rows = result.fetchall() rows = result.fetchall()
if not rows: if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []} return []
total = rows[0].total
environments = [EnvironmentResponse.model_validate(row._mapping["Environment"]) for row in rows] environments = [EnvironmentResponse.model_validate(row._mapping["Environment"]) for row in rows]
return environments
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": environments,
}
@router.get("/{environment_id}", response_model=EnvironmentResponse) @router.get("/{environment_id}", response_model=EnvironmentResponse)
......
...@@ -61,7 +61,7 @@ async def create_goal( ...@@ -61,7 +61,7 @@ async def create_goal(
return db_goal return db_goal
@router.get("", response_model=PaginatedResponse) @router.get("", response_model=List[GoalResponse])
async def list_goals( async def list_goals(
company_id: str = Query(..., description="Company ID"), company_id: str = Query(..., description="Company ID"),
project_id: Optional[str] = Query(None, description="Filter by project"), project_id: Optional[str] = Query(None, description="Filter by project"),
...@@ -88,17 +88,10 @@ async def list_goals( ...@@ -88,17 +88,10 @@ async def list_goals(
rows = result.fetchall() rows = result.fetchall()
if not rows: if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []} return []
total = rows[0].total
goals = [GoalResponse.model_validate(row._mapping["Goal"]) for row in rows] goals = [GoalResponse.model_validate(row._mapping["Goal"]) for row in rows]
return goals
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": goals,
}
@router.get("/{goal_id}", response_model=GoalResponse) @router.get("/{goal_id}", response_model=GoalResponse)
......
"""
Heartbeat runs routes.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from typing import List, Optional
from datetime import datetime
from database import get_db_session
from middleware.auth import require_company_scope, get_current_user
from schemas.auth import AuthUserResponse
from schemas.common import PaginationParams, CompanyScope
from models import HeartbeatRun, Agent
router = APIRouter(tags=["heartbeats"])
# Define schemas for compatibility
from pydantic import BaseModel, ConfigDict
class HeartbeatRunSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
company_id: str
agent_id: str
issue_id: Optional[str] = None
status: str
started_at: datetime
completed_at: Optional[datetime] = None
error_message: Optional[str] = None
created_at: datetime
updated_at: datetime
# Mocked fields for frontend compatibility
invocation_source: str = "system"
trigger_detail: Optional[str] = None
liveness_state: Optional[str] = None
liveness_reason: Optional[str] = None
continuation_attempt: int = 0
last_useful_action_at: Optional[datetime] = None
next_action: Optional[str] = None
class LiveRunSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
status: str
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
created_at: datetime
agent_id: str
agent_name: str
adapter_type: str
issue_id: Optional[str] = None
# Mocked fields for frontend compatibility
invocation_source: str = "system"
trigger_detail: Optional[str] = None
liveness_state: Optional[str] = None
liveness_reason: Optional[str] = None
continuation_attempt: int = 0
last_useful_action_at: Optional[datetime] = None
next_action: Optional[str] = None
@router.get("/heartbeat-runs", response_model=List[HeartbeatRunSchema])
async def list_heartbeat_runs(
company_id: str = Query(..., description="Company ID"),
agent_id: Optional[str] = Query(None, description="Filter by agent"),
limit: int = Query(50, description="Limit"),
scope: CompanyScope = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session)
):
"""List heartbeat runs for a company."""
stmt = select(HeartbeatRun).where(HeartbeatRun.company_id == company_id)
if agent_id:
stmt = stmt.where(HeartbeatRun.agent_id == agent_id)
stmt = stmt.order_by(desc(HeartbeatRun.created_at)).limit(limit)
result = await db.execute(stmt)
runs = result.scalars().all()
return runs
@router.get("/heartbeat-runs/{run_id}", response_model=HeartbeatRunSchema)
async def get_heartbeat_run(
run_id: str,
db: AsyncSession = Depends(get_db_session)
):
"""Get a specific heartbeat run."""
result = await db.execute(select(HeartbeatRun).where(HeartbeatRun.id == run_id))
run = result.scalar_one_or_none()
if not run:
raise HTTPException(status_code=404, detail="Run not found")
return run
@router.get("/live-runs", response_model=List[LiveRunSchema])
async def list_live_runs(
company_id: str = Query(..., description="Company ID"),
min_count: Optional[int] = Query(None, alias="minCount"),
limit: int = Query(50, description="Limit"),
scope: CompanyScope = Depends(require_company_scope),
db: AsyncSession = Depends(get_db_session)
):
"""List active live runs for a company."""
stmt = (
select(
HeartbeatRun.id,
HeartbeatRun.status,
HeartbeatRun.started_at,
HeartbeatRun.completed_at,
HeartbeatRun.created_at,
HeartbeatRun.agent_id,
HeartbeatRun.issue_id,
Agent.name.label("agent_name"),
Agent.adapter_type.label("adapter_type")
)
.join(Agent, HeartbeatRun.agent_id == Agent.id)
.where(HeartbeatRun.company_id == company_id)
.order_by(desc(HeartbeatRun.created_at))
.limit(limit)
)
result = await db.execute(stmt)
rows = result.fetchall()
live_runs = []
for row in rows:
live_runs.append({
"id": row.id,
"status": row.status,
"started_at": row.started_at,
"completed_at": row.completed_at,
"created_at": row.created_at,
"agent_id": row.agent_id,
"agent_name": row.agent_name,
"adapter_type": row.adapter_type,
"issue_id": row.issue_id
})
return live_runs
@router.post("/heartbeat-runs/{run_id}/cancel")
async def cancel_heartbeat_run(
run_id: str,
db: AsyncSession = Depends(get_db_session)
):
"""Cancel a heartbeat run."""
result = await db.execute(select(HeartbeatRun).where(HeartbeatRun.id == run_id))
run = result.scalar_one_or_none()
if not run:
raise HTTPException(status_code=404, detail="Run not found")
run.status = "cancelled"
run.completed_at = datetime.utcnow()
await db.commit()
return {"status": "cancelled"}
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Issue routes. Issue routes.
""" """
from typing import Optional from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status, Body from fastapi import APIRouter, Depends, HTTPException, Query, status, Body
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
...@@ -77,7 +77,7 @@ async def create_issue( ...@@ -77,7 +77,7 @@ async def create_issue(
return db_issue return db_issue
@router.get("", response_model=PaginatedResponse) @router.get("", response_model=List[IssueResponse])
async def list_issues( async def list_issues(
company_id: str = Query(..., description="Company ID"), company_id: str = Query(..., description="Company ID"),
project_id: Optional[str] = Query(None, description="Filter by project"), project_id: Optional[str] = Query(None, description="Filter by project"),
...@@ -110,17 +110,10 @@ async def list_issues( ...@@ -110,17 +110,10 @@ async def list_issues(
rows = result.fetchall() rows = result.fetchall()
if not rows: if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []} return []
total = rows[0].total
issues = [IssueResponse.model_validate(row._mapping["Issue"]) for row in rows] issues = [IssueResponse.model_validate(row._mapping["Issue"]) for row in rows]
return issues
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": issues,
}
@router.get("/{issue_id}", response_model=IssueResponse) @router.get("/{issue_id}", response_model=IssueResponse)
......
...@@ -74,7 +74,7 @@ async def create_project( ...@@ -74,7 +74,7 @@ async def create_project(
return db_project return db_project
@router.get("", response_model=PaginatedResponse) @router.get("", response_model=List[ProjectResponse])
async def list_projects( async def list_projects(
company_id: str = Query(..., description="Company ID"), company_id: str = Query(..., description="Company ID"),
goal_id: Optional[str] = Query(None, description="Filter by goal"), goal_id: Optional[str] = Query(None, description="Filter by goal"),
...@@ -101,17 +101,10 @@ async def list_projects( ...@@ -101,17 +101,10 @@ async def list_projects(
rows = result.fetchall() rows = result.fetchall()
if not rows: if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []} return []
total = rows[0].total
projects = [ProjectResponse.model_validate(row._mapping["Project"]) for row in rows] projects = [ProjectResponse.model_validate(row._mapping["Project"]) for row in rows]
return projects
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": projects,
}
@router.get("/{project_id}", response_model=ProjectResponse) @router.get("/{project_id}", response_model=ProjectResponse)
......
...@@ -71,7 +71,9 @@ async def create_routine( ...@@ -71,7 +71,9 @@ async def create_routine(
return db_routine return db_routine
@router.get("", response_model=PaginatedResponse) from typing import Optional, List
@router.get("", response_model=List[RoutineResponse])
async def list_routines( async def list_routines(
company_id: str = Query(..., description="Company ID"), company_id: str = Query(..., description="Company ID"),
is_enabled: Optional[bool] = Query(None, description="Filter by enabled status"), is_enabled: Optional[bool] = Query(None, description="Filter by enabled status"),
...@@ -91,17 +93,10 @@ async def list_routines( ...@@ -91,17 +93,10 @@ async def list_routines(
rows = result.fetchall() rows = result.fetchall()
if not rows: if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []} return []
total = rows[0].total
routines = [RoutineResponse.model_validate(row._mapping["Routine"]) for row in rows] routines = [RoutineResponse.model_validate(row._mapping["Routine"]) for row in rows]
return routines
return {
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": routines,
}
@router.get("/{routine_id}", response_model=RoutineResponse) @router.get("/{routine_id}", response_model=RoutineResponse)
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Secret routes. Secret routes.
""" """
from typing import Optional from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
...@@ -65,7 +65,7 @@ async def create_secret( ...@@ -65,7 +65,7 @@ async def create_secret(
return db_secret return db_secret
@router.get("", response_model=PaginatedResponse) @router.get("", response_model=List[CompanySecretResponse])
async def list_secrets( async def list_secrets(
company_id: str = Query(..., description="Company ID"), company_id: str = Query(..., description="Company ID"),
include_deleted: bool = Query(False, description="Include deleted secrets"), include_deleted: bool = Query(False, description="Include deleted secrets"),
...@@ -85,9 +85,8 @@ async def list_secrets( ...@@ -85,9 +85,8 @@ async def list_secrets(
rows = result.fetchall() rows = result.fetchall()
if not rows: if not rows:
return {"total": 0, "offset": pagination.offset, "limit": pagination.limit, "items": []} return []
total = rows[0].total
secrets = [] secrets = []
for row in rows: for row in rows:
secret = CompanySecretResponse.model_validate(row._mapping["CompanySecret"]) secret = CompanySecretResponse.model_validate(row._mapping["CompanySecret"])
...@@ -95,12 +94,7 @@ async def list_secrets( ...@@ -95,12 +94,7 @@ async def list_secrets(
secret.value_encrypted = "***" if secret.is_sensitive else secret.value_encrypted secret.value_encrypted = "***" if secret.is_sensitive else secret.value_encrypted
secrets.append(secret) secrets.append(secret)
return { return secrets
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"items": secrets,
}
@router.get("/{secret_id}", response_model=CompanySecretResponse) @router.get("/{secret_id}", response_model=CompanySecretResponse)
......
...@@ -23,5 +23,8 @@ class ActivityLogCreate(ActivityLogBase): ...@@ -23,5 +23,8 @@ class ActivityLogCreate(ActivityLogBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class ActivityLogResponse(ActivityLogBase, TimestampMixin): class ActivityLogResponse(ActivityLogBase):
id: str model_config = ConfigDict(from_attributes=True)
\ No newline at end of file
id: str
occurred_at: datetime
\ No newline at end of file
...@@ -10,7 +10,7 @@ import time ...@@ -10,7 +10,7 @@ import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from api.main_router import main_router as api_router from api.main_router import main_router as api_router
...@@ -84,6 +84,90 @@ async def lifespan(app: FastAPI): ...@@ -84,6 +84,90 @@ async def lifespan(app: FastAPI):
import re import re
import json
from urllib.parse import parse_qsl, urlencode
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
def camel_to_snake(name: str) -> str:
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
def snake_to_camel(name: str) -> str:
components = name.split('_')
if not components:
return name
return components[0] + ''.join(x.title() for x in components[1:])
def convert_keys(data, convert_fn):
if isinstance(data, list):
return [convert_keys(item, convert_fn) for item in data]
elif isinstance(data, dict):
return {convert_fn(k): convert_keys(v, convert_fn) for k, v in data.items()}
else:
return data
class CaseConversionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
is_api = request.url.path.startswith("/api")
# 1. Convert incoming Query String (camelCase -> snake_case)
if is_api and request.url.query:
try:
query_bytes = request.scope.get("query_string", b"")
if query_bytes:
query_str = query_bytes.decode("utf-8")
parsed = parse_qsl(query_str, keep_blank_values=True)
converted = [(camel_to_snake(k), v) for k, v in parsed]
request.scope["query_string"] = urlencode(converted).encode("utf-8")
except Exception:
pass
# 2. Convert incoming JSON request body (camelCase -> snake_case)
if is_api and request.method in ("POST", "PUT", "PATCH", "DELETE"):
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
try:
body = await request.body()
if body:
data = json.loads(body)
converted_data = convert_keys(data, camel_to_snake)
request._body = json.dumps(converted_data, ensure_ascii=False).encode("utf-8")
except Exception:
pass
# 3. Call next middleware/route handler
response = await call_next(request)
# 4. Convert outgoing JSON response body (snake_case -> camelCase)
if is_api:
content_type = response.headers.get("content-type", "")
if "application/json" in content_type:
try:
if hasattr(response, "body"):
body = response.body
else:
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk)
body = b"".join(chunks)
if body:
data = json.loads(body)
converted_data = convert_keys(data, snake_to_camel)
new_body = json.dumps(converted_data, ensure_ascii=False).encode("utf-8")
response = Response(
content=new_body,
status_code=response.status_code,
headers=dict(response.headers),
media_type="application/json"
)
response.headers["content-length"] = str(len(new_body))
except Exception:
pass
return response
class CompanyPathRewriteMiddleware: class CompanyPathRewriteMiddleware:
def __init__(self, app): def __init__(self, app):
...@@ -98,6 +182,16 @@ class CompanyPathRewriteMiddleware: ...@@ -98,6 +182,16 @@ class CompanyPathRewriteMiddleware:
company_id = match.group(2) company_id = match.group(2)
resource_path = match.group(3) resource_path = match.group(3)
# Map specific resource paths for compatibility
if resource_path.startswith("skills"):
resource_path = re.sub(r"^skills(\b|/)", "company-skills\\1", resource_path)
elif resource_path.startswith("members"):
resource_path = re.sub(r"^members(\b|/)", "company-memberships/members\\1", resource_path)
elif resource_path.startswith("user-directory"):
resource_path = re.sub(r"^user-directory(\b|/)", "company-memberships/user-directory\\1", resource_path)
elif resource_path.startswith("join-requests"):
resource_path = re.sub(r"^join-requests(\b|/)", "company-memberships/join-requests\\1", resource_path)
# Rewrite path # Rewrite path
scope["path"] = f"{api_prefix}/{resource_path}" scope["path"] = f"{api_prefix}/{resource_path}"
...@@ -118,8 +212,19 @@ app = FastAPI( ...@@ -118,8 +212,19 @@ app = FastAPI(
version="1.0.0", version="1.0.0",
lifespan=lifespan, lifespan=lifespan,
) )
app.add_middleware(CaseConversionMiddleware)
app.add_middleware(CompanyPathRewriteMiddleware) app.add_middleware(CompanyPathRewriteMiddleware)
@app.websocket("/api/events/ws")
async def websocket_events_endpoint(websocket: WebSocket):
await websocket.accept()
try:
while True:
# Keep client connected and drop messages
data = await websocket.receive_text()
except WebSocketDisconnect:
pass
@app.get("/") @app.get("/")
async def root(): async def root():
...@@ -254,7 +359,7 @@ if __name__ == "__main__": ...@@ -254,7 +359,7 @@ if __name__ == "__main__":
print(f"Event Daily: http://localhost:{PORT}/static/event_daily/index.html") print(f"Event Daily: http://localhost:{PORT}/static/event_daily/index.html")
print("=" * 60) print("=" * 60)
ENABLE_RELOAD = False ENABLE_RELOAD = True
print(f"Hot reload: {ENABLE_RELOAD}") print(f"Hot reload: {ENABLE_RELOAD}")
reload_dirs = ["common", "api", "agent"] reload_dirs = ["common", "api", "agent"]
......
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
from fastapi.encoders import jsonable_encoder
class CamelModel(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
from_attributes=True,
)
class CompanyResponse(CamelModel):
id: str
name: str
issue_prefix: str
budget_monthly_cents: int
# Test serialization
company = CompanyResponse(
id="123",
name="Canifa",
issue_prefix="CNF",
budget_monthly_cents=500000
)
encoded = jsonable_encoder(company)
print("Encoded:", encoded)
# Test deserialization from camelCase
data = {"id": "123", "name": "Canifa", "issuePrefix": "CNF", "budgetMonthlyCents": 500000}
parsed = CompanyResponse.model_validate(data)
print("Parsed:", parsed)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment