Fix error 500 on service page for Discord and add feature "group_to_role"

This commit is contained in:
Erik Kalkoken 2020-07-23 20:58:26 +00:00 committed by colcrunch
parent bd3fe01a12
commit fe36e57d72
10 changed files with 387 additions and 75 deletions

View File

@ -27,14 +27,14 @@ DISCORD_OAUTH_TOKEN_URL = clean_setting(
) )
# How long the Discord guild names retrieved from the server are # How long the Discord guild names retrieved from the server are
# caches locally in milliseconds. # caches locally in seconds.
DISCORD_GUILD_NAME_CACHE_MAX_AGE = clean_setting( DISCORD_GUILD_NAME_CACHE_MAX_AGE = clean_setting(
'DISCORD_GUILD_NAME_CACHE_MAX_AGE', 3600 * 1 * 1000 'DISCORD_GUILD_NAME_CACHE_MAX_AGE', 3600 * 24
) )
# How long Discord roles retrieved from the server are caches locally in milliseconds. # How long Discord roles retrieved from the server are caches locally in seconds.
DISCORD_ROLES_CACHE_MAX_AGE = clean_setting( DISCORD_ROLES_CACHE_MAX_AGE = clean_setting(
'DISCORD_ROLES_CACHE_MAX_AGE', 3600 * 1 * 1000 'DISCORD_ROLES_CACHE_MAX_AGE', 3600 * 1
) )
# Turns off creation of new roles. In case the rate limit for creating roles is # Turns off creation of new roles. In case the rate limit for creating roles is

View File

@ -180,12 +180,19 @@ class DiscordClient:
r = self._api_request(method='get', route=route) r = self._api_request(method='get', route=route)
return r.json() return r.json()
def guild_name(self, guild_id: int) -> str: def guild_name(self, guild_id: int, use_cache: bool = True) -> str:
"""returns the name of this guild (cached) """returns the name of this guild (cached)
or an empty string if something went wrong or an empty string if something went wrong
Params:
- guild_id: ID of current guild
- use_cache: When set to False will force an API call to get the server name
""" """
key_name = self._guild_name_cache_key(guild_id) key_name = self._guild_name_cache_key(guild_id)
guild_name = self._redis_decode(self._redis.get(key_name)) if use_cache:
guild_name = self._redis_decode(self._redis.get(key_name))
else:
guild_name = None
if not guild_name: if not guild_name:
guild_infos = self.guild_infos(guild_id) guild_infos = self.guild_infos(guild_id)
if 'name' in guild_infos: if 'name' in guild_infos:
@ -193,7 +200,7 @@ class DiscordClient:
self._redis.set( self._redis.set(
name=key_name, name=key_name,
value=guild_name, value=guild_name,
px=DISCORD_GUILD_NAME_CACHE_MAX_AGE ex=DISCORD_GUILD_NAME_CACHE_MAX_AGE
) )
else: else:
guild_name = '' guild_name = ''
@ -230,7 +237,7 @@ class DiscordClient:
self._redis.set( self._redis.set(
name=cache_key, name=cache_key,
value=json.dumps(roles), value=json.dumps(roles),
px=DISCORD_ROLES_CACHE_MAX_AGE ex=DISCORD_ROLES_CACHE_MAX_AGE
) )
return roles return roles
@ -274,6 +281,11 @@ class DiscordClient:
gen_key = cls._generate_hash(f'{guild_id}') gen_key = cls._generate_hash(f'{guild_id}')
return f'{cls._KEYPREFIX_GUILD_ROLES}__{gen_key}' return f'{cls._KEYPREFIX_GUILD_ROLES}__{gen_key}'
def match_role_from_name(self, guild_id: int, role_name: str) -> dict:
"""returns Discord role matching the given name or an empty dict"""
guild_roles = DiscordRoles(self.guild_roles(guild_id))
return guild_roles.role_by_name(role_name)
def match_or_create_roles_from_names(self, guild_id: int, role_names: list) -> list: def match_or_create_roles_from_names(self, guild_id: int, role_names: list) -> list:
"""returns Discord roles matching the given names """returns Discord roles matching the given names
@ -281,6 +293,7 @@ class DiscordClient:
Will try to match with existing roles names Will try to match with existing roles names
Non-existing roles will be created, then created flag will be True Non-existing roles will be created, then created flag will be True
Params: Params:
- guild_id: ID of guild - guild_id: ID of guild
- role_names: list of name strings each defining a role - role_names: list of name strings each defining a role
@ -311,6 +324,7 @@ class DiscordClient:
Will try to match with existing roles names Will try to match with existing roles names
Non-existing roles will be created, then created flag will be True Non-existing roles will be created, then created flag will be True
Params: Params:
- guild_id: ID of guild - guild_id: ID of guild
- role_name: strings defining name of a role - role_name: strings defining name of a role

View File

