From 356de988935420058ed4c9e6a71ecce3bdab9230 Mon Sep 17 00:00:00 2001 From: Alexander Metzger Date: Mon, 2 Oct 2023 02:10:14 -0700 Subject: [PATCH] added click analytics models, logic, and admin --- url_shortener/admin.py | 60 ++++++++++ ...hortenedurl_use_analytics_clickanalytic.py | 110 ++++++++++++++++++ url_shortener/models.py | 58 +++++++++ url_shortener/routers.py | 70 ++++++++++- 4 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 url_shortener/migrations/0003_shortenedurl_use_analytics_clickanalytic.py diff --git a/url_shortener/admin.py b/url_shortener/admin.py index 5dc4158ba..f5dccf338 100644 --- a/url_shortener/admin.py +++ b/url_shortener/admin.py @@ -28,6 +28,7 @@ class ShortenedURLAdmin(admin.ModelAdmin): "disabled", "created_at", "updated_at", + "use_analytics", ] readonly_fields = [ "clicks", @@ -35,6 +36,7 @@ class ShortenedURLAdmin(admin.ModelAdmin): "updated_at", "shortened_url", "get_saved_runs", + "get_click_analytics", ] exclude = ["saved_runs"] ordering = ["created_at"] @@ -48,3 +50,61 @@ def get_max_clicks(self, obj): @admin.display(description="Saved Runs") def get_saved_runs(self, obj: models.ShortenedURL): return list_related_html_url(obj.saved_runs, show_add=False) + + @admin.display(description="Analytic Clicks") + def get_click_analytics(self, obj: models.ShortenedURL): + if not obj.use_analytics: + return [] + return list_related_html_url( + models.ClickAnalytic.objects.filter(shortened_url__pk=obj.pk), + query_param="shortened_url__id__exact", + instance_id=obj.pk, + show_add=False, + ) + + +@admin.register(models.ClickAnalytic) +class ClickAnalyticAdmin(admin.ModelAdmin): + list_filter = [ + "shortened_url__url", + "created_at", + ] + search_fields = ( + ["ip_address"] + + [ + f"shortened_url__saved_runs__{field}" + for field in SavedRunAdmin.search_fields + ] + + [f"shortened_url__user__{field}" for field in AppUserAdmin.search_fields] + + [f"shortened_url__{field}" for field in ShortenedURLAdmin.search_fields] + ) + list_display = [ + "ip_address", + "platform", + "operating_system", + "device_model", + "country_name", + "city_name", + "created_at", + "location_data", + "user_agent", + "get_saved_runs", + ] + readonly_fields = [ + "ip_address", + "platform", + "operating_system", + "device_model", + "country_name", + "city_name", + "created_at", + "location_data", + "user_agent", + ] + exclude = ["saved_runs"] + ordering = ["created_at"] + actions = [export_to_csv, export_to_excel] + + @admin.display(description="Saved Runs") + def get_saved_runs(self, obj: models.ClickAnalytic): + return list_related_html_url(obj.shortened_url.saved_runs, show_add=False) diff --git a/url_shortener/migrations/0003_shortenedurl_use_analytics_clickanalytic.py b/url_shortener/migrations/0003_shortenedurl_use_analytics_clickanalytic.py new file mode 100644 index 000000000..ff101c606 --- /dev/null +++ b/url_shortener/migrations/0003_shortenedurl_use_analytics_clickanalytic.py @@ -0,0 +1,110 @@ +# Generated by Django 4.2.5 on 2023-10-02 08:20 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("url_shortener", "0002_alter_shortenedurl_url"), + ] + + operations = [ + migrations.AddField( + model_name="shortenedurl", + name="use_analytics", + field=models.BooleanField( + default=False, + help_text="Collect detailed analytics for this shortened url", + ), + ), + migrations.CreateModel( + name="ClickAnalytic", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "ip_address", + models.GenericIPAddressField( + help_text="The IP address of the user who clicked the shortened url" + ), + ), + ( + "user_agent", + models.CharField( + blank=True, + help_text="The user agent of the user who clicked the shortened url", + max_length=512, + ), + ), + ( + "platform", + models.CharField( + blank=True, + help_text="The platform of the user who clicked the shortened url (mobile vs. desktop)", + max_length=128, + ), + ), + ( + "operating_system", + models.CharField( + blank=True, + help_text="The operating system of the user who clicked the shortened url", + max_length=128, + ), + ), + ( + "device_model", + models.CharField( + blank=True, + help_text="The device model of the user who clicked the shortened url", + max_length=128, + ), + ), + ( + "location_data", + models.JSONField( + blank=True, + help_text="The location data of the user who clicked the shortened url", + ), + ), + ( + "country_name", + models.CharField( + blank=True, + help_text="The country name of the user who clicked the shortened url", + max_length=128, + ), + ), + ( + "city_name", + models.CharField( + blank=True, + help_text="The city name of the user who clicked the shortened url", + max_length=128, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "shortened_url", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="click_analytic", + to="url_shortener.shortenedurl", + ), + ), + ], + options={ + "verbose_name": "Click Analytic", + "ordering": ("-created_at",), + "get_latest_by": "created_at", + }, + ), + ] diff --git a/url_shortener/models.py b/url_shortener/models.py index 94dfa999a..166aad968 100644 --- a/url_shortener/models.py +++ b/url_shortener/models.py @@ -119,6 +119,9 @@ class ShortenedURL(models.Model): disabled = models.BooleanField( default=False, help_text="Disable this shortened url" ) + use_analytics = models.BooleanField( + default=False, help_text="Collect detailed analytics for this shortened url" + ) objects = ShortenedURLQuerySet.as_manager() @@ -134,3 +137,58 @@ class Meta: def __str__(self): return self.shortened_url() + " -> " + self.url + + +class ClickAnalytic(models.Model): + shortened_url = models.ForeignKey( + "url_shortener.ShortenedURL", + on_delete=models.DO_NOTHING, + related_name="click_analytic", + ) + ip_address = models.GenericIPAddressField( + help_text="The IP address of the user who clicked the shortened url" + ) + user_agent = models.CharField( + max_length=512, + blank=True, + help_text="The user agent of the user who clicked the shortened url", + ) + platform = models.CharField( + max_length=128, + blank=True, + help_text="The platform of the user who clicked the shortened url (mobile vs. desktop)", + ) + operating_system = models.CharField( + max_length=128, + blank=True, + help_text="The operating system of the user who clicked the shortened url", + ) + device_model = models.CharField( + max_length=128, + blank=True, + help_text="The device model of the user who clicked the shortened url", + ) + location_data = models.JSONField( + blank=True, + help_text="The location data of the user who clicked the shortened url", + ) + country_name = models.CharField( + max_length=128, + blank=True, + help_text="The country name of the user who clicked the shortened url", + ) + city_name = models.CharField( + max_length=128, + blank=True, + help_text="The city name of the user who clicked the shortened url", + ) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ("-created_at",) + get_latest_by = "created_at" + verbose_name = "Click Analytic" + + def __str__(self): + return f"{self.ip_address} clicked on {self.shortened_url.shortened_url()} -> {self.shortened_url.url}" diff --git a/url_shortener/routers.py b/url_shortener/routers.py index e9872c9e5..d0baecef9 100644 --- a/url_shortener/routers.py +++ b/url_shortener/routers.py @@ -1,16 +1,18 @@ from django.db.models import F -from fastapi import APIRouter +from fastapi import APIRouter, Request from fastapi.responses import RedirectResponse from fastapi.responses import Response +import re +import requests -from url_shortener.models import ShortenedURL +from url_shortener.models import ShortenedURL, ClickAnalytic app = APIRouter() @app.api_route("/2/{hashid}", methods=["GET", "POST"]) @app.api_route("/2/{hashid}/", methods=["GET", "POST"]) -def url_shortener(hashid: str): +def url_shortener(hashid: str, request: Request): try: surl = ShortenedURL.objects.get_by_hashid(hashid) except ShortenedURL.DoesNotExist: @@ -20,6 +22,68 @@ def url_shortener(hashid: str): return Response(status_code=410, content="This link has expired") # increment the click count ShortenedURL.objects.filter(id=surl.id).update(clicks=F("clicks") + 1) + if surl.use_analytics: + ip_address = request.client.host # does not work for localhost or with nginx + user_agent = request.headers.get( + "user-agent", "" + ) # note all user agent info can be spoofed + platform = getPlatform(user_agent) + operating_system = getOperatingSystem(user_agent) + device_model = getAndroidDeviceModel(user_agent) + res = requests.get(f"https://iplist.cc/api/{ip_address}") + location_data = res.json() if res.ok else {} + # not all location data will always be available + country_name = location_data.get("countryname", "") + city_name = location_data.get("city", "") + ClickAnalytic.objects.create( + shortened_url=surl, + ip_address=ip_address, + user_agent=user_agent, + platform=platform, + operating_system=operating_system, + device_model=device_model, + location_data=location_data, + country_name=country_name, + city_name=city_name, + ) return RedirectResponse( url=surl.url, status_code=303 # because youtu.be redirects are 303 ) + + +def getPlatform(user_agent: str): + devices = [ + "Android", + "webOS", + "iPhone", + "iPad", + "iPod", + "BlackBerry", + "IEMobile", + "Opera Mini", + ] + return "mobile" if any(device in user_agent for device in devices) else "desktop" + + +def getOperatingSystem(user_agent: str): + if "Win" in user_agent: + return "Windows" + elif "Mac" in user_agent: + return "MacOS" + elif "Linux" in user_agent: + return "Linux" + elif "Android" in user_agent: + return "Android" + elif "like Mac" in user_agent: + return "iOS" + else: + return "Other" + + +def getAndroidDeviceModel(user_agent: str): + regex = r"Android (\d+(?:\.\d+)*);" + matches = re.search(regex, user_agent) + if matches: + return matches.group(1) + else: + return "Other"