We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
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

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `netbox/extras/reports.py`
Content:
```
1 import inspect
2 import logging
3 import traceback
4 from datetime import timedelta
5 
6 from django.utils import timezone
7 from django.utils.functional import classproperty
8 from django_rq import job
9 
10 from core.choices import JobStatusChoices
11 from core.models import Job
12 from .choices import LogLevelChoices
13 from .models import ReportModule
14 
15 __all__ = (
16     'Report',
17     'get_module_and_report',
18     'run_report',
19 )
20 
21 logger = logging.getLogger(__name__)
22 
23 
24 def get_module_and_report(module_name, report_name):
25     module = ReportModule.objects.get(file_path=f'{module_name}.py')
26     report = module.reports.get(report_name)
27     return module, report
28 
29 
30 @job('default')
31 def run_report(job, *args, **kwargs):
32     """
33     Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance
34     method for queueing into the background processor.
35     """
36     job.start()
37 
38     module = ReportModule.objects.get(pk=job.object_id)
39     report = module.reports.get(job.name)()
40 
41     try:
42         report.run(job)
43     except Exception:
44         job.terminate(status=JobStatusChoices.STATUS_ERRORED)
45         logging.error(f"Error during execution of report {job.name}")
46     finally:
47         # Schedule the next job if an interval has been set
48         if job.interval:
49             new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)
50             Job.enqueue(
51                 run_report,
52                 instance=job.object,
53                 name=job.name,
54                 user=job.user,
55                 job_timeout=report.job_timeout,
56                 schedule_at=new_scheduled_time,
57                 interval=job.interval
58             )
59 
60 
61 class Report(object):
62     """
63     NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
64     report must have one or more test methods named `test_*`.
65 
66     The `_results` attribute of a completed report will take the following form:
67 
68     {
69         'test_bar': {
70             'failures': 42,
71             'log': [
72                 (<datetime>, <level>, <object>, <message>),
73                 ...
74             ]
75         },
76         'test_foo': {
77             'failures': 0,
78             'log': [
79                 (<datetime>, <level>, <object>, <message>),
80                 ...
81             ]
82         }
83     }
84     """
85     description = None
86     scheduling_enabled = True
87     job_timeout = None
88 
89     def __init__(self):
90 
91         self._results = {}
92         self.active_test = None
93         self.failed = False
94 
95         self.logger = logging.getLogger(f"netbox.reports.{self.__module__}.{self.__class__.__name__}")
96 
97         # Compile test methods and initialize results skeleton
98         test_methods = []
99         for method in dir(self):
100             if method.startswith('test_') and callable(getattr(self, method)):
101                 test_methods.append(method)
102                 self._results[method] = {
103                     'success': 0,
104                     'info': 0,
105                     'warning': 0,
106                     'failure': 0,
107                     'log': [],
108                 }
109         if not test_methods:
110             raise Exception("A report must contain at least one test method.")
111         self.test_methods = test_methods
112 
113     @classproperty
114     def module(self):
115         return self.__module__
116 
117     @classproperty
118     def class_name(self):
119         return self.__name__
120 
121     @classproperty
122     def full_name(self):
123         return f'{self.module}.{self.class_name}'
124 
125     @property
126     def name(self):
127         """
128         Override this attribute to set a custom display name.
129         """
130         return self.class_name
131 
132     @property
133     def filename(self):
134         return inspect.getfile(self.__class__)
135 
136     @property
137     def source(self):
138         return inspect.getsource(self.__class__)
139 
140     #
141     # Logging methods
142     #
143 
144     def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
145         """
146         Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
147         """
148         if level not in LogLevelChoices.values():
149             raise Exception(f"Unknown logging level: {level}")
150         self._results[self.active_test]['log'].append((
151             timezone.now().isoformat(),
152             level,
153             str(obj) if obj else None,
154             obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
155             message,
156         ))
157 
158     def log(self, message):
159         """
160         Log a message which is not associated with a particular object.
161         """
162         self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)
163         self.logger.info(message)
164 
165     def log_success(self, obj, message=None):
166         """
167         Record a successful test against an object. Logging a message is optional.
168         """
169         if message:
170             self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)
171         self._results[self.active_test]['success'] += 1
172         self.logger.info(f"Success | {obj}: {message}")
173 
174     def log_info(self, obj, message):
175         """
176         Log an informational message.
177         """
178         self._log(obj, message, level=LogLevelChoices.LOG_INFO)
179         self._results[self.active_test]['info'] += 1
180         self.logger.info(f"Info | {obj}: {message}")
181 
182     def log_warning(self, obj, message):
183         """
184         Log a warning.
185         """
186         self._log(obj, message, level=LogLevelChoices.LOG_WARNING)
187         self._results[self.active_test]['warning'] += 1
188         self.logger.info(f"Warning | {obj}: {message}")
189 
190     def log_failure(self, obj, message):
191         """
192         Log a failure. Calling this method will automatically mark the report as failed.
193         """
194         self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)
195         self._results[self.active_test]['failure'] += 1
196         self.logger.info(f"Failure | {obj}: {message}")
197         self.failed = True
198 
199     #
200     # Run methods
201     #
202 
203     def run(self, job):
204         """
205         Run the report and save its results. Each test method will be executed in order.
206         """
207         self.logger.info(f"Running report")
208 
209         # Perform any post-run tasks
210         self.pre_run()
211 
212         try:
213             for method_name in self.test_methods:
214                 self.active_test = method_name
215                 test_method = getattr(self, method_name)
216                 test_method()
217             job.data = self._results
218             if self.failed:
219                 self.logger.warning("Report failed")
220                 job.terminate(status=JobStatusChoices.STATUS_FAILED)
221             else:
222                 self.logger.info("Report completed successfully")
223                 job.terminate()
224         except Exception as e:
225             stacktrace = traceback.format_exc()
226             self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
227             logger.error(f"Exception raised during report execution: {e}")
228             job.terminate(status=JobStatusChoices.STATUS_ERRORED)
229 
230         # Perform any post-run tasks
231         self.post_run()
232 
233     def pre_run(self):
234         """
235         Extend this method to include any tasks which should execute *before* the report is run.
236         """
237         pass
238 
239     def post_run(self):
240         """
241         Extend this method to include any tasks which should execute *after* the report is run.
242         """
243         pass
244
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py
--- a/netbox/extras/reports.py
+++ b/netbox/extras/reports.py
@@ -106,8 +106,6 @@
                     'failure': 0,
                     'log': [],
                 }
-        if not test_methods:
-            raise Exception("A report must contain at least one test method.")
         self.test_methods = test_methods
 
     @classproperty

{"golden_diff": "diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py\n--- a/netbox/extras/reports.py\n+++ b/netbox/extras/reports.py\n@@ -106,8 +106,6 @@\n                     'failure': 0,\n                     'log': [],\n                 }\n-        if not test_methods:\n-            raise Exception(\"A report must contain at least one test method.\")\n         self.test_methods = test_methods\n \n     @classproperty\n", "issue": "Uploading report without test method yields \"Server Error\"\n### NetBox version\n\nv3.6.0 (But reproducible in 3.6.1-dev, too)\n\n### Python version\n\n3.11\n\n### Steps to Reproduce\n\n1.Create `a.py`:\r\n```py\r\nfrom extras.reports import Report\r\n\r\nclass DeviceConnectionsReport(Report):\r\n    description = \"Validate the minimum physical connections for each device\"\r\n    pass\r\n```\r\n(Note, the class doesn't have a test method)\r\n\r\n2.Upload it as report\r\n\n\n### Expected Behavior\n\nI 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.\n\n### Observed Behavior\n\nReality:\r\n```\r\n<class 'Exception'>\r\n\r\nA report must contain at least one test method.\r\n\r\nPython version: 3.11.4\r\nNetBox version: 3.6.1-dev\r\nPlugins: None installed\r\n```\r\n\r\n![image](https://github.com/netbox-community/netbox/assets/63594136/75bc3936-9ed5-4335-98cb-761829f56fc4)\r\n\r\n\r\nScreenshot from develop branch, but reproducible with stable, too\n", "before_files": [{"content": "import inspect\nimport logging\nimport traceback\nfrom datetime import timedelta\n\nfrom django.utils import timezone\nfrom django.utils.functional import classproperty\nfrom django_rq import job\n\nfrom core.choices import JobStatusChoices\nfrom core.models import Job\nfrom .choices import LogLevelChoices\nfrom .models import ReportModule\n\n__all__ = (\n    'Report',\n    'get_module_and_report',\n    'run_report',\n)\n\nlogger = logging.getLogger(__name__)\n\n\ndef get_module_and_report(module_name, report_name):\n    module = ReportModule.objects.get(file_path=f'{module_name}.py')\n    report = module.reports.get(report_name)\n    return module, report\n\n\n@job('default')\ndef run_report(job, *args, **kwargs):\n    \"\"\"\n    Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance\n    method for queueing into the background processor.\n    \"\"\"\n    job.start()\n\n    module = ReportModule.objects.get(pk=job.object_id)\n    report = module.reports.get(job.name)()\n\n    try:\n        report.run(job)\n    except Exception:\n        job.terminate(status=JobStatusChoices.STATUS_ERRORED)\n        logging.error(f\"Error during execution of report {job.name}\")\n    finally:\n        # Schedule the next job if an interval has been set\n        if job.interval:\n            new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)\n            Job.enqueue(\n                run_report,\n                instance=job.object,\n                name=job.name,\n                user=job.user,\n                job_timeout=report.job_timeout,\n                schedule_at=new_scheduled_time,\n                interval=job.interval\n            )\n\n\nclass Report(object):\n    \"\"\"\n    NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each\n    report must have one or more test methods named `test_*`.\n\n    The `_results` attribute of a completed report will take the following form:\n\n    {\n        'test_bar': {\n            'failures': 42,\n            'log': [\n                (<datetime>, <level>, <object>, <message>),\n                ...\n            ]\n        },\n        'test_foo': {\n            'failures': 0,\n            'log': [\n                (<datetime>, <level>, <object>, <message>),\n                ...\n            ]\n        }\n    }\n    \"\"\"\n    description = None\n    scheduling_enabled = True\n    job_timeout = None\n\n    def __init__(self):\n\n        self._results = {}\n        self.active_test = None\n        self.failed = False\n\n        self.logger = logging.getLogger(f\"netbox.reports.{self.__module__}.{self.__class__.__name__}\")\n\n        # Compile test methods and initialize results skeleton\n        test_methods = []\n        for method in dir(self):\n            if method.startswith('test_') and callable(getattr(self, method)):\n                test_methods.append(method)\n                self._results[method] = {\n                    'success': 0,\n                    'info': 0,\n                    'warning': 0,\n                    'failure': 0,\n                    'log': [],\n                }\n        if not test_methods:\n            raise Exception(\"A report must contain at least one test method.\")\n        self.test_methods = test_methods\n\n    @classproperty\n    def module(self):\n        return self.__module__\n\n    @classproperty\n    def class_name(self):\n        return self.__name__\n\n    @classproperty\n    def full_name(self):\n        return f'{self.module}.{self.class_name}'\n\n    @property\n    def name(self):\n        \"\"\"\n        Override this attribute to set a custom display name.\n        \"\"\"\n        return self.class_name\n\n    @property\n    def filename(self):\n        return inspect.getfile(self.__class__)\n\n    @property\n    def source(self):\n        return inspect.getsource(self.__class__)\n\n    #\n    # Logging methods\n    #\n\n    def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):\n        \"\"\"\n        Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.\n        \"\"\"\n        if level not in LogLevelChoices.values():\n            raise Exception(f\"Unknown logging level: {level}\")\n        self._results[self.active_test]['log'].append((\n            timezone.now().isoformat(),\n            level,\n            str(obj) if obj else None,\n            obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,\n            message,\n        ))\n\n    def log(self, message):\n        \"\"\"\n        Log a message which is not associated with a particular object.\n        \"\"\"\n        self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)\n        self.logger.info(message)\n\n    def log_success(self, obj, message=None):\n        \"\"\"\n        Record a successful test against an object. Logging a message is optional.\n        \"\"\"\n        if message:\n            self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)\n        self._results[self.active_test]['success'] += 1\n        self.logger.info(f\"Success | {obj}: {message}\")\n\n    def log_info(self, obj, message):\n        \"\"\"\n        Log an informational message.\n        \"\"\"\n        self._log(obj, message, level=LogLevelChoices.LOG_INFO)\n        self._results[self.active_test]['info'] += 1\n        self.logger.info(f\"Info | {obj}: {message}\")\n\n    def log_warning(self, obj, message):\n        \"\"\"\n        Log a warning.\n        \"\"\"\n        self._log(obj, message, level=LogLevelChoices.LOG_WARNING)\n        self._results[self.active_test]['warning'] += 1\n        self.logger.info(f\"Warning | {obj}: {message}\")\n\n    def log_failure(self, obj, message):\n        \"\"\"\n        Log a failure. Calling this method will automatically mark the report as failed.\n        \"\"\"\n        self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)\n        self._results[self.active_test]['failure'] += 1\n        self.logger.info(f\"Failure | {obj}: {message}\")\n        self.failed = True\n\n    #\n    # Run methods\n    #\n\n    def run(self, job):\n        \"\"\"\n        Run the report and save its results. Each test method will be executed in order.\n        \"\"\"\n        self.logger.info(f\"Running report\")\n\n        # Perform any post-run tasks\n        self.pre_run()\n\n        try:\n            for method_name in self.test_methods:\n                self.active_test = method_name\n                test_method = getattr(self, method_name)\n                test_method()\n            job.data = self._results\n            if self.failed:\n                self.logger.warning(\"Report failed\")\n                job.terminate(status=JobStatusChoices.STATUS_FAILED)\n            else:\n                self.logger.info(\"Report completed successfully\")\n                job.terminate()\n        except Exception as e:\n            stacktrace = traceback.format_exc()\n            self.log_failure(None, f\"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>\")\n            logger.error(f\"Exception raised during report execution: {e}\")\n            job.terminate(status=JobStatusChoices.STATUS_ERRORED)\n\n        # Perform any post-run tasks\n        self.post_run()\n\n    def pre_run(self):\n        \"\"\"\n        Extend this method to include any tasks which should execute *before* the report is run.\n        \"\"\"\n        pass\n\n    def post_run(self):\n        \"\"\"\n        Extend this method to include any tasks which should execute *after* the report is run.\n        \"\"\"\n        pass\n", "path": "netbox/extras/reports.py"}], "after_files": [{"content": "import inspect\nimport logging\nimport traceback\nfrom datetime import timedelta\n\nfrom django.utils import timezone\nfrom django.utils.functional import classproperty\nfrom django_rq import job\n\nfrom core.choices import JobStatusChoices\nfrom core.models import Job\nfrom .choices import LogLevelChoices\nfrom .models import ReportModule\n\n__all__ = (\n    'Report',\n    'get_module_and_report',\n    'run_report',\n)\n\nlogger = logging.getLogger(__name__)\n\n\ndef get_module_and_report(module_name, report_name):\n    module = ReportModule.objects.get(file_path=f'{module_name}.py')\n    report = module.reports.get(report_name)\n    return module, report\n\n\n@job('default')\ndef run_report(job, *args, **kwargs):\n    \"\"\"\n    Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance\n    method for queueing into the background processor.\n    \"\"\"\n    job.start()\n\n    module = ReportModule.objects.get(pk=job.object_id)\n    report = module.reports.get(job.name)()\n\n    try:\n        report.run(job)\n    except Exception:\n        job.terminate(status=JobStatusChoices.STATUS_ERRORED)\n        logging.error(f\"Error during execution of report {job.name}\")\n    finally:\n        # Schedule the next job if an interval has been set\n        if job.interval:\n            new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)\n            Job.enqueue(\n                run_report,\n                instance=job.object,\n                name=job.name,\n                user=job.user,\n                job_timeout=report.job_timeout,\n                schedule_at=new_scheduled_time,\n                interval=job.interval\n            )\n\n\nclass Report(object):\n    \"\"\"\n    NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each\n    report must have one or more test methods named `test_*`.\n\n    The `_results` attribute of a completed report will take the following form:\n\n    {\n        'test_bar': {\n            'failures': 42,\n            'log': [\n                (<datetime>, <level>, <object>, <message>),\n                ...\n            ]\n        },\n        'test_foo': {\n            'failures': 0,\n            'log': [\n                (<datetime>, <level>, <object>, <message>),\n                ...\n            ]\n        }\n    }\n    \"\"\"\n    description = None\n    scheduling_enabled = True\n    job_timeout = None\n\n    def __init__(self):\n\n        self._results = {}\n        self.active_test = None\n        self.failed = False\n\n        self.logger = logging.getLogger(f\"netbox.reports.{self.__module__}.{self.__class__.__name__}\")\n\n        # Compile test methods and initialize results skeleton\n        test_methods = []\n        for method in dir(self):\n            if method.startswith('test_') and callable(getattr(self, method)):\n                test_methods.append(method)\n                self._results[method] = {\n                    'success': 0,\n                    'info': 0,\n                    'warning': 0,\n                    'failure': 0,\n                    'log': [],\n                }\n        self.test_methods = test_methods\n\n    @classproperty\n    def module(self):\n        return self.__module__\n\n    @classproperty\n    def class_name(self):\n        return self.__name__\n\n    @classproperty\n    def full_name(self):\n        return f'{self.module}.{self.class_name}'\n\n    @property\n    def name(self):\n        \"\"\"\n        Override this attribute to set a custom display name.\n        \"\"\"\n        return self.class_name\n\n    @property\n    def filename(self):\n        return inspect.getfile(self.__class__)\n\n    @property\n    def source(self):\n        return inspect.getsource(self.__class__)\n\n    #\n    # Logging methods\n    #\n\n    def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):\n        \"\"\"\n        Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.\n        \"\"\"\n        if level not in LogLevelChoices.values():\n            raise Exception(f\"Unknown logging level: {level}\")\n        self._results[self.active_test]['log'].append((\n            timezone.now().isoformat(),\n            level,\n            str(obj) if obj else None,\n            obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,\n            message,\n        ))\n\n    def log(self, message):\n        \"\"\"\n        Log a message which is not associated with a particular object.\n        \"\"\"\n        self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)\n        self.logger.info(message)\n\n    def log_success(self, obj, message=None):\n        \"\"\"\n        Record a successful test against an object. Logging a message is optional.\n        \"\"\"\n        if message:\n            self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)\n        self._results[self.active_test]['success'] += 1\n        self.logger.info(f\"Success | {obj}: {message}\")\n\n    def log_info(self, obj, message):\n        \"\"\"\n        Log an informational message.\n        \"\"\"\n        self._log(obj, message, level=LogLevelChoices.LOG_INFO)\n        self._results[self.active_test]['info'] += 1\n        self.logger.info(f\"Info | {obj}: {message}\")\n\n    def log_warning(self, obj, message):\n        \"\"\"\n        Log a warning.\n        \"\"\"\n        self._log(obj, message, level=LogLevelChoices.LOG_WARNING)\n        self._results[self.active_test]['warning'] += 1\n        self.logger.info(f\"Warning | {obj}: {message}\")\n\n    def log_failure(self, obj, message):\n        \"\"\"\n        Log a failure. Calling this method will automatically mark the report as failed.\n        \"\"\"\n        self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)\n        self._results[self.active_test]['failure'] += 1\n        self.logger.info(f\"Failure | {obj}: {message}\")\n        self.failed = True\n\n    #\n    # Run methods\n    #\n\n    def run(self, job):\n        \"\"\"\n        Run the report and save its results. Each test method will be executed in order.\n        \"\"\"\n        self.logger.info(f\"Running report\")\n\n        # Perform any post-run tasks\n        self.pre_run()\n\n        try:\n            for method_name in self.test_methods:\n                self.active_test = method_name\n                test_method = getattr(self, method_name)\n                test_method()\n            job.data = self._results\n            if self.failed:\n                self.logger.warning(\"Report failed\")\n                job.terminate(status=JobStatusChoices.STATUS_FAILED)\n            else:\n                self.logger.info(\"Report completed successfully\")\n                job.terminate()\n        except Exception as e:\n            stacktrace = traceback.format_exc()\n            self.log_failure(None, f\"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>\")\n            logger.error(f\"Exception raised during report execution: {e}\")\n            job.terminate(status=JobStatusChoices.STATUS_ERRORED)\n\n        # Perform any post-run tasks\n        self.post_run()\n\n    def pre_run(self):\n        \"\"\"\n        Extend this method to include any tasks which should execute *before* the report is run.\n        \"\"\"\n        pass\n\n    def post_run(self):\n        \"\"\"\n        Extend this method to include any tasks which should execute *after* the report is run.\n        \"\"\"\n        pass\n", "path": "netbox/extras/reports.py"}]}