Closes #10945: Enable recurring execution of scheduled reports & scripts (#11096)

* Add interval to JobResult

* Accept a recurrence interval when executing scripts & reports

* Cleaned up jobs list display

* Schedule next job only if a reference start time can be determined

* Improve validation for scheduled jobs
This commit is contained in:
Jeremy Stretch 2022-12-08 18:17:13 -05:00 committed by GitHub
parent 62b0f034e7
commit 4297c65f87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 180 additions and 81 deletions

View File

@ -99,9 +99,9 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Site model = Site
fields = ( fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns', 'asn_count', 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude',
'contacts', 'tags', 'created', 'last_updated', 'actions', 'comments', 'contacts', 'tags', 'created', 'last_updated', 'actions',
) )
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')

View File

@ -385,8 +385,8 @@ class JobResultSerializer(BaseModelSerializer):
class Meta: class Meta:
model = JobResult model = JobResult
fields = [ fields = [
'id', 'url', 'display', 'status', 'created', 'scheduled', 'started', 'completed', 'name', 'obj_type', 'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
'user', 'data', 'job_id', 'obj_type', 'user', 'data', 'job_id',
] ]
@ -414,6 +414,7 @@ class ReportDetailSerializer(ReportSerializer):
class ReportInputSerializer(serializers.Serializer): class ReportInputSerializer(serializers.Serializer):
schedule_at = serializers.DateTimeField(required=False, allow_null=True) schedule_at = serializers.DateTimeField(required=False, allow_null=True)
interval = serializers.IntegerField(required=False, allow_null=True)
# #
@ -448,6 +449,7 @@ class ScriptInputSerializer(serializers.Serializer):
data = serializers.JSONField() data = serializers.JSONField()
commit = serializers.BooleanField() commit = serializers.BooleanField()
schedule_at = serializers.DateTimeField(required=False, allow_null=True) schedule_at = serializers.DateTimeField(required=False, allow_null=True)
interval = serializers.IntegerField(required=False, allow_null=True)
class ScriptLogMessageSerializer(serializers.Serializer): class ScriptLogMessageSerializer(serializers.Serializer):

View File

@ -1,5 +1,4 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.http import Http404 from django.http import Http404
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
@ -246,16 +245,14 @@ class ReportViewSet(ViewSet):
input_serializer = serializers.ReportInputSerializer(data=request.data) input_serializer = serializers.ReportInputSerializer(data=request.data)
if input_serializer.is_valid(): if input_serializer.is_valid():
schedule_at = input_serializer.validated_data.get('schedule_at')
report_content_type = ContentType.objects.get(app_label='extras', model='report')
job_result = JobResult.enqueue_job( job_result = JobResult.enqueue_job(
run_report, run_report,
report.full_name, name=report.full_name,
report_content_type, obj_type=ContentType.objects.get_for_model(Report),
request.user, user=request.user,
job_timeout=report.job_timeout, job_timeout=report.job_timeout,
schedule_at=schedule_at, schedule_at=input_serializer.validated_data.get('schedule_at'),
interval=input_serializer.validated_data.get('interval')
) )
report.result = job_result report.result = job_result
@ -329,21 +326,17 @@ class ScriptViewSet(ViewSet):
raise RQWorkerNotRunningException() raise RQWorkerNotRunningException()
if input_serializer.is_valid(): if input_serializer.is_valid():
data = input_serializer.data['data']
commit = input_serializer.data['commit']
schedule_at = input_serializer.validated_data.get('schedule_at')
script_content_type = ContentType.objects.get(app_label='extras', model='script')
job_result = JobResult.enqueue_job( job_result = JobResult.enqueue_job(
run_script, run_script,
script.full_name, name=script.full_name,
script_content_type, obj_type=ContentType.objects.get_for_model(Script),
request.user, user=request.user,
data=data, data=input_serializer.data['data'],
request=copy_safe_request(request), request=copy_safe_request(request),
commit=commit, commit=input_serializer.data['commit'],
job_timeout=script.job_timeout, job_timeout=script.job_timeout,
schedule_at=schedule_at, schedule_at=input_serializer.validated_data.get('schedule_at'),
interval=input_serializer.validated_data.get('interval')
) )
script.result = job_result script.result = job_result
serializer = serializers.ScriptDetailSerializer(script, context={'request': request}) serializer = serializers.ScriptDetailSerializer(script, context={'request': request})

