• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

EsupPortail / Esup-Pod / 26805325804

02 Jun 2026 07:32AM UTC coverage: 69.176%. First build
26805325804

Pull #1454

github

web-flow
Merge 284ffcac1 into 05026e2ce
Pull Request #1454: Security Hardening and Priority-User Support for Encoding/Transcript workflows

406 of 582 new or added lines in 14 files covered. (69.76%)

13618 of 19686 relevant lines covered (69.18%)

0.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.84
/pod/video_encode_transcript/admin.py
1
import requests
1✔
2
from django.contrib import admin, messages
1✔
3
from django.contrib.sites.models import Site
1✔
4
from django.contrib.sites.shortcuts import get_current_site
1✔
5
from django.core.exceptions import PermissionDenied
1✔
6
from django.http import HttpResponseRedirect
1✔
7
from django.urls import path, reverse
1✔
8
from django.utils import timezone
1✔
9
from django.utils.html import format_html
1✔
10
from django.utils.text import Truncator
1✔
11
from django.utils.translation import gettext_lazy as _
1✔
12

13
from pod.video.models import Video
1✔
14

15
from .models import (
1✔
16
    EncodingAudio,
17
    EncodingLog,
18
    EncodingStep,
19
    EncodingVideo,
20
    PlaylistVideo,
21
    PriorityUser,
22
    RunnerManager,
23
    Task,
24
    VideoRendition,
25
)
26
from .task_queue import refresh_pending_task_ranks
1✔
27

28
RUNNER_MANAGER_SOURCE_APP_LABEL = "video_encode_transcript"
1✔
29
RUNNER_MANAGER_SECTION_APP_LABEL = "runner_managers"
1✔
30
RUNNER_MANAGER_SECTION_MODEL_NAMES = {"RunnerManager", "Task", "PriorityUser"}
1✔
31
RUNNER_MANAGER_PRIMARY_MODEL_NAME = "RunnerManager"
1✔
32

33

34
def _admin_or_add_url(model):
1✔
35
    """Return a model admin URL fallbacking to the add URL."""
36
    return model.get("admin_url") or model.get("add_url")
1✔
37

38

39
def _extract_runner_manager_models_from_app(app, section_url):
1✔
40
    """Split a source app into remaining models and runner manager models."""
41
    runner_manager_models = []
1✔
42
    remaining_models = []
1✔
43
    extracted_section_url = section_url
1✔
44

45
    for model in app["models"]:
1✔
46
        if model["object_name"] in RUNNER_MANAGER_SECTION_MODEL_NAMES:
1✔
47
            runner_manager_models.append(model)
1✔
48
            if extracted_section_url is None:
1✔
49
                extracted_section_url = _admin_or_add_url(model)
1✔
50
            continue
1✔
51
        remaining_models.append(model)
1✔
52

53
    extracted_section_url = extracted_section_url or app.get("app_url")
1✔
54
    remaining_app = {**app, "models": remaining_models} if remaining_models else None
1✔
55
    return remaining_app, runner_manager_models, extracted_section_url
1✔
56

57

58
def _resolve_runner_manager_section_url(runner_manager_models, fallback_url):
1✔
59
    """Prefer the RunnerManager changelist URL for the section header link."""
60
    primary_model = next(
1✔
61
        (
62
            model
63
            for model in runner_manager_models
64
            if model["object_name"] == RUNNER_MANAGER_PRIMARY_MODEL_NAME
65
        ),
66
        None,
67
    )
68
    if primary_model is None:
1✔
NEW
69
        return fallback_url
×
70
    return _admin_or_add_url(primary_model) or fallback_url
1✔
71

72

73
def _build_runner_manager_section_entry(runner_manager_models, section_url):
1✔
74
    """Create the synthetic admin section for runner manager objects."""
