diff --git a/.travis.yml b/.travis.yml index 3d80fa22..3295e1ba 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ matrix: - python: "3.5" env: DJANGO_VERSION=2.1 install: - - pip install flake8 + - pip install flake8 mock - pip install -q Django==$DJANGO_VERSION - python setup.py install before_script: diff --git a/README.md b/README.md index bbcb6f9e..f9c8d61b 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ same name. * The results associated to an executable and a revision which has a non blank tag field will be listed as a baseline option in the Timeline view. -* Additionaly, the Comparison view will show the results of the latest revision +* Additionally, the Comparison view will show the results of the latest revision of projects being tracked as an executable as well. ### Defaults @@ -224,6 +224,18 @@ COMP_EXECUTABLES = [ ('myexe', 'L'), ] ``` +* `COMPARISON_COMMIT_TAGS: Defines a list of tags to display on the comparison page. This comes + handy when there are a lot of tags. It defaults to ``None`` which means display all the available + tags. + +### VCS Provider Specific Settings + +#### Github + +* ``GITHUB_OAUTH_TOKEN`` - Github oAuth token to use for authenticating against + the Github API. If not provided, it will default to unauthenticated API requests + which have low rate limits so an exception may be thrown when retrieving info + from the Github API due to the rate limit being reached. ## Getting help diff --git a/codespeed/admin.py b/codespeed/admin.py index 027a8e49..0e29c639 100644 --- a/codespeed/admin.py +++ b/codespeed/admin.py @@ -37,7 +37,7 @@ class ProjectAdmin(admin.ModelAdmin): @admin.register(Branch) class BranchAdmin(admin.ModelAdmin): - list_display = ('name', 'project') + list_display = ('name', 'project', 'display_on_comparison_page') list_filter = ('project',) diff --git a/codespeed/auth.py b/codespeed/auth.py index 85764dcb..487a0098 100644 --- a/codespeed/auth.py +++ b/codespeed/auth.py @@ -1,4 +1,5 @@ import logging +import types from functools import wraps from django.contrib.auth import authenticate, login from django.http import HttpResponse, HttpResponseForbidden @@ -9,6 +10,20 @@ logger = logging.getLogger(__name__) +def is_authenticated(request): + # NOTE: We do type check so we also support newer versions of Django when + # is_authenticated and some other methods have been properties + if isinstance(request.user.is_authenticated, (types.FunctionType, + types.MethodType)): + return request.user.is_authenticated() + elif isinstance(request.user.is_authenticated, bool): + return request.user.is_authenticated + else: + logger.info('Got unexpected type for request.user.is_authenticated ' + 'variable') + return False + + def basic_auth_required(realm='default'): def _helper(func): @wraps(func) @@ -18,7 +33,7 @@ def _decorator(request, *args, **kwargs): if settings.ALLOW_ANONYMOUS_POST: logger.debug('allowing anonymous post') allowed = True - elif hasattr(request, 'user') and request.user.is_authenticated(): + elif hasattr(request, 'user') and is_authenticated(request=request): allowed = True elif 'HTTP_AUTHORIZATION' in request.META: logger.debug('checking for http authorization header') diff --git a/codespeed/commits/git.py b/codespeed/commits/git.py index 3802f630..06bc8a00 100644 --- a/codespeed/commits/git.py +++ b/codespeed/commits/git.py @@ -1,25 +1,29 @@ import datetime import logging import os -from string import strip -from subprocess import Popen, PIPE +from subprocess import Popen, PIPE from django.conf import settings - from .exceptions import CommitLogError logger = logging.getLogger(__name__) +def execute_command(cmd, cwd): + p = Popen(cmd, stdout=PIPE, stderr=PIPE, cwd=cwd) + stdout, stderr = p.communicate() + stdout = stdout.decode('utf8') if stdout is not None else stdout + stderr = stderr.decode('utf8') if stderr is not None else stderr + return (p, stdout, stderr) + + def updaterepo(project, update=True): if os.path.exists(project.working_copy): if not update: return - p = Popen(['git', 'pull'], stdout=PIPE, stderr=PIPE, - cwd=project.working_copy) + p, _, stderr = execute_command(['git', 'pull'], cwd=project.working_copy) - stdout, stderr = p.communicate() if p.returncode != 0: raise CommitLogError("git pull returned %s: %s" % (p.returncode, stderr)) @@ -27,11 +31,9 @@ def updaterepo(project, update=True): return [{'error': False}] else: cmd = ['git', 'clone', project.repo_path, project.repo_name] - p = Popen(cmd, stdout=PIPE, stderr=PIPE, - cwd=settings.REPOSITORY_BASE_PATH) + p, stdout, stderr = execute_command(cmd, settings.REPOSITORY_BASE_PATH) logger.debug('Cloning Git repo {0} for project {1}'.format( project.repo_path, project)) - stdout, stderr = p.communicate() if p.returncode != 0: raise CommitLogError("%s returned %s: %s" % ( @@ -59,32 +61,24 @@ def getlogs(endrev, startrev): cmd.append(endrev.commitid) working_copy = endrev.branch.project.working_copy - p = Popen(cmd, stdout=PIPE, stderr=PIPE, cwd=working_copy) - - stdout, stderr = p.communicate() + p, stdout, stderr = execute_command(cmd, working_copy) if p.returncode != 0: raise CommitLogError("%s returned %s: %s" % ( " ".join(cmd), p.returncode, stderr)) logs = [] - for log in filter(None, stdout.split(b'\x1e')): + for log in filter(None, stdout.split('\x1e')): (short_commit_id, commit_id, date_t, author_name, author_email, - subject, body) = map(strip, log.split(b'\x00', 7)) - - tag = "" + subject, body) = map(lambda s: s.strip(), log.split('\x00', 7)) cmd = ["git", "tag", "--points-at", commit_id] - proc = Popen(cmd, stdout=PIPE, stderr=PIPE, cwd=working_copy) try: - stdout, stderr = proc.communicate() - except ValueError: - stdout = b'' - stderr = b'' - - if proc.returncode == 0: - tag = stdout.strip() + p, stdout, stderr = execute_command(cmd, working_copy) + except Exception: + logger.debug('Failed to get tag', exc_info=True) + tag = stdout.strip() if p.returncode == 0 else "" date = datetime.datetime.fromtimestamp( int(date_t)).strftime("%Y-%m-%d %H:%M:%S") diff --git a/codespeed/commits/github.py b/codespeed/commits/github.py index b06d130e..820b1367 100644 --- a/codespeed/commits/github.py +++ b/codespeed/commits/github.py @@ -11,14 +11,17 @@ try: # Python 3 from urllib.request import urlopen + from urllib.request import Request except ImportError: # Python 2 - from urllib import urlopen + from urllib2 import urlopen + from urllib2 import Request import re import json import isodate from django.core.cache import cache +from django.conf import settings from .exceptions import CommitLogError @@ -42,8 +45,17 @@ def fetch_json(url): json_obj = cache.get(url) if json_obj is None: + github_oauth_token = getattr(settings, 'GITHUB_OAUTH_TOKEN', None) + + if github_oauth_token: + headers = {'Authorization': 'token %s' % (github_oauth_token)} + else: + headers = {} + + request = Request(url=url, headers=headers) + try: - json_obj = json.load(urlopen(url)) + json_obj = json.load(urlopen(request)) except IOError as e: logger.exception("Unable to load %s: %s", url, e, exc_info=True) @@ -91,11 +103,13 @@ def retrieve_revision(commit_id, username, project, revision=None): if revision: # Overwrite any existing data we might have for this revision since # we never want our records to be out of sync with the actual VCS: - - # We need to convert the timezone-aware date to a naive (i.e. - # timezone-less) date in UTC to avoid killing MySQL: - revision.date = date.astimezone( - isodate.tzinfo.Utc()).replace(tzinfo=None) + if not getattr(settings, 'USE_TZ_AWARE_DATES', False): + # We need to convert the timezone-aware date to a naive (i.e. + # timezone-less) date in UTC to avoid killing MySQL: + logger.debug('USE_TZ_AWARE_DATES setting is set to False, ' + 'converting datetime object to a naive one') + revision.date = date.astimezone( + isodate.tzinfo.Utc()).replace(tzinfo=None) revision.author = commit_json['author']['name'] revision.message = commit_json['message'] revision.full_clean() diff --git a/codespeed/commits/tests/__init__.py b/codespeed/commits/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/codespeed/commits/tests/test_git.py b/codespeed/commits/tests/test_git.py new file mode 100644 index 00000000..ad74b280 --- /dev/null +++ b/codespeed/commits/tests/test_git.py @@ -0,0 +1,60 @@ +from datetime import datetime +from django.test import TestCase, override_settings +from mock import Mock, patch + +from codespeed.commits.git import getlogs +from codespeed.models import Project, Revision, Branch, Environment + + +@override_settings(ALLOW_ANONYMOUS_POST=True) +class GitTest(TestCase): + def setUp(self): + self.env = Environment.objects.create(name='env') + self.project = Project.objects.create(name='project', + repo_path='path', + repo_type=Project.GIT) + self.branch = Branch.objects.create(name='default', + project_id=self.project.id) + self.revision = Revision.objects.create( + **{ + 'commitid': 'id1', + 'date': datetime.now(), + 'project_id': self.project.id, + 'branch_id': self.branch.id, + } + ) + + @patch("codespeed.commits.git.Popen") + def test_git_output_parsing(self, popen): + # given + outputs = { + "log": b"id\x00long_id\x001583489681\x00author\x00email\x00msg\x00\x1e", + "tag": b'tag', + } + + def side_effect(cmd, *args, **kwargs): + ret = Mock() + ret.returncode = 0 + git_command = cmd[1] if len(cmd) > 0 else None + output = outputs.get(git_command, b'') + ret.communicate.return_value = (output, b'') + return ret + + popen.side_effect = side_effect + + # when + # revision doesn't matter here, git commands are mocked + logs = getlogs(self.revision, self.revision) + + # then + expected = { + 'date': '2020-03-06 04:14:41', + 'message': 'msg', + 'commitid': 'long_id', + 'author': 'author', + 'author_email': 'email', + 'body': '', + 'short_commit_id': 'id', + 'tag': 'tag', + } + self.assertEquals([expected], logs) diff --git a/codespeed/migrations/0004_branch_display_on_comparison_page.py b/codespeed/migrations/0004_branch_display_on_comparison_page.py new file mode 100644 index 00000000..e5398b05 --- /dev/null +++ b/codespeed/migrations/0004_branch_display_on_comparison_page.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.15 on 2020-02-24 11:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('codespeed', '0003_project_default_branch'), + ] + + operations = [ + migrations.AddField( + model_name='branch', + name='display_on_comparison_page', + field=models.BooleanField(default=True, verbose_name='True to display this branch on the comparison page'), + ), + ] diff --git a/codespeed/models.py b/codespeed/models.py index f673d43c..b67aacb5 100644 --- a/codespeed/models.py +++ b/codespeed/models.py @@ -107,6 +107,9 @@ class Branch(models.Model): name = models.CharField(max_length=32) project = models.ForeignKey( Project, on_delete=models.CASCADE, related_name="branches") + display_on_comparison_page = models.BooleanField( + "True to display this branch on the comparison page", + default=True) def __str__(self): return self.project.name + ":" + self.name @@ -475,7 +478,7 @@ def get_changes_table(self, trend_depth=10, force_save=False): val_max = "-" # Calculate percentage change relative to previous result - result = resobj.value + result = max(resobj.value, 0) change = "-" if len(change_list): c = change_list.filter(benchmark=bench) diff --git a/codespeed/settings.py b/codespeed/settings.py index 42a38678..5290a15e 100644 --- a/codespeed/settings.py +++ b/codespeed/settings.py @@ -78,8 +78,29 @@ # ('myexe', '21df2423ra'), # ('myexe', 'L'),] +COMPARISON_COMMIT_TAGS = None # List of tag names which should be included in the executables list + # on the comparision page. + # This comes handy where project contains a lot of tags, but you only want + # to list subset of them on the comparison page. + # If this value is set to None (default value), all the available tags will + # be included. + +TIMELINE_EXECUTABLE_NAME_MAX_LEN = 22 # Maximum length of the executable name used in the + # Changes and Timeline view. If the name is longer, the name + # will be truncated and "..." will be added at the end. + +COMPARISON_EXECUTABLE_NAME_MAX_LEN = 20 # Maximum length of the executable name used in the + # Coomparison view. If the name is longer, the name + USE_MEDIAN_BANDS = True # True to enable median bands on Timeline view ALLOW_ANONYMOUS_POST = True # Whether anonymous users can post results REQUIRE_SECURE_AUTH = True # Whether auth needs to be over a secure channel + +US_TZ_AWARE_DATES = False # True to use timezone aware datetime objects with Github provider. + # NOTE: Some database backends may not support tz aware dates. + +GITHUB_OAUTH_TOKEN = None # Github oAuth token to use when using Github repo type. If not + # specified, it will utilize unauthenticated requests which have + # low rate limits. diff --git a/codespeed/templates/codespeed/timeline.html b/codespeed/templates/codespeed/timeline.html index 2367e17f..ce6e4ebb 100644 --- a/codespeed/templates/codespeed/timeline.html +++ b/codespeed/templates/codespeed/timeline.html @@ -6,7 +6,7 @@ {% block extra_head %} {{ block.super }} - + {% endblock %} diff --git a/codespeed/tests/test_auth.py b/codespeed/tests/test_auth.py new file mode 100644 index 00000000..8e2868bc --- /dev/null +++ b/codespeed/tests/test_auth.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- + +import mock + +from django.test import TestCase, override_settings +from django.http import HttpResponse +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory + +from codespeed.auth import basic_auth_required +from codespeed.views import add_result + + +@override_settings(ALLOW_ANONYMOUS_POST=False) +class AuthModuleTestCase(TestCase): + @override_settings(ALLOW_ANONYMOUS_POST=True) + def test_allow_anonymous_post_is_true(self): + wrapped_function = mock.Mock() + wrapped_function.__name__ = 'mock' + wrapped_function.return_value = 'success' + + request = mock.Mock() + request.user = AnonymousUser() + request.META = {} + + res = basic_auth_required()(wrapped_function)(request=request) + self.assertEqual(wrapped_function.call_count, 1) + self.assertEqual(res, 'success') + + def test_basic_auth_required_django_pre_2_0_succesful_auth(self): + # request.user.is_authenticated is a method (pre Django 2.0) + user = mock.Mock() + user.is_authenticated = lambda: True + + request = mock.Mock() + request.user = user + + wrapped_function = mock.Mock() + wrapped_function.__name__ = 'mock' + wrapped_function.return_value = 'success' + + res = basic_auth_required()(wrapped_function)(request=request) + self.assertEqual(wrapped_function.call_count, 1) + self.assertEqual(res, 'success') + + def test_basic_auth_required_django_pre_2_0_failed_auth(self): + # request.user.is_authenticated is a method (pre Django 2.0) + user = mock.Mock() + user.is_authenticated = lambda: False + + request = mock.Mock() + request.user = user + request.META = {} + + wrapped_function = mock.Mock() + wrapped_function.__name__ = 'mock' + + res = basic_auth_required()(wrapped_function)(request=request) + self.assertTrue(isinstance(res, HttpResponse)) + self.assertEqual(res.status_code, 401) + self.assertEqual(wrapped_function.call_count, 0) + + # Also test with actual AnonymousUser class which will have different + # implementation under different Django versions + request.user = AnonymousUser() + + res = basic_auth_required()(wrapped_function)(request=request) + self.assertTrue(isinstance(res, HttpResponse)) + self.assertEqual(res.status_code, 401) + self.assertEqual(wrapped_function.call_count, 0) + + def test_basic_auth_required_django_post_2_0_successful_auth(self): + # request.user.is_authenticated is a property (post Django 2.0) + user = mock.Mock() + user.is_authenticated = True + + request = mock.Mock() + request.user = user + + wrapped_function = mock.Mock() + wrapped_function.__name__ = 'mock' + wrapped_function.return_value = 'success' + + res = basic_auth_required()(wrapped_function)(request=request) + self.assertEqual(wrapped_function.call_count, 1) + self.assertEqual(res, 'success') + + def test_basic_auth_required_django_post_2_0_failed_auth(self): + # request.user.is_authenticated is a property (post Django 2.0) + user = mock.Mock() + user.is_authenticated = False + + request = mock.Mock() + request.user = user + request.META = {} + + wrapped_function = mock.Mock() + wrapped_function.__name__ = 'mock' + + res = basic_auth_required()(wrapped_function)(request=request) + self.assertTrue(isinstance(res, HttpResponse)) + self.assertEqual(res.status_code, 401) + self.assertEqual(wrapped_function.call_count, 0) + + # Also test with actual AnonymousUser class which will have different + # implementation under different Django versions + request.user = AnonymousUser() + + res = basic_auth_required()(wrapped_function)(request=request) + self.assertTrue(isinstance(res, HttpResponse)) + self.assertEqual(res.status_code, 401) + self.assertEqual(wrapped_function.call_count, 0) + + @mock.patch('codespeed.views.save_result', mock.Mock()) + def test_basic_auth_with_failed_auth_request_factory(self): + request_factory = RequestFactory() + + request = request_factory.get('/timeline') + request.user = AnonymousUser() + request.method = 'POST' + + response = add_result(request) + self.assertEqual(response.status_code, 403) + + @mock.patch('codespeed.views.create_report_if_enough_data', mock.Mock()) + @mock.patch('codespeed.views.save_result', mock.Mock(return_value=([1, 2, 3], None))) + def test_basic_auth_successefull_auth_request_factory(self): + request_factory = RequestFactory() + + user = mock.Mock() + user.is_authenticated = True + + request = request_factory.get('/result/add') + request.user = user + request.method = 'POST' + + response = add_result(request) + self.assertEqual(response.status_code, 202) diff --git a/codespeed/tests/test_views_data.py b/codespeed/tests/test_views_data.py index 44eb1fb4..3bc5f6c7 100644 --- a/codespeed/tests/test_views_data.py +++ b/codespeed/tests/test_views_data.py @@ -1,8 +1,12 @@ # -*- coding: utf-8 -*- from django.test import TestCase +from django.test import override_settings from codespeed.models import Project, Executable, Branch, Revision from codespeed.views import getbaselineexecutables +from codespeed.views import getcomparisonexes +from codespeed.views_data import get_sanitized_executable_name_for_timeline_view +from codespeed.views_data import get_sanitized_executable_name_for_comparison_view class TestGetBaselineExecutables(TestCase): @@ -38,3 +42,239 @@ def test_get_baseline_executables(self): Revision.objects.create(commitid='3', branch=self.branch) result = getbaselineexecutables() self.assertEqual(len(result), 3) + + +class TestGetComparisonExes(TestCase): + def setUp(self): + self.project = Project.objects.create(name='Test') + self.executable_1 = Executable.objects.create( + name='TestExecutable1', project=self.project) + self.executable_2 = Executable.objects.create( + name='TestExecutable2', project=self.project) + self.branch_master = Branch.objects.create(name='master', + project=self.project) + self.branch_custom = Branch.objects.create(name='custom', + project=self.project) + + self.revision_1_master = Revision.objects.create( + branch=self.branch_master, commitid='1') + self.revision_1_custom = Revision.objects.create( + branch=self.branch_custom, commitid='1') + + def _insert_mock_revision_objects(self): + self.rev_v4 = Revision.objects.create( + branch=self.branch_master, commitid='4', tag='v4.0.0') + self.rev_v5 = Revision.objects.create( + branch=self.branch_master, commitid='5', tag='v5.0.0') + self.rev_v6 = Revision.objects.create( + branch=self.branch_master, commitid='6', tag='v6.0.0') + + def test_get_comparisonexes_master_default_branch(self): + # Standard "master" default branch is used + self.project.default_branch = 'master' + self.project.save() + + executables, exe_keys = getcomparisonexes() + self.assertEqual(len(executables), 1) + self.assertEqual(len(executables[self.project]), 4) + self.assertEqual(len(exe_keys), 4) + + self.assertEqual(executables[self.project][0]['executable'], + self.executable_1) + self.assertEqual(executables[self.project][0]['revision'], + self.revision_1_master) + self.assertEqual(executables[self.project][0]['key'], + '1+L+master') + self.assertEqual(executables[self.project][0]['name'], + 'TestExecutable1 latest') + self.assertEqual(executables[self.project][0]['revision'], + self.revision_1_master) + + self.assertEqual(executables[self.project][1]['key'], + '2+L+master') + self.assertEqual(executables[self.project][1]['name'], + 'TestExecutable2 latest') + + self.assertEqual(executables[self.project][2]['key'], + '1+L+custom') + self.assertEqual(executables[self.project][2]['name'], + 'TestExecutable1 latest in branch \'custom\'') + + self.assertEqual(executables[self.project][3]['key'], + '2+L+custom') + self.assertEqual(executables[self.project][3]['name'], + 'TestExecutable2 latest in branch \'custom\'') + + self.assertEqual(exe_keys[0], '1+L+master') + self.assertEqual(exe_keys[1], '2+L+master') + + def test_get_comparisonexes_custom_default_branch(self): + # Custom default branch is used + self.project.default_branch = 'custom' + self.project.save() + + executables, exe_keys = getcomparisonexes() + self.assertEqual(len(executables), 1) + self.assertEqual(len(executables[self.project]), 4) + self.assertEqual(len(exe_keys), 4) + + self.assertEqual(executables[self.project][0]['executable'], + self.executable_1) + self.assertEqual(executables[self.project][0]['revision'], + self.revision_1_master) + self.assertEqual(executables[self.project][0]['key'], + '1+L+master') + self.assertEqual(executables[self.project][0]['name'], + 'TestExecutable1 latest in branch \'master\'') + self.assertEqual(executables[self.project][0]['revision'], + self.revision_1_master) + + self.assertEqual(executables[self.project][1]['key'], + '2+L+master') + self.assertEqual(executables[self.project][1]['name'], + 'TestExecutable2 latest in branch \'master\'') + + self.assertEqual(executables[self.project][2]['key'], + '1+L+custom') + self.assertEqual(executables[self.project][2]['name'], + 'TestExecutable1 latest') + + self.assertEqual(executables[self.project][3]['key'], + '2+L+custom') + self.assertEqual(executables[self.project][3]['name'], + 'TestExecutable2 latest') + + self.assertEqual(exe_keys[0], '1+L+master') + self.assertEqual(exe_keys[1], '2+L+master') + self.assertEqual(exe_keys[2], '1+L+custom') + self.assertEqual(exe_keys[3], '2+L+custom') + + def test_get_comparisonexes_branch_filtering(self): + # branch1 and branch3 have display_on_comparison_page flag set to False + # so they shouldn't be included in the result + branch1 = Branch.objects.create(name='branch1', project=self.project, + display_on_comparison_page=False) + branch2 = Branch.objects.create(name='branch2', project=self.project, + display_on_comparison_page=True) + branch3 = Branch.objects.create(name='branch3', project=self.project, + display_on_comparison_page=False) + + Revision.objects.create(branch=branch1, commitid='1') + Revision.objects.create(branch=branch2, commitid='1') + Revision.objects.create(branch=branch3, commitid='1') + + executables, exe_keys = getcomparisonexes() + self.assertEqual(len(executables), 1) + self.assertEqual(len(executables[self.project]), 6) + self.assertEqual(len(exe_keys), 6) + + expected_exe_keys = [ + '1+L+master', + '2+L+master', + '1+L+custom', + '2+L+custom', + '1+L+branch2', + '2+L+branch2' + ] + self.assertEqual(exe_keys, expected_exe_keys) + + for index, exe_key in enumerate(expected_exe_keys): + self.assertEqual(executables[self.project][index]['key'], exe_key) + + def test_get_comparisonexes_tag_name_filtering_no_filter_specified(self): + # Insert some mock revisions with tags + self._insert_mock_revision_objects() + + # No COMPARISON_TAGS filters specified, all the tags should be included + executables, exe_keys = getcomparisonexes() + self.assertEqual(len(executables), 1) + self.assertEqual(len(executables[self.project]), 2 * 2 + 2 * 3) + self.assertEqual(len(exe_keys), 2 * 2 + 2 * 3) + + self.assertExecutablesListContainsRevision(executables[self.project], + self.rev_v4) + self.assertExecutablesListContainsRevision(executables[self.project], + self.rev_v5) + self.assertExecutablesListContainsRevision(executables[self.project], + self.rev_v6) + + def test_get_comparisonexes_tag_name_filtering_single_tag_specified(self): + # Insert some mock revisions with tags + self._insert_mock_revision_objects() + + # Only a single tag should be included + with override_settings(COMPARISON_COMMIT_TAGS=['v4.0.0']): + executables, exe_keys = getcomparisonexes() + self.assertEqual(len(executables), 1) + self.assertEqual(len(executables[self.project]), 2 * 2 + 2 * 1) + self.assertEqual(len(exe_keys), 2 * 2 + 2 * 1) + + self.assertExecutablesListContainsRevision( + executables[self.project], self.rev_v4) + self.assertExecutablesListDoesntContainRevision( + executables[self.project], self.rev_v5) + self.assertExecutablesListDoesntContainRevision( + executables[self.project], self.rev_v6) + + def test_get_comparisonexes_tag_name_filtering_empty_list_specified(self): + # Insert some mock revisions with tags + self._insert_mock_revision_objects() + + # No tags should be included + with override_settings(COMPARISON_COMMIT_TAGS=[]): + executables, exe_keys = getcomparisonexes() + self.assertEqual(len(executables), 1) + self.assertEqual(len(executables[self.project]), 2 * 2) + self.assertEqual(len(exe_keys), 2 * 2) + + self.assertExecutablesListDoesntContainRevision( + executables[self.project], self.rev_v4) + self.assertExecutablesListDoesntContainRevision( + executables[self.project], self.rev_v5) + self.assertExecutablesListDoesntContainRevision( + executables[self.project], self.rev_v6) + + def assertExecutablesListContainsRevision(self, executables, revision): + found = self._executable_list_contains_revision(executables=executables, + revision=revision) + + if not found: + self.assertFalse("Didn't find revision \"%s\" in executable list \"%s\"" % + (str(revision), str(executables))) + + def assertExecutablesListDoesntContainRevision(self, executables, revision): + found = self._executable_list_contains_revision(executables=executables, + revision=revision) + + if found: + self.assertFalse("Found revision \"%s\", but didn't expect it" % + (str(revision))) + + def _executable_list_contains_revision(self, executables, revision): + for executable in executables: + if executable['revision'] == revision: + return True + + return False + + +class UtilityFunctionsTestCase(TestCase): + @override_settings(TIMELINE_EXECUTABLE_NAME_MAX_LEN=22) + def test_get_sanitized_executable_name_for_timeline_view(self): + executable = Executable(name='a' * 22) + name = get_sanitized_executable_name_for_timeline_view(executable) + self.assertEqual(name, 'a' * 22) + + executable = Executable(name='a' * 25) + name = get_sanitized_executable_name_for_timeline_view(executable) + self.assertEqual(name, 'a' * 22 + '...') + + @override_settings(COMPARISON_EXECUTABLE_NAME_MAX_LEN=20) + def test_get_sanitized_executable_name_for_comparison_view(self): + executable = Executable(name='b' * 20) + name = get_sanitized_executable_name_for_comparison_view(executable) + self.assertEqual(name, 'b' * 20) + + executable = Executable(name='b' * 25) + name = get_sanitized_executable_name_for_comparison_view(executable) + self.assertEqual(name, 'b' * 20 + '...') diff --git a/codespeed/views.py b/codespeed/views.py index b498bdb4..408d62b0 100644 --- a/codespeed/views.py +++ b/codespeed/views.py @@ -66,7 +66,7 @@ class HomeView(TemplateView): template_name = "home.html" def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) + context = super(HomeView, self).get_context_data(**kwargs) context['show_reports'] = settings.SHOW_REPORTS context['show_historical'] = settings.SHOW_HISTORICAL historical_settings = ['SHOW_HISTORICAL', 'DEF_BASELINE', 'DEF_EXECUTABLE'] @@ -91,38 +91,49 @@ def gethistoricaldata(request): else: env = env.first() - # Fetch Baseline data + # Fetch Baseline data, filter by executable baseline_exe = Executable.objects.get( name=settings.DEF_BASELINE['executable']) - baseline_lastrev = Revision.objects.filter( - branch__project=baseline_exe.project).order_by('-date')[0] + baseline_revs = Revision.objects.filter( + branch__project=baseline_exe.project).order_by('-date') + baseline_lastrev = baseline_revs[0] + for rev in baseline_revs: + baseline_results = Result.objects.filter( + executable=baseline_exe, revision=rev, environment=env) + if baseline_results: + baseline_lastrev = rev + break + if len(baseline_results) == 0: + logger.error('Could not find results for {} rev="{}" env="{}"'.format( + baseline_exe, baseline_lastrev, env)) data['baseline'] = '{} {}'.format( settings.DEF_BASELINE['executable'], baseline_lastrev.tag) - baseline_results = Result.objects.filter( - executable=baseline_exe, revision=baseline_lastrev, environment=env) default_exe = Executable.objects.get(name=settings.DEF_EXECUTABLE) default_branch = Branch.objects.get( name=default_exe.project.default_branch, project=default_exe.project) - # Fetch tagged revisions for default executable + # Fetch tagged revisions for executable default_taggedrevs = Revision.objects.filter( - branch=default_branch - ).exclude(tag="").order_by('date') - data['tagged_revs'] = [rev.tag for rev in default_taggedrevs] + branch=default_branch + ).exclude(tag="").order_by('date') default_results = {} for rev in default_taggedrevs: - default_results[rev.tag] = Result.objects.filter( + res = Result.objects.filter( executable=default_exe, revision=rev, environment=env) - + if not res: + logger.info('no results for %s %s %s' % (str(default_exe), str(rev), str(env))) + continue + default_results[rev.tag] = res + data['tagged_revs'] = [rev.tag for rev in default_taggedrevs if rev.tag in default_results] # Fetch data for latest results revs = Revision.objects.filter( branch=default_branch).order_by('-date')[:5] default_lastrev = None for i in range(4): default_lastrev = revs[i] - if default_lastrev.results.filter(executable=default_exe): + if default_lastrev.results.filter(executable=default_exe, environment=env): break default_lastrev = None if default_lastrev is None: @@ -214,7 +225,7 @@ def comparison(request): else: rev = Revision.objects.get(commitid=rev) key += str(rev.id) - key += "+default" + key += "+%s" % (exe.project.default_branch) if key in exekeys: checkedexecutables.append(key) else: @@ -892,7 +903,6 @@ def add_result(request): return HttpResponseBadRequest(response) else: create_report_if_enough_data(response[0], response[1], response[2]) - logger.debug("add_result: completed") return HttpResponse("Result data saved successfully", status=202) @@ -917,15 +927,11 @@ def add_json_results(request): else: unique_reports.add(response) - logger.debug("add_json_results: about to create reports") for rep in unique_reports: create_report_if_enough_data(rep[0], rep[1], rep[2]) - logger.debug("add_json_results: completed") - return HttpResponse("All result data saved successfully", status=202) - def django_has_content_type(): return (django.VERSION[0] > 1 or (django.VERSION[0] == 1 and django.VERSION[1] >= 6)) diff --git a/codespeed/views_data.py b/codespeed/views_data.py index 8ae6902c..583f5457 100644 --- a/codespeed/views_data.py +++ b/codespeed/views_data.py @@ -49,7 +49,12 @@ def get_default_environment(enviros, data, multi=False): return defaultenviros[0] -def getbaselineexecutables(): +def getbaselineexecutables(include_tags=None): + """ + :param include_tags: A list of tags to include in the result. If set to + None,, it will include all the available tags. + :type include_tags: ``list`` + """ baseline = [{ 'key': "none", 'name': "None", @@ -57,14 +62,18 @@ def getbaselineexecutables(): 'revision': "none", }] executables = Executable.objects.select_related('project') - revs = Revision.objects.exclude(tag="").select_related('branch__project') - maxlen = 22 + + if include_tags is not None: + revs = Revision.objects.filter(tag__in=include_tags) + else: + revs = Revision.objects.exclude(tag="") + + revs = revs.select_related('branch__project') + for rev in revs: # Add executables that correspond to each tagged revision. for exe in [e for e in executables if e.project == rev.branch.project]: - exestring = str(exe) - if len(exestring) > maxlen: - exestring = str(exe)[0:maxlen] + "..." + exestring = get_sanitized_executable_name_for_timeline_view(exe) name = exestring + " " + rev.tag key = str(exe.id) + "+" + str(rev.id) baseline.append({ @@ -110,13 +119,15 @@ def getdefaultexecutable(): def getcomparisonexes(): + comparison_commit_tags = getattr(settings, 'COMPARISON_COMMIT_TAGS', None) + all_executables = {} exekeys = [] - baselines = getbaselineexecutables() + baselines = getbaselineexecutables(include_tags=comparison_commit_tags) + for proj in Project.objects.all(): executables = [] executablekeys = [] - maxlen = 20 # add all tagged revs for any project for exe in baselines: if exe['key'] != "none" and exe['executable'].project == proj: @@ -124,7 +135,7 @@ def getcomparisonexes(): executables.append(exe) # add latest revs of the project - branches = Branch.objects.filter(project=proj) + branches = Branch.objects.filter(project=proj, display_on_comparison_page=True) for branch in branches: try: rev = Revision.objects.filter(branch=branch).latest('date') @@ -134,11 +145,9 @@ def getcomparisonexes(): # because we already added tagged revisions if rev.tag == "": for exe in Executable.objects.filter(project=proj): - exestring = str(exe) - if len(exestring) > maxlen: - exestring = str(exe)[0:maxlen] + "..." + exestring = get_sanitized_executable_name_for_comparison_view(exe) name = exestring + " latest" - if branch.name != 'default': + if branch.name != proj.default_branch: name += " in branch '" + branch.name + "'" key = str(exe.id) + "+L+" + branch.name executablekeys.append(key) @@ -260,3 +269,47 @@ def get_stats_with_defaults(res): if res.q3 is not None: q3 = res.q3 return q1, q3, val_max, val_min + + +def get_sanitized_executable_name_for_timeline_view(executable): + """ + Return sanitized executable name which is used in the sidebar in the + Timeline and Changes view. + + If the name is longer than settings.TIMELINE_EXECUTABLE_NAME_MAX_LEN, + the name will be truncated to that length and "..." appended to it. + + :param executable: Executable object. + :type executable: :class:``codespeed.models.Executable`` + + :return: ``str`` + """ + maxlen = getattr(settings, 'TIMELINE_EXECUTABLE_NAME_MAX_LEN', 20) + + exestring = str(executable) + if len(exestring) > maxlen: + exestring = str(executable)[0:maxlen] + "..." + + return exestring + + +def get_sanitized_executable_name_for_comparison_view(executable): + """ + Return sanitized executable name which is used in the sidebar in the + comparison view. + + If the name is longer than settings.COMPARISON_EXECUTABLE_NAME_MAX_LEN, + the name will be truncated to that length and "..." appended to it. + + :param executable: Executable object. + :type executable: :class:``codespeed.models.Executable`` + + :return: ``str`` + """ + maxlen = getattr(settings, 'COMPARISON_EXECUTABLE_NAME_MAX_LEN', 22) + + exestring = str(executable) + if len(exestring) > maxlen: + exestring = str(executable)[0:maxlen] + "..." + + return exestring diff --git a/sample_project/README.md b/sample_project/README.md index eb0c916a..08fab60d 100644 --- a/sample_project/README.md +++ b/sample_project/README.md @@ -31,12 +31,10 @@ It is assumed you are in the root directory of the Codespeed software. `export PYTHONPATH=../:$PYTHONPATH` or `ln -s ./codespeed ./sample_project` -5. Initialise the Django Database - `python manage.py syncdb` - (Yes, add a superuser.) +5. Apply the migrations: `python manage.py migrate` Optionally, you may want to load the fixture data for a try - `python manage.py loaddata ../codespeed/fixtures/testdata.json` + `python manage.py loaddata ./codespeed/fixtures/testdata.json` 6. Finally, start the Django development server. `python manage.py runserver` 7. Enjoy. diff --git a/sample_project/templates/home.html b/sample_project/templates/home.html index 2828033b..2a0ddf3c 100644 --- a/sample_project/templates/home.html +++ b/sample_project/templates/home.html @@ -179,6 +179,9 @@

How has {{ default_exe.project }} performance evolved over time?

renderer:$.jqplot.BarRenderer, showMarker: false }, + axesDefaults: { + tickRenderer: $.jqplot.CanvasAxisTickRenderer + }, series:[ { pointLabels:{labels:geolabels} @@ -187,7 +190,8 @@

How has {{ default_exe.project }} performance evolved over time?

axes: { xaxis: { renderer: $.jqplot.CategoryAxisRenderer, - ticks: ticks + ticks: ticks, + tickOptions: {angle: -40} }, yaxis:{ min: 0,