Coverage for api/admin/media_report.py: 30%

493 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 06:14 +0000

1import json 

2from collections.abc import Sequence 

3from functools import update_wrapper 

4 

5from django import forms 

6from django.conf import settings 

7from django.contrib import admin, messages 

8from django.contrib.admin import helpers 

9from django.contrib.admin.views.main import ChangeList 

10from django.core.exceptions import ObjectDoesNotExist 

11from django.db.models import Count, F, Min 

12from django.http import JsonResponse 

13from django.shortcuts import redirect 

14from django.template.response import TemplateResponse 

15from django.urls import reverse 

16from django.utils.html import format_html 

17from django.utils.safestring import mark_safe 

18 

19import structlog 

20from elasticsearch import NotFoundError 

21from elasticsearch_dsl import Search 

22 

23from api.constants.moderation import DecisionAction 

24from api.models import ( 

25 Audio, 

26 AudioDecision, 

27 AudioDecisionThrough, 

28 AudioReport, 

29 ContentSource, 

30 DeletedAudio, 

31 DeletedImage, 

32 Image, 

33 ImageDecision, 

34 ImageDecisionThrough, 

35 ImageReport, 

36 SensitiveAudio, 

37 SensitiveImage, 

38) 

39from api.utils.moderation import perform_moderation 

40from api.utils.moderation_lock import LockManager 

41 

42 

43logger = structlog.get_logger(__name__) 

44 

45 

46def register(site): 

47 site.register(Image, ImageListViewAdmin) 

48 site.register(Audio, AudioListViewAdmin) 

49 

50 site.register(AudioReport, AudioReportAdmin) 

51 site.register(ImageReport, ImageReportAdmin) 

52 

53 site.register(SensitiveImage, SensitiveImageAdmin) 

54 site.register(SensitiveAudio, SensitiveAudioAdmin) 

55 

56 site.register(DeletedImage, DeletedImageAdmin) 

57 site.register(DeletedAudio, DeletedAudioAdmin) 

58 

59 site.register(ImageDecision, ImageDecisionAdmin) 

60 site.register(AudioDecision, AudioDecisionAdmin) 

61 

62 

63def get_report_form(media_type: str): 

64 report_class = { 

65 "image": ImageReport, 

66 "audio": AudioReport, 

67 }[media_type] 

68 

69 class MediaReportForm(forms.ModelForm): 

70 class Meta: 

71 fields = ["media_obj", "reason", "description"] 

72 model = report_class 

73 

74 def __init__(self, *args, **kwargs): 

75 super().__init__(*args, **kwargs) 

76 for field in self.fields.values(): 

77 field.widget.attrs.update({"form": "report-create"}) 

78 

79 return MediaReportForm 

80 

81 

82class MultipleValueField(forms.MultipleChoiceField): 

83 """ 

84 This is a variant of ``MultipleChoiceField`` that does not validate 

85 the individual values. 

86 """ 

87 

88 def valid_value(self, value): 

89 return True 

90 

91 

92def get_decision_form(media_type: str): 

93 decision_class, report_class = { 

94 "image": (ImageDecision, ImageReport), 

95 "audio": (AudioDecision, AudioReport), 

96 }[media_type] 

97 

98 class MediaDecisionForm(forms.ModelForm): 

99 report_id = MultipleValueField() # not rendered using its widget 

100 

101 class Meta: 

102 model = decision_class 

103 fields = ["action", "notes"] 

104 

105 def __init__(self, *args, **kwargs): 

106 super().__init__(*args, **kwargs) 

107 for field in self.fields.values(): 

108 field.widget.attrs.update({"form": "decision-create"}) 

109 

110 def clean_report_id(self): 

111 report_ids = set(self.cleaned_data["report_id"]) 

112 report_qs = report_class.objects.filter( 

113 decision=None, 

114 id__in=report_ids, 

115 ) 

116 retrieved_report_ids = set( 

117 str(val) for val in report_qs.values_list("id", flat=True) 

118 ) 

119 if diff := (report_ids - retrieved_report_ids): 

120 raise forms.ValidationError( 

121 "No pending reports found for IDs %(value)s.", 

122 params={"value": ", ".join(diff)}, 

123 ) 

124 self.cleaned_data["reports"] = report_qs 

125 return report_ids 

126 

127 return MediaDecisionForm 

128 

129 

130def _production_deferred(*values: str) -> Sequence[str]: 

131 """ 

132 Define a sequence in all environment except production. 

133 

134 The autocomplete/search queries in Django are woefully unoptimized for massive 

135 tables, and so enabling certain utility features in production will often incur 

136 a significant performance hit or outage. This will return the input values except 

137 when the environment is production, in which case it will return an empty sequence. 

138 """ 

139 if settings.ENVIRONMENT == "production": 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true

140 return () 

141 return values 

142 

143 

144def _non_production_deferred(*values: str) -> Sequence[str]: 

