T04: DMS plugin backend — folders + files + preview + OnlyOffice + shares + search + bulk — 106 tests, 97.90% coverage
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
# 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": "<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
|
||||
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 %
|
||||
Reference in New Issue
Block a user