# Themis v1.0.0

Reusable Django app providing user preferences, DaisyUI theme management, API key management, and standard navigation templates.

**Package:** django-heluca-themis
**Django:** >=5.2, <6.0
**Python:** >=3.10
**License:** MIT

## 🐾 Red Panda Approval™

This project follows Red Panda Approval standards.

---

## Overview

Themis provides the foundational elements every Django application needs:

- **UserProfile** — timezone, date/time/number formatting, DaisyUI theme selection
- **Notifications** — in-app notification bell, polling, browser desktop notifications, user preferences
- **API Key Management** — encrypted storage with per-key instructions
- **Standard Navigation** — navbar, user menu, notification bell, theme toggle, bottom nav
- **Middleware** — automatic timezone activation and theme context
- **Formatting Utilities** — date, time, number formatting respecting user preferences
- **Health Checks** — Kubernetes-ready `/ready/` and `/live/` endpoints

Themis does not provide domain models (Organization, etc.) or notification triggers. Those are documented as patterns for consuming apps to implement.

---

## Installation

### From Git Repository

```bash
pip install git+ssh://git@git.helu.ca:22022/r/themis.git
```

### For Local Development

```bash
pip install -e /path/to/themis
```

### Configuration

**settings.py:**

```python
INSTALLED_APPS = [
    ...
    "rest_framework",
    "themis",
    ...
]

MIDDLEWARE = [
    ...
    "themis.middleware.TimezoneMiddleware",
    "themis.middleware.ThemeMiddleware",
    ...
]

TEMPLATES = [{
    "OPTIONS": {
        "context_processors": [
            ...
            "themis.context_processors.themis_settings",
            "themis.context_processors.user_preferences",
            "themis.context_processors.notifications",
        ],
    },
}]

# Themis app settings
THEMIS_APP_NAME = "My Application"
THEMIS_NOTIFICATION_POLL_INTERVAL = 60  # seconds (0 to disable polling)
THEMIS_NOTIFICATION_MAX_AGE_DAYS = 90   # cleanup ceiling for read notifications
```

**urls.py:**

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

urlpatterns = [
    ...
    path("", include("themis.urls")),
    path("api/v1/", include("themis.api.urls")),
    ...
]
```

**Run migrations:**

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

---

## Models

### UserProfile

Extends Django's User model with display preferences. Automatically created via `post_save` signal when a User is created.

| Field | Type | Default | Description |
|---|---|---|---|
| user | OneToOneField | required | Link to Django User |
| home_timezone | CharField(50) | UTC | Permanent timezone |
| current_timezone | CharField(50) | (blank) | Current timezone when traveling |
| date_format | CharField(20) | YYYY-MM-DD | Date display format |
| time_format | CharField(10) | 24-hour | 12-hour or 24-hour |
| thousand_separator | CharField(10) | comma | Number formatting |
| week_start | CharField(10) | monday | First day of week |
| theme_mode | CharField(10) | auto | light / dark / auto |
| theme_name | CharField(30) | corporate | DaisyUI light theme |
| dark_theme_name | CharField(30) | business | DaisyUI dark theme |
| created_at | DateTimeField | auto | Record creation |
| updated_at | DateTimeField | auto | Last update |

**Properties:**

- `effective_timezone` — returns current_timezone if set, otherwise home_timezone
- `is_traveling` — True if current_timezone differs from home_timezone

**Why two timezone fields?**

Users who travel frequently need to see times in their current location while still knowing what time it is "at home." Setting `current_timezone` enables this without losing the home timezone setting.

### UserAPIKey

Stores encrypted API keys, MCP credentials, DAV passwords, and other service credentials.

| Field | Type | Default | Description |
|---|---|---|---|
| id | UUIDField | auto | Primary key |
| user | ForeignKey | required | Owner |
| service_name | CharField(100) | required | Service name (e.g. "OpenAI") |
| key_type | CharField(30) | api | api / mcp / dav / token / secret / other |
| label | CharField(100) | (blank) | User nickname for this key |
| encrypted_value | TextField | required | Fernet-encrypted credential |
| instructions | TextField | (blank) | How to obtain and use this key |
| help_url | URLField | (blank) | Link to service documentation |
| is_active | BooleanField | True | Whether key is in use |
| last_used_at | DateTimeField | null | Last usage timestamp |
| expires_at | DateTimeField | null | Expiration date |
| created_at | DateTimeField | auto | Record creation |
| updated_at | DateTimeField | auto | Last update |

**Properties:**

- `masked_value` — shows only last 4 characters (e.g. `****7xQ2`)
- `display_name` — returns label if set, otherwise service_name

**Encryption:**

Keys are encrypted at rest using Fernet symmetric encryption derived from Django's `SECRET_KEY`. The plaintext value is never stored and is only shown at creation time.

### UserNotification

In-app notification for a user. Created by consuming apps via `notify_user()`.

| Field | Type | Default | Description |
|---|---|---|---|
| id | UUIDField | auto | Primary key |
| user | ForeignKey | required | Recipient |
| title | CharField(200) | required | Short headline |
| message | TextField | (blank) | Body text |
| level | CharField(10) | info | info / success / warning / danger |
| url | CharField(500) | (blank) | Link to navigate on click |
| source_app | CharField(100) | (blank) | App label of sender |
| source_model | CharField(100) | (blank) | Model that triggered this |
| source_id | CharField(100) | (blank) | PK of source object |
| is_read | BooleanField | False | Whether user has read this |
| read_at | DateTimeField | null | When it was read |
| is_dismissed | BooleanField | False | Whether user dismissed this |
| dismissed_at | DateTimeField | null | When it was dismissed |
| expires_at | DateTimeField | null | Auto-expire datetime |
| created_at | DateTimeField | auto | Record creation |
| updated_at | DateTimeField | auto | Last update |

**Properties:**

- `level_weight` — numeric weight for level comparison (info=0, success=0, warning=1, danger=2)
- `is_expired` — True if expires_at has passed
- `level_css_class` — DaisyUI alert class (e.g. `alert-warning`)
- `level_badge_class` — DaisyUI badge class (e.g. `badge-warning`)

### UserProfile Notification Preferences

The UserProfile model includes four notification preference fields:

| Field | Type | Default | Description |
|---|---|---|---|
| notifications_enabled | BooleanField | True | Master on/off switch |
| notifications_min_level | CharField(10) | info | Minimum level to display |
| browser_notifications_enabled | BooleanField | False | Browser desktop notifications |
| notification_retention_days | PositiveIntegerField | 30 | Days to keep read notifications |

---

## Notifications

### Creating Notifications

All notification creation goes through the `notify_user()` utility:

```python
from themis.notifications import notify_user