145 """ 

146 Define a sequence in only the production environment. 

147 

148 The raw ID field is perfectly suited for massive tables, and so enabling 

149 that in production will often prevent performance hits or outages. This will 

150 return the input values only the environment is production, and in all other 

151 cases it will return an empty sequence. 

152 """ 

153 if settings.ENVIRONMENT != "production": 153 ↛ 155line 153 didn't jump to line 155 because the condition on line 153 was always true

154 return () 

155 return values 

156 

157 

158class PredeterminedOrderChangelist(ChangeList): 

159 """ 

160 ChangeList class which does not apply any default ordering to the items. 

161 

162 This is necessary for lists where the ordering is done on an annotated field, since 

163 the changelist attempts to apply the ordering to a QuerySet which is not aware that 

164 it has the annotated field available (and thus raises a FieldError). 

165 

166 The caveat to this is that the ordering *must* be applied in 

167 ModelAdmin::get_queryset 

168 """ 

169 

170 def _get_default_ordering(self): 

171 return [] 

172 

173 

174def get_source_filter(media_type: str): 

175 class SourceFilter(admin.SimpleListFilter): 

176 """ 

177 Custom filter for source field in admin list view to prevent Django 

178 from performing expensive `DISTINCT` query on media tables. 

179 """ 

180 

181 title = "source" 

182 parameter_name = "source" 

183 

184 def lookups(self, request, model_admin): 

185 return ContentSource.objects.filter(media_type=media_type).values_list( 

186 "source_identifier", "source_name" 

187 ) 

188 

189 def queryset(self, request, queryset): 

190 value = self.value() 

191 if value is not None: 

192 queryset = queryset.filter(source=value) 

193 return queryset 

194 

195 return SourceFilter 

196 

197 

198def get_pending_record_filter(media_type: str): 

199 class PendingRecordCountFilter(admin.SimpleListFilter): 

200 title = "pending record count" 

201 parameter_name = "pending_record_count" 

202 

203 def choices(self, changelist): 

204 for lookup, title in self.lookup_choices: 

205 yield { 

206 "selected": self.value() == lookup, 

207 "query_string": changelist.get_query_string( 

208 {self.parameter_name: lookup}, [] 

209 ), 

210 "display": title, 

211 } 

212 

213 def lookups(self, request, model_admin): 

214 return [ 

215 (None, "Moderation queue"), 

216 ("prev", "Resolved"), 

217 ("all", "All"), 

218 ] 

219 

220 def queryset(self, request, qs): 

221 value = self.value() 

222 if value is None or value == "prev": 

223 # Filter down to only instances with reports 

224 qs = qs.filter(**{f"{media_type}_report__isnull": False}) 

225 

226 # Annotate and order by report count 

227 qs = qs.annotate(total_report_count=Count(f"{media_type}_report")) 

228 # Show total pending reports by subtracting the number of reports 

229 # from the number of reports that have decisions 

230 qs = qs.annotate( 

231 pending_report_count=F("total_report_count") 

232 - Count(f"{media_type}_report__decision__pk") 

233 ) 

234 qs = qs.annotate( 

235 oldest_report_date=Min(f"{media_type}_report__created_at") 

236 ) 

237 qs = qs.order_by( 

238 "-total_report_count", "-pending_report_count", "oldest_report_date" 

239 ) 

240 if value is None: 

241 qs = qs.filter(pending_report_count__gt=0) 

242 if value == "prev": 

243 qs = qs.filter(pending_report_count=0) 

244 

245 return qs 

246 

247 return PendingRecordCountFilter 

248 

249 

250def get_media_decision_filter(media_type: str): 

251 match media_type: 

252 case "image": 

253 MediaDecision = ImageDecision 

254 MediaDecisionThrough = ImageDecisionThrough 

255 case "audio": 

256 MediaDecision = AudioDecision 

257 MediaDecisionThrough = AudioDecisionThrough 

258 

259 class MediaDecisionFilter(admin.ListFilter): 

260 title = f"{media_type} decision" 

261 template = "admin/api/filters/media_decision.html" 

262 

263 parameter_name = "decision_id" 

264 

265 def __init__(self, request, params, *args, **kwargs): 

266 super().__init__(request, params, *args, **kwargs) 

267 if self.parameter_name in params: 

268 value = params.pop(self.parameter_name) 

269 self.used_parameters[self.parameter_name] = value 

270 

271 self.range_min = first.id if (first := MediaDecision.objects.first()) else 0 

272 self.range_max = last.id if (last := MediaDecision.objects.last()) else 0 

273 

274 def has_output(self) -> bool: 

275 """ 

276 Determine if the filter should be displayed. The filter is only 

277 displayed if there is at least one decision for the media type. 

278 """ 

279 

280 return MediaDecision.objects.exists() 

281 

282 def choices(self, changelist): 

