Metadata-Version: 2.5
Name: django-softdelete-model
Version: 0.1.0
Summary: Soft deletion for Django that deletes in bulk, walks the cascade, and honours PROTECT.
Project-URL: Homepage, https://github.com/farannegarestan/django-softdelete-model
Project-URL: Documentation, https://github.com/farannegarestan/django-softdelete-model#readme
Project-URL: Changelog, https://github.com/farannegarestan/django-softdelete-model/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/farannegarestan/django-softdelete-model/issues
Project-URL: Source, https://github.com/farannegarestan/django-softdelete-model
Author: Faran Negarestan
License-Expression: MIT
License-File: LICENSE
Keywords: cascade,django,restore,soft delete,soft-delete,trash
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: django>=4.2
Provides-Extra: test
Requires-Dist: pytest-django>=4.8; extra == 'test'
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown

# django-softdelete-model

Soft deletion for Django that deletes **in bulk**, walks the **cascade** itself,
and keeps `on_delete=PROTECT` meaning something.

```python
from django_softdelete_model import SoftDeleteModel


class Question(SoftDeleteModel):
    name = models.CharField(max_length=200)


question.delete()  # marked, not removed — and so are its answers
Question.objects.count()  # deleted rows are gone from view
Question.all_objects.count()  # …but still there
question.restore()  # brings the cascade back with it
```

---

## Why another one

Soft deletion is easy to implement badly in three specific ways, and this
package exists because of them.

**Queryset deletes are a separate code path.** `Model.delete()` and
`QuerySet.delete()` share nothing in Django — a queryset delete never calls the
model's method. An implementation that overrides only the model leaves
`Thing.objects.filter(...).delete()` issuing a real `DELETE`, silently defeating
the whole mechanism on the call people reach for most. Both are overridden here.

**Cascades are the slow part.** The obvious implementation walks the relations
per row, so deleting a parent with 175 children costs a query per child and per
grandchild. This one issues a single `UPDATE` per relation:

| deleting one parent with 175 children and 175 grandchildren | queries |
| --- | --- |
| per-row cascade | ~1,600 |
| `django-softdelete-model` | **5** |

**`on_delete` stops applying.** It is a *delete-time* behaviour, and a soft
delete is an `UPDATE`, so the database never consults it and neither does
Django's collector. Without help, soft-deleting a row something protects quietly
succeeds and leaves a dangling reference the schema says cannot exist. Here,
PROTECT and RESTRICT raise Django's own `ProtectedError` and `RestrictedError`
— **before anything is written**, so a refused delete leaves no trace.

---

## Install

```bash
pip install django-softdelete-model
```

```python
INSTALLED_APPS = [
    ...
    "django_softdelete_model",
]
```

Installing the app is optional but recommended: it registers the system checks,
which are the part that catches the mistake you cannot otherwise see.

Requires Django 4.2+ and Python 3.10+. No database-specific features — the test
suite runs on both SQLite and PostgreSQL.

---

## Usage

### The model

```python
from django.db import models
from django_softdelete_model import SoftDeleteModel


class Question(SoftDeleteModel):
    name = models.CharField(max_length=200)


class Answer(SoftDeleteModel):
    question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="answers")
```

You get one column, `deleted_at` (nullable, indexed), and three managers:

| manager | sees |
| --- | --- |
| `objects` | live rows only |
| `all_objects` | everything |
| `deleted_objects` | only what has been deleted — a "trash" view |

### Deleting and restoring

```python
question.delete()  # → (3, {"app.Question": 1, "app.Answer": 2})
question.delete(hard=True)  # a real DELETE, cascades and all

Question.objects.filter(...).delete()  # bulk, still soft
Question.objects.filter(...).hard_delete()  # bulk, really gone

question.restore()  # brings back the children it took with it
question.restore(cascade=False)  # just this row
Question.deleted_objects.restore()  # bulk; no cascade — see below
```

`instance.delete()` returns `(count, {label: count})` like Django's.
`queryset.delete()` returns the **number of rows stamped on that model**, which
is what `update()` returns — the parent rows are what you asked about, and
counting the cascade would mean a second pass purely to report it.

### Multiple databases

Every query a delete or restore makes goes to the database the queryset or
instance belongs to, including the cascade and the PROTECT check:

```python
Question.objects.using("replica").filter(...).delete()  # stays on the replica
question.delete(using="replica")
question.restore(using="replica")
```

Without a `using`, the database is chosen the way Django's own `delete()` chooses
it — through `router.db_for_write()`, then the instance's own connection.

### It refuses what Django refuses

`delete()` raises on a sliced or `distinct()` queryset, and on an unsaved
instance, with the same errors Django gives. The override behaves like the
method it replaces, minus the part that actually removes rows.

