✅ Решения мини-проекта Agile Projects

⚡ Ключевые решения

# Задание 1: модель Tag
class Tag(models.Model):
    name = models.CharField(max_length=20, validators=[MinLengthValidator(4)])
    def __str__(self): return self.name

# Задание 4: модель Task — deadline по умолчанию
deadline = models.DateTimeField(default=calculate_end_of_month)
# где calculate_end_of_month() считает последний день текущего месяца

# Задание 5: TagSerializer
class TagSerializer(serializers.ModelSerializer):
    class Meta:
        model = Tag
        fields = '__all__'

# Задание 7: фильтрация по датам
date_from = timezone.make_aware(datetime.strptime(date_from, '%Y-%m-%d'))
projects  = Project.objects.filter(created_at__range=[date_from, date_to])

# Задание 8: частичное обновление
serializer = CreateProjectSerializer(instance=project, data=request.data, partial=True)

# Задание 9: файл передаётся через context
serializer = CreateProjectFileSerializer(data=request.data, context={"raw_file": file_content})

Решение 1 — Модель Tag

Структура файлов:

  1. В пустом проекте создайте папку apps с пустым файлом __init__.py — модуль.
  2. Перейдите в эту папку в консоли и введите команду python ../manage.py startapp tasks.
  3. В настройках Django найдите список INSTALLED_APPS и добавьте строку 'apps.tasks.apps.TasksConfig'.
  4. В новом приложении создайте модуль models: удалите файл models.py, создайте файл tag.py.
# apps/tasks/models/tag.py
from django.db import models
from django.core.validators import MinLengthValidator

class Tag(models.Model):
    name = models.CharField(max_length=20, validators=[MinLengthValidator(4)])

    def __str__(self):
        return self.name
# apps/tasks/models/__init__.py
from apps.tasks.models.tag import Tag

5. В консоль введите python manage.py makemigrations и следом python manage.py migrate.
6. Git-команды: git add . && git commit -m "feat: task 1 — Tag model" && git push origin feature/task-1

Решение 2 — Модель Project

  1. Перейдите в папку apps, введите команду python manage.py startapp projects.
  2. В INSTALLED_APPS добавьте 'apps.projects.apps.ProjectsConfig'.
  3. Удалите models.py, создайте файл project.py.
# apps/projects/models/project.py
from django.db import models

class Project(models.Model):
    name = models.CharField(unique=True, max_length=100)
    description = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name

    class Meta:
        ordering = ['-name']

Сортировка ordering = ['-name'] — по имени в обратном алфавитном порядке (знак - означает убывание).

Решение 3 — Модель ProjectFile и связь M2M

# apps/projects/models/project.py — обновлённая версия
from django.db import models

class Project(models.Model):
    name = models.CharField(unique=True, max_length=100)
    description = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    files = models.ManyToManyField('ProjectFile', related_name='project')

    @property
    def count_of_files(self):
        return self.files.count()

    def __str__(self):
        return self.name

    class Meta:
        ordering = ['-name']


class ProjectFile(models.Model):
    file_name = models.CharField(max_length=120)
    file_path = models.FileField(upload_to='documents/')
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.file_name

    class Meta:
        ordering = ['-created_at']

@property count_of_files — вычисляемое поле, не хранится в БД, вычисляется динамически. Используется в сериализаторе ProjectDetailSerializer.

Решение 4 — Модель Task со статусами, приоритетами и дедлайном

# apps/tasks/utils/set_end_of_month.py
from datetime import datetime
import calendar
from django.utils import timezone

def calculate_end_of_month() -> datetime:
    current_date = timezone.now()
    amount_of_days = calendar.monthrange(
        current_date.year,
        current_date.month
    )[1]
    date = datetime(
        year=current_date.year,
        month=current_date.month,
        day=amount_of_days,
    )
    return date.astimezone()
# apps/tasks/choices/statuses.py
from enum import Enum

class Statuses(Enum):
    NEW = "NEW"
    IN_PROGRESS = "IN_PROGRESS"
    PENDING = "PENDING"
    BLOCKED = "BLOCKED"
    TESTING = "TESTING"
    CLOSED = "CLOSED"

    @classmethod
    def choices(cls):
        return [(attr.name, attr.value) for attr in cls]
