Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added analytics/__init__.py
Empty file.
23 changes: 23 additions & 0 deletions analytics/admin.py
Original file line number Diff line number Diff line change
@@ -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",)
7 changes: 7 additions & 0 deletions analytics/apps.py
Original file line number Diff line number Diff line change
@@ -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")
Empty file.
Empty file.
15 changes: 15 additions & 0 deletions analytics/management/commands/prune_download_logs.py

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we planning to run this Script manually?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, a cronjob setting will run it in a recurring time.

Original file line number Diff line number Diff line change
@@ -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."))
84 changes: 84 additions & 0 deletions analytics/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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",),
},
),
]
Empty file.
117 changes: 117 additions & 0 deletions analytics/models.py
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
szabozoltan69 marked this conversation as resolved.
# 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we check/verify this field?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not clear what you ask.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a simple check to see whether the object_id exists in our system or not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This object_id can point to different systems. So it can be a duplicate one. Not clear yet the existence check.

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(
Comment thread
szabozoltan69 marked this conversation as resolved.
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
10 changes: 10 additions & 0 deletions analytics/serializers.py
Original file line number Diff line number Diff line change
@@ -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",)
45 changes: 45 additions & 0 deletions analytics/views.py
Original file line number Diff line number Diff line change
@@ -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,
)
2 changes: 1 addition & 1 deletion assets
Submodule assets updated 1 files
+128 −0 openapi-schema.yaml
1 change: 1 addition & 0 deletions main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ def parse_domain(*env_keys: str) -> str:
"country_plan",
"local_units",
"alert_system",
"analytics",
]

INSTALLED_APPS = [
Expand Down
4 changes: 4 additions & 0 deletions main/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"

Expand Down