283 """ 

284 Return two URL query strings, one for filtering by an arbitrary 

285 decision ID and for clearing the filter. 

286 """ 

287 

288 return [ 

289 changelist.get_query_string({self.parameter_name: "VALUE"}), 

290 changelist.get_query_string(remove=[self.parameter_name]), 

291 ] 

292 

293 def expected_parameters(self): 

294 return [self.parameter_name] 

295 

296 def queryset(self, request, queryset): 

297 """ 

298 Filter the query set to only show UUIDs associated with the selected 

299 media decision. 

300 """ 

301 

302 if self.value() is not None: 

303 uuids = MediaDecisionThrough.objects.filter( 

304 decision_id=self.value() 

305 ).values_list("media_obj_id") 

306 queryset = queryset.filter(media_obj_id__in=uuids) 

307 return queryset 

308 

309 def value(self): 

310 """ 

311 Parse the value from the URL query string. Any non numerical value 

312 will be treated as ``None``. Any value outside of the filter's range 

313 will also be treated as ``None``. 

314 """ 

315 

316 try: 

317 value = int(self.used_parameters.get(self.parameter_name)) 

318 if not self.range_min <= value <= self.range_max: 

319 return None 

320 return value 

321 except (TypeError, ValueError): 

322 return None 

323 

324 return MediaDecisionFilter 

325 

326 

327def get_single_bulk_moderation_filter(media_type: str): 

328 class SingleBulkModerationFilter(admin.SimpleListFilter): 

329 title = "media count" 

330 parameter_name = "media_count" 

331 

332 def lookups(self, request, model_admin): 

333 return [ 

334 ("single", "Single"), 

335 ("bulk", "Bulk"), 

336 ] 

337 

338 def queryset(self, request, queryset): 

339 conditions = { 

340 "single": {"media_count": 1}, 

341 "bulk": {"media_count__gt": 1}, 

342 } 

343 if self.value() not in conditions: 

344 return queryset 

345 

346 queryset = queryset.annotate( 

347 media_count=Count(f"{media_type}decisionthrough") 

348 ) 

349 return queryset.filter(**conditions[self.value()]) 

350 

351 return SingleBulkModerationFilter 

352 

353 

354class BulkModerationMixin: 

355 def has_bulk_mod_permission(self, request): 

356 return request.user.has_perm(f"api.add_{self.media_type}decision") 

357 

358 def _bulk_mod(self, request, queryset, action: DecisionAction): 

359 """ 

360 Perform bulk moderation for the queryset as per the requested 

361 action. 

362 

363 This follows the pattern of the ``delete_selected`` action 

364 defined in ``django.contrib.admin.actions.py``. 

365 """ 

366 

367 opts = self.model._meta 

368 verbose_name_plural = opts.verbose_name_plural 

369 

370 if action == DecisionAction.MARKED_SENSITIVE: 

371 init_count = queryset.count() 

372 queryset = queryset.filter(**{f"sensitive_{self.media_type}__isnull": True}) 

373 

374 count = len(queryset) 

375 prev_count = init_count - count 

376 stats = { 

377 f"selected {verbose_name_plural}": init_count, 

378 f"{verbose_name_plural} already marked as sensitive": prev_count, 

379 } 

380 else: 

381 # No filtering is needed for any other actions. 

382 count = len(queryset) 

383 stats = {} 

384 stats[f"{verbose_name_plural} to be {action.verb}"] = count 

385 

386 if count == 0: 

387 messages.info( 

388 request, 

389 f"All selected items are already {action.verb}.", 

390 ) 

391 return None 

392 

393 if request.POST.get("post"): 

394 # The user has already confirmed so we will perform the 

395 # moderation and return ``None`` to display the change list 

396 # view again. 

397 decision = perform_moderation(request, self.media_type, queryset, action) 

398 path = reverse( 

399 f"admin:api_{self.media_type}decision_change", args=(decision.id,) 

400 ) 

401 messages.success( 

402 request, 

403 format_html( 

404 'Successfully moderated {} items via <a href="{}">decision {}</a>.', 

405 queryset.count(), 

406 path, 

407 decision.id, 

408 ), 

409 ) 

410 return None 

411 

412 objects_name = opts.verbose_name if count == 1 else opts.verbose_name_plural 

413 moderatable_objects = [ 

414 format_html( 

415 '<a href="{}">{}</a>', 

416 reverse( 

417 f"admin:api_{queryset.model._meta.model_name}_change", 

418 args=(obj.pk,), 

419 ), 

420 obj, 

421 ) 

422 for obj in queryset 

423 ] 

424 

425 context = { 

426 **self.admin_site.each_context(request), 

427 "title": "Are you sure?", 

428 "objects_name": str(objects_name), 

429 "moderatable_objects": [moderatable_objects], 

430 "stats": dict(stats).items(), 

431 "queryset": queryset, 

432 "opts": opts, 

433 "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME, 

434 "media": self.media, 

435 "decision_action": action, 

436 } 