# apps/tasks/choices/priority.py
from enum import Enum

class Priority(Enum):
    VERY_LOW = (1, 'Very Low')
    LOW = (2, 'Low')
    MEDIUM = (3, 'Medium')
    HIGH = (4, 'High')
    CRITICAL = (5, 'Critical')

    @classmethod
    def choices(cls):
        return [(key.value[0], key.value[1]) for key in cls]

    def __getitem__(self, item):
        return self.value[item]
# apps/tasks/models/task.py
from django.contrib.auth.models import User
from django.db import models
from apps.projects.models.project import Project
from apps.tasks.choices.statuses import Statuses
from apps.tasks.choices.priority import Priority
from apps.tasks.utils.set_end_of_month import calculate_end_of_month

class Task(models.Model):
    name = models.CharField(max_length=120)
    description = models.TextField()
    status = models.CharField(
        max_length=15,
        choices=Statuses.choices,
        default=Statuses.NEW
    )
    priority = models.SmallIntegerField(
        choices=Priority,
        default=Priority.MEDIUM[0]
    )
    project = models.ForeignKey(
        Project,
        on_delete=models.CASCADE,
        related_name='tasks'
    )
    tags = models.ManyToManyField('Tag', related_name='tasks')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    deleted_at = models.DateTimeField(null=True, blank=True)
    deadline = models.DateTimeField(default=calculate_end_of_month)
    assignee = models.ForeignKey(
        User,
        on_delete=models.PROTECT,
        related_name='tasks',
        null=True,
        blank=True
    )

    class Meta:
        ordering = ['-deadline']
        unique_together = ('name', 'project')

    def __str__(self):
        return f"{self.name}, status: {self.status}"

Решение 5 — TagListAPIView (GET список + POST создание)

# apps/tasks/serializers/tag_serializers.py
from rest_framework import serializers
from apps.tasks.models import Tag

class TagSerializer(serializers.ModelSerializer):
    class Meta:
        model = Tag
        fields = '__all__'
# apps/tasks/views/tag_views.py
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework import status
from rest_framework.views import APIView
from apps.tasks.models import Tag
from apps.tasks.serializers.tag_serializers import TagSerializer

class TagListAPIView(APIView):
    def get_objects(self):
        return Tag.objects.all()

    def get(self, request: Request) -> Response:
        tags = self.get_objects()
        if not tags.exists():
            return Response(data=[], status=status.HTTP_204_NO_CONTENT)
        serializer = TagSerializer(tags, many=True)
        return Response(serializer.data, status=status.HTTP_200_OK)

    def post(self, request: Request) -> Response:
        serializer = TagSerializer(data=request.data)
        if serializer.is_valid(raise_exception=True):
            serializer.save()
            return Response(serializer.validated_data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# agile_projects/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/v1/', include('apps.routers')),
]

# apps/routers.py
from django.urls import path, include
urlpatterns = [
    path('tasks/', include('apps.tasks.urls')),
]

# apps/tasks/urls.py
from django.urls import path
from apps.tasks.views.tag_views import TagListAPIView
urlpatterns = [
    path('tags/', TagListAPIView.as_view()),
]

Решение 6 — TagDetailAPIView (GET/PUT/DELETE)

# apps/tasks/views/tag_views.py — добавить класс
from rest_framework.generics import get_object_or_404

class TagDetailAPIView(APIView):
    def get_object(self, pk: int) -> Tag:
        return get_object_or_404(Tag, pk=pk)

    def get(self, request: Request, pk: int) -> Response:
        tag = self.get_object(pk=pk)
        serializer = TagSerializer(tag)
        return Response(serializer.data, status=status.HTTP_200_OK)

    def put(self, request: Request, pk: int) -> Response:
        tag = self.get_object(pk=pk)
        serializer = TagSerializer(tag, data=request.data)
        if serializer.is_valid(raise_exception=True):
            serializer.save()
            return Response(serializer.validated_data, status=status.HTTP_200_OK)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    def delete(self, request: Request, pk: int) -> Response:
        tag = self.get_object(pk=pk)
        tag.delete()
        return Response(
            data={"message": "Tag was deleted successfully"},
            status=status.HTTP_200_OK
        )
