Skip to content

Commit

Permalink
added click analytics models, logic, and admin
Browse files Browse the repository at this point in the history
  • Loading branch information
SanderGi committed Oct 2, 2023
1 parent 0a8a22b commit 356de98
Show file tree
Hide file tree
Showing 4 changed files with 295 additions and 3 deletions.
60 changes: 60 additions & 0 deletions url_shortener/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ class ShortenedURLAdmin(admin.ModelAdmin):
"disabled",
"created_at",
"updated_at",
"use_analytics",
]
readonly_fields = [
"clicks",
"created_at",
"updated_at",
"shortened_url",
"get_saved_runs",
"get_click_analytics",
]
exclude = ["saved_runs"]
ordering = ["created_at"]
Expand All @@ -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)
Original file line number Diff line number Diff line change
@@ -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",
},
),
]
58 changes: 58 additions & 0 deletions url_shortener/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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}"
70 changes: 67 additions & 3 deletions url_shortener/routers.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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"

0 comments on commit 356de98

Please sign in to comment.