Coverage for apps / core / models.py: 92%
72 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
1import os
2import secrets
4from django.conf import settings
5from django.db import models
6from django.utils import timezone
8from .encryption import decrypt_value, encrypt_value, is_encrypted
10# Device code character set: alphanumeric excluding confusables (0, O, 1, I, L)
11DEVICE_CODE_CHARS = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" # pragma: allowlist secret
14class AppSettings(models.Model):
15 """Singleton model for application-wide settings."""
17 # Stored encrypted with 'enc:' prefix
18 _openrouter_api_key = models.CharField(max_length=500, blank=True, db_column="openrouter_api_key")
19 default_ai_model = models.CharField(max_length=100, default="anthropic/claude-haiku-4.5")
21 # Per-feature daily AI quota limits (0 = disabled for non-exempt users)
22 daily_limit_remix = models.PositiveIntegerField(default=1)
23 daily_limit_remix_suggestions = models.PositiveIntegerField(default=2)
24 daily_limit_scale = models.PositiveIntegerField(default=2)
25 daily_limit_tips = models.PositiveIntegerField(default=2)
26 daily_limit_discover = models.PositiveIntegerField(default=1)
27 daily_limit_timer = models.PositiveIntegerField(default=3)
29 class Meta:
30 verbose_name = "App Settings"
31 verbose_name_plural = "App Settings"
33 def save(self, *args, **kwargs):
34 self.pk = 1 # Enforce singleton
36 # Encrypt key if not already encrypted
37 if self._openrouter_api_key and not is_encrypted(self._openrouter_api_key):
38 self._openrouter_api_key = encrypt_value(self._openrouter_api_key)
40 super().save(*args, **kwargs)
42 @classmethod
43 def get(cls):
44 """Get or create the singleton instance."""
45 obj, _ = cls.objects.get_or_create(pk=1)
46 return obj
48 @property
49 def openrouter_api_key(self) -> str:
50 """Get the API key. Env var OPENROUTER_API_KEY takes precedence over DB."""
51 env_key = os.environ.get("OPENROUTER_API_KEY", "")
52 if env_key:
53 return env_key
54 return decrypt_value(self._openrouter_api_key)
56 @openrouter_api_key.setter
57 def openrouter_api_key(self, value: str):
58 """Set and encrypt the API key."""
59 if value and not is_encrypted(value):
60 self._openrouter_api_key = encrypt_value(value)
61 else:
62 self._openrouter_api_key = value
65class WebAuthnCredential(models.Model):
66 """Stored passkey credential for a user."""
68 user = models.ForeignKey(
69 settings.AUTH_USER_MODEL,
70 on_delete=models.CASCADE,
71 related_name="webauthn_credentials",
72 )
73 credential_id = models.BinaryField(max_length=1024, unique=True)
74 public_key = models.BinaryField(max_length=1024)
75 sign_count = models.PositiveIntegerField(default=0)
76 transports = models.JSONField(null=True, blank=True)
77 created_at = models.DateTimeField(auto_now_add=True)
78 last_used_at = models.DateTimeField(null=True, blank=True)
80 class Meta:
81 indexes = [
82 models.Index(fields=["user"]),
83 ]
85 def __str__(self):
86 return f"WebAuthnCredential(user={self.user_id}, id={self.pk})"
89def generate_device_code():
90 """Generate a 6-character code from the confusable-free character set."""
91 return "".join(secrets.choice(DEVICE_CODE_CHARS) for _ in range(6))
94class DeviceCode(models.Model):
95 """Temporary pairing code for the device authorization flow."""
97 STATUS_CHOICES = [
98 ("pending", "Pending"),
99 ("authorized", "Authorized"),
100 ("expired", "Expired"),
101 ("invalidated", "Invalidated"),
102 ]
104 code = models.CharField(max_length=6, unique=True)
105 status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
106 session_key = models.CharField(max_length=40)
107 authorizing_user = models.ForeignKey(
108 settings.AUTH_USER_MODEL,
109 null=True,
110 blank=True,
111 on_delete=models.SET_NULL,
112 related_name="authorized_device_codes",
113 )
114 created_at = models.DateTimeField(auto_now_add=True)
115 expires_at = models.DateTimeField()
117 class Meta:
118 indexes = [
119 models.Index(fields=["session_key"]),
120 models.Index(fields=["expires_at"]),
121 ]
123 def __str__(self):
124 return f"DeviceCode({self.code}, status={self.status})"
126 def save(self, *args, **kwargs):
127 if not self.expires_at:
128 self.expires_at = timezone.now() + timezone.timedelta(seconds=settings.DEVICE_CODE_EXPIRY_SECONDS)
129 super().save(*args, **kwargs)
131 @property
132 def is_expired(self):
133 return timezone.now() >= self.expires_at