Skip to content

Commit 8fbb5d0

Browse files
committed
refactor: robust stat tracking system
1 parent b797e56 commit 8fbb5d0

File tree

5 files changed

+250
-164
lines changed

5 files changed

+250
-164
lines changed

courses/management/commands/sync_external_course_runs.py

+9-64
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
update_external_course_runs,
1010
)
1111
from ecommerce.mail_api import send_external_data_sync_email
12+
from courses.management.utils import StatsCollector
1213

1314

1415
class Command(BaseCommand):
@@ -56,80 +57,24 @@ def handle(self, *args, **options): # noqa: ARG002
5657
)
5758
return
5859

60+
stats_collector = StatsCollector()
61+
5962
self.stdout.write(f"Starting course sync for {vendor_name}.")
6063
keymap = keymap()
6164
external_course_runs = fetch_external_courses(keymap)
62-
stats = update_external_course_runs(external_course_runs, keymap)
65+
update_external_course_runs(external_course_runs, keymap, stats_collector)
66+
67+
email_stats = stats_collector.email_stats()
68+
6369
send_external_data_sync_email(
6470
vendor_name=vendor_name,
65-
stats=stats,
71+
stats=email_stats,
6672
)
67-
self.log_stats(stats)
73+
stats_collector.log_stats(self)
6874
self.stdout.write(
6975
self.style.SUCCESS(f"External course sync successful for {vendor_name}.")
7076
)
7177

72-
def log_stats(self, stats):
73-
"""
74-
Logs the stats for the external course sync.
75-
76-
Args:
77-
stats(dict): Dict containing results for the objects created/updated.
78-
"""
79-
80-
def extract_first_item(data_set):
81-
return {item[0] for item in data_set} if data_set else set()
82-
83-
def log_stat(category, key, label):
84-
items = extract_first_item(stats.get(key, set()))
85-
self.log_style_success(f"Number of {category}: {len(items)}.")
86-
self.log_style_success(f"{label}: {items or 0}\n")
87-
88-
log_stat("Courses Created", "courses_created", "External Course Codes")
89-
log_stat("Existing Courses", "existing_courses", "External Course Codes")
90-
log_stat(
91-
"Course Runs Created", "course_runs_created", "External Course Run Codes"
92-
)
93-
log_stat(
94-
"Course Runs Updated", "course_runs_updated", "External Course Run Codes"
95-
)
96-
log_stat("Products Created", "products_created", "Course Run courseware_ids")
97-
log_stat(
98-
"Product Versions Created",
99-
"product_versions_created",
100-
"Course Run courseware_ids",
101-
)
102-
log_stat(
103-
"Course Runs without prices",
104-
"course_runs_without_prices",
105-
"External Course Codes",
106-
)
107-
log_stat(
108-
"Course Pages Created", "course_pages_created", "External Course Codes"
109-
)
110-
log_stat(
111-
"Course Pages Updated", "course_pages_updated", "External Course Codes"
112-
)
113-
log_stat(
114-
"Certificate Pages Created", "certificates_created", "Course Readable IDs"
115-
)
116-
log_stat(
117-
"Certificate Pages Updated", "certificates_updated", "Course Readable IDs"
118-
)
119-
log_stat(
120-
"Course Runs Skipped due to bad data",
121-
"course_runs_skipped",
122-
"External Course Run Codes",
123-
)
124-
log_stat(
125-
"Expired Course Runs", "course_runs_expired", "External Course Run Codes"
126-
)
127-
log_stat(
128-
"Course Runs Deactivated",
129-
"course_runs_deactivated",
130-
"External Course Run Codes",
131-
)
132-
13378
def log_style_success(self, log_msg):
13479
"""
13580
Logs success styled message.

courses/management/utils.py

+144
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Utility functions/classes for course management commands"""
22

3+
from dataclasses import dataclass
4+
from typing import Optional
5+
36
from django.core.management.base import BaseCommand, CommandError
47

