Skip to content

Commit 4948cb6

Browse files
renzonrenzon
authored andcommitted
Moved statistic business rules to domain module
Fix #1932
1 parent a561e93 commit 4948cb6

10 files changed

Lines changed: 164 additions & 133 deletions

File tree

‎pythonpro/cohorts/facade.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33

44
from pythonpro.cohorts.models import Cohort as _Cohort, CohortStudent, LiveClass as _LiveClass, Webinar as _Webinar
55

6+
__all__ = [
7+
'get_all_cohorts_desc',
8+
'find_cohort',
9+
'find_most_recent_cohort',
10+
'calculate_most_recent_cohort_path',
11+
'find_webinars',
12+
'find_webinar',
13+
'find_live_class',
14+
]
15+
616

717
def get_all_cohorts_desc():
818
return tuple(_Cohort.objects.order_by('-start'))

‎pythonpro/dashboard/facade.py‎

Lines changed: 6 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,12 @@
1-
from functools import partial
2-
from itertools import chain, product
3-
from operator import attrgetter
1+
from pythonpro.dashboard.models import TopicInteraction
42

5-
import pytz
6-
from django.db.models import Count, Max, Sum
7-
from django.utils.datetime_safe import datetime
8-
9-
from pythonpro.dashboard.models import TopicInteraction as _TopicInteracion
10-
from pythonpro.modules.facade import get_entire_content_forest
11-
from pythonpro.modules.models import Topic
3+
__all__ = ['has_watched_any_topic', ]
124

135

146
def has_watched_any_topic(user) -> bool:
15-
return _TopicInteracion.objects.filter(user=user).exists()
16-
17-
18-
def calculate_topic_interaction_history(user):
197
"""
20-
Calculate user's interaction history ordered by last topic interaction
21-
It will return a list of topics annotated with:
22-
1. last_interaction: last time user interacted with topic
23-
2. max_watched_time: max video time where user stopped watching
24-
3. total_watched_time: sum of all time user spent watching video
25-
4. interactions_count: number of times user started watching video
26-
5. duration: topic duration based on the min time this class had
27-
:param user:
28-
:return:
8+
Indicates if a user has watched any topic
9+
:param: user
10+
:return: boolean
2911
"""
30-
31-
return Topic.objects.filter(
32-
topicinteraction__user=user
33-
).annotate(
34-
last_interaction=Max('topicinteraction__creation'),
35-
max_watched_time=Max('topicinteraction__max_watched_time'),
36-
total_watched_time=Sum('topicinteraction__total_watched_time'),
37-
interactions_count=Count('topicinteraction')
38-
).order_by('-last_interaction').select_related('chapter').select_related('chapter__section').select_related(
39-
'chapter__section__module')[:20]
40-
41-
42-
def calculate_module_progresses(user):
43-
"""
44-
Calculate the user progress on all modules
45-
:param user:
46-
:return:
47-
"""
48-
# arbitrary default value for last interaction
49-
default_min_time = datetime(1970, 1, 1, tzinfo=pytz.utc)
50-
topic_property_defaults = {
51-
'last_interaction': default_min_time,
52-
'max_watched_time': 0,
53-
'total_watched_time': 0,
54-
'interactions_count': 0,
55-
'topics_count': 0,
56-
'finished_topics_count': 0,
57-
}
58-
59-
# this is here due to bug on Heroku which is not installing Python 3.8:
60-
# https://sentry.io/organizations/python-pro/issues/1471675608/?project=236278&query=is%3Aunresolved
61-
def sum_with_start_0(lst):
62-
lst = list(lst)
63-
if len(lst) == 0:
64-
return 0
65-
return sum(lst)
66-
67-
aggregation_functions = {
68-
'last_interaction': partial(max, default=default_min_time),
69-
'max_watched_time': sum_with_start_0,
70-
'total_watched_time': sum_with_start_0,
71-
'interactions_count': sum_with_start_0,
72-
'topics_count': sum_with_start_0,
73-
'finished_topics_count': sum_with_start_0,
74-
'duration': sum_with_start_0,
75-
}
76-
77-
def _aggregate_statistics(contents, content_children_property_name):
78-
for content, (property_, aggregation_function) in product(contents, aggregation_functions.items()):
79-
children = getattr(content, content_children_property_name)
80-
setattr(content, property_, aggregation_function(map(attrgetter(property_), children)))
81-
82-
def _flaten(iterable, children_property_name):
83-
for i in iterable:
84-
for child in getattr(i, children_property_name):
85-
yield child
86-
87-
def calculate_progression(content):
88-
try:
89-
return min(content.max_watched_time / content.duration, 1)
90-
except ZeroDivisionError:
91-
return 0
92-
93-
qs = Topic.objects.filter(topicinteraction__user=user).values('id').annotate(
94-
last_interaction=Max('topicinteraction__creation'),
95-
interactions_count=Max('topicinteraction'),
96-
max_watched_time=Max('topicinteraction__max_watched_time'),
97-
total_watched_time=Sum('topicinteraction__total_watched_time'),
98-
children_count=Count('topicinteraction')).all()
99-
100-
user_interacted_topics = {t['id']: t for t in qs}
101-
modules = get_entire_content_forest()
102-
all_sections = list(_flaten(modules, 'sections'))
103-
all_chapters = list(_flaten(all_sections, 'chapters'))
104-
all_topics = list(_flaten(all_chapters, 'topics'))
105-
for topic, (property_, default_value) in product(all_topics, topic_property_defaults.items()):
106-
user_interaction_data = user_interacted_topics.get(topic.id, {})
107-
setattr(topic, property_, user_interaction_data.get(property_, default_value))
108-
for topic in all_topics:
109-
topic.progress = calculate_progression(topic)
110-
topic.topics_count = 1
111-
watched_to_end = topic.progress > 0.99
112-
spent_half_time_watching = topic.total_watched_time * 2 > topic.duration
113-
topic.finished_topics_count = 1 if (watched_to_end and spent_half_time_watching) else 0
114-
115-
contents_with_children_property_name = [
116-
(all_chapters, 'topics'),
117-
(all_sections, 'chapters'),
118-
(modules, 'sections')
119-
]
120-
for contents, content_children_property_name in contents_with_children_property_name:
121-
_aggregate_statistics(contents, content_children_property_name)
122-
123-
for content in chain(all_chapters, all_sections, modules):
124-
setattr(content, 'progress', calculate_progression(content))
125-
126-
return modules
12+
return TopicInteraction.objects.filter(user=user).exists()