437 

438 return TemplateResponse( 

439 request, 

440 "admin/api/bulk_moderation_confirmation.html", 

441 context, 

442 ) 

443 

444 

445class MediaListAdmin(BulkModerationMixin, admin.ModelAdmin): 

446 media_type = None 

447 

448 def __init__(self, *args, **kwargs): 

449 self.lock_manager = LockManager(self.media_type) 

450 

451 super().__init__(*args, **kwargs) 

452 

453 def get_urls(self): 

454 # Start of block lifted from Django source. 

455 from django.urls import path 

456 

457 def wrap(view): 

458 def wrapper(*args, **kwargs): 

459 return self.admin_site.admin_view(view)(*args, **kwargs) 

460 

461 wrapper.model_admin = self 

462 return update_wrapper(wrapper, view) 

463 

464 app, model = self.opts.app_label, self.opts.model_name 

465 # End of block lifted from Django source. 

466 

467 urls = super().get_urls() 

468 

469 # Using slice assignment (docs: 

470 # https://docs.python.org/3/tutorial/introduction.html#lists), 

471 # insert custom URLs at the penultimate position so that they 

472 # appear just before the catch-all view. 

473 urls[-1:-1] = [ 

474 path( 

475 "<path:object_id>/report_create/", 

476 wrap(self.report_create_view), 

477 name=f"{app}_{model}_report_create", 

478 ), 

479 path( 

480 "<path:object_id>/decision_create/", 

481 wrap(self.decision_create_view), 

482 name=f"{app}_{model}_decision_create", 

483 ), 

484 path( 

485 "<path:object_id>/lock/", 

486 wrap(self.lock_view), 

487 name=f"{app}_{model}_lock", 

488 ), 

489 ] 

490 return urls 

491 

492 @admin.display(description="Has sensitive text?", boolean=True) 

493 def has_sensitive_text(self, obj): 

494 """ 

495 Determine if the item has sensitive text. 

496 

497 If the item cannot be found in the filtered index, that means it 

498 was filtered out due to text sensitivity. 

499 

500 This is displayed both as a column in the list page as well as a 

501 read-only field in the change page. 

502 

503 :param obj: the item to check for presence of sensitive text 

504 :return: whether the item has sensitive text 

505 """ 

506 

507 filtered_index = f"{settings.MEDIA_INDEX_MAPPING[self.media_type]}-filtered" 

508 try: 

509 search = ( 

510 Search(index=filtered_index) 

511 .query("term", identifier=obj.identifier) 

512 .execute() 

513 ) 

514 if search.hits: 

515 return False 

516 except NotFoundError: 

517 logger.error("Could not resolve index.", name=filtered_index, exc_info=True) 

518 return True 

519 

520 ############# 

521 # List view # 

522 ############# 

523 

524 change_list_template = "admin/api/media/change_list.html" 

525 list_display = ("identifier",) 

526 list_display_links = ("identifier",) 

527 list_per_page = 15 

528 show_full_result_count = False 

529 search_fields = (None,) # Search functionality is overridden below. 

530 search_help_text = format_html( 

531 """ 

532 <p> 

533 You can use <a href="{}">query string syntax</a> for advanced search. 

534 You can use <code>&quot;</code> to wrap phrases and <code>?</code>, <code>*</code> as wildcards. 

535 </p> 

536 <details> 

537 <summary>Examples</summary> 

538 <ul> 

539 <li>Search all indexed fields in the model: <code>Animal</code></li> 

540 <li>Search for an identifier: <code>3b858852-67df-44e1-8d57-683991d3ec67</code></li> 

541 <li> 

542 Search for a creator: <code>creator:Al</code><br> 

543 When searching by creator, make sure to select a single provider using the filters in the sidebar. 

544 </li> 

545 <li>Search for a tag name: <code>tags.name:cat</code></li> 

546 </ul> 

547 </details> 

548 """, 

549 "https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax", 

550 ) 

551 sortable_by = () # Ordering is defined in ``get_queryset``. 

552 actions = ["marked_sensitive", "deindexed_sensitive", "deindexed_copyright"] 

553 

554 def get_list_filter(self, request): 

555 return ( 

556 get_source_filter(self.media_type), 

557 get_pending_record_filter(self.media_type), 

558 ) 

559 

560 def get_list_display(self, request): 

561 if request.GET.get("pending_record_count") == "all": 

562 return self.list_display + ( 

563 "source", 

564 "provider", 

565 ) 

566 elif request.GET.get("pending_record_count") == "prev": 

567 return self.list_display + ( 

568 "total_report_count", 

569 "oldest_report_date", 

570 "has_sensitive_text", 

571 ) 

572 else: 

573 return self.list_display + ( 

574 "total_report_count", 

575 "pending_report_count", 

576 "oldest_report_date", 

577 "pending_reports_links", 

578 "has_sensitive_text", 

579 ) 

