# django-notify-hub

Reusable Django notifications with pluggable delivery backends, template-backed persistence, REST APIs, admin tooling, signals, and optional async dispatch.

## Features

- reusable Django app with `src/` package layout
- clean facade API via `notify.send()` and `notify.send_many()`
- scalable persistence model using shared `NotificationTemplate` rows plus per-user `Notification` rows
- one-off custom notifications with per-record override fields
- localized title/body content with language-aware resolution helpers
- pluggable backends: Dummy, Email, FCM, and WebSocket
- optional Celery async dispatch with retry/backoff
- DRF API for listing, counting, marking read, and deleting notifications
- Django admin with filters, stats, and bulk actions
- model event hooks via `notify.on_create()` / `notify.on_update()`
- architecture based on Facade, Repository, Factory, Strategy, and Observer patterns

## Installation

Install the package with `uv`:

```bash
uv add django-notify-hub
```

Optional extras currently exposed by the package:

- FCM: `uv add 'django-notify-hub[fcm]'`
- Celery: `uv add 'django-notify-hub[celery]'`
- WebSocket / Django Channels: `uv add 'django-notify-hub[websocket]'`
- all packaged extras: `uv add 'django-notify-hub[all]'`

## Quick start

Add the app and run migrations:

```python
INSTALLED_APPS = [
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "rest_framework",
    "django_notify_hub",
]

DJANGO_NOTIFY = {
    "BACKENDS": ["django_notify_hub.backends.dummy.DummyBackend"],
    "ASYNC": False,
}
```

```bash
uv run python manage.py migrate
```

Send a notification:

```python
from django_notify_hub import notify
from django_notify_hub.types import NotificationChannel

notify.send(
    title={"en": "Hello", "ar": "مرحبا"},
    body={"en": "Welcome to django-notify-hub."},
    recipient=user,
    channels=(NotificationChannel.DUMMY,),
)
```

## Django setup

### Required apps

Minimum:

```python
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "rest_framework",
    "django_notify_hub",
]
```

### Minimal settings

```python
DJANGO_NOTIFY = {
    "BACKENDS": ["django_notify_hub.backends.dummy.DummyBackend"],
    "ASYNC": False,
    "PAGINATION_PAGE_SIZE": 20,
    "PAGINATION_MAX_PAGE_SIZE": 100,
}
```

### API URLs

If you want the REST API, include the package URLs:

```python
from django.urls import include, path

urlpatterns = [
    path("api/notifications/", include("django_notify_hub.api.urls")),
]
```

## Public API

Import the facade singleton:

```python
from django_notify_hub import notify
```

### `notify.send(...)`

Send one notification to one recipient.

- `title` / `body`: localized dicts such as `{"en": "Hello", "ar": "مرحبا"}` or callables
- `recipient`: Django user instance
- `related_object`: object used to resolve callable title/body/data
- `data`: JSON-serializable dict or callable
- `priority`: `LOW | NORMAL | HIGH | CRITICAL`
- `channels`: tuple of `NotificationChannel` values

Example:

```python
from django_notify_hub.types import NotificationChannel, NotificationPriority

notify.send(
    title=lambda order: {"en": f"Order #{order.pk} received"},
    body={"en": "We are processing your order."},
    recipient=order.customer,
    related_object=order,
    data=lambda order: {"order_id": str(order.pk), "status": order.status},
    priority=NotificationPriority.HIGH,
    channels=(NotificationChannel.EMAIL,),
)
```

### `notify.send_many(...)`

Broadcast one payload to many users.

This is where the shared-template persistence model shines: one `NotificationTemplate` row is created, then one lightweight `Notification` row per recipient.

```python
notify.send_many(
    title={"en": "Maintenance window", "ar": "صيانة مجدولة"},
    body={"en": "The service will be unavailable at 02:00 UTC."},
    recipients=User.objects.filter(is_staff=True),
    channels=(NotificationChannel.WEBSOCKET,),
)
```

### `notify.on_create(...)`

Register an automatic notification whenever a model instance is created.

```python
notify.on_create(
    app_label="orders",
    model_name="Order",
    title=lambda order: {"en": f"Order #{order.pk} created"},
    body={"en": "Your order has been received."},
    recipients="customer",
)
```