58
from courses.models import CourseRun, CourseRunEnrollment, Program, ProgramEnrollment
@@ -243,3 +246,144 @@ def enroll_in_edx(self, user, course_runs):
243246
) as exc:
244247
self.stdout.write(self.style.WARNING(str(exc)))
245248
return False
249+
250+
251+
@dataclass
252+
class CourseInfo:
253+
"""
254+
Data class for course information with named fields
255+
"""
256+
257+
code: str
258+
title: Optional[str] = None
259+
msg: Optional[str] = None
260+
261+
262+
class StatCategory:
263+
"""Represents a category of statistics with its display information"""
264+
265+
def __init__(self, key, display_name=None, label=None):
266+
"""
267+
Initialize a stat category
268+
269+
Args:
270+
key: The dictionary key for this stat
271+
display_name: Human-readable category name
272+
label: Description of the items
273+
"""
274+
self.key = key
275+
self.display_name = display_name or self._generate_display_name(key)
276+
self.label = label or self._generate_label(key)
277+
self.items = []
278+
279+
def _generate_display_name(self, key):
280+
"""Generate a display name from the key"""
281+
return " ".join(word.capitalize() for word in key.split("_"))
282+
283+
def _generate_label(self, key):
284+
"""Generate a label based on the key"""
285+
if "course" in key and "run" not in key:
286+
return "External Course Codes"
287+
elif "run" in key:
288+
return "External Course Run Codes"
289+
elif "product" in key:
290+
return "Course Run courseware_ids"
291+
elif "certificate" in key:
292+
return "Course Readable IDs"
293+
else:
294+
return "Items"
295+
296+
def add(self, code, title=None, msg=None):
297+
"""Add an item to this stat category"""
298+
self.items.append(CourseInfo(code=code, title=title, msg=msg))
299+
300+
def get_codes(self):
301+
"""Get the set of unique codes in this category"""
302+
return {item.code for item in self.items if item.code is not None}
303+
304+
def __len__(self):
305+
"""Return the number of items in this category"""
306+
return len(self.items)
307+
308+
309+
class StatsCollector:
310+
"""Collector for external course sync statistics with named properties"""
311+
312+
def __init__(self):
313+
self.categories = {
314+
"courses_created": StatCategory("courses_created"),
315+
"existing_courses": StatCategory("existing_courses"),
316+
"course_runs_created": StatCategory("course_runs_created"),
317+
"course_runs_updated": StatCategory("course_runs_updated"),
318+
"course_runs_without_prices": StatCategory("course_runs_without_prices"),
319+
"course_runs_skipped": StatCategory(
320+
"course_runs_skipped",
321+
display_name="Course Runs Skipped due to bad data",
322+
),
323+
"course_runs_expired": StatCategory(
324+
"course_runs_deactivated", display_name="Expired Course Runs"
325+
),
326+
"course_runs_deactivated": StatCategory("course_runs_deactivated"),
327+
"course_pages_created": StatCategory("course_pages_created"),
328+
"course_pages_updated": StatCategory("course_pages_updated"),
329+
"products_created": StatCategory("products_created"),
330+
"product_versions_created": StatCategory("product_versions_created"),
331+
"certificates_created": StatCategory(
332+
"certificates_created", display_name="Certificate Pages Created"
333+
),
334+
"certificates_updated": StatCategory(
335+
"certificates_updated", display_name="Certificate Pages Updated"
336+
),
337+
}
338+
339+
def add_stat(self, key, code, title=None, msg=None):
340+
"""
341+
Add an item to a specific stat category
342+
"""
343+
if key in self.categories:
344+
self.categories[key].add(code, title, msg)
345+
346+
def add_bulk(self, key, codes):
347+
"""
348+
Add multiple items within the same category
349+
"""
350+
if key in self.categories:
351+
for code in codes:
352+
self.add_stat(key, code)
353+
354+
def remove_duplicates(self, source_key, items_to_remove_key):
355+
"""
356+
Remove items from one category that exist in another category
357+
"""
358+
if (
359+
source_key not in self.categories
360+
or items_to_remove_key not in self.categories
361+
):
362+
return
363+
364+
codes_to_remove = {
365+
item.code for item in self.categories[items_to_remove_key].items
366+
}
367+
368+
self.categories[source_key].items = [
369+
item
370+
for item in self.categories[source_key].items
371+
if item.code not in codes_to_remove
372+
]
373+
374+
def log_stats(self, logger):
375+
"""
376+
Log all collected statistics
377+
"""
378+
for category in self.categories.values():
379+
codes = category.get_codes()
380+
logger.log_style_success(
381+
f"Number of {category.display_name}: {len(codes)}."
382+
)
383+
logger.log_style_success(f"{category.label}: {codes or 0}\n")
384+
385+
def email_stats(self):
386+
"""
387+
Return statistics formatted for email template"
388+
"""
389+
return {key: category.items for key, category in self.categories.items()}

0 commit comments

Comments
 (0)