DEV Community

Cover image for Building a Project Budget Manager with Django - Part 2: Authentication and Models
ngemuantony
ngemuantony

Posted on • Edited on

Building a Project Budget Manager with Django - Part 2: Authentication and Models

Introduction

In this part, we'll set up user authentication and create our database models. We'll use Django's built-in authentication system and create custom models for our project management system. This guide assumes you're a beginner, so we'll explain each step in detail.

Setting Up Authentication

Django provides a robust authentication system out of the box. Let's configure it:

  1. First, ensure these apps are in your INSTALLED_APPS (they should be there by default):
# config/settings.py  INSTALLED_APPS = [ # ...  'django.contrib.auth', # Core authentication framework  'django.contrib.contenttypes', # Django content type system  # ... ] # Authentication settings LOGIN_REDIRECT_URL = 'dashboard' # Where to redirect after login LOGOUT_REDIRECT_URL = 'home' # Where to redirect after logout LOGIN_URL = 'login' # URL name for the login page 
Enter fullscreen mode Exit fullscreen mode
  1. Create user-related URLs:
# config/urls.py  from django.contrib import admin from django.urls import path, include from django.contrib.auth import views as auth_views urlpatterns = [ path('admin/', admin.site.urls), # Authentication URLs  path('login/', auth_views.LoginView.as_view( template_name='account/login.html', # Our custom login template  redirect_authenticated_user=True # Redirect if already logged in  ), name='login'), path('logout/', auth_views.LogoutView.as_view(), name='logout'), path('password-change/', auth_views.PasswordChangeView.as_view( template_name='account/password_change.html', success_url='/password-change/done/' ), name='password_change'), path('password-change/done/', auth_views.PasswordChangeDoneView.as_view( template_name='account/password_change_done.html' ), name='password_change_done'), # Our app URLs  path('', include('app.urls')), ] 
Enter fullscreen mode Exit fullscreen mode

Creating the Models

Let's create our database models. We'll need models for Projects, Categories, and Expenses:

