← Alle guider

Django — Komplet guide til Python webudvikling

En praktisk, begyndervenlig guide til at bygge webapps med Django.


Indholdsfortegnelse

  1. Hvad er Django?
  2. Hvornår skal du bruge det?
  3. Kom i gang
  4. Projektstruktur
  5. Models
  6. Views
  7. Templates
  8. Forms
  9. URL routing
  10. Admin panel
  11. REST API
  12. Authentication
  13. Deployment
  14. Database
  15. Pris
  16. Fordele og ulemper
  17. Eksempel-projekt

1. Hvad er Django?

Django er et Python webframework der følger "batteries included"-filosofien. Det betyder at alt du behøver for at bygge en komplet webapp er inkluderet fra starten: database ORM, admin panel, authentication, formularhåndtering, URL routing, og meget mere.

Store virksomheder der bruger Django:

Hvorfor Python?

Python er et af de mest populære programmeringssprog i verden. Det er læsbart, alsidigt og har et enormt økosystem af pakker. Django bygger videre på dette ved at give en struktureret måde at bygge webapps på.

Kernekoncepter:


2. Hvornår skal du bruge det?

Django er ideelt til:

Anvendelsesområde Beskrivelse
REST APIs Byg backend APIs til mobilapps og frontend frameworks
CMS Indholdsstyringssystemer (brug Django CMS eller Wagtail)
Data-tunge apps Apps der håndterer store mængder data og relationer
Admin panels Hurtig oprettelse af administrationsflader
SaaS produkter Multi-tenant applikationer med brugerhåndtering
E-commerce Webshops (brug django-oscar eller Saleor)
Dashboarder Datavisualisering og rapporteringsværktøjer

Hvornår skal du IKKE bruge det?


3. Kom i gang

Forudsætninger

Trin 1: Installer Python

# Windows (hvis ikke installeret)
    winget install Python.Python.3.12

    # macOS
    brew install python@3.12

    # Linux (Ubuntu/Debian)
    sudo apt update && sudo apt install python3.12 python3.12-venv python3-pip
    

Trin 2: Opret virtuelt miljø

# Opret projektmappe
    mkdir mit-projekt && cd mit-projekt

    # Opret virtuelt miljø
    python -m venv venv

    # Aktiver miljøet
    # Windows
    venv\Scripts\activate
    # macOS/Linux
    source venv/bin/activate
    

Trin 3: Installer Django

pip install django
    

Trin 4: Opret dit første projekt

django-admin startproject mysite .
    

Trin 5: Kør serveren

python manage.py runserver
    

Åbn http://127.0.0.1:8000/ i din browser. Tillykke — dit første Django-site kører!

Trin 6: Opret en app

python manage.py startapp blog
    

Tilføj appen til INSTALLED_APPS i settings.py:

INSTALLED_APPS = [
        'django.contrib.admin',
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'django.contrib.sessions',
        'django.contrib.messages',
        'django.contrib.staticfiles',
        'blog',  # <-- tilføj din app her
    ]
    

4. Projektstruktur

myproject/
    ├── mysite/
    │   ├── __init__.py
    │   ├── settings.py      # Konfiguration
    │   ├── urls.py          # Route-definitioner (top-level)
    │   ├── wsgi.py          # WSGI entry point til deployment
    │   └── asgi.py          # ASGI entry point (async support)
    ├── blog/
    │   ├── __init__.py
    │   ├── admin.py         # Admin panel registrering
    │   ├── apps.py          # App konfiguration
    │   ├── models.py        # Database modeller
    │   ├── views.py         # View funktioner/klasse
    │   ├── urls.py          # App-specifikke routes
    │   ├── tests.py         # Tests
    │   └── migrations/      # Database migrations
    │       └── __init__.py
    ├── manage.py            # Kommandolinje-værktøj
    └── venv/                # Virtuelt miljø
    

Nøglefiler forklaret

settings.py — Central konfigurationsfil:

# settings.py (udsnit)

    SECRET_KEY = 'din-hemmelige-nøgle-her'  # Brug env variabel i produktion!

    DEBUG = True  # Sæt til False i produktion

    ALLOWED_HOSTS = []

    INSTALLED_APPS = [
        'django.contrib.admin',
        'django.contrib.auth',
        'django.contrib.contenttypes',
        'django.contrib.sessions',
        'django.contrib.messages',
        'django.contrib.staticfiles',
        'blog',
    ]

    # Database (SQLite som standard)
    DATABASES = {
        'default': {
            'ENGINE': 'django.db.backends.sqlite3',
            'NAME': BASE_DIR / 'db.sqlite3',
        }
    }

    # Statiske filer
    STATIC_URL = 'static/'
    STATIC_ROOT = BASE_DIR / 'staticfiles'

    # Media filer (bruger-uploads)
    MEDIA_URL = 'media/'
    MEDIA_ROOT = BASE_DIR / 'media'
    

manage.py — Dit primære værktøj:

python manage.py runserver          # Start udviklingsserver
    python manage.py makemigrations    # Opret migrations
    python manage.py migrate           # Anvend migrations
    python manage.py createsuperuser   # Opret admin bruger
    python manage.py shell             # Interaktiv Python shell
    python manage.py collectstatic     # Samle statiske filer
    

