Coverage for app/venv/lib/python3.14/site-packages/weblate/utils/stats.py: 71%

843 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 

7import time 

8from datetime import datetime, timedelta 

9from itertools import chain 

10from operator import itemgetter 

11from types import GeneratorType 

12from typing import TYPE_CHECKING, cast 

13 

14import sentry_sdk 

15from django.conf import settings 

16from django.core.cache import cache 

17from django.core.exceptions import ObjectDoesNotExist 

18from django.db import transaction 

19from django.db.models import Count, F, Q 

20from django.db.models.functions import Length 

21from django.urls import reverse 

22from django.utils import timezone 

23from django.utils.functional import cached_property 

24 

25from weblate.checks.models import CHECKS 

26from weblate.lang.models import Language 

27from weblate.trans.checklists import TranslationChecklistMixin 

28from weblate.trans.mixins import BaseURLMixin 

29from weblate.trans.util import translation_percent 

30from weblate.utils.random import get_random_identifier 

31from weblate.utils.site import get_site_url 

32from weblate.utils.state import ( 

33 STATE_APPROVED, 

34 STATE_EMPTY, 

35 STATE_FUZZY, 

36 STATE_READONLY, 

37 STATE_TRANSLATED, 

38) 

39 

40if TYPE_CHECKING: 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true

41 from collections.abc import Generator, Iterable 

42 

43 from django.db.models import Model 

44 

45 from weblate.trans.models import Category, Component, Project 

46 

47StatItem = int | float | str | datetime | None 

48StatDict = dict[str, StatItem] 

49 

50BASICS = { 

51 "all", 

52 "fuzzy", 

53 "todo", 

54 "readonly", 

55 "nottranslated", 

56 "translated", 

57 "approved", 

58 "allchecks", 

59 "translated_checks", 

60 "dismissed_checks", 

61 "suggestions", 

62 "nosuggestions", 

63 "comments", 

64 "approved_suggestions", 

65 "unlabeled", 

66 "unapproved", 

67} 

68BASIC_KEYS = frozenset( 

69 ( 

70 *(f"{x}_words" for x in BASICS), 

71 *(f"{x}_chars" for x in BASICS), 

72 *BASICS, 

73 "languages", 

74 "last_changed", 

75 "last_author", 

76 "recent_changes", 

77 "monthly_changes", 

78 "total_changes", 

79 "stats_timestamp", 

80 ) 

81) 

82SOURCE_KEYS = frozenset( 

83 ( 

84 *BASIC_KEYS, 

85 "source_strings", 

86 "source_words", 

87 "source_chars", 

88 ) 

89) 

90 

91# TODO: Drop in Weblate 6 

92LEGACY_KEYS = { 

93 "unapproved", 

94 "unapproved_chars", 

95 "unapproved_words", 

96 "recent_changes", 

97 "monthly_changes", 

98 "total_changes", 

99 "stats_timestamp", 

100} 

101 

102SOURCE_MAP = { 

103 "source_chars": "all_chars", 

104 "source_words": "all_words", 

105 "source_strings": "all", 

106} 

107 

108 

109def zero_stats(keys: Iterable[str]) -> StatDict: 

110 stats: StatDict = dict.fromkeys(keys, 0) 

111 stats["last_changed"] = None 

112 stats["last_author"] = None 

113 stats["stats_timestamp"] = 0 

114 return stats 

115 

116 

117def yield_stats(queryset) -> Iterable[BaseStats]: 

118 """ 

119 Yield stats attribute from the iterable. 

120 

121 This is an effective wrapper to use iterator over queryset 

122 to generate all stats items. 

123 """ 

124 yield from (item.stats for item in queryset.iterator()) 

125 

126 

127def prefetch_stats(queryset): 

128 """Fetch stats from cache for a queryset.""" 

129 # Force evaluating queryset/iterator, we need all objects 

130 objects = list(queryset) 

131 

132 # This function can either accept queryset, in which case it is 

133 # returned with prefetched stats, or iterator, in which case new list 

134 # is returned. 

135 # This is needed to allow using such querysets further and to support 

136 # processing iterator when it is more effective. 

137 result = objects if isinstance(queryset, (chain, GeneratorType)) else queryset 

138 

139 # Bail out in case the query is empty 

140 if not objects: 

141 return result 

142 

143 # Use stats prefetch 

144 objects[0].stats.prefetch_many([i.stats for i in objects]) 

145 

146 return result 

147 

148 

149def get_non_glossary_stats( 

150 stats_obj: ProjectLanguageStats | ProjectStats | GlobalStats, 

151) -> dict[str, int]: 

152 """Return a dictionary with all, source_strings, and translated strings count excluding glossary content.""" 

153 result = { 

154 "all": stats_obj.all, 

155 "translated": stats_obj.translated, 

156 "source_strings": getattr(stats_obj, "source_strings", stats_obj.all), 

157 } 

158 

159 if isinstance(stats_obj, ProjectLanguageStats): 

160 from weblate.trans.models import Translation 

161 

162 glossaries = Translation.objects.filter( 

163 language=stats_obj.language, component__in=stats_obj.project.glossaries 

164 ).prefetch() 

165 elif isinstance(stats_obj, ProjectStats): 

166 glossaries = stats_obj._object.glossaries # noqa: SLF001 

167 elif isinstance(stats_obj, GlobalStats): 

168 from weblate.trans.models import Component 

169 

170 glossaries = Component.objects.filter(is_glossary=True) 

171 else: 

172 # other stat types do not concern glossaries 

173 return result 

174 

175 for glossary in prefetch_stats(glossaries): 

176 result["all"] -= glossary.stats.all 

177 result["translated"] -= glossary.stats.translated 

178 result["source_strings"] -= getattr( 

179 glossary.stats, "source_strings", glossary.stats.all 

180 ) 

181 

182 return result 

183 

184 

185class BaseStats: 

186 """Caching statistics calculator.""" 

187 

188 basic_keys = BASIC_KEYS 

189 is_ghost = False 

190 

191 def __init__(self, obj) -> None: 

192 self._object = obj 

193 self._data: StatDict = {} 

194 self._loaded: bool = False 

195 self._pending_save: bool = False 

196 self.last_change_cache = None 

197 self._collected_update_objects: list[BaseStats] | None = None 

198 

199 def __repr__(self) -> str: 

200 return f"<{self.__class__.__name__}:{self.cache_key}>" 

201 

202 @property 

203 def pk(self) -> int: 

204 return self._object.pk 

205 

206 def get_absolute_url(self) -> str: 

207 return self._object.get_absolute_url() 

208 

209 def get_translate_url(self) -> str: 

210 return self._object.get_translate_url() 

211 

212 @property 

213 def obj(self): 

214 return self._object 

215 

216 @property 

