Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
98 changes: 98 additions & 0 deletions physionet-django/project/fixtures/demo-project.json
Original file line number Diff line number Diff line change
Expand Up @@ -2488,5 +2488,103 @@
"object_id": 1,
"document": "ethics/Ethics_Approval_567b029d-9ea6-41b8-b738-bf45675b24ce.txt"
}
},
{
"model": "project.log",
"pk": 1,
"fields": {
"category": "LogCategory.ACCESS",
"content_type": ["project", "publishedproject"],
"object_id": 2,
"user": 1,
"data": "",
"count": 3,
"creation_datetime": "2025-11-15T10:00:00Z",
"last_access_datetime": "2025-11-20T14:30:00Z"
}
},
{
"model": "project.log",
"pk": 2,
"fields": {
"category": "LogCategory.ACCESS",
"content_type": ["project", "publishedproject"],
"object_id": 2,
"user": 2,
"data": "",
"count": 5,
"creation_datetime": "2025-11-10T09:00:00Z",
"last_access_datetime": "2025-11-25T16:00:00Z"
}
},
{
"model": "project.log",
"pk": 3,
"fields": {
"category": "LogCategory.ACCESS",
"content_type": ["project", "publishedproject"],
"object_id": 2,
"user": 3,
"data": "",
"count": 2,
"creation_datetime": "2025-12-05T11:00:00Z",
"last_access_datetime": "2025-12-10T15:00:00Z"
}
},
{
"model": "project.log",
"pk": 4,
"fields": {
"category": "LogCategory.ACCESS",
"content_type": ["project", "publishedproject"],
"object_id": 2,
"user": 4,
"data": "",
"count": 1,
"creation_datetime": "2025-12-20T08:00:00Z",
"last_access_datetime": "2025-12-20T08:00:00Z"
}
},
{
"model": "project.log",
"pk": 5,
"fields": {
"category": "LogCategory.ACCESS",
"content_type": ["project", "publishedproject"],
"object_id": 2,
"user": 5,
"data": "",
"count": 4,
"creation_datetime": "2026-01-02T13:00:00Z",
"last_access_datetime": "2026-01-10T17:00:00Z"
}
},
{
"model": "project.log",
"pk": 6,
"fields": {
"category": "LogCategory.ACCESS",
"content_type": ["project", "publishedproject"],
"object_id": 1,
"user": 2,
"data": "",
"count": 2,
"creation_datetime": "2025-12-01T10:00:00Z",
"last_access_datetime": "2025-12-15T12:00:00Z"
}
},
{
"model": "project.log",
"pk": 7,
"fields": {
"category": "LogCategory.ACCESS",
"content_type": ["project", "publishedproject"],
"object_id": 1,
"user": 3,
"data": "",
"count": 1,
"creation_datetime": "2026-01-05T09:00:00Z",
"last_access_datetime": "2026-01-05T09:00:00Z"
}
}
]
21 changes: 20 additions & 1 deletion physionet-django/project/managers/log.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import datetime as dt
from collections import defaultdict

from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import QuerySet, Manager
from django.db.models import QuerySet, Manager, Min
from django.utils import timezone

from physionet.enums import LogCategory
Expand All @@ -23,6 +24,24 @@ def create(self, **kwargs):
kwargs['category'] = LogCategory.ACCESS
return super().create(**kwargs)

def unique_viewers_count(self):
"""Count unique users who have viewed."""
return self.values('user').distinct().count()

def first_views_by_month(self):
"""
Count first-time viewers per month.
Each user is only counted in the month of their first view.
"""
first_views = self.values('user').annotate(first_view=Min('creation_datetime'))

monthly_counts = defaultdict(int)
for fv in first_views:
month = fv['first_view'].replace(day=1, hour=0, minute=0, second=0, microsecond=0)
monthly_counts[month] += 1

return [{'month': m, 'count': c} for m, c in sorted(monthly_counts.items())]

def update_or_create(self, defaults=None, **kwargs):
user = kwargs.get('user')
project = kwargs.get('project')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,9 @@ <h3 class="mb-0">{{ all_versions_views_count }}</h3>
</div>
</div>
<small class="text-muted mb-0"><em>Project Views by Unique Registered Users</em></small>
<div class="mt-2">
<a href="{% url 'published_project_metrics' project.slug project.version %}" class="btn btn-sm btn-outline-secondary">View Details</a>
</div>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{% extends "base.html" %}

{% load static %}

{% block title %}
Metrics for {{ project }}
{% endblock %}

{% block content %}
<div class="container">
<h1>Metrics</h1>
<p class="text-muted">{{ project.title }} (v{{ project.version }})</p>
<hr>

<div class="row">
<div class="col-md-6">
<div class="card text-center mb-4">
<h5 class="card-header">Project Views</h5>
<div class="card-body">
<div class="d-flex justify-content-around mb-2">
<div>
<h3 class="mb-0">{{ project_views_count }}</h3>
<small class="text-muted">Current Version</small>
</div>
<div>
<h3 class="mb-0">{{ all_versions_views_count }}</h3>
<small class="text-muted">All Versions</small>
</div>
</div>
<small class="text-muted"><em>Project Views by Unique Registered Users</em></small>
</div>
</div>
</div>
</div>

