""" Identity and authorization entities: User, Profile, Role, RoleAssignment. Authorization model ------------------- Roles are *additive* capability sets (learner < contributor < reviewer < moderator < admin by ``rank``). A user may hold several roles; permission checks resolve against the highest rank plus any role-specific capabilities. Role grants/revocations are always written to ``AuditLog`` by the service layer and may carry an expiry (e.g. probationary reviewers). """ import uuid from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models from apps.core.models import TimeStampedModel, UUIDModel class User(AbstractUser): """Custom user with UUID primary key and a unique, required email.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) email = models.EmailField("email address", unique=True) REQUIRED_FIELDS = ["email"] def __str__(self) -> str: return self.username class Role(UUIDModel): """One of the five platform roles. Seeded by fixtures; not user-creatable.""" class Slug(models.TextChoices): LEARNER = "learner", "Learner" CONTRIBUTOR = "contributor", "Contributor" REVIEWER = "reviewer", "Reviewer" MODERATOR = "moderator", "Moderator" ADMIN = "admin", "Admin" slug = models.SlugField(max_length=32, unique=True, choices=Slug.choices) name = models.CharField(max_length=64) description = models.TextField(blank=True) rank = models.PositiveSmallIntegerField( default=0, help_text="Capability ordering: learner=0, contributor=10, reviewer=20, " "moderator=30, admin=40.", ) is_default = models.BooleanField( default=False, help_text="Granted automatically at signup (learner)." ) class Meta: ordering = ["rank"] def __str__(self) -> str: return self.name class RoleAssignment(UUIDModel): """Grant of a Role to a User, with provenance and optional expiry.""" user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="role_assignments", ) role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="assignments") granted_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+", ) granted_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField(null=True, blank=True) class Meta: constraints = [ models.UniqueConstraint( fields=["user", "role"], name="uniq_roleassignment_user_role" ), ] def __str__(self) -> str: return f"{self.user_id} → {self.role_id}" class Profile(UUIDModel, TimeStampedModel): """Public-facing profile plus learning/reputation counters. Reputation is denormalized here for fast leaderboard/sort queries; the canonical events feeding it live in Vote/Review records and are recomputable. """ user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile" ) display_name = models.CharField(max_length=120, blank=True) bio = models.TextField(blank=True) avatar = models.ForeignKey( "core.MediaAsset", on_delete=models.SET_NULL, null=True, blank=True, related_name="+", ) website = models.URLField(blank=True) location = models.CharField(max_length=120, blank=True) preferred_language = models.CharField(max_length=12, default="en") timezone = models.CharField(max_length=64, default="UTC") reputation = models.IntegerField(default=0, db_index=True) contribution_count = models.PositiveIntegerField(default=0) review_count = models.PositiveIntegerField(default=0) current_streak_days = models.PositiveIntegerField(default=0) longest_streak_days = models.PositiveIntegerField(default=0) last_activity_date = models.DateField(null=True, blank=True) notification_preferences = models.JSONField( default=dict, blank=True, help_text="Per-channel opt-in/opt-out map, e.g. {'email': {'review_requested': true}}.", ) class Meta: constraints = [ models.CheckConstraint( condition=models.Q( longest_streak_days__gte=models.F("current_streak_days") ), name="profile_longest_streak_gte_current", ), ] def __str__(self) -> str: return self.display_name or str(self.user_id)