Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/change_display.py: 0%

271 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 typing import TYPE_CHECKING, Any, ClassVar, cast 

8 

9from django.template.loader import render_to_string 

10from django.utils.html import escape, format_html 

11from django.utils.translation import gettext, gettext_lazy, npgettext, pgettext 

12 

13from weblate.addons.models import ADDONS 

14from weblate.lang.models import Language 

15from weblate.trans.actions import ActionEvents 

16from weblate.trans.models.alert import ALERTS 

17from weblate.trans.models.change import COMPONENT_ORIGINS 

18from weblate.trans.models.project import Project 

19from weblate.trans.templatetags.translations import ( 

20 format_language_string, 

21 format_unit_source, 

22 format_unit_target, 

23) 

24from weblate.utils.files import FileUploadMethod, get_upload_message 

25from weblate.utils.markdown import render_markdown 

26from weblate.utils.pii import mask_email 

27 

28if TYPE_CHECKING: 

29 from django_stubs_ext import StrOrPromise 

30 

31 from weblate.trans.models.change import Change, Translation 

32 

33AUTO_ACTIONS = { 

34 # Translators: Name of event in the history 

35 ActionEvents.LOCK: gettext_lazy( 

36 "The component was automatically locked because of an alert." 

37 ), 

38 # Translators: Name of event in the history 

39 ActionEvents.UNLOCK: gettext_lazy( 

40 "Fixing an alert automatically unlocked the component." 

41 ), 

42} 

43 

44 

45class ChangeDetailsRenderFactory: 

46 @staticmethod 

47 def get_strategy(change: Change) -> BaseDetailsRenderStrategy: 

48 """Get strategy for displaying change details.""" 

49 for strategy in [ 

50 s for s in DETAILS_DISPLAY_STRATEGIES if not s.details_required 

51 ]: 

52 if change.action in strategy.actions: 

53 return strategy() 

54 

55 if not change.details: 

56 return RenderEmptyDetails() 

57 

58 # Following rendering relies on details present 

59 for strategy in [s for s in DETAILS_DISPLAY_STRATEGIES if s.details_required]: 

60 if change.action in strategy.actions: 

61 return strategy() 

62 

63 # No specific strategy is found, return an empty display strategy 

64 return RenderEmptyDetails() 

65 

66 

67DETAILS_DISPLAY_STRATEGIES: list[type[BaseDetailsRenderStrategy]] = [] 

68 

69 

70def register_details_display_strategy( 

71 strategy: type[BaseDetailsRenderStrategy], 

72) -> type[BaseDetailsRenderStrategy]: 

73 """Register a new strategy for displaying change details.""" 

74 DETAILS_DISPLAY_STRATEGIES.append(strategy) 

75 return strategy 

76 

77 

78class BaseDetailsRenderStrategy: 

79 actions: ClassVar[set[ActionEvents]] = set() 

80 details_required: bool = False 

81 

82 def render_details(self, change: Change) -> StrOrPromise: 

83 raise NotImplementedError 

84 

85 

86@register_details_display_strategy 

87class RenderEmptyDetails(BaseDetailsRenderStrategy): 

88 def render_details(self, change: Change) -> StrOrPromise: 

89 return "" 

90 

91 

92@register_details_display_strategy 

93class RenderFileUploadDetails(BaseDetailsRenderStrategy): 

94 actions: ClassVar[set[ActionEvents]] = {ActionEvents.FILE_UPLOAD} 

95 

96 def render_details(self, change: Change) -> StrOrPromise: 

97 details = change.details 

98 try: 

99 method = FileUploadMethod[details["method"].upper()].label 

100 except KeyError: 

101 method = details["method"] 

102 return format_html( 

103 "{}<br>{} {}", 

104 get_upload_message( 

105 details["not_found"], 

106 details["skipped"], 

107 details["accepted"], 

108 details["total"], 

109 ), 

110 gettext("File upload mode:"), 

111 method, 

112 ) 

113 

114 

115@register_details_display_strategy 

116class RenderTarget(BaseDetailsRenderStrategy): 