5. Models

Models definerer din databasestruktur. Django ORM oversætter Python-koder til SQL.

Grundlæggende model

# blog/models.py

    from django.db import models
    from django.contrib.auth.models import User

    class Category(models.Model):
        name = models.CharField(max_length=100)
        slug = models.SlugField(unique=True)
        created_at = models.DateTimeField(auto_now_add=True)

        class Meta:
            verbose_name_plural = 'categories'
            ordering = ['name']

        def __str__(self):
            return self.name


    class Post(models.Model):
        STATUS_CHOICES = [
            ('draft', 'Kladde'),
            ('published', 'Udgivet'),
        ]

        title = models.CharField(max_length=200)
        slug = models.SlugField(unique_for_date='published_at')
        author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
        category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
        body = models.TextField()
        excerpt = models.TextField(blank=True, max_length=300)
        featured_image = models.ImageField(upload_to='posts/%Y/%m/', blank=True, null=True)
        status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft')
        published_at = models.DateTimeField(blank=True, null=True)
        created_at = models.DateTimeField(auto_now_add=True)
        updated_at = models.DateTimeField(auto_now=True)

        class Meta:
            ordering = ['-published_at']
            indexes = [
                models.Index(fields=['-published_at']),
                models.Index(fields=['slug']),
            ]

        def __str__(self):
            return self.title

        def get_absolute_url(self):
            return f'/blog/{self.slug}/'
    

Field-typer

Field Brug Eksempel
CharField Kort tekst models.CharField(max_length=100)
TextField Lang tekst models.TextField()
IntegerField Heltal models.IntegerField(default=0)
FloatField Decimaltal models.FloatField()
BooleanField Sandt/falsk models.BooleanField(default=False)
DateField Dato models.DateField()
DateTimeField Dato + tid models.DateTimeField(auto_now_add=True)
EmailField E-mail models.EmailField()
URLField URL models.URLField()
SlugField URL-venlig streng models.SlugField(unique=True)
ImageField Billede models.ImageField(upload_to='images/')
FileField Fil models.FileField(upload_to='files/')
JSONField JSON data models.JSONField(default=dict)
ForeignKey Many-to-one relation models.ForeignKey(User, on_delete=models.CASCADE)
ManyToManyField Many-to-many relation models.ManyToManyField('Tag')
OneToOneField En-til-en relation models.OneToOneField(User, on_delete=models.CASCADE)

Migrations

# Når du ændrer en model:
    python manage.py makemigrations blog

    # Se migrations-historikken:
    python manage.py sqlmigrate blog 0001

    # Anvend migrations:
    python manage.py migrate
    

Model metoder

