Тест на вакансию

Создание REST API на Django (Python) с Bearer авторизацией и управлением статьями

2 сентября 2025 г.
279

Установка

Создаём новое приложение:
mkdir django-articles-api
cd django-articles-api

Структура

Первоначальная структура проекта приложения должна выглядеть так:

django-articles-api/
  • .env
  • manage.py
  • requirements.txt
  • config/
    • settings.py
    • urls.py
  • articles/
    • models.py
    • serializers.py
    • views.py
    • urls.py
  • users/
    • models.py
    • serializers.py
    • views.py
    • urls.py

Установка зависимостей

Отредактируем файл (requirements.txt):
Django==4.2.7
djangorestframework==3.14.0
django-cors-headers==4.3.1
django-environ==0.11.2
djangorestframework-simplejwt==5.3.0
PyJWT==2.8.0
Pillow==10.0.1
python-decouple==3.8
Далее выполним следующую команду:
pip install -r requirements.txt
При необходимости установим (обновим):
pip install --upgrade setuptools

Файл .env

Отредактируем файл (.env):
DEBUG=True
SECRET_KEY=your-secret-key-here
DATABASE_URL=sqlite:///db.sqlite3
DB_NAME=django_articles_db
DB_USER=postgres
DB_PASSWORD=password
DB_HOST=localhost
DB_PORT=5432
JWT_SECRET_KEY=your-jwt-secret-key-here
ALLOWED_HOSTS=localhost,127.0.0.1
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000

Настройка Django (config/settings.py)

import os

from pathlib import Path
from datetime import timedelta
from decouple import config, Csv

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = config('SECRET_KEY')
DEBUG = config('DEBUG', default=False, cast=bool)

ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv())
CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS', cast=Csv())

AUTH_USER_MODEL = 'users.CustomUser'

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'corsheaders',
    'articles',
    'users',
    'rest_framework_simplejwt.token_blacklist',
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'config.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

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

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticatedOrReadOnly',
    ),
}

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(hours=24),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
}

STATIC_URL = '/static/'
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
Если потребуется подключение, например, к PostgreSQL:
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': config('DB_NAME'),
        'USER': config('DB_USER'),
        'PASSWORD': config('DB_PASSWORD'),
        'HOST': config('DB_HOST'),
        'PORT': config('DB_PORT'),
    }
}
Обратите внимание на:
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(hours=24),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
}
Здесь мы указали:
  • ACCESS_TOKEN_LIFETIME: время жизни жизни токена доступа - 24 часа;
  • REFRESH_TOKEN_LIFETIME: время жизник refresh токена - 7 дней;
  • ROTATE_REFRESH_TOKENS: ротация refresh токена, т.е. каждое обновление токена создаёт новый refresh токен;
  • BLACKLIST_AFTER_ROTATION: старый refresh заносится в black list и более не действует;

Файл manage.py

Отредактируем файл:
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


def main():
    """Run administrative tasks."""
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == '__main__':
    main()

Модели

Файл (users/models.py):
from django.contrib.auth.models import AbstractUser
from django.db import models

class CustomUser(AbstractUser):
    email = models.EmailField(unique=True)
    bio = models.TextField(blank=True)
    avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
   
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['username']
   
    def __str__(self):
        return self.email
Файл (articles/models.py):
from django.db import models
from django.conf import settings

class Article(models.Model):
    CATEGORY_CHOICES = [
        ('technology', 'Technology'),
        ('science', 'Science'),
        ('politics', 'Politics'),
        ('sports', 'Sports'),
        ('entertainment', 'Entertainment'),
    ]
   
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    category = models.CharField(max_length=50, choices=CATEGORY_CHOICES)
    image = models.ImageField(upload_to='articles/', blank=True, null=True)
    published = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
   
    class Meta:
        ordering = ['-created_at']
   
    def __str__(self):
        return self.title

Сериализаторы