117 actions: ClassVar[set[ActionEvents]] = { 

118 ActionEvents.ANNOUNCEMENT, 

119 ActionEvents.AGREEMENT_CHANGE, 

120 } 

121 

122 def render_details(self, change: Change) -> StrOrPromise: 

123 return render_markdown(change.target) 

124 

125 

126@register_details_display_strategy 

127class RenderComment(BaseDetailsRenderStrategy): 

128 actions: ClassVar[set[ActionEvents]] = { 

129 ActionEvents.COMMENT_DELETE, 

130 ActionEvents.COMMENT, 

131 } 

132 

133 def render_details(self, change: Change) -> StrOrPromise: 

134 if "comment" in change.details: 

135 return render_markdown(change.details["comment"]) 

136 return "" 

137 

138 

139@register_details_display_strategy 

140class RenderAddon(BaseDetailsRenderStrategy): 

141 actions: ClassVar[set[ActionEvents]] = { 

142 ActionEvents.ADDON_CREATE, 

143 ActionEvents.ADDON_CHANGE, 

144 ActionEvents.ADDON_REMOVE, 

145 } 

146 

147 def render_details(self, change: Change) -> StrOrPromise: 

148 try: 

149 return ADDONS[change.target].name 

150 except KeyError: 

151 return change.target 

152 

153 

154@register_details_display_strategy 

155class RenderUpdateEventDetails(BaseDetailsRenderStrategy): 

156 """Strategy for displaying details of an update event.""" 

157 

158 actions: ClassVar[set[ActionEvents]] = {ActionEvents.UPDATE} 

159 

160 def render_details(self, change: Change) -> StrOrPromise: 

161 details = change.details 

162 reason = details.get("reason", "content changed") 

163 filename = format_html( 

164 "<code>{}</code>", 

165 details.get( 

166 "filename", 

167 change.translation.filename if change.translation else "", 

168 ), 

169 ) 

170 if reason == "content changed": 

171 message = gettext("The “{}” file was changed.") 

172 elif reason == "check forced": 

173 message = gettext("Parsing of the “{}” file was enforced.") 

174 elif reason == "new file": 

175 message = gettext("File “{}” was added.") 

176 else: 

177 msg = f"Unknown reason: {reason}" 

178 raise ValueError(msg) 

179 return format_html(escape(message), filename) 

180 

181 

182@register_details_display_strategy 

183class RenderLicenceChangeDetails(BaseDetailsRenderStrategy): 

184 """Strategy for displaying details of a license change event.""" 

185 

186 actions: ClassVar[set[ActionEvents]] = {ActionEvents.LICENSE_CHANGE} 

187 

188 def render_details(self, change: Change) -> StrOrPromise: 

189 not_available = pgettext("License information not available", "N/A") 

190 return gettext( 

191 'The license of the "%(component)s" component was changed ' 

192 "from %(old)s to %(target)s." 

193 ) % { 

194 "component": change.component, 

195 "old": change.old or not_available, 

196 "target": change.target or not_available, 

197 } 

198 

199 

200@register_details_display_strategy 

201class RenderAutoActions(BaseDetailsRenderStrategy): 

202 actions: ClassVar[set[ActionEvents]] = set(AUTO_ACTIONS.keys()) 

203 

204 def render_details(self, change: Change) -> StrOrPromise: 

205 if change.auto_status: 

206 return AUTO_ACTIONS[cast("ActionEvents", change.action)] 

207 return "" 

208 

209 

210@register_details_display_strategy 

211class RenderAccessEdit(BaseDetailsRenderStrategy): 

212 """Strategy for displaying details of an access edit event.""" 

213 

214 details_required = True 

215 actions: ClassVar[set[ActionEvents]] = {ActionEvents.ACCESS_EDIT} 

216 

217 def render_details(self, change: Change) -> StrOrPromise: 

218 details = change.details 

219 for number, name in Project.ACCESS_CHOICES: 

220 if number == details["access_control"]: 

221 return name 

222 return "Unknown {}".format(details["access_control"]) 

223 

224 

225@register_details_display_strategy 

226class RenderUserActions(BaseDetailsRenderStrategy): 

227 """Strategy for displaying details of user actions.""" 

