From bae2cc653ef9d403d2f4e01a39b56d1542078a96 Mon Sep 17 00:00:00 2001 From: Keannu Bernasol Date: Wed, 3 Sep 2025 02:08:07 +0800 Subject: [PATCH] Implement accounts app --- src/accounts/__init__.py | 0 src/accounts/admin.py | 20 +++ src/accounts/apps.py | 9 ++ src/accounts/migrations/0001_initial.py | 131 ++++++++++++++++++ src/accounts/migrations/__init__.py | 0 src/accounts/models.py | 27 ++++ src/accounts/serializers.py | 90 ++++++++++++ src/accounts/signals.py | 25 ++++ src/accounts/urls.py | 17 +++ src/accounts/views.py | 76 ++++++++++ src/api/urls.py | 5 +- src/core/config/__init__.py | 6 +- src/core/config/models.py | 14 +- .../commands/generate_test_users.py | 21 +++ src/core/settings.py | 10 +- src/tests/users/__init__.py | 47 +++++++ src/tests/users/users.json | 32 +++++ 17 files changed, 519 insertions(+), 11 deletions(-) create mode 100644 src/accounts/__init__.py create mode 100644 src/accounts/admin.py create mode 100644 src/accounts/apps.py create mode 100644 src/accounts/migrations/0001_initial.py create mode 100644 src/accounts/migrations/__init__.py create mode 100644 src/accounts/models.py create mode 100644 src/accounts/serializers.py create mode 100644 src/accounts/signals.py create mode 100644 src/accounts/urls.py create mode 100644 src/accounts/views.py create mode 100644 src/core/management/commands/generate_test_users.py create mode 100644 src/tests/users/__init__.py create mode 100644 src/tests/users/users.json diff --git a/src/accounts/__init__.py b/src/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/admin.py b/src/accounts/admin.py new file mode 100644 index 0000000..ad11d14 --- /dev/null +++ b/src/accounts/admin.py @@ -0,0 +1,20 @@ +""" +Admin configuration for accounts app +""" + +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from .models import CustomUser + + +class CustomUserAdmin(UserAdmin): + model = CustomUser + list_display = ( + "id", + "is_active", + "is_new", + ) + UserAdmin.list_display + + +admin.site.register(CustomUser, CustomUserAdmin) diff --git a/src/accounts/apps.py b/src/accounts/apps.py new file mode 100644 index 0000000..2ed0704 --- /dev/null +++ b/src/accounts/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" + + def ready(self): + from . import signals # noqa: F401 diff --git a/src/accounts/migrations/0001_initial.py b/src/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..713b5dd --- /dev/null +++ b/src/accounts/migrations/0001_initial.py @@ -0,0 +1,131 @@ +# Generated by Django 5.2.5 on 2025-09-02 17:51 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="CustomUser", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("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" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "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" + ), + ), + ( + "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={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/src/accounts/migrations/__init__.py b/src/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/accounts/models.py b/src/accounts/models.py new file mode 100644 index 0000000..9f1375c --- /dev/null +++ b/src/accounts/models.py @@ -0,0 +1,27 @@ +""" +Common model schemas +""" + +from datetime import timedelta + +from django.contrib.auth.models import AbstractUser +from django.urls import reverse +from django.utils import timezone + + +class CustomUser(AbstractUser): + # Most fields are inherited from AbstractUser + + # Can be used to show tooltips for newer users + @property + def is_new(self): + current_date = timezone.now() + return self.date_joined + timedelta(days=1) < current_date + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}" + + @property + def admin_url(self): + return reverse("admin:users_customuser_change", args=(self.pk,)) diff --git a/src/accounts/serializers.py b/src/accounts/serializers.py new file mode 100644 index 0000000..a3c6a5b --- /dev/null +++ b/src/accounts/serializers.py @@ -0,0 +1,90 @@ +from django.contrib.auth.password_validation import validate_password +from django.core import exceptions as django_exceptions +from django.core.cache import cache +from djoser.serializers import UserSerializer as BaseUserSerializer +from rest_framework import serializers +from rest_framework.serializers import ImageField, ModelSerializer +from rest_framework.settings import api_settings + +from accounts.models import CustomUser + +# There can be multiple subject instances with the same name, only differing in course, year level, and semester. We filter them here + + +class SimpleCustomUserSerializer(ModelSerializer): + class Meta(BaseUserSerializer.Meta): + model = CustomUser + fields = ("id", "username", "email", "full_name") + + +class CustomUserSerializer(BaseUserSerializer): + class Meta(BaseUserSerializer.Meta): + model = CustomUser + fields = ( + "id", + "username", + "email", + "first_name", + "is_new", + "last_name", + ) + read_only_fields = ( + "id", + "username", + "email", + ) + + def update(self, instance, validated_data): + cache.delete(f"users:{instance.id}") + + return super().update(instance, validated_data) + + +class UserRegistrationSerializer(serializers.ModelSerializer): + email = serializers.EmailField(required=True) + username = serializers.CharField(required=True) + password = serializers.CharField( + write_only=True, style={"input_type": "password", "placeholder": "Password"} + ) + first_name = serializers.CharField( + required=True, allow_blank=False, allow_null=False + ) + last_name = serializers.CharField( + required=True, allow_blank=False, allow_null=False + ) + + class Meta: + model = CustomUser + fields = ["email", "username", "password", "first_name", "last_name"] + + def validate(self, attrs): + user_attrs = attrs.copy() + user = self.Meta.model(**user_attrs) + password = attrs.get("password") + + try: + validate_password(password, user) + except django_exceptions.ValidationError as e: + serializer_error = serializers.as_serializer_error(e) + errors = serializer_error[api_settings.NON_FIELD_ERRORS_KEY] + if len(errors) > 1: + raise serializers.ValidationError({"password": errors[0]}) + else: + raise serializers.ValidationError({"password": errors}) + if self.Meta.model.objects.filter(username=attrs.get("username")).exists(): + raise serializers.ValidationError( + "A user with that username already exists." + ) + + return super().validate(attrs) + + def create(self, validated_data): + user = self.Meta.model(**validated_data) + user.username = validated_data["username"] + user.is_active = False + user.set_password(validated_data["password"]) + user.save() + + cache.delete("users") + + return user diff --git a/src/accounts/signals.py b/src/accounts/signals.py new file mode 100644 index 0000000..4d0f53d --- /dev/null +++ b/src/accounts/signals.py @@ -0,0 +1,25 @@ +""" +Signal handlers for accounts app. +""" + +import logging + +from django.db.models.signals import post_migrate +from django.dispatch import receiver + +from core.settings import config +from tests import users + +logger = logging.getLogger(__name__) + + +@receiver(post_migrate) +def generate_test_users(sender, **kwargs): + """ + Post-migrate signal to create test users in DEBUG mode. + """ + if sender.name == "accounts" and config.DEBUG: + try: + users.generate_test_users() + except Exception as e: + logger.error(f"Error creating test users post-migration: {e}") diff --git a/src/accounts/urls.py b/src/accounts/urls.py new file mode 100644 index 0000000..f604868 --- /dev/null +++ b/src/accounts/urls.py @@ -0,0 +1,17 @@ +""" +URL configuration for accounts app +""" + +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from accounts import views + +router = DefaultRouter() +router.register(r"users", views.CustomUserViewSet, basename="users") + +urlpatterns = [ + path("", include(router.urls)), + path("", include("djoser.urls")), + path("", include("djoser.urls.jwt")), +] diff --git a/src/accounts/views.py b/src/accounts/views.py new file mode 100644 index 0000000..5ed35a5 --- /dev/null +++ b/src/accounts/views.py @@ -0,0 +1,76 @@ +""" +Viewset for accounts app. +""" + +import logging + +from django.contrib.auth.tokens import default_token_generator +from django.core.cache import cache +from djoser.conf import settings +from djoser.views import UserViewSet as DjoserUserViewSet +from rest_framework.decorators import action + +from accounts import serializers +from accounts.models import CustomUser + +logger = logging.getLogger(__name__) + + +class CustomUserViewSet(DjoserUserViewSet): + queryset = CustomUser.objects.all() + serializer_class = serializers.CustomUserSerializer + permission_classes = settings.PERMISSIONS.activation + token_generator = default_token_generator + + def get_queryset(self): + user = self.request.user + + if user.is_superuser: + cache_key = "users:admin" + queryset = cache.get(cache_key) + if not queryset: + queryset = CustomUser.objects.all() + cache.set(cache_key, queryset, 60 * 60) + return queryset + else: + cache_key = f"users:{user.id}" + queryset = cache.get(cache_key) + if not queryset: + queryset = CustomUser.objects.filter(id=user.id) + cache.set(cache_key, queryset, 60 * 60) + return queryset + + def perform_update(self, serializer, *args, **kwargs): + user = self.request.user + + super().perform_update(serializer, *args, **kwargs) + + cache.delete("users") + cache.delete(f"users:{user.id}") + + def perform_create(self, serializer, *args, **kwargs): + user = serializer.save(*args, **kwargs) + + # Try-catch block for email sending + try: + super().perform_create(serializer, *args, **kwargs) + + # Clear cache + cache.delete("users") + cache.delete(f"user:{user.id}") + + except Exception as e: + logger.warning( + f"Registration failure, unable to send activation email for {user.id}: {e}" + ) + + @action( + methods=["post"], detail=False, url_path="activation", url_name="activation" + ) + def activation(self, request, *args, **kwargs): + user = self.request.user + super().activation(request, *args, **kwargs) + + # Clear cache + cache.delete("users") + cache.delete(f"users:{user.id}") diff --git a/src/api/urls.py b/src/api/urls.py index 2194d11..6b6040a 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -1,4 +1,3 @@ -from core.settings import config from django.contrib import admin from django.urls import include, path from drf_spectacular.views import ( @@ -7,8 +6,10 @@ from drf_spectacular.views import ( SpectacularSwaggerView, ) +from core.settings import config + urlpatterns = [ - # path("accounts/", include("accounts.urls")), + path("accounts/", include("accounts.urls")), # Admin Panel path("admin/", admin.site.urls), # Swagger and Redoc API Doc URLs diff --git a/src/core/config/__init__.py b/src/core/config/__init__.py index f1f1e20..1e19508 100644 --- a/src/core/config/__init__.py +++ b/src/core/config/__init__.py @@ -6,9 +6,11 @@ For use in the immediate parent app/directory. import os from typing import Optional -from pydantic.fields import FieldInfo -from .models import Config as ConfigModel + from dotenv import find_dotenv, load_dotenv +from pydantic.fields import FieldInfo + +from .models import Config as ConfigModel class Config: diff --git a/src/core/config/models.py b/src/core/config/models.py index b9317f8..19adb68 100644 --- a/src/core/config/models.py +++ b/src/core/config/models.py @@ -3,13 +3,14 @@ Common model schemas """ import re -from typing import Literal from datetime import timedelta +from typing import Literal + from pydantic import ( BaseModel, - StrictStr, EmailStr, Field, + StrictStr, field_validator, model_validator, ) @@ -42,10 +43,8 @@ class Config(BaseModel): default=False, description="Whether to serve media files locally as oppossed to using a cloud storage solution.", ) - SMTP_HOST: StrictStr = Field( - required=True, description="SMTP server address") - SMTP_PORT: int = Field( - default=587, description="SMTP server port (default: 587)") + SMTP_HOST: StrictStr = Field(required=True, description="SMTP server address") + SMTP_PORT: int = Field(default=587, description="SMTP server port (default: 587)") SMTP_USE_TLS: bool = Field( default=True, description="Whether to use TLS for SMTP connections" ) @@ -64,6 +63,9 @@ class Config(BaseModel): REFRESH_TOKEN_LIFETIME_DAYS: timedelta = Field( default=timedelta(days=3), description="Refresh token lifetime in days" ) + DEBUG_USER_PASSWORD: StrictStr = Field( + required=True, description="Password for test users created during development" + ) @field_validator("CORS_ORIGINS", "ALLOWED_HOSTS", mode="before") def parse_list(cls, v): diff --git a/src/core/management/commands/generate_test_users.py b/src/core/management/commands/generate_test_users.py new file mode 100644 index 0000000..6355e02 --- /dev/null +++ b/src/core/management/commands/generate_test_users.py @@ -0,0 +1,21 @@ +""" +Post-migrate signal handlers for creating initial data for accounts app. +""" + +import logging + +from django.core.management.base import BaseCommand + +from tests.users import generate_test_users + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Generate debug admin and test user accounts." + + def handle(self, *args, **kwargs): + """ + Command handling + """ + generate_test_users() diff --git a/src/core/settings.py b/src/core/settings.py index f108670..c8eb090 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -12,13 +12,18 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ import os from pathlib import Path + from core.config import Config # Config initialization config = Config().get_config() -# Build paths inside the project like this: BASE_DIR / 'subdir'. +# Directory where manage.py file is located BASE_DIR = Path(__file__).resolve().parent.parent +# Directory where docker-compose.yml file is located +ROOT_DIR = Path(__file__).resolve().parent.parent.parent +# Directory where test files are located +TESTS_DIR = os.path.join(BASE_DIR, "tests") # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = config.SECRET_KEY @@ -48,6 +53,8 @@ INSTALLED_APPS = [ "corsheaders", "djoser", "drf_spectacular", + "drf_spectacular_sidecar", + "accounts", *(["silk"] if config.DEBUG else []), ] @@ -227,6 +234,7 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +AUTH_USER_MODEL = "accounts.CustomUser" # Swagger / OpenAPI SPECTACULAR_SETTINGS = { diff --git a/src/tests/users/__init__.py b/src/tests/users/__init__.py new file mode 100644 index 0000000..3b43fe6 --- /dev/null +++ b/src/tests/users/__init__.py @@ -0,0 +1,47 @@ +""" +Post-migrate signal handlers for creating initial data for accounts app. +""" + +import os +import json +import logging + +from accounts.models import CustomUser +from core.settings import config, TESTS_DIR + +logger = logging.getLogger(__name__) + + +def generate_test_users(): + """ + Function to create test users in DEBUG mode. + """ + if config.DEBUG: + with open(os.path.join(TESTS_DIR, "users", "users.json"), "r") as f: + # Load JSON data + data = json.loads(f.read()) + for user in data["users"]: + # Check if user already exists + USER = CustomUser.objects.filter(email=user["email"]).first() + if not USER: + # Create user + if user["is_superuser"]: + USER = CustomUser.objects.create_superuser( + username=user["username"], + email=user["email"], + password=config.DEBUG_USER_PASSWORD, + ) + print("Created Superuser:", user["email"]) + else: + USER = CustomUser.objects.create_user( + username=user["email"], + email=user["email"], + password=config.DEBUG_USER_PASSWORD, + ) + print("Created User:", user["email"]) + + # Additional user fields not covered by create() methods + USER.first_name = user["first_name"] + USER.last_name = user["last_name"] + USER.is_active = True + USER.save() diff --git a/src/tests/users/users.json b/src/tests/users/users.json new file mode 100644 index 0000000..26a0e17 --- /dev/null +++ b/src/tests/users/users.json @@ -0,0 +1,32 @@ +{ + "users": [ + { + "username": "admin", + "email": "admin@test.com", + "is_superuser": true, + "first_name": "Test", + "last_name": "Admin" + }, + { + "username": "testuser1", + "email": "user1@test.com", + "is_superuser": false, + "first_name": "Test", + "last_name": "User 1" + }, + { + "username": "testuser2", + "email": "user1@test.com", + "is_superuser": false, + "first_name": "Test", + "last_name": "User 2" + }, + { + "username": "testuser3", + "email": "user3@test.com", + "is_superuser": false, + "first_name": "Test", + "last_name": "User 3" + } + ] +} \ No newline at end of file