# app/models.py  from django.db import models from django.conf import settings from django.urls import reverse from django.utils import timezone from decimal import Decimal class Category(models.Model): """ Categories for organizing expenses (e.g., 'Marketing', 'Development', 'Operations') Fields: name: The category name description: Optional description of the category created_at: When the category was created created_by: User who created the category """ name = models.CharField( max_length=100, unique=True, help_text="Enter a category name (e.g., Marketing)" ) description = models.TextField( blank=True, help_text="Optional: Provide more details about this category" ) created_at = models.DateTimeField( auto_now_add=True, help_text="When this category was created" ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, # Don't delete category if user is deleted  related_name='categories', help_text="User who created this category" ) class Meta: verbose_name_plural = "Categories" ordering = ['name'] # Sort alphabetically by name  def __str__(self): """String representation of the category""" return self.name def get_absolute_url(self): """Get the URL for this category's detail view""" return reverse('category_detail', kwargs={'pk': self.pk}) class Project(models.Model): """ Main project model for tracking budgets and expenses Fields: name: Project name description: Project description budget: Total budget allocated start_date: When the project starts end_date: When the project ends status: Current project status created_by: User who created the project team_members: Users assigned to this project """ # Status choices for projects  STATUS_CHOICES = [ ('planning', 'Planning'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ('on_hold', 'On Hold'), ('cancelled', 'Cancelled'), ] name = models.CharField( max_length=200, help_text="Enter the project name" ) description = models.TextField( help_text="Describe the project and its objectives" ) budget = models.DecimalField( max_digits=10, decimal_places=2, help_text="Total budget allocated for this project" ) start_date = models.DateField( help_text="When does this project start?" ) end_date = models.DateField( help_text="When should this project be completed?" ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, default='planning', help_text="Current status of the project" ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_projects', help_text="User who created this project" ) team_members = models.ManyToManyField( settings.AUTH_USER_MODEL, related_name='assigned_projects', blank=True, help_text="Users assigned to this project" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-created_at'] # Newest first  def __str__(self): """String representation of the project""" return f"{self.name} ({self.get_status_display()})" def get_absolute_url(self): """Get the URL for this project's detail view""" return reverse('project_detail', kwargs={'pk': self.pk}) def get_budget_remaining(self): """ Calculate remaining budget Returns: Decimal: Amount of budget remaining """ total_expenses = self.expenses.aggregate( total=models.Sum('amount') )['total'] or Decimal('0') return self.budget - total_expenses def is_over_budget(self): """Check if project has exceeded its budget""" return self.get_budget_remaining() < 0 def get_completion_percentage(self): """ Calculate project completion percentage based on expenses Returns: float: Percentage of budget used (0-100) """ if self.budget <= 0: return 0 used = (self.budget - self.get_budget_remaining()) / self.budget * 100 return min(used, 100) # Cap at 100%  class Expense(models.Model): """ Track individual expenses within a project Fields: project: Project this expense belongs to category: Type of expense description: What this expense is for amount: How much was spent date: When it was spent receipt: Optional receipt image """ project = models.ForeignKey( Project, on_delete=models.CASCADE, # Delete expenses if project is deleted  related_name='expenses', help_text="Project this expense belongs to" ) category = models.ForeignKey( Category, on_delete=models.PROTECT, related_name='expenses', help_text="Type of expense" ) description = models.TextField( help_text="What is this expense for?" ) amount = models.DecimalField( max_digits=10, decimal_places=2, help_text="Amount spent" ) date = models.DateField( default=timezone.now, help_text="When was this expense incurred?" ) receipt = models.ImageField( upload_to='receipts/', blank=True, null=True, help_text="Upload a receipt image (optional)" ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='expenses', help_text="User who recorded this expense" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-date', '-created_at'] # Most recent first  def __str__(self): """String representation of the expense""" return f"{self.category.name}: {self.amount} ({self.date})" def get_absolute_url(self): """Get the URL for this expense's detail view""" return reverse('expense_detail', kwargs={'pk': self.pk}) def is_recent(self): """Check if expense is from the last 7 days""" return timezone.now().date() - self.date <= timezone.timedelta(days=7) 
Enter fullscreen mode Exit fullscreen mode

Model Forms

Create forms for our models:

# app/forms.py  from django import forms from .models import Project, Expense, Category class ProjectForm(forms.ModelForm): """ Form for creating and editing projects Features: - Date picker widgets for start and end dates - Multi-select for team members - Custom validation for dates and budget """ class Meta: model = Project fields = [ 'name', 'description', 'budget', 'start_date', 'end_date', 'status', 'team_members' ] widgets = { # Use date picker widgets  'start_date': forms.DateInput(attrs={'type': 'date'}), 'end_date': forms.DateInput(attrs={'type': 'date'}), # Use multi-select for team members  'team_members': forms.SelectMultiple(attrs={ 'class': 'select2', # For enhanced select widget  }), } def clean(self): """ Custom validation: - End date must be after start date - Budget must be positive """ cleaned_data = super().clean() start_date = cleaned_data.get('start_date') end_date = cleaned_data.get('end_date') budget = cleaned_data.get('budget') if start_date and end_date and end_date < start_date: raise forms.ValidationError( "End date cannot be before start date" ) if budget and budget <= 0: raise forms.ValidationError( "Budget must be greater than zero" ) return cleaned_data class ExpenseForm(forms.ModelForm): """ Form for recording expenses Features: - Date picker for expense date - File upload for receipts - Custom validation for amount """ class Meta: model = Expense fields = [ 'project', 'category', 'description', 'amount', 'date', 'receipt' ] widgets = { 'date': forms.DateInput(attrs={'type': 'date'}), 'description': forms.Textarea(attrs={'rows': 3}), } def __init__(self, *args, **kwargs): """Initialize form with user's projects only""" user = kwargs.pop('user', None) super().__init__(*args, **kwargs) if user: # Show only projects the user is involved with  self.fields['project'].queryset = Project.objects.filter( models.Q(created_by=user) | models.Q(team_members=user) ).distinct() def clean_amount(self): """Ensure expense amount is positive""" amount = self.cleaned_data.get('amount') if amount and amount <= 0: raise forms.ValidationError( "Expense amount must be greater than zero" ) return amount 
Enter fullscreen mode Exit fullscreen mode

