Skip to content

Commit 7484c27

Browse files
authored
Merge branch 'master' into fix_default_branch_handling
2 parents 393d0cd + 2de039c commit 7484c27

File tree

8 files changed

+253
-16
lines changed

8 files changed

+253
-16
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ matrix:
1414
- python: "3.5"
1515
env: DJANGO_VERSION=2.1
1616
install:
17-
- pip install flake8
17+
- pip install flake8 mock
1818
- pip install -q Django==$DJANGO_VERSION
1919
- python setup.py install
2020
before_script:

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,15 @@ COMP_EXECUTABLES = [
225225
]
226226
```
227227

228+
### VCS Provider Specific Settings
229+
230+
#### Github
231+
232+
* ``GITHUB_OAUTH_TOKEN`` - Github oAuth token to use for authenticating against
233+
the Github API. If not provided, it will default to unauthenticated API requests
234+
which have low rate limits so an exception may be thrown when retrieving info
235+
from the Github API due to the rate limit being reached.
236+
228237
## Getting help
229238

230239
For help regarding the configuration of Codespeed, or to share any ideas or

codespeed/auth.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import types
23
from functools import wraps
34
from django.contrib.auth import authenticate, login
45
from django.http import HttpResponse, HttpResponseForbidden
@@ -9,6 +10,20 @@
910
logger = logging.getLogger(__name__)
1011

1112

13+
def is_authenticated(request):
14+
# NOTE: We do type check so we also support newer versions of Django when
15+
# is_authenticated and some other methods have been properties
16+
if isinstance(request.user.is_authenticated, (types.FunctionType,
17+
types.MethodType)):
18+
return request.user.is_authenticated()
19+
elif isinstance(request.user.is_authenticated, bool):
20+
return request.user.is_authenticated
21+
else:
22+
logger.info('Got unexpected type for request.user.is_authenticated '
23+
'variable')
24+
return False
25+
26+
1227
def basic_auth_required(realm='default'):
1328
def _helper(func):
1429
@wraps(func)
@@ -18,7 +33,7 @@ def _decorator(request, *args, **kwargs):
1833
if settings.ALLOW_ANONYMOUS_POST:
1934
logger.debug('allowing anonymous post')
2035
allowed = True
21-
elif hasattr(request, 'user') and request.user.is_authenticated():
36+
elif hasattr(request, 'user') and is_authenticated(request=request):
2237
allowed = True
2338
elif 'HTTP_AUTHORIZATION' in request.META:
2439
logger.debug('checking for http authorization header')

codespeed/commits/github.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
import json
2121

2222
import isodate
23-
from django.conf import settings
2423
from django.core.cache import cache
24+
from django.conf import settings
2525

2626
from .exceptions import CommitLogError
2727

@@ -103,11 +103,13 @@ def retrieve_revision(commit_id, username, project, revision=None):
103103
if revision:
104104
# Overwrite any existing data we might have for this revision since
105105
# we never want our records to be out of sync with the actual VCS:
106-
107-
# We need to convert the timezone-aware date to a naive (i.e.
108-
# timezone-less) date in UTC to avoid killing MySQL:
109-
revision.date = date.astimezone(
110-
isodate.tzinfo.Utc()).replace(tzinfo=None)
106+
if not getattr(settings, 'USE_TZ_AWARE_DATES', False):
107+
# We need to convert the timezone-aware date to a naive (i.e.
108+
# timezone-less) date in UTC to avoid killing MySQL:
109+
logger.debug('USE_TZ_AWARE_DATES setting is set to False, '
110+
'converting datetime object to a naive one')
111+
revision.date = date.astimezone(
112+
isodate.tzinfo.Utc()).replace(tzinfo=None)
111113
revision.author = commit_json['author']['name']
112114
revision.message = commit_json['message']
113115
revision.full_clean()

codespeed/settings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,22 @@
7878
# ('myexe', '21df2423ra'),
7979
# ('myexe', 'L'),]
8080

81+
TIMELINE_EXECUTABLE_NAME_MAX_LEN = 22 # Maximum length of the executable name used in the
82+
# Changes and Timeline view. If the name is longer, the name
83+
# will be truncated and "..." will be added at the end.
84+
85+
COMPARISON_EXECUTABLE_NAME_MAX_LEN = 20 # Maximum length of the executable name used in the
86+
# Coomparison view. If the name is longer, the name
87+
8188
USE_MEDIAN_BANDS = True # True to enable median bands on Timeline view
8289

8390

8491
ALLOW_ANONYMOUS_POST = True # Whether anonymous users can post results
8592
REQUIRE_SECURE_AUTH = True # Whether auth needs to be over a secure channel
8693

94+
US_TZ_AWARE_DATES = False # True to use timezone aware datetime objects with Github provider.
95+
# NOTE: Some database backends may not support tz aware dates.
96+
8797
GITHUB_OAUTH_TOKEN = None # Github oAuth token to use when using Github repo type. If not
8898
# specified, it will utilize unauthenticated requests which have
8999
# low rate limits.

codespeed/tests/test_auth.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# -*- coding: utf-8 -*-
2+
3+
import mock
4+
5+
from django.test import TestCase, override_settings
6+
from django.http import HttpResponse
7+
from django.contrib.auth.models import AnonymousUser
8+
from django.test import RequestFactory
9+
10+
from codespeed.auth import basic_auth_required
11+
from codespeed.views import add_result
12+
13+
14+
@override_settings(ALLOW_ANONYMOUS_POST=False)
15+
class AuthModuleTestCase(TestCase):
16+
@override_settings(ALLOW_ANONYMOUS_POST=True)
17+
def test_allow_anonymous_post_is_true(self):
18+
wrapped_function = mock.Mock()
19+
wrapped_function.__name__ = 'mock'
20+
wrapped_function.return_value = 'success'
21+
22+
request = mock.Mock()
23+
request.user = AnonymousUser()
24+
request.META = {}
25+
26+
res = basic_auth_required()(wrapped_function)(request=request)
27+
self.assertEqual(wrapped_function.call_count, 1)
28+
self.assertEqual(res, 'success')
29+
30+
def test_basic_auth_required_django_pre_2_0_succesful_auth(self):
31+
# request.user.is_authenticated is a method (pre Django 2.0)
32+
user = mock.Mock()
33+
user.is_authenticated = lambda: True
34+
35+
request = mock.Mock()
36+
request.user = user
37+
38+
wrapped_function = mock.Mock()
39+
wrapped_function.__name__ = 'mock'
40+
wrapped_function.return_value = 'success'
41+
42+
res = basic_auth_required()(wrapped_function)(request=request)
43+
self.assertEqual(wrapped_function.call_count, 1)
44+
self.assertEqual(res, 'success')
45+
46+
def test_basic_auth_required_django_pre_2_0_failed_auth(self):
47+
# request.user.is_authenticated is a method (pre Django 2.0)
48+
user = mock.Mock()
49+
user.is_authenticated = lambda: False
50+
51+
request = mock.Mock()
52+
request.user = user
53+
request.META = {}
54+
55+
wrapped_function = mock.Mock()
56+
wrapped_function.__name__ = 'mock'
57+
58+
res = basic_auth_required()(wrapped_function)(request=request)
59+
self.assertTrue(isinstance(res, HttpResponse))
60+
self.assertEqual(res.status_code, 401)
61+
self.assertEqual(wrapped_function.call_count, 0)
62+
63+
# Also test with actual AnonymousUser class which will have different
64+
# implementation under different Django versions
65+
request.user = AnonymousUser()
66+
67+
res = basic_auth_required()(wrapped_function)(request=request)
68+
self.assertTrue(isinstance(res, HttpResponse))
69+
self.assertEqual(res.status_code, 401)
70+
self.assertEqual(wrapped_function.call_count, 0)
71+
72+
def test_basic_auth_required_django_post_2_0_successful_auth(self):
73+
# request.user.is_authenticated is a property (post Django 2.0)
74+
user = mock.Mock()
75+
user.is_authenticated = True
76+
77+
request = mock.Mock()
78+
request.user = user
79+
80+
wrapped_function = mock.Mock()
81+
wrapped_function.__name__ = 'mock'
82+
wrapped_function.return_value = 'success'
83+
84+
res = basic_auth_required()(wrapped_function)(request=request)
85+
self.assertEqual(wrapped_function.call_count, 1)
86+
self.assertEqual(res, 'success')
87+
88+
def test_basic_auth_required_django_post_2_0_failed_auth(self):
89+
# request.user.is_authenticated is a property (post Django 2.0)
90+
user = mock.Mock()
91+
user.is_authenticated = False
92+
93+
request = mock.Mock()
94+
request.user = user
95+
request.META = {}
96+
97+
wrapped_function = mock.Mock()
98+
wrapped_function.__name__ = 'mock'
99+
100+
res = basic_auth_required()(wrapped_function)(request=request)
101+
self.assertTrue(isinstance(res, HttpResponse))
102+
self.assertEqual(res.status_code, 401)
103+
self.assertEqual(wrapped_function.call_count, 0)
104+
105+
# Also test with actual AnonymousUser class which will have different
106+
# implementation under different Django versions
107+
request.user = AnonymousUser()
108+
109+
res = basic_auth_required()(wrapped_function)(request=request)
110+
self.assertTrue(isinstance(res, HttpResponse))
111+
self.assertEqual(res.status_code, 401)
112+
self.assertEqual(wrapped_function.call_count, 0)
113+
114+
@mock.patch('codespeed.views.save_result', mock.Mock())
115+
def test_basic_auth_with_failed_auth_request_factory(self):
116+
request_factory = RequestFactory()
117+
118+
request = request_factory.get('/timeline')
119+
request.user = AnonymousUser()
120+
request.method = 'POST'
121+
122+
response = add_result(request)
123+
self.assertEqual(response.status_code, 403)
124+
125+
@mock.patch('codespeed.views.create_report_if_enough_data', mock.Mock())
126+
@mock.patch('codespeed.views.save_result', mock.Mock(return_value=([1, 2, 3], None)))
127+
def test_basic_auth_successefull_auth_request_factory(self):
128+
request_factory = RequestFactory()
129+
130+
user = mock.Mock()
131+
user.is_authenticated = True
132+
133+
request = request_factory.get('/result/add')
134+
request.user = user
135+
request.method = 'POST'
136+
137+
response = add_result(request)
138+
self.assertEqual(response.status_code, 202)

codespeed/tests/test_views_data.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
# -*- coding: utf-8 -*-
22
from django.test import TestCase
3+
from django.test import override_settings
34

45
from codespeed.models import Project, Executable, Branch, Revision
56
from codespeed.views import getbaselineexecutables
67
from codespeed.views import getcomparisonexes
8+
from codespeed.views_data import get_sanitized_executable_name_for_timeline_view
9+
from codespeed.views_data import get_sanitized_executable_name_for_comparison_view
710

811

912
class TestGetBaselineExecutables(TestCase):
@@ -137,3 +140,25 @@ def test_get_comparisionexes_custom_default_branch(self):
137140
self.assertEqual(exe_keys[1], '2+L+master')
138141
self.assertEqual(exe_keys[2], '1+L+custom')
139142
self.assertEqual(exe_keys[3], '2+L+custom')
143+
144+
145+
class UtilityFunctionsTestCase(TestCase):
146+
@override_settings(TIMELINE_EXECUTABLE_NAME_MAX_LEN=22)
147+
def test_get_sanitized_executable_name_for_timeline_view(self):
148+
executable = Executable(name='a' * 22)
149+
name = get_sanitized_executable_name_for_timeline_view(executable)
150+
self.assertEqual(name, 'a' * 22)
151+
152+
executable = Executable(name='a' * 25)
153+
name = get_sanitized_executable_name_for_timeline_view(executable)
154+
self.assertEqual(name, 'a' * 22 + '...')
155+
156+
@override_settings(COMPARISON_EXECUTABLE_NAME_MAX_LEN=20)
157+
def test_get_sanitized_executable_name_for_comparision_view(self):
158+
executable = Executable(name='b' * 20)
159+
name = get_sanitized_executable_name_for_comparison_view(executable)
160+
self.assertEqual(name, 'b' * 20)
161+
162+
executable = Executable(name='b' * 25)
163+
name = get_sanitized_executable_name_for_comparison_view(executable)
164+
self.assertEqual(name, 'b' * 20 + '...')

codespeed/views_data.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,10 @@ def getbaselineexecutables():
5858
}]
5959
executables = Executable.objects.select_related('project')
6060
revs = Revision.objects.exclude(tag="").select_related('branch__project')
61-
maxlen = 22
6261
for rev in revs:
6362
# Add executables that correspond to each tagged revision.
6463
for exe in [e for e in executables if e.project == rev.branch.project]:
65-
exestring = str(exe)
66-
if len(exestring) > maxlen:
67-
exestring = str(exe)[0:maxlen] + "..."
64+
exestring = get_sanitized_executable_name_for_timeline_view(exe)
6865
name = exestring + " " + rev.tag
6966
key = str(exe.id) + "+" + str(rev.id)
7067
baseline.append({
@@ -116,7 +113,6 @@ def getcomparisonexes():
116113
for proj in Project.objects.all():
117114
executables = []
118115
executablekeys = []
119-
maxlen = 20
120116
# add all tagged revs for any project
121117
for exe in baselines:
122118
if exe['key'] != "none" and exe['executable'].project == proj:
@@ -134,9 +130,7 @@ def getcomparisonexes():
134130
# because we already added tagged revisions
135131
if rev.tag == "":
136132
for exe in Executable.objects.filter(project=proj):
137-
exestring = str(exe)
138-
if len(exestring) > maxlen:
139-
exestring = str(exe)[0:maxlen] + "..."
133+
exestring = get_sanitized_executable_name_for_comparison_view(exe)
140134
name = exestring + " latest"
141135
if branch.name != proj.default_branch:
142136
name += " in branch '" + branch.name + "'"
@@ -260,3 +254,47 @@ def get_stats_with_defaults(res):
260254
if res.q3 is not None:
261255
q3 = res.q3
262256
return q1, q3, val_max, val_min
257+
258+
259+
def get_sanitized_executable_name_for_timeline_view(executable):
260+
"""
261+
Return sanitized executable name which is used in the sidebar in the
262+
Timeline and Changes view.
263+
264+
If the name is longer than settings.TIMELINE_EXECUTABLE_NAME_MAX_LEN,
265+
the name will be truncated to that length and "..." appended to it.
266+
267+
:param executable: Executable object.
268+
:type executable: :class:``codespeed.models.Executable``
269+
270+
:return: ``str``
271+
"""
272+
maxlen = getattr(settings, 'TIMELINE_EXECUTABLE_NAME_MAX_LEN', 20)
273+
274+
exestring = str(executable)
275+
if len(exestring) > maxlen:
276+
exestring = str(executable)[0:maxlen] + "..."
277+
278+
return exestring
279+
280+
281+
def get_sanitized_executable_name_for_comparison_view(executable):
282+
"""
283+
Return sanitized executable name which is used in the sidebar in the
284+
comparision view.
285+
286+
If the name is longer than settings.COMPARISON_EXECUTABLE_NAME_MAX_LEN,
287+
the name will be truncated to that length and "..." appended to it.
288+
289+
:param executable: Executable object.
290+
:type executable: :class:``codespeed.models.Executable``
291+
292+
:return: ``str``
293+
"""
294+
maxlen = getattr(settings, 'COMPARISON_EXECUTABLE_NAME_MAX_LEN', 22)
295+
296+
exestring = str(executable)
297+
if len(exestring) > maxlen:
298+
exestring = str(executable)[0:maxlen] + "..."
299+
300+
return exestring

0 commit comments

Comments
 (0)