Coverage for sm/models.py: 96%

83 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-24 19:06 +0000

1from __future__ import annotations 

2 

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 

12 

13 

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 ) 

32 

33 def __str__(self) -> str: 

34 return f"Profile for {self.group.name}" 

35 

36 class Meta: 

37 verbose_name = "Group Profile" 

38 verbose_name_plural = "Group Profiles" 

39 

40 

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 

48 

49 # Default to view-only, GroupCreateView will upgrade this if needed 

50 sync_group_permissions(instance, grant_all=False) 

51 

52 

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]})" 

65 

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() 

71 

72 instance.groups.add(group) 

73 

74 # Grant full permissions for personal workspace 

75 from .utils_permissions import sync_group_permissions 

76 

77 sync_group_permissions(group, grant_all=True) 

78 

79 

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) 

91 

92 class Meta: 

93 verbose_name = "Invitation" 

94 verbose_name_plural = "Invitations" 

95 unique_together = ["email", "group"] 

96 

97 def __str__(self) -> str: 

98 return f"Invitation for {self.email} to {self.group.name}" 

99 

100 def is_expired(self) -> bool: 

101 return timezone.now() - self.created_at > timezone.timedelta(hours=24) 

102 

103 

104class ApiKey(models.Model): 

105 """ 

106 API credentials for programmatic access. 

107 

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 """ 

113 

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 ) 

131 

132 class Meta: 

133 verbose_name = "API Key" 

134 verbose_name_plural = "API Keys" 

135 ordering = ["-created_at"] 

136 

137 def __str__(self) -> str: 

138 return self.name or self.client_id 

139 

140 def is_expired(self) -> bool: 

141 return self.expires_at is not None and timezone.now() >= self.expires_at 

142 

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 

161 

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 )