Replace plaintext token storage with SHA-256 hashes so leaked database contents cannot be used to authenticate. Plaintext is generated, shown once at creation time, and never persisted. - Add `hash_token()` helper and `MCPTokenManager.create_token()` that returns `(instance, plaintext)`. - Replace `token` field with indexed `token_hash`; look up bearers by hashing the incoming value. - Update dashboard, management command, and admin to surface plaintext only at creation. Disable admin "add" since it cannot reveal plaintext. - Migration drops the old `token` column and adds `token_hash`; pre-existing tokens are invalidated and must be reissued.
499 lines
14 KiB
Python
499 lines
14 KiB
Python
"""
|
|
Themis models — user preferences, API key management, and notifications.
|
|
"""
|
|
import uuid
|
|
from zoneinfo import available_timezones
|
|
|
|
from django.conf import settings
|
|
from django.db import models
|
|
from django.urls import reverse
|
|
|
|
|
|
# Build sorted timezone choices once at module load
|
|
TIMEZONE_CHOICES = [(tz, tz) for tz in sorted(available_timezones())]
|
|
|
|
# DaisyUI theme choices
|
|
DAISYUI_THEMES = [
|
|
("light", "Light"),
|
|
("dark", "Dark"),
|
|
("cupcake", "Cupcake"),
|
|
("bumblebee", "Bumblebee"),
|
|
("emerald", "Emerald"),
|
|
("corporate", "Corporate"),
|
|
("synthwave", "Synthwave"),
|
|
("retro", "Retro"),
|
|
("cyberpunk", "Cyberpunk"),
|
|
("valentine", "Valentine"),
|
|
("halloween", "Halloween"),
|
|
("garden", "Garden"),
|
|
("forest", "Forest"),
|
|
("aqua", "Aqua"),
|
|
("lofi", "Lo-Fi"),
|
|
("pastel", "Pastel"),
|
|
("fantasy", "Fantasy"),
|
|
("wireframe", "Wireframe"),
|
|
("black", "Black"),
|
|
("luxury", "Luxury"),
|
|
("dracula", "Dracula"),
|
|
("cmyk", "CMYK"),
|
|
("autumn", "Autumn"),
|
|
("business", "Business"),
|
|
("acid", "Acid"),
|
|
("lemonade", "Lemonade"),
|
|
("night", "Night"),
|
|
("coffee", "Coffee"),
|
|
("winter", "Winter"),
|
|
("dim", "Dim"),
|
|
("nord", "Nord"),
|
|
("sunset", "Sunset"),
|
|
]
|
|
|
|
|
|
class UserProfile(models.Model):
|
|
"""
|
|
Extends Django's User model with display preferences and theme settings.
|
|
Automatically created via signal when a User is created.
|
|
|
|
Supports dual timezones for traveling users: a permanent home timezone
|
|
and an optional current timezone that takes precedence when set.
|
|
"""
|
|
|
|
# Theme mode choices
|
|
THEME_MODE_CHOICES = [
|
|
("light", "Light"),
|
|
("dark", "Dark"),
|
|
("auto", "Auto (System)"),
|
|
]
|
|
|
|
# Date format choices
|
|
DATE_FORMAT_CHOICES = [
|
|
("YYYY-MM-DD", "2024-12-25"),
|
|
("DD/MM/YYYY", "25/12/2024"),
|
|
("MM/DD/YYYY", "12/25/2024"),
|
|
("DD.MM.YYYY", "25.12.2024"),
|
|
("DD-MM-YYYY", "25-12-2024"),
|
|
]
|
|
|
|
# Time format choices
|
|
TIME_FORMAT_CHOICES = [
|
|
("12-hour", "12-hour (3:30 PM)"),
|
|
("24-hour", "24-hour (15:30)"),
|
|
]
|
|
|
|
# Thousand separator choices
|
|
SEPARATOR_CHOICES = [
|
|
("comma", "Comma (1,000)"),
|
|
("period", "Period (1.000)"),
|
|
("space", "Space (1 000)"),
|
|
("none", "None (1000)"),
|
|
]
|
|
|
|
# Week start choices
|
|
WEEK_START_CHOICES = [
|
|
("monday", "Monday"),
|
|
("sunday", "Sunday"),
|
|
("saturday", "Saturday"),
|
|
]
|
|
|
|
# Core relationship
|
|
user = models.OneToOneField(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name="profile",
|
|
)
|
|
|
|
# Timezone settings
|
|
home_timezone = models.CharField(
|
|
max_length=50,
|
|
default="UTC",
|
|
choices=TIMEZONE_CHOICES,
|
|
help_text="User's home/permanent timezone",
|
|
)
|
|
current_timezone = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
default="",
|
|
choices=[("", "---------")] + TIMEZONE_CHOICES,
|
|
help_text="User's current timezone when traveling (leave blank if at home)",
|
|
)
|
|
|
|
# Display preferences
|
|
date_format = models.CharField(
|
|
max_length=20,
|
|
choices=DATE_FORMAT_CHOICES,
|
|
default="YYYY-MM-DD",
|
|
help_text="Preferred date display format",
|
|
)
|
|
time_format = models.CharField(
|
|
max_length=10,
|
|
choices=TIME_FORMAT_CHOICES,
|
|
default="24-hour",
|
|
help_text="12-hour or 24-hour time format",
|
|
)
|
|
thousand_separator = models.CharField(
|
|
max_length=10,
|
|
choices=SEPARATOR_CHOICES,
|
|
default="comma",
|
|
help_text="Number formatting preference",
|
|
)
|
|
week_start = models.CharField(
|
|
max_length=10,
|
|
choices=WEEK_START_CHOICES,
|
|
default="monday",
|
|
help_text="First day of the week",
|
|
)
|
|
|
|
# Theme settings (DaisyUI)
|
|
theme_mode = models.CharField(
|
|
max_length=10,
|
|
choices=THEME_MODE_CHOICES,
|
|
default="auto",
|
|
help_text="Theme mode: light, dark, or auto (follows system)",
|
|
)
|
|
theme_name = models.CharField(
|
|
max_length=30,
|
|
choices=DAISYUI_THEMES,
|
|
default="corporate",
|
|
help_text="DaisyUI theme for light mode",
|
|
)
|
|
dark_theme_name = models.CharField(
|
|
max_length=30,
|
|
choices=DAISYUI_THEMES,
|
|
default="business",
|
|
help_text="DaisyUI theme for dark mode",
|
|
)
|
|
|
|
# Notification preferences
|
|
NOTIFICATION_LEVEL_CHOICES = [
|
|
("info", "All notifications"),
|
|
("warning", "Warnings and errors only"),
|
|
("danger", "Errors only"),
|
|
]
|
|
|
|
notifications_enabled = models.BooleanField(
|
|
default=True,
|
|
help_text="Enable in-app notifications",
|
|
)
|
|
notifications_min_level = models.CharField(
|
|
max_length=10,
|
|
choices=NOTIFICATION_LEVEL_CHOICES,
|
|
default="info",
|
|
help_text="Minimum notification level to display",
|
|
)
|
|
browser_notifications_enabled = models.BooleanField(
|
|
default=False,
|
|
help_text="Enable browser desktop notifications (requires permission)",
|
|
)
|
|
notification_retention_days = models.PositiveIntegerField(
|
|
default=30,
|
|
help_text="Days to keep read notifications before auto-cleanup",
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
verbose_name = "User Profile"
|
|
verbose_name_plural = "User Profiles"
|
|
|
|
def __str__(self):
|
|
return f"{self.user.username}'s Profile"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("themis:profile-settings")
|
|
|
|
@property
|
|
def effective_timezone(self):
|
|
"""Return current timezone if set, otherwise home timezone."""
|
|
return self.current_timezone or self.home_timezone
|
|
|
|
@property
|
|
def is_traveling(self):
|
|
"""Check if user has a current timezone different from home."""
|
|
return bool(
|
|
self.current_timezone and self.current_timezone != self.home_timezone
|
|
)
|
|
|
|
|
|
class UserAPIKey(models.Model):
|
|
"""
|
|
Stores encrypted API keys, MCP credentials, DAV passwords, and other
|
|
service credentials for a user. Keys are encrypted at rest using Fernet.
|
|
|
|
This model does not define what the keys are for — consuming apps
|
|
register which key types they need. The instructions field allows
|
|
per-key documentation so users know how to obtain and use each key.
|
|
"""
|
|
|
|
KEY_TYPE_CHOICES = [
|
|
("api", "API Key"),
|
|
("dav", "DAV Credentials"),
|
|
("token", "Access Token"),
|
|
("secret", "Secret Key"),
|
|
("other", "Other"),
|
|
]
|
|
|
|
id = models.UUIDField(
|
|
primary_key=True,
|
|
default=uuid.uuid4,
|
|
editable=False,
|
|
)
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name="api_keys",
|
|
)
|
|
|
|
# Key identity
|
|
service_name = models.CharField(
|
|
max_length=100,
|
|
help_text="Service this key belongs to (e.g. OpenAI, Anthropic, CalDAV)",
|
|
)
|
|
key_type = models.CharField(
|
|
max_length=30,
|
|
choices=KEY_TYPE_CHOICES,
|
|
default="api",
|
|
help_text="Type of credential",
|
|
)
|
|
label = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
default="",
|
|
help_text="Your nickname for this key (e.g. 'Work account')",
|
|
)
|
|
|
|
# Encrypted key value
|
|
encrypted_value = models.TextField(
|
|
help_text="Fernet-encrypted credential value",
|
|
)
|
|
|
|
# Documentation
|
|
instructions = models.TextField(
|
|
blank=True,
|
|
default="",
|
|
help_text="How to obtain and use this key",
|
|
)
|
|
help_url = models.URLField(
|
|
blank=True,
|
|
default="",
|
|
help_text="Link to service documentation",
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text="Whether this key is currently in use",
|
|
)
|
|
last_used_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Last time this key was used",
|
|
)
|
|
expires_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="When this key expires (if applicable)",
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
verbose_name = "API Key"
|
|
verbose_name_plural = "API Keys"
|
|
|
|
def __str__(self):
|
|
return f"{self.service_name} ({self.key_type}) — {self.user.username}"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("themis:key-detail", kwargs={"pk": self.pk})
|
|
|
|
@property
|
|
def masked_value(self):
|
|
"""Return masked version of the decrypted key, showing only last 4 chars."""
|
|
from themis.encryption import decrypt_value
|
|
|
|
try:
|
|
value = decrypt_value(self.encrypted_value)
|
|
if len(value) <= 4:
|
|
return "****"
|
|
return f"{'*' * (len(value) - 4)}{value[-4:]}"
|
|
except Exception:
|
|
return "****"
|
|
|
|
@property
|
|
def display_name(self):
|
|
"""Return label if set, otherwise service_name."""
|
|
return self.label or self.service_name
|
|
|
|
|
|
class UserNotification(models.Model):
|
|
"""
|
|
In-app notification for a user.
|
|
|
|
Notifications are created by consuming apps via ``themis.notifications.notify_user()``
|
|
and displayed in the notification bell dropdown. Source tracking fields
|
|
(``source_app``, ``source_model``, ``source_id``) enable deduplication and
|
|
bulk cleanup when the originating object is deleted.
|
|
"""
|
|
|
|
LEVEL_CHOICES = [
|
|
("info", "Info"),
|
|
("success", "Success"),
|
|
("warning", "Warning"),
|
|
("danger", "Danger"),
|
|
]
|
|
|
|
# Numeric weights for level comparison
|
|
LEVEL_WEIGHTS = {
|
|
"info": 0,
|
|
"success": 0,
|
|
"warning": 1,
|
|
"danger": 2,
|
|
}
|
|
|
|
id = models.UUIDField(
|
|
primary_key=True,
|
|
default=uuid.uuid4,
|
|
editable=False,
|
|
)
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name="notifications",
|
|
)
|
|
|
|
# Content
|
|
title = models.CharField(
|
|
max_length=200,
|
|
help_text="Short notification headline",
|
|
)
|
|
message = models.TextField(
|
|
blank=True,
|
|
default="",
|
|
help_text="Notification body text",
|
|
)
|
|
level = models.CharField(
|
|
max_length=10,
|
|
choices=LEVEL_CHOICES,
|
|
default="info",
|
|
help_text="Notification severity level",
|
|
)
|
|
url = models.CharField(
|
|
max_length=500,
|
|
blank=True,
|
|
default="",
|
|
help_text="URL to navigate to when notification is clicked",
|
|
)
|
|
|
|
# Source tracking (for dedup and cleanup)
|
|
source_app = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
default="",
|
|
db_index=True,
|
|
help_text="App label that created this notification",
|
|
)
|
|
source_model = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
default="",
|
|
help_text="Model name that triggered this notification",
|
|
)
|
|
source_id = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
default="",
|
|
help_text="Primary key of the source object",
|
|
)
|
|
|
|
# State
|
|
is_read = models.BooleanField(
|
|
default=False,
|
|
db_index=True,
|
|
help_text="Whether the user has read this notification",
|
|
)
|
|
read_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="When the notification was read",
|
|
)
|
|
is_dismissed = models.BooleanField(
|
|
default=False,
|
|
help_text="Whether the user has dismissed this notification",
|
|
)
|
|
dismissed_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="When the notification was dismissed",
|
|
)
|
|
expires_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Auto-dismiss after this time (optional)",
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
verbose_name = "Notification"
|
|
verbose_name_plural = "Notifications"
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(
|
|
fields=["user", "is_read", "is_dismissed"],
|
|
name="themis_notif_user_state",
|
|
),
|
|
models.Index(
|
|
fields=["user", "created_at"],
|
|
name="themis_notif_user_created",
|
|
),
|
|
models.Index(
|
|
fields=["source_app", "source_model", "source_id"],
|
|
name="themis_notif_source",
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"[{self.level}] {self.title} → {self.user.username}"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("themis:notification-list")
|
|
|
|
@property
|
|
def level_weight(self):
|
|
"""Numeric weight for level comparison."""
|
|
return self.LEVEL_WEIGHTS.get(self.level, 0)
|
|
|
|
@property
|
|
def is_expired(self):
|
|
"""Check if notification has passed its expiry time."""
|
|
if not self.expires_at:
|
|
return False
|
|
from django.utils import timezone
|
|
return timezone.now() >= self.expires_at
|
|
|
|
@property
|
|
def level_css_class(self):
|
|
"""Return DaisyUI alert class for this notification level."""
|
|
return {
|
|
"info": "alert-info",
|
|
"success": "alert-success",
|
|
"warning": "alert-warning",
|
|
"danger": "alert-error",
|
|
}.get(self.level, "alert-info")
|
|
|
|
@property
|
|
def level_badge_class(self):
|
|
"""Return DaisyUI badge class for this notification level."""
|
|
return {
|
|
"info": "badge-info",
|
|
"success": "badge-success",
|
|
"warning": "badge-warning",
|
|
"danger": "badge-error",
|
|
}.get(self.level, "badge-info")
|