Skip to content

Commit 4139ca5

Browse files
committed
refactor: change the admin stats
- create a stats model to cache all stats - create schedulers and tasks to update stats - refactor schedulers to use standard template
1 parent c12f2b7 commit 4139ca5

27 files changed

Lines changed: 722 additions & 147 deletions

File tree

backend/donations/admin/donors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22

33
from django.contrib import admin, messages
4+
from django.core.management import call_command
45
from django.core.validators import EMPTY_VALUES
56
from django.db.models import QuerySet
67
from django.http import HttpRequest
@@ -140,6 +141,7 @@ class DonorAdmin(ModelAdmin):
140141
)
141142

142143
actions = ("remove_donations",)
144+
actions_list = ("run_redirections_stats_generator", "run_redirections_stats_generator_force")
143145

144146
def has_change_permission(self, request, obj=None):
145147
return False
@@ -179,3 +181,11 @@ def remove_donations(self, request, queryset: QuerySet[Donor]):
179181
) % {"failure": task_results[REMOVE_DONATIONS_FAILURE_FLAG]}
180182

181183
self.message_user(request, ", ".join([part_1, part_2, part_3 + "."]))
184+
185+
@action(description=_("Schedule redirections stats"), url_path="schedule-redirections-stats-generator")
186+
def run_redirections_stats_generator(self, request, queryset: QuerySet[Donor]):
187+
call_command("generate_redirections_stats")
188+
189+
@action(description=_("Schedule redirections stats [FORCE]"), url_path="schedule-redirections-stats-generator-f")
190+
def run_redirections_stats_generator_force(self, request, queryset: QuerySet[Donor]):
191+
call_command("generate_redirections_stats", "--force")
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import logging
2+
from datetime import date
3+
from typing import Set
4+
5+
from django.core.management import BaseCommand
6+
from django_q.tasks import async_task
7+
8+
from donations.models import Donor
9+
from donations.models.stat_configs import StatsChoices, create_stat
10+
from stats.models import Stat
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class Command(BaseCommand):
16+
help = "Run a generator that looks over all of the dates that are supposed to have donation stats and generates them."
17+
18+
def add_arguments(self, parser):
19+
parser.add_argument(
20+
"--force",
21+
action="store_true",
22+
help="Force regeneration of stats even if they already exist for certain dates.",
23+
default=False,
24+
)
25+
26+
def handle(self, *args, **kwargs):
27+
force: bool = kwargs.get("force", False)
28+
29+
target_set: Set[date] = {
30+
dt.date() for dt in (Donor.available.all().values_list("date_created", flat=True).distinct())
31+
}
32+
33+
if not force:
34+
existing_stats_dates: Set[date] = set(
35+
Stat.objects.filter(name=StatsChoices.REDIRECTIONS_PER_DAY).values_list("date", flat=True).distinct()
36+
)
37+
38+
target_set: Set[date] = target_set - existing_stats_dates
39+
40+
for single_date in target_set:
41+
async_task(
42+
create_stat,
43+
stat_choice=StatsChoices.REDIRECTIONS_PER_DAY,
44+
for_date=single_date,
45+
)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from datetime import datetime
2+
3+
from django.core.management import BaseCommand
4+
5+
from donations.models.stat_configs import StatsChoices, create_stat
6+
7+
8+
class Command(BaseCommand):
9+
help = "Generate the statistics for the dashboard."
10+
11+
def add_arguments(self, parser):
12+
parser.add_argument(
13+
"statistic",
14+
type=str,
15+
help="What type of statistic to generate",
16+
choices=StatsChoices.values,
17+
)
18+
parser.add_argument(
19+
"--date",
20+
type=str,
21+
help="Date for which to generate the statistic (YYYY-MM-DD). Required for REDIRECTIONS_PER_DAY.",
22+
)
23+
24+
def handle(self, *args, **options):
25+
statistic_type: str = options["statistic"]
26+
for_date_str: str = options.get("date")
27+
28+
if statistic_type == StatsChoices.REDIRECTIONS_PER_DAY and not for_date_str:
29+
self.stderr.write("Error: --date argument is required for REDIRECTIONS_PER_DAY statistic.")
30+
return
31+
32+
self.stdout.write(f"Generating statistics for: {statistic_type}")
33+
34+
if for_date_str:
35+
for_date = datetime.strptime(for_date_str, "%Y-%m-%d").date()
36+
create_stat(stat_choice=StatsChoices(statistic_type), for_date=for_date)
37+
else:
38+
create_stat(stat_choice=StatsChoices(statistic_type))
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import logging
2+
from datetime import timedelta
3+
4+
from django.utils import timezone
5+
from django_q.models import Schedule
6+
7+
from utils.common.commands import SchedulerCommand
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class Command(SchedulerCommand):
13+
help = "Schedule a recurring task to generate donation statistics."
14+
15+
command_name: str = "generate_redirections_stats"
16+
17+
schedule_name: str = "REDIRECTION_STATS"
18+
schedule_details = {
19+
"schedule_type": Schedule.MINUTES,
20+
"minutes": 5,
21+
"repeats": -1,
22+
"next_run": timezone.now() + timedelta(minutes=0),
23+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from datetime import date, datetime, timedelta
2+
from decimal import Decimal
3+
from typing import Optional
4+
5+
from django.db import models
6+
from django.utils.timezone import now
7+
8+
from donations.models import Donor, Ngo
9+
from stats.models import Stat
10+
11+
12+
class StatsChoices(models.TextChoices):
13+
REDIRECTIONS_PER_DAY = "donations_per_day", "Donations per Year and Month"
14+
NGOS_REGISTERED = "ngos_registered", "Registered NGOs"
15+
NGOS_ACTIVE = "ngos_active", "Active NGOs"
16+
NGOS_WITH_NGOHUB = "ngos_with_ngohub", "NGOs with NGO Hub"
17+
18+
19+
def _create_stat_redirections_per_day(for_date: date):
20+
metric: Decimal = Donor.available.filter(date_created__date=for_date).count()
21+
expiration: Optional[datetime] = now() + timedelta(days=5)
22+
if for_date < now().date():
23+
expiration = None
24+
25+
Stat.objects.update_or_create(
26+
name=StatsChoices.REDIRECTIONS_PER_DAY,
27+
date=for_date,
28+
value=metric,
29+
expires_at=expiration,
30+
)
31+
32+
33+
def _create_stat_ngos_registered():
34+
metric: Decimal = Ngo.active.count()
35+
expiration: datetime = now() + timedelta(minutes=5)
36+
37+
Stat.objects.update_or_create(
38+
name=StatsChoices.NGOS_REGISTERED,
39+
value=metric,
40+
expires_at=expiration,
41+
)
42+
43+
44+
def _create_stat_ngos_active():
45+
metric: Decimal = Ngo.with_forms_this_year.count()
46+
expiration: datetime = now() + timedelta(minutes=5)
47+
48+
Stat.objects.update_or_create(
49+
name=StatsChoices.NGOS_ACTIVE,
50+
value=metric,
51+
expires_at=expiration,
52+
)
53+
54+
55+
def _create_stat_ngos_with_ngohub():
56+
metric: Decimal = Ngo.ngo_hub.count()
57+
expiration: datetime = now() + timedelta(minutes=5)
58+
59+
Stat.objects.update_or_create(
60+
name=StatsChoices.NGOS_WITH_NGOHUB,
61+
value=metric,
62+
expires_at=expiration,
63+
)
64+
65+
66+
def create_stat(*, stat_choice: StatsChoices, for_date: date = None) -> None:
67+
if stat_choice == StatsChoices.REDIRECTIONS_PER_DAY:
68+
if for_date is None:
69+
raise ValueError("for_date must be provided for REDIRECTIONS_PER_DAY statistic.")
70+
_create_stat_redirections_per_day(for_date=for_date)
71+
elif stat_choice == StatsChoices.NGOS_REGISTERED:
72+
_create_stat_ngos_registered()
73+
elif stat_choice == StatsChoices.NGOS_ACTIVE:
74+
_create_stat_ngos_active()
75+
elif stat_choice == StatsChoices.NGOS_WITH_NGOHUB:
76+
_create_stat_ngos_with_ngohub()

backend/donations/views/dashboard/admin_dashboard.py

Lines changed: 45 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime
1+
from datetime import datetime, tzinfo
22
from typing import Dict, List, Union
33

44
from django.conf import settings
@@ -7,10 +7,17 @@
77
from django.utils.timezone import now
88
from django.utils.translation import gettext_lazy as _
99

10-
from donations.models.ngos import Ngo
10+
from donations.views.dashboard.stats_helpers.chart import donors_for_month
11+
from donations.views.dashboard.stats_helpers.metrics import (
12+
all_redirections,
13+
all_registered_ngos,
14+
current_year_redirections,
15+
ngos_active_in_current_year,
16+
ngos_with_ngo_hub,
17+
)
18+
from donations.views.dashboard.stats_helpers.yearly import get_stats_for_year
1119
from redirectioneaza.common.cache import cache_decorator
1220

13-
from ...models.donors import Donor
1421
from .helpers import (
1522
generate_donations_per_month_chart,
1623
get_current_year_range,
@@ -19,20 +26,18 @@
1926

2027
ADMIN_DASHBOARD_CACHE_KEY = "ADMIN_DASHBOARD"
2128
ADMIN_DASHBOARD_STATS_CACHE_KEY = "ADMIN_DASHBOARD_STATS"
29+
ADMIN_DASHBOARD_HEADER_CACHE_KEY = "ADMIN_DASHBOARD_HEADER"
2230

2331

24-
def callback(request, context) -> Dict:
32+
def callback(_, context) -> Dict:
2533
context.update(_get_admin_stats())
26-
2734
return context
2835

2936

30-
@cache_decorator(timeout=settings.TIMEOUT_CACHE_NORMAL, cache_key=ADMIN_DASHBOARD_STATS_CACHE_KEY)
3137
def _get_admin_stats() -> Dict:
32-
today = now()
3338
years_range_ascending = get_current_year_range()
3439

35-
header_stats: List[List[Dict[str, Union[str, int]]]] = _get_header_stats(today)
40+
header_stats: List[List[Dict[str, Union[str, int]]]] = _get_header_stats()
3641

3742
yearly_stats: List[Dict] = _get_yearly_stats(years_range_ascending)
3843

@@ -45,72 +50,79 @@ def _get_admin_stats() -> Dict:
4550
}
4651

4752

48-
def _get_header_stats(today) -> List[List[Dict[str, Union[str, int | datetime]]]]:
49-
current_year = today.year
53+
@cache_decorator(timeout=settings.TIMEOUT_CACHE_SHORT, cache_key=ADMIN_DASHBOARD_HEADER_CACHE_KEY)
54+
def _get_header_stats() -> List[List[Dict[str, Union[str, int | datetime]]]]:
55+
today: datetime = now()
5056

51-
current_year_range = get_encoded_current_year_range(current_year, today.tzinfo)
57+
current_year: int = today.year
58+
tz_info: tzinfo = today.tzinfo
59+
60+
current_year_range = get_encoded_current_year_range(current_year, tz_info)
5261

5362
return [
5463
[
5564
{
5665
"title": _("Donations this year"),
5766
"icon": "edit_document",
58-
"metric": Donor.available.filter(date_created__year=current_year).count(),
67+
"metric": current_year_redirections(),
5968
"footer": _create_stat_link(
60-
url=f"{reverse('admin:donations_donor_changelist')}?{current_year_range}", text=_("View all")
69+
url=f"{reverse('admin:donations_donor_changelist')}?{current_year_range}",
70+
text=_("View all"),
6171
),
62-
"timestamp": now(),
6372
},
6473
{
6574
"title": _("Donations all-time"),
6675
"icon": "edit_document",
67-
"metric": Donor.available.count(),
68-
"footer": _create_stat_link(url=reverse("admin:donations_donor_changelist"), text=_("View all")),
69-
"timestamp": now(),
76+
"metric": all_redirections(),
77+
"footer": _create_stat_link(
78+
url=reverse("admin:donations_donor_changelist"),
79+
text=_("View all"),
80+
),
7081
},
7182
{
7283
"title": _("NGOs registered"),
7384
"icon": "foundation",
74-
"metric": Ngo.active.count(),
85+
"metric": all_registered_ngos(),
7586
"footer": _create_stat_link(
76-
url=f"{reverse('admin:donations_ngo_changelist')}?is_active=1", text=_("View all")
87+
url=f"{reverse('admin:donations_ngo_changelist')}?is_active=1",
88+
text=_("View all"),
7789
),
78-
"timestamp": now(),
7990
},
8091
{
8192
"title": _("Functioning NGOs"),
8293
"icon": "foundation",
83-
"metric": Ngo.with_forms_this_year.count(),
84-
"footer": _create_stat_link(url=f"{reverse('admin:donations_ngo_changelist')}", text=_("View all")),
85-
"timestamp": now(),
94+
"metric": ngos_active_in_current_year(),
95+
"footer": _create_stat_link(
96+
url=f"{reverse('admin:donations_ngo_changelist')}",
97+
text=_("View all"),
98+
),
8699
},
87100
{
88101
"title": _("NGOs from NGO Hub"),
89102
"icon": "foundation",
90-
"metric": Ngo.ngo_hub.count(),
103+
"metric": ngos_with_ngo_hub(),
91104
"footer": _create_stat_link(
92-
url=f"{reverse('admin:donations_ngo_changelist')}?is_active=1&has_ngohub=1", text=_("View all")
105+
url=f"{reverse('admin:donations_ngo_changelist')}?is_active=1&has_ngohub=1",
106+
text=_("View all"),
93107
),
94-
"timestamp": now(),
95108
},
96109
]
97110
]
98111

99112

100113
def _create_chart_statistics() -> Dict[str, str]:
101114
default_border_width: int = 3
115+
current_year = now().year
102116

103-
donations_per_month_queryset = [
104-
Donor.available.filter(date_created__month=month) for month in range(1, settings.DONATIONS_LIMIT.month + 1)
117+
donations_per_month_queryset: List[int] = [
118+
donors_for_month(month, current_year)["metric"] for month in range(1, settings.DONATIONS_LIMIT.month + 1)
105119
]
106120

107-
forms_per_month_chart = generate_donations_per_month_chart(default_border_width, donations_per_month_queryset)
108-
109-
return forms_per_month_chart
121+
return generate_donations_per_month_chart(default_border_width, donations_per_month_queryset)
110122

111123

112124
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]
125+
statistics = [get_stats_for_year(year) for year in years_range_ascending]
114126

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

131143

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-
150144
def _format_yearly_stats(statistics) -> List[Dict[str, Union[int, List[Dict]]]]:
151145
return [
152146
{
@@ -191,5 +185,5 @@ def _format_yearly_stats(statistics) -> List[Dict[str, Union[int, List[Dict]]]]:
191185
]
192186

193187

194-
def _create_stat_link(url: str, text: str) -> str:
188+
def _create_stat_link(url: str, text) -> str:
195189
return mark_safe(f'<a href="{url}" class="text-orange-700 font-semibold">{text}</a>')

0 commit comments

Comments
 (0)