Metadata-Version: 2.4
Name: zibanu-django-logging
Version: 2.0.0
Summary: Zibanu logging library for django projects
Author-email: Mario Cerón <mario.ceron@cqinversiones.co>
License-Expression: GPL-3.0-or-later
Keywords: django,zibanu,library
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: Django
Classifier: Framework :: Django :: 3.2
Classifier: Framework :: Django :: 4
Classifier: Framework :: Django :: 5
Classifier: Environment :: Web Environment
Classifier: Operating System :: OS Independent
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=3.2
Requires-Dist: djangorestframework
Requires-Dist: djangorestframework-simplejwt
Requires-Dist: zibanu-django>=3.0.0
Dynamic: license-file

# Zibanu Django Logging

Optional Zibanu Django package that persists application events, mail-delivery status and per-model audit trails to the database.

## Overview

`zibanu.django.logging` turns runtime events into stored database rows. It provides three complementary mechanisms:

- **Database log handler** — `DbHandler`, a `logging.Handler` subclass referenced from Django's `LOGGING` configuration, so records emitted through the standard `logging` module are written as `Log` rows.
- **Mail logging** — the `mail_sent` signal (its historical name, `send_mail`, is kept as a deprecated alias — see API Reference) and its `on_mail_sent` receiver record each outgoing mail's SMTP result as a `Log` plus a linked `MailLog`.
- **Model auditing** — `AuditEntity` rows select which models are audited and on which events; a signal factory wires the `audit_model` receiver to those models' `post_save` / `pre_save` / `post_delete` signals, writing a `Log` row (with old/new values) for each change. The set of audited models is managed from the Django admin.

This package exposes **no REST endpoints**; it is a persistence and signal layer only.

## Installation

Install the package from its standalone installer:

```bash
pip install ./installers/zibanu-django-logging/
```

Add it to `INSTALLED_APPS` in `mainApp/components/base.py` (the app label is `zb_logging`):

```python
INSTALLED_APPS = [
    # ... existing apps ...
    "zibanu.django.logging",
]
```

Run migrations to create the `Log`, `MailLog` and `AuditEntity` tables:

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

## Quick Start

Route the standard `logging` module into the database by adding the handler to your Django `LOGGING` setting:

```python
LOGGING = {
    "version": 1,
    "disable_existing_loggers": True,
    "formatters": {
        "simple": {
            "format": "[{levelname}] - {asctime} - {module}: {message}",
            "style": "{",
        },
    },
    "handlers": {
        "zibanu": {
            "level": "DEBUG",
            "class": "zibanu.django.logging.lib.handlers.DbHandler",
            "formatter": "simple",
        },
    },
    "loggers": {
        # Do not route the SQL logger through DbHandler: emit() itself runs an
        # INSERT, so letting django.db.backends propagate to the root handler
        # would feed the handler its own queries.
        "django.db.backends": {
            "handlers": [],
            "propagate": False,
        },
    },
    "root": {
        "handlers": ["zibanu"],
        "level": "DEBUG",
    },
}
```

> **Transactional semantics.** `DbHandler` writes synchronously on the
> caller's database connection, inside the caller's transaction: a record
> logged within an `atomic()` block that rolls back is lost with the
> rollback, and once a transaction is broken the insert fails and the record
> is dropped (routed to `handleError`). Records that must survive a rollback
> need an out-of-band channel (file handler, external service).

Any log call now creates a `Log` row:

```python
import logging

logging.getLogger(__name__).info("User profile updated")
```

## Features

- `DbHandler` logging handler that persists each emitted `LogRecord` as a `Log` row; failures are routed to `handleError`, so logging never breaks the caller.
- `mail_sent` signal + `on_mail_sent` receiver that record SMTP delivery status (`Log` + `MailLog`) atomically. Deliberately **not** resilient (unlike `DbHandler` and `audit_model` below): a persistence failure propagates, with its original cause intact, to whoever called `mail_sent.send()`.
- Per-model auditing driven by `AuditEntity` configuration, with create / update / delete captured as `Log` rows including serialized `new_values` and (on update) `old_values`.
- Audit detail serialization is resilient: an unserializable field value degrades the row's `detail` to an error payload instead of aborting the audited model's `save()`/`delete()`, and GIS geometry fields (`GEOSGeometry`/`Point`/`Polygon`/`MultiPolygon`, via the optional `zibanu.django.gis` package) are serialized as GeoJSON.
- Sensitive fields (`password`, `token`, `secret`, `api_key`, `private_key` by default) are masked with a fixed placeholder before an audit payload is written, including inside any model nested in the dump — configurable via `ZB_LOGGING_REDACTED_FIELDS`.
- Runtime rewiring of audit signals from the Django admin — no restart needed when the audit configuration changes.
- Startup wiring deferred to a background thread that waits for the app registry, so audit signals attach only once every app is fully loaded.