217 def stats(self): 

218 return self 

219 

220 @property 

221 def is_loaded(self) -> bool: 

222 return self._loaded 

223 

224 def set_data(self, data: StatDict) -> None: 

225 self._loaded = True 

226 self._data = data 

227 

228 def get_data(self) -> StatDict: 

229 """ 

230 Return a copy of data including percents. 

231 

232 Used in stats endpoints. 

233 """ 

234 self.ensure_loaded() 

235 percents = [ 

236 "translated_percent", 

237 "approved_percent", 

238 "fuzzy_percent", 

239 "readonly_percent", 

240 "allchecks_percent", 

241 "translated_checks_percent", 

242 "translated_words_percent", 

243 "approved_words_percent", 

244 "fuzzy_words_percent", 

245 "readonly_words_percent", 

246 "allchecks_words_percent", 

247 "translated_checks_words_percent", 

248 ] 

249 data: StatDict = { 

250 percent: self.calculate_percent(percent) for percent in percents 

251 } 

252 data.update(self._data) 

253 return data 

254 

255 @staticmethod 

256 def prefetch_many(stats) -> None: 

257 lookup = {i.cache_key: i for i in stats if not i.is_loaded} 

258 if not lookup: 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true

259 return 

260 data = cache.get_many(lookup.keys()) 

261 for item, value in data.items(): 

262 lookup[item].set_data(value) 

263 for item in set(lookup.keys()) - set(data.keys()): 

264 lookup[item].set_data({}) 

265 

266 @cached_property 

267 def has_review(self) -> bool: 

268 return True 

269 

270 @cached_property 

271 def cache_key(self) -> str: 

272 return f"stats-{self._object.cache_key}" 

273 

274 def remove_stats(self, *names: str) -> None: 

275 self.ensure_loaded() 

276 if not self._data: 

277 return 

278 changed = False 

279 for name in names: 

280 if name in self._data: 

281 del self._data[name] 

282 changed = True 

283 if changed: 

284 self.save() 

285 

286 def ensure_loaded(self) -> None: 

287 """Load from cache if not already done.""" 

288 if not self._loaded: 

289 self.force_load() 

290 

291 def force_load(self) -> None: 

292 """Enforced loading of stats.""" 

293 self._data = self.load() 

294 self._loaded = True 

295 

296 def aggregate_get(self, name: str) -> StatItem: 

297 """ 

298 Fast getter for aggregation. 

299 

300 - expects data is present 

301 - expects valid basic keys only 

302 - maps source keys 

303 """ 

304 try: 

305 return self._data[name] 

306 except KeyError: 

307 # Handle source_* keys as virtual on translation level for easier aggregation 

308 if name.startswith("source_"): 308 ↛ 314line 308 didn't jump to line 314 because the condition on line 308 was always true

309 return self._data[SOURCE_MAP[name]] 

310 # Legacy keys were calculated on demand before and are precalculated 

311 # since Weblate 5.2, so they are missing on stats calculated before. 

312 # Using zero here is most likely a wrong value, but safe and cheap. 

313 # TODO: Drop in Weblate 6 

314 if name in LEGACY_KEYS: 

315 return 0 

316 raise 

317 

318 def __getattr__(self, name: str): 

319 if name.startswith("_"): 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 msg = f"Invalid stats for {self}: {name}" 

321 raise AttributeError(msg) 

322 

323 self.ensure_loaded() 

324 

325 # Calculate virtual percents 

326 if name.endswith("_percent"): 

327 return self.calculate_percent(name) 

328 

329 if name == "stats_timestamp": 

330 # TODO: Drop in Weblate 6 

331 # Migration path for legacy stat data 

332 return self._data.get(name, 0) 

333 

334 # Virtual fields 

335 if name == "translated_without_checks": 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true

336 return self.translated - self.translated_checks 

337 if name == "translated_without_checks_words": 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 return self.translated_words - self.translated_checks_words 

339 if name == "translated_without_checks_chars": 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true

340 return self.translated_chars - self.translated_checks_chars 

341 

342 # Calculate missing data 

343 if name not in self._data: 

344 was_pending = self._pending_save 

345 self._pending_save = True 

346 self.calculate_by_name(name) 

347 if name not in self._data: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true

348 msg = f"Unsupported stats for {self}: {name}" 

349 self._pending_save = was_pending 

350 raise AttributeError(msg) 

351 if not was_pending: 351 ↛ 355line 351 didn't jump to line 355 because the condition on line 351 was always true

352 self.save() 

353 self._pending_save = False 

354 

355 return self._data[name] 

356 

357 def calculate_by_name(self, name: str) -> None: 

358 if name in self.basic_keys: 358 ↛ exitline 358 didn't return from function 'calculate_by_name' because the condition on line 358 was always true

359 self.calculate_basic() 

360 self.save() 

361 

362 def load(self) -> StatDict: 

363 return cache.get(self.cache_key, {}) 

364 

365 def delete(self): 

366 return cache.delete(self.cache_key) 

367 

368 def save(self, update_parents: bool = True) -> None: 

369 """Save stats to cache.""" 

370 cache.set(self.cache_key, self._data, 30 * 86400) 

371 

372 def get_update_objects(self, *, full: bool = True) -> Generator[BaseStats]: 

373 yield GlobalStats() 

374 

375 def collect_update_objects(self) -> None: 

376 """ 

377 Collect update objects. 

378 

379 This is used on the pre_delete signal as the objects 

380 cannot be collected once the object is deleted. 

381 """ 

382 # Use list to force materializing the generator 

383 self._collected_update_objects = list(self.get_update_objects()) 

384 

385 def _iterate_update_objects( 

386 self, *, extra_objects: Iterable[BaseStats] | None = None 

387 ) -> Generator[BaseStats]: 

388 """Get list of stats to update.""" 

389 stat_objects: Iterable[BaseStats] 

390 if self._collected_update_objects is not None: 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true

391 stat_objects = self._collected_update_objects 

392 self._collected_update_objects = None 

393 else: 

394 stat_objects = self.get_update_objects() 

395 if extra_objects: 

396 stat_objects = chain(stat_objects, extra_objects) 

397 

398 # Prefetch stats data from cache 

399 fetched_stat_objects: list[BaseStats] = prefetch_stats(stat_objects) 

400 

401 # Discard references to no longer needed objects 

402 while fetched_stat_objects: 

403 yield fetched_stat_objects.pop(0) 

404 

405 def update_parents( 

406 self, *, extra_objects: Iterable[BaseStats] | None = None 

407 ) -> None: 

408 """Update parent statistics.""" 

409 for stat in self._iterate_update_objects(extra_objects=extra_objects): 

410 if self.stats_timestamp and self.stats_timestamp <= stat.stats_timestamp: 

