mirror of
https://github.com/ansible/awx.git
synced 2026-03-23 11:55:04 -02:30
Merge pull request #8559 from ryanpetrello/yet-anooooooother-downstream-merge
Merge in some downstream bug fixes Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -453,7 +453,7 @@ class BaseSerializer(serializers.ModelSerializer, metaclass=BaseSerializerMetacl
|
|||||||
if 'capability_map' not in self.context:
|
if 'capability_map' not in self.context:
|
||||||
if hasattr(self, 'polymorphic_base'):
|
if hasattr(self, 'polymorphic_base'):
|
||||||
model = self.polymorphic_base.Meta.model
|
model = self.polymorphic_base.Meta.model
|
||||||
prefetch_list = self.polymorphic_base._capabilities_prefetch
|
prefetch_list = self.polymorphic_base.capabilities_prefetch
|
||||||
else:
|
else:
|
||||||
model = self.Meta.model
|
model = self.Meta.model
|
||||||
prefetch_list = self.capabilities_prefetch
|
prefetch_list = self.capabilities_prefetch
|
||||||
@@ -640,12 +640,9 @@ class EmptySerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class UnifiedJobTemplateSerializer(BaseSerializer):
|
class UnifiedJobTemplateSerializer(BaseSerializer):
|
||||||
# As a base serializer, the capabilities prefetch is not used directly
|
# As a base serializer, the capabilities prefetch is not used directly,
|
||||||
_capabilities_prefetch = [
|
# instead they are derived from the Workflow Job Template Serializer and the Job Template Serializer, respectively.
|
||||||
'admin', 'execute',
|
capabilities_prefetch = []
|
||||||
{'copy': ['jobtemplate.project.use', 'jobtemplate.inventory.use',
|
|
||||||
'organization.workflow_admin']}
|
|
||||||
]
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UnifiedJobTemplate
|
model = UnifiedJobTemplate
|
||||||
@@ -695,7 +692,7 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
|
|||||||
serializer.polymorphic_base = self
|
serializer.polymorphic_base = self
|
||||||
# capabilities prefetch is only valid for these models
|
# capabilities prefetch is only valid for these models
|
||||||
if isinstance(obj, (JobTemplate, WorkflowJobTemplate)):
|
if isinstance(obj, (JobTemplate, WorkflowJobTemplate)):
|
||||||
serializer.capabilities_prefetch = self._capabilities_prefetch
|
serializer.capabilities_prefetch = serializer_class.capabilities_prefetch
|
||||||
else:
|
else:
|
||||||
serializer.capabilities_prefetch = None
|
serializer.capabilities_prefetch = None
|
||||||
return serializer.to_representation(obj)
|
return serializer.to_representation(obj)
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
# Generated by Django 2.2.11 on 2020-05-01 13:25
|
# Generated by Django 2.2.11 on 2020-05-01 13:25
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from awx.main.migrations._inventory_source import create_scm_script_substitute
|
from awx.main.migrations._inventory_source import delete_cloudforms_inv_source
|
||||||
|
|
||||||
|
|
||||||
def convert_cloudforms_to_scm(apps, schema_editor):
|
|
||||||
create_scm_script_substitute(apps, 'cloudforms')
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@@ -15,7 +11,7 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RunPython(convert_cloudforms_to_scm),
|
migrations.RunPython(delete_cloudforms_inv_source),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='inventorysource',
|
model_name='inventorysource',
|
||||||
name='source',
|
name='source',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from uuid import uuid4
|
|||||||
from django.utils.encoding import smart_text
|
from django.utils.encoding import smart_text
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
|
||||||
|
from awx.main.utils.common import set_current_apps
|
||||||
from awx.main.utils.common import parse_yaml_or_json
|
from awx.main.utils.common import parse_yaml_or_json
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.migrations')
|
logger = logging.getLogger('awx.main.migrations')
|
||||||
@@ -91,43 +92,14 @@ def back_out_new_instance_id(apps, source, new_id):
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
def create_scm_script_substitute(apps, source):
|
def delete_cloudforms_inv_source(apps, schema_editor):
|
||||||
"""Only applies for cloudforms in practice, but written generally.
|
set_current_apps(apps)
|
||||||
Given a source type, this will replace all inventory sources of that type
|
|
||||||
with SCM inventory sources that source the script from Ansible core
|
|
||||||
"""
|
|
||||||
# the revision in the Ansible 2.9 stable branch this project will start out as
|
|
||||||
# it can still be updated manually later (but staying within 2.9 branch), if desired
|
|
||||||
ansible_rev = '6f83b9aff42331e15c55a171de0a8b001208c18c'
|
|
||||||
InventorySource = apps.get_model('main', 'InventorySource')
|
InventorySource = apps.get_model('main', 'InventorySource')
|
||||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
InventoryUpdate = apps.get_model('main', 'InventoryUpdate')
|
||||||
Project = apps.get_model('main', 'Project')
|
CredentialType = apps.get_model('main', 'CredentialType')
|
||||||
if not InventorySource.objects.filter(source=source).exists():
|
InventoryUpdate.objects.filter(inventory_source__source='cloudforms').delete()
|
||||||
logger.debug('No sources of type {} to migrate'.format(source))
|
InventorySource.objects.filter(source='cloudforms').delete()
|
||||||
return
|
ct = CredentialType.objects.filter(namespace='cloudforms').first()
|
||||||
proj_name = 'Replacement project for {} type sources - {}'.format(source, uuid4())
|
|
||||||
right_now = now()
|
|
||||||
project = Project.objects.create(
|
|
||||||
name=proj_name,
|
|
||||||
created=right_now,
|
|
||||||
modified=right_now,
|
|
||||||
description='Created by migration',
|
|
||||||
polymorphic_ctype=ContentType.objects.get(model='project'),
|
|
||||||
# project-specific fields
|
|
||||||
scm_type='git',
|
|
||||||
scm_url='https://github.com/ansible/ansible.git',
|
|
||||||
scm_branch='stable-2.9',
|
|
||||||
scm_revision=ansible_rev
|
|
||||||
)
|
|
||||||
ct = 0
|
|
||||||
for inv_src in InventorySource.objects.filter(source=source).iterator():
|
|
||||||
inv_src.source = 'scm'
|
|
||||||
inv_src.source_project = project
|
|
||||||
inv_src.source_path = 'contrib/inventory/{}.py'.format(source)
|
|
||||||
inv_src.scm_last_revision = ansible_rev
|
|
||||||
inv_src.save(update_fields=['source', 'source_project', 'source_path', 'scm_last_revision'])
|
|
||||||
logger.debug('Changed inventory source {} to scm type'.format(inv_src.pk))
|
|
||||||
ct += 1
|
|
||||||
if ct:
|
if ct:
|
||||||
logger.info('Changed total of {} inventory sources from {} type to scm'.format(ct, source))
|
ct.credentials.all().delete()
|
||||||
|
ct.delete()
|
||||||
|
|||||||
@@ -881,33 +881,6 @@ ManagedCredentialType(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
ManagedCredentialType(
|
|
||||||
namespace='cloudforms',
|
|
||||||
kind='cloud',
|
|
||||||
name=ugettext_noop('Red Hat CloudForms'),
|
|
||||||
managed_by_tower=True,
|
|
||||||
inputs={
|
|
||||||
'fields': [{
|
|
||||||
'id': 'host',
|
|
||||||
'label': ugettext_noop('CloudForms URL'),
|
|
||||||
'type': 'string',
|
|
||||||
'help_text': ugettext_noop('Enter the URL for the virtual machine that '
|
|
||||||
'corresponds to your CloudForms instance. '
|
|
||||||
'For example, https://cloudforms.example.org')
|
|
||||||
}, {
|
|
||||||
'id': 'username',
|
|
||||||
'label': ugettext_noop('Username'),
|
|
||||||
'type': 'string'
|
|
||||||
}, {
|
|
||||||
'id': 'password',
|
|
||||||
'label': ugettext_noop('Password'),
|
|
||||||
'type': 'string',
|
|
||||||
'secret': True,
|
|
||||||
}],
|
|
||||||
'required': ['host', 'username', 'password'],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ManagedCredentialType(
|
ManagedCredentialType(
|
||||||
namespace='gce',
|
namespace='gce',
|
||||||
kind='cloud',
|
kind='cloud',
|
||||||
|
|||||||
@@ -675,33 +675,6 @@ def test_net_create_ok(post, organization, admin):
|
|||||||
assert cred.inputs['authorize'] is True
|
assert cred.inputs['authorize'] is True
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Cloudforms Credentials
|
|
||||||
#
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_cloudforms_create_ok(post, organization, admin):
|
|
||||||
params = {
|
|
||||||
'credential_type': 1,
|
|
||||||
'name': 'Best credential ever',
|
|
||||||
'inputs': {
|
|
||||||
'host': 'some_host',
|
|
||||||
'username': 'some_username',
|
|
||||||
'password': 'some_password',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cloudforms = CredentialType.defaults['cloudforms']()
|
|
||||||
cloudforms.save()
|
|
||||||
params['organization'] = organization.id
|
|
||||||
response = post(reverse('api:credential_list'), params, admin)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
assert Credential.objects.count() == 1
|
|
||||||
cred = Credential.objects.all()[:1].get()
|
|
||||||
assert cred.inputs['host'] == 'some_host'
|
|
||||||
assert cred.inputs['username'] == 'some_username'
|
|
||||||
assert decrypt_field(cred, 'password') == 'some_password'
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# GCE Credentials
|
# GCE Credentials
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -282,10 +282,6 @@ def test_prefetch_ujt_project_capabilities(alice, project, job_template, mocker)
|
|||||||
list_serializer.child.to_representation(project)
|
list_serializer.child.to_representation(project)
|
||||||
assert 'capability_map' not in list_serializer.child.context
|
assert 'capability_map' not in list_serializer.child.context
|
||||||
|
|
||||||
# Models for which the prefetch is valid for do
|
|
||||||
list_serializer.child.to_representation(job_template)
|
|
||||||
assert set(list_serializer.child.context['capability_map'][job_template.id].keys()) == set(('copy', 'edit', 'start'))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_prefetch_group_capabilities(group, rando):
|
def test_prefetch_group_capabilities(group, rando):
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ def test_default_cred_types():
|
|||||||
'aws',
|
'aws',
|
||||||
'azure_kv',
|
'azure_kv',
|
||||||
'azure_rm',
|
'azure_rm',
|
||||||
'cloudforms',
|
|
||||||
'conjur',
|
'conjur',
|
||||||
'galaxy_api_token',
|
'galaxy_api_token',
|
||||||
'gce',
|
'gce',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from awx.main.migrations import _inventory_source as invsrc
|
|||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
|
||||||
from awx.main.models import InventorySource
|
from awx.main.models import InventorySource, InventoryUpdate, ManagedCredentialType, CredentialType, Credential
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('vars,id_var,result', [
|
@pytest.mark.parametrize('vars,id_var,result', [
|
||||||
@@ -42,16 +42,40 @@ def test_apply_new_instance_id(inventory_source):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_replacement_scm_sources(inventory):
|
def test_cloudforms_inventory_removal(inventory):
|
||||||
inv_source = InventorySource.objects.create(
|
ManagedCredentialType(
|
||||||
name='test',
|
name='Red Hat CloudForms',
|
||||||
inventory=inventory,
|
namespace='cloudforms',
|
||||||
organization=inventory.organization,
|
kind='cloud',
|
||||||
source='ec2'
|
managed_by_tower=True,
|
||||||
|
inputs={},
|
||||||
)
|
)
|
||||||
invsrc.create_scm_script_substitute(apps, 'ec2')
|
CredentialType.defaults['cloudforms']().save()
|
||||||
inv_source.refresh_from_db()
|
cloudforms = CredentialType.objects.get(namespace='cloudforms')
|
||||||
assert inv_source.source == 'scm'
|
Credential.objects.create(
|
||||||
assert inv_source.source_project
|
name='test',
|
||||||
project = inv_source.source_project
|
credential_type=cloudforms,
|
||||||
assert 'Replacement project for' in project.name
|
)
|
||||||
|
|
||||||
|
for source in ('ec2', 'cloudforms'):
|
||||||
|
i = InventorySource.objects.create(
|
||||||
|
name='test',
|
||||||
|
inventory=inventory,
|
||||||
|
organization=inventory.organization,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
InventoryUpdate.objects.create(
|
||||||
|
name='test update',
|
||||||
|
inventory_source=i,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
assert Credential.objects.count() == 1
|
||||||
|
assert InventorySource.objects.count() == 2 # ec2 + cf
|
||||||
|
assert InventoryUpdate.objects.count() == 2 # ec2 + cf
|
||||||
|
invsrc.delete_cloudforms_inv_source(apps, None)
|
||||||
|
assert InventorySource.objects.count() == 1 # ec2
|
||||||
|
assert InventoryUpdate.objects.count() == 1 # ec2
|
||||||
|
assert InventorySource.objects.first().source == 'ec2'
|
||||||
|
assert InventoryUpdate.objects.first().source == 'ec2'
|
||||||
|
assert Credential.objects.count() == 0
|
||||||
|
assert CredentialType.objects.filter(namespace='cloudforms').exists() is False
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function AddEditCredentialsController (
|
|||||||
const isExternal = credentialType.get('kind') === 'external';
|
const isExternal = credentialType.get('kind') === 'external';
|
||||||
const mode = $state.current.name.startsWith('credentials.add') ? 'add' : 'edit';
|
const mode = $state.current.name.startsWith('credentials.add') ? 'add' : 'edit';
|
||||||
|
|
||||||
|
vm.isEditable = credential.get('summary_fields.user_capabilities.edit');
|
||||||
vm.mode = mode;
|
vm.mode = mode;
|
||||||
vm.strings = strings;
|
vm.strings = strings;
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ function AddEditCredentialsController (
|
|||||||
vm.form = credential.createFormSchema({ omit });
|
vm.form = credential.createFormSchema({ omit });
|
||||||
vm.form.disabled = !isEditable;
|
vm.form.disabled = !isEditable;
|
||||||
}
|
}
|
||||||
|
vm.form.disabled = !vm.isEditable;
|
||||||
|
|
||||||
vm.form._organization._disabled = !isOrgEditableByUser;
|
vm.form._organization._disabled = !isOrgEditableByUser;
|
||||||
// Only exists for permissions compatibility
|
// Only exists for permissions compatibility
|
||||||
|
|||||||
@@ -29,30 +29,10 @@ export default ['$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors', '$q', 'Con
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.analytics_status === 'detailed') {
|
|
||||||
this.setDetailed(options, config);
|
|
||||||
} else if (config.analytics_status === 'anonymous') {
|
|
||||||
this.setAnonymous(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Detailed mode sends:
|
|
||||||
// VisitorId: userid+hash of license_key
|
|
||||||
// AccountId: hash of license_key from license
|
|
||||||
setDetailed: function(options, config) {
|
|
||||||
// config.deployment_id is a hash of the tower license_key
|
|
||||||
options.visitor.id = $rootScope.current_user.id + '@' + config.deployment_id;
|
|
||||||
options.account.id = config.deployment_id;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Anonymous mode sends:
|
|
||||||
// VisitorId: <hardcoded id that is the same across all anonymous>
|
|
||||||
// AccountId: <hardcoded id that is the same across all anonymous>
|
|
||||||
setAnonymous: function (options) {
|
|
||||||
options.visitor.id = 0;
|
options.visitor.id = 0;
|
||||||
options.account.id = "tower.ansible.com";
|
options.account.id = "tower.ansible.com";
|
||||||
|
|
||||||
|
return options;
|
||||||
},
|
},
|
||||||
|
|
||||||
setRole: function(options) {
|
setRole: function(options) {
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ EXAMPLES = '''
|
|||||||
- name: Export all tower assets
|
- name: Export all tower assets
|
||||||
tower_export:
|
tower_export:
|
||||||
all: True
|
all: True
|
||||||
|
|
||||||
- name: Export all inventories
|
- name: Export all inventories
|
||||||
tower_export:
|
tower_export:
|
||||||
inventory: 'all'
|
inventory: 'all'
|
||||||
|
|
||||||
- name: Export a job template named "My Template" and all Credentials
|
- name: Export a job template named "My Template" and all Credentials
|
||||||
tower_export:
|
tower_export:
|
||||||
job_template: "My Template"
|
job_template: "My Template"
|
||||||
@@ -135,27 +137,27 @@ def main():
|
|||||||
# Otherwise we take either the string or None (if the parameter was not passed) to get one or no items
|
# Otherwise we take either the string or None (if the parameter was not passed) to get one or no items
|
||||||
export_args[resource] = module.params.get(resource)
|
export_args[resource] = module.params.get(resource)
|
||||||
|
|
||||||
# Currently the import process does not return anything on error
|
# Currently the export process does not return anything on error
|
||||||
# It simply just logs to pythons logger
|
# It simply just logs to Python's logger
|
||||||
# Setup a log gobbler to get error messages from import_assets
|
# Set up a log gobbler to get error messages from export_assets
|
||||||
log_capture_string = StringIO()
|
log_capture_string = StringIO()
|
||||||
ch = logging.StreamHandler(log_capture_string)
|
ch = logging.StreamHandler(log_capture_string)
|
||||||
for logger_name in ['awxkit.api.pages.api', 'awxkit.api.pages.page']:
|
for logger_name in ['awxkit.api.pages.api', 'awxkit.api.pages.page']:
|
||||||
logger = logging.getLogger(logger_name)
|
logger = logging.getLogger(logger_name)
|
||||||
logger.setLevel(logging.WARNING)
|
logger.setLevel(logging.ERROR)
|
||||||
ch.setLevel(logging.WARNING)
|
ch.setLevel(logging.ERROR)
|
||||||
|
|
||||||
logger.addHandler(ch)
|
logger.addHandler(ch)
|
||||||
log_contents = ''
|
log_contents = ''
|
||||||
|
|
||||||
# Run the import process
|
# Run the export process
|
||||||
try:
|
try:
|
||||||
module.json_output['assets'] = module.get_api_v2_object().export_assets(**export_args)
|
module.json_output['assets'] = module.get_api_v2_object().export_assets(**export_args)
|
||||||
module.exit_json(**module.json_output)
|
module.exit_json(**module.json_output)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
module.fail_json(msg="Failed to export assets {0}".format(e))
|
module.fail_json(msg="Failed to export assets {0}".format(e))
|
||||||
finally:
|
finally:
|
||||||
# Finally consume the logs incase there were any errors and die if there were
|
# Finally, consume the logs in case there were any errors and die if there were
|
||||||
log_contents = log_capture_string.getvalue()
|
log_contents = log_capture_string.getvalue()
|
||||||
log_capture_string.close()
|
log_capture_string.close()
|
||||||
if log_contents != '':
|
if log_contents != '':
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ EXAMPLES = '''
|
|||||||
- name: Export all assets
|
- name: Export all assets
|
||||||
tower_export:
|
tower_export:
|
||||||
all: True
|
all: True
|
||||||
registeR: export_output
|
register: export_output
|
||||||
|
|
||||||
- name: Import all tower assets from our export
|
- name: Import all tower assets from our export
|
||||||
tower_import:
|
tower_import:
|
||||||
@@ -51,7 +51,7 @@ EXAMPLES = '''
|
|||||||
|
|
||||||
from ..module_utils.tower_awxkit import TowerAWXKitModule
|
from ..module_utils.tower_awxkit import TowerAWXKitModule
|
||||||
|
|
||||||
# These two lines are not needed if awxkit changes to do progamatic notifications on issues
|
# These two lines are not needed if awxkit changes to do programatic notifications on issues
|
||||||
from ansible.module_utils.six.moves import StringIO
|
from ansible.module_utils.six.moves import StringIO
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -76,13 +76,15 @@ def main():
|
|||||||
module.fail_json(msg="Your version of awxkit does not appear to have import/export")
|
module.fail_json(msg="Your version of awxkit does not appear to have import/export")
|
||||||
|
|
||||||
# Currently the import process does not return anything on error
|
# Currently the import process does not return anything on error
|
||||||
# It simply just logs to pythons logger
|
# It simply just logs to Python's logger
|
||||||
# Setup a log gobbler to get error messages from import_assets
|
# Set up a log gobbler to get error messages from import_assets
|
||||||
logger = logging.getLogger('awxkit.api.pages.api')
|
logger = logging.getLogger('awxkit.api.pages.api')
|
||||||
logger.setLevel(logging.WARNING)
|
logger.setLevel(logging.ERROR)
|
||||||
|
|
||||||
log_capture_string = StringIO()
|
log_capture_string = StringIO()
|
||||||
ch = logging.StreamHandler(log_capture_string)
|
ch = logging.StreamHandler(log_capture_string)
|
||||||
ch.setLevel(logging.WARNING)
|
ch.setLevel(logging.ERROR)
|
||||||
|
|
||||||
logger.addHandler(ch)
|
logger.addHandler(ch)
|
||||||
log_contents = ''
|
log_contents = ''
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ def main():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
module.fail_json(msg="Failed to import assets {0}".format(e))
|
module.fail_json(msg="Failed to import assets {0}".format(e))
|
||||||
finally:
|
finally:
|
||||||
# Finally consume the logs incase there were any errors and die if there were
|
# Finally, consume the logs in case there were any errors and die if there were
|
||||||
log_contents = log_capture_string.getvalue()
|
log_contents = log_capture_string.getvalue()
|
||||||
log_capture_string.close()
|
log_capture_string.close()
|
||||||
if log_contents != '':
|
if log_contents != '':
|
||||||
|
|||||||
Reference in New Issue
Block a user