feat(automation_jobs): add CRUD repository methods
This commit is contained in:
@@ -1,24 +1,162 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from core.db.base_repository import BaseRepository
|
from core.db.base_repository import BaseRepository
|
||||||
from models.agent_chat_session import AgentChatSession, SessionType
|
from models.agent_chat_session import AgentChatSession, SessionType
|
||||||
from models.automation_jobs import AutomationJob
|
from models.automation_jobs import AutomationJob
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
pass
|
from v1.automation_jobs.schemas import (
|
||||||
|
AutomationJobCreateRequest,
|
||||||
|
AutomationJobUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_next_local_time_utc(
|
||||||
|
*,
|
||||||
|
now_utc: datetime,
|
||||||
|
timezone_name: str,
|
||||||
|
local_hour: int,
|
||||||
|
local_minute: int,
|
||||||
|
) -> tuple[datetime, datetime]:
|
||||||
|
try:
|
||||||
|
timezone_obj = ZoneInfo(timezone_name)
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
timezone_obj = ZoneInfo("UTC")
|
||||||
|
local_now = now_utc.astimezone(timezone_obj)
|
||||||
|
today_run_local = local_now.replace(
|
||||||
|
hour=local_hour,
|
||||||
|
minute=local_minute,
|
||||||
|
second=0,
|
||||||
|
microsecond=0,
|
||||||
|
)
|
||||||
|
run_local = (
|
||||||
|
today_run_local
|
||||||
|
if local_now <= today_run_local
|
||||||
|
else today_run_local + timedelta(days=1)
|
||||||
|
)
|
||||||
|
next_local = run_local + timedelta(days=1)
|
||||||
|
return run_local.astimezone(timezone.utc), next_local.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class AutomationJobsRepository(BaseRepository[AutomationJob]):
|
class AutomationJobsRepository(BaseRepository[AutomationJob]):
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
super().__init__(session=session, model=AutomationJob)
|
super().__init__(session=session, model=AutomationJob)
|
||||||
|
|
||||||
|
async def list_by_owner(self, owner_id: UUID) -> list[AutomationJob]:
|
||||||
|
stmt = (
|
||||||
|
select(AutomationJob)
|
||||||
|
.where(AutomationJob.owner_id == owner_id)
|
||||||
|
.where(AutomationJob.deleted_at.is_(None))
|
||||||
|
.order_by(AutomationJob.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = (await self._session.execute(stmt)).scalars().all()
|
||||||
|
return list(rows)
|
||||||
|
|
||||||
|
async def get_by_id(self, job_id: UUID) -> AutomationJob | None: # type: ignore[override]
|
||||||
|
stmt = (
|
||||||
|
select(AutomationJob)
|
||||||
|
.where(AutomationJob.id == job_id)
|
||||||
|
.where(AutomationJob.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def count_user_jobs(self, owner_id: UUID) -> int:
|
||||||
|
stmt = (
|
||||||
|
select(func.count(AutomationJob.id))
|
||||||
|
.where(AutomationJob.owner_id == owner_id)
|
||||||
|
.where(AutomationJob.bootstrap_key.is_(None))
|
||||||
|
.where(AutomationJob.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
result = (await self._session.execute(stmt)).scalar_one()
|
||||||
|
return int(result)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, owner_id: UUID, data: "AutomationJobCreateRequest"
|
||||||
|
) -> AutomationJob:
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
run_at_dt, next_run_at = _compute_next_local_time_utc(
|
||||||
|
now_utc=now_utc,
|
||||||
|
timezone_name=data.timezone,
|
||||||
|
local_hour=data.run_at.hour,
|
||||||
|
local_minute=data.run_at.minute,
|
||||||
|
)
|
||||||
|
new_job = AutomationJob(
|
||||||
|
id=uuid4(),
|
||||||
|
owner_id=owner_id,
|
||||||
|
created_by=owner_id,
|
||||||
|
bootstrap_key=None,
|
||||||
|
title=data.title,
|
||||||
|
config=data.config.model_dump(mode="json"),
|
||||||
|
schedule_type=data.schedule_type,
|
||||||
|
run_at=run_at_dt,
|
||||||
|
next_run_at=next_run_at,
|
||||||
|
timezone=data.timezone,
|
||||||
|
status=data.status,
|
||||||
|
)
|
||||||
|
self._session.add(new_job)
|
||||||
|
await self._session.flush()
|
||||||
|
return new_job
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, job_id: UUID, data: "AutomationJobUpdateRequest"
|
||||||
|
) -> AutomationJob | None:
|
||||||
|
update_values: dict[str, object] = {}
|
||||||
|
if data.title is not None:
|
||||||
|
update_values["title"] = data.title
|
||||||
|
if data.schedule_type is not None:
|
||||||
|
update_values["schedule_type"] = data.schedule_type
|
||||||
|
if data.run_at is not None:
|
||||||
|
stmt = select(AutomationJob).where(AutomationJob.id == job_id)
|
||||||
|
existing = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if existing is None:
|
||||||
|
return None
|
||||||
|
run_at_dt, next_run_at = _compute_next_local_time_utc(
|
||||||
|
now_utc=datetime.now(timezone.utc),
|
||||||
|
timezone_name=data.timezone or existing.timezone,
|
||||||
|
local_hour=data.run_at.hour,
|
||||||
|
local_minute=data.run_at.minute,
|
||||||
|
)
|
||||||
|
update_values["run_at"] = run_at_dt
|
||||||
|
update_values["next_run_at"] = next_run_at
|
||||||
|
update_values["timezone"] = data.timezone or existing.timezone
|
||||||
|
if data.status is not None:
|
||||||
|
update_values["status"] = data.status
|
||||||
|
if data.config is not None:
|
||||||
|
update_values["config"] = data.config.model_dump(mode="json")
|
||||||
|
|
||||||
|
if not update_values:
|
||||||
|
return await self.get_by_id(job_id)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
update(AutomationJob)
|
||||||
|
.where(AutomationJob.id == job_id)
|
||||||
|
.where(AutomationJob.deleted_at.is_(None))
|
||||||
|
.values(**update_values)
|
||||||
|
.returning(AutomationJob)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
await self._session.flush()
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def soft_delete(self, job_id: UUID) -> None:
|
||||||
|
stmt = (
|
||||||
|
update(AutomationJob)
|
||||||
|
.where(AutomationJob.id == job_id)
|
||||||
|
.where(AutomationJob.deleted_at.is_(None))
|
||||||
|
.values(deleted_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
await self._session.execute(stmt)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
async def list_due_jobs(
|
async def list_due_jobs(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, time, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from models.automation_jobs import AutomationJobStatus, ScheduleType
|
||||||
|
from v1.automation_jobs.repository import AutomationJobsRepository
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecuteResult:
|
||||||
|
def __init__(self, value: object) -> None:
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
def scalar_one_or_none(self) -> object:
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def scalar_one(self) -> int:
|
||||||
|
return self._value # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
class _ScalarRows:
|
||||||
|
def __init__(self, rows: list[object]) -> None:
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def all(self) -> list[object]:
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
|
||||||
|
class _ExecuteRowsResult:
|
||||||
|
def __init__(self, rows: list[object]) -> None:
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def scalars(self) -> _ScalarRows:
|
||||||
|
return _ScalarRows(self._rows)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.added: list[object] = []
|
||||||
|
self.flushed = False
|
||||||
|
self._execute_result: object = None
|
||||||
|
self._return_rows: bool = False
|
||||||
|
|
||||||
|
def set_execute_result(self, value: object) -> None:
|
||||||
|
self._execute_result = value
|
||||||
|
self._return_rows = isinstance(value, list)
|
||||||
|
|
||||||
|
async def execute(self, stmt): # noqa: ANN001
|
||||||
|
del stmt
|
||||||
|
if self._return_rows:
|
||||||
|
return _ExecuteRowsResult(self._execute_result)
|
||||||
|
return _ExecuteResult(self._execute_result)
|
||||||
|
|
||||||
|
def add(self, obj: object) -> None:
|
||||||
|
self.added.append(obj)
|
||||||
|
|
||||||
|
async def flush(self) -> None:
|
||||||
|
self.flushed = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_session() -> _FakeSession:
|
||||||
|
return _FakeSession()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repository(fake_session: _FakeSession) -> AutomationJobsRepository:
|
||||||
|
return AutomationJobsRepository(session=fake_session) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_job() -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=uuid4(),
|
||||||
|
owner_id=uuid4(),
|
||||||
|
bootstrap_key=None,
|
||||||
|
title="Test Job",
|
||||||
|
config={"input_template": "Hello {name}"},
|
||||||
|
schedule_type=ScheduleType.DAILY,
|
||||||
|
run_at=datetime(2026, 3, 23, 0, 0, tzinfo=timezone.utc),
|
||||||
|
next_run_at=datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc),
|
||||||
|
timezone="UTC",
|
||||||
|
status=AutomationJobStatus.ACTIVE,
|
||||||
|
created_by=uuid4(),
|
||||||
|
deleted_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_by_owner_returns_jobs(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
sample_job: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
fake_session.set_execute_result([sample_job])
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
jobs = await repository.list_by_owner(owner_id)
|
||||||
|
|
||||||
|
assert len(jobs) == 1
|
||||||
|
assert jobs[0].title == "Test Job"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_by_owner_returns_empty_list(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
) -> None:
|
||||||
|
fake_session.set_execute_result([])
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
jobs = await repository.list_by_owner(owner_id)
|
||||||
|
|
||||||
|
assert jobs == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_by_id_returns_job(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
sample_job: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
fake_session.set_execute_result(sample_job)
|
||||||
|
|
||||||
|
job_id = uuid4()
|
||||||
|
job = await repository.get_by_id(job_id)
|
||||||
|
|
||||||
|
assert job is not None
|
||||||
|
assert job.title == "Test Job"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_by_id_returns_none_when_not_found(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
) -> None:
|
||||||
|
fake_session.set_execute_result(None)
|
||||||
|
|
||||||
|
job_id = uuid4()
|
||||||
|
job = await repository.get_by_id(job_id)
|
||||||
|
|
||||||
|
assert job is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_count_user_jobs_returns_count(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
) -> None:
|
||||||
|
fake_session.set_execute_result(5)
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
count = await repository.count_user_jobs(owner_id)
|
||||||
|
|
||||||
|
assert count == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_count_user_jobs_returns_zero_when_none(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
) -> None:
|
||||||
|
fake_session.set_execute_result(0)
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
count = await repository.count_user_jobs(owner_id)
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_job(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
) -> None:
|
||||||
|
from v1.automation_jobs.schemas import AutomationJobCreateRequest
|
||||||
|
from schemas.automation import AutomationJobConfig
|
||||||
|
|
||||||
|
owner_id = uuid4()
|
||||||
|
request = AutomationJobCreateRequest(
|
||||||
|
title="New Job",
|
||||||
|
schedule_type=ScheduleType.DAILY,
|
||||||
|
run_at=time(0, 0),
|
||||||
|
timezone="UTC",
|
||||||
|
status=AutomationJobStatus.ACTIVE,
|
||||||
|
config=AutomationJobConfig(input_template="Test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
job = await repository.create(owner_id, request)
|
||||||
|
|
||||||
|
assert job.title == "New Job"
|
||||||
|
assert job.owner_id == owner_id
|
||||||
|
assert job.created_by == owner_id
|
||||||
|
assert job.bootstrap_key is None
|
||||||
|
assert job.schedule_type == ScheduleType.DAILY
|
||||||
|
assert fake_session.flushed is True
|
||||||
|
assert len(fake_session.added) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_soft_delete(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
) -> None:
|
||||||
|
job_id = uuid4()
|
||||||
|
fake_session.set_execute_result(None)
|
||||||
|
|
||||||
|
await repository.soft_delete(job_id)
|
||||||
|
|
||||||
|
assert fake_session.flushed is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_job_title(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
sample_job: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
from v1.automation_jobs.schemas import AutomationJobUpdateRequest
|
||||||
|
|
||||||
|
sample_job.title = "Updated Title"
|
||||||
|
fake_session.set_execute_result(sample_job)
|
||||||
|
|
||||||
|
request = AutomationJobUpdateRequest(title="Updated Title")
|
||||||
|
job = await repository.update(sample_job.id, request)
|
||||||
|
|
||||||
|
assert job is not None
|
||||||
|
assert job.title == "Updated Title"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_job_run_at_recomputes_next_run_at(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
sample_job: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
from v1.automation_jobs.schemas import AutomationJobUpdateRequest
|
||||||
|
|
||||||
|
fake_session.set_execute_result(sample_job)
|
||||||
|
|
||||||
|
request = AutomationJobUpdateRequest(
|
||||||
|
run_at=time(12, 0),
|
||||||
|
timezone="UTC",
|
||||||
|
)
|
||||||
|
job = await repository.update(sample_job.id, request)
|
||||||
|
|
||||||
|
assert job is not None
|
||||||
|
assert fake_session.flushed is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_returns_none_when_job_not_found(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
) -> None:
|
||||||
|
from v1.automation_jobs.schemas import AutomationJobUpdateRequest
|
||||||
|
|
||||||
|
fake_session.set_execute_result(None)
|
||||||
|
|
||||||
|
request = AutomationJobUpdateRequest(title="New Title")
|
||||||
|
job = await repository.update(uuid4(), request)
|
||||||
|
|
||||||
|
assert job is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_with_no_changes_returns_existing_job(
|
||||||
|
repository: AutomationJobsRepository,
|
||||||
|
fake_session: _FakeSession,
|
||||||
|
sample_job: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
from v1.automation_jobs.schemas import AutomationJobUpdateRequest
|
||||||
|
|
||||||
|
fake_session.set_execute_result(sample_job)
|
||||||
|
|
||||||
|
request = AutomationJobUpdateRequest()
|
||||||
|
job = await repository.update(sample_job.id, request)
|
||||||
|
|
||||||
|
assert job is not None
|
||||||
|
assert job.title == "Test Job"
|
||||||
Reference in New Issue
Block a user