228 

229 actions: ClassVar[set[ActionEvents]] = { 

230 ActionEvents.ADD_USER, 

231 ActionEvents.INVITE_USER, 

232 ActionEvents.REMOVE_USER, 

233 } 

234 details_required = True 

235 

236 def render_details(self, change: Change) -> StrOrPromise: 

237 details = change.details 

238 if "username" in details: 

239 result = details["username"] 

240 else: 

241 result = mask_email(details["email"]) 

242 if "group" in details: 

243 result = f"{result} ({details['group']})" 

244 return result 

245 

246 

247@register_details_display_strategy 

248class RenderLanguage(BaseDetailsRenderStrategy): 

249 """Strategy for displaying details of language actions.""" 

250 

251 actions: ClassVar[set[ActionEvents]] = { 

252 ActionEvents.ADDED_LANGUAGE, 

253 ActionEvents.REQUESTED_LANGUAGE, 

254 } 

255 details_required = True 

256 

257 def render_details(self, change: Change) -> StrOrPromise: 

258 try: 

259 return Language.objects.get(code=change.details["language"]) 

260 except Language.DoesNotExist: 

261 return change.details["language"] 

262 

263 

264@register_details_display_strategy 

265class RenderAlert(BaseDetailsRenderStrategy): 

266 """Strategy for displaying details of an alert event.""" 

267 

268 actions: ClassVar[set[ActionEvents]] = {ActionEvents.ALERT} 

269 details_required = True 

270 

271 def render_details(self, change: Change) -> StrOrPromise: 

272 try: 

273 return ALERTS[change.details["alert"]].verbose 

274 except KeyError: 

275 return change.details["alert"] 

276 

277 

278@register_details_display_strategy 

279class RenderRepositoryDetails(BaseDetailsRenderStrategy): 

280 """Strategy for displaying details of repository events.""" 

281 

282 actions: ClassVar[set[ActionEvents]] = { 

283 ActionEvents.RESET, 

284 ActionEvents.MERGE, 

285 ActionEvents.REBASE, 

286 } 

287 

288 def render_details(self, change: Change) -> StrOrPromise: 

289 return format_html( 

290 "{}<br/><br/>{}<br/>{}", 

291 change.get_action_display(), 

292 format_html( 

293 escape(gettext("Original revision: {}")), 

294 change.details.get("previous_head", "N/A"), 

295 ), 

296 format_html( 

297 escape(gettext("New revision: {}")), 

298 change.details.get("new_head", "N/A"), 

299 ), 

300 ) 

301 

302 

303@register_details_display_strategy 

304class RenderParseError(BaseDetailsRenderStrategy): 

305 """Strategy for displaying details of a parse error event.""" 

306 

307 actions: ClassVar[set[ActionEvents]] = {ActionEvents.PARSE_ERROR} 

308 details_required = True 

309 

310 def render_details(self, change: Change) -> StrOrPromise: 

311 return "{filename}: {error_message}".format(**change.details) 

312 

313 

314@register_details_display_strategy 

315class RenderHook(BaseDetailsRenderStrategy): 

316 """Strategy for displaying details of a hook event.""" 

317 

318 actions: ClassVar[set[ActionEvents]] = {ActionEvents.HOOK} 

319 details_required = True 

320 

321 def render_details(self, change: Change) -> StrOrPromise: 

322 return "{service_long_name}: {repo_url}, {branch}".format(**change.details) 

323 

324 

325@register_details_display_strategy 

326class RenderCreateComponent(BaseDetailsRenderStrategy): 

327 """Strategy for displaying details of a component creation event.""" 

328 

329 actions: ClassVar[set[ActionEvents]] = {ActionEvents.CREATE_COMPONENT} 

330 

331 def render_details(self, change: Change) -> StrOrPromise: 

332 try: 

333 origin = change.details["origin"] 

334 except KeyError: 

335 return ActionEvents.CREATE_COMPONENT.label 

336 try: 

337 return COMPONENT_ORIGINS[origin] 

338 except KeyError: 

339 return f"{ActionEvents.CREATE_COMPONENT.label} ({origin})" 

340 

341 

