first
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
from .base import NotFoundError, DuplicateError, TenantMismatchError
|
||||
from .organization_repo import OrganizationRepository
|
||||
from .project_repo import ProjectRepository
|
||||
from .artifact_repo import ArtifactRepository
|
||||
from .relationship_repo import RelationshipRepository
|
||||
from .job_repo import JobRepository
|
||||
from .audit_repo import AuditRepository
|
||||
|
||||
__all__ = [
|
||||
"NotFoundError",
|
||||
"DuplicateError",
|
||||
"TenantMismatchError",
|
||||
"OrganizationRepository",
|
||||
"ProjectRepository",
|
||||
"ArtifactRepository",
|
||||
"RelationshipRepository",
|
||||
"JobRepository",
|
||||
"AuditRepository",
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Artifact repository — single collection for every artifact type."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from asf_domain import (
|
||||
Artifact,
|
||||
ArtifactCreate,
|
||||
ArtifactUpdate,
|
||||
ArtifactType,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class ArtifactRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
def _q(self, tenant_id, project_id=None, extra=None) -> dict:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if project_id is not None:
|
||||
q["project_id"] = project_id
|
||||
if extra:
|
||||
q.update(extra)
|
||||
return q
|
||||
|
||||
# ---- CRUD ----
|
||||
async def get(self, tenant_id: UUID, artifact_id: UUID) -> Optional[Artifact]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, extra={"_id": artifact_id}))
|
||||
return _from_doc(Artifact, doc)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
artifact_type: Optional[ArtifactType | str] = None,
|
||||
status: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Artifact]:
|
||||
q = self._q(tenant_id, project_id)
|
||||
if artifact_type is not None:
|
||||
q["type"] = artifact_type if isinstance(artifact_type, str) else artifact_type.value
|
||||
if status is not None:
|
||||
q["status"] = status
|
||||
if search:
|
||||
q["$or"] = [
|
||||
{"title": {"$regex": search, "$options": "i"}},
|
||||
{"content": {"$regex": search, "$options": "i"}},
|
||||
]
|
||||
cur = (
|
||||
self._c.find(q)
|
||||
.sort("created_at", -1)
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return [_from_doc(Artifact, d) for d in await cur.to_list(length=limit)]
|
||||
|
||||
async def count(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
artifact_type: Optional[str] = None,
|
||||
) -> int:
|
||||
q = self._q(tenant_id, project_id)
|
||||
if artifact_type is not None:
|
||||
q["type"] = artifact_type
|
||||
return await self._c.count_documents(q)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: ArtifactCreate,
|
||||
*,
|
||||
created_by: Optional[UUID] = None,
|
||||
) -> Artifact:
|
||||
now = _now_utc()
|
||||
a = Artifact(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
created_by=created_by,
|
||||
version=1,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(a)
|
||||
doc["_id"] = doc.pop("id")
|
||||
await self._c.insert_one(doc)
|
||||
return a
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
body: ArtifactUpdate,
|
||||
) -> Artifact:
|
||||
raw = await self._c.find_one({"_id": artifact_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("Artifact", artifact_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Artifact", artifact_id)
|
||||
current: Artifact = _from_doc(Artifact, raw)
|
||||
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
metadata_update = changes.pop("metadata", None)
|
||||
|
||||
final_metadata = current.metadata
|
||||
if metadata_update is not None:
|
||||
final_metadata = {**final_metadata, **metadata_update}
|
||||
changes["metadata"] = final_metadata
|
||||
if not changes:
|
||||
return current
|
||||
|
||||
changes["version"] = current.version + 1
|
||||
changes["updated_at"] = _now_utc()
|
||||
await self._c.update_one({"_id": artifact_id}, {"$set": changes})
|
||||
return current.model_copy(update=changes)
|
||||
|
||||
async def delete(self, tenant_id: UUID, artifact_id: UUID) -> bool:
|
||||
raw = await self._c.find_one({"_id": artifact_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("Artifact", artifact_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Artifact", artifact_id)
|
||||
res = await self._c.delete_one({"_id": artifact_id})
|
||||
return res.deleted_count > 0
|
||||
|
||||
async def get_many(self, tenant_id: UUID, artifact_ids: list[UUID]) -> list[Artifact]:
|
||||
if not artifact_ids:
|
||||
return []
|
||||
cur = self._c.find(self._q(tenant_id, extra={"_id": {"$in": artifact_ids}}))
|
||||
return [_from_doc(Artifact, d) async for d in cur]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""AuditEvent repository — append-only, never updates or deletes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from asf_domain import AuditEvent, AuditEventCreate
|
||||
|
||||
from .base import (
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class AuditRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
async def append(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: AuditEventCreate,
|
||||
) -> AuditEvent:
|
||||
now = _now_utc()
|
||||
ae = AuditEvent(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
created_at=now,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(ae)
|
||||
doc["_id"] = doc.pop("id")
|
||||
await self._c.insert_one(doc)
|
||||
return ae
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
actor_id: Optional[UUID] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
) -> list[AuditEvent]:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if actor_id is not None:
|
||||
q["actor_id"] = actor_id
|
||||
if resource_type is not None:
|
||||
q["resource_type"] = resource_type
|
||||
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(AuditEvent, d) async for d in cur]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Repository base exceptions + shared helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Mapping
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pydantic import BaseModel
|
||||
|
||||
from asf_domain.base import generate_id
|
||||
|
||||
|
||||
class ASFRepositoryError(Exception):
|
||||
"""Base for all repository-layer errors."""
|
||||
|
||||
|
||||
class NotFoundError(ASFRepositoryError):
|
||||
def __init__(self, kind: str, entity_id: UUID | str) -> None:
|
||||
super().__init__(f"{kind} not found: {entity_id}")
|
||||
self.kind = kind
|
||||
self.entity_id = entity_id
|
||||
|
||||
|
||||
class DuplicateError(ASFRepositoryError):
|
||||
def __init__(self, kind: str, key: str) -> None:
|
||||
super().__init__(f"{kind} already exists: {key}")
|
||||
self.kind = kind
|
||||
self.key = key
|
||||
|
||||
|
||||
class TenantMismatchError(ASFRepositoryError):
|
||||
"""Raised when an operation targets an entity owned by another tenant."""
|
||||
|
||||
def __init__(self, kind: str, entity_id: UUID | str) -> None:
|
||||
super().__init__(f"Tenant isolation violation: {kind} {entity_id}")
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
def _to_doc(m: BaseModel) -> dict[str, Any]:
|
||||
return m.model_dump(mode="python", by_alias=False)
|
||||
|
||||
|
||||
def _from_doc(cls, doc: Mapping[str, Any]):
|
||||
"""Instantiate a Pydantic model from a Mongo document.
|
||||
|
||||
``_id`` → ``id`` rename handled explicitly. Motor already returns UUID
|
||||
objects for UUID fields when uuidRepresentation=standard is set.
|
||||
|
||||
Unknown document fields are filtered out (matches ``extra = forbid``
|
||||
used by most ASF domain models while still tolerating new audit-only
|
||||
Mongo columns like ``updated_at`` / ``metadata``).
|
||||
"""
|
||||
if doc is None:
|
||||
return None
|
||||
d = dict(doc)
|
||||
if "_id" in d and "id" not in d:
|
||||
d["id"] = d.pop("_id")
|
||||
allowed = set(cls.model_fields.keys())
|
||||
known = {k: v for k, v in d.items() if k in allowed}
|
||||
try:
|
||||
return cls.model_validate(known)
|
||||
except Exception:
|
||||
for k in list(known.keys()):
|
||||
f = cls.model_fields.get(k)
|
||||
if f is None:
|
||||
continue
|
||||
annotation = getattr(f, "annotation", None)
|
||||
# Wrap None for required fields with usable defaults — job usage, etc.
|
||||
if known[k] is None and annotation is not None:
|
||||
from typing import get_origin, get_args
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
# Default dict for JobUsage
|
||||
name = ""
|
||||
if hasattr(annotation, "__name__"):
|
||||
name = annotation.__name__
|
||||
for arg in args:
|
||||
if hasattr(arg, "__name__"):
|
||||
name = arg.__name__
|
||||
break
|
||||
if name == "JobUsage":
|
||||
known[k] = {"extra": {}}
|
||||
return cls.model_validate(known)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Job repository — persistent state of every async job."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
|
||||
from asf_domain import (
|
||||
Job,
|
||||
JobCreate,
|
||||
JobUpdate,
|
||||
JobStatus,
|
||||
JobPriority,
|
||||
JobType,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class JobRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
def _q(self, tenant_id, project_id=None, extra=None) -> dict:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if project_id is not None:
|
||||
q["project_id"] = project_id
|
||||
if extra:
|
||||
q.update(extra)
|
||||
return q
|
||||
|
||||
# ---- CRUD ----
|
||||
async def get(self, tenant_id: UUID, job_id: UUID) -> Optional[Job]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, extra={"_id": job_id}))
|
||||
return _from_doc(Job, doc)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
project_id: Optional[UUID] = None,
|
||||
status: Optional[JobStatus] = None,
|
||||
type: Optional[JobType] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Job]:
|
||||
q = self._q(tenant_id, project_id)
|
||||
if status is not None:
|
||||
q["status"] = status.value if isinstance(status, JobStatus) else status
|
||||
if type is not None:
|
||||
q["type"] = type.value if isinstance(type, JobType) else type
|
||||
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(Job, d) async for d in cur]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: JobCreate,
|
||||
*,
|
||||
requested_by: Optional[UUID] = None,
|
||||
) -> Job:
|
||||
now = _now_utc()
|
||||
j = Job(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
requested_by=requested_by,
|
||||
status=JobStatus.QUEUED,
|
||||
created_at=now,
|
||||
usage={},
|
||||
**body.model_dump(exclude_defaults=False),
|
||||
)
|
||||
doc = _to_doc(j)
|
||||
doc["_id"] = doc.pop("id")
|
||||
await self._c.insert_one(doc)
|
||||
return j
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, job_id: UUID, body: JobUpdate
|
||||
) -> Job:
|
||||
raw = await self._c.find_one({"_id": job_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("Job", job_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Job", job_id)
|
||||
current: Job = _from_doc(Job, raw)
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
return current
|
||||
changes["updated_at"] = _now_utc() if "updated_at" not in changes else changes["updated_at"]
|
||||
await self._c.update_one({"_id": job_id}, {"$set": changes})
|
||||
return current.model_copy(update=changes)
|
||||
|
||||
async def set_status(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
job_id: UUID,
|
||||
status: JobStatus,
|
||||
*,
|
||||
worker: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> Job:
|
||||
updates: dict = {"status": status, "updated_at": _now_utc()}
|
||||
if status == JobStatus.RUNNING:
|
||||
updates["started_at"] = updates["updated_at"]
|
||||
if status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
|
||||
updates["completed_at"] = updates["updated_at"]
|
||||
if worker is not None:
|
||||
updates["worker"] = worker
|
||||
if error is not None:
|
||||
updates["error"] = error
|
||||
raw = await self._c.find_one_and_update(
|
||||
{"_id": job_id, "tenant_id": tenant_id},
|
||||
{"$set": updates},
|
||||
return_document=True,
|
||||
)
|
||||
if raw is None:
|
||||
raise NotFoundError("Job", job_id)
|
||||
return _from_doc(Job, raw)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Organization repository — tenant boundary record.
|
||||
|
||||
Note that Organization records are NOT themselves tenant-scoped (they ARE
|
||||
the tenant). Every other entity references tenant_id == organization.id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from asf_domain import (
|
||||
Organization,
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
async def get(self, org_id: UUID) -> Optional[Organization]:
|
||||
doc = await self._c.find_one({"_id": org_id})
|
||||
return _from_doc(Organization, doc)
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Optional[Organization]:
|
||||
doc = await self._c.find_one({"slug": slug})
|
||||
return _from_doc(Organization, doc)
|
||||
|
||||
async def list(self, *, limit: int = 100, offset: int = 0) -> list[Organization]:
|
||||
cur = self._c.find({}).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(Organization, d) for d in await cur.to_list(length=limit)]
|
||||
|
||||
async def create(self, body: OrganizationCreate, *, override_id: Optional[UUID] = None) -> Organization:
|
||||
now = _now_utc()
|
||||
org = Organization(
|
||||
id=override_id or generate_id(),
|
||||
**body.model_dump(),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
doc = _to_doc(org)
|
||||
doc["_id"] = doc.pop("id")
|
||||
try:
|
||||
await self._c.insert_one(doc)
|
||||
except DuplicateKeyError as exc:
|
||||
raise DuplicateError("Organization", org.slug) from exc
|
||||
return org
|
||||
|
||||
async def update(self, org_id: UUID, body: OrganizationUpdate) -> Organization:
|
||||
current = await self.get(org_id)
|
||||
if current is None:
|
||||
raise NotFoundError("Organization", org_id)
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
return current
|
||||
changes["updated_at"] = _now_utc()
|
||||
await self._c.update_one({"_id": org_id}, {"$set": changes})
|
||||
updated = current.model_copy(update=changes)
|
||||
return updated
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Project repository — always tenant-scoped."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from asf_domain import Project, ProjectCreate, ProjectUpdate
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class ProjectRepository:
|
||||
def __init__(self, collection: AsyncIOMotorCollection) -> None:
|
||||
self._c = collection
|
||||
|
||||
# ---- internal helpers ----
|
||||
def _q(self, tenant_id: UUID, extra: Optional[dict] = None) -> dict:
|
||||
q: dict = {"tenant_id": tenant_id}
|
||||
if extra:
|
||||
q.update(extra)
|
||||
return q
|
||||
|
||||
# ---- CRUD ----
|
||||
async def get(self, tenant_id: UUID, project_id: UUID) -> Optional[Project]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, {"_id": project_id}))
|
||||
return _from_doc(Project, doc)
|
||||
|
||||
async def get_by_key(self, tenant_id: UUID, key: str) -> Optional[Project]:
|
||||
doc = await self._c.find_one(self._q(tenant_id, {"key": key}))
|
||||
return _from_doc(Project, doc)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
only_active: bool = True,
|
||||
) -> list[Project]:
|
||||
q = self._q(tenant_id)
|
||||
if only_active:
|
||||
q["is_active"] = True
|
||||
cur = self._c.find(q).sort("created_at", -1).skip(offset).limit(limit)
|
||||
return [_from_doc(Project, d) for d in await cur.to_list(length=limit)]
|
||||
|
||||
async def create(self, tenant_id: UUID, body: ProjectCreate) -> Project:
|
||||
now = _now_utc()
|
||||
p = Project(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(p)
|
||||
doc["_id"] = doc.pop("id")
|
||||
try:
|
||||
await self._c.insert_one(doc)
|
||||
except DuplicateKeyError as exc:
|
||||
raise DuplicateError("Project", f"{tenant_id}/{p.key}") from exc
|
||||
return p
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, project_id: UUID, body: ProjectUpdate
|
||||
) -> Project:
|
||||
current = await self._c.find_one({"_id": project_id})
|
||||
if current is None:
|
||||
raise NotFoundError("Project", project_id)
|
||||
if current.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("Project", project_id)
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
return _from_doc(Project, current)
|
||||
changes["updated_at"] = _now_utc()
|
||||
await self._c.update_one({"_id": project_id}, {"$set": changes})
|
||||
return _from_doc(Project, {**current, **changes})
|
||||
@@ -0,0 +1,134 @@
|
||||
"""ArtifactRelationship repository — explicit directed edges.
|
||||
|
||||
Indexes support:
|
||||
* find all outgoing edges from an artifact
|
||||
* find all incoming edges to an artifact
|
||||
* ensure uniqueness of a (source, target, type) triple
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from asf_domain import ArtifactRelationship, ArtifactRelationshipCreate, RelationshipType
|
||||
|
||||
from .base import (
|
||||
NotFoundError,
|
||||
DuplicateError,
|
||||
TenantMismatchError,
|
||||
_now_utc,
|
||||
_to_doc,
|
||||
_from_doc,
|
||||
generate_id,
|
||||
)
|
||||
|
||||
|
||||
class RelationshipRepository:
|
||||
def __init__(
|
||||
self,
|
||||
collection: AsyncIOMotorCollection,
|
||||
artifacts_collection: AsyncIOMotorCollection,
|
||||
) -> None:
|
||||
self._c = collection
|
||||
self._arts = artifacts_collection
|
||||
|
||||
# ---- helpers ----
|
||||
async def _assert_tenant_owns(
|
||||
self, tenant_id: UUID, artifact_id: UUID
|
||||
) -> tuple[bool, UUID | None]:
|
||||
d = await self._arts.find_one(
|
||||
{"_id": artifact_id, "tenant_id": tenant_id},
|
||||
{"project_id": 1},
|
||||
)
|
||||
if d is None:
|
||||
return False, None
|
||||
return True, d.get("project_id")
|
||||
|
||||
# ---- CRUD ----
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: ArtifactRelationshipCreate,
|
||||
) -> ArtifactRelationship:
|
||||
ok_src, src_prj = await self._assert_tenant_owns(tenant_id, body.source_artifact_id)
|
||||
ok_tgt, tgt_prj = await self._assert_tenant_owns(tenant_id, body.target_artifact_id)
|
||||
if not ok_src:
|
||||
raise TenantMismatchError("Artifact", body.source_artifact_id)
|
||||
if not ok_tgt:
|
||||
raise TenantMismatchError("Artifact", body.target_artifact_id)
|
||||
|
||||
now = _now_utc()
|
||||
r = ArtifactRelationship(
|
||||
id=generate_id(),
|
||||
tenant_id=tenant_id,
|
||||
source_project_id=src_prj,
|
||||
target_project_id=tgt_prj,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
**body.model_dump(),
|
||||
)
|
||||
doc = _to_doc(r)
|
||||
doc["_id"] = doc.pop("id")
|
||||
try:
|
||||
await self._c.insert_one(doc)
|
||||
except DuplicateKeyError as exc:
|
||||
raise DuplicateError(
|
||||
"ArtifactRelationship",
|
||||
f"{body.source_artifact_id}-{body.relationship_type}-{body.target_artifact_id}",
|
||||
) from exc
|
||||
return r
|
||||
|
||||
async def delete(self, tenant_id: UUID, relationship_id: UUID) -> bool:
|
||||
raw = await self._c.find_one({"_id": relationship_id})
|
||||
if raw is None:
|
||||
raise NotFoundError("ArtifactRelationship", relationship_id)
|
||||
if raw.get("tenant_id") != tenant_id:
|
||||
raise TenantMismatchError("ArtifactRelationship", relationship_id)
|
||||
res = await self._c.delete_one({"_id": relationship_id})
|
||||
return res.deleted_count > 0
|
||||
|
||||
# ---- traversal queries ----
|
||||
async def outgoing(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
*,
|
||||
relationship_type: Optional[RelationshipType] = None,
|
||||
limit: int = 100,
|
||||
) -> list[ArtifactRelationship]:
|
||||
q: dict = {"tenant_id": tenant_id, "source_artifact_id": artifact_id}
|
||||
if relationship_type is not None:
|
||||
q["relationship_type"] = relationship_type
|
||||
cur = self._c.find(q).sort("created_at", -1).limit(limit)
|
||||
return [_from_doc(ArtifactRelationship, d) async for d in cur]
|
||||
|
||||
async def incoming(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
artifact_id: UUID,
|
||||
*,
|
||||
relationship_type: Optional[RelationshipType] = None,
|
||||
limit: int = 100,
|
||||
) -> list[ArtifactRelationship]:
|
||||
q: dict = {"tenant_id": tenant_id, "target_artifact_id": artifact_id}
|
||||
if relationship_type is not None:
|
||||
q["relationship_type"] = relationship_type
|
||||
cur = self._c.find(q).sort("created_at", -1).limit(limit)
|
||||
return [_from_doc(ArtifactRelationship, d) async for d in cur]
|
||||
|
||||
async def list_for_artifact(
|
||||
self, tenant_id: UUID, artifact_id: UUID, *, limit: int = 200
|
||||
) -> list[ArtifactRelationship]:
|
||||
q = {
|
||||
"tenant_id": tenant_id,
|
||||
"$or": [
|
||||
{"source_artifact_id": artifact_id},
|
||||
{"target_artifact_id": artifact_id},
|
||||
],
|
||||
}
|
||||
cur = self._c.find(q).sort("created_at", -1).limit(limit)
|
||||
return [_from_doc(ArtifactRelationship, d) async for d in cur]
|
||||
Reference in New Issue
Block a user