## Usage

### Basic Usage — logging to the database

With `DbHandler` configured (see Quick Start), standard `logging` calls are persisted. To attach a user and source IP to a record, pass a **single dict** as the log arguments — `DbHandler.emit` reads `user_id` and `ip_address` from it when `record.args` is a dict:

```python
import logging

logging.getLogger(__name__).info(
    "Sensitive action performed",
    {"user_id": request.user.id, "ip_address": "203.0.113.7"},
)
```

### Basic Usage — recording mail delivery

Dispatch the `mail_sent` signal after attempting to send a mail; the `on_mail_sent` receiver stores a `Log` and a linked `MailLog`:

```python
from zibanu.django.logging.lib.signals import mail_sent

mail_sent.send(
    sender=MyMailerClass,
    mail_from="noreply@example.com",
    mail_to=["user@example.com"],       # list; stored joined by ";"
    subject="Welcome",
    smtp_error="",
    smtp_code=0,
)
```

### Advanced Usage — auditing a model

1. Ensure `zibanu.django.logging` is in `INSTALLED_APPS` and migrated.
2. In the Django admin, open **Audit Entities**, add an entry, pick the target model (dotted `app_label.ModelName`) from the live choice list, and check the events to audit (`on_create`, `on_update`, `on_delete`). Keep `enabled` checked.
3. Saving the entity calls `signals_factory()`, which rewires the audit signals immediately — no restart required.

From then on, each configured event writes **exactly one** `Log` row — an UPDATE fires both `pre_save` (`on_update`) and `post_save` (`on_create`, `created=False`) on the audited model, but only the former populates `detail`, so the event is recorded once, not twice. That row's `detail` is a JSON payload containing `new_values` (and, for updates on an existing row, `old_values`), with the acting user and source IP resolved from the current request stack.

Audit entities can also be created programmatically:

```python
from zibanu.django.logging.models import AuditEntity

AuditEntity.objects.create(
    model_name="auth.User",
    on_create=True,
    on_update=True,
    on_delete=True,
    enabled=True,
)
# Rewire signals so the new rule takes effect without a restart:
from zibanu.django.logging.lib import signals_factory

signals_factory()
```

> **Behavior — disabling an entity.** Calling `signals_factory()` without a `model` argument processes **every** `AuditEntity` row, enabled or not: each one is unconditionally disconnected first, then reconnected only if it is still `enabled`. So setting an entity's `enabled=False` **does** disconnect its previously wired handlers, the next time `signals_factory()` runs with no `model` argument — which happens automatically on every admin save/delete of any entity, including the one just disabled. Deleting the entity (`signals_factory(model=obj, delete=True)`) also disconnects it, immediately and without waiting for a general rewire.

## API Reference

### Models (`zibanu.django.logging.models`)

- **`Log`** — extends `zibanu.django.db.DatedModel` (auto `created_at` / `modified_at`). Fields: `action`, `sender`, `detail` (text, default `""`), `ip_address` (nullable), `user` (FK to the user model, nullable, `on_delete=SET_NULL` — the log row survives deletion of the referenced user). Indexed on `user`, `action` and `created_at`.
- **`MailLog`** — extends `zibanu.django.db.Model`. One-to-one `log` link to `Log` with `on_delete=PROTECT` (a `Log` cannot be deleted while a `MailLog` references it). Fields: `mail_from`, `mail_to`, `subject`, `smtp_code` (default `0`), `smtp_error` (nullable).
- **`AuditEntity`** — extends `zibanu.django.db.Model`. Fields: `model_name` (unique dotted `app_label.ModelName`), booleans `on_create` / `on_update` / `on_delete`, and `enabled` (default `True`). Ordered by `model_name`. `save()` normalizes `model_name` before persisting: a name that resolves against the app registry (case-insensitively) is rewritten to the model's canonical `Model._meta.label`, so case variants of the same model collapse to one string and `unique=True` holds under any database collation; a name that does **not** resolve (stale or mistyped) is instead lower-cased in place — it still can't be resolved to a canonical label, but folding its case still makes two variants of the same unresolvable name collide under `unique=True` regardless of the database's collation.

