Skip to content

fix: modularize admin dashboard statistics with reusable helpers #555

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 22 additions & 34 deletions backend/donations/views/dashboard/admin_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _

from donations.models.ngos import Ngo
from redirectioneaza.common.cache import cache_decorator
from donations.views.dashboard.stats_helpers.chart import donors_for_month
from donations.views.dashboard.stats_helpers.metrics import (
all_active_ngos,
all_redirections,
current_year_redirections,
ngos_active_in_current_year,
ngos_with_ngo_hub,
)
from donations.views.dashboard.stats_helpers.yearly import get_stats_for_year

from ...models.donors import Donor
from .helpers import (
generate_donations_per_month_chart,
get_current_year_range,
Expand All @@ -27,7 +33,6 @@ def callback(request, context) -> Dict:
return context


@cache_decorator(timeout=settings.TIMEOUT_CACHE_NORMAL, cache_key=ADMIN_DASHBOARD_STATS_CACHE_KEY)
def _get_admin_stats() -> Dict:
today = now()
years_range_ascending = get_current_year_range()
Expand Down Expand Up @@ -55,53 +60,54 @@ def _get_header_stats(today) -> List[List[Dict[str, Union[str, int | datetime]]]
{
"title": _("Donations this year"),
"icon": "edit_document",
"metric": Donor.available.filter(date_created__year=current_year).count(),
"metric": current_year_redirections["metric"],
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function call parentheses. Should be current_year_redirections()["metric"] to actually invoke the function.

Suggested change
"metric": current_year_redirections["metric"],
"metric": current_year_redirections()["metric"],

Copilot uses AI. Check for mistakes.

"footer": _create_stat_link(
url=f'{reverse("admin:donations_donor_changelist")}?{current_year_range}', text=_("View all")
),
"timestamp": now(),
"timestamp": current_year_redirections["timestamp"],
},
{
"title": _("Donations all-time"),
"icon": "edit_document",
"metric": Donor.available.count(),
"metric": all_redirections["metric"],
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function call parentheses. Should be all_redirections()["metric"] to actually invoke the function.

Suggested change
"metric": all_redirections["metric"],
"metric": all_redirections()["metric"],

Copilot uses AI. Check for mistakes.

"footer": _create_stat_link(url=reverse("admin:donations_donor_changelist"), text=_("View all")),
"timestamp": now(),
"timestamp": all_redirections["timestamp"],
Comment on lines +72 to +74
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function call parentheses. Should be all_redirections()["timestamp"] to actually invoke the function.

Copilot uses AI. Check for mistakes.

},
{
"title": _("NGOs registered"),
"icon": "foundation",
"metric": Ngo.active.count(),
"metric": all_active_ngos["metric"],
"footer": _create_stat_link(
url=f'{reverse("admin:donations_ngo_changelist")}?is_active=1', text=_("View all")
),
"timestamp": now(),
"timestamp": all_active_ngos["timestamp"],
Comment on lines +79 to +83
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function call parentheses. Should be all_active_ngos()["metric"] to actually invoke the function.

Copilot uses AI. Check for mistakes.

Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function call parentheses. Should be all_active_ngos()["timestamp"] to actually invoke the function.

Suggested change
"timestamp": all_active_ngos["timestamp"],
"timestamp": all_active_ngos()["timestamp"],

Copilot uses AI. Check for mistakes.

},
{
"title": _("Functioning NGOs"),
"icon": "foundation",
"metric": Ngo.with_forms_this_year.count(),
"metric": ngos_active_in_current_year["metric"],
"footer": _create_stat_link(url=f'{reverse("admin:donations_ngo_changelist")}', text=_("View all")),
"timestamp": now(),
"timestamp": ngos_active_in_current_year["timestamp"],
Comment on lines +88 to +90
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function call parentheses. Should be ngos_active_in_current_year()["metric"] to actually invoke the function.

Copilot uses AI. Check for mistakes.

Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function call parentheses. Should be ngos_active_in_current_year()["timestamp"] to actually invoke the function.

Suggested change
"timestamp": ngos_active_in_current_year["timestamp"],
"timestamp": ngos_active_in_current_year()["timestamp"],

Copilot uses AI. Check for mistakes.

},
{
"title": _("NGOs from NGO Hub"),
"icon": "foundation",
"metric": Ngo.ngo_hub.count(),
"metric": ngos_with_ngo_hub["metric"],
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function call parentheses. Should be ngos_with_ngo_hub()["metric"] to actually invoke the function.

Suggested change
"metric": ngos_with_ngo_hub["metric"],
"metric": ngos_with_ngo_hub()["metric"],

Copilot uses AI. Check for mistakes.

"footer": _create_stat_link(
url=f'{reverse("admin:donations_ngo_changelist")}?is_active=1&has_ngohub=1', text=_("View all")
),
"timestamp": now(),
"timestamp": ngos_with_ngo_hub["timestamp"],
Comment on lines +63 to +99
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function call parentheses. Should be current_year_redirections()["timestamp"] to actually invoke the function.

Copilot uses AI. Check for mistakes.

Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing function call parentheses. Should be ngos_with_ngo_hub()["timestamp"] to actually invoke the function.

Suggested change
"timestamp": ngos_with_ngo_hub["timestamp"],
"timestamp": ngos_with_ngo_hub()["timestamp"],

Copilot uses AI. Check for mistakes.

},
]
]


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

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

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


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

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


