Compare commits
2 Commits
3dcd07bdf3
...
1c062ff4f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c062ff4f1 | |||
| a9b4fa14b4 |
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.api.deps import get_current_user, get_password_hash
|
from app.api.deps import get_current_user, get_password_hash
|
||||||
from app.core.config import get_db
|
from app.core.config import get_db
|
||||||
from app.models import models
|
from app.models import models
|
||||||
|
from app.models.agent import Agent
|
||||||
from app.models.role_permission import Permission, Role, RolePermission
|
from app.models.role_permission import Permission, Role, RolePermission
|
||||||
from app.models.worklog import WorkLog
|
from app.models.worklog import WorkLog
|
||||||
from app.schemas import schemas
|
from app.schemas import schemas
|
||||||
@@ -17,6 +18,23 @@ from app.schemas import schemas
|
|||||||
router = APIRouter(prefix="/users", tags=["Users"])
|
router = APIRouter(prefix="/users", tags=["Users"])
|
||||||
|
|
||||||
|
|
||||||
|
def _user_response(user: models.User) -> dict:
|
||||||
|
"""Build a UserResponse-compatible dict that includes the agent_id when present."""
|
||||||
|
data = {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"full_name": user.full_name,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"is_admin": user.is_admin,
|
||||||
|
"role_id": user.role_id,
|
||||||
|
"role_name": user.role_name,
|
||||||
|
"agent_id": user.agent.agent_id if user.agent else None,
|
||||||
|
"created_at": user.created_at,
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def require_admin(current_user: models.User = Depends(get_current_user)):
|
def require_admin(current_user: models.User = Depends(get_current_user)):
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Admin required")
|
raise HTTPException(status_code=403, detail="Admin required")
|
||||||
@@ -69,12 +87,27 @@ def create_user(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: models.User = Depends(require_account_creator),
|
_: models.User = Depends(require_account_creator),
|
||||||
):
|
):
|
||||||
|
# Validate agent_id / claw_identifier: both or neither
|
||||||
|
has_agent_id = bool(user.agent_id)
|
||||||
|
has_claw = bool(user.claw_identifier)
|
||||||
|
if has_agent_id != has_claw:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="agent_id and claw_identifier must both be provided or both omitted",
|
||||||
|
)
|
||||||
|
|
||||||
existing = db.query(models.User).filter(
|
existing = db.query(models.User).filter(
|
||||||
(models.User.username == user.username) | (models.User.email == user.email)
|
(models.User.username == user.username) | (models.User.email == user.email)
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="Username or email already exists")
|
raise HTTPException(status_code=400, detail="Username or email already exists")
|
||||||
|
|
||||||
|
# Check agent_id uniqueness
|
||||||
|
if has_agent_id:
|
||||||
|
existing_agent = db.query(Agent).filter(Agent.agent_id == user.agent_id).first()
|
||||||
|
if existing_agent:
|
||||||
|
raise HTTPException(status_code=400, detail="agent_id already in use")
|
||||||
|
|
||||||
assigned_role = _resolve_user_role(db, user.role_id)
|
assigned_role = _resolve_user_role(db, user.role_id)
|
||||||
hashed_password = get_password_hash(user.password) if user.password else None
|
hashed_password = get_password_hash(user.password) if user.password else None
|
||||||
db_user = models.User(
|
db_user = models.User(
|
||||||
@@ -87,9 +120,20 @@ def create_user(
|
|||||||
role_id=assigned_role.id,
|
role_id=assigned_role.id,
|
||||||
)
|
)
|
||||||
db.add(db_user)
|
db.add(db_user)
|
||||||
|
db.flush() # get db_user.id
|
||||||
|
|
||||||
|
# Create Agent record if agent binding is requested (BE-CAL-003)
|
||||||
|
if has_agent_id:
|
||||||
|
db_agent = Agent(
|
||||||
|
user_id=db_user.id,
|
||||||
|
agent_id=user.agent_id,
|
||||||
|
claw_identifier=user.claw_identifier,
|
||||||
|
)
|
||||||
|
db.add(db_agent)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_user)
|
db.refresh(db_user)
|
||||||
return db_user
|
return _user_response(db_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[schemas.UserResponse])
|
@router.get("", response_model=List[schemas.UserResponse])
|
||||||
@@ -99,7 +143,8 @@ def list_users(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: models.User = Depends(require_admin),
|
_: models.User = Depends(require_admin),
|
||||||
):
|
):
|
||||||
return db.query(models.User).order_by(models.User.created_at.desc()).offset(skip).limit(limit).all()
|
users = db.query(models.User).order_by(models.User.created_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
return [_user_response(u) for u in users]
|
||||||
|
|
||||||
|
|
||||||
def _find_user_by_id_or_username(db: Session, identifier: str) -> models.User | None:
|
def _find_user_by_id_or_username(db: Session, identifier: str) -> models.User | None:
|
||||||
@@ -120,7 +165,7 @@ def get_user(
|
|||||||
user = _find_user_by_id_or_username(db, identifier)
|
user = _find_user_by_id_or_username(db, identifier)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
return user
|
return _user_response(user)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{identifier}", response_model=schemas.UserResponse)
|
@router.patch("/{identifier}", response_model=schemas.UserResponse)
|
||||||
@@ -159,7 +204,7 @@ def update_user(
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
return user
|
return _user_response(user)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
23
app/main.py
23
app/main.py
@@ -261,6 +261,27 @@ def _migrate_schema():
|
|||||||
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_sites_json"):
|
if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_sites_json"):
|
||||||
db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_sites_json TEXT NULL"))
|
db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_sites_json TEXT NULL"))
|
||||||
|
|
||||||
|
# --- agents table (BE-CAL-003) ---
|
||||||
|
if not _has_table(db, "agents"):
|
||||||
|
db.execute(text("""
|
||||||
|
CREATE TABLE agents (
|
||||||
|
id INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
agent_id VARCHAR(128) NOT NULL,
|
||||||
|
claw_identifier VARCHAR(128) NOT NULL,
|
||||||
|
status ENUM('idle','on_call','busy','exhausted','offline') NOT NULL DEFAULT 'idle',
|
||||||
|
last_heartbeat DATETIME NULL,
|
||||||
|
exhausted_at DATETIME NULL,
|
||||||
|
recovery_at DATETIME NULL,
|
||||||
|
exhaust_reason ENUM('rate_limit','billing') NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE INDEX idx_agents_user_id (user_id),
|
||||||
|
UNIQUE INDEX idx_agents_agent_id (agent_id),
|
||||||
|
CONSTRAINT fk_agents_user_id FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
"""))
|
||||||
|
|
||||||
# --- essentials table (BE-PR-003) ---
|
# --- essentials table (BE-PR-003) ---
|
||||||
if not _has_table(db, "essentials"):
|
if not _has_table(db, "essentials"):
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
@@ -316,7 +337,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
|
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_migrate_schema()
|
_migrate_schema()
|
||||||
|
|
||||||
|
|||||||
140
app/models/agent.py
Normal file
140
app/models/agent.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""Agent model — tracks OpenClaw agents linked to HarborForge users.
|
||||||
|
|
||||||
|
An Agent represents an AI agent (identified by its OpenClaw ``agent_id``)
|
||||||
|
that is bound to exactly one HarborForge User. The Calendar system uses
|
||||||
|
Agent status to decide whether to wake an agent for scheduled slots.
|
||||||
|
|
||||||
|
See: NEXT_WAVE_DEV_DIRECTION.md §1.4 (Agent table) and §6 (Agent wakeup)
|
||||||
|
Implements: BE-CAL-003
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
DateTime,
|
||||||
|
Enum,
|
||||||
|
ForeignKey,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.config import Base
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Enums
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AgentStatus(str, enum.Enum):
|
||||||
|
"""Runtime status of an Agent."""
|
||||||
|
IDLE = "idle"
|
||||||
|
ON_CALL = "on_call"
|
||||||
|
BUSY = "busy"
|
||||||
|
EXHAUSTED = "exhausted"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
|
||||||
|
|
||||||
|
class ExhaustReason(str, enum.Enum):
|
||||||
|
"""Why an agent entered the Exhausted state."""
|
||||||
|
RATE_LIMIT = "rate_limit"
|
||||||
|
BILLING = "billing"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Agent(Base):
|
||||||
|
"""An OpenClaw agent bound to a HarborForge user.
|
||||||
|
|
||||||
|
Fields
|
||||||
|
------
|
||||||
|
user_id : int
|
||||||
|
One-to-one FK to ``users.id``. Each user has at most one agent.
|
||||||
|
agent_id : str
|
||||||
|
The ``$AGENT_ID`` value from OpenClaw (globally unique).
|
||||||
|
claw_identifier : str
|
||||||
|
The OpenClaw instance identifier (matches ``MonitoredServer.identifier``
|
||||||
|
by convention, but has no FK — they are independent concepts).
|
||||||
|
status : AgentStatus
|
||||||
|
Current runtime status, managed by heartbeat / calendar wakeup logic.
|
||||||
|
last_heartbeat : datetime | None
|
||||||
|
Timestamp of the most recent heartbeat received from this agent.
|
||||||
|
exhausted_at : datetime | None
|
||||||
|
When the agent entered the ``EXHAUSTED`` state.
|
||||||
|
recovery_at : datetime | None
|
||||||
|
Estimated time the agent will recover from ``EXHAUSTED`` → ``IDLE``.
|
||||||
|
exhaust_reason : ExhaustReason | None
|
||||||
|
Why the agent became exhausted (rate-limit vs billing).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "agents"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
user_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
comment="1-to-1 link to the owning HarborForge user",
|
||||||
|
)
|
||||||
|
|
||||||
|
agent_id = Column(
|
||||||
|
String(128),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
comment="OpenClaw $AGENT_ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
claw_identifier = Column(
|
||||||
|
String(128),
|
||||||
|
nullable=False,
|
||||||
|
comment="OpenClaw instance identifier (same value as MonitoredServer.identifier by convention)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- runtime status fields ----------------------------------------------
|
||||||
|
|
||||||
|
status = Column(
|
||||||
|
Enum(AgentStatus, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=False,
|
||||||
|
default=AgentStatus.IDLE,
|
||||||
|
comment="Current agent status: idle | on_call | busy | exhausted | offline",
|
||||||
|
)
|
||||||
|
|
||||||
|
last_heartbeat = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Timestamp of the most recent heartbeat",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- exhausted state detail ---------------------------------------------
|
||||||
|
|
||||||
|
exhausted_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="When the agent entered EXHAUSTED state",
|
||||||
|
)
|
||||||
|
|
||||||
|
recovery_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Estimated recovery time from EXHAUSTED → IDLE",
|
||||||
|
)
|
||||||
|
|
||||||
|
exhaust_reason = Column(
|
||||||
|
Enum(ExhaustReason, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=True,
|
||||||
|
comment="rate_limit | billing — why the agent is exhausted",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- timestamps ---------------------------------------------------------
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# -- relationships ------------------------------------------------------
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="agent", uselist=False)
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
"""Calendar models — TimeSlot and related enums.
|
"""Calendar models — TimeSlot, SchedulePlan and related enums.
|
||||||
|
|
||||||
TimeSlot represents a single scheduled slot on a user's calendar.
|
TimeSlot represents a single scheduled slot on a user's calendar.
|
||||||
Slots can be created manually or materialized from a SchedulePlan.
|
Slots can be created manually or materialized from a SchedulePlan.
|
||||||
|
|
||||||
See: NEXT_WAVE_DEV_DIRECTION.md §1.1
|
SchedulePlan represents a recurring schedule rule that generates
|
||||||
|
virtual slots on matching dates. Virtual slots are materialized
|
||||||
|
into real TimeSlot rows on demand (daily pre-compute, or when
|
||||||
|
edited/cancelled).
|
||||||
|
|
||||||
|
See: NEXT_WAVE_DEV_DIRECTION.md §1.1 – §1.3
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, Integer, String, Text, DateTime, Date, Time,
|
Column, Integer, String, Text, DateTime, Date, Time,
|
||||||
ForeignKey, Enum, Boolean, JSON,
|
ForeignKey, Enum, Boolean, JSON, CheckConstraint,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.orm import relationship, validates
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.core.config import Base
|
from app.core.config import Base
|
||||||
import enum
|
import enum
|
||||||
@@ -45,6 +51,33 @@ class EventType(str, enum.Enum):
|
|||||||
SYSTEM_EVENT = "system_event"
|
SYSTEM_EVENT = "system_event"
|
||||||
|
|
||||||
|
|
||||||
|
class DayOfWeek(str, enum.Enum):
|
||||||
|
"""Day-of-week for SchedulePlan.on_day."""
|
||||||
|
SUN = "sun"
|
||||||
|
MON = "mon"
|
||||||
|
TUE = "tue"
|
||||||
|
WED = "wed"
|
||||||
|
THU = "thu"
|
||||||
|
FRI = "fri"
|
||||||
|
SAT = "sat"
|
||||||
|
|
||||||
|
|
||||||
|
class MonthOfYear(str, enum.Enum):
|
||||||
|
"""Month for SchedulePlan.on_month."""
|
||||||
|
JAN = "jan"
|
||||||
|
FEB = "feb"
|
||||||
|
MAR = "mar"
|
||||||
|
APR = "apr"
|
||||||
|
MAY = "may"
|
||||||
|
JUN = "jun"
|
||||||
|
JUL = "jul"
|
||||||
|
AUG = "aug"
|
||||||
|
SEP = "sep"
|
||||||
|
OCT = "oct"
|
||||||
|
NOV = "nov"
|
||||||
|
DEC = "dec"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# TimeSlot model
|
# TimeSlot model
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -141,3 +174,142 @@ class TimeSlot(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# relationship ----------------------------------------------------------
|
||||||
|
plan = relationship("SchedulePlan", back_populates="materialized_slots")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SchedulePlan model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SchedulePlan(Base):
|
||||||
|
"""A recurring schedule rule that generates virtual TimeSlots.
|
||||||
|
|
||||||
|
Hierarchy constraint for the period parameters:
|
||||||
|
• ``at_time`` is always required.
|
||||||
|
• ``on_month`` requires ``on_week`` (which in turn requires ``on_day``).
|
||||||
|
• ``on_week`` requires ``on_day``.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
• ``--at 09:00`` → every day at 09:00
|
||||||
|
• ``--at 09:00 --on-day sun`` → every Sunday at 09:00
|
||||||
|
• ``--at 09:00 --on-day sun --on-week 1`` → 1st-week Sunday each month
|
||||||
|
• ``--at … --on-day sun --on-week 1 --on-month jan`` → Jan 1st-week Sunday
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "schedule_plans"
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# on_month requires on_week
|
||||||
|
CheckConstraint(
|
||||||
|
"(on_month IS NULL) OR (on_week IS NOT NULL)",
|
||||||
|
name="ck_plan_month_requires_week",
|
||||||
|
),
|
||||||
|
# on_week requires on_day
|
||||||
|
CheckConstraint(
|
||||||
|
"(on_week IS NULL) OR (on_day IS NOT NULL)",
|
||||||
|
name="ck_plan_week_requires_day",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
user_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="Owner of this plan",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- slot template fields -----------------------------------------------
|
||||||
|
slot_type = Column(
|
||||||
|
Enum(SlotType, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=False,
|
||||||
|
comment="work | on_call | entertainment | system",
|
||||||
|
)
|
||||||
|
|
||||||
|
estimated_duration = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
comment="Estimated duration in minutes (1-50)",
|
||||||
|
)
|
||||||
|
|
||||||
|
event_type = Column(
|
||||||
|
Enum(EventType, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=True,
|
||||||
|
comment="job | entertainment | system_event",
|
||||||
|
)
|
||||||
|
|
||||||
|
event_data = Column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
comment="Event details JSON — copied to materialized slots",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- period parameters --------------------------------------------------
|
||||||
|
at_time = Column(
|
||||||
|
Time,
|
||||||
|
nullable=False,
|
||||||
|
comment="Daily scheduled time (--at HH:mm), always required",
|
||||||
|
)
|
||||||
|
|
||||||
|
on_day = Column(
|
||||||
|
Enum(DayOfWeek, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=True,
|
||||||
|
comment="Day of week (--on-day); NULL = every day",
|
||||||
|
)
|
||||||
|
|
||||||
|
on_week = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=True,
|
||||||
|
comment="Week-of-month 1-4 (--on-week); NULL = every week",
|
||||||
|
)
|
||||||
|
|
||||||
|
on_month = Column(
|
||||||
|
Enum(MonthOfYear, values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=True,
|
||||||
|
comment="Month (--on-month); NULL = every month",
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active = Column(
|
||||||
|
Boolean,
|
||||||
|
default=True,
|
||||||
|
nullable=False,
|
||||||
|
comment="Soft-delete / plan-cancel flag",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# relationship ----------------------------------------------------------
|
||||||
|
materialized_slots = relationship(
|
||||||
|
"TimeSlot",
|
||||||
|
back_populates="plan",
|
||||||
|
lazy="dynamic",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- application-level validation ---------------------------------------
|
||||||
|
|
||||||
|
@validates("on_week")
|
||||||
|
def _validate_on_week(self, _key: str, value: int | None) -> int | None:
|
||||||
|
if value is not None and not (1 <= value <= 4):
|
||||||
|
raise ValueError("on_week must be between 1 and 4")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@validates("on_month")
|
||||||
|
def _validate_on_month(self, _key: str, value):
|
||||||
|
"""Enforce: on_month requires on_week (and transitively on_day)."""
|
||||||
|
if value is not None and self.on_week is None:
|
||||||
|
raise ValueError(
|
||||||
|
"on_month requires on_week to be set "
|
||||||
|
"(hierarchy: on_month → on_week → on_day)"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@validates("estimated_duration")
|
||||||
|
def _validate_estimated_duration(self, _key: str, value: int) -> int:
|
||||||
|
if not (1 <= value <= 50):
|
||||||
|
raise ValueError("estimated_duration must be between 1 and 50")
|
||||||
|
return value
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class User(Base):
|
|||||||
owned_projects = relationship("Project", back_populates="owner")
|
owned_projects = relationship("Project", back_populates="owner")
|
||||||
comments = relationship("Comment", back_populates="author")
|
comments = relationship("Comment", back_populates="author")
|
||||||
project_memberships = relationship("ProjectMember", back_populates="user")
|
project_memberships = relationship("ProjectMember", back_populates="user")
|
||||||
|
agent = relationship("Agent", back_populates="user", uselist=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def role_name(self):
|
def role_name(self):
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ class UserBase(BaseModel):
|
|||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
role_id: Optional[int] = None
|
role_id: Optional[int] = None
|
||||||
|
# Agent binding (both must be provided or both omitted)
|
||||||
|
agent_id: Optional[str] = None
|
||||||
|
claw_identifier: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
@@ -192,6 +195,7 @@ class UserResponse(UserBase):
|
|||||||
is_admin: bool
|
is_admin: bool
|
||||||
role_id: Optional[int] = None
|
role_id: Optional[int] = None
|
||||||
role_name: Optional[str] = None
|
role_name: Optional[str] = None
|
||||||
|
agent_id: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -389,6 +393,47 @@ class ProposalAcceptResponse(ProposalResponse):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent schemas (BE-CAL-003)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AgentStatusEnum(str, Enum):
|
||||||
|
IDLE = "idle"
|
||||||
|
ON_CALL = "on_call"
|
||||||
|
BUSY = "busy"
|
||||||
|
EXHAUSTED = "exhausted"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
|
||||||
|
|
||||||
|
class ExhaustReasonEnum(str, Enum):
|
||||||
|
RATE_LIMIT = "rate_limit"
|
||||||
|
BILLING = "billing"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentResponse(BaseModel):
|
||||||
|
"""Read-only representation of an Agent."""
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
agent_id: str
|
||||||
|
claw_identifier: str
|
||||||
|
status: AgentStatusEnum
|
||||||
|
last_heartbeat: Optional[datetime] = None
|
||||||
|
exhausted_at: Optional[datetime] = None
|
||||||
|
recovery_at: Optional[datetime] = None
|
||||||
|
exhaust_reason: Optional[ExhaustReasonEnum] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AgentStatusUpdate(BaseModel):
|
||||||
|
"""Payload for updating an agent's runtime status."""
|
||||||
|
status: AgentStatusEnum
|
||||||
|
exhaust_reason: Optional[ExhaustReasonEnum] = None
|
||||||
|
recovery_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
# Backward-compatible aliases
|
# Backward-compatible aliases
|
||||||
ProposeStatusEnum = ProposalStatusEnum
|
ProposeStatusEnum = ProposalStatusEnum
|
||||||
ProposeBase = ProposalBase
|
ProposeBase = ProposalBase
|
||||||
|
|||||||
Reference in New Issue
Block a user