mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-18 05:28:16 -06:00
12510 remove reports
This commit is contained in:
parent
8a990d7372
commit
73190fa996
@ -50,7 +50,6 @@ __all__ = (
|
|||||||
'ScriptDetailSerializer',
|
'ScriptDetailSerializer',
|
||||||
'ScriptInputSerializer',
|
'ScriptInputSerializer',
|
||||||
'ScriptLogMessageSerializer',
|
'ScriptLogMessageSerializer',
|
||||||
'ScriptOutputSerializer',
|
|
||||||
'ScriptSerializer',
|
'ScriptSerializer',
|
||||||
'TagSerializer',
|
'TagSerializer',
|
||||||
'WebhookSerializer',
|
'WebhookSerializer',
|
||||||
@ -593,22 +592,6 @@ class ScriptInputSerializer(serializers.Serializer):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ScriptLogMessageSerializer(serializers.Serializer):
|
|
||||||
status = serializers.SerializerMethodField(read_only=True)
|
|
||||||
message = serializers.SerializerMethodField(read_only=True)
|
|
||||||
|
|
||||||
def get_status(self, instance):
|
|
||||||
return instance[0]
|
|
||||||
|
|
||||||
def get_message(self, instance):
|
|
||||||
return instance[1]
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptOutputSerializer(serializers.Serializer):
|
|
||||||
log = ScriptLogMessageSerializer(many=True, read_only=True)
|
|
||||||
output = serializers.CharField(read_only=True)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Change logging
|
# Change logging
|
||||||
#
|
#
|
||||||
|
@ -20,7 +20,6 @@ router.register('image-attachments', views.ImageAttachmentViewSet)
|
|||||||
router.register('journal-entries', views.JournalEntryViewSet)
|
router.register('journal-entries', views.JournalEntryViewSet)
|
||||||
router.register('config-contexts', views.ConfigContextViewSet)
|
router.register('config-contexts', views.ConfigContextViewSet)
|
||||||
router.register('config-templates', views.ConfigTemplateViewSet)
|
router.register('config-templates', views.ConfigTemplateViewSet)
|
||||||
router.register('reports', views.ReportViewSet, basename='report')
|
|
||||||
router.register('scripts', views.ScriptViewSet, basename='script')
|
router.register('scripts', views.ScriptViewSet, basename='script')
|
||||||
router.register('object-changes', views.ObjectChangeViewSet)
|
router.register('object-changes', views.ObjectChangeViewSet)
|
||||||
router.register('content-types', views.ContentTypeViewSet)
|
router.register('content-types', views.ContentTypeViewSet)
|
||||||
|
@ -16,7 +16,6 @@ from core.choices import JobStatusChoices
|
|||||||
from core.models import Job
|
from core.models import Job
|
||||||
from extras import filtersets
|
from extras import filtersets
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.reports import get_module_and_report, run_report
|
|
||||||
from extras.scripts import get_module_and_script, run_script
|
from extras.scripts import get_module_and_script, run_script
|
||||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||||
from netbox.api.features import SyncedDataMixin
|
from netbox.api.features import SyncedDataMixin
|
||||||
@ -211,111 +210,6 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
|
|||||||
return self.render_configtemplate(request, configtemplate, context)
|
return self.render_configtemplate(request, configtemplate, context)
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Reports
|
|
||||||
#
|
|
||||||
|
|
||||||
class ReportViewSet(ViewSet):
|
|
||||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
|
||||||
_ignore_model_permissions = True
|
|
||||||
schema = None
|
|
||||||
lookup_value_regex = '[^/]+' # Allow dots
|
|
||||||
|
|
||||||
def _get_report(self, pk):
|
|
||||||
try:
|
|
||||||
module_name, report_name = pk.split('.', maxsplit=1)
|
|
||||||
except ValueError:
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
module, report = get_module_and_report(module_name, report_name)
|
|
||||||
if report is None:
|
|
||||||
raise Http404
|
|
||||||
|
|
||||||
return module, report
|
|
||||||
|
|
||||||
def list(self, request):
|
|
||||||
"""
|
|
||||||
Compile all reports and their related results (if any). Result data is deferred in the list view.
|
|
||||||
"""
|
|
||||||
results = {
|
|
||||||
job.name: job
|
|
||||||
for job in Job.objects.filter(
|
|
||||||
object_type=ContentType.objects.get(app_label='extras', model='reportmodule'),
|
|
||||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
|
||||||
).order_by('name', '-created').distinct('name').defer('data')
|
|
||||||
}
|
|
||||||
|
|
||||||
report_list = []
|
|
||||||
for report_module in ReportModule.objects.restrict(request.user):
|
|
||||||
report_list.extend([report() for report in report_module.reports.values()])
|
|
||||||
|
|
||||||
# Attach Job objects to each report (if any)
|
|
||||||
for report in report_list:
|
|
||||||
report.result = results.get(report.name, None)
|
|
||||||
|
|
||||||
serializer = serializers.ReportSerializer(report_list, many=True, context={
|
|
||||||
'request': request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return Response({'count': len(report_list), 'results': serializer.data})
|
|
||||||
|
|
||||||
def retrieve(self, request, pk):
|
|
||||||
"""
|
|
||||||
Retrieve a single Report identified as "<module>.<report>".
|
|
||||||
"""
|
|
||||||
module, report = self._get_report(pk)
|
|
||||||
|
|
||||||
# Retrieve the Report and Job, if any.
|
|
||||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
|
||||||
report.result = Job.objects.filter(
|
|
||||||
object_type=object_type,
|
|
||||||
name=report.name,
|
|
||||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
|
||||||
).first()
|
|
||||||
|
|
||||||
serializer = serializers.ReportDetailSerializer(report, context={
|
|
||||||
'request': request
|
|
||||||
})
|
|
||||||
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
|
||||||
def run(self, request, pk):
|
|
||||||
"""
|
|
||||||
Run a Report identified as "<module>.<script>" and return the pending Job as the result
|
|
||||||
"""
|
|
||||||
# Check that the user has permission to run reports.
|
|
||||||
if not request.user.has_perm('extras.run_report'):
|
|
||||||
raise PermissionDenied("This user does not have permission to run reports.")
|
|
||||||
|
|
||||||
# Check that at least one RQ worker is running
|
|
||||||
if not Worker.count(get_connection('default')):
|
|
||||||
raise RQWorkerNotRunningException()
|
|
||||||
|
|
||||||
# Retrieve and run the Report. This will create a new Job.
|
|
||||||
module, report_cls = self._get_report(pk)
|
|
||||||
report = report_cls
|
|
||||||
input_serializer = serializers.ReportInputSerializer(
|
|
||||||
data=request.data,
|
|
||||||
context={'report': report}
|
|
||||||
)
|
|
||||||
|
|
||||||
if input_serializer.is_valid():
|
|
||||||
report.result = Job.enqueue(
|
|
||||||
run_report,
|
|
||||||
instance=module,
|
|
||||||
name=report.class_name,
|
|
||||||
user=request.user,
|
|
||||||
job_timeout=report.job_timeout,
|
|
||||||
schedule_at=input_serializer.validated_data.get('schedule_at'),
|
|
||||||
interval=input_serializer.validated_data.get('interval')
|
|
||||||
)
|
|
||||||
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
|
|
||||||
|
|
||||||
return Response(serializer.data)
|
|
||||||
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Scripts
|
# Scripts
|
||||||
#
|
#
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.0.1 on 2024-01-26 22:22
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extras', '0105_customfield_min_max_values'),
|
||||||
|
('core', '0011_job_report_to_script'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Report',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='ReportModule',
|
||||||
|
),
|
||||||
|
]
|
@ -3,7 +3,6 @@ from .configs import *
|
|||||||
from .customfields import *
|
from .customfields import *
|
||||||
from .dashboard import *
|
from .dashboard import *
|
||||||
from .models import *
|
from .models import *
|
||||||
from .reports import *
|
|
||||||
from .scripts import *
|
from .scripts import *
|
||||||
from .search import *
|
from .search import *
|
||||||
from .staging import *
|
from .staging import *
|
||||||
|
@ -1,80 +0,0 @@
|
|||||||
import inspect
|
|
||||||
import logging
|
|
||||||
from functools import cached_property
|
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from core.choices import ManagedFileRootPathChoices
|
|
||||||
from core.models import ManagedFile
|
|
||||||
from extras.utils import is_report
|
|
||||||
from netbox.models.features import JobsMixin, EventRulesMixin
|
|
||||||
from utilities.querysets import RestrictedQuerySet
|
|
||||||
from .mixins import PythonModuleMixin
|
|
||||||
|
|
||||||
logger = logging.getLogger('netbox.reports')
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
'Report',
|
|
||||||
'ReportModule',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Report(EventRulesMixin, models.Model):
|
|
||||||
"""
|
|
||||||
Dummy model used to generate permissions for reports. Does not exist in the database.
|
|
||||||
"""
|
|
||||||
class Meta:
|
|
||||||
managed = False
|
|
||||||
|
|
||||||
|
|
||||||
class ReportModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.REPORTS)
|
|
||||||
|
|
||||||
|
|
||||||
class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
|
||||||
"""
|
|
||||||
Proxy model for report module files.
|
|
||||||
"""
|
|
||||||
objects = ReportModuleManager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
proxy = True
|
|
||||||
verbose_name = _('report module')
|
|
||||||
verbose_name_plural = _('report modules')
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
return reverse('extras:report_list')
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.python_name
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def reports(self):
|
|
||||||
|
|
||||||
def _get_name(cls):
|
|
||||||
# For child objects in submodules use the full import path w/o the root module as the name
|
|
||||||
return cls.full_name.split(".", maxsplit=1)[1]
|
|
||||||
|
|
||||||
try:
|
|
||||||
module = self.get_module()
|
|
||||||
except (ImportError, SyntaxError) as e:
|
|
||||||
logger.error(f"Unable to load report module {self.name}, exception: {e}")
|
|
||||||
return {}
|
|
||||||
reports = {}
|
|
||||||
ordered = getattr(module, 'report_order', [])
|
|
||||||
|
|
||||||
for cls in ordered:
|
|
||||||
reports[_get_name(cls)] = cls
|
|
||||||
for name, cls in inspect.getmembers(module, is_report):
|
|
||||||
if cls not in ordered:
|
|
||||||
reports[_get_name(cls)] = cls
|
|
||||||
|
|
||||||
return reports
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
self.file_root = ManagedFileRootPathChoices.REPORTS
|
|
||||||
return super().save(*args, **kwargs)
|
|
@ -1,73 +1,19 @@
|
|||||||
import inspect
|
|
||||||
import logging
|
|
||||||
import traceback
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.functional import classproperty
|
|
||||||
from django_rq import job
|
|
||||||
|
|
||||||
from core.choices import JobStatusChoices
|
|
||||||
from core.models import Job
|
|
||||||
from .choices import LogLevelChoices
|
|
||||||
from .models import ReportModule
|
|
||||||
from .scripts import BaseScript
|
from .scripts import BaseScript
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Report',
|
'Report',
|
||||||
'get_module_and_report',
|
|
||||||
'run_report',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_module_and_report(module_name, report_name):
|
|
||||||
module = ReportModule.objects.get(file_path=f'{module_name}.py')
|
|
||||||
report = module.reports.get(report_name)()
|
|
||||||
return module, report
|
|
||||||
|
|
||||||
|
|
||||||
@job('default')
|
|
||||||
def run_report(job, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance
|
|
||||||
method for queueing into the background processor.
|
|
||||||
"""
|
|
||||||
job.start()
|
|
||||||
|
|
||||||
module = ReportModule.objects.get(pk=job.object_id)
|
|
||||||
report = module.reports.get(job.name)()
|
|
||||||
|
|
||||||
try:
|
|
||||||
report.run(job)
|
|
||||||
except Exception as e:
|
|
||||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
|
|
||||||
logging.error(f"Error during execution of report {job.name}")
|
|
||||||
finally:
|
|
||||||
# Schedule the next job if an interval has been set
|
|
||||||
if job.interval:
|
|
||||||
new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)
|
|
||||||
Job.enqueue(
|
|
||||||
run_report,
|
|
||||||
instance=job.object,
|
|
||||||
name=job.name,
|
|
||||||
user=job.user,
|
|
||||||
job_timeout=report.job_timeout,
|
|
||||||
schedule_at=new_scheduled_time,
|
|
||||||
interval=job.interval
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Report(BaseScript):
|
class Report(BaseScript):
|
||||||
def log_success(self, obj, message=None):
|
def log_success(self, obj=None, message=None):
|
||||||
super().log_success(message, obj)
|
super().log_success(message, obj)
|
||||||
|
|
||||||
def log_info(self, obj, message):
|
def log_info(self, obj=None, message=None):
|
||||||
super().log_info(message, obj)
|
super().log_info(message, obj)
|
||||||
|
|
||||||
def log_warning(self, obj, message):
|
def log_warning(self, obj=None, message=None):
|
||||||
super().log_warning(message, obj)
|
super().log_warning(message, obj)
|
||||||
|
|
||||||
def log_failure(self, obj, message):
|
def log_failure(self, obj=None, message=None):
|
||||||
super().log_failure(message, obj)
|
super().log_failure(message, obj)
|
||||||
|
@ -15,7 +15,6 @@ from django.utils.functional import classproperty
|
|||||||
|
|
||||||
from core.choices import JobStatusChoices
|
from core.choices import JobStatusChoices
|
||||||
from core.models import Job
|
from core.models import Job
|
||||||
from extras.api.serializers import ScriptOutputSerializer
|
|
||||||
from extras.choices import LogLevelChoices
|
from extras.choices import LogLevelChoices
|
||||||
from extras.models import ScriptModule
|
from extras.models import ScriptModule
|
||||||
from extras.signals import clear_events
|
from extras.signals import clear_events
|
||||||
@ -271,9 +270,10 @@ class BaseScript(object):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._results = {}
|
self._logs = {}
|
||||||
self.failed = False
|
self.failed = False
|
||||||
self._current_method = 'total'
|
self._current_method = 'total'
|
||||||
|
self._output = ''
|
||||||
|
|
||||||
# Initiate the log
|
# Initiate the log
|
||||||
self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}")
|
self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}")
|
||||||
@ -283,7 +283,7 @@ class BaseScript(object):
|
|||||||
self.request = None
|
self.request = None
|
||||||
|
|
||||||
# Compile test methods and initialize results skeleton
|
# Compile test methods and initialize results skeleton
|
||||||
self._results['total'] = {
|
self._logs['total'] = {
|
||||||
'success': 0,
|
'success': 0,
|
||||||
'info': 0,
|
'info': 0,
|
||||||
'warning': 0,
|
'warning': 0,
|
||||||
@ -294,7 +294,7 @@ class BaseScript(object):
|
|||||||
for method in dir(self):
|
for method in dir(self):
|
||||||
if method.startswith('test_') and callable(getattr(self, method)):
|
if method.startswith('test_') and callable(getattr(self, method)):
|
||||||
test_methods.append(method)
|
test_methods.append(method)
|
||||||
self._results[method] = {
|
self._logs[method] = {
|
||||||
'success': 0,
|
'success': 0,
|
||||||
'info': 0,
|
'info': 0,
|
||||||
'warning': 0,
|
'warning': 0,
|
||||||
@ -443,7 +443,7 @@ class BaseScript(object):
|
|||||||
raise Exception(f"Unknown logging level: {log_level}")
|
raise Exception(f"Unknown logging level: {log_level}")
|
||||||
|
|
||||||
if message:
|
if message:
|
||||||
self._results[self._current_method]['log'].append((
|
self._logs[self._current_method]['log'].append((
|
||||||
timezone.now().isoformat(),
|
timezone.now().isoformat(),
|
||||||
log_level,
|
log_level,
|
||||||
str(obj) if obj else None,
|
str(obj) if obj else None,
|
||||||
@ -452,10 +452,10 @@ class BaseScript(object):
|
|||||||
))
|
))
|
||||||
|
|
||||||
if log_level != LogLevelChoices.LOG_DEFAULT:
|
if log_level != LogLevelChoices.LOG_DEFAULT:
|
||||||
self._results[self._current_method][log_level] += 1
|
self._logs[self._current_method][log_level] += 1
|
||||||
|
|
||||||
if self._current_method != 'total':
|
if self._current_method != 'total':
|
||||||
self._results['total'][log_level] += 1
|
self._logs['total'][log_level] += 1
|
||||||
|
|
||||||
if obj:
|
if obj:
|
||||||
self.logger.log(level, f"{log_level.capitalize()} | {obj}: {message}")
|
self.logger.log(level, f"{log_level.capitalize()} | {obj}: {message}")
|
||||||
@ -471,7 +471,7 @@ class BaseScript(object):
|
|||||||
def log_debug(self, message, obj=None):
|
def log_debug(self, message, obj=None):
|
||||||
self._log(str(message), obj, log_level=LogLevelChoices.LOG_DEFAULT, level=logging.DEBUG)
|
self._log(str(message), obj, log_level=LogLevelChoices.LOG_DEFAULT, level=logging.DEBUG)
|
||||||
|
|
||||||
def log_success(self, message=None, obj=None):
|
def log_success(self, message, obj=None):
|
||||||
self._log(str(message), obj, log_level=LogLevelChoices.LOG_SUCCESS, level=logging.INFO)
|
self._log(str(message), obj, log_level=LogLevelChoices.LOG_SUCCESS, level=logging.INFO)
|
||||||
|
|
||||||
def log_info(self, message, obj=None):
|
def log_info(self, message, obj=None):
|
||||||
@ -524,7 +524,6 @@ class BaseScript(object):
|
|||||||
self._current_method = method_name
|
self._current_method = method_name
|
||||||
test_method = getattr(self, method_name)
|
test_method = getattr(self, method_name)
|
||||||
test_method()
|
test_method()
|
||||||
job.data = self._results
|
|
||||||
if self.failed:
|
if self.failed:
|
||||||
self.logger.warning("Report failed")
|
self.logger.warning("Report failed")
|
||||||
job.terminate(status=JobStatusChoices.STATUS_FAILED)
|
job.terminate(status=JobStatusChoices.STATUS_FAILED)
|
||||||
@ -540,7 +539,7 @@ class BaseScript(object):
|
|||||||
# Perform any post-run tasks
|
# Perform any post-run tasks
|
||||||
self.post_run()
|
self.post_run()
|
||||||
|
|
||||||
def run(self, job):
|
def run(self, data, commit, job):
|
||||||
self.run_test_scripts(job)
|
self.run_test_scripts(job)
|
||||||
|
|
||||||
def pre_run(self):
|
def pre_run(self):
|
||||||
@ -609,6 +608,53 @@ def run_script(data, job, request=None, commit=True, **kwargs):
|
|||||||
# Add the current request as a property of the script
|
# Add the current request as a property of the script
|
||||||
script.request = request
|
script.request = request
|
||||||
|
|
||||||
|
def signature(fn):
|
||||||
|
"""
|
||||||
|
Taken from django.tables2 (https://github.com/jieter/django-tables2/blob/master/django_tables2/utils.py)
|
||||||
|
Returns:
|
||||||
|
tuple: Returns a (arguments, kwarg_name)-tuple:
|
||||||
|
- the arguments (positional or keyword)
|
||||||
|
- the name of the ** kwarg catch all.
|
||||||
|
|
||||||
|
The self-argument for methods is always removed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
signature = inspect.signature(fn)
|
||||||
|
|
||||||
|
args = []
|
||||||
|
keywords = None
|
||||||
|
for arg in signature.parameters.values():
|
||||||
|
if arg.kind == arg.VAR_KEYWORD:
|
||||||
|
keywords = arg.name
|
||||||
|
elif arg.kind == arg.VAR_POSITIONAL:
|
||||||
|
continue # skip *args catch-all
|
||||||
|
else:
|
||||||
|
args.append(arg.name)
|
||||||
|
|
||||||
|
return tuple(args), keywords
|
||||||
|
|
||||||
|
def call_with_appropriate(fn, kwargs):
|
||||||
|
"""
|
||||||
|
Taken from django.tables2 (https://github.com/jieter/django-tables2/blob/master/django_tables2/utils.py)
|
||||||
|
Calls the function ``fn`` with the keyword arguments from ``kwargs`` it expects
|
||||||
|
|
||||||
|
If the kwargs argument is defined, pass all arguments, else provide exactly
|
||||||
|
the arguments wanted.
|
||||||
|
|
||||||
|
If one of the arguments of ``fn`` are not contained in kwargs, ``fn`` will not
|
||||||
|
be called and ``None`` will be returned.
|
||||||
|
"""
|
||||||
|
args, kwargs_name = signature(fn)
|
||||||
|
# no catch-all defined, we need to exactly pass the arguments specified.
|
||||||
|
if not kwargs_name:
|
||||||
|
kwargs = {key: kwargs[key] for key in kwargs if key in args}
|
||||||
|
|
||||||
|
# if any argument of fn is not in kwargs, just return None
|
||||||
|
if any(arg not in kwargs for arg in args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return fn(**kwargs)
|
||||||
|
|
||||||
def _run_script():
|
def _run_script():
|
||||||
"""
|
"""
|
||||||
Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
|
Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
|
||||||
@ -617,25 +663,31 @@ def run_script(data, job, request=None, commit=True, **kwargs):
|
|||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
script.output = script.run(data=data, commit=commit)
|
script._output = call_with_appropriate(script.run, kwargs={'data': data, 'commit': commit, 'job': job})
|
||||||
if not commit:
|
if not commit:
|
||||||
raise AbortTransaction()
|
raise AbortTransaction()
|
||||||
except AbortTransaction:
|
except AbortTransaction:
|
||||||
script.log_info("Database changes have been reverted automatically.")
|
call_with_appropriate(script.log_info, kwargs={'message': "Database changes have been reverted automatically."})
|
||||||
if request:
|
if request:
|
||||||
clear_events.send(request)
|
clear_events.send(request)
|
||||||
job.data = ScriptOutputSerializer(script).data
|
job.data = {
|
||||||
|
'logs': script._logs,
|
||||||
|
'output': script._output,
|
||||||
|
}
|
||||||
job.terminate()
|
job.terminate()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if type(e) is AbortScript:
|
if type(e) is AbortScript:
|
||||||
script.log_failure(f"Script aborted with error: {e}")
|
call_with_appropriate(script.log_failure, kwargs={'message': f"Script aborted with error: {e}"})
|
||||||
logger.error(f"Script aborted with error: {e}")
|
logger.error(f"Script aborted with error: {e}")
|
||||||
else:
|
else:
|
||||||
stacktrace = traceback.format_exc()
|
stacktrace = traceback.format_exc()
|
||||||
script.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```")
|
call_with_appropriate(script.log_failure, kwargs={'message': f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"})
|
||||||
logger.error(f"Exception raised during script execution: {e}")
|
logger.error(f"Exception raised during script execution: {e}")
|
||||||
script.log_info("Database changes have been reverted due to error.")
|
call_with_appropriate(script.log_info, kwargs={'message': "Database changes have been reverted due to error."})
|
||||||
job.data = ScriptOutputSerializer(script).data
|
job.data = {
|
||||||
|
'logs': script._logs,
|
||||||
|
'output': script._output,
|
||||||
|
}
|
||||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
|
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
|
||||||
if request:
|
if request:
|
||||||
clear_events.send(request)
|
clear_events.send(request)
|
||||||
|
@ -116,15 +116,6 @@ urlpatterns = [
|
|||||||
path('dashboard/widgets/<uuid:id>/configure/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_config'),
|
path('dashboard/widgets/<uuid:id>/configure/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_config'),
|
||||||
path('dashboard/widgets/<uuid:id>/delete/', views.DashboardWidgetDeleteView.as_view(), name='dashboardwidget_delete'),
|
path('dashboard/widgets/<uuid:id>/delete/', views.DashboardWidgetDeleteView.as_view(), name='dashboardwidget_delete'),
|
||||||
|
|
||||||
# Reports
|
|
||||||
path('reports/', views.ReportListView.as_view(), name='report_list'),
|
|
||||||
path('reports/add/', views.ReportModuleCreateView.as_view(), name='reportmodule_add'),
|
|
||||||
path('reports/results/<int:job_pk>/', views.ReportResultView.as_view(), name='report_result'),
|
|
||||||
path('reports/<int:pk>/', include(get_model_urls('extras', 'reportmodule'))),
|
|
||||||
path('reports/<str:module>/<str:name>/', views.ReportView.as_view(), name='report'),
|
|
||||||
path('reports/<str:module>/<str:name>/source/', views.ReportSourceView.as_view(), name='report_source'),
|
|
||||||
path('reports/<str:module>/<str:name>/jobs/', views.ReportJobsView.as_view(), name='report_jobs'),
|
|
||||||
|
|
||||||
# Scripts
|
# Scripts
|
||||||
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
|
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
|
||||||
path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
|
path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
|
||||||
|
@ -26,7 +26,6 @@ from utilities.views import ContentTypePermissionRequiredMixin, register_model_v
|
|||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .forms.reports import ReportForm
|
from .forms.reports import ReportForm
|
||||||
from .models import *
|
from .models import *
|
||||||
from .reports import run_report
|
|
||||||
from .scripts import run_script
|
from .scripts import run_script
|
||||||
|
|
||||||
|
|
||||||
@ -1006,185 +1005,6 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
|
|||||||
return redirect(reverse('home'))
|
return redirect(reverse('home'))
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Reports
|
|
||||||
#
|
|
||||||
|
|
||||||
@register_model_view(ReportModule, 'edit')
|
|
||||||
class ReportModuleCreateView(generic.ObjectEditView):
|
|
||||||
queryset = ReportModule.objects.all()
|
|
||||||
form = ManagedFileForm
|
|
||||||
|
|
||||||
def alter_object(self, obj, *args, **kwargs):
|
|
||||||
obj.file_root = ManagedFileRootPathChoices.REPORTS
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ReportModule, 'delete')
|
|
||||||
class ReportModuleDeleteView(generic.ObjectDeleteView):
|
|
||||||
queryset = ReportModule.objects.all()
|
|
||||||
default_return_url = 'extras:report_list'
|
|
||||||
|
|
||||||
|
|
||||||
class ReportListView(ContentTypePermissionRequiredMixin, View):
|
|
||||||
"""
|
|
||||||
Retrieve all the available reports from disk and the recorded Job (if any) for each.
|
|
||||||
"""
|
|
||||||
def get_required_permission(self):
|
|
||||||
return 'extras.view_report'
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
report_modules = ReportModule.objects.restrict(request.user)
|
|
||||||
|
|
||||||
return render(request, 'extras/report_list.html', {
|
|
||||||
'model': ReportModule,
|
|
||||||
'report_modules': report_modules,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def get_report_module(module, request):
|
|
||||||
return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
|
|
||||||
|
|
||||||
|
|
||||||
class ReportView(ContentTypePermissionRequiredMixin, View):
|
|
||||||
"""
|
|
||||||
Display a single Report and its associated Job (if any).
|
|
||||||
"""
|
|
||||||
def get_required_permission(self):
|
|
||||||
return 'extras.view_report'
|
|
||||||
|
|
||||||
def get(self, request, module, name):
|
|
||||||
module = get_report_module(module, request)
|
|
||||||
report = module.reports[name]()
|
|
||||||
|
|
||||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
|
||||||
report.result = Job.objects.filter(
|
|
||||||
object_type=object_type,
|
|
||||||
object_id=module.pk,
|
|
||||||
name=report.name,
|
|
||||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
|
||||||
).first()
|
|
||||||
|
|
||||||
return render(request, 'extras/report.html', {
|
|
||||||
'module': module,
|
|
||||||
'report': report,
|
|
||||||
'form': ReportForm(scheduling_enabled=report.scheduling_enabled),
|
|
||||||
})
|
|
||||||
|
|
||||||
def post(self, request, module, name):
|
|
||||||
if not request.user.has_perm('extras.run_report'):
|
|
||||||
return HttpResponseForbidden()
|
|
||||||
|
|
||||||
module = get_report_module(module, request)
|
|
||||||
report = module.reports[name]()
|
|
||||||
form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
|
|
||||||
|
|
||||||
if form.is_valid():
|
|
||||||
|
|
||||||
# Allow execution only if RQ worker process is running
|
|
||||||
if not get_workers_for_queue('default'):
|
|
||||||
messages.error(request, "Unable to run report: RQ worker process not running.")
|
|
||||||
return render(request, 'extras/report.html', {
|
|
||||||
'report': report,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Run the Report. A new Job is created.
|
|
||||||
job = Job.enqueue(
|
|
||||||
run_report,
|
|
||||||
instance=module,
|
|
||||||
name=report.class_name,
|
|
||||||
user=request.user,
|
|
||||||
schedule_at=form.cleaned_data.get('schedule_at'),
|
|
||||||
interval=form.cleaned_data.get('interval'),
|
|
||||||
job_timeout=report.job_timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
return redirect('extras:report_result', job_pk=job.pk)
|
|
||||||
|
|
||||||
return render(request, 'extras/report.html', {
|
|
||||||
'module': module,
|
|
||||||
'report': report,
|
|
||||||
'form': form,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class ReportSourceView(ContentTypePermissionRequiredMixin, View):
|
|
||||||
|
|
||||||
def get_required_permission(self):
|
|
||||||
return 'extras.view_report'
|
|
||||||
|
|
||||||
def get(self, request, module, name):
|
|
||||||
module = get_report_module(module, request)
|
|
||||||
report = module.reports[name]()
|
|
||||||
|
|
||||||
return render(request, 'extras/report/source.html', {
|
|
||||||
'module': module,
|
|
||||||
'report': report,
|
|
||||||
'tab': 'source',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
|
||||||
|
|
||||||
def get_required_permission(self):
|
|
||||||
return 'extras.view_report'
|
|
||||||
|
|
||||||
def get(self, request, module, name):
|
|
||||||
module = get_report_module(module, request)
|
|
||||||
report = module.reports[name]()
|
|
||||||
|
|
||||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
|
||||||
jobs = Job.objects.filter(
|
|
||||||
object_type=object_type,
|
|
||||||
object_id=module.pk,
|
|
||||||
name=report.class_name
|
|
||||||
)
|
|
||||||
|
|
||||||
jobs_table = JobTable(
|
|
||||||
data=jobs,
|
|
||||||
orderable=False,
|
|
||||||
user=request.user
|
|
||||||
)
|
|
||||||
jobs_table.configure(request)
|
|
||||||
|
|
||||||
return render(request, 'extras/report/jobs.html', {
|
|
||||||
'module': module,
|
|
||||||
'report': report,
|
|
||||||
'table': jobs_table,
|
|
||||||
'tab': 'jobs',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class ReportResultView(ContentTypePermissionRequiredMixin, View):
|
|
||||||
"""
|
|
||||||
Display a Job pertaining to the execution of a Report.
|
|
||||||
"""
|
|
||||||
def get_required_permission(self):
|
|
||||||
return 'extras.view_report'
|
|
||||||
|
|
||||||
def get(self, request, job_pk):
|
|
||||||
object_type = ContentType.objects.get_by_natural_key(app_label='extras', model='reportmodule')
|
|
||||||
job = get_object_or_404(Job.objects.all(), pk=job_pk, object_type=object_type)
|
|
||||||
|
|
||||||
module = job.object
|
|
||||||
report = module.reports[job.name]
|
|
||||||
|
|
||||||
# If this is an HTMX request, return only the result HTML
|
|
||||||
if request.htmx:
|
|
||||||
response = render(request, 'extras/htmx/report_result.html', {
|
|
||||||
'report': report,
|
|
||||||
'job': job,
|
|
||||||
})
|
|
||||||
if job.completed or not job.started:
|
|
||||||
response.status_code = 286
|
|
||||||
return response
|
|
||||||
|
|
||||||
return render(request, 'extras/report_result.html', {
|
|
||||||
'report': report,
|
|
||||||
'job': job,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Scripts
|
# Scripts
|
||||||
#
|
#
|
||||||
|
@ -317,14 +317,8 @@ CUSTOMIZATION_MENU = Menu(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
MenuGroup(
|
MenuGroup(
|
||||||
label=_('Reports & Scripts'),
|
label=_('Scripts'),
|
||||||
items=(
|
items=(
|
||||||
MenuItem(
|
|
||||||
link='extras:report_list',
|
|
||||||
link_text=_('Reports'),
|
|
||||||
permissions=['extras.view_report'],
|
|
||||||
buttons=get_model_buttons('extras', "reportmodule", actions=['add'])
|
|
||||||
),
|
|
||||||
MenuItem(
|
MenuItem(
|
||||||
link='extras:script_list',
|
link='extras:script_list',
|
||||||
link_text=_('Scripts'),
|
link_text=_('Scripts'),
|
||||||
|
@ -1,77 +0,0 @@
|
|||||||
{% load humanize %}
|
|
||||||
{% load helpers %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
<p>
|
|
||||||
{% if job.started %}
|
|
||||||
{% trans "Started" %}: <strong>{{ job.started|annotated_date }}</strong>
|
|
||||||
{% elif job.scheduled %}
|
|
||||||
{% trans "Scheduled for" %}: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
|
|
||||||
{% else %}
|
|
||||||
{% trans "Created" %}: <strong>{{ job.created|annotated_date }}</strong>
|
|
||||||
{% endif %}
|
|
||||||
{% if job.completed %}
|
|
||||||
{% trans "Duration" %}: <strong>{{ job.duration }}</strong>
|
|
||||||
{% endif %}
|
|
||||||
<span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
|
|
||||||
</p>
|
|
||||||
{% if job.completed %}
|
|
||||||
<div class="card">
|
|
||||||
<h5 class="card-header">{% trans "Report Methods" %}</h5>
|
|
||||||
<table class="table table-hover">
|
|
||||||
{% for method, data in job.data.items %}
|
|
||||||
<tr>
|
|
||||||
<td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
|
|
||||||
<td class="text-end report-stats">
|
|
||||||
<span class="badge text-bg-success">{{ data.success }}</span>
|
|
||||||
<span class="badge text-bg-info">{{ data.info }}</span>
|
|
||||||
<span class="badge text-bg-warning">{{ data.warning }}</span>
|
|
||||||
<span class="badge text-bg-danger">{{ data.failure }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h5 class="card-header">{% trans "Report Results" %}</h5>
|
|
||||||
<table class="table table-hover report">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{% trans "Time" %}</th>
|
|
||||||
<th>{% trans "Level" %}</th>
|
|
||||||
<th>{% trans "Object" %}</th>
|
|
||||||
<th>{% trans "Message" %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for method, data in job.data.items %}
|
|
||||||
<tr>
|
|
||||||
<th colspan="4" style="font-family: monospace">
|
|
||||||
<a name="{{ method }}"></a>{{ method }}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
{% for time, level, obj, url, message in data.log %}
|
|
||||||
<tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
|
|
||||||
<td>{{ time }}</td>
|
|
||||||
<td>
|
|
||||||
<label class="badge text-bg-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if obj and url %}
|
|
||||||
<a href="{{ url }}">{{ obj }}</a>
|
|
||||||
{% elif obj %}
|
|
||||||
{{ obj }}
|
|
||||||
{% else %}
|
|
||||||
{{ ''|placeholder }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="rendered-markdown">{{ message|markdown }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% elif job.started %}
|
|
||||||
{% include 'extras/inc/result_pending.html' %}
|
|
||||||
{% endif %}
|
|
@ -19,7 +19,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">{% trans "Script Methods" %}</h5>
|
<h5 class="card-header">{% trans "Script Methods" %}</h5>
|
||||||
<table class="table table-hover">
|
<table class="table table-hover">
|
||||||
{% for method, data in job.data.items %}
|
{% for method, data in job.data.logs.items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
|
<td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
|
||||||
<td class="text-end script-stats">
|
<td class="text-end script-stats">
|
||||||
@ -44,7 +44,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for method, data in job.data.items %}
|
{% for method, data in job.data.logs.items %}
|
||||||
|
{% if method != 'total' %}
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="4" style="font-family: monospace">
|
<th colspan="4" style="font-family: monospace">
|
||||||
<a name="{{ method }}"></a>{{ method }}
|
<a name="{{ method }}"></a>{{ method }}
|
||||||
@ -68,6 +69,7 @@
|
|||||||
<td class="rendered-markdown">{{ message|markdown }}</td>
|
<td class="rendered-markdown">{{ message|markdown }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -1,43 +0,0 @@
|
|||||||
{% extends 'extras/report/base.html' %}
|
|
||||||
{% load helpers %}
|
|
||||||
{% load form_helpers %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div role="tabpanel" class="tab-pane active" id="report">
|
|
||||||
{% if perms.extras.run_report %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
{% if not report.is_valid %}
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<i class="mdi mdi-alert"></i>
|
|
||||||
{% trans "This report is invalid and cannot be run." %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post" class="object-edit">
|
|
||||||
{% csrf_token %}
|
|
||||||
{% render_form form %}
|
|
||||||
<div class="float-end">
|
|
||||||
<button type="submit" name="_run" class="btn btn-primary"{% if not report.is_valid %} disabled{% endif %}>
|
|
||||||
{% if report.result %}
|
|
||||||
<i class="mdi mdi-replay"></i> {% trans "Run Again" %}
|
|
||||||
{% else %}
|
|
||||||
<i class="mdi mdi-play"></i> {% trans "Run Report" %}
|
|
||||||
{% endif %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col col-md-12">
|
|
||||||
{% if report.result %}
|
|
||||||
{% trans "Last run" %}: <a href="{% url 'extras:report_result' job_pk=report.result.pk %}">
|
|
||||||
<strong>{{ report.result.created|annotated_date }}</strong>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
@ -1,128 +0,0 @@
|
|||||||
{% extends 'generic/_base.html' %}
|
|
||||||
{% load buttons %}
|
|
||||||
{% load helpers %}
|
|
||||||
{% load perms %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block title %}{% trans "Reports" %}{% endblock %}
|
|
||||||
|
|
||||||
{% block tabs %}
|
|
||||||
<ul class="nav nav-tabs">
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<a class="nav-link active" role="tab">{% trans "Reports" %}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{% endblock tabs %}
|
|
||||||
|
|
||||||
{% block controls %}
|
|
||||||
{% add_button model %}
|
|
||||||
{% endblock controls %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% for module in report_modules %}
|
|
||||||
<div class="card">
|
|
||||||
<h5 class="card-header justify-content-between" id="module{{ module.pk }}">
|
|
||||||
<div>
|
|
||||||
<i class="mdi mdi-file-document-outline"></i> {{ module }}
|
|
||||||
</div>
|
|
||||||
{% if perms.extras.delete_reportmodule %}
|
|
||||||
<a href="{% url 'extras:reportmodule_delete' pk=module.pk %}" class="btn btn-danger btn-sm">
|
|
||||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</h5>
|
|
||||||
<div class="card-body">
|
|
||||||
{% include 'inc/sync_warning.html' with object=module %}
|
|
||||||
{% if module.reports %}
|
|
||||||
<table class="table table-hover reports">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th width="250">{% trans "Name" %}</th>
|
|
||||||
<th>{% trans "Description" %}</th>
|
|
||||||
<th>{% trans "Last Run" %}</th>
|
|
||||||
<th>{% trans "Status" %}</th>
|
|
||||||
<th width="120"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% with jobs=module.get_latest_jobs %}
|
|
||||||
{% for report_name, report in module.reports.items %}
|
|
||||||
{% with last_job=jobs|get_key:report.class_name %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
|
|
||||||
</td>
|
|
||||||
<td>{{ report.description|markdown|placeholder }}</td>
|
|
||||||
{% if last_job %}
|
|
||||||
<td>
|
|
||||||
<a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% badge last_job.get_status_display last_job.get_status_color %}
|
|
||||||
</td>
|
|
||||||
{% else %}
|
|
||||||
<td class="text-muted">{% trans "Never" %}</td>
|
|
||||||
<td>
|
|
||||||
{% if report.is_valid %}
|
|
||||||
{{ ''|placeholder }}
|
|
||||||
{% else %}
|
|
||||||
<span class="badge text-bg-danger" title="{% trans "Report has no test methods" %}">
|
|
||||||
{% trans "Invalid" %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
<td>
|
|
||||||
{% if perms.extras.run_report and report.is_valid %}
|
|
||||||
<div class="float-end d-print-none">
|
|
||||||
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" name="_run" class="btn btn-primary" style="width: 110px">
|
|
||||||
{% if last_job %}
|
|
||||||
<i class="mdi mdi-replay"></i> {% trans "Run Again" %}
|
|
||||||
{% else %}
|
|
||||||
<i class="mdi mdi-play"></i> {% trans "Run Report" %}
|
|
||||||
{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% for method, stats in last_job.data.items %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="4" class="method">
|
|
||||||
<span class="ps-3">{{ method }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-end text-nowrap report-stats">
|
|
||||||
<span class="badge text-bg-success">{{ stats.success }}</span>
|
|
||||||
<span class="badge text-bg-info">{{ stats.info }}</span>
|
|
||||||
<span class="badge text-bg-warning">{{ stats.warning }}</span>
|
|
||||||
<span class="badge text-bg-danger">{{ stats.failure }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endwith %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<div class="alert alert-warning" role="alert">
|
|
||||||
<i class="mdi mdi-alert"></i> Could not load reports from {{ module.name }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% empty %}
|
|
||||||
<div class="alert alert-info" role="alert">
|
|
||||||
<h4 class="alert-heading">{% trans "No Reports Found" %}</h4>
|
|
||||||
{% if perms.extras.add_reportmodule %}
|
|
||||||
{% url 'extras:reportmodule_add' as create_report_url %}
|
|
||||||
{% blocktrans trimmed %}
|
|
||||||
Get started by <a href="{{ create_report_url }}">creating a report</a> from an uploaded file or data source.
|
|
||||||
{% endblocktrans %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endblock content %}
|
|
@ -1,17 +0,0 @@
|
|||||||
{% extends 'extras/report.html' %}
|
|
||||||
{% load buttons %}
|
|
||||||
{% load perms %}
|
|
||||||
|
|
||||||
{% block controls %}
|
|
||||||
{% if request.user|can_delete:job %}
|
|
||||||
{% delete_button job %}
|
|
||||||
{% endif %}
|
|
||||||
{% endblock controls %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:report_result' job_pk=job.pk %}" hx-trigger="load delay:0.5s, every 5s"{% endif %}>
|
|
||||||
{% include 'extras/htmx/report_result.html' %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
@ -89,7 +89,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% for method, stats in last_job.data.items %}
|
{% for method, stats in last_job.data.logs.items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="method">
|
<td colspan="4" class="method">
|
||||||
<span class="ps-3">{{ method }}</span>
|
<span class="ps-3">{{ method }}</span>
|
||||||
|
Loading…
Reference in New Issue
Block a user