From 4b5f88f0e7b872dc33f239ffd594b386072fbde4 Mon Sep 17 00:00:00 2001 From: Etchegoyen Matthieu Date: Tue, 7 Jan 2025 23:14:44 +0100 Subject: [PATCH] Implement the hitcount app --- recoco/apps/hitcount/admin.py | 27 ++++ recoco/apps/hitcount/apps.py | 6 + .../apps/hitcount/migrations/0001_initial.py | 149 ++++++++++++++++++ recoco/apps/hitcount/migrations/__init__.py | 0 recoco/apps/hitcount/models.py | 56 +++++++ recoco/apps/hitcount/serializers.py | 59 +++++++ recoco/apps/hitcount/tests/__init__.py | 0 recoco/apps/hitcount/tests/test_views.py | 47 ++++++ recoco/apps/hitcount/urls.py | 7 + recoco/apps/hitcount/utils.py | 12 ++ recoco/apps/hitcount/views.py | 34 ++++ recoco/settings/common.py | 1 + recoco/urls.py | 2 + 13 files changed, 400 insertions(+) create mode 100644 recoco/apps/hitcount/admin.py create mode 100644 recoco/apps/hitcount/apps.py create mode 100644 recoco/apps/hitcount/migrations/0001_initial.py create mode 100644 recoco/apps/hitcount/migrations/__init__.py create mode 100644 recoco/apps/hitcount/models.py create mode 100644 recoco/apps/hitcount/serializers.py create mode 100644 recoco/apps/hitcount/tests/__init__.py create mode 100644 recoco/apps/hitcount/tests/test_views.py create mode 100644 recoco/apps/hitcount/urls.py create mode 100644 recoco/apps/hitcount/utils.py create mode 100644 recoco/apps/hitcount/views.py diff --git a/recoco/apps/hitcount/admin.py b/recoco/apps/hitcount/admin.py new file mode 100644 index 000000000..d0c79a930 --- /dev/null +++ b/recoco/apps/hitcount/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin + +from .models import Hit, HitCount + + +@admin.register(HitCount) +class HitCountAdmin(admin.ModelAdmin): + list_display = ( + "site", + "content_object", + "context_object", + "created", + ) + list_filter = ( + "site", + "created", + ) + + +@admin.register(Hit) +class HitAdmin(admin.ModelAdmin): + list_display = ( + "user", + "hitcount", + "created", + ) + list_filter = ("created",) diff --git a/recoco/apps/hitcount/apps.py b/recoco/apps/hitcount/apps.py new file mode 100644 index 000000000..c0c1ec29e --- /dev/null +++ b/recoco/apps/hitcount/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HitcountConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "recoco.apps.hitcount" diff --git a/recoco/apps/hitcount/migrations/0001_initial.py b/recoco/apps/hitcount/migrations/0001_initial.py new file mode 100644 index 000000000..6fe08f593 --- /dev/null +++ b/recoco/apps/hitcount/migrations/0001_initial.py @@ -0,0 +1,149 @@ +# Generated by Django 4.2.17 on 2025-01-08 08:12 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="HitCount", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ("content_object_id", models.PositiveIntegerField()), + ( + "context_object_id", + models.PositiveIntegerField(blank=True, null=True), + ), + ( + "content_ct", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="hitcount_set_for_content", + to="contenttypes.contenttype", + ), + ), + ( + "context_ct", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="hitcount_set_for_context", + to="contenttypes.contenttype", + ), + ), + ( + "site", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="sites.site" + ), + ), + ], + options={ + "verbose_name": "hit count", + "verbose_name_plural": "hit counts", + "ordering": ("-created",), + "get_latest_by": "modified", + "unique_together": { + ( + "site", + "content_ct", + "content_object_id", + "context_ct", + "context_object_id", + ) + }, + }, + ), + migrations.CreateModel( + name="Hit", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="modified", + ), + ), + ("user_agent", models.CharField(editable=False, max_length=255)), + ( + "hitcount", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="hits", + to="hitcount.hitcount", + ), + ), + ( + "user", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "hit", + "verbose_name_plural": "hits", + "ordering": ("-created",), + "get_latest_by": "created", + }, + ), + ] diff --git a/recoco/apps/hitcount/migrations/__init__.py b/recoco/apps/hitcount/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/recoco/apps/hitcount/models.py b/recoco/apps/hitcount/models.py new file mode 100644 index 000000000..4b4e3d16b --- /dev/null +++ b/recoco/apps/hitcount/models.py @@ -0,0 +1,56 @@ +from django.contrib.auth.models import User +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.contrib.sites.models import Site +from django.db import models +from django.utils.translation import gettext_lazy as _ +from model_utils.models import TimeStampedModel + + +class HitCount(TimeStampedModel): + site = models.ForeignKey(Site, on_delete=models.CASCADE) + + content_ct = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + related_name="hitcount_set_for_content", + ) + content_object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_ct", "content_object_id") + + context_ct = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + related_name="hitcount_set_for_context", + null=True, + blank=True, + ) + context_object_id = models.PositiveIntegerField(null=True, blank=True) + context_object = GenericForeignKey("context_ct", "context_object_id") + + class Meta: + verbose_name = _("hit count") + verbose_name_plural = _("hit counts") + ordering = ("-created",) + get_latest_by = "modified" + unique_together = ( + "site", + "content_ct", + "content_object_id", + "context_ct", + "context_object_id", + ) + + +class Hit(TimeStampedModel): + user_agent = models.CharField(max_length=255, editable=False) + user = models.ForeignKey(User, editable=False, on_delete=models.CASCADE) + hitcount = models.ForeignKey( + HitCount, editable=False, on_delete=models.CASCADE, related_name="hits" + ) + + class Meta: + verbose_name = _("hit") + verbose_name_plural = _("hits") + ordering = ("-created",) + get_latest_by = "created" diff --git a/recoco/apps/hitcount/serializers.py b/recoco/apps/hitcount/serializers.py new file mode 100644 index 000000000..4067454b0 --- /dev/null +++ b/recoco/apps/hitcount/serializers.py @@ -0,0 +1,59 @@ +from typing import Any + +from django.apps import apps +from rest_framework import serializers + +from .utils import ct_from_label + + +class HitInputSerializer(serializers.Serializer): + content_ct = serializers.CharField() + content_object_id = serializers.IntegerField() + context_ct = serializers.CharField(required=False) + context_object_id = serializers.IntegerField(required=False) + + def validate(self, data): + content_ct = data.get("content_ct") + content_object_id = data.get("content_object_id") + context_ct = data.get("context_ct") + context_object_id = data.get("context_object_id") + + if context_ct and not context_object_id: + raise serializers.ValidationError( + "context_ct is required when context_object_id is provided" + ) + + if context_object_id and not context_ct: + raise serializers.ValidationError( + "context_object_id is required when context_ct is provided" + ) + + content_obj_model = apps.get_model(content_ct) + try: + content_obj_model.objects.get(id=content_object_id) + except content_obj_model.DoesNotExist as exc: + raise serializers.ValidationError("Invalid content_object_id") from exc + + if context_ct: + context_object_model = apps.get_model(context_ct) + try: + context_object_model.objects.get(id=data.get("context_object_id")) + except context_object_model.DoesNotExist as exc: + raise serializers.ValidationError("Invalid context_object_id") from exc + + return data + + @property + def output_data(self) -> dict[str, Any]: + data = { + "content_ct": ct_from_label(self.validated_data["content_ct"]), + "content_object_id": self.validated_data["content_object_id"], + } + if not self.validated_data.get("context_ct"): + data.update( + { + "context_ct": ct_from_label(self.validated_data["context_ct"]), + "context_object_id": self.validated_data["context_object_id"], + } + ) + return data diff --git a/recoco/apps/hitcount/tests/__init__.py b/recoco/apps/hitcount/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/recoco/apps/hitcount/tests/test_views.py b/recoco/apps/hitcount/tests/test_views.py new file mode 100644 index 000000000..49393debe --- /dev/null +++ b/recoco/apps/hitcount/tests/test_views.py @@ -0,0 +1,47 @@ +import json + +import pytest +from django.contrib.auth.models import User +from django.urls import reverse +from model_bakery import baker +from rest_framework.test import APIClient + +from recoco.apps.addressbook.models import Contact +from recoco.apps.hitcount.models import HitCount +from recoco.apps.hitcount.utils import ct_label +from recoco.apps.tasks.models import Task + + +@pytest.mark.django_db +def test_hit_view(): + user = baker.make(User) + contact = baker.make(Contact) + recommendation = baker.make(Task) + + api_client = APIClient() + api_client.force_authenticate(user=user) + + url = reverse("api_hit") + headers = {"user-agent": "Mozilla/5.0"} + payload = json.dumps( + { + "content_ct": ct_label(contact), + "content_object_id": contact.id, + "context_ct": ct_label(recommendation), + "context_object_id": recommendation.id, + } + ) + + response = api_client.post( + path=url, headers=headers, data=payload, content_type="application/json" + ) + assert response.status_code == 200 + + response = api_client.post( + path=url, headers=headers, data=payload, content_type="application/json" + ) + assert response.status_code == 200 + + assert HitCount.objects.count() == 1 + hitcount = HitCount.objects.first() + assert hitcount.hits.count() == 2 diff --git a/recoco/apps/hitcount/urls.py b/recoco/apps/hitcount/urls.py new file mode 100644 index 000000000..94bcb52ba --- /dev/null +++ b/recoco/apps/hitcount/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import HitView + +urlpatterns = [ + path("hit/", HitView.as_view(), name="api_hit"), +] diff --git a/recoco/apps/hitcount/utils.py b/recoco/apps/hitcount/utils.py new file mode 100644 index 000000000..3bcee52fa --- /dev/null +++ b/recoco/apps/hitcount/utils.py @@ -0,0 +1,12 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.models import Model + + +def ct_label(obj: Model) -> str: + obj_content_type = ContentType.objects.get_for_model(obj) + return f"{obj_content_type.app_label}.{obj_content_type.model}" + + +def ct_from_label(content_type: str) -> ContentType: + app_label, model = content_type.split(".") + return ContentType.objects.get(app_label=app_label, model=model) diff --git a/recoco/apps/hitcount/views.py b/recoco/apps/hitcount/views.py new file mode 100644 index 000000000..59b0f5715 --- /dev/null +++ b/recoco/apps/hitcount/views.py @@ -0,0 +1,34 @@ +from django.db import transaction +from rest_framework import status +from rest_framework.parsers import JSONParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import Hit, HitCount +from .serializers import HitInputSerializer + + +class HitView(APIView): + permission_classes = [IsAuthenticated] + parser_classes = (JSONParser,) + + def post(self, request, *args, **kwargs): + serializer = HitInputSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + hitcount, _ = HitCount.objects.get_or_create( + site=request.site, + **serializer.output_data, + ) + Hit.objects.create( + user=request.user, + user_agent=request.headers.get("user-agent"), + hitcount=hitcount, + ) + + return Response( + status=status.HTTP_200_OK, + data={"message": "Hit registered."}, + ) diff --git a/recoco/settings/common.py b/recoco/settings/common.py index 3711debd9..bbde6e21c 100644 --- a/recoco/settings/common.py +++ b/recoco/settings/common.py @@ -72,6 +72,7 @@ "phonenumber_field", "cookie_consent", "recoco.apps.feature_flag", + "recoco.apps.hitcount", "recoco.apps.dsrc", "recoco.apps.onboarding", "recoco.apps.home", diff --git a/recoco/urls.py b/recoco/urls.py index 1907eddc7..7c25b23c1 100644 --- a/recoco/urls.py +++ b/recoco/urls.py @@ -19,6 +19,7 @@ from recoco.apps.addressbook.urls import urlpatterns as addressbook_urls from recoco.apps.crm.urls import urlpatterns as crm_urls +from recoco.apps.hitcount.urls import urlpatterns as hitcount_urls from recoco.apps.home.urls import urlpatterns as home_urls from recoco.apps.invites.urls import urlpatterns as invites_urls from recoco.apps.onboarding.urls import urlpatterns as onboarding_urls @@ -51,6 +52,7 @@ urlpatterns.extend(survey_urls) urlpatterns.extend(invites_urls) urlpatterns.extend(crm_urls) +urlpatterns.extend(hitcount_urls) if settings.DEBUG: import debug_toolbar