411 self._object.log_debug("skipping update of %s", stat) 

412 else: 

413 self._object.log_debug("updating stats %s", stat) 

414 stat.update_stats() 

415 

416 def clear(self) -> None: 

417 """Clear local cache.""" 

418 self._loaded = True 

419 self._data = {} 

420 

421 def store(self, key: str, value: StatItem) -> None: 

422 if value is None and not key.startswith("last_"): 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 self._data[key] = 0 

424 else: 

425 self._data[key] = value 

426 

427 def update_stats(self, update_parents: bool = True) -> None: 

428 self.clear() 

429 if settings.STATS_LAZY: 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true

430 self.save(update_parents=update_parents) 

431 else: 

432 self.calculate_basic() 

433 self.save(update_parents=update_parents) 

434 

435 def calculate_basic(self) -> None: 

436 with sentry_sdk.start_span(op="stats", name=f"CALCULATE {self.cache_key}"): 

437 self.ensure_loaded() 

438 self._calculate_basic() 

439 

440 def _calculate_basic(self) -> None: 

441 raise NotImplementedError 

442 

443 def calculate_percent(self, item: str) -> float: 

444 """Calculate percent value for given item.""" 

445 base = item[:-8] 

446 

447 if base.endswith("_words"): 

448 total = self.all_words 

449 elif base.endswith("_chars"): 

450 total = self.all_chars 

451 else: 

452 total = self.all 

453 

454 if self.has_review: 

455 completed = {"approved", "approved_words", "approved_chars"} 

456 else: 

457 completed = {"translated", "translated_words", "translated_chars"} 

458 

459 return translation_percent( 

460 getattr(self, base), total, zero_complete=(base in completed) 

461 ) 

462 

463 @property 

464 def waiting_review_percent(self): 

465 return self.translated_percent - self.approved_percent - self.readonly_percent 

466 

467 @property 

468 def waiting_review(self): 

469 return self.translated - self.approved - self.readonly 

470 

471 @property 

472 def waiting_review_words_percent(self): 

473 return ( 

474 self.translated_words_percent 

475 - self.approved_words_percent 

476 - self.readonly_words_percent 

477 ) 

478 

479 @property 

480 def waiting_review_words(self): 

481 return self.translated_words - self.approved_words - self.readonly_words 

482 

483 @property 

484 def waiting_review_chars_percent(self): 

485 return ( 

486 self.translated_chars_percent 

487 - self.approved_chars_percent 

488 - self.readonly_chars_percent 

489 ) 

490 

491 @property 

492 def waiting_review_chars(self): 

493 return self.translated_chars - self.approved_chars - self.readonly_chars 

494 

495 

496class DummyTranslationStats(BaseStats): 

497 """ 

498 Dummy stats to report 0 in all cases. 

499 

500 Used when given language does not exist in a component. 

501 """ 

502 

503 def __init__(self, obj) -> None: 

504 super().__init__(obj) 

505 self.language = obj 

506 

507 @property 

508 def pk(self) -> int: 

509 return 100000 + self.language.pk 

510 

511 @cached_property 

512 def cache_key(self) -> str: 

513 return "" 

514 

515 def save(self, update_parents: bool = True) -> None: 

516 return 

517 

518 def load(self) -> StatDict: 

519 return {} 

520 

521 def _calculate_basic(self) -> None: 

522 self._data = zero_stats(self.basic_keys) 

523 

524 

525class TranslationStats(BaseStats): 

526 """Per translation stats.""" 

527 

528 @cached_property 

529 def is_source(self): 

530 return self._object.is_source 

531 

532 def save(self, update_parents: bool = True) -> None: 

533 from weblate.utils.tasks import update_translation_stats_parents 

534 

535 super().save() 

536 

537 if update_parents: 

538 if settings.CELERY_TASK_ALWAYS_EAGER: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true

539 transaction.on_commit(self.update_parents) 

540 else: 

541 pk = self._object.pk 

542 update_translation_stats_parents.delay_on_commit(pk) 

543 

544 def get_update_objects(self, *, full: bool = True) -> Generator[BaseStats]: 

545 translation = self._object 

546 component = translation.component 

547 

548 # Language 

549 yield translation.language.stats 

550 

551 # Project / language 

552 yield component.project.stats.get_single_language_stats(translation.language) 

553 

554 # Linked project / language 

555 for link in component.cached_links: 555 ↛ 556line 555 didn't jump to line 556 because the loop on line 555 never started

556 yield link.stats.get_single_language_stats(translation.language) 

557 

558 # Category / language 

559 category = component.category 

560 while category: 560 ↛ 561line 560 didn't jump to line 561 because the condition on line 560 was never true

561 yield category.stats.get_single_language_stats(translation.language) 

562 category = category.category 

563 

564 if full: 

565 # Component 

566 yield component.stats 

567 # Component list, category, project and global 

568 yield from component.stats.get_update_objects() 

569 

570 @property 

571 def language(self): 

572 return self._object.language 

573 

574 @cached_property 

575 def has_review(self): 

576 return self._object.enable_review 

577 

578 def _calculate_basic(self) -> None: # noqa: PLR0914 

579 values = ( 

580 "state", 

581 "num_words", 

582 "active_checks_count", 

583 "dismissed_checks_count", 

584 "suggestion_count", 

585 "source_label_count", 

586 "label_count", 

587 "comment_count", 

588 "num_chars", 

589 ) 

590 

591 # Calculate summary for each unit in the database. 

592 # We only need presence check, not actual count, but using Exists(OuterRef()) 

593 # creates a subquery for each field, while Count() creates a single join 

594 # and calculates based on that, what performs better. 

595 units = self._object.unit_set.annotate( 

596 active_checks_count=Count("check", filter=Q(check__dismissed=False)), 

597 dismissed_checks_count=Count("check", filter=Q(check__dismissed=True)), 

598 suggestion_count=Count("suggestion"), 

599 source_label_count=Count("source_unit__labels"), 

600 label_count=Count("source_unit__labels"), 

601 comment_count=Count("comment", filter=Q(comment__resolved=False)), 

602 num_chars=Length("source"), 

603 ).values_list(*values) 

604 

605 ( 

606 get_state, 

607 get_num_words, 

608 get_active_checks_count, 

609 get_dismissed_checks_count, 

610 get_suggestion_count, 

611 get_source_label_count, 

612 get_label_count, 

613 get_comment_count, 

614 get_num_chars, 

615 ) = (itemgetter(i) for i in range(len(values))) 

616 

617 # Sum stats in Python, this is way faster than conditional sums in the database 

618 units_all = units 

619 units_fuzzy = [unit for unit in units if get_state(unit) == STATE_FUZZY] 

620 units_readonly = [unit for unit in units if get_state(unit) == STATE_READONLY] 

