-
Notifications
You must be signed in to change notification settings - Fork 8
Track document downloads #2776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Track document downloads #2776
Changes from all commits
929dc89
14389fa
f295f69
d763b19
5757210
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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",) |
| 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") |
| 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.")) |
| 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",), | ||
| }, | ||
| ), | ||
| ] |
| 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): | ||
|
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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How do we check/verify this field?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not clear what you ask.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a simple check to see whether the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
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 | ||
| 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",) |
| 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, | ||
| ) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.