diff --git a/docs/architecture/decisions/0012-user-models.md b/docs/architecture/decisions/0012-user-models.md new file mode 100644 index 000000000..3e404106e --- /dev/null +++ b/docs/architecture/decisions/0012-user-models.md @@ -0,0 +1,40 @@ +# 12. Use custom User model with separate UserProfile + +Date: 2022-09-26 + +## Status + +Proposed + +## Context + +Django strongly recommends that a new project use a custom User model in their +first migration +. +This allows for future customization which would not be possible at a later +date if it isn’t done first. + +In order to separate authentication concerns from various user-related details +we might want to store, we want to decide how and where to store that +additional information. + +## Decision + +We use a custom user model derived from Django’s django.contrib.auth.User as +recommended along with a one-to-one related UserProfile model where we can +separately store any particular information about a user that we want to. That +includes contact information and the name that a person wants to use in the +application. + +Because the UserProfile is a place to store additional information about a +particular user, we mark each row in the UserProfile table to “cascade” deletes +so that when a single user is deleted, the matching UserProfile will also be +deleted. + +## Consequences + +If a user in our application is deleted (we don’t know at this point how or +when that might happen) then their profile would disappear. That means if the +same person returns to the application and makes a new account, there will be +no way to get back their UserProfile information and they will have to re-enter +it. diff --git a/src/.flake8 b/src/.flake8 index 42ef88a71..8c4d4851a 100644 --- a/src/.flake8 +++ b/src/.flake8 @@ -2,3 +2,5 @@ max-line-length = 88 max-complexity = 10 extend-ignore = E203 +# migrations are auto-generated and often break rules +exclude=registrar/migrations/* diff --git a/src/registrar/admin.py b/src/registrar/admin.py new file mode 100644 index 000000000..f2ada24e5 --- /dev/null +++ b/src/registrar/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from .models import User, UserProfile + + +class UserProfileInline(admin.StackedInline): + + """Edit a user's profile on the user page.""" + + model = UserProfile + + +class MyUserAdmin(UserAdmin): + + """Custom user admin class to use our inlines.""" + + inlines = [UserProfileInline] + + +admin.site.register(User, MyUserAdmin) diff --git a/src/registrar/apps.py b/src/registrar/apps.py new file mode 100644 index 000000000..9f1b186ad --- /dev/null +++ b/src/registrar/apps.py @@ -0,0 +1,17 @@ +from django.apps import AppConfig + + +class RegistrarConfig(AppConfig): + + """Configure signal handling for our registrar Django application.""" + + name = "registrar" + + def ready(self): + """Runs when all Django applications have been loaded. + + We use it here to load signals that connect related models. + """ + # noqa here because we are importing something to make the signals + # get registered, but not using what we import + from . import signals # noqa diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index b17a0e4ce..1e4b921d9 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -187,6 +187,9 @@ DATABASES = { # Specify default field type to use for primary keys DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +# Use our user model instead of the default +AUTH_USER_MODEL = "registrar.User" + # endregion # region: Email-------------------------------------------------------------### @@ -570,6 +573,10 @@ if DEBUG: INSTALLED_APPS += ("nplusone.ext.django",) MIDDLEWARE += ("nplusone.ext.django.NPlusOneMiddleware",) NPLUSONE_RAISE = True + NPLUSONE_WHITELIST = [ + {"model": "admin.LogEntry", "field": "user"}, + {"model": "registrar.UserProfile"}, + ] # insert the amazing django-debug-toolbar INSTALLED_APPS += ("debug_toolbar",) diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 95802704f..64b84dbff 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -7,12 +7,13 @@ For more information see: from django.contrib import admin from django.urls import include, path -from registrar.views import health, index +from registrar.views import health, index, profile urlpatterns = [ path("", index.index, name="home"), path("admin/", admin.site.urls), path("health/", health.health), + path("edit_profile/", profile.edit_profile, name="edit-profile"), path("openid/", include("djangooidc.urls")), # these views respect the DEBUG setting path("__debug__/", include("debug_toolbar.urls")), diff --git a/src/registrar/forms.py b/src/registrar/forms.py new file mode 100644 index 000000000..09bbb72a6 --- /dev/null +++ b/src/registrar/forms.py @@ -0,0 +1,21 @@ +from django import forms + +from .models import UserProfile + + +class EditProfileForm(forms.ModelForm): + + """Custom form class for editing a UserProfile. + + We can add whatever fields we want to this form and customize how they + are displayed. The form is rendered into a template `profile.html` by a + view called `edit_profile` in `profile.py`. + """ + + display_name = forms.CharField( + widget=forms.TextInput(attrs={"class": "usa-input"}), label="Display Name" + ) + + class Meta: + model = UserProfile + fields = ["display_name"] diff --git a/src/registrar/migrations/0001_initial.py b/src/registrar/migrations/0001_initial.py new file mode 100644 index 000000000..4440576a1 --- /dev/null +++ b/src/registrar/migrations/0001_initial.py @@ -0,0 +1,172 @@ +# Generated by Django 4.1.1 on 2022-09-26 15:26 + +from django.conf import settings +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + 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()), + ], + ), + migrations.CreateModel( + name="UserProfile", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("street1", models.TextField(blank=True)), + ("street2", models.TextField(blank=True)), + ("street3", models.TextField(blank=True)), + ("city", models.TextField(blank=True)), + ("sp", models.TextField(blank=True)), + ("pc", models.TextField(blank=True)), + ("cc", models.TextField(blank=True)), + ("voice", models.TextField(blank=True)), + ("fax", models.TextField(blank=True)), + ("email", models.TextField(blank=True)), + ("display_name", models.TextField()), + ( + "user", + models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/src/registrar/migrations/__init__.py b/src/registrar/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py new file mode 100644 index 000000000..4723061e4 --- /dev/null +++ b/src/registrar/models/__init__.py @@ -0,0 +1,3 @@ +from .models import User, UserProfile + +__all__ = ["User", "UserProfile"] diff --git a/src/registrar/models/models.py b/src/registrar/models/models.py new file mode 100644 index 000000000..bf6148dbf --- /dev/null +++ b/src/registrar/models/models.py @@ -0,0 +1,85 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth.models import AbstractUser +from django.db import models + + +class User(AbstractUser): + """ + A custom user model that performs identically to the default user model + but can be customized later. + """ + + def __str__(self): + try: + return self.userprofile.display_name + except ObjectDoesNotExist: + return self.username + + +class TimeStampedModel(models.Model): + """ + An abstract base model that provides self-updating + `created_at` and `updated_at` fields. + """ + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + # don't put anything else here, it will be ignored + + +class AddressModel(models.Model): + """ + An abstract base model that provides common fields + for postal addresses. + """ + + # contact's street (null ok) + street1 = models.TextField(blank=True) + # contact's street (null ok) + street2 = models.TextField(blank=True) + # contact's street (null ok) + street3 = models.TextField(blank=True) + # contact's city + city = models.TextField(blank=True) + # contact's state or province (null ok) + sp = models.TextField(blank=True) + # contact's postal code (null ok) + pc = models.TextField(blank=True) + # contact's country code + cc = models.TextField(blank=True) + + class Meta: + abstract = True + # don't put anything else here, it will be ignored + + +class ContactModel(models.Model): + """ + An abstract base model that provides common fields + for contact information. + """ + + voice = models.TextField(blank=True) + fax = models.TextField(blank=True) + email = models.TextField(blank=True) + + class Meta: + abstract = True + # don't put anything else here, it will be ignored + + +class UserProfile(TimeStampedModel, ContactModel, AddressModel): + user = models.OneToOneField(User, null=True, on_delete=models.CASCADE) + display_name = models.TextField() + + def __str__(self): + if self.display_name: + return self.display_name + else: + try: + return self.user.username + except ObjectDoesNotExist: + return "No username" diff --git a/src/registrar/signals.py b/src/registrar/signals.py new file mode 100644 index 000000000..a39a90397 --- /dev/null +++ b/src/registrar/signals.py @@ -0,0 +1,23 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from .models import User, UserProfile + + +@receiver(post_save, sender=User) +def handle_profile(sender, instance, **kwargs): + + """Method for when a User is saved. + + If the user is being created, then create a matching UserProfile. Otherwise + save an updated profile or create one if it doesn't exist. + """ + + if kwargs.get("created", False): + UserProfile.objects.create(user=instance) + else: + # the user is not being created. + if hasattr(instance, "userprofile"): + instance.userprofile.save() + else: + UserProfile.objects.create(user=instance) diff --git a/src/registrar/templates/profile.html b/src/registrar/templates/profile.html new file mode 100644 index 000000000..7539abb2f --- /dev/null +++ b/src/registrar/templates/profile.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} + +{% block title %} +Edit your User Profile +{% endblock title %} + +{% block content %} +
+ {% csrf_token %} +
+ Your profile +