Admin Interface

Customize the admin interface for our models:

# app/admin.py  from django.contrib import admin from .models import Project, Expense, Category @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): """Admin interface for categories""" list_display = ['name', 'created_by', 'created_at'] search_fields = ['name', 'description'] list_filter = ['created_at'] @admin.register(Project) class ProjectAdmin(admin.ModelAdmin): """Admin interface for projects""" list_display = [ 'name', 'status', 'budget', 'start_date', 'end_date', 'created_by' ] list_filter = ['status', 'start_date', 'end_date'] search_fields = ['name', 'description'] filter_horizontal = ['team_members'] # Custom columns  def get_budget_used(self, obj): """Show percentage of budget used""" return f"{obj.get_completion_percentage():.1f}%" get_budget_used.short_description = "Budget Used" @admin.register(Expense) class ExpenseAdmin(admin.ModelAdmin): """Admin interface for expenses""" list_display = [ 'project', 'category', 'amount', 'date', 'created_by' ] list_filter = ['project', 'category', 'date'] search_fields = [ 'description', 'project__name', 'category__name' ] date_hierarchy = 'date' 
Enter fullscreen mode Exit fullscreen mode

Migrations

After creating our models, we need to create and apply migrations:

# Create migrations python manage.py makemigrations app # Review the migrations (optional but recommended) python manage.py sqlmigrate app 0001 # Apply migrations python manage.py migrate 
Enter fullscreen mode Exit fullscreen mode

Testing the Models

Create some basic tests:

# app/tests/test_models.py  from django.test import TestCase from django.contrib.auth import get_user_model from django.utils import timezone from decimal import Decimal from ..models import Project, Category, Expense class ProjectModelTests(TestCase): """Test cases for the Project model""" def setUp(self): """Set up test data""" # Create a test user  self.user = get_user_model().objects.create_user( username='testuser', password='testpass123' ) # Create a test project  self.project = Project.objects.create( name='Test Project', description='A test project', budget=Decimal('1000.00'), start_date=timezone.now().date(), end_date=timezone.now().date(), created_by=self.user ) # Create a test category  self.category = Category.objects.create( name='Test Category', created_by=self.user ) def test_project_budget_calculations(self): """Test budget calculations""" # Create some expenses  Expense.objects.create( project=self.project, category=self.category, amount=Decimal('300.00'), description='Test Expense 1', created_by=self.user ) Expense.objects.create( project=self.project, category=self.category, amount=Decimal('200.00'), description='Test Expense 2', created_by=self.user ) # Test calculations  self.assertEqual( self.project.get_budget_remaining(), Decimal('500.00') ) self.assertEqual( self.project.get_completion_percentage(), 50.0 ) self.assertFalse(self.project.is_over_budget()) 
Enter fullscreen mode Exit fullscreen mode

Next Steps

In Part 3, we'll create views and templates to interact with our models. We'll build:

  • Project listing and detail views
  • Forms for creating and editing projects
  • Expense tracking interface
  • Dashboard with budget summaries

Common Issues and Solutions

  1. Migration conflicts

    • Delete all migrations and start fresh
    • Use python manage.py migrate --fake if needed
  2. Related name conflicts

    • Ensure unique related_name for each relationship
    • Use descriptive names like 'created_projects'
  3. Form validation errors

    • Check form.errors in views
    • Use clean() method for cross-field validation

Additional Resources


This article is part of the "Building a Project Budget Manager with Django" series. Check out Part 1 if you haven't already!

Top comments (0)