Skip to content

Commit 92f300d

Browse files
committed
fix: modularize admin dashboard statistics with reusable helpers
- extract yearly stats logic into `stats_helpers_yearly.py` - extract metrics-related logic into `stats_helper_metrics.py` - extract chart-related logic into `stats_helper_chart.py` - refactor admin dashboard to use new helpers for stats and metrics
1 parent 0985636 commit 92f300d

File tree

4 files changed

+312
-34
lines changed

4 files changed

+312
-34
lines changed

backend/donations/views/dashboard/admin_dashboard.py

Lines changed: 22 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@
77
from django.utils.timezone import now
88
from django.utils.translation import gettext_lazy as _
99

10-
from donations.models.ngos import Ngo
11-
from redirectioneaza.common.cache import cache_decorator
10+
from .stats_helper_chart import donors_for_month
11+
from .stats_helper_metrics import (
12+
all_active_ngos,
13+
all_redirections,
14+
current_year_redirections,
15+
ngos_active_in_current_year,
16+
ngos_with_ngo_hub,
17+
)
18+
from .stats_helpers_yearly import get_stats_for_year
1219

13-
from ...models.donors import Donor
1420
from .helpers import (
1521
generate_donations_per_month_chart,
1622
get_current_year_range,
@@ -27,7 +33,6 @@ def callback(request, context) -> Dict:
2733
return context
2834

2935

30-
@cache_decorator(timeout=settings.TIMEOUT_CACHE_NORMAL, cache_key=ADMIN_DASHBOARD_STATS_CACHE_KEY)
3136
def _get_admin_stats() -> Dict:
3237
today = now()
3338
years_range_ascending = get_current_year_range()
@@ -55,53 +60,54 @@ def _get_header_stats(today) -> List[List[Dict[str, Union[str, int | datetime]]]
5560
{
5661
"title": _("Donations this year"),
5762
"icon": "edit_document",
58-
"metric": Donor.available.filter(date_created__year=current_year).count(),
63+
"metric": current_year_redirections["metric"],
5964
"footer": _create_stat_link(
6065
url=f'{reverse("admin:donations_donor_changelist")}?{current_year_range}', text=_("View all")
6166
),
62-
"timestamp": now(),
67+
"timestamp": current_year_redirections["timestamp"],
6368
},
6469
{
6570
"title": _("Donations all-time"),
6671
"icon": "edit_document",
67-
"metric": Donor.available.count(),
72+
"metric": all_redirections["metric"],
6873
"footer": _create_stat_link(url=reverse("admin:donations_donor_changelist"), text=_("View all")),
69-
"timestamp": now(),
74+
"timestamp": all_redirections["timestamp"],
7075
},
7176
{
7277
"title": _("NGOs registered"),
7378
"icon": "foundation",
74-
"metric": Ngo.active.count(),
79+
"metric": all_active_ngos["metric"],
7580
"footer": _create_stat_link(
7681
url=f'{reverse("admin:donations_ngo_changelist")}?is_active=1', text=_("View all")
7782
),
78-
"timestamp": now(),
83+
"timestamp": all_active_ngos["timestamp"],
7984
},
8085
{
8186
"title": _("Functioning NGOs"),
8287
"icon": "foundation",
83-
"metric": Ngo.with_forms_this_year.count(),
88+
"metric": ngos_active_in_current_year["metric"],
8489
"footer": _create_stat_link(url=f'{reverse("admin:donations_ngo_changelist")}', text=_("View all")),
85-
"timestamp": now(),
90+
"timestamp": ngos_active_in_current_year["timestamp"],
8691
},
8792
{
8893
"title": _("NGOs from NGO Hub"),
8994
"icon": "foundation",
90-
"metric": Ngo.ngo_hub.count(),
95+
"metric": ngos_with_ngo_hub["metric"],
9196
"footer": _create_stat_link(
9297
url=f'{reverse("admin:donations_ngo_changelist")}?is_active=1&has_ngohub=1', text=_("View all")
9398
),
94-
"timestamp": now(),
99+
"timestamp": ngos_with_ngo_hub["timestamp"],
95100
},
96101
]
97102
]
98103

