Skip to content

Commit faf41ae

Browse files
Add detailed public metrics view for published projects
1 parent e33b743 commit faf41ae

File tree

6 files changed

+279
-0
lines changed

6 files changed

+279
-0
lines changed

physionet-django/project/fixtures/demo-project.json

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2488,5 +2488,103 @@
24882488
"object_id": 1,
24892489
"document": "ethics/Ethics_Approval_567b029d-9ea6-41b8-b738-bf45675b24ce.txt"
24902490
}
2491+
},
2492+
{
2493+
"model": "project.log",
2494+
"pk": 1,
2495+
"fields": {
2496+
"category": "LogCategory.ACCESS",
2497+
"content_type": ["project", "publishedproject"],
2498+
"object_id": 2,
2499+
"user": 1,
2500+
"data": "",
2501+
"count": 3,
2502+
"creation_datetime": "2025-11-15T10:00:00Z",
2503+
"last_access_datetime": "2025-11-20T14:30:00Z"
2504+
}
2505+
},
2506+
{
2507+
"model": "project.log",
2508+
"pk": 2,
2509+
"fields": {
2510+
"category": "LogCategory.ACCESS",
2511+
"content_type": ["project", "publishedproject"],
2512+
"object_id": 2,
2513+
"user": 2,
2514+
"data": "",
2515+
"count": 5,
2516+
"creation_datetime": "2025-11-10T09:00:00Z",
2517+
"last_access_datetime": "2025-11-25T16:00:00Z"
2518+
}
2519+
},
2520+
{
2521+
"model": "project.log",
2522+
"pk": 3,
2523+
"fields": {
2524+
"category": "LogCategory.ACCESS",
2525+
"content_type": ["project", "publishedproject"],
2526+
"object_id": 2,
2527+
"user": 3,
2528+
"data": "",
2529+
"count": 2,
2530+
"creation_datetime": "2025-12-05T11:00:00Z",
2531+
"last_access_datetime": "2025-12-10T15:00:00Z"
2532+
}
2533+
},
2534+
{
2535+
"model": "project.log",
2536+
"pk": 4,
2537+
"fields": {
2538+
"category": "LogCategory.ACCESS",
2539+
"content_type": ["project", "publishedproject"],
2540+
"object_id": 2,
2541+
"user": 4,
2542+
"data": "",
2543+
"count": 1,
2544+
"creation_datetime": "2025-12-20T08:00:00Z",
2545+
"last_access_datetime": "2025-12-20T08:00:00Z"
2546+
}
2547+
},
2548+
{
2549+
"model": "project.log",
2550+
"pk": 5,
2551+
"fields": {
2552+
"category": "LogCategory.ACCESS",
2553+
"content_type": ["project", "publishedproject"],
2554+
"object_id": 2,
2555+
"user": 5,
2556+
"data": "",
2557+
"count": 4,
2558+
"creation_datetime": "2026-01-02T13:00:00Z",
2559+
"last_access_datetime": "2026-01-10T17:00:00Z"
2560+
}
2561+
},
2562+
{
2563+
"model": "project.log",
2564+
"pk": 6,
2565+
"fields": {
2566+
"category": "LogCategory.ACCESS",
2567+
"content_type": ["project", "publishedproject"],
2568+
"object_id": 1,
2569+
"user": 2,
2570+
"data": "",
2571+
"count": 2,
2572+
"creation_datetime": "2025-12-01T10:00:00Z",
2573+
"last_access_datetime": "2025-12-15T12:00:00Z"
2574+
}
2575+
},
2576+
{
2577+
"model": "project.log",
2578+
"pk": 7,
2579+
"fields": {
2580+
"category": "LogCategory.ACCESS",
2581+
"content_type": ["project", "publishedproject"],
2582+
"object_id": 1,
2583+
"user": 3,
2584+
"data": "",
2585+
"count": 1,
2586+
"creation_datetime": "2026-01-05T09:00:00Z",
2587+
"last_access_datetime": "2026-01-05T09:00:00Z"
2588+
}
24912589
}
24922590
]