342def get_change_history_context(change: Change) -> dict[str, Any]: 

343 """ 

344 Get context for rendering change history. 

345 

346 This function returns a dictionary containing the main content and fields 

347 for displaying the change history. 

348 """ 

349 if details := change.get_details_display(): 

350 return {"description": details, "change_details_fields": []} 

351 if change.show_content() and change.unit: 

352 return ShowChangeContent(change).get_context() 

353 if change.show_source(): 

354 return ShowChangeSource(change).get_context() 

355 if change.show_diff(): 

356 return ShowChangeDiff(change).get_context() 

357 if change.show_removed_string(): 

358 return ShowRemovedString(change).get_context() 

359 if change.target: 

360 return ShowChangeTarget(change).get_context() 

361 return ShowChangeAction(change).get_context() 

362 

363 

364class BaseChangeHistoryContext: 

365 """Base class for change history context rendering.""" 

366 

367 def __init__(self, change: Change) -> None: 

368 self.change = change 

369 self.fields: list[dict[str, Any]] = [] 

370 

371 def get_context(self) -> dict[str, Any]: 

372 """Get context for rendering change history.""" 

373 return { 

374 "description": self.get_description(), 

375 "change_details_fields": self.get_change_details_fields(), 

376 } 

377 

378 def get_description(self) -> str: 

379 return "" 

380 

381 def get_change_details_fields(self) -> list[dict]: 

382 """ 

383 Generate a list of dictionaries representing fields to be displayed as label and input-like content. 

384 

385 Returns: 

386 list[dict]: A list of dictionaries, each representing a field as returned by `make_field`. 

387 

388 """ 

389 return [] 

390 

391 def make_field( 

392 self, 

393 label: str, 

394 field_content: str, 

395 label_badges: list[str] | None = None, 

396 extra_label_classes: list[str] | None = None, 

397 ) -> dict: 

398 return { 

399 "label": label, 

400 "content": field_content, 

401 "tags": label_badges or [], 

402 "label_class": " ".join(extra_label_classes or []), 

403 } 

404 

405 def format_translation(self, context: dict) -> str: 

406 """Format translation context for rendering.""" 

407 return render_to_string("snippets/format-translation.html", context) 

408 

409 def make_distance_badge(self, count: int) -> str: 

410 """Create a badge for the Damerau–Levenshtein distance.""" # noqa: RUF002 

411 return npgettext( 

412 "Number of edits on a change in Damerau–Levenshtein distance", 

413 "%(count)d character edited", 

414 "%(count)d characters edited", 

415 count, 

416 ) % {"count": count} 

417 

418 def add_context_field(self, translation: Translation) -> None: 

419 if context := self.change.details.get("context"): 

420 self.fields.append( 

421 self.make_field( 

422 gettext("Context"), 

423 self.format_translation( 

424 format_language_string(context, translation) 

425 ), 

426 ) 

427 ) 

428 

429 

430class ShowChangeTarget(BaseChangeHistoryContext): 

431 """Display the target of a change in the history context.""" 

432 

433 def get_description(self) -> str: 

434 return format_html("<pre>{}</pre>", self.change.target) 

435 

436 

437class ShowChangeAction(BaseChangeHistoryContext): 

438 """Display the action of a change in the history context.""" 

439 

440 def get_description(self) -> str: 

441 return self.change.get_action_display() 

442 

443 

444class ShowChangeContent(BaseChangeHistoryContext): 

445 """Display the content of a change in the history context.""" 

446 

447 LIST_GROUP_ITEM_TEMPLATE = """ 

448 <div class="list-group"> 

449 <div class="list-group-item sidebar-button">{0}</div> 

450 </div> 

451 """ 

452 

453 def get_change_details_fields(self) -> list[dict]: 

454 """Return the fields to be displayed in the change history.""" 

455 self.fields = [] 

456 change = self.change 

457 if change.unit is None: 

458 msg = "Change does not contain unit, can not render content!" 

459 raise ValueError(msg) 

460 unit = change.unit 

461 

462 # rejection reason field 

463 if "rejection_reason" in change.details: 

