diff --git a/analytics/__init__.py b/analytics/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/analytics/admin.py b/analytics/admin.py new file mode 100644 index 000000000..ff70aadb9 --- /dev/null +++ b/analytics/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from dref.admin import ReadOnlyMixin + +from .models import DocumentDownloadLog + + +@admin.register(DocumentDownloadLog) +class DocumentDownloadLogAdmin(ReadOnlyMixin, admin.ModelAdmin): + list_display = ("downloaded_at", "document_type", "source", "object_id", "user", "ip_address") + list_filter = ("document_type", "source") + date_hierarchy = "downloaded_at" + search_fields = ("url", "user__username", "user__email") + readonly_fields = ( + "document_type", + "object_id", + "url", + "source", + "user", + "downloaded_at", + "ip_address", + ) + ordering = ("-downloaded_at",) diff --git a/analytics/apps.py b/analytics/apps.py new file mode 100644 index 000000000..037da2200 --- /dev/null +++ b/analytics/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AnalyticsConfig(AppConfig): + name = "analytics" + verbose_name = _("analytics") diff --git a/analytics/management/__init__.py b/analytics/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/analytics/management/commands/__init__.py b/analytics/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/analytics/management/commands/prune_download_logs.py b/analytics/management/commands/prune_download_logs.py new file mode 100644 index 000000000..45948772f --- /dev/null +++ b/analytics/management/commands/prune_download_logs.py @@ -0,0 +1,15 @@ +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from analytics.models import DocumentDownloadLog + + +class Command(BaseCommand): + help = "Delete DocumentDownloadLog entries older than 6 months." + + def handle(self, *args, **options): + cutoff = timezone.now() - timedelta(days=183) + deleted, _ = DocumentDownloadLog.objects.filter(downloaded_at__lt=cutoff).delete() + self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} document download log entries older than 6 months.")) diff --git a/analytics/migrations/0001_initial.py b/analytics/migrations/0001_initial.py new file mode 100644 index 000000000..e9e915ab2 --- /dev/null +++ b/analytics/migrations/0001_initial.py @@ -0,0 +1,84 @@ +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DocumentDownloadLog", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "document_type", + models.CharField( + choices=[ + ("situation_report", "Situation Report"), + ("appeal_document", "Appeal Document"), + ("general_document", "General Document"), + ("country_document", "Country Key Document"), + ("featured_document", "Event Featured Document"), + ("dref_file", "DREF File"), + ("per_document", "PER Document"), + ("other", "Other"), + ], + max_length=50, + verbose_name="document type", + ), + ), + ( + "object_id", + models.PositiveIntegerField(blank=True, db_index=True, null=True, verbose_name="object id"), + ), + ("url", models.URLField(max_length=2000, verbose_name="url")), + ( + "source", + models.CharField( + choices=[ + ("azure_blob", "Azure Blob"), + ("adore", "Adore"), + ("sharepoint", "SharePoint"), + ("other", "Other"), + ], + default="other", + max_length=20, + verbose_name="source", + ), + ), + ( + "downloaded_at", + models.DateTimeField(auto_now_add=True, db_index=True, verbose_name="downloaded at"), + ), + ( + "ip_address", + models.GenericIPAddressField( + blank=True, null=True, unpack_ipv4=True, verbose_name="ip address" + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="document_download_logs", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ], + options={ + "verbose_name": "document download log", + "verbose_name_plural": "document download logs", + "ordering": ("-downloaded_at",), + }, + ), + ] diff --git a/analytics/migrations/0002_alter_documentdownloadlog_source.py b/analytics/migrations/0002_alter_documentdownloadlog_source.py new file mode 100644 index 000000000..3e03d6e81 --- /dev/null +++ b/analytics/migrations/0002_alter_documentdownloadlog_source.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.30 on 2026-07-11 11:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('analytics', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='documentdownloadlog', + name='source', + field=models.CharField(choices=[('azure_blob', 'Azure Blob'), ('adore', 'Adore'), ('goapi', 'GOAPI'), ('sharepoint', 'SharePoint'), ('other', 'Other')], default='other', max_length=20, verbose_name='source'), + ), + ] diff --git a/analytics/migrations/__init__.py b/analytics/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/analytics/models.py b/analytics/models.py new file mode 100644 index 000000000..bc43c6f1f --- /dev/null +++ b/analytics/models.py @@ -0,0 +1,117 @@ +import ipaddress + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +def mask_ip_address(ip: str) -> str | None: + """ + Return the IP address with its first segment zeroed out for privacy. + IPv4: 192.168.1.5 -> 0.168.1.5 + IPv6: 2001:db8::1 -> 0:db8::1 + """ + if not ip: + return None + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return None + if addr.version == 4: + parts = ip.split(".") + parts[0] = "0" + return ".".join(parts) + # IPv6 – zero the first group + # Expand so we always have 8 groups before masking + expanded = ipaddress.ip_address(ip).exploded + parts = expanded.split(":") + parts[0] = "0000" + return ":".join(parts) + + +class DocumentDownloadLog(models.Model): + class DocumentSource(models.TextChoices): + # External Source + AZURE_BLOB = "azure_blob", _("Azure Blob") + # Internal Source + ADORE = "adore", _("Adore") + GOAPI = "goapi", _("GOAPI") + SHAREPOINT = "sharepoint", _("SharePoint") + OTHER = "other", _("Other") + + class DocumentType(models.TextChoices): + SITUATION_REPORT = "situation_report", _("Situation Report") + APPEAL_DOCUMENT = "appeal_document", _("Appeal Document") + GENERAL_DOCUMENT = "general_document", _("General Document") + COUNTRY_DOCUMENT = "country_document", _("Country Key Document") + FEATURED_DOCUMENT = "featured_document", _("Event Featured Document") + DREF_FILE = "dref_file", _("DREF File") + PER_DOCUMENT = "per_document", _("PER Document") + OTHER = "other", _("Other") + + document_type = models.CharField( + verbose_name=_("document type"), + max_length=50, + choices=DocumentType.choices, + ) + # PK of the source record – nullable for purely external documents + object_id = models.PositiveIntegerField( + verbose_name=_("object id"), + null=True, + blank=True, + db_index=True, + ) + url = models.URLField( + verbose_name=_("url"), + max_length=2000, + ) + source = models.CharField( + verbose_name=_("source"), + max_length=20, + choices=DocumentSource.choices, + default=DocumentSource.OTHER, + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_("user"), + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="document_download_logs", + ) + downloaded_at = models.DateTimeField( + verbose_name=_("downloaded at"), + auto_now_add=True, + db_index=True, + ) + # First segment zeroed; e.g. 0.168.1.5 for IPv4 + ip_address = models.GenericIPAddressField( + verbose_name=_("ip address"), + null=True, + blank=True, + unpack_ipv4=True, + ) + + class Meta: + verbose_name = _("document download log") + verbose_name_plural = _("document download logs") + ordering = ("-downloaded_at",) + + def __str__(self): + return f"{self.document_type} / {self.object_id} @ {self.downloaded_at}" + + +def detect_source(url: str) -> str: + """Classify a download URL into a DocumentSource choice.""" + azure_account = getattr(settings, "AZURE_STORAGE_ACCOUNT", None) + if azure_account and azure_account in url: + return DocumentDownloadLog.DocumentSource.AZURE_BLOB + if "blob.core.windows.net" in url: + return DocumentDownloadLog.DocumentSource.AZURE_BLOB + if "go-api.ifrc.org" in url: + return DocumentDownloadLog.DocumentSource.GOAPI + if "adore.ifrc.org" in url: + return DocumentDownloadLog.DocumentSource.ADORE + if "sharepoint.com" in url: + return DocumentDownloadLog.DocumentSource.SHAREPOINT + return DocumentDownloadLog.DocumentSource.OTHER diff --git a/analytics/serializers.py b/analytics/serializers.py new file mode 100644 index 000000000..868d1b665 --- /dev/null +++ b/analytics/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from .models import DocumentDownloadLog + + +class DocumentDownloadLogSerializer(serializers.ModelSerializer): + class Meta: + model = DocumentDownloadLog + fields = ("id", "document_type", "object_id", "url", "source") + read_only_fields = ("id",) diff --git a/analytics/views.py b/analytics/views.py new file mode 100644 index 000000000..01b6b9c8e --- /dev/null +++ b/analytics/views.py @@ -0,0 +1,45 @@ +from rest_framework import mixins, viewsets +from rest_framework.authentication import TokenAuthentication + +from .models import DocumentDownloadLog, detect_source, mask_ip_address +from .serializers import DocumentDownloadLogSerializer + + +def _get_client_ip(request) -> str | None: + forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if forwarded_for: + return forwarded_for.split(",")[0].strip() + return request.META.get("REMOTE_ADDR") + + +class DocumentDownloadLogViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): + """ + Log a document download event. + + The frontend calls this endpoint (fire-and-forget) each time a user + initiates a download. Authentication is optional: anonymous downloads + are recorded with a null user. + """ + + authentication_classes = (TokenAuthentication,) + # No permission_classes – allow unauthenticated requests + permission_classes = [] + serializer_class = DocumentDownloadLogSerializer + + def perform_create(self, serializer): + url = serializer.validated_data.get("url", "") + # Auto-detect source from URL when not explicitly provided or defaulted + source = serializer.validated_data.get("source") or detect_source(url) + if source == DocumentDownloadLog.DocumentSource.OTHER: + source = detect_source(url) + + raw_ip = _get_client_ip(self.request) + masked_ip = mask_ip_address(raw_ip) if raw_ip else None + + user = self.request.user if self.request.user.is_authenticated else None + + serializer.save( + user=user, + ip_address=masked_ip, + source=source, + ) diff --git a/assets b/assets index 51c75f2e7..3fef0a515 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 51c75f2e70420268de63f06932329771b72ec95a +Subproject commit 3fef0a515af4f3f1f46093574330ce69b742803b diff --git a/main/settings.py b/main/settings.py index 0290e1216..3d5afc918 100644 --- a/main/settings.py +++ b/main/settings.py @@ -245,6 +245,7 @@ def parse_domain(*env_keys: str) -> str: "country_plan", "local_units", "alert_system", + "analytics", ] INSTALLED_APPS = [ diff --git a/main/urls.py b/main/urls.py index ddf055962..e0c6a0484 100644 --- a/main/urls.py +++ b/main/urls.py @@ -22,6 +22,7 @@ from rest_framework import routers from alert_system.dev_views import AlertEmailPreview +from analytics import views as analytics_views from api import drf_views as api_views from api.admin_reports import UsersPerPermissionViewSet from api.views import ( @@ -214,6 +215,9 @@ router.register(r"eap/global-files", eap_views.EAPGlobalFilesViewSet, basename="eap_global_files") router.register(r"eap-share-users", eap_views.EAPShareUserViewSet, basename="eap_share_users") +# Analytics +router.register(r"document-download-log", analytics_views.DocumentDownloadLogViewSet, basename="document_download_log") + admin.site.site_header = "IFRC Go administration" admin.site.site_title = "IFRC Go admin"