580 

581 def get_search_results(self, request, queryset, search_term): 

582 """ 

583 Override admin search to use Elasticsearch. 

584 

585 This enables the admin to make complex search queries using 

586 query string syntax, that would not normally be possible with 

587 Django's native search. 

588 """ 

589 

590 if not search_term: 

591 return queryset, False 

592 

593 search = Search(index=settings.MEDIA_INDEX_MAPPING[self.media_type]) 

594 search = search.query("query_string", query=search_term) 

595 identifiers = (hit.identifier for hit in search.scan()) 

596 return queryset.filter(identifier__in=identifiers), False 

597 

598 def total_report_count(self, obj): 

599 return obj.total_report_count 

600 

601 def pending_report_count(self, obj): 

602 return obj.pending_report_count 

603 

604 def oldest_report_date(self, obj): 

605 return obj.oldest_report_date 

606 

607 @admin.display(description="Open reports") 

608 def pending_reports_links(self, obj): 

609 reports = getattr(obj, f"{self.media_type}_report") 

610 pending_reports = reports.filter(decision__isnull=True) 

611 data = [] 

612 for report in pending_reports.all(): 

613 path = reverse( 

614 f"admin:api_{self.media_type}report_change", args=(report.id,) 

615 ) 

616 data.append(format_html('• <a href="{}">Report {}</a>', path, report.id)) 

617 

618 return mark_safe("<br>".join(data)) 

619 

620 def changelist_view(self, request, extra_context=None): 

621 extra_context = extra_context or {} 

622 

623 extra_context["media_type"] = self.media_type 

624 

625 valid_locks = self.lock_manager.prune() 

626 locked_media = list( 

627 int(item.replace(f"{self.media_type}:", "")) 

628 for moderator, lock_set in valid_locks.items() 

629 for item in lock_set 

630 if self.media_type in item and moderator != request.user.get_username() 

631 ) 

632 extra_context["locked_media"] = locked_media 

633 

634 return super().changelist_view(request, extra_context) 

635 

636 ############### 

637 # Change view # 

638 ############### 

639 

640 change_form_template = "admin/api/media/change_form.html" 

641 readonly_fields = ( 

642 "attribution", 

643 "license_url", 

644 "has_sensitive_text", 

645 ) 

646 

647 def change_view(self, request, object_id, form_url="", extra_context=None): 

648 # Populate a warning message for locked items. 

649 mods = self.lock_manager.moderator_set(object_id) 

650 mods -= {request.user.get_username()} 

651 if len(mods): 

652 messages.warning( 

653 request, 

654 f"This {self.media_type} is also being viewed by {', '.join(mods)}.", 

655 ) 

656 

657 # Expand the context based on the template's needs. 

658 extra_context = extra_context or {} 

659 

660 extra_context["media_type"] = self.media_type 

661 

662 media_obj = self.get_object(request, object_id) 

663 if media_obj: 

664 extra_context["media_obj"] = media_obj 

665 else: 

666 messages.warning(request, f"No media object found with ID {object_id}.") 

667 return redirect(f"admin:api_{self.media_type}_changelist") 

668 

669 tags_by_provider = {} 

670 if tags := media_obj.tags: 

671 for tag in tags: 

672 text = tag["name"] 

673 if acc := tag.get("accuracy"): 

674 text = f"{text} ({acc})" 

675 tags_by_provider.setdefault( 

676 tag.get("provider", "No provider"), [] 

677 ).append(text) 

678 extra_context["tags"] = tags_by_provider 

679 

680 manager = getattr(media_obj, f"{self.media_type}decisionthrough_set") 

681 decision_throughs = manager.order_by("decision__created_on") 

682 extra_context["decision_throughs"] = decision_throughs 

683 

684 manager = getattr(media_obj, f"{self.media_type}_report") 

685 reports = manager.order_by("-created_at") 

686 extra_context["reports"] = reports 

687 

688 pending_report_count = reports.filter(decision_id=None).count() 

689 extra_context["pending_report_count"] = pending_report_count 

690 

691 extra_context["mod_form"] = get_decision_form(self.media_type)() 

692 

693 extra_context["report_form"] = get_report_form(self.media_type)() 

694 

695 return super().change_view(request, object_id, form_url, extra_context) 

696 

697 ############# 

698 # Lock view # 

699 ############# 

700 

701 def lock_view(self, request, object_id): 

702 """ 

703 Softly lock the media object with the current user to notify 

704 other moderators about a potential conflict. 

705 """ 

706 

707 if request.method == "POST": 

708 expiration = self.lock_manager.add_locks( 

709 request.user.get_username(), object_id 

710 ) 

711 return JsonResponse( 

712 data={"expiration": expiration}, 

713 status=503 if expiration == 0 else 200, 

714 ) 

715 

716 return redirect(f"admin:api_{self.media_type}_change", object_id) 

717 

718 ######################## 