Файл (users/serializers.py):
from rest_framework import serializers
from django.contrib.auth import authenticate
from .models import CustomUser

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = CustomUser
        fields = ('id', 'username', 'email', 'first_name', 'last_name', 'bio')

class RegisterSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True)
   
    class Meta:
        model = CustomUser
        fields = ('username', 'email', 'password', 'first_name', 'last_name')
   
    def create(self, validated_data):
        user = CustomUser.objects.create_user(
            username=validated_data['username'],
            email=validated_data['email'],
            password=validated_data['password'],
            first_name=validated_data.get('first_name', ''),
            last_name=validated_data.get('last_name', '')
        )
        return user

class LoginSerializer(serializers.Serializer):
    email = serializers.EmailField()
    password = serializers.CharField()
   
    def validate(self, data):
        email = data.get('email')
        password = data.get('password')
       
        if email and password:
            user = authenticate(username=email, password=password)
            if not user:
                raise serializers.ValidationError('Invalid credentials')
            if not user.is_active:
                raise serializers.ValidationError('User account is disabled')
            data['user'] = user
            return data
        raise serializers.ValidationError('Must include email and password')
Файл (articles/serializers.py):
from rest_framework import serializers
from .models import Article

class ArticleSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source='author.username', read_only=True)
   
    class Meta:
        model = Article
        fields = '__all__'
        read_only_fields = ('author', 'created_at', 'updated_at')

class ArticleListSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source='author.username', read_only=True)
    excerpt = serializers.SerializerMethodField()
   
    class Meta:
        model = Article
        fields = ('id', 'title', 'author_name', 'category', 'excerpt', 'created_at', 'published')
   
    def get_excerpt(self, obj):
        return obj.content[:100] + '...' if len(obj.content) > 100 else obj.content

Представления (Views)

Файл (users/views.py):
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import login
from .serializers import RegisterSerializer, LoginSerializer, UserSerializer
from .models import CustomUser

@api_view(['POST'])
@permission_classes([AllowAny])
def register(request):
    serializer = RegisterSerializer(data=request.data)
    if serializer.is_valid():
        user = serializer.save()
        refresh = RefreshToken.for_user(user)
        return Response({
            'user': UserSerializer(user).data,
            'refresh': str(refresh),
            'access': str(refresh.access_token),
        }, status=status.HTTP_201_CREATED)
    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

@api_view(['POST'])
@permission_classes([AllowAny])
def login_view(request):
    serializer = LoginSerializer(data=request.data)
    if serializer.is_valid():
        user = serializer.validated_data['user']
        login(request, user)
        refresh = RefreshToken.for_user(user)
        return Response({
            'user': UserSerializer(user).data,
            'refresh': str(refresh),
            'access': str(refresh.access_token),
        })
    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

@api_view(['GET'])
def profile(request):
    serializer = UserSerializer(request.user)
    return Response(serializer.data)
Файл (articles/views.py):
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticatedOrReadOnly, IsAuthenticated, BasePermission
from django.shortcuts import get_object_or_404
from .models import Article
from .serializers import ArticleSerializer, ArticleListSerializer

class IsAuthorOrReadOnly(BasePermission):
    def has_object_permission(self, request, view, obj):
        if request.method in ['GET', 'HEAD', 'OPTIONS']:
            return True
        return obj.author == request.user