class Post(models.Model):
        # ... felter ovenfor ...

        def save(self, *args, **kwargs):
            # Custom logik før gem
            if not self.slug:
                self.slug = slugify(self.title)
            super().save(*args, **kwargs)

        @property
        def reading_time(self):
            """Beregn læsetid i minutter."""
            words = len(self.body.split())
            return max(1, words // 200)

        @property
        def comment_count(self):
            return self.comments.count()

        def get_related_posts(self, limit=3):
            return Post.objects.filter(
                category=self.category,
                status='published'
            ).exclude(id=self.id)[:limit]
    

6. Views

Views håndterer logikken for hver URL og returnerer svar.

Function-Based Views (FBV)

# blog/views.py

    from django.shortcuts import render, get_object_or_404, redirect
    from django.http import JsonResponse
    from django.contrib.auth.decorators import login_required
    from .models import Post, Category

    def post_list(request):
        posts = Post.objects.filter(status='published')
        return render(request, 'blog/post_list.html', {'posts': posts})

    def post_detail(request, slug):
        post = get_object_or_404(Post, slug=slug, status='published')
        return render(request, 'blog/post_detail.html', {'post': post})

    @login_required
    def post_create(request):
        if request.method == 'POST':
            form = PostForm(request.POST, request.FILES)
            if form.is_valid():
                post = form.save(commit=False)
                post.author = request.user
                post.save()
                return redirect('post_detail', slug=post.slug)
        else:
            form = PostForm()
        return render(request, 'blog/post_form.html', {'form': form})

    def api_posts(request):
        posts = Post.objects.filter(status='published').values(
            'id', 'title', 'slug', 'excerpt', 'published_at'
        )
        return JsonResponse({'posts': list(posts)})
    

Class-Based Views (CBV)

from django.views.generic import ListView, DetailView, CreateView, UpdateView
    from django.contrib.auth.mixins import LoginRequiredMixin
    from django.urls import reverse_lazy
    from .models import Post

    class PostListView(ListView):
        model = Post
        template_name = 'blog/post_list.html'
        context_object_name = 'posts'
        paginate_by = 10

        def get_queryset(self):
            return Post.objects.filter(status='published')


    class PostDetailView(DetailView):
        model = Post
        template_name = 'blog/post_detail.html'
        slug_field = 'slug'
        slug_url_kwarg = 'slug'


    class PostCreateView(LoginRequiredMixin, CreateView):
        model = Post
        template_name = 'blog/post_form.html'
        fields = ['title', 'slug', 'category', 'body', 'excerpt', 'featured_image']

        def form_valid(self, form):
            form.instance.author = self.request.user
            return super().form_valid(form)


    class PostUpdateView(LoginRequiredMixin, UpdateView):
        model = Post
        template_name = 'blog/post_form.html'
        fields = ['title', 'category', 'body', 'excerpt', 'featured_image']
    

Asynkrone views (Django 4.1+)

import httpx
    from django.http import JsonResponse

    async def fetch_external_data(request):
        async with httpx.AsyncClient() as client:
            response = await client.get('https://api.example.com/data')
            data = response.json()
        return JsonResponse(data)
    

7. Templates

Django Template Language (DTL) bruges til HTML-skabeloner.

Grundlæggende syntax

<!-- blog/templates/blog/base.html -->

    <!DOCTYPE html>
    <html lang="da">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{% block title %}Min Blog{% endblock %}</title>
        {% load static %}
        <link rel="stylesheet" href="{% static 'css/style.css' %}">
    </head>
    <body>
        <nav>
            <a href="{% url 'home' %}">Hjem</a>
            <a href="{% url 'post_list' %}">Blog</a>
            {% if user.is_authenticated %}
                <a href="{% url 'post_create' %}">Skriv indlæg</a>
                <a href="{% url 'logout' %}">Log ud</a>
            {% else %}
                <a href="{% url 'login' %}">Log ind</a>
            {% endif %}
        </nav>

        <main>
            {% block content %}{% endblock %}
        </main>

        <footer>
            <p>&copy; 2026 Min Blog. Lavet med Django.</p>
        </footer>

        <script src="{% static 'js/main.js' %}"></script>
    </body>
    </html>
    
<!-- blog/templates/blog/post_list.html -->

    {% extends 'blog/base.html' %}

    {% block title %}Blogindlæg — Min Blog{% endblock %}

    {% block content %}
    <h1>Seneste indlæg</h1>

    {% for post in posts %}
    <article class="post-card">
        <h2><a href="{% url 'post_detail' post.slug %}">{{ post.title }}</a></h2>
        <div class="meta">
            Af {{ post.author.get_full_name }} · {{ post.published_at|date:"d. M Y" }}
            · {{ post.reading_time }} minutters læsetid
        </div>
        <p>{{ post.excerpt }}</p>
        {% if post.featured_image %}
            <img src="{{ post.featured_image.url }}" alt="{{ post.title }}">
        {% endif %}
    </article>
    {% empty %}
    <p>Ingen indlæg endnu.</p>
    {% endfor %}

    {% if is_paginated %}
    <div class="pagination">
        {% if page_obj.has_previous %}
            <a href="?page={{ page_obj.previous_page_number }}">Forrige</a>
        {% endif %}
        <span>Side {{ page_obj.number }} af {{ page_obj.paginator.num_pages }}</span>
        {% if page_obj.has_next %}
            <a href="?page={{ page_obj.next_page_number }}">Næste</a>
        {% endif %}
    </div>
    {% endif %}
    {% endblock %}
    

Template filters og tags

<!-- Filtrering -->
    {{ post.title|upper }}                    <!-- STORE BOGSTAVER -->
    {{ post.body|truncatewords:30 }}          <!-- Afkort til 30 ord -->
    {{ post.body|linebreaksbr }}              <!-- Linjeskift til <br> -->
    {{ post.created_at|date:"d. F Y" }}      <!-- Datoformatering -->
    {{ value|default:"Ingen værdi" }}         <!-- Standardværdi -->
    {{ text|safe }}                           <!-- Undgå HTML-escaping -->
    {{ list|length }}                         <!-- Længde af liste -->

    <!-- Tags -->
    {% if post.featured %}
        <span class="badge">Udvalgt</span>
    {% endif %}

    {% for tag in post.tags.all %}
        <span class="tag">{{ tag.name }}</span>
    {% empty %}
        <span>Ingen tags</span>
    {% endfor %}

    {% url 'post_detail' post.slug as post_url %}
    <a href="{{ post_url }}">Del link</a>
    

Statiske filer

{% load static %}

    <link rel="stylesheet" href="{% static 'css/style.css' %}">
    <script src="{% static 'js/main.js' %}"></script>
    <img src="{% static 'images/logo.png' %}" alt="Logo">
    
myproject/
    ├── static/
    │   ├── css/
    │   │   └── style.css
    │   ├── js/
    │   │   └── main.js
    │   └── images/
    │       └── logo.png
    

8. Forms

ModelForm

# blog/forms.py

    from django import forms
    from .models import Post, Comment

    class PostForm(forms.ModelForm):
        class Meta:
            model = Post
            fields = ['title', 'slug', 'category', 'body', 'excerpt', 'featured_image']
            widgets = {
                'title': forms.TextInput(attrs={
                    'class': 'form-control',
                    'placeholder': 'Indlægstitel'
                }),
                'slug': forms.TextInput(attrs={
                    'class': 'form-control'
                }),
                'body': forms.Textarea(attrs={
                    'class': 'form-control',
                    'rows': 10
                }),
                'excerpt': forms.Textarea(attrs={
                    'class': 'form-control',
                    'rows': 3
                }),
            }

        def clean_title(self):
            title = self.cleaned_data.get('title')
            if len(title) < 5:
                raise forms.ValidationError('Titlen skal være mindst 5 tegn.')
            return title


    class CommentForm(forms.ModelForm):
        class Meta:
            model = Comment
            fields = ['body']
            widgets = {
                'body': forms.Textarea(attrs={
                    'class': 'form-control',
                    'rows': 4,
                    'placeholder': 'Skriv din kommentar...'
                }),
            }
    

Brug af forms i views

from django.shortcuts import render, redirect
    from django.contrib import messages
    from .forms import PostForm, CommentForm

    def post_create(request):
        if request.method == 'POST':
            form = PostForm(request.POST, request.FILES)
            if form.is_valid():
                post = form.save(commit=False)
                post.author = request.user
                post.save()
                messages.success(request, 'Indlægget blev oprettet!')
                return redirect('post_detail', slug=post.slug)
        else:
            form = PostForm()
        return render(request, 'blog/post_form.html', {'form': form})

    def post_detail(request, slug):
        post = get_object_or_404(Post, slug=slug)
        comments = post.comments.all()

        if request.method == 'POST':
            comment_form = CommentForm(request.POST)
            if comment_form.is_valid():
                comment = comment_form.save(commit=False)
                comment.post = post
                comment.author = request.user
                comment.save()
                messages.success(request, 'Kommentaren blev tilføjet!')
                return redirect('post_detail', slug=slug)
        else:
            comment_form = CommentForm()

        return render(request, 'blog/post_detail.html', {
            'post': post,
            'comments': comments,
            'comment_form': comment_form,
        })
    

Crispy Forms (ekstern pakke)

pip install django-crispy-forms crispy-bootstrap5
    
# settings.py
    INSTALLED_APPS += ['crispy_forms', 'crispy_bootstrap5']
    CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
    CRISPY_TEMPLATE_PACK = 'bootstrap5'
    
{% load crispy_forms_tags %}

    <form method="post" enctype="multipart/form-data">
        {% csrf_token %}
        {{ form|crispy }}
        <button type="submit" class="btn btn-primary">Gem</button>
    </form>
    

Validering

class ContactForm(forms.Form):
        name = forms.CharField(max_length=100)
        email = forms.EmailField()
        message = forms.CharField(widget=forms.Textarea)

        def clean_email(self):
            email = self.cleaned_data.get('email')
            if not email.endswith(('.com', '.dk')):
                raise forms.ValidationError('Kun .com og .dk domæner accepteres.')
            return email

        def clean(self):
            cleaned_data = super().clean()
            name = cleaned_data.get('name')
            message = cleaned_data.get('message')
            if name and message and name.lower() in message.lower():
                raise forms.ValidationError('Dit navn må ikke forekomme i beskeden.')
            return cleaned_data
    

9. URL routing

Projekt-urls (mysite/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('', include('blog.urls', namespace='blog')),
        path('accounts/', include('django.contrib.auth.urls')),
    ]

    if settings.DEBUG:
        urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
    

App-urls (blog/urls.py)

from django.urls import path
    from . import views

    app_name = 'blog'

    urlpatterns = [
        path('', views.post_list, name='post_list'),
        path('opret/', views.post_create, name='post_create'),
        path('<slug:slug>/', views.post_detail, name='post_detail'),
        path('<slug:slug>/rediger/', views.post_update, name='post_update'),
        path('<slug:slug>/slet/', views.post_delete, name='post_delete'),
    ]
    

Path converters

from django.urls import path

    urlpatterns = [
        # Int
        path('post/<int:id>/', views.post_detail),

        # Slug (bogstaver, tal, bindestreger, understreger)
        path('blog/<slug:slug>/', views.post_detail),

        # UUID
        path('api/<uuid:item_id>/', views.item_detail),

        # Path (matchmere end et segment)
        path('archive/<path:filepath>/', views.archive_detail),
    ]
    

Resolver og URL navngivning

<!-- I templates -->
    {% url 'blog:post_list' %}
    {% url 'blog:post_detail' post.slug %}
    
# I views
    from django.urls import reverse

    def post_create(request):
        # ...
        return redirect(reverse('blog:post_detail', kwargs={'slug': post.slug}))
    

10. Admin panel

Django's admin panel er en af dets kraftigste features. Det genererer automatisk en administrationsflade fra dine modeller.

Grundlæggende registrering

# blog/admin.py

    from django.contrib import admin
    from .models import Post, Category, Comment

    @admin.register(Post)
    class PostAdmin(admin.ModelAdmin):
        list_display = ['title', 'author', 'category', 'status', 'published_at']
        list_filter = ['status', 'category', 'author']
        search_fields = ['title', 'body']
        prepopulated_fields = {'slug': ('title',)}
        raw_id_fields = ['author']
        date_hierarchy = 'published_at'
        ordering = ['-published_at']
        list_editable = ['status']
        list_per_page = 25

        actions = ['make_published', 'make_draft']

        @admin.action(description='Marker som udgivet')
        def make_published(self, request, queryset):
            updated = queryset.update(status='published')
            self.message_user(request, f'{updated} indlæg blev markeret som udgivet.')

        @admin.action(description='Marker som kladde')
        def make_draft(self, request, queryset):
            updated = queryset.update(status='draft')
            self.message_user(request, f'{updated} indlæg blev markeret som kladde.')


    @admin.register(Category)
    class CategoryAdmin(admin.ModelAdmin):
        list_display = ['name', 'slug', 'created_at']
        prepopulated_fields = {'slug': ('name',)}


    @admin.register(Comment)
    class CommentAdmin(admin.ModelAdmin):
        list_display = ['author', 'post', 'created_at', 'is_approved']
        list_filter = ['is_approved', 'created_at']
        list_editable = ['is_approved']
        actions = ['approve_comments']

        @admin.action(description='Godkend valgte kommentarer')
        def approve_comments(self, request, queryset):
            updated = queryset.update(is_approved=True)
            self.message_user(request, f'{updated} kommentarer blev godkendt.')
    

Inline editing

class CommentInline(admin.TabularInline):
        model = Comment
        extra = 0
        readonly_fields = ['author', 'body', 'created_at']

    @admin.register(Post)
    class PostAdmin(admin.ModelAdmin):
        # ... ovenstående konfiguration ...
        inlines = [CommentInline]
    

Custom admin site

# mysite/admin.py

    from django.contrib.admin import AdminSite

    class MyAdminSite(AdminSite):
        site_header = 'Min Blog Administration'
        site_title = 'Min Blog Admin'
        index_title = 'Velkommen til administrationen'

    # Brug i urls.py:
    # from mysite.admin import MyAdminSite
    # admin_site = MyAdminSite(name='admin')
    

Opret superuser

python manage.py createsuperuser
    

Følg promptsene for at angive brugernavn, e-mail og adgangskode. Log ind på http://127.0.0.1:8000/admin/.


11. REST API

Django REST Framework (DRF) er den de facto standard til at bygge APIs med Django.

Installation

pip install djangorestframework
    
# settings.py
    INSTALLED_APPS += [
        'rest_framework',
        'rest_framework.authtoken',  # Token-baseret auth
    ]
    

Serializers

# blog/serializers.py

    from rest_framework import serializers
    from .models import Post, Category, Comment

    class CategorySerializer(serializers.ModelSerializer):
        post_count = serializers.SerializerMethodField()

        class Meta:
            model = Category
            fields = ['id', 'name', 'slug', 'post_count']

        def get_post_count(self, obj):
            return obj.post_set.filter(status='published').count()


    class PostListSerializer(serializers.ModelSerializer):
        author_name = serializers.CharField(source='author.get_full_name', read_only=True)
        category_name = serializers.CharField(source='category.name', read_only=True)

        class Meta:
            model = Post
            fields = [
                'id', 'title', 'slug', 'author_name', 'category_name',
                'excerpt', 'featured_image', 'published_at', 'reading_time'
            ]


    class PostDetailSerializer(serializers.ModelSerializer):
        author_name = serializers.CharField(source='author.get_full_name', read_only=True)
        category = CategorySerializer(read_only=True)
        comments = serializers.SerializerMethodField()

        class Meta:
            model = Post
            fields = [
                'id', 'title', 'slug', 'author_name', 'category',
                'body', 'excerpt', 'featured_image', 'published_at',
                'created_at', 'updated_at', 'reading_time', 'comments'
            ]

        def get_comments(self, obj):
            comments = obj.comments.filter(is_approved=True)
            return CommentSerializer(comments, many=True).data


    class CommentSerializer(serializers.ModelSerializer):
        author_name = serializers.CharField(source='author.get_full_name', read_only=True)

        class Meta:
            model = Comment
            fields = ['id', 'author_name', 'body', 'created_at']
            read_only_fields = ['author']
    

Views (ViewSets)

# blog/api_views.py

    from rest_framework import viewsets, permissions, status
    from rest_framework.decorators import action
    from rest_framework.response import Response
    from .models import Post, Category
    from .serializers import (
        PostListSerializer, PostDetailSerializer,
        CategorySerializer
    )
    from .forms import PostForm


    class IsAuthorOrReadOnly(permissions.BasePermission):
        def has_object_permission(self, request, view, obj):
            if request.method in permissions.SAFE_METHODS:
                return True
            return obj.author == request.user


    class PostViewSet(viewsets.ModelViewSet):
        queryset = Post.objects.filter(status='published')
        permission_classes = [IsAuthorOrReadOnly]
        lookup_field = 'slug'

        def get_serializer_class(self):
            if self.action == 'list':
                return PostListSerializer
            return PostDetailSerializer

        def perform_create(self, serializer):
            serializer.save(author=self.request.user)

        @action(detail=False, methods=['get'])
        def mine(self, request):
            posts = Post.objects.filter(author=request.user)
            serializer = PostListSerializer(posts, many=True)
            return Response(serializer.data)

        @action(detail=True, methods=['post'])
        def like(self, request, slug=None):
            post = self.get_object()
            # Tilføj like-logik her
            return Response({'status': 'liked'})


    class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
        queryset = Category.objects.all()
        serializer_class = CategorySerializer
    

URLs

# mysite/api_urls.py

    from django.urls import path, include
    from rest_framework.routers import DefaultRouter
    from blog.api_views import PostViewSet, CategoryViewSet

    router = DefaultRouter()
    router.register(r'posts', PostViewSet)
    router.register(r'categories', CategoryViewSet)

    urlpatterns = [
        path('', include(router.urls)),
    ]
    
# mysite/urls.py (tilføj)

    urlpatterns = [
        path('admin/', admin.site.urls),
        path('api/', include('mysite.api_urls')),
        path('api-auth/', include('rest_framework.urls')),  # Browsable API
    ]
    

API endpoints

Endpoint Metode Beskrivelse
/api/posts/ GET Liste alle indlæg
/api/posts/ POST Opret nyt indlæg
/api/posts/{slug}/ GET Detaljeret indlæg
/api/posts/{slug}/ PUT Opdater indlæg
/api/posts/{slug}/ DELETE Slet indlæg
/api/posts/mine/ GET Dine egne indlæg
/api/posts/{slug}/like/ POST Like et indlæg
/api/categories/ GET Liste alle kategorier

Test din API

# Brug httpie eller curl
    pip install httpie

    # Hent alle indlæg
    http GET http://127.0.0.1:8000/api/posts/

    # Opret et indlæg
    http POST http://127.0.0.1:8000/api/posts/ \
        title="Mit første API-indlæg" \
        body="Dette er indholdet" \
        category:=1
    

12. Authentication

Bruger model

Django inkluderer en indbygget User model. For custom brugere:

# accounts/models.py

    from django.contrib.auth.models import AbstractUser
    from django.db import models

    class CustomUser(AbstractUser):
        bio = models.TextField(blank=True)
        avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
        website = models.URLField(blank=True)

        def __str__(self):
            return self.username
    
# settings.py
    AUTH_USER_MODEL = 'accounts.CustomUser'
    

Login/Logout views

# accounts/views.py

    from django.shortcuts import render, redirect
    from django.contrib.auth import login, logout, authenticate
    from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
    from django.contrib import messages

    def register_view(request):
        if request.method == 'POST':
            form = UserCreationForm(request.POST)
            if form.is_valid():
                user = form.save()
                login(request, user)
                messages.success(request, 'Konto oprettet!')
                return redirect('home')
        else:
            form = UserCreationForm()
        return render(request, 'accounts/register.html', {'form': form})

    def login_view(request):
        if request.method == 'POST':
            form = AuthenticationForm(request, data=request.POST)
            if form.is_valid():
                user = form.get_user()
                login(request, user)
                messages.success(request, f'Velkommen tilbage, {user.username}!')
                return redirect('home')
        else:
            form = AuthenticationForm()
        return render(request, 'accounts/login.html', {'form': form})

    def logout_view(request):
        logout(request)
        messages.info(request, 'Du er nu logget ud.')
        return redirect('home')
    

Permissions

from rest_framework import permissions

    class IsAuthorOrReadOnly(permissions.BasePermission):
        def has_object_permission(self, request, view, obj):
            if request.method in permissions.SAFE_METHODS:
                return True
            return obj.author == request.user

    class IsAdminOrReadOnly(permissions.BasePermission):
        def has_permission(self, request, view):
            if request.method in permissions.SAFE_METHODS:
                return True
            return request.user and request.user.is_staff
    

Brug i views:

from rest_framework.permissions import IsAuthenticated, IsAdminUser

    class PostViewSet(viewsets.ModelViewSet):
        permission_classes = [IsAuthenticated | IsAdminOrReadOnly]
    

JWT Tokens (med djangorestframework-simplejwt)

pip install djangorestframework-simplejwt
    
# settings.py
    REST_FRAMEWORK = {
        'DEFAULT_AUTHENTICATION_CLASSES': (
            'rest_framework_simplejwt.authentication.JWTAuthentication',
        ),
    }

    from datetime import timedelta

    SIMPLE_JWT = {
        'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
        'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
        'ROTATE_REFRESH_TOKENS': True,
    }
    
# mysite/urls.py
    from rest_framework_simplejwt.views import (
        TokenObtainPairView,
        TokenRefreshView,
    )

    urlpatterns = [
        # ...
        path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
        path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    ]
    

Anvendelse i praksis:

# Login og få token
    http POST http://127.0.0.1:8000/api/token/ \
        username="brugernavn" \
        password="adgangskode"

    # Brug token til beskyttede endpoints
    http GET http://127.0.0.1:8000/api/posts/mine/ \
        Authorization:"Bearer <access_token>"
    

13. Deployment

Railway (gratis tier)

Railway tilbyder en gratis tier med $5 kredit om måneden.

  1. Opret konto på https://railway.app
  2. Installer Railway CLI:
npm install -g @railway/cli
    railway login
    
  1. Opret Procfile:
web: gunicorn mysite.wsgi
    
  1. Opret requirements.txt:
pip freeze > requirements.txt
    
  1. Opret runtime.txt:
python-3.12.3
    
  1. Deploy:
railway init
    railway up
    
  1. Tilføj miljøvariabler i Railway dashboard:
SECRET_KEY=din-hemmelige-nøgle
    DEBUG=False
    DATABASE_URL=postgres://...
    

DigitalOcean

DigitalOcean App Platform (~$5/måned):

  1. Opret konto på https://www.digitalocean.com
  2. Opret en App via dashboard
  3. Kobl dit GitHub repo
  4. Tilføj environment variables
  5. Deploy automatisk

Gunicorn + Nginx (VPS)

Til en VPS (f.eks. DigitalOcean Droplet ~$4/måned):

# Installer Gunicorn
    pip install gunicorn

    # Opret gunicorn config
    # gunicorn.conf.py
    bind = '127.0.0.1:8000'
    workers = 3
    worker_class = 'gthread'
    threads = 2
    
# Kør Gunicorn
    gunicorn mysite.wsgi
    

Nginx config (/etc/nginx/sites-available/mysite):

server {
        listen 80;
        server_name dit-domæne.dk;

        location /static/ {
            alias /home/bruger/mysite/staticfiles/;
        }

        location /media/ {
            alias /home/bruger/mysite/media/;
        }

        location / {
            proxy_pass http://127.0.0.1:8000;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
    

Checklist før deployment

# 1. Sæt DEBUG=False i settings.py
    # 2. Tilføj dit domæne til ALLOWED_HOSTS
    # 3. Sæt en stærk SECRET_KEY fra miljøvariabel
    # 4. Kør migrations
    python manage.py migrate
    # 5. Samle statiske filer
    python manage.py collectstatic
    # 6. Opret superuser
    python manage.py createsuperuser
    

14. Database

SQLite (udvikling)

Django bruger SQLite som standard. Perfekt til udvikling og testing. Ingen konfiguration nødvendig.

# settings.py
    DATABASES = {
        'default': {
            'ENGINE': 'django.db.backends.sqlite3',
            'NAME': BASE_DIR / 'db.sqlite3',
        }
    }
    

PostgreSQL (produktion)

PostgreSQL er det anbefalede databasevalg til produktion.

Gratis hosted PostgreSQL:

pip install psycopg2-binary
    
# settings.py
    import os

    DATABASES = {
        'default': {
            'ENGINE': 'django.db.backends.postgresql',
            'NAME': os.environ.get('DB_NAME', 'mysite'),
            'USER': os.environ.get('DB_USER', 'postgres'),
            'PASSWORD': os.environ.get('DB_PASSWORD', ''),
            'HOST': os.environ.get('DB_HOST', 'localhost'),
            'PORT': os.environ.get('DB_PORT', '5432'),
        }
    }
    

Database tips

# Brug select_related() til ForeignKey (1 forespørgsel)
    posts = Post.objects.select_related('author', 'category').all()

    # Brug prefetch_related() til ManyToMany (2 forespørgsler)
    posts = Post.objects.prefetch_related('tags', 'comments').all()

    # Brug annotate() til beregninger i DB
    from django.db.models import Count
    categories = Category.objects.annotate(post_count=Count('post'))

    # Brug Q objects til komplekse forespørgsler
    from django.db.models import Q
    posts = Post.objects.filter(
        Q(title__icontains='django') | Q(body__icontains='python')
    )
    

15. Pris

Hvad er gratis?

Komponent Pris
Django framework Gratis (open source)
Python Gratis
SQLite Gratis (inkluderet)
PostgreSQL (Supabase) Gratis (500 MB)
Git + GitHub Gratis
Railway hosting Gratis ($5/måned kredit)
Vercel (frontend) Gratis (med begrænsninger)
Let's Encrypt SSL Gratis

Hvad koster penge?

Komponent Pris
DigitalOcean Droplet $4-12/måned
DigitalOcean App Platform $5+/måned
AWS EC2 $5-50+/måned
Custom domæne (.dk) ~$10-15/år
Managed PostgreSQL $7-25+/måned
SendGrid (e-mails) Gratis tier, derefter $15+/måned
CDN (Cloudflare) Gratis tier tilgængelig

Total estimering for en simpel SaaS


16. Fordele og ulemper

Django vs Flask

Aspekt Django Flask
Filosofi "Batteries included" Minimalt, modular
Bedst til Store projekter, teams Små projekter, microservices
ORM Indbygget Ingen (brug SQLAlchemy)
Admin panel Indbygget Skal bygges manuelt
Learning curve Stejlere Lettere at starte
Fleksibilitet Mere struktureret Mere fleksibel
Performance God Lidt hurtigere (lettere)

Vælg Django når: Du bygger et større projekt med database, authentication, og admin behov.

Vælg Flask når: Du bygger et lille API, en microservice, eller har brug for fuld kontrol over arkitekturen.

Django vs Node.js (Express)

Aspekt Django Node.js/Express
Sprog Python JavaScript
Database ORM (Python) Mongoose/SQL drivers
Async God (Django 4.1+) Fremragende (native)
Real-time Kan med Channels WebSocket (naturlig)
Ecosistema Modent, stabilt Kæmpe, mange pakker
Fællesskab Stort Større

Vælg Django når: Du vil have stabilitet, en stærk ORM, og et modent økosystem.

Vælg Node.js når: Du bygger real-time features, microservices, eller allerede kender JavaScript godt.

Django vs Laravel

Aspekt Django Laravel
Sprog Python PHP
Fællesskab Stort (globalt) Stort (især i Europa)
ORM Django ORM Eloquent
Kostbar hosting Overalt Overalt
Performance God God (PHP 8+)

Begge er fremragende valg. Vælg baseret på dit teams kompetencer og økosystempræferencer.

Samlet fordele/ulemper for Django

Fordele:

Ulemper:


17. Eksempel-projekt

Lad os bygge en simpel blog med API på ~100 linjer kode.

Trin 1: Opret projektet

mkdir django-blog && cd django-blog
    python -m venv venv
    venv\Scripts\activate  # Windows
    pip install django djangorestframework
    django-admin startproject config .
    python manage.py startapp blog
    

Trin 2: Models

# blog/models.py

    from django.db import models
    from django.contrib.auth.models import User

    class Post(models.Model):
        title = models.CharField(max_length=200)
        slug = models.SlugField(unique=True)
        author = models.ForeignKey(User, on_delete=models.CASCADE)
        body = models.TextField()
        created = models.DateTimeField(auto_now_add=True)
        updated = models.DateTimeField(auto_now=True)

        class Meta:
            ordering = ['-created']

        def __str__(self):
            return self.title
    

Trin 3: Serializer

# blog/serializers.py

    from rest_framework import serializers
    from .models import Post

    class PostSerializer(serializers.ModelSerializer):
        author_name = serializers.CharField(source='author.username', read_only=True)

        class Meta:
            model = Post
            fields = ['id', 'title', 'slug', 'author_name', 'body', 'created', 'updated']
    

Trin 4: Views

# blog/api_views.py

    from rest_framework import viewsets, permissions
    from .models import Post
    from .serializers import PostSerializer

    class PostViewSet(viewsets.ModelViewSet):
        queryset = Post.objects.all()
        serializer_class = PostSerializer
        lookup_field = 'slug'
        permission_classes = [permissions.IsAuthenticatedOrReadOnly]

        def perform_create(self, serializer):
            serializer.save(author=self.request.user)
    

Trin 5: URLs

# blog/urls.py

    from django.urls import path
    from . import views

    app_name = 'blog'

    urlpatterns = [
        path('', views.post_list, name='post_list'),
        path('<slug:slug>/', views.post_detail, name='post_detail'),
    ]
    
# config/api_urls.py

    from django.urls import path, include
    from rest_framework.routers import DefaultRouter
    from blog.api_views import PostViewSet

    router = DefaultRouter()
    router.register('posts', PostViewSet)

    urlpatterns = [
        path('', include(router.urls)),
    ]
    
# config/urls.py

    from django.contrib import admin
    from django.urls import path, include

    urlpatterns = [
        path('admin/', admin.site.urls),
        path('', include('blog.urls')),
        path('api/', include('config.api_urls')),
        path('api-auth/', include('rest_framework.urls')),
    ]
    

Trin 6: Template

<!-- blog/templates/blog/base.html -->

    <!DOCTYPE html>
    <html lang="da">
    <head>
        <meta charset="UTF-8">
        <title>Min Blog</title>
    </head>
    <body>
        <h1>Min Blog</h1>
        <nav>
            <a href="/">Hjem</a> |
            <a href="/api/posts/">API</a> |
            {% if user.is_authenticated %}
                <a href="/admin/">Admin</a>
            {% endif %}
        </nav>
        <hr>
        {% block content %}{% endblock %}
    </body>
    </html>
    
<!-- blog/templates/blog/post_list.html -->

    {% extends 'blog/base.html' %}

    {% block content %}
    <h2>Alle indlæg</h2>
    {% for post in posts %}
        <article>
            <h3><a href="{% url 'blog:post_detail' post.slug %}">{{ post.title }}</a></h3>
            <small>Af {{ post.author }} · {{ post.created|date:"d. M Y" }}</small>
            <p>{{ body|truncatewords:30 }}</p>
        </article>
    {% empty %}
        <p>Ingen indlæg endnu.</p>
    {% endfor %}
    {% endblock %}
    

Trin 7: Kør det

python manage.py makemigrations
    python manage.py migrate
    python manage.py createsuperuser
    python manage.py runserver
    

Åbn:

Brug API'et

# Installer httpie
    pip install httpie

    # Hent alle indlæg
    http GET http://127.0.0.1:8000/api/posts/

    # Opret et indlæg (med token auth)
    http POST http://127.0.0.1:8000/api/posts/ \
        title="Mit første indlæg" \
        slug="mit-forste-indlaeg" \
        body="Hej verden!" \
        --auth=brugernavn:adgangskode
    

Næste skridt

Her er nogle forslag til hvad du kan bygge videre på:

  1. Tilføj comments — Comment model med relation til Post
  2. Tilføj tags — ManyToMany relation og filtrering
  3. Tilføj brugerprofiler — Custom User model med avatar og bio
  4. Tilføj search — Fuldtekstsøgning med PostgreSQL eller Elasticsearch
  5. Tilføj caching — Redis til hurtigere forespørgsler
  6. Tilføj e-mails — SendGrid til nyhedsbrev
  7. Byg en frontend — React, Vue eller Svelte som单独 frontend

Nyttige ressourcer


Denne guide er skrevet til Django 4.2+. Koden kan fungere lidt anderledes i ældre versioner.