notify_user(
    user=user,
    title="Task overdue",
    message="Task 'Deploy v2' was due yesterday.",
    level="warning",
    url="/tasks/42/",
    source_app="tasks",
    source_model="Task",
    source_id="42",
    deduplicate=True,
)
```

This function respects user preferences (enabled flag, minimum level) and supports deduplication via source tracking fields.

### Notification Bell

The notification bell appears in the navbar for authenticated users with notifications enabled. It shows an unread count badge and a dropdown with a link to the full notification list.

### Polling

The `notifications.js` script polls the `/notifications/count/` endpoint at a configurable interval (default: 60 seconds) and updates the badge. Set `THEMIS_NOTIFICATION_POLL_INTERVAL = 0` to disable polling.

### Browser Desktop Notifications

When a user enables browser notifications in their preferences, Themis will request permission from the browser and show OS-level desktop notifications when new notifications arrive via polling.

### Cleanup

Old read/dismissed/expired notifications can be cleaned up with:

```bash
python manage.py cleanup_notifications
python manage.py cleanup_notifications --max-age-days=60
```

For details on trigger patterns, see **[Notification Trigger Pattern](Pattern_Notification_V1-00.md)**.

---

## Templates

### Base Template

All consuming apps extend `themis/base.html`:

```html
{% extends "themis/base.html" %}

{% block title %}Dashboard — My App{% endblock %}

{% block nav_items %}
<li><a href="{% url 'dashboard' %}">Dashboard</a></li>
<li><a href="{% url 'reports' %}">Reports</a></li>
{% endblock %}

{% block content %}
<h1 class="text-2xl font-bold">Dashboard</h1>
<!-- app content -->
{% endblock %}
```

### Available Blocks

| Block | Location | Purpose |
|---|---|---|
| `title` | `<title>` | Page title |
| `extra_head` | `<head>` | Additional CSS/meta |
| `navbar` | Top of `<body>` | Entire navbar (override to customize) |
| `nav_items` | Navbar (mobile) | Navigation links |
| `nav_items_desktop` | Navbar (desktop) | Desktop-only nav links |
| `nav_items_mobile` | Navbar (mobile) | Mobile-only nav links |
| `body_attrs` | `<body>` | Extra body attributes |
| `content` | `<main>` | Page content |
| `footer` | Bottom of `<body>` | Entire footer (override to customize) |
| `extra_scripts` | Before `</body>` | Additional JavaScript |

### Navigation Structure

**Navbar (fixed):**

```
[App Logo/Name]   [Nav Items]     [Theme ☀/🌙] [🔔 3] [User ▾]
                                                         ├─ Settings
                                                         ├─ API Keys
                                                         └─ Logout
