✅ Решения задач мини-проекта ч.3

Разбор всех 7 задач с объяснением ключевых моментов

⚡ Решения — ключевые файлы

  • Задача 1: project_file_views.py — get_serializer_class + get_queryset + list
  • Задача 2: project_file_serializers.py + project_file_views.py + upload_file_helpers.py + urls.py
  • Задача 3: users/models.py + users/choices/positions.py + settings.py
  • Задача 4: users/serializers.py + users/views.py + users/urls.py + router.py
  • Задача 5: users/serializers.py (RegisterUserSerializer) + users/views.py + users/urls.py
  • Задача 6: tasks/serializers/task_serializers.py (SlugRelatedField)
  • Задача 7: project_file_views.py (DownloadProjectFileView) + urls.py

Задача 1 — Список файлов проекта

1. Добавление методов (apps/projects/views/project_file_views.py)

from rest_framework.generics import ListCreateAPIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework import status

from apps.projects.models import ProjectFile
from apps.projects.serializers.project_file_serializers import (
    AllProjectFilesSerializer,
    CreateProjectFileSerializer,
)


class ProjectFileListGenericView(ListCreateAPIView):
    def get_serializer_class(self):
        if self.request.method == 'GET':
            return AllProjectFilesSerializer
        return CreateProjectFileSerializer

    def get_queryset(self):
        project_name = self.request.query_params.get('project')
        if project_name:
            project_file = ProjectFile.objects.filter(
                project__name=project_name
            )
            return project_file
        return ProjectFile.objects.all()

    def list(self, request: Request, *args, **kwargs) -> Response:
        project_files = self.get_queryset()
        if not project_files.exists():
            return Response(
                data=[],
                status=status.HTTP_204_NO_CONTENT
            )
        serializer = self.get_serializer(project_files, many=True)
        return Response(
            serializer.data,
            status=status.HTTP_200_OK
        )

Почему get_serializer_class? Метод вызывается DRF автоматически при каждом запросе — не нужно переопределять get() и post() отдельно. GET возвращает больше полей (для чтения), POST принимает минимум (для записи).

2. Запуск и тестирование

python manage.py runserver
# GET /api/v1/projects/files/           — все файлы
# GET /api/v1/projects/files/?project=Alpha  — файлы проекта "Alpha"

3. Git

git add apps/projects/views/project_file_views.py
git commit -m "feat: add get_serializer_class and list to ProjectFileListGenericView"
git push origin feature/task-35-1

Задача 2 — Детали файла + удаление

1. Сериализатор (apps/projects/serializers/project_file_serializers.py)

from rest_framework import serializers
from apps.projects.models import ProjectFile
from apps.projects.serializers.project_serializers import ProjectShortInfoSerializer


class ProjectFileDetailSerializer(serializers.ModelSerializer):
    project = ProjectShortInfoSerializer(many=False)

    class Meta:
        model = ProjectFile
        exclude = ('file_path',)

2. Отображение (apps/projects/views/project_file_views.py)

from rest_framework.generics import RetrieveDestroyAPIView, get_object_or_404

from apps.projects.serializers.project_file_serializers import ProjectFileDetailSerializer
from apps.projects.utils.upload_file_helpers import delete_file


class ProjectFileDetailGenericView(RetrieveDestroyAPIView):
    serializer_class = ProjectFileDetailSerializer

    def get_object(self):
        return get_object_or_404(ProjectFile, pk=self.kwargs['pk'])

    def retrieve(self, request: Request, *args, **kwargs) -> Response:
        task = self.get_object()
        serializer = self.get_serializer(task)
        return Response(
            data=serializer.data,
            status=status.HTTP_200_OK
        )

    def destroy(self, request: Request, *args, **kwargs) -> Response:
        task = self.get_object()
        try:
            delete_file(file_path=task.file_path.path)
        except Exception as e:
            return Response(
                data={"message": str(e)},
                status=status.HTTP_400_BAD_REQUEST
            )
        else:
            task.delete()
        return Response(
            data={"message": "File deleted successfully"},
            status=status.HTTP_200_OK
        )

3. Функция удаления (apps/projects/utils/upload_file_helpers.py)

import os

def delete_file(file_path):
    os.remove(os.path.realpath(file_path))

4. URL (apps/projects/urls.py)

from apps.projects.views.project_file_views import (
    ProjectFileListGenericView,
    ProjectFileDetailGenericView,
)

