# 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 5. `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) 6. `GET /api/v1/dms/files/{id}` → 200, file metadata 7. `PATCH /api/v1/dms/files/{id}` → 200, rename/move - Body: `{name?, folder_id?}` 8. `DELETE /api/v1/dms/files/{id}` → 204, soft-delete 9. `POST /api/v1/dms/files/{id}/restore` → 200, restore from trash ### Preview & Edit 10. `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 11. `POST /api/v1/dms/files/{id}/edit-session` → 200, OnlyOffice config - Return JSON config for OnlyOffice editor: ```json { "document": {"fileType": "docx", "key": "", "title": "", "url": ""}, "editorConfig": {"mode": "edit", "callbackUrl": "", "user": {"id": "", "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 14. `GET /api/v1/dms/search?q=text` → 200, matching files - Case-insensitive filename search with ILIKE - Search across all files in tenant (not deleted) 15. `GET /api/v1/dms/shared-with-me` → 200, shared files list - Files where user has been granted permission (via permissions plugin) 16. `POST /api/v1/dms/files/bulk-move` → 200 - Body: `{file_ids: [uuid], target_folder_id: uuid?}` 17. `POST /api/v1/dms/files/bulk-delete` → 200 - Body: `{file_ids: [uuid]}` — soft-delete all ## Migration SQL ```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`: ```python 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 ```bash 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 %