Implement accounts app

This commit is contained in:
Keannu Christian Bernasol 2025-09-03 02:08:07 +08:00
parent 82c48cf5eb
commit bae2cc653e
17 changed files with 519 additions and 11 deletions

0
src/accounts/__init__.py Normal file
View file

20
src/accounts/admin.py Normal file
View file

@ -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)

9
src/accounts/apps.py Normal file
View file

@ -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

View file

@ -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()),
],
),
]

View file

27
src/accounts/models.py Normal file
View file

@ -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,))

View file

@ -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

25
src/accounts/signals.py Normal file
View file

@ -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}")

17
src/accounts/urls.py Normal file
View file

@ -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")),
]

76
src/accounts/views.py Normal file
View file

@ -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}")

View file

@ -1,4 +1,3 @@
from core.settings import config
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from drf_spectacular.views import ( from drf_spectacular.views import (
@ -7,8 +6,10 @@ from drf_spectacular.views import (
SpectacularSwaggerView, SpectacularSwaggerView,
) )
from core.settings import config
urlpatterns = [ urlpatterns = [
# path("accounts/", include("accounts.urls")), path("accounts/", include("accounts.urls")),
# Admin Panel # Admin Panel
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
# Swagger and Redoc API Doc URLs # Swagger and Redoc API Doc URLs

View file

@ -6,9 +6,11 @@ For use in the immediate parent app/directory.
import os import os
from typing import Optional from typing import Optional
from pydantic.fields import FieldInfo
from .models import Config as ConfigModel
from dotenv import find_dotenv, load_dotenv from dotenv import find_dotenv, load_dotenv
from pydantic.fields import FieldInfo
from .models import Config as ConfigModel
class Config: class Config:

View file

@ -3,13 +3,14 @@ Common model schemas
""" """
import re import re
from typing import Literal
from datetime import timedelta from datetime import timedelta
from typing import Literal
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
StrictStr,
EmailStr, EmailStr,
Field, Field,
StrictStr,
field_validator, field_validator,
model_validator, model_validator,
) )
@ -42,10 +43,8 @@ class Config(BaseModel):
default=False, default=False,
description="Whether to serve media files locally as oppossed to using a cloud storage solution.", description="Whether to serve media files locally as oppossed to using a cloud storage solution.",
) )
SMTP_HOST: StrictStr = Field( SMTP_HOST: StrictStr = Field(required=True, description="SMTP server address")
required=True, description="SMTP server address") SMTP_PORT: int = Field(default=587, description="SMTP server port (default: 587)")
SMTP_PORT: int = Field(
default=587, description="SMTP server port (default: 587)")
SMTP_USE_TLS: bool = Field( SMTP_USE_TLS: bool = Field(
default=True, description="Whether to use TLS for SMTP connections" default=True, description="Whether to use TLS for SMTP connections"
) )
@ -64,6 +63,9 @@ class Config(BaseModel):
REFRESH_TOKEN_LIFETIME_DAYS: timedelta = Field( REFRESH_TOKEN_LIFETIME_DAYS: timedelta = Field(
default=timedelta(days=3), description="Refresh token lifetime in days" 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") @field_validator("CORS_ORIGINS", "ALLOWED_HOSTS", mode="before")
def parse_list(cls, v): def parse_list(cls, v):

View file

@ -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()

View file

@ -12,13 +12,18 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
import os import os
from pathlib import Path from pathlib import Path
from core.config import Config from core.config import Config
# Config initialization # Config initialization
config = Config().get_config() 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 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! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = config.SECRET_KEY SECRET_KEY = config.SECRET_KEY
@ -48,6 +53,8 @@ INSTALLED_APPS = [
"corsheaders", "corsheaders",
"djoser", "djoser",
"drf_spectacular", "drf_spectacular",
"drf_spectacular_sidecar",
"accounts",
*(["silk"] if config.DEBUG else []), *(["silk"] if config.DEBUG else []),
] ]
@ -227,6 +234,7 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
AUTH_USER_MODEL = "accounts.CustomUser"
# Swagger / OpenAPI # Swagger / OpenAPI
SPECTACULAR_SETTINGS = { SPECTACULAR_SETTINGS = {

View file

@ -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()

View file

@ -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"
}
]
}