Coverage for modules\authentication\models.py: 74%

95 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-10-17 16:50 +0800

1import uuid 

2from django.db import models 

3from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager 

4from django.utils.text import slugify 

5from core.models import UUIDModel, TimeStampedModel 

6 

7class Organization(UUIDModel, TimeStampedModel): 

8 """An organization / tenant in the multi-tenant system.""" 

9 name = models.CharField(max_length=255) 

10 slug = models.SlugField(max_length=255, unique=True) 

11 logo_url = models.URLField(max_length=500, blank=True, null=True) 

12 currency = models.CharField(max_length=3, default='PHP') 

13 fiscal_year_start = models.IntegerField(default=1) # Month (1-12) 

14 tax_id = models.CharField(max_length=64, blank=True, null=True) 

15 website = models.URLField(blank=True, null=True) 

16 address = models.TextField(blank=True, null=True) 

17 is_active = models.BooleanField(default=True) 

18 

19 def save(self, *args, **kwargs): 

20 if not self.slug: 

21 base_slug = slugify(self.name) or 'org' 

22 slug = base_slug 

23 counter = 1 

24 while Organization.objects.filter(slug=slug).exclude(id=self.id).exists(): 

25 slug = f"{base_slug}-{counter}" 

26 counter += 1 

27 self.slug = slug 

28 super().save(*args, **kwargs) 

29 

30 def __str__(self): 

31 return self.name 

32 

33class UserManager(BaseUserManager): 

34 """Custom manager for email-based authentication.""" 

35 def create_user(self, email, password=None, **extra_fields): 

36 if not email: 

37 raise ValueError('Email address is required') 

38 email = self.normalize_email(email) 

39 user = self.model(email=email, **extra_fields) 

40 if password: 

41 user.set_password(password) 

42 else: 

43 user.set_unusable_password() 

44 user.save(using=self._db) 

45 return user 

46 

47 def create_superuser(self, email, password=None, **extra_fields): 

48 extra_fields.setdefault('is_staff', True) 

49 extra_fields.setdefault('is_superuser', True) 

50 extra_fields.setdefault('is_active', True) 

51 

52 if extra_fields.get('is_staff') is not True: 

53 raise ValueError('Superuser must have is_staff=True.') 

54 if extra_fields.get('is_superuser') is not True: 

55 raise ValueError('Superuser must have is_superuser=True.') 

56 

57 return self.create_user(email, password, **extra_fields) 

58 

59class User(AbstractBaseUser, PermissionsMixin, UUIDModel, TimeStampedModel): 

60 """Custom user model using email as unique identifier.""" 

61 email = models.EmailField(unique=True, max_length=255, db_index=True) 

62 external_email = models.EmailField(blank=True, null=True, max_length=255) 

63 first_name = models.CharField(max_length=150, blank=True) 

64 last_name = models.CharField(max_length=150, blank=True) 

65 phone_number = models.CharField(max_length=32, blank=True, null=True) 

66 avatar_url = models.URLField(max_length=500, blank=True, null=True) 

67 

68 current_organization = models.ForeignKey( 

69 Organization, 

70 on_delete=models.SET_NULL, 

71 null=True, 

72 blank=True, 

73 related_name='current_users' 

74 ) 

75 

76 is_active = models.BooleanField(default=True) 

77 is_staff = models.BooleanField(default=False) 

78 

79 objects = UserManager() 

80 

81 USERNAME_FIELD = 'email' 

82 REQUIRED_FIELDS = [] 

83 

84 @property 

85 def full_name(self) -> str: 

86 name = f"{self.first_name} {self.last_name}".strip() 

87 return name if name else self.email.split('@')[0] 

88 

89 @property 

90 def current_organization_id(self): 

91 return self.current_organization.id if self.current_organization else None 

92 

93 def __str__(self): 

94 return self.email 

95 

96class Membership(UUIDModel, TimeStampedModel): 

97 """User membership within an organization.""" 

98 ROLE_CHOICES = [ 

99 ('OWNER', 'Owner'), 

100 ('ADMIN', 'Administrator'), 

101 ('MANAGER', 'Manager'), 

102 ('HR', 'Human Resources'), 

103 ('MEMBER', 'Member'), 

104 ('GUEST', 'Guest'), 

105 ] 

106 

107 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='memberships') 

108 organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name='memberships') 

109 role = models.CharField(max_length=32, choices=ROLE_CHOICES, default='MEMBER') 

110 is_default = models.BooleanField(default=False) 

111 

112 class Meta: 

113 unique_together = ('user', 'organization') 

114 

115 def __str__(self): 

116 return f"{self.user.email} - {self.organization.name} ({self.role})" 

117 

118 

119class PasswordResetToken(UUIDModel, TimeStampedModel): 

120 """One-time password reset token stored only as a SHA-256 digest.""" 

121 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='password_reset_tokens') 

122 token_hash = models.CharField(max_length=64, unique=True) 

123 expires_at = models.DateTimeField(db_index=True) 

124 used_at = models.DateTimeField(null=True, blank=True) 

125 

126 @property 

127 def is_usable(self): 

128 from django.utils import timezone 

129 return self.used_at is None and self.expires_at > timezone.now() 

130 

131 

132class AuthAuditEvent(UUIDModel, TimeStampedModel): 

133 """Security-relevant authentication events; never stores passwords or tokens.""" 

134 EVENT_CHOICES = [ 

135 ('LOGIN_SUCCEEDED', 'Login succeeded'), 

136 ('LOGIN_FAILED', 'Login failed'), 

137 ('PASSWORD_RESET_REQUESTED', 'Password reset requested'), 

138 ('PASSWORD_RESET_COMPLETED', 'Password reset completed'), 

139 ('REFRESH_TOKEN_REUSED', 'Refresh token reused'), 

140 ] 

141 user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='auth_audit_events') 

142 event_type = models.CharField(max_length=32, choices=EVENT_CHOICES) 

143 email = models.EmailField(max_length=255, blank=True) 

144 ip_address = models.GenericIPAddressField(null=True, blank=True) 

145 metadata = models.JSONField(default=dict, blank=True)