Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
55 changes: 54 additions & 1 deletion physionet-django/project/modelcomponents/publishedproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from distutils.version import StrictVersion

from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from django.utils import timezone
Expand All @@ -15,7 +16,8 @@
from project.modelcomponents.fields import SafeHTMLField
from project.modelcomponents.metadata import Metadata, PublishedTopic
from project.modelcomponents.submission import SubmissionInfo
from project.models import AccessPolicy
from project.modelcomponents.access import AccessPolicy
from project.modelcomponents.log import AccessLog
from project.utility import StorageInfo, clear_directory, get_tree_size
from project.validators import MAX_PROJECT_SLUG_LENGTH, validate_slug, validate_subdir
from user.models import Training
Expand Down Expand Up @@ -385,3 +387,54 @@ def get_all_news(self):
link_all_versions=True).exclude(project=self)

return direct_news | linked_news

def view_count(self, all_versions=False):
"""
Return the count of unique registered users who have viewed this project.

Args:
all_versions: If True, count views across all versions of this project.
If False (default), count only views of this specific version.
"""
if all_versions:
versions = PublishedProject.objects.filter(slug=self.slug)
total = 0
for v in versions:
content_type = ContentType.objects.get_for_model(v)
total += AccessLog.objects.filter(
object_id=v.id,
content_type=content_type
).unique_viewers_count()
return total
else:
content_type = ContentType.objects.get_for_model(self)
return AccessLog.objects.filter(
object_id=self.id,
content_type=content_type
).unique_viewers_count()

def views_by_version(self):
"""
Return a list of view counts for each version of this project.
"""
versions = PublishedProject.objects.filter(slug=self.slug).order_by('version_order')
result = []
for v in versions:
content_type = ContentType.objects.get_for_model(v)
count = AccessLog.objects.filter(
object_id=v.id,
content_type=content_type
).unique_viewers_count()
result.append({'version': v.version, 'count': count})
return result

def views_over_time(self):
"""
Return monthly view counts for this project version.
Each user is only counted in the month of their first view.
"""
content_type = ContentType.objects.get_for_model(self)
return AccessLog.objects.filter(
object_id=self.id,
content_type=content_type
).first_views_by_month()
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 %}
Loading
Loading