Coverage for sm/models.py: 96%
83 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 19:06 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 19:06 +0000
1from __future__ import annotations
3from django.db import models
4from django.contrib.auth.models import Group, User
5from django.db.models.signals import post_save
6from django.dispatch import receiver
7from django.contrib.auth.hashers import make_password
8from django.utils import timezone
9from typing import Any
10import secrets
11import uuid
14class GroupProfile(models.Model):
15 group = models.OneToOneField(
16 Group, on_delete=models.CASCADE, related_name="profile"
17 )
18 owner = models.ForeignKey(
19 User,
20 on_delete=models.SET_NULL,
21 null=True,
22 blank=True,
23 related_name="owned_groups",
24 )
25 max_items = models.PositiveIntegerField(
26 default=200,
27 help_text="Maximum number of items across all models for this group.",
28 )
29 max_users = models.PositiveIntegerField(
30 default=2, help_text="Maximum number of users allowed in this group."
31 )
33 def __str__(self) -> str:
34 return f"Profile for {self.group.name}"
36 class Meta:
37 verbose_name = "Group Profile"
38 verbose_name_plural = "Group Profiles"
41@receiver(post_save, sender=Group)
42def create_group_profile(
43 sender: Any, instance: Group, created: bool, **kwargs: Any
44) -> None:
45 if created:
46 GroupProfile.objects.get_or_create(group=instance)
47 from .utils_permissions import sync_group_permissions
49 # Default to view-only, GroupCreateView will upgrade this if needed
50 sync_group_permissions(instance, grant_all=False)
53@receiver(post_save, sender=User)
54def create_personal_group(
55 sender: Any, instance: User, created: bool, **kwargs: Any
56) -> None:
57 """
58 Ensures every new user has their own personal group to start with.
59 """
60 if created:
61 group_name = f"{instance.username}'s Workspace"
62 # Ensure name uniqueness
63 if Group.objects.filter(name=group_name).exists():
64 group_name = f"{instance.username}'s Workspace ({uuid.uuid4().hex[:4]})"
66 group = Group.objects.create(name=group_name)
67 # Profile is already created by create_group_profile signal
68 profile = group.profile
69 profile.owner = instance
70 profile.save()
72 instance.groups.add(group)
74 # Grant full permissions for personal workspace
75 from .utils_permissions import sync_group_permissions
77 sync_group_permissions(group, grant_all=True)
80class Invitation(models.Model):
81 email = models.EmailField()
82 group = models.ForeignKey(
83 Group, on_delete=models.CASCADE, related_name="invitations"
84 )
85 token = models.UUIDField(default=uuid.uuid4, unique=True)
86 created_at = models.DateTimeField(default=timezone.now)
87 created_by = models.ForeignKey(
88 User, on_delete=models.SET_NULL, null=True, related_name="invitations_sent"
89 )
90 accepted_at = models.DateTimeField(null=True, blank=True)
92 class Meta:
93 verbose_name = "Invitation"
94 verbose_name_plural = "Invitations"
95 unique_together = ["email", "group"]
97 def __str__(self) -> str:
98 return f"Invitation for {self.email} to {self.group.name}"
100 def is_expired(self) -> bool:
101 return timezone.now() - self.created_at > timezone.timedelta(hours=24)
104class ApiKey(models.Model):
105 """
106 API credentials for programmatic access.
108 Each key belongs to a single user and grants exactly the same access the
109 owning user has (including group-based permissions). The secret is only
110 ever stored as a one-way hash; the plaintext value is returned a single
111 time when the key is created.
112 """
114 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="api_keys")
115 name = models.CharField(
116 max_length=100, blank=True, default="", help_text="Optional label."
117 )
118 client_id = models.CharField(max_length=64, unique=True, editable=False)
119 secret_hash = models.CharField(max_length=255, editable=False)
120 is_active = models.BooleanField(default=True)
121 created_at = models.DateTimeField(default=timezone.now)
122 last_used_at = models.DateTimeField(null=True, blank=True)
123 expires_at = models.DateTimeField(
124 null=True,
125 blank=True,
126 help_text="Optional expiry timestamp. Expired keys are rejected.",
127 )
128 revoked_at = models.DateTimeField(
129 null=True, blank=True, help_text="When the key was revoked or rotated."
130 )
132 class Meta:
133 verbose_name = "API Key"
134 verbose_name_plural = "API Keys"
135 ordering = ["-created_at"]
137 def __str__(self) -> str:
138 return self.name or self.client_id
140 def is_expired(self) -> bool:
141 return self.expires_at is not None and timezone.now() >= self.expires_at
143 @classmethod
144 def create_for_user(
145 cls, user: User, name: str = "", expires_at=None
146 ) -> tuple[ApiKey, str]:
147 """
148 Creates a new key for the user and returns the key instance together
149 with the plaintext secret. The secret is not persisted in plaintext.
150 """
151 client_id = secrets.token_urlsafe(16)
152 secret = secrets.token_urlsafe(32)
153 key = cls.objects.create(
154 user=user,
155 name=name,
156 client_id=client_id,
157 secret_hash=make_password(secret),
158 expires_at=expires_at,
159 )
160 return key, secret
162 def rotate(self, name: str = "") -> tuple[ApiKey, str]:
163 """
164 Revokes this key and returns a fresh key with the same name. The old
165 key keeps its client_id/secret but is immediately invalidated.
166 """
167 self.is_active = False
168 self.revoked_at = timezone.now()
169 self.save(update_fields=["is_active", "revoked_at"])
170 return self.create_for_user(
171 self.user, name=name or self.name, expires_at=self.expires_at
172 )