621 units_nottranslated = [unit for unit in units if get_state(unit) == STATE_EMPTY] 

622 units_unapproved = [ 

623 unit for unit in units if get_state(unit) == STATE_TRANSLATED 

624 ] 

625 units_approved = [unit for unit in units if get_state(unit) == STATE_APPROVED] 

626 units_translated = [ 

627 unit for unit in units if get_state(unit) >= STATE_TRANSLATED 

628 ] 

629 units_todo = [unit for unit in units if get_state(unit) < STATE_TRANSLATED] 

630 units_unlabeled = [ 

631 unit 

632 for unit in units 

633 if not get_source_label_count(unit) and not get_label_count(unit) 

634 ] 

635 units_allchecks = [unit for unit in units if get_active_checks_count(unit)] 

636 units_translated_checks = [ 

637 unit 

638 for unit in units 

639 if get_active_checks_count(unit) 

640 and get_state(unit) in {STATE_TRANSLATED, STATE_APPROVED} 

641 ] 

642 units_dismissed_checks = [ 

643 unit for unit in units if get_dismissed_checks_count(unit) 

644 ] 

645 units_suggestions = [unit for unit in units if get_suggestion_count(unit)] 

646 units_nosuggestions = [ 

647 unit 

648 for unit in units 

649 if not get_suggestion_count(unit) and get_state(unit) < STATE_TRANSLATED 

650 ] 

651 units_approved_suggestions = [ 

652 unit 

653 for unit in units 

654 if get_suggestion_count(unit) and get_state(unit) == STATE_APPROVED 

655 ] 

656 units_comments = [unit for unit in units if get_comment_count(unit)] 

657 

658 # Store in a cache 

659 self.store("all", len(units_all)) 

660 self.store("all_words", sum(get_num_words(unit) for unit in units_all)) 

661 self.store("all_chars", sum(get_num_chars(unit) for unit in units_all)) 

662 self.store("fuzzy", len(units_fuzzy)) 

663 self.store("fuzzy_words", sum(get_num_words(unit) for unit in units_fuzzy)) 

664 self.store("fuzzy_chars", sum(get_num_chars(unit) for unit in units_fuzzy)) 

665 self.store("readonly", len(units_readonly)) 

666 self.store( 

667 "readonly_words", sum(get_num_words(unit) for unit in units_readonly) 

668 ) 

669 self.store( 

670 "readonly_chars", sum(get_num_chars(unit) for unit in units_readonly) 

671 ) 

672 self.store("translated", len(units_translated)) 

673 self.store( 

674 "translated_words", sum(get_num_words(unit) for unit in units_translated) 

675 ) 

676 self.store( 

677 "translated_chars", sum(get_num_chars(unit) for unit in units_translated) 

678 ) 

679 self.store("todo", len(units_todo)) 

680 self.store("todo_words", sum(get_num_words(unit) for unit in units_todo)) 

681 self.store("todo_chars", sum(get_num_chars(unit) for unit in units_todo)) 

682 self.store("nottranslated", len(units_nottranslated)) 

683 self.store( 

684 "nottranslated_words", 

685 sum(get_num_words(unit) for unit in units_nottranslated), 

686 ) 

687 self.store( 

688 "nottranslated_chars", 

689 sum(get_num_chars(unit) for unit in units_nottranslated), 

690 ) 

691 # Review workflow 

692 self.store("approved", len(units_approved)) 

693 self.store( 

694 "approved_words", sum(get_num_words(unit) for unit in units_approved) 

695 ) 

696 self.store( 

697 "approved_chars", sum(get_num_chars(unit) for unit in units_approved) 

698 ) 

699 self.store("unapproved", len(units_unapproved)) 

700 self.store( 

701 "unapproved_words", sum(get_num_words(unit) for unit in units_unapproved) 

702 ) 

703 self.store( 

704 "unapproved_chars", sum(get_num_chars(unit) for unit in units_unapproved) 

705 ) 

706 # Labels 

707 self.store("unlabeled", len(units_unlabeled)) 

708 self.store( 

709 "unlabeled_words", sum(get_num_words(unit) for unit in units_unlabeled) 

710 ) 

711 self.store( 

712 "unlabeled_chars", sum(get_num_chars(unit) for unit in units_unlabeled) 

713 ) 

714 # Checks 

715 self.store("allchecks", len(units_allchecks)) 

716 self.store( 

717 "allchecks_words", sum(get_num_words(unit) for unit in units_allchecks) 

718 ) 

719 self.store( 

720 "allchecks_chars", sum(get_num_chars(unit) for unit in units_allchecks) 

721 ) 

722 self.store("translated_checks", len(units_translated_checks)) 

723 self.store( 

724 "translated_checks_words", 

725 sum(get_num_words(unit) for unit in units_translated_checks), 

726 ) 

727 self.store( 

728 "translated_checks_chars", 

729 sum(get_num_chars(unit) for unit in units_translated_checks), 

730 ) 

731 self.store("dismissed_checks", len(units_dismissed_checks)) 

732 self.store( 

733 "dismissed_checks_words", 

734 sum(get_num_words(unit) for unit in units_dismissed_checks), 

735 ) 

736 self.store( 

737 "dismissed_checks_chars", 

738 sum(get_num_chars(unit) for unit in units_dismissed_checks), 

739 ) 

740 # Suggestions 

741 self.store("suggestions", len(units_suggestions)) 

742 self.store( 

743 "suggestions_words", sum(get_num_words(unit) for unit in units_suggestions) 

744 ) 

745 self.store( 

746 "suggestions_chars", sum(get_num_chars(unit) for unit in units_suggestions) 

747 ) 

748 self.store("nosuggestions", len(units_nosuggestions)) 

749 self.store( 

750 "nosuggestions_words", 

751 sum(get_num_words(unit) for unit in units_nosuggestions), 

752 ) 

753 self.store( 

754 "nosuggestions_chars", 

755 sum(get_num_chars(unit) for unit in units_nosuggestions), 

756 ) 

757 self.store("approved_suggestions", len(units_approved_suggestions)) 

758 self.store( 

759 "approved_suggestions_words", 

760 sum(get_num_words(unit) for unit in units_approved_suggestions), 

761 ) 

762 self.store( 

763 "approved_suggestions_chars", 

764 sum(get_num_chars(unit) for unit in units_approved_suggestions), 

765 ) 

766 # Comments 

767 self.store("comments", len(units_comments)) 

768 self.store( 

769 "comments_words", sum(get_num_words(unit) for unit in units_comments) 

770 ) 

771 self.store( 

772 "comments_chars", sum(get_num_chars(unit) for unit in units_comments) 

773 ) 

774 

775 # There is single language here, but it is aggregated at higher levels 

