T11: tags plugin + permissions plugin + entity links backend — 68 tests, 66.61% coverage

This commit is contained in:
leocrm-bot
2026-06-29 14:01:24 +02:00
parent 700b7a71ad
commit 5d1850768a
26 changed files with 2863 additions and 7 deletions
+9
View File
@@ -2,4 +2,13 @@
Each module in this package that exports a BasePlugin subclass will be
discovered automatically by the plugin registry on application startup.
Subdirectory plugins (tags, permissions, entity_links) export their plugin
class via __init__.py so the registry can discover them as packages.
"""
from app.plugins.builtins.tags import TagsPlugin
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.builtins.entity_links import EntityLinksPlugin
__all__ = ["TagsPlugin", "PermissionsPlugin", "EntityLinksPlugin"]
@@ -0,0 +1,5 @@
"""Entity Links builtin plugin."""
from app.plugins.builtins.entity_links.plugin import EntityLinksPlugin
__all__ = ["EntityLinksPlugin"]
@@ -0,0 +1,14 @@
-- Entity Links plugin initial migration: creates entity_links table
CREATE TABLE IF NOT EXISTS entity_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
file_id UUID NOT NULL,
entity_type VARCHAR(20) NOT NULL,
entity_id UUID NOT NULL,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_entity_links_file_entity UNIQUE (tenant_id, file_id, entity_type, entity_id)
);
CREATE INDEX IF NOT EXISTS ix_entity_links_file ON entity_links(tenant_id, file_id);
CREATE INDEX IF NOT EXISTS ix_entity_links_entity ON entity_links(tenant_id, entity_type, entity_id);
@@ -0,0 +1,35 @@
"""EntityLink model for the Entity Links plugin."""
from __future__ import annotations
import uuid
from sqlalchemy import String, ForeignKey, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class EntityLink(Base, TenantMixin):
"""N:M link between files/folders and companies/contacts."""
__tablename__ = "entity_links"
__table_args__ = (
UniqueConstraint(
"tenant_id", "file_id", "entity_type", "entity_id",
name="uq_entity_links_file_entity",
),
Index("ix_entity_links_file", "tenant_id", "file_id"),
Index("ix_entity_links_entity", "tenant_id", "entity_type", "entity_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
entity_type: Mapped[str] = mapped_column(String(20), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
@@ -0,0 +1,86 @@
"""Entity Links plugin — link files to companies/contacts, reverse links, event cleanup."""
from __future__ import annotations
from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class EntityLinksPlugin(BasePlugin):
"""Entity Links plugin for N:M file-to-entity links with event-driven cleanup."""
manifest = PluginManifest(
name="entity_links",
version="1.0.0",
display_name="Entity Links",
description="Link files to companies/contacts (N:M), reverse links, event cleanup on deletion.",
dependencies=[],
routes=[
PluginRouteDef(
path="/api/v1/dms",
module="app.plugins.builtins.entity_links.routes",
router_attr="router",
),
PluginRouteDef(
path="/api/v1/companies",
module="app.plugins.builtins.entity_links.routes",
router_attr="company_router",
),
PluginRouteDef(
path="/api/v1/contacts",
module="app.plugins.builtins.entity_links.routes",
router_attr="contact_router",
),
],
events=["company.deleted", "contact.deleted"],
migrations=["0001_initial.sql"],
permissions=[],
)
async def on_company_deleted(self, payload: dict[str, Any]) -> None:
"""Handle company.deleted event — remove all EntityLink rows for that company."""
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.plugins.builtins.entity_links.models import EntityLink
entity_id = payload.get("entity_id") or payload.get("company_id")
tenant_id = payload.get("tenant_id")
if entity_id is None or tenant_id is None:
return
import uuid as _uuid
factory = get_session_factory()
async with factory() as session:
await session.execute(
delete(EntityLink).where(
EntityLink.tenant_id == _uuid.UUID(tenant_id),
EntityLink.entity_type == "company",
EntityLink.entity_id == _uuid.UUID(str(entity_id)),
)
)
await session.commit()
async def on_contact_deleted(self, payload: dict[str, Any]) -> None:
"""Handle contact.deleted event — remove all EntityLink rows for that contact."""
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.plugins.builtins.entity_links.models import EntityLink
entity_id = payload.get("entity_id") or payload.get("contact_id")
tenant_id = payload.get("tenant_id")
if entity_id is None or tenant_id is None:
return
import uuid as _uuid
factory = get_session_factory()
async with factory() as session:
await session.execute(
delete(EntityLink).where(
EntityLink.tenant_id == _uuid.UUID(tenant_id),
EntityLink.entity_type == "contact",
EntityLink.entity_id == _uuid.UUID(str(entity_id)),
)
)
await session.commit()
+194
View File
@@ -0,0 +1,194 @@
"""Entity Links plugin routes — link files to companies/contacts, reverse links."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user
from app.plugins.builtins.entity_links.models import EntityLink
from app.plugins.builtins.entity_links.schemas import EntityLinkRequest
router = APIRouter(prefix="/api/v1/dms", tags=["entity-links"])
company_router = APIRouter(prefix="/api/v1/companies", tags=["entity-links"])
contact_router = APIRouter(prefix="/api/v1/contacts", tags=["entity-links"])
VALID_ENTITY_TYPES = {"company", "contact"}
def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
@router.post("/files/{file_id}/link")
async def link_file_to_entity(
file_id: str,
body: EntityLinkRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Link a file to a company or contact."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
fid = _parse_uuid(file_id, "file_id")
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
# Check if link already exists
existing = await db.execute(
select(EntityLink).where(
EntityLink.tenant_id == tenant_id,
EntityLink.file_id == fid,
EntityLink.entity_type == body.entity_type,
EntityLink.entity_id == entity_id,
)
)
existing_link = existing.scalar_one_or_none()
if existing_link is not None:
return {
"id": str(existing_link.id),
"file_id": str(fid),
"entity_type": body.entity_type,
"entity_id": str(entity_id),
"already_linked": True,
}
link = EntityLink(
tenant_id=tenant_id,
file_id=fid,
entity_type=body.entity_type,
entity_id=entity_id,
created_by=user_id,
)
db.add(link)
await db.flush()
return {
"id": str(link.id),
"file_id": str(link.file_id),
"entity_type": link.entity_type,
"entity_id": str(link.entity_id),
"already_linked": False,
}
@router.delete("/files/{file_id}/link", status_code=status.HTTP_204_NO_CONTENT)
async def unlink_file_from_entity(
file_id: str,
body: EntityLinkRequest = Body(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Remove a link between a file and an entity."""
tenant_id = uuid.UUID(current_user["tenant_id"])
fid = _parse_uuid(file_id, "file_id")
entity_id = _parse_uuid(body.entity_id, "entity_id")
result = await db.execute(
select(EntityLink).where(
EntityLink.tenant_id == tenant_id,
EntityLink.file_id == fid,
EntityLink.entity_type == body.entity_type,
EntityLink.entity_id == entity_id,
)
)
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Link not found", "code": "not_found"})
await db.delete(link)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/files/{file_id}/links")
async def list_file_links(
file_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all entities linked to a file."""
tenant_id = uuid.UUID(current_user["tenant_id"])
fid = _parse_uuid(file_id, "file_id")
result = await db.execute(
select(EntityLink).where(
EntityLink.tenant_id == tenant_id,
EntityLink.file_id == fid,
)
)
links = result.scalars().all()
return [
{
"id": str(link.id),
"file_id": str(link.file_id),
"entity_type": link.entity_type,
"entity_id": str(link.entity_id),
}
for link in links
]
@company_router.get("/{company_id}/files")
async def list_company_files(
company_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all files linked to a company (reverse link)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
cid = _parse_uuid(company_id, "company_id")
result = await db.execute(
select(EntityLink).where(
EntityLink.tenant_id == tenant_id,
EntityLink.entity_type == "company",
EntityLink.entity_id == cid,
)
)
links = result.scalars().all()
return [
{
"id": str(link.id),
"file_id": str(link.file_id),
"entity_type": link.entity_type,
"entity_id": str(link.entity_id),
}
for link in links
]
@contact_router.get("/{contact_id}/files")
async def list_contact_files(
contact_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all files linked to a contact (reverse link)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
cid = _parse_uuid(contact_id, "contact_id")
result = await db.execute(
select(EntityLink).where(
EntityLink.tenant_id == tenant_id,
EntityLink.entity_type == "contact",
EntityLink.entity_id == cid,
)
)
links = result.scalars().all()
return [
{
"id": str(link.id),
"file_id": str(link.file_id),
"entity_type": link.entity_type,
"entity_id": str(link.entity_id),
}
for link in links
]
@@ -0,0 +1,22 @@
"""Pydantic schemas for the Entity Links plugin."""
from __future__ import annotations
from pydantic import BaseModel, Field
class EntityLinkRequest(BaseModel):
entity_type: str = Field(..., pattern="^(company|contact)$")
entity_id: str
class EntityLinkResponse(BaseModel):
id: str
file_id: str
entity_type: str
entity_id: str
class FileListResponse(BaseModel):
file_id: str
entity_type: str
@@ -0,0 +1,5 @@
"""Permissions builtin plugin."""
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
__all__ = ["PermissionsPlugin"]
@@ -0,0 +1,29 @@
-- Permissions plugin initial migration: creates permissions and share_links tables
CREATE TABLE IF NOT EXISTS permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
file_id UUID NOT NULL,
user_id UUID NOT NULL,
group_id UUID,
access_level VARCHAR(10) NOT NULL DEFAULT 'read',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_permissions_file_user_level UNIQUE (tenant_id, file_id, user_id, access_level)
);
CREATE INDEX IF NOT EXISTS ix_permissions_file ON permissions(tenant_id, file_id);
CREATE INDEX IF NOT EXISTS ix_permissions_user ON permissions(tenant_id, user_id);
CREATE TABLE IF NOT EXISTS share_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
file_id UUID NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255),
expires_at TIMESTAMPTZ,
access_level VARCHAR(10) NOT NULL DEFAULT 'download',
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_share_links_token ON share_links(token);
CREATE INDEX IF NOT EXISTS ix_share_links_file ON share_links(tenant_id, file_id);
@@ -0,0 +1,60 @@
"""Permission and ShareLink models for the Permissions plugin."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class Permission(Base, TenantMixin):
"""File/folder permission — grants access_level to a user or group."""
__tablename__ = "permissions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "file_id", "user_id", "access_level",
name="uq_permissions_file_user_level",
),
Index("ix_permissions_file", "tenant_id", "file_id"),
Index("ix_permissions_user", "tenant_id", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
group_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="read")
class ShareLink(Base, TenantMixin):
"""Public share link for a file — optional password and expiry."""
__tablename__ = "share_links"
__table_args__ = (
Index("ix_share_links_token", "token", unique=True),
Index("ix_share_links_file", "tenant_id", "file_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
file_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
token: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
access_level: Mapped[str] = mapped_column(String(10), nullable=False, default="download")
created_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
@@ -0,0 +1,37 @@
"""Permissions plugin — file/folder permissions, share links, public access."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class PermissionsPlugin(BasePlugin):
"""Permissions plugin for managing file/folder access and public share links."""
manifest = PluginManifest(
name="permissions",
version="1.0.0",
display_name="Permissions",
description="File/folder permissions, share links with password+expiry, public access.",
dependencies=[],
routes=[
PluginRouteDef(
path="/api/v1/dms",
module="app.plugins.builtins.permissions.routes",
router_attr="router",
),
PluginRouteDef(
path="/api/public",
module="app.plugins.builtins.permissions.routes",
router_attr="public_router",
),
],
events=[],
migrations=["0001_initial.sql"],
permissions=[],
)
+294
View File
@@ -0,0 +1,294 @@
"""Permissions plugin routes — file/folder permissions, share links, public access."""
from __future__ import annotations
import secrets
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import hash_password, verify_password
from app.core.db import get_db
from app.deps import get_current_user
from app.plugins.builtins.permissions.models import Permission, ShareLink
from app.plugins.builtins.permissions.schemas import (
PermissionGrantRequest,
ShareLinkCreateRequest,
ShareLinkVerifyRequest,
)
router = APIRouter(prefix="/api/v1/dms", tags=["permissions"])
public_router = APIRouter(prefix="/api/public", tags=["public-share"])
def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
# ─── Permissions CRUD ───
@router.get("/files/{file_id}/permissions")
async def list_permissions(
file_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all permissions for a file."""
tenant_id = uuid.UUID(current_user["tenant_id"])
fid = _parse_uuid(file_id, "file_id")
result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
)
)
perms = result.scalars().all()
return [
{
"id": str(p.id),
"file_id": str(p.file_id),
"user_id": str(p.user_id),
"group_id": str(p.group_id) if p.group_id else None,
"access_level": p.access_level,
}
for p in perms
]
@router.post("/files/{file_id}/permissions", status_code=status.HTTP_201_CREATED)
async def grant_permission(
file_id: str,
body: PermissionGrantRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Grant a permission on a file to a user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
fid = _parse_uuid(file_id, "file_id")
user_id = _parse_uuid(body.user_id, "user_id")
group_id = _parse_uuid(body.group_id, "group_id") if body.group_id else None
# Check if permission already exists
existing = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.user_id == user_id,
Permission.access_level == body.access_level,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Permission already exists", "code": "duplicate"})
perm = Permission(
tenant_id=tenant_id,
file_id=fid,
user_id=user_id,
group_id=group_id,
access_level=body.access_level,
)
db.add(perm)
await db.flush()
return {
"id": str(perm.id),
"file_id": str(perm.file_id),
"user_id": str(perm.user_id),
"group_id": str(perm.group_id) if perm.group_id else None,
"access_level": perm.access_level,
}
@router.delete("/files/{file_id}/permissions/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_permission(
file_id: str,
user_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Revoke all permissions for a user on a file."""
tenant_id = uuid.UUID(current_user["tenant_id"])
fid = _parse_uuid(file_id, "file_id")
uid = _parse_uuid(user_id, "user_id")
result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == fid,
Permission.user_id == uid,
)
)
perms = result.scalars().all()
if not perms:
raise HTTPException(404, detail={"detail": "Permission not found", "code": "not_found"})
for p in perms:
await db.delete(p)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ─── Permission Check Helper ───
async def check_user_file_permission(
db: AsyncSession, tenant_id: uuid.UUID, file_id: uuid.UUID, user_id: uuid.UUID, required: str = "read"
) -> bool:
"""Check if user has required permission on a file."""
result = await db.execute(
select(Permission).where(
Permission.tenant_id == tenant_id,
Permission.file_id == file_id,
Permission.user_id == user_id,
)
)
perms = result.scalars().all()
if not perms:
return False
if required == "read":
return any(p.access_level in ("read", "write") for p in perms)
if required == "write":
return any(p.access_level == "write" for p in perms)
return False
# ─── Share Links ───
@router.post("/files/{file_id}/share-link")
async def create_share_link(
file_id: str,
body: ShareLinkCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a public share link for a file."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
fid = _parse_uuid(file_id, "file_id")
token = secrets.token_urlsafe(32)
password_hash = hash_password(body.password) if body.password else None
link = ShareLink(
tenant_id=tenant_id,
file_id=fid,
token=token,
password_hash=password_hash,
expires_at=body.expires_at,
access_level=body.access_level,
created_by=user_id,
)
db.add(link)
await db.flush()
return {
"id": str(link.id),
"file_id": str(link.file_id),
"token": token,
"public_url": f"/api/public/share/{token}",
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
"access_level": link.access_level,
"has_password": password_hash is not None,
}
@router.delete("/share-links/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_share_link(
link_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Revoke (delete) a share link."""
tenant_id = uuid.UUID(current_user["tenant_id"])
lid = _parse_uuid(link_id, "link_id")
result = await db.execute(
select(ShareLink).where(ShareLink.id == lid, ShareLink.tenant_id == tenant_id)
)
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
await db.delete(link)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ─── Public Share Access (NO AUTH) ───
@public_router.get("/share/{token}")
async def public_access(
token: str,
db: AsyncSession = Depends(get_db),
):
"""Public share access — no auth required.
Returns 410 Gone if link is expired.
Returns 403 if password is required but not provided/invalid.
"""
result = await db.execute(
select(ShareLink).where(ShareLink.token == token)
)
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
# Check expiry
if link.expires_at is not None:
now = datetime.now(timezone.utc)
if now > link.expires_at:
raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"})
# Check password
if link.password_hash is not None:
# Password must be provided via query param or header — check query param
from fastapi import Request
# We need the request to get the password param
# For simplicity, return that password is required
raise HTTPException(
401,
detail={"detail": "Password required", "code": "password_required"},
)
return {
"file_id": str(link.file_id),
"access_level": link.access_level,
"has_password": False,
}
@public_router.post("/share/{token}")
async def public_access_with_password(
token: str,
body: ShareLinkVerifyRequest,
db: AsyncSession = Depends(get_db),
):
"""Public share access with password verification — no auth required."""
result = await db.execute(
select(ShareLink).where(ShareLink.token == token)
)
link = result.scalar_one_or_none()
if link is None:
raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"})
# Check expiry
if link.expires_at is not None:
now = datetime.now(timezone.utc)
if now > link.expires_at:
raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"})
# Verify password if required
if link.password_hash is not None:
if body.password is None:
raise HTTPException(401, detail={"detail": "Password required", "code": "password_required"})
if not verify_password(body.password, link.password_hash):
raise HTTPException(403, detail={"detail": "Invalid password", "code": "invalid_password"})
return {
"file_id": str(link.file_id),
"access_level": link.access_level,
"has_password": link.password_hash is not None,
}
@@ -0,0 +1,41 @@
"""Pydantic schemas for the Permissions plugin."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class PermissionGrantRequest(BaseModel):
user_id: str
group_id: str | None = None
access_level: str = Field("read", pattern="^(read|write)$")
class PermissionResponse(BaseModel):
id: str
file_id: str
user_id: str
group_id: str | None = None
access_level: str
class ShareLinkCreateRequest(BaseModel):
password: str | None = Field(None, min_length=4, max_length=100)
expires_at: datetime | None = None
access_level: str = Field("download", pattern="^(download|preview)$")
class ShareLinkResponse(BaseModel):
id: str
file_id: str
token: str
public_url: str
expires_at: datetime | None = None
access_level: str
has_password: bool
class ShareLinkVerifyRequest(BaseModel):
password: str | None = None
+5
View File
@@ -0,0 +1,5 @@
"""Tags builtin plugin."""
from app.plugins.builtins.tags.plugin import TagsPlugin
__all__ = ["TagsPlugin"]
@@ -0,0 +1,24 @@
-- Tags plugin initial migration: creates tags and tag_assignments tables
CREATE TABLE IF NOT EXISTS tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
color VARCHAR(7) NOT NULL DEFAULT '#6B7280',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_tags_tenant_name UNIQUE (tenant_id, name)
);
CREATE INDEX IF NOT EXISTS ix_tags_tenant ON tags(tenant_id);
CREATE TABLE IF NOT EXISTS tag_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
entity_type VARCHAR(20) NOT NULL,
entity_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_tag_assignments_entity UNIQUE (tenant_id, tag_id, entity_type, entity_id)
);
CREATE INDEX IF NOT EXISTS ix_tag_assignments_tag ON tag_assignments(tenant_id, tag_id);
CREATE INDEX IF NOT EXISTS ix_tag_assignments_entity ON tag_assignments(tenant_id, entity_type, entity_id);
+50
View File
@@ -0,0 +1,50 @@
"""Tag and TagAssignment models for the Tags plugin."""
from __future__ import annotations
import uuid
from sqlalchemy import String, ForeignKey, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class Tag(Base, TenantMixin):
"""Tag entity — globally managed, tenant-scoped."""
__tablename__ = "tags"
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_tags_tenant_name"),
Index("ix_tags_tenant", "tenant_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6B7280")
class TagAssignment(Base, TenantMixin):
"""N:M assignment between tags and entities (companies, contacts, files, folders)."""
__tablename__ = "tag_assignments"
__table_args__ = (
UniqueConstraint(
"tenant_id", "tag_id", "entity_type", "entity_id",
name="uq_tag_assignments_entity",
),
Index("ix_tag_assignments_tag", "tenant_id", "tag_id"),
Index("ix_tag_assignments_entity", "tenant_id", "entity_type", "entity_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tag_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("tags.id", ondelete="CASCADE"), nullable=False
)
entity_type: Mapped[str] = mapped_column(String(20), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
+28
View File
@@ -0,0 +1,28 @@
"""Tags plugin — CRUD, assign, bulk-assign, entity counts, cascade delete."""
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class TagsPlugin(BasePlugin):
"""Tags plugin for managing tags and tag assignments across entities."""
manifest = PluginManifest(
name="tags",
version="1.0.0",
display_name="Tags",
description="Tag management with CRUD, assignment, bulk-assign, and entity counts.",
dependencies=[],
routes=[
PluginRouteDef(
path="/api/v1/tags",
module="app.plugins.builtins.tags.routes",
router_attr="router",
),
],
events=[],
migrations=["0001_initial.sql"],
permissions=[],
)
+321
View File
@@ -0,0 +1,321 @@
"""Tags plugin routes — CRUD, assign, bulk-assign, entity counts."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user
from app.plugins.builtins.tags.models import Tag, TagAssignment
from app.plugins.builtins.tags.schemas import (
TagCreate,
TagUpdate,
TagAssignRequest,
TagUnassignRequest,
TagBulkAssignRequest,
)
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
VALID_ENTITY_TYPES = {"company", "contact", "file", "folder"}
def _parse_uuid(val: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(val)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
@router.get("")
async def list_tags(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all tags with entity counts."""
tenant_id = uuid.UUID(current_user["tenant_id"])
# Subquery for entity counts per tag
count_sq = (
select(
TagAssignment.tag_id,
func.count(TagAssignment.id).label("entity_count"),
)
.where(TagAssignment.tenant_id == tenant_id)
.group_by(TagAssignment.tag_id)
.subquery()
)
result = await db.execute(
select(Tag, func.coalesce(count_sq.c.entity_count, 0))
.outerjoin(count_sq, Tag.id == count_sq.c.tag_id)
.where(Tag.tenant_id == tenant_id)
.order_by(Tag.name)
)
rows = result.all()
return [
{
"id": str(tag.id),
"name": tag.name,
"color": tag.color,
"entity_count": count,
}
for tag, count in rows
]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_tag(
body: TagCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new tag."""
tenant_id = uuid.UUID(current_user["tenant_id"])
# Check name uniqueness within tenant
existing = await db.execute(
select(Tag).where(Tag.tenant_id == tenant_id, Tag.name == body.name)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Tag name already exists", "code": "duplicate"})
tag = Tag(tenant_id=tenant_id, name=body.name, color=body.color)
db.add(tag)
await db.flush()
return {
"id": str(tag.id),
"name": tag.name,
"color": tag.color,
"entity_count": 0,
}
@router.patch("/{tag_id}")
async def update_tag(
tag_id: str,
body: TagUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a tag (name and/or color)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(tag_id, "tag_id")
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
tag = result.scalar_one_or_none()
if tag is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
data = body.model_dump(exclude_unset=True)
if "name" in data:
# Check uniqueness if name is changing
if data["name"] != tag.name:
dup = await db.execute(
select(Tag).where(Tag.tenant_id == tenant_id, Tag.name == data["name"])
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Tag name already exists", "code": "duplicate"})
tag.name = data["name"]
if "color" in data:
tag.color = data["color"]
await db.flush()
return {
"id": str(tag.id),
"name": tag.name,
"color": tag.color,
"entity_count": 0,
}
@router.post("/assign")
async def assign_tag(
body: TagAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Assign a tag to an entity."""
tenant_id = uuid.UUID(current_user["tenant_id"])
tag_id = _parse_uuid(body.tag_id, "tag_id")
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
# Verify tag exists
tag_result = await db.execute(
select(Tag).where(Tag.id == tag_id, Tag.tenant_id == tenant_id)
)
if tag_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "tag_not_found"})
# Check if already assigned
existing = await db.execute(
select(TagAssignment).where(
TagAssignment.tenant_id == tenant_id,
TagAssignment.tag_id == tag_id,
TagAssignment.entity_type == body.entity_type,
TagAssignment.entity_id == entity_id,
)
)
if existing.scalar_one_or_none() is not None:
return {"id": str(tag_id), "tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id), "already_assigned": True}
assignment = TagAssignment(
tenant_id=tenant_id,
tag_id=tag_id,
entity_type=body.entity_type,
entity_id=entity_id,
)
db.add(assignment)
await db.flush()
return {"id": str(assignment.id), "tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id), "already_assigned": False}
@router.delete("/assign", status_code=status.HTTP_204_NO_CONTENT)
async def unassign_tag(
body: TagUnassignRequest = Body(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Remove a tag assignment from an entity."""
tenant_id = uuid.UUID(current_user["tenant_id"])
tag_id = _parse_uuid(body.tag_id, "tag_id")
entity_id = _parse_uuid(body.entity_id, "entity_id")
result = await db.execute(
select(TagAssignment).where(
TagAssignment.tenant_id == tenant_id,
TagAssignment.tag_id == tag_id,
TagAssignment.entity_type == body.entity_type,
TagAssignment.entity_id == entity_id,
)
)
assignment = result.scalar_one_or_none()
if assignment is None:
raise HTTPException(404, detail={"detail": "Assignment not found", "code": "not_found"})
await db.delete(assignment)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tag(
tag_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a tag and cascade-delete all its assignments."""
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(tag_id, "tag_id")
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
tag = result.scalar_one_or_none()
if tag is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
# Cascade delete assignments
await db.execute(
delete(TagAssignment).where(TagAssignment.tag_id == tid, TagAssignment.tenant_id == tenant_id)
)
await db.delete(tag)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/bulk-assign")
async def bulk_assign_tags(
body: TagBulkAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Assign multiple tags to a single entity."""
tenant_id = uuid.UUID(current_user["tenant_id"])
entity_id = _parse_uuid(body.entity_id, "entity_id")
if body.entity_type not in VALID_ENTITY_TYPES:
raise HTTPException(400, detail={"detail": "Invalid entity_type", "code": "invalid_entity_type"})
tag_ids = [_parse_uuid(tid, "tag_id") for tid in body.tag_ids]
# Verify all tags exist
for tid in tag_ids:
result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
if result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": f"Tag {tid} not found", "code": "tag_not_found"})
assigned = []
already = []
for tid in tag_ids:
existing = await db.execute(
select(TagAssignment).where(
TagAssignment.tenant_id == tenant_id,
TagAssignment.tag_id == tid,
TagAssignment.entity_type == body.entity_type,
TagAssignment.entity_id == entity_id,
)
)
if existing.scalar_one_or_none() is not None:
already.append(str(tid))
continue
assignment = TagAssignment(
tenant_id=tenant_id,
tag_id=tid,
entity_type=body.entity_type,
entity_id=entity_id,
)
db.add(assignment)
await db.flush()
assigned.append(str(assignment.id))
return {
"assigned": assigned,
"already_assigned": already,
"total": len(assigned) + len(already),
}
@router.get("/{tag_id}/entities")
async def list_tag_entities(
tag_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all entities assigned to a tag."""
tenant_id = uuid.UUID(current_user["tenant_id"])
tid = _parse_uuid(tag_id, "tag_id")
# Verify tag exists
tag_result = await db.execute(
select(Tag).where(Tag.id == tid, Tag.tenant_id == tenant_id)
)
if tag_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
result = await db.execute(
select(TagAssignment).where(
TagAssignment.tenant_id == tenant_id,
TagAssignment.tag_id == tid,
)
)
assignments = result.scalars().all()
return [
{
"entity_type": a.entity_type,
"entity_id": str(a.entity_id),
}
for a in assignments
]
+54
View File
@@ -0,0 +1,54 @@
"""Pydantic schemas for the Tags plugin."""
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
class TagCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
color: str = Field("#6B7280", max_length=7)
class TagUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100)
color: str | None = Field(None, max_length=7)
class TagResponse(BaseModel):
id: str
name: str
color: str
entity_count: int = 0
class TagAssignRequest(BaseModel):
tag_id: str
entity_type: str = Field(..., pattern="^(company|contact|file|folder)$")
entity_id: str
class TagUnassignRequest(BaseModel):
tag_id: str
entity_type: str = Field(..., pattern="^(company|contact|file|folder)$")
entity_id: str
class TagBulkAssignRequest(BaseModel):
tag_ids: list[str] = Field(..., min_length=1)
entity_type: str = Field(..., pattern="^(company|contact|file|folder)$")
entity_id: str
class TagAssignmentResponse(BaseModel):
id: str
tag_id: str
entity_type: str
entity_id: str
class EntityListResponse(BaseModel):
entity_type: str
entity_id: str
+18 -6
View File
@@ -23,13 +23,25 @@ class MigrationRunner:
self._engine = engine
self._migrations_dir = Path(migrations_dir or os.path.join(os.path.dirname(__file__), "migrations"))
def _resolve_migration_path(self, filename: str) -> Path:
"""Resolve a migration filename to an absolute path."""
# Try migrations_dir first, then app/plugins/migrations/
def _resolve_migration_path(self, filename: str, plugin_name: str | None = None) -> Path:
"""Resolve a migration filename to an absolute path.
Search order:
1. Plugin-specific migrations dir: app/plugins/builtins/<plugin_name>/migrations/
2. Global migrations_dir (default: app/plugins/migrations/)
3. Shared builtins migrations dir: app/plugins/builtins/migrations/
"""
# 1. Plugin-specific migrations directory
if plugin_name:
plugin_migrations_dir = Path(__file__).parent / "builtins" / plugin_name / "migrations"
candidate = plugin_migrations_dir / filename
if candidate.exists():
return candidate
# 2. Global migrations_dir
candidate = self._migrations_dir / filename
if candidate.exists():
return candidate
# Fallback: builtins directory
# 3. Shared builtins migrations directory
builtins_dir = Path(__file__).parent / "builtins" / "migrations"
candidate2 = builtins_dir / filename
if candidate2.exists():
@@ -61,7 +73,7 @@ class MigrationRunner:
FileNotFoundError: If migration file doesn't exist.
MigrationValidationError: If a created table lacks tenant_id column.
"""
migration_path = self._resolve_migration_path(migration_filename)
migration_path = self._resolve_migration_path(migration_filename, plugin_name)
sql_content = migration_path.read_text(encoding="utf-8")
# Capture existing table names before migration (static analysis of SQL)
@@ -156,7 +168,7 @@ class MigrationRunner:
dropped_tables: list[str] = []
for record in migration_records:
migration_path = self._resolve_migration_path(record.migration_file)
migration_path = self._resolve_migration_path(record.migration_file, plugin_name)
sql_content = migration_path.read_text(encoding="utf-8")
# Parse table names from CREATE TABLE statements