View File

@ -148,12 +148,12 @@ class JobResultStatusChoices(ChoiceSet):
STATUS_FAILED = 'failed' STATUS_FAILED = 'failed'
CHOICES = ( CHOICES = (
(STATUS_PENDING, 'Pending'), (STATUS_PENDING, 'Pending', 'cyan'),
(STATUS_SCHEDULED, 'Scheduled'), (STATUS_SCHEDULED, 'Scheduled', 'gray'),
(STATUS_RUNNING, 'Running'), (STATUS_RUNNING, 'Running', 'blue'),
(STATUS_COMPLETED, 'Completed'), (STATUS_COMPLETED, 'Completed', 'green'),
(STATUS_ERRORED, 'Errored'), (STATUS_ERRORED, 'Errored', 'red'),
(STATUS_FAILED, 'Failed'), (STATUS_FAILED, 'Failed', 'red'),
) )
TERMINAL_STATE_CHOICES = ( TERMINAL_STATE_CHOICES = (

View File

@ -17,10 +17,10 @@ __all__ = (
'ConfigContextFilterSet', 'ConfigContextFilterSet',
'ContentTypeFilterSet', 'ContentTypeFilterSet',
'CustomFieldFilterSet', 'CustomFieldFilterSet',
'JobResultFilterSet',
'CustomLinkFilterSet', 'CustomLinkFilterSet',
'ExportTemplateFilterSet', 'ExportTemplateFilterSet',
'ImageAttachmentFilterSet', 'ImageAttachmentFilterSet',
'JobResultFilterSet',
'JournalEntryFilterSet', 'JournalEntryFilterSet',
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'ObjectChangeFilterSet', 'ObjectChangeFilterSet',
@ -537,7 +537,7 @@ class JobResultFilterSet(BaseFilterSet):
class Meta: class Meta:
model = JobResult model = JobResult
fields = ('id', 'status', 'user', 'obj_type', 'name') fields = ('id', 'interval', 'status', 'user', 'obj_type', 'name')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from utilities.forms import BootstrapMixin, DateTimePicker from utilities.forms import BootstrapMixin, DateTimePicker
@ -15,3 +16,16 @@ class ReportForm(BootstrapMixin, forms.Form):
label=_("Schedule at"), label=_("Schedule at"),
help_text=_("Schedule execution of report to a set time"), help_text=_("Schedule execution of report to a set time"),
) )
interval = forms.IntegerField(
required=False,
min_value=1,
label=_("Recurs every"),
help_text=_("Interval at which this report is re-run (in minutes)")
)
def clean_schedule_at(self):
scheduled_time = self.cleaned_data['schedule_at']
if scheduled_time and scheduled_time < timezone.now():
raise forms.ValidationError(_('Scheduled time must be in the future.'))
return scheduled_time

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from utilities.forms import BootstrapMixin, DateTimePicker from utilities.forms import BootstrapMixin, DateTimePicker
@ -21,19 +22,36 @@ class ScriptForm(BootstrapMixin, forms.Form):
label=_("Schedule at"), label=_("Schedule at"),
help_text=_("Schedule execution of script to a set time"), help_text=_("Schedule execution of script to a set time"),
) )
_interval = forms.IntegerField(
required=False,
min_value=1,
label=_("Recurs every"),
help_text=_("Interval at which this script is re-run (in minutes)")
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Move _commit and _schedule_at to the end of the form # Move _commit and _schedule_at to the end of the form
schedule_at = self.fields.pop('_schedule_at') schedule_at = self.fields.pop('_schedule_at')
interval = self.fields.pop('_interval')
commit = self.fields.pop('_commit') commit = self.fields.pop('_commit')
self.fields['_schedule_at'] = schedule_at self.fields['_schedule_at'] = schedule_at
self.fields['_interval'] = interval
self.fields['_commit'] = commit self.fields['_commit'] = commit
def clean__schedule_at(self):
scheduled_time = self.cleaned_data['_schedule_at']
if scheduled_time and scheduled_time < timezone.now():
raise forms.ValidationError({
'_schedule_at': _('Scheduled time must be in the future.')
})
return scheduled_time
@property @property
def requires_input(self): def requires_input(self):
""" """
A boolean indicating whether the form requires user input (ignore the _commit and _schedule_at fields). A boolean indicating whether the form requires user input (ignore the built-in fields).
""" """
return bool(len(self.fields) > 2) return bool(len(self.fields) > 3)

View File

@ -1,3 +1,4 @@
import django.core.validators
from django.db import migrations, models from django.db import migrations, models
@ -13,6 +14,11 @@ class Migration(migrations.Migration):
name='scheduled', name='scheduled',
field=models.DateTimeField(blank=True, null=True), field=models.DateTimeField(blank=True, null=True),
), ),
migrations.AddField(
model_name='jobresult',
name='interval',
field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField( migrations.AddField(
model_name='jobresult', model_name='jobresult',
name='started', name='started',

View File

@ -7,7 +7,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.core.validators import ValidationError from django.core.validators import MinValueValidator, ValidationError
from django.db import models from django.db import models
from django.http import HttpResponse, QueryDict from django.http import HttpResponse, QueryDict
from django.urls import reverse from django.urls import reverse
@ -587,6 +587,14 @@ class JobResult(models.Model):
null=True, null=True,
blank=True blank=True
) )
interval = models.PositiveIntegerField(
blank=True,
null=True,
validators=(
MinValueValidator(1),
),
help_text=_("Recurrence interval (in minutes)")
)
started = models.DateTimeField( started = models.DateTimeField(
null=True, null=True,
blank=True blank=True
@ -635,6 +643,9 @@ class JobResult(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk]) return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
def get_status_color(self):
return JobResultStatusChoices.colors.get(self.status)
@property @property
def duration(self): def duration(self):
if not self.completed: if not self.completed:
@ -664,33 +675,32 @@ class JobResult(models.Model):
self.completed = timezone.now() self.completed = timezone.now()
@classmethod @classmethod
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, *args, **kwargs): def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
""" """
Create a JobResult instance and enqueue a job using the given callable Create a JobResult instance and enqueue a job using the given callable
func: The callable object to be enqueued for execution Args:
name: Name for the JobResult instance func: The callable object to be enqueued for execution
obj_type: ContentType to link to the JobResult instance obj_type name: Name for the JobResult instance
user: User object to link to the JobResult instance obj_type: ContentType to link to the JobResult instance obj_type
schedule_at: Schedule the job to be executed at the passed date and time user: User object to link to the JobResult instance
args: additional args passed to the callable schedule_at: Schedule the job to be executed at the passed date and time
kwargs: additional kargs passed to the callable interval: Recurrence interval (in minutes)
""" """
job_result: JobResult = cls.objects.create( rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT)
queue = django_rq.get_queue(rq_queue_name)
status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING
job_result: JobResult = JobResult.objects.create(
name=name, name=name,
status=status,
obj_type=obj_type, obj_type=obj_type,
scheduled=schedule_at,
interval=interval,
user=user, user=user,
job_id=uuid.uuid4() job_id=uuid.uuid4()
) )
rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT)
queue = django_rq.get_queue(rq_queue_name)
if schedule_at: if schedule_at:
job_result.status = JobResultStatusChoices.STATUS_SCHEDULED
job_result.scheduled = schedule_at
job_result.save()
queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
else: else:
queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs) queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)

