Metadata-Version: 2.4
Name: django-guitars
Version: 0.6.0
Summary: A kit of reusable Django utilities: base models, PostgreSQL soft deletion, and more.
Project-URL: Homepage, https://github.com/Behnam-RK/django-guitars
Project-URL: Repository, https://github.com/Behnam-RK/django-guitars
Project-URL: Issues, https://github.com/Behnam-RK/django-guitars/issues
Project-URL: Changelog, https://github.com/Behnam-RK/django-guitars/blob/main/CHANGELOG.md
Author-email: Behnam RK <behnam.rk47@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: django,guitars,reusable-app
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
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: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Requires-Dist: django>=5.0
Description-Content-Type: text/markdown

# django-guitars

🎸 *Django object-metadata the **database** enforces — not your `.save()` method.*

Most Django soft-delete and timestamp libraries live in Python: a signal here, a
`save()` override there. It holds up right until a `bulk_update`, a raw `UPDATE`,
or a `queryset.delete()` strolls straight past your code — and leaves the
metadata lying.

**django-guitars pushes that work down into PostgreSQL itself** — rules and
triggers, not signals. So `_created_at` / `_updated_at` / `_deleted_at` stay
honest no matter how a row gets touched: ORM, bulk, raw SQL, all of it. The
database keeps score; you just write models. Use only the pieces you need.