776 self.store("languages", 1) 

777 

778 # Last change timestamp 

779 self.fetch_last_change() 

780 

781 self.count_changes() 

782 

783 # Store timestamp 

784 self.store("stats_timestamp", time.time()) 

785 

786 def get_last_change_obj(self): 

787 from weblate.trans.models import Change 

788 

789 # This is set in Change.save 

790 if self.last_change_cache is not None: 

791 return self.last_change_cache 

792 

793 cache_key = Change.get_last_change_cache_key(self._object.pk) 

794 change_pk = cache.get(cache_key) 

795 if change_pk == 0: 

796 # No change 

797 return None 

798 if change_pk is not None: 

799 try: 

800 return Change.objects.get(pk=change_pk) 

801 except Change.DoesNotExist: 

802 pass 

803 try: 

804 last_change = self._object.change_set.order()[0] 

805 except IndexError: 

806 Change.store_last_change(self._object, None) 

807 return None 

808 last_change.update_cache_last_change() 

809 return last_change 

810 

811 def fetch_last_change(self) -> None: 

812 last_change = self.get_last_change_obj() 

813 

814 if last_change is None: 

815 self.store("last_changed", None) 

816 self.store("last_author", None) 

817 else: 

818 self.store("last_changed", last_change.timestamp) 

819 self.store("last_author", last_change.author_id) 

820 

821 def count_changes(self) -> None: 

822 if self.last_changed: 

823 monthly = timezone.now() - timedelta(days=30) 

824 recently = self.last_changed - timedelta(hours=6) 

825 changes = self._object.change_set.content().aggregate( 

826 total=Count("id"), 

827 recent=Count("id", filter=Q(timestamp__gt=recently)), 

828 monthly=Count("id", filter=Q(timestamp__gt=monthly)), 

829 ) 

830 self.store("recent_changes", changes["recent"]) 

831 self.store("monthly_changes", changes["monthly"]) 

832 self.store("total_changes", changes["total"]) 

833 else: 

834 self.store("recent_changes", 0) 

835 self.store("monthly_changes", 0) 

836 self.store("total_changes", 0) 

837 

838 def calculate_by_name(self, name: str) -> None: 

839 super().calculate_by_name(name) 

840 if name.startswith("check:"): 840 ↛ 841line 840 didn't jump to line 841 because the condition on line 840 was never true

841 self.calculate_checks() 

842 elif name.startswith("label:"): 842 ↛ 843line 842 didn't jump to line 843 because the condition on line 842 was never true

843 self.calculate_labels() 

844 

845 def calculate_checks(self) -> None: 

846 """Prefetch check stats.""" 

847 self.ensure_loaded() 

848 allchecks = {check.url_id for check in CHECKS.values()} 

849 stats = ( 

850 self._object.unit_set.filter(check__dismissed=False) 

851 .values("check__name") 

852 .annotate_stats() 

853 ) 

854 for check, strings, words, chars in stats.values_list( 

855 "check__name", "strings", "words", "chars" 

856 ): 

857 # Filtering here is way more effective than in SQL 

858 if check is None: 

859 continue 

860 check = f"check:{check}" 

861 self.store(check, strings) 

862 self.store(f"{check}_words", words) 

863 self.store(f"{check}_chars", chars) 

864 allchecks.discard(check) 

865 for check in allchecks: 

866 self.store(check, 0) 

867 self.store(f"{check}_words", 0) 

868 self.store(f"{check}_chars", 0) 

869 self.save() 

870 

871 def calculate_labels(self) -> None: 

872 """Prefetch check stats.""" 

873 from weblate.trans.models.label import TRANSLATION_LABELS 

874 

875 self.ensure_loaded() 

876 alllabels = set( 

877 self._object.component.project.label_set.values_list("name", flat=True) 

878 ) 

879 stats = ( 

880 self._object.unit_set.values("source_unit__labels__name") 

881 .annotate_stats() 

882 .values_list("source_unit__labels__name", "strings", "words", "chars") 

883 ) 

884 translation_stats = ( 

885 self._object.unit_set.filter(labels__name__in=TRANSLATION_LABELS) 

886 .values("labels__name") 

887 .annotate_stats() 

888 .values_list("labels__name", "strings", "words", "chars") 

889 ) 

890 

891 for label_name, strings, words, chars in chain(stats, translation_stats): 

892 # Filtering here is way more effective than in SQL 

893 if label_name is None: 

894 continue 

895 label = f"label:{label_name}" 

896 self.store(label, strings) 

897 self.store(f"{label}_words", words) 

898 self.store(f"{label}_chars", chars) 

899 alllabels.discard(label_name) 

900 for label_name in alllabels: 

901 label = f"label:{label_name}" 

902 self.store(label, 0) 

903 self.store(f"{label}_words", 0) 

904 self.store(f"{label}_chars", 0) 

905 self.save() 

906 

907 

908class AggregatingStats(BaseStats): 

909 basic_keys = SOURCE_KEYS 

910 sum_source_keys = True 

911 

912 def get_child_objects(self) -> Iterable[Model]: 

913 raise NotImplementedError 

914 

915 def get_category_objects(self): 

916 return [] 

917 

918 def calculate_source(self, stats: dict, all_stats: list) -> None: 

919 return 

920 

921 @cached_property 

922 def aggregated_stats(self) -> list[BaseStats]: 

923 return [ 

924 obj.stats 

925 for obj in prefetch_stats( 

926 chain(self.get_child_objects(), self.get_category_objects()) 

927 ) 

928 ] 

929 

930 def _calculate_basic(self) -> None: 

931 stats = zero_stats(self.basic_keys) 

932 all_stats: list[BaseStats] = self.aggregated_stats 

933 

934 # Ensure all objects have data available so that we can use _dict directly 

935 for stats_obj in all_stats: 

936 if "all" not in stats_obj._data: # noqa: SLF001 

937 stats_obj.calculate_basic() 

938 stats_obj.save() 

939 

940 for item in self.basic_keys: 

941 if not self.sum_source_keys and item.startswith("source_"): 

942 # Handle in calculate_source when logic for source strings differs 

943 continue 

944 

945 # Extract all values by dedicated getter 

946 values = (stats_obj.aggregate_get(item) for stats_obj in all_stats) 

947 

948 if item == "stats_timestamp": 

949 stats[item] = max(cast("Generator[float]", values), default=time.time()) 

950 elif item == "last_changed": 

951 # We need to access values twice here 

952 values_list = list(values) 

953 stats[item] = max_last_changed = max( 

954 (value for value in values_list if value is not None), default=None 

955 ) 

956 if max_last_changed is not None: 

957 offset = values_list.index(max_last_changed) 

958 stats["last_author"] = all_stats[offset].last_author 

959 elif item == "last_author": 