physionet-django/project/templates/project/published_project.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,9 @@ <h3 class="mb-0">{{ all_versions_views_count }}</h3>
256256
</div>
257257
</div>
258258
<small class="text-muted mb-0"><em>Project Views by Unique Registered Users</em></small>
259+
<div class="mt-2">
260+
<a href="{% url 'published_project_metrics' project.slug project.version %}" class="btn btn-sm btn-outline-secondary">View Details</a>
261+
</div>
259262
</div>
260263
</div>
261264

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{% extends "base.html" %}
2+
3+
{% load static %}
4+
5+
{% block title %}
6+
Metrics for {{ project }}
7+
{% endblock %}
8+
9+
{% block content %}
10+
<div class="container">
11+
<h1>Metrics</h1>
12+
<p class="text-muted">{{ project.title }} (v{{ project.version }})</p>
13+
<hr>
14+
15+
<div class="row">
16+
<div class="col-md-4">
17+
<div class="card text-center mb-4">
18+
<div class="card-body">
19+
<h2 class="mb-0">{{ project_views_count }}</h2>
20+
<small class="text-muted">Total Project Views</small>
21+
</div>
22+
</div>
23+
</div>
24+
</div>
25+
26+
{% if views_by_version|length > 1 %}
27+
<h3>Views by Version</h3>
28+
<div class="table-responsive mb-4">
29+
<table class="table table-bordered">
30+
<thead>
31+
<tr>
32+
<th>Version</th>
33+
<th>Project Views</th>
34+
</tr>
35+
</thead>
36+
<tbody>
37+
{% for item in views_by_version %}
38+
<tr>
39+
<td>{{ item.version }}</td>
40+
<td>{{ item.count }}</td>
41+
</tr>
42+
{% endfor %}
43+
</tbody>
44+
</table>
45+
</div>
46+
{% endif %}
47+
48+
{% if views_over_time %}
49+
<h3>Views Over Time</h3>
50+
<div class="table-responsive mb-4">
51+
<table class="table table-bordered">
52+
<thead>
53+
<tr>
54+
<th>Month</th>
55+
<th>Unique Viewers</th>
56+
</tr>
57+
</thead>
58+
<tbody>
59+
{% for item in views_over_time %}
60+
<tr>
61+
<td>{{ item.month|date:"F Y" }}</td>
62+
<td>{{ item.count }}</td>
63+
</tr>
64+
{% endfor %}
65+
</tbody>
66+
</table>
67+
</div>
68+
{% endif %}
69+
70+
<hr>
71+
<p><a href="{% url 'published_project' project.slug project.version %}">Back to {{ project }}</a></p>
72+
</div>
73+
{% endblock %}