719 # Decision create view # 

720 ######################## 

721 

722 def decision_create_view(self, request, object_id): 

723 """ 

724 Create a decision for the media object and associate selected 

725 reports referencing the media with this decision. 

726 """ 

727 

728 redir = redirect(f"admin:api_{self.media_type}_change", object_id) 

729 

730 if request.method != "POST": 

731 return redir 

732 

733 if not request.user.has_perm(f"api.add_{self.media_type}decision"): 

734 messages.error( 

735 request, 

736 "You do not have permission to create decisions.", 

737 ) 

738 return redir 

739 

740 media_obj = self.get_object(request, object_id) 

741 

742 through_model = { 

743 "image": ImageDecisionThrough, 

744 "audio": AudioDecisionThrough, 

745 }[self.media_type] 

746 form = get_decision_form(self.media_type)(request.POST) 

747 if form.is_valid(): 

748 decision = form.save(commit=False) 

749 decision.moderator = request.user 

750 decision.save() 

751 

752 logger.debug( 

753 "Decision created", 

754 decision=decision.id, 

755 action=decision.action, 

756 notes=decision.notes, 

757 moderator=request.user.get_username(), 

758 ) 

759 

760 through = through_model.objects.create( 

761 decision=decision, 

762 media_obj=media_obj, 

763 ) 

764 logger.debug( 

765 "Through model created", 

766 through=through.id, 

767 decision=decision.id, 

768 media_obj=media_obj.id, 

769 ) 

770 

771 reports = form.cleaned_data["reports"] 

772 count = reports.update(decision=decision) 

773 logger.debug( 

774 "Decision recorded in reports", 

775 report_count=count, 

776 decision=decision.id, 

777 ) 

778 

779 if decision.action in { 

780 DecisionAction.DEINDEXED_COPYRIGHT, 

781 DecisionAction.DEINDEXED_SENSITIVE, 

782 }: 

783 messages.debug( 

784 request, 

785 "The media object has been deindexed from ES and deleted from DB.", 

786 ) 

787 return redirect(f"admin:api_{self.media_type}_changelist") 

788 

789 if decision.action == DecisionAction.MARKED_SENSITIVE: 

790 messages.debug( 

791 request, 

792 "The media object has been marked as sensitive.", 

793 ) 

794 else: 

795 messages.warning( 

796 request, 

797 f"Form is invalid: {json.dumps(form.errors)}", 

798 ) 

799 

800 return redir 

801 

802 ###################### 

803 # Report create view # 

804 ###################### 

805 

806 def report_create_view(self, request, object_id): 

807 """Create a report for the media object.""" 

808 

809 if request.method == "POST": 

810 media_obj = self.get_object(request, object_id) 

811 

812 form = get_report_form(self.media_type)(request.POST) 

813 if form.is_valid(): 

814 report = form.save(commit=False) 

815 report.media_obj = media_obj 

816 report.save() 

817 

818 logger.debug( 

819 "Report created", 

820 report=report.id, 

821 reason=report.reason, 

822 description=report.description, 

823 media_obj=media_obj.id, 

824 ) 

825 else: 

826 messages.warning( 

827 request, 

828 f"Form is invalid: {json.dumps(form.errors)}", 

829 ) 

830 

831 return redirect(f"admin:api_{self.media_type}_change", object_id) 

832 

833 ############# 

834 # Overrides # 

835 ############# 

836 

837 def get_changelist(self, request, **kwargs): 

838 return PredeterminedOrderChangelist 

839 

840 ################ 

841 # Bulk actions # 

842 ################ 

843 

844 @admin.action( 

845 permissions=["bulk_mod"], 

846 description="Mark selected %(verbose_name_plural)s as sensitive", 

847 ) 

848 def marked_sensitive(self, request, queryset): 

849 return self._bulk_mod(request, queryset, DecisionAction.MARKED_SENSITIVE) 

850 

851 @admin.action( 

852 permissions=["bulk_mod"], 

853 description="Deindex selected %(verbose_name_plural)s (sensitive)", 

854 ) 

855 def deindexed_sensitive(self, request, queryset): 

856 return self._bulk_mod(request, queryset, DecisionAction.DEINDEXED_SENSITIVE) 

857 

858 @admin.action( 

859 permissions=["bulk_mod"], 

860 description="Deindex selected %(verbose_name_plural)s (copyright)", 

861 ) 

862 def deindexed_copyright(self, request, queryset): 

863 return self._bulk_mod(request, queryset, DecisionAction.DEINDEXED_COPYRIGHT) 

864 

865 

866class MediaReportAdmin(admin.ModelAdmin): 

867 media_type = None 

868 

869 @admin.display(description="Is pending?", boolean=True) 

870 def is_pending(self, obj): 

871 """ 

872 Set an explicit display type for the ``is_pending`` property. 

873 

874 This is required so that the property, which otherwise renders 

875 "True" or "False" strings, now renders as check/cross icons in 

876 Django Admin. 

877 """ 