960 # The last_author is calculated together with last_changed 

961 continue 

962 else: 

963 stats[item] = sum(cast("Generator[float]", values)) 

964 

965 if not self.sum_source_keys: 

966 self.calculate_source(stats, all_stats) 

967 

968 for key, value in stats.items(): 

969 self.store(key, value) 

970 

971 

972class SingleLanguageStats(AggregatingStats): 

973 def _calculate_basic(self) -> None: 

974 super()._calculate_basic() 

975 self.store("languages", 1) 

976 

977 def get_single_language_stats(self, language): 

978 return self 

979 

980 @cached_property 

981 def is_source(self): 

982 return self.obj.is_source 

983 

984 

985class ParentAggregatingStats(AggregatingStats): 

986 sum_source_keys = True 

987 

988 

989class LanguageStats(AggregatingStats): 

990 def get_child_objects(self): 

991 return self._object.translation_set.only("id", "language") 

992 

993 @property 

994 def language(self): 

995 return self._object 

996 

997 

998class ComponentStats(AggregatingStats): 

999 sum_source_keys = False 

1000 

1001 def get_child_objects(self): 

1002 return self._object.translation_set.only("id", "component", "language") 

1003 

1004 @cached_property 

1005 def has_review(self): 

1006 return self._object.enable_review 

1007 

1008 def calculate_source(self, stats: dict, all_stats: list) -> None: 

1009 """Fetch source info from source translation.""" 

1010 for obj in all_stats: 1010 ↛ exitline 1010 didn't return from function 'calculate_source' because the loop on line 1010 didn't complete

1011 if obj.is_source: 

1012 stats["source_chars"] = obj.all_chars 

1013 stats["source_words"] = obj.all_words 

1014 stats["source_strings"] = obj.all 

1015 break 

1016 

1017 def get_update_objects(self, *, full: bool = True) -> Generator[BaseStats]: 

1018 # Component lists 

1019 yield from yield_stats(self._object.componentlist_set.only("id", "slug")) 

1020 

1021 # Projects this component is shared to 

1022 yield from yield_stats(self._object.cached_links) 

1023 

1024 if self._object.category: 1024 ↛ 1026line 1024 didn't jump to line 1026 because the condition on line 1024 was never true

1025 # Category 

1026 yield self._object.category.stats 

1027 # Category parents, project and global 

1028 yield from self._object.category.stats.get_update_objects() 

1029 else: 

1030 # Project 

1031 yield self._object.project.stats 

1032 # Global 

1033 yield from self._object.project.stats.get_update_objects() 

1034 

1035 def update_language_stats_parents(self) -> None: 

1036 # Fetch language stats to update 

1037 extras: Iterable[Iterable[BaseStats]] = ( 

1038 translation.stats.get_update_objects(full=False) 

1039 for translation in prefetch_stats( 

1040 self.get_child_objects().select_related("language") 

1041 ) 

1042 ) 

1043 

1044 # Update all parents 

1045 self.update_parents(extra_objects=chain.from_iterable(extras)) 

1046 

1047 def update_language_stats(self) -> None: 

1048 from weblate.utils.tasks import update_language_stats_parents 

1049 

1050 # Update languages 

1051 for translation in prefetch_stats(self.get_child_objects()): 

1052 translation.stats.update_stats(update_parents=False) 

1053 

1054 # Update our stats 

1055 self.update_stats() 

1056 

1057 # Update all parents 

1058 if settings.CELERY_TASK_ALWAYS_EAGER: 1058 ↛ 1059line 1058 didn't jump to line 1059 because the condition on line 1058 was never true

1059 transaction.on_commit(self.update_language_stats_parents) 

1060 else: 

1061 pk = self._object.pk 

1062 update_language_stats_parents.delay_on_commit(pk) 

1063 

1064 def get_language_stats(self): 

1065 return ( 

1066 translation.stats 

1067 for translation in prefetch_stats( 

1068 self._object.translation_set.select_related("language") 

1069 ) 

1070 ) 

1071 

1072 def get_single_language_stats(self, language): 

1073 try: 

1074 return TranslationStats(self._object.translation_set.get(language=language)) 

1075 except ObjectDoesNotExist: 

1076 return DummyTranslationStats(language) 

1077 

1078 

1079class ProjectLanguageComponent: 

1080 is_glossary = False 

1081 

1082 def __init__(self, parent) -> None: 

1083 self.slug = "-" 

1084 self.parent = parent 

1085 

1086 @property 

1087 def translation_set(self): 

1088 return self.parent.translation_set 

1089 

1090 @property 

1091 def context_label(self): 

1092 return self.translation_set[0].component.context_label 

1093 

1094 @property 

1095 def source_language(self): 

1096 return self.translation_set[0].component.source_language 

1097 

1098 

1099class ProjectLanguage(BaseURLMixin, TranslationChecklistMixin): 

1100 """Wrapper class used in project-language listings and stats.""" 

1101 

1102 remove_permission = "translation.delete" 

1103 settings_permission = "project.edit" 

1104 

1105 def __init__(self, project, language: Language) -> None: 

1106 self.project = project 

1107 self.language = language 

1108 self.component = ProjectLanguageComponent(self) 

1109 

1110 def __str__(self) -> str: 

1111 return f"{self.project} - {self.language}" 

1112 

1113 @property 

1114 def enable_review(self) -> bool: 

1115 return self.project.enable_review 

1116 

1117 @property 

1118 def is_readonly(self) -> bool: 

1119 return False 

1120 

1121 @property 

1122 def code(self): 

1123 return self.language.code 

1124 

1125 @cached_property 

1126 def stats(self): 

1127 return ProjectLanguageStats(self) 

1128 

1129 def get_share_url(self): 

1130 """Return absolute URL usable for sharing.""" 

1131 return get_site_url( 

1132 reverse( 

1133 "engage", 

1134 kwargs={"path": self.get_url_path()}, 

1135 ) 

1136 ) 

1137 

1138 def get_widgets_url(self) -> str: 

1139 """Return absolute URL for widgets.""" 

1140 return f"{self.project.get_widgets_url()}?lang={self.language.code}" 

1141 

1142 @cached_property 

1143 def pk(self) -> str: 

1144 return f"{self.project.pk}-{self.language.pk}" 

1145 

1146 @cached_property 

1147 def cache_key(self) -> str: 

1148 return f"{self.project.cache_key}-{self.language.pk}" 

1149 

1150 def get_url_path(self): 

1151 return [*self.project.get_url_path(), "-", self.language.code] 

1152 

1153 def get_absolute_url(self) -> str: 

1154 return reverse("show", kwargs={"path": self.get_url_path()}) 

1155 

1156 def get_translate_url(self): 

1157 return reverse("translate", kwargs={"path": self.get_url_path()}) 

