Files
leocrm/.a0/briefings/T04_briefing.md
T

9.9 KiB

T04 — DMS Plugin Backend (Folders, Files, Preview, OnlyOffice, Share Links)

Project Root

/a0/usr/workdir/dev-projects/leocrm

Context Files (READ FIRST)

  • app/plugins/base.py — BasePlugin abstract class
  • app/plugins/manifest.py — PluginManifest, PluginRouteDef
  • app/plugins/builtins/tags/ — Reference plugin (subdirectory pattern)
  • app/plugins/builtins/permissions/ — Already has share links + file permissions
  • app/plugins/builtins/entity_links/ — Links files to companies/contacts
  • app/core/db.py — Base, TenantMixin, TimestampMixin
  • app/models/company.py — Model pattern reference
  • app/routes/companies.py — Route pattern reference
  • app/schemas/company.py — Schema pattern reference
  • architecture.md — Architecture decisions
  • requirements.md — F-DMS-, F-FILE-, F-FILEUI-* requirements

Overview

Implement DMS (Document Management System) plugin as app/plugins/builtins/dms/.

Plugin Structure

app/plugins/builtins/dms/
├── __init__.py          # Export DmsPlugin
├── plugin.py            # DmsPlugin(BasePlugin) with manifest
├── models.py            # Folder, File models
├── schemas.py           # Pydantic schemas for all endpoints
├── routes.py            # FastAPI APIRouter with all endpoints
└── migrations/
    └── 0001_initial.sql # Create folders + files tables

Models

Folder

  • id (UUID, PK)
  • name (str, not null)
  • parent_id (UUID, FK to folders.id, nullable — null = root)
  • tenant_id (UUID, not null)
  • created_by (UUID, not null)
  • deleted_at (datetime, nullable — soft delete)
  • created_at, updated_at (TimestampMixin)
  • Unique constraint: (name, parent_id, tenant_id) where deleted_at IS NULL

File

  • id (UUID, PK)
  • name (str, not null)
  • folder_id (UUID, FK to folders.id, nullable — null = root)
  • tenant_id (UUID, not null)
  • uploaded_by (UUID, not null)
  • mime_type (str, not null)
  • size_bytes (int, not null)
  • storage_path (str, not null — relative path on disk)
  • deleted_at (datetime, nullable — soft delete)
  • created_at, updated_at (TimestampMixin)

File Storage

  • Store files at: /data/dms/{tenant_id}/{file_uuid} (configurable via plugin config)
  • Use shutil.copyfileobj for upload streaming
  • Generate UUID for filename on disk, keep original name in DB
  • Create directory with os.makedirs(path, exist_ok=True)

Endpoints (19 ACs)

Folders

  1. GET /api/v1/dms/folders → 200, folder tree (recursive tree structure)
    • Query param parent_id (optional, null = root level)
    • Returns list of folders with children nested
  2. POST /api/v1/dms/folders → 201, create folder
    • Body: {name, parent_id?}
    • Returns created folder with full path
  3. PATCH /api/v1/dms/folders/{id} → 200, rename/move folder
    • Body: {name?, parent_id?}
  4. DELETE /api/v1/dms/folders/{id} → 204, soft-delete (set deleted_at)
    • Cascade: soft-delete all child folders and files

Files

  1. POST /api/v1/dms/files/upload → 201, multipart upload
    • Form fields: file (UploadFile), folder_id? (optional)
    • Store file on disk, create metadata record
    • Max file size: 100MB (configurable)
  2. GET /api/v1/dms/files/{id} → 200, file metadata
  3. PATCH /api/v1/dms/files/{id} → 200, rename/move
    • Body: {name?, folder_id?}
  4. DELETE /api/v1/dms/files/{id} → 204, soft-delete
  5. POST /api/v1/dms/files/{id}/restore → 200, restore from trash

Preview & Edit

  1. GET /api/v1/dms/files/{id}/preview → 200, PDF stream
    • Only for PDF files (mime_type == application/pdf)
    • Return StreamingResponse with media_type='application/pdf'
    • Non-PDF files: return 400
  2. POST /api/v1/dms/files/{id}/edit-session → 200, OnlyOffice config
    • Return JSON config for OnlyOffice editor:
      {
        "document": {"fileType": "docx", "key": "<uuid>", "title": "<filename>", "url": "<download_url>"},
        "editorConfig": {"mode": "edit", "callbackUrl": "<callback_url>", "user": {"id": "<user_id>", "name": "<user_name>"}}
      }
      
    • Only for Office files (docx, xlsx, pptx)
    • Non-Office files: return 400

Sharing (INTERNAL — different from T11 permissions plugin)

NOTE: T11 permissions plugin already handles:

  • POST /api/v1/dms/files/{id}/share-link — public share links with token
  • GET /api/public/share/{token} — public access
  • POST /api/v1/dms/files/{id}/permissions — grant permissions

T04 DMS plugin handles INTERNAL sharing (different endpoints): 12. POST /api/v1/dms/files/{id}/share → 200, internal share - Body: {user_ids?: [uuid], group_ids?: [uuid], access_level: 'read'|'write'} - Creates permission records (reuse permissions plugin Permission model OR DMS-specific) 13. DELETE /api/v1/dms/files/{id}/share → 204, remove share - Body: {user_id?: uuid, group_id?: uuid}

