diff --git a/django/apps/__init__.py b/django/apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/__pycache__/__init__.cpython-313.pyc b/django/apps/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..bf8e4e22 Binary files /dev/null and b/django/apps/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/core/__init__.py b/django/apps/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/core/__pycache__/__init__.cpython-313.pyc b/django/apps/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..2473403c Binary files /dev/null and b/django/apps/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/core/__pycache__/apps.cpython-313.pyc b/django/apps/core/__pycache__/apps.cpython-313.pyc new file mode 100644 index 00000000..d74d5dc9 Binary files /dev/null and b/django/apps/core/__pycache__/apps.cpython-313.pyc differ diff --git a/django/apps/core/__pycache__/models.cpython-313.pyc b/django/apps/core/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..bfd57cca Binary files /dev/null and b/django/apps/core/__pycache__/models.cpython-313.pyc differ diff --git a/django/apps/core/apps.py b/django/apps/core/apps.py new file mode 100644 index 00000000..8acdc11d --- /dev/null +++ b/django/apps/core/apps.py @@ -0,0 +1,11 @@ +""" +Core app configuration. +""" + +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.core' + verbose_name = 'Core' diff --git a/django/apps/core/migrations/0001_initial.py b/django/apps/core/migrations/0001_initial.py new file mode 100644 index 00000000..4bedf241 --- /dev/null +++ b/django/apps/core/migrations/0001_initial.py @@ -0,0 +1,194 @@ +# Generated by Django 4.2.8 on 2025-11-08 16:35 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import django_lifecycle.mixins +import model_utils.fields +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Country", + fields=[ + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ( + "code", + models.CharField( + help_text="ISO 3166-1 alpha-2 country code", + max_length=2, + unique=True, + ), + ), + ( + "code3", + models.CharField( + blank=True, + help_text="ISO 3166-1 alpha-3 country code", + max_length=3, + ), + ), + ], + options={ + "verbose_name_plural": "countries", + "db_table": "countries", + "ordering": ["name"], + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + migrations.CreateModel( + name="Subdivision", + fields=[ + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=255)), + ( + "code", + models.CharField( + help_text="ISO 3166-2 subdivision code (without country prefix)", + max_length=10, + ), + ), + ( + "subdivision_type", + models.CharField( + blank=True, + help_text="Type of subdivision (state, province, region, etc.)", + max_length=50, + ), + ), + ( + "country", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subdivisions", + to="core.country", + ), + ), + ], + options={ + "db_table": "subdivisions", + "ordering": ["country", "name"], + "unique_together": {("country", "code")}, + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + migrations.CreateModel( + name="Locality", + fields=[ + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=255)), + ( + "latitude", + models.DecimalField( + blank=True, decimal_places=6, max_digits=9, null=True + ), + ), + ( + "longitude", + models.DecimalField( + blank=True, decimal_places=6, max_digits=9, null=True + ), + ), + ( + "subdivision", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="localities", + to="core.subdivision", + ), + ), + ], + options={ + "verbose_name_plural": "localities", + "db_table": "localities", + "ordering": ["subdivision", "name"], + "indexes": [ + models.Index( + fields=["subdivision", "name"], + name="localities_subdivi_675d5a_idx", + ) + ], + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + ] diff --git a/django/apps/core/migrations/__init__.py b/django/apps/core/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc b/django/apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 00000000..88ad8153 Binary files /dev/null and b/django/apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/django/apps/core/migrations/__pycache__/__init__.cpython-313.pyc b/django/apps/core/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..6da67ee5 Binary files /dev/null and b/django/apps/core/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/core/models.py b/django/apps/core/models.py new file mode 100644 index 00000000..a98cbe8c --- /dev/null +++ b/django/apps/core/models.py @@ -0,0 +1,264 @@ +""" +Core base models and utilities for ThrillWiki. +These abstract models provide common functionality for all entities. +""" + +import uuid +from django.db import models +from model_utils.models import TimeStampedModel +from django_lifecycle import LifecycleModel, hook, AFTER_CREATE, AFTER_UPDATE +from dirtyfields import DirtyFieldsMixin + + +class BaseModel(LifecycleModel, TimeStampedModel): + """ + Abstract base model for all entities. + + Provides: + - UUID primary key + - created_at and updated_at timestamps (from TimeStampedModel) + - Lifecycle hooks for versioning + """ + id = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + editable=False + ) + + class Meta: + abstract = True + + def __str__(self): + return f"{self.__class__.__name__}({self.id})" + + +class VersionedModel(DirtyFieldsMixin, BaseModel): + """ + Abstract base model for entities that need version tracking. + + Automatically creates a version record whenever the model is created or updated. + Uses DirtyFieldsMixin to track which fields changed. + """ + + @hook(AFTER_CREATE) + def create_version_on_create(self): + """Create initial version when entity is created""" + self._create_version('created') + + @hook(AFTER_UPDATE) + def create_version_on_update(self): + """Create version when entity is updated""" + if self.get_dirty_fields(): + self._create_version('updated') + + def _create_version(self, change_type): + """ + Create a version record for this entity. + Deferred import to avoid circular dependencies. + """ + try: + from apps.versioning.services import VersionService + VersionService.create_version( + entity=self, + change_type=change_type, + changed_fields=self.get_dirty_fields() if change_type == 'updated' else {} + ) + except ImportError: + # Versioning app not yet available (e.g., during initial migrations) + pass + + class Meta: + abstract = True + + +# Location Models + +class Country(BaseModel): + """ + Country reference data (ISO 3166-1). + + Examples: United States, Canada, United Kingdom, etc. + """ + name = models.CharField(max_length=255, unique=True) + code = models.CharField( + max_length=2, + unique=True, + help_text="ISO 3166-1 alpha-2 country code" + ) + code3 = models.CharField( + max_length=3, + blank=True, + help_text="ISO 3166-1 alpha-3 country code" + ) + + class Meta: + db_table = 'countries' + ordering = ['name'] + verbose_name_plural = 'countries' + + def __str__(self): + return self.name + + +class Subdivision(BaseModel): + """ + State/Province/Region reference data (ISO 3166-2). + + Examples: California, Ontario, England, etc. + """ + country = models.ForeignKey( + Country, + on_delete=models.CASCADE, + related_name='subdivisions' + ) + name = models.CharField(max_length=255) + code = models.CharField( + max_length=10, + help_text="ISO 3166-2 subdivision code (without country prefix)" + ) + subdivision_type = models.CharField( + max_length=50, + blank=True, + help_text="Type of subdivision (state, province, region, etc.)" + ) + + class Meta: + db_table = 'subdivisions' + ordering = ['country', 'name'] + unique_together = [['country', 'code']] + + def __str__(self): + return f"{self.name}, {self.country.code}" + + +class Locality(BaseModel): + """ + City/Town reference data. + + Examples: Los Angeles, Toronto, London, etc. + """ + subdivision = models.ForeignKey( + Subdivision, + on_delete=models.CASCADE, + related_name='localities' + ) + name = models.CharField(max_length=255) + latitude = models.DecimalField( + max_digits=9, + decimal_places=6, + null=True, + blank=True + ) + longitude = models.DecimalField( + max_digits=9, + decimal_places=6, + null=True, + blank=True + ) + + class Meta: + db_table = 'localities' + ordering = ['subdivision', 'name'] + verbose_name_plural = 'localities' + indexes = [ + models.Index(fields=['subdivision', 'name']), + ] + + def __str__(self): + return f"{self.name}, {self.subdivision.code}" + + @property + def full_location(self): + """Return full location string: City, State, Country""" + return f"{self.name}, {self.subdivision.name}, {self.subdivision.country.name}" + + +# Date Precision Tracking + +class DatePrecisionMixin(models.Model): + """ + Mixin for models that need to track date precision. + + Allows tracking whether a date is known to year, month, or day precision. + This is important for historical records where exact dates may not be known. + """ + + DATE_PRECISION_CHOICES = [ + ('year', 'Year'), + ('month', 'Month'), + ('day', 'Day'), + ] + + class Meta: + abstract = True + + @classmethod + def add_date_precision_field(cls, field_name): + """ + Helper to add a precision field for a date field. + + Usage in subclass: + opening_date = models.DateField(null=True, blank=True) + opening_date_precision = models.CharField(...) + """ + return models.CharField( + max_length=20, + choices=cls.DATE_PRECISION_CHOICES, + default='day', + help_text=f"Precision level for {field_name}" + ) + + +# Soft Delete Mixin + +class SoftDeleteMixin(models.Model): + """ + Mixin for soft-deletable models. + + Instead of actually deleting records, mark them as deleted. + This preserves data integrity and allows for undelete functionality. + """ + is_deleted = models.BooleanField(default=False, db_index=True) + deleted_at = models.DateTimeField(null=True, blank=True) + deleted_by = models.ForeignKey( + 'users.User', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='%(class)s_deletions' + ) + + class Meta: + abstract = True + + def soft_delete(self, user=None): + """Mark this record as deleted""" + from django.utils import timezone + self.is_deleted = True + self.deleted_at = timezone.now() + if user: + self.deleted_by = user + self.save(update_fields=['is_deleted', 'deleted_at', 'deleted_by']) + + def undelete(self): + """Restore a soft-deleted record""" + self.is_deleted = False + self.deleted_at = None + self.deleted_by = None + self.save(update_fields=['is_deleted', 'deleted_at', 'deleted_by']) + + +# Model Managers + +class ActiveManager(models.Manager): + """Manager that filters out soft-deleted records by default""" + + def get_queryset(self): + return super().get_queryset().filter(is_deleted=False) + + +class AllObjectsManager(models.Manager): + """Manager that includes all records, even soft-deleted ones""" + + def get_queryset(self): + return super().get_queryset() diff --git a/django/apps/entities/__init__.py b/django/apps/entities/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/entities/__pycache__/__init__.cpython-313.pyc b/django/apps/entities/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..027a3570 Binary files /dev/null and b/django/apps/entities/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/entities/__pycache__/apps.cpython-313.pyc b/django/apps/entities/__pycache__/apps.cpython-313.pyc new file mode 100644 index 00000000..506484b1 Binary files /dev/null and b/django/apps/entities/__pycache__/apps.cpython-313.pyc differ diff --git a/django/apps/entities/__pycache__/models.cpython-313.pyc b/django/apps/entities/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..62112874 Binary files /dev/null and b/django/apps/entities/__pycache__/models.cpython-313.pyc differ diff --git a/django/apps/entities/apps.py b/django/apps/entities/apps.py new file mode 100644 index 00000000..4b090053 --- /dev/null +++ b/django/apps/entities/apps.py @@ -0,0 +1,11 @@ +""" +Entities app configuration. +""" + +from django.apps import AppConfig + + +class EntitiesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.entities' + verbose_name = 'Entities' diff --git a/django/apps/entities/models.py b/django/apps/entities/models.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/media/__init__.py b/django/apps/media/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/media/__pycache__/__init__.cpython-313.pyc b/django/apps/media/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..0ba36b50 Binary files /dev/null and b/django/apps/media/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/media/__pycache__/apps.cpython-313.pyc b/django/apps/media/__pycache__/apps.cpython-313.pyc new file mode 100644 index 00000000..4296cff5 Binary files /dev/null and b/django/apps/media/__pycache__/apps.cpython-313.pyc differ diff --git a/django/apps/media/__pycache__/models.cpython-313.pyc b/django/apps/media/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..10abc1d6 Binary files /dev/null and b/django/apps/media/__pycache__/models.cpython-313.pyc differ diff --git a/django/apps/media/apps.py b/django/apps/media/apps.py new file mode 100644 index 00000000..9eab08e5 --- /dev/null +++ b/django/apps/media/apps.py @@ -0,0 +1,11 @@ +""" +Media app configuration. +""" + +from django.apps import AppConfig + + +class MediaConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.media' + verbose_name = 'Media' diff --git a/django/apps/media/models.py b/django/apps/media/models.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/moderation/__init__.py b/django/apps/moderation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/moderation/__pycache__/__init__.cpython-313.pyc b/django/apps/moderation/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..496944e2 Binary files /dev/null and b/django/apps/moderation/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/moderation/__pycache__/apps.cpython-313.pyc b/django/apps/moderation/__pycache__/apps.cpython-313.pyc new file mode 100644 index 00000000..a5d69eb8 Binary files /dev/null and b/django/apps/moderation/__pycache__/apps.cpython-313.pyc differ diff --git a/django/apps/moderation/__pycache__/models.cpython-313.pyc b/django/apps/moderation/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..1917e1a2 Binary files /dev/null and b/django/apps/moderation/__pycache__/models.cpython-313.pyc differ diff --git a/django/apps/moderation/apps.py b/django/apps/moderation/apps.py new file mode 100644 index 00000000..7989d7f3 --- /dev/null +++ b/django/apps/moderation/apps.py @@ -0,0 +1,11 @@ +""" +Moderation app configuration. +""" + +from django.apps import AppConfig + + +class ModerationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.moderation' + verbose_name = 'Moderation' diff --git a/django/apps/moderation/models.py b/django/apps/moderation/models.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/notifications/__init__.py b/django/apps/notifications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/notifications/__pycache__/__init__.cpython-313.pyc b/django/apps/notifications/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..e6c36319 Binary files /dev/null and b/django/apps/notifications/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/notifications/__pycache__/apps.cpython-313.pyc b/django/apps/notifications/__pycache__/apps.cpython-313.pyc new file mode 100644 index 00000000..6794673c Binary files /dev/null and b/django/apps/notifications/__pycache__/apps.cpython-313.pyc differ diff --git a/django/apps/notifications/__pycache__/models.cpython-313.pyc b/django/apps/notifications/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..70b457c5 Binary files /dev/null and b/django/apps/notifications/__pycache__/models.cpython-313.pyc differ diff --git a/django/apps/notifications/apps.py b/django/apps/notifications/apps.py new file mode 100644 index 00000000..a581e111 --- /dev/null +++ b/django/apps/notifications/apps.py @@ -0,0 +1,11 @@ +""" +Notifications app configuration. +""" + +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.notifications' + verbose_name = 'Notifications' diff --git a/django/apps/notifications/models.py b/django/apps/notifications/models.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/users/__init__.py b/django/apps/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/users/__pycache__/__init__.cpython-313.pyc b/django/apps/users/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..dcee91e3 Binary files /dev/null and b/django/apps/users/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/users/__pycache__/apps.cpython-313.pyc b/django/apps/users/__pycache__/apps.cpython-313.pyc new file mode 100644 index 00000000..62883dc3 Binary files /dev/null and b/django/apps/users/__pycache__/apps.cpython-313.pyc differ diff --git a/django/apps/users/__pycache__/models.cpython-313.pyc b/django/apps/users/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..ebc5caf9 Binary files /dev/null and b/django/apps/users/__pycache__/models.cpython-313.pyc differ diff --git a/django/apps/users/apps.py b/django/apps/users/apps.py new file mode 100644 index 00000000..0a698b2f --- /dev/null +++ b/django/apps/users/apps.py @@ -0,0 +1,17 @@ +""" +Users app configuration. +""" + +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.users' + verbose_name = 'Users' + + def ready(self): + """Import signal handlers when app is ready""" + # Import signals here to avoid circular imports + # import apps.users.signals + pass diff --git a/django/apps/users/migrations/0001_initial.py b/django/apps/users/migrations/0001_initial.py new file mode 100644 index 00000000..2dc5b86d --- /dev/null +++ b/django/apps/users/migrations/0001_initial.py @@ -0,0 +1,370 @@ +# Generated by Django 4.2.8 on 2025-11-08 16:35 + +from django.conf import settings +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import django_lifecycle.mixins +import model_utils.fields +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "email", + models.EmailField( + help_text="Email address for authentication", + max_length=254, + unique=True, + ), + ), + ( + "oauth_provider", + models.CharField( + blank=True, + choices=[ + ("", "None"), + ("google", "Google"), + ("discord", "Discord"), + ], + help_text="OAuth provider used for authentication", + max_length=50, + ), + ), + ( + "oauth_sub", + models.CharField( + blank=True, + help_text="OAuth subject identifier from provider", + max_length=255, + ), + ), + ( + "mfa_enabled", + models.BooleanField( + default=False, + help_text="Whether two-factor authentication is enabled", + ), + ), + ( + "avatar_url", + models.URLField(blank=True, help_text="URL to user's avatar image"), + ), + ( + "bio", + models.TextField( + blank=True, help_text="User biography", max_length=500 + ), + ), + ( + "banned", + models.BooleanField( + db_index=True, + default=False, + help_text="Whether this user is banned", + ), + ), + ( + "ban_reason", + models.TextField(blank=True, help_text="Reason for ban"), + ), + ( + "banned_at", + models.DateTimeField( + blank=True, help_text="When the user was banned", null=True + ), + ), + ( + "reputation_score", + models.IntegerField( + default=0, + help_text="User reputation score based on contributions", + ), + ), + ( + "banned_by", + models.ForeignKey( + blank=True, + help_text="Moderator who banned this user", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="users_banned", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "db_table": "users", + "ordering": ["-date_joined"], + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name="UserRole", + fields=[ + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "role", + models.CharField( + choices=[ + ("user", "User"), + ("moderator", "Moderator"), + ("admin", "Admin"), + ], + db_index=True, + default="user", + max_length=20, + ), + ), + ("granted_at", models.DateTimeField(auto_now_add=True)), + ( + "granted_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="roles_granted", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="role", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "user_roles", + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + migrations.CreateModel( + name="UserProfile", + fields=[ + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "email_notifications", + models.BooleanField( + default=True, help_text="Receive email notifications" + ), + ), + ( + "email_on_submission_approved", + models.BooleanField( + default=True, help_text="Email when submissions are approved" + ), + ), + ( + "email_on_submission_rejected", + models.BooleanField( + default=True, help_text="Email when submissions are rejected" + ), + ), + ( + "profile_public", + models.BooleanField( + default=True, help_text="Make profile publicly visible" + ), + ), + ( + "show_email", + models.BooleanField( + default=False, help_text="Show email on public profile" + ), + ), + ( + "total_submissions", + models.IntegerField( + default=0, help_text="Total number of submissions made" + ), + ), + ( + "approved_submissions", + models.IntegerField( + default=0, help_text="Number of approved submissions" + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "user_profiles", + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + migrations.AddIndex( + model_name="user", + index=models.Index(fields=["email"], name="users_email_4b85f2_idx"), + ), + migrations.AddIndex( + model_name="user", + index=models.Index(fields=["banned"], name="users_banned_ee00ad_idx"), + ), + ] diff --git a/django/apps/users/migrations/__init__.py b/django/apps/users/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc b/django/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 00000000..19b98e3a Binary files /dev/null and b/django/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/django/apps/users/migrations/__pycache__/__init__.cpython-313.pyc b/django/apps/users/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..85360da1 Binary files /dev/null and b/django/apps/users/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/users/models.py b/django/apps/users/models.py new file mode 100644 index 00000000..ed4d14be --- /dev/null +++ b/django/apps/users/models.py @@ -0,0 +1,257 @@ +""" +User models for ThrillWiki. +Custom user model with OAuth and MFA support. +""" + +import uuid +from django.contrib.auth.models import AbstractUser +from django.db import models +from apps.core.models import BaseModel + + +class User(AbstractUser): + """ + Custom user model with UUID primary key and additional fields. + + Supports: + - Email-based authentication + - OAuth (Google, Discord) + - Two-factor authentication (TOTP) + - User reputation and moderation + """ + + # Override id to use UUID + id = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + editable=False + ) + + # Email as primary identifier + email = models.EmailField( + unique=True, + help_text="Email address for authentication" + ) + + # OAuth fields + oauth_provider = models.CharField( + max_length=50, + blank=True, + choices=[ + ('', 'None'), + ('google', 'Google'), + ('discord', 'Discord'), + ], + help_text="OAuth provider used for authentication" + ) + oauth_sub = models.CharField( + max_length=255, + blank=True, + help_text="OAuth subject identifier from provider" + ) + + # MFA fields + mfa_enabled = models.BooleanField( + default=False, + help_text="Whether two-factor authentication is enabled" + ) + + # Profile fields + avatar_url = models.URLField( + blank=True, + help_text="URL to user's avatar image" + ) + bio = models.TextField( + blank=True, + max_length=500, + help_text="User biography" + ) + + # Moderation fields + 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" + ) + banned_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the user was banned" + ) + banned_by = models.ForeignKey( + 'self', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='users_banned', + help_text="Moderator who banned this user" + ) + + # Reputation system + reputation_score = models.IntegerField( + default=0, + help_text="User reputation score based on contributions" + ) + + # Timestamps (inherited from AbstractUser) + # date_joined, last_login + + # Use email for authentication + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['username'] + + class Meta: + db_table = 'users' + ordering = ['-date_joined'] + indexes = [ + models.Index(fields=['email']), + models.Index(fields=['banned']), + ] + + def __str__(self): + return self.email + + def ban(self, reason, banned_by=None): + """Ban this user""" + from django.utils import timezone + self.banned = True + self.ban_reason = reason + self.banned_at = timezone.now() + self.banned_by = banned_by + self.save(update_fields=['banned', 'ban_reason', 'banned_at', 'banned_by']) + + def unban(self): + """Unban this user""" + self.banned = False + self.ban_reason = '' + self.banned_at = None + self.banned_by = None + self.save(update_fields=['banned', 'ban_reason', 'banned_at', 'banned_by']) + + @property + def display_name(self): + """Return the user's display name (full name or username)""" + if self.first_name or self.last_name: + return f"{self.first_name} {self.last_name}".strip() + return self.username or self.email.split('@')[0] + + +class UserRole(BaseModel): + """ + User role assignments for permission management. + + Roles: + - user: Standard user (default) + - moderator: Can approve submissions and moderate content + - admin: Full access to admin features + """ + + ROLE_CHOICES = [ + ('user', 'User'), + ('moderator', 'Moderator'), + ('admin', 'Admin'), + ] + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name='role' + ) + role = models.CharField( + max_length=20, + choices=ROLE_CHOICES, + default='user', + db_index=True + ) + granted_at = models.DateTimeField(auto_now_add=True) + granted_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='roles_granted' + ) + + class Meta: + db_table = 'user_roles' + + def __str__(self): + return f"{self.user.email} - {self.role}" + + @property + def is_moderator(self): + """Check if user is a moderator or admin""" + return self.role in ['moderator', 'admin'] + + @property + def is_admin(self): + """Check if user is an admin""" + return self.role == 'admin' + + +class UserProfile(BaseModel): + """ + Extended user profile information. + + Stores additional user preferences and settings. + """ + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name='profile' + ) + + # Preferences + email_notifications = models.BooleanField( + default=True, + help_text="Receive email notifications" + ) + email_on_submission_approved = models.BooleanField( + default=True, + help_text="Email when submissions are approved" + ) + email_on_submission_rejected = models.BooleanField( + default=True, + help_text="Email when submissions are rejected" + ) + + # Privacy + profile_public = models.BooleanField( + default=True, + help_text="Make profile publicly visible" + ) + show_email = models.BooleanField( + default=False, + help_text="Show email on public profile" + ) + + # Statistics + total_submissions = models.IntegerField( + default=0, + help_text="Total number of submissions made" + ) + approved_submissions = models.IntegerField( + default=0, + help_text="Number of approved submissions" + ) + + class Meta: + db_table = 'user_profiles' + + def __str__(self): + return f"Profile for {self.user.email}" + + def update_submission_stats(self): + """Update submission statistics""" + from apps.moderation.models import ContentSubmission + self.total_submissions = ContentSubmission.objects.filter(user=self.user).count() + self.approved_submissions = ContentSubmission.objects.filter( + user=self.user, + status='approved' + ).count() + self.save(update_fields=['total_submissions', 'approved_submissions']) diff --git a/django/apps/versioning/__init__.py b/django/apps/versioning/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django/apps/versioning/__pycache__/__init__.cpython-313.pyc b/django/apps/versioning/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..aeb90c26 Binary files /dev/null and b/django/apps/versioning/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/apps/versioning/__pycache__/apps.cpython-313.pyc b/django/apps/versioning/__pycache__/apps.cpython-313.pyc new file mode 100644 index 00000000..3f38d574 Binary files /dev/null and b/django/apps/versioning/__pycache__/apps.cpython-313.pyc differ diff --git a/django/apps/versioning/__pycache__/models.cpython-313.pyc b/django/apps/versioning/__pycache__/models.cpython-313.pyc new file mode 100644 index 00000000..1fb55383 Binary files /dev/null and b/django/apps/versioning/__pycache__/models.cpython-313.pyc differ diff --git a/django/apps/versioning/apps.py b/django/apps/versioning/apps.py new file mode 100644 index 00000000..84c20f0c --- /dev/null +++ b/django/apps/versioning/apps.py @@ -0,0 +1,11 @@ +""" +Versioning app configuration. +""" + +from django.apps import AppConfig + + +class VersioningConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.versioning' + verbose_name = 'Versioning' diff --git a/django/apps/versioning/models.py b/django/apps/versioning/models.py new file mode 100644 index 00000000..e69de29b diff --git a/django/config/__pycache__/__init__.cpython-313.pyc b/django/config/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..f2b166cc Binary files /dev/null and b/django/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/config/__pycache__/urls.cpython-313.pyc b/django/config/__pycache__/urls.cpython-313.pyc new file mode 100644 index 00000000..93e36b08 Binary files /dev/null and b/django/config/__pycache__/urls.cpython-313.pyc differ diff --git a/django/config/settings.py b/django/config/settings.py deleted file mode 100644 index d2be1b2f..00000000 --- a/django/config/settings.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -Django settings for config project. - -Generated by 'django-admin startproject' using Django 4.2.8. - -For more information on this file, see -https://docs.djangoproject.com/en/4.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/4.2/ref/settings/ -""" - -from pathlib import Path - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-6h8r1j#p%g5^x%7970n6fe&9)5o9e4p-i#_okjib7=2--#a8b=" - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] - - -# Application definition - -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", -] - -ROOT_URLCONF = "config.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - }, - }, -] - -WSGI_APPLICATION = "config.wsgi.application" - - -# Database -# https://docs.djangoproject.com/en/4.2/ref/settings/#databases - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", - } -} - - -# Password validation -# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/4.2/topics/i18n/ - -LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.2/howto/static-files/ - -STATIC_URL = "static/" - -# Default primary key field type -# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/django/config/settings/__init__.py b/django/config/settings/__init__.py new file mode 100644 index 00000000..8c404068 --- /dev/null +++ b/django/config/settings/__init__.py @@ -0,0 +1,17 @@ +""" +Django settings package. +Automatically loads the correct settings based on DJANGO_SETTINGS_MODULE environment variable. +""" + +import os + +# Determine which settings to use +settings_module = os.getenv('DJANGO_SETTINGS_MODULE', 'config.settings.local') + +if settings_module == 'config.settings.production': + from .production import * +elif settings_module == 'config.settings.local': + from .local import * +else: + # Default to local for development + from .local import * diff --git a/django/config/settings/__pycache__/__init__.cpython-313.pyc b/django/config/settings/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..f923913e Binary files /dev/null and b/django/config/settings/__pycache__/__init__.cpython-313.pyc differ diff --git a/django/config/settings/__pycache__/base.cpython-313.pyc b/django/config/settings/__pycache__/base.cpython-313.pyc new file mode 100644 index 00000000..e0890b3b Binary files /dev/null and b/django/config/settings/__pycache__/base.cpython-313.pyc differ diff --git a/django/config/settings/__pycache__/local.cpython-313.pyc b/django/config/settings/__pycache__/local.cpython-313.pyc new file mode 100644 index 00000000..3a2faef8 Binary files /dev/null and b/django/config/settings/__pycache__/local.cpython-313.pyc differ diff --git a/django/config/settings/base.py b/django/config/settings/base.py new file mode 100644 index 00000000..a315d74f --- /dev/null +++ b/django/config/settings/base.py @@ -0,0 +1,322 @@ +""" +Django base settings for ThrillWiki project. +These settings are common across all environments. +""" + +from pathlib import Path +import environ + +# Build paths +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +# Initialize environment variables +env = environ.Env( + DEBUG=(bool, False), + ALLOWED_HOSTS=(list, []), +) + +# Read .env file if it exists +environ.Env.read_env(BASE_DIR / '.env') + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = env('SECRET_KEY', default='django-insecure-change-this-in-production') + +# Application definition +INSTALLED_APPS = [ + # Django apps + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # Third-party apps + 'rest_framework', + 'rest_framework_simplejwt', + 'ninja', + 'django_filters', + 'corsheaders', + 'guardian', + 'django_otp', + 'django_otp.plugins.otp_totp', + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.google', + 'allauth.socialaccount.providers.discord', + 'django_celery_beat', + 'django_celery_results', + 'django_extensions', + 'channels', + 'storages', + 'defender', + + # Local apps + 'apps.core', + 'apps.users', + 'apps.entities', + 'apps.moderation', + 'apps.versioning', + 'apps.media', + 'apps.notifications', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django_otp.middleware.OTPMiddleware', + 'allauth.account.middleware.AccountMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'defender.middleware.FailedLoginMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' +ASGI_APPLICATION = 'config.asgi.application' + +# Database +DATABASES = { + 'default': env.db('DATABASE_URL', default='postgresql://localhost/thrillwiki') +} + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +# Static files +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +STATICFILES_DIRS = [BASE_DIR / 'static'] + +# Media files +MEDIA_URL = 'media/' +MEDIA_ROOT = BASE_DIR / 'media' + +# Default primary key field type +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Custom User Model +AUTH_USER_MODEL = 'users.User' + +# Authentication Backends +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', + 'guardian.backends.ObjectPermissionBackend', +] + +# Django REST Framework +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticatedOrReadOnly', + ], + 'DEFAULT_FILTER_BACKENDS': [ + 'django_filters.rest_framework.DjangoFilterBackend', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 50, +} + +# JWT Settings +from datetime import timedelta + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# CORS Settings +CORS_ALLOWED_ORIGINS = env.list( + 'CORS_ALLOWED_ORIGINS', + default=['http://localhost:5173', 'http://localhost:3000'] +) +CORS_ALLOW_CREDENTIALS = True + +# Redis Configuration +REDIS_URL = env('REDIS_URL', default='redis://localhost:6379/0') + +# Caching +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': REDIS_URL, + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + 'PARSER_CLASS': 'redis.connection.HiredisParser', + }, + 'KEY_PREFIX': 'thrillwiki', + 'TIMEOUT': 300, + } +} + +# Session Configuration +SESSION_ENGINE = 'django.contrib.sessions.backends.cache' +SESSION_CACHE_ALIAS = 'default' +SESSION_COOKIE_AGE = 86400 * 30 # 30 days + +# Celery Configuration +CELERY_BROKER_URL = env('CELERY_BROKER_URL', default=REDIS_URL) +CELERY_RESULT_BACKEND = env('CELERY_RESULT_BACKEND', default='redis://localhost:6379/1') +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE +CELERY_TASK_TRACK_STARTED = True +CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes + +# Django Channels +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': { + 'hosts': [REDIS_URL], + }, + }, +} + +# Django Cacheops +CACHEOPS_REDIS = REDIS_URL +CACHEOPS_DEFAULTS = { + 'timeout': 60*15 # 15 minutes +} +CACHEOPS = { + 'entities.park': {'ops': 'all', 'timeout': 60*15}, + 'entities.ride': {'ops': 'all', 'timeout': 60*15}, + 'entities.company': {'ops': 'all', 'timeout': 60*15}, + 'core.*': {'ops': 'all', 'timeout': 60*60}, # 1 hour for reference data + '*.*': {'timeout': 60*60}, +} + +# Django Allauth +ACCOUNT_AUTHENTICATION_METHOD = 'email' +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_USERNAME_REQUIRED = False +ACCOUNT_EMAIL_VERIFICATION = 'optional' +SITE_ID = 1 + +# CloudFlare Images +CLOUDFLARE_ACCOUNT_ID = env('CLOUDFLARE_ACCOUNT_ID', default='') +CLOUDFLARE_IMAGE_TOKEN = env('CLOUDFLARE_IMAGE_TOKEN', default='') +CLOUDFLARE_IMAGE_HASH = env('CLOUDFLARE_IMAGE_HASH', default='') + +# Novu +NOVU_API_KEY = env('NOVU_API_KEY', default='') +NOVU_API_URL = env('NOVU_API_URL', default='https://api.novu.co') + +# Sentry +SENTRY_DSN = env('SENTRY_DSN', default='') +if SENTRY_DSN: + import sentry_sdk + from sentry_sdk.integrations.django import DjangoIntegration + from sentry_sdk.integrations.celery import CeleryIntegration + + sentry_sdk.init( + dsn=SENTRY_DSN, + integrations=[ + DjangoIntegration(), + CeleryIntegration(), + ], + traces_sample_rate=0.1, + send_default_pii=False, + ) + +# Logging Configuration +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + 'file': { + 'class': 'logging.FileHandler', + 'filename': BASE_DIR / 'logs' / 'django.log', + 'formatter': 'verbose', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, + 'apps': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + 'propagate': False, + }, + }, +} + +# Rate Limiting +RATELIMIT_ENABLE = True +RATELIMIT_USE_CACHE = 'default' + +# Django Defender +DEFENDER_LOGIN_FAILURE_LIMIT = 5 +DEFENDER_COOLOFF_TIME = 300 # 5 minutes +DEFENDER_LOCKOUT_TEMPLATE = 'defender/lockout.html' diff --git a/django/config/settings/local.py b/django/config/settings/local.py new file mode 100644 index 00000000..8bfd5776 --- /dev/null +++ b/django/config/settings/local.py @@ -0,0 +1,51 @@ +""" +Django development settings for ThrillWiki project. +These settings are used during local development. +""" + +from .base import * + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = env.bool('DEBUG', default=True) + +ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['localhost', '127.0.0.1']) + +# Development-specific apps +# INSTALLED_APPS += [ +# 'silk', # Profiling (optional, install django-silk if needed) +# ] + +# MIDDLEWARE += [ +# 'silk.middleware.SilkyMiddleware', +# ] + +# Database - Use SQLite for quick local development if PostgreSQL not available +DATABASES = { + 'default': env.db( + 'DATABASE_URL', + default='sqlite:///db.sqlite3' + ) +} + +# Disable caching in development +CACHEOPS_ENABLED = False + +# Email backend for development (console) +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +# Django Debug Toolbar (optional, install if needed) +# INSTALLED_APPS += ['debug_toolbar'] +# MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] +# INTERNAL_IPS = ['127.0.0.1'] + +# Celery - Use eager mode in development +CELERY_TASK_ALWAYS_EAGER = env.bool('CELERY_TASK_ALWAYS_EAGER', default=True) +CELERY_TASK_EAGER_PROPAGATES = True + +# CORS - Allow all origins in development +CORS_ALLOW_ALL_ORIGINS = True + +# Logging - More verbose in development +LOGGING['root']['level'] = 'DEBUG' +LOGGING['loggers']['django']['level'] = 'DEBUG' +LOGGING['loggers']['apps']['level'] = 'DEBUG' diff --git a/django/config/settings/production.py b/django/config/settings/production.py new file mode 100644 index 00000000..f42e6677 --- /dev/null +++ b/django/config/settings/production.py @@ -0,0 +1,67 @@ +""" +Django production settings for ThrillWiki project. +These settings are used in production environments. +""" + +from .base import * + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False + +ALLOWED_HOSTS = env.list('ALLOWED_HOSTS') + +# Security Settings +SECURE_SSL_REDIRECT = True +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_HSTS_SECONDS = 31536000 # 1 year +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_BROWSER_XSS_FILTER = True +X_FRAME_OPTIONS = 'DENY' + +# Static files (WhiteNoise) +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' +MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware') + +# Email Configuration (configure for production email backend) +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = env('EMAIL_HOST', default='smtp.gmail.com') +EMAIL_PORT = env.int('EMAIL_PORT', default=587) +EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS', default=True) +EMAIL_HOST_USER = env('EMAIL_HOST_USER', default='') +EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default='') +DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='noreply@thrillwiki.com') + +# Database - Require DATABASE_URL in production +if not env('DATABASE_URL', default=None): + raise ImproperlyConfigured('DATABASE_URL environment variable is required in production') + +# Connection pooling +DATABASES['default']['CONN_MAX_AGE'] = env.int('CONN_MAX_AGE', default=600) + +# Redis - Require REDIS_URL in production +if not env('REDIS_URL', default=None): + raise ImproperlyConfigured('REDIS_URL environment variable is required in production') + +# Celery - Run tasks asynchronously in production +CELERY_TASK_ALWAYS_EAGER = False + +# Logging - Send errors to file and Sentry +LOGGING['handlers']['file']['filename'] = '/var/log/thrillwiki/django.log' +LOGGING['root']['level'] = 'WARNING' +LOGGING['loggers']['django']['level'] = 'WARNING' +LOGGING['loggers']['apps']['level'] = 'INFO' + +# Admin URL (obfuscate in production) +ADMIN_URL = env('ADMIN_URL', default='admin/') + +# Performance +CACHEOPS_ENABLED = True + +# CORS - Strict in production +CORS_ALLOW_ALL_ORIGINS = False +if not CORS_ALLOWED_ORIGINS: + raise ImproperlyConfigured('CORS_ALLOWED_ORIGINS must be set in production') diff --git a/django/db.sqlite3 b/django/db.sqlite3 new file mode 100644 index 00000000..577fd2a0 Binary files /dev/null and b/django/db.sqlite3 differ