75
    return {
1✔
76
        "name": _("Runner managers"),
77
        "app_label": RUNNER_MANAGER_SECTION_APP_LABEL,
78
        "app_url": section_url or reverse("admin:index"),
79
        "has_module_perms": True,
80
        "models": runner_manager_models,
81
    }
82

83

84
def _remove_runner_manager_models_from_source_app_list(app_list):
1✔
85
    """Remove runner manager models from the source app section."""
86
    filtered_app_list = []
1✔
87
    for app in app_list:
1✔
88
        if app["app_label"] != RUNNER_MANAGER_SOURCE_APP_LABEL:
1✔
NEW
89
            filtered_app_list.append(app)
×
NEW
90
            continue
×
91

92
        remaining_models = [
1✔
93
            model
94
            for model in app["models"]
95
            if model["object_name"] not in RUNNER_MANAGER_SECTION_MODEL_NAMES
96
        ]
97
        if remaining_models:
1✔
98
            filtered_app_list.append({**app, "models": remaining_models})
1✔
99
    return filtered_app_list
1✔
100

101

102
def _build_runner_manager_admin_section(app_list):
1✔
103
    """Move runner manager models from video encoding app to a dedicated section."""
104
    reorganized_apps = []
1✔
105
    runner_manager_models = []
1✔
106
    runner_manager_section_url = None
1✔
107

108
    for app in app_list:
1✔
109
        if app["app_label"] != RUNNER_MANAGER_SOURCE_APP_LABEL:
1✔
110
            reorganized_apps.append(app)
1✔
111
            continue
1✔
112

113
        (
1✔
114
            remaining_app,
115
            extracted_models,
116
            runner_manager_section_url,
117
        ) = _extract_runner_manager_models_from_app(app, runner_manager_section_url)
118
        runner_manager_models.extend(extracted_models)
1✔
119
        if remaining_app:
1✔
120
            reorganized_apps.append(remaining_app)
1✔
121

122
    if not runner_manager_models:
1✔
NEW
123
        return app_list
×
124

125
    runner_manager_models.sort(key=lambda model: model["name"])
1✔
126
    runner_manager_section_url = _resolve_runner_manager_section_url(
1✔
127
        runner_manager_models,
128
        runner_manager_section_url,
129
    )
130
    reorganized_apps.append(
1✔
131
        _build_runner_manager_section_entry(
132
            runner_manager_models,
133
            runner_manager_section_url,
134
        )
135
    )
136
    reorganized_apps.sort(key=lambda app: app["name"].lower())
1✔
137
    return reorganized_apps
1✔
138

139

140
if not hasattr(admin.AdminSite, "_runner_manager_original_get_app_list"):
1✔
141
    # Keep a stable pointer to Django's original implementation so this
142
    # module can safely wrap it without losing baseline admin behavior.
143
    # The hasattr guard also prevents stacking wrappers on autoreload/imports.
144
    admin.AdminSite._runner_manager_original_get_app_list = admin.AdminSite.get_app_list
1✔
145

146
    def _runner_manager_grouped_get_app_list(self, request, app_label=None):
1✔
147
        """Customize admin sections on dashboard and source app index."""
148
        app_list = self._runner_manager_original_get_app_list(request, app_label)
1✔
149
        # On the source app page, hide runner-manager-related models from the
150
        # original section to avoid duplicate navigation entries.
151
        if app_label == RUNNER_MANAGER_SOURCE_APP_LABEL:
1✔
152
            return _remove_runner_manager_models_from_source_app_list(app_list)
1✔
153
        # For other app-specific pages, keep Django's default app list.
154
        if app_label:
1✔
NEW
155
            return app_list
×
156
        # On the admin index, build the synthetic "Runner managers" section.
157
        return _build_runner_manager_admin_section(app_list)
1✔
158

159
    admin.AdminSite.get_app_list = _runner_manager_grouped_get_app_list
1✔
160

161

162
@admin.register(EncodingVideo)
1✔
163
class EncodingVideoAdmin(admin.ModelAdmin):
1✔
164
    """Admin model for EncodingVideo."""
