Skip to content

Commit 9cda5cc

Browse files
authored
Merge pull request #11810 from CenterForOpenScience/ENG-11734-downloadevent-model
ENG-11734 | feat(osf): add DownloadEvent model and migration with dashb…
2 parents f9cdf85 + ed3ea81 commit 9cda5cc

3 files changed

Lines changed: 139 additions & 0 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import django.db.models.deletion
2+
from django.db import migrations, models
3+
4+
5+
DASHBOARD_GROUP_NAME = 'download_telemetry'
6+
7+
DASHBOARD_USERS = [
8+
'sheredko.andriy@gmail.com',
9+
'bodintsov@exoft.net',
10+
'isokhan@exoft.net',
11+
'ykopka@exoft.net',
12+
'bgeiger@cos.io',
13+
'osmand@cos.io',
14+
'ramya@cos.io',
15+
]
16+
17+
18+
def create_dashboard_group(apps, schema_editor):
19+
"""Create the allow-list group the dashboard loads against and seed it.
20+
21+
The group carries no permissions of its own — membership is the only gate.
22+
"""
23+
Group = apps.get_model('auth', 'Group')
24+
OSFUser = apps.get_model('osf', 'OSFUser')
25+
group, _ = Group.objects.get_or_create(name=DASHBOARD_GROUP_NAME)
26+
for username in DASHBOARD_USERS:
27+
user = OSFUser.objects.filter(username=username).first()
28+
if user:
29+
group.user_set.add(user)
30+
31+
32+
def remove_dashboard_group(apps, schema_editor):
33+
Group = apps.get_model('auth', 'Group')
34+
Group.objects.filter(name=DASHBOARD_GROUP_NAME).delete()
35+
36+
37+
class Migration(migrations.Migration):
38+
39+
dependencies = [
40+
('osf', '0044_notification_scheduled'),
41+
]
42+
43+
operations = [
44+
migrations.CreateModel(
45+
name='DownloadEvent',
46+
fields=[
47+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
48+
('created', models.DateTimeField(auto_now_add=True, db_index=True)),
49+
('resource_guid', models.CharField(blank=True, db_index=True, default='', max_length=255)),
50+
('path', models.TextField(blank=True, default='')),
51+
('download_type', models.CharField(choices=[('file', 'Single file'), ('folder_zip', 'Folder zip'), ('project', 'Whole-project zip')], max_length=16)),
52+
('zip_completed', models.BooleanField(blank=True, null=True)),
53+
('size_bytes', models.BigIntegerField(blank=True, null=True)),
54+
('storage_region', models.CharField(blank=True, default='', max_length=64)),
55+
('user_region', models.CharField(blank=True, default='', max_length=64)),
56+
('ip', models.GenericIPAddressField(blank=True, null=True)),
57+
('source_area', models.CharField(blank=True, default='', max_length=128)),
58+
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='download_events', to='osf.osfuser')),
59+
],
60+
),
61+
migrations.AddIndex(
62+
model_name='downloadevent',
63+
index=models.Index(fields=['created', 'download_type'], name='download_event_crt_type'),
64+
),
65+
migrations.AddIndex(
66+
model_name='downloadevent',
67+
index=models.Index(fields=['created', 'storage_region'], name='download_event_crt_regn'),
68+
),
69+
migrations.AddIndex(
70+
model_name='downloadevent',
71+
index=models.Index(fields=['created', 'user_region'], name='download_event_crt_user'),
72+
),
73+
migrations.RunPython(create_dashboard_group, remove_dashboard_group),
74+
]

osf/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .admin_log_entry import AdminLogEntry
1313
from .admin_profile import AdminProfile
1414
from .analytics import UserActivityCounter, PageCounter
15+
from .download_event import DownloadEvent
1516
from .archive import ArchiveJob, ArchiveTarget
1617
from .banner import ScheduledBanner
1718
from .base import (

osf/models/download_event.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from django.db import models
2+
3+
4+
class DownloadEvent(models.Model):
5+
"""One metadata row per download — never the file contents.
6+
7+
Foundation for the download-telemetry capture and dashboard. Append-only:
8+
rows are written from the download flow (single files at the osf.io redirect
9+
view, folder/project zips from the WaterButler callback) and read, always
10+
scoped to a time range, by the dashboard.
11+
"""
12+
13+
FILE = 'file'
14+
FOLDER_ZIP = 'folder_zip'
15+
PROJECT = 'project'
16+
DOWNLOAD_TYPES = (
17+
(FILE, 'Single file'),
18+
(FOLDER_ZIP, 'Folder zip'),
19+
(PROJECT, 'Whole-project zip'),
20+
)
21+
22+
created = models.DateTimeField(auto_now_add=True, db_index=True)
23+
24+
# what was downloaded
25+
resource_guid = models.CharField(max_length=255, blank=True, default='', db_index=True)
26+
path = models.TextField(blank=True, default='')
27+
download_type = models.CharField(max_length=16, choices=DOWNLOAD_TYPES)
28+
# null for single files (only zips stream through WB, which reports completion)
29+
zip_completed = models.BooleanField(null=True, blank=True)
30+
size_bytes = models.BigIntegerField(null=True, blank=True)
31+
32+
# storage_region = where the bytes were served from (capacity);
33+
# user_region = roughly where the user is. Kept separate on purpose.
34+
storage_region = models.CharField(max_length=64, blank=True, default='')
35+
user_region = models.CharField(max_length=64, blank=True, default='')
36+
ip = models.GenericIPAddressField(null=True, blank=True)
37+
source_area = models.CharField(max_length=128, blank=True, default='')
38+
39+
# nullable: anonymous downloads of public files
40+
user = models.ForeignKey(
41+
'osf.OSFUser',
42+
null=True,
43+
blank=True,
44+
on_delete=models.SET_NULL,
45+
related_name='download_events',
46+
)
47+
48+
class Meta:
49+
# `created` is indexed on the field; these cover the dashboard's
50+
# time-range group-bys.
51+
indexes = [
52+
models.Index(fields=['created', 'download_type'], name='download_event_crt_type'),
53+
models.Index(fields=['created', 'storage_region'], name='download_event_crt_regn'),
54+
models.Index(fields=['created', 'user_region'], name='download_event_crt_user'),
55+
]
56+
57+
def __repr__(self):
58+
return (
59+
f'<DownloadEvent(id={self.id}, user={self.user_id}, '
60+
f'type={self.download_type}, size_bytes={self.size_bytes})>'
61+
)
62+
63+
def __str__(self):
64+
return self.__repr__()

0 commit comments

Comments
 (0)