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 classapp/plugins/manifest.py— PluginManifest, PluginRouteDefapp/plugins/builtins/tags/— Reference plugin (subdirectory pattern)app/plugins/builtins/permissions/— Already has share links + file permissionsapp/plugins/builtins/entity_links/— Links files to companies/contactsapp/core/db.py— Base, TenantMixin, TimestampMixinapp/models/company.py— Model pattern referenceapp/routes/companies.py— Route pattern referenceapp/schemas/company.py— Schema pattern referencearchitecture.md— Architecture decisionsrequirements.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.copyfileobjfor 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
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
- Query param
POST /api/v1/dms/folders→ 201, create folder- Body:
{name, parent_id?} - Returns created folder with full path
- Body:
PATCH /api/v1/dms/folders/{id}→ 200, rename/move folder- Body:
{name?, parent_id?}
- Body:
DELETE /api/v1/dms/folders/{id}→ 204, soft-delete (set deleted_at)- Cascade: soft-delete all child folders and files
Files
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)
- Form fields:
GET /api/v1/dms/files/{id}→ 200, file metadataPATCH /api/v1/dms/files/{id}→ 200, rename/move- Body:
{name?, folder_id?}
- Body:
DELETE /api/v1/dms/files/{id}→ 204, soft-deletePOST /api/v1/dms/files/{id}/restore→ 200, restore from trash
Preview & Edit
GET /api/v1/dms/files/{id}/preview→ 200, PDF stream- Only for PDF files (mime_type == application/pdf)
- Return
StreamingResponsewithmedia_type='application/pdf' - Non-PDF files: return 400
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
- Return JSON config for OnlyOffice editor:
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 tokenGET /api/public/share/{token}— public accessPOST /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
GET /api/v1/dms/search?q=text→ 200, matching files- Case-insensitive filename search with ILIKE
- Search across all files in tenant (not deleted)
GET /api/v1/dms/shared-with-me→ 200, shared files list- Files where user has been granted permission (via permissions plugin)
POST /api/v1/dms/files/bulk-move→ 200- Body:
{file_ids: [uuid], target_folder_id: uuid?}
- Body:
POST /api/v1/dms/files/bulk-delete→ 200- Body:
{file_ids: [uuid]}— soft-delete all
- Body:
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.AsyncClientwithASGITransport - Use existing fixtures from
tests/conftest.py - For file upload tests: use
httpx.AsyncClient.postwithfiles={'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)
- GET /api/v1/dms/folders → 200 + folder tree
- POST /api/v1/dms/folders → 201, folder created with path
- PATCH /api/v1/dms/folders/{id} → 200, folder renamed/moved
- DELETE /api/v1/dms/folders/{id} → 204, soft-delete
- POST /api/v1/dms/files/upload (multipart) → 201, file stored + metadata
- GET /api/v1/dms/files/{id} → 200 + file metadata
- PATCH /api/v1/dms/files/{id} → 200, renamed/moved
- DELETE /api/v1/dms/files/{id} → 204, soft-delete
- POST /api/v1/dms/files/{id}/restore → 200, restored from trash
- GET /api/v1/dms/files/{id}/preview → 200 + PDF stream
- POST /api/v1/dms/files/{id}/edit-session → 200 + OnlyOffice config
- POST /api/v1/dms/files/{id}/share → 200, internal share created
- DELETE /api/v1/dms/files/{id}/share → 204, share removed
- GET /api/public/share/{token} → 200 (no auth, public access) — MAY already exist via T11
- GET /api/public/share/{token} mit password → 401 ohne password — MAY already exist via T11
- GET /api/v1/dms/search?q=text → 200 + matching files
- GET /api/v1/dms/shared-with-me → 200 + shared files list
- POST /api/v1/dms/files/bulk-move → 200, files moved
- 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: F401for __init__.py re-exports - Use
from Nonein 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 %