mirror of
https://github.com/ansible/awx.git
synced 2026-03-22 03:17:39 -02:30
Merge branch 'release_3.0.1' into stable
This commit is contained in:
@@ -5,7 +5,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
__version__ = '3.0.0'
|
__version__ = '3.0.1'
|
||||||
|
|
||||||
__all__ = ['__version__']
|
__all__ = ['__version__']
|
||||||
|
|
||||||
|
|||||||
@@ -1573,6 +1573,7 @@ class InventoryScanJobTemplateList(SubListAPIView):
|
|||||||
|
|
||||||
class HostList(ListCreateAPIView):
|
class HostList(ListCreateAPIView):
|
||||||
|
|
||||||
|
always_allow_superuser = False
|
||||||
model = Host
|
model = Host
|
||||||
serializer_class = HostSerializer
|
serializer_class = HostSerializer
|
||||||
|
|
||||||
|
|||||||
@@ -245,16 +245,18 @@ class UserAccess(BaseAccess):
|
|||||||
|
|
||||||
|
|
||||||
def can_add(self, data):
|
def can_add(self, data):
|
||||||
if data is not None and 'is_superuser' in data:
|
if data is not None and ('is_superuser' in data or 'is_system_auditor' in data):
|
||||||
if to_python_boolean(data['is_superuser'], allow_none=True) and not self.user.is_superuser:
|
if (to_python_boolean(data.get('is_superuser', 'false'), allow_none=True) or
|
||||||
|
to_python_boolean(data.get('is_system_auditor', 'false'), allow_none=True)) and not self.user.is_superuser:
|
||||||
return False
|
return False
|
||||||
if self.user.is_superuser:
|
if self.user.is_superuser:
|
||||||
return True
|
return True
|
||||||
return Organization.accessible_objects(self.user, 'admin_role').exists()
|
return Organization.accessible_objects(self.user, 'admin_role').exists()
|
||||||
|
|
||||||
def can_change(self, obj, data):
|
def can_change(self, obj, data):
|
||||||
if data is not None and 'is_superuser' in data:
|
if data is not None and ('is_superuser' in data or 'is_system_auditor' in data):
|
||||||
if to_python_boolean(data['is_superuser'], allow_none=True) and not self.user.is_superuser:
|
if (to_python_boolean(data.get('is_superuser', 'false'), allow_none=True) or
|
||||||
|
to_python_boolean(data.get('is_system_auditor', 'false'), allow_none=True)) and not self.user.is_superuser:
|
||||||
return False
|
return False
|
||||||
# A user can be changed if they are themselves, or by org admins or
|
# A user can be changed if they are themselves, or by org admins or
|
||||||
# superusers. Change permission implies changing only certain fields
|
# superusers. Change permission implies changing only certain fields
|
||||||
@@ -720,18 +722,25 @@ class TeamAccess(BaseAccess):
|
|||||||
def can_attach(self, obj, sub_obj, relationship, *args, **kwargs):
|
def can_attach(self, obj, sub_obj, relationship, *args, **kwargs):
|
||||||
"""Reverse obj and sub_obj, defer to RoleAccess if this is an assignment
|
"""Reverse obj and sub_obj, defer to RoleAccess if this is an assignment
|
||||||
of a resource role to the team."""
|
of a resource role to the team."""
|
||||||
if isinstance(sub_obj, Role) and isinstance(sub_obj.content_object, ResourceMixin):
|
if isinstance(sub_obj, Role):
|
||||||
role_access = RoleAccess(self.user)
|
if sub_obj.content_object is None:
|
||||||
return role_access.can_attach(sub_obj, obj, 'member_role.parents',
|
raise PermissionDenied("The {} role cannot be assigned to a team".format(sub_obj.name))
|
||||||
*args, **kwargs)
|
elif isinstance(sub_obj.content_object, User):
|
||||||
|
raise PermissionDenied("The admin_role for a User cannot be assigned to a team")
|
||||||
|
|
||||||
|
if isinstance(sub_obj.content_object, ResourceMixin):
|
||||||
|
role_access = RoleAccess(self.user)
|
||||||
|
return role_access.can_attach(sub_obj, obj, 'member_role.parents',
|
||||||
|
*args, **kwargs)
|
||||||
return super(TeamAccess, self).can_attach(obj, sub_obj, relationship,
|
return super(TeamAccess, self).can_attach(obj, sub_obj, relationship,
|
||||||
*args, **kwargs)
|
*args, **kwargs)
|
||||||
|
|
||||||
def can_unattach(self, obj, sub_obj, relationship, *args, **kwargs):
|
def can_unattach(self, obj, sub_obj, relationship, *args, **kwargs):
|
||||||
if isinstance(sub_obj, Role) and isinstance(sub_obj.content_object, ResourceMixin):
|
if isinstance(sub_obj, Role):
|
||||||
role_access = RoleAccess(self.user)
|
if isinstance(sub_obj.content_object, ResourceMixin):
|
||||||
return role_access.can_unattach(sub_obj, obj, 'member_role.parents',
|
role_access = RoleAccess(self.user)
|
||||||
*args, **kwargs)
|
return role_access.can_unattach(sub_obj, obj, 'member_role.parents',
|
||||||
|
*args, **kwargs)
|
||||||
return super(TeamAccess, self).can_unattach(obj, sub_obj, relationship,
|
return super(TeamAccess, self).can_unattach(obj, sub_obj, relationship,
|
||||||
*args, **kwargs)
|
*args, **kwargs)
|
||||||
|
|
||||||
@@ -906,8 +915,7 @@ class JobTemplateAccess(BaseAccess):
|
|||||||
project = get_value(Project, 'project')
|
project = get_value(Project, 'project')
|
||||||
if 'job_type' in data and data['job_type'] == PERM_INVENTORY_SCAN:
|
if 'job_type' in data and data['job_type'] == PERM_INVENTORY_SCAN:
|
||||||
if inventory:
|
if inventory:
|
||||||
org = inventory.organization
|
accessible = self.user in inventory.use_role
|
||||||
accessible = self.user in org.admin_role
|
|
||||||
else:
|
else:
|
||||||
accessible = False
|
accessible = False
|
||||||
if not project and accessible:
|
if not project and accessible:
|
||||||
@@ -979,7 +987,8 @@ class JobTemplateAccess(BaseAccess):
|
|||||||
|
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
if hasattr(obj, k) and getattr(obj, k) != v:
|
if hasattr(obj, k) and getattr(obj, k) != v:
|
||||||
if k not in field_whitelist and v != getattr(obj, '%s_id' % k, None):
|
if k not in field_whitelist and v != getattr(obj, '%s_id' % k, None) \
|
||||||
|
and not (hasattr(obj, '%s_id' % k) and getattr(obj, '%s_id' % k) is None and v == ''): # Equate '' to None in the case of foreign keys
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1681,8 +1690,7 @@ class RoleAccess(BaseAccess):
|
|||||||
if not check_user_access(self.user, sub_obj.__class__, 'read', sub_obj):
|
if not check_user_access(self.user, sub_obj.__class__, 'read', sub_obj):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if obj.object_id and \
|
if isinstance(obj.content_object, ResourceMixin) and \
|
||||||
isinstance(obj.content_object, ResourceMixin) and \
|
|
||||||
self.user in obj.content_object.admin_role:
|
self.user in obj.content_object.admin_role:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ def load_inventory_source(source, all_group=None, group_filter_re=None,
|
|||||||
# Sanity check: We sanitize these module names for our API but Ansible proper doesn't follow
|
# Sanity check: We sanitize these module names for our API but Ansible proper doesn't follow
|
||||||
# good naming conventions
|
# good naming conventions
|
||||||
source = source.replace('azure.py', 'windows_azure.py')
|
source = source.replace('azure.py', 'windows_azure.py')
|
||||||
|
source = source.replace('satellite6.py', 'foreman.py')
|
||||||
logger.debug('Analyzing type of source: %s', source)
|
logger.debug('Analyzing type of source: %s', source)
|
||||||
original_all_group = all_group
|
original_all_group = all_group
|
||||||
if not os.path.exists(source):
|
if not os.path.exists(source):
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ from __future__ import unicode_literals
|
|||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
|
||||||
from awx.api.license import feature_enabled
|
|
||||||
|
|
||||||
|
|
||||||
def create_system_job_templates(apps, schema_editor):
|
def create_system_job_templates(apps, schema_editor):
|
||||||
'''
|
'''
|
||||||
Create default system job templates if not present. Create default schedules
|
Create default system job templates if not present. Create default schedules
|
||||||
@@ -80,7 +77,7 @@ def create_system_job_templates(apps, schema_editor):
|
|||||||
polymorphic_ctype=sjt_ct,
|
polymorphic_ctype=sjt_ct,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if created and feature_enabled('system_tracking', bypass_database=True):
|
if created:
|
||||||
sched = Schedule(
|
sched = Schedule(
|
||||||
name='Cleanup Fact Schedule',
|
name='Cleanup Fact Schedule',
|
||||||
rrule='DTSTART:%s RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=1' % now_str,
|
rrule='DTSTART:%s RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=1' % now_str,
|
||||||
|
|||||||
@@ -8,25 +8,8 @@ from collections import defaultdict
|
|||||||
from awx.main.utils import getattrd
|
from awx.main.utils import getattrd
|
||||||
from awx.main.models.rbac import Role, batch_role_ancestor_rebuilding
|
from awx.main.models.rbac import Role, batch_role_ancestor_rebuilding
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger('rbac_migrations')
|
||||||
|
|
||||||
def log_migration(wrapped):
|
|
||||||
'''setup the logging mechanism for each migration method
|
|
||||||
as it runs, Django resets this, so we use a decorator
|
|
||||||
to re-add the handler for each method.
|
|
||||||
'''
|
|
||||||
handler = logging.FileHandler("/tmp/tower_rbac_migrations.log", mode="a", encoding="UTF-8")
|
|
||||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
handler.setLevel(logging.DEBUG)
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
logger.handlers = []
|
|
||||||
logger.addHandler(handler)
|
|
||||||
return wrapped(*args, **kwargs)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
@log_migration
|
|
||||||
def create_roles(apps, schema_editor):
|
def create_roles(apps, schema_editor):
|
||||||
'''
|
'''
|
||||||
Implicit role creation happens in our post_save hook for all of our
|
Implicit role creation happens in our post_save hook for all of our
|
||||||
@@ -56,7 +39,6 @@ def create_roles(apps, schema_editor):
|
|||||||
obj.save()
|
obj.save()
|
||||||
|
|
||||||
|
|
||||||
@log_migration
|
|
||||||
def migrate_users(apps, schema_editor):
|
def migrate_users(apps, schema_editor):
|
||||||
User = apps.get_model('auth', "User")
|
User = apps.get_model('auth', "User")
|
||||||
Role = apps.get_model('main', "Role")
|
Role = apps.get_model('main', "Role")
|
||||||
@@ -89,7 +71,6 @@ def migrate_users(apps, schema_editor):
|
|||||||
sa_role.members.add(user)
|
sa_role.members.add(user)
|
||||||
logger.warning(smart_text(u"added superuser: {}".format(user.username)))
|
logger.warning(smart_text(u"added superuser: {}".format(user.username)))
|
||||||
|
|
||||||
@log_migration
|
|
||||||
def migrate_organization(apps, schema_editor):
|
def migrate_organization(apps, schema_editor):
|
||||||
Organization = apps.get_model('main', "Organization")
|
Organization = apps.get_model('main', "Organization")
|
||||||
for org in Organization.objects.iterator():
|
for org in Organization.objects.iterator():
|
||||||
@@ -100,7 +81,6 @@ def migrate_organization(apps, schema_editor):
|
|||||||
org.member_role.members.add(user)
|
org.member_role.members.add(user)
|
||||||
logger.info(smart_text(u"added member: {}, {}".format(org.name, user.username)))
|
logger.info(smart_text(u"added member: {}, {}".format(org.name, user.username)))
|
||||||
|
|
||||||
@log_migration
|
|
||||||
def migrate_team(apps, schema_editor):
|
def migrate_team(apps, schema_editor):
|
||||||
Team = apps.get_model('main', 'Team')
|
Team = apps.get_model('main', 'Team')
|
||||||
for t in Team.objects.iterator():
|
for t in Team.objects.iterator():
|
||||||
@@ -172,7 +152,6 @@ def _discover_credentials(instances, cred, orgfunc):
|
|||||||
|
|
||||||
_update_credential_parents(org, cred)
|
_update_credential_parents(org, cred)
|
||||||
|
|
||||||
@log_migration
|
|
||||||
def migrate_credential(apps, schema_editor):
|
def migrate_credential(apps, schema_editor):
|
||||||
Credential = apps.get_model('main', "Credential")
|
Credential = apps.get_model('main', "Credential")
|
||||||
JobTemplate = apps.get_model('main', 'JobTemplate')
|
JobTemplate = apps.get_model('main', 'JobTemplate')
|
||||||
@@ -180,7 +159,7 @@ def migrate_credential(apps, schema_editor):
|
|||||||
InventorySource = apps.get_model('main', 'InventorySource')
|
InventorySource = apps.get_model('main', 'InventorySource')
|
||||||
|
|
||||||
for cred in Credential.objects.iterator():
|
for cred in Credential.objects.iterator():
|
||||||
results = [x for x in JobTemplate.objects.filter(Q(credential=cred) | Q(cloud_credential=cred)).all()] + \
|
results = [x for x in JobTemplate.objects.filter(Q(credential=cred) | Q(cloud_credential=cred), inventory__isnull=False).all()] + \
|
||||||
[x for x in InventorySource.objects.filter(credential=cred).all()]
|
[x for x in InventorySource.objects.filter(credential=cred).all()]
|
||||||
if cred.deprecated_team is not None and results:
|
if cred.deprecated_team is not None and results:
|
||||||
if len(results) == 1:
|
if len(results) == 1:
|
||||||
@@ -210,7 +189,6 @@ def migrate_credential(apps, schema_editor):
|
|||||||
logger.warning(smart_text(u"orphaned credential found Credential(name={}, kind={}, host={}), superuser only".format(cred.name, cred.kind, cred.host, )))
|
logger.warning(smart_text(u"orphaned credential found Credential(name={}, kind={}, host={}), superuser only".format(cred.name, cred.kind, cred.host, )))
|
||||||
|
|
||||||
|
|
||||||
@log_migration
|
|
||||||
def migrate_inventory(apps, schema_editor):
|
def migrate_inventory(apps, schema_editor):
|
||||||
Inventory = apps.get_model('main', 'Inventory')
|
Inventory = apps.get_model('main', 'Inventory')
|
||||||
Permission = apps.get_model('main', 'Permission')
|
Permission = apps.get_model('main', 'Permission')
|
||||||
@@ -254,7 +232,6 @@ def migrate_inventory(apps, schema_editor):
|
|||||||
execrole.members.add(perm.user)
|
execrole.members.add(perm.user)
|
||||||
logger.info(smart_text(u'added User({}) access to Inventory({})'.format(perm.user.username, inventory.name)))
|
logger.info(smart_text(u'added User({}) access to Inventory({})'.format(perm.user.username, inventory.name)))
|
||||||
|
|
||||||
@log_migration
|
|
||||||
def migrate_projects(apps, schema_editor):
|
def migrate_projects(apps, schema_editor):
|
||||||
'''
|
'''
|
||||||
I can see projects when:
|
I can see projects when:
|
||||||
@@ -368,7 +345,6 @@ def migrate_projects(apps, schema_editor):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@log_migration
|
|
||||||
def migrate_job_templates(apps, schema_editor):
|
def migrate_job_templates(apps, schema_editor):
|
||||||
'''
|
'''
|
||||||
NOTE: This must be run after orgs, inventory, projects, credential, and
|
NOTE: This must be run after orgs, inventory, projects, credential, and
|
||||||
@@ -420,6 +396,11 @@ def migrate_job_templates(apps, schema_editor):
|
|||||||
jt_queryset = JobTemplate.objects.select_related('inventory', 'project', 'inventory__organization', 'execute_role')
|
jt_queryset = JobTemplate.objects.select_related('inventory', 'project', 'inventory__organization', 'execute_role')
|
||||||
|
|
||||||
for jt in jt_queryset.iterator():
|
for jt in jt_queryset.iterator():
|
||||||
|
if jt.inventory is None:
|
||||||
|
# If inventory is None, then only system admins and org admins can
|
||||||
|
# do anything with the JT in 2.4
|
||||||
|
continue
|
||||||
|
|
||||||
jt_permission_qs = Permission.objects.filter(
|
jt_permission_qs = Permission.objects.filter(
|
||||||
inventory=jt.inventory,
|
inventory=jt.inventory,
|
||||||
project=jt.project,
|
project=jt.project,
|
||||||
@@ -494,7 +475,6 @@ def migrate_job_templates(apps, schema_editor):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@log_migration
|
|
||||||
def rebuild_role_hierarchy(apps, schema_editor):
|
def rebuild_role_hierarchy(apps, schema_editor):
|
||||||
logger.info('Computing role roots..')
|
logger.info('Computing role roots..')
|
||||||
start = time()
|
start = time()
|
||||||
|
|||||||
@@ -9,25 +9,8 @@ from awx.fact.utils.dbtransform import KeyTransform
|
|||||||
from mongoengine.connection import ConnectionError
|
from mongoengine.connection import ConnectionError
|
||||||
from pymongo.errors import OperationFailure
|
from pymongo.errors import OperationFailure
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger('system_tracking_migrations')
|
||||||
|
|
||||||
def log_migration(wrapped):
|
|
||||||
'''setup the logging mechanism for each migration method
|
|
||||||
as it runs, Django resets this, so we use a decorator
|
|
||||||
to re-add the handler for each method.
|
|
||||||
'''
|
|
||||||
handler = logging.FileHandler("/tmp/tower_system_tracking_migrations.log", mode="a", encoding="UTF-8")
|
|
||||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
handler.setLevel(logging.DEBUG)
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
logger.handlers = []
|
|
||||||
logger.addHandler(handler)
|
|
||||||
return wrapped(*args, **kwargs)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
@log_migration
|
|
||||||
def migrate_facts(apps, schema_editor):
|
def migrate_facts(apps, schema_editor):
|
||||||
Fact = apps.get_model('main', "Fact")
|
Fact = apps.get_model('main', "Fact")
|
||||||
Host = apps.get_model('main', "Host")
|
Host = apps.get_model('main', "Host")
|
||||||
@@ -52,7 +35,7 @@ def migrate_facts(apps, schema_editor):
|
|||||||
migrated_count = 0
|
migrated_count = 0
|
||||||
not_migrated_count = 0
|
not_migrated_count = 0
|
||||||
transform = KeyTransform([('.', '\uff0E'), ('$', '\uff04')])
|
transform = KeyTransform([('.', '\uff0E'), ('$', '\uff04')])
|
||||||
for factver in FactVersion.objects.all():
|
for factver in FactVersion.objects.all().no_cache():
|
||||||
try:
|
try:
|
||||||
host = Host.objects.only('id').get(inventory__id=factver.host.inventory_id, name=factver.host.hostname)
|
host = Host.objects.only('id').get(inventory__id=factver.host.inventory_id, name=factver.host.hostname)
|
||||||
fact_obj = transform.replace_outgoing(factver.fact)
|
fact_obj = transform.replace_outgoing(factver.fact)
|
||||||
|
|||||||
@@ -2,25 +2,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from django.utils.encoding import smart_text
|
from django.utils.encoding import smart_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger('rbac_migrations')
|
||||||
|
|
||||||
def log_migration(wrapped):
|
|
||||||
'''setup the logging mechanism for each migration method
|
|
||||||
as it runs, Django resets this, so we use a decorator
|
|
||||||
to re-add the handler for each method.
|
|
||||||
'''
|
|
||||||
handler = logging.FileHandler("/tmp/tower_rbac_migrations.log", mode="a", encoding="UTF-8")
|
|
||||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
handler.setLevel(logging.DEBUG)
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
logger.handlers = []
|
|
||||||
logger.addHandler(handler)
|
|
||||||
return wrapped(*args, **kwargs)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
@log_migration
|
|
||||||
def migrate_team(apps, schema_editor):
|
def migrate_team(apps, schema_editor):
|
||||||
'''If an orphan team exists that is still active, delete it.'''
|
'''If an orphan team exists that is still active, delete it.'''
|
||||||
Team = apps.get_model('main', 'Team')
|
Team = apps.get_model('main', 'Team')
|
||||||
|
|||||||
@@ -873,7 +873,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
|||||||
if not self.cancel_flag:
|
if not self.cancel_flag:
|
||||||
self.cancel_flag = True
|
self.cancel_flag = True
|
||||||
cancel_fields = ['cancel_flag']
|
cancel_fields = ['cancel_flag']
|
||||||
if self.status in ('pending', 'waiting'):
|
if self.status in ('pending', 'waiting', 'new'):
|
||||||
self.status = 'canceled'
|
self.status = 'canceled'
|
||||||
cancel_fields.append('status')
|
cancel_fields.append('status')
|
||||||
self.save(update_fields=cancel_fields)
|
self.save(update_fields=cancel_fields)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
import json
|
|
||||||
|
|
||||||
from django.utils.encoding import smart_text
|
from django.utils.encoding import smart_text
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ class WebhookBackend(TowerBaseEmailBackend):
|
|||||||
self.headers['User-Agent'] = "Tower {}".format(get_awx_version())
|
self.headers['User-Agent'] = "Tower {}".format(get_awx_version())
|
||||||
for m in messages:
|
for m in messages:
|
||||||
r = requests.post("{}".format(m.recipients()[0]),
|
r = requests.post("{}".format(m.recipients()[0]),
|
||||||
data=json.dumps(m.body),
|
json=m.body,
|
||||||
headers=self.headers)
|
headers=self.headers)
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
logger.error(smart_text("Error sending notification webhook: {}".format(r.text)))
|
logger.error(smart_text("Error sending notification webhook: {}".format(r.text)))
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ from django.contrib.auth.models import User
|
|||||||
from awx.main.constants import CLOUD_PROVIDERS
|
from awx.main.constants import CLOUD_PROVIDERS
|
||||||
from awx.main.models import * # noqa
|
from awx.main.models import * # noqa
|
||||||
from awx.main.models import UnifiedJob
|
from awx.main.models import UnifiedJob
|
||||||
from awx.main.models.label import Label
|
|
||||||
from awx.main.queue import FifoQueue
|
from awx.main.queue import FifoQueue
|
||||||
from awx.main.conf import tower_settings
|
from awx.main.conf import tower_settings
|
||||||
from awx.main.task_engine import TaskSerializer, TASK_TIMEOUT_INTERVAL
|
from awx.main.task_engine import TaskSerializer, TASK_TIMEOUT_INTERVAL
|
||||||
@@ -123,13 +122,6 @@ def run_administrative_checks(self):
|
|||||||
tower_admin_emails,
|
tower_admin_emails,
|
||||||
fail_silently=True)
|
fail_silently=True)
|
||||||
|
|
||||||
@task(bind=True)
|
|
||||||
def run_label_cleanup(self):
|
|
||||||
qs = Label.get_orphaned_labels()
|
|
||||||
labels_count = qs.count()
|
|
||||||
qs.delete()
|
|
||||||
return labels_count
|
|
||||||
|
|
||||||
@task(bind=True)
|
@task(bind=True)
|
||||||
def cleanup_authtokens(self):
|
def cleanup_authtokens(self):
|
||||||
AuthToken.objects.filter(expires__lt=now()).delete()
|
AuthToken.objects.filter(expires__lt=now()).delete()
|
||||||
@@ -1307,9 +1299,11 @@ class RunInventoryUpdate(BaseTask):
|
|||||||
cp.set(section, 'password', decrypt_field(credential, 'password'))
|
cp.set(section, 'password', decrypt_field(credential, 'password'))
|
||||||
|
|
||||||
section = 'ansible'
|
section = 'ansible'
|
||||||
|
cp.add_section(section)
|
||||||
cp.set(section, 'group_patterns', '["{app}-{tier}-{color}", "{app}-{color}", "{app}", "{tier}"]')
|
cp.set(section, 'group_patterns', '["{app}-{tier}-{color}", "{app}-{color}", "{app}", "{tier}"]')
|
||||||
|
|
||||||
section = 'cache'
|
section = 'cache'
|
||||||
|
cp.add_section(section)
|
||||||
cp.set(section, 'path', '/tmp')
|
cp.set(section, 'path', '/tmp')
|
||||||
cp.set(section, 'max_age', '0')
|
cp.set(section, 'max_age', '0')
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ def mock_feature_enabled(feature, bypass_database=None):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def role():
|
def role():
|
||||||
return Role.objects.create()
|
return Role.objects.create(role_field='admin_role')
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -210,33 +210,33 @@ def test_get_teams_roles_list(get, team, organization, admin):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_add_role_to_teams(team, role, post, admin):
|
def test_add_role_to_teams(team, post, admin):
|
||||||
assert team.member_role.children.filter(id=role.id).count() == 0
|
assert team.member_role.children.filter(id=team.member_role.id).count() == 0
|
||||||
url = reverse('api:team_roles_list', args=(team.id,))
|
url = reverse('api:team_roles_list', args=(team.id,))
|
||||||
|
|
||||||
response = post(url, {'id': role.id}, admin)
|
response = post(url, {'id': team.member_role.id}, admin)
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert team.member_role.children.filter(id=role.id).count() == 1
|
assert team.member_role.children.filter(id=team.member_role.id).count() == 1
|
||||||
|
|
||||||
response = post(url, {'id': role.id}, admin)
|
response = post(url, {'id': team.member_role.id}, admin)
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert team.member_role.children.filter(id=role.id).count() == 1
|
assert team.member_role.children.filter(id=team.member_role.id).count() == 1
|
||||||
|
|
||||||
response = post(url, {}, admin)
|
response = post(url, {}, admin)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert team.member_role.children.filter(id=role.id).count() == 1
|
assert team.member_role.children.filter(id=team.member_role.id).count() == 1
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_remove_role_from_teams(team, role, post, admin):
|
def test_remove_role_from_teams(team, post, admin):
|
||||||
assert team.member_role.children.filter(id=role.id).count() == 0
|
assert team.member_role.children.filter(id=team.member_role.id).count() == 0
|
||||||
url = reverse('api:team_roles_list', args=(team.id,))
|
url = reverse('api:team_roles_list', args=(team.id,))
|
||||||
response = post(url, {'id': role.id}, admin)
|
response = post(url, {'id': team.member_role.id}, admin)
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert team.member_role.children.filter(id=role.id).count() == 1
|
assert team.member_role.children.filter(id=team.member_role.id).count() == 1
|
||||||
|
|
||||||
response = post(url, {'disassociate': role.id, 'id': role.id}, admin)
|
response = post(url, {'disassociate': team.member_role.id, 'id': team.member_role.id}, admin)
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert team.member_role.children.filter(id=role.id).count() == 0
|
assert team.member_role.children.filter(id=team.member_role.id).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ def test_team_attach_unattach(team, user):
|
|||||||
access = TeamAccess(u)
|
access = TeamAccess(u)
|
||||||
|
|
||||||
team.member_role.members.add(u)
|
team.member_role.members.add(u)
|
||||||
assert not access.can_attach(team, u.admin_role, 'member_role.children', None)
|
assert not access.can_attach(team, team.member_role, 'member_role.children', None)
|
||||||
assert not access.can_unattach(team, u.admin_role, 'member_role.children')
|
assert not access.can_unattach(team, team.member_role, 'member_role.children')
|
||||||
|
|
||||||
team.admin_role.members.add(u)
|
team.admin_role.members.add(u)
|
||||||
assert access.can_attach(team, u.admin_role, 'member_role.children', None)
|
assert access.can_attach(team, team.member_role, 'member_role.children', None)
|
||||||
assert access.can_unattach(team, u.admin_role, 'member_role.children')
|
assert access.can_unattach(team, team.member_role, 'member_role.children')
|
||||||
|
|
||||||
u2 = user('non-member', False)
|
u2 = user('non-member', False)
|
||||||
access = TeamAccess(u2)
|
access = TeamAccess(u2)
|
||||||
assert not access.can_attach(team, u2.admin_role, 'member_role.children', None)
|
assert not access.can_attach(team, team.member_role, 'member_role.children', None)
|
||||||
assert not access.can_unattach(team, u2.admin_role, 'member_role.chidlren')
|
assert not access.can_unattach(team, team.member_role, 'member_role.chidlren')
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_team_access_superuser(team, user):
|
def test_team_access_superuser(team, user):
|
||||||
|
|||||||
@@ -75,3 +75,16 @@ def test_org_user_removed(user, organization):
|
|||||||
|
|
||||||
organization.member_role.members.remove(member)
|
organization.member_role.members.remove(member)
|
||||||
assert admin not in member.admin_role
|
assert admin not in member.admin_role
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_org_admin_create_sys_auditor(org_admin):
|
||||||
|
access = UserAccess(org_admin)
|
||||||
|
assert not access.can_add(data=dict(
|
||||||
|
username='new_user', password="pa$$sowrd", email="asdf@redhat.com",
|
||||||
|
is_system_auditor='true'))
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_org_admin_edit_sys_auditor(org_admin, alice, organization):
|
||||||
|
organization.member_role.members.add(alice)
|
||||||
|
access = UserAccess(org_admin)
|
||||||
|
assert not access.can_change(obj=alice, data=dict(is_system_auditor='true'))
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.conf import settings
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
@pytest.mark.parametrize("job_name,function_path", [
|
@pytest.mark.parametrize("job_name,function_path", [
|
||||||
('label_cleanup', 'awx.main.tasks.run_label_cleanup'),
|
|
||||||
('admin_checks', 'awx.main.tasks.run_administrative_checks'),
|
('admin_checks', 'awx.main.tasks.run_administrative_checks'),
|
||||||
('tower_scheduler', 'awx.main.tasks.tower_periodic_scheduler'),
|
('tower_scheduler', 'awx.main.tasks.tower_periodic_scheduler'),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from awx.main.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from awx.main.tasks import (
|
from awx.main.tasks import (
|
||||||
run_label_cleanup,
|
|
||||||
send_notifications,
|
send_notifications,
|
||||||
run_administrative_checks,
|
run_administrative_checks,
|
||||||
)
|
)
|
||||||
@@ -21,16 +20,6 @@ def apply_patches(_patches):
|
|||||||
yield
|
yield
|
||||||
[p.stop() for p in _patches]
|
[p.stop() for p in _patches]
|
||||||
|
|
||||||
def test_run_label_cleanup(mocker):
|
|
||||||
qs = mocker.Mock(**{'count.return_value': 3, 'delete.return_value': None})
|
|
||||||
mock_label = mocker.patch('awx.main.models.label.Label.get_orphaned_labels',return_value=qs)
|
|
||||||
|
|
||||||
ret = run_label_cleanup()
|
|
||||||
|
|
||||||
mock_label.assert_called_with()
|
|
||||||
qs.delete.assert_called_with()
|
|
||||||
assert 3 == ret
|
|
||||||
|
|
||||||
def test_send_notifications_not_list():
|
def test_send_notifications_not_list():
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
send_notifications(None)
|
send_notifications(None)
|
||||||
|
|||||||
0
awx/plugins/inventory/cloudforms.py
Normal file → Executable file
0
awx/plugins/inventory/cloudforms.py
Normal file → Executable file
3
awx/plugins/inventory/foreman.py
Normal file → Executable file
3
awx/plugins/inventory/foreman.py
Normal file → Executable file
@@ -1,9 +1,8 @@
|
|||||||
#!/usr/bin/python
|
#!/usr/bin/python
|
||||||
|
# vim: set fileencoding=utf-8 :
|
||||||
#
|
#
|
||||||
# NOTE FOR TOWER: change foreman_ to sattelite_ for the group prefix
|
# NOTE FOR TOWER: change foreman_ to sattelite_ for the group prefix
|
||||||
#
|
#
|
||||||
# vim: set fileencoding=utf-8 :
|
|
||||||
#
|
|
||||||
# Copyright (C) 2016 Guido Günther <agx@sigxcpu.org>
|
# Copyright (C) 2016 Guido Günther <agx@sigxcpu.org>
|
||||||
#
|
#
|
||||||
# This script is free software: you can redistribute it and/or modify
|
# This script is free software: you can redistribute it and/or modify
|
||||||
|
|||||||
@@ -345,10 +345,6 @@ CELERYBEAT_SCHEDULE = {
|
|||||||
'task': 'awx.main.tasks.run_administrative_checks',
|
'task': 'awx.main.tasks.run_administrative_checks',
|
||||||
'schedule': timedelta(days=30)
|
'schedule': timedelta(days=30)
|
||||||
},
|
},
|
||||||
'label_cleanup': {
|
|
||||||
'task': 'awx.main.tasks.run_label_cleanup',
|
|
||||||
'schedule': timedelta(days=7)
|
|
||||||
},
|
|
||||||
'authtoken_cleanup': {
|
'authtoken_cleanup': {
|
||||||
'task': 'awx.main.tasks.cleanup_authtokens',
|
'task': 'awx.main.tasks.cleanup_authtokens',
|
||||||
'schedule': timedelta(days=30)
|
'schedule': timedelta(days=30)
|
||||||
@@ -676,6 +672,16 @@ OPENSTACK_HOST_FILTER = r'^.+$'
|
|||||||
OPENSTACK_EXCLUDE_EMPTY_GROUPS = True
|
OPENSTACK_EXCLUDE_EMPTY_GROUPS = True
|
||||||
OPENSTACK_INSTANCE_ID_VAR = 'openstack.id'
|
OPENSTACK_INSTANCE_ID_VAR = 'openstack.id'
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# ----- Foreman -----
|
||||||
|
# ---------------------
|
||||||
|
SATELLITE6_ENABLED_VAR = 'foreman.enabled'
|
||||||
|
SATELLITE6_ENABLED_VALUE = 'true'
|
||||||
|
SATELLITE6_GROUP_FILTER = r'^.+$'
|
||||||
|
SATELLITE6_HOST_FILTER = r'^.+$'
|
||||||
|
SATELLITE6_EXCLUDE_EMPTY_GROUPS = True
|
||||||
|
SATELLITE6_INSTANCE_ID_VAR = 'foreman.id'
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# -- Activity Stream --
|
# -- Activity Stream --
|
||||||
# ---------------------
|
# ---------------------
|
||||||
@@ -941,7 +947,34 @@ LOGGING = {
|
|||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||||
'backupCount': 5,
|
'backupCount': 5,
|
||||||
'formatter':'simple',
|
'formatter':'simple',
|
||||||
}
|
},
|
||||||
|
'fact_receiver': {
|
||||||
|
'level': 'WARNING',
|
||||||
|
'class':'logging.handlers.RotatingFileHandler',
|
||||||
|
'filters': ['require_debug_false'],
|
||||||
|
'filename': os.path.join(LOG_ROOT, 'fact_receiver.log'),
|
||||||
|
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||||
|
'backupCount': 5,
|
||||||
|
'formatter':'simple',
|
||||||
|
},
|
||||||
|
'system_tracking_migrations': {
|
||||||
|
'level': 'WARNING',
|
||||||
|
'class':'logging.handlers.RotatingFileHandler',
|
||||||
|
'filters': ['require_debug_false'],
|
||||||
|
'filename': os.path.join(LOG_ROOT, 'tower_system_tracking_migrations.log'),
|
||||||
|
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||||
|
'backupCount': 5,
|
||||||
|
'formatter':'simple',
|
||||||
|
},
|
||||||
|
'rbac_migrations': {
|
||||||
|
'level': 'WARNING',
|
||||||
|
'class':'logging.handlers.RotatingFileHandler',
|
||||||
|
'filters': ['require_debug_false'],
|
||||||
|
'filename': os.path.join(LOG_ROOT, 'tower_rbac_migrations.log'),
|
||||||
|
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||||
|
'backupCount': 5,
|
||||||
|
'formatter':'simple',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'loggers': {
|
'loggers': {
|
||||||
'django': {
|
'django': {
|
||||||
@@ -1000,6 +1033,14 @@ LOGGING = {
|
|||||||
'handlers': ['console', 'file', 'tower_warnings'],
|
'handlers': ['console', 'file', 'tower_warnings'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
},
|
},
|
||||||
|
'system_tracking_migrations': {
|
||||||
|
'handlers': ['console', 'file', 'tower_warnings'],
|
||||||
|
'level': 'DEBUG',
|
||||||
|
},
|
||||||
|
'rbac_migrations': {
|
||||||
|
'handlers': ['console', 'file', 'tower_warnings'],
|
||||||
|
'level': 'DEBUG',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,56 +49,13 @@ ANSIBLE_VENV_PATH = "/var/lib/awx/venv/ansible"
|
|||||||
TOWER_USE_VENV = True
|
TOWER_USE_VENV = True
|
||||||
TOWER_VENV_PATH = "/var/lib/awx/venv/tower"
|
TOWER_VENV_PATH = "/var/lib/awx/venv/tower"
|
||||||
|
|
||||||
LOGGING['handlers']['tower_warnings'] = {
|
LOGGING['handlers']['tower_warnings']['filename'] = '/var/log/tower/tower.log'
|
||||||
'level': 'WARNING',
|
LOGGING['handlers']['callback_receiver']['filename'] = '/var/log/tower/callback_receiver.log'
|
||||||
'class':'logging.handlers.RotatingFileHandler',
|
LOGGING['handlers']['socketio_service']['filename'] = '/var/log/tower/socketio_service.log'
|
||||||
'filters': ['require_debug_false'],
|
LOGGING['handlers']['task_system']['filename'] = '/var/log/tower/task_system.log'
|
||||||
'filename': '/var/log/tower/tower.log',
|
LOGGING['handlers']['fact_receiver']['filename'] = '/var/log/tower/fact_receiver.log'
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
LOGGING['handlers']['system_tracking_migrations']['filename'] = '/var/log/tower/tower_system_tracking_migrations.log'
|
||||||
'backupCount': 5,
|
LOGGING['handlers']['rbac_migrations']['filename'] = '/var/log/tower/tower_rbac_migrations.log'
|
||||||
'formatter':'simple',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
LOGGING['handlers']['callback_receiver'] = {
|
|
||||||
'level': 'WARNING',
|
|
||||||
'class':'logging.handlers.RotatingFileHandler',
|
|
||||||
'filters': ['require_debug_false'],
|
|
||||||
'filename': '/var/log/tower/callback_receiver.log',
|
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGGING['handlers']['socketio_service'] = {
|
|
||||||
'level': 'WARNING',
|
|
||||||
'class':'logging.handlers.RotatingFileHandler',
|
|
||||||
'filters': ['require_debug_false'],
|
|
||||||
'filename': '/var/log/tower/socketio_service.log',
|
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGGING['handlers']['task_system'] = {
|
|
||||||
'level': 'INFO',
|
|
||||||
'class':'logging.handlers.RotatingFileHandler',
|
|
||||||
'filters': ['require_debug_false'],
|
|
||||||
'filename': '/var/log/tower/task_system.log',
|
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGGING['handlers']['fact_receiver'] = {
|
|
||||||
'level': 'WARNING',
|
|
||||||
'class':'logging.handlers.RotatingFileHandler',
|
|
||||||
'filters': ['require_debug_false'],
|
|
||||||
'filename': '/var/log/tower/fact_receiver.log',
|
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Load settings from any .py files in the global conf.d directory specified in
|
# Load settings from any .py files in the global conf.d directory specified in
|
||||||
# the environment, defaulting to /etc/tower/conf.d/.
|
# the environment, defaulting to /etc/tower/conf.d/.
|
||||||
|
|||||||
@@ -872,7 +872,7 @@ var tower = angular.module('Tower', [
|
|||||||
} else {
|
} else {
|
||||||
var lastUser = $cookieStore.get('current_user'),
|
var lastUser = $cookieStore.get('current_user'),
|
||||||
timestammp = Store('sessionTime');
|
timestammp = Store('sessionTime');
|
||||||
if(lastUser && lastUser.id && timestammp && timestammp[lastUser.id]){
|
if(lastUser && lastUser.id && timestammp && timestammp[lastUser.id] && timestammp[lastUser.id].loggedIn){
|
||||||
var stime = timestammp[lastUser.id].time,
|
var stime = timestammp[lastUser.id].time,
|
||||||
now = new Date().getTime();
|
now = new Date().getTime();
|
||||||
if ((stime - now) <= 0) {
|
if ((stime - now) <= 0) {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function JobsListController ($rootScope, $log, $scope, $compile, $statePa
|
|||||||
list: AllJobsList,
|
list: AllJobsList,
|
||||||
id: 'active-jobs',
|
id: 'active-jobs',
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
url: GetBasePath('unified_jobs') + '?status__in=pending,waiting,running,completed,failed,successful,error,canceled&order_by=-finished',
|
url: GetBasePath('unified_jobs') + '?status__in=pending,waiting,running,completed,failed,successful,error,canceled,new&order_by=-finished',
|
||||||
searchParams: search_params,
|
searchParams: search_params,
|
||||||
spinner: false
|
spinner: false
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
|
import sys
|
||||||
|
if sys.prefix != '/var/lib/awx/venv/tower':
|
||||||
|
raise RuntimeError('Tower virtualenv not activated. Check WSGIPythonHome in Apache configuration.')
|
||||||
from awx.wsgi import application # NOQA
|
from awx.wsgi import application # NOQA
|
||||||
|
|||||||
@@ -3,11 +3,6 @@
|
|||||||
# All Rights Reserved
|
# All Rights Reserved
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings.development") # noqa
|
|
||||||
|
|
||||||
import django
|
|
||||||
django.setup() # noqa
|
|
||||||
|
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -15,7 +10,7 @@ from optparse import make_option, OptionParser
|
|||||||
|
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
|
import django
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
@@ -23,7 +18,8 @@ from django.db import transaction
|
|||||||
# awx
|
# awx
|
||||||
from awx.main.models import * # noqa
|
from awx.main.models import * # noqa
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings.development") # noqa
|
||||||
|
django.setup() # noqa
|
||||||
|
|
||||||
|
|
||||||
option_list = [
|
option_list = [
|
||||||
|
|||||||
Reference in New Issue
Block a user