# apps/tasks/urls.py — обновлённый
from apps.tasks.views.tag_views import TagListAPIView, TagDetailAPIView
urlpatterns = [
    path('tags/', TagListAPIView.as_view()),
    path('tags/<int:pk>/', TagDetailAPIView.as_view()),
]

Решение 7 — ProjectsListAPIView с фильтрацией по датам

# apps/projects/serializers/project_serializers.py
from rest_framework import serializers
from apps.projects.models import Project

class AllProjectsSerializer(serializers.ModelSerializer):
    class Meta:
        model = Project
        fields = ('id', 'name', 'created_at')

class CreateProjectSerializer(serializers.ModelSerializer):
    created_at = serializers.DateTimeField(read_only=True)

    class Meta:
        model = Project
        fields = ('name', 'description', 'created_at')

    def validate_description(self, value: str) -> str:
        if len(value) < 30:
            raise serializers.ValidationError(
                "Description must be at least 30 characters long"
            )
        return value
# apps/projects/views/project_views.py
from datetime import datetime
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework import status
from rest_framework.views import APIView
from django.utils import timezone
from apps.projects.models import Project
from apps.projects.serializers.project_serializers import (
    AllProjectsSerializer, CreateProjectSerializer
)

class ProjectsListAPIView(APIView):
    def get_objects(self, date_from=None, date_to=None):
        if date_from and date_to:
            date_from = timezone.make_aware(
                datetime.strptime(date_from, '%Y-%m-%d')
            )
            date_to = timezone.make_aware(
                datetime.strptime(date_to, '%Y-%m-%d')
            ) if date_from else timezone.now().strftime('%Y-%m-%d')
            return Project.objects.filter(
                created_at__range=[date_from, date_to]
            )
        return Project.objects.all()

    def get(self, request: Request) -> Response:
        date_from = request.query_params.get('date_from')
        date_to   = request.query_params.get('date_to')
        projects  = self.get_objects(date_from, date_to)
        if not projects.exists():
            return Response(data=[], status=status.HTTP_204_NO_CONTENT)
        serializer = AllProjectsSerializer(projects, many=True)
        return Response(serializer.data, status=status.HTTP_200_OK)

    def post(self, request: Request) -> Response:
        serializer = CreateProjectSerializer(data=request.data)
        if serializer.is_valid(raise_exception=True):
            serializer.save()
            return Response(serializer.validated_data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# apps/routers.py — обновлённый
urlpatterns = [
    path('tasks/', include('apps.tasks.urls')),
    path('projects/', include('apps.projects.urls')),
]

# apps/projects/urls.py
from apps.projects.views.project_views import ProjectsListAPIView
urlpatterns = [
    path('', ProjectsListAPIView.as_view()),
]

Решение 8 — ProjectDetailAPIView с частичным обновлением

# apps/projects/serializers/project_serializers.py — добавить
class ProjectDetailSerializer(serializers.ModelSerializer):
    class Meta:
        model = Project
        fields = ('id', 'name', 'description', 'count_of_files')
# apps/projects/views/project_views.py — добавить класс
from rest_framework.generics import get_object_or_404

class ProjectDetailAPIView(APIView):
    def get_object(self, pk: int):
        return get_object_or_404(Project, pk=pk)

    def get(self, request: Request, pk: int) -> Response:
        project = self.get_object(pk=pk)
        serializer = ProjectDetailSerializer(project)
        return Response(serializer.data, status=status.HTTP_200_OK)

    def put(self, request: Request, pk: int) -> Response:
        project = self.get_object(pk=pk)
        serializer = CreateProjectSerializer(
            instance=project,
            data=request.data,
            partial=True          # разрешить частичное обновление
        )
        if serializer.is_valid(raise_exception=True):
            serializer.save()
            return Response(serializer.validated_data, status=status.HTTP_200_OK)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    def delete(self, request: Request, pk: int) -> Response:
        project = self.get_object(pk=pk)
        project.delete()
        return Response(
            data={"message": "Project was deleted successfully"},
            status=status.HTTP_200_OK
        )
# apps/projects/urls.py — обновлённый
urlpatterns = [
    path('', ProjectsListAPIView.as_view()),
    path('<int:pk>/', ProjectDetailAPIView.as_view()),
]

Решение 9 — Работа с файлами

# apps/projects/utils/upload_file_helpers.py
import os
from pathlib import Path

ALLOWED_EXTENSIONS = ['.csv', '.doc', '.pdf', '.xlsx', '.py']

def check_extension(filename: str) -> bool:
    extension = Path(filename).suffix
    return extension in ALLOWED_EXTENSIONS

def check_file_size(file, required_size: int = 2) -> bool:
    file_size = file.size / (1024 * 1024)
    return file_size <= required_size

def create_file_path(file_name: str) -> str:
    new_file_name, file_ext = file_name.split('.')
    return f"documents/{new_file_name}.{file_ext}"

def save_file(file_path: str, file_content) -> str:
    os.makedirs(os.path.dirname('documents/'), exist_ok=True)
    with open(file_path, 'wb') as f:
        for chunk in file_content.chunks():
            f.write(chunk)
    return file_path
# apps/projects/serializers/project_file_serializers.py
from rest_framework import serializers
from apps.projects.models import ProjectFile
from apps.projects.utils.upload_file_helpers import (
    check_extension, create_file_path, check_file_size, save_file
)

class AllProjectFilesSerializer(serializers.ModelSerializer):
    class Meta:
        model = ProjectFile
        fields = ('id', 'file_name', 'project')

class CreateProjectFileSerializer(serializers.ModelSerializer):
    class Meta:
        model = ProjectFile
        fields = ('file_name',)

    def validate_file_name(self, value: str) -> str:
        if not value.isascii():
            raise serializers.ValidationError("Please, provide a valid file name.")
        if not check_extension(value):
            raise serializers.ValidationError(
                "Valid file extensions: ['.csv', '.doc', '.pdf', '.xlsx']"
            )
        return value

    def create(self, validated_data):
        file_path = create_file_path(file_name=validated_data['file_name'])
        raw_file  = self.context.get('raw_file')
        if check_file_size(file=raw_file):
            save_file(file_path=file_path, file_content=raw_file)
            validated_data['file_path'] = file_path
            return ProjectFile.objects.create(**validated_data)
        raise serializers.ValidationError("File size is too large (2 MB as maximum).")
# apps/projects/views/project_file_views.py
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.generics import get_object_or_404
from apps.projects.models import ProjectFile, Project
from apps.projects.serializers.project_file_serializers import (
    AllProjectFilesSerializer, CreateProjectFileSerializer
)

class ProjectFileListAPIView(APIView):
    def get_objects(self, project_name=None):
        if project_name:
            return ProjectFile.objects.filter(project__name=project_name)
        return ProjectFile.objects.all()

    def get(self, request: Request) -> Response:
        project_name  = request.query_params.get('project')
        project_files = self.get_objects(project_name)
        if not project_files.exists():
            return Response(data=[], status=status.HTTP_204_NO_CONTENT)
        serializer = AllProjectFilesSerializer(project_files, many=True)
        return Response(serializer.data, status=status.HTTP_200_OK)

    def post(self, request: Request) -> Response:
        file_content = request.FILES["file"]
        project_id   = request.data["project_id"]
        project      = get_object_or_404(Project, pk=project_id)
        serializer   = CreateProjectFileSerializer(
            data=request.data,
            context={"raw_file": file_content}
        )
        if serializer.is_valid(raise_exception=True):
            project_file = serializer.save()
            project_file.project.set([project])
            return Response(
                {"message": "File upload successfully"},
                status=status.HTTP_200_OK
            )
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# apps/projects/urls.py — финальный
from apps.projects.views.project_file_views import ProjectFileListAPIView
urlpatterns = [
    path('', ProjectsListAPIView.as_view()),
    path('<int:pk>/', ProjectDetailAPIView.as_view()),
    path('files/', ProjectFileListAPIView.as_view()),
]

Проверка через Postman: POST /api/v1/projects/files/ с Content-Type: multipart/form-data, поля: file_name, project_id, file (файл).