1158 

1159 @cached_property 

1160 def translation_set(self): 

1161 all_langs = self.language.translation_set.prefetch() 

1162 result = all_langs.filter(component__project=self.project) 

1163 if self.project.has_shared_components: 

1164 result |= all_langs.filter(component__links=self.project) 

1165 result = result.distinct() 

1166 for item in result: 

1167 item.is_shared = ( 

1168 None 

1169 if item.component.project == self.project 

1170 else item.component.project 

1171 ) 

1172 return sorted( 

1173 result, 

1174 key=lambda trans: (trans.component.priority, trans.component.name.lower()), 

1175 ) 

1176 

1177 @cached_property 

1178 def is_source(self): 

1179 return self.language.id in self.project.source_language_ids 

1180 

1181 @cached_property 

1182 def change_set(self): 

1183 return self.project.change_set.filter(language=self.language) 

1184 

1185 @cached_property 

1186 def workflow_settings(self): 

1187 from weblate.trans.models.workflow import WorkflowSetting 

1188 

1189 workflow_settings = WorkflowSetting.objects.filter( 

1190 Q(project=None) | Q(project=self.project), 

1191 language=self.language, 

1192 ).order_by(F("project").desc(nulls_last=True)) 

1193 if len(workflow_settings) == 0: 1193 ↛ 1196line 1193 didn't jump to line 1196 because the condition on line 1193 was always true

1194 return None 

1195 # Project specific is first, project NULL is last 

1196 return workflow_settings[0] 

1197 

1198 

1199class ChecklistStats(SingleLanguageStats): 

1200 def calculate_by_name(self, name: str) -> None: 

1201 super().calculate_by_name(name) 

1202 if name.startswith("check:"): 

1203 self.calculate_checks() 

1204 elif name.startswith("label:"): 

1205 self.calculate_labels() 

1206 

1207 def aggregate_stats(self, keys: Iterable[str]) -> None: 

1208 self.ensure_loaded() 

1209 all_stats: list[BaseStats] = self.aggregated_stats 

1210 suffixes: tuple[str, ...] = ("", "_words", "_chars") 

1211 for key in keys: 

1212 for suffix in suffixes: 

1213 name = f"{key}{suffix}" 

1214 # The attribute might be missing in some corner cases such as 

1215 # when component is shared in different project and calculating 

1216 # label stats. 

1217 values = (getattr(stats_obj, name, 0) for stats_obj in all_stats) 

1218 self.store(name, sum(values)) 

1219 self.save() 

1220 

1221 def calculate_checks(self) -> None: 

1222 """Prefetch check stats.""" 

1223 self.aggregate_stats(check.url_id for check in CHECKS.values()) 

1224 

1225 def calculate_labels(self) -> None: 

1226 """Prefetch check stats.""" 

1227 self.aggregate_stats( 

1228 f"label:{name}" 

1229 for name in self._object.project.label_set.values_list("name", flat=True) 

1230 ) 

1231 

1232 

1233class ProjectLanguageStats(ChecklistStats): 

1234 def __init__(self, obj: ProjectLanguage) -> None: 

1235 self.language = obj.language 

1236 self.project = obj.project 

1237 super().__init__(obj) 

1238 obj.stats = self 

1239 

1240 @cached_property 

1241 def has_review(self): 

1242 return self.project.source_review or self.project.translation_review 

1243 

1244 def get_child_objects(self): 

1245 return ( 

1246 self.language.translation_set.filter( 

1247 Q(component__project=self.project) | Q(component__links=self.project) 

1248 ) 

1249 .distinct() 

1250 .only("id", "language") 

1251 ) 

1252 

1253 

1254class CategoryLanguage(BaseURLMixin, TranslationChecklistMixin): 

1255 """Wrapper class used in category-language listings and stats.""" 

1256 

1257 remove_permission = "translation.delete" 

1258 

1259 def __init__(self, category: Category, language: Language) -> None: 

1260 self.category = category 

1261 self.language = language 

1262 self.component = ProjectLanguageComponent(self) 

1263 

1264 def __str__(self) -> str: 

1265 return f"{self.category} - {self.language}" 

1266 

1267 @property 

1268 def project(self) -> Project: 

1269 return self.category.project 

1270 

1271 @property 

1272 def enable_review(self) -> bool: 

1273 return self.project.enable_review 

1274 

1275 @property 

1276 def is_readonly(self) -> bool: 

1277 return False 

1278 

1279 @property 

1280 def code(self): 

1281 return self.language.code 

1282 

1283 @property 

1284 def name(self): 

1285 return self.category.name 

1286 

1287 @cached_property 

1288 def stats(self): 

1289 return CategoryLanguageStats(self) 

1290 

1291 @cached_property 

1292 def pk(self) -> str: 

1293 return f"{self.category.pk}-{self.language.pk}" 

1294 

1295 @cached_property 

1296 def cache_key(self) -> str: 

1297 return f"{self.category.cache_key}-{self.language.pk}" 

1298 

1299 def get_url_path(self): 

1300 return [*self.category.get_url_path(), "-", self.language.code] 

1301 

1302 def get_absolute_url(self) -> str: 

1303 return reverse("show", kwargs={"path": self.get_url_path()}) 

1304 

1305 def get_translate_url(self): 

1306 return reverse("translate", kwargs={"path": self.get_url_path()}) 

1307 

1308 @cached_property 

1309 def translation_set(self): 

1310 result = self.language.translation_set.filter( 

1311 component__category=self.category 

1312 ).prefetch() 

1313 for item in result: 

1314 item.is_shared = ( 

1315 None 

1316 if item.component.project == self.category.project 

1317 else item.component.project 

1318 ) 

1319 return sorted( 

1320 result, 

1321 key=lambda trans: (trans.component.priority, trans.component.name.lower()), 

1322 ) 

1323 

1324 @cached_property 

1325 def is_source(self): 

1326 return self.language.id in self.category.source_language_ids 

1327 

1328 @cached_property 

1329 def change_set(self): 

1330 return self.language.change_set.for_category(self.category) 

1331 

1332 

1333class CategoryLanguageStats(ChecklistStats): 

1334 def __init__(self, obj: CategoryLanguage) -> None: 

1335 self.language = obj.language 

1336 self.category = obj.category 

1337 super().__init__(obj) 

1338 obj.stats = self 

1339 

1340 @cached_property 

1341 def has_review(self): 

1342 return ( 

1343 self.category.project.source_review 

1344 or self.category.project.translation_review 

1345 ) 

1346 

1347 def get_category_objects(self): 

1348 return [ 

1349 CategoryLanguage(category, self.language) 

1350 for category in self.category.stats.get_category_objects() 

1351 ] 

1352 

1353 def get_child_objects(self): 

