Skip to content

Commit

Permalink
Server-wide stats by Project (#150)
Browse files Browse the repository at this point in the history
  • Loading branch information
charman committed Oct 5, 2021
1 parent 9391a40 commit 3eb2fec
Show file tree
Hide file tree
Showing 7 changed files with 243 additions and 7 deletions.
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Add "Activity Calendar Heatmap" visualization to Batch Stats and
Project Stats admin views and User Stats view showing how many
Task Assignments were completed for each day of the past year.
Project Stats admin views and User Stats view. Visualization shows
how many Task Assignments were completed for each day of the past
year.
- Add "Active Projects" admin view that uses the "Activity Calendar
Heatmap" visualization to show activity on all projects active
in last N days.
- Add "Active Users" admin view that lists all users active in last
N days.
### Changed
- Users with Staff privileges can now access User Stats view for
any User. The User table on the User Admin changelist page now
Expand Down
42 changes: 41 additions & 1 deletion turkle/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from django.contrib.admin.templatetags.admin_list import _boolean_icon
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.db.models import DurationField, ExpressionWrapper, F
from django.db.models import Count, DurationField, ExpressionWrapper, F, Max, Q
from django.forms import (FileField, FileInput, HiddenInput, IntegerField,
ModelForm, ModelMultipleChoiceField, TextInput, ValidationError, Widget)
from django.http import HttpResponse, JsonResponse
Expand Down Expand Up @@ -947,6 +947,42 @@ class ProjectAdmin(GuardedModelAdmin):
class Media:
pass

def active_projects(self, request):
if 'days' in request.GET:
days = int(request.GET['days'])
else:
days = 7

projects = Project.get_with_recently_updated_taskassignments(days)
return render(request, 'admin/turkle/active_projects.html', {
'days': days,
'projects': projects,
})

def active_users(self, request):
if 'days' in request.GET:
days = int(request.GET['days'])
else:
days = 7
recent_past = datetime.now(timezone.utc) - timedelta(days=days)

active_users = User.objects.\
filter(Q(taskassignment__updated_at__gt=recent_past) &
Q(taskassignment__completed=True)).\
distinct().\
annotate(
total_assignments=Count('taskassignment',
filter=(Q(taskassignment__updated_at__gt=recent_past) &
Q(taskassignment__completed=True)))).\
annotate(last_finished_time=Max('taskassignment__updated_at',
filter=Q(taskassignment__completed=True)))
active_user_count = active_users.count()
return render(request, 'admin/turkle/active_users.html', {
'active_users': active_users,
'active_user_count': active_user_count,
'days': days,
})

def activity_json(self, request, project_id):
try:
project = Project.objects.get(id=project_id)
Expand Down Expand Up @@ -975,6 +1011,10 @@ def get_urls(self):
self.admin_site.admin_view(self.activity_json), name='project_activity_json'),
path('<int:project_id>/stats/',
self.admin_site.admin_view(self.project_stats), name='project_stats'),
path('active-projects/',
self.admin_site.admin_view(self.active_projects), name='active_projects'),
path('active-users/',
self.admin_site.admin_view(self.active_users), name='active_users'),
]
return my_urls + urls

Expand Down
18 changes: 18 additions & 0 deletions turkle/migrations/0012_auto_20210923_1503.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 2.2.20 on 2021-09-23 19:03

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('turkle', '0011_add_turkle_user_admin_group'),
]

operations = [
migrations.AlterField(
model_name='taskassignment',
name='updated_at',
field=models.DateTimeField(auto_now=True, db_index=True),
),
]
16 changes: 15 additions & 1 deletion turkle/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ class Meta:
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True)
task = models.ForeignKey(Task, on_delete=models.CASCADE)
updated_at = models.DateTimeField(auto_now=True)
updated_at = models.DateTimeField(auto_now=True, db_index=True)

@classmethod
def expire_all_abandoned(cls):
Expand Down Expand Up @@ -719,6 +719,20 @@ class Meta:
# Fieldnames are automatically extracted from html_template text
fieldnames = JSONField(blank=True)

@classmethod
def get_with_recently_updated_taskassignments(cls, n_days):
"""Return QuerySet of Projects with TaskAssignments modified in last N days
Args:
n_days (int): Number of days to use for "recently updated" window
Returns:
QuerySet
"""
recent_past = (datetime.datetime.now(datetime.timezone.utc) -
datetime.timedelta(days=n_days))
return Project.objects.\
filter(batch__task__taskassignment__updated_at__gt=recent_past).distinct()

