Django — Komplet guide til Python webudvikling
En praktisk, begyndervenlig guide til at bygge webapps med Django.
Indholdsfortegnelse
- Hvad er Django?
- Hvornår skal du bruge det?
- Kom i gang
- Projektstruktur
- Models
- Views
- Templates
- Forms
- URL routing
- Admin panel
- REST API
- Authentication
- Deployment
- Database
- Pris
- Fordele og ulemper
- 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:
- Instagram (milliarder af brugere)
- Spotify (backend tjenester)
- Mozilla
- The Washington Post
- Disqus
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:
- ORM — Objektrelationelt mapping til at arbejde med databaser uden SQL
- Admin panel — Automatisk genereret administrationsflade
- Middleware — Et lag af hooks der kan modificere requests/responses
- Apps — Modulært system der opdeler logik i logiske enheder
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?
- Meigt enkle sites der kun behøver statisk indhold (brug Hugo/Jekyll)
- Real-time apps med WebSocket som kernefunktionalitet (overvej Node.js + Socket.io)
- Microservices der kun behøver et lille API endpoint (Flask/FastAPI er lettere)
3. Kom i gang
Forudsætninger
- Python 3.10+ installeret
- Grundlæggende kendskab til kommandolinjen
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 |
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>© 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.
- Opret konto på https://railway.app
- Installer Railway CLI:
npm install -g @railway/cli
railway login
- Opret
Procfile:
web: gunicorn mysite.wsgi
- Opret
requirements.txt:
pip freeze > requirements.txt
- Opret
runtime.txt:
python-3.12.3
- Deploy:
railway init
railway up
- 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):
- Opret konto på https://www.digitalocean.com
- Opret en App via dashboard
- Kobl dit GitHub repo
- Tilføj environment variables
- 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:
- Supabase — 500 MB gratis (https://supabase.com)
- Neon — 512 MB gratis (https://neon.tech)
- Railway — Inkluderet i gratis tier
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
- Udvikling: Gratis (din tid)
- Hosting (Railway): $0-5/måned
- Database (Supabase): $0/måned
- Domæne: ~$10/år
- E-mail: $0 (SendGrid gratis tier)
- SSL: $0 (inkluderet)
- Total: ~$5-15/måned
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:
- Modent og stabilt framework
- Fremragende dokumentation
- Kraftfuldt admin panel
- Stærk ORM
- Godt fællesskab og økosystem
- Indbygget sikkerhed
- God til store projekter
- Python er et læsbart sprog
Ulemper:
- Mere tungt end micro-frameworks
- Kan være overkill til meget simple projekter
- Template systemet er mindre fleksibelt end f.eks. React
- Kan være langsomt uden caching
- Monolitisk arkitektur kan være udfordrende til microservices
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:
- http://127.0.0.1:8000/ — Frontend
- http://127.0.0.1:8000/admin/ — Admin panel
- http://127.0.0.1:8000/api/posts/ — REST API
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å:
- Tilføj comments — Comment model med relation til Post
- Tilføj tags — ManyToMany relation og filtrering
- Tilføj brugerprofiler — Custom User model med avatar og bio
- Tilføj search — Fuldtekstsøgning med PostgreSQL eller Elasticsearch
- Tilføj caching — Redis til hurtigere forespørgsler
- Tilføj e-mails — SendGrid til nyhedsbrev
- Byg en frontend — React, Vue eller Svelte som单独 frontend
Nyttige ressourcer
- Django dokumentation: https://docs.djangoproject.com/
- Django REST Framework: https://www.django-rest-framework.org/
- Django Girls tutorial (på dansk): https://tutorial.djangogirls.org/da/
- Django Polls tutorial: https://docs.djangoproject.com/da/4.2/intro/tutorial01/
- Two Scoops of Django (bog): https://www.twoscoopspress.com/products/two-scoops-of-django-3-x/
Denne guide er skrevet til Django 4.2+. Koden kan fungere lidt anderledes i ældre versioner.