1354 return self.language.translation_set.filter( 

1355 component__category=self.category 

1356 ).only("id", "language") 

1357 

1358 

1359class CategoryStats(ParentAggregatingStats): 

1360 def get_update_objects(self, *, full: bool = True) -> Generator[BaseStats]: 

1361 if self._object.category: 

1362 yield self._object.category.stats 

1363 yield from self._object.category.stats.get_update_objects() 

1364 else: 

1365 # Global 

1366 yield from self._object.project.stats.get_update_objects() 

1367 

1368 def get_child_objects(self): 

1369 return self._object.component_set.only("id", "slug", "category", "check_flags") 

1370 

1371 def get_category_objects(self): 

1372 return self._object.category_set.only("id", "slug", "category") 

1373 

1374 def get_single_language_stats(self, language): 

1375 return CategoryLanguageStats(CategoryLanguage(self._object, language)) 

1376 

1377 def get_language_stats(self): 

1378 return prefetch_stats( 

1379 self.get_single_language_stats(language) 

1380 for language in self._object.languages 

1381 ) 

1382 

1383 

1384class ProjectStats(ParentAggregatingStats): 

1385 @cached_property 

1386 def has_review(self): 

1387 return self._object.enable_review 

1388 

1389 def get_child_objects(self): 

1390 own = self._object.component_set.only("id", "project", "check_flags") 

1391 shared = self._object.shared_components.only("id", "project", "check_flags") 

1392 if shared: 1392 ↛ 1393line 1392 didn't jump to line 1393 because the condition on line 1392 was never true

1393 return (own | shared).distinct() 

1394 return own 

1395 

1396 def get_single_language_stats(self, language: Language) -> ProjectLanguageStats: 

1397 return ProjectLanguageStats(ProjectLanguage(self._object, language)) 

1398 

1399 def get_language_stats(self) -> list[ProjectLanguageStats]: 

1400 return prefetch_stats( 

1401 self.get_single_language_stats(language) 

1402 for language in self._object.languages 

1403 ) 

1404 

1405 def _calculate_basic(self) -> None: 

1406 super()._calculate_basic() 

1407 self.store("languages", self._object.languages.count()) 

1408 

1409 

1410class ComponentListStats(ParentAggregatingStats): 

1411 def get_child_objects(self): 

1412 return self._object.components.only( 

1413 "id", "slug", "componentlist", "check_flags" 

1414 ) 

1415 

1416 

1417class GlobalStats(ParentAggregatingStats): 

1418 def __init__(self) -> None: 

1419 super().__init__(None) 

1420 

1421 def get_child_objects(self): 

1422 from weblate.trans.models import Project 

1423 

1424 return Project.objects.only("id", "slug") 

1425 

1426 def _calculate_basic(self) -> None: 

1427 super()._calculate_basic() 

1428 self.store("languages", Language.objects.have_translation().count()) 

1429 

1430 @cached_property 

1431 def cache_key(self) -> str: 

1432 return "stats-global" 

1433 

1434 def get_single_language_stats(self, language): 

1435 return LanguageStats(language) 

1436 

1437 def get_language_stats(self): 

1438 return prefetch_stats( 

1439 self.get_single_language_stats(language) 

1440 for language in Language.objects.have_translation() 

1441 ) 

1442 

1443 # The following fields are used in MetricsSerializer in API 

1444 def get_languages(self): 

1445 return Language.objects.count() 

1446 

1447 def get_users(self): 

1448 from weblate.auth.models import User 

1449 

1450 return User.objects.count() 

1451 

1452 def get_projects(self): 

1453 from weblate.trans.models import Project 

1454 

1455 return Project.objects.count() 

1456 

1457 def get_components(self): 

1458 from weblate.trans.models import Component 

1459 

1460 return Component.objects.count() 

1461 

1462 def get_translations(self): 

1463 from weblate.trans.models import Translation 

1464 

1465 return Translation.objects.count() 

1466 

1467 def get_checks(self): 

1468 from weblate.checks.models import Check 

1469 

1470 return Check.objects.count() 

1471 

1472 def get_configuration_errors(self): 

1473 from weblate.wladmin.models import ConfigurationError 

1474 

1475 return ConfigurationError.objects.filter(ignored=False).count() 

1476 

1477 def get_suggestions(self): 

1478 from weblate.trans.models import Suggestion 

1479 

1480 return Suggestion.objects.count() 

1481 

1482 def get_celery_queues(self): 

1483 from weblate.utils.celery import get_queue_stats 

1484 

1485 return get_queue_stats() 

1486 

1487 def get_name(self): 

1488 return settings.SITE_TITLE 

1489 

1490 

1491class GhostStats(BaseStats): 

1492 basic_keys = SOURCE_KEYS 

1493 is_ghost = True 

1494 

1495 def __init__(self, base=None) -> None: 

1496 super().__init__(None) 

1497 self.base = base 

1498 

1499 @cached_property 

1500 def pk(self): 

1501 return get_random_identifier() 

1502 

1503 def _calculate_basic(self) -> None: 

1504 stats = zero_stats(self.basic_keys) 

1505 if self.base is not None: 

1506 for skey, dkey, tkey in ( 

1507 ("source_strings", "all", "todo"), 

1508 ("source_words", "all_words", "todo_words"), 

1509 ("source_chars", "all_chars", "todo_chars"), 

1510 ): 

1511 stats[tkey] = stats[dkey] = getattr(self.base, skey) 

1512 for key, value in stats.items(): 

1513 self.store(key, value) 

1514 

1515 @cached_property 

1516 def cache_key(self) -> str: 

1517 return "stats-zero" 

1518 

1519 def save(self, update_parents: bool = True) -> None: 

1520 pass 

1521 

1522 def load(self) -> StatDict: 

1523 return {} 

1524 

1525 def get_absolute_url(self) -> str: 

1526 return "" 

1527 

1528 def base_obj(self) -> Project | Component: 

1529 raise NotImplementedError 

1530 

1531 

1532class GhostProjectLanguageStats(GhostStats): 

1533 language: Language 

1534 project: Project 

1535 is_source: bool = False 

1536 

1537 def __init__(self, project: Project, language: Language) -> None: 

1538 super().__init__(project.stats) 

1539 self.project = project 

1540 self.language = language 

1541 

1542 def base_obj(self): 

1543 return self.project 

1544 

1545 

1546class GhostCategoryLanguageStats(GhostStats): 

1547 category: Category 

1548 language: Language 

1549 is_source: bool = False 

1550 

1551 def __init__(self, category: Category, language: Language) -> None: 

1552 super().__init__(category.stats) 

1553 self.project = category.project 

1554 self.category = category 

1555 self.language = language 

1556 

1557 def base_obj(self): 

1558 return self.category