physionet-django/project/test_views.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1734,3 +1734,58 @@ def test_project_views_increments_on_authenticated_view(self):
17341734
args=(project.slug, project.version)))
17351735
self.assertEqual(response.context['project_views_count'], 1)
17361736
self.assertEqual(response.context['all_versions_views_count'], 1)
1737+
1738+
def test_metrics_detail_page_accessible(self):
1739+
"""Metrics detail page is publicly accessible."""
1740+
project = PublishedProject.objects.get(title='Demo ECG Signal Toolbox')
1741+
response = self.client.get(reverse('published_project_metrics',
1742+
args=(project.slug, project.version)))
1743+
self.assertEqual(response.status_code, 200)
1744+
self.assertContains(response, 'Metrics')
1745+
self.assertContains(response, 'Total Project Views')
1746+
1747+
def test_metrics_detail_page_context(self):
1748+
"""Metrics detail page includes required context data."""
1749+
project = PublishedProject.objects.get(title='Demo ECG Signal Toolbox')
1750+
response = self.client.get(reverse('published_project_metrics',
1751+
args=(project.slug, project.version)))
1752+
self.assertIn('project_views_count', response.context)
1753+
self.assertIn('views_over_time', response.context)
1754+
self.assertIn('views_by_version', response.context)
1755+
1756+
def test_metrics_link_on_project_page(self):
1757+
"""Project page includes link to metrics detail page."""
1758+
project = PublishedProject.objects.get(title='Demo ECG Signal Toolbox')
1759+
response = self.client.get(reverse('published_project',
1760+
args=(project.slug, project.version)))
1761+
self.assertContains(response, 'View Details')
1762+
1763+
def test_metrics_detail_with_access_data(self):
1764+
"""Metrics detail page shows correct counts with access data."""
1765+
from django.contrib.contenttypes.models import ContentType
1766+
from project.models import AccessLog
1767+
1768+
project = PublishedProject.objects.get(title='Demo ECG Signal Toolbox')
1769+
user1 = User.objects.get(email='rgmark@mit.edu')
1770+
user2 = User.objects.get(email='admin@mit.edu')
1771+
content_type = ContentType.objects.get_for_model(project)
1772+
1773+
# Create access logs for two users
1774+
AccessLog.objects.create(
1775+
user=user1,
1776+
object_id=project.id,
1777+
content_type=content_type,
1778+
data=''
1779+
)
1780+
AccessLog.objects.create(
1781+
user=user2,
1782+
object_id=project.id,
1783+
content_type=content_type,
1784+
data=''
1785+
)
1786+
1787+
response = self.client.get(reverse('published_project_metrics',
1788+
args=(project.slug, project.version)))
1789+
self.assertEqual(response.context['project_views_count'], 2)
1790+
self.assertEqual(len(response.context['views_by_version']), 1)
1791+
self.assertEqual(response.context['views_by_version'][0]['count'], 2)

physionet-django/project/views.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2108,6 +2108,51 @@ def published_project(request, project_slug, version, subdir=''):
21082108
status=status)
21092109

21102110

2111+
def published_project_metrics(request, project_slug, version):
2112+
"""
2113+
Public metrics page for a published project.
2114+
"""
2115+
from django.db.models import Count
2116+
from django.db.models.functions import TruncMonth
2117+
2118+
try:
2119+
project = PublishedProject.objects.get(slug=project_slug, version=version)
2120+
except ObjectDoesNotExist:
2121+
raise Http404()
2122+
2123+
content_type = ContentType.objects.get_for_model(project)
2124+
2125+
project_views_count = AccessLog.objects.filter(
2126+
object_id=project.id,
2127+
content_type=content_type
2128+
).values('user').distinct().count()
2129+
2130+
views_over_time = (
2131+
AccessLog.objects.filter(object_id=project.id, content_type=content_type)
2132+
.annotate(month=TruncMonth('creation_datetime'))
2133+
.values('month')
2134+
.annotate(count=Count('user', distinct=True))
2135+
.order_by('month')
2136+
)
2137+
2138+
all_versions = PublishedProject.objects.filter(slug=project_slug).order_by('version_order')
2139+
views_by_version = []
2140+
for v in all_versions:
2141+
v_content_type = ContentType.objects.get_for_model(v)
2142+
v_count = AccessLog.objects.filter(
2143+
object_id=v.id,
2144+
content_type=v_content_type
2145+
).values('user').distinct().count()
2146+
views_by_version.append({'version': v.version, 'count': v_count})
2147+
2148+
return render(request, 'project/published_project_metrics.html', {
2149+
'project': project,
2150+
'project_views_count': project_views_count,
2151+
'views_over_time': views_over_time,
2152+
'views_by_version': views_by_version,
2153+
})
2154+
2155+
21112156
@login_required
21122157
def sign_dua(request, project_slug, version):
21132158
"""

physionet-django/search/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@
5959
project_views.published_project_dua,
6060
name='published_project_dua',
6161
),
62+
path(
63+
'content/<project_slug>/metrics/<version>/',
64+
project_views.published_project_metrics,
65+
name='published_project_metrics',
66+
),
6267

6368
path('sign-dua/<project_slug>/<version>/', project_views.sign_dua,
6469
name='sign_dua'),

0 commit comments

Comments
 (0)