165

166
    list_display = ("video", "get_resolution", "encoding_format")
1✔
167
    list_filter = ["encoding_format", "rendition"]
1✔
168
    search_fields = ["id", "video__id", "video__title"]
1✔
169

170
    @admin.display(description=_("resolution"))
1✔
171
    def get_resolution(self, obj):
1✔
172
        """Get the resolution of the video rendition."""
173
        return obj.rendition.resolution
×
174

175
    def get_queryset(self, request):
1✔
176
        """Get the queryset based on the request."""
177
        qs = super().get_queryset(request)
×
178
        if not request.user.is_superuser:
×
179
            qs = qs.filter(video__sites=get_current_site(request))
×
180
        return qs
×
181

182
    def formfield_for_foreignkey(self, db_field, request, **kwargs):
1✔
183
        """Customize the form field for foreign keys."""
184
        if (db_field.name) == "video":
×
185
            kwargs["queryset"] = Video.objects.filter(sites=Site.objects.get_current())
×
186
        if (db_field.name) == "rendition":
×
187
            kwargs["queryset"] = VideoRendition.objects.filter(
×
188
                sites=Site.objects.get_current()
189
            )
190
        return super().formfield_for_foreignkey(db_field, request, **kwargs)
×
191

192

193
@admin.register(EncodingAudio)
1✔
194
class EncodingAudioAdmin(admin.ModelAdmin):
1✔
195
    """Admin model for EncodingAudio."""
196

197
    list_display = ("video", "encoding_format")
1✔
198
    list_filter = ["encoding_format"]
1✔
199
    search_fields = ["id", "video__id", "video__title"]
1✔
200

201
    def get_queryset(self, request):
1✔
202
        """Get the queryset based on the request."""
203
        qs = super().get_queryset(request)
×
204
        if not request.user.is_superuser:
×
205
            qs = qs.filter(video__sites=get_current_site(request))
×
206
        return qs
×
207

208
    def formfield_for_foreignkey(self, db_field, request, **kwargs):
1✔
209
        """Customize the form field for foreign keys."""
210
        if (db_field.name) == "video":
×
211
            kwargs["queryset"] = Video.objects.filter(sites=Site.objects.get_current())
×
212
        return super().formfield_for_foreignkey(db_field, request, **kwargs)
×
213

214

215
@admin.register(EncodingLog)
1✔
216
class EncodingLogAdmin(admin.ModelAdmin):
1✔
217
    """Admin model for EncodingLog."""
218

219
    def video_id(self, obj):
1✔
220
        """Get the video ID."""
221
        return obj.video.id
×
222

223
    list_display = (
1✔
224
        "id",
225
        "video_id",
226
        "video",
227
    )
228
    readonly_fields = ("video", "log")
1✔
229
    search_fields = ["id", "video__id", "video__title"]
1✔
230

231
    def get_queryset(self, request):
1✔
232
        """Get the queryset based on the request."""
233
        qs = super().get_queryset(request)
×
234
        if not request.user.is_superuser:
×
235
            qs = qs.filter(video__sites=get_current_site(request))
×
236
        return qs
×
237

238

239
@admin.register(EncodingStep)
1✔
240
class EncodingStepAdmin(admin.ModelAdmin):
1✔
241
    """Admin model for EncodingStep."""
242

243
    list_display = ("video", "num_step", "desc_step")
1✔
244
    readonly_fields = ("video", "num_step", "desc_step")
1✔
245
    search_fields = ["id", "video__id", "video__title"]
1✔
246

247
    def get_queryset(self, request):
1✔
248
        """Get the queryset based on the request."""
249
        qs = super().get_queryset(request)
×
250
        if not request.user.is_superuser:
×
251
            qs = qs.filter(video__sites=get_current_site(request))
×
252
        return qs
×
253

254

255
@admin.register(VideoRendition)
1✔
256
class VideoRenditionAdmin(admin.ModelAdmin):
1✔
257
    """Admin model for VideoRendition."""
