In Cluster ERP a "delete" from the user's side deactivates a record: the row stays and gets a deactivation time, actor, and reason. This page explains the core mechanism in core/models.py, core/change_history.py, core/bulk_operations.py, and the start-up column sweep, and it lists the places that still remove rows. Reactivation, the reason catalogue, and the retention purge belong to the records module; see records.
In short¶
deleted_at IS NULLmeans active; a value means deactivated (src/cluster_erp/core/models.py:49-57).- A deactivation records
deleted_by,deactivation_reason, anddeactivation_noteon the row (src/cluster_erp/core/models.py:58-72,src/cluster_erp/core/change_history.py:242-259). - The reason and note travel as the request headers
X-Deact-ReasonandX-Deact-Note(src/cluster_erp/server/dependencies.py:64-72). - Bulk deactivation loads the objects and sets
deleted_atthrough the ORM, so the change history and the reason stamp apply to every row (src/cluster_erp/core/bulk_operations.py:20-55). - Unique constraints apply only to live rows, through partial indexes with
WHERE deleted_at IS NULL, for examplesrc/cluster_erp/core/currencies.py:61-74.
How it works¶
The deactivation flow¶
The sequence diagram shows one deactivation request.
sequenceDiagram participant C as Client participant D as get_db_session participant S as Service participant H as before_flush hook participant DB as PostgreSQL C->>D: DELETE with X-Deact-Reason, X-Deact-Note D->>D: set_deactivation_context(reason, note) D->>S: session S->>S: obj.deleted_at = now S->>H: flush H->>H: stamp deleted_by, deactivation_reason, deactivation_note H->>DB: UPDATE row and INSERT core_change_history (action deactivate)
In prose:
- The session dependency reads the two headers, URL-decodes them, and stores them in the request context (
src/cluster_erp/server/dependencies.py:64-72). - A service sets
deleted_at. Examples:AttachmentService.deleteandNoteService.delete(src/cluster_erp/core/attachments.py:155-161,src/cluster_erp/core/notes.py:94-100). - At flush, the change-history hook detects the transition from
NULLto a value (src/cluster_erp/core/change_history.py:229-239). It stampsdeleted_bywith the actor, anddeactivation_reasonanddeactivation_notefrom the context, when they are empty (src/cluster_erp/core/change_history.py:242-259,src/cluster_erp/core/change_history.py:336-337). - The hook classifies the change as
deactivate; a transition back toNULLisactivate(src/cluster_erp/core/change_history.py:197-213). See change history.
Bulk deactivation¶
bulk_soft_delete(session, model_class, ids, deleted_by=None) selects the live rows among ids, sets deleted_at (and deleted_by when given) on each, flushes, and returns the count (src/cluster_erp/core/bulk_operations.py:20-55). Its docstring states why it does not issue one UPDATE: a Core update bypasses the before_flush hook, so the history and the reason stamp would be lost (src/cluster_erp/core/bulk_operations.py:26-34). 54 files outside core/bulk_operations.py call it (counted with grep -rln bulk_soft_delete). BulkDeleteRequest and BulkDeleteResponse are the shared request and response schemas (src/cluster_erp/core/bulk_operations.py:12-17).
Deactivation columns on every table¶
At start-up the server runs an idempotent SQL block that adds deactivation_reason VARCHAR(64) and deactivation_note TEXT to every public table that has a deleted_at column but lacks them (src/cluster_erp/server/app.py:1111-1149, src/cluster_erp/server/app.py:1152-1166). The block takes a transaction-level advisory lock so that two workers starting together do not deadlock (src/cluster_erp/server/app.py:1111-1115, src/cluster_erp/server/app.py:1121).
Live-row uniqueness¶
A deactivated row must not block its unique value. Models declare unique indexes with postgresql_where=text("deleted_at IS NULL"). 120 lines in src/cluster_erp/modules and src/cluster_erp/core contain both postgresql_where and deleted_at IS NULL (counted with grep). Example: uq_core_currencies_code_live (src/cluster_erp/core/currencies.py:59-74). When an insert still collides, the error handler answers DB_UNIQUE_VIOLATION with HTTP 409 and names the constraint (src/cluster_erp/server/error_handler.py:352-390).
Reactivation and passive lists¶
The records module serves generic endpoints that list deactivated records of an entity, resolve labels, and reactivate a record (src/cluster_erp/modules/records/lifecycle_routes.py:114, src/cluster_erp/modules/records/lifecycle_routes.py:175, src/cluster_erp/modules/records/lifecycle_routes.py:224). The server builds the map from (module, entity) to model at start-up (src/cluster_erp/server/app.py:1090-1099).
Physical removal¶
The retention purge in the records module removes deactivated rows and change-history rows older than the configured window. Its docstring calls it "the ONLY place a record is ever physically deleted" (src/cluster_erp/modules/records/retention/purge.py:1-7). The server runs it daily (src/cluster_erp/server/app.py:404-416). See scheduled jobs and retention.
orm_delete_where(session, model_class, *where) deletes rows through the ORM, so each removal still gets a delete change-history row. Its docstring limits it to child rows whose recoverable unit is the parent record (src/cluster_erp/core/bulk_operations.py:58-75). It is called from calendars, costing, and production (src/cluster_erp/modules/calendars/services.py:186, src/cluster_erp/modules/costing/costing_analyses/services.py:318, src/cluster_erp/modules/production/work_centers/services.py:159).
Why it is built this way¶
The project's written engineering rules forbid hard delete: a delete is a removal from use with a reason, and history is kept. The comment in src/cluster_erp/core/models.py:49-53 restates this for the columns. The bulk_soft_delete docstring gives the reason for going through the ORM (src/cluster_erp/core/bulk_operations.py:26-34).
Rules and boundaries¶
- A deactivation reason is a code from the catalogue in
core_deactivation_reasons; a free-text note is required only when the reason demands it (src/cluster_erp/core/models.py:62-64). The catalogue is documented on records. - The change history also treats an
is_activeflag going from true to false as a deactivation whendeleted_atdid not move (src/cluster_erp/core/change_history.py:205-212).
Interaction with other parts¶
- Change history records every deactivation and activation.
- Base model and ids lists the columns.
- Search, pagination, and bulk operations covers the bulk schemas.
- The web app sends the reason headers; see the web app section.
Limits and open points¶
- Whether every module service filters
deleted_at IS NULLin its default lists was not audited for this page. - Whether the rows removed by
sa.deleteon analytics summary cells and intranet analytics events count as records under the rule is not determined from code.
Terms introduced¶
| Term | Code identifier | Definition | Turkish term | Source |
|---|---|---|---|---|
| deactivation | deleted_at |
Removing a record from use by setting its deactivation time; the row stays. | kullanımdan kaldırma | src/cluster_erp/core/models.py:49-57 |
| deactivation reason | deactivation_reason |
A catalogue entry that says why a record was removed from use. | devre dışı bırakma nedeni | src/cluster_erp/core/models.py:62-68 |
| live-row uniqueness | postgresql_where partial index |
A unique index that ignores deactivated rows. | canlı satır benzersizliği | src/cluster_erp/core/currencies.py:61-74 |
Related pages¶
Verified against commit 92f8051 on 2026-11-13.