Skip to content

Commit 8abe75a

Browse files
committed
Re-add analytics view to the application.
1 parent a3e2cc5 commit 8abe75a

4 files changed

Lines changed: 159 additions & 1 deletion

File tree

project/newsletter/test_views.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,81 @@ def test_update(self):
419419
)
420420
self.assertRedirects(response, reverse("newsletter:list_posts"))
421421
self.assertEqual(self.subscription.categories.get(), self.data.career)
422+
423+
424+
class TestAnalytics(DataTestCase):
425+
def test_basic(self):
426+
self.client.force_login(self.user)
427+
response = self.client.get(reverse("newsletter:analytics"))
428+
self.assertTemplateUsed(response, "staff/analytics.html")
429+
self.assertEqual(
430+
response.context["aggregates"],
431+
{
432+
"Subscriptions": 1,
433+
"Subscriptions (30 days)": 1,
434+
"Subscriptions (90 days)": 1,
435+
"Subscriptions (180 days)": 1,
436+
"Posts": 3,
437+
"Posts (30 days)": 3,
438+
"Posts (90 days)": 3,
439+
"Posts (180 days)": 3,
440+
},
441+
)
442+
443+
self.assertEqual(
444+
response.context["subscription_category_aggregates"],
445+
{
446+
self.data.career.title: 1,
447+
self.data.social.title: 1,
448+
},
449+
)
450+
self.assertEqual(
451+
response.context["post_category_aggregates"],
452+
{
453+
self.data.career.title: 2,
454+
self.data.social.title: 2,
455+
},
456+
)
457+
458+
def test_date_aggregates(self):
459+
# Create users that are outside the cut-off points.
460+
for days in [31, 91, 181]:
461+
user = User.objects.create_user(username=f"days{days}")
462+
subscription = Subscription.objects.create(user=user)
463+
# We can't specify created in .create() because it's automatically set.
464+
Subscription.objects.filter(id=subscription.id).update(
465+
created=timezone.now() - timedelta(days=days)
466+
)
467+
subscription.categories.set([self.data.career, self.data.social])
468+
469+
self.client.force_login(self.user)
470+
response = self.client.get(reverse("newsletter:analytics"))
471+
self.assertTemplateUsed(response, "staff/analytics.html")
472+
self.assertEqual(
473+
response.context["aggregates"],
474+
{
475+
"Subscriptions": 4,
476+
"Subscriptions (30 days)": 1,
477+
"Subscriptions (90 days)": 2,
478+
"Subscriptions (180 days)": 3,
479+
"Posts": 3,
480+
"Posts (30 days)": 3,
481+
"Posts (90 days)": 3,
482+
"Posts (180 days)": 3,
483+
},
484+
)
485+
486+
self.assertEqual(
487+
response.context["subscription_category_aggregates"],
488+
{
489+
self.data.career.title: 4,
490+
self.data.social.title: 4,
491+
},
492+
)
493+
self.assertEqual(
494+
response.context["post_category_aggregates"],
495+
{
496+
self.data.career.title: 2,
497+
self.data.social.title: 2,
498+
},
499+
)

project/newsletter/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
path("markdown/uploader/", views.markdown_uploader, name="markdown_uploader"),
88
path("", views.landing, name="landing"),
99
path("account/", views.update_subscription, name="update_subscription"),
10+
path("analytics/", views.analytics, name="analytics"),
1011
path("post/unpublished/", views.unpublished_posts, name="unpublished_posts"),
1112
path("post/create/", views.create_post, name="create_post"),
1213
path("post/<slug>/update/", views.update_post, name="update_post"),

project/newsletter/views.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import uuid
3+
from datetime import timedelta
34

