Compare commits
2 Commits
main
...
d52861fd9c
| Author | SHA1 | Date | |
|---|---|---|---|
| d52861fd9c | |||
| 3aa6dd2d6e |
43
Dockerfile
43
Dockerfile
@@ -1,46 +1,25 @@
|
|||||||
# Stage 1: build dependencies
|
|
||||||
FROM python:3.11-slim AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install build dependencies
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
build-essential \
|
|
||||||
default-libmysqlclient-dev \
|
|
||||||
pkg-config \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Pre-download wheels to avoid recompiling bcrypt from source
|
|
||||||
RUN pip install --no-cache-dir --prefix=/install \
|
|
||||||
'bcrypt==4.0.1' \
|
|
||||||
'cffi>=2.0' \
|
|
||||||
'pycparser>=2.0'
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
|
||||||
|
|
||||||
# Stage 2: slim runtime
|
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install runtime dependencies only (no build tools)
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
default-libmysqlclient-dev \
|
build-essential \
|
||||||
curl \
|
curl \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
pkg-config \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy installed packages from builder
|
# Install Python dependencies
|
||||||
COPY --from=builder /install /usr/local
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY app/ ./app/
|
COPY . .
|
||||||
COPY requirements.txt ./
|
|
||||||
|
|
||||||
# Make entrypoint
|
|
||||||
COPY entrypoint.sh .
|
|
||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
|
# Expose port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Wait for wizard config, then start uvicorn
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|||||||
@@ -361,6 +361,62 @@ def agent_heartbeat(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/sync",
|
||||||
|
summary="Sync today's schedules for all agents on a claw instance",
|
||||||
|
)
|
||||||
|
def sync_schedules(
|
||||||
|
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Return today's slots for all agents belonging to the given claw instance.
|
||||||
|
|
||||||
|
Used by the HF OpenClaw plugin to maintain a local schedule cache.
|
||||||
|
Returns a dict of { agent_id: [slots] } for all agents with matching
|
||||||
|
claw_identifier.
|
||||||
|
"""
|
||||||
|
today = date_type.today()
|
||||||
|
|
||||||
|
# Find all agents on this claw instance
|
||||||
|
agents = (
|
||||||
|
db.query(Agent)
|
||||||
|
.filter(Agent.claw_identifier == x_claw_identifier)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
schedules: dict[str, list[dict]] = {}
|
||||||
|
for agent in agents:
|
||||||
|
# Get real slots for today
|
||||||
|
real_slots = (
|
||||||
|
db.query(TimeSlot)
|
||||||
|
.filter(
|
||||||
|
TimeSlot.user_id == agent.user_id,
|
||||||
|
TimeSlot.date == today,
|
||||||
|
TimeSlot.status.notin_(list(_INACTIVE_STATUSES)),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
items = [_real_slot_to_item(s).model_dump(mode="json") for s in real_slots]
|
||||||
|
|
||||||
|
# Get virtual plan slots
|
||||||
|
virtual_slots = get_virtual_slots_for_date(db, agent.user_id, today)
|
||||||
|
for vs in virtual_slots:
|
||||||
|
items.append(_virtual_slot_to_item(vs).model_dump(mode="json"))
|
||||||
|
|
||||||
|
schedules[agent.agent_id] = items
|
||||||
|
|
||||||
|
# Record heartbeat for liveness
|
||||||
|
for agent in agents:
|
||||||
|
record_heartbeat(db, agent)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schedules": schedules,
|
||||||
|
"date": today.isoformat(),
|
||||||
|
"agent_count": len(agents),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/slots/{slot_id}/agent-update",
|
"/slots/{slot_id}/agent-update",
|
||||||
response_model=TimeSlotEditResponse,
|
response_model=TimeSlotEditResponse,
|
||||||
|
|||||||
209
app/api/routers/schedule_type.py
Normal file
209
app/api/routers/schedule_type.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""ScheduleType API router.
|
||||||
|
|
||||||
|
CRUD for schedule types (work/entertainment time periods)
|
||||||
|
and agent schedule type assignment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.core.config import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.models.models import User
|
||||||
|
from app.models.agent import Agent
|
||||||
|
from app.models.schedule_type import ScheduleType
|
||||||
|
from app.models.role_permission import Permission, RolePermission
|
||||||
|
from app.schemas.schedule_type import (
|
||||||
|
ScheduleTypeCreate,
|
||||||
|
ScheduleTypeUpdate,
|
||||||
|
ScheduleTypeResponse,
|
||||||
|
AgentScheduleTypeAssign,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/schedule-types", tags=["ScheduleTypes"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Permission helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _has_permission(db: Session, user: User, permission_name: str) -> bool:
|
||||||
|
if user.is_admin:
|
||||||
|
return True
|
||||||
|
if not user.role_id:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
db.query(RolePermission)
|
||||||
|
.join(Permission)
|
||||||
|
.filter(
|
||||||
|
RolePermission.role_id == user.role_id,
|
||||||
|
Permission.name == permission_name,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_schedule_read(db: Session, user: User) -> User:
|
||||||
|
if not _has_permission(db, user, "schedule_type.read"):
|
||||||
|
raise HTTPException(403, "Permission denied: schedule_type.read")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _require_schedule_manage(db: Session, user: User) -> User:
|
||||||
|
if not _has_permission(db, user, "schedule_type.manage"):
|
||||||
|
raise HTTPException(403, "Permission denied: schedule_type.manage")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schedule Type CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/",
|
||||||
|
response_model=List[ScheduleTypeResponse],
|
||||||
|
summary="List all schedule types",
|
||||||
|
)
|
||||||
|
def list_schedule_types(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_schedule_read(db, current_user)
|
||||||
|
return db.query(ScheduleType).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/",
|
||||||
|
response_model=ScheduleTypeResponse,
|
||||||
|
summary="Create a schedule type",
|
||||||
|
)
|
||||||
|
def create_schedule_type(
|
||||||
|
payload: ScheduleTypeCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_schedule_manage(db, current_user)
|
||||||
|
|
||||||
|
existing = db.query(ScheduleType).filter(ScheduleType.name == payload.name).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(409, f"Schedule type '{payload.name}' already exists")
|
||||||
|
|
||||||
|
st = ScheduleType(
|
||||||
|
name=payload.name,
|
||||||
|
work_from=payload.work_from,
|
||||||
|
work_to=payload.work_to,
|
||||||
|
entertainment_from=payload.entertainment_from,
|
||||||
|
entertainment_to=payload.entertainment_to,
|
||||||
|
)
|
||||||
|
db.add(st)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(st)
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{schedule_type_id}",
|
||||||
|
response_model=ScheduleTypeResponse,
|
||||||
|
summary="Update a schedule type",
|
||||||
|
)
|
||||||
|
def update_schedule_type(
|
||||||
|
schedule_type_id: int,
|
||||||
|
payload: ScheduleTypeUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_schedule_manage(db, current_user)
|
||||||
|
|
||||||
|
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
|
||||||
|
if not st:
|
||||||
|
raise HTTPException(404, "Schedule type not found")
|
||||||
|
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(st, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(st)
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{schedule_type_id}",
|
||||||
|
summary="Delete a schedule type",
|
||||||
|
)
|
||||||
|
def delete_schedule_type(
|
||||||
|
schedule_type_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_schedule_manage(db, current_user)
|
||||||
|
|
||||||
|
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
|
||||||
|
if not st:
|
||||||
|
raise HTTPException(404, "Schedule type not found")
|
||||||
|
|
||||||
|
# Check if any agents are using this schedule type
|
||||||
|
agents_using = db.query(Agent).filter(Agent.schedule_type_id == schedule_type_id).count()
|
||||||
|
if agents_using > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Cannot delete: {agents_using} agent(s) are assigned to this schedule type",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(st)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "deleted": schedule_type_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent schedule type assignment (agent-facing, uses X-Agent-ID header)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/agent/me",
|
||||||
|
response_model=ScheduleTypeResponse | None,
|
||||||
|
summary="Get my schedule type",
|
||||||
|
)
|
||||||
|
def get_my_schedule_type(
|
||||||
|
x_agent_id: str = Header(..., alias="X-Agent-ID"),
|
||||||
|
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
agent = (
|
||||||
|
db.query(Agent)
|
||||||
|
.filter(Agent.agent_id == x_agent_id, Agent.claw_identifier == x_claw_identifier)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(404, "Agent not found")
|
||||||
|
|
||||||
|
if not agent.schedule_type_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/agent/{agent_id}/assign",
|
||||||
|
summary="Assign a schedule type to an agent",
|
||||||
|
)
|
||||||
|
def assign_schedule_type(
|
||||||
|
agent_id: str,
|
||||||
|
payload: AgentScheduleTypeAssign,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_require_schedule_manage(db, current_user)
|
||||||
|
|
||||||
|
agent = db.query(Agent).filter(Agent.agent_id == agent_id).first()
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(404, f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
st = db.query(ScheduleType).filter(ScheduleType.name == payload.schedule_type_name).first()
|
||||||
|
if not st:
|
||||||
|
raise HTTPException(404, f"Schedule type '{payload.schedule_type_name}' not found")
|
||||||
|
|
||||||
|
agent.schedule_type_id = st.id
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "agent_id": agent_id, "schedule_type": st.name}
|
||||||
@@ -9,7 +9,6 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
|
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
|
||||||
from app.core.config import get_db
|
from app.core.config import get_db
|
||||||
from app.init_wizard import DELETED_USER_USERNAME
|
|
||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
from app.models.role_permission import Permission, Role, RolePermission
|
from app.models.role_permission import Permission, Role, RolePermission
|
||||||
@@ -213,86 +212,6 @@ def update_user(
|
|||||||
return _user_response(user)
|
return _user_response(user)
|
||||||
|
|
||||||
|
|
||||||
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}
|
|
||||||
|
|
||||||
|
|
||||||
def _reassign_user_references(db: Session, old_id: int, new_id: int) -> None:
|
|
||||||
"""Reassign all foreign key references from old_id to new_id, then delete
|
|
||||||
records that would be meaningless under deleted-user (api_keys, notifications,
|
|
||||||
project memberships)."""
|
|
||||||
from app.models.apikey import APIKey
|
|
||||||
from app.models.notification import Notification
|
|
||||||
from app.models.activity import ActivityLog as Activity
|
|
||||||
from app.models.worklog import WorkLog as WorkLogModel
|
|
||||||
from app.models.meeting import Meeting, MeetingParticipant
|
|
||||||
from app.models.task import Task
|
|
||||||
from app.models.support import Support
|
|
||||||
from app.models.proposal import Proposal
|
|
||||||
from app.models.milestone import Milestone
|
|
||||||
from app.models.calendar import TimeSlot, SchedulePlan
|
|
||||||
from app.models.minimum_workload import MinimumWorkload
|
|
||||||
from app.models.essential import Essential
|
|
||||||
|
|
||||||
# Delete records that are meaningless without the real user
|
|
||||||
db.query(APIKey).filter(APIKey.user_id == old_id).delete()
|
|
||||||
db.query(Notification).filter(Notification.user_id == old_id).delete()
|
|
||||||
db.query(models.ProjectMember).filter(models.ProjectMember.user_id == old_id).delete()
|
|
||||||
|
|
||||||
# Reassign ownership/authorship references
|
|
||||||
db.query(models.Project).filter(models.Project.owner_id == old_id).update(
|
|
||||||
{"owner_id": new_id})
|
|
||||||
db.query(models.Comment).filter(models.Comment.author_id == old_id).update(
|
|
||||||
{"author_id": new_id})
|
|
||||||
db.query(Activity).filter(Activity.user_id == old_id).update(
|
|
||||||
{"user_id": new_id})
|
|
||||||
db.query(WorkLogModel).filter(WorkLogModel.user_id == old_id).update(
|
|
||||||
{"user_id": new_id})
|
|
||||||
|
|
||||||
# Tasks
|
|
||||||
db.query(Task).filter(Task.reporter_id == old_id).update(
|
|
||||||
{"reporter_id": new_id})
|
|
||||||
db.query(Task).filter(Task.assignee_id == old_id).update(
|
|
||||||
{"assignee_id": new_id})
|
|
||||||
db.query(Task).filter(Task.created_by_id == old_id).update(
|
|
||||||
{"created_by_id": new_id})
|
|
||||||
|
|
||||||
# Meetings
|
|
||||||
db.query(Meeting).filter(Meeting.reporter_id == old_id).update(
|
|
||||||
{"reporter_id": new_id})
|
|
||||||
db.query(MeetingParticipant).filter(MeetingParticipant.user_id == old_id).update(
|
|
||||||
{"user_id": new_id})
|
|
||||||
|
|
||||||
# Support
|
|
||||||
db.query(Support).filter(Support.reporter_id == old_id).update(
|
|
||||||
{"reporter_id": new_id})
|
|
||||||
db.query(Support).filter(Support.assignee_id == old_id).update(
|
|
||||||
{"assignee_id": new_id})
|
|
||||||
|
|
||||||
# Proposals
|
|
||||||
db.query(Proposal).filter(Proposal.created_by_id == old_id).update(
|
|
||||||
{"created_by_id": new_id})
|
|
||||||
|
|
||||||
# Milestones
|
|
||||||
db.query(Milestone).filter(Milestone.created_by_id == old_id).update(
|
|
||||||
{"created_by_id": new_id})
|
|
||||||
|
|
||||||
# Calendar
|
|
||||||
db.query(TimeSlot).filter(TimeSlot.user_id == old_id).update(
|
|
||||||
{"user_id": new_id})
|
|
||||||
db.query(SchedulePlan).filter(SchedulePlan.user_id == old_id).update(
|
|
||||||
{"user_id": new_id})
|
|
||||||
|
|
||||||
# Minimum workload / Essential
|
|
||||||
db.query(MinimumWorkload).filter(MinimumWorkload.user_id == old_id).update(
|
|
||||||
{"user_id": new_id})
|
|
||||||
db.query(Essential).filter(Essential.created_by_id == old_id).update(
|
|
||||||
{"created_by_id": new_id})
|
|
||||||
|
|
||||||
# Agent profile
|
|
||||||
db.query(Agent).filter(Agent.user_id == old_id).update(
|
|
||||||
{"user_id": new_id})
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_user(
|
def delete_user(
|
||||||
identifier: str,
|
identifier: str,
|
||||||
@@ -304,26 +223,17 @@ def delete_user(
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
if current_user.id == user.id:
|
if current_user.id == user.id:
|
||||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||||
|
# Protect built-in accounts from deletion
|
||||||
if user.is_admin:
|
if user.is_admin:
|
||||||
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
|
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
|
||||||
if user.username in _BUILTIN_USERNAMES:
|
if user.username == "acc-mgr":
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
|
||||||
status_code=400,
|
try:
|
||||||
detail=f"The {user.username} account is a built-in account and cannot be deleted",
|
db.delete(user)
|
||||||
)
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
deleted_user = db.query(models.User).filter(
|
db.rollback()
|
||||||
models.User.username == DELETED_USER_USERNAME
|
raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.")
|
||||||
).first()
|
|
||||||
if not deleted_user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Built-in deleted-user account not found. Run init_wizard first.",
|
|
||||||
)
|
|
||||||
|
|
||||||
_reassign_user_references(db, user.id, deleted_user.id)
|
|
||||||
db.delete(user)
|
|
||||||
db.commit()
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -331,7 +241,7 @@ def delete_user(
|
|||||||
def reset_user_apikey(
|
def reset_user_apikey(
|
||||||
identifier: str,
|
identifier: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Reset (regenerate) a user's API key.
|
"""Reset (regenerate) a user's API key.
|
||||||
|
|
||||||
@@ -339,8 +249,6 @@ def reset_user_apikey(
|
|||||||
- user.reset-apikey: can reset any user's API key
|
- user.reset-apikey: can reset any user's API key
|
||||||
- user.reset-self-apikey: can reset only own API key
|
- user.reset-self-apikey: can reset only own API key
|
||||||
- admin: can reset any user's API key
|
- admin: can reset any user's API key
|
||||||
|
|
||||||
Accepts both OAuth2 Bearer token and X-API-Key authentication.
|
|
||||||
"""
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
from app.models.apikey import APIKey
|
from app.models.apikey import APIKey
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ _DEV_PERMISSIONS = {
|
|||||||
|
|
||||||
_ACCOUNT_MANAGER_PERMISSIONS = {
|
_ACCOUNT_MANAGER_PERMISSIONS = {
|
||||||
"account.create",
|
"account.create",
|
||||||
"user.reset-apikey",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Role definitions: (name, description, permission_set)
|
# Role definitions: (name, description, permission_set)
|
||||||
@@ -295,39 +294,6 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
DELETED_USER_USERNAME = "deleted-user"
|
|
||||||
|
|
||||||
|
|
||||||
def init_deleted_user(db: Session) -> models.User | None:
|
|
||||||
"""Create the built-in deleted-user if not exists.
|
|
||||||
|
|
||||||
This user serves as a foreign key sink: when a real user is deleted,
|
|
||||||
all references are reassigned here instead of cascading deletes.
|
|
||||||
It has no role (no permissions) and cannot log in.
|
|
||||||
"""
|
|
||||||
existing = db.query(models.User).filter(
|
|
||||||
models.User.username == DELETED_USER_USERNAME
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
logger.info("deleted-user already exists (id=%d), skipping", existing.id)
|
|
||||||
return existing
|
|
||||||
|
|
||||||
user = models.User(
|
|
||||||
username=DELETED_USER_USERNAME,
|
|
||||||
email="deleted-user@harborforge.internal",
|
|
||||||
full_name="Deleted User",
|
|
||||||
hashed_password=None,
|
|
||||||
is_admin=False,
|
|
||||||
is_active=False,
|
|
||||||
role_id=None,
|
|
||||||
)
|
|
||||||
db.add(user)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(user)
|
|
||||||
logger.info("Created deleted-user (id=%d)", user.id)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def run_init(db: Session) -> None:
|
def run_init(db: Session) -> None:
|
||||||
"""Main initialization entry point. Reads config from shared volume."""
|
"""Main initialization entry point. Reads config from shared volume."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -352,9 +318,6 @@ def run_init(db: Session) -> None:
|
|||||||
# Built-in acc-mgr user (after roles are created)
|
# Built-in acc-mgr user (after roles are created)
|
||||||
init_acc_mgr_user(db)
|
init_acc_mgr_user(db)
|
||||||
|
|
||||||
# Built-in deleted-user (foreign key sink for deleted accounts)
|
|
||||||
init_deleted_user(db)
|
|
||||||
|
|
||||||
# Default project
|
# Default project
|
||||||
project_cfg = config.get("default_project")
|
project_cfg = config.get("default_project")
|
||||||
if project_cfg and admin_user:
|
if project_cfg and admin_user:
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ from app.api.routers.proposes import router as proposes_router # legacy compat
|
|||||||
from app.api.routers.milestone_actions import router as milestone_actions_router
|
from app.api.routers.milestone_actions import router as milestone_actions_router
|
||||||
from app.api.routers.meetings import router as meetings_router
|
from app.api.routers.meetings import router as meetings_router
|
||||||
from app.api.routers.essentials import router as essentials_router
|
from app.api.routers.essentials import router as essentials_router
|
||||||
|
from app.api.routers.schedule_type import router as schedule_type_router
|
||||||
from app.api.routers.calendar import router as calendar_router
|
from app.api.routers.calendar import router as calendar_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
@@ -80,6 +81,7 @@ app.include_router(proposes_router) # legacy compat
|
|||||||
app.include_router(milestone_actions_router)
|
app.include_router(milestone_actions_router)
|
||||||
app.include_router(meetings_router)
|
app.include_router(meetings_router)
|
||||||
app.include_router(essentials_router)
|
app.include_router(essentials_router)
|
||||||
|
app.include_router(schedule_type_router)
|
||||||
app.include_router(calendar_router)
|
app.include_router(calendar_router)
|
||||||
|
|
||||||
|
|
||||||
@@ -363,6 +365,10 @@ def _migrate_schema():
|
|||||||
if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"):
|
if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"):
|
||||||
db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL"))
|
db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL"))
|
||||||
|
|
||||||
|
# --- agents: add schedule_type_id FK ---
|
||||||
|
if _has_table(db, "agents") and not _has_column(db, "agents", "schedule_type_id"):
|
||||||
|
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
@@ -397,7 +403,7 @@ def _sync_default_user_roles(db):
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup():
|
def startup():
|
||||||
from app.core.config import Base, engine, SessionLocal
|
from app.core.config import Base, engine, SessionLocal
|
||||||
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload
|
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_migrate_schema()
|
_migrate_schema()
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ class Agent(Base):
|
|||||||
comment="rate_limit | billing — why the agent is exhausted",
|
comment="rate_limit | billing — why the agent is exhausted",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# -- schedule type ------------------------------------------------------
|
||||||
|
|
||||||
|
schedule_type_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("schedule_types.id"),
|
||||||
|
nullable=True,
|
||||||
|
comment="FK to schedule_types — defines work/entertainment periods",
|
||||||
|
)
|
||||||
|
|
||||||
# -- timestamps ---------------------------------------------------------
|
# -- timestamps ---------------------------------------------------------
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -138,3 +147,4 @@ class Agent(Base):
|
|||||||
# -- relationships ------------------------------------------------------
|
# -- relationships ------------------------------------------------------
|
||||||
|
|
||||||
user = relationship("User", back_populates="agent", uselist=False)
|
user = relationship("User", back_populates="agent", uselist=False)
|
||||||
|
schedule_type = relationship("ScheduleType", lazy="joined")
|
||||||
|
|||||||
52
app/models/schedule_type.py
Normal file
52
app/models/schedule_type.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""ScheduleType model — defines work/entertainment time periods.
|
||||||
|
|
||||||
|
Each ScheduleType defines the daily work and entertainment windows.
|
||||||
|
Agents reference a schedule_type to know when they should be working
|
||||||
|
vs when they can engage in entertainment activities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.config import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleType(Base):
|
||||||
|
"""Work/entertainment period definition."""
|
||||||
|
|
||||||
|
__tablename__ = "schedule_types"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
name = Column(
|
||||||
|
String(64),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
|
||||||
|
)
|
||||||
|
|
||||||
|
work_from = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
comment="Work period start hour (0-23, UTC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
work_to = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
comment="Work period end hour (0-23, UTC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
entertainment_from = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
comment="Entertainment period start hour (0-23, UTC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
entertainment_to = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
comment="Entertainment period end hour (0-23, UTC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
36
app/schemas/schedule_type.py
Normal file
36
app/schemas/schedule_type.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Schemas for ScheduleType CRUD."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleTypeCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=64)
|
||||||
|
work_from: int = Field(..., ge=0, le=23)
|
||||||
|
work_to: int = Field(..., ge=0, le=23)
|
||||||
|
entertainment_from: int = Field(..., ge=0, le=23)
|
||||||
|
entertainment_to: int = Field(..., ge=0, le=23)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleTypeUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||||
|
work_from: Optional[int] = Field(None, ge=0, le=23)
|
||||||
|
work_to: Optional[int] = Field(None, ge=0, le=23)
|
||||||
|
entertainment_from: Optional[int] = Field(None, ge=0, le=23)
|
||||||
|
entertainment_to: Optional[int] = Field(None, ge=0, le=23)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleTypeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
work_from: int
|
||||||
|
work_to: int
|
||||||
|
entertainment_from: int
|
||||||
|
entertainment_to: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AgentScheduleTypeAssign(BaseModel):
|
||||||
|
schedule_type_name: str = Field(..., description="Name of the schedule type to assign")
|
||||||
Reference in New Issue
Block a user