Django learning is done. Today I started building a real, portfolio-worthy project called DevBoard, a developer job board where companies post jobs and developers find them. Day 73 was about laying the foundation right: project setup, a custom user model with two distinct roles, and a complete authentication system. Everything that comes after depends on getting this right first.
What is DevBoard?
DevBoard is a job board platform built specifically for developers. Two types of users:
- Employers — companies that post job listings and review applications
- Candidates — developers who browse listings, search by stack and location, and apply
By the end of day 77, DevBoard will be a fully deployed, working web application with both an HTML interface and a REST API.
Project Setup
python -m venv env
source env/bin/activate
pip install django djangorestframework python-decouple dj-database-url Pillow
django-admin startproject devboard
cd devboard
python manage.py startapp accounts
python manage.py startapp jobs
Two apps:
-
accounts— handles everything user-related: registration, login, profiles, roles -
jobs— handles job listings, applications, and search
Registered both in settings.py:
INSTALLED_APPS = [
...
'rest_framework',
'rest_framework.authtoken',
'accounts',
'jobs',
]
Environment setup right from day one:
# .env
SECRET_KEY=your-secret-key-here
DEBUG=True
DATABASE_URL=sqlite:///db.sqlite3
ALLOWED_HOSTS=localhost,127.0.0.1
# settings.py
from decouple import config, Csv
import dj_database_url
SECRET_KEY = config('SECRET_KEY')
DEBUG = config('DEBUG', cast=bool, default=False)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv())
DATABASES = {'default': dj_database_url.config(default=config('DATABASE_URL'))}
Custom User Model
This is the most important decision in any Django project, and it must be made before the first migration. Django's built-in User model works fine for basic projects, but once you need custom fields like a role, you need a custom user model.
If you start with Django's built-in User and try to swap it out later, it breaks migrations. Always set up a custom user model before running any migrations.
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
EMPLOYER = 'employer'
CANDIDATE = 'candidate'
ROLE_CHOICES = [
(EMPLOYER, 'Employer'),
(CANDIDATE, 'Candidate'),
]
role = models.CharField(max_length=20, choices=ROLE_CHOICES)
bio = models.TextField(blank=True)
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
location = models.CharField(max_length=100, blank=True)
def is_employer(self):
return self.role == self.EMPLOYER
def is_candidate(self):
return self.role == self.CANDIDATE
def __str__(self):
return f"{self.username} ({self.role})"
AbstractUser gives us everything Django's built-in user has: username, password, email, is_staff, is_active, plus the custom fields on top.
Tell Django to use this model instead of the default:
# settings.py
AUTH_USER_MODEL = 'accounts.User'
Now run the first migration:
python manage.py makemigrations
python manage.py migrate
Employer Profile
Employers need a company profile name, website, logo, description. This lives in a separate model linked to the User:
class EmployerProfile(models.Model):
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='employer_profile'
)
company_name = models.CharField(max_length=200)
company_website = models.URLField(blank=True)
company_logo = models.ImageField(upload_to='logos/', blank=True, null=True)
company_description = models.TextField(blank=True)
founded_year = models.IntegerField(blank=True, null=True)
def __str__(self):
return self.company_name
OneToOneField means one user has exactly one employer profile. related_name='employer_profile' lets you access it as user.employer_profile from anywhere.
Candidate Profile
Candidates have a different profile, tech stack, experience, resume:
class CandidateProfile(models.Model):
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='candidate_profile'
)
skills = models.TextField(help_text="Comma separated skills")
experience_years = models.IntegerField(default=0)
resume = models.FileField(upload_to='resumes/', blank=True, null=True)
portfolio_url = models.URLField(blank=True)
github_url = models.URLField(blank=True)
linkedin_url = models.URLField(blank=True)
def get_skills_list(self):
return [skill.strip() for skill in self.skills.split(',')]
def __str__(self):
return f"{self.user.username}'s profile"
get_skills_list() is a convenience method that splits a comma-separated list of skills into a Python list, useful for displaying tags in templates.
Auto-Creating Profiles with Signals
When a user registers, the corresponding profile should be created automatically based on their role:
# accounts/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import User, EmployerProfile, CandidateProfile
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
if instance.role == User.EMPLOYER:
EmployerProfile.objects.create(user=instance)
elif instance.role == User.CANDIDATE:
CandidateProfile.objects.create(user=instance)
# accounts/apps.py
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = 'accounts'
def ready(self):
import accounts.signals
Registration Forms
Two separate registration forms, one for each role:
# accounts/forms.py
from django import forms
from django.contrib.auth.forms import UserCreationForm
from .models import User
class EmployerRegistrationForm(UserCreationForm):
company_name = forms.CharField(max_length=200)
company_website = forms.URLField(required=False)
class Meta:
model = User
fields = ['username', 'email', 'password1', 'password2']
def save(self, commit=True):
user = super().save(commit=False)
user.role = User.EMPLOYER
if commit:
user.save()
user.employer_profile.company_name = self.cleaned_data['company_name']
user.employer_profile.company_website = self.cleaned_data.get('company_website', '')
user.employer_profile.save()
return user
class CandidateRegistrationForm(UserCreationForm):
skills = forms.CharField(
help_text="Enter your skills separated by commas",
widget=forms.TextInput(attrs={'placeholder': 'Python, Django, React'})
)
experience_years = forms.IntegerField(min_value=0, initial=0)
class Meta:
model = User
fields = ['username', 'email', 'password1', 'password2']
def save(self, commit=True):
user = super().save(commit=False)
user.role = User.CANDIDATE
if commit:
user.save()
user.candidate_profile.skills = self.cleaned_data['skills']
user.candidate_profile.experience_years = self.cleaned_data['experience_years']
user.candidate_profile.save()
return user
Both forms set role automatically on save; the user never selects it manually. Role is determined by which registration page they use.
Registration Views
# accounts/views.py
from django.shortcuts import render, redirect
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from .forms import EmployerRegistrationForm, CandidateRegistrationForm
def register_employer(request):
if request.method == 'POST':
form = EmployerRegistrationForm(request.POST)
if form.is_valid():
user = form.save()
login(request, user)
return redirect('employer_dashboard')
else:
form = EmployerRegistrationForm()
return render(request, 'accounts/register_employer.html', {'form': form})
def register_candidate(request):
if request.method == 'POST':
form = CandidateRegistrationForm(request.POST)
if form.is_valid():
user = form.save()
login(request, user)
return redirect('job_list')
else:
form = CandidateRegistrationForm()
return render(request, 'accounts/register_candidate.html', {'form': form})
After registration, employers go to their dashboard; candidates go to the job listings. The redirect is role-aware.
Role-Based Access Decorators
Instead of checking request.user.role in every view manually, custom decorators keep things clean:
# accounts/decorators.py
from django.shortcuts import redirect
from functools import wraps
def employer_required(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
if not request.user.is_authenticated:
return redirect('login')
if not request.user.is_employer():
return redirect('job_list')
return view_func(request, *args, **kwargs)
return wrapper
def candidate_required(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
if not request.user.is_authenticated:
return redirect('login')
if not request.user.is_candidate():
return redirect('job_list')
return view_func(request, *args, **kwargs)
return wrapper
Usage:
@employer_required
def post_job(request):
...
@candidate_required
def apply_for_job(request, pk):
...
URL Configuration
# accounts/urls.py
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
urlpatterns = [
path('register/employer/', views.register_employer, name='register_employer'),
path('register/candidate/', views.register_candidate, name='register_candidate'),
path('login/', auth_views.LoginView.as_view(template_name='accounts/login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
]
# devboard/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('accounts/', include('accounts.urls')),
path('', include('jobs.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Admin Setup
# accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User, EmployerProfile, CandidateProfile
@admin.register(User)
class CustomUserAdmin(UserAdmin):
list_display = ['username', 'email', 'role', 'is_active']
list_filter = ['role', 'is_active']
fieldsets = UserAdmin.fieldsets + (
('Role & Profile', {'fields': ('role', 'bio', 'avatar', 'location')}),
)
admin.site.register(EmployerProfile)
admin.site.register(CandidateProfile)
Where Things Stand After Day 73
The foundation is solid:
- Custom user model with employer and candidate roles
- Automatic profile creation on registration via signals
- Separate registration flows for each role
- Role-based decorators for protecting views
- Environment variables configured from day one
- Admin panel set up to manage users
Tomorrow: the employer side: posting jobs, managing listings, and viewing applications.
Top comments (0)