45
from django.conf import settings
56
from django.contrib import messages
@@ -12,13 +13,14 @@
1213
from django.db.models import Case, Count, F, Q, Value, When
1314
from django.http import Http404, HttpResponse, JsonResponse
1415
from django.shortcuts import get_object_or_404, redirect, render
16+
from django.utils import timezone
1517
from django.utils.translation import gettext_lazy as _
1618
from django.views.decorators.http import require_http_methods
1719
from martor.utils import LazyEncoder
1820

1921
from project.newsletter import operations
2022
from project.newsletter.forms import PostForm, SubscriptionForm
21-
from project.newsletter.models import Post, Subscription
23+
from project.newsletter.models import Category, Post, Subscription
2224

2325
LIST_POSTS_PAGE_SIZE = 100
2426

@@ -162,6 +164,80 @@ def update_post(request, slug):
162164
return render(request, "staff/post_form.html", {"form": form, "post": post})
163165

164166

167+
def determine_buckets(instance):
168+
"""
169+
Helper function to determine which buckets should be incremented.
170+
171+
:param instance: A model instance with ``created`` property.
172+
:return: tuple(bool, bool, bool)
173+
"""
174+
now = timezone.now()
175+
bound_30_days = now - timedelta(days=30)
176+
bound_90_days = now - timedelta(days=90)
177+
bound_180_days = now - timedelta(days=180)
178+
return (
179+
bound_30_days <= instance.created,
180+
bound_90_days <= instance.created,
181+
bound_180_days <= instance.created,
182+
)
183+
184+
185+
def analyze_categorized_model(Model, label):
186+
"""
187+
Count the number of instances in the last X days and with a category.
188+
189+
This can be used with Subscription or Post.
190+
"""
191+
count = 0
192+
count_30_days = 0
193+
count_90_days = 0
194+
count_180_days = 0
195+
category_aggregates = {
196+
category.title: 0 for category in Category.objects.order_by("title")
197+
}
198+
for obj in Model.objects.all().prefetch_related("categories"):
199+
# Increment category buckets
200+
for category in obj.categories.all():
201+
category_aggregates[category.title] += 1
202+
# Increment our post counts
203+
count += 1
204+
incr_30, incr_90, incr_180 = determine_buckets(obj)
205+
if incr_30:
206+
count_30_days += 1
207+
if incr_90:
208+
count_90_days += 1
209+
if incr_180:
210+
count_180_days += 1
211+
return {
212+
label: count,
213+
f"{label} (30 days)": count_30_days,
214+
f"{label} (90 days)": count_90_days,
215+
f"{label} (180 days)": count_180_days,
216+
}, category_aggregates
217+
218+
219+
@staff_member_required(login_url=settings.LOGIN_URL)
220+
@require_http_methods(["GET"])
221+
def analytics(request):
222+
"""
223+
The post detail view.
224+
"""
225+
(
226+
subscription_aggregates,
227+
subscription_category_aggregates,
228+
) = analyze_categorized_model(Subscription, "Subscriptions")
229+
post_aggregates, post_category_aggregates = analyze_categorized_model(Post, "Posts")
230+
return render(
231+
request,
232+
"staff/analytics.html",
233+
{
234+
"aggregates": {**subscription_aggregates, **post_aggregates},
235+
"subscription_category_aggregates": subscription_category_aggregates,
236+
"post_category_aggregates": post_category_aggregates,
237+
},
238+
)
239+
240+
165241
@staff_member_required(login_url=settings.LOGIN_URL)
166242
@require_http_methods(["POST"])
167243
def toggle_post_privacy(request, slug):

project/templates/base.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
<a href="{% url "auth_login" %}" class="header item">Login</a>
3636
{% else %}
3737
<a href="{% url "newsletter:update_subscription" %}" class="header item">Settings</a>
38+
{% if request.user.is_staff %}
39+
<a href="{% url "newsletter:analytics" %}" class="header item">Analytics</a>
40+
{% endif %}
3841
{% endif %}
3942
<div class="right menu">
4043
<a class="item" href="https://github.com/tim-schilling/debug-tutorial" target="_blank">

0 commit comments

Comments
 (0)