IMPORTANT: For GET /api/public/share/{token} (AC14, AC15) — T11 permissions plugin ALREADY implements this endpoint. Do NOT create a duplicate. If T11's endpoint already handles password-protected links (401 without password), then AC14 and AC15 are already satisfied. Verify by reading app/plugins/builtins/permissions/routes.py.

Search & Bulk

  1. GET /api/v1/dms/search?q=text → 200, matching files
    • Case-insensitive filename search with ILIKE
    • Search across all files in tenant (not deleted)
  2. GET /api/v1/dms/shared-with-me → 200, shared files list
    • Files where user has been granted permission (via permissions plugin)
  3. POST /api/v1/dms/files/bulk-move → 200
    • Body: {file_ids: [uuid], target_folder_id: uuid?}
  4. POST /api/v1/dms/files/bulk-delete → 200
    • Body: {file_ids: [uuid]} — soft-delete all

Migration SQL

CREATE TABLE IF NOT EXISTS folders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
    tenant_id UUID NOT NULL,
    created_by UUID NOT NULL,
    deleted_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_folders_parent ON folders(parent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_folders_tenant ON folders(tenant_id) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS files (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
    tenant_id UUID NOT NULL,
    uploaded_by UUID NOT NULL,
    mime_type VARCHAR(255) NOT NULL,
    size_bytes BIGINT NOT NULL,
    storage_path VARCHAR(1024) NOT NULL,
    deleted_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_files_folder ON files(folder_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_files_tenant ON files(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_files_name ON files USING gin (to_tsvector('simple', name));

Register Plugin

Add to app/plugins/builtins/__init__.py:

from app.plugins.builtins.dms import DmsPlugin
__all__.append("DmsPlugin")

Test File

Create tests/test_dms.py with tests for ALL 19 ACs.

Test Patterns

  • Follow existing test patterns in tests/test_tags.py, tests/test_permissions.py
  • Use async test client via httpx.AsyncClient with ASGITransport
  • Use existing fixtures from tests/conftest.py
  • For file upload tests: use httpx.AsyncClient.post with files={'file': ('test.pdf', b'%PDF-1.4...', 'application/pdf')}
  • For preview tests: verify response status 200 + content-type application/pdf
  • For OnlyOffice: verify config structure returned
  • For bulk operations: create multiple files, then bulk-move/bulk-delete

Verification Commands

cd /a0/usr/workdir/dev-projects/leocrm
python -m pytest tests/test_dms.py -v --tb=short
python -m pytest tests/test_dms.py --cov=app/plugins/builtins/dms --cov-report=term-missing

Acceptance Criteria (19 — ALL must pass)

  1. GET /api/v1/dms/folders → 200 + folder tree
  2. POST /api/v1/dms/folders → 201, folder created with path
  3. PATCH /api/v1/dms/folders/{id} → 200, folder renamed/moved
  4. DELETE /api/v1/dms/folders/{id} → 204, soft-delete
  5. POST /api/v1/dms/files/upload (multipart) → 201, file stored + metadata
  6. GET /api/v1/dms/files/{id} → 200 + file metadata
  7. PATCH /api/v1/dms/files/{id} → 200, renamed/moved
  8. DELETE /api/v1/dms/files/{id} → 204, soft-delete
  9. POST /api/v1/dms/files/{id}/restore → 200, restored from trash
  10. GET /api/v1/dms/files/{id}/preview → 200 + PDF stream
  11. POST /api/v1/dms/files/{id}/edit-session → 200 + OnlyOffice config
  12. POST /api/v1/dms/files/{id}/share → 200, internal share created
  13. DELETE /api/v1/dms/files/{id}/share → 204, share removed
  14. GET /api/public/share/{token} → 200 (no auth, public access) — MAY already exist via T11
  15. GET /api/public/share/{token} mit password → 401 ohne password — MAY already exist via T11
  16. GET /api/v1/dms/search?q=text → 200 + matching files
  17. GET /api/v1/dms/shared-with-me → 200 + shared files list
  18. POST /api/v1/dms/files/bulk-move → 200, files moved
  19. POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted

Rules

  • No placeholder code. No Lorem Ipsum.
  • Follow existing patterns exactly (SQLAlchemy 2.0, FastAPI APIRouter, Pydantic v2)
  • All routes must have tenant_id scoping
  • Use # noqa: F401 for __init__.py re-exports
  • Use from None in except blocks (B904)
  • File storage path: /data/dms/{tenant_id}/{file_uuid}
  • OnlyOffice: generate config only, don't run OnlyOffice server
  • For AC14/AC15: check if T11 permissions plugin already satisfies these. If yes, write tests that verify existing endpoint. If no, implement in DMS plugin.

Deliverables

  • All plugin files created
  • Plugin registered in builtins __init__.py
  • tests/test_dms.py with all 19 ACs tested
  • All tests passing
  • Coverage ≥80%
  • Report: files created, test count + pass/fail, coverage %