diff --git a/apps/users/__init__.py b/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/admin.py b/apps/users/admin.py new file mode 100644 index 0000000..300ea48 --- /dev/null +++ b/apps/users/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin +from django.contrib.auth.models import Group +from apps.users.models import User + +admin.site.unregister(Group) + +@admin.register(User) +class UserAdmin(DjangoUserAdmin): + fieldsets = ( + (None, {'fields': ('email_address', 'password')}), + ('Personal info', {'fields': ('first_name', 'last_name')}), + ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'is_manager')}), + ('Dates', {'fields': ('last_login',)}), + ) + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email_address', 'first_name', 'last_name', 'password1', 'password2'), + }), + ) + + 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 new file mode 100644 index 0000000..7f2dacd --- /dev/null +++ b/apps/users/apps.py @@ -0,0 +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 new file mode 100644 index 0000000..a3760f4 --- /dev/null +++ b/apps/users/managers.py @@ -0,0 +1,27 @@ +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 + +class UserManager(BaseUserManager["User"]): + + def _create_user(self, email_address: str, password: str | None, **extra_fields): + if not email_address: + 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) + user.save(using=self._db) + return user + + def create_user(self, email_address: str, password: str | None = None, **extra_fields): + 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): + extra_fields.setdefault("is_staff", True) + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + return self._create_user(email_address, password, **extra_fields) diff --git a/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..8c7a96f --- /dev/null +++ b/apps/users/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.10 on 2026-01-17 14:35 + +import uuid +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='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')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='User ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='User UUID')), + ('email_address', models.EmailField(max_length=255, unique=True, verbose_name='Email Address')), + ('first_name', models.CharField(max_length=255, verbose_name='First Name')), + ('last_name', models.CharField(max_length=255, verbose_name='Last Name')), + ('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')), + ('bio', models.TextField(blank=True, default='')), + ('timezone', models.CharField(blank=True, default='UTC', max_length=16)), + ('avatar_url', models.URLField(blank=True)), + ('is_active', models.BooleanField(default=True, verbose_name='Account Active')), + ('is_staff', models.BooleanField(default=False, verbose_name='Account Admin')), + ('is_manager', models.BooleanField(default=False, verbose_name='Organization Manager')), + ('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', + }, + ), + ] diff --git a/apps/users/migrations/__init__.py b/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/mixins.py b/apps/users/mixins.py new file mode 100644 index 0000000..30915b0 --- /dev/null +++ b/apps/users/mixins.py @@ -0,0 +1,10 @@ +from django.db.models import DateTimeField, Model +from django.utils.translation import gettext_lazy as _ + +class TimeStampMixin(Model): + + created_at = DateTimeField(verbose_name="Created At", auto_now_add=True) + updated_at = DateTimeField(verbose_name="Updated At", auto_now=True) + + class Meta: + abstract = True \ No newline at end of file diff --git a/apps/users/models.py b/apps/users/models.py new file mode 100644 index 0000000..6eaf546 --- /dev/null +++ b/apps/users/models.py @@ -0,0 +1,50 @@ +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.db.models import AutoField, BooleanField, CharField, DateField, EmailField, UUIDField, TextField, URLField +from django.utils.translation import gettext_lazy as _ +from typing import ClassVar +from uuid import uuid4 +from apps.users.managers import UserManager +from apps.users.mixins import TimeStampMixin +from django.conf import settings + +class User(AbstractBaseUser, TimeStampMixin, PermissionsMixin): + + id = AutoField(verbose_name = _("User ID"), primary_key = True) + 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) + + 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_manager = BooleanField(verbose_name = _("Organization Manager"), default = False) + + USERNAME_FIELD = 'email_address' + EMAIL_FIELD = 'email_address' + REQUIRED_FIELDS = ['first_name', 'last_name', 'date_of_birth'] + + objects: ClassVar[UserManager] = UserManager() + + def has_perm(self, perm, obj=None): + return True + + def has_module_perms(self, app_label): + return True + + class Meta: + verbose_name = _('User') + verbose_name_plural = _('Users') + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}" + + def __str__(self): + return self.full_name + diff --git a/apps/users/serializers.py b/apps/users/serializers.py new file mode 100644 index 0000000..d6e2ae7 --- /dev/null +++ b/apps/users/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers +from apps.users.models import User + +class UserSerializer(serializers.ModelSerializer): + + class Meta: + model = User + fields = ['id', 'uuid', 'email_address', 'first_name', 'last_name', 'bio', 'timezone', 'avatar_url', 'is_manager', 'date_of_birth', 'created_at', 'updated_at'] + read_only_fields = ['id', 'uuid', 'created_at', 'updated_at'] \ No newline at end of file diff --git a/apps/users/tests/__init__.py b/apps/users/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/tests/test_api_auth.py b/apps/users/tests/test_api_auth.py new file mode 100644 index 0000000..73a6972 --- /dev/null +++ b/apps/users/tests/test_api_auth.py @@ -0,0 +1,641 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN +from rest_framework.test import APIClient + +User = get_user_model() + +class UserLoginActionTests(TestCase): + + def setUp(self): + self.client = APIClient() + self.user_data = { + 'email_address': 'testuser@example.com', + 'password': 'testpass123', + 'first_name': 'Test', + 'last_name': 'User', + 'date_of_birth': '1990-01-01' + } + self.user = User.objects.create_user(**self.user_data) + + def test_login_successful(self): + response = self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertTrue(data['success']) + self.assertEqual(data['message'], 'Login successful') + self.assertIn('user', data) + self.assertEqual(data['user']['email_address'], 'testuser@example.com') + + def test_login_missing_email(self): + response = self.client.post('/api/user/login/', { + 'password': 'testpass123' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + data = response.json() + self.assertIn('error', data) + + def test_login_missing_password(self): + response = self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + data = response.json() + self.assertIn('error', data) + + def test_login_invalid_credentials(self): + response = self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'wrongpassword' + }) + self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) + + def test_login_nonexistent_user(self): + response = self.client.post('/api/user/login/', { + 'email_address': 'nonexistent@example.com', + 'password': 'testpass123' + }) + self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) + + def test_login_session_created(self): + response = self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + self.assertEqual(response.status_code, HTTP_200_OK) + self.assertIn('sessionid', self.client.cookies) + + def test_login_inactive_user(self): + self.user.is_active = False + self.user.save() + response = self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) + + def test_login_case_insensitive_email(self): + response = self.client.post('/api/user/login/', { + 'email_address': 'testuser@EXAMPLE.COM', + 'password': 'testpass123' + }) + self.assertEqual(response.status_code, HTTP_200_OK) + + +class UserLogoutActionTests(TestCase): + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + email_address='testuser@example.com', + password='testpass123', + first_name='Test', + last_name='User' + ) + + def test_logout_successful(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + response = self.client.post('/api/user/logout/') + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertTrue(data['success']) + + def test_logout_without_login(self): + response = self.client.post('/api/user/logout/') + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + def test_session_destroyed_after_logout(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + self.client.post('/api/user/logout/') + response = self.client.get('/api/user/me/') + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + +class UserMeActionTests(TestCase): + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + email_address='testuser@example.com', + password='testpass123', + first_name='Test', + last_name='User' + ) + + def test_me_authenticated(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + response = self.client.get('/api/user/me/') + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertTrue(data['success']) + self.assertEqual(data['email_address'], 'testuser@example.com') + + def test_me_unauthenticated(self): + response = self.client.get('/api/user/me/') + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + def test_me_returns_correct_user_data(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + response = self.client.get('/api/user/me/') + data = response.json() + expected_fields = {'id', 'uuid', 'email_address', 'first_name', 'last_name'} + self.assertTrue(expected_fields.issubset(set(data.keys()))) + + +class UserSessionActionTests(TestCase): + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + email_address='testuser@example.com', + password='testpass123', + first_name='Test', + last_name='User' + ) + + def test_session_authenticated(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + response = self.client.get('/api/user/session/') + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertTrue(data['isAuthenticated']) + + def test_session_unauthenticated(self): + response = self.client.get('/api/user/session/') + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertFalse(data['isAuthenticated']) + + def test_session_staff_status(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + response = self.client.get('/api/user/session/') + data = response.json() + self.assertIn('isStaff', data) + self.assertFalse(data['isStaff']) + + def test_session_unauthenticated_no_staff(self): + response = self.client.get('/api/user/session/') + data = response.json() + self.assertFalse(data['isAuthenticated']) + + +class UserSignupActionTests(TestCase): + + def setUp(self): + self.client = APIClient() + + def test_signup_successful(self): + response = self.client.post('/api/user/signup/', { + 'email_address': 'newuser@example.com', + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'first_name': 'New', + 'last_name': 'User', + 'date_of_birth': '1995-05-05' + }) + self.assertEqual(response.status_code, HTTP_201_CREATED) + data = response.json() + self.assertTrue(data['success']) + self.assertIn('User account created successfully', data['detail']) + self.assertTrue(User.objects.filter(email_address='newuser@example.com').exists()) + + def test_signup_email_exists(self): + User.objects.create_user( + email_address='existing@example.com', + password='pass', + first_name='Existing', + last_name='User' + ) + response = self.client.post('/api/user/signup/', { + 'email_address': 'existing@example.com', + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'first_name': 'New', + 'last_name': 'User' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + data = response.json() + self.assertFalse(data['success']) + self.assertIn('Email address already exists', data['detail']) + + def test_signup_missing_first_name(self): + response = self.client.post('/api/user/signup/', { + 'email_address': 'newuser2@example.com', + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'last_name': 'User' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + data = response.json() + self.assertFalse(data['success']) + + def test_signup_missing_last_name(self): + response = self.client.post('/api/user/signup/', { + 'email_address': 'newuser3@example.com', + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'first_name': 'New' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + data = response.json() + self.assertFalse(data['success']) + + def test_signup_passwords_mismatch(self): + response = self.client.post('/api/user/signup/', { + 'email_address': 'newuser4@example.com', + 'password': 'newpass123', + 'confirm_password': 'differentpass', + 'first_name': 'New', + 'last_name': 'User' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + data = response.json() + self.assertIn('Passwords do not match', data['detail']) + + def test_signup_missing_email(self): + response = self.client.post('/api/user/signup/', { + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'first_name': 'New', + 'last_name': 'User' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + def test_signup_missing_password(self): + response = self.client.post('/api/user/signup/', { + 'email_address': 'newuser@example.com', + 'confirm_password': 'newpass123', + 'first_name': 'New', + 'last_name': 'User' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + def test_signup_empty_data(self): + response = self.client.post('/api/user/signup/', {}) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + def test_signup_case_insensitive_email(self): + response = self.client.post('/api/user/signup/', { + 'email_address': 'NewUser@EXAMPLE.COM', + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'first_name': 'New', + 'last_name': 'User' + }) + self.assertEqual(response.status_code, HTTP_201_CREATED) + user = User.objects.get(email_address='NewUser@example.com') + self.assertEqual(user.email_address, 'NewUser@example.com') + + def test_signup_duplicate_case_insensitive(self): + User.objects.create_user( + email_address='test@example.com', + password='pass', + first_name='Test', + last_name='User' + ) + response = self.client.post('/api/user/signup/', { + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'first_name': 'New', + 'last_name': 'User' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + +class UserChangePasswordActionTests(TestCase): + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + email_address='testuser@example.com', + password='testpass123', + first_name='Test', + last_name='User' + ) + + def test_change_password_successful(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + response = self.client.post('/api/user/change_password/', { + 'old_password': 'testpass123', + 'password': 'newpass456', + 'confirm_password': 'newpass456' + }) + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertTrue(data['success']) + self.user.refresh_from_db() + self.assertTrue(self.user.check_password('newpass456')) + + def test_change_password_wrong_old_password(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + response = self.client.post('/api/user/change_password/', { + 'old_password': 'wrongoldpass', + 'password': 'newpass456', + 'confirm_password': 'newpass456' + }) + self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) + data = response.json() + self.assertFalse(data['success']) + + def test_change_password_mismatch(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + response = self.client.post('/api/user/change_password/', { + 'old_password': 'testpass123', + 'password': 'newpass456', + 'confirm_password': 'differentpass' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + data = response.json() + self.assertIn('Passwords do not match', data['detail']) + + def test_change_password_missing_old_password(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + response = self.client.post('/api/user/change_password/', { + 'password': 'newpass456', + 'confirm_password': 'newpass456' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + data = response.json() + self.assertIn('old_password', data['detail']) + + def test_change_password_missing_new_password(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + response = self.client.post('/api/user/change_password/', { + 'old_password': 'testpass123', + 'confirm_password': 'newpass456' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + def test_change_password_unauthenticated(self): + response = self.client.post('/api/user/change_password/', { + 'old_password': 'testpass123', + 'password': 'newpass456', + 'confirm_password': 'newpass456' + }) + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) + + def test_change_password_empty_old_password(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + response = self.client.post('/api/user/change_password/', { + 'old_password': '', + 'password': 'newpass456', + 'confirm_password': 'newpass456' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + def test_can_login_with_new_password_after_change(self): + self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'testpass123' + }) + self.client.post('/api/user/change_password/', { + 'old_password': 'testpass123', + 'password': 'brandnewpass789', + 'confirm_password': 'brandnewpass789' + }) + self.client.logout() + response = self.client.post('/api/user/login/', { + 'email_address': 'testuser@example.com', + 'password': 'brandnewpass789' + }) + self.assertEqual(response.status_code, HTTP_200_OK) + + +class UserEdgeCaseTests(TestCase): + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + email_address='edgecase@example.com', + password='testpass123', + first_name='Edge', + last_name='Case' + ) + + def test_login_with_whitespace_email(self): + response = self.client.post('/api/user/login/', { + 'email_address': ' testuser@example.com ', + 'password': 'testpass123' + }) + self.assertEqual(response.status_code, HTTP_401_UNAUTHORIZED) + + def test_signup_with_very_long_name(self): + long_name = 'A' * 255 + response = self.client.post('/api/user/signup/', { + 'email_address': 'longname@example.com', + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'first_name': long_name, + 'last_name': long_name + }) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + def test_signup_with_too_long_name(self): + too_long_name = 'A' * 256 + response = self.client.post('/api/user/signup/', { + 'email_address': 'verylongname@example.com', + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'first_name': too_long_name, + 'last_name': 'User' + }) + self.assertIn(response.status_code, [HTTP_400_BAD_REQUEST, HTTP_201_CREATED]) + + def test_signup_with_special_characters_in_name(self): + response = self.client.post('/api/user/signup/', { + 'email_address': 'special@example.com', + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'first_name': 'José', + 'last_name': "O'Brien-Smith" + }) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + def test_change_password_same_as_old(self): + self.client.post('/api/user/login/', { + 'email_address': 'edgecase@example.com', + 'password': 'testpass123' + }) + response = self.client.post('/api/user/change_password/', { + 'old_password': 'testpass123', + 'password': 'testpass123', + 'confirm_password': 'testpass123' + }) + self.assertEqual(response.status_code, HTTP_200_OK) + + def test_signup_missing_confirm_password_field(self): + response = self.client.post('/api/user/signup/', { + 'email_address': 'missingconfirm@example.com', + 'password': 'newpass123', + 'first_name': 'Missing', + 'last_name': 'Confirm' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + def test_login_multiple_times_same_session(self): + response1 = self.client.post('/api/user/login/', { + 'email_address': 'edgecase@example.com', + 'password': 'testpass123' + }) + session_id_1 = self.client.cookies.get('sessionid') + + me1 = self.client.get('/api/user/me/') + self.assertEqual(me1.status_code, HTTP_200_OK) + + response2 = self.client.post('/api/user/login/', { + 'email_address': 'edgecase@example.com', + 'password': 'testpass123' + }) + session_id_2 = self.client.cookies.get('sessionid') + self.assertEqual(response1.status_code, HTTP_200_OK) + self.assertEqual(response2.status_code, HTTP_200_OK) + + def test_staff_user_login_shows_staff_status(self): + staff_user = User.objects.create_user( + email_address='staff@example.com', + password='staffpass', + first_name='Staff', + last_name='User', + is_staff=True + ) + response = self.client.post('/api/user/login/', { + 'email_address': 'staff@example.com', + 'password': 'staffpass' + }) + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertIn('user', data) + + def test_session_status_after_explicit_logout(self): + self.client.post('/api/user/login/', { + 'email_address': 'edgecase@example.com', + 'password': 'testpass123' + }) + self.client.post('/api/user/logout/') + + response = self.client.get('/api/user/session/') + data = response.json() + self.assertFalse(data['isAuthenticated']) + + def test_signup_with_null_optional_fields(self): + response = self.client.post('/api/user/signup/', { + 'email_address': 'optional@example.com', + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'first_name': 'Optional', + 'last_name': 'Fields' + }) + self.assertEqual(response.status_code, HTTP_201_CREATED) + + def test_change_password_with_missing_confirm_password(self): + self.client.post('/api/user/login/', { + 'email_address': 'edgecase@example.com', + 'password': 'testpass123' + }) + response = self.client.post('/api/user/change_password/', { + 'old_password': 'testpass123', + 'password': 'newpass456' + }) + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + def test_login_and_logout_sequence(self): + resp1 = self.client.post('/api/user/login/', { + 'email_address': 'edgecase@example.com', + 'password': 'testpass123' + }) + self.assertEqual(resp1.status_code, HTTP_200_OK) + + me1 = self.client.get('/api/user/me/') + self.assertEqual(me1.status_code, HTTP_200_OK) + + logout_resp = self.client.post('/api/user/logout/') + self.assertEqual(logout_resp.status_code, HTTP_200_OK) + + me2 = self.client.get('/api/user/me/') + self.assertEqual(me2.status_code, HTTP_403_FORBIDDEN) + + resp2 = self.client.post('/api/user/login/', { + 'email_address': 'edgecase@example.com', + 'password': 'testpass123' + }) + self.assertEqual(resp2.status_code, HTTP_200_OK) + + me3 = self.client.get('/api/user/me/') + self.assertEqual(me3.status_code, HTTP_200_OK) + + def test_invalid_email_format(self): + response = self.client.post('/api/user/signup/', { + 'email_address': 'not-an-email', + 'password': 'newpass123', + 'confirm_password': 'newpass123', + 'first_name': 'Invalid', + 'last_name': 'Email' + }) + self.assertIn(response.status_code, [HTTP_400_BAD_REQUEST, HTTP_201_CREATED]) + + def test_empty_password_signup(self): + response = self.client.post('/api/user/signup/', { + 'email_address': 'emptypass@example.com', + 'password': '', + 'confirm_password': '', + 'first_name': 'Empty', + 'last_name': 'Pass' + }) + self.assertIn(response.status_code, [HTTP_400_BAD_REQUEST, HTTP_201_CREATED]) + + def test_role_preserved_after_login(self): + user = User.objects.create_user( + email_address='manager@example.com', + password='managerpass', + first_name='Manager', + last_name='User', + is_manager=True + ) + response = self.client.post('/api/user/login/', { + 'email_address': 'manager@example.com', + 'password': 'managerpass' + }) + self.assertEqual(response.status_code, HTTP_200_OK) + data = response.json() + self.assertIn('user', data) + self.assertEqual(data['user']['email_address'], 'manager@example.com') + self.assertTrue(data['user']['is_manager']) \ No newline at end of file diff --git a/apps/users/tests/test_api_list.py b/apps/users/tests/test_api_list.py new file mode 100644 index 0000000..f659775 --- /dev/null +++ b/apps/users/tests/test_api_list.py @@ -0,0 +1,55 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND + +User = get_user_model() + +class UserListAPITests(TestCase): + + 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/user/' + resp = self.client.get(url) + self.assertEqual(resp.status_code, HTTP_200_OK) + data = resp.json() + self.assertIsInstance(data, (list, dict)) + + def test_api_response_contains_expected_fields(self): + url = '/api/user/' + resp = self.client.get(url) + self.assertEqual(resp.status_code, HTTP_200_OK) + data = resp.json() + + if isinstance(data, dict) and 'results' in data: + users = data['results'] + else: + users = data + + self.assertTrue(len(users) >= 1) + sample = users[0] + expected_keys = {'id', 'uuid', 'email_address', 'first_name', 'last_name', 'bio', 'timezone', 'avatar_url'} + self.assertTrue(expected_keys.issubset(set(sample.keys()))) + + def test_retrieve_user_by_uuid(self): + url = f'/api/user/{self.user.uuid}/' + resp = self.client.get(url) + self.assertEqual(resp.status_code, HTTP_200_OK) + data = resp.json() + self.assertEqual(data['email_address'], 'apiuser@example.com') + + def test_retrieve_user_not_found(self): + import uuid + fake_uuid = uuid.uuid4() + url = f'/api/user/{fake_uuid}/' + resp = self.client.get(url) + self.assertEqual(resp.status_code, HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/apps/users/tests/test_models.py b/apps/users/tests/test_models.py new file mode 100644 index 0000000..08b6278 --- /dev/null +++ b/apps/users/tests/test_models.py @@ -0,0 +1,121 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.db import IntegrityError +from django.conf import settings +import uuid + + +User = get_user_model() + + +class UserModelTests(TestCase): + + 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) + self.assertEqual(user.email_address, 'Test@example.com') + 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.assertIsNotNone(su.pk) + self.assertTrue(su.is_active) + + 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, "") + self.assertTrue(u.is_active) + self.assertFalse(u.is_staff) + + 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_email_normalization_domain_lowercase(self): + user1 = User.objects.create_user(email_address='Test@EXAMPLE.COM', password='p') + self.assertEqual(user1.email_address, 'Test@example.com') + user2 = User.objects.create_user(email_address='test@EXAMPLE.COM', password='p2') + self.assertEqual(user2.email_address, 'test@example.com') + self.assertNotEqual(user1.email_address, user2.email_address) + + def test_superuser_must_have_is_staff(self): + with self.assertRaises(ValueError): + User.objects.create_superuser( + email_address='fail@example.com', + password='p', + is_staff=False + ) + + def test_role_default_is_employee(self): + u = User.objects.create_user(email_address='role@example.com', password='p') + self.assertFalse(getattr(u, 'is_manager', False)) + + def test_role_choices(self): + u = User.objects.create_user( + email_address='manager@example.com', + password='p', + is_manager=True + ) + self.assertTrue(u.is_manager) + + def test_timestamps_auto_set(self): + from datetime import timedelta + u = User.objects.create_user(email_address='timestamps@example.com', password='p') + self.assertIsNotNone(u.created_at) + self.assertIsNotNone(u.updated_at) + time_diff = abs((u.updated_at - u.created_at).total_seconds()) + self.assertLess(time_diff, 1.0) + + def test_has_perm_returns_true(self): + u = User.objects.create_user(email_address='perm@example.com', password='p') + self.assertTrue(u.has_perm('any.permission')) + self.assertTrue(u.has_perm('another.permission', obj=None)) + + def test_has_module_perms_returns_true(self): + u = User.objects.create_user(email_address='modperm@example.com', password='p') + self.assertTrue(u.has_module_perms('auth')) + self.assertTrue(u.has_module_perms('users')) diff --git a/apps/users/viewsets.py b/apps/users/viewsets.py new file mode 100644 index 0000000..392a2fd --- /dev/null +++ b/apps/users/viewsets.py @@ -0,0 +1,90 @@ +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticatedOrReadOnly, AllowAny, IsAuthenticated +from django.contrib.auth import authenticate, login, logout +from apps.users.models import User +from apps.users.serializers import UserSerializer + +class UserViewSet(ReadOnlyModelViewSet): + + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + lookup_field = 'uuid' + + @action(detail=False, methods=['post'], permission_classes=[AllowAny]) + def login(self, request): + email_address = request.data.get('email_address') + password = request.data.get('password') + if not email_address or not password: + return Response({'error': 'Email and password are required'}, status=HTTP_400_BAD_REQUEST) + email_address = User.objects.normalize_email(email_address) + user = authenticate(request, username=email_address, password=password) + if user is None: + return Response({'error': 'Invalid credentials'}, status=HTTP_401_UNAUTHORIZED) + + login(request, user) + return Response({'user': UserSerializer(user).data, 'message': 'Login successful', 'success': True}, status=HTTP_200_OK) + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def logout(self, request): + logout(request) + return Response({'message': 'Logout successful', 'success': True}, status=HTTP_200_OK) + + @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated]) + def me(self, request): + user_data = UserSerializer(request.user).data + user_data['success'] = True + return Response(user_data) + + @action(detail=False, methods=['get'], permission_classes=[AllowAny]) + def session(self, request): + return Response({'isAuthenticated': request.user.is_authenticated, 'isStaff': request.user.is_staff if request.user.is_authenticated else False}) + + @action(detail=False, methods=['post'], permission_classes=[AllowAny]) + def signup(self, request): + try: + data = request.data + except: + return Response({'detail': 'Invalid data provided.', 'success': False}, status=HTTP_400_BAD_REQUEST) + email_address = data.get('email_address') + if not email_address: + return Response({'detail': 'Email address is required.', 'success': False}, status=HTTP_400_BAD_REQUEST) + email_address = User.objects.normalize_email(email_address) + if User.objects.filter(email_address=email_address).exists(): + return Response({'detail': 'Email address already exists.', 'success': False}, status=HTTP_400_BAD_REQUEST) + + if not data.get('first_name') or not data.get('last_name'): + return Response({'detail': 'First and last name(s) must be provided.', 'success': False}, status=HTTP_400_BAD_REQUEST) + + if data.get('password') != data.get('confirm_password'): + return Response({'detail': 'Passwords do not match.', 'success': False}, status=HTTP_400_BAD_REQUEST) + try: + user = User.objects.create_user( + email_address=email_address, + password=data.get('password'), + first_name=data.get('first_name'), + last_name=data.get('last_name'), + date_of_birth=data.get('date_of_birth') + ) + return Response({'detail': 'User account created successfully.', 'success': True}, status=HTTP_201_CREATED) + except Exception as e: + return Response({'detail': str(e), 'success': False}, status=HTTP_400_BAD_REQUEST) + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def change_password(self, request): + data = request.data + required_fields = ['old_password', 'password', 'confirm_password'] + for field in required_fields: + if not data.get(field): + return Response({'detail': f'"{field}" not provided', 'success': False}, status=HTTP_400_BAD_REQUEST) + if data.get('password') != data.get('confirm_password'): + return Response({'detail': 'Passwords do not match', 'success': False}, status=HTTP_400_BAD_REQUEST) + user = request.user + if not user.check_password(data.get('old_password')): + return Response({'detail': 'Old password is incorrect', 'success': False}, status=HTTP_401_UNAUTHORIZED) + user.set_password(data.get('password')) + user.save() + return Response({'detail': 'Password changed successfully', 'success': True}, status=HTTP_200_OK) diff --git a/config/api.py b/config/api.py index 18cb509..bb626b2 100644 --- a/config/api.py +++ b/config/api.py @@ -1,5 +1,8 @@ from rest_framework.routers import DefaultRouter +from apps.users.viewsets import UserViewSet + router = DefaultRouter() +router.register(r'user', UserViewSet, basename='user') urlpatterns = router.urls