@ -280,6 +280,8 @@ class TestGuildGetName(TestCase):
client = DiscordClient2(TEST_BOT_TOKEN, my_mock_redis) client = DiscordClient2(TEST_BOT_TOKEN, my_mock_redis)
result = client.guild_name(TEST_GUILD_ID) result = client.guild_name(TEST_GUILD_ID)
self.assertEqual(result, guild_name) self.assertEqual(result, guild_name)
self.assertTrue(my_mock_redis.get.called)
self.assertFalse(my_mock_redis.set.called)
@patch(MODULE_PATH + '.DiscordClient.guild_infos') @patch(MODULE_PATH + '.DiscordClient.guild_infos')
def test_fetches_from_server_if_not_found_in_cache_and_stores_in_cache( def test_fetches_from_server_if_not_found_in_cache_and_stores_in_cache(
@ -291,6 +293,20 @@ class TestGuildGetName(TestCase):
client = DiscordClient2(TEST_BOT_TOKEN, my_mock_redis) client = DiscordClient2(TEST_BOT_TOKEN, my_mock_redis)
result = client.guild_name(TEST_GUILD_ID) result = client.guild_name(TEST_GUILD_ID)
self.assertEqual(result, guild_name) self.assertEqual(result, guild_name)
self.assertTrue(my_mock_redis.get.called)
self.assertTrue(my_mock_redis.set.called)
@patch(MODULE_PATH + '.DiscordClient.guild_infos')
def test_fetches_from_server_if_asked_to_ignore_cache_and_stores_in_cache(
self, mock_guild_get_infos
):
guild_name = 'Omega'
my_mock_redis = MagicMock(**{'get.return_value': False})
mock_guild_get_infos.return_value = {'id': TEST_GUILD_ID, 'name': guild_name}
client = DiscordClient2(TEST_BOT_TOKEN, my_mock_redis)
result = client.guild_name(TEST_GUILD_ID, use_cache=False)
self.assertFalse(my_mock_redis.get.called)
self.assertEqual(result, guild_name)
self.assertTrue(my_mock_redis.set.called) self.assertTrue(my_mock_redis.set.called)
@patch(MODULE_PATH + '.DiscordClient.guild_infos') @patch(MODULE_PATH + '.DiscordClient.guild_infos')
@ -302,6 +318,7 @@ class TestGuildGetName(TestCase):
client = DiscordClient2(TEST_BOT_TOKEN, my_mock_redis) client = DiscordClient2(TEST_BOT_TOKEN, my_mock_redis)
result = client.guild_name(TEST_GUILD_ID) result = client.guild_name(TEST_GUILD_ID)
self.assertEqual(result, '') self.assertEqual(result, '')
self.assertTrue(my_mock_redis.get.called)
self.assertFalse(my_mock_redis.set.called) self.assertFalse(my_mock_redis.set.called)
@ -844,9 +861,45 @@ class TestGuildMemberRemoveRole(TestCase):
self.assertFalse(result) self.assertFalse(result)
class TestMatchGuildRolesToName(TestCase):
def setUp(self):
self.url = f'{API_BASE_URL}guilds/{TEST_GUILD_ID}/roles'
@requests_mock.Mocker()
def test_return_role_if_known(self, requests_mocker):
my_mock_redis = MagicMock(**{
'get.return_value': None,
'pttl.return_value': -1,
})
requests_mocker.get(
url=self.url,
request_headers=DEFAULT_REQUEST_HEADERS,
json=ALL_ROLES
)
client = DiscordClient2(TEST_BOT_TOKEN, my_mock_redis)
result = client.match_role_from_name(TEST_GUILD_ID, "alpha")
self.assertDictEqual(result, ROLE_ALPHA)
@requests_mock.Mocker()
def test_return_empty_dict_if_not_known(self, requests_mocker):
my_mock_redis = MagicMock(**{
'get.return_value': None,
'pttl.return_value': -1,
})
requests_mocker.get(
url=self.url,
request_headers=DEFAULT_REQUEST_HEADERS,
json=ALL_ROLES
)
client = DiscordClient2(TEST_BOT_TOKEN, my_mock_redis)
result = client.match_role_from_name(TEST_GUILD_ID, "unknown")
self.assertDictEqual(result, dict())
@patch(MODULE_PATH + '.DiscordClient.create_guild_role') @patch(MODULE_PATH + '.DiscordClient.create_guild_role')
@patch(MODULE_PATH + '.DiscordClient.guild_roles') @patch(MODULE_PATH + '.DiscordClient.guild_roles')
class TestMatchGuildRolesToName(TestCase): class TestMatchOrCreateGuildRolesToName(TestCase):
def test_return_role_if_known( def test_return_role_if_known(
self, mock_guild_get_roles, mock_guild_create_role, self, mock_guild_get_roles, mock_guild_create_role,
@ -896,7 +949,7 @@ class TestMatchGuildRolesToName(TestCase):
@patch(MODULE_PATH + '.DiscordClient.create_guild_role') @patch(MODULE_PATH + '.DiscordClient.create_guild_role')
@patch(MODULE_PATH + '.DiscordClient.guild_roles') @patch(MODULE_PATH + '.DiscordClient.guild_roles')
class TestMatchGuildRolesToNames(TestCase): class TestMatchOrCreateGuildRolesToNames(TestCase):
def test_return_roles_if_known( def test_return_roles_if_known(
self, mock_guild_get_roles, mock_guild_create_role, self, mock_guild_get_roles, mock_guild_create_role,

View File

@ -4,7 +4,7 @@ from urllib.parse import urlencode
from requests_oauthlib import OAuth2Session from requests_oauthlib import OAuth2Session
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from django.contrib.auth.models import User from django.contrib.auth.models import User, Group
from django.db import models from django.db import models
from django.utils.timezone import now from django.utils.timezone import now
@ -19,7 +19,8 @@ from .app_settings import (
DISCORD_GUILD_ID, DISCORD_GUILD_ID,
DISCORD_SYNC_NAMES DISCORD_SYNC_NAMES
) )
from .discord_client import DiscordClient, DiscordApiBackoff from .discord_client import DiscordClient
from .discord_client.exceptions import DiscordClientException, DiscordApiBackoff
from .discord_client.helpers import match_or_create_roles_from_names from .discord_client.helpers import match_or_create_roles_from_names
from .utils import LoggerAddTag from .utils import LoggerAddTag
@ -149,7 +150,7 @@ class DiscordUserManager(models.Manager):
return self.filter(user=user).select_related('user').exists() return self.filter(user=user).select_related('user').exists()
@classmethod @classmethod
def generate_bot_add_url(cls): def generate_bot_add_url(cls) -> str:
params = urlencode({ params = urlencode({
'client_id': DISCORD_APP_ID, 'client_id': DISCORD_APP_ID,
'scope': 'bot', 'scope': 'bot',
@ -159,7 +160,7 @@ class DiscordUserManager(models.Manager):
return f'{DiscordClient.OAUTH_BASE_URL}?{params}' return f'{DiscordClient.OAUTH_BASE_URL}?{params}'
@classmethod @classmethod
def generate_oauth_redirect_url(cls): def generate_oauth_redirect_url(cls) -> str:
oauth = OAuth2Session( oauth = OAuth2Session(
DISCORD_APP_ID, redirect_uri=DISCORD_CALLBACK_URL, scope=cls.SCOPES DISCORD_APP_ID, redirect_uri=DISCORD_CALLBACK_URL, scope=cls.SCOPES
) )
@ -178,18 +179,38 @@ class DiscordUserManager(models.Manager):
return token['access_token'] return token['access_token']
@classmethod @classmethod
def server_name(cls): def server_name(cls, use_cache: bool = True) -> str:
"""returns the name of the current Discord server """returns the name of the current Discord server
or an empty string if the name could not be retrieved or an empty string if the name could not be retrieved
Params:
- use_cache: When set False will force an API call to get the server name
""" """
try: try:
server_name = cls._bot_client().guild_name(DISCORD_GUILD_ID) server_name = cls._bot_client().guild_name(
except HTTPError: guild_id=DISCORD_GUILD_ID, use_cache=use_cache
)
except (HTTPError, DiscordClientException):
server_name = ""
except Exception:
logger.warning(
"Unexpected error when trying to retrieve the server name from Discord",
exc_info=True
)
server_name = "" server_name = ""
return server_name return server_name
@classmethod
def group_to_role(cls, group: Group) -> dict:
"""returns the Discord role matching the given Django group by name
or an empty dict() if no matching role exist
"""
return cls._bot_client().match_role_from_name(
guild_id=DISCORD_GUILD_ID, role_name=group.name
)
@staticmethod @staticmethod
def _bot_client(is_rate_limited: bool = True): def _bot_client(is_rate_limited: bool = True) -> DiscordClient:
"""returns a bot client for access to the Discord API""" """returns a bot client for access to the Discord API"""
return DiscordClient(DISCORD_BOT_TOKEN, is_rate_limited=is_rate_limited) return DiscordClient(DISCORD_BOT_TOKEN, is_rate_limited=is_rate_limited)

View File

@ -1,4 +1,5 @@
import logging import logging
from typing import Any
from celery import shared_task, chain from celery import shared_task, chain
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
@ -94,7 +95,7 @@ def _task_perform_user_action(self, user_pk: int, method: str, **kwargs) -> None
raise self.retry(countdown=bo.retry_after_seconds) raise self.retry(countdown=bo.retry_after_seconds)
except AttributeError: except AttributeError:
raise ValueError(f'{method} not a valid method for DiscordUser: %r') raise ValueError(f'{method} not a valid method for DiscordUser')
except (HTTPError, ConnectionError): except (HTTPError, ConnectionError):
logger.warning( logger.warning(
@ -115,7 +116,7 @@ def _task_perform_user_action(self, user_pk: int, method: str, **kwargs) -> None
) )
except Exception: except Exception:
logger.error( logger.error(
'%s for %s failed due to unexpected exception', '%s for user %s failed due to unexpected exception',
method, method,
user, user,
exc_info=True exc_info=True
@ -186,9 +187,58 @@ def _bulk_update_nicknames_for_users(discord_users_qs: QuerySet) -> None:
chain(update_nicknames_chain).apply_async(priority=BULK_TASK_PRIORITY) chain(update_nicknames_chain).apply_async(priority=BULK_TASK_PRIORITY)
def _task_perform_users_action(self, method: str, **kwargs) -> Any:
"""Perform an action that concerns a group of users or the whole server
and that hits the API
"""
result = None
try:
result = getattr(DiscordUser.objects, method)(**kwargs)
except AttributeError:
raise ValueError(f'{method} not a valid method for DiscordUser.objects')
except DiscordApiBackoff as bo:
logger.info(
"API back off for %s due to %r, retrying in %s seconds",
method,
bo,
bo.retry_after_seconds
)
raise self.retry(countdown=bo.retry_after_seconds)
except (HTTPError, ConnectionError):
logger.warning(
'%s failed, retrying in %d secs',
method,
DISCORD_TASKS_RETRY_PAUSE,
exc_info=True
)
if self.request.retries < DISCORD_TASKS_MAX_RETRIES:
raise self.retry(countdown=DISCORD_TASKS_RETRY_PAUSE)
else:
logger.error('%s failed after max retries', method, exc_info=True)
except Exception:
logger.error('%s failed due to unexpected exception', method, exc_info=True)
return result
@shared_task(
bind=True, name='discord.update_servername', base=QueueOnce, max_retries=None
)
def update_servername(self) -> None:
"""Updates the Discord server name"""
_task_perform_users_action(self, method="server_name", use_cache=False)
@shared_task(name='discord.update_all_usernames') @shared_task(name='discord.update_all_usernames')
def update_all_usernames() -> None: def update_all_usernames() -> None:
"""Update all usernames for all known users with a Discord account.""" """Update all usernames for all known users with a Discord account.
Also updates the server name
"""
update_servername.delay()
discord_users_qs = DiscordUser.objects.all() discord_users_qs = DiscordUser.objects.all()
_bulk_update_usernames_for_users(discord_users_qs) _bulk_update_usernames_for_users(discord_users_qs)

View File

@ -10,6 +10,7 @@ from ..discord_client.tests import ( # noqa
ROLE_BRAVO, ROLE_BRAVO,
ROLE_CHARLIE, ROLE_CHARLIE,
ROLE_MIKE, ROLE_MIKE,
ALL_ROLES,
create_user_info create_user_info
) )

View File

@ -41,7 +41,9 @@ from . import (
create_user_info create_user_info
) )
from ..discord_client.app_settings import DISCORD_API_BASE_URL from ..discord_client.app_settings import DISCORD_API_BASE_URL
from ..discord_client.exceptions import DiscordApiBackoff
from ..models import DiscordUser from ..models import DiscordUser
from .. import tasks
logger = logging.getLogger('allianceauth') logger = logging.getLogger('allianceauth')
@ -444,25 +446,26 @@ class TestUserFeatures(WebTest):
disconnect_signals=True disconnect_signals=True
) )
add_permissions_to_members() add_permissions_to_members()
@patch(MODULE_PATH + '.views.messages') @patch(MODULE_PATH + '.views.messages')
@patch(MODULE_PATH + '.managers.OAuth2Session') @patch(MODULE_PATH + '.managers.OAuth2Session')
def test_user_activation_normal( def test_user_activation_normal(
self, requests_mocker, mock_OAuth2Session, mock_messages self, requests_mocker, mock_OAuth2Session, mock_messages
): ):
# user_get_current() # setup
requests_mocker.get(
guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'}
)
requests_mocker.get( requests_mocker.get(
user_get_current_request.url, user_get_current_request.url,
json=create_user_info( json=create_user_info(
TEST_USER_ID, TEST_USER_NAME, TEST_USER_DISCRIMINATOR TEST_USER_ID, TEST_USER_NAME, TEST_USER_DISCRIMINATOR
) )
) )
# guild_roles()
requests_mocker.get( requests_mocker.get(
guild_roles_request.url, guild_roles_request.url,
json=[ROLE_ALPHA, ROLE_BRAVO, ROLE_MIKE, ROLE_MEMBER] json=[ROLE_ALPHA, ROLE_BRAVO, ROLE_MIKE, ROLE_MEMBER]
) )
# add_guild_member()
requests_mocker.put(add_guild_member_request.url, status_code=201) requests_mocker.put(add_guild_member_request.url, status_code=201)
authentication_code = 'auth_code' authentication_code = 'auth_code'
@ -474,8 +477,12 @@ class TestUserFeatures(WebTest):
# login # login
self.app.set_user(self.member) self.app.set_user(self.member)
# click activate on the service page # user opens services page
response = self.app.get(reverse('discord:activate')) services_page = self.app.get(reverse('services:services'))
self.assertEqual(services_page.status_code, 200)
# user clicks Discord service activation link on page
response = services_page.click(href=reverse('discord:activate'))
# check we got a redirect to Discord OAuth # check we got a redirect to Discord OAuth
self.assertRedirects( self.assertRedirects(
@ -497,7 +504,10 @@ class TestUserFeatures(WebTest):
requests_made.append(obj) requests_made.append(obj)
expected = [ expected = [
user_get_current_request, guild_roles_request, add_guild_member_request guild_infos_request,
user_get_current_request,
guild_roles_request,
add_guild_member_request
] ]
self.assertListEqual(requests_made, expected) self.assertListEqual(requests_made, expected)
@ -506,19 +516,21 @@ class TestUserFeatures(WebTest):
def test_user_activation_failed( def test_user_activation_failed(
self, requests_mocker, mock_OAuth2Session, mock_messages self, requests_mocker, mock_OAuth2Session, mock_messages
): ):
# user_get_current() # setup
requests_mocker.get(
guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'}
)
requests_mocker.get( requests_mocker.get(
user_get_current_request.url, user_get_current_request.url,
json=create_user_info( json=create_user_info(
TEST_USER_ID, TEST_USER_NAME, TEST_USER_DISCRIMINATOR TEST_USER_ID, TEST_USER_NAME, TEST_USER_DISCRIMINATOR
) )
) )
# guild_roles()
requests_mocker.get( requests_mocker.get(
guild_roles_request.url, guild_roles_request.url,
json=[ROLE_ALPHA, ROLE_BRAVO, ROLE_MIKE, ROLE_MEMBER] json=[ROLE_ALPHA, ROLE_BRAVO, ROLE_MIKE, ROLE_MEMBER]
) )
# add_guild_member()
mock_exception = HTTPError('error') mock_exception = HTTPError('error')
mock_exception.response = Mock() mock_exception.response = Mock()
mock_exception.response.status_code = 503 mock_exception.response.status_code = 503
@ -532,9 +544,13 @@ class TestUserFeatures(WebTest):
# login # login
self.app.set_user(self.member) self.app.set_user(self.member)
# user opens services page
services_page = self.app.get(reverse('services:services'))
self.assertEqual(services_page.status_code, 200)
# click activate on the service page # click activate on the service page
response = self.app.get(reverse('discord:activate')) response = services_page.click(href=reverse('discord:activate'))
# check we got a redirect to Discord OAuth # check we got a redirect to Discord OAuth
self.assertRedirects( self.assertRedirects(
@ -556,27 +572,31 @@ class TestUserFeatures(WebTest):
requests_made.append(obj) requests_made.append(obj)
expected = [ expected = [
user_get_current_request, guild_roles_request, add_guild_member_request guild_infos_request,
user_get_current_request,
guild_roles_request,
add_guild_member_request
] ]
self.assertListEqual(requests_made, expected) self.assertListEqual(requests_made, expected)
@patch(MODULE_PATH + '.views.messages') @patch(MODULE_PATH + '.views.messages')
def test_user_deactivation_normal(self, requests_mocker, mock_messages): def test_user_deactivation_normal(self, requests_mocker, mock_messages):
# guild_infos() # setup
requests_mocker.get( requests_mocker.get(
guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'}) guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'}
)
# remove_guild_member()
requests_mocker.delete(remove_guild_member_request.url, status_code=204) requests_mocker.delete(remove_guild_member_request.url, status_code=204)
# user needs have an account
DiscordUser.objects.create(user=self.member, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.member, uid=TEST_USER_ID)
# login # login
self.app.set_user(self.member) self.app.set_user(self.member)
# click deactivate on the service page # user opens services page
response = self.app.get(reverse('discord:deactivate')) services_page = self.app.get(reverse('services:services'))
self.assertEqual(services_page.status_code, 200)
# click deactivate on the service page
response = services_page.click(href=reverse('discord:deactivate'))
# check we got a redirect to service page # check we got a redirect to service page
self.assertRedirects(response, expected_url=reverse('services:services')) self.assertRedirects(response, expected_url=reverse('services:services'))
@ -590,29 +610,31 @@ class TestUserFeatures(WebTest):
obj = DiscordRequest(r.method, r.url) obj = DiscordRequest(r.method, r.url)
requests_made.append(obj) requests_made.append(obj)
expected = [remove_guild_member_request, guild_infos_request] expected = [guild_infos_request, remove_guild_member_request]
self.assertListEqual(requests_made, expected) self.assertListEqual(requests_made, expected)
@patch(MODULE_PATH + '.views.messages') @patch(MODULE_PATH + '.views.messages')
def test_user_deactivation_fails(self, requests_mocker, mock_messages): def test_user_deactivation_fails(self, requests_mocker, mock_messages):
# guild_infos() # setup
requests_mocker.get( requests_mocker.get(
guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'}) guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'}
)
# remove_guild_member()
mock_exception = HTTPError('error') mock_exception = HTTPError('error')
mock_exception.response = Mock() mock_exception.response = Mock()
mock_exception.response.status_code = 503 mock_exception.response.status_code = 503
requests_mocker.delete(remove_guild_member_request.url, exc=mock_exception) requests_mocker.delete(remove_guild_member_request.url, exc=mock_exception)
# user needs have an account
DiscordUser.objects.create(user=self.member, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.member, uid=TEST_USER_ID)
# login # login
self.app.set_user(self.member) self.app.set_user(self.member)
# click deactivate on the service page # user opens services page
response = self.app.get(reverse('discord:deactivate')) services_page = self.app.get(reverse('services:services'))
self.assertEqual(services_page.status_code, 200)
# click deactivate on the service page
response = services_page.click(href=reverse('discord:deactivate'))
# check we got a redirect to service page # check we got a redirect to service page
self.assertRedirects(response, expected_url=reverse('services:services')) self.assertRedirects(response, expected_url=reverse('services:services'))
@ -626,15 +648,13 @@ class TestUserFeatures(WebTest):
obj = DiscordRequest(r.method, r.url) obj = DiscordRequest(r.method, r.url)
requests_made.append(obj) requests_made.append(obj)
expected = [remove_guild_member_request, guild_infos_request] expected = [guild_infos_request, remove_guild_member_request]
self.assertListEqual(requests_made, expected) self.assertListEqual(requests_made, expected)
@patch(MODULE_PATH + '.views.messages') @patch(MODULE_PATH + '.views.messages')
def test_user_add_new_server(self, requests_mocker, mock_messages): def test_user_add_new_server(self, requests_mocker, mock_messages):
# guild_infos() # setup
mock_exception = HTTPError('can not get guild info from Discord API') mock_exception = HTTPError(Mock(**{"response.status_code": 400}))
mock_exception.response = Mock()
mock_exception.response.status_code = 440
requests_mocker.get(guild_infos_request.url, exc=mock_exception) requests_mocker.get(guild_infos_request.url, exc=mock_exception)
# login # login
@ -649,3 +669,39 @@ class TestUserFeatures(WebTest):
# check we got can see the page and the "link server" button # check we got can see the page and the "link server" button
self.assertEqual(response.status_int, 200) self.assertEqual(response.status_int, 200)
self.assertIsNotNone(response.html.find(id='btnLinkDiscordServer')) self.assertIsNotNone(response.html.find(id='btnLinkDiscordServer'))
def test_when_server_name_fails_user_can_still_see_service_page(
self, requests_mocker
):
# setup
requests_mocker.get(guild_infos_request.url, exc=DiscordApiBackoff(1000))
# login
self.app.set_user(self.member)
# user opens services page
services_page = self.app.get(reverse('services:services'))
self.assertEqual(services_page.status_code, 200)
@override_settings(CELERY_ALWAYS_EAGER=True)
def test_server_name_is_updated_by_task(
self, requests_mocker
):
# setup
requests_mocker.get(
guild_infos_request.url, json={'id': TEST_GUILD_ID, 'name': 'Test Guild'}
)
# run task to update usernames
tasks.update_all_usernames()
# login
self.app.set_user(self.member)
# disable API call to make sure server name is not retrieved from API
mock_exception = HTTPError(Mock(**{"response.status_code": 400}))
requests_mocker.get(guild_infos_request.url, exc=mock_exception)
# user opens services page
services_page = self.app.get(reverse('services:services'))
self.assertEqual(services_page.status_code, 200)
self.assertIn("Test Guild", services_page.text)

View File

@ -17,7 +17,7 @@ from . import (
MODULE_PATH, MODULE_PATH,
ROLE_ALPHA, ROLE_ALPHA,
ROLE_BRAVO, ROLE_BRAVO,
ROLE_CHARLIE ROLE_CHARLIE,
) )
from ..discord_client.tests import create_matched_role from ..discord_client.tests import create_matched_role
from ..app_settings import ( from ..app_settings import (
@ -364,6 +364,7 @@ class TestUserHasAccount(TestCase):
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient) @patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient)
@patch(MODULE_PATH + '.managers.logger')
class TestServerName(TestCase): class TestServerName(TestCase):
@classmethod @classmethod
@ -371,16 +372,50 @@ class TestServerName(TestCase):
super().setUpClass() super().setUpClass()
cls.user = AuthUtils.create_user(TEST_USER_NAME) cls.user = AuthUtils.create_user(TEST_USER_NAME)
def test_returns_name_when_api_returns_it(self, mock_DiscordClient): def test_returns_name_when_api_returns_it(self, mock_logger, mock_DiscordClient):
server_name = "El Dorado" server_name = "El Dorado"
mock_DiscordClient.return_value.guild_name.return_value = server_name mock_DiscordClient.return_value.guild_name.return_value = server_name
self.assertEqual(DiscordUser.objects.server_name(), server_name) self.assertEqual(DiscordUser.objects.server_name(), server_name)
self.assertFalse(mock_logger.warning.called)
def test_returns_empty_string_when_api_throws_http_error(self, mock_DiscordClient): def test_returns_empty_string_when_api_throws_http_error(
self, mock_logger, mock_DiscordClient
):
mock_exception = HTTPError('Test exception') mock_exception = HTTPError('Test exception')
mock_exception.response = Mock(**{"status_code": 440}) mock_exception.response = Mock(**{"status_code": 440})
mock_DiscordClient.return_value.guild_name.side_effect = mock_exception mock_DiscordClient.return_value.guild_name.side_effect = mock_exception
self.assertEqual(DiscordUser.objects.server_name(), "") self.assertEqual(DiscordUser.objects.server_name(), "")
self.assertFalse(mock_logger.warning.called)
def test_returns_empty_string_when_api_throws_service_error(
self, mock_logger, mock_DiscordClient
):
mock_DiscordClient.return_value.guild_name.side_effect = DiscordApiBackoff(1000)
self.assertEqual(DiscordUser.objects.server_name(), "")
self.assertFalse(mock_logger.warning.called)
def test_returns_empty_string_when_api_throws_unexpected_error(
self, mock_logger, mock_DiscordClient
):
mock_DiscordClient.return_value.guild_name.side_effect = RuntimeError
self.assertEqual(DiscordUser.objects.server_name(), "")
self.assertTrue(mock_logger.warning.called)
@patch(MODULE_PATH + '.managers.DiscordClient', spec=DiscordClient)
class TestRoleForGroup(TestCase):
def test_return_role_if_found(self, mock_DiscordClient):
mock_DiscordClient.return_value.match_role_from_name.return_value = ROLE_ALPHA
group = Group.objects.create(name='alpha')
self.assertEqual(DiscordUser.objects.group_to_role(group), ROLE_ALPHA)
def test_return_empty_dict_if_not_found(self, mock_DiscordClient):
mock_DiscordClient.return_value.match_role_from_name.return_value = dict()
group = Group.objects.create(name='unknown')
self.assertEqual(DiscordUser.objects.group_to_role(group), dict())

View File

@ -21,6 +21,7 @@ logger = set_logger_to_file(MODULE_PATH, __file__)
@patch(MODULE_PATH + '.DiscordUser.update_groups') @patch(MODULE_PATH + '.DiscordUser.update_groups')
@patch(MODULE_PATH + ".logger")
class TestUpdateGroups(TestCase): class TestUpdateGroups(TestCase):
@classmethod @classmethod
@ -32,16 +33,18 @@ class TestUpdateGroups(TestCase):
cls.group_1.user_set.add(cls.user) cls.group_1.user_set.add(cls.user)
cls.group_2.user_set.add(cls.user) cls.group_2.user_set.add(cls.user)
def test_can_update_groups(self, mock_update_groups): def test_can_update_groups(self, mock_logger, mock_update_groups):
DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID)
tasks.update_groups(self.user.pk) tasks.update_groups(self.user.pk)
self.assertTrue(mock_update_groups.called) self.assertTrue(mock_update_groups.called)
def test_no_action_if_user_has_no_discord_account(self, mock_update_groups): def test_no_action_if_user_has_no_discord_account(
self, mock_logger, mock_update_groups
):
tasks.update_groups(self.user.pk) tasks.update_groups(self.user.pk)
self.assertFalse(mock_update_groups.called) self.assertFalse(mock_update_groups.called)
def test_retries_on_api_backoff(self, mock_update_groups): def test_retries_on_api_backoff(self, mock_logger, mock_update_groups):
DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID)
mock_exception = DiscordApiBackoff(999) mock_exception = DiscordApiBackoff(999)
mock_update_groups.side_effect = mock_exception mock_update_groups.side_effect = mock_exception
@ -49,7 +52,7 @@ class TestUpdateGroups(TestCase):
with self.assertRaises(Retry): with self.assertRaises(Retry):
tasks.update_groups(self.user.pk) tasks.update_groups(self.user.pk)
def test_retry_on_http_error_except_404(self, mock_update_groups): def test_retry_on_http_error_except_404(self, mock_logger, mock_update_groups):
DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID)
mock_exception = HTTPError('error') mock_exception = HTTPError('error')
mock_exception.response = MagicMock() mock_exception.response = MagicMock()
@ -58,8 +61,12 @@ class TestUpdateGroups(TestCase):
with self.assertRaises(Retry): with self.assertRaises(Retry):
tasks.update_groups(self.user.pk) tasks.update_groups(self.user.pk)
self.assertTrue(mock_logger.warning.called)
def test_retry_on_http_error_404_when_user_not_deleted(self, mock_update_groups): def test_retry_on_http_error_404_when_user_not_deleted(
self, mock_logger, mock_update_groups
):
DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID)
mock_exception = HTTPError('error') mock_exception = HTTPError('error')
mock_exception.response = MagicMock() mock_exception.response = MagicMock()
@ -68,26 +75,31 @@ class TestUpdateGroups(TestCase):
with self.assertRaises(Retry): with self.assertRaises(Retry):
tasks.update_groups(self.user.pk) tasks.update_groups(self.user.pk)
self.assertTrue(mock_logger.warning.called)
def test_retry_on_non_http_error(self, mock_update_groups): def test_retry_on_non_http_error(self, mock_logger, mock_update_groups):
DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID)
mock_update_groups.side_effect = ConnectionError mock_update_groups.side_effect = ConnectionError
with self.assertRaises(Retry): with self.assertRaises(Retry):
tasks.update_groups(self.user.pk) tasks.update_groups(self.user.pk)
self.assertTrue(mock_logger.warning.called)
@patch(MODULE_PATH + '.DISCORD_TASKS_MAX_RETRIES', 3) @patch(MODULE_PATH + '.DISCORD_TASKS_MAX_RETRIES', 3)
def test_log_error_if_retries_exhausted(self, mock_update_groups): def test_log_error_if_retries_exhausted(self, mock_logger, mock_update_groups):
DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID) DiscordUser.objects.create(user=self.user, uid=TEST_USER_ID)
mock_task = MagicMock(**{'request.retries': 3}) mock_task = MagicMock(**{'request.retries': 3})
mock_update_groups.side_effect = ConnectionError mock_update_groups.side_effect = ConnectionError
update_groups_inner = tasks.update_groups.__wrapped__.__func__ update_groups_inner = tasks.update_groups.__wrapped__.__func__
update_groups_inner(mock_task, self.user.pk) update_groups_inner(mock_task, self.user.pk)
self.assertTrue(mock_logger.error.called)
@patch(MODULE_PATH + '.delete_user.delay') @patch(MODULE_PATH + '.delete_user.delay')
def test_delete_user_if_user_is_no_longer_member_of_discord_server( def test_delete_user_if_user_is_no_longer_member_of_discord_server(
self, mock_delete_user, mock_update_groups self, mock_delete_user, mock_logger, mock_update_groups
): ):
mock_update_groups.return_value = None mock_update_groups.return_value = None
@ -222,6 +234,72 @@ class TestTaskPerformUserAction(TestCase):
tasks._task_perform_user_action(mock_task, self.user.pk, 'update_groups') tasks._task_perform_user_action(mock_task, self.user.pk, 'update_groups')
@patch(MODULE_PATH + '.DiscordUser.objects.server_name')
@patch(MODULE_PATH + ".logger")
class TestTaskUpdateServername(TestCase):
def test_normal(self, mock_logger, mock_server_name):
tasks.update_servername()
self.assertTrue(mock_server_name.called)
self.assertFalse(mock_logger.error.called)
_, kwargs = mock_server_name.call_args
self.assertFalse(kwargs["use_cache"])
def test_retries_on_api_backoff(self, mock_logger, mock_server_name):
mock_server_name.side_effect = DiscordApiBackoff(999)
with self.assertRaises(Retry):
tasks.update_servername()
self.assertFalse(mock_logger.error.called)
def test_retry_on_http_error(self, mock_logger, mock_server_name):
mock_exception = HTTPError(MagicMock(**{"response.status_code": 500}))
mock_server_name.side_effect = mock_exception
with self.assertRaises(Retry):
tasks.update_servername()
self.assertTrue(mock_logger.warning.called)
def test_retry_on_connection_error(self, mock_logger, mock_server_name):
mock_server_name.side_effect = ConnectionError
with self.assertRaises(Retry):
tasks.update_servername()
self.assertTrue(mock_logger.warning.called)
@patch(MODULE_PATH + '.DISCORD_TASKS_MAX_RETRIES', 3)
def test_log_error_if_retries_exhausted(self, mock_logger, mock_server_name):
mock_task = MagicMock(**{'request.retries': 3})
mock_server_name.side_effect = ConnectionError
update_groups_inner = tasks.update_servername.__wrapped__.__func__
update_groups_inner(mock_task)
self.assertTrue(mock_logger.error.called)
@patch(MODULE_PATH + '.DiscordUser.objects.server_name')
class TestTaskPerformUsersAction(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
def test_raise_value_error_on_unknown_method(self, mock_server_name):
mock_task = MagicMock(**{'request.retries': 0})
with self.assertRaises(ValueError):
tasks._task_perform_users_action(mock_task, 'invalid_method')
def test_catch_and_log_unexpected_exceptions(self, mock_server_name):
mock_server_name.side_effect = RuntimeError
mock_task = MagicMock(**{'request.retries': 0})
tasks._task_perform_users_action(mock_task, 'server_name')
@override_settings(CELERY_ALWAYS_EAGER=True) @override_settings(CELERY_ALWAYS_EAGER=True)
class TestBulkTasks(TestCase): class TestBulkTasks(TestCase):
@ -299,15 +377,19 @@ class TestBulkTasks(TestCase):
self.assertSetEqual(set(current_pks), set(expected_pks)) self.assertSetEqual(set(current_pks), set(expected_pks))
@patch(MODULE_PATH + '.update_username.si') @patch(MODULE_PATH + '.update_username')
def test_can_update_all_usernames(self, mock_update_username): @patch(MODULE_PATH + '.update_servername')
def test_can_update_all_usernames(
self, mock_update_servername, mock_update_username
):
du_1 = DiscordUser.objects.create(user=self.user_1, uid=123) du_1 = DiscordUser.objects.create(user=self.user_1, uid=123)
du_2 = DiscordUser.objects.create(user=self.user_2, uid=456) du_2 = DiscordUser.objects.create(user=self.user_2, uid=456)
du_3 = DiscordUser.objects.create(user=self.user_3, uid=789) du_3 = DiscordUser.objects.create(user=self.user_3, uid=789)
tasks.update_all_usernames() tasks.update_all_usernames()
self.assertEqual(mock_update_username.call_count, 3) self.assertTrue(mock_update_servername.delay.called)
current_pks = [args[0][0] for args in mock_update_username.call_args_list] self.assertEqual(mock_update_username.si.call_count, 3)
current_pks = [args[0][0] for args in mock_update_username.si.call_args_list]
expected_pks = [du_1.pk, du_2.pk, du_3.pk] expected_pks = [du_1.pk, du_2.pk, du_3.pk]
self.assertSetEqual(set(current_pks), set(expected_pks)) self.assertSetEqual(set(current_pks), set(expected_pks))

View File

@ -131,8 +131,8 @@ Name Description
`DISCORD_BOT_TOKEN` Generated bot token for the Discord Auth app `''` `DISCORD_BOT_TOKEN` Generated bot token for the Discord Auth app `''`
`DISCORD_CALLBACK_URL` Oauth callback URL `''` `DISCORD_CALLBACK_URL` Oauth callback URL `''`
`DISCORD_GUILD_ID` Discord ID of your Discord server `''` `DISCORD_GUILD_ID` Discord ID of your Discord server `''`
`DISCORD_GUILD_NAME_CACHE_MAX_AGE` How long the Discord server name is cached locally in milliseconds `3600000` `DISCORD_GUILD_NAME_CACHE_MAX_AGE` How long the Discord server name is cached locally in seconds `86400`
`DISCORD_ROLES_CACHE_MAX_AGE` How long roles retrieved from the Discord server are cached locally in milliseconds `3600000` `DISCORD_ROLES_CACHE_MAX_AGE` How long roles retrieved from the Discord server are cached locally in seconds `3600`
`DISCORD_SYNC_NAMES` When set to True the nicknames of Discord users will be set to the user's main character name `False` `DISCORD_SYNC_NAMES` When set to True the nicknames of Discord users will be set to the user's main character name `False`
`DISCORD_TASKS_RETRY_PAUSE` Pause in seconds until next retry for tasks after an error occurred `60` `DISCORD_TASKS_RETRY_PAUSE` Pause in seconds until next retry for tasks after an error occurred `60`
`DISCORD_TASKS_MAX_RETRIES` max retries of tasks after an error occurred `3` `DISCORD_TASKS_MAX_RETRIES` max retries of tasks after an error occurred `3`