View File

@ -1,8 +1,8 @@
import importlib
import inspect import inspect
import logging import logging
import pkgutil import pkgutil
import traceback import traceback
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
@ -11,7 +11,6 @@ from django_rq import job
from .choices import JobResultStatusChoices, LogLevelChoices from .choices import JobResultStatusChoices, LogLevelChoices
from .models import JobResult from .models import JobResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -85,10 +84,24 @@ def run_report(job_result, *args, **kwargs):
try: try:
job_result.start() job_result.start()
report.run(job_result) report.run(job_result)
except Exception as e: except Exception:
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
job_result.save() job_result.save()
logging.error(f"Error during execution of report {job_result.name}") logging.error(f"Error during execution of report {job_result.name}")
finally:
# Schedule the next job if an interval has been set
start_time = job_result.scheduled or job_result.started
if start_time and job_result.interval:
new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
JobResult.enqueue_job(
run_report,
name=job_result.name,
obj_type=job_result.obj_type,
user=job_result.user,
job_timeout=report.job_timeout,
schedule_at=new_scheduled_time,
interval=job_result.interval
)
class Report(object): class Report(object):

View File

@ -4,8 +4,9 @@ import logging
import os import os
import pkgutil import pkgutil
import sys import sys
import traceback
import threading import threading
import traceback
from datetime import timedelta
import yaml import yaml
from django import forms from django import forms
@ -16,6 +17,7 @@ from django.utils.functional import classproperty
from extras.api.serializers import ScriptOutputSerializer from extras.api.serializers import ScriptOutputSerializer
from extras.choices import JobResultStatusChoices, LogLevelChoices from extras.choices import JobResultStatusChoices, LogLevelChoices
from extras.models import JobResult
from extras.signals import clear_webhooks from extras.signals import clear_webhooks
from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@ -491,6 +493,22 @@ def run_script(data, request, commit=True, *args, **kwargs):
else: else:
_run_script() _run_script()
# Schedule the next job if an interval has been set
if job_result.interval:
new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
JobResult.enqueue_job(
run_script,
name=job_result.name,
obj_type=job_result.obj_type,
user=job_result.user,
schedule_at=new_scheduled_time,
interval=job_result.interval,
job_timeout=script.job_timeout,
data=data,
request=request,
commit=commit
)
def get_scripts(use_names=False): def get_scripts(use_names=False):
""" """