urlpatterns = [
    path('', ProjectListAPIView.as_view()),
    path('<int:pk>/', ProjectDetailAPIView.as_view()),
    path('files/', ProjectFileListGenericView.as_view()),
    path('files/<int:pk>/', ProjectFileDetailGenericView.as_view()),  # NEW
]

Почему try/except вокруг delete_file? Файл на диске может быть уже удалён вручную, или у сервиса нет прав на директорию. Нужно поймать ошибку и вернуть 400, не давая краша. После успешного удаления с диска — удаляем запись из БД.

Задача 3 — Кастомный пользователь

1. Создание приложения

python manage.py startapp users

2. Позиции (apps/users/choices/positions.py)

from enum import Enum


class Positions(Enum):
    CEO = "CEO"
    CTO = "CTO"
    DESIGNER = "Designer"
    PRODUCT_OWNER = "Product Owner"
    PROJECT_OWNER = "Project Owner"
    PROGRAMMER = "Programmer"
    PROJECT_MANAGER = "Project Manager"
    QA = "QA"

    @classmethod
    def choices(cls):
        return [(attr.name, attr.value) for attr in cls]

Современная альтернатива — TextChoices, но в лекции используется Enum.

3. Модель User (apps/users/models.py)

from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import PermissionsMixin, UserManager
from django.core.validators import MinLengthValidator
from django.db import models
from django.utils.translation import gettext_lazy as _

from apps.projects.models import Project
from apps.users.choices.positions import UserPositions


class User(AbstractBaseUser, PermissionsMixin):
    username = models.CharField(
        _("username"), max_length=50, unique=True,
        error_messages={"unique": _("A user with that username already exists.")}
    )
    first_name = models.CharField(
        _("first name"), max_length=40, validators=[MinLengthValidator(2)]
    )
    last_name = models.CharField(
        _("last name"), max_length=40, validators=[MinLengthValidator(2)]
    )
    email = models.EmailField(_("email address"), max_length=150, unique=True)
    phone = models.CharField(max_length=75, null=True, blank=True)
    is_staff = models.BooleanField(default=False)
    is_active = models.BooleanField(default=True)
    date_joined = models.DateTimeField(name="registered", auto_now_add=True)
    last_login = models.DateTimeField(null=True, blank=True)
    updated_at = models.DateTimeField(auto_now=True)
    deleted_at = models.DateTimeField(null=True, blank=True)
    deleted = models.BooleanField(default=False)
    position = models.CharField(
        max_length=15,
        choices=UserPositions.choices,
        default=UserPositions.PROGRAMMER
    )
    project = models.ForeignKey(
        Project,
        on_delete=models.CASCADE,
        related_name="users",
        null=True,
        blank=True,
    )

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["username", "first_name", "last_name", "position"]
    objects = UserManager()

    def __str__(self):
        return f"{self.last_name} {self.first_name}"

4. settings.py

INSTALLED_APPS = [
    ...
    'apps.users',
]
AUTH_USER_MODEL = 'users.User'

5. Миграции и суперпользователь

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

Задача 4 — Список пользователей

1. Сериализатор (apps/users/serializers.py)

from rest_framework import serializers
from apps.users.models import User


class UserListSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = (
            'first_name',
            'last_name',
            'position',
            'email',
            'phone',
            'last_login',
        )

2. Отображение (apps/users/views.py)

from rest_framework.generics import ListAPIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework import status

from apps.users.models import User
from apps.users.serializers import UserListSerializer


class UserListGenericView(ListAPIView):
    serializer_class = UserListSerializer

    def get_queryset(self):
        project_name = self.request.query_params.get('project_name')
        if project_name:
            return User.objects.filter(project__name=project_name)
        return User.objects.all()

    def list(self, request: Request, *args, **kwargs) -> Response:
        projects = self.get_queryset()
        if not projects.exists():
            return Response(data=[], status=status.HTTP_204_NO_CONTENT)
        serializer = self.get_serializer(projects, many=True)
        return Response(serializer.data, status=status.HTTP_200_OK)

3. router.py

from django.urls import path, include

urlpatterns = [
    path('tasks/', include('apps.tasks.urls')),
    path('projects/', include('apps.projects.urls')),
    path('users/', include('apps.users.urls')),  # NEW
]

4. apps/users/urls.py

from django.urls import path
from apps.users.views import UserListGenericView

urlpatterns = [
    path('', UserListGenericView.as_view()),
]

Задача 5 — Регистрация пользователя

1. Сериализатор (apps/users/serializers.py)