class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    permission_classes = [IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
   
    def get_serializer_class(self):
        if self.action == 'list':
            return ArticleListSerializer
        return ArticleSerializer
   
    def get_queryset(self):
        queryset = Article.objects.all()
        category = self.request.query_params.get('category')
        author = self.request.query_params.get('author')
       
        if category:
            queryset = queryset.filter(category=category)
        if author:
            queryset = queryset.filter(author__username=author)
       
        # Для неавторизованных пользователей показываем только опубликованные
        if not self.request.user.is_authenticated:
            queryset = queryset.filter(published=True)
        else:
            # Для авторизованных показываем опубликованные + свои неопубликованные
            if not self.request.user.is_staff:
                queryset = queryset.filter(published=True) | queryset.filter(author=self.request.user)
       
        return queryset
   
    def perform_create(self, serializer):
        serializer.save(author=self.request.user)
   
    @action(detail=True, methods=['patch'], permission_classes=[IsAuthenticated])
    def publish(self, request, pk=None):
        article = self.get_object()
        if article.author != request.user and not request.user.is_staff:
            return Response({'error': 'You can only publish your own articles'},
                          status=status.HTTP_403_FORBIDDEN)
        article.published = True
        article.save()
        return Response({'status': 'article published'})
   
    @action(detail=True, methods=['patch'], permission_classes=[IsAuthenticated])
    def unpublish(self, request, pk=None):
        article = self.get_object()
        if article.author != request.user and not request.user.is_staff:
            return Response({'error': 'You can only unpublish your own articles'},
                          status=status.HTTP_403_FORBIDDEN)
        article.published = False
        article.save()
        return Response({'status': 'article unpublished'})

URLs

Файл (config/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('api/auth/', include('users.urls')),
    path('api/articles/', include('articles.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Файл (users/urls.py):
from django.urls import path
from . import views
from rest_framework_simplejwt.views import TokenRefreshView

urlpatterns = [
    path('register/', views.register, name='register'),
    path('login/', views.login_view, name='login'),
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('profile/', views.profile, name='profile'),
]
Файл (articles/urls.py):
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views

router = DefaultRouter()
router.register(r'', views.ArticleViewSet, basename='article')

urlpatterns = [
    path('', include(router.urls)),
]

Миграции и запуск

Создание миграций:
python manage.py makemigrations users
python manage.py makemigrations articles
Применение миграций:
python manage.py migrate
Создание суперпользователя:
python manage.py createsuperuser
Запуск сервера:
python manage.py runserver

Тестирование API

Регистрация:
curl -X POST http://localhost:8000/api/auth/register/ \
  -H "Content-Type: application/json" \
  -d '{
    "username": "user1",
    "email": "user1@jas.ru",
    "password": "password123",
    "first_name": "Eugene",
    "last_name": "Starodubcev"
  }'
Логин (получаем оба токена):
curl -X POST http://localhost:8000/api/auth/login/ \
  -H "Content-Type: application/json" \
  -d '{"email": "user1@jas.ru", "password": "password123"}'
Когда access token истекает, обновляем его:
curl -X POST http://localhost:8000/api/auth/token/refresh/ \
  -H "Content-Type: application/json" \
  -d '{"refresh":"eyJ..."}'
Получение статей (без авторизации):
curl http://localhost:8000/api/articles/
Получение статей (с авторизацией):
curl http://localhost:8000/api/articles/ \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Получение статей (с фильтрацией по категории):
curl "http://localhost:8000/api/articles/?category=technology"
Создание статьи:
curl -X POST http://localhost:8000/api/articles/ \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Новая статья",
    "content": "Содержание статьи...",
    "category": "technology",
    "published": true
  }'
Частичное изменение статьи (PATCH-запрос):
curl -X PATCH http://localhost:8000/api/articles/5/ \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Это совершенно новый заголовок",
    "content": "А это обновлённое содержимое статьи...",
    "category": "politics"
  }'
Полное изменение статьи (PUT-запрос):
curl -X PUT http://localhost:8000/api/articles/5/ \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Полностью новая статья",
    "content": "Старое содержимое было полностью стёрто и заменено на это.",
    "category": "science",
    "published": false
  }'
Удаление статьи (DELETE-запрос):
curl -X DELETE http://localhost:8000/api/articles/5/ \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Итого

Мы создали полнофункциональное RESTful API на Django и Django REST Framework (DRF). Основной фокус был сделан на реализации JWT-аутентификации (Bearer Token) и CRUD-операций (Create, Read, Update, Delete) для управления статьями с разграничением прав доступа.
Поделиться: