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

__all__ = (
    '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:
        job.terminate(status=JobStatusChoices.STATUS_ERRORED)
        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(object):
    """
    NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
    report must have one or more test methods named `test_*`.

    The `_results` attribute of a completed report will take the following form:

    {
        'test_bar': {
            'failures': 42,
            'log': [
                (<datetime>, <level>, <object>, <message>),
                ...
            ]
        },
        'test_foo': {
            'failures': 0,
            'log': [
                (<datetime>, <level>, <object>, <message>),
                ...
            ]
        }
    }
    """
    description = None
    scheduling_enabled = True
    job_timeout = None

    def __init__(self):

        self._results = {}
        self.active_test = None
        self.failed = False

        self.logger = logging.getLogger(f"netbox.reports.{self.__module__}.{self.__class__.__name__}")

        # Compile test methods and initialize results skeleton
        test_methods = []
        for method in dir(self):
            if method.startswith('test_') and callable(getattr(self, method)):
                test_methods.append(method)
                self._results[method] = {
                    'success': 0,
                    'info': 0,
                    'warning': 0,
                    'failure': 0,
                    'log': [],
                }
        if not test_methods:
            raise Exception("A report must contain at least one test method.")
        self.test_methods = test_methods

    @classproperty
    def module(self):
        return self.__module__

    @classproperty
    def class_name(self):
        return self.__name__

    @classproperty
    def full_name(self):
        return f'{self.module}.{self.class_name}'

    @property
    def name(self):
        """
        Override this attribute to set a custom display name.
        """
        return self.class_name

    @property
    def filename(self):
        return inspect.getfile(self.__class__)

    @property
    def source(self):
        return inspect.getsource(self.__class__)

    #
    # Logging methods
    #

    def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
        """
        Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
        """
        if level not in LogLevelChoices.values():
            raise Exception(f"Unknown logging level: {level}")
        self._results[self.active_test]['log'].append((
            timezone.now().isoformat(),
            level,
            str(obj) if obj else None,
            obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
            message,
        ))

    def log(self, message):
        """
        Log a message which is not associated with a particular object.
        """
        self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)
        self.logger.info(message)

    def log_success(self, obj, message=None):
        """
        Record a successful test against an object. Logging a message is optional.
        """
        if message:
            self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)
        self._results[self.active_test]['success'] += 1
        self.logger.info(f"Success | {obj}: {message}")

    def log_info(self, obj, message):
        """
        Log an informational message.
        """
        self._log(obj, message, level=LogLevelChoices.LOG_INFO)
        self._results[self.active_test]['info'] += 1
        self.logger.info(f"Info | {obj}: {message}")

    def log_warning(self, obj, message):
        """
        Log a warning.
        """
        self._log(obj, message, level=LogLevelChoices.LOG_WARNING)
        self._results[self.active_test]['warning'] += 1
        self.logger.info(f"Warning | {obj}: {message}")

    def log_failure(self, obj, message):
        """
        Log a failure. Calling this method will automatically mark the report as failed.
        """
        self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)
        self._results[self.active_test]['failure'] += 1
        self.logger.info(f"Failure | {obj}: {message}")
        self.failed = True

    #
    # Run methods
    #

    def run(self, job):
        """
        Run the report and save its results. Each test method will be executed in order.
        """
        self.logger.info(f"Running report")

        # Perform any post-run tasks
        self.pre_run()

        try:
            for method_name in self.test_methods:
                self.active_test = method_name
                test_method = getattr(self, method_name)
                test_method()
            job.data = self._results
            if self.failed:
                self.logger.warning("Report failed")
                job.terminate(status=JobStatusChoices.STATUS_FAILED)
            else:
                self.logger.info("Report completed successfully")
                job.terminate()
        except Exception as e:
            stacktrace = traceback.format_exc()
            self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
            logger.error(f"Exception raised during report execution: {e}")
            job.terminate(status=JobStatusChoices.STATUS_ERRORED)

        # Perform any post-run tasks
        self.post_run()

    def pre_run(self):
        """
        Extend this method to include any tasks which should execute *before* the report is run.
        """
        pass

    def post_run(self):
        """
        Extend this method to include any tasks which should execute *after* the report is run.
        """
        pass

netbox/extras/reports.py
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

__all__ = (
    '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:
        job.terminate(status=JobStatusChoices.STATUS_ERRORED)
        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(object):
    """
    NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
    report must have one or more test methods named `test_*`.

    The `_results` attribute of a completed report will take the following form:

    {
        'test_bar': {
            'failures': 42,
            'log': [
                (<datetime>, <level>, <object>, <message>),
                ...
            ]
        },
        'test_foo': {
            'failures': 0,
            'log': [
                (<datetime>, <level>, <object>, <message>),
                ...
            ]
        }
    }
    """
    description = None
    scheduling_enabled = True
    job_timeout = None

    def __init__(self):

        self._results = {}
        self.active_test = None
        self.failed = False

        self.logger = logging.getLogger(f"netbox.reports.{self.__module__}.{self.__class__.__name__}")

        # Compile test methods and initialize results skeleton
        test_methods = []
        for method in dir(self):
            if method.startswith('test_') and callable(getattr(self, method)):
                test_methods.append(method)
                self._results[method] = {
                    'success': 0,
                    'info': 0,
                    'warning': 0,
                    'failure': 0,
                    'log': [],
                }
        self.test_methods = test_methods

    @classproperty
    def module(self):
        return self.__module__

    @classproperty
    def class_name(self):
        return self.__name__

    @classproperty
    def full_name(self):
        return f'{self.module}.{self.class_name}'

    @property
    def name(self):
        """
        Override this attribute to set a custom display name.
        """
        return self.class_name

    @property
    def filename(self):
        return inspect.getfile(self.__class__)

    @property
    def source(self):
        return inspect.getsource(self.__class__)

    #
    # Logging methods
    #

    def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
        """
        Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
        """
        if level not in LogLevelChoices.values():
            raise Exception(f"Unknown logging level: {level}")
        self._results[self.active_test]['log'].append((
            timezone.now().isoformat(),
            level,
            str(obj) if obj else None,
            obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
            message,
        ))

    def log(self, message):
        """
        Log a message which is not associated with a particular object.
        """
        self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)
        self.logger.info(message)

    def log_success(self, obj, message=None):
        """
        Record a successful test against an object. Logging a message is optional.
        """
        if message:
            self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)
        self._results[self.active_test]['success'] += 1
        self.logger.info(f"Success | {obj}: {message}")

    def log_info(self, obj, message):
        """
        Log an informational message.
        """
        self._log(obj, message, level=LogLevelChoices.LOG_INFO)
        self._results[self.active_test]['info'] += 1
        self.logger.info(f"Info | {obj}: {message}")

    def log_warning(self, obj, message):
        """
        Log a warning.
        """
        self._log(obj, message, level=LogLevelChoices.LOG_WARNING)
        self._results[self.active_test]['warning'] += 1
        self.logger.info(f"Warning | {obj}: {message}")

    def log_failure(self, obj, message):
        """
        Log a failure. Calling this method will automatically mark the report as failed.
        """
        self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)
        self._results[self.active_test]['failure'] += 1
        self.logger.info(f"Failure | {obj}: {message}")
        self.failed = True

    #
    # Run methods
    #

    def run(self, job):
        """
        Run the report and save its results. Each test method will be executed in order.
        """
        self.logger.info(f"Running report")

        # Perform any post-run tasks
        self.pre_run()

        try:
            for method_name in self.test_methods:
                self.active_test = method_name
                test_method = getattr(self, method_name)
                test_method()
            job.data = self._results
            if self.failed:
                self.logger.warning("Report failed")
                job.terminate(status=JobStatusChoices.STATUS_FAILED)
            else:
                self.logger.info("Report completed successfully")
                job.terminate()
        except Exception as e:
            stacktrace = traceback.format_exc()
            self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
            logger.error(f"Exception raised during report execution: {e}")
            job.terminate(status=JobStatusChoices.STATUS_ERRORED)

        # Perform any post-run tasks
        self.post_run()

    def pre_run(self):
        """
        Extend this method to include any tasks which should execute *before* the report is run.
        """
        pass

    def post_run(self):
        """
        Extend this method to include any tasks which should execute *after* the report is run.
        """
        pass

netbox/extras/reports.py
diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py
index 6af81a9d950..284f69d777e 100644
--- a/netbox/extras/reports.py
+++ b/netbox/extras/reports.py
@@ -106,8 +106,6 @@ def __init__(self):
                     'failure': 0,
                     'log': [],
                 }
-        if not test_methods:
-            raise Exception("A report must contain at least one test method.")
         self.test_methods = test_methods
 
     @classproperty
diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html
index 07d6fcfd58b..717c24eabba 100644
--- a/netbox/templates/extras/report.html
+++ b/netbox/templates/extras/report.html
@@ -12,7 +12,11 @@
             {% csrf_token %}
             {% render_form form %}
             <div class="float-end">
-              <button type="submit" name="_run" class="btn btn-primary">
+              <button type="submit" name="_run" class="btn btn-primary"
+              {% if not report.test_methods|length %}
+                disabled
+              {% endif %}
+              >
                 {% if report.result %}
                   <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
                 {% else %}
diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html
index 49353f9ccbd..ff245d26586 100644
--- a/netbox/templates/extras/report_list.html
+++ b/netbox/templates/extras/report_list.html
@@ -68,10 +68,16 @@ <h5 class="card-header" id="module{{ module.pk }}">
                           </td>
                         {% else %}
                           <td class="text-muted">{% trans "Never" %}</td>
-                          <td>{{ ''|placeholder }}</td>
+                          {% if report.test_methods|length %}
+                            <td>{{ ''|placeholder }}</td>
+                          {% else %}
+                            <td>
+                              Invalid (no test methods found)
+                           </td>
+                          {% endif %}
                         {% endif %}
                         <td>
-                          {% if perms.extras.run_report %}
+                          {% if perms.extras.run_report and report.test_methods|length %}
                             <div class="float-end noprint">
                               <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
                                 {% csrf_token %}

Uploading report without test method yields "Server Error"
### NetBox version

v3.6.0 (But reproducible in 3.6.1-dev, too)

### Python version

3.11

### Steps to Reproduce

1.Create `a.py`:
```py
from extras.reports import Report

class DeviceConnectionsReport(Report):
    description = "Validate the minimum physical connections for each device"
    pass
```
(Note, the class doesn't have a test method)

2.Upload it as report


### Expected Behavior

I expected it to either get an error, that there are no test methods in it, and the ability to delete it without using the CLI.

### Observed Behavior

Reality:
```
<class 'Exception'>

A report must contain at least one test method.

Python version: 3.11.4
NetBox version: 3.6.1-dev
Plugins: None installed
```

![image](https://github.com/netbox-community/netbox/assets/63594136/75bc3936-9ed5-4335-98cb-761829f56fc4)


Screenshot from develop branch, but reproducible with stable, too
