mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-27 06:07:04 -05:00
725 lines
25 KiB
Python
725 lines
25 KiB
Python
from django.dispatch import receiver
|
|
from django.db.models.signals import post_save
|
|
from django.contrib.auth.models import AbstractUser
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
from django.db import models
|
|
from django.urls import reverse
|
|
from django.utils.translation import gettext_lazy as _
|
|
import secrets
|
|
from datetime import timedelta
|
|
from django.utils import timezone
|
|
from apps.core.history import TrackedModel
|
|
from apps.core.choices import RichChoiceField
|
|
import pghistory
|
|
# from django_cloudflareimages_toolkit.models import CloudflareImage
|
|
|
|
|
|
def generate_random_id(model_class, id_field):
|
|
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
|
|
while True:
|
|
# Try to get a 4-digit number first
|
|
new_id = str(secrets.SystemRandom().randint(1000, 9999))
|
|
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
|
return new_id
|
|
|
|
# If all 4-digit numbers are taken, try 5 digits
|
|
new_id = str(secrets.SystemRandom().randint(10000, 99999))
|
|
if not model_class.objects.filter(**{id_field: new_id}).exists():
|
|
return new_id
|
|
|
|
|
|
@pghistory.track()
|
|
class User(AbstractUser):
|
|
# Override inherited fields to remove them
|
|
first_name = None
|
|
last_name = None
|
|
|
|
# Read-only ID
|
|
user_id = models.CharField(
|
|
max_length=10,
|
|
unique=True,
|
|
editable=False,
|
|
help_text=(
|
|
"Unique identifier for this user that remains constant even if the "
|
|
"username changes"
|
|
),
|
|
)
|
|
|
|
role = RichChoiceField(
|
|
choice_group="user_roles",
|
|
domain="accounts",
|
|
max_length=10,
|
|
default="USER",
|
|
db_index=True,
|
|
help_text="User role (user, moderator, admin)",
|
|
)
|
|
is_banned = models.BooleanField(
|
|
default=False, db_index=True, help_text="Whether this user is banned"
|
|
)
|
|
ban_reason = models.TextField(blank=True, help_text="Reason for ban")
|
|
ban_date = models.DateTimeField(
|
|
null=True, blank=True, help_text="Date the user was banned"
|
|
)
|
|
pending_email = models.EmailField(blank=True, null=True)
|
|
theme_preference = RichChoiceField(
|
|
choice_group="theme_preferences",
|
|
domain="accounts",
|
|
max_length=5,
|
|
default="light",
|
|
help_text="User's theme preference (light/dark)",
|
|
)
|
|
|
|
# Notification preferences
|
|
email_notifications = models.BooleanField(
|
|
default=True, help_text="Whether to send email notifications"
|
|
)
|
|
push_notifications = models.BooleanField(
|
|
default=False, help_text="Whether to send push notifications"
|
|
)
|
|
|
|
# Privacy settings
|
|
privacy_level = RichChoiceField(
|
|
choice_group="privacy_levels",
|
|
domain="accounts",
|
|
max_length=10,
|
|
default="public",
|
|
help_text="Overall privacy level",
|
|
)
|
|
show_email = models.BooleanField(
|
|
default=False, help_text="Whether to show email on profile"
|
|
)
|
|
show_real_name = models.BooleanField(
|
|
default=True, help_text="Whether to show real name on profile"
|
|
)
|
|
show_join_date = models.BooleanField(
|
|
default=True, help_text="Whether to show join date on profile"
|
|
)
|
|
show_statistics = models.BooleanField(
|
|
default=True, help_text="Whether to show statistics on profile"
|
|
)
|
|
show_reviews = models.BooleanField(
|
|
default=True, help_text="Whether to show reviews on profile"
|
|
)
|
|
show_photos = models.BooleanField(
|
|
default=True, help_text="Whether to show photos on profile"
|
|
)
|
|
show_top_lists = models.BooleanField(
|
|
default=True, help_text="Whether to show top lists on profile"
|
|
)
|
|
allow_friend_requests = models.BooleanField(
|
|
default=True, help_text="Whether to allow friend requests"
|
|
)
|
|
allow_messages = models.BooleanField(
|
|
default=True, help_text="Whether to allow direct messages"
|
|
)
|
|
allow_profile_comments = models.BooleanField(
|
|
default=False, help_text="Whether to allow profile comments"
|
|
)
|
|
search_visibility = models.BooleanField(
|
|
default=True, help_text="Whether profile appears in search results"
|
|
)
|
|
activity_visibility = RichChoiceField(
|
|
choice_group="privacy_levels",
|
|
domain="accounts",
|
|
max_length=10,
|
|
default="friends",
|
|
help_text="Who can see user activity",
|
|
)
|
|
|
|
# Security settings
|
|
two_factor_enabled = models.BooleanField(
|
|
default=False, help_text="Whether two-factor authentication is enabled"
|
|
)
|
|
login_notifications = models.BooleanField(
|
|
default=True, help_text="Whether to send login notifications"
|
|
)
|
|
session_timeout = models.IntegerField(
|
|
default=30, help_text="Session timeout in days"
|
|
)
|
|
login_history_retention = models.IntegerField(
|
|
default=90, help_text="How long to retain login history (days)"
|
|
)
|
|
last_password_change = models.DateTimeField(
|
|
auto_now_add=True, help_text="When the password was last changed"
|
|
)
|
|
|
|
# Display name - core user data for better performance
|
|
display_name = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
help_text="Display name shown throughout the site. Falls back to username if not set.",
|
|
)
|
|
|
|
# Detailed notification preferences (JSON field for flexibility)
|
|
notification_preferences = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text="Detailed notification preferences stored as JSON",
|
|
)
|
|
|
|
def __str__(self):
|
|
return self.get_display_name()
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("profile", kwargs={"username": self.username})
|
|
|
|
def get_display_name(self):
|
|
"""Get the user's display name, falling back to username if not set"""
|
|
if self.display_name:
|
|
return self.display_name
|
|
# Fallback to profile display_name for backward compatibility
|
|
profile = getattr(self, "profile", None)
|
|
if profile and profile.display_name:
|
|
return profile.display_name
|
|
return self.username
|
|
|
|
class Meta:
|
|
verbose_name = "User"
|
|
verbose_name_plural = "Users"
|
|
indexes = [
|
|
models.Index(fields=['is_banned', 'role'], name='accounts_user_banned_role_idx'),
|
|
]
|
|
constraints = [
|
|
models.CheckConstraint(
|
|
name='user_ban_consistency',
|
|
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
|
|
violation_error_message='Banned users must have a ban_date set'
|
|
),
|
|
]
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.user_id:
|
|
self.user_id = generate_random_id(User, "user_id")
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
@pghistory.track()
|
|
class UserProfile(models.Model):
|
|
# Read-only ID
|
|
profile_id = models.CharField(
|
|
max_length=10,
|
|
unique=True,
|
|
editable=False,
|
|
help_text="Unique identifier for this profile that remains constant",
|
|
)
|
|
|
|
user = models.OneToOneField(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
related_name="profile",
|
|
help_text="User this profile belongs to",
|
|
)
|
|
display_name = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
help_text="Legacy display name field - use User.display_name instead",
|
|
)
|
|
avatar = models.ForeignKey(
|
|
"django_cloudflareimages_toolkit.CloudflareImage",
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="user_profiles",
|
|
help_text="User's avatar image",
|
|
)
|
|
pronouns = models.CharField(
|
|
max_length=50, blank=True, help_text="User's preferred pronouns"
|
|
)
|
|
|
|
bio = models.TextField(max_length=500, blank=True, help_text="User biography")
|
|
location = models.CharField(
|
|
max_length=100, blank=True, help_text="User's location (City, Country)"
|
|
)
|
|
unit_system = RichChoiceField(
|
|
choice_group="unit_systems",
|
|
domain="accounts",
|
|
max_length=10,
|
|
default="metric",
|
|
help_text="Preferred measurement system",
|
|
)
|
|
|
|
# Social media links
|
|
twitter = models.URLField(blank=True, help_text="Twitter profile URL")
|
|
instagram = models.URLField(blank=True, help_text="Instagram profile URL")
|
|
youtube = models.URLField(blank=True, help_text="YouTube channel URL")
|
|
discord = models.CharField(max_length=100, blank=True, help_text="Discord username")
|
|
|
|
# Ride statistics
|
|
coaster_credits = models.IntegerField(
|
|
default=0, help_text="Number of roller coasters ridden"
|
|
)
|
|
dark_ride_credits = models.IntegerField(
|
|
default=0, help_text="Number of dark rides ridden"
|
|
)
|
|
flat_ride_credits = models.IntegerField(
|
|
default=0, help_text="Number of flat rides ridden"
|
|
)
|
|
water_ride_credits = models.IntegerField(
|
|
default=0, help_text="Number of water rides ridden"
|
|
)
|
|
|
|
def get_avatar_url(self):
|
|
"""
|
|
Return the avatar URL or generate a default letter-based avatar URL
|
|
"""
|
|
if self.avatar and self.avatar.is_uploaded:
|
|
# Try to get avatar variant first, fallback to public
|
|
avatar_url = self.avatar.get_url('avatar')
|
|
if avatar_url:
|
|
return avatar_url
|
|
|
|
# Fallback to public variant
|
|
public_url = self.avatar.get_url('public')
|
|
if public_url:
|
|
return public_url
|
|
|
|
# Last fallback - try any available variant
|
|
if self.avatar.variants:
|
|
if isinstance(self.avatar.variants, list) and self.avatar.variants:
|
|
return self.avatar.variants[0]
|
|
elif isinstance(self.avatar.variants, dict):
|
|
# Return first available variant
|
|
for variant_url in self.avatar.variants.values():
|
|
if variant_url:
|
|
return variant_url
|
|
|
|
# Generate default letter-based avatar using first letter of username
|
|
first_letter = self.user.username[0].upper() if self.user.username else "U"
|
|
# Use a service like UI Avatars or generate a simple colored avatar
|
|
return f"https://ui-avatars.com/api/?name={first_letter}&size=200&background=random&color=fff&bold=true"
|
|
|
|
def get_avatar_variants(self):
|
|
"""
|
|
Return avatar variants for different use cases
|
|
"""
|
|
if self.avatar and self.avatar.is_uploaded:
|
|
variants = {}
|
|
|
|
# Try to get specific variants
|
|
thumbnail_url = self.avatar.get_url('thumbnail')
|
|
avatar_url = self.avatar.get_url('avatar')
|
|
large_url = self.avatar.get_url('large')
|
|
public_url = self.avatar.get_url('public')
|
|
|
|
# Use specific variants if available, otherwise fallback to public or first available
|
|
fallback_url = public_url
|
|
if not fallback_url and self.avatar.variants:
|
|
if isinstance(self.avatar.variants, list) and self.avatar.variants:
|
|
fallback_url = self.avatar.variants[0]
|
|
elif isinstance(self.avatar.variants, dict):
|
|
fallback_url = next(iter(self.avatar.variants.values()), None)
|
|
|
|
variants = {
|
|
"thumbnail": thumbnail_url or fallback_url,
|
|
"avatar": avatar_url or fallback_url,
|
|
"large": large_url or fallback_url,
|
|
}
|
|
|
|
# Only return variants if we have at least one valid URL
|
|
if any(variants.values()):
|
|
return variants
|
|
|
|
# For default avatars, return the same URL for all variants
|
|
default_url = self.get_avatar_url()
|
|
return {
|
|
"thumbnail": default_url,
|
|
"avatar": default_url,
|
|
"large": default_url,
|
|
}
|
|
|
|
def save(self, *args, **kwargs):
|
|
# If no display name is set, use the username
|
|
if not self.display_name:
|
|
self.display_name = self.user.username
|
|
|
|
if not self.profile_id:
|
|
self.profile_id = generate_random_id(UserProfile, "profile_id")
|
|
super().save(*args, **kwargs)
|
|
|
|
def __str__(self):
|
|
return self.display_name
|
|
|
|
class Meta:
|
|
verbose_name = "User Profile"
|
|
verbose_name_plural = "User Profiles"
|
|
ordering = ["user"]
|
|
|
|
|
|
@pghistory.track()
|
|
class EmailVerification(models.Model):
|
|
user = models.OneToOneField(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
help_text="User this verification belongs to",
|
|
)
|
|
token = models.CharField(
|
|
max_length=64, unique=True, help_text="Verification token"
|
|
)
|
|
created_at = models.DateTimeField(
|
|
auto_now_add=True, help_text="When this verification was created"
|
|
)
|
|
last_sent = models.DateTimeField(
|
|
auto_now_add=True, help_text="When the verification email was last sent"
|
|
)
|
|
|
|
def __str__(self):
|
|
return f"Email verification for {self.user.username}"
|
|
|
|
class Meta:
|
|
verbose_name = "Email Verification"
|
|
verbose_name_plural = "Email Verifications"
|
|
|
|
|
|
@pghistory.track()
|
|
class PasswordReset(models.Model):
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
help_text="User requesting password reset",
|
|
)
|
|
token = models.CharField(max_length=64, help_text="Reset token")
|
|
created_at = models.DateTimeField(
|
|
auto_now_add=True, help_text="When this reset was requested"
|
|
)
|
|
expires_at = models.DateTimeField(help_text="When this reset token expires")
|
|
used = models.BooleanField(default=False, help_text="Whether this token has been used")
|
|
|
|
def __str__(self):
|
|
return f"Password reset for {self.user.username}"
|
|
|
|
class Meta:
|
|
verbose_name = "Password Reset"
|
|
verbose_name_plural = "Password Resets"
|
|
|
|
|
|
|
|
|
|
@pghistory.track()
|
|
class UserDeletionRequest(models.Model):
|
|
"""
|
|
Model to track user deletion requests with email verification.
|
|
|
|
When a user requests to delete their account, a verification code
|
|
is sent to their email. The deletion is only processed when they
|
|
provide the correct code.
|
|
"""
|
|
|
|
user = models.OneToOneField(
|
|
User, on_delete=models.CASCADE, related_name="deletion_request"
|
|
)
|
|
|
|
verification_code = models.CharField(
|
|
max_length=32,
|
|
unique=True,
|
|
help_text="Unique verification code sent to user's email",
|
|
)
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
expires_at = models.DateTimeField(help_text="When this deletion request expires")
|
|
|
|
email_sent_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When the verification email was sent"
|
|
)
|
|
|
|
attempts = models.PositiveIntegerField(
|
|
default=0, help_text="Number of verification attempts made"
|
|
)
|
|
|
|
max_attempts = models.PositiveIntegerField(
|
|
default=5, help_text="Maximum number of verification attempts allowed"
|
|
)
|
|
|
|
is_used = models.BooleanField(
|
|
default=False, help_text="Whether this deletion request has been used"
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "User Deletion Request"
|
|
verbose_name_plural = "User Deletion Requests"
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["verification_code"]),
|
|
models.Index(fields=["expires_at"]),
|
|
models.Index(fields=["user", "is_used"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"Deletion request for {self.user.username} - {self.verification_code}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.verification_code:
|
|
self.verification_code = self.generate_verification_code()
|
|
|
|
if not self.expires_at:
|
|
# Deletion requests expire after 24 hours
|
|
self.expires_at = timezone.now() + timedelta(hours=24)
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@staticmethod
|
|
def generate_verification_code():
|
|
"""Generate a unique 8-character verification code."""
|
|
while True:
|
|
# Generate a random 8-character alphanumeric code
|
|
code = "".join(
|
|
secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8)
|
|
)
|
|
|
|
# Ensure it's unique
|
|
if not UserDeletionRequest.objects.filter(verification_code=code).exists():
|
|
return code
|
|
|
|
def is_expired(self):
|
|
"""Check if this deletion request has expired."""
|
|
return timezone.now() > self.expires_at
|
|
|
|
def is_valid(self):
|
|
"""Check if this deletion request is still valid."""
|
|
return (
|
|
not self.is_used
|
|
and not self.is_expired()
|
|
and self.attempts < self.max_attempts
|
|
)
|
|
|
|
def increment_attempts(self):
|
|
"""Increment the number of verification attempts."""
|
|
self.attempts += 1
|
|
self.save(update_fields=["attempts"])
|
|
|
|
def mark_as_used(self):
|
|
"""Mark this deletion request as used."""
|
|
self.is_used = True
|
|
self.save(update_fields=["is_used"])
|
|
|
|
@classmethod
|
|
def cleanup_expired(cls):
|
|
"""Remove expired deletion requests."""
|
|
expired_requests = cls.objects.filter(
|
|
expires_at__lt=timezone.now(), is_used=False
|
|
)
|
|
count = expired_requests.count()
|
|
expired_requests.delete()
|
|
return count
|
|
|
|
|
|
@pghistory.track()
|
|
class UserNotification(TrackedModel):
|
|
"""
|
|
Model to store user notifications for various events.
|
|
|
|
This includes submission approvals, rejections, system announcements,
|
|
and other user-relevant notifications.
|
|
"""
|
|
|
|
# Core fields
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
related_name="notifications",
|
|
help_text="User this notification is for",
|
|
)
|
|
|
|
notification_type = RichChoiceField(
|
|
choice_group="notification_types",
|
|
domain="accounts",
|
|
max_length=30,
|
|
)
|
|
|
|
title = models.CharField(max_length=200, help_text="Notification title")
|
|
message = models.TextField(help_text="Notification message")
|
|
|
|
# Optional related object (submission, review, etc.)
|
|
content_type = models.ForeignKey(
|
|
"contenttypes.ContentType",
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Type of related object",
|
|
)
|
|
object_id = models.PositiveIntegerField(
|
|
null=True, blank=True, help_text="ID of related object"
|
|
)
|
|
related_object = GenericForeignKey("content_type", "object_id")
|
|
|
|
# Metadata
|
|
priority = RichChoiceField(
|
|
choice_group="notification_priorities",
|
|
domain="accounts",
|
|
max_length=10,
|
|
default="normal",
|
|
)
|
|
|
|
# Status tracking
|
|
is_read = models.BooleanField(
|
|
default=False, help_text="Whether this notification has been read"
|
|
)
|
|
read_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When this notification was read"
|
|
)
|
|
|
|
# Delivery tracking
|
|
email_sent = models.BooleanField(default=False, help_text="Whether email was sent")
|
|
email_sent_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When email was sent"
|
|
)
|
|
push_sent = models.BooleanField(
|
|
default=False, help_text="Whether push notification was sent"
|
|
)
|
|
push_sent_at = models.DateTimeField(
|
|
null=True, blank=True, help_text="When push notification was sent"
|
|
)
|
|
|
|
# Additional data (JSON field for flexibility)
|
|
extra_data = models.JSONField(default=dict, blank=True)
|
|
|
|
# Timestamps
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
expires_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta(TrackedModel.Meta):
|
|
verbose_name = "User Notification"
|
|
verbose_name_plural = "User Notifications"
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["user", "is_read"]),
|
|
models.Index(fields=["user", "notification_type"]),
|
|
models.Index(fields=["created_at"]),
|
|
models.Index(fields=["expires_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user.username}: {self.title}"
|
|
|
|
def mark_as_read(self):
|
|
"""Mark notification as read."""
|
|
if not self.is_read:
|
|
self.is_read = True
|
|
self.read_at = timezone.now()
|
|
self.save(update_fields=["is_read", "read_at"])
|
|
|
|
def is_expired(self):
|
|
"""Check if notification has expired."""
|
|
if not self.expires_at:
|
|
return False
|
|
return timezone.now() > self.expires_at
|
|
|
|
@classmethod
|
|
def cleanup_expired(cls):
|
|
"""Remove expired notifications."""
|
|
expired_notifications = cls.objects.filter(expires_at__lt=timezone.now())
|
|
count = expired_notifications.count()
|
|
expired_notifications.delete()
|
|
return count
|
|
|
|
@classmethod
|
|
def mark_all_read_for_user(cls, user):
|
|
"""Mark all notifications as read for a specific user."""
|
|
return cls.objects.filter(user=user, is_read=False).update(
|
|
is_read=True, read_at=timezone.now()
|
|
)
|
|
|
|
|
|
@pghistory.track()
|
|
class NotificationPreference(TrackedModel):
|
|
"""
|
|
User preferences for different types of notifications.
|
|
|
|
This allows users to control which notifications they receive
|
|
and through which channels (email, push, in-app).
|
|
"""
|
|
|
|
user = models.OneToOneField(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
related_name="notification_preference",
|
|
help_text="User these preferences belong to",
|
|
)
|
|
|
|
# Submission notifications
|
|
submission_approved_email = models.BooleanField(default=True)
|
|
submission_approved_push = models.BooleanField(default=True)
|
|
submission_approved_inapp = models.BooleanField(default=True)
|
|
|
|
submission_rejected_email = models.BooleanField(default=True)
|
|
submission_rejected_push = models.BooleanField(default=True)
|
|
submission_rejected_inapp = models.BooleanField(default=True)
|
|
|
|
submission_pending_email = models.BooleanField(default=False)
|
|
submission_pending_push = models.BooleanField(default=False)
|
|
submission_pending_inapp = models.BooleanField(default=True)
|
|
|
|
# Review notifications
|
|
review_reply_email = models.BooleanField(default=True)
|
|
review_reply_push = models.BooleanField(default=True)
|
|
review_reply_inapp = models.BooleanField(default=True)
|
|
|
|
review_helpful_email = models.BooleanField(default=False)
|
|
review_helpful_push = models.BooleanField(default=True)
|
|
review_helpful_inapp = models.BooleanField(default=True)
|
|
|
|
# Social notifications
|
|
friend_request_email = models.BooleanField(default=True)
|
|
friend_request_push = models.BooleanField(default=True)
|
|
friend_request_inapp = models.BooleanField(default=True)
|
|
|
|
friend_accepted_email = models.BooleanField(default=False)
|
|
friend_accepted_push = models.BooleanField(default=True)
|
|
friend_accepted_inapp = models.BooleanField(default=True)
|
|
|
|
message_received_email = models.BooleanField(default=True)
|
|
message_received_push = models.BooleanField(default=True)
|
|
message_received_inapp = models.BooleanField(default=True)
|
|
|
|
# System notifications
|
|
system_announcement_email = models.BooleanField(default=True)
|
|
system_announcement_push = models.BooleanField(default=False)
|
|
system_announcement_inapp = models.BooleanField(default=True)
|
|
|
|
account_security_email = models.BooleanField(default=True)
|
|
account_security_push = models.BooleanField(default=True)
|
|
account_security_inapp = models.BooleanField(default=True)
|
|
|
|
feature_update_email = models.BooleanField(default=True)
|
|
feature_update_push = models.BooleanField(default=False)
|
|
feature_update_inapp = models.BooleanField(default=True)
|
|
|
|
# Achievement notifications
|
|
achievement_unlocked_email = models.BooleanField(default=False)
|
|
achievement_unlocked_push = models.BooleanField(default=True)
|
|
achievement_unlocked_inapp = models.BooleanField(default=True)
|
|
|
|
milestone_reached_email = models.BooleanField(default=False)
|
|
milestone_reached_push = models.BooleanField(default=True)
|
|
milestone_reached_inapp = models.BooleanField(default=True)
|
|
|
|
class Meta(TrackedModel.Meta):
|
|
verbose_name = "Notification Preference"
|
|
verbose_name_plural = "Notification Preferences"
|
|
|
|
def __str__(self):
|
|
return f"Notification preferences for {self.user.username}"
|
|
|
|
def should_send_notification(self, notification_type, channel):
|
|
"""
|
|
Check if a notification should be sent for a specific type and channel.
|
|
|
|
Args:
|
|
notification_type: The type of notification (from UserNotification.NotificationType)
|
|
channel: The delivery channel ('email', 'push', 'inapp')
|
|
|
|
Returns:
|
|
bool: True if notification should be sent, False otherwise
|
|
"""
|
|
field_name = f"{notification_type}_{channel}"
|
|
return getattr(self, field_name, False)
|
|
|
|
|
|
# Signal handlers for automatic notification preference creation
|
|
|
|
|
|
@receiver(post_save, sender=User)
|
|
def create_notification_preference(sender, instance, created, **kwargs):
|
|
"""Create notification preferences when a new user is created."""
|
|
if created:
|
|
NotificationPreference.objects.create(user=instance)
|