mirror of
https://github.com/lemeow125/DRF_Template.git
synced 2025-09-18 05:29:37 +08:00
Implement accounts app
This commit is contained in:
parent
82c48cf5eb
commit
bae2cc653e
17 changed files with 519 additions and 11 deletions
0
src/accounts/__init__.py
Normal file
0
src/accounts/__init__.py
Normal file
20
src/accounts/admin.py
Normal file
20
src/accounts/admin.py
Normal 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
9
src/accounts/apps.py
Normal 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
|
131
src/accounts/migrations/0001_initial.py
Normal file
131
src/accounts/migrations/0001_initial.py
Normal 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()),
|
||||
],
|
||||
),
|
||||
]
|
0
src/accounts/migrations/__init__.py
Normal file
0
src/accounts/migrations/__init__.py
Normal file
27
src/accounts/models.py
Normal file
27
src/accounts/models.py
Normal 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,))
|
90
src/accounts/serializers.py
Normal file
90
src/accounts/serializers.py
Normal 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
25
src/accounts/signals.py
Normal 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
17
src/accounts/urls.py
Normal 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
76
src/accounts/views.py
Normal 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}")
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
21
src/core/management/commands/generate_test_users.py
Normal file
21
src/core/management/commands/generate_test_users.py
Normal 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()
|
|
@ -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 = {
|
||||
|
|
47
src/tests/users/__init__.py
Normal file
47
src/tests/users/__init__.py
Normal 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()
|
32
src/tests/users/users.json
Normal file
32
src/tests/users/users.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue