Django 视图与 URL 路由
2026/3/20大约 10 分钟
Django 视图与 URL 路由
一、URL 配置基础
1.1 URLconf 概念
URLconf(URL configuration)是 Django 中将 URL 模式映射到视图的机制。Django 使用 ROOT_URLCONF 设置来指定根 URL 配置模块。
# settings.py
ROOT_URLCONF = 'myproject.urls'
1.2 path() 和 re_path()
# urls.py
from django.urls import path, re_path
from . import views
urlpatterns = [
# path() - 简单路由
path('', views.index, name='index'),
path('articles/', views.article_list, name='article_list'),
path('articles/<int:pk>/', views.article_detail, name='article_detail'),
path('articles/<slug:slug>/', views.article_by_slug, name='article_by_slug'),
# re_path() - 正则表达式路由
re_path(r'^archive/(?P<year>[0-9]{4})/$', views.archive_year, name='archive_year'),
re_path(
r'^archive/(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$',
views.archive_month,
name='archive_month'
),
]
1.3 URL 参数捕获
Django 提供了内置的路径转换器:
urlpatterns = [
# str - 匹配非空字符串,不包含 /
path('category/<str:name>/', views.category_detail),
# int - 匹配正整数
path('article/<int:pk>/', views.article_detail),
# slug - 匹配字母、数字、连字符、下划线
path('post/<slug:slug>/', views.post_detail),
# uuid - 匹配 UUID
path('user/<uuid:user_id>/', views.user_detail),
# path - 匹配任意非空字符串,包含 /
path('files/<path:file_path>/', views.download_file),
]
1.4 自定义路径转换器
# converters.py
class FourDigitYearConverter:
regex = '[0-9]{4}'
def to_python(self, value):
return int(value)
def to_url(self, value):
return '%04d' % value
class UsernameConverter:
regex = '[a-zA-Z][a-zA-Z0-9_]{2,19}'
def to_python(self, value):
return value.lower()
def to_url(self, value):
return value
# urls.py
from django.urls import path, register_converter
from . import converters, views
register_converter(converters.FourDigitYearConverter, 'yyyy')
register_converter(converters.UsernameConverter, 'username')
urlpatterns = [
path('archive/<yyyy:year>/', views.archive_year),
path('user/<username:name>/', views.user_profile),
]
1.5 URL 命名和反向解析
# urls.py
urlpatterns = [
path('articles/<int:pk>/', views.article_detail, name='article_detail'),
]
# 在视图中使用 reverse()
from django.urls import reverse
from django.shortcuts import redirect
def some_view(request):
url = reverse('article_detail', args=[123])
# 或者
url = reverse('article_detail', kwargs={'pk': 123})
return redirect(url)
# 在模板中使用 {% url %}
# <a href="{% url 'article_detail' pk=article.pk %}">{{ article.title }}</a>
# 在模型中使用 get_absolute_url()
class Article(models.Model):
title = models.CharField(max_length=200)
def get_absolute_url(self):
return reverse('article_detail', kwargs={'pk': self.pk})
1.6 URL 命名空间
# blog/urls.py
from django.urls import path
from . import views
app_name = 'blog' # 应用命名空间
urlpatterns = [
path('', views.index, name='index'),
path('<int:pk>/', views.detail, name='detail'),
]
# 项目 urls.py
from django.urls import path, include
urlpatterns = [
path('blog/', include('blog.urls', namespace='blog')),
path('news/', include('news.urls', namespace='news')),
]
# 使用命名空间反向解析
reverse('blog:index')
reverse('blog:detail', kwargs={'pk': 1})
# 模板中
# {% url 'blog:detail' pk=article.pk %}
1.7 include() 模块化
# 项目 urls.py
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('blog/', include('blog.urls')),
path('api/', include('api.urls')),
# 传递额外参数
path('api/v1/', include('api.urls', namespace='api_v1')),
path('api/v2/', include('api.v2.urls', namespace='api_v2')),
# 嵌套 include
path('accounts/', include([
path('login/', views.login, name='login'),
path('logout/', views.logout, name='logout'),
path('profile/', include('accounts.profile_urls')),
])),
]
二、函数视图(FBV)
2.1 基本函数视图
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from .models import Article
def article_list(request):
"""文章列表视图"""
articles = Article.objects.filter(status='published')
return render(request, 'blog/article_list.html', {'articles': articles})
def article_detail(request, pk):
"""文章详情视图"""
article = get_object_or_404(Article, pk=pk, status='published')
return render(request, 'blog/article_detail.html', {'article': article})
2.2 HttpRequest 对象详解
def example_view(request):
# 请求方法
method = request.method # 'GET', 'POST', etc.
# GET 参数
page = request.GET.get('page', 1)
tags = request.GET.getlist('tag') # 获取多个同名参数
# POST 数据
username = request.POST.get('username')
data = request.POST.dict() # 转换为字典
# 请求体(原始数据)
body = request.body # bytes
import json
data = json.loads(request.body) # JSON 数据
# 文件上传
file = request.FILES.get('file')
files = request.FILES.getlist('files')
# 请求头
content_type = request.headers.get('Content-Type')
auth = request.headers.get('Authorization')
# 或使用 META
user_agent = request.META.get('HTTP_USER_AGENT')
# Cookie
session_id = request.COOKIES.get('sessionid')
# Session
request.session['user_id'] = 123
user_id = request.session.get('user_id')
# 用户信息
user = request.user
is_authenticated = request.user.is_authenticated
# 请求路径
path = request.path # '/articles/1/'
full_path = request.get_full_path() # '/articles/1/?page=2'
absolute_uri = request.build_absolute_uri()
# 是否 AJAX
is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
return HttpResponse('OK')
2.3 HttpResponse 及其子类
from django.http import (
HttpResponse,
JsonResponse,
HttpResponseRedirect,
HttpResponsePermanentRedirect,
HttpResponseNotFound,
HttpResponseForbidden,
HttpResponseBadRequest,
HttpResponseServerError,
FileResponse,
StreamingHttpResponse,
)
def response_examples(request):
# 基本响应
response = HttpResponse('Hello, World!')
response = HttpResponse(content_type='text/plain')
response.write('Hello')
response['X-Custom-Header'] = 'value'
# JSON 响应
response = JsonResponse({'message': 'success', 'data': [1, 2, 3]})
response = JsonResponse([1, 2, 3], safe=False) # 非字典数据
# 重定向
response = HttpResponseRedirect('/new-url/')
response = HttpResponsePermanentRedirect('/new-url/') # 301
# 错误响应
response = HttpResponseNotFound('Page not found') # 404
response = HttpResponseForbidden('Access denied') # 403
response = HttpResponseBadRequest('Bad request') # 400
response = HttpResponseServerError('Server error') # 500
# 文件下载
response = FileResponse(
open('file.pdf', 'rb'),
as_attachment=True,
filename='document.pdf'
)
return response
2.4 快捷函数
from django.shortcuts import (
render,
redirect,
get_object_or_404,
get_list_or_404,
)
from django.http import Http404
def article_detail(request, pk):
# render - 渲染模板
article = get_object_or_404(Article, pk=pk)
return render(request, 'blog/article_detail.html', {
'article': article,
})
def article_create(request):
if request.method == 'POST':
# 处理表单
article = Article.objects.create(**data)
# redirect - 重定向
return redirect('article_detail', pk=article.pk)
# 或者
return redirect(article) # 调用 get_absolute_url()
return redirect('/articles/')
return render(request, 'blog/article_form.html')
def category_articles(request, slug):
# get_list_or_404 - 获取列表或 404
articles = get_list_or_404(Article, category__slug=slug)
return render(request, 'blog/category.html', {'articles': articles})
2.5 视图装饰器
from django.views.decorators.http import require_http_methods, require_GET, require_POST
from django.views.decorators.csrf import csrf_exempt, csrf_protect
from django.contrib.auth.decorators import login_required, permission_required
from django.views.decorators.cache import cache_page
# 限制 HTTP 方法
@require_GET
def article_list(request):
pass
@require_POST
def article_create(request):
pass
@require_http_methods(['GET', 'POST'])
def article_form(request):
pass
# 登录验证
@login_required(login_url='/accounts/login/')
def profile(request):
pass
# 权限验证
@permission_required('blog.add_article', raise_exception=True)
def article_create(request):
pass
# CSRF 豁免(仅用于 API)
@csrf_exempt
def api_endpoint(request):
pass
# 缓存
@cache_page(60 * 15) # 缓存 15 分钟
def article_list(request):
pass
# 组合装饰器
@login_required
@permission_required('blog.add_article')
@require_POST
def article_create(request):
pass
三、类视图(CBV)
3.1 View 基类
from django.views import View
from django.http import JsonResponse
from django.shortcuts import render
class ArticleView(View):
"""基本类视图"""
def get(self, request, *args, **kwargs):
articles = Article.objects.all()
return render(request, 'blog/article_list.html', {'articles': articles})
def post(self, request, *args, **kwargs):
# 处理 POST 请求
return JsonResponse({'status': 'success'})
def http_method_not_allowed(self, request, *args, **kwargs):
return JsonResponse({'error': 'Method not allowed'}, status=405)
# urls.py
urlpatterns = [
path('articles/', ArticleView.as_view(), name='articles'),
]
3.2 类视图的请求处理流程
class MyView(View):
def setup(self, request, *args, **kwargs):
"""初始化,设置实例属性"""
super().setup(request, *args, **kwargs)
self.user = request.user
def dispatch(self, request, *args, **kwargs):
"""分发请求到对应的方法"""
# 可以在这里添加通用逻辑
if not request.user.is_authenticated:
return redirect('login')
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
return render(request, 'template.html')
def post(self, request, *args, **kwargs):
return JsonResponse({'status': 'ok'})
3.3 装饰类视图
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.decorators.cache import cache_page
# 方式一:装饰 dispatch
@method_decorator(login_required, name='dispatch')
class ArticleCreateView(View):
def get(self, request):
pass
# 方式二:装饰特定方法
class ArticleView(View):
@method_decorator(cache_page(60 * 15))
def get(self, request):
pass
@method_decorator(login_required)
def post(self, request):
pass
# 方式三:使用 Mixin
from django.contrib.auth.mixins import LoginRequiredMixin
class ArticleCreateView(LoginRequiredMixin, View):
login_url = '/accounts/login/'
redirect_field_name = 'next'
def get(self, request):
pass
四、通用视图
4.1 TemplateView
from django.views.generic import TemplateView
class HomePageView(TemplateView):
template_name = 'home.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['featured_articles'] = Article.objects.filter(is_featured=True)[:5]
return context
# urls.py 中直接使用
urlpatterns = [
path('about/', TemplateView.as_view(template_name='about.html'), name='about'),
]
4.2 ListView
from django.views.generic import ListView
class ArticleListView(ListView):
model = Article
template_name = 'blog/article_list.html' # 默认: article_list.html
context_object_name = 'articles' # 默认: object_list
paginate_by = 10
ordering = ['-created_at']
def get_queryset(self):
"""自定义查询集"""
queryset = super().get_queryset()
queryset = queryset.filter(status='published')
# 搜索功能
query = self.request.GET.get('q')
if query:
queryset = queryset.filter(title__icontains=query)
# 分类过滤
category = self.kwargs.get('category_slug')
if category:
queryset = queryset.filter(category__slug=category)
return queryset.select_related('author', 'category')
def get_context_data(self, **kwargs):
"""添加额外上下文"""
context = super().get_context_data(**kwargs)
context['categories'] = Category.objects.all()
context['query'] = self.request.GET.get('q', '')
return context
4.3 DetailView
from django.views.generic import DetailView
class ArticleDetailView(DetailView):
model = Article
template_name = 'blog/article_detail.html'
context_object_name = 'article'
slug_field = 'slug' # 模型字段名
slug_url_kwarg = 'slug' # URL 参数名
def get_queryset(self):
return super().get_queryset().filter(
status='published'
).select_related('author', 'category').prefetch_related('tags')
def get_object(self, queryset=None):
"""自定义获取对象的逻辑"""
obj = super().get_object(queryset)
# 增加浏览量
obj.views += 1
obj.save(update_fields=['views'])
return obj
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['related_articles'] = Article.objects.filter(
category=self.object.category
).exclude(pk=self.object.pk)[:5]
return context
4.4 CreateView
from django.views.generic import CreateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
class ArticleCreateView(LoginRequiredMixin, CreateView):
model = Article
template_name = 'blog/article_form.html'
fields = ['title', 'content', 'category', 'tags']
# 或使用 form_class
# form_class = ArticleForm
success_url = reverse_lazy('blog:article_list')
def form_valid(self, form):
"""表单验证通过时调用"""
form.instance.author = self.request.user
return super().form_valid(form)
def form_invalid(self, form):
"""表单验证失败时调用"""
return super().form_invalid(form)
def get_success_url(self):
"""动态成功 URL"""
return reverse('blog:article_detail', kwargs={'pk': self.object.pk})
4.5 UpdateView
from django.views.generic import UpdateView
from django.contrib.auth.mixins import UserPassesTestMixin
class ArticleUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
model = Article
template_name = 'blog/article_form.html'
fields = ['title', 'content', 'category', 'tags']
def test_func(self):
"""检查用户是否有权限编辑"""
article = self.get_object()
return self.request.user == article.author
def handle_no_permission(self):
"""无权限时的处理"""
from django.contrib import messages
messages.error(self.request, '您没有权限编辑此文章')
return redirect('blog:article_list')
4.6 DeleteView
from django.views.generic import DeleteView
class ArticleDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView):
model = Article
template_name = 'blog/article_confirm_delete.html'
success_url = reverse_lazy('blog:article_list')
def test_func(self):
article = self.get_object()
return self.request.user == article.author
def delete(self, request, *args, **kwargs):
"""删除前的自定义逻辑"""
from django.contrib import messages
messages.success(request, '文章已删除')
return super().delete(request, *args, **kwargs)
4.7 FormView
from django.views.generic import FormView
from .forms import ContactForm
class ContactView(FormView):
template_name = 'contact.html'
form_class = ContactForm
success_url = reverse_lazy('contact_success')
def form_valid(self, form):
# 发送邮件
form.send_email()
return super().form_valid(form)
def get_initial(self):
"""设置表单初始值"""
initial = super().get_initial()
if self.request.user.is_authenticated:
initial['email'] = self.request.user.email
return initial
4.8 RedirectView
from django.views.generic import RedirectView
class ArticleRedirectView(RedirectView):
permanent = False # 302 临时重定向
query_string = True # 保留查询字符串
pattern_name = 'blog:article_detail'
def get_redirect_url(self, *args, **kwargs):
article = get_object_or_404(Article, pk=kwargs['pk'])
return article.get_absolute_url()
# urls.py
urlpatterns = [
path('old-article/<int:pk>/', ArticleRedirectView.as_view()),
]
五、Mixin 类
5.1 内置 Mixin
from django.contrib.auth.mixins import (
LoginRequiredMixin,
PermissionRequiredMixin,
UserPassesTestMixin,
)
class ArticleCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
permission_required = 'blog.add_article'
# 或多个权限
permission_required = ['blog.add_article', 'blog.change_article']
# LoginRequiredMixin 配置
login_url = '/accounts/login/'
redirect_field_name = 'next'
raise_exception = False # True 则返回 403
class ArticleUpdateView(UserPassesTestMixin, UpdateView):
def test_func(self):
obj = self.get_object()
return self.request.user == obj.author or self.request.user.is_staff
5.2 自定义 Mixin
class AuthorRequiredMixin:
"""确保用户是对象的作者"""
def dispatch(self, request, *args, **kwargs):
obj = self.get_object()
if obj.author != request.user:
return HttpResponseForbidden('您没有权限')
return super().dispatch(request, *args, **kwargs)
class AjaxResponseMixin:
"""为 AJAX 请求返回 JSON"""
def form_valid(self, form):
response = super().form_valid(form)
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({
'status': 'success',
'redirect_url': self.get_success_url()
})
return response
def form_invalid(self, form):
response = super().form_invalid(form)
if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return JsonResponse({
'status': 'error',
'errors': form.errors
}, status=400)
return response
class PaginationMixin:
"""通用分页 Mixin"""
paginate_by = 10
def get_paginate_by(self, queryset):
return self.request.GET.get('per_page', self.paginate_by)
六、请求与响应处理
6.1 文件上传处理
from django.core.files.storage import default_storage
def upload_file(request):
if request.method == 'POST' and request.FILES.get('file'):
file = request.FILES['file']
# 文件信息
name = file.name
size = file.size
content_type = file.content_type
# 验证
if size > 10 * 1024 * 1024: # 10MB
return JsonResponse({'error': '文件过大'}, status=400)
if not content_type.startswith('image/'):
return JsonResponse({'error': '只允许图片'}, status=400)
# 保存文件
path = default_storage.save(f'uploads/{name}', file)
url = default_storage.url(path)
return JsonResponse({'url': url})
return render(request, 'upload.html')
# 批量上传
def upload_files(request):
if request.method == 'POST':
files = request.FILES.getlist('files')
urls = []
for file in files:
path = default_storage.save(f'uploads/{file.name}', file)
urls.append(default_storage.url(path))
return JsonResponse({'urls': urls})
6.2 Cookie 和 Session
def cookie_example(request):
# 读取 Cookie
username = request.COOKIES.get('username')
# 设置 Cookie
response = HttpResponse('OK')
response.set_cookie(
'username',
'john',
max_age=3600, # 过期时间(秒)
expires=datetime, # 过期日期
path='/', # 路径
domain='.example.com', # 域名
secure=True, # 仅 HTTPS
httponly=True, # 禁止 JS 访问
samesite='Lax', # SameSite 属性
)
# 删除 Cookie
response.delete_cookie('username')
return response
def session_example(request):
# 设置 Session
request.session['user_id'] = 123
request.session['cart'] = {'item1': 2, 'item2': 1}
# 读取 Session
user_id = request.session.get('user_id')
cart = request.session.get('cart', {})
# 删除 Session
del request.session['user_id']
# 清空 Session
request.session.flush()
# 设置过期时间
request.session.set_expiry(3600) # 1小时
request.session.set_expiry(0) # 浏览器关闭时过期
return HttpResponse('OK')
6.3 流式响应
from django.http import StreamingHttpResponse
import csv
def export_csv(request):
"""流式导出大型 CSV"""
def generate():
yield 'ID,Title,Author\n'
for article in Article.objects.iterator(chunk_size=1000):
yield f'{article.id},{article.title},{article.author.username}\n'
response = StreamingHttpResponse(
generate(),
content_type='text/csv'
)
response['Content-Disposition'] = 'attachment; filename="articles.csv"'
return response
def stream_large_file(request, filename):
"""流式传输大文件"""
def file_iterator(file_path, chunk_size=8192):
with open(file_path, 'rb') as f:
while chunk := f.read(chunk_size):
yield chunk
response = StreamingHttpResponse(
file_iterator(f'/path/to/{filename}'),
content_type='application/octet-stream'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
七、错误处理
7.1 自定义错误页面
# views.py
from django.shortcuts import render
def handler404(request, exception):
return render(request, 'errors/404.html', status=404)
def handler500(request):
return render(request, 'errors/500.html', status=500)
def handler403(request, exception):
return render(request, 'errors/403.html', status=403)
def handler400(request, exception):
return render(request, 'errors/400.html', status=400)
# urls.py
handler404 = 'myapp.views.handler404'
handler500 = 'myapp.views.handler500'
handler403 = 'myapp.views.handler403'
handler400 = 'myapp.views.handler400'
7.2 抛出 HTTP 异常
from django.http import Http404
from django.core.exceptions import PermissionDenied, SuspiciousOperation
def article_detail(request, pk):
try:
article = Article.objects.get(pk=pk)
except Article.DoesNotExist:
raise Http404('文章不存在')
if not article.is_public and article.author != request.user:
raise PermissionDenied('您没有权限查看此文章')
return render(request, 'article_detail.html', {'article': article})
八、API 视图设计
8.1 返回 JSON 响应
from django.http import JsonResponse
from django.views import View
import json
class ArticleAPIView(View):
def get(self, request):
articles = Article.objects.values('id', 'title', 'created_at')[:20]
return JsonResponse({
'status': 'success',
'data': list(articles)
})
def post(self, request):
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON'}, status=400)
# 验证和创建
article = Article.objects.create(
title=data.get('title'),
content=data.get('content'),
author=request.user
)
return JsonResponse({
'status': 'success',
'data': {'id': article.id, 'title': article.title}
}, status=201)
8.2 处理 AJAX 请求
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_protect
import json
@require_POST
@csrf_protect
def like_article(request):
try:
data = json.loads(request.body)
article_id = data.get('article_id')
except (json.JSONDecodeError, KeyError):
return JsonResponse({'error': 'Invalid request'}, status=400)
article = get_object_or_404(Article, pk=article_id)
# 切换点赞状态
like, created = Like.objects.get_or_create(
article=article,
user=request.user
)
if not created:
like.delete()
liked = False
else:
liked = True
return JsonResponse({
'liked': liked,
'like_count': article.likes.count()
})
# 前端 JavaScript
/*
fetch('/api/like/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken'),
},
body: JSON.stringify({article_id: 1})
})
*/
8.3 CORS 处理
# 安装 django-cors-headers
# pip install django-cors-headers
# settings.py
INSTALLED_APPS = [
...
'corsheaders',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
...
]
# 允许所有来源(开发环境)
CORS_ALLOW_ALL_ORIGINS = True
# 或指定允许的来源
CORS_ALLOWED_ORIGINS = [
'http://localhost:3000',
'https://example.com',
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = [
'accept',
'authorization',
'content-type',
'x-csrftoken',
]
九、最佳实践
9.1 视图组织结构
blog/
├── views/
│ ├── __init__.py
│ ├── article.py # 文章相关视图
│ ├── category.py # 分类相关视图
│ ├── comment.py # 评论相关视图
│ └── api.py # API 视图
├── urls.py
└── ...
# views/__init__.py
from .article import (
ArticleListView,
ArticleDetailView,
ArticleCreateView,
ArticleUpdateView,
ArticleDeleteView,
)
from .category import CategoryDetailView
from .comment import CommentCreateView
9.2 胖模型瘦视图
# models.py
class Article(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
status = models.CharField(max_length=20, default='draft')
def publish(self):
"""发布文章"""
self.status = 'published'
self.published_at = timezone.now()
self.save(update_fields=['status', 'published_at'])
def archive(self):
"""归档文章"""
self.status = 'archived'
self.save(update_fields=['status'])
@classmethod
def get_published(cls):
return cls.objects.filter(status='published')
# views.py - 保持简洁
class ArticlePublishView(LoginRequiredMixin, View):
def post(self, request, pk):
article = get_object_or_404(Article, pk=pk, author=request.user)
article.publish()
messages.success(request, '文章已发布')
return redirect(article)
9.3 使用服务层
# services/article.py
class ArticleService:
@staticmethod
def create_article(user, data):
"""创建文章"""
article = Article.objects.create(
author=user,
**data
)
# 发送通知
notify_followers(user, article)
return article
@staticmethod
def publish_article(article):
"""发布文章"""
article.publish()
# 更新搜索索引
update_search_index(article)
# 发送邮件通知
send_publish_notification(article)
# views.py
from .services.article import ArticleService
class ArticleCreateView(CreateView):
def form_valid(self, form):
article = ArticleService.create_article(
self.request.user,
form.cleaned_data
)
return redirect(article)