878 

879 return obj.is_pending 

880 

881 ############# 

882 # List view # 

883 ############# 

884 

885 list_display = ( 

886 "id", 

887 "created_at", 

888 "reason", 

889 "description", 

890 "is_pending", 

891 "media_id", # used because ``media_obj`` does not render a link 

892 ) 

893 search_fields = ("description", *_production_deferred("media_obj__identifier")) 

894 

895 @admin.display(description="Media obj") 

896 def media_id(self, obj): 

897 path = reverse(f"admin:api_{self.media_type}_change", args=(obj.media_obj.id,)) 

898 return format_html(f'<a href="{path}">{obj.media_obj}</a>') 

899 

900 ############### 

901 # Change view # 

902 ############### 

903 

904 autocomplete_fields = ("decision", *_production_deferred("media_obj")) 

905 raw_id_fields = _non_production_deferred("media_obj") 

906 actions = None 

907 

908 def get_readonly_fields(self, request, obj=None): 

909 if obj is None: # Create form 

910 return () 

911 # These fields only make sense after a report has been created. 

912 # Hence they are only shown in the change form. 

913 return ( 

914 "created_at", 

915 "is_pending", 

916 "media_obj", 

917 ) 

918 

919 def get_exclude(self, request, obj=None): 

920 if obj is None: # Create form 

921 # The decision will be linked to the report after it has 

922 # been created, not during. 

923 return ("decision",) 

924 else: # Change form 

925 # In the change form, we do not want to allow the media 

926 # object to be changed. 

927 return ("media_obj",) 

928 

929 

930class MediaDecisionAdmin(admin.ModelAdmin): 

931 media_type = None 

932 through_model = None 

933 

934 ############### 

935 # Permissions # 

936 ############### 

937 

938 def has_add_permission(self, request) -> bool: 

939 """ 

940 Disables the "Add" form for media decisions from the admin site. 

941 

942 This hides the form even for users who are authorised to create 

943 decisions (have the permissions "api.add_imagedecision" or 

944 "api.add_audiodecision"), because decisions are supposed to be 

945 created from the media item view. 

946 """ 

947 

948 return False 

949 

950 ############# 

951 # List view # 

952 ############# 

953 

954 list_display = ( 

955 "id", 

956 "created_on", 

957 "moderator", 

958 "action", 

959 "notes", 

960 "media_ids", 

961 ) 

962 list_filter = ("moderator", "action") 

963 list_prefetch_related = ("media_objs",) 

964 search_fields = ("notes", *_production_deferred("media_objs__identifier")) 

965 

966 def get_list_filter(self, request): 

967 return (get_single_bulk_moderation_filter(self.media_type),) 

968 

969 @admin.display(description="Media objs") 

970 def media_ids(self, obj): 

971 through_objs = getattr(obj, f"{self.media_type}decisionthrough_set").all() 

972 data = [] 

973 for obj in through_objs: 

974 try: 

975 path = reverse( 

976 f"admin:api_{self.media_type}_change", args=(obj.media_obj.id,) 

977 ) 

978 data.append( 

979 format_html('• <a href="{}">{}</a>', path, obj.media_obj.identifier) 

980 ) 

981 except ObjectDoesNotExist: 

982 data.append(f"• {obj.media_obj_id}") 

983 return mark_safe("<br>".join(data)) 

984 

985 ############### 

986 # Change view # 

987 ############### 

988 

989 change_form_template = "admin/api/media_decision/change_form.html" 

990 

991 def change_view(self, request, object_id, form_url="", extra_context=None): 

992 # Expand the context based on the template's needs. 

993 extra_context = extra_context or {} 

994 

995 extra_context["media_type"] = self.media_type 

996 

997 decision_obj = self.get_object(request, object_id) 

998 if decision_obj: 

999 extra_context["decision_obj"] = decision_obj 

1000 else: 

1001 messages.warning(request, f"No media decision found with ID {object_id}.") 

1002 return redirect(f"admin:api_{self.media_type}decision_changelist") 

1003 

1004 return super().change_view(request, object_id, form_url, extra_context) 

1005 

1006 def get_readonly_fields(self, request, obj=None): 

1007 if obj is None: 

1008 return () 

1009 # These fields only make sense after a decision has been created. 

1010 # Moderator is set automatically and cannot be changed. 

1011 return ( 

1012 "created_on", 

1013 "moderator", 

1014 ) 

1015 

1016 def get_exclude(self, request, obj=None): 

1017 if obj is None: # Create form 

1018 # Moderator is set automatically and cannot be changed. 

1019 return ("moderator",) 

1020 return () 

1021 

1022 def get_inlines(self, request, obj=None): 

1023 if obj is None: 

1024 # New decision, can make changes to the media objects. 

1025 is_mutable = True 

1026 else: 

1027 # Once created, media objects associated with decisions are 

