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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5from __future__ import annotations 

6 

7from calendar import monthrange 

8from datetime import date, timedelta 

9from typing import TYPE_CHECKING 

10 

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 

15 

16from weblate.metrics.models import Metric 

17 

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 

20 

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] 

35 

36 

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 

43 

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]] 

48 

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])) 

54 

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 

64 

65 return (current, past_30, past_60) 

66 

67 @property 

68 def current(self) -> Metric: 

69 return self._data[0] 

70 

71 @property 

72 def past_30(self) -> Metric: 

73 return self._data[1] 

74 

75 @property 

76 def past_60(self) -> Metric: 

77 return self._data[2] 

78 

79 @property 

80 def all_words(self) -> int: 

81 return self.current["all_words"] 

82 

83 @property 

84 def all(self) -> int: 

85 return self.current["all"] 

86 

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 

93 

94 @property 

95 def contributors(self) -> int: 

96 return self.current.get("contributors", 0) 

97 

98 @property 

99 def contributors_total(self) -> int: 

100 return self.current.get("contributors_total", 0) 

101 

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 

112 

113 past = origin[key] 

114 if not past: 

115 return total 

116 

117 divisor = origin[modkey] 

118 if not divisor: 

119 return total 

120 past = 100 * past / divisor 

121 return total - past 

122 

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 

128 

129 @property 

130 def trend_30_all(self) -> float: 

131 return self.calculate_trend("all", self.current, self.past_30) 

132 

133 @property 

134 def trend_30_all_words(self) -> float: 

135 return self.calculate_trend("all_words", self.current, self.past_30) 

136 

137 @property 

138 def trend_30_contributors(self) -> float: 

139 return self.calculate_trend("contributors", self.current, self.past_30) 

140 

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 ) 

146 

147 @property 

148 def trend_60_all(self) -> float: 

149 return self.calculate_trend("all", self.past_30, self.past_60) 

150 

151 @property 

152 def trend_60_all_words(self) -> float: 

153 return self.calculate_trend("all_words", self.past_30, self.past_60) 

154 

155 @property 

156 def trend_60_contributors(self) -> float: 

157 return self.calculate_trend("contributors", self.past_30, self.past_60) 

158 

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 ) 

164 

165 @property 

166 def projects(self) -> int: 

167 return self.current["projects"] 

168 

169 @property 

170 def languages(self) -> int: 

171 return self.current["languages"] 

172 

173 @property 

174 def components(self) -> int: 

175 return self.current["components"] 

176 

177 @property 

178 def translations(self) -> int: 

179 return self.current["translations"] 

180 

181 @property 

182 def screenshots(self) -> int: 

183 return self.current["screenshots"] 

184 

185 @property 

186 def users(self) -> int: 

187 return self.current["users"] 

188 

189 @property 

190 def trend_30_projects(self) -> float: 

191 return self.calculate_trend("projects", self.current, self.past_30) 

192 

193 @property 

194 def trend_30_languages(self) -> float: 

195 return self.calculate_trend("languages", self.current, self.past_30) 

196 

197 @property 

198 def trend_30_components(self) -> float: 

199 return self.calculate_trend("components", self.current, self.past_30) 

200 

201 @property 

202 def trend_30_users(self) -> float: 

203 return self.calculate_trend("users", self.current, self.past_30) 

204 

205 @property 

206 def trend_60_projects(self) -> float: 

207 return self.calculate_trend("projects", self.past_30, self.past_60) 

208 

209 @property 

210 def trend_60_languages(self) -> float: 

211 return self.calculate_trend("languages", self.past_30, self.past_60) 

212 

213 @property 

214 def trend_60_components(self) -> float: 

215 return self.calculate_trend("components", self.past_30, self.past_60) 

216 

217 @property 

218 def trend_60_users(self) -> float: 

219 return self.calculate_trend("users", self.past_30, self.past_60) 

220 

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 

249 

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 

257 

258 @cached_property 

259 def cache_key_prefix(self) -> str: 

260 return f"metrics:{self.scope}:{self.relation}:{self.secondary}" 

261 

262 def get_month_cache_key(self, year, month) -> str: 

263 return f"{self.cache_key_prefix}:month:{year}:{month}" 

264 

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 

277 

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 

297 

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 ] 

316 

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] 

323 

324 return result