The recording for this lesson has not been published yet.
Your blog now has accounts, and the next wish arrives immediately: a short bio under every
post, an avatar next to the author name, maybe a link to a personal site. You could add
those fields to the user model from lesson 4.1 - and for two or three simple fields that is
the right answer. A separate Profile model earns its place when:
auth.User;| Fields on the custom user | Separate Profile model | |
|---|---|---|
| Queries | one row, always loaded | a JOIN or a second query - use select_related |
| Existence | guaranteed | can be missing, which is the classic bug (section 3) |
| Works on a legacy project | no | yes, this is its main selling point |
| Admin | a fieldset | an inline |
# accounts/models.py
from django.conf import settings
from django.db import models
class Profile(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
primary_key=True,
related_name="profile",
)
bio = models.TextField(max_length=500, blank=True)
website = models.URLField(blank=True)
avatar = models.ImageField(upload_to="avatars/%Y/%m/", blank=True)
def __str__(self):
return f"Profile of {self.user}"
@property
def display_name(self):
return self.user.get_full_name() or self.user.get_username()
OneToOneField is a ForeignKey with unique=True and a
friendlier accessor: user.profile returns the object itself, not a manager, so
there is no .all() and no .first(). Django would derive that
accessor from the lowercased model name anyway, but writing
related_name="profile" makes it explicit. primary_key=True is
optional; it saves a column and guarantees one profile per user at the database level.
ImageField needs Pillow and a place to write files:
pip install Pillow
# mysite/settings.py
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
# mysite/urls.py - development only
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [...]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
STATIC_ROOT holds code you ship; MEDIA_ROOT holds what users
upload. collectstatic never touches media, and the helper above only serves
it while DEBUG is on. On PythonAnywhere add a second static-files mapping
for /media/ in the Web tab.
A profile row does not appear by itself. If it is missing,
user.profile raises Profile.DoesNotExist - and in a template that
failure is silent, so the avatar simply never shows up. There are two ways to make sure the
row exists.
Option A - a post_save signal. Implicit but automatic:
# accounts/signals.py
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Profile
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)
# accounts/apps.py
class AccountsConfig(AppConfig):
name = "accounts"
def ready(self):
from . import signals # noqa: F401
Option B - create it where the user is created. Explicit:
# accounts/views.py
from django.db import transaction
from .models import Profile
class SignUpView(CreateView):
...
@transaction.atomic
def form_valid(self, form):
response = super().form_valid(form)
Profile.objects.create(user=self.object)
login(self.request, self.object)
return response
| post_save signal | Explicit creation | |
|---|---|---|
| Covers createsuperuser, fixtures, the admin, the shell | yes - anything that saves a user | no - you must remember every path |
| Easy to follow when reading the code | no, the call is invisible | yes |
| Behaviour in tests and data migrations | fires unexpectedly; loaddata that contains profiles then conflicts |
predictable |
| Failure mode | a signal that raises breaks user creation everywhere | a forgotten call leaves a user without a profile |
The pragmatic middle ground: create profiles explicitly in your own code, and make reading
them impossible to break with a helper on the user or with
get_or_create:
def get_profile(user):
profile, _created = Profile.objects.get_or_create(user=user)
return profile
Whichever you choose, backfill the users that already exist with a data migration rather than by hand:
# accounts/migrations/0003_backfill_profiles.py
from django.db import migrations
def create_missing_profiles(apps, schema_editor):
User = apps.get_model("accounts", "User")
Profile = apps.get_model("accounts", "Profile")
for user in User.objects.filter(profile__isnull=True):
Profile.objects.create(user=user)
class Migration(migrations.Migration):
dependencies = [("accounts", "0002_profile")]
operations = [
migrations.RunPython(create_missing_profiles, migrations.RunPython.noop),
]
The popular snippet that calls instance.profile.save() on every user save
crashes with RelatedObjectDoesNotExist for any user created before the
model existed, and it writes to the database on every login (because
last_login triggers a save). Only create, only when
created is true.
Two models, two ModelForms, one template and one POST. Nothing clever is
needed - just validate both before you save either.
# accounts/forms.py
from django import forms
from django.contrib.auth import get_user_model
from .models import Profile
class UserForm(forms.ModelForm):
class Meta:
model = get_user_model()
fields = ("first_name", "last_name", "email")
class ProfileForm(forms.ModelForm):
class Meta:
model = Profile
fields = ("bio", "website", "avatar")
widgets = {"bio": forms.Textarea(attrs={"rows": 4})}
# accounts/views.py
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.shortcuts import redirect, render
from .forms import ProfileForm, UserForm
@login_required
@transaction.atomic
def profile_edit(request):
profile = get_profile(request.user)
if request.method == "POST":
user_form = UserForm(request.POST, instance=request.user)
profile_form = ProfileForm(request.POST, request.FILES, instance=profile)
if user_form.is_valid() and profile_form.is_valid():
user_form.save()
profile_form.save()
messages.success(request, "Your profile has been updated.")
return redirect("accounts:profile_edit")
else:
user_form = UserForm(instance=request.user)
profile_form = ProfileForm(instance=profile)
return render(
request,
"accounts/profile_edit.html",
{"user_form": user_form, "profile_form": profile_form},
)
<!-- templates/accounts/profile_edit.html -->
{% extends "base.html" %}
{% block content %}
<h1>Your profile</h1>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ user_form.as_p }}
{{ profile_form.as_p }}
<button type="submit">Save</button>
</form>
{% endblock %}
Forgetting enctype="multipart/form-data" on the form, and forgetting
request.FILES in the view. The page will save happily and the avatar will
stay empty.
The same pair of models is one inline in the admin:
# accounts/admin.py
class ProfileInline(admin.StackedInline):
model = Profile
can_delete = False
@admin.register(User)
class UserAdmin(BaseUserAdmin):
inlines = [ProfileInline]
Now put the profile to work: a page per author listing their posts. Use
select_related so the profile does not cost an extra query per row.
# blog/urls.py
path("author/<str:username>/", views.author_detail, name="author_detail"),
# blog/views.py
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404, render
def author_detail(request, username):
author = get_object_or_404(
get_user_model().objects.select_related("profile"), username=username
)
posts = author.posts.filter(published_date__isnull=False).order_by("-published_date")
return render(request, "blog/author_detail.html", {"author": author, "posts": posts})
<!-- templates/blog/author_detail.html -->
{% extends "base.html" %}
{% block content %}
{% with profile=author.profile %}
{% if profile.avatar %}
<img src="{{ profile.avatar.url }}" alt="{{ author.get_username }}" width="96">
{% endif %}
<h1>{{ profile.display_name }}</h1>
<p>{{ profile.bio|linebreaks }}</p>
{% if profile.website %}
<p><a href="{{ profile.website }}" rel="nofollow noopener">{{ profile.website }}</a></p>
{% endif %}
{% endwith %}
<h2>{{ posts|length }} post(s)</h2>
{% for post in posts %}
<h3><a href="{% url 'blog:post_detail' post.pk %}">{{ post.title }}</a></h3>
{% empty %}
<p>Nothing published yet.</p>
{% endfor %}
{% endblock %}
In the post list, link the author name to that page and prefetch in one go:
Post.objects.select_related("author__profile"). Without it, a list of twenty
posts fires twenty extra queries - the N+1 problem you will meet properly in module 7.
Profile model, install Pillow, configure MEDIA_ROOT
and serve media in development.media/avatars/.on_delete=models.CASCADE).assertNumQueries in a test to prove select_related removed the
extra queries.