BuyingAgent/backend/app/services/project_service.py

347 lines
16 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
from decimal import Decimal
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.clock import now_utc
from app.discovery.criteria import criterion_payload, parse_project_criteria
from app.discovery.definitions import get_definition, get_definition_by_key, upsert_definition
from app.discovery.enums import CandidateStatus, DiffStatus, ProjectStatus, RankingStatus, SearchStatus
from app.domain.serialize import jsonable
from app.domain.validation import DomainValidationError
from app.errors import AppError
from app.persistence.discovery_models import (
BuyingProjectCriteriaRecord,
BuyingProjectRecord,
DiscoverySearchRecord,
ProductCategoryRecord,
ProjectCandidateRecord,
ProjectCriterionRecord,
SearchRunItemRecord,
SearchRunRecord,
)
from app.persistence.repositories import CatalogRepository
from app.services.catalog_service import CatalogService, ensure_product_category
INACTIVE_SEARCH = {SearchStatus.BLOCKED.value, SearchStatus.DISABLED.value, SearchStatus.PAUSED.value}
def _uuid(value: UUID | None) -> UUID:
return value or uuid4()
class ProjectService:
def __init__(self, session: Session) -> None:
self.session = session
self.catalog = CatalogRepository(session)
self.categories = CatalogService(session)
def create(self, payload: dict[str, Any]) -> dict[str, Any]:
template_id = payload.get("template_id") or payload.get("profile_id")
template_version = payload.get("template_version") or payload.get("profile_version")
profile = None
if template_id is not None:
if template_version is None:
raise AppError(422, "VALIDATION_ERROR", "template_version is required when template_id is set")
profile = self.catalog.get_profile(str(template_id), int(template_version))
if profile is None:
raise AppError(404, "NOT_FOUND", "template version not found")
product_category_id = payload.get("product_category_id") or (None if profile is None else profile.category)
if not product_category_id:
raise AppError(422, "VALIDATION_ERROR", "product_category_id is required")
if self.session.get(ProductCategoryRecord, product_category_id) is None:
ensure_product_category(self.session, str(product_category_id))
market = payload.get("market") or (None if profile is None else profile.market)
currency = payload.get("currency") or (None if profile is None else profile.currency)
if not market or not currency:
raise AppError(422, "VALIDATION_ERROR", "market and currency are required")
target_budget = payload.get("target_budget")
if target_budget is None and profile is not None and getattr(profile, "budget", None) is not None:
target_budget = getattr(profile.budget, "target", None)
criteria_payload = payload.get("criteria")
if not criteria_payload:
raise AppError(422, "CRITERIA_REQUIRED", "criteria are required")
try:
parsed = parse_project_criteria(criteria_payload)
except DomainValidationError as exc:
raise AppError(422, "CRITERIA_INVALID", str(exc), exc.details) from exc
shortlist_size = int(payload.get("shortlist_size") or 5)
if shortlist_size < 1:
raise AppError(422, "VALIDATION_ERROR", "shortlist_size must be > 0")
now = now_utc()
project = BuyingProjectRecord(
id=_uuid(payload.get("id")),
name=str(payload["name"]),
status=ProjectStatus.ACTIVE.value,
workspace_id=payload.get("workspace_id"),
product_category_id=str(product_category_id),
market=str(market),
currency=str(currency),
target_budget=None if target_budget is None else Decimal(str(target_budget)),
template_id=None if profile is None else profile.id,
template_version=None if profile is None else profile.version,
shortlist_size=shortlist_size,
criteria_version=1,
created_at=now,
updated_at=now,
archived_at=None,
)
self.session.add(project)
self.session.flush()
self._store_criteria(project.id, 1, parsed, str(product_category_id), now)
return self.get(project.id)
def replace_criteria(self, project_id: UUID, payload: dict[str, Any]) -> dict[str, Any]:
project = self._require_project(project_id)
try:
parsed = parse_project_criteria(payload)
except DomainValidationError as exc:
raise AppError(422, "CRITERIA_INVALID", str(exc), exc.details) from exc
current = self.latest_criteria(project_id)
version = (current.version if current is not None else 0) + 1
now = now_utc()
self._store_criteria(project.id, version, parsed, project.product_category_id, now)
project.criteria_version = version
project.updated_at = now
for search in self.session.scalars(
select(DiscoverySearchRecord).where(DiscoverySearchRecord.buying_project_id == project.id)
):
if search.status not in INACTIVE_SEARCH:
search.status = SearchStatus.OUT_OF_SYNC.value
search.updated_at = now
self.session.flush()
return self.get(project.id)
def add_criteria(self, project_id: UUID, payload: dict[str, Any]) -> dict[str, Any]:
return self.replace_criteria(project_id, payload)
def patch(self, project_id: UUID, payload: dict[str, Any]) -> dict[str, Any]:
project = self._require_project(project_id)
allowed = {"name", "target_budget", "shortlist_size", "status"}
unknown = set(payload) - allowed
if unknown:
raise AppError(422, "VALIDATION_ERROR", "field cannot be patched", {"fields": sorted(unknown)})
if "name" in payload:
project.name = str(payload["name"])
if "target_budget" in payload:
project.target_budget = None if payload["target_budget"] is None else Decimal(str(payload["target_budget"]))
if "shortlist_size" in payload:
size = int(payload["shortlist_size"])
if size < 1:
raise AppError(422, "VALIDATION_ERROR", "shortlist_size must be > 0")
project.shortlist_size = size
if "status" in payload:
status = str(payload["status"])
if status not in {item.value for item in ProjectStatus}:
raise AppError(422, "VALIDATION_ERROR", "invalid project status")
project.status = status
if status == ProjectStatus.ARCHIVED.value:
project.archived_at = now_utc()
project.updated_at = now_utc()
self.session.flush()
return self.get(project.id)
def get(self, project_id: UUID) -> dict[str, Any]:
project = self._require_project(project_id)
criteria = self.latest_criteria(project_id)
return self._out(project, criteria)
def get_criteria(self, project_id: UUID) -> dict[str, Any]:
project = self._require_project(project_id)
criteria = self.latest_criteria(project_id)
items = [] if criteria is None else self.criteria_items(project.id, criteria.version)
return jsonable(
{
"project_id": project.id,
"criteria_version": None if criteria is None else criteria.version,
"items": items,
}
)
def list(self, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]:
total = self.session.scalar(select(func.count()).select_from(BuyingProjectRecord)) or 0
rows = self.session.scalars(
select(BuyingProjectRecord).order_by(BuyingProjectRecord.created_at).limit(limit).offset(offset)
).all()
return [self._summary(row) for row in rows], total
def latest_criteria(self, project_id: UUID) -> BuyingProjectCriteriaRecord | None:
return self.session.scalars(
select(BuyingProjectCriteriaRecord)
.where(BuyingProjectCriteriaRecord.project_id == project_id)
.order_by(BuyingProjectCriteriaRecord.version.desc())
).first()
def criteria_version(self, project_id: UUID, version: int) -> BuyingProjectCriteriaRecord | None:
return self.session.get(BuyingProjectCriteriaRecord, (project_id, version))
def criteria_items(self, project_id: UUID, version: int) -> list[dict[str, Any]]:
rows = self.session.scalars(
select(ProjectCriterionRecord)
.where(ProjectCriterionRecord.project_id == project_id, ProjectCriterionRecord.version == version)
.order_by(ProjectCriterionRecord.item_id)
).all()
return [criterion_payload(row) for row in rows]
def available_criteria(self, project_id: UUID) -> dict[str, Any]:
project = self._require_project(project_id)
sources = self.categories.sources_for_category(project.product_category_id)
source = sources[0]["source"] if sources else "geizhals"
return self.categories.available_criteria(project.product_category_id, source)
def _store_criteria(
self,
project_id: UUID,
version: int,
parsed: dict[str, Any],
category: str | None,
created_at,
) -> None:
self.session.add(
BuyingProjectCriteriaRecord(
project_id=project_id,
version=version,
definition_hash=parsed["definition_hash"],
created_at=created_at,
)
)
self.session.flush()
for item in parsed["items"]:
definition = self._resolve_definition(item, category)
self.session.add(
ProjectCriterionRecord(
id=uuid4(),
project_id=project_id,
version=version,
item_id=item["id"],
criterion_definition_id=definition.id,
criterion_key=definition.key,
operator=item["operator"],
value=item["value"],
enabled_for_discovery=item["enabled_for_discovery"],
created_at=created_at,
)
)
self.session.flush()
def _resolve_definition(self, item: dict[str, Any], category: str | None):
if item.get("criterion_definition_id") is not None:
definition = get_definition(self.session, item["criterion_definition_id"])
if definition is None:
raise AppError(422, "UNKNOWN_CRITERION", "criterion definition not found")
return definition
existing = get_definition_by_key(self.session, str(item["criterion"]))
if existing is not None:
return existing
if not item.get("value_type"):
raise AppError(
422,
"UNKNOWN_CRITERION",
"criterion definition missing; provide value_type or create it first",
{"criterion": item["criterion"]},
)
return upsert_definition(
self.session,
{
"key": item["criterion"],
"origin": "INTERNAL",
"product_category_id": item.get("category") or category,
"label": item["criterion"],
"value_type": item["value_type"],
"unit": item.get("unit"),
"semantic": item.get("semantic"),
"category": item.get("category") or category,
"scoring_attribute_key": item["criterion"],
},
)
def _require_project(self, project_id: UUID) -> BuyingProjectRecord:
project = self.session.get(BuyingProjectRecord, project_id)
if project is None:
raise AppError(404, "NOT_FOUND", "buying project not found")
return project
def _out(self, project: BuyingProjectRecord, criteria: BuyingProjectCriteriaRecord | None) -> dict[str, Any]:
items = [] if criteria is None else self.criteria_items(project.id, criteria.version)
return jsonable(
{
"id": project.id,
"name": project.name,
"status": project.status,
"workspace_id": project.workspace_id,
"product_category_id": project.product_category_id,
"market": project.market,
"currency": project.currency,
"target_budget": project.target_budget,
"template_id": project.template_id,
"template_version": project.template_version,
"profile_id": project.template_id,
"profile_version": project.template_version,
"shortlist_size": project.shortlist_size,
"created_at": project.created_at,
"updated_at": project.updated_at,
"archived_at": project.archived_at,
"criteria_version": project.criteria_version if criteria is None else criteria.version,
"criteria": None if criteria is None else {"items": items},
"criteria_hash": None if criteria is None else criteria.definition_hash,
"ranking_status": RankingStatus.NOT_CONFIGURED.value
if project.template_id is None
else RankingStatus.CONFIGURED.value,
}
)
def _summary(self, project: BuyingProjectRecord) -> dict[str, Any]:
category = self.session.get(ProductCategoryRecord, project.product_category_id)
searches = list(
self.session.scalars(
select(DiscoverySearchRecord).where(DiscoverySearchRecord.buying_project_id == project.id)
)
)
active = next((row for row in searches if row.status not in INACTIVE_SEARCH), searches[0] if searches else None)
last_run = None
if active is not None:
last_run = self.session.scalars(
select(SearchRunRecord)
.where(SearchRunRecord.discovery_search_id == active.id)
.order_by(SearchRunRecord.started_at.desc())
).first()
candidates = list(
self.session.scalars(
select(ProjectCandidateRecord).where(ProjectCandidateRecord.buying_project_id == project.id)
)
)
counts = {"NEW": 0, "CHANGED": 0, "MISSING": 0}
if last_run is not None:
for item in self.session.scalars(
select(SearchRunItemRecord).where(SearchRunItemRecord.search_run_id == last_run.id)
):
if item.diff_status in counts:
counts[item.diff_status] += 1
return jsonable(
{
"id": project.id,
"name": project.name,
"product_category": None
if category is None
else {"id": category.id, "name": category.name, "status": category.status},
"status": project.status,
"last_successful_run_at": None if active is None else active.last_successful_run_at,
"next_run_at": None if active is None else active.next_run_at,
"discovery_status": None if active is None else active.status,
"candidate_count": len(candidates),
"shortlist_count": sum(1 for row in candidates if row.status == CandidateStatus.SHORTLISTED.value),
"new_count": last_run.new_count if last_run is not None else counts[DiffStatus.NEW.value],
"changed_count": last_run.changed_count if last_run is not None else counts[DiffStatus.CHANGED.value],
"missing_count": last_run.missing_count if last_run is not None else counts[DiffStatus.MISSING.value],
"current_search_result_count": None if last_run is None else last_run.result_count,
"ranking_status": RankingStatus.NOT_CONFIGURED.value
if project.template_id is None
else RankingStatus.CONFIGURED.value,
}
)