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
« 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
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)
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)
30 def __str__(self):
31 return self.name
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
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)
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.')
57 return self.create_user(email, password, **extra_fields)
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)
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 )
76 is_active = models.BooleanField(default=True)
77 is_staff = models.BooleanField(default=False)
79 objects = UserManager()
81 USERNAME_FIELD = 'email'
82 REQUIRED_FIELDS = []
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]
89 @property
90 def current_organization_id(self):
91 return self.current_organization.id if self.current_organization else None
93 def __str__(self):
94 return self.email
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 ]
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)
112 class Meta:
113 unique_together = ('user', 'organization')
115 def __str__(self):
116 return f"{self.user.email} - {self.organization.name} ({self.role})"
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)
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()
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)