Plain is headed towards 1.0! Subscribe for development updates →

  1from datetime import timedelta
  2
  3from plain import models
  4from plain.admin.cards import Card
  5from plain.admin.views import (
  6    AdminModelDetailView,
  7    AdminModelListView,
  8    AdminViewset,
  9    register_viewset,
 10)
 11from plain.http import ResponseRedirect
 12from plain.runtime import settings
 13
 14from .models import Job, JobRequest, JobResult
 15
 16
 17def _td_format(td_object):
 18    seconds = int(td_object.total_seconds())
 19    periods = [
 20        ("year", 60 * 60 * 24 * 365),
 21        ("month", 60 * 60 * 24 * 30),
 22        ("day", 60 * 60 * 24),
 23        ("hour", 60 * 60),
 24        ("minute", 60),
 25        ("second", 1),
 26    ]
 27
 28    strings = []
 29    for period_name, period_seconds in periods:
 30        if seconds > period_seconds:
 31            period_value, seconds = divmod(seconds, period_seconds)
 32            has_s = "s" if period_value > 1 else ""
 33            strings.append(f"{period_value} {period_name}{has_s}")
 34
 35    return ", ".join(strings)
 36
 37
 38class SuccessfulJobsCard(Card):
 39    title = "Successful Jobs"
 40    text = "View"
 41
 42    def get_number(self):
 43        return JobResult.query.successful().count()
 44
 45    def get_link(self):
 46        return JobResultViewset.ListView.get_view_url() + "?display=Successful"
 47
 48
 49class ErroredJobsCard(Card):
 50    title = "Errored Jobs"
 51    text = "View"
 52
 53    def get_number(self):
 54        return JobResult.query.errored().count()
 55
 56    def get_link(self):
 57        return JobResultViewset.ListView.get_view_url() + "?display=Errored"
 58
 59
 60class LostJobsCard(Card):
 61    title = "Lost Jobs"
 62    text = "View"  # TODO make not required - just an icon?
 63
 64    def get_description(self):
 65        delta = timedelta(seconds=settings.WORKER_JOBS_LOST_AFTER)
 66        return f"Jobs are considered lost after {_td_format(delta)}"
 67
 68    def get_number(self):
 69        return JobResult.query.lost().count()
 70
 71    def get_link(self):
 72        return JobResultViewset.ListView.get_view_url() + "?display=Lost"
 73
 74
 75class RetriedJobsCard(Card):
 76    title = "Retried Jobs"
 77    text = "View"  # TODO make not required - just an icon?
 78
 79    def get_number(self):
 80        return JobResult.query.retried().count()
 81
 82    def get_link(self):
 83        return JobResultViewset.ListView.get_view_url() + "?display=Retried"
 84
 85
 86class WaitingJobsCard(Card):
 87    title = "Waiting Jobs"
 88
 89    def get_number(self):
 90        return Job.query.waiting().count()
 91
 92
 93class RunningJobsCard(Card):
 94    title = "Running Jobs"
 95
 96    def get_number(self):
 97        return Job.query.running().count()
 98
 99
100@register_viewset
101class JobRequestViewset(AdminViewset):
102    class ListView(AdminModelListView):
103        nav_section = "Worker"
104        nav_icon = "gear"
105        model = JobRequest
106        title = "Job requests"
107        fields = ["id", "job_class", "priority", "created_at", "start_at", "unique_key"]
108        actions = ["Delete"]
109
110        def perform_action(self, action: str, target_ids: list):
111            if action == "Delete":
112                JobRequest.query.filter(id__in=target_ids).delete()
113
114    class DetailView(AdminModelDetailView):
115        model = JobRequest
116        title = "Job Request"
117
118
119@register_viewset
120class JobViewset(AdminViewset):
121    class ListView(AdminModelListView):
122        nav_section = "Worker"
123        nav_icon = "gear"
124        model = Job
125        fields = [
126            "id",
127            "job_class",
128            "priority",
129            "created_at",
130            "started_at",
131            "unique_key",
132        ]
133        actions = ["Delete"]
134        cards = [
135            WaitingJobsCard,
136            RunningJobsCard,
137        ]
138
139        def perform_action(self, action: str, target_ids: list):
140            if action == "Delete":
141                Job.query.filter(id__in=target_ids).delete()
142
143    class DetailView(AdminModelDetailView):
144        model = Job
145
146
147@register_viewset
148class JobResultViewset(AdminViewset):
149    class ListView(AdminModelListView):
150        nav_section = "Worker"
151        nav_icon = "gear"
152        model = JobResult
153        title = "Job results"
154        fields = [
155            "id",
156            "job_class",
157            "priority",
158            "created_at",
159            "status",
160            "retried",
161            "is_retry",
162        ]
163        search_fields = [
164            "uuid",
165            "job_uuid",
166            "job_request_uuid",
167            "job_class",
168        ]
169        cards = [
170            SuccessfulJobsCard,
171            ErroredJobsCard,
172            LostJobsCard,
173            RetriedJobsCard,
174        ]
175        filters = [
176            "Successful",
177            "Errored",
178            "Cancelled",
179            "Lost",
180            "Retried",
181        ]
182        actions = [
183            "Retry",
184        ]
185        allow_global_search = False
186
187        def get_initial_queryset(self):
188            queryset = super().get_initial_queryset()
189            queryset = queryset.annotate(
190                retried=models.Case(
191                    models.When(retry_job_request_uuid__isnull=False, then=True),
192                    default=False,
193                    output_field=models.BooleanField(),
194                ),
195                is_retry=models.Case(
196                    models.When(retry_attempt__gt=0, then=True),
197                    default=False,
198                    output_field=models.BooleanField(),
199                ),
200            )
201            if self.display == "Successful":
202                return queryset.successful()
203            if self.display == "Errored":
204                return queryset.errored()
205            if self.display == "Cancelled":
206                return queryset.cancelled()
207            if self.display == "Lost":
208                return queryset.lost()
209            if self.display == "Retried":
210                return queryset.retried()
211            return queryset
212
213        def get_fields(self):
214            fields = super().get_fields()
215            if self.display == "Retried":
216                fields.append("retries")
217                fields.append("retry_attempt")
218            return fields
219
220        def perform_action(self, action: str, target_ids: list):
221            if action == "Retry":
222                for result in JobResult.query.filter(id__in=target_ids):
223                    result.retry_job(delay=0)
224            else:
225                raise ValueError("Invalid action")
226
227    class DetailView(AdminModelDetailView):
228        model = JobResult
229        title = "Job result"
230
231        def post(self):
232            self.object.retry_job(delay=0)
233            return ResponseRedirect(".")