{% if views_by_version|length > 1 %}
<h3>Views by Version</h3>
<div class="table-responsive mb-4">
<table class="table table-bordered">
<thead>
<tr>
<th>Version</th>
<th>Project Views</th>
</tr>
</thead>
<tbody>
{% for item in views_by_version %}
<tr>
<td>{{ item.version }}</td>
<td>{{ item.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}

{% if views_over_time %}
<h3>Views Over Time</h3>
<div class="table-responsive mb-4">
<table class="table table-bordered">
<thead>
<tr>
<th>Month</th>
<th>Unique Viewers</th>
</tr>
</thead>
<tbody>
{% for item in views_over_time %}
<tr>
<td>{{ item.month|date:"F Y" }}</td>
<td>{{ item.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}

<hr>
<p><a href="{% url 'published_project' project.slug project.version %}">Back to {{ project }}</a></p>
</div>
{% endblock %}
112 changes: 110 additions & 2 deletions physionet-django/project/test_views.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import base64
from datetime import timedelta
import html.parser
import os
from http import HTTPStatus
import json
from unittest import mock

from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core import mail
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from project.forms import ContentForm
from project.models import (
AccessLog,
AccessPolicy,
ActiveProject,
Author,
Expand All @@ -20,6 +24,7 @@
DataAccessRequest,
DataAccessRequestReviewer,
License,
Log,
ProjectType,
PublishedAuthor,
PublishedProject,
Expand Down Expand Up @@ -1707,8 +1712,13 @@ def test_bibtex_in_citation_text_all(self):
self.assertIn('@article{', citations['BibTeX'])


class TestFileViewsMetric(TestMixin):
"""Test the file views metric on published project pages."""
class TestProjectViewsMetric(TestMixin):
"""Test the project views metric on published project pages."""

def setUp(self):
"""Clear AccessLog data before each test for isolation."""
super().setUp()
Log.objects.all().delete()

def test_project_views_count_displayed(self):
"""Project views count is displayed on the published project page."""
Expand All @@ -1734,3 +1744,101 @@ def test_project_views_increments_on_authenticated_view(self):
args=(project.slug, project.version)))
self.assertEqual(response.context['project_views_count'], 1)
self.assertEqual(response.context['all_versions_views_count'], 1)

def test_metrics_detail_page_accessible(self):
"""Metrics detail page is publicly accessible."""
project = PublishedProject.objects.get(title='Demo ECG Signal Toolbox')
response = self.client.get(reverse('published_project_metrics',
args=(project.slug, project.version)))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Metrics')
self.assertContains(response, 'Project Views')

def test_metrics_detail_page_context(self):
"""Metrics detail page includes required context data."""
project = PublishedProject.objects.get(title='Demo ECG Signal Toolbox')
response = self.client.get(reverse('published_project_metrics',
args=(project.slug, project.version)))
self.assertIn('project_views_count', response.context)
self.assertIn('views_over_time', response.context)
self.assertIn('views_by_version', response.context)

def test_metrics_link_on_project_page(self):
"""Project page includes link to metrics detail page."""
project = PublishedProject.objects.get(title='Demo ECG Signal Toolbox')
response = self.client.get(reverse('published_project',
args=(project.slug, project.version)))
self.assertContains(response, 'View Details')

def test_metrics_detail_with_access_data(self):
"""Metrics detail page shows correct counts with access data."""
project = PublishedProject.objects.get(title='Demo ECG Signal Toolbox')
user1 = User.objects.get(email='rgmark@mit.edu')
user2 = User.objects.get(email='admin@mit.edu')
content_type = ContentType.objects.get_for_model(project)

# Create access logs for two users
AccessLog.objects.create(
user=user1,
object_id=project.id,
content_type=content_type,
data=''
)
AccessLog.objects.create(
user=user2,
object_id=project.id,
content_type=content_type,
data=''
)

response = self.client.get(reverse('published_project_metrics',
args=(project.slug, project.version)))
self.assertEqual(response.context['project_views_count'], 2)
self.assertEqual(len(response.context['views_by_version']), 1)
self.assertEqual(response.context['views_by_version'][0]['count'], 2)

def test_unique_viewers_count(self):
"""AccessLog.unique_viewers_count() returns correct count."""
project = PublishedProject.objects.get(title='Demo ECG Signal Toolbox')
user1 = User.objects.get(email='rgmark@mit.edu')
user2 = User.objects.get(email='admin@mit.edu')
content_type = ContentType.objects.get_for_model(project)

# Create multiple logs for same user
AccessLog.objects.create(
user=user1, object_id=project.id, content_type=content_type, data='')
AccessLog.objects.create(
user=user1, object_id=project.id, content_type=content_type, data='')
AccessLog.objects.create(
user=user2, object_id=project.id, content_type=content_type, data='')

logs = AccessLog.objects.filter(object_id=project.id, content_type=content_type)
self.assertEqual(logs.unique_viewers_count(), 2)

def test_first_views_by_month_no_duplicates(self):
"""AccessLog.first_views_by_month() counts each user only once."""
project = PublishedProject.objects.get(title='Demo ECG Signal Toolbox')
user1 = User.objects.get(email='rgmark@mit.edu')
user2 = User.objects.get(email='admin@mit.edu')
content_type = ContentType.objects.get_for_model(project)

now = timezone.now()
last_month = now - timedelta(days=35)

# User1 views in both months
AccessLog.objects.create(
user=user1, object_id=project.id, content_type=content_type, data='')
AccessLog.objects.filter(user=user1).update(creation_datetime=last_month)
AccessLog.objects.create(
user=user1, object_id=project.id, content_type=content_type, data='')

# User2 views only this month
AccessLog.objects.create(
user=user2, object_id=project.id, content_type=content_type, data='')

logs = AccessLog.objects.filter(object_id=project.id, content_type=content_type)
views_by_month = logs.first_views_by_month()

# Sum of monthly counts should equal total unique viewers
total_from_months = sum(m['count'] for m in views_by_month)
self.assertEqual(total_from_months, logs.unique_viewers_count())
Loading
Loading