View File

@ -1,5 +1,6 @@
import django_tables2 as tables import django_tables2 as tables
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext as _
from extras.models import * from extras.models import *
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
@ -8,9 +9,9 @@ from .template_code import *
__all__ = ( __all__ = (
'ConfigContextTable', 'ConfigContextTable',
'CustomFieldTable', 'CustomFieldTable',
'JobResultTable',
'CustomLinkTable', 'CustomLinkTable',
'ExportTemplateTable', 'ExportTemplateTable',
'JobResultTable',
'JournalEntryTable', 'JournalEntryTable',
'ObjectChangeTable', 'ObjectChangeTable',
'SavedFilterTable', 'SavedFilterTable',
@ -41,7 +42,15 @@ class JobResultTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
obj_type = columns.ContentTypeColumn(
verbose_name=_('Type')
)
status = columns.ChoiceFieldColumn()
created = columns.DateTimeColumn()
scheduled = columns.DateTimeColumn()
interval = columns.DurationColumn()
started = columns.DateTimeColumn()
completed = columns.DateTimeColumn()
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=('delete',) actions=('delete',)
) )
@ -49,10 +58,12 @@ class JobResultTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = JobResult model = JobResult
fields = ( fields = (
'pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'started', 'completed', 'user', 'job_id', 'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
'user', 'job_id',
) )
default_columns = ( default_columns = (
'pk', 'id', 'name', 'obj_type', 'status', 'created', 'scheduled', 'started', 'completed', 'user', 'pk', 'id', 'obj_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
'user',
) )

View File