```

Collapses to hamburger menu on mobile.

**Bottom Nav (fixed):**

```
© 2026 App Name
```

### What Apps Cannot Change

- Navbar is always a horizontal bar at the top
- User menu is always on the right
- Theme toggle is always in the navbar
- Bottom nav is always present
- Messages display below the navbar
- Content is in a centered container

---

## Middleware

### TimezoneMiddleware

Activates the user's effective timezone for each request using `zoneinfo`. All datetime operations within the request use the user's timezone. Falls back to UTC for anonymous users.

```python
MIDDLEWARE = [
    ...
    "themis.middleware.TimezoneMiddleware",
    ...
]
```

### ThemeMiddleware

Attaches DaisyUI theme information to the request. The context processor reads these values for template rendering.

```python
MIDDLEWARE = [
    ...
    "themis.middleware.ThemeMiddleware",
    ...
]
```

---

## Context Processors

### themis_settings

Provides app configuration from `THEMIS_*` settings:

- `themis_app_name`
- `themis_notification_poll_interval`

### user_preferences

Provides user preferences:

- `user_timezone`
- `user_date_format`
- `user_time_format`
- `user_is_traveling`
- `user_theme_mode`
- `user_theme_name`
- `user_dark_theme_name`
- `user_profile`

### notifications

Provides notification state:

- `themis_unread_notification_count`
- `themis_notifications_enabled`
- `themis_browser_notifications_enabled`

---

## Utilities

### Formatting

```python
from themis.utils import format_date_for_user, format_time_for_user, format_number_for_user

formatted_date = format_date_for_user(date_obj, request.user)
formatted_time = format_time_for_user(time_obj, request.user)
formatted_num = format_number_for_user(1000000, request.user)
```

### Timezone

```python
from themis.utils import convert_to_user_timezone, get_timezone_display

user_time = convert_to_user_timezone(utc_datetime, request.user)
tz_name = get_timezone_display(request.user)
```

### Template Tags

```html
{% load themis_tags %}

{{ event.date|user_date:request.user }}
{{ event.time|user_time:request.user }}
{{ revenue|user_number:request.user }}
{% user_timezone_name request.user %}
```

---

## URL Patterns

| URL | View | Purpose |
|---|---|---|
| `/ready/` | `ready` | Kubernetes readiness probe |
| `/live/` | `live` | Kubernetes liveness probe |
| `/profile/settings/` | `profile_settings` | User preferences page |
| `/profile/keys/` | `key_list` | API key list |
| `/profile/keys/add/` | `key_create` | Add new key |
| `/profile/keys/<uuid>/` | `key_detail` | Key detail + instructions |
| `/profile/keys/<uuid>/edit/` | `key_edit` | Edit key metadata |
| `/profile/keys/<uuid>/delete/` | `key_delete` | Delete key (POST only) |
| `/notifications/` | `notification_list` | Notification list page |
| `/notifications/<uuid>/read/` | `notification_mark_read` | Mark as read (POST) |
| `/notifications/read-all/` | `notification_mark_all_read` | Mark all read (POST) |
| `/notifications/<uuid>/dismiss/` | `notification_dismiss` | Dismiss (POST) |
| `/notifications/count/` | `notification_count` | Unread count JSON |

---

## REST API

| Endpoint | Method | Description |
|---|---|---|
| `/api/v1/profiles/` | GET | List profiles (own only, admin sees all) |
| `/api/v1/profiles/{id}/` | GET/PATCH | View/update profile |
| `/api/v1/keys/` | GET | List own API keys |
| `/api/v1/keys/` | POST | Create new key |
| `/api/v1/keys/{uuid}/` | GET/PATCH/DELETE | View/update/delete key |
| `/api/v1/notifications/` | GET | List own notifications (filterable) |
| `/api/v1/notifications/{uuid}/` | GET/PATCH/DELETE | View/update/delete notification |
| `/api/v1/notifications/{uuid}/mark_read/` | PATCH | Mark as read |
| `/api/v1/notifications/mark-all-read/` | PATCH | Mark all as read |
| `/api/v1/notifications/{uuid}/dismiss/` | PATCH | Dismiss notification |
| `/api/v1/notifications/count/` | GET | Unread count |

---

## DaisyUI Themes

Themis supports all 32 built-in DaisyUI themes. Users select separate themes for light and dark modes. The theme toggle cycles: light → dark → auto (system).

No database table needed — themes are a simple CharField storing the DaisyUI theme name.

### Available Themes

light, dark, cupcake, bumblebee, emerald, corporate, synthwave, retro, cyberpunk, valentine, halloween, garden, forest, aqua, lofi, pastel, fantasy, wireframe, black, luxury, dracula, cmyk, autumn, business, acid, lemonade, night, coffee, winter, dim, nord, sunset

---

## Dependencies

```toml
dependencies = [
    "Django>=5.2,<6.0",
    "djangorestframework>=3.14,<4.0",
    "cryptography>=41.0,<45.0",
]
```

No `pytz` (uses stdlib `zoneinfo`). No `Pillow`. No database-stored themes.