258

259
    list_display = (
1✔
260
        "resolution",
261
        "video_bitrate",
262
        "audio_bitrate",
263
        "encode_mp4",
264
    )
265

266
    def get_form(self, request, obj=None, **kwargs):
1✔
267
        """Get the form to be used in the admin."""
268
        if not request.user.is_superuser:
×
269
            exclude = ()
×
270
            exclude += ("sites",)
×
271
            self.exclude = exclude
×
272
        form = super(VideoRenditionAdmin, self).get_form(request, obj, **kwargs)
×
273
        return form
×
274

275
    def save_model(self, request, obj, form, change):
1✔
276
        """Save the VideoRendition model."""
277
        super().save_model(request, obj, form, change)
×
278
        if not change:
×
279
            obj.sites.add(get_current_site(request))
×
280
            obj.save()
×
281

282
    def get_queryset(self, request):
1✔
283
        """Get the queryset based on the request."""
284
        qs = super().get_queryset(request)
×
285
        if not request.user.is_superuser:
×
286
            qs = qs.filter(sites=get_current_site(request))
×
287
        return qs
×
288

289

290
@admin.register(PlaylistVideo)
1✔
291
class PlaylistVideoAdmin(admin.ModelAdmin):
1✔
292
    autocomplete_fields = ["video"]
1✔
293
    list_display = ("name", "video", "encoding_format")
1✔
294
    search_fields = ["id", "video__id", "video__title"]
1✔
295
    list_filter = ["encoding_format"]
1✔
296

297
    def get_queryset(self, request):
1✔
298
        """Limit queryset to objects linked to the current site for non-superusers."""
299
        qs = super().get_queryset(request)
×
300
        if not request.user.is_superuser:
×
301
            qs = qs.filter(video__sites=get_current_site(request))
×
302
        return qs
×
303

304
    def formfield_for_foreignkey(self, db_field, request, **kwargs):
1✔
305
        """Restrict selectable videos to those available on the current site."""
306
        if (db_field.name) == "video":
×
307
            kwargs["queryset"] = Video.objects.filter(sites=Site.objects.get_current())
×
308

309
        return super().formfield_for_foreignkey(db_field, request, **kwargs)
×
310

311

312
@admin.register(RunnerManager)
1✔
313
class RunnerManagerAdmin(admin.ModelAdmin):
1✔
314
    """Administration for runner managers.
315

316
    Args:
317
        admin (ModelAdmin): admin model
318
    """
319

320
    change_list_template = "admin_runnermanager_change_list.html"
1✔
321
    change_form_template = "admin_test_connection.html"
1✔
322

323
    list_display = (
1✔
324
        "id",
325
        "name",
326
        "active_badge",
327
        "priority",
328
        "url",
329
        "runner_admin_link",
330
        "site",
331
    )
332
    list_display_links = ("id", "name")
1✔
333
    ordering = ("-id", "priority")
1✔
334
    readonly_fields = []
1✔
335
    search_fields = ["id", "name", "site"]
1✔
336
    list_filter = ("is_active", "site")
1✔
337

338
    def get_urls(self):
1✔
339
        """Register the custom admin endpoint used to test runner connectivity."""
340
        custom_urls = [
1✔
341
            path(
342
                "<path:object_id>/test-connection/",
343
                self.admin_site.admin_view(self.test_connection_view),
344
                name="video_encode_transcript_runnermanager_test_connection",
345
            ),
346
        ]
347
        return custom_urls + super().get_urls()
1✔
348

349
    def changelist_view(self, request, extra_context=None):
1✔
350
        """Hide the source app index link from the runner manager changelist sidebar."""
351
        response = super().changelist_view(request, extra_context=extra_context)
1✔
352
        context_data = getattr(response, "context_data", None)
1✔
353
        if context_data is None:
1✔
NEW
354
            return response
×
355