# TODO: This cache seems useless because we already cache the entire dashboard stats
@cache_decorator(timeout=settings.TIMEOUT_CACHE_NORMAL, cache_key_prefix=ADMIN_DASHBOARD_CACHE_KEY)
def _get_stats_for_year(year: int) -> Dict[str, int | datetime]:
donations: int = Donor.available.filter(date_created__year=year).count()
ngos_registered: int = Ngo.objects.filter(date_created__year=year).count()
ngos_with_forms: int = Donor.available.filter(date_created__year=year).values("ngo_id").distinct().count()

statistic = {
"year": year,
"donations": donations,
"ngos_registered": ngos_registered,
"ngos_with_forms": ngos_with_forms,
"timestamp": now(),
}

return statistic


def _format_yearly_stats(statistics) -> List[Dict[str, Union[int, List[Dict]]]]:
return [
{
Expand Down
Empty file.
88 changes: 88 additions & 0 deletions backend/donations/views/dashboard/stats_helpers/chart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from datetime import datetime
from typing import Dict

from django.core.cache import cache
from django.utils.timezone import now
from django_q.tasks import async_task

from donations.models import Donor
from donations.views.dashboard.stats_helpers.utils import cache_set

STATS_FOR_MONTH_CACHE_PREFIX = "STATS_FOR_MONTH_"


def donors_for_month(month: int, year: int = None) -> Dict[str, int]:
"""
Determines the number of donors for a specified month and year.

This function retrieves the number of donors for a specific month and year
from the cache if available and valid. If the cache is invalid or absent,
it initiates an asynchronous task to update the stats and returns a default
statistic. If the year parameter is not provided, it defaults to the
current year.

Parameters:
month (int): The month for which donor statistics are requested.
year (int, optional): The year for which donor statistics are required or current year.

Returns:
Dict[str, int]: A dictionary containing the number of donors for the specified month and year.
"""
current_time = now()

if year is None:
year = current_time.year

cache_key = f"{STATS_FOR_MONTH_CACHE_PREFIX}{year}_{month}"

if cached_stats := cache.get(cache_key):
if _is_cache_valid(current_time, month, year):
return cached_stats

cache.delete(cache_key)

default_stat = {
"metric": -2,
"year": year,
"month": month,
}

async_task(
_update_stats_for_month,
month,
year,
cache_key,
)

return cached_stats or default_stat


def _is_cache_valid(current_time: datetime, month: int, year: int) -> bool:
if not current_time:
return False

if year < current_time.year:
return True

if month < current_time.month:
return True

return False


def _update_stats_for_month(month: int, year: int, cache_key: str) -> Dict[str, int]:
"""
Updates the number of donors for a specific month and year, and caches the result.
If the cache is valid, it returns the cached number of donors.
"""
donors_count = Donor.objects.filter(date_created__year=year, date_created__month=month).count()

stat = {
"metric": donors_count,
"year": year,
"month": month,
}

cache_set(cache_key, stat)

return stat
104 changes: 104 additions & 0 deletions backend/donations/views/dashboard/stats_helpers/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from django.conf import settings
from django.core.cache import cache
from django.utils.timezone import now
from django_q.tasks import async_task

from donations.models import Donor, Ngo
from donations.views.dashboard.stats_helpers.utils import cache_set


def _cache_key_for_metric(metric_name: str) -> str:
"""
Generates a cache key for the given metric name.
"""
return f"METRIC_{metric_name.upper()}"


def metrics_cache_decorator(func):
"""
Decorator to cache the metrics functions.
"""

def wrapper(*args, **kwargs):
CACHE_KEY = _cache_key_for_metric(func.__name__)

if cached_result := cache.get(CACHE_KEY):
# if the cache has expired (the timestamp is older than TIMEOUT_CACHE_NORMAL),
# we delete the cache entry, trigger an async task to recalculate the metric,
# and return the cached result; the updated metric will be available in the next request
cache_timestamp = cached_result.get("timestamp")
if cache_timestamp and (now() - cache_timestamp).total_seconds() > settings.TIMEOUT_CACHE_NORMAL:
cache.delete(CACHE_KEY)
async_task(func.__name__, *args, **kwargs)

return cached_result
default_result = {
"metric": -2,
"timestamp": now(),
}

async_task(func.__name__, *args, **kwargs)

return default_result

return wrapper


@metrics_cache_decorator
def current_year_redirections():
result = {
"metric": Donor.available.filter(date_created__year=now().year).count(),
"timestamp": now(),
}

cache_set(_cache_key_for_metric("current_year_redirections"), result)

return result


@metrics_cache_decorator
def all_redirections():
result = {
"metric": Donor.available.count(),
"timestamp": now(),
}

cache_set(_cache_key_for_metric("all_redirections"), result)

return result


@metrics_cache_decorator
def all_active_ngos():
result = {
"metric": Ngo.active.count(),
"timestamp": now(),
}

cache_set(_cache_key_for_metric("all_active_ngos"), result)

return result


@metrics_cache_decorator
def ngos_active_in_current_year():
result = {
"metric": Ngo.with_forms_this_year.count(),
"timestamp": now(),
}

cache_set(_cache_key_for_metric("ngos_active_in_current_year"), result)

return result


@metrics_cache_decorator
def ngos_with_ngo_hub():
result = {
"metric": Ngo.ngo_hub.count(),
"timestamp": now(),
}

cache_set(_cache_key_for_metric("ngos_with_ngo_hub"), result)

return result
9 changes: 9 additions & 0 deletions backend/donations/views/dashboard/stats_helpers/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.core.cache import cache


def cache_set(key: str, value: dict) -> None:
"""
Sets a value in the cache with a specified key.
The timeout is set to None, meaning the cache will not expire.
"""
cache.set(key, value, timeout=None)
Loading