1028 # immutable. 

1029 is_mutable = False 

1030 

1031 class MediaDecisionThroughAdmin(admin.TabularInline): 

1032 model = self.through_model 

1033 extra = 1 

1034 autocomplete_fields = _production_deferred("media_obj") 

1035 raw_id_fields = _non_production_deferred("media_obj") 

1036 

1037 def get_readonly_fields(self, request, obj): 

1038 if is_mutable: 

1039 return super().get_readonly_fields(request, obj) 

1040 else: 

1041 return ("media_obj_id",) 

1042 

1043 def get_exclude(self, request, obj): 

1044 if is_mutable: 

1045 return super().get_exclude(request, obj) 

1046 else: 

1047 return ("media_obj",) 

1048 

1049 def has_add_permission(self, request, obj=None): 

1050 return is_mutable and super().has_add_permission(request, obj) 

1051 

1052 def has_change_permission(self, request, obj=None): 

1053 return is_mutable and super().has_change_permission(request, obj) 

1054 

1055 def has_delete_permission(self, request, obj=None): 

1056 return is_mutable and super().has_delete_permission(request, obj) 

1057 

1058 return (MediaDecisionThroughAdmin,) 

1059 

1060 def save_model(self, request, obj, form, change): 

1061 obj.moderator = request.user 

1062 return super().save_model(request, obj, form, change) 

1063 

1064 

1065class MediaSubreportAdmin(BulkModerationMixin, admin.ModelAdmin): 

1066 media_type = None 

1067 

1068 exclude = ("media_obj",) 

1069 ordering = ("-created_on",) 

1070 search_fields = ("media_obj__identifier",) 

1071 readonly_fields = ("media_obj_id",) 

1072 

1073 def get_list_filter(self, request): 

1074 return ( 

1075 "created_on", 

1076 get_media_decision_filter(self.media_type), 

1077 ) 

1078 

1079 def has_add_permission(self, *args, **kwargs): 

1080 # These objects are created through moderation and 

1081 # bulk-moderation operations. 

1082 return False 

1083 

1084 

1085class DeletedMediaAdmin(MediaSubreportAdmin): 

1086 actions = ["reversed_deindex"] 

1087 list_display = ("media_obj_id", "created_on") 

1088 

1089 ################ 

1090 # Bulk actions # 

1091 ################ 

1092 

1093 @admin.action( 

1094 permissions=["bulk_mod"], 

1095 description="Reindex selected %(verbose_name_plural)s", 

1096 ) 

1097 def reversed_deindex(self, request, queryset): 

1098 return self._bulk_mod(request, queryset, DecisionAction.REVERSED_DEINDEX) 

1099 

1100 

1101class SensitiveMediaAdmin(MediaSubreportAdmin): 

1102 actions = ["reversed_mark_sensitive"] 

1103 list_display = ("media_obj_id", "created_on", "is_deindexed") 

1104 

1105 @admin.display(description="Deindexed?", boolean=True) 

1106 def is_deindexed(self, obj): 

1107 try: 

1108 getattr(obj, "media_obj") 

1109 return False 

1110 except ObjectDoesNotExist: 

1111 return True 

1112 

1113 ################ 

1114 # Bulk actions # 

1115 ################ 

1116 

1117 @admin.action( 

1118 permissions=["bulk_mod"], 

1119 description="Unmark selected %(verbose_name_plural)s as sensitive", 

1120 ) 

1121 def reversed_mark_sensitive(self, request, queryset): 

1122 return self._bulk_mod(request, queryset, DecisionAction.REVERSED_MARK_SENSITIVE) 

1123 

1124 

1125class ImageReportAdmin(MediaReportAdmin): 

1126 media_type = "image" 

1127 

1128 

1129class AudioReportAdmin(MediaReportAdmin): 

1130 media_type = "audio" 

1131 

1132 

1133class ImageListViewAdmin(MediaListAdmin): 

1134 media_type = "image" 

1135 

1136 

1137class AudioListViewAdmin(MediaListAdmin): 

1138 media_type = "audio" 

1139 

1140 

1141class ImageDecisionAdmin(MediaDecisionAdmin): 

1142 media_type = "image" 

1143 through_model = ImageDecisionThrough 

1144 

1145 

1146class AudioDecisionAdmin(MediaDecisionAdmin): 

1147 media_type = "audio" 

1148 through_model = AudioDecisionThrough 

1149 

1150 

1151class SensitiveImageAdmin(SensitiveMediaAdmin): 

1152 media_type = "image" 

1153 

1154 

1155class SensitiveAudioAdmin(SensitiveMediaAdmin): 

1156 media_type = "audio" 

1157 

1158 

1159class DeletedImageAdmin(DeletedMediaAdmin): 

1160 media_type = "image" 

1161 

1162 

1163class DeletedAudioAdmin(DeletedMediaAdmin): 

1164 media_type = "audio"