356
        context_data["available_apps"] = [
1✔
357
            app
358
            for app in context_data.get("available_apps", [])
359
            if app.get("app_label") != RUNNER_MANAGER_SOURCE_APP_LABEL
360
        ]
361
        return response
1✔
362

363
    def _health_url(self, runner_manager: RunnerManager) -> str:
1✔
364
        """Build runner manager health endpoint URL."""
365
        return (
1✔
366
            runner_manager.url + "manager/health"
367
            if runner_manager.url.endswith("/")
368
            else runner_manager.url + "/manager/health"
369
        )
370

371
    def _auth_headers(self, runner_manager: RunnerManager) -> dict[str, str]:
1✔
372
        """Build headers used for the runner manager availability check."""
373
        return {
1✔
374
            "Accept": "application/json",
375
            "Authorization": f"Bearer {runner_manager.token}",
376
        }
377

378
    def _change_url(self, runner_manager: RunnerManager) -> str:
1✔
379
        """Build the admin change URL for a runner manager instance."""
380
        return reverse(
1✔
381
            "admin:video_encode_transcript_runnermanager_change",
382
            args=[runner_manager.pk],
383
        )
384

385
    def _runner_admin_url(self, runner_manager: RunnerManager) -> str:
1✔
386
        """Build runner manager admin URL, handling optional trailing slash."""
387
        return f"{runner_manager.url.rstrip('/')}/admin"
1✔
388

389
    @admin.display(description=_("Status"), ordering="is_active")
1✔
390
    def active_badge(self, obj):
1✔
391
        """Render runner manager activation status with a colored badge."""
392
        if obj.is_active:
1✔
393
            return format_html('<span class="badge bg-success">{}</span>', _("Active"))
1✔
394
        return format_html('<span class="badge bg-secondary">{}</span>', _("Inactive"))
1✔
395

396
    @admin.display(description=_("Runner administration"))
1✔
397
    def runner_admin_link(self, obj):
1✔
398
        """Render link to the remote runner manager administration."""
399
        return format_html(
1✔
400
            '<a class="runner-admin-list-link" href="{}" target="_blank" rel="noopener noreferrer" title="{}">'
401
            '<i class="bi bi-box-arrow-up-right" aria-hidden="true"></i>'
402
            '<span class="visually-hidden">{}</span>'
403
            "</a>",
404
            self._runner_admin_url(obj),
405
            _("Open runner administration"),
406
            _("Open runner administration"),
407
        )
408

409
    def test_connection_view(self, request, object_id):
1✔
410
        """Call the runner health endpoint and show the result in admin messages."""
411
        runner_manager = self.get_object(request, object_id)
1✔
412
        if runner_manager is None:
1✔
413
            self.message_user(
×
414
                request,
415
                _("Runner manager not found."),
416
                level=messages.ERROR,
417
            )
418
            return HttpResponseRedirect(
×
419
                reverse("admin:video_encode_transcript_runnermanager_changelist")
420
            )
421
        if not self.has_change_permission(request, runner_manager):
1✔
422
            raise PermissionDenied
×
423

424
        health_url = self._health_url(runner_manager)
1✔
425
        try:
1✔
426
            response = requests.get(
1✔
427
                health_url,
428
                headers=self._auth_headers(runner_manager),
429
                timeout=15,
430
            )
431
        except requests.RequestException as exc:
1✔
432
            self.message_user(
1✔
433
                request,
434
                _(
435
                    "Unable to reach runner manager '%(name)s' at %(url)s. "
436
                    "Check the URL and network access. Error: %(error)s"
437
                )
438
                % {
439
                    "name": runner_manager.name,
440
                    "url": runner_manager.url,
441
                    "error": str(exc),
442
                },
443
                level=messages.ERROR,
444
            )
445
            return HttpResponseRedirect(self._change_url(runner_manager))
1✔
446

447
        if response.status_code in (401, 403):
