"""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)