99104

100105
def _create_chart_statistics() -> Dict[str, str]:
101106
default_border_width: int = 3
107+
current_year = now().year
102108

103109
donations_per_month_queryset = [
104-
Donor.available.filter(date_created__month=month) for month in range(1, settings.DONATIONS_LIMIT.month + 1)
110+
donors_for_month(month, current_year)["metric"] for month in range(1, settings.DONATIONS_LIMIT.month + 1)
105111
]
106112

107113
forms_per_month_chart = generate_donations_per_month_chart(default_border_width, donations_per_month_queryset)
@@ -110,7 +116,7 @@ def _create_chart_statistics() -> Dict[str, str]:
110116

111117

112118
def _get_yearly_stats(years_range_ascending) -> List[Dict[str, Union[int, List[Dict]]]]:
113-
statistics = [_get_stats_for_year(year) for year in years_range_ascending]
119+
statistics = [get_stats_for_year(year) for year in years_range_ascending]
114120

115121
for index, statistic in enumerate(statistics):
116122
if index == 0:
@@ -129,24 +135,6 @@ def _get_yearly_stats(years_range_ascending) -> List[Dict[str, Union[int, List[D
129135
return sorted(final_statistics, key=lambda x: x["year"], reverse=True)
130136

131137

132-
# TODO: This cache seems useless because we already cache the entire dashboard stats
133-
@cache_decorator(timeout=settings.TIMEOUT_CACHE_NORMAL, cache_key_prefix=ADMIN_DASHBOARD_CACHE_KEY)
134-
def _get_stats_for_year(year: int) -> Dict[str, int | datetime]:
135-
donations: int = Donor.available.filter(date_created__year=year).count()
136-
ngos_registered: int = Ngo.objects.filter(date_created__year=year).count()
137-
ngos_with_forms: int = Donor.available.filter(date_created__year=year).values("ngo_id").distinct().count()
138-
139-
statistic = {
140-
"year": year,
141-
"donations": donations,
142-
"ngos_registered": ngos_registered,
143-
"ngos_with_forms": ngos_with_forms,
144-
"timestamp": now(),
145-
}
146-
147-
return statistic
148-
149-
150138
def _format_yearly_stats(statistics) -> List[Dict[str, Union[int, List[Dict]]]]:
151139
return [
152140
{
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from datetime import datetime
2+
from typing import Dict
3+
4+
from django.core.cache import cache
5+
from django.utils.timezone import now
6+
from django_q.tasks import async_task
7+
8+
from donations.models import Donor
9+
10+
11+
STATS_FOR_MONTH_CACHE_PREFIX = "STATS_FOR_MONTH_"
12+
13+
14+
def donors_for_month(month: int, year: int = None) -> Dict[str, int]:
15+
"""
16+
Determines the number of donors for a specified month and year.
17+
18+
This function retrieves the number of donors for a specific month and year
19+
from the cache if available and valid. If the cache is invalid or absent,
20+
it initiates an asynchronous task to update the stats and returns a default
21+
statistic. If the year parameter is not provided, it defaults to the
22+
current year.
23+
24+
Parameters:
25+
month (int): The month for which donor statistics are requested.
26+
year (int, optional): The year for which donor statistics are required or current year.
27+
28+
Returns:
29+
Dict[str, int]: A dictionary containing the number of donors for the specified month and year.
30+
"""
31+
current_time = now()
32+
33+
if year is None:
34+
year = current_time.year
35+
36+
cache_key = f"{STATS_FOR_MONTH_CACHE_PREFIX}{year}_{month}"
37+
38+
if cached_stats := cache.get(cache_key):
39+
if _is_cache_valid(current_time, month, year):
40+
return cached_stats
41+
42+
cache.delete(cache_key)
43+
44+
default_stat = {
45+
"metric": -2,
46+
"year": year,
47+
"month": month,
48+
}
49+
50+
async_task(
51+
_update_stats_for_month,
52+
month,
53+
year,
54+
cache_key,
55+
hook="_donors_for_month_callback",
56+
task_name="donors_for_month_task",
57+
)
58+
59+
return cached_stats or default_stat
60+
61+
62+
def _is_cache_valid(current_time: datetime, month: int, year: int) -> bool:
63+
if not current_time:
64+
return False
65+
66+
if year < current_time.year:
67+
return True
68+
69+
if month < current_time.month:
70+
return True
71+
72+
return False
73+
74+
75+
def _update_stats_for_month(month: int, year: int, cache_key: str) -> Dict[str, int]:
76+
"""
77+
Updates the number of donors for a specific month and year, and caches the result.
78+
If the cache is valid, it returns the cached number of donors.
79+
"""
80+
donors_count = Donor.objects.filter(date_created__year=year, date_created__month=month).count()
81+
82+
stat = {
83+
"metric": donors_count,
84+
"year": year,
85+
"month": month,
86+
}
87+
88+
cache.set(cache_key, stat)
89+
90+
return stat
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from django.conf import settings
2+
from django.core.cache import cache
3+
from django.utils.timezone import now
4+
from django_q.tasks import async_task
5+
6+
from donations.models import Donor, Ngo
7+
8+
9+
def _cache_key_for_metric(metric_name: str) -> str:
10+
"""
11+
Generates a cache key for the given metric name.
12+
"""
13+
return f"METRIC_{metric_name.upper()}"
14+
15+
16+
def metrics_cache_decorator(func):
17+
"""
18+
Decorator to cache the metrics functions.
19+
"""
20+
21+
def wrapper(*args, **kwargs):
22+
CACHE_KEY = _cache_key_for_metric(func.__name__)
23+
24+
if cached_result := cache.get(CACHE_KEY):
25+
# if the cache has expired (the timestamp is older than TIMEOUT_CACHE_NORMAL),
26+
# we delete the cache entry, trigger an async task to recalculate the metric,
27+
# and return the cached result; the updated metric will be available in the next request
28+
cache_timestamp = cached_result.get("timestamp")
29+
if cache_timestamp and (now() - cache_timestamp).total_seconds() > settings.TIMEOUT_CACHE_NORMAL:
30+
cache.delete(CACHE_KEY)
31+
async_task(func.__name__, *args, **kwargs)
32+
33+
return cached_result
34+
default_result = {
35+
"metric": -2,
36+
"timestamp": now(),
37+
}
38+
39+
async_task(func.__name__, *args, **kwargs)
40+
41+
return default_result
42+
43+
return wrapper
44+
45+
46+
@metrics_cache_decorator
47+
def current_year_redirections():
48+
result = {
49+
"metric": Donor.available.filter(date_created__year=now().year).count(),
50+
"timestamp": now(),
51+
}
52+
53+
cache.set(_cache_key_for_metric("current_year_redirections"), result)
54+
55+
return result
56+
57+
58+
@metrics_cache_decorator
59+
def all_redirections():
60+
result = {
61+
"metric": Donor.available.count(),
62+
"timestamp": now(),
63+
}
64+
65+
cache.set(_cache_key_for_metric("all_redirections"), result)
66+
67+
return result
68+
69+
70+
@metrics_cache_decorator
71+
def all_active_ngos():
72+
result = {
73+
"metric": Ngo.active.count(),
74+
"timestamp": now(),
75+
}
76+
77+
cache.set(_cache_key_for_metric("all_active_ngos"), result)
78+
79+
return result
80+
81+
82+
@metrics_cache_decorator
83+
def ngos_active_in_current_year():
84+
result = {
85+
"metric": Ngo.with_forms_this_year.count(),
86+
"timestamp": now(),
87+
}
88+
89+
cache.set(_cache_key_for_metric("ngos_active_in_current_year"), result)
90+
91+
return result
92+
93+
94+
@metrics_cache_decorator
95+
def ngos_with_ngo_hub():
96+
result = {
97+
"metric": Ngo.ngo_hub.count(),
98+
"timestamp": now(),
99+
}
100+
101+
cache.set(_cache_key_for_metric("ngos_with_ngo_hub"), result)
102+
103+
return result

0 commit comments

Comments
 (0)