> **Database engine.** All three models persist exclusively through the Django ORM on top of the base package's `Model` / `DatedModel` classes — no raw SQL, no engine-specific `RunSQL` migration operations, no required database extension, and no declared dependency on a concrete driver (`psycopg`, `mysqlclient`, `oracledb`, …). The database alias is resolved through the base package's `use_db` attribute (defaults to `"default"`, never overridden here); this package declares no `DATABASE_ROUTERS` of its own, so the host project's multi-database routing is respected as-is.

> **Retention.** This package provides no purge mechanism for `Log`, `MailLog` or `AuditEntity`: no management command, no periodic task, no signal or receiver that deletes rows, and no `TTL`/archival/retention-by-row-count setting. A row is written once, and the package itself never modifies or deletes it afterwards — the only two ways a row disappears are a manual delete through the Django admin (standard `delete`/`delete_queryset` permission, unrestricted by this package — see Admin below) or a direct action by a database administrator, both outside the package's scope. Sizing and retention policy for the `Log`/`MailLog` tables are a decision of the host project, not of this package.

### Logging handler (`zibanu.django.logging.lib.handlers`)

- **`DbHandler(Handler)`** — subclasses `logging.Handler` (not `StreamHandler`). `emit(record)` builds a `Log` from the record (`action` = level name, `sender` = `module/funcName`, `detail` = formatted message, truncated to `ZB_LOGGING_DETAIL_MAX_LENGTH` characters with a truncation marker — see Configuration), reads `user_id` / `ip_address` from `extra=` attributes on the record (falling back to `record.args` when it is a dict), and inserts the row with `force_insert=True`. Exceptions are sent to `handleError` instead of being raised.

### Signals and receivers (`zibanu.django.logging.lib`)

- **`mail_sent`** (`zibanu.django.logging.lib.signals`) — `django.dispatch.Signal` dispatched when a mail is sent. Expected keyword arguments: `mail_from` (str), `mail_to` (list), `subject` (str), `smtp_error` (str), `smtp_code` (int). `send_mail` is kept as a **deprecated alias** of the same `Signal` object (the historical, README-documented name, which collides with `django.core.mail.send_mail`) — it is expected to be removed in the next release; use `mail_sent`.
- **`on_mail_sent(sender, mail_from, mail_to, subject, smtp_error, smtp_code, **kwargs)`** (`zibanu.django.logging.lib.receivers`) — receiver connected to `mail_sent`; creates a `Log` and a linked `MailLog` inside a single transaction. Returns `None`. **Accepted exception to this package's resilience rule**: there is no `try`/`except` around this receiver — unlike `DbHandler.emit` (routes failures to `handleError`) and `audit_model` (catches broadly and logs at error level), a persistence failure here propagates, with its original cause intact, to whoever called `mail_sent.send()`.
- **`audit_model(sender, action=None, **kwargs)`** (`zibanu.django.logging.lib.receivers`) — generic audit receiver connected by `signals_factory`. Resolves the acting user/IP from the request stack and writes exactly **one** `Log` row per audited event: `new_values` is populated only for an actual insert (`action == "on_create"` and `created=True`) or an update (`action == "on_update"`) — the `post_save` call that also fires on every UPDATE with `action="on_create"` and `created=False` yields an empty payload and inserts nothing, so an UPDATE is logged once (via the `pre_save`/`on_update` wiring), not twice. For updates on an existing row it also captures `old_values`, built as a **diff**: only the fields whose value actually changed, plus the primary key, on both sides — comparison runs on the raw dump so masking (below) never hides a real change, and a save that changed no field writes no row at all. Before serialization, sensitive field values — named `password`, `token`, `secret`, `api_key` or `private_key` by default, configurable via `ZB_LOGGING_REDACTED_FIELDS` (see Configuration) — are replaced with a fixed placeholder, including inside any model nested in the dump (e.g. a many-to-many row); only model field names are matched, never the keys of a plain mapping such as a `JSONField` payload. Detail is JSON-encoded with `zibanu.django.lib.ExtendedJSONEncoder`, which also serializes GIS geometries (`GEOSGeometry`/`Point`/`Polygon`/`MultiPolygon`) via their `.geojson` property — duck-typed so this base-package encoder never imports `django.contrib.gis`; if a field value is still unserializable, the resulting `TypeError` from `json.dumps` is caught and `detail` degrades to `{"error": ...}` instead of aborting the audited model's `save()`/`delete()`.
- **`signals_factory(model=None, delete=False)`** (`zibanu.django.logging.lib`) — reads the relevant `AuditEntity` rows and (re)connects `audit_model` to each model's signals (`on_create` → `post_save`, `on_update` → `pre_save`, `on_delete` → `post_delete`) using stable `dispatch_uid`s (`zb_audit_<model_name>_<action>`) and `weak=False`. Handlers are unconditionally disconnected before reconnecting, so the function is idempotent. Raises `TypeError` if `model` is given and is not an `AuditEntity` instance (nor `None`) — a programming error is propagated rather than silently reinterpreted as "rewire every entity". Deliberately refuses to wire audit signals onto this package's own models (`app_label == "zb_logging"`), to avoid a `Log` insert re-firing the very signal that created it. `ImportError` is logged at **warning** level (not info: it means the deployment runs completely unaudited) and any other exception at error level; failures never break startup, and are also recorded in the module-level `wiring_errors` list, keyed by lowercased `model_name` (or a reserved global key for failures not attributable to a single entity, e.g. an `ImportError`) — a no-`model` call clears and repopulates every entry, while a single-entity call (`model=<AuditEntity>`) clears and repopulates only that entity's entry, leaving every other entity's recorded warning untouched (the global entry is always cleared and retried, regardless of scope) — surfaced as admin warnings by `AuditEntityAdmin.changelist_view` (see Admin below). See the *Behavior* note above regarding disabled entities.