@ -676,7 +676,6 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
form = ReportForm(request.POST) form = ReportForm(request.POST)
if form.is_valid(): if form.is_valid():
schedule_at = form.cleaned_data.get("schedule_at")
# Allow execution only if RQ worker process is running # Allow execution only if RQ worker process is running
if not Worker.count(get_connection('default')): if not Worker.count(get_connection('default')):
@ -686,14 +685,14 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
}) })
# Run the Report. A new JobResult is created. # Run the Report. A new JobResult is created.
report_content_type = ContentType.objects.get(app_label='extras', model='report')
job_result = JobResult.enqueue_job( job_result = JobResult.enqueue_job(
run_report, run_report,
report.full_name, name=report.full_name,
report_content_type, obj_type=ContentType.objects.get_for_model(Report),
request.user, user=request.user,
job_timeout=report.job_timeout, schedule_at=form.cleaned_data.get('schedule_at'),
schedule_at=schedule_at, interval=form.cleaned_data.get('interval'),
job_timeout=report.job_timeout
) )
return redirect('extras:report_result', job_result_pk=job_result.pk) return redirect('extras:report_result', job_result_pk=job_result.pk)
@ -787,9 +786,8 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
form = script.as_form(initial=normalize_querydict(request.GET)) form = script.as_form(initial=normalize_querydict(request.GET))
# Look for a pending JobResult (use the latest one by creation timestamp) # Look for a pending JobResult (use the latest one by creation timestamp)
script_content_type = ContentType.objects.get(app_label='extras', model='script')
script.result = JobResult.objects.filter( script.result = JobResult.objects.filter(
obj_type=script_content_type, obj_type=ContentType.objects.get_for_model(Script),
name=script.full_name, name=script.full_name,
).exclude( ).exclude(
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
@ -815,21 +813,17 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
messages.error(request, "Unable to run script: RQ worker process not running.") messages.error(request, "Unable to run script: RQ worker process not running.")
elif form.is_valid(): elif form.is_valid():
commit = form.cleaned_data.pop('_commit')
schedule_at = form.cleaned_data.pop("_schedule_at")
script_content_type = ContentType.objects.get(app_label='extras', model='script')
job_result = JobResult.enqueue_job( job_result = JobResult.enqueue_job(
run_script, run_script,
script.full_name, name=script.full_name,
script_content_type, obj_type=ContentType.objects.get_for_model(Script),
request.user, user=request.user,
schedule_at=form.cleaned_data.pop('_schedule_at'),
interval=form.cleaned_data.pop('_interval'),
data=form.cleaned_data, data=form.cleaned_data,
request=copy_safe_request(request), request=copy_safe_request(request),
commit=commit,
job_timeout=script.job_timeout, job_timeout=script.job_timeout,
schedule_at=schedule_at, commit=form.cleaned_data.pop('_commit')
) )
return redirect('extras:script_result', job_result_pk=job_result.pk) return redirect('extras:script_result', job_result_pk=job_result.pk)

View File

@ -299,7 +299,7 @@ OTHER_MENU = Menu(
), ),
MenuItem( MenuItem(
link='extras:jobresult_list', link='extras:jobresult_list',
link_text=_('Job Results'), link_text=_('Jobs'),
permissions=['extras.view_jobresult'], permissions=['extras.view_jobresult'],
), ),
), ),

View File

@ -28,6 +28,7 @@ __all__ = (
'ContentTypesColumn', 'ContentTypesColumn',
'CustomFieldColumn', 'CustomFieldColumn',
'CustomLinkColumn', 'CustomLinkColumn',
'DurationColumn',
'LinkedCountColumn', 'LinkedCountColumn',
'MarkdownColumn', 'MarkdownColumn',
'ManyToManyColumn', 'ManyToManyColumn',
@ -77,6 +78,24 @@ class DateTimeColumn(tables.DateTimeColumn):
return cls(**kwargs) return cls(**kwargs)
class DurationColumn(tables.Column):
"""
Express a duration of time (in minutes) in a human-friendly format. Example: 437 minutes becomes "7h 17m"
"""
def render(self, value):
ret = ''
if days := value // 1440:
ret += f'{days}d '
if hours := value % 1440 // 60:
ret += f'{hours}h '
if minutes := value % 60:
ret += f'{minutes}m'
return ret.strip()
def value(self, value):
return value
class ManyToManyColumn(tables.ManyToManyColumn): class ManyToManyColumn(tables.ManyToManyColumn):
""" """
Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data. Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data.

View File

@ -1,10 +1,11 @@
{% load humanize %}
{% load helpers %} {% load helpers %}
<p> <p>
{% if result.started %} {% if result.started %}
Started: <strong>{{ result.started|annotated_date }}</strong> Started: <strong>{{ result.started|annotated_date }}</strong>
{% elif result.scheduled %} {% elif result.scheduled %}
Scheduled for: <strong>{{ result.scheduled|annotated_date }}</strong> Scheduled for: <strong>{{ result.scheduled|annotated_date }}</strong> ({{ result.scheduled|naturaltime }})
{% else %} {% else %}
Created: <strong>{{ result.created|annotated_date }}</strong> Created: <strong>{{ result.created|annotated_date }}</strong>
{% endif %} {% endif %}

View File

@ -5,7 +5,7 @@
{% if result.started %} {% if result.started %}
Started: <strong>{{ result.started|annotated_date }}</strong> Started: <strong>{{ result.started|annotated_date }}</strong>
{% elif result.scheduled %} {% elif result.scheduled %}
Scheduled for: <strong>{{ result.scheduled|annotated_date }}</strong> Scheduled for: <strong>{{ result.scheduled|annotated_date }}</strong> ({{ result.scheduled|naturaltime }})
{% else %} {% else %}
Created: <strong>{{ result.created|annotated_date }}</strong> Created: <strong>{{ result.created|annotated_date }}</strong>
{% endif %} {% endif %}