diff --git a/allianceauth/services/modules/teamspeak3/admin.py b/allianceauth/services/modules/teamspeak3/admin.py index a8b614d8..dbba1cb1 100644 --- a/allianceauth/services/modules/teamspeak3/admin.py +++ b/allianceauth/services/modules/teamspeak3/admin.py @@ -1,7 +1,8 @@ from django.contrib import admin - -from .models import AuthTS, Teamspeak3User, StateGroup +from django.contrib.auth.models import Group +from .models import AuthTS, Teamspeak3User, StateGroup, TSgroup from ...admin import ServicesUserAdmin +from allianceauth.groupmanagement.models import ReservedGroupName @admin.register(Teamspeak3User) @@ -25,6 +26,16 @@ class AuthTSgroupAdmin(admin.ModelAdmin): fields = ('auth_group', 'ts_group') filter_horizontal = ('ts_group',) + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == 'auth_group': + kwargs['queryset'] = Group.objects.exclude(name__in=ReservedGroupName.objects.values_list('name', flat=True)) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def formfield_for_manytomany(self, db_field, request, **kwargs): + if db_field.name == 'ts_group': + kwargs['queryset'] = TSgroup.objects.exclude(ts_group_name__in=ReservedGroupName.objects.values_list('name', flat=True)) + return super().formfield_for_manytomany(db_field, request, **kwargs) + def _ts_group(self, obj): return [x for x in obj.ts_group.all().order_by('ts_group_id')] diff --git a/allianceauth/services/modules/teamspeak3/manager.py b/allianceauth/services/modules/teamspeak3/manager.py index d05f910d..519a8a47 100755 --- a/allianceauth/services/modules/teamspeak3/manager.py +++ b/allianceauth/services/modules/teamspeak3/manager.py @@ -4,6 +4,7 @@ from django.conf import settings from .util.ts3 import TS3Server, TeamspeakError from .models import TSgroup +from allianceauth.groupmanagement.models import ReservedGroupName logger = logging.getLogger(__name__) @@ -156,32 +157,25 @@ class Teamspeak3Manager: logger.info(f"Removed user id {uid} from group id {groupid} on TS3 server.") def _sync_ts_group_db(self): - logger.debug("_sync_ts_group_db function called.") try: remote_groups = self._group_list() - local_groups = TSgroup.objects.all() - logger.debug("Comparing remote groups to TSgroup objects: %s" % local_groups) - for key in remote_groups: - logger.debug(f"Typecasting remote_group value at position {key} to int: {remote_groups[key]}") - remote_groups[key] = int(remote_groups[key]) + managed_groups = {g:int(remote_groups[g]) for g in remote_groups if g in set(remote_groups.keys()) - set(ReservedGroupName.objects.values_list('name', flat=True))} + remove = TSgroup.objects.exclude(ts_group_id__in=managed_groups.values()) + + if remove: + logger.debug(f"Deleting {remove.count()} TSgroup models: not found on server, or reserved name.") + remove.delete() + + add = {g:managed_groups[g] for g in managed_groups if managed_groups[g] in set(managed_groups.values()) - set(TSgroup.objects.values_list("ts_group_id", flat=True))} + if add: + logger.debug(f"Adding {len(add)} new TSgroup models.") + models = [TSgroup(ts_group_name=name, ts_group_id=add[name]) for name in add] + TSgroup.objects.bulk_create(models) - for group in local_groups: - logger.debug("Checking local group %s" % group) - if group.ts_group_id not in remote_groups.values(): - logger.debug( - f"Local group id {group.ts_group_id} not found on server. Deleting model {group}") - TSgroup.objects.filter(ts_group_id=group.ts_group_id).delete() - for key in remote_groups: - g = TSgroup(ts_group_id=remote_groups[key], ts_group_name=key) - q = TSgroup.objects.filter(ts_group_id=g.ts_group_id) - if not q: - logger.debug("Local group does not exist for TS group {}. Creating TSgroup model {}".format( - remote_groups[key], g)) - g.save() except TeamspeakError as e: - logger.error("Error occured while syncing TS group db: %s" % str(e)) - except: - logger.exception("An unhandled exception has occured while syncing TS groups.") + logger.error(f"Error occurred while syncing TS group db: {str(e)}") + except Exception: + logger.exception(f"An unhandled exception has occurred while syncing TS groups.") def add_user(self, user, fmt_name): username_clean = self.__santatize_username(fmt_name[:30]) @@ -240,7 +234,7 @@ class Teamspeak3Manager: logger.exception(f"Failed to delete user id {uid} from TS3 - received response {ret}") return False else: - logger.warn("User with id %s not found on TS3 server. Assuming succesful deletion." % uid) + logger.warning("User with id %s not found on TS3 server. Assuming succesful deletion." % uid) return True def check_user_exists(self, uid): @@ -270,7 +264,8 @@ class Teamspeak3Manager: addgroups.append(ts_groups[ts_group_key]) for user_ts_group_key in user_ts_groups: if user_ts_groups[user_ts_group_key] not in ts_groups.values(): - remgroups.append(user_ts_groups[user_ts_group_key]) + if not ReservedGroupName.objects.filter(name=user_ts_group_key).exists(): + remgroups.append(user_ts_groups[user_ts_group_key]) for g in addgroups: logger.info(f"Adding Teamspeak user {userid} into group {g}") diff --git a/allianceauth/services/modules/teamspeak3/tests.py b/allianceauth/services/modules/teamspeak3/tests.py index 1cb923ed..2354bf98 100644 --- a/allianceauth/services/modules/teamspeak3/tests.py +++ b/allianceauth/services/modules/teamspeak3/tests.py @@ -5,16 +5,18 @@ from django import urls from django.contrib.auth.models import User, Group, Permission from django.core.exceptions import ObjectDoesNotExist from django.db.models import signals +from django.contrib.admin import AdminSite from allianceauth.tests.auth_utils import AuthUtils from .auth_hooks import Teamspeak3Service from .models import Teamspeak3User, AuthTS, TSgroup, StateGroup from .tasks import Teamspeak3Tasks from .signals import m2m_changed_authts_group, post_save_authts, post_delete_authts +from .admin import AuthTSgroupAdmin from .manager import Teamspeak3Manager from .util.ts3 import TeamspeakError -from allianceauth.authentication.models import State +from allianceauth.groupmanagement.models import ReservedGroupName MODULE_PATH = 'allianceauth.services.modules.teamspeak3' DEFAULT_AUTH_GROUP = 'Member' @@ -315,6 +317,9 @@ class Teamspeak3SignalsTestCase(TestCase): class Teamspeak3ManagerTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.reserved = ReservedGroupName.objects.create(name='reserved', reason='tests', created_by='Bob, praise be!') @staticmethod def my_side_effect(*args, **kwargs): @@ -334,8 +339,135 @@ class Teamspeak3ManagerTestCase(TestCase): manager._server = server # create test data - user = User.objects.create_user("dummy") - user.profile.state = State.objects.filter(name="Member").first() + user = AuthUtils.create_user("dummy") + AuthUtils.assign_state(user, AuthUtils.get_member_state()) # perform test manager.add_user(user, "Dummy User") + + @mock.patch.object(Teamspeak3Manager, '_get_userid') + @mock.patch.object(Teamspeak3Manager, '_user_group_list') + @mock.patch.object(Teamspeak3Manager, '_add_user_to_group') + @mock.patch.object(Teamspeak3Manager, '_remove_user_from_group') + def test_update_groups_add(self, remove, add, groups, userid): + """Add to one group""" + userid.return_value = 1 + groups.return_value = {'test': 1} + + Teamspeak3Manager().update_groups(1, {'test': 1, 'dummy': 2}) + self.assertEqual(add.call_count, 1) + self.assertEqual(remove.call_count, 0) + self.assertEqual(add.call_args[0][1], 2) + + @mock.patch.object(Teamspeak3Manager, '_get_userid') + @mock.patch.object(Teamspeak3Manager, '_user_group_list') + @mock.patch.object(Teamspeak3Manager, '_add_user_to_group') + @mock.patch.object(Teamspeak3Manager, '_remove_user_from_group') + def test_update_groups_remove(self, remove, add, groups, userid): + """Remove from one group""" + userid.return_value = 1 + groups.return_value = {'test': '1', 'dummy': '2'} + + Teamspeak3Manager().update_groups(1, {'test': 1}) + self.assertEqual(add.call_count, 0) + self.assertEqual(remove.call_count, 1) + self.assertEqual(remove.call_args[0][1], 2) + + @mock.patch.object(Teamspeak3Manager, '_get_userid') + @mock.patch.object(Teamspeak3Manager, '_user_group_list') + @mock.patch.object(Teamspeak3Manager, '_add_user_to_group') + @mock.patch.object(Teamspeak3Manager, '_remove_user_from_group') + def test_update_groups_remove_reserved(self, remove, add, groups, userid): + """Remove from one group, but do not touch reserved group""" + userid.return_value = 1 + groups.return_value = {'test': 1, 'dummy': 2, self.reserved.name: 3} + + Teamspeak3Manager().update_groups(1, {'test': 1}) + self.assertEqual(add.call_count, 0) + self.assertEqual(remove.call_count, 1) + self.assertEqual(remove.call_args[0][1], 2) + + @mock.patch.object(Teamspeak3Manager, '_group_list') + def test_sync_group_db_create(self, group_list): + """Populate the list of all TSgroups""" + group_list.return_value = {'allowed':'1', 'also allowed':'2'} + Teamspeak3Manager()._sync_ts_group_db() + self.assertEqual(TSgroup.objects.all().count(), 2) + + @mock.patch.object(Teamspeak3Manager, '_group_list') + def test_sync_group_db_delete(self, group_list): + """Populate the list of all TSgroups, and delete one which no longer exists""" + TSgroup.objects.create(ts_group_name='deleted', ts_group_id=3) + group_list.return_value = {'allowed': '1'} + Teamspeak3Manager()._sync_ts_group_db() + self.assertEqual(TSgroup.objects.all().count(), 1) + self.assertFalse(TSgroup.objects.filter(ts_group_name='deleted').exists()) + + @mock.patch.object(Teamspeak3Manager, '_group_list') + def test_sync_group_db_dont_create_reserved(self, group_list): + """Populate the list of all TSgroups, ignoring a reserved group name""" + group_list.return_value = {'allowed': '1', 'reserved': '4'} + Teamspeak3Manager()._sync_ts_group_db() + self.assertEqual(TSgroup.objects.all().count(), 1) + self.assertFalse(TSgroup.objects.filter(ts_group_name='reserved').exists()) + + @mock.patch.object(Teamspeak3Manager, '_group_list') + def test_sync_group_db_delete_reserved(self, group_list): + """Populate the list of all TSgroups, deleting the TSgroup model for one which has become reserved""" + TSgroup.objects.create(ts_group_name='reserved', ts_group_id=4) + group_list.return_value = {'allowed': '1', 'reserved': '4'} + Teamspeak3Manager()._sync_ts_group_db() + self.assertEqual(TSgroup.objects.all().count(), 1) + self.assertFalse(TSgroup.objects.filter(ts_group_name='reserved').exists()) + + @mock.patch.object(Teamspeak3Manager, '_group_list') + def test_sync_group_db_partial_addition(self, group_list): + """Some TSgroups already exist in database, add new ones""" + TSgroup.objects.create(ts_group_name='allowed', ts_group_id=1) + group_list.return_value = {'allowed': '1', 'also allowed': '2'} + Teamspeak3Manager()._sync_ts_group_db() + self.assertEqual(TSgroup.objects.all().count(), 2) + + @mock.patch.object(Teamspeak3Manager, '_group_list') + def test_sync_group_db_partial_removal(self, group_list): + """One TSgroup has been deleted on server, so remove its model""" + TSgroup.objects.create(ts_group_name='allowed', ts_group_id=1) + TSgroup.objects.create(ts_group_name='also allowed', ts_group_id=2) + group_list.return_value = {'allowed': '1'} + Teamspeak3Manager()._sync_ts_group_db() + self.assertEqual(TSgroup.objects.all().count(), 1) + + +class MockRequest: + pass + + +class MockSuperUser: + def has_perm(self, perm, obj=None): + return True + + +request = MockRequest() +request.user = MockSuperUser() + + +class Teamspeak3AdminTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.site = AdminSite() + cls.admin = AuthTSgroupAdmin(AuthTS, cls.site) + cls.group = Group.objects.create(name='test') + cls.ts_group = TSgroup.objects.create(ts_group_name='test') + + def test_field_queryset_no_reserved_names(self): + """Ensure all groups are listed when no reserved names""" + form = self.admin.get_form(request) + self.assertQuerysetEqual(form.base_fields['auth_group']._get_queryset(), Group.objects.all()) + self.assertQuerysetEqual(form.base_fields['ts_group']._get_queryset(), TSgroup.objects.all()) + + def test_field_queryset_reserved_names(self): + """Ensure reserved group names are filtered out""" + ReservedGroupName.objects.bulk_create([ReservedGroupName(name='test', reason='tests', created_by='Bob')]) + form = self.admin.get_form(request) + self.assertQuerysetEqual(form.base_fields['auth_group']._get_queryset(), Group.objects.none()) + self.assertQuerysetEqual(form.base_fields['ts_group']._get_queryset(), TSgroup.objects.none()) diff --git a/docs/features/core/groups.md b/docs/features/core/groups.md index 0d75d327..2c15cb91 100644 --- a/docs/features/core/groups.md +++ b/docs/features/core/groups.md @@ -48,7 +48,7 @@ When using Alliance Auth to manage external services like Discord, Auth will aut ```eval_rst .. note:: - While this feature can help to avoid naming conflicts with groups on external services, the respective service component in Alliance Auth also needs to be build in such a way that it knows how to prevent these conflicts. Currently only the Discord service has this ability. + While this feature can help to avoid naming conflicts with groups on external services, the respective service component in Alliance Auth also needs to be build in such a way that it knows how to prevent these conflicts. Currently only the Discord and Teamspeak3 services have this ability. ``` ## Managing groups diff --git a/docs/features/services/teamspeak3.md b/docs/features/services/teamspeak3.md index 80c7dad3..507ab1bc 100644 --- a/docs/features/services/teamspeak3.md +++ b/docs/features/services/teamspeak3.md @@ -160,7 +160,7 @@ This error generally means teamspeak returned an error message that went unhandl This most commonly happens when your teamspeak server is externally hosted. You need to add the auth server IP to the teamspeak serverquery whitelist. This varies by provider. -If you have SSH access to the server hosting it, you need to locate the teamspeak server folder and add the auth server IP on a new line in `server_query_whitelist.txt` +If you have SSH access to the server hosting it, you need to locate the teamspeak server folder and add the auth server IP on a new line in `query_ip_allowlist.txt` (named `query_ip_whitelist.txt` on older teamspeak versions). ### `520 invalid loginname or password`