### `notify.on_update(...)`

Register an automatic notification when a model is updated. The `condition` callback receives `(old_instance, new_instance)`.

```python
notify.on_update(
    app_label="orders",
    model_name="Order",
    title={"en": "Order updated"},
    body=lambda order: {"en": f"Order is now {order.status}"},
    recipients="customer",
    condition=lambda old, new: old.status != new.status,
)
```

### Recipient resolution in signal hooks

For `notify.on_create()` and `notify.on_update()`:

- pass a concrete user object,
- or pass a dotted attribute path like `"customer"` or `"invoice.order.customer"`.

## Storage model: template + override fields

The package now uses a mixed persistence model optimized for broadcast and per-user customization.

### `NotificationTemplate`

Stores shared content and shared metadata:

- `title` JSON
- `body` JSON
- `data`
- `priority`
- `channel`

### `Notification`

Stores per-user state and optional overrides:

- `to_user`
- nullable `template`
- `lang`
- `override_title`
- `override_body`
- `custom_data`
- `custom_priority`
- `custom_channel`
- `status`, `is_read`, timestamps

### How persistence works

#### Broadcast/shared notifications

- created through `NotificationRepository.bulk_create(...)`
- one shared `NotificationTemplate`
- many per-user `Notification` rows
- each recipient row stores only user-specific state plus `lang`

#### One-off/custom notifications

- created through `NotificationRepository.create(...)`
- no template required
- values are stored directly in override/custom fields on the `Notification` row

### Language resolution

When a notification is persisted for a recipient, the repository resolves the recipient language from the first available attribute in this order:

- `language`
- `lang`
- `locale`
- `preferred_language`

If nothing is found, it falls back to `"en"`.

### Compatibility layer

The `Notification` model still exposes resolved properties so existing integrations can continue reading:

- `notification.title`
- `notification.body`
- `notification.data`
- `notification.priority`
- `notification.channel`

Language-aware helpers are also available:

- `notification.get_title(lang="en")`
- `notification.get_body(lang="en")`

## REST API

The package ships with DRF endpoints for admins and authenticated users.

### Endpoints

- `GET /api/notifications/` — admin list of all notifications
- `GET /api/notifications/my/` — current user's notifications
- `GET /api/notifications/unread-count/` — current user's unread count
- `PATCH /api/notifications/{id}/mark-read/` — mark one as read
- `POST /api/notifications/mark-many-read/` — bulk mark read
- `DELETE /api/notifications/{id}/` — delete one of the current user's notifications
- `POST /api/notifications/delete-many/` — bulk delete

### Permissions

- admin list endpoint requires `IsAdminUser`
- self-service endpoints require `IsAuthenticated`

### List filtering and ordering

- `my/` accepts `?is_read=true` or `?is_read=false`
- ordering fields: `created_at`, `is_read`
- page size comes from `DJANGO_NOTIFY["PAGINATION_PAGE_SIZE"]`

### Serializer fields

Responses include:

- `id`
- `title` and `body` as full localized JSON
- `title_display` and `body_display` resolved for the active request language
- `data`, `channel`, `priority`, `status`, `is_read`, `created_at`, `updated_at`

## Admin integration

The Django admin includes:

- read/unread filters
- channel and priority filters
- recipient/title preview columns
- pretty-printed notification data
- bulk actions to mark read/unread
- bulk deletion of old read notifications
- changelist stats from `NotificationRepository.get_stats()`

## Backends

Backends are loaded from `DJANGO_NOTIFY["BACKENDS"]` using the `BackendFactory`.

### Dummy backend

Best for tests and local development.

```python
DJANGO_NOTIFY = {
    "BACKENDS": ["django_notify_hub.backends.dummy.DummyBackend"],
    "ASYNC": False,
}
```

### Email backend

Uses Django `send_mail` and requires recipients to have an `email` attribute.
The backend falls back to Django's `DEFAULT_FROM_EMAIL` and renders localized content using English by default.

```python
DJANGO_NOTIFY = {
    "BACKENDS": ["django_notify_hub.backends.email.EmailBackend"],
    "ASYNC": False,
}
```

