Coverage for app/venv/lib/python3.14/site-packages/weblate/wladmin/models.py: 59%

215 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 json 

8from typing import TYPE_CHECKING, ClassVar, TypedDict 

9 

10import dateutil.parser 

11from appconf import AppConf 

12from django.conf import settings 

13from django.contrib.admin import ModelAdmin 

14from django.core.cache import cache 

15from django.core.checks import run_checks 

16from django.db import models 

17from django.utils import timezone 

18from django.utils.functional import cached_property 

19from django.utils.translation import gettext, gettext_lazy 

20 

21from weblate.auth.models import User 

22from weblate.trans.models import Component, Project 

23from weblate.utils.backup import ( 

24 BackupError, 

25 backup, 

26 cleanup, 

27 get_paper_key, 

28 initialize, 

29 make_password, 

30 prune, 

31) 

32from weblate.utils.const import SUPPORT_STATUS_CACHE_KEY 

33from weblate.utils.requests import request 

34from weblate.utils.site import get_site_url 

35from weblate.utils.stats import GlobalStats 

36from weblate.utils.validators import validate_backup_path 

37from weblate.vcs.ssh import ensure_ssh_key 

38 

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

40 from weblate.auth.models import AuthenticatedHttpRequest 

41 

42 

43class WeblateConf(AppConf): 

44 BACKGROUND_ADMIN_CHECKS = True 

45 

46 class Meta: 

47 prefix = "" 

48 

49 

50class WeblateModelAdmin(ModelAdmin): 

51 """Customized Model Admin object.""" 

52 

53 delete_confirmation_template = "wladmin/delete_confirmation.html" 

54 delete_selected_confirmation_template = "wladmin/delete_selected_confirmation.html" 

55 

56 

57class ConfigurationErrorManager(models.Manager["ConfigurationError"]): 

58 def configuration_health_check(self, checks=None) -> None: 

59 # Run deployment checks if needed 

60 if checks is None: 60 ↛ 62line 60 didn't jump to line 62 because the condition on line 60 was always true

61 checks = run_checks(include_deployment_checks=True) 

62 checks_dict = {check.id: check for check in checks} 

63 criticals = { 

64 "weblate.E002", 

65 "weblate.E003", 

66 "weblate.E007", 

67 "weblate.E009", 

68 "weblate.E012", 

69 "weblate.E013", 

70 "weblate.E014", 

71 "weblate.E015", 

72 "weblate.E017", 

73 "weblate.E018", 

74 "weblate.E019", 

75 "weblate.C023", 

76 "weblate.C029", 

77 "weblate.C030", 

78 "weblate.C031", 

79 "weblate.C032", 

80 "weblate.E034", 

81 "weblate.C035", 

82 "weblate.C036", 

83 "weblate.C037", 

84 "weblate.C038", 

85 "weblate.C040", 

86 "weblate.C041", 

87 "weblate.E006", 

88 } 

89 removals = [] 

90 existing = {error.name: error for error in self.all()} 

91 

92 for check_id in criticals: 

93 if check_id in checks_dict: 

94 check = checks_dict[check_id] 

95 if check_id in existing: 

96 error = existing[check_id] 

97 if error.message != check.msg: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true

98 error.message = check.msg 

99 error.save(update_fields=["message"]) 

100 else: 

101 self.create(name=check_id, message=check.msg) 

102 elif check_id in existing: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true

103 removals.append(check_id) 

104 

105 if removals: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true

106 self.filter(name__in=removals).delete() 

107 

108 

109class ConfigurationError(models.Model): 

110 name = models.CharField(unique=True, max_length=150) 

111 message = models.TextField() 

112 timestamp = models.DateTimeField(default=timezone.now) 

113 ignored = models.BooleanField(default=False, db_index=True) 

114 

115 objects = ConfigurationErrorManager() 

116 

117 class Meta: 

118 indexes: ClassVar = [ 

119 models.Index(fields=["ignored", "timestamp"]), 

120 ] 

121 verbose_name = "Configuration error" 

122 verbose_name_plural = "Configuration errors" 

123 

124 def __str__(self) -> str: 

125 return self.name 

126 

127 

128SUPPORT_NAMES = { 

129 # Translators: The Weblate server has no paid support by the Weblate team 

130 "community": gettext_lazy("Community support"), 

131 # Translators: The Weblate server is hosted by the Weblate team 

132 "hosted": gettext_lazy("Hosted service"), 

133 # Translators: The Weblate server has paid support from the Weblate team 

134 "basic": gettext_lazy("Basic self-hosted support"), 

135 # Translators: The Weblate server has extended support from the Weblate team 

136 "extended": gettext_lazy("Extended self-hosted support"), 

137 # Translators: The Weblate server has premium support from the Weblate team 

138 "premium": gettext_lazy("Premium self-hosted support"), 

139} 

140 

