diff --git a/python-backend-engineer/README.md b/python-backend-engineer/README.md index dd9a3f4..9102d37 100644 --- a/python-backend-engineer/README.md +++ b/python-backend-engineer/README.md @@ -10,6 +10,7 @@ Follow the instructions at [https://mise.jdx.dev/getting-started.html](https://m ```bash cd python-backend-engineer mise trust +mise install ``` Specific version of Python and Poetry will be installed in the current folder, and a Python virtual environment will be created and activated. diff --git a/python-backend-engineer/links/migrations/0002_linkclick.py b/python-backend-engineer/links/migrations/0002_linkclick.py new file mode 100644 index 0000000..244e5e5 --- /dev/null +++ b/python-backend-engineer/links/migrations/0002_linkclick.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.6 on 2025-09-25 13:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('links', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='LinkClick', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('clicked_at', models.DateTimeField(auto_now_add=True)), + ('link', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='links.link')), + ], + ), + ] diff --git a/python-backend-engineer/links/models.py b/python-backend-engineer/links/models.py index 0b8ddd9..ca4c203 100644 --- a/python-backend-engineer/links/models.py +++ b/python-backend-engineer/links/models.py @@ -17,3 +17,8 @@ class Link(models.Model): def __str__(self) -> str: return f"{self.short_code} -> {self.original_url}" + + +class LinkClick(models.Model): + link: models.ForeignKey = models.ForeignKey(Link, on_delete=models.CASCADE, related_name="clicks") + clicked_at = models.DateTimeField(auto_now_add=True) diff --git a/python-backend-engineer/links/serializers.py b/python-backend-engineer/links/serializers.py index 3ec50e0..57f974a 100644 --- a/python-backend-engineer/links/serializers.py +++ b/python-backend-engineer/links/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .models import Link +from .models import Link, LinkClick class LinkSerializer(serializers.ModelSerializer): @@ -10,5 +10,12 @@ class Meta: # type: ignore read_only_fields = ["short_code", "created_at"] + +class LinkClickSerializer(serializers.Serializer): + short_code = serializers.CharField() + click_count = serializers.IntegerField() + last_click = serializers.DateTimeField(allow_null=True) + + class LinkCreateSerializer(serializers.Serializer): url = serializers.URLField() diff --git a/python-backend-engineer/links/urls.py b/python-backend-engineer/links/urls.py index d50c72f..5fddc9f 100644 --- a/python-backend-engineer/links/urls.py +++ b/python-backend-engineer/links/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .views import RedirectLinkAPIView, RetrieveLinkAPIView, ShortenLinkAPIView +from .views import LinkStatsAPIView, RedirectLinkAPIView, RetrieveLinkAPIView, ShortenLinkAPIView urlpatterns = [ path("api/shorten/", ShortenLinkAPIView.as_view(), name="shorten_link"), @@ -10,4 +10,5 @@ RetrieveLinkAPIView.as_view(), name="retrieve_link", ), + path("api/stats/", LinkStatsAPIView.as_view(), name="link_stats"), ] diff --git a/python-backend-engineer/links/views.py b/python-backend-engineer/links/views.py index bf59c35..dffc817 100644 --- a/python-backend-engineer/links/views.py +++ b/python-backend-engineer/links/views.py @@ -1,4 +1,5 @@ import json +from re import L from typing import Any from django.http import HttpRequest, HttpResponse, JsonResponse @@ -6,8 +7,8 @@ from drf_spectacular.utils import extend_schema from rest_framework.views import APIView -from .models import Link -from .serializers import LinkCreateSerializer, LinkSerializer +from .models import Link, LinkClick +from .serializers import LinkClickSerializer, LinkCreateSerializer, LinkSerializer class ShortenLinkAPIView(APIView): @@ -27,7 +28,7 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> JsonResponse: serializer = LinkCreateSerializer(data=data) if serializer.is_valid(): original_url: str = serializer.validated_data["url"] - link_obj, created = Link.objects.get_or_create(original_url=original_url) + link_obj, _ = Link.objects.get_or_create(original_url=original_url) output_serializer = LinkSerializer(link_obj) return JsonResponse(output_serializer.data, status=201) return JsonResponse(serializer.errors, status=400) @@ -39,6 +40,9 @@ def get( self, request: HttpRequest, short_code: str, *args: Any, **kwargs: Any ) -> HttpResponse: link_obj = get_object_or_404(Link, short_code=short_code) + LinkClick.objects.create( + link=link_obj, + ) return redirect(link_obj.original_url) @@ -55,3 +59,24 @@ def get( link_obj = get_object_or_404(Link, short_code=short_code) serializer = LinkSerializer(link_obj) return JsonResponse(serializer.data) + + +class LinkStatsAPIView(APIView): + @extend_schema( + description="Retrieve statistics about a shortened URL", + methods=["GET"], + responses={200: LinkClickSerializer}, + tags=["Links"], + ) + def get(self, request: HttpRequest) -> JsonResponse: + links: list[Link] = Link.objects.all() + stats_data = [] + for link in links: + stats_data.append({ + 'short_code': link.short_code, + 'click_count': link.clicks.count(), + 'last_click': link.clicks.first().clicked_at if link.clicks.exists() else None + }) + + serializer = LinkClickSerializer(stats_data, many=True) + return JsonResponse(serializer.data, safe=False) diff --git a/python-backend-engineer/poetry.lock b/python-backend-engineer/poetry.lock index 555c9a1..65b8aa2 100644 --- a/python-backend-engineer/poetry.lock +++ b/python-backend-engineer/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "asgiref"