Merge pull request #142 from cisagov/nmb/user-models

User and UserProfile data models
This commit is contained in:
Neil MartinsenBurrell 2022-09-27 13:43:33 -05:00 committed by GitHub
commit f4ee330259
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 459 additions and 1 deletions

View file

@ -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
<https://docs.djangoproject.com/en/4.1/topics/auth/customizing/#using-a-custom-user-model-when-starting-a-project>.
This allows for future customization which would not be possible at a later
date if it isnt 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 Djangos 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 dont 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.

View file

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

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

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

17
src/registrar/apps.py Normal file
View file

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

View file

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

View file

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

21
src/registrar/forms.py Normal file
View file

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

View file

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

View file

View file

@ -0,0 +1,3 @@
from .models import User, UserProfile
__all__ = ["User", "UserProfile"]

View file

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

23
src/registrar/signals.py Normal file
View file

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

View file

@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block title %}
Edit your User Profile
{% endblock title %}
{% block content %}
<form class="usa-form usa-form--large" method="post" enctype="multipart/form-data">
{% csrf_token %}
<fieldset class="usa-fieldset">
<legend class="usa-legend usa-legend--large">Your profile</legend>
<p>
Required fields are marked with an asterisk (<abbr
title="required"
class="usa-hint usa-hint--required"
>*</abbr
>).
</p>
{% for field in profile_form %}
<label class="usa-label" for="id_{{ field.name }}">{{ field.label }}</label>
{{ field }}
{% endfor %}
</fieldset>
<button type="submit" class="usa-button usa-button--big">Save Changes</button>
</form>
{% endblock content %}

View file

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

View file

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