Add support for passing parameters while creating audit
This cr implements following features: - Adds a new "Strategy Parameters" field accepts json fjson formatted parameters values. - The Create Audit dialog also shows available parameters dynamically using AJAX for the selected strategy coming from Audit template. - Impoved audit info view to include parameters Closes-Bug: #2120704 Assisted-By: Cursor (claude-4-sonnet) Change-Id: I0f5382e5f87f6ed533c733349573cbba4bf8903b Signed-off-by: Chandan Kumar (raukadah) <chkumar@redhat.com>
This commit is contained in:

committed by
Chandan Kumar

parent
4a0f64b5cb
commit
6552f02ca6
@@ -0,0 +1,18 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Watcher Dashboard now supports passing strategy parameters when creating
|
||||
audits, matching the OpenStack CLI ``openstack optimize audit create -p
|
||||
<name=value>``.
|
||||
|
||||
A new "Strategy Parameters" field accepts all the parameters as a JSON object.
|
||||
Server-side validation parses and validates the input, and the field remains
|
||||
backward compatible when left empty.
|
||||
|
||||
The Create Audit dialog also shows available parameters for the selected
|
||||
strategy (when the Watcher API exposes ``parameters_spec``), including
|
||||
types, defaults, and descriptions from audit templates. This provides
|
||||
real-time guidance to users and improves parity with the CLI experience.
|
||||
|
||||
The Audit Details view now displays the parameters that were used for the
|
||||
audit.
|
@@ -55,7 +55,8 @@ def insert_watcher_policy_file():
|
||||
class Audit(base.APIDictWrapper):
|
||||
_attrs = ('uuid', 'name', 'created_at', 'modified_at', 'deleted_at',
|
||||
'state', 'audit_type', 'audit_template_uuid',
|
||||
'audit_template_name', 'interval')
|
||||
'audit_template_name', 'interval', 'parameters', 'auto_trigger',
|
||||
'goal_name', 'strategy_name')
|
||||
|
||||
def __init__(self, apiresource, request=None):
|
||||
super(Audit, self).__init__(apiresource)
|
||||
@@ -63,7 +64,7 @@ class Audit(base.APIDictWrapper):
|
||||
|
||||
@classmethod
|
||||
def create(cls, request, audit_template_uuid, audit_type, name=None,
|
||||
auto_trigger=False, interval=None):
|
||||
auto_trigger=False, interval=None, parameters=None):
|
||||
|
||||
"""Create an audit in Watcher
|
||||
|
||||
@@ -82,18 +83,28 @@ class Audit(base.APIDictWrapper):
|
||||
:param name: Name for this audit
|
||||
:type name: string
|
||||
|
||||
:param parameters: Strategy parameters (default: None)
|
||||
:type parameters: dict
|
||||
|
||||
:return: the created Audit object
|
||||
:rtype: :py:class:`~.Audit`
|
||||
"""
|
||||
|
||||
# Build the parameters to pass to watcherclient
|
||||
create_params = {
|
||||
'audit_template_uuid': audit_template_uuid,
|
||||
'audit_type': audit_type,
|
||||
'auto_trigger': auto_trigger
|
||||
}
|
||||
|
||||
if name:
|
||||
create_params['name'] = name
|
||||
if interval:
|
||||
return watcherclient(request).audit.create(
|
||||
audit_template_uuid=audit_template_uuid, audit_type=audit_type,
|
||||
auto_trigger=auto_trigger, interval=interval, name=name)
|
||||
else:
|
||||
return watcherclient(request).audit.create(
|
||||
audit_template_uuid=audit_template_uuid, audit_type=audit_type,
|
||||
auto_trigger=auto_trigger, name=name)
|
||||
create_params['interval'] = interval
|
||||
if parameters:
|
||||
create_params['parameters'] = parameters
|
||||
|
||||
return watcherclient(request).audit.create(**create_params)
|
||||
|
||||
@classmethod
|
||||
def list(cls, request, **filters):
|
||||
@@ -478,7 +489,7 @@ class Strategy(base.APIDictWrapper):
|
||||
"""Strategy resource."""
|
||||
|
||||
_attrs = ('uuid', 'name', 'display_name', 'goal_uuid', 'goal_name',
|
||||
'created_at', 'updated_at', 'deleted_at')
|
||||
'created_at', 'updated_at', 'deleted_at', 'parameters_spec')
|
||||
|
||||
def __init__(self, apiresource, request=None):
|
||||
super(Strategy, self).__init__(apiresource)
|
||||
|
@@ -16,6 +16,7 @@
|
||||
"""
|
||||
Forms for starting Watcher Audits.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.urls import reverse
|
||||
@@ -57,6 +58,23 @@ class CreateForm(forms.SelfHandlingForm):
|
||||
_("Interval (in seconds or cron"
|
||||
" format)")}),
|
||||
required=False)
|
||||
parameters = forms.CharField(
|
||||
label=_("Strategy Parameters (JSON)"),
|
||||
help_text=_("Provide strategy parameters as a JSON object. "
|
||||
"See examples on the right."),
|
||||
widget=forms.widgets.Textarea(attrs={
|
||||
'rows': 8,
|
||||
'placeholder': ('{\n'
|
||||
' "memory_threshold": 0.8,\n'
|
||||
' "enable_migration": true,\n'
|
||||
' "compute_nodes": [\n'
|
||||
' {"src_node": "compute1", '
|
||||
'"dst_node": "compute2"}\n'
|
||||
' ]\n'
|
||||
'}')
|
||||
}),
|
||||
required=False
|
||||
)
|
||||
failure_url = 'horizon:admin:audits:index'
|
||||
auto_trigger = forms.BooleanField(label=_("Auto Trigger"),
|
||||
required=False)
|
||||
@@ -86,12 +104,42 @@ class CreateForm(forms.SelfHandlingForm):
|
||||
choices.insert(0, ("", _("No Audit Template found")))
|
||||
return choices
|
||||
|
||||
def _parse_parameters(self, param_string):
|
||||
"""Parse parameters JSON string into a dictionary
|
||||
|
||||
:param param_string: String containing a JSON object
|
||||
:returns: Dictionary of parsed parameters
|
||||
"""
|
||||
if not param_string or not param_string.strip():
|
||||
return {}
|
||||
|
||||
try:
|
||||
parsed = json.loads(param_string)
|
||||
except ValueError as e:
|
||||
raise forms.ValidationError(
|
||||
_('Parameters must be valid JSON: %s') % str(e))
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
raise forms.ValidationError(
|
||||
_('Parameters must be a JSON object'))
|
||||
|
||||
return parsed
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super(CreateForm, self).clean()
|
||||
audit_type = cleaned_data.get('audit_type')
|
||||
if audit_type == 'continuous' and not cleaned_data.get('interval'):
|
||||
msg = _('Please input an interval for continuous audit')
|
||||
raise forms.ValidationError(msg)
|
||||
|
||||
# Validate parameters
|
||||
param_string = cleaned_data.get('parameters', '')
|
||||
try:
|
||||
parsed_params = self._parse_parameters(param_string)
|
||||
cleaned_data['parsed_parameters'] = parsed_params
|
||||
except forms.ValidationError:
|
||||
raise # Re-raise the validation error
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def handle(self, request, data):
|
||||
@@ -104,6 +152,12 @@ class CreateForm(forms.SelfHandlingForm):
|
||||
params['interval'] = data['interval']
|
||||
else:
|
||||
params['interval'] = None
|
||||
|
||||
# Add parsed parameters if they exist
|
||||
parsed_parameters = data.get('parsed_parameters')
|
||||
if parsed_parameters:
|
||||
params['parameters'] = parsed_parameters
|
||||
|
||||
audit = watcher.Audit.create(request, **params)
|
||||
message = _('Audit was successfully created.')
|
||||
messages.success(request, message)
|
||||
|
@@ -25,4 +25,6 @@ urlpatterns = [
|
||||
views.CreateView.as_view(), name='create'),
|
||||
re_path(r'^(?P<audit_uuid>[^/]+)/detail$',
|
||||
views.DetailView.as_view(), name='detail'),
|
||||
re_path(r'^get_strategy_parameters/$',
|
||||
views.get_strategy_parameters, name='get_strategy_parameters'),
|
||||
]
|
||||
|
@@ -13,10 +13,13 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import horizon.exceptions
|
||||
from horizon import forms
|
||||
@@ -24,6 +27,7 @@ import horizon.tables
|
||||
import horizon.tabs
|
||||
from horizon.utils import memoized
|
||||
import horizon.workflows
|
||||
import yaml
|
||||
|
||||
from watcher_dashboard.api import watcher
|
||||
from watcher_dashboard.content.action_plans import tables as action_plan_tables
|
||||
@@ -109,6 +113,46 @@ class DetailView(horizon.tables.MultiTableView):
|
||||
redirect=self.redirect_url)
|
||||
return audit
|
||||
|
||||
def _render_pretty_parameters(self, params):
|
||||
"""Return a human-friendly rendering of parameters.
|
||||
|
||||
It is used to render parameters on the audit details page
|
||||
in a YAML format for readability.
|
||||
|
||||
Rules:
|
||||
- If params is a JSON string, parse it first.
|
||||
- If result is a dict/list, pretty-print as YAML in a <pre> block.
|
||||
- If result is a scalar (str/int/bool), return it as-is.
|
||||
- On any error, fall back to the original params value.
|
||||
"""
|
||||
try:
|
||||
obj = params
|
||||
# Parameters may be stored as a JSON-serialized string. Try to
|
||||
# decode it but tolerate non-JSON strings (e.g. plain text).
|
||||
if isinstance(params, str):
|
||||
try:
|
||||
obj = json.loads(params)
|
||||
except Exception:
|
||||
obj = params
|
||||
|
||||
# Dicts/lists are presented as indented YAML for readability.
|
||||
if isinstance(obj, (dict, list)):
|
||||
dumped = yaml.safe_dump(
|
||||
obj,
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
)
|
||||
return mark_safe(
|
||||
'<pre style="margin:0">{}</pre>'.format(dumped)
|
||||
)
|
||||
|
||||
# Scalars or unknown types: return directly.
|
||||
if obj is not None:
|
||||
return obj
|
||||
except Exception:
|
||||
# Any unexpected issue: show the raw parameters.
|
||||
return params
|
||||
|
||||
def get_related_action_plans_data(self):
|
||||
try:
|
||||
action_plan = self._get_data()
|
||||
@@ -125,9 +169,62 @@ class DetailView(horizon.tables.MultiTableView):
|
||||
context = super(DetailView, self).get_context_data(**kwargs)
|
||||
audit = self._get_data()
|
||||
context["audit"] = audit
|
||||
# Prepare pretty parameters rendering (YAML) for the template. The
|
||||
# helper encapsulates the logic so it is easier to test/maintain.
|
||||
context["audit_parameters_pretty"] = self._render_pretty_parameters(
|
||||
getattr(audit, 'parameters', None)
|
||||
)
|
||||
return context
|
||||
|
||||
def get_tabs(self, request, *args, **kwargs):
|
||||
audit = self._get_data()
|
||||
# ports = self._get_ports()
|
||||
return self.tab_group_class(request, audit=audit, **kwargs)
|
||||
|
||||
|
||||
def get_strategy_parameters(request):
|
||||
"""AJAX endpoint to get strategy parameters based on audit template."""
|
||||
try:
|
||||
audit_template_uuid = request.GET.get('audit_template_uuid')
|
||||
if not audit_template_uuid:
|
||||
return JsonResponse(
|
||||
{'error': 'Audit template UUID is required'},
|
||||
status=400)
|
||||
|
||||
# Get the audit template
|
||||
audit_template = watcher.AuditTemplate.get(
|
||||
request, audit_template_uuid)
|
||||
|
||||
if not audit_template.strategy_uuid:
|
||||
return JsonResponse({
|
||||
'strategy_name': audit_template.strategy_name or 'auto',
|
||||
'parameters_spec': {},
|
||||
'message': ('No specific strategy selected. Parameters will '
|
||||
'be automatically determined.')
|
||||
})
|
||||
|
||||
# Get the strategy details
|
||||
strategy = watcher.Strategy.get(request, audit_template.strategy_uuid)
|
||||
|
||||
# Parse parameters_spec if it exists
|
||||
parameters_spec = {}
|
||||
if hasattr(strategy, 'parameters_spec') and strategy.parameters_spec:
|
||||
if isinstance(strategy.parameters_spec, dict):
|
||||
properties = strategy.parameters_spec.get('properties', {})
|
||||
parameters_spec = properties
|
||||
elif isinstance(strategy.parameters_spec, str):
|
||||
try:
|
||||
parsed_spec = json.loads(strategy.parameters_spec)
|
||||
parameters_spec = parsed_spec.get('properties', {})
|
||||
except ValueError:
|
||||
parameters_spec = {}
|
||||
|
||||
return JsonResponse({
|
||||
'strategy_name': strategy.display_name or strategy.name,
|
||||
'strategy_uuid': strategy.uuid,
|
||||
'parameters_spec': parameters_spec
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
LOG.exception("Error getting strategy parameters")
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
@@ -5,4 +5,143 @@
|
||||
<h3>{% trans "Description:" %}</h3>
|
||||
<p>{% trans "Creates a audit with specified parameters." %}</p>
|
||||
<p>{% trans "If you check 'Auto Trigger' option, the action plan, recommended by the audit, will be automatically started." %}</p>
|
||||
|
||||
<h3>{% trans "Strategy Parameters (JSON):" %}</h3>
|
||||
<p>{% trans "Strategy parameters control how the optimization strategy behaves during audit execution." %}</p>
|
||||
|
||||
<div id="strategy-info" style="display: none; margin: 10px 0; padding: 10px; background: #e8f4fd; border-left: 4px solid #0088cc; border-radius: 4px;">
|
||||
<h4 id="strategy-name" style="margin: 0 0 10px 0; color: #0088cc;"></h4>
|
||||
<div id="parameters-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="loading-strategy" style="display: none; margin: 10px 0; padding: 10px; background: #f5f5f5; border-radius: 4px;">
|
||||
<p style="margin: 0;"><i class="fa fa-spinner fa-spin"></i> {% trans "Loading strategy parameters..." %}</p>
|
||||
</div>
|
||||
|
||||
<p><strong>{% trans "Parameter format:" %}</strong><br/>
|
||||
{% trans "Enter a JSON object with parameter names as keys." %}
|
||||
</p>
|
||||
|
||||
<p><strong>{% trans "Example:" %}</strong></p>
|
||||
<pre style="font-size: 12px; padding: 8px; background: #f8f9fa; border: 1px solid #eee; border-radius: 4px;">
|
||||
{
|
||||
"memory_threshold": 0.8,
|
||||
"cpu_threshold": 0.9,
|
||||
"enable_migration": true,
|
||||
"compute_nodes": [
|
||||
{"src_node": "compute1", "dst_node": "compute2"}
|
||||
]
|
||||
}
|
||||
</pre>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
var auditTemplateSelect = $('#id_audit_template');
|
||||
var strategyInfo = $('#strategy-info');
|
||||
var loadingDiv = $('#loading-strategy');
|
||||
var strategyName = $('#strategy-name');
|
||||
var parametersList = $('#parameters-list');
|
||||
|
||||
function loadStrategyParameters(auditTemplateUuid) {
|
||||
if (!auditTemplateUuid) {
|
||||
strategyInfo.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
loadingDiv.show();
|
||||
strategyInfo.hide();
|
||||
|
||||
$.ajax({
|
||||
url: "{% url 'horizon:admin:audits:get_strategy_parameters' %}",
|
||||
data: { audit_template_uuid: auditTemplateUuid },
|
||||
success: function(data) {
|
||||
loadingDiv.hide();
|
||||
|
||||
if (data.error) {
|
||||
console.error('Error loading strategy parameters:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
strategyName.text('Strategy: ' + data.strategy_name);
|
||||
|
||||
var paramHtml = '';
|
||||
if (data.parameters_spec && Object.keys(data.parameters_spec).length > 0) {
|
||||
paramHtml = '<p><strong>Available parameters:</strong></p><ul style="margin: 5px 0; padding-left: 20px;">';
|
||||
for (var param in data.parameters_spec) {
|
||||
var spec = data.parameters_spec[param];
|
||||
var typeInfo = spec.type ? ' (type: ' + spec.type + ')' : '';
|
||||
var defaultInfo = '';
|
||||
if (spec.hasOwnProperty('default')) {
|
||||
var renderedDefault = spec.default;
|
||||
// Render objects/arrays as compact JSON
|
||||
if (renderedDefault && (typeof renderedDefault === 'object')) {
|
||||
try {
|
||||
renderedDefault = JSON.stringify(renderedDefault);
|
||||
} catch (e) {
|
||||
renderedDefault = String(renderedDefault);
|
||||
}
|
||||
}
|
||||
defaultInfo = ' - default: ' + renderedDefault;
|
||||
}
|
||||
var description = spec.description ? ' - ' + spec.description : '';
|
||||
paramHtml += '<li style="margin: 2px 0;"><code>' + param + '</code>' + typeInfo + defaultInfo + description + '</li>';
|
||||
}
|
||||
paramHtml += '</ul>';
|
||||
} else if (data.message) {
|
||||
paramHtml = '<p><em>' + data.message + '</em></p>';
|
||||
} else {
|
||||
paramHtml = '<p><em>No parameter specifications available for this strategy.</em></p>';
|
||||
}
|
||||
|
||||
parametersList.html(paramHtml);
|
||||
strategyInfo.show();
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
loadingDiv.hide();
|
||||
console.error('AJAX error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load parameters when audit template changes
|
||||
auditTemplateSelect.change(function() {
|
||||
loadStrategyParameters($(this).val());
|
||||
});
|
||||
|
||||
// Some Horizon helpers may programmatically update the select without
|
||||
// triggering a native change event (e.g., after creating a new
|
||||
// Audit Template via the + button). Use a MutationObserver to detect
|
||||
// option/selection changes and load parameters accordingly.
|
||||
var lastSelectedValue = auditTemplateSelect.val();
|
||||
if (window.MutationObserver) {
|
||||
var observer = new MutationObserver(function(mutations) {
|
||||
var current = auditTemplateSelect.val();
|
||||
if (current !== lastSelectedValue) {
|
||||
lastSelectedValue = current;
|
||||
loadStrategyParameters(current);
|
||||
}
|
||||
});
|
||||
observer.observe(auditTemplateSelect[0], {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
// If Chosen or similar libraries are used, listen for their update event
|
||||
// to catch programmatic selection changes.
|
||||
auditTemplateSelect.on('chosen:updated', function() {
|
||||
var current = auditTemplateSelect.val();
|
||||
if (current !== lastSelectedValue) {
|
||||
lastSelectedValue = current;
|
||||
loadStrategyParameters(current);
|
||||
}
|
||||
});
|
||||
|
||||
// Load parameters for initially selected template
|
||||
if (auditTemplateSelect.val()) {
|
||||
loadStrategyParameters(auditTemplateSelect.val());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@@ -19,6 +19,10 @@
|
||||
<dd>{{ audit.strategy_name|default:_("-") }}</dd>
|
||||
<dt>{% trans "Type" %}</dt>
|
||||
<dd>{{ audit.audit_type|default:_("-") }}</dd>
|
||||
{% if audit_parameters_pretty %}
|
||||
<dt>{% trans "Parameters" %}</dt>
|
||||
<dd>{{ audit_parameters_pretty|safe }}</dd>
|
||||
{% endif %}
|
||||
<dt>{% trans "Auto Trigger" %}</dt>
|
||||
<dd>{{ audit.auto_trigger }}</dd>
|
||||
{% url 'horizon:admin:audit_templates:detail' audit.audit_template_uuid as audit_template_url %}
|
||||
|
@@ -273,6 +273,82 @@ class WatcherAPITests(test.APITestCase):
|
||||
auto_trigger=True,
|
||||
name=audit_name)
|
||||
|
||||
def test_audit_create_with_parameters(self):
|
||||
audit = self.api_audits.first()
|
||||
audit_template_id = self.api_audit_templates.first()['uuid']
|
||||
|
||||
audit_type = self.api_audits.first()['audit_type']
|
||||
audit_name = self.api_audits.first()['name']
|
||||
audit_template_uuid = audit_template_id
|
||||
parameters = {'memory_threshold': 0.8, 'cpu_threshold': 0.9}
|
||||
|
||||
watcherclient = self.stub_watcherclient()
|
||||
watcherclient.audit.create = mock.Mock(
|
||||
return_value=audit)
|
||||
|
||||
ret_val = api.watcher.Audit.create(
|
||||
self.request, audit_template_uuid, audit_type, audit_name,
|
||||
False, None, parameters)
|
||||
self.assertIsInstance(ret_val, dict)
|
||||
watcherclient.audit.create.assert_called_with(
|
||||
audit_template_uuid=audit_template_uuid,
|
||||
audit_type=audit_type,
|
||||
auto_trigger=False,
|
||||
name=audit_name,
|
||||
parameters=parameters)
|
||||
|
||||
def test_audit_create_with_complex_parameters(self):
|
||||
audit = self.api_audits.first()
|
||||
audit_template_id = self.api_audit_templates.first()['uuid']
|
||||
|
||||
audit_type = self.api_audits.first()['audit_type']
|
||||
audit_name = self.api_audits.first()['name']
|
||||
audit_template_uuid = audit_template_id
|
||||
# Test complex JSON parameters like arrays and objects
|
||||
parameters = {
|
||||
'memory_threshold': 0.8,
|
||||
'enable_migration': True,
|
||||
'compute_nodes': [{'src_node': 's01', 'dst_node': 'd01'}],
|
||||
'excluded_instances': ['instance1', 'instance2']
|
||||
}
|
||||
|
||||
watcherclient = self.stub_watcherclient()
|
||||
watcherclient.audit.create = mock.Mock(
|
||||
return_value=audit)
|
||||
|
||||
ret_val = api.watcher.Audit.create(
|
||||
self.request, audit_template_uuid, audit_type, audit_name,
|
||||
False, None, parameters)
|
||||
self.assertIsInstance(ret_val, dict)
|
||||
watcherclient.audit.create.assert_called_with(
|
||||
audit_template_uuid=audit_template_uuid,
|
||||
audit_type=audit_type,
|
||||
auto_trigger=False,
|
||||
name=audit_name,
|
||||
parameters=parameters)
|
||||
|
||||
def test_audit_show_with_parameters(self):
|
||||
"""Test that audit detail view shows parameters when present"""
|
||||
audit = self.api_audits.first()
|
||||
audit_id = audit['uuid']
|
||||
# Add parameters to the audit data
|
||||
audit_with_params = dict(audit)
|
||||
audit_with_params['parameters'] = {
|
||||
'memory_threshold': 0.8,
|
||||
'cpu_threshold': 0.9
|
||||
}
|
||||
|
||||
watcherclient = self.stub_watcherclient()
|
||||
watcherclient.audit.get = mock.Mock(
|
||||
return_value=audit_with_params)
|
||||
|
||||
ret_val = api.watcher.Audit.get(self.request, audit_id)
|
||||
self.assertIsInstance(ret_val, dict)
|
||||
expected_params = {'memory_threshold': 0.8, 'cpu_threshold': 0.9}
|
||||
self.assertEqual(ret_val['parameters'], expected_params)
|
||||
watcherclient.audit.get.assert_called_with(
|
||||
audit=audit_id)
|
||||
|
||||
def test_audit_delete(self):
|
||||
audit_id = self.api_audits.first()['uuid']
|
||||
|
||||
|
Reference in New Issue
Block a user