### Restoring is timestamp-matched

A cascade stamps one timestamp across the parent and its children, and
`restore()` only brings back children carrying *that* timestamp. So a child you
deleted last week stays deleted when you restore its parent today — it was not
part of this delete and bringing it back would undo an unrelated decision.

`queryset.restore()` deliberately does **not** cascade: a queryset of deleted
rows has no single deletion moment to match against, so restoring their children
would mean guessing. Call `instance.restore()` per row when you want the
cascade.

---

## Three things soft deletion does not do for you

These are properties of the idea, not of this package. No library can take them
off your hands — but knowing about them is most of the battle, and all three
fail *silently*.

### 1. Aggregates ignore managers

`Count("answers")` compiles to a SQL join. It never consults `Answer.objects`,
so deleted rows keep counting. The page still renders; the number is just wrong.

```python
from django_softdelete_model import live

Question.objects.annotate(n=Count("answers", filter=live("answers")))  # right
Question.objects.annotate(n=Count("answers"))  # silently wrong
```

`live()` takes several relations when a chain of joins needs them:

```python
# A citation counts while the answer it belongs to also stands.
Count("answers__citations", filter=live("answers__citations", "answers"))
```

### 2. Unique constraints still apply to deleted rows

A soft-deleted row keeps occupying its slot, so deleting a thing and recreating
it collides. Declare constraints partially:

```python
class Meta:
    constraints = [
        models.UniqueConstraint(
            fields=["name"],
            condition=models.Q(deleted_at__isnull=True),
            name="question_name_unique_alive",
        )
    ]
```

The same goes for indexes you want to stay small — add
`condition=Q(deleted_at__isnull=True)`.

### 3. The cascade walks one level

Deleting a question stamps its answers. A row hanging off an *answer* is not
reached, because a deep cascade means either loading the whole tree or issuing a
query per level, and the shallow one covers the common case at a fixed cost.

Rather than deepen it, say what your counts actually mean — that is the second
form of `live()` above. If you need the depth, override `delete()` and call
`super()` first.

---

## Manager order matters

`objects` must resolve to a `SoftDeleteManager`. If another abstract base in
your bases also declares `objects`, Django keeps the first one it finds walking
the MRO:

```python
class Question(SoftDeleteModel, YourBaseModel):   # right
class Question(YourBaseModel, SoftDeleteModel):   # silently unfiltered
```

The second form looks fine and behaves fine until you notice deleted rows in
every list. The `softdelete.E001` system check refuses to start on it.

### Composing your own manager

`SoftDeleteManager` is an ordinary manager — subclass it:

```python
from django_softdelete_model import SoftDeleteManager


class AuthoredManager(SoftDeleteManager):
    def bulk_create(self, objs, *args, **kwargs):
        for obj in objs:
            obj.created_by = obj.created_by or current_user()
        return super().bulk_create(objs, *args, **kwargs)


class Question(SoftDeleteModel):
    objects = AuthoredManager()
    ...
```

---

## System checks

| id | level | catches |
| --- | --- | --- |
| `softdelete.E001` | error | `objects` does not filter deleted rows — usually base-class order |
| `softdelete.E002` | error | no `all_objects`, so deleted rows are unreachable |
| `softdelete.W001` | warning | a CASCADE child that is not itself soft-deletable |

`W001` is a warning rather than an error because it may be deliberate. Deleting
the parent leaves those children pointing at a row that no longer resolves
through `objects`; the cascade will not hard-delete them, because destroying rows
as a side effect of a reversible operation is the wrong surprise. Either make the
child soft-deletable or change the relation to `SET_NULL` — or silence it with
`SILENCED_SYSTEM_CHECKS` if you have decided.

---

## What is *not* handled

Being explicit, so nothing is a surprise:

- **Many-to-many** relations. `through` rows are not stamped.
- **Generic relations.** Nothing follows a `GenericForeignKey`.
- **Multi-table inheritance** parent links.
- **Deep cascades**, deliberately — see above.
- **`Model.objects.update()`** and raw SQL, which bypass everything by design.

---

## Migrating an existing model

Adding `SoftDeleteModel` to a model that already exists adds one nullable
column, so the migration is cheap:

```bash
python manage.py makemigrations
python manage.py migrate
```

Existing rows get `deleted_at = NULL`, which means live — the state you want.
Then add partial constraints for any `unique=True` field, since those now need
to ignore deleted rows.

---

## Development

```bash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[test]"
pytest
```

The suite runs on SQLite by default so a clean checkout needs no services. To
run it against PostgreSQL, as CI does:

```bash
SOFTDELETE_TEST_POSTGRES=1 pytest
```

See [CONTRIBUTING.md](CONTRIBUTING.md).

---

## Licence

MIT. See [LICENSE](LICENSE).