def assignments_completed_by(self, user):
"""
Returns:
Expand Down
76 changes: 76 additions & 0 deletions turkle/templates/admin/turkle/active_projects.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{% extends "admin/base_bootcamp.html" %}
{% load static %}

{% block header %}
<script type="text/javascript" src="{% static 'turkle/jquery-3.3.1.min.js' %}"></script>
<script type="text/javascript" src="{% static 'turkle/datatables-1.10.24/datatables.min.js' %}"></script>
<script type="text/javascript" src="{% static 'turkle/d3-3.5.17.min.js' %}"></script>
<script type="text/javascript" src="{% static 'turkle/cal-heatmap-3.6.2.min.js' %}"></script>

<link href="{% static 'turkle/datatables-1.10.24/datatables.bootstrap4.min.css' %}" rel="stylesheet" type="text/css"/>
<link href="{% static 'turkle/cal-heatmap.css' %}" rel="stylesheet" type="text/css"/>
<style>
.activity-calendar {
margin-bottom: 2em;
}
</style>
<script>
$(document).ready(function() {
// Set startDate to include previous 11 months plus all of current month
var currentDate = new Date();
var year = currentDate.getFullYear();
var month = currentDate.getMonth(); // 0-indexed
month -= 11;
if (month < 0) {
month += 12;
year -= 1;
}
var startDate = new Date(year, month, 1);

$('.activity-calendar').each(function() {
var $calendarDiv = $(this);
var containerId = $calendarDiv.attr('id');
const projectActivityJSON = $calendarDiv.data('projectActivityJson');

var cal = new CalHeatMap();
cal.init({
itemSelector: '#' + containerId,
data: projectActivityJSON,
domain: "month",
subDomain: "day",
start: startDate,
weekStartOnMonday: false,
});
});

});
</script>
{% endblock %}

{% block body %}

<div class="container">

<div style="margin-top: 1em;">
<a href="?days=2" role="button" class="btn btn-primary">Last 2 days</a>
<a href="?days=7" role="button" class="btn btn-primary">Last 7 days</a>
<a href="?days=30" role="button" class="btn btn-primary">Last 30 days</a>
</div>

<h1>Active Projects in last {{ days }} Days:</h1>

{% for project in projects %}
<h3>
<a href="{% url 'turkle_admin:project_stats' project.id %}">
{{ project.name }}
</a>
</h3>
<div id="activity-calendar-{{ project.id }}"
class="activity-calendar"
data-project-activity-json="{% url 'turkle_admin:project_activity_json' project.id %}">
</div>
{% endfor %}

</div><!-- /.container -->

{% endblock %}
66 changes: 66 additions & 0 deletions turkle/templates/admin/turkle/active_users.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{% extends "admin/base_bootcamp.html" %}
{% load static %}

{% block header %}
<script type="text/javascript" src="{% static 'turkle/jquery-3.3.1.min.js' %}"></script>
<script type="text/javascript" src="{% static 'turkle/datatables-1.10.24/datatables.min.js' %}"></script>
<script type="text/javascript" src="{% static 'turkle/d3-3.5.17.min.js' %}"></script>

<link href="{% static 'turkle/datatables-1.10.24/datatables.bootstrap4.min.css' %}" rel="stylesheet" type="text/css"/>
<style>
</style>
<script>
$(document).ready(function() {
$('#user-stats').DataTable({
info: false,
paging: false,
searching: false
});
});
</script>
{% endblock %}

{% block body %}

<div class="container">

<div style="margin-top: 1em;">
<a href="?days=2" role="button" class="btn btn-primary">Last 2 days</a>
<a href="?days=7" role="button" class="btn btn-primary">Last 7 days</a>
<a href="?days=30" role="button" class="btn btn-primary">Last 30 days</a>
</div>

<h1>Active Users in last {{ days }} Days: {{ active_user_count }}</h1>

<div style="margin-bottom: 2em;">
<table id="user-stats" class="table table-sm table-bordered thead-light">
<thead class="thead-light">
<tr>
<th>Name</th>
<th>Username</th>
<th>Assignments</th>
<th>Most Recent</th>
</tr>
</thead>
<tbody>
{% for active_user in active_users %}
<tr>
<td data-order="{{ active_user.last_name }}, {{ active_user.first_name }}">
<a href="{% url 'stats_for_user' active_user.id %}">
{{ active_user.first_name }} {{ active_user.last_name }}
</a>
</td>
<td>{{ active_user.username }}</td>
<td>{{ active_user.total_assignments }}</td>
<td data-order="{{ active_user.last_finished_time|date:"c" }}">
{{ active_user.last_finished_time }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

</div><!-- /.container -->

{% endblock %}
22 changes: 19 additions & 3 deletions turkle/templates/admin/turkle/app_index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,25 @@

{% block content %}
{{ block.super }}
<a href="{% url 'turkle_admin:expire_abandoned_assignments' %}" class="button">
Expire Abandoned Assignments
</a>
<p>
<h3>
<a href="{% url 'turkle_admin:active_projects' %}">
Active Projects
</a>
</h3>
</p>
<p>
<h3>
<a href="{% url 'turkle_admin:active_users' %}">
Active Users
</a>
</h3>
</p>
<p style="margin-top: 2em;">
<a href="{% url 'turkle_admin:expire_abandoned_assignments' %}" class="button">
Expire Abandoned Assignments
</a>
</p>
{% endblock %}

{% block sidebar %}{% endblock %}

0 comments on commit 3eb2fec

Please sign in to comment.