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
  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')),
]

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)

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

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'

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

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

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!