### FCM backend

Install the extra:

```bash
uv add 'django-notify-hub[fcm]'
```

Configure credentials with either:

- `DJANGO_NOTIFY["FCM_CREDENTIALS_FILE"]`, or
- `GOOGLE_APPLICATION_CREDENTIALS`

```python
DJANGO_NOTIFY = {
    "BACKENDS": ["django_notify_hub.backends.fcm.FCMBackend"],
    "FCM_CREDENTIALS_FILE": BASE_DIR / "firebase.json",
}
```

### WebSocket backend

Install the packaged websocket extra in the host project:

```bash
uv add 'django-notify-hub[websocket]'
```

Configure the backend:

```python
DJANGO_NOTIFY = {
    "BACKENDS": ["django_notify_hub.backends.websocket.WebSocketBackend"],
    "WEBSOCKET_LAYER_ALIAS": "default",
    "WEBSOCKET_EVENT_TYPE": "notify.message",
    "WEBSOCKET_USER_GROUP_TEMPLATE": "notify.user.{user_id}",
}
```

Optional custom group resolver:

```python
DJANGO_NOTIFY = {
    "WEBSOCKET_GROUP_RESOLVER": "myapp.notifications.websocket_groups",
}
```

Resolver signature:

```python
def websocket_groups(recipient, payload):
    return [f"tenant.{recipient.tenant_id}", f"notify.user.{recipient.pk}"]
```

The backend sends a Channels event containing `event["notification"]` with:

- `channel`
- `recipient_ids`
- `recipient_id` for single-recipient sends
- `title`, `body`, `data`, `priority`

Your consumer should implement the configured event type, which defaults to `notify.message`.

### Custom backends

Create a subclass of `AbstractNotificationBackend` and implement:

- `channel`
- `_deliver(payload, recipient)`
- optionally `_deliver_many(payload, recipients)` for native batch delivery
- optionally `setup()` for one-time initialization

## Async delivery with Celery

Install the extra:

```bash
uv add 'django-notify-hub[celery]'
```

Configure:

```python
DJANGO_NOTIFY = {
    "ASYNC": True,
    "CELERY_QUEUE": "notifications",
    "CELERY_MAX_RETRIES": 3,
    "CELERY_RETRY_BACKOFF": 60,
}
```

Behavior:

- sync mode persists and delivers in-process
- async mode dispatches Celery tasks
- if Celery is not installed, the service falls back to synchronous delivery

## Settings reference

- `BACKENDS`: dotted backend class paths
- `DEFAULT_CHANNELS`: available config key; current facade methods still use the explicit `channels=` argument and default to FCM if omitted
- `ASYNC`: toggle Celery task dispatch
- `CELERY_QUEUE`: queue name for notification tasks
- `CELERY_MAX_RETRIES`: max retry count
- `CELERY_RETRY_BACKOFF`: exponential retry base delay in seconds
- `FCM_CREDENTIALS_FILE`: Firebase service account file path
- `WEBSOCKET_LAYER_ALIAS`: Channels layer alias
- `WEBSOCKET_EVENT_TYPE`: Channels event type
- `WEBSOCKET_USER_GROUP_TEMPLATE`: default group naming format
- `WEBSOCKET_GROUP_RESOLVER`: dotted path or callable for custom group routing
- `DEFAULT_PRIORITY`: available config key; current facade methods use the explicit `priority=` argument and default to `NotificationPriority.NORMAL` if omitted
- `PAGINATION_PAGE_SIZE` / `PAGINATION_MAX_PAGE_SIZE`: REST API pagination limits

## Important channel behavior

`NotificationPayload.channels` accepts a tuple, but the current runtime implementation persists and dispatches using the first channel in that tuple. If you need multi-channel fan-out, call `notify.send()` more than once or add orchestration in your project layer.

## Testing

Run the test suite:

```bash
uv run python -m pytest tests/ --no-cov -q
```

Migration sanity check:

```bash
uv run python -m django makemigrations django_notify_hub --check --dry-run --settings=test_settings
```

Build distributions:

```bash
uv build
```

## Status

The package is fully structured as a reusable Django app and includes tests for models, repository, services, API, registry, and backends.