from django.core.exceptions import ValidationError
from django.contrib.auth.password_validation import validate_password
import re


class RegisterUserSerializer(serializers.ModelSerializer):
    re_password = serializers.CharField(
        max_length=128,
        write_only=True,
    )

    class Meta:
        model = User
        fields = (
            'username', 'first_name', 'last_name',
            'email', 'position', 'password', 're_password',
        )
        extra_kwargs = {
            'password': {'write_only': True}
        }

    def validate(self, data):
        username = data.get('username')
        first_name = data.get('first_name')
        last_name = data.get('last_name')

        if not re.match('^[a-zA-Z0-9_]*$', username):
            raise serializers.ValidationError(
                "The username must be alphanumeric characters or have only _ symbol"
            )
        if not re.match('^[a-zA-Z]*$', first_name):
            raise serializers.ValidationError(
                "The first name must contain only alphabet symbols"
            )
        if not re.match('^[a-zA-Z]*$', last_name):
            raise serializers.ValidationError(
                "The last name must contain only alphabet symbols"
            )

        password = data.get("password")
        re_password = data.get("re_password")

        if password != re_password:
            raise serializers.ValidationError({"password": "Passwords don't match"})

        try:
            validate_password(password)
        except ValidationError as err:
            raise serializers.ValidationError({"password": err.messages})

        return data

    def create(self, validated_data):
        password = validated_data.pop('password')
        validated_data.pop('re_password')   # удаляем служебное поле!
        user = User(**validated_data)
        user.set_password(password)         # хешируем
        user.save()
        return user

Ключевой момент: validated_data.pop('re_password') обязателен — иначе Django попытается сохранить несуществующее поле модели и выдаст ошибку.

2. Отображение (apps/users/views.py)

from rest_framework.generics import CreateAPIView
from apps.users.serializers import RegisterUserSerializer


class RegisterUserGenericView(CreateAPIView):
    serializer_class = RegisterUserSerializer

    def create(self, request: Request, *args, **kwargs) -> Response:
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save()
        headers = self.get_success_headers(serializer.data)
        return Response(
            serializer.data,
            status=status.HTTP_201_CREATED,
            headers=headers
        )

3. URL (apps/users/urls.py)

from apps.users.views import UserListGenericView, RegisterUserGenericView

urlpatterns = [
    path('', UserListGenericView.as_view()),
    path('register/', RegisterUserGenericView.as_view()),
]

Задача 6 — Assignee в задаче

1. Добавление поля (apps/tasks/serializers/task_serializers.py)

from apps.users.models import User

class CreateUpdateTaskSerializer(serializers.ModelSerializer):
    # ... другие поля ...
    assignee = serializers.SlugRelatedField(  # NEW
        slug_field='email',
        queryset=User.objects.all(),
        required=False
    )

    class Meta:
        model = Task
        fields = (
            'deadline',
            'assignee',  # NEW (строчная буква!)
            # ... другие нужные поля
        )

Почему SlugRelatedField? Вместо передачи числового ID пользователя передаём email — более читаемо в API и не требует предварительного запроса для получения ID.

Задача 7 — Скачивание файла

1. Отображение (apps/projects/views/project_file_views.py)

from django.http import FileResponse
from rest_framework.views import APIView


class DownloadProjectFileView(APIView):
    def get_object(self):
        return get_object_or_404(ProjectFile, pk=self.kwargs['pk'])

    def get(self, request: Request, *args, **kwargs) -> FileResponse:
        project_file = self.get_object()
        file_handle = project_file.file_path.open()
        response = FileResponse(
            file_handle,
            content_type='application/octet-stream'
        )
        response['Content-Disposition'] = (
            f'attachment; filename="{project_file.file_name}"'
        )
        return response

2. URL (apps/projects/urls.py)

from apps.projects.views.project_file_views import (
    ProjectFileListGenericView,
    ProjectFileDetailGenericView,
    DownloadProjectFileView,  # NEW
)

urlpatterns = [
    path('', ProjectsListAPIView.as_view()),
    path('<int:pk>/', ProjectDetailAPIView.as_view()),
    path('files/', ProjectFileListGenericView.as_view()),
    path('files/download/<int:pk>/', DownloadProjectFileView.as_view()),  # NEW — ВЫШЕ files/<pk>/!
    path('files/<int:pk>/', ProjectFileDetailGenericView.as_view()),
]

Важно: files/download/<pk>/ должен стоять ВЫШЕ files/<pk>/. Иначе Django попытается найти файл с pk="download", что приведёт к 404.