Coverage for app/venv/lib/python3.14/site-packages/weblate/metrics/wrapper.py: 36%
203 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7from calendar import monthrange
8from datetime import date, timedelta
9from typing import TYPE_CHECKING
11from django.core.cache import cache
12from django.utils import timezone
13from django.utils.functional import cached_property
14from django.utils.translation import pgettext_lazy
16from weblate.metrics.models import Metric
18if TYPE_CHECKING: 18 ↛ 19line 18 didn't jump to line 19 because the condition on line 18 was never true
19 from django.utils.functional import Promise
21MONTH_NAMES = [
22 pgettext_lazy("Short name of month", "Jan"),
23 pgettext_lazy("Short name of month", "Feb"),
24 pgettext_lazy("Short name of month", "Mar"),
25 pgettext_lazy("Short name of month", "Apr"),
26 pgettext_lazy("Short name of month", "May"),
27 pgettext_lazy("Short name of month", "Jun"),
28 pgettext_lazy("Short name of month", "Jul"),
29 pgettext_lazy("Short name of month", "Aug"),
30 pgettext_lazy("Short name of month", "Sep"),
31 pgettext_lazy("Short name of month", "Oct"),
32 pgettext_lazy("Short name of month", "Nov"),
33 pgettext_lazy("Short name of month", "Dec"),
34]
37class MetricsWrapper:
38 def __init__(self, obj, scope: int, relation: int, secondary: int = 0) -> None:
39 self.obj = obj
40 self.scope = scope
41 self.relation = relation
42 self.secondary = secondary
44 @cached_property
45 def _data(self) -> tuple[Metric, Metric, Metric]:
46 today = timezone.now().date()
47 dates = [today - timedelta(days=days) for days in [0, 1, 30, 31, 60, 61]]
49 # Use use range as it is more likely to use index than
50 # the IN operator with six values.
51 metrics = Metric.objects.filter_metric(
52 self.scope, self.relation, self.secondary
53 ).filter(date__range=(dates[-1], dates[0]))
55 # Fetch the most recent metric for each date
56 current = past_30 = past_60 = Metric()
57 for metric in metrics:
58 if metric.date in dates[0:2] and current.pk is None:
59 current = metric
60 if metric.date in dates[2:4] and past_30.pk is None:
61 past_30 = metric
62 if metric.date in dates[4:6] and past_60.pk is None:
63 past_30 = metric
65 return (current, past_30, past_60)
67 @property
68 def current(self) -> Metric:
69 return self._data[0]
71 @property
72 def past_30(self) -> Metric:
73 return self._data[1]
75 @property
76 def past_60(self) -> Metric:
77 return self._data[2]
79 @property
80 def all_words(self) -> int:
81 return self.current["all_words"]
83 @property
84 def all(self) -> int:
85 return self.current["all"]
87 @property
88 def translated_percent(self) -> float:
89 total = self.all
90 if not total:
91 return 0
92 return 100 * self.current["translated"] / total
94 @property
95 def contributors(self) -> int:
96 return self.current.get("contributors", 0)
98 @property
99 def contributors_total(self) -> int:
100 return self.current.get("contributors_total", 0)
102 def calculate_trend_percent(
103 self, key, modkey, base: Metric, origin: Metric
104 ) -> float:
105 total = base.get(key, 0)
106 if not total:
107 return 0
108 divisor = base[modkey]
109 if not divisor:
110 return 0
111 total = 100 * total / divisor
113 past = origin[key]
114 if not past:
115 return total
117 divisor = origin[modkey]
118 if not divisor:
119 return total
120 past = 100 * past / divisor
121 return total - past
123 def calculate_trend(self, key, base: Metric, origin: Metric) -> float:
124 total = base.get(key, 0)
125 if not total:
126 return 0
127 return 100 * (total - origin[key]) / total
129 @property
130 def trend_30_all(self) -> float:
131 return self.calculate_trend("all", self.current, self.past_30)
133 @property
134 def trend_30_all_words(self) -> float:
135 return self.calculate_trend("all_words", self.current, self.past_30)
137 @property
138 def trend_30_contributors(self) -> float:
139 return self.calculate_trend("contributors", self.current, self.past_30)
141 @property
142 def trend_30_translated_percent(self) -> float:
143 return self.calculate_trend_percent(
144 "translated", "all", self.current, self.past_30
145 )
147 @property
148 def trend_60_all(self) -> float:
149 return self.calculate_trend("all", self.past_30, self.past_60)
151 @property
152 def trend_60_all_words(self) -> float:
153 return self.calculate_trend("all_words", self.past_30, self.past_60)
155 @property
156 def trend_60_contributors(self) -> float:
157 return self.calculate_trend("contributors", self.past_30, self.past_60)
159 @property
160 def trend_60_translated_percent(self) -> float:
161 return self.calculate_trend_percent(
162 "translated", "all", self.past_30, self.past_60
163 )
165 @property
166 def projects(self) -> int:
167 return self.current["projects"]
169 @property
170 def languages(self) -> int:
171 return self.current["languages"]
173 @property
174 def components(self) -> int:
175 return self.current["components"]
177 @property
178 def translations(self) -> int:
179 return self.current["translations"]
181 @property
182 def screenshots(self) -> int:
183 return self.current["screenshots"]
185 @property
186 def users(self) -> int:
187 return self.current["users"]
189 @property
190 def trend_30_projects(self) -> float:
191 return self.calculate_trend("projects", self.current, self.past_30)
193 @property
194 def trend_30_languages(self) -> float:
195 return self.calculate_trend("languages", self.current, self.past_30)
197 @property
198 def trend_30_components(self) -> float:
199 return self.calculate_trend("components", self.current, self.past_30)
201 @property
202 def trend_30_users(self) -> float:
203 return self.calculate_trend("users", self.current, self.past_30)
205 @property
206 def trend_60_projects(self) -> float:
207 return self.calculate_trend("projects", self.past_30, self.past_60)
209 @property
210 def trend_60_languages(self) -> float:
211 return self.calculate_trend("languages", self.past_30, self.past_60)
213 @property
214 def trend_60_components(self) -> float:
215 return self.calculate_trend("components", self.past_30, self.past_60)
217 @property
218 def trend_60_users(self) -> float:
219 return self.calculate_trend("users", self.past_30, self.past_60)
221 def get_daily_activity(self, start: date, days: int) -> dict[date, int]:
222 today = timezone.now().date()
223 kwargs = {
224 "scope": self.scope,
225 "relation": self.relation,
226 }
227 if self.secondary:
228 kwargs["secondary"] = self.secondary
229 result = dict(
230 Metric.objects.filter(
231 date__range=(start - timedelta(days=days), start),
232 **kwargs,
233 ).values_list("date", "changes")
234 )
235 for offset in range(days):
236 current = start - timedelta(days=offset)
237 if current not in result:
238 if current == today:
239 # Lazily calculate today value (if task is not running)
240 result[current] = Metric.objects.calculate_changes(
241 date=current,
242 obj=self.obj,
243 **kwargs,
244 )
245 else:
246 # Use zero if metric is not stored
247 result[current] = 0
248 return result
250 @cached_property
251 def daily_activity(self) -> list[int]:
252 today = timezone.now().date()
253 result = [0] * 52
254 for pos, value in self.get_daily_activity(today, 52).items():
255 result[51 - (today - pos).days] = value
256 return result
258 @cached_property
259 def cache_key_prefix(self) -> str:
260 return f"metrics:{self.scope}:{self.relation}:{self.secondary}"
262 def get_month_cache_key(self, year, month) -> str:
263 return f"{self.cache_key_prefix}:month:{year}:{month}"
265 def get_month_activity(
266 self, year: int, month: int, cached_results: dict[str, int]
267 ) -> int:
268 cache_key = self.get_month_cache_key(year, month)
269 if cache_key in cached_results:
270 return cached_results[cache_key]
271 numdays = monthrange(year, month)[1]
272 daily = self.get_daily_activity(date(year, month, numdays), numdays - 1)
273 result = sum(daily.values())
274 # Cache for one year
275 cache.set(cache_key, result, 365 * 24 * 3600)
276 return result
278 @cached_property
279 def monthly_activity(self) -> list[dict[str, int | date | str | Promise]]:
280 months: list[tuple[int, int]] = []
281 prefetch: list[str] = []
282 last_month_date = timezone.now().date().replace(day=1) - timedelta(days=1)
283 month = last_month_date.month
284 year = last_month_date.year
285 for _dummy in range(12):
286 months.append((year, month))
287 prefetch.extend(
288 (
289 self.get_month_cache_key(year, month),
290 self.get_month_cache_key(year - 1, month),
291 )
292 )
293 month -= 1
294 if month < 1:
295 month = 12
296 year -= 1
298 cached_results: dict[str, int] = cache.get_many(prefetch)
299 result: list[dict[str, int | date | str | Promise]] = [
300 {
301 "month": month,
302 "year": year,
303 "previous_year": year - 1,
304 "month_name": MONTH_NAMES[month - 1],
305 "start_date": date(year, month, 1),
306 "end_date": date(year, month, monthrange(year, month)[1]),
307 "previous_start_date": date(year - 1, month, 1),
308 "previous_end_date": date(
309 year - 1, month, monthrange(year - 1, month)[1]
310 ),
311 "current": self.get_month_activity(year, month, cached_results),
312 "previous": self.get_month_activity(year - 1, month, cached_results),
313 }
314 for year, month in reversed(months)
315 ]
317 maximum = max(1, *(max(item["current"], item["previous"]) for item in result)) # type: ignore[call-overload]
318 for item in result:
319 item["current_height"] = 140 * item["current"] // maximum # type: ignore[operator]
320 item["current_offset"] = 140 - item["current_height"] # type: ignore[operator]
321 item["previous_height"] = 140 * item["previous"] // maximum # type: ignore[operator]
322 item["previous_offset"] = 140 - item["previous_height"] # type: ignore[operator]
324 return result