Skip to content

Commit 5dede4a

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 5dede4a

28 files changed

Lines changed: 801 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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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 task that "
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+
"""
28+
Generate redirection statistics for donors.
29+
`force` argument forces regeneration of stats even if they already exist.
30+
If the `force` argument is not provided, the command will only generate stats for dates
31+
that do not already have stats recorded and expired stats will be cleaned up first.
32+
"""
33+
force: bool = kwargs.get("force", False)
34+
35+
target_set: Set[date] = {
36+
dt.date() for dt in (Donor.available.all().values_list("date_created", flat=True).distinct())
37+
}
38+
39+
if not force:
40+
# Get existing stats dates that are not expired
41+
existing_stats_dates: Set[date] = set(
42+
Stat.objects.filter(name=StatsChoices.REDIRECTIONS_PER_DAY)
43+
.exclude(expires_at__lte=date.today())
44+
.values_list("date", flat=True)
45+
.distinct()
46+
)
47+
48+
target_set: Set[date] = target_set - existing_stats_dates
49+
50+
for single_date in target_set:
51+
async_task(
52+
create_stat,
53+
stat_choice=StatsChoices.REDIRECTIONS_PER_DAY,
54+
for_date=single_date,
55+
)
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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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 donations.models.stat_configs import StatsChoices
8+
from utils.common.commands import SchedulerCommand
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class Command(SchedulerCommand):
14+
help = "Schedule a recurring task to generate ngos statistics."
15+
16+
command_name: str = "generate_stats"
17+
18+
schedule_prefix: str = "GENERATE_STATS_NGOS"
19+
schedule_details = {
20+
"schedule_type": Schedule.MINUTES,
21+
"minutes": 15,
22+
"repeats": -1,
23+
"next_run": timezone.now() + timedelta(minutes=0),
24+
}
25+
26+
choices = [StatsChoices.NGOS_REGISTERED, StatsChoices.NGOS_ACTIVE, StatsChoices.NGOS_WITH_NGOHUB]
27+
28+
def add_arguments(self, parser):
29+
parser.add_argument(
30+
"statistic",
31+
type=str,
32+
help="What type of statistic to generate",
33+
choices=self.choices,
34+
)
35+
36+
def handle(self, *args, **kwargs):
37+
statistic: str = kwargs["statistic"]
38+
39+
if statistic not in self.choices:
40+
self.stderr.write("Error: Invalid statistic type provided.")
41+
return
42+
43+
if statistic == StatsChoices.NGOS_REGISTERED:
44+
self.schedule_name = f"{self.schedule_prefix}_REGISTERED"
45+
self.function_args = (StatsChoices.NGOS_REGISTERED,)
46+
47+
if statistic == StatsChoices.NGOS_ACTIVE:
48+
self.schedule_name = f"{self.schedule_prefix}_ACTIVE"
49+
self.function_args = (StatsChoices.NGOS_ACTIVE,)
50+
51+
if statistic == StatsChoices.NGOS_WITH_NGOHUB:
52+
self.schedule_name = f"{self.schedule_prefix}_WITH_NGOHUB"
53+
self.function_args = (StatsChoices.NGOS_WITH_NGOHUB,)
54+
55+
super().handle(*args, **kwargs)
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 = "GENERATE_STATS_REDIRECTIONS"
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: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
defaults={
29+
"value": metric,
30+
"expires_at": expiration,
31+
},
32+
)
33+
34+
35+
def _create_stat_ngos_registered():
36+
metric: Decimal = Ngo.active.count()
37+
expiration: datetime = now() + timedelta(minutes=5)
38+
39+
Stat.objects.update_or_create(
40+
name=StatsChoices.NGOS_REGISTERED,
41+
defaults={
42+
"value": metric,
43+
"expires_at": expiration,
44+
},
45+
)
46+
47+
48+
def _create_stat_ngos_active():
49+
metric: Decimal = Ngo.with_forms_this_year.count()
50+
expiration: datetime = now() + timedelta(minutes=5)
51+
52+
Stat.objects.update_or_create(
53+
name=StatsChoices.NGOS_ACTIVE,
54+
defaults={
55+
"value": metric,
56+
"expires_at": expiration,
57+
},
58+
)
59+
60+
61+
def _create_stat_ngos_with_ngohub():
62+
metric: Decimal = Ngo.ngo_hub.count()
63+
expiration: datetime = now() + timedelta(minutes=5)
64+
65+
Stat.objects.update_or_create(
66+
name=StatsChoices.NGOS_WITH_NGOHUB,
67+
defaults={
68+
"value": metric,
69+
"expires_at": expiration,
70+
},
71+
)
72+
73+
74+
def create_stat(*, stat_choice: StatsChoices, for_date: date = None) -> None:
75+
if stat_choice == StatsChoices.REDIRECTIONS_PER_DAY:
76+
if for_date is None:
77+
raise ValueError("for_date must be provided for REDIRECTIONS_PER_DAY statistic.")
78+
_create_stat_redirections_per_day(for_date=for_date)
79+
elif stat_choice == StatsChoices.NGOS_REGISTERED:
80+
_create_stat_ngos_registered()
81+
elif stat_choice == StatsChoices.NGOS_ACTIVE:
82+
_create_stat_ngos_active()
83+
elif stat_choice == StatsChoices.NGOS_WITH_NGOHUB:
84+
_create_stat_ngos_with_ngohub()

0 commit comments

Comments
 (0)