1✔
448
            self.message_user(
1✔
449
                request,
450
                _(
451
                    "Runner manager '%(name)s' responded but rejected authentication "
452
                    "(HTTP %(status)s). Check the bearer token."
453
                )
454
                % {"name": runner_manager.name, "status": response.status_code},
455
                level=messages.ERROR,
456
            )
457
        elif response.status_code in (200, 204):
1✔
458
            self.message_user(
1✔
459
                request,
460
                _(
461
                    "Connection to runner manager '%(name)s' succeeded "
462
                    "(HTTP %(status)s)."
463
                )
464
                % {"name": runner_manager.name, "status": response.status_code},
465
                level=messages.SUCCESS,
466
            )
467
        elif response.status_code == 404:
×
468
            self.message_user(
×
469
                request,
470
                _(
471
                    "Runner manager '%(name)s' is reachable but endpoint %(url)s "
472
                    "was not found (HTTP 404). Check the configured URL."
473
                )
474
                % {"name": runner_manager.name, "url": health_url},
475
                level=messages.ERROR,
476
            )
477
        else:
478
            self.message_user(
×
479
                request,
480
                _(
481
                    "Runner manager '%(name)s' is reachable but returned an "
482
                    "unexpected response (HTTP %(status)s)."
483
                )
484
                % {"name": runner_manager.name, "status": response.status_code},
485
                level=messages.WARNING,
486
            )
487

488
        return HttpResponseRedirect(self._change_url(runner_manager))
1✔
489

490

491
@admin.register(PriorityUser)
1✔
492
class PriorityUserAdmin(admin.ModelAdmin):
1✔
493
    """Administration for users with absolute queue priority."""
494

495
    change_list_template = "admin_priorityuser_change_list.html"
1✔
496
    autocomplete_fields = ("user",)
1✔
497
    list_display = ("id", "user", "date_added")
1✔
498
    search_fields = (
1✔
499
        "user__username",
500
        "user__email",
501
        "user__first_name",
502
        "user__last_name",
503
    )
504
    readonly_fields = ("date_added",)
1✔
505
    list_select_related = ("user",)
1✔
506
    ordering = ("user__username", "id")
1✔
507

508

509
@admin.register(Task)
1✔
510
class TaskAdmin(admin.ModelAdmin):
1✔
511
    """Administration for runner manager tasks.
512

513
    Args:
514
        admin (ModelAdmin): admin model
515
    """
516

517
    change_list_template = "admin_task_change_list.html"
1✔
518
    list_display = (
1✔
519
        "id",
520
        "video_id_display",
521
        "video_label",
522
        "recording_id_display",
523
        "recording_label",
524
        "type",
525
        "status_badge",
526
        "task_id",
527
        "date_added",
528
        "runner_manager",
529
    )
530
    list_display_links = ("id",)
1✔
531
    ordering = ("-id",)
1✔
532
    readonly_fields = ["date_added"]
1✔
533
    fields = (
1✔
534
        "type",
535
        "status",
536
        "date_added",
537
        "task_id",
538
        "video",
539
        "recording",
540
        "runner_manager",
541
        "script_output",
542
    )
543
    search_fields = [
1✔
544
        "id",
545
        "task_id",
546
        "video__id",
547
        "video__title",
548
        "recording__id",
549
        "recording__title",
550
        "runner_manager__name",
551
    ]
552
    actions = ["relaunch_selected_tasks"]
1✔
553

554
    def get_readonly_fields(self, request, obj=None):
1✔
555
        """Keep type and status immutable after task creation."""
556
        if obj is None:
×
557
            return self.readonly_fields
×
558
        return [*self.readonly_fields, "type", "status"]
×
559

560
    def _truncate_label(self, label):
1✔
561
        """Return a short label for list display."""
562
        return Truncator(label).chars(50)
×
563

564
    @admin.display(description=_("Video ID"), ordering="video__id")
1✔
565
    def video_id_display(self, obj):
1✔
566
        """Display the related video identifier, or '-' when absent."""
