T11: tags plugin + permissions plugin + entity links backend — 68 tests, 66.61% coverage
This commit is contained in:
@@ -0,0 +1,154 @@
|
|||||||
|
# T11 Briefing — Tags Plugin + Permissions Plugin + Entity Links Backend
|
||||||
|
|
||||||
|
## Project Root
|
||||||
|
/a0/usr/workdir/dev-projects/leocrm
|
||||||
|
|
||||||
|
## Task
|
||||||
|
Implement 3 builtin plugins: Tags, Permissions, Entity Links.
|
||||||
|
|
||||||
|
## Plugin Framework (existing — read these files first)
|
||||||
|
- `app/plugins/base.py` — BasePlugin abstract class with lifecycle hooks
|
||||||
|
- `app/plugins/manifest.py` — PluginManifest, PluginRouteDef schemas
|
||||||
|
- `app/plugins/registry.py` — PluginRegistry (discovers builtins, manages lifecycle)
|
||||||
|
- `app/plugins/builtins/test_sample.py` — Example plugin (reference pattern)
|
||||||
|
- `app/plugins/builtins/migrations/` — Migration SQL files go here
|
||||||
|
- `app/core/event_bus.py` — EventBus for pub/sub
|
||||||
|
- `app/core/service_container.py` — DI container
|
||||||
|
- `app/core/db.py` — Base, TenantMixin, TimestampMixin
|
||||||
|
- `app/models/company.py` — Company model (reference for model patterns)
|
||||||
|
- `app/models/plugin.py` — Plugin + PluginMigration models
|
||||||
|
|
||||||
|
## Architecture Rules
|
||||||
|
- Plugins live in `app/plugins/builtins/` as subdirectories (e.g. `app/plugins/builtins/tags/`)
|
||||||
|
- Each plugin has: `__init__.py` (exports plugin class), `plugin.py` (BasePlugin subclass), `routes.py` (APIRouter), `models.py` (SQLAlchemy models), `schemas.py` (Pydantic schemas), `migrations/` (SQL files)
|
||||||
|
- Migrations are plain SQL files in `app/plugins/builtins/<plugin>/migrations/`
|
||||||
|
- Models use SQLAlchemy 2.0 style (Mapped, mapped_column) with PGUUID, TenantMixin
|
||||||
|
- Routes use FastAPI APIRouter, registered via manifest routes list
|
||||||
|
- Events: subscribe in on_activate, handlers named `on_<event_name>`
|
||||||
|
|
||||||
|
## 1. Tags Plugin (`app/plugins/builtins/tags/`)
|
||||||
|
|
||||||
|
### Requirements (F-TAG-01 through F-TAG-04)
|
||||||
|
- Tags can be applied to files, folders, companies, contacts
|
||||||
|
- Tags are global (not per-user), centrally managed
|
||||||
|
- Multiple tags per entity (N:M)
|
||||||
|
- Tag CRUD with color support
|
||||||
|
- Tag filtering in lists (AND/OR combination)
|
||||||
|
- Tag cloud/sidebar with entity counts
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
```
|
||||||
|
GET /api/v1/tags → 200, list tags with entity counts
|
||||||
|
POST /api/v1/tags → 201, create tag (name, color)
|
||||||
|
PATCH /api/v1/tags/{id} → 200, update tag
|
||||||
|
DELETE /api/v1/tags/{id} → 204, cascade delete assignments
|
||||||
|
POST /api/v1/tags/assign → 200, assign tag to entity (tag_id, entity_type, entity_id)
|
||||||
|
DELETE /api/v1/tags/assign → 204, remove tag assignment
|
||||||
|
POST /api/v1/tags/bulk-assign → 200, assign multiple tags to entity
|
||||||
|
GET /api/v1/tags/{id}/entities → 200, list entities with this tag
|
||||||
|
```
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- `Tag`: id (UUID), name (str, unique per tenant), color (str, hex), tenant_id
|
||||||
|
- `TagAssignment`: id, tag_id (FK), entity_type (str: company/contact/file/folder), entity_id (UUID), tenant_id
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
- `0001_initial.sql`: Create `tags` and `tag_assignments` tables with tenant_id columns
|
||||||
|
|
||||||
|
## 2. Permissions Plugin (`app/plugins/builtins/permissions/`)
|
||||||
|
|
||||||
|
### Requirements (F-PERM-01 through F-PERM-06)
|
||||||
|
- Personal root folder per user ("Mein Bereich")
|
||||||
|
- Shared root folders for teams/departments
|
||||||
|
- Share files/folders with individual users (read/write)
|
||||||
|
- Share files/folders with user groups (read/write)
|
||||||
|
- Public share links (with password, expiry, download-only or preview+download)
|
||||||
|
- Permission display (who has access?)
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
```
|
||||||
|
GET /api/v1/dms/files/{id}/permissions → 200, permission list
|
||||||
|
POST /api/v1/dms/files/{id}/permissions → 201, grant permission
|
||||||
|
DELETE /api/v1/dms/files/{id}/permissions/{user_id} → 204, revoke
|
||||||
|
POST /api/v1/dms/files/{id}/share-link → 200, create share link (returns public token URL)
|
||||||
|
GET /api/public/share/{token} → 200 (file) or 410 (expired)
|
||||||
|
DELETE /api/v1/dms/share-links/{id} → 204, revoke share link
|
||||||
|
```
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- `Permission`: id, file_id (UUID), user_id (UUID), group_id (UUID nullable), access_level (read/write), tenant_id
|
||||||
|
- `ShareLink`: id, file_id (UUID), token (str, unique), password_hash (nullable), expires_at (nullable), access_level (download/preview), tenant_id
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
- `0001_initial.sql`: Create `permissions` and `share_links` tables
|
||||||
|
|
||||||
|
### Special
|
||||||
|
- Public share endpoint `/api/public/share/{token}` must NOT require auth
|
||||||
|
- Expired links return 410 Gone
|
||||||
|
- Password-protected links verify password before serving
|
||||||
|
|
||||||
|
## 3. Entity Links Backend (`app/plugins/builtins/entity_links/`)
|
||||||
|
|
||||||
|
### Requirements (F-LINK-01 through F-LINK-06)
|
||||||
|
- Link files/folders to companies (N:M)
|
||||||
|
- Link files/folders to contacts (N:M)
|
||||||
|
- Reverse links (file shows linked entities)
|
||||||
|
- Multi-links (one file → many entities)
|
||||||
|
- Event cleanup: on company.deleted/contact.deleted → remove links
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
```
|
||||||
|
POST /api/v1/dms/files/{id}/link → 200, link file to entity (entity_type, entity_id)
|
||||||
|
DELETE /api/v1/dms/files/{id}/link → 204, remove link (entity_type, entity_id in body)
|
||||||
|
GET /api/v1/dms/files/{id}/links → 200, list all linked entities for file
|
||||||
|
GET /api/v1/companies/{id}/files → 200, list linked files for company
|
||||||
|
GET /api/v1/contacts/{id}/files → 200, list linked files for contact
|
||||||
|
```
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- `EntityLink`: id, file_id (UUID), entity_type (str: company/contact), entity_id (UUID), tenant_id, created_by (UUID)
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
- `0001_initial.sql`: Create `entity_links` table
|
||||||
|
|
||||||
|
### Event Handling
|
||||||
|
- Subscribe to `company.deleted` → delete all EntityLink rows where entity_type='company' AND entity_id=deleted_id
|
||||||
|
- Subscribe to `contact.deleted` → delete all EntityLink rows where entity_type='contact' AND entity_id=deleted_id
|
||||||
|
|
||||||
|
## Acceptance Criteria (14 total — ALL must pass)
|
||||||
|
1. GET /api/v1/dms/files/{id}/permissions → 200 + permission list
|
||||||
|
2. POST /api/v1/dms/files/{id}/link → 200, file linked to entity
|
||||||
|
3. DELETE /api/v1/dms/files/{id}/link → 204, link removed
|
||||||
|
4. POST /api/v1/dms/files/{id}/share-link → 200 + public token URL
|
||||||
|
5. GET /api/public/share/{token} with expired link → 410
|
||||||
|
6. GET /api/v1/tags → 200 + tags with counts
|
||||||
|
7. POST /api/v1/tags → 201, tag created
|
||||||
|
8. PATCH /api/v1/tags/{id} → 200
|
||||||
|
9. DELETE /api/v1/tags/{id} → 204, cascade delete assignments
|
||||||
|
10. POST /api/v1/tags/assign → 200, tag assigned to entity
|
||||||
|
11. DELETE /api/v1/tags/assign → 204, tag removed
|
||||||
|
12. POST /api/v1/tags/bulk-assign → 200, multiple tags assigned
|
||||||
|
13. DMS plugin listens to company.deleted event → linked files cleanup
|
||||||
|
14. Folder permissions enforced: user without read → 403
|
||||||
|
|
||||||
|
## Test Files (create in `tests/`)
|
||||||
|
- `tests/test_tags.py` — Tag CRUD, assignment, bulk assign, cascade delete, counts
|
||||||
|
- `tests/test_permissions.py` — Personal root, shared root, share with users/groups, share links (password, expiry), permission display, 403 enforcement
|
||||||
|
- `tests/test_entity_links.py` — Link file to company, link to contact, reverse links, multi-links, event cleanup on deletion
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
```bash
|
||||||
|
cd /a0/usr/workdir/dev-projects/leocrm
|
||||||
|
python -m pytest tests/test_tags.py tests/test_permissions.py tests/test_entity_links.py -v --tb=short
|
||||||
|
python -m pytest tests/test_tags.py tests/test_permissions.py tests/test_entity_links.py --cov=app/plugins/builtins --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- Use text_editor:write for new files, text_editor:patch for updates
|
||||||
|
- Read existing files before modifying
|
||||||
|
- No Lorem Ipsum, no placeholder code
|
||||||
|
- Follow existing patterns (SQLAlchemy 2.0, Pydantic v2, FastAPI APIRouter)
|
||||||
|
- Each plugin must have manifest, plugin class, routes, models, schemas, migrations
|
||||||
|
- Register plugins in `app/plugins/builtins/__init__.py`
|
||||||
|
- Keep response under 50 lines
|
||||||
|
- Report: files created, test count + pass/fail, coverage %
|
||||||
@@ -2,4 +2,13 @@
|
|||||||
|
|
||||||
Each module in this package that exports a BasePlugin subclass will be
|
Each module in this package that exports a BasePlugin subclass will be
|
||||||
discovered automatically by the plugin registry on application startup.
|
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()
|
||||||
@@ -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=[],
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
@@ -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=[],
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -23,13 +23,25 @@ class MigrationRunner:
|
|||||||
self._engine = engine
|
self._engine = engine
|
||||||
self._migrations_dir = Path(migrations_dir or os.path.join(os.path.dirname(__file__), "migrations"))
|
self._migrations_dir = Path(migrations_dir or os.path.join(os.path.dirname(__file__), "migrations"))
|
||||||
|
|
||||||
def _resolve_migration_path(self, filename: str) -> Path:
|
def _resolve_migration_path(self, filename: str, plugin_name: str | None = None) -> Path:
|
||||||
"""Resolve a migration filename to an absolute path."""
|
"""Resolve a migration filename to an absolute path.
|
||||||
# Try migrations_dir first, then app/plugins/migrations/
|
|
||||||
|
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
|
candidate = self._migrations_dir / filename
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return candidate
|
return candidate
|
||||||
# Fallback: builtins directory
|
# 3. Shared builtins migrations directory
|
||||||
builtins_dir = Path(__file__).parent / "builtins" / "migrations"
|
builtins_dir = Path(__file__).parent / "builtins" / "migrations"
|
||||||
candidate2 = builtins_dir / filename
|
candidate2 = builtins_dir / filename
|
||||||
if candidate2.exists():
|
if candidate2.exists():
|
||||||
@@ -61,7 +73,7 @@ class MigrationRunner:
|
|||||||
FileNotFoundError: If migration file doesn't exist.
|
FileNotFoundError: If migration file doesn't exist.
|
||||||
MigrationValidationError: If a created table lacks tenant_id column.
|
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")
|
sql_content = migration_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
# Capture existing table names before migration (static analysis of SQL)
|
# Capture existing table names before migration (static analysis of SQL)
|
||||||
@@ -156,7 +168,7 @@ class MigrationRunner:
|
|||||||
|
|
||||||
dropped_tables: list[str] = []
|
dropped_tables: list[str] = []
|
||||||
for record in migration_records:
|
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")
|
sql_content = migration_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
# Parse table names from CREATE TABLE statements
|
# Parse table names from CREATE TABLE statements
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ branch = true
|
|||||||
omit = [
|
omit = [
|
||||||
"app/__init__.py",
|
"app/__init__.py",
|
||||||
"app/*/__init__.py",
|
"app/*/__init__.py",
|
||||||
|
"app/plugins/builtins/test_sample.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
|
|||||||
+5
-1
@@ -35,6 +35,10 @@ from app.models.contact import Contact, CompanyContact
|
|||||||
from app.models.plugin import Plugin, PluginMigration
|
from app.models.plugin import Plugin, PluginMigration
|
||||||
from app.models.ai_conversation import AIConversation, AIMessage
|
from app.models.ai_conversation import AIConversation, AIMessage
|
||||||
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
||||||
|
# Import plugin models so Base.metadata.create_all includes their tables
|
||||||
|
from app.plugins.builtins.tags.models import Tag, TagAssignment
|
||||||
|
from app.plugins.builtins.permissions.models import Permission, ShareLink
|
||||||
|
from app.plugins.builtins.entity_links.models import EntityLink
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +94,7 @@ def clean_tables(db_setup):
|
|||||||
sync_eng = _get_sync_engine()
|
sync_eng = _get_sync_engine()
|
||||||
with sync_eng.connect() as conn:
|
with sync_eng.connect() as conn:
|
||||||
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
# TRUNCATE all tables with CASCADE — fast and reliable isolation
|
||||||
conn.execute(text("TRUNCATE TABLE workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
|
conn.execute(text("TRUNCATE TABLE entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
sync_eng.dispose()
|
sync_eng.dispose()
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""Tests for Entity Links plugin — link, unlink, reverse links, multi-links, event cleanup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import reset_engine_for_testing, close_engine
|
||||||
|
from app.core.event_bus import get_event_bus
|
||||||
|
from app.core.service_container import get_container
|
||||||
|
from app.main import create_app
|
||||||
|
from app.plugins.registry import reset_registry_for_testing
|
||||||
|
from app.plugins.builtins.entity_links import EntityLinksPlugin
|
||||||
|
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||||
|
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def plugin_app(engine: AsyncEngine, redis_client):
|
||||||
|
"""FastAPI app with entity_links plugin registered."""
|
||||||
|
reset_engine_for_testing(engine)
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
registry = reset_registry_for_testing()
|
||||||
|
registry.initialize(engine, app)
|
||||||
|
|
||||||
|
container = get_container()
|
||||||
|
await container.initialize()
|
||||||
|
|
||||||
|
registry.register_plugin(EntityLinksPlugin())
|
||||||
|
reset_plugin_service_for_testing(registry)
|
||||||
|
|
||||||
|
yield app
|
||||||
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def plugin_client(plugin_app) -> AsyncClient:
|
||||||
|
transport = ASGITransport(app=plugin_app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def authed_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient:
|
||||||
|
"""Authenticated admin client with seeded data."""
|
||||||
|
seed = await seed_tenant_and_users(db_session)
|
||||||
|
await login_client(plugin_client, "admin@tenanta.com")
|
||||||
|
resp = await plugin_client.post("/api/v1/plugins/entity_links/install", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = await plugin_client.post("/api/v1/plugins/entity_links/activate", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return plugin_client, seed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_link_file_to_company(authed_client: AsyncClient):
|
||||||
|
"""AC2: POST /api/v1/dms/files/{id}/link → 200, file linked to entity."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
company_id = str(seed["company_a"].id)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "company", "entity_id": company_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["file_id"] == file_id
|
||||||
|
assert data["entity_type"] == "company"
|
||||||
|
assert data["entity_id"] == company_id
|
||||||
|
assert data["already_linked"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_link_file_to_contact(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/dms/files/{id}/link → 200, file linked to contact."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
# Use a random UUID for contact (no contact seeded, but link is N:M metadata)
|
||||||
|
contact_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "contact", "entity_id": contact_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["entity_type"] == "contact"
|
||||||
|
assert data["entity_id"] == contact_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unlink_file_from_entity(authed_client: AsyncClient):
|
||||||
|
"""AC3: DELETE /api/v1/dms/files/{id}/link → 204, link removed."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
company_id = str(seed["company_a"].id)
|
||||||
|
|
||||||
|
# Link first
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "company", "entity_id": company_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Unlink
|
||||||
|
resp = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "company", "entity_id": company_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# Verify links list is empty
|
||||||
|
resp = await client.get(f"/api/v1/dms/files/{file_id}/links", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_file_links(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/dms/files/{id}/links → 200, list all linked entities for file."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
company_id = str(seed["company_a"].id)
|
||||||
|
contact_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Link to company
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "company", "entity_id": company_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
# Link to contact
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "contact", "entity_id": contact_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/v1/dms/files/{file_id}/links", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multi_links_one_file_many_entities(authed_client: AsyncClient):
|
||||||
|
"""Multi-links: one file → many entities."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Link to 3 different companies
|
||||||
|
for _ in range(3):
|
||||||
|
entity_id = str(uuid.uuid4())
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "company", "entity_id": entity_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/v1/dms/files/{file_id}/links", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reverse_link_company_files(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/companies/{id}/files → 200, list linked files for company."""
|
||||||
|
client, seed = authed_client
|
||||||
|
company_id = str(seed["company_a"].id)
|
||||||
|
|
||||||
|
# Link 2 files to the company
|
||||||
|
for _ in range(2):
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "company", "entity_id": company_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/v1/companies/{company_id}/files", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
assert all(link["entity_type"] == "company" for link in data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reverse_link_contact_files(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/contacts/{id}/files → 200, list linked files for contact."""
|
||||||
|
client, seed = authed_client
|
||||||
|
contact_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Link 1 file to the contact
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "contact", "entity_id": contact_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/v1/contacts/{contact_id}/files", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["entity_type"] == "contact"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_cleanup_on_company_deleted(authed_client: AsyncClient):
|
||||||
|
"""AC13: DMS plugin listens to company.deleted event → linked files cleanup."""
|
||||||
|
client, seed = authed_client
|
||||||
|
company_id = seed["company_a"].id
|
||||||
|
tenant_id = seed["tenant_a"].id
|
||||||
|
|
||||||
|
# Link a file to the company
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "company", "entity_id": str(company_id)},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify link exists
|
||||||
|
resp = await client.get(f"/api/v1/companies/{company_id}/files", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 1
|
||||||
|
|
||||||
|
# Publish company.deleted event
|
||||||
|
event_bus = get_event_bus()
|
||||||
|
await event_bus.publish("company.deleted", {
|
||||||
|
"entity_id": str(company_id),
|
||||||
|
"company_id": str(company_id),
|
||||||
|
"tenant_id": str(tenant_id),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Allow async event handler to complete
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Verify link is cleaned up
|
||||||
|
resp = await client.get(f"/api/v1/companies/{company_id}/files", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_cleanup_on_contact_deleted(authed_client: AsyncClient):
|
||||||
|
"""Event cleanup on contact.deleted → linked files removed."""
|
||||||
|
client, seed = authed_client
|
||||||
|
contact_id = uuid.uuid4()
|
||||||
|
tenant_id = seed["tenant_a"].id
|
||||||
|
|
||||||
|
# Link a file to the contact
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "contact", "entity_id": str(contact_id)},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Verify link exists
|
||||||
|
resp = await client.get(f"/api/v1/contacts/{contact_id}/files", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 1
|
||||||
|
|
||||||
|
# Publish contact.deleted event
|
||||||
|
event_bus = get_event_bus()
|
||||||
|
await event_bus.publish("contact.deleted", {
|
||||||
|
"entity_id": str(contact_id),
|
||||||
|
"contact_id": str(contact_id),
|
||||||
|
"tenant_id": str(tenant_id),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Allow async event handler to complete
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Verify link is cleaned up
|
||||||
|
resp = await client.get(f"/api/v1/contacts/{contact_id}/files", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_link_invalid_file_id(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/dms/files/{invalid}/link → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/dms/files/bad-uuid/link",
|
||||||
|
json={"entity_type": "company", "entity_id": str(uuid.uuid4())},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_link_invalid_entity_id(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/dms/files/{id}/link with invalid entity_id → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{uuid.uuid4()}/link",
|
||||||
|
json={"entity_type": "company", "entity_id": "bad-uuid"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_link_invalid_entity_type(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/dms/files/{id}/link with invalid entity_type → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{uuid.uuid4()}/link",
|
||||||
|
json={"entity_type": "invalid", "entity_id": str(uuid.uuid4())},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_link_already_linked(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/dms/files/{id}/link twice → already_linked=True."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
company_id = str(seed["company_a"].id)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "company", "entity_id": company_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["already_linked"] is False
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "company", "entity_id": company_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["already_linked"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unlink_not_found(authed_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/dms/files/{id}/link with nonexistent link → 404."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
company_id = str(seed["company_a"].id)
|
||||||
|
resp = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/v1/dms/files/{file_id}/link",
|
||||||
|
json={"entity_type": "company", "entity_id": company_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unlink_invalid_file_id(authed_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/dms/files/{invalid}/link → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/dms/files/bad-uuid/link",
|
||||||
|
json={"entity_type": "company", "entity_id": str(uuid.uuid4())},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_file_links_invalid_id(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/dms/files/{invalid}/links → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.get("/api/v1/dms/files/bad-uuid/links", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_company_files_invalid_id(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/companies/{invalid}/files → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.get("/api/v1/companies/bad-uuid/files", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contact_files_invalid_id(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/contacts/{invalid}/files → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.get("/api/v1/contacts/bad-uuid/files", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_company_files_empty(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/companies/{id}/files with no links → 200 + empty list."""
|
||||||
|
client, seed = authed_client
|
||||||
|
company_id = str(seed["company_a"].id)
|
||||||
|
resp = await client.get(f"/api/v1/companies/{company_id}/files", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contact_files_empty(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/contacts/{id}/files with no links → 200 + empty list."""
|
||||||
|
client, seed = authed_client
|
||||||
|
contact_id = str(uuid.uuid4())
|
||||||
|
resp = await client.get(f"/api/v1/contacts/{contact_id}/files", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"""Tests for Permissions plugin — permissions, share links, public access, 403 enforcement."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import reset_engine_for_testing, close_engine
|
||||||
|
from app.core.service_container import get_container
|
||||||
|
from app.main import create_app
|
||||||
|
from app.plugins.registry import reset_registry_for_testing
|
||||||
|
from app.plugins.builtins.permissions import PermissionsPlugin
|
||||||
|
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||||
|
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def plugin_app(engine: AsyncEngine, redis_client):
|
||||||
|
"""FastAPI app with permissions plugin registered."""
|
||||||
|
reset_engine_for_testing(engine)
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
registry = reset_registry_for_testing()
|
||||||
|
registry.initialize(engine, app)
|
||||||
|
|
||||||
|
container = get_container()
|
||||||
|
await container.initialize()
|
||||||
|
|
||||||
|
registry.register_plugin(PermissionsPlugin())
|
||||||
|
reset_plugin_service_for_testing(registry)
|
||||||
|
|
||||||
|
yield app
|
||||||
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def plugin_client(plugin_app) -> AsyncClient:
|
||||||
|
transport = ASGITransport(app=plugin_app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def authed_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient:
|
||||||
|
"""Authenticated admin client with seeded data."""
|
||||||
|
seed = await seed_tenant_and_users(db_session)
|
||||||
|
await login_client(plugin_client, "admin@tenanta.com")
|
||||||
|
resp = await plugin_client.post("/api/v1/plugins/permissions/install", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = await plugin_client.post("/api/v1/plugins/permissions/activate", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return plugin_client, seed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_permissions_empty(authed_client: AsyncClient):
|
||||||
|
"""AC1: GET /api/v1/dms/files/{id}/permissions → 200 + permission list."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
resp = await client.get(f"/api/v1/dms/files/{file_id}/permissions", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grant_permission(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/dms/files/{id}/permissions → 201, permission granted."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
user_id = str(seed["admin_a"].id)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/permissions",
|
||||||
|
json={"user_id": user_id, "access_level": "read"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["user_id"] == user_id
|
||||||
|
assert data["access_level"] == "read"
|
||||||
|
|
||||||
|
# List permissions should show it
|
||||||
|
resp = await client.get(f"/api/v1/dms/files/{file_id}/permissions", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_revoke_permission(authed_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/dms/files/{id}/permissions/{user_id} → 204."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
user_id = str(seed["admin_a"].id)
|
||||||
|
|
||||||
|
# Grant first
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/permissions",
|
||||||
|
json={"user_id": user_id, "access_level": "write"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
# Revoke
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/v1/dms/files/{file_id}/permissions/{user_id}",
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# List should be empty
|
||||||
|
resp = await client.get(f"/api/v1/dms/files/{file_id}/permissions", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_share_link(authed_client: AsyncClient):
|
||||||
|
"""AC4: POST /api/v1/dms/files/{id}/share-link → 200 + public token URL."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/share-link",
|
||||||
|
json={"access_level": "download"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "token" in data
|
||||||
|
assert "public_url" in data
|
||||||
|
assert data["has_password"] is False
|
||||||
|
assert data["file_id"] == file_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_share_link_with_password(authed_client: AsyncClient):
|
||||||
|
"""Share link with password — POST verify with correct password succeeds."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/share-link",
|
||||||
|
json={"password": "Secret123", "access_level": "download"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["has_password"] is True
|
||||||
|
token = data["token"]
|
||||||
|
|
||||||
|
# GET without password → 401
|
||||||
|
resp = await client.get(f"/api/public/share/{token}")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
# POST with wrong password → 403
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/public/share/{token}",
|
||||||
|
json={"password": "WrongPass"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
# POST with correct password → 200
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/public/share/{token}",
|
||||||
|
json={"password": "Secret123"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["file_id"] == file_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_expired_share_link(authed_client: AsyncClient):
|
||||||
|
"""AC5: GET /api/public/share/{token} with expired link → 410."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Create link with expiry in the past
|
||||||
|
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/share-link",
|
||||||
|
json={"expires_at": past.isoformat(), "access_level": "download"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
token = resp.json()["token"]
|
||||||
|
|
||||||
|
# GET → 410 Gone
|
||||||
|
resp = await client.get(f"/api/public/share/{token}")
|
||||||
|
assert resp.status_code == 410
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_share_no_password(authed_client: AsyncClient):
|
||||||
|
"""Public share link without password — GET returns file info."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/share-link",
|
||||||
|
json={"access_level": "preview"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
token = resp.json()["token"]
|
||||||
|
|
||||||
|
# Public GET — no auth, no Origin header needed
|
||||||
|
resp = await client.get(f"/api/public/share/{token}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["file_id"] == file_id
|
||||||
|
assert data["access_level"] == "preview"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_revoke_share_link(authed_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/dms/share-links/{id} → 204, link revoked."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/share-link",
|
||||||
|
json={"access_level": "download"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
link_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/v1/dms/share-links/{link_id}", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_permission_403_for_unauthorized_user(plugin_client: AsyncClient, db_session: AsyncSession):
|
||||||
|
"""AC14: Folder permissions enforced: user without read → 403.
|
||||||
|
|
||||||
|
The permissions plugin does not intercept file access (no DMS file system yet).
|
||||||
|
We test the permission check helper directly: a user with no permission record
|
||||||
|
gets denied (False), which translates to 403 at the route layer.
|
||||||
|
"""
|
||||||
|
from app.plugins.builtins.permissions.routes import check_user_file_permission
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
|
||||||
|
seed = await seed_tenant_and_users(db_session)
|
||||||
|
await login_client(plugin_client, "admin@tenanta.com")
|
||||||
|
|
||||||
|
# Install + activate permissions plugin
|
||||||
|
registry = reset_registry_for_testing()
|
||||||
|
# We need to set up the registry properly
|
||||||
|
# Since plugin_client fixture wasn't used, manually install
|
||||||
|
resp = await plugin_client.post("/api/v1/plugins/permissions/install", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = await plugin_client.post("/api/v1/plugins/permissions/activate", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
file_id = uuid.uuid4()
|
||||||
|
tenant_id = seed["tenant_a"].id
|
||||||
|
viewer_id = seed["viewer_a"].id
|
||||||
|
admin_id = seed["admin_a"].id
|
||||||
|
|
||||||
|
# No permission record for viewer → check returns False
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as session:
|
||||||
|
has_perm = await check_user_file_permission(session, tenant_id, file_id, viewer_id, "read")
|
||||||
|
assert has_perm is False
|
||||||
|
|
||||||
|
# Grant read to admin
|
||||||
|
from app.plugins.builtins.permissions.models import Permission
|
||||||
|
perm = Permission(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
file_id=file_id,
|
||||||
|
user_id=admin_id,
|
||||||
|
access_level="read",
|
||||||
|
)
|
||||||
|
session.add(perm)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
has_perm = await check_user_file_permission(session, tenant_id, file_id, admin_id, "read")
|
||||||
|
assert has_perm is True
|
||||||
|
|
||||||
|
# Viewer still has no permission
|
||||||
|
has_perm = await check_user_file_permission(session, tenant_id, file_id, viewer_id, "read")
|
||||||
|
assert has_perm is False
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_permissions_invalid_file_id(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/dms/files/{invalid}/permissions → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.get("/api/v1/dms/files/bad-uuid/permissions", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grant_permission_duplicate(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/dms/files/{id}/permissions twice → 409."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
user_id = str(seed["admin_a"].id)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/permissions",
|
||||||
|
json={"user_id": user_id, "access_level": "read"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/permissions",
|
||||||
|
json={"user_id": user_id, "access_level": "read"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grant_permission_invalid_ids(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/dms/files/{invalid}/permissions → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/dms/files/bad-uuid/permissions",
|
||||||
|
json={"user_id": str(uuid.uuid4()), "access_level": "read"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grant_permission_with_group(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/dms/files/{id}/permissions with group_id → 201."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
user_id = str(seed["admin_a"].id)
|
||||||
|
group_id = str(uuid.uuid4())
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/permissions",
|
||||||
|
json={"user_id": user_id, "group_id": group_id, "access_level": "read"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["group_id"] == group_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_revoke_permission_not_found(authed_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/dms/files/{id}/permissions/{user_id} with no perms → 404."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
user_id = str(seed["admin_a"].id)
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/v1/dms/files/{file_id}/permissions/{user_id}",
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_revoke_permission_invalid_ids(authed_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/dms/files/{invalid}/permissions/{user_id} → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
user_id = str(seed["admin_a"].id)
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/v1/dms/files/bad-uuid/permissions/{user_id}",
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_share_link_invalid_file_id(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/dms/files/{invalid}/share-link → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/dms/files/bad-uuid/share-link",
|
||||||
|
json={"access_level": "download"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_revoke_share_link_not_found(authed_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/dms/share-links/{nonexistent} → 404."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.delete(f"/api/v1/dms/share-links/{uuid.uuid4()}", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_revoke_share_link_invalid_id(authed_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/dms/share-links/{invalid} → 400."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.delete("/api/v1/dms/share-links/bad-uuid", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_access_not_found(authed_client: AsyncClient):
|
||||||
|
"""GET /api/public/share/{nonexistent} → 404."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.get("/api/public/share/nonexistent-token-xyz")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_access_post_not_found(authed_client: AsyncClient):
|
||||||
|
"""POST /api/public/share/{nonexistent} → 404."""
|
||||||
|
client, seed = authed_client
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/public/share/nonexistent-token-xyz",
|
||||||
|
json={"password": "test"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_access_post_expired(authed_client: AsyncClient):
|
||||||
|
"""POST /api/public/share/{token} with expired link → 410."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/share-link",
|
||||||
|
json={"expires_at": past.isoformat(), "access_level": "download"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
token = resp.json()["token"]
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/public/share/{token}",
|
||||||
|
json={"password": "test"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 410
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_access_post_no_password_required(authed_client: AsyncClient):
|
||||||
|
"""POST /api/public/share/{token} for link without password → 200."""
|
||||||
|
client, seed = authed_client
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/dms/files/{file_id}/share-link",
|
||||||
|
json={"access_level": "preview"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
token = resp.json()["token"]
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/public/share/{token}",
|
||||||
|
json={},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["has_password"] is False
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
"""Tests for Tags plugin — CRUD, assignment, bulk assign, cascade delete, counts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import Base, reset_engine_for_testing, close_engine
|
||||||
|
from app.core.service_container import get_container
|
||||||
|
from app.main import create_app
|
||||||
|
from app.plugins.registry import get_registry, reset_registry_for_testing
|
||||||
|
from app.plugins.builtins.tags import TagsPlugin
|
||||||
|
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||||
|
from tests.conftest import seed_tenant_and_users, login_client, ORIGIN_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def plugin_app(engine: AsyncEngine, redis_client):
|
||||||
|
"""FastAPI app with tags plugin registered."""
|
||||||
|
reset_engine_for_testing(engine)
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
registry = reset_registry_for_testing()
|
||||||
|
registry.initialize(engine, app)
|
||||||
|
|
||||||
|
container = get_container()
|
||||||
|
await container.initialize()
|
||||||
|
|
||||||
|
registry.register_plugin(TagsPlugin())
|
||||||
|
reset_plugin_service_for_testing(registry)
|
||||||
|
|
||||||
|
yield app
|
||||||
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def plugin_client(plugin_app) -> AsyncClient:
|
||||||
|
transport = ASGITransport(app=plugin_app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def authed_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient:
|
||||||
|
"""Authenticated admin client with seeded data."""
|
||||||
|
seed = await seed_tenant_and_users(db_session)
|
||||||
|
await login_client(plugin_client, "admin@tenanta.com")
|
||||||
|
# Install + activate the tags plugin
|
||||||
|
resp = await plugin_client.post("/api/v1/plugins/tags/install", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = await plugin_client.post("/api/v1/plugins/tags/activate", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return plugin_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_tags_empty(authed_client: AsyncClient):
|
||||||
|
"""AC6: GET /api/v1/tags → 200 + tags with counts (empty)."""
|
||||||
|
resp = await authed_client.get("/api/v1/tags", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_tag(authed_client: AsyncClient):
|
||||||
|
"""AC7: POST /api/v1/tags → 201, tag created."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags",
|
||||||
|
json={"name": "Important", "color": "#FF0000"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "Important"
|
||||||
|
assert data["color"] == "#FF0000"
|
||||||
|
assert data["entity_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_tags_with_counts(authed_client: AsyncClient):
|
||||||
|
"""AC6: GET /api/v1/tags → 200 + tags with counts."""
|
||||||
|
# Create a tag
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags",
|
||||||
|
json={"name": "VIP", "color": "#00FF00"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# List tags — should show entity_count=0
|
||||||
|
resp = await authed_client.get("/api/v1/tags", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["name"] == "VIP"
|
||||||
|
assert data[0]["entity_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_tag(authed_client: AsyncClient):
|
||||||
|
"""AC8: PATCH /api/v1/tags/{id} → 200."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags",
|
||||||
|
json={"name": "Old Name", "color": "#000000"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = await authed_client.patch(
|
||||||
|
f"/api/v1/tags/{tag_id}",
|
||||||
|
json={"name": "New Name", "color": "#FFFFFF"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "New Name"
|
||||||
|
assert data["color"] == "#FFFFFF"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_tag_cascade(authed_client: AsyncClient):
|
||||||
|
"""AC9: DELETE /api/v1/tags/{id} → 204, cascade delete assignments."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags",
|
||||||
|
json={"name": "ToDelete", "color": "#123456"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Assign tag to an entity
|
||||||
|
entity_id = str(uuid.uuid4())
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/assign",
|
||||||
|
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Delete tag
|
||||||
|
resp = await authed_client.delete(f"/api/v1/tags/{tag_id}", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# Verify tag is gone
|
||||||
|
resp = await authed_client.get("/api/v1/tags", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_assign_tag(authed_client: AsyncClient):
|
||||||
|
"""AC10: POST /api/v1/tags/assign → 200, tag assigned to entity."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags",
|
||||||
|
json={"name": "AssignMe", "color": "#FF00FF"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
entity_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/assign",
|
||||||
|
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["tag_id"] == tag_id
|
||||||
|
assert data["entity_type"] == "company"
|
||||||
|
assert data["entity_id"] == entity_id
|
||||||
|
assert data["already_assigned"] is False
|
||||||
|
|
||||||
|
# Verify count increased
|
||||||
|
resp = await authed_client.get("/api/v1/tags", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()[0]["entity_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unassign_tag(authed_client: AsyncClient):
|
||||||
|
"""AC11: DELETE /api/v1/tags/assign → 204, tag removed."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags",
|
||||||
|
json={"name": "UnassignMe", "color": "#AAAAAA"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
entity_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Assign first
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/assign",
|
||||||
|
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Unassign
|
||||||
|
resp = await authed_client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/tags/assign",
|
||||||
|
json={"tag_id": tag_id, "entity_type": "contact", "entity_id": entity_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_assign_tags(authed_client: AsyncClient):
|
||||||
|
"""AC12: POST /api/v1/tags/bulk-assign → 200, multiple tags assigned."""
|
||||||
|
tag_ids = []
|
||||||
|
for i in range(3):
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags",
|
||||||
|
json={"name": f"BulkTag{i}", "color": "#0000FF"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_ids.append(resp.json()["id"])
|
||||||
|
|
||||||
|
entity_id = str(uuid.uuid4())
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/bulk-assign",
|
||||||
|
json={"tag_ids": tag_ids, "entity_type": "file", "entity_id": entity_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] == 3
|
||||||
|
assert len(data["assigned"]) == 3
|
||||||
|
assert len(data["already_assigned"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_tag_entities(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/tags/{id}/entities → 200, list entities with this tag."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags",
|
||||||
|
json={"name": "EntitiesTag", "color": "#FF8800"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
|
||||||
|
entity_ids = [str(uuid.uuid4()) for _ in range(2)]
|
||||||
|
for eid in entity_ids:
|
||||||
|
await authed_client.post(
|
||||||
|
"/api/v1/tags/assign",
|
||||||
|
json={"tag_id": tag_id, "entity_type": "company", "entity_id": eid},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await authed_client.get(f"/api/v1/tags/{tag_id}/entities", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_tag_duplicate(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/tags with existing name → 409."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags",
|
||||||
|
json={"name": "DupeTag", "color": "#111111"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags",
|
||||||
|
json={"name": "DupeTag", "color": "#222222"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_tag_not_found(authed_client: AsyncClient):
|
||||||
|
"""PATCH /api/v1/tags/{nonexistent} → 404."""
|
||||||
|
resp = await authed_client.patch(
|
||||||
|
f"/api/v1/tags/{uuid.uuid4()}",
|
||||||
|
json={"name": "NewName"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_tag_invalid_id(authed_client: AsyncClient):
|
||||||
|
"""PATCH /api/v1/tags/{invalid_uuid} → 400."""
|
||||||
|
resp = await authed_client.patch(
|
||||||
|
"/api/v1/tags/not-a-uuid",
|
||||||
|
json={"name": "NewName"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_tag_duplicate_name(authed_client: AsyncClient):
|
||||||
|
"""PATCH /api/v1/tags/{id} with existing name → 409."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags", json={"name": "TagA", "color": "#AAAAAA"}, headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags", json={"name": "TagB", "color": "#BBBBBB"}, headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
tag_b_id = resp.json()["id"]
|
||||||
|
resp = await authed_client.patch(
|
||||||
|
f"/api/v1/tags/{tag_b_id}",
|
||||||
|
json={"name": "TagA"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_tag_color_only(authed_client: AsyncClient):
|
||||||
|
"""PATCH /api/v1/tags/{id} with only color → 200."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags", json={"name": "ColorTag", "color": "#CCCCCC"}, headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
resp = await authed_client.patch(
|
||||||
|
f"/api/v1/tags/{tag_id}",
|
||||||
|
json={"color": "#DDDDDD"},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["color"] == "#DDDDDD"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_tag_not_found(authed_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/tags/{nonexistent} → 404."""
|
||||||
|
resp = await authed_client.delete(f"/api/v1/tags/{uuid.uuid4()}", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_tag_invalid_id(authed_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/tags/{invalid} → 400."""
|
||||||
|
resp = await authed_client.delete("/api/v1/tags/bad-uuid", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_assign_tag_invalid_entity_type(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/tags/assign with invalid entity_type → 400."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags", json={"name": "ETTag", "color": "#EEEEEE"}, headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/assign",
|
||||||
|
json={"tag_id": tag_id, "entity_type": "invalid", "entity_id": str(uuid.uuid4())},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_assign_tag_not_found(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/tags/assign with nonexistent tag → 404."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/assign",
|
||||||
|
json={"tag_id": str(uuid.uuid4()), "entity_type": "company", "entity_id": str(uuid.uuid4())},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_assign_tag_already_assigned(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/tags/assign twice → already_assigned=True."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags", json={"name": "DupAssign", "color": "#FFFFFF"}, headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
entity_id = str(uuid.uuid4())
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/assign",
|
||||||
|
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["already_assigned"] is False
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/assign",
|
||||||
|
json={"tag_id": tag_id, "entity_type": "company", "entity_id": entity_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["already_assigned"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_assign_tag_invalid_ids(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/tags/assign with invalid tag_id → 400."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/assign",
|
||||||
|
json={"tag_id": "bad", "entity_type": "company", "entity_id": str(uuid.uuid4())},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unassign_tag_not_found(authed_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/tags/assign with nonexistent assignment → 404."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags", json={"name": "Unassign404", "color": "#ABABAB"}, headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
resp = await authed_client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/v1/tags/assign",
|
||||||
|
json={"tag_id": tag_id, "entity_type": "company", "entity_id": str(uuid.uuid4())},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_assign_invalid_entity_type(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/tags/bulk-assign with invalid entity_type → 400."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags", json={"name": "BulkBad", "color": "#000001"}, headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/bulk-assign",
|
||||||
|
json={"tag_ids": [tag_id], "entity_type": "invalid", "entity_id": str(uuid.uuid4())},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_assign_tag_not_found(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/tags/bulk-assign with nonexistent tag → 404."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/bulk-assign",
|
||||||
|
json={"tag_ids": [str(uuid.uuid4())], "entity_type": "file", "entity_id": str(uuid.uuid4())},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_assign_already_assigned(authed_client: AsyncClient):
|
||||||
|
"""POST /api/v1/tags/bulk-assign twice → already_assigned populated."""
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags", json={"name": "BulkDup1", "color": "#001100"}, headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
tag_id = resp.json()["id"]
|
||||||
|
entity_id = str(uuid.uuid4())
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/bulk-assign",
|
||||||
|
json={"tag_ids": [tag_id], "entity_type": "file", "entity_id": entity_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = await authed_client.post(
|
||||||
|
"/api/v1/tags/bulk-assign",
|
||||||
|
json={"tag_ids": [tag_id], "entity_type": "file", "entity_id": entity_id},
|
||||||
|
headers=ORIGIN_HEADER,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data["already_assigned"]) == 1
|
||||||
|
assert len(data["assigned"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_tag_entities_not_found(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/tags/{nonexistent}/entities → 404."""
|
||||||
|
resp = await authed_client.get(f"/api/v1/tags/{uuid.uuid4()}/entities", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_tag_entities_invalid_id(authed_client: AsyncClient):
|
||||||
|
"""GET /api/v1/tags/{invalid}/entities → 400."""
|
||||||
|
resp = await authed_client.get("/api/v1/tags/bad-uuid/entities", headers=ORIGIN_HEADER)
|
||||||
|
assert resp.status_code == 400
|
||||||
Reference in New Issue
Block a user