[![PyPI version](https://img.shields.io/pypi/v/django-guitars.svg)](https://pypi.org/project/django-guitars/)
[![Python versions](https://img.shields.io/pypi/pyversions/django-guitars.svg)](https://pypi.org/project/django-guitars/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

## Requirements

- **Python** ≥ 3.10
- **Django** ≥ 5.0 — uses `db_default`
- **PostgreSQL** — currently the only supported backend; the soft-delete rule and
  `_updated_at` trigger live in the database itself. Other backends are on the
  roadmap.

> **Status:** early days (alpha). The API may still shift between minor versions.

## Installation

```bash
pip install django-guitars
```

Add the app to your settings:

```python
INSTALLED_APPS = [
    # ...
    "guitars",
]
```

## Pick your instrument

The base models are named after string instruments, fewest strings to most — and
the strings *are* the feature ladder. (`du` = two, `se` = three in Persian; `tar`
= "string". A guitar has six. Django Reinhardt, the jazz guitarist this whole
package winks at, would approve.)

| Base | Strings | What you get |
| --- | :---: | --- |
| `DutarModel`  | 2 | `.update()` / `.aupdate()` and cached-property invalidation on `refresh_from_db()`. The featherweight — adds no columns. |
| `SetarModel`  | 3 | Everything in `DutarModel` **plus** DB-managed `_created_at` / `_updated_at` (default `NOW()`; `_updated_at` is ridden by a statement trigger, so it's right even under bulk/raw updates) and `app_label()` / `model_name()` / `class_name()` helpers. |
| `GuitarModel` | 6 | Everything in `SetarModel` **plus** PostgreSQL soft deletion. The full kit. |

Prefer to tune your own chord? Each capability is a standalone mixin in
`guitars.models`: `UpdatableModel`, `HasCachedPropertyModel`, `DatedModel`, and
`SoftDeletableModel`.

```python
from django.db import models

from guitars.models import GuitarModel


class Article(GuitarModel):
    title = models.CharField(max_length=200)
```

### `.update()` — set and save in one strum

Available on every rung (it comes from `DutarModel`):

```python
article.update(title="New title")         # set fields + save (only changed fields)
article.update(title="x", _save=False)    # change in memory only, no DB write
await article.aupdate(title="async")      # async variant
```

> Note: attributes set with `_save=False` are **not** carried into a later
> `_save=True` call unless you also pass `_save_all_fields=True`.

### Soft deletion

For models inheriting `SoftDeletableModel` (or `GuitarModel`), `.delete()` becomes
a **soft delete**: the row stays and `_deleted_at` is set. Because a PostgreSQL
rule does the work, it holds even for queryset bulk deletes and raw SQL — there's
no `.save()` to skip. Three managers expose the data:

```python
Article.objects.all()         # live rows only (the default manager)
Article._archives.all()       # soft-deleted rows only
Article._all_objects.all()    # everything

article.delete()              # soft delete — sets _deleted_at
article.is_deleted            # True
article.is_alive              # False

# Actually want it gone? hard_delete bypasses the rule (and takes CASCADE kids with it):
article.hard_delete()                           # this row + CASCADE children
Article._all_objects.filter(...).hard_delete()  # in bulk
```

Soft-deleting a row also soft-deletes rows related by `on_delete=CASCADE`.

> ⚠️ **Required setup.** The soft-delete rule (and the `_updated_at` trigger) live
> in a migration generated by [`makeguitarmigrations`](#makeguitarmigrations).
> By default `makemigrations` generates it for you (see below); until it's
> created and you `migrate`, **`.delete()` permanently deletes the row** — the
> protection isn't wired up yet.

### `makeguitarmigrations`

The triggers and rules don't come from plain Django schema migrations — they
live in separate migrations generated by this command, and they're **required**
for soft deletion and the `_updated_at` trigger to work.

By default you don't run it directly: `makemigrations` is extended to generate
these advanced migrations right after the core ones, so a single command keeps
both in sync:

```bash
python manage.py makemigrations   # generates core + trigger/rule migrations
```

Prefer the explicit two-command workflow? Turn the extension off and run the
command yourself:

```python
# settings.py
GUITARS_AUTO_MAKE_MIGRATIONS = False   # defaults to True
```

```bash
python manage.py makemigrations
python manage.py makeguitarmigrations
```

Either way, the command scans your **first-party** apps for models with
`_updated_at` / `_deleted_at` and writes the matching trigger/rule migrations.
Tell it which apps are yours:

```python
# settings.py
LOCAL_APPS = ["blog", "shop"]      # apps the command scans

# Optional: which app hosts the shared trigger-function migration.
# Defaults to LOCAL_APPS[0].
# TRIGGER_FUNCTION_APP = "blog"
```

Both commands accept optional app labels to scope generation, mirroring
Django's own `makemigrations`: `makemigrations blog` (or `makeguitarmigrations
blog`) only touches `blog`, and an unknown label is rejected the same way
Django's is. With no labels, every app in `LOCAL_APPS` is scanned.

> **Cross-app cascade rules:** a soft-delete cascade rule (e.g. "deleting a
> `Band` cascades to its `Album`s") is written into the *parent* model's
> migration — `Band`'s app here. If that parent's app isn't named in a scoped
> run, the cascade rule is skipped even when the child's app is; the command
> prints a warning naming the skipped rule and the app to include. Run without
> labels, or name the parent's app, to close the gap.

Use `--check` in CI to fail when advanced migrations are missing. With the
extension on, `makemigrations --check` validates **both** the core and the
trigger/rule migrations; the standalone form still works too:

```bash
python manage.py makemigrations --check       # checks both layers
python manage.py makeguitarmigrations --check # checks the trigger/rule layer only
```

### `DisableSignals`

A context manager that temporarily disconnects Django signals — handy for bulk
imports or silent saves:

```python
from django.db.models.signals import post_save

from guitars.signals import DisableSignals

with DisableSignals():                    # all default signals
    instance.save()                       # nothing fires

with DisableSignals(signals=[post_save]): # only the listed signals
    instance.save()
```

## Development

Requires [uv](https://docs.astral.sh/uv/) and Docker (for PostgreSQL).

```bash
uv sync                  # install dependencies + the package (editable)
docker compose up -d     # start PostgreSQL (skip if you already run one on :4455)
uv run pytest            # run the test suite
uv run pytest --cov=guitars --cov-report=term-missing
```

The test suite defines concrete models in `tests/testapp` (the shipped package is
abstract-only) and runs against a real PostgreSQL database, so the rules and
triggers are actually exercised — not mocked.

### Releasing

Two interactive helpers in [`scripts/`](scripts/README.md) drive a release:

```bash
./scripts/bump.sh minor   # bump pyproject.toml + seed CHANGELOG, then commit
$EDITOR CHANGELOG.md       # write the release notes
./scripts/release.sh       # git tag + push + GitHub release (via gh)
```

`pyproject.toml` is the single source of truth for the version —
`guitars.__version__` reads it from the installed package metadata. See
[`scripts/README.md`](scripts/README.md) for details.

## License

[MIT](LICENSE) © 2026 Behnam RK