141 

142class SupportStatusManager(models.Manager["SupportStatus"]): 

143 def get_current(self, *, for_update: bool = False) -> SupportStatus: 

144 base = self.filter(enabled=True) 

145 if for_update: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 base = base.select_for_update() 

147 try: 

148 return base.latest("expiry") 

149 except SupportStatus.DoesNotExist: 

150 return SupportStatus(name="community", enabled=False) 

151 

152 

153class SupportStatus(models.Model): 

154 name = models.CharField(max_length=150) 

155 secret = models.CharField(max_length=400) 

156 expiry = models.DateTimeField(db_index=True, null=True) 

157 in_limits = models.BooleanField(default=True) 

158 discoverable = models.BooleanField(default=False) 

159 limits = models.JSONField(default=dict) 

160 has_subscription = models.BooleanField(default=False) 

161 backup_repository = models.CharField(max_length=500, default="", blank=True) 

162 enabled = models.BooleanField(default=True, db_index=True, blank=True) 

163 

164 objects = SupportStatusManager() 

165 

166 class Meta: 

167 verbose_name = "Support status" 

168 verbose_name_plural = "Support statuses" 

169 

170 def __str__(self) -> str: 

171 return f"{self.name}:{self.expiry}" 

172 

173 def get_verbose(self): 

174 if self.has_expired_support: 

175 return gettext("Unpaid subscription") 

176 return SUPPORT_NAMES.get(self.name, self.name) 

177 

178 def refresh(self) -> None: 

179 stats = GlobalStats() 

180 data = { 

181 "secret": self.secret, 

182 "site_url": get_site_url(), 

183 "site_title": settings.SITE_TITLE, 

184 "users": User.objects.count(), 

185 "projects": Project.objects.count(), 

186 "components": Component.objects.count(), 

187 "languages": stats.languages, 

188 "source_strings": stats.source_strings, 

189 "strings": stats.all, 

190 "words": stats.all_words, 

191 } 

192 if self.discoverable: 

193 data["discoverable"] = 1 

194 data["public_projects"] = json.dumps( 

195 [ 

196 { 

197 "name": project.name, 

198 "url": project.get_absolute_url(), 

199 "web": project.web, 

200 } 

201 for project in Project.objects.filter( 

202 access_control=Project.ACCESS_PUBLIC 

203 ).iterator() 

204 ] 

205 ) 

206 ssh_key = ensure_ssh_key() 

207 if ssh_key: 

208 data["ssh_key"] = ssh_key["key"] 

209 response = request("post", settings.SUPPORT_API_URL, data=data, timeout=360) 

210 response.raise_for_status() 

211 payload = response.json() 

212 self.name = payload["name"] 

213 self.expiry = dateutil.parser.parse(payload["expiry"]) 

214 self.in_limits = payload["in_limits"] 

215 self.limits = payload["limits"] 

216 self.has_subscription = payload["has_subscription"] 

217 self.backup_repository = payload["backup_repository"] 

218 if self.backup_repository: 

219 BackupService.objects.get_or_create( 

220 repository=self.backup_repository, defaults={"enabled": False} 

221 ) 

222 # Invalidate support status cache 

223 cache.delete(SUPPORT_STATUS_CACHE_KEY) 

224 

225 def get_limits_details(self): 

226 stats = GlobalStats() 

227 current_values = { 

228 "hosted_words": stats.all_words, 

229 "hosted_strings": stats.all, 

230 "source_strings": stats.source_strings, 

231 "projects": Project.objects.count(), 

232 "languages": stats.languages, 

233 } 

234 names = { 

235 "hosted_words": gettext_lazy("Hosted words"), 

236 "hosted_strings": gettext_lazy("Hosted strings"), 

237 "source_strings": gettext_lazy("Source strings"), 

238 "projects": gettext_lazy("Projects"), 

239 "languages": gettext_lazy("Languages"), 

240 } 

241 result = [] 

242 for limit, value in self.limits.items(): 

243 if not value or limit not in names: 

244 continue 

245 current = current_values[limit] 

246 result.append( 

247 { 

248 "name": names[limit], 

249 "limit": value, 

250 "current": current, 

251 "in_limit": current < value, 

252 } 

253 ) 

254 return result 

255 

256 @property 

257 def has_support(self) -> bool: 

258 return self.name != "community" 

259 

260 @property 

261 def has_expired_support(self) -> bool: 

262 return self.pk is not None and self.has_subscription and not self.has_support 

263 

264 

265class BackupService(models.Model): 

266 repository = models.CharField( 

267 max_length=500, 

268 default="", 

269 verbose_name=gettext_lazy("Backup repository URL"), 

270 help_text=gettext_lazy( 

271 "Use /path/to/repo for local backups " 

272 "or user@host:/path/to/repo " 

273 "or ssh://user@host:port/path/to/backups for remote SSH backups." 

274 ), 

275 validators=[validate_backup_path], 

276 ) 

