12510 remove reports

This commit is contained in:
Arthur 2024-01-26 14:23:30 -08:00
parent 8a990d7372
commit 73190fa996
17 changed files with 121 additions and 766 deletions

View File

@ -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
# #

View File

@ -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)

View File

@ -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
# #

View File

@ -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',
),
]

View File

@ -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 *

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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'),

View File

@ -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
# #

View File

@ -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'),

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>