### Application config (`zibanu.django.logging.apps`)

- **`ZbDjangoLogging(AppConfig)`** — `name = "zibanu.django.logging"`, `label = "zb_logging"`. `ready()` starts a background thread (`after_ready()`) that blocks on the app-registry ready event and then calls `signals_factory()`, so audit signals are wired only after all apps are loaded — **except** for a fixed set of management commands (`makemigrations`, `migrate`, `collectstatic`, `makemessages`, `compilemessages`, `check`, `showmigrations`, `sqlmigrate`, `inspectdb`), detected from `sys.argv`, for which `ready()` returns immediately without starting the thread: none of them write audited models, and some run before the schema (hence the `AuditEntity` table) exists. Every other command — `runserver`, `shell`, `test`, `createsuperuser`, custom commands — still defers normally. `after_ready()` waits up to 60 seconds on `apps.ready_event` (falling back to a bounded poll on `apps.ready` if a future Django release removes that attribute) before calling `signals_factory()`; an unexpected exception from the call itself is caught and logged at error level, and if the registry is never marked ready within the timeout, a warning is logged instead and signals are never wired for that process. Either way, the thread closes its own (thread-local) database connections in a `finally` block before exiting.

### Admin (`zibanu.django.logging.admin`, `admin_views`)

- **`AuditEntityAdmin(ModelAdmin)`** — registered for `AuditEntity`. Bulk actions are enabled (including Django's default bulk delete, unlike `LogAdmin`/`MailLogAdmin` below); `list_display = ("model_name", "on_create", "on_update", "on_delete", "enabled")`, `list_filter = ("enabled",)`, `search_fields = ("model_name",)`, and `fieldsets` group the model name, the three audited-event flags and `enabled` into separate sections. `get_form` populates the `model_name` widget with the live model choices (`get_model_choices`), computed once per process and cached (the app registry cannot change without a restart); a blank first option is prepended (otherwise editing an entity whose stored `model_name` fell out of the live choices would make the browser preselect another model and silently repoint the rule on save), and a stale stored value is kept as its own choice labeled "(model no longer available)" instead of disappearing. The field is rebuilt as a plain `ChoiceField` so the *submitted* value is validated against the live choices, not just the widget's rendered options. `save_model` calls `super().save_model()` and then unconditionally `signals_factory()` (so the rewire acts on the configuration just persisted); when the edit **renames** `model_name`, it additionally disconnects the previous model's handlers first — via `signals_factory(model=AuditEntity(model_name=previous_name), delete=True)`, called *before* `super().save_model()` while the DB row still holds the pre-edit name — since the general rewire that follows only processes rows currently in the table and would otherwise never unhook the old model. `delete_model` calls `signals_factory(model=obj, delete=True)` before deleting; `delete_queryset` (bulk admin deletes) does the same for every selected entity, mirroring `delete_model`. `changelist_view` surfaces any recorded `signals_factory.wiring_errors` as admin warnings, so a broken audit configuration is visible where it is managed.
- **`LogAdmin(ModelAdmin)`** — read-only admin for `Log`: `has_add_permission`/`has_change_permission` both return `False` and every field is listed in `readonly_fields`, since rows are evidence written only by the package's handlers/receivers. `actions = None` disables bulk actions (including bulk delete); `list_display = ("created_at", "action", "sender", "user", "ip_address")`, `list_filter = ("action", "created_at")`. `search_fields = ("^sender",)` — a prefix match (`istartswith`) that can use `IDX_logging_log_sender`; `detail` is deliberately excluded from search, since an unindexed `LIKE` over that never-pruned `TextField` would turn the change-list search box into an unbounded full scan.
- **`MailLogAdmin(ModelAdmin)`** — read-only admin for `MailLog`, same policy as `LogAdmin` (`has_add_permission`/`has_change_permission` return `False`, `actions = None`). `list_display = ("log_link", "mail_from", "mail_to", "subject", "smtp_code")`, `list_filter = ("smtp_code",)`, `search_fields = ("mail_to", "subject")`, `readonly_fields` covers every field. `log_link` renders the linked `Log` row as an anchor to its own admin change page.

## Configuration

- **`LOGGING`** (Django setting) — reference `zibanu.django.logging.lib.handlers.DbHandler` as a handler class to persist `logging` output to the `Log` table (see Quick Start).
- **`ZB_LOGGING_DETAIL_MAX_LENGTH`** (int, default `10000`) — read by `DbHandler.emit`. Caps the length of the formatted `detail` written to the `Log` row; a message longer than this is truncated to the limit and appended with a `" … [truncated]"` marker, so an arbitrarily large formatted traceback cannot be written verbatim into a table that is never pruned.
- **`ZB_LOGGING_REDACTED_FIELDS`** (iterable of str, default: `password`, `token`, `secret`, `api_key`, `private_key`) — read by `audit_model`. Field names (matched exactly, case-insensitively) whose values are replaced with a fixed placeholder before an audit payload is JSON-encoded into `Log.detail`, so credentials never persist there. Replaces the default set entirely rather than extending it; a bare string value is treated as a single field name rather than iterated character by character.
- **Audit configuration** — managed as `AuditEntity` rows through the Django admin (or the ORM). No other dedicated `settings.py` keys are required by this package.

## Testing

The package's unit tests live in `zibanu/django/logging/tests/` (one `test_*.py` module per unit under test — the `Log`/`MailLog`/`AuditEntity` models, `DbHandler`, the `mail_sent` signal, the `audit_model`/`on_mail_sent` receivers, `signals_factory`, the admin classes and `ZbDjangoLogging`).

```bash
# Run every test of this package
python manage.py test zibanu.django.logging.tests

# Run a specific module, class or method
python manage.py test zibanu.django.logging.tests.test_signals_factory
python manage.py test zibanu.django.logging.tests.test_audit_entity_model.AuditEntitySaveNormalizationTest

# Verbose output
python manage.py test --verbosity=2
```

Every DB-accessing test (model persistence, `DbHandler.emit`, the receivers, `signals_factory`'s effect on real rows) runs against the project's own **local/dev database** — isolation relies on `TestCase`'s per-test transaction rollback, never `TransactionTestCase`, which would truncate real tables. DB-free units (field/`Meta` checks, the app-config command-skip gate, the bare signal object) use `django.test.SimpleTestCase` instead.

## Requirements

- Python 3.11+
- Django and Django REST Framework
- The Zibanu base package `zibanu.django` — this package imports `ExtendedJSONEncoder`, `ModelName`, `get_model_choices`, `get_user`, `get_ip_address` and `get_request_from_stack` from `zibanu.django.lib` / `zibanu.django.lib.utils`, and the base model classes from `zibanu.django.db`.

No dependency on any other optional Zibanu package.

## License

Developed by CQ Inversiones SAS. Copyright © 2019–2026. All rights reserved.

# Changelog

All notable changes to this package are documented here, one section per principal
version (`x.y.z`). Pre-releases of the same version (`-dev.N`, etc.) are folded into
its section.

## [2.0.0] — 2026-10-01

No tag exists yet for this version, and the package is still on a pre-release
(`2.0.0-dev.5`) as of this writing.

### Added
- Read-oriented Django admin registered for `Log` and `MailLog` (list/search/filter),
  plus an audit-entity changelist showing which event hooks (create/update/delete/
  save-error) are wired for each entity (LOG-S21, LOG-S25).
- Sensitive fields (`password`, `token`, `secret`, `api_key`, `private_key` by
  default, configurable via `ZB_LOGGING_REDACTED_FIELDS`) are now masked before an
  audited model is serialized into `Log.detail`, including fields nested inside a
  related model value — so a credential on an audited model, or on one it embeds,
  is never persisted in the clear (LOG-R02, ZBDJ-629, ZBDJ-666, ZBDJ-667). A
  `ZB_LOGGING_REDACTED_FIELDS` set to a single string is normalized into a
  one-item list instead of being iterated character by character, which would
  otherwise mask nothing without any error (ZBDJ-667).
- `AuditEntityAdmin`'s bulk actions, including Django's default bulk delete, are
  enabled — `delete_queryset` already disconnected each selected entity's audit
  signals before removal, so no extra wiring was needed (GAP-CFG-02).
- `MailLog`'s admin list links each row to its associated `Log` entry (ZBDJ-685);
  `Log.sender` gained a database index and `LogAdmin` now searches it by prefix
  (`istartswith`) instead of a substring match, so the search box can use the new
  index instead of an unbounded scan (ZBDJ-651).

### Changed
- **Breaking:** `DbHandler` now subclasses `logging.Handler` instead of
  `logging.StreamHandler` — the correct base for a database sink. A consumer that
  configured this handler with a `stream` argument, or relied on
  `StreamHandler`-specific behavior, needs to drop that configuration (LOG-S53).
- **Breaking:** `Log.user`'s foreign key `on_delete` changed from `CASCADE` to
  `SET_NULL`, so deleting a `User` no longer deletes their audit trail; requires
  running the accompanying migration (LOG-I7).
- **Breaking:** an update (`on_update`) now records only the fields whose value
  actually changed in `old_values`/`new_values`, instead of the full object;
  both dicts share the same key set, the primary key is always included as an
  identifier (never counted as a change on its own), and no row is written at
  all when the diff is empty. Masking is applied to the already-computed diff,
  not before comparing, so two different values of a redacted field are never
  collapsed into the same placeholder and silently dropped from the diff.
  `on_create`/`on_delete` are unaffected and keep logging the full object
  (ZBDJ-661, ZBDJ-662, ZBDJ-663).
- A nested model's own many-to-many fields are excluded from its dump inside an
  audit payload, so masking a related model no longer issues one extra query
  per many-to-many field on every related row it holds (ZBDJ-664).
- The receiver connected to `mail_sent` was renamed `on_send_mail` →
  `on_mail_sent`, matching the signal it handles instead of the historical
  `send_mail` alias; new rows record `Log.action="on_mail_sent"` (previously
  `"on_send_mail"` — existing rows keep the old value). It now also truncates
  the sender class name to `Log.sender`'s column width, so an unusually long
  name can no longer raise `DataError` from inside the signal handler (ZBDJ-628),
  and normalizes a plain-string `mail_to` into a single-recipient list instead of
  silently joining it character by character.
- `MailLog.mail_to` and `MailLog.smtp_error` widened to `TextField`, so a long
  recipient list or a real multi-line SMTP rejection message no longer overflows
  on insert (LOG-I8, LOG-S29).
- `MailLog.smtp_error` dropped `null=True` in favor of a single "no value"
  representation, `default=""`; existing `NULL` rows are backfilled by the
  accompanying migration (LOG-S14, LOG-R05).
- Added an index on `Log.created_at` for retention/cleanup queries, and normalized
  `AuditEntity.model_name` storage so it agrees with its case-sensitive lookup;
  a name that still fails to resolve against the app registry (typo, an
  app not yet installed) is now lower-cased rather than stored verbatim, so two
  case-variants of the same unresolvable name collide under `unique=True`
  regardless of the database's collation (LOG-S30, LOG-S31, GAP-CFG-03).
- Internal `lib` package surface cleaned up: wildcard re-exports replaced with
  explicit imports and `__all__` at each level; the public entry point
  `zibanu.django.logging.lib.signals_factory` is unchanged (LOG-I22, LOG-I23,
  LOG-S34–S37, LOG-S51, LOG-S52).
- Admin: the model-choices list for audit entities is cached instead of walking
  the app registry on every render (LOG-S23); editing an entity whose stored
  `model_name` is no longer among the live choices now keeps that stale value
  visible (labeled as no longer available) instead of letting the widget silently
  substitute another model on save, and renaming `model_name` on an existing
  entity now disconnects the previous model's signal handlers before the rename
  is persisted.
- `audit_model` now isolates its own failures (catches and logs at error level,
  mirroring `DbHandler`) instead of letting an unexpected exception propagate into
  the audited model's own `save()`/`delete()`; `on_mail_sent` is deliberately left
  unwrapped, so a persistence failure there still propagates to the caller
  (LOG-R05).
- Signal-wiring failures are now tracked per audited entity rather than as one
  shared list: deleting or disabling a single entity clears only that entity's
  recorded failure, instead of blanking the warnings recorded for every other
  entity.

### Fixed
- Admin: entity save/signal rewiring now runs after `super().save_model()`, so it
  reads post-save state instead of stale pre-save state; a crafted admin POST can
  no longer target an arbitrary model by name; bulk-deleting entities from the
  admin now unwires each deleted entity's signal handlers (LOG-C1, LOG-I12, LOG-S24).
- App startup: the deferred signal-wiring thread is now a bounded, daemon thread
  that closes its own database connection, so it can no longer block interpreter
  shutdown or leak a connection (LOG-I9, LOG-I11); the gate that skips wiring
  during management commands (`migrate`, `makemigrations`, etc.) now correctly
  recognizes the subcommand even when it follows a leading option (e.g.
  `manage.py --settings=x migrate`).
- Signal wiring: disabling an audit entity now disconnects **all** of its rows
  (previously some stayed connected); a wiring failure on one entity no longer
  aborts wiring for every entity after it; wiring failures are now recorded in an
  observable list instead of only being logged (LOG-I13, LOG-I14, LOG-S38).
- Change detection: object creation is now detected from `instance.pk` instead of
  a `created` flag that `pre_save` never actually receives, and delete logging is
  now populated from the in-memory instance `post_delete` still holds — both
  previously produced inaccurate or missing audit rows (LOG-I16, LOG-I17).
- `DbHandler.emit`: a thread-local guard prevents an INSERT-triggered log record
  from recursively re-entering `emit()` (LOG-I20).
- `audit_model` no longer raises when a field holds a value the JSON encoder can't
  serialize (e.g. a GIS geometry) — it now degrades to an `{"error": ...}` detail
  instead of propagating the exception into the caller's `save()`/`delete()`
  (LOG-C2).
- Duplicate/mislabeled audit rows on updates: an `UPDATE` was writing a second row
  incorrectly labeled `on_create`, because `post_save` fires with `created=False`
  on every update, not just inserts. `new_values` is now populated only for a
  genuine create or update, and no row is written when the resulting detail is
  empty.
- Fixed a name collision where `lib/utils/__init__.py`'s
  `from .signals_factory import signals_factory` re-export shadowed the
  `signals_factory` **submodule** with the function of the same name, so code
  that imported the module itself (to read its `wiring_errors` list) would have
  raised `AttributeError`. The submodule was renamed to
  `signals_factory_module.py`; the public `zibanu.django.logging.lib.signals_factory`
  function is unchanged.

### Documentation
- Transaction/rollback semantics of `DbHandler`, and the recommended `LOGGING`
  example excluding `django.db.backends`, documented in the README (LOG-I20,
  LOG-I21).
- README (EN/ES) updated to describe the one-row-per-event guarantee and the
  GIS-geometry serialization fix above.
- `MailLog.smtp_error`'s empty-string default documented as the field's only
  "no value" representation (no more `None`), following the `null=True` removal
  above (LOG-S14).
- README corrected to describe disabling an audit entity accurately: a
  `signals_factory()` call with no `model` argument processes every entity
  (enabled or not), disconnecting each one first and reconnecting only the
  still-enabled ones — so disabling an entity does disconnect it, on the next
  general rewire (which happens on every admin save/delete of any entity), not
  only when the entity is deleted (ZBDJ-656).
- README documents that `on_mail_sent` is deliberately excluded from this
  package's general exception-resilience rule (ZBDJ-683), and its own docstrings
  and the `mail_sent` signal's docstring now reference the current receiver name
  (ZBDJ-678).
- README documents `DbHandler`'s transactional semantics (ZBDJ-648) and updates
  the recommended `LOGGING` example (`README.es.md`) to exclude
  `django.db.backends` from the routing to `DbHandler` (ZBDJ-643).
- README documents the cleanup scope of the wiring-failure inventory and the
  `ImportError`-at-info-level detail (ZBDJ-674), the three models' database-engine
  agnosticism (ZBDJ-636), and the absence of automatic purging — retention is the
  hosting project's responsibility (ZBDJ-634).
- README (EN/ES) refreshed for internal consistency after the `GAP-CFG-01`/
  `GAP-CFG-02`/`GAP-CFG-03` changes above and several other prose/behavior
  divergences found in the same pass.

## [1.2.5] — never released (superseded by 2.0.0)

Only ever existed as `1.2.5-dev.0`: development jumped straight to `2.0.0-dev.0`
without ever releasing a `1.2.5`, so no consumer could have installed this
version.

### Added
- `Log` and `MailLog` gained `__str__` representations (LOG-S13).

### Changed
- `AuditEntity.model_name` uniqueness now enforced with `unique=True` directly,
  replacing a single-field `unique_together`; `Log`/`MailLog` saves wrapped in
  `transaction.atomic()` (LOG-S12, LOG-S6).
- Internal cleanup: dropped a redundant `.all()` on an already-filtered queryset,
  renamed a loop variable that shadowed the `model` parameter, replaced
  `zip(get_model_list(), get_model_list())` with `get_model_choices()`, and
  removed several unused imports (LOG-S1, LOG-S2, LOG-S9, LOG-S5, LOG-S15,
  LOG-S17, LOG-S18).

### Fixed
- Signal wiring: `dispatch_uid` is now stable and connects with `weak=False`, so
  a later disconnect actually matches the original connect — previously this
  could leave stale handlers or silently fail to reconnect (LOG-I1).
- `DbHandler.emit`: guards `record.args` before treating it as a mapping, and now
  calls `self.handleError(record)` on failure instead of silently swallowing the
  exception (LOG-I2, LOG-I3).
- Mail logging: SMTP errors are now recorded on the `MailLog` row instead of
  being reset to `None` before saving (LOG-I4).
- Admin: the model-choices list for audit entities is now built per request
  instead of being frozen at import time, so a newly-registered model appears
  without restarting the process (LOG-I5).
- The acting user is now coerced to `None` before being assigned to `Log.user`
  for anonymous requests, instead of assigning an anonymous-user object
  incompatible with the foreign key (LOG-S7).
- Mail-sending logs now use the literal `action="on_send_mail"` instead of
  inspecting the call stack with `inspect.currentframe()` (LOG-S8).

### Documentation
- Full NumPy-style docstrings added across models, `DbHandler`, signal
  receivers, `signals_factory`, `AppConfig`, and admin views; README rewritten
  and a Spanish translation (`README.es.md`) added. The `signals_factory`
  connect/disconnect asymmetry was documented as real, intentional behavior — no
  logic changed by this pass.
- Documented why the deferred signal wiring runs on the `AppConfig`'s
  `ready_event` thread rather than in `ready()` itself (LOG-S16).

## [1.2.4] — 2025-03-18

There is no `1.2.4` tag. Note that the repository's only tag, `1.2.3`, actually
marks a *later* point (dated 2025-08-27) at which the package version already
read `1.2.4` — the tag name is out of sync with the version it marks, so it
cannot be used to identify what `1.2.3` contained.

### Changed
- Admin app label changed from "Zibanu Logging" to "Zibanu Django Logging"
  (`apps.py`, `verbose_name`).

> Note: the commit that introduced this version is titled after a GIS model, but
> its actual diff touches only the version file and the one-line admin label
> change above —
> no GIS code. This looks like a commit-message artifact carried over when this
> package's history was extracted from the former Zibanu Django monorepo into
> its own repository, not an undocumented feature; read as such rather than as
> evidence of a missing GIS-related change in this package.

## [1.2.3] — 2025-03-14

Initial commit of this package as its own repository, extracted from the former
Zibanu Django monorepo. No earlier git history is available in this repository
to derive changes from — this section marks the starting point of the tracked
history, not a diff.