464 self.fields.append( 

465 self.make_field( 

466 gettext("Rejection reason"), 

467 format_html( 

468 self.LIST_GROUP_ITEM_TEMPLATE, 

469 change.details["rejection_reason"], 

470 ), 

471 ) 

472 ) 

473 

474 # source language field 

475 self.fields.append( 

476 self.make_field( 

477 str(unit.translation.component.source_language), 

478 self.format_translation( 

479 format_unit_source(unit, value=self.change.get_source()) 

480 ), 

481 ) 

482 ) 

483 

484 # translation language field 

485 translation_language_badges = [ 

486 self.make_distance_badge(change.get_distance()), 

487 ] 

488 

489 if unit.target == change.target: 

490 translation_language_badges.append(gettext("Current translation")) 

491 else: 

492 translation_language_badges.append(gettext("Previous translation")) 

493 if change.show_unit_state(): 

494 state = change.get_state_display() 

495 translation_language_badges.append( 

496 gettext("State: %(state)s") % {"state": state} 

497 ) 

498 

499 self.fields.append( 

500 self.make_field( 

501 str(unit.translation.language), 

502 self.format_translation( 

503 format_unit_target(unit, value=change.target, diff=change.old) 

504 ), 

505 label_badges=translation_language_badges, 

506 extra_label_classes=["tags-list"], 

507 ) 

508 ) 

509 self.add_context_field(unit.translation) 

510 return self.fields 

511 

512 

513class ShowChangeSource(BaseChangeHistoryContext): 

514 """Display the source of a change in the history context.""" 

515 

516 def get_change_details_fields(self) -> list[dict]: 

517 self.fields = [] 

518 change = self.change 

519 if change.unit is None: 

520 msg = "Change does not contain unit, can not render content!" 

521 raise ValueError(msg) 

522 unit = change.unit 

523 

524 # source language field 

525 source_lang_badges = [ 

526 self.make_distance_badge(change.get_distance()), 

527 ] 

528 if change.show_unit_state(): 

529 source_lang_badges.append(change.get_state_display()) 

530 

531 if change.target: 

532 source_lang_content = format_unit_source( 

533 unit, value=change.target, diff=change.old 

534 ) 

535 else: 

536 source_lang_content = format_unit_source(unit) 

537 

538 self.fields.append( 

539 self.make_field( 

540 str(unit.translation.component.source_language), 

541 self.format_translation(source_lang_content), 

542 label_badges=source_lang_badges, 

543 ) 

544 ) 

545 self.add_context_field(unit.translation) 

546 return self.fields 

547 

548 

549class ShowChangeDiff(BaseChangeHistoryContext): 

550 """Display the diff of a change in the history context.""" 

551 

552 def get_change_details_fields(self) -> list[dict]: 

553 change = self.change 

554 if change.unit is None: 

555 msg = "Change does not contain unit, can not render content!" 

556 raise ValueError(msg) 

557 unit = change.unit 

558 return [ 

559 self.make_field( 

560 "", 

561 self.format_translation( 

562 format_unit_source(unit, value=change.target, diff=change.old) 

563 ), 

564 label_badges=[self.make_distance_badge(change.get_distance())], 

565 ) 

566 ] 

567 

568 

569class ShowRemovedString(BaseChangeHistoryContext): 

570 """Display the removed string of a change in the history context.""" 

571 

572 def get_change_details_fields(self) -> list[dict]: 

573 change = self.change 

574 component = change.component 

575 translation = change.translation 

576 if component is None or translation is None: 

577 msg = "Change does not contain translation, can not render content!" 

578 raise ValueError(msg) 

579 

580 self.fields = [ 

581 self.make_field( 

582 str(component.source_translation.language), 

583 self.format_translation( 

584 format_language_string( 

585 change.details["source"], component.source_translation 

586 ) 

587 ), 

588 ) 

589 ] 

590 if "target" in change.details and not translation.is_source: 

591 self.fields.append( 

592 self.make_field( 

593 str(translation.language), 

594 self.format_translation( 

595 format_language_string(change.details["target"], translation) 

596 ), 

597 ) 

598 ) 

599 

600 self.add_context_field(translation) 

601 return self.fields