277 enabled = models.BooleanField(default=True) 

278 timestamp = models.DateTimeField(default=timezone.now) 

279 passphrase = models.CharField(max_length=100, default=make_password) 

280 paperkey = models.TextField() 

281 

282 class Meta: 

283 verbose_name = "Support service" 

284 verbose_name_plural = "Support services" 

285 

286 def __str__(self) -> str: 

287 return self.repository 

288 

289 @cached_property 

290 def last_logs(self) -> models.QuerySet[BackupLog]: 

291 return self.backuplog_set.order_by("-timestamp")[:10] 

292 

293 @cached_property 

294 def has_errors(self) -> bool: 

295 for log in self.last_logs: 

296 if log.event == "error": 

297 return True 

298 if log.event == "backup": 

299 return False 

300 return False 

301 

302 def ensure_init(self) -> None: 

303 if not self.paperkey: 

304 try: 

305 self.paperkey = get_paper_key(self.repository) 

306 self.save(update_fields=["paperkey"]) 

307 except BackupError: 

308 try: 

309 log = initialize(self.repository, self.passphrase) 

310 self.backuplog_set.create(event="init", log=log) 

311 self.paperkey = get_paper_key(self.repository) 

312 self.save() 

313 except BackupError as error: 

314 self.backuplog_set.create(event="error", log=str(error)) 

315 

316 def backup(self) -> None: 

317 try: 

318 log = backup(self.repository, self.passphrase) 

319 self.backuplog_set.create(event="backup", log=log) 

320 except BackupError as error: 

321 self.backuplog_set.create(event="error", log=str(error)) 

322 

323 def prune(self) -> None: 

324 try: 

325 log = prune(self.repository, self.passphrase) 

326 self.backuplog_set.create(event="prune", log=log) 

327 except BackupError as error: 

328 self.backuplog_set.create(event="error", log=str(error)) 

329 

330 def cleanup(self) -> None: 

331 initial = self.backuplog_set.filter(event="cleanup").exists() 

332 try: 

333 log = cleanup(self.repository, self.passphrase, initial=initial) 

334 self.backuplog_set.create(event="cleanup", log=log) 

335 except BackupError as error: 

336 self.backuplog_set.create(event="error", log=str(error)) 

337 

338 

339class BackupLog(models.Model): 

340 service = models.ForeignKey(BackupService, on_delete=models.deletion.CASCADE) 

341 timestamp = models.DateTimeField(default=timezone.now) 

342 event = models.CharField( 

343 max_length=100, 

344 choices=( 

345 # Translators: Backup repository operation 

346 ("backup", gettext_lazy("Backup performed")), 

347 # Translators: Backup repository operation 

348 ("error", gettext_lazy("Backup failed")), 

349 # Translators: Backup repository operation 

350 ("prune", gettext_lazy("Deleted the oldest backups")), 

351 # Translators: Backup repository operation 

352 ("cleanup", gettext_lazy("Cleaned up backup storage")), 

353 # Translators: Backup repository operation 

354 ("init", gettext_lazy("Repository initialization")), 

355 ), 

356 db_index=True, 

357 ) 

358 log = models.TextField() 

359 

360 class Meta: 

361 verbose_name = "Backup log" 

362 verbose_name_plural = "Backup logs" 

363 

364 def __str__(self) -> str: 

365 return f"{self.service}:{self.event}" 

366 

367 

368class SupportStatusDict(TypedDict): 

369 has_support: bool 

370 in_limits: bool 

371 has_expired_support: bool 

372 backup_repository: str 

373 name: str 

374 is_hosted_weblate: bool 

375 is_dedicated: bool 

376 

377 

378def get_support_status(request: AuthenticatedHttpRequest) -> SupportStatusDict: 

379 support_status: SupportStatusDict 

380 if hasattr(request, "weblate_support_status"): 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true

381 support_status = request.weblate_support_status 

382 else: 

383 support_status = cache.get(SUPPORT_STATUS_CACHE_KEY) 

384 if support_status is None: 

385 support_status_instance = SupportStatus.objects.get_current() 

386 is_hosted = support_status_instance.name == "hosted" 

387 support_status = { 

388 "name": support_status_instance.name, 

389 "is_hosted_weblate": is_hosted 

390 and settings.SITE_DOMAIN == "hosted.weblate.org", 

391 "is_dedicated": is_hosted, 

392 "has_support": support_status_instance.has_support, 

393 "has_expired_support": support_status_instance.has_expired_support, 

394 "in_limits": support_status_instance.in_limits, 

395 "backup_repository": support_status_instance.backup_repository, 

396 } 

397 cache.set(SUPPORT_STATUS_CACHE_KEY, support_status, 86400) 

398 request.weblate_support_status = support_status 

399 return support_status