From e3933998ef5ef7f15447dfafe20e0a5df0ea6c36 Mon Sep 17 00:00:00 2001
From: Erik Kalkoken
Date: Wed, 27 May 2020 02:21:00 +0000
Subject: [PATCH] PEP440 versioning for admin dashboard
---
.../authentication/tests/test_templatetags.py | 268 ++++++++++++++++++
.../allianceauth/admin-status/overview.html | 6 +-
allianceauth/templatetags/admin_status.py | 214 ++++++++------
setup.py | 1 +
4 files changed, 398 insertions(+), 91 deletions(-)
create mode 100644 allianceauth/authentication/tests/test_templatetags.py
diff --git a/allianceauth/authentication/tests/test_templatetags.py b/allianceauth/authentication/tests/test_templatetags.py
new file mode 100644
index 00000000..5a999fa3
--- /dev/null
+++ b/allianceauth/authentication/tests/test_templatetags.py
@@ -0,0 +1,268 @@
+from math import ceil
+from unittest.mock import patch
+
+from requests import RequestException
+import requests_mock
+from packaging.version import Version as Pep440Version
+
+from django.test import TestCase
+
+from allianceauth.templatetags.admin_status import (
+ status_overview,
+ _fetch_list_from_gitlab,
+ _current_notifications,
+ _current_version_summary,
+ _fetch_notification_issues_from_gitlab,
+ _fetch_tags_from_gitlab,
+ _latests_versions
+)
+
+MODULE_PATH = 'allianceauth.templatetags'
+
+
+def create_tags_list(tag_names: list):
+ return [{'name': str(tag_name)} for tag_name in tag_names]
+
+
+GITHUB_TAGS = create_tags_list(['v2.4.6a1', 'v2.4.5', 'v2.4.0', 'v2.0.0', 'v1.1.1'])
+GITHUB_NOTIFICATION_ISSUES = [
+ {
+ 'id': 1,
+ 'title': 'first issue'
+ },
+ {
+ 'id': 2,
+ 'title': 'second issue'
+ },
+ {
+ 'id': 3,
+ 'title': 'third issue'
+ },
+ {
+ 'id': 4,
+ 'title': 'forth issue'
+ },
+ {
+ 'id': 5,
+ 'title': 'fifth issue'
+ },
+ {
+ 'id': 6,
+ 'title': 'sixth issue'
+ },
+]
+TEST_VERSION = '2.6.5'
+
+
+class TestStatusOverviewTag(TestCase):
+
+ @patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
+ @patch(MODULE_PATH + '.admin_status._fetch_celery_queue_length')
+ @patch(MODULE_PATH + '.admin_status._current_version_summary')
+ @patch(MODULE_PATH + '.admin_status._current_notifications')
+ def test_status_overview(
+ self,
+ mock_current_notifications,
+ mock_current_version_info,
+ mock_fetch_celery_queue_length
+ ):
+ notifications = {
+ 'notifications': GITHUB_NOTIFICATION_ISSUES[:5]
+ }
+ mock_current_notifications.return_value = notifications
+
+ version_info = {
+ 'latest_major': True,
+ 'latest_minor': True,
+ 'latest_patch': True,
+ 'current_version': TEST_VERSION,
+ 'latest_major_version': '2.0.0',
+ 'latest_minor_version': '2.4.0',
+ 'latest_patch_version': '2.4.5',
+ }
+ mock_current_version_info.return_value = version_info
+ mock_fetch_celery_queue_length.return_value = 3
+
+ context = {}
+ result = status_overview(context)
+ expected = {
+ 'notifications': GITHUB_NOTIFICATION_ISSUES[:5],
+ 'latest_major': True,
+ 'latest_minor': True,
+ 'latest_patch': True,
+ 'current_version': TEST_VERSION,
+ 'latest_major_version': '2.0.0',
+ 'latest_minor_version': '2.4.0',
+ 'latest_patch_version': '2.4.5',
+ 'task_queue_length': 3,
+ }
+ self.assertEqual(result, expected)
+
+
+class TestNotifications(TestCase):
+
+ @requests_mock.mock()
+ def test_fetch_notification_issues_from_gitlab(self, requests_mocker):
+ url = (
+ 'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues'
+ '?labels=announcement'
+ )
+ requests_mocker.get(url, json=GITHUB_NOTIFICATION_ISSUES)
+ result = _fetch_notification_issues_from_gitlab()
+ self.assertEqual(result, GITHUB_NOTIFICATION_ISSUES)
+
+ @patch(MODULE_PATH + '.admin_status.cache')
+ def test_current_notifications_normal(self, mock_cache):
+ mock_cache.get_or_set.return_value = GITHUB_NOTIFICATION_ISSUES
+
+ result = _current_notifications()
+ self.assertEqual(result['notifications'], GITHUB_NOTIFICATION_ISSUES[:5])
+
+ @patch(MODULE_PATH + '.admin_status.cache')
+ def test_current_notifications_failed(self, mock_cache):
+ mock_cache.get_or_set.side_effect = RequestException
+
+ result = _current_notifications()
+ self.assertEqual(result['notifications'], list())
+
+
+class TestCeleryQueueLength(TestCase):
+
+ def test_get_celery_queue_length(self):
+ pass
+
+
+class TestVersionTags(TestCase):
+
+ @patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
+ @patch(MODULE_PATH + '.admin_status.cache')
+ def test_current_version_info_normal(self, mock_cache):
+ mock_cache.get_or_set.return_value = GITHUB_TAGS
+
+ result = _current_version_summary()
+ self.assertTrue(result['latest_major'])
+ self.assertTrue(result['latest_minor'])
+ self.assertTrue(result['latest_patch'])
+ self.assertEqual(result['latest_major_version'], '2.0.0')
+ self.assertEqual(result['latest_minor_version'], '2.4.0')
+ self.assertEqual(result['latest_patch_version'], '2.4.5')
+
+ @patch(MODULE_PATH + '.admin_status.__version__', TEST_VERSION)
+ @patch(MODULE_PATH + '.admin_status.cache')
+ def test_current_version_info_failed(self, mock_cache):
+ mock_cache.get_or_set.side_effect = RequestException
+
+ expected = {}
+ result = _current_version_summary()
+ self.assertEqual(result, expected)
+
+ @requests_mock.mock()
+ def test_fetch_tags_from_gitlab(self, requests_mocker):
+ url = (
+ 'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth'
+ '/repository/tags'
+ )
+ requests_mocker.get(url, json=GITHUB_TAGS)
+ result = _fetch_tags_from_gitlab()
+ self.assertEqual(result, GITHUB_TAGS)
+
+
+class TestLatestsVersion(TestCase):
+
+ def test_all_version_types_defined(self):
+
+ tags = create_tags_list(
+ ['2.1.1', '2.1.0', '2.0.0', '2.1.1a1', '1.1.1', '1.1.0', '1.0.0']
+ )
+ major, minor, patch = _latests_versions(tags)
+ self.assertEqual(major, Pep440Version('2.0.0'))
+ self.assertEqual(minor, Pep440Version('2.1.0'))
+ self.assertEqual(patch, Pep440Version('2.1.1'))
+
+ def test_major_and_minor_not_defined_with_zero(self):
+
+ tags = create_tags_list(
+ ['2.1.2', '2.1.1', '2.0.1', '2.1.1a1', '1.1.1', '1.1.0', '1.0.0']
+ )
+ major, minor, patch = _latests_versions(tags)
+ self.assertEqual(major, Pep440Version('2.0.1'))
+ self.assertEqual(minor, Pep440Version('2.1.1'))
+ self.assertEqual(patch, Pep440Version('2.1.2'))
+
+ def test_can_ignore_invalid_versions(self):
+
+ tags = create_tags_list(
+ ['2.1.1', '2.1.0', '2.0.0', '2.1.1a1', 'invalid']
+ )
+ major, minor, patch = _latests_versions(tags)
+ self.assertEqual(major, Pep440Version('2.0.0'))
+ self.assertEqual(minor, Pep440Version('2.1.0'))
+ self.assertEqual(patch, Pep440Version('2.1.1'))
+
+
+class TestFetchListFromGitlab(TestCase):
+
+ page_size = 2
+
+ def setUp(self):
+ self.url = (
+ 'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth'
+ '/repository/tags'
+ )
+
+ @classmethod
+ def my_callback(cls, request, context):
+ page = int(request.qs['page'][0])
+ start = (page - 1) * cls.page_size
+ end = start + cls.page_size
+ return GITHUB_TAGS[start:end]
+
+ @requests_mock.mock()
+ def test_can_fetch_one_page_with_header(self, requests_mocker):
+ headers = {
+ 'x-total-pages': '1'
+ }
+ requests_mocker.get(self.url, json=GITHUB_TAGS, headers=headers)
+ result = _fetch_list_from_gitlab(self.url)
+ self.assertEqual(result, GITHUB_TAGS)
+ self.assertEqual(requests_mocker.call_count, 1)
+
+ @requests_mock.mock()
+ def test_can_fetch_one_page_wo_header(self, requests_mocker):
+ requests_mocker.get(self.url, json=GITHUB_TAGS)
+ result = _fetch_list_from_gitlab(self.url)
+ self.assertEqual(result, GITHUB_TAGS)
+ self.assertEqual(requests_mocker.call_count, 1)
+
+ @requests_mock.mock()
+ def test_can_fetch_one_page_and_ignore_invalid_header(self, requests_mocker):
+ headers = {
+ 'x-total-pages': 'invalid'
+ }
+ requests_mocker.get(self.url, json=GITHUB_TAGS, headers=headers)
+ result = _fetch_list_from_gitlab(self.url)
+ self.assertEqual(result, GITHUB_TAGS)
+ self.assertEqual(requests_mocker.call_count, 1)
+
+ @requests_mock.mock()
+ def test_can_fetch_multiple_pages(self, requests_mocker):
+ total_pages = ceil(len(GITHUB_TAGS) / self.page_size)
+ headers = {
+ 'x-total-pages': str(total_pages)
+ }
+ requests_mocker.get(self.url, json=self.my_callback, headers=headers)
+ result = _fetch_list_from_gitlab(self.url)
+ self.assertEqual(result, GITHUB_TAGS)
+ self.assertEqual(requests_mocker.call_count, total_pages)
+
+ @requests_mock.mock()
+ def test_can_fetch_given_number_of_pages_only(self, requests_mocker):
+ total_pages = ceil(len(GITHUB_TAGS) / self.page_size)
+ headers = {
+ 'x-total-pages': str(total_pages)
+ }
+ requests_mocker.get(self.url, json=self.my_callback, headers=headers)
+ max_pages = 2
+ result = _fetch_list_from_gitlab(self.url, max_pages=max_pages)
+ self.assertEqual(result, GITHUB_TAGS[:4])
+ self.assertEqual(requests_mocker.call_count, max_pages)
diff --git a/allianceauth/templates/allianceauth/admin-status/overview.html b/allianceauth/templates/allianceauth/admin-status/overview.html
index ecce642f..3d6ce5ef 100644
--- a/allianceauth/templates/allianceauth/admin-status/overview.html
+++ b/allianceauth/templates/allianceauth/admin-status/overview.html
@@ -36,7 +36,7 @@
{{ current_version }}
-
+
{% trans "Latest Major" %}
@@ -46,7 +46,7 @@
{% if not latest_major %}
{% trans "Update available" %}{% endif %}
-
+
{% trans "Latest Minor" %}
@@ -56,7 +56,7 @@
{% if not latest_minor %}
{% trans "Update available" %}{% endif %}
-
+
{% trans "Latest Patch" %}
diff --git a/allianceauth/templatetags/admin_status.py b/allianceauth/templatetags/admin_status.py
index fc98063c..808a69be 100644
--- a/allianceauth/templatetags/admin_status.py
+++ b/allianceauth/templatetags/admin_status.py
@@ -1,60 +1,59 @@
-import requests
import logging
+
+import requests
import amqp.exceptions
-import semantic_version as semver
+from packaging.version import Version as Pep440Version, InvalidVersion
+from celery.app import app_or_default
+
from django import template
from django.conf import settings
from django.core.cache import cache
-from celery.app import app_or_default
+
from allianceauth import __version__
+
register = template.Library()
-TAG_CACHE_TIME = 10800 # 3 hours
+# cache timers
+TAG_CACHE_TIME = 3600 # 1 hours
NOTIFICATION_CACHE_TIME = 300 # 5 minutes
+# timeout for all requests
+REQUESTS_TIMEOUT = 5 # 5 seconds
+# max pages to be fetched from gitlab
+MAX_PAGES = 50
+GITLAB_AUTH_REPOSITORY_TAGS_URL = (
+ 'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/repository/tags'
+)
+GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL = (
+ 'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues'
+ '?labels=announcement'
+)
logger = logging.getLogger(__name__)
-def get_git_tags():
- request = requests.get('https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/repository/tags')
- request.raise_for_status()
- return request.json()
-
-
-def get_notification_issues():
- # notification
- request = requests.get(
- 'https://gitlab.com/api/v4/projects/allianceauth%2Fallianceauth/issues?labels=announcement')
- request.raise_for_status()
- return request.json()
-
-
@register.inclusion_tag('allianceauth/admin-status/overview.html', takes_context=True)
def status_overview(context):
response = {
- 'notifications': list(),
- 'latest_major': True,
- 'latest_minor': True,
- 'latest_patch': True,
+ 'notifications': list(),
'current_version': __version__,
'task_queue_length': -1,
}
-
- response.update(get_notifications())
- response.update(get_version_info())
- response.update({'task_queue_length': get_celery_queue_length()})
-
+ response.update(_current_notifications())
+ response.update(_current_version_summary())
+ response.update({'task_queue_length': _fetch_celery_queue_length()})
return response
-def get_celery_queue_length():
+def _fetch_celery_queue_length():
try:
app = app_or_default(None)
with app.connection_or_acquire() as conn:
return conn.default_channel.queue_declare(
- queue=getattr(settings, 'CELERY_DEFAULT_QUEUE', 'celery'), passive=True).message_count
+ queue=getattr(settings, 'CELERY_DEFAULT_QUEUE', 'celery'),
+ passive=True
+ ).message_count
except amqp.exceptions.ChannelError:
# Queue doesn't exist, probably empty
return 0
@@ -63,72 +62,111 @@ def get_celery_queue_length():
return -1
-def get_notifications():
- response = {
- 'notifications': list(),
- }
+def _current_notifications() -> dict:
+ """returns the newest 5 announcement issues"""
try:
- notifications = cache.get_or_set('gitlab_notification_issues', get_notification_issues,
- NOTIFICATION_CACHE_TIME)
- # Limit notifications to those posted by repo owners and members
- response['notifications'] += notifications[:5]
+ notifications = cache.get_or_set(
+ 'gitlab_notification_issues',
+ _fetch_notification_issues_from_gitlab,
+ NOTIFICATION_CACHE_TIME
+ )
+ top_notifications = notifications[:5]
except requests.RequestException:
logger.exception('Error while getting gitlab notifications')
+ top_notifications = []
+
+ response = {
+ 'notifications': top_notifications,
+ }
return response
-def get_version_info():
- response = {
- 'latest_major': True,
- 'latest_minor': True,
- 'latest_patch': True,
- 'current_version': __version__,
- }
+def _fetch_notification_issues_from_gitlab() -> list:
+ return _fetch_list_from_gitlab(GITLAB_AUTH_ANNOUNCEMENT_ISSUES_URL, max_pages=10)
+
+
+def _current_version_summary() -> dict:
+ """returns the current version info"""
try:
- tags = cache.get_or_set('git_release_tags', get_git_tags, TAG_CACHE_TIME)
- current_ver = semver.Version.coerce(__version__)
-
- # Set them all to the current version to start
- # If the server has only earlier or the same version
- # then this will become the major/minor/patch versions
- latest_major = current_ver
- latest_minor = current_ver
- latest_patch = current_ver
-
- response.update({
- 'latest_major_version': str(latest_major),
- 'latest_minor_version': str(latest_minor),
- 'latest_patch_version': str(latest_patch),
- })
-
- for tag in tags:
- tag_name = tag.get('name')
- if tag_name[0] == 'v':
- # Strip 'v' off front of verison if it exists
- tag_name = tag_name[1:]
- try:
- tag_ver = semver.Version.coerce(tag_name)
- except ValueError:
- tag_ver = semver.Version('0.0.0', partial=True)
- if tag_ver > current_ver:
- if latest_major is None or tag_ver > latest_major:
- latest_major = tag_ver
- response['latest_major_version'] = tag_name
- if tag_ver.major > current_ver.major:
- response['latest_major'] = False
- elif tag_ver.major == current_ver.major:
- if latest_minor is None or tag_ver > latest_minor:
- latest_minor = tag_ver
- response['latest_minor_version'] = tag_name
- if tag_ver.minor > current_ver.minor:
- response['latest_minor'] = False
- elif tag_ver.minor == current_ver.minor:
- if latest_patch is None or tag_ver > latest_patch:
- latest_patch = tag_ver
- response['latest_patch_version'] = tag_name
- if tag_ver.patch > current_ver.patch:
- response['latest_patch'] = False
-
+ tags = cache.get_or_set(
+ 'git_release_tags', _fetch_tags_from_gitlab, TAG_CACHE_TIME
+ )
except requests.RequestException:
logger.exception('Error while getting gitlab release tags')
+ return {}
+
+ latest_major_version, latest_minor_version, latest_patch_version = \
+ _latests_versions(tags)
+ current_version = Pep440Version(__version__)
+
+ has_latest_major = \
+ current_version >= latest_major_version if latest_major_version else False
+ has_latest_minor = \
+ current_version >= latest_minor_version if latest_minor_version else False
+ has_latest_patch = \
+ current_version >= latest_patch_version if latest_patch_version else False
+
+ response = {
+ 'latest_major': has_latest_major,
+ 'latest_minor': has_latest_minor,
+ 'latest_patch': has_latest_patch,
+ 'current_version': str(current_version),
+ 'latest_major_version': str(latest_major_version),
+ 'latest_minor_version': str(latest_minor_version),
+ 'latest_patch_version': str(latest_patch_version)
+ }
return response
+
+
+def _fetch_tags_from_gitlab():
+ return _fetch_list_from_gitlab(GITLAB_AUTH_REPOSITORY_TAGS_URL)
+
+
+def _latests_versions(tags: list) -> tuple:
+ """returns latests version from given tags list
+
+ Non-compliant tags will be ignored
+ """
+ versions = list()
+ for tag in tags:
+ try:
+ version = Pep440Version(tag.get('name'))
+ except InvalidVersion:
+ pass
+ else:
+ if not version.is_prerelease:
+ versions.append(version)
+
+ latest_version = latest_patch_version = max(versions)
+ latest_major_version = min([
+ v for v in versions if v.major == latest_version.major
+ ])
+ latest_minor_version = min([
+ v for v in versions
+ if v.major == latest_version.major and v.minor == latest_version.minor
+ ])
+
+ return latest_major_version, latest_minor_version, latest_patch_version
+
+
+def _fetch_list_from_gitlab(url: str, max_pages: int = MAX_PAGES):
+ """returns a list from the GitLab API. Supports pageing"""
+ result = list()
+ for page in range(1, max_pages + 1):
+ request = requests.get(
+ url, params={'page': page}, timeout=REQUESTS_TIMEOUT
+ )
+ request.raise_for_status()
+ result += request.json()
+ if 'x-total-pages' in request.headers:
+ try:
+ total_pages = int(request.headers['x-total-pages'])
+ except ValueError:
+ total_pages = None
+ else:
+ total_pages = None
+
+ if not total_pages or page >= total_pages:
+ break
+
+ return result
diff --git a/setup.py b/setup.py
index 5ba64616..0f43438f 100644
--- a/setup.py
+++ b/setup.py
@@ -16,6 +16,7 @@ install_requires = [
'python-slugify>=1.2',
'requests-oauthlib',
'semantic_version',
+ 'packaging>=20.1,<21',
'redis>=3.3.1,<4.0.0',
'celery>=4.3.0,<5.0.0',