From 42cd79662d1bbe8717e76a4808cdf116b528af60 Mon Sep 17 00:00:00 2001 From: Viswamedha Nalabotu Date: Wed, 19 Nov 2025 14:35:30 +0000 Subject: [PATCH] Updated users model for removing username with more tests --- apps/users/admin.py | 23 ++-- apps/users/apps.py | 1 - apps/users/managers.py | 20 +-- ...er_remove_user_unique_username_and_more.py | 57 ++++++++ apps/users/models.py | 71 +++------- apps/users/serializers.py | 16 +-- apps/users/tests.py | 130 ++++++++++++------ apps/users/views.py | 3 - apps/users/viewsets.py | 14 +- config/__pycache__/settings.cpython-313.pyc | Bin 4081 -> 4134 bytes config/settings.py | 5 +- requirements.txt | 1 + 12 files changed, 186 insertions(+), 155 deletions(-) create mode 100644 apps/users/migrations/0002_remove_userprofile_user_remove_user_unique_username_and_more.py delete mode 100644 apps/users/views.py diff --git a/apps/users/admin.py b/apps/users/admin.py index 77afc7d..e975a5e 100644 --- a/apps/users/admin.py +++ b/apps/users/admin.py @@ -1,30 +1,23 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin -from .models import UserProfile, User - - -@admin.register(UserProfile) -class UserProfileAdmin(admin.ModelAdmin): - list_display = ('user', 'display_name', 'created_at') - search_fields = ('user__username', 'display_name') - +from apps.users.models import User @admin.register(User) class UserAdmin(DjangoUserAdmin): fieldsets = ( - (None, {'fields': ('username', 'password')}), - ('Personal info', {'fields': ('first_name', 'last_name', 'email_address')}), + (None, {'fields': ('email_address', 'password')}), + ('Personal info', {'fields': ('first_name', 'last_name')}), ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), - ('Important dates', {'fields': ('last_login', 'password_reset_at')}), + ('Dates', {'fields': ('last_login',)}), ) add_fieldsets = ( (None, { 'classes': ('wide',), - 'fields': ('username', 'email_address', 'first_name', 'last_name', 'password1', 'password2'), + 'fields': ('email_address', 'first_name', 'last_name', 'password1', 'password2'), }), ) - list_display = ('username', 'email_address', 'first_name', 'last_name', 'is_staff') - search_fields = ('username', 'email_address', 'first_name', 'last_name') - ordering = ('username',) + list_display = ('email_address', 'first_name', 'last_name', 'is_staff') + search_fields = ('email_address', 'first_name', 'last_name') + ordering = ('email_address',) diff --git a/apps/users/apps.py b/apps/users/apps.py index 2bb189c..7f2dacd 100644 --- a/apps/users/apps.py +++ b/apps/users/apps.py @@ -1,6 +1,5 @@ from django.apps import AppConfig - class UsersConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.users' diff --git a/apps/users/managers.py b/apps/users/managers.py index 72a1920..a1be585 100644 --- a/apps/users/managers.py +++ b/apps/users/managers.py @@ -1,22 +1,15 @@ -from typing import TYPE_CHECKING - from django.contrib.auth.hashers import make_password from django.contrib.auth.models import BaseUserManager +from typing import TYPE_CHECKING if TYPE_CHECKING: from apps.users.models import User # noqa: F401 - class UserManager(BaseUserManager["User"]): - """Custom manager for the User model.""" def _create_user(self, email_address: str, password: str | None, **extra_fields): - """ - Create and save a user with the given email and password. - """ if not email_address: - msg = "The given email must be set" - raise ValueError(msg) + raise ValueError("The given email must be set") email_address = self.normalize_email(email_address) user: User = self.model(email_address=email_address, **extra_fields) user.password = make_password(password) @@ -24,18 +17,11 @@ class UserManager(BaseUserManager["User"]): return user def create_user(self, email_address: str, password: str | None = None, **extra_fields): # type: ignore[override] - """ - Create and save a regular user with the given email and password. - """ extra_fields.setdefault("is_staff", False) return self._create_user(email_address, password, **extra_fields) def create_superuser(self, email_address: str, password: str | None = None, **extra_fields): # type: ignore[override] - """ - Create and save a superuser with the given email and password. - """ extra_fields.setdefault("is_staff", True) if extra_fields.get("is_staff") is not True: - msg = "Superuser must have is_staff=True." - raise ValueError(msg) + raise ValueError("Superuser must have is_staff=True.") return self._create_user(email_address, password, **extra_fields) diff --git a/apps/users/migrations/0002_remove_userprofile_user_remove_user_unique_username_and_more.py b/apps/users/migrations/0002_remove_userprofile_user_remove_user_unique_username_and_more.py new file mode 100644 index 0000000..38098db --- /dev/null +++ b/apps/users/migrations/0002_remove_userprofile_user_remove_user_unique_username_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.8 on 2025-11-19 14:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='userprofile', + name='user', + ), + migrations.RemoveConstraint( + model_name='user', + name='unique_username', + ), + migrations.RenameField( + model_name='user', + old_name='user_uuid', + new_name='uuid', + ), + migrations.RemoveField( + model_name='user', + name='is_verified', + ), + migrations.RemoveField( + model_name='user', + name='username', + ), + migrations.AddField( + model_name='user', + name='avatar_url', + field=models.URLField(blank=True), + ), + migrations.AddField( + model_name='user', + name='bio', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='user', + name='timezone', + field=models.CharField(blank=True, default='UTC', max_length=16), + ), + migrations.AlterField( + model_name='user', + name='id', + field=models.AutoField(primary_key=True, serialize=False, verbose_name='User ID'), + ), + migrations.DeleteModel( + name='UserProfile', + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index 7240d0a..1ddc754 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -1,30 +1,24 @@ -from typing import ClassVar -from uuid import uuid4 - from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin from django.db.models import ( AutoField, BooleanField, - CASCADE, CharField, DateField, DateTimeField, EmailField, UUIDField, Model, - OneToOneField, TextField, - UniqueConstraint, URLField, ) -from django.conf import settings from django.utils.translation import gettext_lazy as _ - +from typing import ClassVar +from uuid import uuid4 from apps.users.managers import UserManager +from django.conf import settings class TimeStampMixin(Model): - """Abstract model that provides created_at and updated_at fields.""" created_at = DateTimeField(verbose_name="Created At", auto_now_add=True) updated_at = DateTimeField(verbose_name="Updated At", auto_now=True) @@ -34,30 +28,25 @@ class TimeStampMixin(Model): class User(AbstractBaseUser, TimeStampMixin, PermissionsMixin): - """Default custom user model for viswamedha.com (adapted). - Uses username as the USERNAME_FIELD and maintains an email_address - field used as the primary contact address. - """ + id = AutoField(verbose_name = _("User ID"), primary_key = True) + uuid = UUIDField(verbose_name = _("User UUID"), default = uuid4, editable = False) - id = AutoField(primary_key=True) - user_uuid = UUIDField(verbose_name=_("User UUID"), default=uuid4, editable=False) + email_address = EmailField(verbose_name = _("Email Address"), max_length = 255, unique = True) + first_name = CharField(verbose_name = _("First Name"), max_length = 255) + last_name = CharField(verbose_name = _("Last Name"), max_length = 255) + date_of_birth = DateField(verbose_name = _("Date of Birth"), null = True, blank = True) - # Required fields - email_address = EmailField(verbose_name=_("Email Address"), max_length=255, unique=True) - username = CharField(verbose_name=_("Username"), max_length=25, unique=True) - first_name = CharField(verbose_name=_("First Name"), max_length=255) - last_name = CharField(verbose_name=_("Last Name"), max_length=255) - date_of_birth = DateField(verbose_name=_("Date of Birth"), null=True, blank=True) + bio = TextField(default = "", blank = True) + timezone = CharField(default = settings.TIME_ZONE, max_length = 16, blank = True) + avatar_url = URLField(blank = True) - is_active = BooleanField(verbose_name=_("Account Active"), default=True) - is_staff = BooleanField(verbose_name=_("Account Admin"), default=False) - is_verified = BooleanField(verbose_name=_("Account Verified"), default=False) + is_active = BooleanField(verbose_name = _("Account Active"), default = True) + is_staff = BooleanField(verbose_name = _("Account Admin"), default = False) - - USERNAME_FIELD = 'username' + USERNAME_FIELD = 'email_address' EMAIL_FIELD = 'email_address' - REQUIRED_FIELDS = ['email_address', 'first_name', 'last_name', 'date_of_birth'] + REQUIRED_FIELDS = ['first_name', 'last_name', 'date_of_birth'] objects: ClassVar[UserManager] = UserManager() @@ -70,39 +59,11 @@ class User(AbstractBaseUser, TimeStampMixin, PermissionsMixin): class Meta: verbose_name = _('User') verbose_name_plural = _('Users') - constraints = [ - UniqueConstraint(fields=['username'], name='unique_username'), - ] @property def full_name(self): return f"{self.first_name} {self.last_name}" - @property - def is_student(self): - return hasattr(self, 'student') - def __str__(self): return self.full_name - def save(self, *args, **kwargs): - if self.username: - self.username = self.username.lower() - super().save(*args, **kwargs) - - -class UserProfile(Model): - """A lightweight profile attached to the project's `AUTH_USER_MODEL`.""" - - user = OneToOneField( - settings.AUTH_USER_MODEL, on_delete=CASCADE, related_name='profile' - ) - display_name = CharField(max_length=150, blank=True) - bio = TextField(blank=True) - timezone = CharField(max_length=64, blank=True) - avatar_url = URLField(blank=True) - created_at = DateTimeField(auto_now_add=True) - updated_at = DateTimeField(auto_now=True) - - def __str__(self) -> str: # pragma: no cover - trivial - return self.display_name or getattr(self.user, 'username', str(self.user)) diff --git a/apps/users/serializers.py b/apps/users/serializers.py index 62c0da0..11097ea 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -1,20 +1,8 @@ -from django.contrib.auth import get_user_model from rest_framework import serializers - -from apps.users.models import UserProfile - -User = get_user_model() - - -class UserProfileSerializer(serializers.ModelSerializer): - class Meta: - model = UserProfile - fields = ['display_name', 'bio', 'timezone', 'avatar_url'] - +from apps.users.models import User class UserSerializer(serializers.ModelSerializer): - profile = UserProfileSerializer(read_only=True) class Meta: model = User - fields = ['id', 'user_uuid', 'username', 'email_address', 'first_name', 'last_name', 'profile'] + fields = ['id', 'uuid', 'email_address', 'first_name', 'last_name', 'bio', 'timezone', 'avatar_url'] diff --git a/apps/users/tests.py b/apps/users/tests.py index fa53abc..86f65d4 100644 --- a/apps/users/tests.py +++ b/apps/users/tests.py @@ -1,56 +1,108 @@ from django.test import TestCase from django.contrib.auth import get_user_model -from django.urls import reverse from rest_framework.test import APIClient from rest_framework import status +from django.conf import settings +from django.db import IntegrityError +import uuid User = get_user_model() class UserModelTests(TestCase): - def setUp(self): - self.user_data = { - 'email_address': 'Test@Example.com', - 'username': 'TestUser', - 'first_name': 'Test', - 'last_name': 'User', - 'date_of_birth': '1990-01-01', - } + def setUp(self): + self.user_data = { + 'email_address': 'Test@Example.com', + 'first_name': 'Test', + 'last_name': 'User', + 'date_of_birth': '1990-01-01', + } - def test_create_user_and_properties(self): - user = User.objects.create_user(password='pass1234', **self.user_data) - self.assertIsNotNone(user.pk) - # email should be normalized by the manager - self.assertEqual(user.email_address.lower(), 'test@example.com') - # username should be saved lowercase by model.save() - self.assertEqual(user.username, 'testuser') - # full_name property - self.assertEqual(user.full_name, 'Test User') + def test_create_user_and_properties(self): + user = User.objects.create_user(password='pass1234', **self.user_data) + self.assertIsNotNone(user.pk) + # email should be normalized by the manager (domain lowercased) + self.assertEqual(user.email_address.lower(), 'test@example.com') + # full_name property + self.assertEqual(user.full_name, 'Test User') - def test_create_superuser(self): - su = User.objects.create_superuser(password='adminpass', **self.user_data) - self.assertTrue(su.is_staff) - self.assertTrue(su.pk) + def test_create_superuser(self): + su = User.objects.create_superuser(password='adminpass', **self.user_data) + self.assertTrue(su.is_staff) + self.assertTrue(su.pk) class UserAPITests(TestCase): - def setUp(self): - self.client = APIClient() - self.user = User.objects.create_user( - password='pass1234', - email_address='apiuser@example.com', - username='apiuser', - first_name='API', - last_name='User', - date_of_birth='1995-05-05', - ) + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + password='pass1234', + email_address='apiuser@example.com', + first_name='API', + last_name='User', + date_of_birth='1995-05-05', + ) - def test_list_users(self): - url = '/api/users/' - resp = self.client.get(url) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - # Should contain at least the created user - usernames = [u.get('username') for u in resp.json()] - self.assertIn(self.user.username, usernames) + def test_list_users(self): + url = '/api/users/' + resp = self.client.get(url) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + emails = [u.get('email_address') for u in resp.json()] + self.assertIn(self.user.email_address, emails) + + def test_api_response_contains_expected_fields(self): + url = '/api/users/' + resp = self.client.get(url) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.json() + self.assertTrue(len(data) >= 1) + sample = data[0] + expected_keys = {'id', 'uuid', 'email_address', 'first_name', 'last_name', 'bio', 'timezone', 'avatar_url'} + self.assertTrue(expected_keys.issubset(set(sample.keys()))) + + +class UserModelExtraTests(TestCase): + def test_password_hashed_and_check(self): + user = User.objects.create_user(email_address='hashme@example.com', password='secret123') + self.assertNotEqual(user.password, 'secret123') + self.assertTrue(user.check_password('secret123')) + + def test_uuid_and_id_auto_populated(self): + u1 = User.objects.create_user(email_address='one@example.com', password='p') + u2 = User.objects.create_user(email_address='two@example.com', password='p') + self.assertIsNotNone(u1.uuid) + self.assertIsInstance(u1.uuid, uuid.UUID) + self.assertNotEqual(u1.uuid, u2.uuid) + self.assertIsNotNone(u1.id) + self.assertIsNotNone(u2.id) + + def test_default_fields(self): + u = User.objects.create_user(email_address='defaults@example.com', password='p') + self.assertEqual(u.bio, "") + self.assertEqual(u.timezone, settings.TIME_ZONE) + self.assertEqual(u.avatar_url, "") + + def test_unique_email_constraint(self): + User.objects.create_user(email_address='dup@example.com', password='p') + with self.assertRaises(IntegrityError): + User.objects.create_user(email_address='dup@example.com', password='p') + + def test_create_user_without_email_raises(self): + with self.assertRaises(ValueError): + User.objects.create_user(email_address='', password='p') + + def test_date_of_birth_optional(self): + u = User.objects.create_user(email_address='nodob@example.com', password='p') + self.assertIsNone(u.date_of_birth) + + def test_str_and_full_name(self): + u = User.objects.create_user(email_address='name@example.com', password='p', first_name='A', last_name='B') + self.assertEqual(u.full_name, 'A B') + self.assertEqual(str(u), 'A B') + + def test_create_superuser_defaults(self): + su = User.objects.create_superuser(email_address='admin@example.com', password='admin') + self.assertTrue(su.is_staff) + self.assertTrue(su.is_active) diff --git a/apps/users/views.py b/apps/users/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/apps/users/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/apps/users/viewsets.py b/apps/users/viewsets.py index 84efd9a..58e280a 100644 --- a/apps/users/viewsets.py +++ b/apps/users/viewsets.py @@ -1,16 +1,10 @@ from rest_framework import viewsets -from rest_framework import permissions - -from django.contrib.auth import get_user_model - -from .serializers import UserSerializer - -User = get_user_model() - +from rest_framework.permissions import IsAuthenticatedOrReadOnly +from apps.users.models import User +from apps.users.serializers import UserSerializer class UserViewSet(viewsets.ReadOnlyModelViewSet): - """Read-only viewset exposing users for the POC.""" queryset = User.objects.all() serializer_class = UserSerializer - permission_classes = [permissions.AllowAny] + permission_classes = [IsAuthenticatedOrReadOnly] diff --git a/config/__pycache__/settings.cpython-313.pyc b/config/__pycache__/settings.cpython-313.pyc index f58be6d02e69733addb965f3e546281babacfc88..9cce306d7c64c21a027c27d3bbf05d00d3c36e63 100644 GIT binary patch delta 429 zcmW-cOH0F05QQ^0v1;oL1jL%Ov8j*N*0zan5JUt;6v4Iwf)FSLA5^r$t+;Tbe?fwK zSGp9*9}r!*wctW@j@S zKC~#8#9m@w2^_%wfw zD4suRf<*bMgDDnYTd3L9`4ra{-eSaFudosX|Q~~{T(3hvB;~+S61iC<;A)A%51TS z{#(QZ81q{AQn*Z;fx2<(ukmG2KSNTB`YAJw9rN#yccGeE;w;wGa`*O3Q=7TYHnnB{ ZUJXNZQI@m?*65(&n<-;~hi<#W#6KY0WZVD% delta 333 zcmW-cJ5Iw;5JlfSI}jW@CoLw zLEZrf(IzzP0MQ`v*#JXEx<|TmuSS|X@7pzg4MSrz(`V=W;>IxH_M!d44+X>W2&9P> z0n#E@C1=NGYz?R!+y9g`G^K-KX@3~Lcj1EqY0F0DOWCvJ=&1gr-t9jjvl zn{ggng92mN4tNN%fgSX*8&XR)v4?$X_i!+0FsBGXsIrBjY~!ZvpeKD&7q>7H9JdAF zPQYdFOi8%M5@iCD92+x#H+xl9Ap2Cg3TFD1KL9rimOcRbX{GR<>3R(yN`2#4JEez! xlw4<*#tHBrHGL~Ox-)gftR(Kcv(kQ+Th^d*q_EPECpvW>_pIUK!c$cv{sACKNXP&H diff --git a/config/settings.py b/config/settings.py index 105196e..8c14e93 100644 --- a/config/settings.py +++ b/config/settings.py @@ -21,6 +21,9 @@ MEDIA_URL = os.getenv('DJANGO_MEDIA_URL', '/media/') STATIC_ROOT = os.getenv('DJANGO_STATIC_ROOT', BASE_DIR / 'static') MEDIA_ROOT = os.getenv('DJANGO_MEDIA_ROOT', BASE_DIR / 'media') +OVERRIDE_APPS = [ + 'jazzmin', +] DJANGO_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -37,7 +40,7 @@ LOCAL_APPS = [ 'apps.domains', 'apps.agents', ] -INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS +INSTALLED_APPS = OVERRIDE_APPS + DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS AUTH_USER_MODEL = 'users.User' diff --git a/requirements.txt b/requirements.txt index 70a541c..82b29f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ ddgs==9.9.0 debugpy==1.8.17 decorator==5.2.1 Django==5.2.8 +django-jazzmin==3.0.1 djangorestframework==3.16.1 duckduckgo_search==8.1.1 executing==2.2.1