‎pythonpro/dashboard/tests/test_dashboard/__init__.py‎

Whitespace-only changes.

‎pythonpro/dashboard/views.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,20 @@
33
from django.shortcuts import render
44
from django.views.decorators.csrf import csrf_exempt
55

6-
from pythonpro.dashboard import facade as dashboard_facade
76
from pythonpro.dashboard.facade import has_watched_any_topic
87
from pythonpro.dashboard.forms import TopicInteractionForm
9-
from pythonpro.domain import user_facade
8+
from pythonpro.domain import content_statistics_domain, user_facade
109
from pythonpro.modules.models import Topic
1110

1211

1312
@login_required
1413
def home(request):
15-
topics = list(dashboard_facade.calculate_topic_interaction_history(request.user))
14+
topics = list(content_statistics_domain.calculate_topic_interaction_history(request.user))
1615

1716
for topic in topics:
1817
topic.calculated_module = topic.find_module()
1918

20-
module_progresses = dashboard_facade.calculate_module_progresses(request.user)
19+
module_progresses = content_statistics_domain.calculate_module_progresses(request.user)
2120
ctx = {'topics': topics, 'module_progresses': module_progresses}
2221
return render(
2322
request,
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""
2+
Domain responsible for calculate content statistics in general
3+
"""
4+
from functools import partial
5+
from itertools import chain, product
6+
from operator import attrgetter
7+
8+
import pytz
9+
from django.db.models import Count, Max, Sum
10+
from django.utils.datetime_safe import datetime
11+
12+
from pythonpro.modules.facade import get_entire_content_forest, topics_user_interacted_queryset
13+
from pythonpro.modules.models import Topic
14+
15+
__all = [
16+
'calculate_topic_interaction_history',
17+
'calculate_module_progresses'
18+
]
19+
20+
21+
def calculate_topic_interaction_history(user):
22+
"""
23+
Calculate user's interaction history ordered by last topic interaction
24+
It will return a list of topics annotated with:
25+
1. last_interaction: last time user interacted with topic
26+
2. max_watched_time: max video time where user stopped watching
27+
3. total_watched_time: sum of all time user spent watching video
28+
4. interactions_count: number of times user started watching video
29+
5. duration: topic duration based on the min time this class had
30+
:param user:
31+
:return: Topic annotated with statistics
32+
"""
33+
34+
return topics_user_interacted_queryset(user).annotate(
35+
last_interaction=Max('topicinteraction__creation'),
36+
max_watched_time=Max('topicinteraction__max_watched_time'),
37+
total_watched_time=Sum('topicinteraction__total_watched_time'),
38+
interactions_count=Count('topicinteraction')
39+
).order_by('-last_interaction')[:20]
40+
41+
42+
def calculate_module_progresses(user):
43+
"""
44+
Calculate the user progress on all modules
45+
:param user:
46+
:return:
47+
"""
48+
# arbitrary default value for last interaction
49+
default_min_time = datetime(1970, 1, 1, tzinfo=pytz.utc)
50+
topic_property_defaults = {
51+
'last_interaction': default_min_time,
52+
'max_watched_time': 0,
53+
'total_watched_time': 0,
54+
'interactions_count': 0,
55+
'topics_count': 0,
56+
'finished_topics_count': 0,
57+
}
58+
59+
# this is here due to bug on Heroku which is not installing Python 3.8:
60+
# https://sentry.io/organizations/python-pro/issues/1471675608/?project=236278&query=is%3Aunresolved
61+
def sum_with_start_0(lst):
62+
lst = list(lst)
63+
if len(lst) == 0:
64+
return 0
65+
return sum(lst)
66+
67+
aggregation_functions = {
68+
'last_interaction': partial(max, default=default_min_time),
69+
'max_watched_time': sum_with_start_0,
70+
'total_watched_time': sum_with_start_0,
71+
'interactions_count': sum_with_start_0,
72+
'topics_count': sum_with_start_0,
73+
'finished_topics_count': sum_with_start_0,
74+
'duration': sum_with_start_0,
75+
}
76+
77+
def _aggregate_statistics(contents, content_children_property_name):
78+
for content, (property_, aggregation_function) in product(contents, aggregation_functions.items()):
79+
children = getattr(content, content_children_property_name)
80+
setattr(content, property_, aggregation_function(map(attrgetter(property_), children)))
81+
82+
def _flaten(iterable, children_property_name):
83+
for i in iterable:
84+
for child in getattr(i, children_property_name):
85+
yield child
86+
87+
def calculate_progression(content):
88+
try:
89+
return min(content.max_watched_time / content.duration, 1)
90+
except ZeroDivisionError:
91+
return 0
92+
93+
qs = Topic.objects.filter(topicinteraction__user=user).values('id').annotate(
94+
last_interaction=Max('topicinteraction__creation'),
95+
interactions_count=Max('topicinteraction'),
96+
max_watched_time=Max('topicinteraction__max_watched_time'),
97+
total_watched_time=Sum('topicinteraction__total_watched_time'),
98+
children_count=Count('topicinteraction')).all()
99+
100+
user_interacted_topics = {t['id']: t for t in qs}
101+
modules = get_entire_content_forest()
102+
all_sections = list(_flaten(modules, 'sections'))
103+
all_chapters = list(_flaten(all_sections, 'chapters'))
104+
all_topics = list(_flaten(all_chapters, 'topics'))
105+
for topic, (property_, default_value) in product(all_topics, topic_property_defaults.items()):
106+
user_interaction_data = user_interacted_topics.get(topic.id, {})
107+
setattr(topic, property_, user_interaction_data.get(property_, default_value))
108+
for topic in all_topics:
109+
topic.progress = calculate_progression(topic)
110+
topic.topics_count = 1
111+
watched_to_end = topic.progress > 0.99
112+
spent_half_time_watching = topic.total_watched_time * 2 > topic.duration
113+
topic.finished_topics_count = 1 if (watched_to_end and spent_half_time_watching) else 0
114+
115+
contents_with_children_property_name = [
116+
(all_chapters, 'topics'),
117+
(all_sections, 'chapters'),
118+
(modules, 'sections')
119+
]
120+
for contents, content_children_property_name in contents_with_children_property_name:
121+
_aggregate_statistics(contents, content_children_property_name)
122+
123+
for content in chain(all_chapters, all_sections, modules):
124+
setattr(content, 'progress', calculate_progression(content))
125+
126+
return modules

pythonpro/dashboard/tests/test_dashboard/test_certificate_list.py renamed to pythonpro/domain/tests/test_certificate_list.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from django.urls import reverse
33
from model_mommy import mommy
44

5-
from pythonpro.dashboard import facade
5+
import pythonpro.domain.content_statistics_domain
66
from pythonpro.dashboard.models import TopicInteraction
77
from pythonpro.django_assertions import dj_assert_contains
88
from pythonpro.modules.models import Chapter, Module, Section, Topic
@@ -72,7 +72,7 @@ def test_module_title_is_present_on_card(resp, modules):
7272

7373

7474
def test_module_percentage_style_on_card(resp, logged_user):
75-
for m in facade.calculate_module_progresses(logged_user):
75+
for m in pythonpro.domain.content_statistics_domain.calculate_module_progresses(logged_user):
7676
dj_assert_contains(resp, f'style="width: {m.progress:.0%}"')
7777

7878

pythonpro/dashboard/tests/test_dashboard/test_topic_interaction_aggregate.py renamed to pythonpro/domain/tests/test_topic_interaction_aggregate.py

File renamed without changes.

‎pythonpro/modules/facade.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from django.db.models import Prefetch
22

3-
from pythonpro.modules.models import (Chapter as _Chapter, Module as _Module, Section as _Section, Topic as _Topic)
3+
from pythonpro.modules.models import (
4+
Chapter as _Chapter, Module as _Module, Section as _Section, Topic as _Topic,
5+
)
46

57

68
def get_topic_model():
@@ -99,3 +101,11 @@ def get_entire_content_forest():
99101
),
100102
to_attr='sections')
101103
))
104+
105+
106+
def topics_user_interacted_queryset(user):
107+
return _Topic.objects.filter(
108+
topicinteraction__user=user
109+
).select_related('chapter').select_related('chapter__section').select_related(
110+
'chapter__section__module'
111+
)

‎pythonpro/payments/views.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from django.views.generic import RedirectView
1313

1414
from pythonpro.cohorts import facade as cohorts_facade
15-
from pythonpro.domain import membership_facade, user_facade
15+
from pythonpro.domain import membership_domain, user_facade
1616
from pythonpro.payments import facade as payment_facade
1717
from pythonpro.payments.facade import (
1818
PYTOOLS_OTO_PRICE, PYTOOLS_PRICE, PYTOOLS_PROMOTION_PRICE, PagarmeNotPaidTransaction,
@@ -34,7 +34,7 @@ def member_capture(request):
3434
return
3535
user = request.user
3636
token = request.POST['token']
37-
dct = membership_facade.capture_payment(token, user, request.GET.get('utm_source', default='unknown'))
37+
dct = membership_domain.capture_payment(token, user, request.GET.get('utm_source', default='unknown'))
3838
return JsonResponse(dct)
3939

4040

@@ -156,10 +156,10 @@ def _render_launch_page(is_launch_open, request, template_closed_launch, templat
156156
notification_url = reverse('payments:membership_anonymous_notification')
157157
if is_launch_open:
158158
template = template_open_launch
159-
discount = membership_facade.calculate_discount(user)
159+
discount = membership_domain.calculate_discount(user)
160160
discount_float = discount / 100
161161

162-
price = membership_facade.calculate_membership_price(user)
162+
price = membership_domain.calculate_membership_price(user)
163163
price_float = price / 100
164164
full_price_float = price_float + discount_float
165165
price_installment = (price // 10) / 100
@@ -256,7 +256,7 @@ def waiting_list_ty(request):
256256
def membership_notification(request, user_id: int):
257257
if request.method != 'POST':
258258
return HttpResponseNotAllowed([request.method])
259-
membership_facade.subscribe_member_who_paid_boleto(
259+
membership_domain.subscribe_member_who_paid_boleto(
260260
user_id,
261261
request.POST,
262262
request.body.decode('utf8'),
@@ -300,7 +300,7 @@ def pagarme_anonymous_notification(request):
300300
def membership_anonymous_notification(request):
301301
if request.method != 'POST':
302302
return HttpResponseNotAllowed([request.method])
303-
membership_facade.subscribe_anonymous_member_who_paid_boleto(
303+
membership_domain.subscribe_anonymous_member_who_paid_boleto(
304304
request.POST,
305305
request.body.decode('utf8'),
306306
request.headers['X-Hub-Signature'],

0 commit comments

Comments
 (0)