567
        if not obj.video_id:
×
568
            return "-"
×
569
        return obj.video_id
×
570

571
    @admin.display(description=_("Video"), ordering="video__title")
1✔
572
    def video_label(self, obj):
1✔
573
        """Display a truncated video title, or '-' when no video is linked."""
574
        if not obj.video_id:
×
575
            return "-"
×
576
        return self._truncate_label(obj.video.title)
×
577

578
    @admin.display(description=_("Recording ID"), ordering="recording__id")
1✔
579
    def recording_id_display(self, obj):
1✔
580
        """Display the related recording identifier, or '-' when absent."""
581
        if not obj.recording_id:
×
582
            return "-"
×
583
        return obj.recording_id
×
584

585
    @admin.display(description=_("Recording"), ordering="recording__title")
1✔
586
    def recording_label(self, obj):
1✔
587
        """Display a truncated recording title, or '-' when not linked."""
588
        if not obj.recording_id:
×
589
            return "-"
×
590
        return self._truncate_label(obj.recording.title)
×
591

592
    @admin.display(description=_("Status"), ordering="status")
1✔
593
    def status_badge(self, obj):
1✔
594
        """Render task status with a colored badge in list display."""
595
        badge_map = {
×
596
            "pending": "bg-secondary",
597
            "running": "bg-warning text-dark",
598
            "failed": "bg-danger",
599
            "timeout": "bg-danger",
600
            "completed": "bg-success",
601
        }
602
        badge_class = badge_map.get(obj.status, "bg-secondary")
×
603
        status_label = obj.get_status_display()
×
604
        return format_html(
×
605
            '<span class="badge {}">{}</span>',
606
            badge_class,
607
            status_label,
608
        )
609

610
    @admin.action(description=_("Restart selected tasks"))
1✔
611
    def relaunch_selected_tasks(self, request, queryset):
1✔
612
        """Reset selected tasks and relaunch one job per unique source."""
613
        from .runner_manager import (
×
614
            encode_studio_recording,
615
            encode_video,
616
            transcript_video,
617
        )
618

619
        relaunched_count = 0
×
620
        skipped_count = 0
×
621
        launched_sources = set()
×
622

623
        for task in queryset:
×
624
            source_key = (task.type, task.video_id, task.recording_id)
×
625
            if source_key in launched_sources:
×
626
                skipped_count += 1
×
627
                continue
×
628

629
            # Force selected task as pending so runner_manager helpers update this row
630
            # instead of creating a new pending task.
631
            task.status = "pending"
×
632
            task.task_id = None
×
633
            task.runner_manager = None
×
634
            task.rank = None
×
635
            task.script_output = None
×
636
            task.date_added = timezone.now()
×
637
            task.save(
×
638
                update_fields=[
639
                    "status",
640
                    "task_id",
641
                    "runner_manager",
642
                    "rank",
643
                    "script_output",
644
                    "date_added",
645
                ]
646
            )
647

648
            if task.type == "encoding" and task.video_id:
×
649
                encode_video(task.video_id)
×
650
            elif task.type == "transcription" and task.video_id:
×
651
                transcript_video(task.video_id)
×
652
            elif task.type == "studio" and task.recording_id:
×
653
                encode_studio_recording(task.recording_id)
×
654
            else:
655
                skipped_count += 1
×
656
                continue
×
657

658
            launched_sources.add(source_key)
×
659
            relaunched_count += 1
×
660

661
        refresh_pending_task_ranks()
×
662
        self.message_user(
×
663
            request,
664
            _("%(count)s task(s) relaunched immediately.") % {"count": relaunched_count},
665
            level=messages.SUCCESS,
666
        )
667
        if skipped_count:
×
668
            self.message_user(
×
669
                request,
670
                _("%(count)s task(s) skipped (duplicate or missing source).")
671
                % {"count": skipped_count},
672
                level=messages.WARNING,
673
            )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc