Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"""Source CRUD + the ad-hoc "test this source" action. Collection
|
||||
orchestration itself (persisting documents/snapshots) lives in
|
||||
collection_service.py; this module is the ownership-checked API-facing
|
||||
layer on top of it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import NotFoundError
|
||||
from app.models.company import Company
|
||||
from app.models.source import Source
|
||||
from app.repositories.company_repository import CompanyRepository
|
||||
from app.repositories.source_repository import SourceRepository
|
||||
from app.schemas.source import SourceCreate, SourceUpdate
|
||||
from app.services.collection_service import CollectionResult, collect_source
|
||||
from app.services.scheduling import validate_and_compute_next_run
|
||||
|
||||
|
||||
async def _get_owned_company(
|
||||
db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID
|
||||
) -> Company:
|
||||
company = await CompanyRepository(db).get_for_user(company_id, user_id)
|
||||
if company is None:
|
||||
raise NotFoundError("Company not found")
|
||||
return company
|
||||
|
||||
|
||||
async def list_sources(db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID) -> list[Source]:
|
||||
await _get_owned_company(db, user_id, company_id)
|
||||
return await SourceRepository(db).list_for_company(company_id)
|
||||
|
||||
|
||||
async def create_source(
|
||||
db: AsyncSession, user_id: uuid.UUID, company_id: uuid.UUID, payload: SourceCreate
|
||||
) -> Source:
|
||||
await _get_owned_company(db, user_id, company_id)
|
||||
source = await SourceRepository(db).create(
|
||||
company_id=company_id,
|
||||
source_type=payload.source_type,
|
||||
name=payload.name,
|
||||
base_url=payload.base_url,
|
||||
)
|
||||
await db.commit()
|
||||
return source
|
||||
|
||||
|
||||
async def _get_owned_source(db: AsyncSession, user_id: uuid.UUID, source_id: uuid.UUID) -> Source:
|
||||
source = await SourceRepository(db).get_for_user(source_id, user_id)
|
||||
if source is None:
|
||||
raise NotFoundError("Source not found")
|
||||
return source
|
||||
|
||||
|
||||
async def update_source(
|
||||
db: AsyncSession,
|
||||
settings: Settings,
|
||||
user_id: uuid.UUID,
|
||||
source_id: uuid.UUID,
|
||||
payload: SourceUpdate,
|
||||
) -> Source:
|
||||
source = await _get_owned_source(db, user_id, source_id)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
|
||||
schedule_changed = any(
|
||||
key in updates for key in ("frequency_type", "interval_minutes", "cron_expression")
|
||||
)
|
||||
if schedule_changed:
|
||||
frequency_type = updates.get("frequency_type", source.frequency_type)
|
||||
if frequency_type is not None:
|
||||
company = await CompanyRepository(db).get_for_user(source.company_id, user_id)
|
||||
tz_name = (
|
||||
company.monitor_configuration.timezone
|
||||
if company is not None and company.monitor_configuration is not None
|
||||
else "UTC"
|
||||
)
|
||||
# Validate only - the actual next_check is computed for real the
|
||||
# next time this source is collected (tasks/collection.py), same
|
||||
# as a brand-new source. Resetting it to None here means a
|
||||
# changed override takes effect on the very next scheduler tick
|
||||
# rather than waiting out whatever cadence was previously set.
|
||||
validate_and_compute_next_run(
|
||||
frequency_type=frequency_type,
|
||||
interval_minutes=updates.get("interval_minutes", source.interval_minutes),
|
||||
cron_expression=updates.get("cron_expression", source.cron_expression),
|
||||
tz_name=tz_name,
|
||||
minimum_interval_minutes=settings.minimum_monitoring_interval_minutes,
|
||||
)
|
||||
source.next_check = None
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(source, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(source)
|
||||
return source
|
||||
|
||||
|
||||
async def delete_source(db: AsyncSession, user_id: uuid.UUID, source_id: uuid.UUID) -> None:
|
||||
source = await _get_owned_source(db, user_id, source_id)
|
||||
await SourceRepository(db).delete(source)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def test_source(
|
||||
db: AsyncSession, settings: Settings, user_id: uuid.UUID, source_id: uuid.UUID
|
||||
) -> CollectionResult:
|
||||
source = await _get_owned_source(db, user_id, source_id)
|
||||
company = await CompanyRepository(db).get_for_user(source.company_id, user_id)
|
||||
if company is None: # pragma: no cover - defensive, implied by _get_owned_source
|
||||
raise NotFoundError("Company not found")
|
||||
return await collect_source(db, settings, source, company)
|
||||
Reference in New Issue
Block a user