From 70c4b1751824033a03398e6123c6dd25171667be Mon Sep 17 00:00:00 2001 From: Basraah Date: Mon, 4 Dec 2017 12:52:05 +1000 Subject: [PATCH] [v2] Alliance and Corp Autogroups (#902) * Add pseudo foreign keys to EveCharacter model * Ensure populate alliance is called on create * Add unit tests for model * Add extra signal for state removal/addition * Add unit tests for signals * Add tests for manager * Add migrations * Add sync command to admin control * Prevent whitespace being stripped from group names * Add documentation --- allianceauth/eveonline/autogroups/__init__.py | 1 + allianceauth/eveonline/autogroups/admin.py | 39 ++ allianceauth/eveonline/autogroups/apps.py | 6 + .../autogroups/migrations/0001_initial.py | 77 ++++ .../autogroups/migrations/__init__.py | 0 allianceauth/eveonline/autogroups/models.py | 238 ++++++++++++ allianceauth/eveonline/autogroups/signals.py | 66 ++++ .../eveonline/autogroups/tests/__init__.py | 27 ++ .../autogroups/tests/test_managers.py | 34 ++ .../eveonline/autogroups/tests/test_models.py | 342 ++++++++++++++++++ .../autogroups/tests/test_signals.py | 208 +++++++++++ .../features/autogroups/group-creation.png | Bin 0 -> 30202 bytes docs/features/autogroups.md | 35 ++ docs/features/index.md | 1 + tests/settings.py | 1 + 15 files changed, 1075 insertions(+) create mode 100644 allianceauth/eveonline/autogroups/__init__.py create mode 100644 allianceauth/eveonline/autogroups/admin.py create mode 100644 allianceauth/eveonline/autogroups/apps.py create mode 100644 allianceauth/eveonline/autogroups/migrations/0001_initial.py create mode 100644 allianceauth/eveonline/autogroups/migrations/__init__.py create mode 100644 allianceauth/eveonline/autogroups/models.py create mode 100644 allianceauth/eveonline/autogroups/signals.py create mode 100644 allianceauth/eveonline/autogroups/tests/__init__.py create mode 100644 allianceauth/eveonline/autogroups/tests/test_managers.py create mode 100644 allianceauth/eveonline/autogroups/tests/test_models.py create mode 100644 allianceauth/eveonline/autogroups/tests/test_signals.py create mode 100644 docs/_static/images/features/autogroups/group-creation.png create mode 100644 docs/features/autogroups.md diff --git a/allianceauth/eveonline/autogroups/__init__.py b/allianceauth/eveonline/autogroups/__init__.py new file mode 100644 index 00000000..e18231ce --- /dev/null +++ b/allianceauth/eveonline/autogroups/__init__.py @@ -0,0 +1 @@ +default_app_config = 'allianceauth.eveonline.autogroups.apps.EveAutogroupsConfig' diff --git a/allianceauth/eveonline/autogroups/admin.py b/allianceauth/eveonline/autogroups/admin.py new file mode 100644 index 00000000..9c1a58d3 --- /dev/null +++ b/allianceauth/eveonline/autogroups/admin.py @@ -0,0 +1,39 @@ +from django.contrib import admin +from django.db import models +from .models import AutogroupsConfig + +import logging + + +logger = logging.getLogger(__name__) + + +def sync_user_groups(modeladmin, request, queryset): + for agc in queryset: + logger.debug("update_all_states_group_membership for {}".format(agc)) + agc.update_all_states_group_membership() + + +class AutogroupsConfigAdmin(admin.ModelAdmin): + formfield_overrides = { + models.CharField: {'strip': False} + } + + def get_readonly_fields(self, request, obj=None): + if obj: # This is the case when obj is already created i.e. it's an edit + return [ + 'corp_group_prefix', 'corp_name_source', 'alliance_group_prefix', 'alliance_name_source', + 'replace_spaces', 'replace_spaces_with' + ] + else: + return [] + + def get_actions(self, request): + actions = super(AutogroupsConfigAdmin, self).get_actions(request) + actions['sync_user_groups'] = (sync_user_groups, + 'sync_user_groups', + 'Sync all users groups for this Autogroup Config') + return actions + + +admin.site.register(AutogroupsConfig, AutogroupsConfigAdmin) diff --git a/allianceauth/eveonline/autogroups/apps.py b/allianceauth/eveonline/autogroups/apps.py new file mode 100644 index 00000000..503ab4de --- /dev/null +++ b/allianceauth/eveonline/autogroups/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EveAutogroupsConfig(AppConfig): + name = 'allianceauth.eveonline.autogroups' + label = 'eve_autogroups' diff --git a/allianceauth/eveonline/autogroups/migrations/0001_initial.py b/allianceauth/eveonline/autogroups/migrations/0001_initial.py new file mode 100644 index 00000000..219ed64a --- /dev/null +++ b/allianceauth/eveonline/autogroups/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2017-09-29 14:44 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('authentication', '0015_user_profiles'), + ('auth', '0008_alter_user_username_max_length'), + ('eveonline', '0009_on_delete'), + ] + + operations = [ + migrations.CreateModel( + name='AutogroupsConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('corp_groups', models.BooleanField(default=False, help_text='Setting this to false will delete all the created groups.')), + ('corp_group_prefix', models.CharField(blank=True, default='Corp ', max_length=50)), + ('corp_name_source', models.CharField(choices=[('ticker', 'Ticker'), ('name', 'Full name')], default='name', max_length=20)), + ('alliance_groups', models.BooleanField(default=False, help_text='Setting this to false will delete all the created groups.')), + ('alliance_group_prefix', models.CharField(blank=True, default='Alliance ', max_length=50)), + ('alliance_name_source', models.CharField(choices=[('ticker', 'Ticker'), ('name', 'Full name')], default='name', max_length=20)), + ('replace_spaces', models.BooleanField(default=False)), + ('replace_spaces_with', models.CharField(blank=True, default='', help_text='Any spaces in the group name will be replaced with this.', max_length=10)), + ('states', models.ManyToManyField(related_name='autogroups', to='authentication.State')), + ], + ), + migrations.CreateModel( + name='ManagedGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), + migrations.CreateModel( + name='ManagedAllianceGroup', + fields=[ + ('managedgroup_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='eve_autogroups.ManagedGroup')), + ('alliance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eveonline.EveAllianceInfo')), + ], + bases=('eve_autogroups.managedgroup',), + ), + migrations.CreateModel( + name='ManagedCorpGroup', + fields=[ + ('managedgroup_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='eve_autogroups.ManagedGroup')), + ('corp', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eveonline.EveCorporationInfo')), + ], + bases=('eve_autogroups.managedgroup',), + ), + migrations.AddField( + model_name='managedgroup', + name='config', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='eve_autogroups.AutogroupsConfig'), + ), + migrations.AddField( + model_name='managedgroup', + name='group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.Group'), + ), + migrations.AddField( + model_name='autogroupsconfig', + name='alliance_managed_groups', + field=models.ManyToManyField(help_text="A list of alliance groups created and maintained by this AutogroupConfig. You should not edit this list unless you know what you're doing.", related_name='alliance_managed_config', through='eve_autogroups.ManagedAllianceGroup', to='auth.Group'), + ), + migrations.AddField( + model_name='autogroupsconfig', + name='corp_managed_groups', + field=models.ManyToManyField(help_text="A list of corporation groups created and maintained by this AutogroupConfig. You should not edit this list unless you know what you're doing.", related_name='corp_managed_config', through='eve_autogroups.ManagedCorpGroup', to='auth.Group'), + ), + ] diff --git a/allianceauth/eveonline/autogroups/migrations/__init__.py b/allianceauth/eveonline/autogroups/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/allianceauth/eveonline/autogroups/models.py b/allianceauth/eveonline/autogroups/models.py new file mode 100644 index 00000000..03afcbc8 --- /dev/null +++ b/allianceauth/eveonline/autogroups/models.py @@ -0,0 +1,238 @@ +import logging +from django.db import models, transaction +from django.contrib.auth.models import Group, User +from django.core.exceptions import ObjectDoesNotExist + +from allianceauth.authentication.models import State +from allianceauth.eveonline.models import EveCorporationInfo, EveAllianceInfo + +logger = logging.getLogger(__name__) + + +def get_users_for_state(state: State): + return User.objects.select_related('profile').prefetch_related('profile__main_character')\ + .filter(profile__state__pk=state.pk) + + +class AutogroupsConfigManager(models.Manager): + def update_groups_for_state(self, state: State): + """ + Update all the Group memberships for the users + who have State + :param state: State to update for + :return: + """ + users = get_users_for_state(state) + for config in self.filter(states=state): + logger.debug("in state loop") + for user in users: + logger.debug("in user loop for {}".format(user)) + config.update_group_membership_for_user(user) + + def update_groups_for_user(self, user: User, state: State = None): + """ + Update the Group memberships for the given users state + :param user: User to update for + :param state: State to update user for + :return: + """ + if state is None: + state = user.profile.state + for config in self.filter(states=state): + config.update_group_membership_for_user(user) + + +class AutogroupsConfig(models.Model): + OPT_TICKER = 'ticker' + OPT_NAME = 'name' + NAME_OPTIONS = ( + (OPT_TICKER, 'Ticker'), + (OPT_NAME, 'Full name'), + ) + + states = models.ManyToManyField(State, related_name='autogroups') + + corp_groups = models.BooleanField(default=False, + help_text="Setting this to false will delete all the created groups.") + corp_group_prefix = models.CharField(max_length=50, default='Corp ', blank=True) + corp_name_source = models.CharField(max_length=20, choices=NAME_OPTIONS, default=OPT_NAME) + + alliance_groups = models.BooleanField(default=False, + help_text="Setting this to false will delete all the created groups.") + alliance_group_prefix = models.CharField(max_length=50, default='Alliance ', blank=True) + alliance_name_source = models.CharField(max_length=20, choices=NAME_OPTIONS, default=OPT_NAME) + + corp_managed_groups = models.ManyToManyField( + Group, through='ManagedCorpGroup', related_name='corp_managed_config', + help_text='A list of corporation groups created and maintained by this AutogroupConfig. ' + 'You should not edit this list unless you know what you\'re doing.') + + alliance_managed_groups = models.ManyToManyField( + Group, through='ManagedAllianceGroup', related_name='alliance_managed_config', + help_text='A list of alliance groups created and maintained by this AutogroupConfig. ' + 'You should not edit this list unless you know what you\'re doing.') + + replace_spaces = models.BooleanField(default=False) + replace_spaces_with = models.CharField( + max_length=10, default='', blank=True, + help_text='Any spaces in the group name will be replaced with this.') + + objects = AutogroupsConfigManager() + + def __init__(self, *args, **kwargs): + super(AutogroupsConfig, self).__init__(*args, **kwargs) + + def __repr__(self): + return self.__class__.__name__ + + def __str__(self): + return 'States: ' + (' '.join(list(self.states.all().values_list('name', flat=True))) if self.pk else str(None)) + + def update_all_states_group_membership(self): + list(map(self.update_group_membership_for_state, self.states.all())) + + def update_group_membership_for_state(self, state: State): + list(map(self.update_group_membership_for_user, get_users_for_state(state))) + + @transaction.atomic + def update_group_membership_for_user(self, user: User): + self.update_alliance_group_membership(user) + self.update_corp_group_membership(user) + + def user_entitled_to_groups(self, user: User) -> bool: + try: + return user.profile.state in self.states.all() + except ObjectDoesNotExist: + return False + + @transaction.atomic + def update_alliance_group_membership(self, user: User): + group = None + try: + if not self.alliance_groups or not self.user_entitled_to_groups(user): + logger.debug('User {} does not have required state'.format(user)) + return + else: + alliance = user.profile.main_character.alliance + if alliance is None: + return + group = self.get_alliance_group(alliance) + except EveAllianceInfo.DoesNotExist: + logger.warning('User {} main characters alliance does not exist in the database.' + ' Group membership not updated'.format(user)) + except AttributeError: + logger.warning('User {} does not have a main character. Group membership not updated'.format(user)) + finally: + self.remove_user_from_corp_groups(user, except_group=group) + if group is not None: + user.groups.add(group) + + @transaction.atomic + def update_corp_group_membership(self, user: User): + group = None + try: + if not self.corp_groups or not self.user_entitled_to_groups(user): + logger.debug('User {} does not have required state'.format(user)) + else: + corp = user.profile.main_character.corporation + group = self.get_corp_group(corp) + except EveCorporationInfo.DoesNotExist: + logger.warning('User {} main characters corporation does not exist in the database.' + ' Group membership not updated'.format(user)) + except AttributeError: + logger.warning('User {} does not have a main character. Group membership not updated'.format(user)) + finally: + self.remove_user_from_corp_groups(user, except_group=group) + if group is not None: + user.groups.add(group) + + @transaction.atomic + def remove_user_from_alliance_groups(self, user: User, except_group: Group = None): + remove_groups = user.groups.filter(pk__in=self.alliance_managed_groups.all().values_list('pk', flat=True)) + if except_group is not None: + remove_groups = remove_groups.exclude(pk=except_group.pk) + list(map(user.groups.remove, remove_groups)) + + @transaction.atomic + def remove_user_from_corp_groups(self, user: User, except_group: Group = None): + remove_groups = user.groups.filter(pk__in=self.corp_managed_groups.all().values_list('pk', flat=True)) + if except_group is not None: + remove_groups = remove_groups.exclude(pk=except_group.pk) + list(map(user.groups.remove, remove_groups)) + + def get_alliance_group(self, alliance: EveAllianceInfo) -> Group: + return self.create_alliance_group(alliance) + + def get_corp_group(self, corp: EveCorporationInfo) -> Group: + return self.create_corp_group(corp) + + @transaction.atomic + def create_alliance_group(self, alliance: EveAllianceInfo) -> Group: + group, created = Group.objects.get_or_create(name=self.get_alliance_group_name(alliance)) + if created: + ManagedAllianceGroup.objects.create(group=group, config=self, alliance=alliance) + return group + + @transaction.atomic + def create_corp_group(self, corp: EveCorporationInfo) -> Group: + group, created = Group.objects.get_or_create(name=self.get_corp_group_name(corp)) + if created: + ManagedCorpGroup.objects.create(group=group, config=self, corp=corp) + return group + + def delete_alliance_managed_groups(self): + """ + Deletes ALL managed alliance groups + """ + self.alliance_managed_groups.all().delete() + + def delete_corp_managed_groups(self): + """ + Deletes ALL managed corp groups + """ + self.corp_managed_groups.all().delete() + + def get_alliance_group_name(self, alliance: EveAllianceInfo) -> str: + if self.alliance_name_source == self.OPT_TICKER: + name = alliance.alliance_ticker + elif self.alliance_name_source == self.OPT_NAME: + name = alliance.alliance_name + else: + raise NameSourceException('Not a valid name source') + return self._replace_spaces(self.alliance_group_prefix + name) + + def get_corp_group_name(self, corp: EveCorporationInfo) -> str: + if self.corp_name_source == self.OPT_TICKER: + name = corp.corporation_ticker + elif self.corp_name_source == self.OPT_NAME: + name = corp.corporation_name + else: + raise NameSourceException('Not a valid name source') + return self._replace_spaces(self.corp_group_prefix + name) + + def _replace_spaces(self, name: str) -> str: + """ + Replace the spaces in the given name based on the config + :param name: name to replace spaces in + :return: name with spaces replaced with the configured character(s) or unchanged if configured + """ + if self.replace_spaces: + return name.strip().replace(' ', str(self.replace_spaces_with)) + return name + + +class ManagedGroup(models.Model): + group = models.ForeignKey(Group, on_delete=models.CASCADE) + config = models.ForeignKey(AutogroupsConfig, on_delete=models.CASCADE) + + +class ManagedCorpGroup(ManagedGroup): + corp = models.ForeignKey(EveCorporationInfo, on_delete=models.CASCADE) + + +class ManagedAllianceGroup(ManagedGroup): + alliance = models.ForeignKey(EveAllianceInfo, on_delete=models.CASCADE) + + +class NameSourceException(Exception): + pass diff --git a/allianceauth/eveonline/autogroups/signals.py b/allianceauth/eveonline/autogroups/signals.py new file mode 100644 index 00000000..4fcfdeeb --- /dev/null +++ b/allianceauth/eveonline/autogroups/signals.py @@ -0,0 +1,66 @@ +import logging +from django.dispatch import receiver +from django.db.models.signals import pre_save, post_save, pre_delete, m2m_changed +from allianceauth.authentication.models import UserProfile, State + +from .models import AutogroupsConfig + +logger = logging.getLogger(__name__) + + +@receiver(pre_save, sender=AutogroupsConfig) +def pre_save_config(sender, instance, *args, **kwargs): + """ + Checks if enable was toggled on group config and + deletes groups if necessary. + """ + logger.debug("Received pre_save from {}".format(instance)) + if not instance.pk: + # new model being created + return + try: + old_instance = AutogroupsConfig.objects.get(pk=instance.pk) + + # Check if enable was toggled, delete groups? + if old_instance.alliance_groups is True and instance.alliance_groups is False: + instance.delete_alliance_managed_groups() + + if old_instance.corp_groups is True and instance.corp_groups is False: + instance.delete_corp_managed_groups() + except AutogroupsConfig.DoesNotExist: + pass + + +@receiver(pre_delete, sender=AutogroupsConfig) +def pre_delete_config(sender, instance, *args, **kwargs): + """ + Delete groups on deleting config + """ + instance.delete_corp_managed_groups() + instance.delete_alliance_managed_groups() + + +@receiver(post_save, sender=UserProfile) +def check_groups_on_profile_update(sender, instance, created, *args, **kwargs): + """ + Trigger check when main character or state changes. + """ + update_fields = kwargs.pop('update_fields', []) or [] + if 'main_character' in update_fields or 'state' in update_fields: + AutogroupsConfig.objects.update_groups_for_user(instance.user) + + +@receiver(m2m_changed, sender=AutogroupsConfig.states.through) +def autogroups_states_changed(sender, instance, action, reverse, model, pk_set, *args, **kwargs): + """ + Trigger group membership update when a state is added or removed from + an autogroup config. + """ + if action.startswith('post_'): + for pk in pk_set: + try: + state = State.objects.get(pk=pk) + instance.update_group_membership_for_state(state) + except State.DoesNotExist: + # Deleted States handled by the profile state change + pass diff --git a/allianceauth/eveonline/autogroups/tests/__init__.py b/allianceauth/eveonline/autogroups/tests/__init__.py new file mode 100644 index 00000000..d46d9d35 --- /dev/null +++ b/allianceauth/eveonline/autogroups/tests/__init__.py @@ -0,0 +1,27 @@ +from unittest import mock +from django.db.models.signals import pre_save, post_save, pre_delete, m2m_changed +from allianceauth.authentication.models import UserProfile +from allianceauth.authentication.signals import state_changed +from allianceauth.eveonline.models import EveCharacter +from .. import signals +from ..models import AutogroupsConfig + +MODULE_PATH = 'allianceauth.eveonline.autogroups' + + +def patch(target, *args, **kwargs): + return mock.patch('{}{}'.format(MODULE_PATH, target), *args, **kwargs) + + +def connect_signals(): + pre_save.connect(receiver=signals.pre_save_config, sender=AutogroupsConfig) + pre_delete.connect(receiver=signals.pre_delete_config, sender=AutogroupsConfig) + post_save.connect(receiver=signals.check_groups_on_profile_update, sender=UserProfile) + m2m_changed.connect(receiver=signals.autogroups_states_changed, sender=AutogroupsConfig.states.through) + + +def disconnect_signals(): + pre_save.disconnect(receiver=signals.pre_save_config, sender=AutogroupsConfig) + pre_delete.disconnect(receiver=signals.pre_delete_config, sender=AutogroupsConfig) + post_save.disconnect(receiver=signals.check_groups_on_profile_update, sender=UserProfile) + m2m_changed.disconnect(receiver=signals.autogroups_states_changed, sender=AutogroupsConfig.states.through) diff --git a/allianceauth/eveonline/autogroups/tests/test_managers.py b/allianceauth/eveonline/autogroups/tests/test_managers.py new file mode 100644 index 00000000..dbdecfb2 --- /dev/null +++ b/allianceauth/eveonline/autogroups/tests/test_managers.py @@ -0,0 +1,34 @@ +from django.test import TestCase +from allianceauth.tests.auth_utils import AuthUtils + +from ..models import AutogroupsConfig +from . import patch + + +class AutogroupsConfigManagerTestCase(TestCase): + + def test_update_groups_for_state(self, ): + member = AuthUtils.create_member('test member') + obj = AutogroupsConfig.objects.create() + obj.states.add(member.profile.state) + + with patch('.models.AutogroupsConfig.update_group_membership_for_user') as update_group_membership_for_user: + AutogroupsConfig.objects.update_groups_for_state(member.profile.state) + + self.assertTrue(update_group_membership_for_user.called) + self.assertEqual(update_group_membership_for_user.call_count, 1) + args, kwargs = update_group_membership_for_user.call_args + self.assertEqual(args[0], member) + + def test_update_groups_for_user(self): + member = AuthUtils.create_member('test member') + obj = AutogroupsConfig.objects.create() + obj.states.add(member.profile.state) + + with patch('.models.AutogroupsConfig.update_group_membership_for_user') as update_group_membership_for_user: + AutogroupsConfig.objects.update_groups_for_user(member) + + self.assertTrue(update_group_membership_for_user.called) + self.assertEqual(update_group_membership_for_user.call_count, 1) + args, kwargs = update_group_membership_for_user.call_args + self.assertEqual(args[0], member) diff --git a/allianceauth/eveonline/autogroups/tests/test_models.py b/allianceauth/eveonline/autogroups/tests/test_models.py new file mode 100644 index 00000000..e62082e4 --- /dev/null +++ b/allianceauth/eveonline/autogroups/tests/test_models.py @@ -0,0 +1,342 @@ +from django.test import TestCase +from django.contrib.auth.models import Group + +from allianceauth.tests.auth_utils import AuthUtils + +from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo, EveAllianceInfo + +from ..models import AutogroupsConfig, get_users_for_state + + +from . import patch, connect_signals, disconnect_signals + + +class AutogroupsConfigTestCase(TestCase): + def setUp(self): + # Disconnect signals + disconnect_signals() + + self.member = AuthUtils.create_member('test user') + + state = AuthUtils.get_member_state() + + self.alliance = EveAllianceInfo.objects.create( + alliance_id='3456', + alliance_name='alliance name', + alliance_ticker='TIKR', + executor_corp_id='2345', + ) + + self.corp = EveCorporationInfo.objects.create( + corporation_id='2345', + corporation_name='corp name', + corporation_ticker='TIKK', + member_count=10, + alliance=self.alliance, + ) + + state.member_alliances.add(self.alliance) + state.member_corporations.add(self.corp) + + def tearDown(self): + # Reconnect signals + connect_signals() + + def test_get_users_for_state(self): + result = get_users_for_state(self.member.profile.state) + + self.assertIn(self.member, result) + self.assertEqual(len(result), 1) + + @patch('.models.AutogroupsConfig.update_alliance_group_membership') + @patch('.models.AutogroupsConfig.update_corp_group_membership') + def test_update_group_membership(self, update_corp, update_alliance): + agc = AutogroupsConfig.objects.create() + agc.update_group_membership_for_user(self.member) + + self.assertTrue(update_corp.called) + self.assertTrue(update_alliance.called) + + args, kwargs = update_corp.call_args + self.assertEqual(args[0], self.member) + + args, kwargs = update_alliance.call_args + self.assertEqual(args[0], self.member) + + def test_update_alliance_group_membership(self): + obj = AutogroupsConfig.objects.create(alliance_groups=True) + obj.states.add(AuthUtils.get_member_state()) + char = EveCharacter.objects.create( + character_id='1234', + character_name='test character', + corporation_id='2345', + corporation_name='test corp', + corporation_ticker='tickr', + alliance_id='3456', + alliance_name='alliance name', + ) + self.member.profile.main_character = char + self.member.profile.save() + + pre_groups = self.member.groups.all() + + # Act + obj.update_alliance_group_membership(self.member) + + group = obj.create_alliance_group(self.alliance) + group_qs = Group.objects.filter(pk=group.pk) + + self.assertIn(group, self.member.groups.all()) + self.assertQuerysetEqual(self.member.groups.all(), map(repr, pre_groups | group_qs), ordered=False) + + def test_update_alliance_group_membership_no_main_character(self): + obj = AutogroupsConfig.objects.create() + obj.states.add(AuthUtils.get_member_state()) + + # Act + obj.update_alliance_group_membership(self.member) + + group = obj.get_alliance_group(self.alliance) + + self.assertNotIn(group, self.member.groups.all()) + + def test_update_alliance_group_membership_no_alliance_model(self): + obj = AutogroupsConfig.objects.create() + obj.states.add(AuthUtils.get_member_state()) + char = EveCharacter.objects.create( + character_id='1234', + character_name='test character', + corporation_id='2345', + corporation_name='test corp', + corporation_ticker='tickr', + alliance_id='3459', + alliance_name='alliance name', + ) + self.member.profile.main_character = char + self.member.profile.save() + + # Act + obj.update_alliance_group_membership(self.member) + + group = obj.get_alliance_group(self.alliance) + + self.assertNotIn(group, self.member.groups.all()) + + def test_update_corp_group_membership(self): + obj = AutogroupsConfig.objects.create(corp_groups=True) + obj.states.add(AuthUtils.get_member_state()) + char = EveCharacter.objects.create( + character_id='1234', + character_name='test character', + corporation_id='2345', + corporation_name='test corp', + corporation_ticker='tickr', + alliance_id='3456', + alliance_name='alliance name', + ) + self.member.profile.main_character = char + self.member.profile.save() + + pre_groups = self.member.groups.all() + + # Act + obj.update_corp_group_membership(self.member) + + group = obj.get_corp_group(self.corp) + group_qs = Group.objects.filter(pk=group.pk) + + self.assertIn(group, self.member.groups.all()) + self.assertQuerysetEqual(self.member.groups.all(), map(repr, pre_groups | group_qs), ordered=False) + + def test_update_corp_group_membership_no_state(self): + obj = AutogroupsConfig.objects.create(corp_groups=True) + char = EveCharacter.objects.create( + character_id='1234', + character_name='test character', + corporation_id='2345', + corporation_name='test corp', + corporation_ticker='tickr', + alliance_id='3456', + alliance_name='alliance name', + ) + self.member.profile.main_character = char + self.member.profile.save() + + pre_groups = list(self.member.groups.all()) + + # Act + obj.update_corp_group_membership(self.member) + + group = obj.get_corp_group(self.corp) + + post_groups = list(self.member.groups.all()) + + self.assertNotIn(group, post_groups) + self.assertListEqual(pre_groups, post_groups) + + def test_update_corp_group_membership_no_main_character(self): + obj = AutogroupsConfig.objects.create() + obj.states.add(AuthUtils.get_member_state()) + + # Act + obj.update_corp_group_membership(self.member) + + group = obj.get_corp_group(self.corp) + + self.assertNotIn(group, self.member.groups.all()) + + def test_update_corp_group_membership_no_corp_model(self): + obj = AutogroupsConfig.objects.create() + obj.states.add(AuthUtils.get_member_state()) + char = EveCharacter.objects.create( + character_id='1234', + character_name='test character', + corporation_id='2348', + corporation_name='test corp', + corporation_ticker='tickr', + alliance_id='3456', + alliance_name='alliance name', + ) + self.member.profile.main_character = char + self.member.profile.save() + + # Act + obj.update_corp_group_membership(self.member) + + group = obj.get_corp_group(self.corp) + + self.assertNotIn(group, self.member.groups.all()) + + def test_remove_user_from_alliance_groups(self): + obj = AutogroupsConfig.objects.create() + result = obj.get_alliance_group(self.alliance) + + result.user_set.add(self.member) + + self.assertIn(result, self.member.groups.all()) + + # Act + obj.remove_user_from_alliance_groups(self.member) + + self.assertNotIn(result, self.member.groups.all()) + + def test_remove_user_from_corp_groups(self): + obj = AutogroupsConfig.objects.create() + result = obj.get_corp_group(self.corp) + + result.user_set.add(self.member) + + self.assertIn(result, self.member.groups.all()) + + # Act + obj.remove_user_from_corp_groups(self.member) + + self.assertNotIn(result, self.member.groups.all()) + + def test_get_alliance_group(self): + obj = AutogroupsConfig.objects.create() + result = obj.get_alliance_group(self.alliance) + + group = Group.objects.get(name='Alliance alliance name') + + self.assertEqual(result, group) + self.assertEqual(obj.get_alliance_group_name(self.alliance), result.name) + self.assertTrue(obj.alliance_managed_groups.filter(pk=result.pk).exists()) + + def test_get_corp_group(self): + obj = AutogroupsConfig.objects.create() + result = obj.get_corp_group(self.corp) + + group = Group.objects.get(name='Corp corp name') + + self.assertEqual(result, group) + self.assertEqual(obj.get_corp_group_name(self.corp), group.name) + self.assertTrue(obj.corp_managed_groups.filter(pk=group.pk).exists()) + + def test_create_alliance_group(self): + obj = AutogroupsConfig.objects.create() + result = obj.create_alliance_group(self.alliance) + + group = Group.objects.get(name='Alliance alliance name') + + self.assertEqual(result, group) + self.assertEqual(obj.get_alliance_group_name(self.alliance), group.name) + self.assertTrue(obj.alliance_managed_groups.filter(pk=group.pk).exists()) + + def test_create_corp_group(self): + obj = AutogroupsConfig.objects.create() + result = obj.create_corp_group(self.corp) + + group = Group.objects.get(name='Corp corp name') + + self.assertEqual(result, group) + self.assertEqual(obj.get_corp_group_name(self.corp), group.name) + self.assertTrue(obj.corp_managed_groups.filter(pk=group.pk).exists()) + + def test_delete_alliance_managed_groups(self): + obj = AutogroupsConfig.objects.create() + obj.create_alliance_group(self.alliance) + + self.assertTrue(obj.alliance_managed_groups.all().exists()) + + obj.delete_alliance_managed_groups() + + self.assertFalse(obj.alliance_managed_groups.all().exists()) + + def test_delete_corp_managed_groups(self): + obj = AutogroupsConfig.objects.create() + obj.create_corp_group(self.corp) + + self.assertTrue(obj.corp_managed_groups.all().exists()) + + obj.delete_corp_managed_groups() + + self.assertFalse(obj.corp_managed_groups.all().exists()) + + def test_get_alliance_group_name(self): + obj = AutogroupsConfig() + obj.replace_spaces = True + obj.replace_spaces_with = '_' + + result = obj.get_alliance_group_name(self.alliance) + + self.assertEqual(result, 'Alliance_alliance_name') + + def test_get_alliance_group_name_ticker(self): + obj = AutogroupsConfig() + obj.replace_spaces = True + obj.replace_spaces_with = '_' + obj.alliance_name_source = obj.OPT_TICKER + + result = obj.get_alliance_group_name(self.alliance) + + self.assertEqual(result, 'Alliance_TIKR') + + def test_get_corp_group_name(self): + obj = AutogroupsConfig() + obj.replace_spaces = True + obj.replace_spaces_with = '_' + + result = obj.get_corp_group_name(self.corp) + + self.assertEqual(result, 'Corp_corp_name') + + def test_get_corp_group_name_ticker(self): + obj = AutogroupsConfig() + obj.replace_spaces = True + obj.replace_spaces_with = '_' + obj.corp_name_source = obj.OPT_TICKER + + result = obj.get_corp_group_name(self.corp) + + self.assertEqual(result, 'Corp_TIKK') + + def test__replace_spaces(self): + obj = AutogroupsConfig() + obj.replace_spaces = True + obj.replace_spaces_with = '*' + name = ' test name ' + + result = obj._replace_spaces(name) + + self.assertEqual(result, 'test*name') diff --git a/allianceauth/eveonline/autogroups/tests/test_signals.py b/allianceauth/eveonline/autogroups/tests/test_signals.py new file mode 100644 index 00000000..d89be966 --- /dev/null +++ b/allianceauth/eveonline/autogroups/tests/test_signals.py @@ -0,0 +1,208 @@ +from django.test import TestCase +from django.contrib.auth.models import Group, User + +from allianceauth.tests.auth_utils import AuthUtils + +from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo, EveAllianceInfo + +from ..models import AutogroupsConfig, ManagedAllianceGroup + +from . import patch, disconnect_signals, connect_signals + + +class SignalsTestCase(TestCase): + def setUp(self): + disconnect_signals() + self.member = AuthUtils.create_member('test user') + + state = AuthUtils.get_member_state() + + self.char = EveCharacter.objects.create( + character_id='1234', + character_name='test character', + corporation_id='2345', + corporation_name='test corp', + corporation_ticker='tickr', + alliance_id='3456', + alliance_name='alliance name', + ) + + self.member.profile.main_character = self.char + self.member.profile.save() + + self.alliance = EveAllianceInfo.objects.create( + alliance_id='3456', + alliance_name='alliance name', + alliance_ticker='TIKR', + executor_corp_id='2345', + ) + + self.corp = EveCorporationInfo.objects.create( + corporation_id='2345', + corporation_name='corp name', + corporation_ticker='TIKK', + member_count=10, + alliance=self.alliance, + ) + + state.member_alliances.add(self.alliance) + state.member_corporations.add(self.corp) + connect_signals() + + @patch('.models.AutogroupsConfigManager.update_groups_for_user') + def test_check_groups_on_profile_update_state(self, update_groups_for_user): + # Trigger signal + self.member.profile.state = AuthUtils.get_guest_state() + self.member.profile.save() + + self.assertTrue(update_groups_for_user.called) + self.assertEqual(update_groups_for_user.call_count, 1) + args, kwargs = update_groups_for_user.call_args + self.assertEqual(args[0], self.member) + + @patch('.models.AutogroupsConfigManager.update_groups_for_user') + def test_check_groups_on_profile_update_main_character(self, update_groups_for_user): + char = EveCharacter.objects.create( + character_id='1266', + character_name='test character2', + corporation_id='2345', + corporation_name='test corp', + corporation_ticker='tickr', + alliance_id='3456', + alliance_name='alliance name', + ) + # Trigger signal + self.member.profile.main_character = char + self.member.profile.save() + + self.assertTrue(update_groups_for_user.called) + self.assertEqual(update_groups_for_user.call_count, 1) + args, kwargs = update_groups_for_user.call_args + self.assertEqual(args[0], self.member) + member = User.objects.get(pk=self.member.pk) + self.assertEqual(member.profile.state, AuthUtils.get_member_state()) + + @patch('.models.AutogroupsConfigManager.update_groups_for_user') + def test_check_groups_on_character_update(self, update_groups_for_user): + """ + Test update_groups_for_user is called when main_character properties + are changed. + """ + + # Trigger signal + self.member.profile.main_character.corporation_id = '2300' + self.member.profile.main_character.save() + + self.assertTrue(update_groups_for_user.called) + self.assertEqual(update_groups_for_user.call_count, 1) + args, kwargs = update_groups_for_user.call_args + self.assertEqual(args[0], self.member) + member = User.objects.get(pk=self.member.pk) + self.assertEqual(member.profile.state, AuthUtils.get_member_state()) + + @patch('.models.AutogroupsConfig.delete_corp_managed_groups') + @patch('.models.AutogroupsConfig.delete_alliance_managed_groups') + def test_pre_save_config_deletes_alliance_groups(self, delete_alliance_managed_groups, delete_corp_managed_groups): + """ + Test that delete_alliance_managed_groups is called when the alliance_groups + setting is toggled to False + """ + obj = AutogroupsConfig.objects.create(alliance_groups=True) + + obj.create_alliance_group(self.alliance) + + # Trigger signal + obj.alliance_groups = False + obj.save() + + self.assertTrue(delete_alliance_managed_groups.called) + self.assertFalse(delete_corp_managed_groups.called) + self.assertEqual(delete_alliance_managed_groups.call_count, 1) + + @patch('.models.AutogroupsConfig.delete_alliance_managed_groups') + @patch('.models.AutogroupsConfig.delete_corp_managed_groups') + def test_pre_save_config_deletes_corp_groups(self, delete_corp_managed_groups, delete_alliance_managed_groups): + """ + Test that delete_corp_managed_groups is called when the corp_groups + setting is toggled to False + """ + obj = AutogroupsConfig.objects.create(corp_groups=True) + + obj.create_corp_group(self.corp) + + # Trigger signal + obj.corp_groups = False + obj.save() + + self.assertTrue(delete_corp_managed_groups.called) + self.assertFalse(delete_alliance_managed_groups.called) + self.assertEqual(delete_corp_managed_groups.call_count, 1) + + @patch('.models.AutogroupsConfig.delete_alliance_managed_groups') + @patch('.models.AutogroupsConfig.delete_corp_managed_groups') + def test_pre_save_config_does_nothing(self, delete_corp_managed_groups, delete_alliance_managed_groups): + """ + Test groups arent deleted if we arent setting the enabled params to False + """ + obj = AutogroupsConfig.objects.create(corp_groups=True) + + obj.create_corp_group(self.corp) + + # Trigger signal + obj.alliance_groups = True + obj.save() + + self.assertFalse(delete_corp_managed_groups.called) + self.assertFalse(delete_alliance_managed_groups.called) + + @patch('.models.AutogroupsConfig.delete_alliance_managed_groups') + @patch('.models.AutogroupsConfig.delete_corp_managed_groups') + def test_pre_delete_config(self, delete_corp_managed_groups, delete_alliance_managed_groups): + """ + Test groups are deleted if config is deleted + """ + obj = AutogroupsConfig.objects.create() + + # Trigger signal + obj.delete() + + self.assertTrue(delete_corp_managed_groups.called) + self.assertTrue(delete_alliance_managed_groups.called) + + @patch('.models.AutogroupsConfig.update_group_membership_for_state') + def test_autogroups_states_changed_add(self, update_group_membership_for_state): + """ + Test update_group_membership_for_state is called when a state is added to + the AutogroupsConfig + """ + obj = AutogroupsConfig.objects.create(alliance_groups=True) + state = AuthUtils.get_member_state() + + # Trigger + obj.states.add(state) + + self.assertTrue(update_group_membership_for_state.called) + self.assertEqual(update_group_membership_for_state.call_count, 1) + args, kwargs = update_group_membership_for_state.call_args + self.assertEqual(args[0], state) + + @patch('.models.AutogroupsConfig.update_group_membership_for_state') + def test_autogroups_states_changed_remove(self, update_group_membership_for_state): + """ + Test update_group_membership_for_state is called when a state is removed from + the AutogroupsConfig + """ + obj = AutogroupsConfig.objects.create(alliance_groups=True) + state = AuthUtils.get_member_state() + + disconnect_signals() + obj.states.add(state) + connect_signals() + + # Trigger + obj.states.remove(state) + + self.assertTrue(update_group_membership_for_state.called) + self.assertEqual(update_group_membership_for_state.call_count, 1) + args, kwargs = update_group_membership_for_state.call_args + self.assertEqual(args[0], state) diff --git a/docs/_static/images/features/autogroups/group-creation.png b/docs/_static/images/features/autogroups/group-creation.png new file mode 100644 index 0000000000000000000000000000000000000000..086492265df3608b1311b85d9c1cf02bf3983445 GIT binary patch literal 30202 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 literal 0 HcmV?d00001 diff --git a/docs/features/autogroups.md b/docs/features/autogroups.md new file mode 100644 index 00000000..0c11b1b0 --- /dev/null +++ b/docs/features/autogroups.md @@ -0,0 +1,35 @@ +# Auto Groups + +```eval_rst +.. note:: + New in 2.0 +``` + +Auto groups allows you to automatically place users of certain states into Corp or Alliance based groups. These groups are created when the first user is added to them and removed when the configuration is deleted. + + +## Installation + +Add `allianceauth.eveonline.autogroups` to your `INSTALLED_APPS` and run migrations. All other settings are controlled via the admin panel under the `Eve_Autogroups` section. + + +## Configuring a group + +When you create an autogroup config you will be given the following options: + +![Create Autogroup page](/_static/images/features/autogroups/group-creation.png) + +```eval_rst +.. warning:: + After creating a group you wont be able to change the Corp and Alliance group prefixes, name source and the replace spaces settings. Make sure you configure these the way you want before creating the config. If you need to change these you will have to create a new autogroup config. +``` + +- States selects which states will be added to automatic corp/alliance groups + +- Corp/Alliance groups checkbox toggles corp/alliance autogroups on or off for this config. + +- Corp/Alliance group prefix sets the prefix for the group name, e.g. if your corp was called `MyCorp` and your prefix was `Corp `, your autogroup name would be created as `Corp MyCorp`. This field accepts leading/trailing spaces. + +- Corp/Alliance name source sets the source of the corp/alliance name used in creating the group name. Currently the options are Full name and Ticker. + +- Replace spaces allows you to replace spaces in the autogroup name with the value in the Replace spaces with field. This can be blank. diff --git a/docs/features/index.md b/docs/features/index.md index 55878306..404dfbec 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -8,6 +8,7 @@ hrapplications corpstats groups + autogroups permissions_tool nameformats ``` diff --git a/tests/settings.py b/tests/settings.py index f653c0e3..b0304290 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -17,6 +17,7 @@ NOSE_ARGS = [ CELERY_ALWAYS_EAGER = True # Forces celery to run locally for testing INSTALLED_APPS += [ + 'allianceauth.eveonline.autogroups', 'allianceauth.hrapplications', 'allianceauth.timerboard', 'allianceauth.srp',