Live Coding: Tomatex

Codando e Aprendendo: Criando pomodoro timer com Django + React #5

Fala pessoal,

Nessa live continuamos na implementação dos endpoints relacionados a criação e consulta de pomodoros completos e incompletos. No meio do caminho me deparei sobre a decisão de usar uma rota para criação de pomodoro para ser o mais próximo possível das boas prática em um app RESTFull, como o nested resources . Foi interessante lidar com isso na live porque mostra algumas limitações do Django REST Framework, e como ele pode amarrar você em relação a algumas decisões de design. Segue o vídeo para entender melhor todo o processo:

Implementando a criação de pomodoros

No Pull Request você consegue ver com mais detalhes as mudanças feitas, mas vamos pontuar algumas evoluções importantes. Na última live parei nos testes e na implementação do endpoint de criação de pomodoros (mas que ainda não salvava nada e retornada dados fake), e no serializer responsável por lidar com recebimento dos dados e sua serialização para a resposta. Agora retomamos isso implementando os testes e o modelo de Pomodoro, para refatorarmos as camadas superiores e assim finalizar essa funcionalidade.

@pytest.mark.django_db
def test_task_pomodoro_model(add_task, add_pomodoro):
    task = add_task(description="Task Pomodoro")
    pomodoro = add_pomodoro(task=task, completed=True)

    assert pomodoro.id
    assert pomodoro.task.id == task.id
    assert pomodoro.completed

Como podem ver eu fiz uso do pytest fixtures para que criasse uma task e depois um pomodoro completo (com mais pelo menos 25 minutos de duração). Segue o trecho do arquivo conftest.py.

# ...

@pytest.fixture(scope="function")
def add_pomodoro():
    def _add_pomodoro(task, completed=True):
        started_at = timezone.now()
        ended_at = timezone.now() + timedelta(minutes=POMODORO_UNIT_TIMER)
        pomodoro = Pomodoro.objects.create(
            task=task, started_at=started_at, ended_at=ended_at
        )
        return pomodoro

    return _add_pomodoro

Fiz o uso do django.utils para importar o timezone e gerar uma data, respeitando a propriedade USE_TZ do projeto. Além disso movi a constante POMODORO_UNIT_TIMER para o modelo e fiz uso para gerar um intervalo de tempo de exatos 25 minutos. Esse código vou refatorar porque o parâmetro completed não fez muito sentido aqui, porque caso o programador escolher um pomodoro incompleto, não vai retornar corretamente porque não implementei isso (estava focado em finalizar pelo menos esse recurso na API na live 😂). Agora segue o modelo:

# ...

POMODORO_UNIT_TIMER = 25
ONE_MINUTE = 60

# ...

class Pomodoro(models.Model):
    task = models.ForeignKey(
        "core.Task", related_name="pomodoros", on_delete=models.CASCADE
    )
    started_at = models.DateTimeField()
    ended_at = models.DateTimeField()
    completed = models.BooleanField(default=False)

    def clean(self) -> None:
        diff = self.ended_at - self.started_at
        total_minutes = diff.seconds / ONE_MINUTE

        if total_minutes >= POMODORO_UNIT_TIMER:
            self.completed = True

    def save(self, *args, **kwargs) -> None:
        self.clean()
        return super().save(*args, **kwargs)

Aqui vou explicar em partes:

  • Nas linhas 3-4 eu movi aquelas constantes porque eles seriam usados de fato no modelo e não mais no serializer
  • Na linha 8 em diante está o modelo de pomodoro em que vai armazenar a data de inicio e fim do pomodoro, a relação com a tarefa que é importante, além de uma flag para garantir se o pomodoro foi completo ou não
  • Nas linhas 16-21 implementei o método clean, para validar os dados do modelo, movendo toda aquela regra de pomodoro que estava no serializer. Tomei essa decisão porque faz mais sentido o modelo ter isso internamente do que o serializer, já que, por exemplo, se o modelo for utilizado em outros lugares e criassem um pomodoro através dela, essa regra vai ser garantida.
  • E nas linhas 23-25 sobrescrevi o método save somente para garantir que o método clean será executado.

Com o modelo criado e testado, partimos para refatorar as outras partes relacionados ao recurso de novo pomodoro:

class TaskPomodoroSerializer(serializers.ModelSerializer):
    class Meta:
        model = Pomodoro
        fields = ("id", "started_at", "ended_at", "completed")

    def __init__(self, task, **kwargs):
        self.task = task
        super().__init__(**kwargs)

    def create(self, validated_data):
        return Pomodoro.objects.create(task=self.task, **validated_data)
class TaskPomodoroAPIView(APIView):
	# def get(...)
    
    def post(self, request, uid):
        task = get_object_or_404(Task, uid=uid)
        serializer = TaskPomodoroSerializer(task=task, data=request.data)
        if serializer.is_valid():
            serializer.save()
        return Response(serializer.data, status=status.HTTP_201_CREATED)

Olhando esse código pode-se perguntar: porque alterei o __init__ do serializer? Fiz isso por conta da decisão de design da rota que falei no início. Para que um pomodoro seja criado ele precisa usar a seguinte a rota POST /api/tasks/{uid}/pomodoros. Tentando fazer uma leitura dessa rota a idéia é:

Crie um novo pomodoro para a tarefa XYZ

Que é aonde entra no que falei sobre Nested Resources. O Django REST nesse momento acabou me limitando nessa questão, tanto que existem alguns apps focados em ajudar a resolver isso como o drf-extensions por exemplo. O problema é que eles oferecem a solução em cima de ViewSets e como a idéia é construir parte por parte via TDD, eu tomei a decisão em usar o APIView da biblioteca. Dessa forma, eu teria um impacto considerável em mudar para ViewSets para somente suportar o nested routes da dependência externa. Portanto fiz o seguinte:

  • Sobrescrevi o __init__ do TaskPomodoroSerializer para receber a instância de task (nas linhas 6-8 do serializers.py) buscada na view por meio do get_object_or_404 do Django (na linha 5 do views.py)
  • Atribuo essa instância a uma propriedade na classe (self.task) para ser utilizado posteriormente (na linha 7 do serializers.py).
  • Quando a view invoca o serializer.save() ele vai invocar o método create() que também foi sobrescrito para associar a tarefa ao modelo de Pomodoro (nas linhas 10-11 do serializers.py)

Feito dessa forma, o impacto foi pequeno e não precisei de uma nova dependência. Mas por outro lado mostra que para uma abordagem como essa que não é complexa, precisei “sair um pouco dos trilhos” do Django REST. Ele ajuda a implementar APIs de forma rápida, mas quando você começa a precisar ter mais autonomia no design da sua aplicação, muita das vezes vai ter que passar dos limites do Convention over Configuration da biblioteca.

Quem quiser acompanhar a evolução do codebase é só acessar o repositório e visualizar com mais detalhes a implementação na live.

Até a próxima!

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *