Any record in Cluster ERP can carry files and notes through two shared tables in the core. This page lists the tables, services, and routes in core/attachments.py, core/notes.py, server/routes/attachments.py, and server/routes/notes.py, the upload policy in core/upload_policy.py, and the storage backends in core/storage.py. The versioned document management of the documents module is a separate feature; see documents.
Scope and source¶
- Defined in:
src/cluster_erp/core/attachments.py(Attachment,AttachmentService),src/cluster_erp/core/notes.py(Note,NoteService),src/cluster_erp/core/upload_policy.py(check_upload),src/cluster_erp/core/storage.py(StorageBackend,create_storage_backend),src/cluster_erp/server/routes/attachments.py,src/cluster_erp/server/routes/notes.py - Count: 8 attachment routes and 6 note routes (counted from the
@router.decorators); 3 storage backend classes; 40 default allowed extensions
Both tables link to their parent record by entity_type and entity_id, with no foreign key, so any entity can use them.
Attachments¶
core_attachments derives from BaseModel and adds these columns (src/cluster_erp/core/attachments.py:51-63):
| Column | Type | Meaning | Source |
|---|---|---|---|
code |
String(20), unique |
Business code from the attachment sequence |
src/cluster_erp/core/attachments.py:54, src/cluster_erp/core/attachments.py:123 |
entity_type |
String(50) |
Type of the parent record | src/cluster_erp/core/attachments.py:55 |
entity_id |
UUID |
Id of the parent record | src/cluster_erp/core/attachments.py:56 |
filename |
String(255) |
Stored name: a random hex name plus the cleaned extension | src/cluster_erp/core/attachments.py:57, src/cluster_erp/core/attachments.py:124-125 |
original_filename |
String(255) |
The name the user uploaded | src/cluster_erp/core/attachments.py:58 |
content_type |
String(100) |
The content type declared by the upload | src/cluster_erp/core/attachments.py:59 |
file_size |
BigInteger |
Size in bytes | src/cluster_erp/core/attachments.py:60 |
storage_path |
String(500) |
Key in the storage backend | src/cluster_erp/core/attachments.py:61 |
sha256 |
String(64) |
SHA-256 of the content | src/cluster_erp/core/attachments.py:62 |
description |
Text |
Optional description | src/cluster_erp/core/attachments.py:63 |
The storage key is attachments/<entity_type>/<entity_id>/<stored name>, prefixed with tenants/<tenant key>/ on a multi-tenant node (src/cluster_erp/core/attachments.py:38-48). Downloads carry a Content-Disposition header built by safe_content_disposition, which strips quote, backslash, line-break, and semicolon characters from the ASCII name and percent-encodes the UTF-8 name (src/cluster_erp/core/attachments.py:24-35).
AttachmentService methods (src/cluster_erp/core/attachments.py:88-206):
| Method | Behaviour | Source |
|---|---|---|
list_for_entity |
Live attachments of a record | src/cluster_erp/core/attachments.py:93-104 |
get |
One live attachment | src/cluster_erp/core/attachments.py:106-112 |
create |
Saves the bytes to storage, then inserts the row | src/cluster_erp/core/attachments.py:114-143 |
update |
Changes description or original_filename |
src/cluster_erp/core/attachments.py:145-153 |
delete |
Sets deleted_at; the stored file stays |
src/cluster_erp/core/attachments.py:155-161 |
bulk_delete |
Deactivates many through bulk_soft_delete |
src/cluster_erp/core/attachments.py:163-166 |
get_file_data |
Loads the bytes from storage | src/cluster_erp/core/attachments.py:168-169 |
replace_file |
Deletes the old stored file, saves the new one, and updates the row | src/cluster_erp/core/attachments.py:171-197 |
count_for_entity |
Number of live attachments of a record | src/cluster_erp/core/attachments.py:199-206 |
Attachment routes¶
The router prefix is /api/v1/attachments (src/cluster_erp/server/routes/attachments.py:27).
| Method and path | Permission | Purpose | Source |
|---|---|---|---|
POST / (multipart: entity_type, entity_id, description, file) |
attachment.create |
Upload a file | src/cluster_erp/server/routes/attachments.py:62-85 |
GET /count/{entity_type}/{entity_id} |
attachment.read |
Count a record's files | src/cluster_erp/server/routes/attachments.py:88-99 |
GET /{attachment_id}/download |
attachment.download |
Download the bytes | src/cluster_erp/server/routes/attachments.py:102-121 |
GET /{entity_type}/{entity_id} |
attachment.read |
List a record's files | src/cluster_erp/server/routes/attachments.py:124-135 |
PUT /{attachment_id}/replace |
attachment.update |
Replace the file | src/cluster_erp/server/routes/attachments.py:138-162 |
PUT /{attachment_id} |
attachment.update |
Change description or name | src/cluster_erp/server/routes/attachments.py:165-181 |
POST /bulk-delete |
attachment.bulk_delete |
Deactivate many | src/cluster_erp/server/routes/attachments.py:184-199 |
DELETE /{attachment_id} |
attachment.delete |
Deactivate one | src/cluster_erp/server/routes/attachments.py:202-215 |
A download of a row whose file is missing answers FILE_NOT_FOUND_ON_DISK with HTTP 404 (src/cluster_erp/server/routes/attachments.py:114-115).
Upload policy¶
check_upload(filename, data, allowed) returns the accepted extension or raises AppError with code UPLOAD_TYPE_REFUSED and HTTP 415 (src/cluster_erp/core/upload_policy.py:65-84). It applies two checks:
- The lower-case extension must be in the allow list: the setting
attachment_allowed_extensions, or the 40 defaults inDEFAULT_ALLOWED_EXTENSIONS(src/cluster_erp/core/upload_policy.py:21-27,src/cluster_erp/config/settings.py:368-374). - For 24 extensions with a known file signature, the content must start with that signature (
src/cluster_erp/core/upload_policy.py:30-55,src/cluster_erp/core/upload_policy.py:76-82).
The upload route calls check_upload with the configured list (src/cluster_erp/server/routes/attachments.py:73-75). The module docstring states that executables and scripts are not on the list (src/cluster_erp/core/upload_policy.py:1-10).
Notes¶
core_notes derives from BaseModel and adds code, entity_type, entity_id, and content (src/cluster_erp/core/notes.py:17-23). NoteCreate takes entity_type (at most 50 characters), entity_id, and a non-empty content; NoteUpdate takes content (src/cluster_erp/core/notes.py:26-33). NoteService lists, gets, creates (with a code from the note sequence), updates, deactivates, bulk-deactivates, and counts notes (src/cluster_erp/core/notes.py:48-114).
Note routes¶
The router prefix is /api/v1/notes (src/cluster_erp/server/routes/notes.py:14).
| Method and path | Permission | Purpose | Source |
|---|---|---|---|
GET /{entity_type}/{entity_id} |
note.read |
List a record's notes | src/cluster_erp/server/routes/notes.py:33-43 |
GET /count/{entity_type}/{entity_id} |
note.read |
Count a record's notes | src/cluster_erp/server/routes/notes.py:46-56 |
POST / |
note.create |
Create a note | src/cluster_erp/server/routes/notes.py:59-68 |
PUT /{note_id} |
note.update |
Change a note | src/cluster_erp/server/routes/notes.py:71-86 |
POST /bulk-delete |
note.bulk_delete |
Deactivate many | src/cluster_erp/server/routes/notes.py:89-101 |
DELETE /{note_id} |
note.delete |
Deactivate one | src/cluster_erp/server/routes/notes.py:104-116 |
Storage backends¶
StorageBackend is a protocol with save, load, delete, and exists (src/cluster_erp/core/storage.py:11-18). The lifespan creates one backend per process and stores it on app.state.storage (src/cluster_erp/server/app.py:65-75, src/cluster_erp/server/app.py:92).
| Class | Stores in | Notes | Source |
|---|---|---|---|
LocalStorageBackend |
A folder on disk (storage_path) |
Refuses keys containing .. or resolving outside the root |
src/cluster_erp/core/storage.py:21-48 |
S3StorageBackend |
An S3 or S3-compatible bucket | Optional key prefix; blocking client calls run in a thread | src/cluster_erp/core/storage.py:51-135 |
EncryptedStorageBackend |
Wraps either backend | Seals every saved object with AES-256-GCM, using the storage key as associated data; reads legacy plaintext objects unchanged | src/cluster_erp/core/storage.py:138-174 |
create_storage_backend picks S3 when storage_backend is s3, else local storage. It wraps the result in EncryptedStorageBackend when storage_encryption_key is set, and logs a warning when it is not (src/cluster_erp/core/storage.py:177-209). The settings validator requires the key in multi-tenant mode (src/cluster_erp/config/settings.py:805-809). Key handling is documented in second factor and secret storage.
Constraints and validation¶
- An attachment row and its file are written in the same request; the file is saved before the row is flushed (
src/cluster_erp/core/attachments.py:126-142). - Deactivating an attachment does not remove the stored file (
src/cluster_erp/core/attachments.py:155-161). Removal of files at retention time belongs to therecordsmodule; see records.
Terms introduced¶
| Term | Code identifier | Definition | Turkish term | Source |
|---|---|---|---|---|
| attachment | Attachment |
A file linked to any record by entity type and id. | ek | src/cluster_erp/core/attachments.py:51 |
| note | Note |
A text note linked to any record by entity type and id. | not | src/cluster_erp/core/notes.py:17 |
| storage backend | StorageBackend |
The interface that saves and loads file bytes by key. | depolama arka ucu | src/cluster_erp/core/storage.py:11 |
Related pages¶
Verified against commit 92f8051 on 2026-11-13.