+ Required fields are marked with an asterisk (*). +

+ {% for field in profile_form %} + + {{ field }} + {% endfor %} +
+ +
+{% endblock content %} diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 72aa19efc..b13c84aad 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1,4 +1,7 @@ from django.test import Client, TestCase +from django.contrib.auth import get_user_model + +from registrar.models import UserProfile class HealthTest(TestCase): @@ -9,3 +12,19 @@ class HealthTest(TestCase): response = self.client.get("/health/") self.assertEqual(response.status_code, 200) self.assertContains(response, "OK") + + +class LoggedInTests(TestCase): + def setUp(self): + username = "test_user" + first_name = "First" + last_name = "Last" + email = "info@example.com" + self.user = get_user_model().objects.create( + username=username, first_name=first_name, last_name=last_name, email=email + ) + self.client.force_login(self.user) + + def test_edit_profile(self): + response = self.client.get("/edit_profile/") + self.assertContains(response, "Display Name") diff --git a/src/registrar/views/profile.py b/src/registrar/views/profile.py new file mode 100644 index 000000000..cfb7d63a0 --- /dev/null +++ b/src/registrar/views/profile.py @@ -0,0 +1,22 @@ +from django.shortcuts import redirect, render +from django.contrib.auth.decorators import login_required +from django.contrib import messages + +from ..forms import EditProfileForm + + +@login_required +def edit_profile(request): + + """View for a profile editing page.""" + + if request.method == "POST": + # post to this view when changes are made + profile_form = EditProfileForm(request.POST, instance=request.user.userprofile) + if profile_form.is_valid(): + profile_form.save() + messages.success(request, "Your profile is updated successfully") + return redirect(to="edit-profile") + else: + profile_form = EditProfileForm(instance=request.user.userprofile) + return render(request, "profile.html", {"profile_form": profile_form})