##// END OF EJS Templates
emails: updated emails design and data structure they provide....
dan -
r4038:4a4a02a9 default
parent child Browse files
Show More
@@ -0,0 +1,29 b''
1 <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
2
3 <html>
4 <head></head>
5
6 <body>
7
8 SUBJECT:
9 <pre>${c.subject}</pre>
10
11 HEADERS:
12 <pre>
13 ${c.headers}
14 </pre>
15
16 PLAINTEXT:
17 <pre>
18 ${c.email_body_plaintext|n}
19 </pre>
20
21 </body>
22 </html>
23 <br/><br/>
24
25 HTML:
26
27 ${c.email_body|n}
28
29
@@ -0,0 +1,49 b''
1 ## -*- coding: utf-8 -*-
2 <%inherit file="/base/base.mako"/>
3
4 <%def name="title()">
5 ${_('Show notification')} ${c.rhodecode_user.username}
6 %if c.rhodecode_name:
7 &middot; ${h.branding(c.rhodecode_name)}
8 %endif
9 </%def>
10
11 <%def name="breadcrumbs_links()">
12 ${h.link_to(_('My Notifications'), h.route_path('notifications_show_all'))}
13 &raquo;
14 ${_('Show notification')}
15 </%def>
16
17 <%def name="menu_bar_nav()">
18 ${self.menu_items(active='admin')}
19 </%def>
20
21 <%def name="main()">
22 <div class="box">
23
24 <!-- box / title -->
25 <div class="title">
26 Rendered plain text using markup renderer
27 </div>
28 <div class="table">
29 <div >
30 <div class="notification-header">
31 GRAVATAR
32 <div class="desc">
33 DESC
34 </div>
35 </div>
36 <div class="notification-body">
37 <div class="notification-subject">
38 <h3>${_('Subject')}: ${c.subject}</h3>
39 </div>
40 ${c.email_body|n}
41 </div>
42 </div>
43 </div>
44 </div>
45
46 </%def>
47
48
49
@@ -0,0 +1,34 b''
1 ## -*- coding: utf-8 -*-
2 <%inherit file="/debug_style/index.html"/>
3
4 <%def name="breadcrumbs_links()">
5 ${h.link_to(_('Style'), h.route_path('debug_style_home'))}
6 &raquo;
7 ${c.active}
8 </%def>
9
10
11 <%def name="real_main()">
12 <div class="box">
13 <div class="title">
14 ${self.breadcrumbs()}
15 </div>
16
17 <div class='sidebar-col-wrapper'>
18 ${self.sidebar()}
19 <div class="main-content">
20 <h2>Emails</h2>
21 <ul>
22 % for elem in sorted(c.email_types.keys()):
23 <li>
24 <a href="${request.route_path('debug_style_email', email_id=elem, _query={'user':c.rhodecode_user.username})}">${elem}</a>
25 |
26 <a href="${request.route_path('debug_style_email_plain_rendered', email_id=elem, _query={'user':c.rhodecode_user.username})}">plain rendered</a>
27 </li>
28 % endfor
29 </ul>
30
31 </div> <!-- .main-content -->
32 </div>
33 </div> <!-- .box -->
34 </%def>
@@ -1,51 +1,59 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20 from rhodecode.apps._base import ADMIN_PREFIX
21 21 from rhodecode.lib.utils2 import str2bool
22 22
23 23
24 24 class DebugStylePredicate(object):
25 25 def __init__(self, val, config):
26 26 self.val = val
27 27
28 28 def text(self):
29 29 return 'debug style route = %s' % self.val
30 30
31 31 phash = text
32 32
33 33 def __call__(self, info, request):
34 34 return str2bool(request.registry.settings.get('debug_style'))
35 35
36 36
37 37 def includeme(config):
38 38 config.add_route_predicate(
39 39 'debug_style', DebugStylePredicate)
40 40
41 41 config.add_route(
42 42 name='debug_style_home',
43 43 pattern=ADMIN_PREFIX + '/debug_style',
44 44 debug_style=True)
45 45 config.add_route(
46 name='debug_style_email',
47 pattern=ADMIN_PREFIX + '/debug_style/email/{email_id}',
48 debug_style=True)
49 config.add_route(
50 name='debug_style_email_plain_rendered',
51 pattern=ADMIN_PREFIX + '/debug_style/email-rendered/{email_id}',
52 debug_style=True)
53 config.add_route(
46 54 name='debug_style_template',
47 55 pattern=ADMIN_PREFIX + '/debug_style/t/{t_path}',
48 56 debug_style=True)
49 57
50 58 # Scan module for configuration decorators.
51 59 config.scan('.views', ignore='.tests')
@@ -1,59 +1,338 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import os
22 22 import logging
23 import datetime
23 24
24 25 from pyramid.view import view_config
25 26 from pyramid.renderers import render_to_response
26 27 from rhodecode.apps._base import BaseAppView
28 from rhodecode.lib.celerylib import run_task, tasks
29 from rhodecode.lib.utils2 import AttributeDict
30 from rhodecode.model.db import User
31 from rhodecode.model.notification import EmailNotificationModel
27 32
28 33 log = logging.getLogger(__name__)
29 34
30 35
31 36 class DebugStyleView(BaseAppView):
32 37 def load_default_context(self):
33 38 c = self._get_local_tmpl_context()
34 39
35 40 return c
36 41
37 42 @view_config(
38 43 route_name='debug_style_home', request_method='GET',
39 44 renderer=None)
40 45 def index(self):
41 46 c = self.load_default_context()
42 47 c.active = 'index'
43 48
44 49 return render_to_response(
45 50 'debug_style/index.html', self._get_template_context(c),
46 51 request=self.request)
47 52
48 53 @view_config(
54 route_name='debug_style_email', request_method='GET',
55 renderer=None)
56 @view_config(
57 route_name='debug_style_email_plain_rendered', request_method='GET',
58 renderer=None)
59 def render_email(self):
60 c = self.load_default_context()
61 email_id = self.request.matchdict['email_id']
62 c.active = 'emails'
63
64 pr = AttributeDict(
65 pull_request_id=123,
66 title='digital_ocean: fix redis, elastic search start on boot, '
67 'fix fd limits on supervisor, set postgres 11 version',
68 description='''
69 Check if we should use full-topic or mini-topic.
70
71 - full topic produces some problems with merge states etc
72 - server-mini-topic needs probably tweeks.
73 ''',
74 repo_name='foobar',
75 source_ref_parts=AttributeDict(type='branch', name='fix-ticket-2000'),
76 target_ref_parts=AttributeDict(type='branch', name='master'),
77 )
78 target_repo = AttributeDict(repo_name='repo_group/target_repo')
79 source_repo = AttributeDict(repo_name='repo_group/source_repo')
80 user = User.get_by_username(self.request.GET.get('user')) or self._rhodecode_db_user
81
82 email_kwargs = {
83 'test': {},
84 'message': {
85 'body': 'message body !'
86 },
87 'email_test': {
88 'user': user,
89 'date': datetime.datetime.now(),
90 'rhodecode_version': c.rhodecode_version
91 },
92 'password_reset': {
93 'password_reset_url': 'http://example.com/reset-rhodecode-password/token',
94
95 'user': user,
96 'date': datetime.datetime.now(),
97 'email': 'test@rhodecode.com',
98 'first_admin_email': User.get_first_super_admin().email
99 },
100 'password_reset_confirmation': {
101 'new_password': 'new-password-example',
102 'user': user,
103 'date': datetime.datetime.now(),
104 'email': 'test@rhodecode.com',
105 'first_admin_email': User.get_first_super_admin().email
106 },
107 'registration': {
108 'user': user,
109 'date': datetime.datetime.now(),
110 },
111
112 'pull_request_comment': {
113 'user': user,
114
115 'status_change': None,
116 'status_change_type': None,
117
118 'pull_request': pr,
119 'pull_request_commits': [],
120
121 'pull_request_target_repo': target_repo,
122 'pull_request_target_repo_url': 'http://target-repo/url',
123
124 'pull_request_source_repo': source_repo,
125 'pull_request_source_repo_url': 'http://source-repo/url',
126
127 'pull_request_url': 'http://localhost/pr1',
128 'pr_comment_url': 'http://comment-url',
129
130 'comment_file': None,
131 'comment_line': None,
132 'comment_type': 'note',
133 'comment_body': 'This is my comment body. *I like !*',
134
135 'renderer_type': 'markdown',
136 'mention': True,
137
138 },
139 'pull_request_comment+status': {
140 'user': user,
141
142 'status_change': 'approved',
143 'status_change_type': 'approved',
144
145 'pull_request': pr,
146 'pull_request_commits': [],
147
148 'pull_request_target_repo': target_repo,
149 'pull_request_target_repo_url': 'http://target-repo/url',
150
151 'pull_request_source_repo': source_repo,
152 'pull_request_source_repo_url': 'http://source-repo/url',
153
154 'pull_request_url': 'http://localhost/pr1',
155 'pr_comment_url': 'http://comment-url',
156
157 'comment_type': 'todo',
158 'comment_file': None,
159 'comment_line': None,
160 'comment_body': '''
161 I think something like this would be better
162
163 ```py
164
165 def db():
166 global connection
167 return connection
168
169 ```
170
171 ''',
172
173 'renderer_type': 'markdown',
174 'mention': True,
175
176 },
177 'pull_request_comment+file': {
178 'user': user,
179
180 'status_change': None,
181 'status_change_type': None,
182
183 'pull_request': pr,
184 'pull_request_commits': [],
185
186 'pull_request_target_repo': target_repo,
187 'pull_request_target_repo_url': 'http://target-repo/url',
188
189 'pull_request_source_repo': source_repo,
190 'pull_request_source_repo_url': 'http://source-repo/url',
191
192 'pull_request_url': 'http://localhost/pr1',
193
194 'pr_comment_url': 'http://comment-url',
195
196 'comment_file': 'rhodecode/model/db.py',
197 'comment_line': 'o1210',
198 'comment_type': 'todo',
199 'comment_body': '''
200 I like this !
201
202 But please check this code::
203
204 def main():
205 print 'ok'
206
207 This should work better !
208 ''',
209
210 'renderer_type': 'rst',
211 'mention': True,
212
213 },
214
215 'cs_comment': {
216 'user': user,
217 'commit': AttributeDict(idx=123, raw_id='a'*40, message='Commit message'),
218 'status_change': None,
219 'status_change_type': None,
220
221 'commit_target_repo_url': 'http://foo.example.com/#comment1',
222 'repo_name': 'test-repo',
223 'comment_type': 'note',
224 'comment_file': None,
225 'comment_line': None,
226 'commit_comment_url': 'http://comment-url',
227 'comment_body': 'This is my comment body. *I like !*',
228 'renderer_type': 'markdown',
229 'mention': True,
230 },
231 'cs_comment+status': {
232 'user': user,
233 'commit': AttributeDict(idx=123, raw_id='a' * 40, message='Commit message'),
234 'status_change': 'approved',
235 'status_change_type': 'approved',
236
237 'commit_target_repo_url': 'http://foo.example.com/#comment1',
238 'repo_name': 'test-repo',
239 'comment_type': 'note',
240 'comment_file': None,
241 'comment_line': None,
242 'commit_comment_url': 'http://comment-url',
243 'comment_body': '''
244 Hello **world**
245
246 This is a multiline comment :)
247
248 - list
249 - list2
250 ''',
251 'renderer_type': 'markdown',
252 'mention': True,
253 },
254 'cs_comment+file': {
255 'user': user,
256 'commit': AttributeDict(idx=123, raw_id='a' * 40, message='Commit message'),
257 'status_change': None,
258 'status_change_type': None,
259
260 'commit_target_repo_url': 'http://foo.example.com/#comment1',
261 'repo_name': 'test-repo',
262
263 'comment_type': 'note',
264 'comment_file': 'test-file.py',
265 'comment_line': 'n100',
266
267 'commit_comment_url': 'http://comment-url',
268 'comment_body': 'This is my comment body. *I like !*',
269 'renderer_type': 'markdown',
270 'mention': True,
271 },
272
273 'pull_request': {
274 'user': user,
275 'pull_request': pr,
276 'pull_request_commits': [
277 ('472d1df03bf7206e278fcedc6ac92b46b01c4e21', '''\
278 my-account: moved email closer to profile as it's similar data just moved outside.
279 '''),
280 ('cbfa3061b6de2696c7161ed15ba5c6a0045f90a7', '''\
281 users: description edit fixes
282
283 - tests
284 - added metatags info
285 '''),
286 ],
287
288 'pull_request_target_repo': target_repo,
289 'pull_request_target_repo_url': 'http://target-repo/url',
290
291 'pull_request_source_repo': source_repo,
292 'pull_request_source_repo_url': 'http://source-repo/url',
293
294 'pull_request_url': 'http://code.rhodecode.com/_pull-request/123',
295 }
296
297 }
298
299 template_type = email_id.split('+')[0]
300 (c.subject, c.headers, c.email_body,
301 c.email_body_plaintext) = EmailNotificationModel().render_email(
302 template_type, **email_kwargs.get(email_id, {}))
303
304 test_email = self.request.GET.get('email')
305 if test_email:
306 recipients = [test_email]
307 run_task(tasks.send_email, recipients, c.subject,
308 c.email_body_plaintext, c.email_body)
309
310 if self.request.matched_route.name == 'debug_style_email_plain_rendered':
311 template = 'debug_style/email_plain_rendered.mako'
312 else:
313 template = 'debug_style/email.mako'
314 return render_to_response(
315 template, self._get_template_context(c),
316 request=self.request)
317
318 @view_config(
49 319 route_name='debug_style_template', request_method='GET',
50 320 renderer=None)
51 321 def template(self):
52 322 t_path = self.request.matchdict['t_path']
53 323 c = self.load_default_context()
54 324 c.active = os.path.splitext(t_path)[0]
55 325 c.came_from = ''
326 c.email_types = {
327 'cs_comment+file': {},
328 'cs_comment+status': {},
329
330 'pull_request_comment+file': {},
331 'pull_request_comment+status': {},
332 }
333 c.email_types.update(EmailNotificationModel.email_types)
56 334
57 335 return render_to_response(
58 336 'debug_style/' + t_path, self._get_template_context(c),
59 request=self.request) No newline at end of file
337 request=self.request)
338
@@ -1,320 +1,320 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import pytest
22 22
23 23 from rhodecode.tests import TestController
24 24
25 25 from rhodecode.model.db import (
26 26 ChangesetComment, Notification, UserNotification)
27 27 from rhodecode.model.meta import Session
28 28 from rhodecode.lib import helpers as h
29 29
30 30
31 31 def route_path(name, params=None, **kwargs):
32 32 import urllib
33 33
34 34 base_url = {
35 35 'repo_commit': '/{repo_name}/changeset/{commit_id}',
36 36 'repo_commit_comment_create': '/{repo_name}/changeset/{commit_id}/comment/create',
37 37 'repo_commit_comment_preview': '/{repo_name}/changeset/{commit_id}/comment/preview',
38 38 'repo_commit_comment_delete': '/{repo_name}/changeset/{commit_id}/comment/{comment_id}/delete',
39 39 }[name].format(**kwargs)
40 40
41 41 if params:
42 42 base_url = '{}?{}'.format(base_url, urllib.urlencode(params))
43 43 return base_url
44 44
45 45
46 46 @pytest.mark.backends("git", "hg", "svn")
47 47 class TestRepoCommitCommentsView(TestController):
48 48
49 49 @pytest.fixture(autouse=True)
50 50 def prepare(self, request, baseapp):
51 51 for x in ChangesetComment.query().all():
52 52 Session().delete(x)
53 53 Session().commit()
54 54
55 55 for x in Notification.query().all():
56 56 Session().delete(x)
57 57 Session().commit()
58 58
59 59 request.addfinalizer(self.cleanup)
60 60
61 61 def cleanup(self):
62 62 for x in ChangesetComment.query().all():
63 63 Session().delete(x)
64 64 Session().commit()
65 65
66 66 for x in Notification.query().all():
67 67 Session().delete(x)
68 68 Session().commit()
69 69
70 70 @pytest.mark.parametrize('comment_type', ChangesetComment.COMMENT_TYPES)
71 71 def test_create(self, comment_type, backend):
72 72 self.log_user()
73 73 commit = backend.repo.get_commit('300')
74 74 commit_id = commit.raw_id
75 75 text = u'CommentOnCommit'
76 76
77 77 params = {'text': text, 'csrf_token': self.csrf_token,
78 78 'comment_type': comment_type}
79 79 self.app.post(
80 80 route_path('repo_commit_comment_create',
81 81 repo_name=backend.repo_name, commit_id=commit_id),
82 82 params=params)
83 83
84 84 response = self.app.get(
85 85 route_path('repo_commit',
86 86 repo_name=backend.repo_name, commit_id=commit_id))
87 87
88 88 # test DB
89 89 assert ChangesetComment.query().count() == 1
90 90 assert_comment_links(response, ChangesetComment.query().count(), 0)
91 91
92 92 assert Notification.query().count() == 1
93 93 assert ChangesetComment.query().count() == 1
94 94
95 95 notification = Notification.query().all()[0]
96 96
97 97 comment_id = ChangesetComment.query().first().comment_id
98 98 assert notification.type_ == Notification.TYPE_CHANGESET_COMMENT
99 99
100 100 author = notification.created_by_user.username_and_name
101 sbj = '{0} left a {1} on commit `{2}` in the {3} repository'.format(
101 sbj = '@{0} left a {1} on commit `{2}` in the `{3}` repository'.format(
102 102 author, comment_type, h.show_id(commit), backend.repo_name)
103 103 assert sbj == notification.subject
104 104
105 105 lnk = (u'/{0}/changeset/{1}#comment-{2}'.format(
106 106 backend.repo_name, commit_id, comment_id))
107 107 assert lnk in notification.body
108 108
109 109 @pytest.mark.parametrize('comment_type', ChangesetComment.COMMENT_TYPES)
110 110 def test_create_inline(self, comment_type, backend):
111 111 self.log_user()
112 112 commit = backend.repo.get_commit('300')
113 113 commit_id = commit.raw_id
114 114 text = u'CommentOnCommit'
115 115 f_path = 'vcs/web/simplevcs/views/repository.py'
116 116 line = 'n1'
117 117
118 118 params = {'text': text, 'f_path': f_path, 'line': line,
119 119 'comment_type': comment_type,
120 120 'csrf_token': self.csrf_token}
121 121
122 122 self.app.post(
123 123 route_path('repo_commit_comment_create',
124 124 repo_name=backend.repo_name, commit_id=commit_id),
125 125 params=params)
126 126
127 127 response = self.app.get(
128 128 route_path('repo_commit',
129 129 repo_name=backend.repo_name, commit_id=commit_id))
130 130
131 131 # test DB
132 132 assert ChangesetComment.query().count() == 1
133 133 assert_comment_links(response, 0, ChangesetComment.query().count())
134 134
135 135 if backend.alias == 'svn':
136 136 response.mustcontain(
137 137 '''data-f-path="vcs/commands/summary.py" '''
138 138 '''data-anchor-id="c-300-ad05457a43f8"'''
139 139 )
140 140 if backend.alias == 'git':
141 141 response.mustcontain(
142 142 '''data-f-path="vcs/backends/hg.py" '''
143 143 '''data-anchor-id="c-883e775e89ea-9c390eb52cd6"'''
144 144 )
145 145
146 146 if backend.alias == 'hg':
147 147 response.mustcontain(
148 148 '''data-f-path="vcs/backends/hg.py" '''
149 149 '''data-anchor-id="c-e58d85a3973b-9c390eb52cd6"'''
150 150 )
151 151
152 152 assert Notification.query().count() == 1
153 153 assert ChangesetComment.query().count() == 1
154 154
155 155 notification = Notification.query().all()[0]
156 156 comment = ChangesetComment.query().first()
157 157 assert notification.type_ == Notification.TYPE_CHANGESET_COMMENT
158 158
159 159 assert comment.revision == commit_id
160 160
161 161 author = notification.created_by_user.username_and_name
162 sbj = '{0} left a {1} on file `{2}` in commit `{3}` in the {4} repository'.format(
162 sbj = '@{0} left a {1} on file `{2}` in commit `{3}` in the `{4}` repository'.format(
163 163 author, comment_type, f_path, h.show_id(commit), backend.repo_name)
164 164
165 165 assert sbj == notification.subject
166 166
167 167 lnk = (u'/{0}/changeset/{1}#comment-{2}'.format(
168 168 backend.repo_name, commit_id, comment.comment_id))
169 169 assert lnk in notification.body
170 170 assert 'on line n1' in notification.body
171 171
172 172 def test_create_with_mention(self, backend):
173 173 self.log_user()
174 174
175 175 commit_id = backend.repo.get_commit('300').raw_id
176 176 text = u'@test_regular check CommentOnCommit'
177 177
178 178 params = {'text': text, 'csrf_token': self.csrf_token}
179 179 self.app.post(
180 180 route_path('repo_commit_comment_create',
181 181 repo_name=backend.repo_name, commit_id=commit_id),
182 182 params=params)
183 183
184 184 response = self.app.get(
185 185 route_path('repo_commit',
186 186 repo_name=backend.repo_name, commit_id=commit_id))
187 187 # test DB
188 188 assert ChangesetComment.query().count() == 1
189 189 assert_comment_links(response, ChangesetComment.query().count(), 0)
190 190
191 191 notification = Notification.query().one()
192 192
193 193 assert len(notification.recipients) == 2
194 194 users = [x.username for x in notification.recipients]
195 195
196 196 # test_regular gets notification by @mention
197 197 assert sorted(users) == [u'test_admin', u'test_regular']
198 198
199 199 def test_create_with_status_change(self, backend):
200 200 self.log_user()
201 201 commit = backend.repo.get_commit('300')
202 202 commit_id = commit.raw_id
203 203 text = u'CommentOnCommit'
204 204 f_path = 'vcs/web/simplevcs/views/repository.py'
205 205 line = 'n1'
206 206
207 207 params = {'text': text, 'changeset_status': 'approved',
208 208 'csrf_token': self.csrf_token}
209 209
210 210 self.app.post(
211 211 route_path(
212 212 'repo_commit_comment_create',
213 213 repo_name=backend.repo_name, commit_id=commit_id),
214 214 params=params)
215 215
216 216 response = self.app.get(
217 217 route_path('repo_commit',
218 218 repo_name=backend.repo_name, commit_id=commit_id))
219 219
220 220 # test DB
221 221 assert ChangesetComment.query().count() == 1
222 222 assert_comment_links(response, ChangesetComment.query().count(), 0)
223 223
224 224 assert Notification.query().count() == 1
225 225 assert ChangesetComment.query().count() == 1
226 226
227 227 notification = Notification.query().all()[0]
228 228
229 229 comment_id = ChangesetComment.query().first().comment_id
230 230 assert notification.type_ == Notification.TYPE_CHANGESET_COMMENT
231 231
232 232 author = notification.created_by_user.username_and_name
233 sbj = '[status: Approved] {0} left a note on commit `{1}` in the {2} repository'.format(
233 sbj = '[status: Approved] @{0} left a note on commit `{1}` in the `{2}` repository'.format(
234 234 author, h.show_id(commit), backend.repo_name)
235 235 assert sbj == notification.subject
236 236
237 237 lnk = (u'/{0}/changeset/{1}#comment-{2}'.format(
238 238 backend.repo_name, commit_id, comment_id))
239 239 assert lnk in notification.body
240 240
241 241 def test_delete(self, backend):
242 242 self.log_user()
243 243 commit_id = backend.repo.get_commit('300').raw_id
244 244 text = u'CommentOnCommit'
245 245
246 246 params = {'text': text, 'csrf_token': self.csrf_token}
247 247 self.app.post(
248 248 route_path(
249 249 'repo_commit_comment_create',
250 250 repo_name=backend.repo_name, commit_id=commit_id),
251 251 params=params)
252 252
253 253 comments = ChangesetComment.query().all()
254 254 assert len(comments) == 1
255 255 comment_id = comments[0].comment_id
256 256
257 257 self.app.post(
258 258 route_path('repo_commit_comment_delete',
259 259 repo_name=backend.repo_name,
260 260 commit_id=commit_id,
261 261 comment_id=comment_id),
262 262 params={'csrf_token': self.csrf_token})
263 263
264 264 comments = ChangesetComment.query().all()
265 265 assert len(comments) == 0
266 266
267 267 response = self.app.get(
268 268 route_path('repo_commit',
269 269 repo_name=backend.repo_name, commit_id=commit_id))
270 270 assert_comment_links(response, 0, 0)
271 271
272 272 @pytest.mark.parametrize('renderer, input, output', [
273 273 ('rst', 'plain text', '<p>plain text</p>'),
274 274 ('rst', 'header\n======', '<h1 class="title">header</h1>'),
275 275 ('rst', '*italics*', '<em>italics</em>'),
276 276 ('rst', '**bold**', '<strong>bold</strong>'),
277 277 ('markdown', 'plain text', '<p>plain text</p>'),
278 278 ('markdown', '# header', '<h1>header</h1>'),
279 279 ('markdown', '*italics*', '<em>italics</em>'),
280 280 ('markdown', '**bold**', '<strong>bold</strong>'),
281 281 ], ids=['rst-plain', 'rst-header', 'rst-italics', 'rst-bold', 'md-plain',
282 282 'md-header', 'md-italics', 'md-bold', ])
283 283 def test_preview(self, renderer, input, output, backend, xhr_header):
284 284 self.log_user()
285 285 params = {
286 286 'renderer': renderer,
287 287 'text': input,
288 288 'csrf_token': self.csrf_token
289 289 }
290 290 commit_id = '0' * 16 # fake this for tests
291 291 response = self.app.post(
292 292 route_path('repo_commit_comment_preview',
293 293 repo_name=backend.repo_name, commit_id=commit_id,),
294 294 params=params,
295 295 extra_environ=xhr_header)
296 296
297 297 response.mustcontain(output)
298 298
299 299
300 300 def assert_comment_links(response, comments, inline_comments):
301 301 if comments == 1:
302 302 comments_text = "%d General" % comments
303 303 else:
304 304 comments_text = "%d General" % comments
305 305
306 306 if inline_comments == 1:
307 307 inline_comments_text = "%d Inline" % inline_comments
308 308 else:
309 309 inline_comments_text = "%d Inline" % inline_comments
310 310
311 311 if comments:
312 312 response.mustcontain('<a href="#comments">%s</a>,' % comments_text)
313 313 else:
314 314 response.mustcontain(comments_text)
315 315
316 316 if inline_comments:
317 317 response.mustcontain(
318 318 'id="inline-comments-counter">%s' % inline_comments_text)
319 319 else:
320 320 response.mustcontain(inline_comments_text)
@@ -1,1221 +1,1217 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20 import mock
21 21 import pytest
22 22
23 23 import rhodecode
24 24 from rhodecode.lib.vcs.backends.base import MergeResponse, MergeFailureReason
25 25 from rhodecode.lib.vcs.nodes import FileNode
26 26 from rhodecode.lib import helpers as h
27 27 from rhodecode.model.changeset_status import ChangesetStatusModel
28 28 from rhodecode.model.db import (
29 29 PullRequest, ChangesetStatus, UserLog, Notification, ChangesetComment, Repository)
30 30 from rhodecode.model.meta import Session
31 31 from rhodecode.model.pull_request import PullRequestModel
32 32 from rhodecode.model.user import UserModel
33 33 from rhodecode.tests import (
34 34 assert_session_flash, TEST_USER_ADMIN_LOGIN, TEST_USER_REGULAR_LOGIN)
35 35
36 36
37 37 def route_path(name, params=None, **kwargs):
38 38 import urllib
39 39
40 40 base_url = {
41 41 'repo_changelog': '/{repo_name}/changelog',
42 42 'repo_changelog_file': '/{repo_name}/changelog/{commit_id}/{f_path}',
43 43 'repo_commits': '/{repo_name}/commits',
44 44 'repo_commits_file': '/{repo_name}/commits/{commit_id}/{f_path}',
45 45 'pullrequest_show': '/{repo_name}/pull-request/{pull_request_id}',
46 46 'pullrequest_show_all': '/{repo_name}/pull-request',
47 47 'pullrequest_show_all_data': '/{repo_name}/pull-request-data',
48 48 'pullrequest_repo_refs': '/{repo_name}/pull-request/refs/{target_repo_name:.*?[^/]}',
49 49 'pullrequest_repo_targets': '/{repo_name}/pull-request/repo-destinations',
50 50 'pullrequest_new': '/{repo_name}/pull-request/new',
51 51 'pullrequest_create': '/{repo_name}/pull-request/create',
52 52 'pullrequest_update': '/{repo_name}/pull-request/{pull_request_id}/update',
53 53 'pullrequest_merge': '/{repo_name}/pull-request/{pull_request_id}/merge',
54 54 'pullrequest_delete': '/{repo_name}/pull-request/{pull_request_id}/delete',
55 55 'pullrequest_comment_create': '/{repo_name}/pull-request/{pull_request_id}/comment',
56 56 'pullrequest_comment_delete': '/{repo_name}/pull-request/{pull_request_id}/comment/{comment_id}/delete',
57 57 }[name].format(**kwargs)
58 58
59 59 if params:
60 60 base_url = '{}?{}'.format(base_url, urllib.urlencode(params))
61 61 return base_url
62 62
63 63
64 64 @pytest.mark.usefixtures('app', 'autologin_user')
65 65 @pytest.mark.backends("git", "hg")
66 66 class TestPullrequestsView(object):
67 67
68 68 def test_index(self, backend):
69 69 self.app.get(route_path(
70 70 'pullrequest_new',
71 71 repo_name=backend.repo_name))
72 72
73 73 def test_option_menu_create_pull_request_exists(self, backend):
74 74 repo_name = backend.repo_name
75 75 response = self.app.get(h.route_path('repo_summary', repo_name=repo_name))
76 76
77 77 create_pr_link = '<a href="%s">Create Pull Request</a>' % route_path(
78 78 'pullrequest_new', repo_name=repo_name)
79 79 response.mustcontain(create_pr_link)
80 80
81 81 def test_create_pr_form_with_raw_commit_id(self, backend):
82 82 repo = backend.repo
83 83
84 84 self.app.get(
85 85 route_path('pullrequest_new', repo_name=repo.repo_name,
86 86 commit=repo.get_commit().raw_id),
87 87 status=200)
88 88
89 89 @pytest.mark.parametrize('pr_merge_enabled', [True, False])
90 90 @pytest.mark.parametrize('range_diff', ["0", "1"])
91 91 def test_show(self, pr_util, pr_merge_enabled, range_diff):
92 92 pull_request = pr_util.create_pull_request(
93 93 mergeable=pr_merge_enabled, enable_notifications=False)
94 94
95 95 response = self.app.get(route_path(
96 96 'pullrequest_show',
97 97 repo_name=pull_request.target_repo.scm_instance().name,
98 98 pull_request_id=pull_request.pull_request_id,
99 99 params={'range-diff': range_diff}))
100 100
101 101 for commit_id in pull_request.revisions:
102 102 response.mustcontain(commit_id)
103 103
104 104 assert pull_request.target_ref_parts.type in response
105 105 assert pull_request.target_ref_parts.name in response
106 106 target_clone_url = pull_request.target_repo.clone_url()
107 107 assert target_clone_url in response
108 108
109 109 assert 'class="pull-request-merge"' in response
110 110 if pr_merge_enabled:
111 111 response.mustcontain('Pull request reviewer approval is pending')
112 112 else:
113 113 response.mustcontain('Server-side pull request merging is disabled.')
114 114
115 115 if range_diff == "1":
116 116 response.mustcontain('Turn off: Show the diff as commit range')
117 117
118 118 def test_close_status_visibility(self, pr_util, user_util, csrf_token):
119 119 # Logout
120 120 response = self.app.post(
121 121 h.route_path('logout'),
122 122 params={'csrf_token': csrf_token})
123 123 # Login as regular user
124 124 response = self.app.post(h.route_path('login'),
125 125 {'username': TEST_USER_REGULAR_LOGIN,
126 126 'password': 'test12'})
127 127
128 128 pull_request = pr_util.create_pull_request(
129 129 author=TEST_USER_REGULAR_LOGIN)
130 130
131 131 response = self.app.get(route_path(
132 132 'pullrequest_show',
133 133 repo_name=pull_request.target_repo.scm_instance().name,
134 134 pull_request_id=pull_request.pull_request_id))
135 135
136 136 response.mustcontain('Server-side pull request merging is disabled.')
137 137
138 138 assert_response = response.assert_response()
139 139 # for regular user without a merge permissions, we don't see it
140 140 assert_response.no_element_exists('#close-pull-request-action')
141 141
142 142 user_util.grant_user_permission_to_repo(
143 143 pull_request.target_repo,
144 144 UserModel().get_by_username(TEST_USER_REGULAR_LOGIN),
145 145 'repository.write')
146 146 response = self.app.get(route_path(
147 147 'pullrequest_show',
148 148 repo_name=pull_request.target_repo.scm_instance().name,
149 149 pull_request_id=pull_request.pull_request_id))
150 150
151 151 response.mustcontain('Server-side pull request merging is disabled.')
152 152
153 153 assert_response = response.assert_response()
154 154 # now regular user has a merge permissions, we have CLOSE button
155 155 assert_response.one_element_exists('#close-pull-request-action')
156 156
157 157 def test_show_invalid_commit_id(self, pr_util):
158 158 # Simulating invalid revisions which will cause a lookup error
159 159 pull_request = pr_util.create_pull_request()
160 160 pull_request.revisions = ['invalid']
161 161 Session().add(pull_request)
162 162 Session().commit()
163 163
164 164 response = self.app.get(route_path(
165 165 'pullrequest_show',
166 166 repo_name=pull_request.target_repo.scm_instance().name,
167 167 pull_request_id=pull_request.pull_request_id))
168 168
169 169 for commit_id in pull_request.revisions:
170 170 response.mustcontain(commit_id)
171 171
172 172 def test_show_invalid_source_reference(self, pr_util):
173 173 pull_request = pr_util.create_pull_request()
174 174 pull_request.source_ref = 'branch:b:invalid'
175 175 Session().add(pull_request)
176 176 Session().commit()
177 177
178 178 self.app.get(route_path(
179 179 'pullrequest_show',
180 180 repo_name=pull_request.target_repo.scm_instance().name,
181 181 pull_request_id=pull_request.pull_request_id))
182 182
183 183 def test_edit_title_description(self, pr_util, csrf_token):
184 184 pull_request = pr_util.create_pull_request()
185 185 pull_request_id = pull_request.pull_request_id
186 186
187 187 response = self.app.post(
188 188 route_path('pullrequest_update',
189 189 repo_name=pull_request.target_repo.repo_name,
190 190 pull_request_id=pull_request_id),
191 191 params={
192 192 'edit_pull_request': 'true',
193 193 'title': 'New title',
194 194 'description': 'New description',
195 195 'csrf_token': csrf_token})
196 196
197 197 assert_session_flash(
198 198 response, u'Pull request title & description updated.',
199 199 category='success')
200 200
201 201 pull_request = PullRequest.get(pull_request_id)
202 202 assert pull_request.title == 'New title'
203 203 assert pull_request.description == 'New description'
204 204
205 205 def test_edit_title_description_closed(self, pr_util, csrf_token):
206 206 pull_request = pr_util.create_pull_request()
207 207 pull_request_id = pull_request.pull_request_id
208 208 repo_name = pull_request.target_repo.repo_name
209 209 pr_util.close()
210 210
211 211 response = self.app.post(
212 212 route_path('pullrequest_update',
213 213 repo_name=repo_name, pull_request_id=pull_request_id),
214 214 params={
215 215 'edit_pull_request': 'true',
216 216 'title': 'New title',
217 217 'description': 'New description',
218 218 'csrf_token': csrf_token}, status=200)
219 219 assert_session_flash(
220 220 response, u'Cannot update closed pull requests.',
221 221 category='error')
222 222
223 223 def test_update_invalid_source_reference(self, pr_util, csrf_token):
224 224 from rhodecode.lib.vcs.backends.base import UpdateFailureReason
225 225
226 226 pull_request = pr_util.create_pull_request()
227 227 pull_request.source_ref = 'branch:invalid-branch:invalid-commit-id'
228 228 Session().add(pull_request)
229 229 Session().commit()
230 230
231 231 pull_request_id = pull_request.pull_request_id
232 232
233 233 response = self.app.post(
234 234 route_path('pullrequest_update',
235 235 repo_name=pull_request.target_repo.repo_name,
236 236 pull_request_id=pull_request_id),
237 237 params={'update_commits': 'true', 'csrf_token': csrf_token})
238 238
239 239 expected_msg = str(PullRequestModel.UPDATE_STATUS_MESSAGES[
240 240 UpdateFailureReason.MISSING_SOURCE_REF])
241 241 assert_session_flash(response, expected_msg, category='error')
242 242
243 243 def test_missing_target_reference(self, pr_util, csrf_token):
244 244 from rhodecode.lib.vcs.backends.base import MergeFailureReason
245 245 pull_request = pr_util.create_pull_request(
246 246 approved=True, mergeable=True)
247 247 unicode_reference = u'branch:invalid-branch:invalid-commit-id'
248 248 pull_request.target_ref = unicode_reference
249 249 Session().add(pull_request)
250 250 Session().commit()
251 251
252 252 pull_request_id = pull_request.pull_request_id
253 253 pull_request_url = route_path(
254 254 'pullrequest_show',
255 255 repo_name=pull_request.target_repo.repo_name,
256 256 pull_request_id=pull_request_id)
257 257
258 258 response = self.app.get(pull_request_url)
259 259 target_ref_id = 'invalid-branch'
260 260 merge_resp = MergeResponse(
261 261 True, True, '', MergeFailureReason.MISSING_TARGET_REF,
262 262 metadata={'target_ref': PullRequest.unicode_to_reference(unicode_reference)})
263 263 response.assert_response().element_contains(
264 264 'span[data-role="merge-message"]', merge_resp.merge_status_message)
265 265
266 266 def test_comment_and_close_pull_request_custom_message_approved(
267 267 self, pr_util, csrf_token, xhr_header):
268 268
269 269 pull_request = pr_util.create_pull_request(approved=True)
270 270 pull_request_id = pull_request.pull_request_id
271 271 author = pull_request.user_id
272 272 repo = pull_request.target_repo.repo_id
273 273
274 274 self.app.post(
275 275 route_path('pullrequest_comment_create',
276 276 repo_name=pull_request.target_repo.scm_instance().name,
277 277 pull_request_id=pull_request_id),
278 278 params={
279 279 'close_pull_request': '1',
280 280 'text': 'Closing a PR',
281 281 'csrf_token': csrf_token},
282 282 extra_environ=xhr_header,)
283 283
284 284 journal = UserLog.query()\
285 285 .filter(UserLog.user_id == author)\
286 286 .filter(UserLog.repository_id == repo) \
287 287 .order_by(UserLog.user_log_id.asc()) \
288 288 .all()
289 289 assert journal[-1].action == 'repo.pull_request.close'
290 290
291 291 pull_request = PullRequest.get(pull_request_id)
292 292 assert pull_request.is_closed()
293 293
294 294 status = ChangesetStatusModel().get_status(
295 295 pull_request.source_repo, pull_request=pull_request)
296 296 assert status == ChangesetStatus.STATUS_APPROVED
297 297 comments = ChangesetComment().query() \
298 298 .filter(ChangesetComment.pull_request == pull_request) \
299 299 .order_by(ChangesetComment.comment_id.asc())\
300 300 .all()
301 301 assert comments[-1].text == 'Closing a PR'
302 302
303 303 def test_comment_force_close_pull_request_rejected(
304 304 self, pr_util, csrf_token, xhr_header):
305 305 pull_request = pr_util.create_pull_request()
306 306 pull_request_id = pull_request.pull_request_id
307 307 PullRequestModel().update_reviewers(
308 308 pull_request_id, [(1, ['reason'], False, []), (2, ['reason2'], False, [])],
309 309 pull_request.author)
310 310 author = pull_request.user_id
311 311 repo = pull_request.target_repo.repo_id
312 312
313 313 self.app.post(
314 314 route_path('pullrequest_comment_create',
315 315 repo_name=pull_request.target_repo.scm_instance().name,
316 316 pull_request_id=pull_request_id),
317 317 params={
318 318 'close_pull_request': '1',
319 319 'csrf_token': csrf_token},
320 320 extra_environ=xhr_header)
321 321
322 322 pull_request = PullRequest.get(pull_request_id)
323 323
324 324 journal = UserLog.query()\
325 325 .filter(UserLog.user_id == author, UserLog.repository_id == repo) \
326 326 .order_by(UserLog.user_log_id.asc()) \
327 327 .all()
328 328 assert journal[-1].action == 'repo.pull_request.close'
329 329
330 330 # check only the latest status, not the review status
331 331 status = ChangesetStatusModel().get_status(
332 332 pull_request.source_repo, pull_request=pull_request)
333 333 assert status == ChangesetStatus.STATUS_REJECTED
334 334
335 335 def test_comment_and_close_pull_request(
336 336 self, pr_util, csrf_token, xhr_header):
337 337 pull_request = pr_util.create_pull_request()
338 338 pull_request_id = pull_request.pull_request_id
339 339
340 340 response = self.app.post(
341 341 route_path('pullrequest_comment_create',
342 342 repo_name=pull_request.target_repo.scm_instance().name,
343 343 pull_request_id=pull_request.pull_request_id),
344 344 params={
345 345 'close_pull_request': 'true',
346 346 'csrf_token': csrf_token},
347 347 extra_environ=xhr_header)
348 348
349 349 assert response.json
350 350
351 351 pull_request = PullRequest.get(pull_request_id)
352 352 assert pull_request.is_closed()
353 353
354 354 # check only the latest status, not the review status
355 355 status = ChangesetStatusModel().get_status(
356 356 pull_request.source_repo, pull_request=pull_request)
357 357 assert status == ChangesetStatus.STATUS_REJECTED
358 358
359 359 def test_create_pull_request(self, backend, csrf_token):
360 360 commits = [
361 361 {'message': 'ancestor'},
362 362 {'message': 'change'},
363 363 {'message': 'change2'},
364 364 ]
365 365 commit_ids = backend.create_master_repo(commits)
366 366 target = backend.create_repo(heads=['ancestor'])
367 367 source = backend.create_repo(heads=['change2'])
368 368
369 369 response = self.app.post(
370 370 route_path('pullrequest_create', repo_name=source.repo_name),
371 371 [
372 372 ('source_repo', source.repo_name),
373 373 ('source_ref', 'branch:default:' + commit_ids['change2']),
374 374 ('target_repo', target.repo_name),
375 375 ('target_ref', 'branch:default:' + commit_ids['ancestor']),
376 376 ('common_ancestor', commit_ids['ancestor']),
377 377 ('pullrequest_title', 'Title'),
378 378 ('pullrequest_desc', 'Description'),
379 379 ('description_renderer', 'markdown'),
380 380 ('__start__', 'review_members:sequence'),
381 381 ('__start__', 'reviewer:mapping'),
382 382 ('user_id', '1'),
383 383 ('__start__', 'reasons:sequence'),
384 384 ('reason', 'Some reason'),
385 385 ('__end__', 'reasons:sequence'),
386 386 ('__start__', 'rules:sequence'),
387 387 ('__end__', 'rules:sequence'),
388 388 ('mandatory', 'False'),
389 389 ('__end__', 'reviewer:mapping'),
390 390 ('__end__', 'review_members:sequence'),
391 391 ('__start__', 'revisions:sequence'),
392 392 ('revisions', commit_ids['change']),
393 393 ('revisions', commit_ids['change2']),
394 394 ('__end__', 'revisions:sequence'),
395 395 ('user', ''),
396 396 ('csrf_token', csrf_token),
397 397 ],
398 398 status=302)
399 399
400 400 location = response.headers['Location']
401 401 pull_request_id = location.rsplit('/', 1)[1]
402 402 assert pull_request_id != 'new'
403 403 pull_request = PullRequest.get(int(pull_request_id))
404 404
405 405 # check that we have now both revisions
406 406 assert pull_request.revisions == [commit_ids['change2'], commit_ids['change']]
407 407 assert pull_request.source_ref == 'branch:default:' + commit_ids['change2']
408 408 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
409 409 assert pull_request.target_ref == expected_target_ref
410 410
411 411 def test_reviewer_notifications(self, backend, csrf_token):
412 412 # We have to use the app.post for this test so it will create the
413 413 # notifications properly with the new PR
414 414 commits = [
415 415 {'message': 'ancestor',
416 416 'added': [FileNode('file_A', content='content_of_ancestor')]},
417 417 {'message': 'change',
418 418 'added': [FileNode('file_a', content='content_of_change')]},
419 419 {'message': 'change-child'},
420 420 {'message': 'ancestor-child', 'parents': ['ancestor'],
421 421 'added': [
422 422 FileNode('file_B', content='content_of_ancestor_child')]},
423 423 {'message': 'ancestor-child-2'},
424 424 ]
425 425 commit_ids = backend.create_master_repo(commits)
426 426 target = backend.create_repo(heads=['ancestor-child'])
427 427 source = backend.create_repo(heads=['change'])
428 428
429 429 response = self.app.post(
430 430 route_path('pullrequest_create', repo_name=source.repo_name),
431 431 [
432 432 ('source_repo', source.repo_name),
433 433 ('source_ref', 'branch:default:' + commit_ids['change']),
434 434 ('target_repo', target.repo_name),
435 435 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
436 436 ('common_ancestor', commit_ids['ancestor']),
437 437 ('pullrequest_title', 'Title'),
438 438 ('pullrequest_desc', 'Description'),
439 439 ('description_renderer', 'markdown'),
440 440 ('__start__', 'review_members:sequence'),
441 441 ('__start__', 'reviewer:mapping'),
442 442 ('user_id', '2'),
443 443 ('__start__', 'reasons:sequence'),
444 444 ('reason', 'Some reason'),
445 445 ('__end__', 'reasons:sequence'),
446 446 ('__start__', 'rules:sequence'),
447 447 ('__end__', 'rules:sequence'),
448 448 ('mandatory', 'False'),
449 449 ('__end__', 'reviewer:mapping'),
450 450 ('__end__', 'review_members:sequence'),
451 451 ('__start__', 'revisions:sequence'),
452 452 ('revisions', commit_ids['change']),
453 453 ('__end__', 'revisions:sequence'),
454 454 ('user', ''),
455 455 ('csrf_token', csrf_token),
456 456 ],
457 457 status=302)
458 458
459 459 location = response.headers['Location']
460 460
461 461 pull_request_id = location.rsplit('/', 1)[1]
462 462 assert pull_request_id != 'new'
463 463 pull_request = PullRequest.get(int(pull_request_id))
464 464
465 465 # Check that a notification was made
466 466 notifications = Notification.query()\
467 467 .filter(Notification.created_by == pull_request.author.user_id,
468 468 Notification.type_ == Notification.TYPE_PULL_REQUEST,
469 469 Notification.subject.contains(
470 "wants you to review pull request #%s" % pull_request_id))
470 "requested a pull request review. !%s" % pull_request_id))
471 471 assert len(notifications.all()) == 1
472 472
473 473 # Change reviewers and check that a notification was made
474 474 PullRequestModel().update_reviewers(
475 475 pull_request.pull_request_id, [(1, [], False, [])],
476 476 pull_request.author)
477 477 assert len(notifications.all()) == 2
478 478
479 479 def test_create_pull_request_stores_ancestor_commit_id(self, backend,
480 480 csrf_token):
481 481 commits = [
482 482 {'message': 'ancestor',
483 483 'added': [FileNode('file_A', content='content_of_ancestor')]},
484 484 {'message': 'change',
485 485 'added': [FileNode('file_a', content='content_of_change')]},
486 486 {'message': 'change-child'},
487 487 {'message': 'ancestor-child', 'parents': ['ancestor'],
488 488 'added': [
489 489 FileNode('file_B', content='content_of_ancestor_child')]},
490 490 {'message': 'ancestor-child-2'},
491 491 ]
492 492 commit_ids = backend.create_master_repo(commits)
493 493 target = backend.create_repo(heads=['ancestor-child'])
494 494 source = backend.create_repo(heads=['change'])
495 495
496 496 response = self.app.post(
497 497 route_path('pullrequest_create', repo_name=source.repo_name),
498 498 [
499 499 ('source_repo', source.repo_name),
500 500 ('source_ref', 'branch:default:' + commit_ids['change']),
501 501 ('target_repo', target.repo_name),
502 502 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
503 503 ('common_ancestor', commit_ids['ancestor']),
504 504 ('pullrequest_title', 'Title'),
505 505 ('pullrequest_desc', 'Description'),
506 506 ('description_renderer', 'markdown'),
507 507 ('__start__', 'review_members:sequence'),
508 508 ('__start__', 'reviewer:mapping'),
509 509 ('user_id', '1'),
510 510 ('__start__', 'reasons:sequence'),
511 511 ('reason', 'Some reason'),
512 512 ('__end__', 'reasons:sequence'),
513 513 ('__start__', 'rules:sequence'),
514 514 ('__end__', 'rules:sequence'),
515 515 ('mandatory', 'False'),
516 516 ('__end__', 'reviewer:mapping'),
517 517 ('__end__', 'review_members:sequence'),
518 518 ('__start__', 'revisions:sequence'),
519 519 ('revisions', commit_ids['change']),
520 520 ('__end__', 'revisions:sequence'),
521 521 ('user', ''),
522 522 ('csrf_token', csrf_token),
523 523 ],
524 524 status=302)
525 525
526 526 location = response.headers['Location']
527 527
528 528 pull_request_id = location.rsplit('/', 1)[1]
529 529 assert pull_request_id != 'new'
530 530 pull_request = PullRequest.get(int(pull_request_id))
531 531
532 532 # target_ref has to point to the ancestor's commit_id in order to
533 533 # show the correct diff
534 534 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
535 535 assert pull_request.target_ref == expected_target_ref
536 536
537 537 # Check generated diff contents
538 538 response = response.follow()
539 539 assert 'content_of_ancestor' not in response.body
540 540 assert 'content_of_ancestor-child' not in response.body
541 541 assert 'content_of_change' in response.body
542 542
543 543 def test_merge_pull_request_enabled(self, pr_util, csrf_token):
544 544 # Clear any previous calls to rcextensions
545 545 rhodecode.EXTENSIONS.calls.clear()
546 546
547 547 pull_request = pr_util.create_pull_request(
548 548 approved=True, mergeable=True)
549 549 pull_request_id = pull_request.pull_request_id
550 550 repo_name = pull_request.target_repo.scm_instance().name,
551 551
552 response = self.app.post(
553 route_path('pullrequest_merge',
554 repo_name=str(repo_name[0]),
555 pull_request_id=pull_request_id),
556 params={'csrf_token': csrf_token}).follow()
552 url = route_path('pullrequest_merge',
553 repo_name=str(repo_name[0]),
554 pull_request_id=pull_request_id)
555 response = self.app.post(url, params={'csrf_token': csrf_token}).follow()
557 556
558 557 pull_request = PullRequest.get(pull_request_id)
559 558
560 559 assert response.status_int == 200
561 560 assert pull_request.is_closed()
562 561 assert_pull_request_status(
563 562 pull_request, ChangesetStatus.STATUS_APPROVED)
564 563
565 564 # Check the relevant log entries were added
566 565 user_logs = UserLog.query().order_by(UserLog.user_log_id.desc()).limit(3)
567 566 actions = [log.action for log in user_logs]
568 567 pr_commit_ids = PullRequestModel()._get_commit_ids(pull_request)
569 568 expected_actions = [
570 569 u'repo.pull_request.close',
571 570 u'repo.pull_request.merge',
572 571 u'repo.pull_request.comment.create'
573 572 ]
574 573 assert actions == expected_actions
575 574
576 575 user_logs = UserLog.query().order_by(UserLog.user_log_id.desc()).limit(4)
577 576 actions = [log for log in user_logs]
578 577 assert actions[-1].action == 'user.push'
579 578 assert actions[-1].action_data['commit_ids'] == pr_commit_ids
580 579
581 580 # Check post_push rcextension was really executed
582 581 push_calls = rhodecode.EXTENSIONS.calls['_push_hook']
583 582 assert len(push_calls) == 1
584 583 unused_last_call_args, last_call_kwargs = push_calls[0]
585 584 assert last_call_kwargs['action'] == 'push'
586 585 assert last_call_kwargs['commit_ids'] == pr_commit_ids
587 586
588 587 def test_merge_pull_request_disabled(self, pr_util, csrf_token):
589 588 pull_request = pr_util.create_pull_request(mergeable=False)
590 589 pull_request_id = pull_request.pull_request_id
591 590 pull_request = PullRequest.get(pull_request_id)
592 591
593 592 response = self.app.post(
594 593 route_path('pullrequest_merge',
595 594 repo_name=pull_request.target_repo.scm_instance().name,
596 595 pull_request_id=pull_request.pull_request_id),
597 596 params={'csrf_token': csrf_token}).follow()
598 597
599 598 assert response.status_int == 200
600 599 response.mustcontain(
601 600 'Merge is not currently possible because of below failed checks.')
602 601 response.mustcontain('Server-side pull request merging is disabled.')
603 602
604 603 @pytest.mark.skip_backends('svn')
605 604 def test_merge_pull_request_not_approved(self, pr_util, csrf_token):
606 605 pull_request = pr_util.create_pull_request(mergeable=True)
607 606 pull_request_id = pull_request.pull_request_id
608 607 repo_name = pull_request.target_repo.scm_instance().name
609 608
610 609 response = self.app.post(
611 610 route_path('pullrequest_merge',
612 611 repo_name=repo_name, pull_request_id=pull_request_id),
613 612 params={'csrf_token': csrf_token}).follow()
614 613
615 614 assert response.status_int == 200
616 615
617 616 response.mustcontain(
618 617 'Merge is not currently possible because of below failed checks.')
619 618 response.mustcontain('Pull request reviewer approval is pending.')
620 619
621 620 def test_merge_pull_request_renders_failure_reason(
622 621 self, user_regular, csrf_token, pr_util):
623 622 pull_request = pr_util.create_pull_request(mergeable=True, approved=True)
624 623 pull_request_id = pull_request.pull_request_id
625 624 repo_name = pull_request.target_repo.scm_instance().name
626 625
627 626 merge_resp = MergeResponse(True, False, 'STUB_COMMIT_ID',
628 627 MergeFailureReason.PUSH_FAILED,
629 628 metadata={'target': 'shadow repo',
630 629 'merge_commit': 'xxx'})
631 630 model_patcher = mock.patch.multiple(
632 631 PullRequestModel,
633 632 merge_repo=mock.Mock(return_value=merge_resp),
634 633 merge_status=mock.Mock(return_value=(True, 'WRONG_MESSAGE')))
635 634
636 635 with model_patcher:
637 636 response = self.app.post(
638 637 route_path('pullrequest_merge',
639 638 repo_name=repo_name,
640 639 pull_request_id=pull_request_id),
641 640 params={'csrf_token': csrf_token}, status=302)
642 641
643 642 merge_resp = MergeResponse(True, True, '', MergeFailureReason.PUSH_FAILED,
644 643 metadata={'target': 'shadow repo',
645 644 'merge_commit': 'xxx'})
646 645 assert_session_flash(response, merge_resp.merge_status_message)
647 646
648 647 def test_update_source_revision(self, backend, csrf_token):
649 648 commits = [
650 649 {'message': 'ancestor'},
651 650 {'message': 'change'},
652 651 {'message': 'change-2'},
653 652 ]
654 653 commit_ids = backend.create_master_repo(commits)
655 654 target = backend.create_repo(heads=['ancestor'])
656 655 source = backend.create_repo(heads=['change'])
657 656
658 657 # create pr from a in source to A in target
659 658 pull_request = PullRequest()
660 659
661 660 pull_request.source_repo = source
662 661 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
663 662 branch=backend.default_branch_name, commit_id=commit_ids['change'])
664 663
665 664 pull_request.target_repo = target
666 665 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
667 666 branch=backend.default_branch_name, commit_id=commit_ids['ancestor'])
668 667
669 668 pull_request.revisions = [commit_ids['change']]
670 669 pull_request.title = u"Test"
671 670 pull_request.description = u"Description"
672 671 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
673 672 pull_request.pull_request_state = PullRequest.STATE_CREATED
674 673 Session().add(pull_request)
675 674 Session().commit()
676 675 pull_request_id = pull_request.pull_request_id
677 676
678 677 # source has ancestor - change - change-2
679 678 backend.pull_heads(source, heads=['change-2'])
680 679
681 680 # update PR
682 681 self.app.post(
683 682 route_path('pullrequest_update',
684 683 repo_name=target.repo_name, pull_request_id=pull_request_id),
685 684 params={'update_commits': 'true', 'csrf_token': csrf_token})
686 685
687 686 response = self.app.get(
688 687 route_path('pullrequest_show',
689 688 repo_name=target.repo_name,
690 689 pull_request_id=pull_request.pull_request_id))
691 690
692 691 assert response.status_int == 200
693 692 assert 'Pull request updated to' in response.body
694 693 assert 'with 1 added, 0 removed commits.' in response.body
695 694
696 695 # check that we have now both revisions
697 696 pull_request = PullRequest.get(pull_request_id)
698 697 assert pull_request.revisions == [commit_ids['change-2'], commit_ids['change']]
699 698
700 699 def test_update_target_revision(self, backend, csrf_token):
701 700 commits = [
702 701 {'message': 'ancestor'},
703 702 {'message': 'change'},
704 703 {'message': 'ancestor-new', 'parents': ['ancestor']},
705 704 {'message': 'change-rebased'},
706 705 ]
707 706 commit_ids = backend.create_master_repo(commits)
708 707 target = backend.create_repo(heads=['ancestor'])
709 708 source = backend.create_repo(heads=['change'])
710 709
711 710 # create pr from a in source to A in target
712 711 pull_request = PullRequest()
713 712
714 713 pull_request.source_repo = source
715 714 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
716 715 branch=backend.default_branch_name, commit_id=commit_ids['change'])
717 716
718 717 pull_request.target_repo = target
719 718 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
720 719 branch=backend.default_branch_name, commit_id=commit_ids['ancestor'])
721 720
722 721 pull_request.revisions = [commit_ids['change']]
723 722 pull_request.title = u"Test"
724 723 pull_request.description = u"Description"
725 724 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
726 725 pull_request.pull_request_state = PullRequest.STATE_CREATED
727 726
728 727 Session().add(pull_request)
729 728 Session().commit()
730 729 pull_request_id = pull_request.pull_request_id
731 730
732 731 # target has ancestor - ancestor-new
733 732 # source has ancestor - ancestor-new - change-rebased
734 733 backend.pull_heads(target, heads=['ancestor-new'])
735 734 backend.pull_heads(source, heads=['change-rebased'])
736 735
737 736 # update PR
738 self.app.post(
739 route_path('pullrequest_update',
740 repo_name=target.repo_name,
741 pull_request_id=pull_request_id),
742 params={'update_commits': 'true', 'csrf_token': csrf_token},
743 status=200)
737 url = route_path('pullrequest_update',
738 repo_name=target.repo_name,
739 pull_request_id=pull_request_id)
740 self.app.post(url,
741 params={'update_commits': 'true', 'csrf_token': csrf_token},
742 status=200)
744 743
745 744 # check that we have now both revisions
746 745 pull_request = PullRequest.get(pull_request_id)
747 746 assert pull_request.revisions == [commit_ids['change-rebased']]
748 747 assert pull_request.target_ref == 'branch:{branch}:{commit_id}'.format(
749 748 branch=backend.default_branch_name, commit_id=commit_ids['ancestor-new'])
750 749
751 750 response = self.app.get(
752 751 route_path('pullrequest_show',
753 752 repo_name=target.repo_name,
754 753 pull_request_id=pull_request.pull_request_id))
755 754 assert response.status_int == 200
756 755 assert 'Pull request updated to' in response.body
757 756 assert 'with 1 added, 1 removed commits.' in response.body
758 757
759 758 def test_update_target_revision_with_removal_of_1_commit_git(self, backend_git, csrf_token):
760 759 backend = backend_git
761 760 commits = [
762 761 {'message': 'master-commit-1'},
763 762 {'message': 'master-commit-2-change-1'},
764 763 {'message': 'master-commit-3-change-2'},
765 764
766 765 {'message': 'feat-commit-1', 'parents': ['master-commit-1']},
767 766 {'message': 'feat-commit-2'},
768 767 ]
769 768 commit_ids = backend.create_master_repo(commits)
770 769 target = backend.create_repo(heads=['master-commit-3-change-2'])
771 770 source = backend.create_repo(heads=['feat-commit-2'])
772 771
773 772 # create pr from a in source to A in target
774 773 pull_request = PullRequest()
775 774 pull_request.source_repo = source
776 775
777 776 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
778 777 branch=backend.default_branch_name,
779 778 commit_id=commit_ids['master-commit-3-change-2'])
780 779
781 780 pull_request.target_repo = target
782 781 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
783 782 branch=backend.default_branch_name, commit_id=commit_ids['feat-commit-2'])
784 783
785 784 pull_request.revisions = [
786 785 commit_ids['feat-commit-1'],
787 786 commit_ids['feat-commit-2']
788 787 ]
789 788 pull_request.title = u"Test"
790 789 pull_request.description = u"Description"
791 790 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
792 791 pull_request.pull_request_state = PullRequest.STATE_CREATED
793 792 Session().add(pull_request)
794 793 Session().commit()
795 794 pull_request_id = pull_request.pull_request_id
796 795
797 796 # PR is created, now we simulate a force-push into target,
798 797 # that drops a 2 last commits
799 798 vcsrepo = target.scm_instance()
800 799 vcsrepo.config.clear_section('hooks')
801 800 vcsrepo.run_git_command(['reset', '--soft', 'HEAD~2'])
802 801
803 802 # update PR
804 self.app.post(
805 route_path('pullrequest_update',
806 repo_name=target.repo_name,
807 pull_request_id=pull_request_id),
808 params={'update_commits': 'true', 'csrf_token': csrf_token},
809 status=200)
803 url = route_path('pullrequest_update',
804 repo_name=target.repo_name,
805 pull_request_id=pull_request_id)
806 self.app.post(url,
807 params={'update_commits': 'true', 'csrf_token': csrf_token},
808 status=200)
810 809
811 810 response = self.app.get(route_path('pullrequest_new', repo_name=target.repo_name))
812 811 assert response.status_int == 200
813 812 response.mustcontain('Pull request updated to')
814 813 response.mustcontain('with 0 added, 0 removed commits.')
815 814
816 815 def test_update_of_ancestor_reference(self, backend, csrf_token):
817 816 commits = [
818 817 {'message': 'ancestor'},
819 818 {'message': 'change'},
820 819 {'message': 'change-2'},
821 820 {'message': 'ancestor-new', 'parents': ['ancestor']},
822 821 {'message': 'change-rebased'},
823 822 ]
824 823 commit_ids = backend.create_master_repo(commits)
825 824 target = backend.create_repo(heads=['ancestor'])
826 825 source = backend.create_repo(heads=['change'])
827 826
828 827 # create pr from a in source to A in target
829 828 pull_request = PullRequest()
830 829 pull_request.source_repo = source
831 830
832 831 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
833 832 branch=backend.default_branch_name, commit_id=commit_ids['change'])
834 833 pull_request.target_repo = target
835 834 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
836 835 branch=backend.default_branch_name, commit_id=commit_ids['ancestor'])
837 836 pull_request.revisions = [commit_ids['change']]
838 837 pull_request.title = u"Test"
839 838 pull_request.description = u"Description"
840 839 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
841 840 pull_request.pull_request_state = PullRequest.STATE_CREATED
842 841 Session().add(pull_request)
843 842 Session().commit()
844 843 pull_request_id = pull_request.pull_request_id
845 844
846 845 # target has ancestor - ancestor-new
847 846 # source has ancestor - ancestor-new - change-rebased
848 847 backend.pull_heads(target, heads=['ancestor-new'])
849 848 backend.pull_heads(source, heads=['change-rebased'])
850 849
851 850 # update PR
852 851 self.app.post(
853 852 route_path('pullrequest_update',
854 853 repo_name=target.repo_name, pull_request_id=pull_request_id),
855 854 params={'update_commits': 'true', 'csrf_token': csrf_token},
856 855 status=200)
857 856
858 857 # Expect the target reference to be updated correctly
859 858 pull_request = PullRequest.get(pull_request_id)
860 859 assert pull_request.revisions == [commit_ids['change-rebased']]
861 860 expected_target_ref = 'branch:{branch}:{commit_id}'.format(
862 861 branch=backend.default_branch_name,
863 862 commit_id=commit_ids['ancestor-new'])
864 863 assert pull_request.target_ref == expected_target_ref
865 864
866 865 def test_remove_pull_request_branch(self, backend_git, csrf_token):
867 866 branch_name = 'development'
868 867 commits = [
869 868 {'message': 'initial-commit'},
870 869 {'message': 'old-feature'},
871 870 {'message': 'new-feature', 'branch': branch_name},
872 871 ]
873 872 repo = backend_git.create_repo(commits)
874 873 repo_name = repo.repo_name
875 874 commit_ids = backend_git.commit_ids
876 875
877 876 pull_request = PullRequest()
878 877 pull_request.source_repo = repo
879 878 pull_request.target_repo = repo
880 879 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
881 880 branch=branch_name, commit_id=commit_ids['new-feature'])
882 881 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
883 882 branch=backend_git.default_branch_name, commit_id=commit_ids['old-feature'])
884 883 pull_request.revisions = [commit_ids['new-feature']]
885 884 pull_request.title = u"Test"
886 885 pull_request.description = u"Description"
887 886 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
888 887 pull_request.pull_request_state = PullRequest.STATE_CREATED
889 888 Session().add(pull_request)
890 889 Session().commit()
891 890
892 891 pull_request_id = pull_request.pull_request_id
893 892
894 893 vcs = repo.scm_instance()
895 894 vcs.remove_ref('refs/heads/{}'.format(branch_name))
896 895
897 896 response = self.app.get(route_path(
898 897 'pullrequest_show',
899 898 repo_name=repo_name,
900 899 pull_request_id=pull_request_id))
901 900
902 901 assert response.status_int == 200
903 902
904 903 response.assert_response().element_contains(
905 904 '#changeset_compare_view_content .alert strong',
906 905 'Missing commits')
907 906 response.assert_response().element_contains(
908 907 '#changeset_compare_view_content .alert',
909 908 'This pull request cannot be displayed, because one or more'
910 909 ' commits no longer exist in the source repository.')
911 910
912 911 def test_strip_commits_from_pull_request(
913 912 self, backend, pr_util, csrf_token):
914 913 commits = [
915 914 {'message': 'initial-commit'},
916 915 {'message': 'old-feature'},
917 916 {'message': 'new-feature', 'parents': ['initial-commit']},
918 917 ]
919 918 pull_request = pr_util.create_pull_request(
920 919 commits, target_head='initial-commit', source_head='new-feature',
921 920 revisions=['new-feature'])
922 921
923 922 vcs = pr_util.source_repository.scm_instance()
924 923 if backend.alias == 'git':
925 924 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
926 925 else:
927 926 vcs.strip(pr_util.commit_ids['new-feature'])
928 927
929 928 response = self.app.get(route_path(
930 929 'pullrequest_show',
931 930 repo_name=pr_util.target_repository.repo_name,
932 931 pull_request_id=pull_request.pull_request_id))
933 932
934 933 assert response.status_int == 200
935 934
936 935 response.assert_response().element_contains(
937 936 '#changeset_compare_view_content .alert strong',
938 937 'Missing commits')
939 938 response.assert_response().element_contains(
940 939 '#changeset_compare_view_content .alert',
941 940 'This pull request cannot be displayed, because one or more'
942 941 ' commits no longer exist in the source repository.')
943 942 response.assert_response().element_contains(
944 943 '#update_commits',
945 944 'Update commits')
946 945
947 946 def test_strip_commits_and_update(
948 947 self, backend, pr_util, csrf_token):
949 948 commits = [
950 949 {'message': 'initial-commit'},
951 950 {'message': 'old-feature'},
952 951 {'message': 'new-feature', 'parents': ['old-feature']},
953 952 ]
954 953 pull_request = pr_util.create_pull_request(
955 954 commits, target_head='old-feature', source_head='new-feature',
956 955 revisions=['new-feature'], mergeable=True)
957 956
958 957 vcs = pr_util.source_repository.scm_instance()
959 958 if backend.alias == 'git':
960 959 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
961 960 else:
962 961 vcs.strip(pr_util.commit_ids['new-feature'])
963 962
964 response = self.app.post(
965 route_path('pullrequest_update',
966 repo_name=pull_request.target_repo.repo_name,
967 pull_request_id=pull_request.pull_request_id),
968 params={'update_commits': 'true',
969 'csrf_token': csrf_token})
963 url = route_path('pullrequest_update',
964 repo_name=pull_request.target_repo.repo_name,
965 pull_request_id=pull_request.pull_request_id)
966 response = self.app.post(url,
967 params={'update_commits': 'true',
968 'csrf_token': csrf_token})
970 969
971 970 assert response.status_int == 200
972 971 assert response.body == 'true'
973 972
974 973 # Make sure that after update, it won't raise 500 errors
975 974 response = self.app.get(route_path(
976 975 'pullrequest_show',
977 976 repo_name=pr_util.target_repository.repo_name,
978 977 pull_request_id=pull_request.pull_request_id))
979 978
980 979 assert response.status_int == 200
981 980 response.assert_response().element_contains(
982 981 '#changeset_compare_view_content .alert strong',
983 982 'Missing commits')
984 983
985 984 def test_branch_is_a_link(self, pr_util):
986 985 pull_request = pr_util.create_pull_request()
987 986 pull_request.source_ref = 'branch:origin:1234567890abcdef'
988 987 pull_request.target_ref = 'branch:target:abcdef1234567890'
989 988 Session().add(pull_request)
990 989 Session().commit()
991 990
992 991 response = self.app.get(route_path(
993 992 'pullrequest_show',
994 993 repo_name=pull_request.target_repo.scm_instance().name,
995 994 pull_request_id=pull_request.pull_request_id))
996 995 assert response.status_int == 200
997 996
998 997 origin = response.assert_response().get_element('.pr-origininfo .tag')
999 998 origin_children = origin.getchildren()
1000 999 assert len(origin_children) == 1
1001 1000 target = response.assert_response().get_element('.pr-targetinfo .tag')
1002 1001 target_children = target.getchildren()
1003 1002 assert len(target_children) == 1
1004 1003
1005 1004 expected_origin_link = route_path(
1006 1005 'repo_commits',
1007 1006 repo_name=pull_request.source_repo.scm_instance().name,
1008 1007 params=dict(branch='origin'))
1009 1008 expected_target_link = route_path(
1010 1009 'repo_commits',
1011 1010 repo_name=pull_request.target_repo.scm_instance().name,
1012 1011 params=dict(branch='target'))
1013 1012 assert origin_children[0].attrib['href'] == expected_origin_link
1014 1013 assert origin_children[0].text == 'branch: origin'
1015 1014 assert target_children[0].attrib['href'] == expected_target_link
1016 1015 assert target_children[0].text == 'branch: target'
1017 1016
1018 1017 def test_bookmark_is_not_a_link(self, pr_util):
1019 1018 pull_request = pr_util.create_pull_request()
1020 1019 pull_request.source_ref = 'bookmark:origin:1234567890abcdef'
1021 1020 pull_request.target_ref = 'bookmark:target:abcdef1234567890'
1022 1021 Session().add(pull_request)
1023 1022 Session().commit()
1024 1023
1025 1024 response = self.app.get(route_path(
1026 1025 'pullrequest_show',
1027 1026 repo_name=pull_request.target_repo.scm_instance().name,
1028 1027 pull_request_id=pull_request.pull_request_id))
1029 1028 assert response.status_int == 200
1030 1029
1031 1030 origin = response.assert_response().get_element('.pr-origininfo .tag')
1032 1031 assert origin.text.strip() == 'bookmark: origin'
1033 1032 assert origin.getchildren() == []
1034 1033
1035 1034 target = response.assert_response().get_element('.pr-targetinfo .tag')
1036 1035 assert target.text.strip() == 'bookmark: target'
1037 1036 assert target.getchildren() == []
1038 1037
1039 1038 def test_tag_is_not_a_link(self, pr_util):
1040 1039 pull_request = pr_util.create_pull_request()
1041 1040 pull_request.source_ref = 'tag:origin:1234567890abcdef'
1042 1041 pull_request.target_ref = 'tag:target:abcdef1234567890'
1043 1042 Session().add(pull_request)
1044 1043 Session().commit()
1045 1044
1046 1045 response = self.app.get(route_path(
1047 1046 'pullrequest_show',
1048 1047 repo_name=pull_request.target_repo.scm_instance().name,
1049 1048 pull_request_id=pull_request.pull_request_id))
1050 1049 assert response.status_int == 200
1051 1050
1052 1051 origin = response.assert_response().get_element('.pr-origininfo .tag')
1053 1052 assert origin.text.strip() == 'tag: origin'
1054 1053 assert origin.getchildren() == []
1055 1054
1056 1055 target = response.assert_response().get_element('.pr-targetinfo .tag')
1057 1056 assert target.text.strip() == 'tag: target'
1058 1057 assert target.getchildren() == []
1059 1058
1060 1059 @pytest.mark.parametrize('mergeable', [True, False])
1061 1060 def test_shadow_repository_link(
1062 1061 self, mergeable, pr_util, http_host_only_stub):
1063 1062 """
1064 1063 Check that the pull request summary page displays a link to the shadow
1065 1064 repository if the pull request is mergeable. If it is not mergeable
1066 1065 the link should not be displayed.
1067 1066 """
1068 1067 pull_request = pr_util.create_pull_request(
1069 1068 mergeable=mergeable, enable_notifications=False)
1070 1069 target_repo = pull_request.target_repo.scm_instance()
1071 1070 pr_id = pull_request.pull_request_id
1072 1071 shadow_url = '{host}/{repo}/pull-request/{pr_id}/repository'.format(
1073 1072 host=http_host_only_stub, repo=target_repo.name, pr_id=pr_id)
1074 1073
1075 1074 response = self.app.get(route_path(
1076 1075 'pullrequest_show',
1077 1076 repo_name=target_repo.name,
1078 1077 pull_request_id=pr_id))
1079 1078
1080 1079 if mergeable:
1081 1080 response.assert_response().element_value_contains(
1082 1081 'input.pr-mergeinfo', shadow_url)
1083 1082 response.assert_response().element_value_contains(
1084 1083 'input.pr-mergeinfo ', 'pr-merge')
1085 1084 else:
1086 1085 response.assert_response().no_element_exists('.pr-mergeinfo')
1087 1086
1088 1087
1089 1088 @pytest.mark.usefixtures('app')
1090 1089 @pytest.mark.backends("git", "hg")
1091 1090 class TestPullrequestsControllerDelete(object):
1092 1091 def test_pull_request_delete_button_permissions_admin(
1093 1092 self, autologin_user, user_admin, pr_util):
1094 1093 pull_request = pr_util.create_pull_request(
1095 1094 author=user_admin.username, enable_notifications=False)
1096 1095
1097 1096 response = self.app.get(route_path(
1098 1097 'pullrequest_show',
1099 1098 repo_name=pull_request.target_repo.scm_instance().name,
1100 1099 pull_request_id=pull_request.pull_request_id))
1101 1100
1102 1101 response.mustcontain('id="delete_pullrequest"')
1103 1102 response.mustcontain('Confirm to delete this pull request')
1104 1103
1105 1104 def test_pull_request_delete_button_permissions_owner(
1106 1105 self, autologin_regular_user, user_regular, pr_util):
1107 1106 pull_request = pr_util.create_pull_request(
1108 1107 author=user_regular.username, enable_notifications=False)
1109 1108
1110 1109 response = self.app.get(route_path(
1111 1110 'pullrequest_show',
1112 1111 repo_name=pull_request.target_repo.scm_instance().name,
1113 1112 pull_request_id=pull_request.pull_request_id))
1114 1113
1115 1114 response.mustcontain('id="delete_pullrequest"')
1116 1115 response.mustcontain('Confirm to delete this pull request')
1117 1116
1118 1117 def test_pull_request_delete_button_permissions_forbidden(
1119 1118 self, autologin_regular_user, user_regular, user_admin, pr_util):
1120 1119 pull_request = pr_util.create_pull_request(
1121 1120 author=user_admin.username, enable_notifications=False)
1122 1121
1123 1122 response = self.app.get(route_path(
1124 1123 'pullrequest_show',
1125 1124 repo_name=pull_request.target_repo.scm_instance().name,
1126 1125 pull_request_id=pull_request.pull_request_id))
1127 1126 response.mustcontain(no=['id="delete_pullrequest"'])
1128 1127 response.mustcontain(no=['Confirm to delete this pull request'])
1129 1128
1130 1129 def test_pull_request_delete_button_permissions_can_update_cannot_delete(
1131 1130 self, autologin_regular_user, user_regular, user_admin, pr_util,
1132 1131 user_util):
1133 1132
1134 1133 pull_request = pr_util.create_pull_request(
1135 1134 author=user_admin.username, enable_notifications=False)
1136 1135
1137 1136 user_util.grant_user_permission_to_repo(
1138 1137 pull_request.target_repo, user_regular,
1139 1138 'repository.write')
1140 1139
1141 1140 response = self.app.get(route_path(
1142 1141 'pullrequest_show',
1143 1142 repo_name=pull_request.target_repo.scm_instance().name,
1144 1143 pull_request_id=pull_request.pull_request_id))
1145 1144
1146 1145 response.mustcontain('id="open_edit_pullrequest"')
1147 1146 response.mustcontain('id="delete_pullrequest"')
1148 1147 response.mustcontain(no=['Confirm to delete this pull request'])
1149 1148
1150 1149 def test_delete_comment_returns_404_if_comment_does_not_exist(
1151 1150 self, autologin_user, pr_util, user_admin, csrf_token, xhr_header):
1152 1151
1153 1152 pull_request = pr_util.create_pull_request(
1154 1153 author=user_admin.username, enable_notifications=False)
1155 1154
1156 1155 self.app.post(
1157 1156 route_path(
1158 1157 'pullrequest_comment_delete',
1159 1158 repo_name=pull_request.target_repo.scm_instance().name,
1160 1159 pull_request_id=pull_request.pull_request_id,
1161 1160 comment_id=1024404),
1162 1161 extra_environ=xhr_header,
1163 1162 params={'csrf_token': csrf_token},
1164 1163 status=404
1165 1164 )
1166 1165
1167 1166 def test_delete_comment(
1168 1167 self, autologin_user, pr_util, user_admin, csrf_token, xhr_header):
1169 1168
1170 1169 pull_request = pr_util.create_pull_request(
1171 1170 author=user_admin.username, enable_notifications=False)
1172 1171 comment = pr_util.create_comment()
1173 1172 comment_id = comment.comment_id
1174 1173
1175 1174 response = self.app.post(
1176 1175 route_path(
1177 1176 'pullrequest_comment_delete',
1178 1177 repo_name=pull_request.target_repo.scm_instance().name,
1179 1178 pull_request_id=pull_request.pull_request_id,
1180 1179 comment_id=comment_id),
1181 1180 extra_environ=xhr_header,
1182 1181 params={'csrf_token': csrf_token},
1183 1182 status=200
1184 1183 )
1185 1184 assert response.body == 'true'
1186 1185
1187 1186 @pytest.mark.parametrize('url_type', [
1188 1187 'pullrequest_new',
1189 1188 'pullrequest_create',
1190 1189 'pullrequest_update',
1191 1190 'pullrequest_merge',
1192 1191 ])
1193 1192 def test_pull_request_is_forbidden_on_archived_repo(
1194 1193 self, autologin_user, backend, xhr_header, user_util, url_type):
1195 1194
1196 1195 # create a temporary repo
1197 1196 source = user_util.create_repo(repo_type=backend.alias)
1198 1197 repo_name = source.repo_name
1199 1198 repo = Repository.get_by_repo_name(repo_name)
1200 1199 repo.archived = True
1201 1200 Session().commit()
1202 1201
1203 1202 response = self.app.get(
1204 1203 route_path(url_type, repo_name=repo_name, pull_request_id=1), status=302)
1205 1204
1206 1205 msg = 'Action not supported for archived repository.'
1207 1206 assert_session_flash(response, msg)
1208 1207
1209 1208
1210 1209 def assert_pull_request_status(pull_request, expected_status):
1211 status = ChangesetStatusModel().calculated_review_status(
1212 pull_request=pull_request)
1210 status = ChangesetStatusModel().calculated_review_status(pull_request=pull_request)
1213 1211 assert status == expected_status
1214 1212
1215 1213
1216 1214 @pytest.mark.parametrize('route', ['pullrequest_new', 'pullrequest_create'])
1217 1215 @pytest.mark.usefixtures("autologin_user")
1218 1216 def test_forbidde_to_repo_summary_for_svn_repositories(backend_svn, app, route):
1219 response = app.get(
1220 route_path(route, repo_name=backend_svn.repo_name), status=404)
1221
1217 app.get(route_path(route, repo_name=backend_svn.repo_name), status=404)
@@ -1,734 +1,740 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 comments model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from pyramid.threadlocal import get_current_registry, get_current_request
30 30 from sqlalchemy.sql.expression import null
31 31 from sqlalchemy.sql.functions import coalesce
32 32
33 33 from rhodecode.lib import helpers as h, diffs, channelstream
34 34 from rhodecode.lib import audit_logger
35 35 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str
36 36 from rhodecode.model import BaseModel
37 37 from rhodecode.model.db import (
38 38 ChangesetComment, User, Notification, PullRequest, AttributeDict)
39 39 from rhodecode.model.notification import NotificationModel
40 40 from rhodecode.model.meta import Session
41 41 from rhodecode.model.settings import VcsSettingsModel
42 42 from rhodecode.model.notification import EmailNotificationModel
43 43 from rhodecode.model.validation_schema.schemas import comment_schema
44 44
45 45
46 46 log = logging.getLogger(__name__)
47 47
48 48
49 49 class CommentsModel(BaseModel):
50 50
51 51 cls = ChangesetComment
52 52
53 53 DIFF_CONTEXT_BEFORE = 3
54 54 DIFF_CONTEXT_AFTER = 3
55 55
56 56 def __get_commit_comment(self, changeset_comment):
57 57 return self._get_instance(ChangesetComment, changeset_comment)
58 58
59 59 def __get_pull_request(self, pull_request):
60 60 return self._get_instance(PullRequest, pull_request)
61 61
62 62 def _extract_mentions(self, s):
63 63 user_objects = []
64 64 for username in extract_mentioned_users(s):
65 65 user_obj = User.get_by_username(username, case_insensitive=True)
66 66 if user_obj:
67 67 user_objects.append(user_obj)
68 68 return user_objects
69 69
70 70 def _get_renderer(self, global_renderer='rst', request=None):
71 71 request = request or get_current_request()
72 72
73 73 try:
74 74 global_renderer = request.call_context.visual.default_renderer
75 75 except AttributeError:
76 76 log.debug("Renderer not set, falling back "
77 77 "to default renderer '%s'", global_renderer)
78 78 except Exception:
79 79 log.error(traceback.format_exc())
80 80 return global_renderer
81 81
82 82 def aggregate_comments(self, comments, versions, show_version, inline=False):
83 83 # group by versions, and count until, and display objects
84 84
85 85 comment_groups = collections.defaultdict(list)
86 86 [comment_groups[
87 87 _co.pull_request_version_id].append(_co) for _co in comments]
88 88
89 89 def yield_comments(pos):
90 90 for co in comment_groups[pos]:
91 91 yield co
92 92
93 93 comment_versions = collections.defaultdict(
94 94 lambda: collections.defaultdict(list))
95 95 prev_prvid = -1
96 96 # fake last entry with None, to aggregate on "latest" version which
97 97 # doesn't have an pull_request_version_id
98 98 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
99 99 prvid = ver.pull_request_version_id
100 100 if prev_prvid == -1:
101 101 prev_prvid = prvid
102 102
103 103 for co in yield_comments(prvid):
104 104 comment_versions[prvid]['at'].append(co)
105 105
106 106 # save until
107 107 current = comment_versions[prvid]['at']
108 108 prev_until = comment_versions[prev_prvid]['until']
109 109 cur_until = prev_until + current
110 110 comment_versions[prvid]['until'].extend(cur_until)
111 111
112 112 # save outdated
113 113 if inline:
114 114 outdated = [x for x in cur_until
115 115 if x.outdated_at_version(show_version)]
116 116 else:
117 117 outdated = [x for x in cur_until
118 118 if x.older_than_version(show_version)]
119 119 display = [x for x in cur_until if x not in outdated]
120 120
121 121 comment_versions[prvid]['outdated'] = outdated
122 122 comment_versions[prvid]['display'] = display
123 123
124 124 prev_prvid = prvid
125 125
126 126 return comment_versions
127 127
128 128 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
129 129 qry = Session().query(ChangesetComment) \
130 130 .filter(ChangesetComment.repo == repo)
131 131
132 132 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
133 133 qry = qry.filter(ChangesetComment.comment_type == comment_type)
134 134
135 135 if user:
136 136 user = self._get_user(user)
137 137 if user:
138 138 qry = qry.filter(ChangesetComment.user_id == user.user_id)
139 139
140 140 if commit_id:
141 141 qry = qry.filter(ChangesetComment.revision == commit_id)
142 142
143 143 qry = qry.order_by(ChangesetComment.created_on)
144 144 return qry.all()
145 145
146 146 def get_repository_unresolved_todos(self, repo):
147 147 todos = Session().query(ChangesetComment) \
148 148 .filter(ChangesetComment.repo == repo) \
149 149 .filter(ChangesetComment.resolved_by == None) \
150 150 .filter(ChangesetComment.comment_type
151 151 == ChangesetComment.COMMENT_TYPE_TODO)
152 152 todos = todos.all()
153 153
154 154 return todos
155 155
156 156 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
157 157
158 158 todos = Session().query(ChangesetComment) \
159 159 .filter(ChangesetComment.pull_request == pull_request) \
160 160 .filter(ChangesetComment.resolved_by == None) \
161 161 .filter(ChangesetComment.comment_type
162 162 == ChangesetComment.COMMENT_TYPE_TODO)
163 163
164 164 if not show_outdated:
165 165 todos = todos.filter(
166 166 coalesce(ChangesetComment.display_state, '') !=
167 167 ChangesetComment.COMMENT_OUTDATED)
168 168
169 169 todos = todos.all()
170 170
171 171 return todos
172 172
173 173 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True):
174 174
175 175 todos = Session().query(ChangesetComment) \
176 176 .filter(ChangesetComment.pull_request == pull_request) \
177 177 .filter(ChangesetComment.resolved_by != None) \
178 178 .filter(ChangesetComment.comment_type
179 179 == ChangesetComment.COMMENT_TYPE_TODO)
180 180
181 181 if not show_outdated:
182 182 todos = todos.filter(
183 183 coalesce(ChangesetComment.display_state, '') !=
184 184 ChangesetComment.COMMENT_OUTDATED)
185 185
186 186 todos = todos.all()
187 187
188 188 return todos
189 189
190 190 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
191 191
192 192 todos = Session().query(ChangesetComment) \
193 193 .filter(ChangesetComment.revision == commit_id) \
194 194 .filter(ChangesetComment.resolved_by == None) \
195 195 .filter(ChangesetComment.comment_type
196 196 == ChangesetComment.COMMENT_TYPE_TODO)
197 197
198 198 if not show_outdated:
199 199 todos = todos.filter(
200 200 coalesce(ChangesetComment.display_state, '') !=
201 201 ChangesetComment.COMMENT_OUTDATED)
202 202
203 203 todos = todos.all()
204 204
205 205 return todos
206 206
207 207 def get_commit_resolved_todos(self, commit_id, show_outdated=True):
208 208
209 209 todos = Session().query(ChangesetComment) \
210 210 .filter(ChangesetComment.revision == commit_id) \
211 211 .filter(ChangesetComment.resolved_by != None) \
212 212 .filter(ChangesetComment.comment_type
213 213 == ChangesetComment.COMMENT_TYPE_TODO)
214 214
215 215 if not show_outdated:
216 216 todos = todos.filter(
217 217 coalesce(ChangesetComment.display_state, '') !=
218 218 ChangesetComment.COMMENT_OUTDATED)
219 219
220 220 todos = todos.all()
221 221
222 222 return todos
223 223
224 224 def _log_audit_action(self, action, action_data, auth_user, comment):
225 225 audit_logger.store(
226 226 action=action,
227 227 action_data=action_data,
228 228 user=auth_user,
229 229 repo=comment.repo)
230 230
231 231 def create(self, text, repo, user, commit_id=None, pull_request=None,
232 232 f_path=None, line_no=None, status_change=None,
233 233 status_change_type=None, comment_type=None,
234 234 resolves_comment_id=None, closing_pr=False, send_email=True,
235 235 renderer=None, auth_user=None):
236 236 """
237 237 Creates new comment for commit or pull request.
238 238 IF status_change is not none this comment is associated with a
239 239 status change of commit or commit associated with pull request
240 240
241 241 :param text:
242 242 :param repo:
243 243 :param user:
244 244 :param commit_id:
245 245 :param pull_request:
246 246 :param f_path:
247 247 :param line_no:
248 248 :param status_change: Label for status change
249 249 :param comment_type: Type of comment
250 250 :param status_change_type: type of status change
251 251 :param closing_pr:
252 252 :param send_email:
253 253 :param renderer: pick renderer for this comment
254 254 """
255 255
256 256 if not text:
257 257 log.warning('Missing text for comment, skipping...')
258 258 return
259 259 request = get_current_request()
260 260 _ = request.translate
261 261
262 262 if not renderer:
263 263 renderer = self._get_renderer(request=request)
264 264
265 265 repo = self._get_repo(repo)
266 266 user = self._get_user(user)
267 267 auth_user = auth_user or user
268 268
269 269 schema = comment_schema.CommentSchema()
270 270 validated_kwargs = schema.deserialize(dict(
271 271 comment_body=text,
272 272 comment_type=comment_type,
273 273 comment_file=f_path,
274 274 comment_line=line_no,
275 275 renderer_type=renderer,
276 276 status_change=status_change_type,
277 277 resolves_comment_id=resolves_comment_id,
278 278 repo=repo.repo_id,
279 279 user=user.user_id,
280 280 ))
281 281
282 282 comment = ChangesetComment()
283 283 comment.renderer = validated_kwargs['renderer_type']
284 284 comment.text = validated_kwargs['comment_body']
285 285 comment.f_path = validated_kwargs['comment_file']
286 286 comment.line_no = validated_kwargs['comment_line']
287 287 comment.comment_type = validated_kwargs['comment_type']
288 288
289 289 comment.repo = repo
290 290 comment.author = user
291 291 resolved_comment = self.__get_commit_comment(
292 292 validated_kwargs['resolves_comment_id'])
293 293 # check if the comment actually belongs to this PR
294 294 if resolved_comment and resolved_comment.pull_request and \
295 295 resolved_comment.pull_request != pull_request:
296 296 log.warning('Comment tried to resolved unrelated todo comment: %s',
297 297 resolved_comment)
298 298 # comment not bound to this pull request, forbid
299 299 resolved_comment = None
300 300
301 301 elif resolved_comment and resolved_comment.repo and \
302 302 resolved_comment.repo != repo:
303 303 log.warning('Comment tried to resolved unrelated todo comment: %s',
304 304 resolved_comment)
305 305 # comment not bound to this repo, forbid
306 306 resolved_comment = None
307 307
308 308 comment.resolved_comment = resolved_comment
309 309
310 310 pull_request_id = pull_request
311 311
312 312 commit_obj = None
313 313 pull_request_obj = None
314 314
315 315 if commit_id:
316 316 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
317 317 # do a lookup, so we don't pass something bad here
318 318 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
319 319 comment.revision = commit_obj.raw_id
320 320
321 321 elif pull_request_id:
322 322 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
323 323 pull_request_obj = self.__get_pull_request(pull_request_id)
324 324 comment.pull_request = pull_request_obj
325 325 else:
326 326 raise Exception('Please specify commit or pull_request_id')
327 327
328 328 Session().add(comment)
329 329 Session().flush()
330 330 kwargs = {
331 331 'user': user,
332 332 'renderer_type': renderer,
333 333 'repo_name': repo.repo_name,
334 334 'status_change': status_change,
335 335 'status_change_type': status_change_type,
336 336 'comment_body': text,
337 337 'comment_file': f_path,
338 338 'comment_line': line_no,
339 339 'comment_type': comment_type or 'note'
340 340 }
341 341
342 342 if commit_obj:
343 343 recipients = ChangesetComment.get_users(
344 344 revision=commit_obj.raw_id)
345 345 # add commit author if it's in RhodeCode system
346 346 cs_author = User.get_from_cs_author(commit_obj.author)
347 347 if not cs_author:
348 348 # use repo owner if we cannot extract the author correctly
349 349 cs_author = repo.user
350 350 recipients += [cs_author]
351 351
352 352 commit_comment_url = self.get_url(comment, request=request)
353 353
354 354 target_repo_url = h.link_to(
355 355 repo.repo_name,
356 356 h.route_url('repo_summary', repo_name=repo.repo_name))
357 357
358 358 # commit specifics
359 359 kwargs.update({
360 360 'commit': commit_obj,
361 361 'commit_message': commit_obj.message,
362 'commit_target_repo': target_repo_url,
362 'commit_target_repo_url': target_repo_url,
363 363 'commit_comment_url': commit_comment_url,
364 364 })
365 365
366 366 elif pull_request_obj:
367 367 # get the current participants of this pull request
368 368 recipients = ChangesetComment.get_users(
369 369 pull_request_id=pull_request_obj.pull_request_id)
370 370 # add pull request author
371 371 recipients += [pull_request_obj.author]
372 372
373 373 # add the reviewers to notification
374 374 recipients += [x.user for x in pull_request_obj.reviewers]
375 375
376 376 pr_target_repo = pull_request_obj.target_repo
377 377 pr_source_repo = pull_request_obj.source_repo
378 378
379 379 pr_comment_url = h.route_url(
380 380 'pullrequest_show',
381 381 repo_name=pr_target_repo.repo_name,
382 382 pull_request_id=pull_request_obj.pull_request_id,
383 383 _anchor='comment-%s' % comment.comment_id)
384 384
385 pr_url = h.route_url(
386 'pullrequest_show',
387 repo_name=pr_target_repo.repo_name,
388 pull_request_id=pull_request_obj.pull_request_id, )
389
385 390 # set some variables for email notification
386 391 pr_target_repo_url = h.route_url(
387 392 'repo_summary', repo_name=pr_target_repo.repo_name)
388 393
389 394 pr_source_repo_url = h.route_url(
390 395 'repo_summary', repo_name=pr_source_repo.repo_name)
391 396
392 397 # pull request specifics
393 398 kwargs.update({
394 399 'pull_request': pull_request_obj,
395 400 'pr_id': pull_request_obj.pull_request_id,
396 'pr_target_repo': pr_target_repo,
397 'pr_target_repo_url': pr_target_repo_url,
398 'pr_source_repo': pr_source_repo,
399 'pr_source_repo_url': pr_source_repo_url,
401 'pull_request_url': pr_url,
402 'pull_request_target_repo': pr_target_repo,
403 'pull_request_target_repo_url': pr_target_repo_url,
404 'pull_request_source_repo': pr_source_repo,
405 'pull_request_source_repo_url': pr_source_repo_url,
400 406 'pr_comment_url': pr_comment_url,
401 407 'pr_closing': closing_pr,
402 408 })
403 409 if send_email:
404 410 # pre-generate the subject for notification itself
405 411 (subject,
406 412 _h, _e, # we don't care about those
407 413 body_plaintext) = EmailNotificationModel().render_email(
408 414 notification_type, **kwargs)
409 415
410 416 mention_recipients = set(
411 417 self._extract_mentions(text)).difference(recipients)
412 418
413 419 # create notification objects, and emails
414 420 NotificationModel().create(
415 421 created_by=user,
416 422 notification_subject=subject,
417 423 notification_body=body_plaintext,
418 424 notification_type=notification_type,
419 425 recipients=recipients,
420 426 mention_recipients=mention_recipients,
421 427 email_kwargs=kwargs,
422 428 )
423 429
424 430 Session().flush()
425 431 if comment.pull_request:
426 432 action = 'repo.pull_request.comment.create'
427 433 else:
428 434 action = 'repo.commit.comment.create'
429 435
430 436 comment_data = comment.get_api_data()
431 437 self._log_audit_action(
432 438 action, {'data': comment_data}, auth_user, comment)
433 439
434 440 msg_url = ''
435 441 channel = None
436 442 if commit_obj:
437 443 msg_url = commit_comment_url
438 444 repo_name = repo.repo_name
439 445 channel = u'/repo${}$/commit/{}'.format(
440 446 repo_name,
441 447 commit_obj.raw_id
442 448 )
443 449 elif pull_request_obj:
444 450 msg_url = pr_comment_url
445 451 repo_name = pr_target_repo.repo_name
446 452 channel = u'/repo${}$/pr/{}'.format(
447 453 repo_name,
448 454 pull_request_id
449 455 )
450 456
451 457 message = '<strong>{}</strong> {} - ' \
452 458 '<a onclick="window.location=\'{}\';' \
453 459 'window.location.reload()">' \
454 460 '<strong>{}</strong></a>'
455 461 message = message.format(
456 462 user.username, _('made a comment'), msg_url,
457 463 _('Show it now'))
458 464
459 465 channelstream.post_message(
460 466 channel, message, user.username,
461 467 registry=get_current_registry())
462 468
463 469 return comment
464 470
465 471 def delete(self, comment, auth_user):
466 472 """
467 473 Deletes given comment
468 474 """
469 475 comment = self.__get_commit_comment(comment)
470 476 old_data = comment.get_api_data()
471 477 Session().delete(comment)
472 478
473 479 if comment.pull_request:
474 480 action = 'repo.pull_request.comment.delete'
475 481 else:
476 482 action = 'repo.commit.comment.delete'
477 483
478 484 self._log_audit_action(
479 485 action, {'old_data': old_data}, auth_user, comment)
480 486
481 487 return comment
482 488
483 489 def get_all_comments(self, repo_id, revision=None, pull_request=None):
484 490 q = ChangesetComment.query()\
485 491 .filter(ChangesetComment.repo_id == repo_id)
486 492 if revision:
487 493 q = q.filter(ChangesetComment.revision == revision)
488 494 elif pull_request:
489 495 pull_request = self.__get_pull_request(pull_request)
490 496 q = q.filter(ChangesetComment.pull_request == pull_request)
491 497 else:
492 498 raise Exception('Please specify commit or pull_request')
493 499 q = q.order_by(ChangesetComment.created_on)
494 500 return q.all()
495 501
496 502 def get_url(self, comment, request=None, permalink=False):
497 503 if not request:
498 504 request = get_current_request()
499 505
500 506 comment = self.__get_commit_comment(comment)
501 507 if comment.pull_request:
502 508 pull_request = comment.pull_request
503 509 if permalink:
504 510 return request.route_url(
505 511 'pull_requests_global',
506 512 pull_request_id=pull_request.pull_request_id,
507 513 _anchor='comment-%s' % comment.comment_id)
508 514 else:
509 515 return request.route_url(
510 516 'pullrequest_show',
511 517 repo_name=safe_str(pull_request.target_repo.repo_name),
512 518 pull_request_id=pull_request.pull_request_id,
513 519 _anchor='comment-%s' % comment.comment_id)
514 520
515 521 else:
516 522 repo = comment.repo
517 523 commit_id = comment.revision
518 524
519 525 if permalink:
520 526 return request.route_url(
521 527 'repo_commit', repo_name=safe_str(repo.repo_id),
522 528 commit_id=commit_id,
523 529 _anchor='comment-%s' % comment.comment_id)
524 530
525 531 else:
526 532 return request.route_url(
527 533 'repo_commit', repo_name=safe_str(repo.repo_name),
528 534 commit_id=commit_id,
529 535 _anchor='comment-%s' % comment.comment_id)
530 536
531 537 def get_comments(self, repo_id, revision=None, pull_request=None):
532 538 """
533 539 Gets main comments based on revision or pull_request_id
534 540
535 541 :param repo_id:
536 542 :param revision:
537 543 :param pull_request:
538 544 """
539 545
540 546 q = ChangesetComment.query()\
541 547 .filter(ChangesetComment.repo_id == repo_id)\
542 548 .filter(ChangesetComment.line_no == None)\
543 549 .filter(ChangesetComment.f_path == None)
544 550 if revision:
545 551 q = q.filter(ChangesetComment.revision == revision)
546 552 elif pull_request:
547 553 pull_request = self.__get_pull_request(pull_request)
548 554 q = q.filter(ChangesetComment.pull_request == pull_request)
549 555 else:
550 556 raise Exception('Please specify commit or pull_request')
551 557 q = q.order_by(ChangesetComment.created_on)
552 558 return q.all()
553 559
554 560 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
555 561 q = self._get_inline_comments_query(repo_id, revision, pull_request)
556 562 return self._group_comments_by_path_and_line_number(q)
557 563
558 564 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
559 565 version=None):
560 566 inline_cnt = 0
561 567 for fname, per_line_comments in inline_comments.iteritems():
562 568 for lno, comments in per_line_comments.iteritems():
563 569 for comm in comments:
564 570 if not comm.outdated_at_version(version) and skip_outdated:
565 571 inline_cnt += 1
566 572
567 573 return inline_cnt
568 574
569 575 def get_outdated_comments(self, repo_id, pull_request):
570 576 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
571 577 # of a pull request.
572 578 q = self._all_inline_comments_of_pull_request(pull_request)
573 579 q = q.filter(
574 580 ChangesetComment.display_state ==
575 581 ChangesetComment.COMMENT_OUTDATED
576 582 ).order_by(ChangesetComment.comment_id.asc())
577 583
578 584 return self._group_comments_by_path_and_line_number(q)
579 585
580 586 def _get_inline_comments_query(self, repo_id, revision, pull_request):
581 587 # TODO: johbo: Split this into two methods: One for PR and one for
582 588 # commit.
583 589 if revision:
584 590 q = Session().query(ChangesetComment).filter(
585 591 ChangesetComment.repo_id == repo_id,
586 592 ChangesetComment.line_no != null(),
587 593 ChangesetComment.f_path != null(),
588 594 ChangesetComment.revision == revision)
589 595
590 596 elif pull_request:
591 597 pull_request = self.__get_pull_request(pull_request)
592 598 if not CommentsModel.use_outdated_comments(pull_request):
593 599 q = self._visible_inline_comments_of_pull_request(pull_request)
594 600 else:
595 601 q = self._all_inline_comments_of_pull_request(pull_request)
596 602
597 603 else:
598 604 raise Exception('Please specify commit or pull_request_id')
599 605 q = q.order_by(ChangesetComment.comment_id.asc())
600 606 return q
601 607
602 608 def _group_comments_by_path_and_line_number(self, q):
603 609 comments = q.all()
604 610 paths = collections.defaultdict(lambda: collections.defaultdict(list))
605 611 for co in comments:
606 612 paths[co.f_path][co.line_no].append(co)
607 613 return paths
608 614
609 615 @classmethod
610 616 def needed_extra_diff_context(cls):
611 617 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
612 618
613 619 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
614 620 if not CommentsModel.use_outdated_comments(pull_request):
615 621 return
616 622
617 623 comments = self._visible_inline_comments_of_pull_request(pull_request)
618 624 comments_to_outdate = comments.all()
619 625
620 626 for comment in comments_to_outdate:
621 627 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
622 628
623 629 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
624 630 diff_line = _parse_comment_line_number(comment.line_no)
625 631
626 632 try:
627 633 old_context = old_diff_proc.get_context_of_line(
628 634 path=comment.f_path, diff_line=diff_line)
629 635 new_context = new_diff_proc.get_context_of_line(
630 636 path=comment.f_path, diff_line=diff_line)
631 637 except (diffs.LineNotInDiffException,
632 638 diffs.FileNotInDiffException):
633 639 comment.display_state = ChangesetComment.COMMENT_OUTDATED
634 640 return
635 641
636 642 if old_context == new_context:
637 643 return
638 644
639 645 if self._should_relocate_diff_line(diff_line):
640 646 new_diff_lines = new_diff_proc.find_context(
641 647 path=comment.f_path, context=old_context,
642 648 offset=self.DIFF_CONTEXT_BEFORE)
643 649 if not new_diff_lines:
644 650 comment.display_state = ChangesetComment.COMMENT_OUTDATED
645 651 else:
646 652 new_diff_line = self._choose_closest_diff_line(
647 653 diff_line, new_diff_lines)
648 654 comment.line_no = _diff_to_comment_line_number(new_diff_line)
649 655 else:
650 656 comment.display_state = ChangesetComment.COMMENT_OUTDATED
651 657
652 658 def _should_relocate_diff_line(self, diff_line):
653 659 """
654 660 Checks if relocation shall be tried for the given `diff_line`.
655 661
656 662 If a comment points into the first lines, then we can have a situation
657 663 that after an update another line has been added on top. In this case
658 664 we would find the context still and move the comment around. This
659 665 would be wrong.
660 666 """
661 667 should_relocate = (
662 668 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
663 669 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
664 670 return should_relocate
665 671
666 672 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
667 673 candidate = new_diff_lines[0]
668 674 best_delta = _diff_line_delta(diff_line, candidate)
669 675 for new_diff_line in new_diff_lines[1:]:
670 676 delta = _diff_line_delta(diff_line, new_diff_line)
671 677 if delta < best_delta:
672 678 candidate = new_diff_line
673 679 best_delta = delta
674 680 return candidate
675 681
676 682 def _visible_inline_comments_of_pull_request(self, pull_request):
677 683 comments = self._all_inline_comments_of_pull_request(pull_request)
678 684 comments = comments.filter(
679 685 coalesce(ChangesetComment.display_state, '') !=
680 686 ChangesetComment.COMMENT_OUTDATED)
681 687 return comments
682 688
683 689 def _all_inline_comments_of_pull_request(self, pull_request):
684 690 comments = Session().query(ChangesetComment)\
685 691 .filter(ChangesetComment.line_no != None)\
686 692 .filter(ChangesetComment.f_path != None)\
687 693 .filter(ChangesetComment.pull_request == pull_request)
688 694 return comments
689 695
690 696 def _all_general_comments_of_pull_request(self, pull_request):
691 697 comments = Session().query(ChangesetComment)\
692 698 .filter(ChangesetComment.line_no == None)\
693 699 .filter(ChangesetComment.f_path == None)\
694 700 .filter(ChangesetComment.pull_request == pull_request)
695 701 return comments
696 702
697 703 @staticmethod
698 704 def use_outdated_comments(pull_request):
699 705 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
700 706 settings = settings_model.get_general_settings()
701 707 return settings.get('rhodecode_use_outdated_comments', False)
702 708
703 709
704 710 def _parse_comment_line_number(line_no):
705 711 """
706 712 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
707 713 """
708 714 old_line = None
709 715 new_line = None
710 716 if line_no.startswith('o'):
711 717 old_line = int(line_no[1:])
712 718 elif line_no.startswith('n'):
713 719 new_line = int(line_no[1:])
714 720 else:
715 721 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
716 722 return diffs.DiffLineNumber(old_line, new_line)
717 723
718 724
719 725 def _diff_to_comment_line_number(diff_line):
720 726 if diff_line.new is not None:
721 727 return u'n{}'.format(diff_line.new)
722 728 elif diff_line.old is not None:
723 729 return u'o{}'.format(diff_line.old)
724 730 return u''
725 731
726 732
727 733 def _diff_line_delta(a, b):
728 734 if None not in (a.new, b.new):
729 735 return abs(a.new - b.new)
730 736 elif None not in (a.old, b.old):
731 737 return abs(a.old - b.old)
732 738 else:
733 739 raise ValueError(
734 740 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,1744 +1,1744 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31 import collections
32 32
33 33 from pyramid import compat
34 34 from pyramid.threadlocal import get_current_request
35 35
36 36 from rhodecode import events
37 37 from rhodecode.translation import lazy_ugettext
38 38 from rhodecode.lib import helpers as h, hooks_utils, diffs
39 39 from rhodecode.lib import audit_logger
40 40 from rhodecode.lib.compat import OrderedDict
41 41 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
42 42 from rhodecode.lib.markup_renderer import (
43 43 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
44 44 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
45 45 from rhodecode.lib.vcs.backends.base import (
46 46 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
47 47 from rhodecode.lib.vcs.conf import settings as vcs_settings
48 48 from rhodecode.lib.vcs.exceptions import (
49 49 CommitDoesNotExistError, EmptyRepositoryError)
50 50 from rhodecode.model import BaseModel
51 51 from rhodecode.model.changeset_status import ChangesetStatusModel
52 52 from rhodecode.model.comment import CommentsModel
53 53 from rhodecode.model.db import (
54 54 or_, PullRequest, PullRequestReviewers, ChangesetStatus,
55 55 PullRequestVersion, ChangesetComment, Repository, RepoReviewRule)
56 56 from rhodecode.model.meta import Session
57 57 from rhodecode.model.notification import NotificationModel, \
58 58 EmailNotificationModel
59 59 from rhodecode.model.scm import ScmModel
60 60 from rhodecode.model.settings import VcsSettingsModel
61 61
62 62
63 63 log = logging.getLogger(__name__)
64 64
65 65
66 66 # Data structure to hold the response data when updating commits during a pull
67 67 # request update.
68 68 UpdateResponse = collections.namedtuple('UpdateResponse', [
69 69 'executed', 'reason', 'new', 'old', 'changes',
70 70 'source_changed', 'target_changed'])
71 71
72 72
73 73 class PullRequestModel(BaseModel):
74 74
75 75 cls = PullRequest
76 76
77 77 DIFF_CONTEXT = diffs.DEFAULT_CONTEXT
78 78
79 79 UPDATE_STATUS_MESSAGES = {
80 80 UpdateFailureReason.NONE: lazy_ugettext(
81 81 'Pull request update successful.'),
82 82 UpdateFailureReason.UNKNOWN: lazy_ugettext(
83 83 'Pull request update failed because of an unknown error.'),
84 84 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
85 85 'No update needed because the source and target have not changed.'),
86 86 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
87 87 'Pull request cannot be updated because the reference type is '
88 88 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
89 89 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
90 90 'This pull request cannot be updated because the target '
91 91 'reference is missing.'),
92 92 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
93 93 'This pull request cannot be updated because the source '
94 94 'reference is missing.'),
95 95 }
96 96 REF_TYPES = ['bookmark', 'book', 'tag', 'branch']
97 97 UPDATABLE_REF_TYPES = ['bookmark', 'book', 'branch']
98 98
99 99 def __get_pull_request(self, pull_request):
100 100 return self._get_instance((
101 101 PullRequest, PullRequestVersion), pull_request)
102 102
103 103 def _check_perms(self, perms, pull_request, user, api=False):
104 104 if not api:
105 105 return h.HasRepoPermissionAny(*perms)(
106 106 user=user, repo_name=pull_request.target_repo.repo_name)
107 107 else:
108 108 return h.HasRepoPermissionAnyApi(*perms)(
109 109 user=user, repo_name=pull_request.target_repo.repo_name)
110 110
111 111 def check_user_read(self, pull_request, user, api=False):
112 112 _perms = ('repository.admin', 'repository.write', 'repository.read',)
113 113 return self._check_perms(_perms, pull_request, user, api)
114 114
115 115 def check_user_merge(self, pull_request, user, api=False):
116 116 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
117 117 return self._check_perms(_perms, pull_request, user, api)
118 118
119 119 def check_user_update(self, pull_request, user, api=False):
120 120 owner = user.user_id == pull_request.user_id
121 121 return self.check_user_merge(pull_request, user, api) or owner
122 122
123 123 def check_user_delete(self, pull_request, user):
124 124 owner = user.user_id == pull_request.user_id
125 125 _perms = ('repository.admin',)
126 126 return self._check_perms(_perms, pull_request, user) or owner
127 127
128 128 def check_user_change_status(self, pull_request, user, api=False):
129 129 reviewer = user.user_id in [x.user_id for x in
130 130 pull_request.reviewers]
131 131 return self.check_user_update(pull_request, user, api) or reviewer
132 132
133 133 def check_user_comment(self, pull_request, user):
134 134 owner = user.user_id == pull_request.user_id
135 135 return self.check_user_read(pull_request, user) or owner
136 136
137 137 def get(self, pull_request):
138 138 return self.__get_pull_request(pull_request)
139 139
140 140 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
141 141 opened_by=None, order_by=None,
142 142 order_dir='desc', only_created=False):
143 143 repo = None
144 144 if repo_name:
145 145 repo = self._get_repo(repo_name)
146 146
147 147 q = PullRequest.query()
148 148
149 149 # source or target
150 150 if repo and source:
151 151 q = q.filter(PullRequest.source_repo == repo)
152 152 elif repo:
153 153 q = q.filter(PullRequest.target_repo == repo)
154 154
155 155 # closed,opened
156 156 if statuses:
157 157 q = q.filter(PullRequest.status.in_(statuses))
158 158
159 159 # opened by filter
160 160 if opened_by:
161 161 q = q.filter(PullRequest.user_id.in_(opened_by))
162 162
163 163 # only get those that are in "created" state
164 164 if only_created:
165 165 q = q.filter(PullRequest.pull_request_state == PullRequest.STATE_CREATED)
166 166
167 167 if order_by:
168 168 order_map = {
169 169 'name_raw': PullRequest.pull_request_id,
170 170 'id': PullRequest.pull_request_id,
171 171 'title': PullRequest.title,
172 172 'updated_on_raw': PullRequest.updated_on,
173 173 'target_repo': PullRequest.target_repo_id
174 174 }
175 175 if order_dir == 'asc':
176 176 q = q.order_by(order_map[order_by].asc())
177 177 else:
178 178 q = q.order_by(order_map[order_by].desc())
179 179
180 180 return q
181 181
182 182 def count_all(self, repo_name, source=False, statuses=None,
183 183 opened_by=None):
184 184 """
185 185 Count the number of pull requests for a specific repository.
186 186
187 187 :param repo_name: target or source repo
188 188 :param source: boolean flag to specify if repo_name refers to source
189 189 :param statuses: list of pull request statuses
190 190 :param opened_by: author user of the pull request
191 191 :returns: int number of pull requests
192 192 """
193 193 q = self._prepare_get_all_query(
194 194 repo_name, source=source, statuses=statuses, opened_by=opened_by)
195 195
196 196 return q.count()
197 197
198 198 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
199 199 offset=0, length=None, order_by=None, order_dir='desc'):
200 200 """
201 201 Get all pull requests for a specific repository.
202 202
203 203 :param repo_name: target or source repo
204 204 :param source: boolean flag to specify if repo_name refers to source
205 205 :param statuses: list of pull request statuses
206 206 :param opened_by: author user of the pull request
207 207 :param offset: pagination offset
208 208 :param length: length of returned list
209 209 :param order_by: order of the returned list
210 210 :param order_dir: 'asc' or 'desc' ordering direction
211 211 :returns: list of pull requests
212 212 """
213 213 q = self._prepare_get_all_query(
214 214 repo_name, source=source, statuses=statuses, opened_by=opened_by,
215 215 order_by=order_by, order_dir=order_dir)
216 216
217 217 if length:
218 218 pull_requests = q.limit(length).offset(offset).all()
219 219 else:
220 220 pull_requests = q.all()
221 221
222 222 return pull_requests
223 223
224 224 def count_awaiting_review(self, repo_name, source=False, statuses=None,
225 225 opened_by=None):
226 226 """
227 227 Count the number of pull requests for a specific repository that are
228 228 awaiting review.
229 229
230 230 :param repo_name: target or source repo
231 231 :param source: boolean flag to specify if repo_name refers to source
232 232 :param statuses: list of pull request statuses
233 233 :param opened_by: author user of the pull request
234 234 :returns: int number of pull requests
235 235 """
236 236 pull_requests = self.get_awaiting_review(
237 237 repo_name, source=source, statuses=statuses, opened_by=opened_by)
238 238
239 239 return len(pull_requests)
240 240
241 241 def get_awaiting_review(self, repo_name, source=False, statuses=None,
242 242 opened_by=None, offset=0, length=None,
243 243 order_by=None, order_dir='desc'):
244 244 """
245 245 Get all pull requests for a specific repository that are awaiting
246 246 review.
247 247
248 248 :param repo_name: target or source repo
249 249 :param source: boolean flag to specify if repo_name refers to source
250 250 :param statuses: list of pull request statuses
251 251 :param opened_by: author user of the pull request
252 252 :param offset: pagination offset
253 253 :param length: length of returned list
254 254 :param order_by: order of the returned list
255 255 :param order_dir: 'asc' or 'desc' ordering direction
256 256 :returns: list of pull requests
257 257 """
258 258 pull_requests = self.get_all(
259 259 repo_name, source=source, statuses=statuses, opened_by=opened_by,
260 260 order_by=order_by, order_dir=order_dir)
261 261
262 262 _filtered_pull_requests = []
263 263 for pr in pull_requests:
264 264 status = pr.calculated_review_status()
265 265 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
266 266 ChangesetStatus.STATUS_UNDER_REVIEW]:
267 267 _filtered_pull_requests.append(pr)
268 268 if length:
269 269 return _filtered_pull_requests[offset:offset+length]
270 270 else:
271 271 return _filtered_pull_requests
272 272
273 273 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
274 274 opened_by=None, user_id=None):
275 275 """
276 276 Count the number of pull requests for a specific repository that are
277 277 awaiting review from a specific user.
278 278
279 279 :param repo_name: target or source repo
280 280 :param source: boolean flag to specify if repo_name refers to source
281 281 :param statuses: list of pull request statuses
282 282 :param opened_by: author user of the pull request
283 283 :param user_id: reviewer user of the pull request
284 284 :returns: int number of pull requests
285 285 """
286 286 pull_requests = self.get_awaiting_my_review(
287 287 repo_name, source=source, statuses=statuses, opened_by=opened_by,
288 288 user_id=user_id)
289 289
290 290 return len(pull_requests)
291 291
292 292 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
293 293 opened_by=None, user_id=None, offset=0,
294 294 length=None, order_by=None, order_dir='desc'):
295 295 """
296 296 Get all pull requests for a specific repository that are awaiting
297 297 review from a specific user.
298 298
299 299 :param repo_name: target or source repo
300 300 :param source: boolean flag to specify if repo_name refers to source
301 301 :param statuses: list of pull request statuses
302 302 :param opened_by: author user of the pull request
303 303 :param user_id: reviewer user of the pull request
304 304 :param offset: pagination offset
305 305 :param length: length of returned list
306 306 :param order_by: order of the returned list
307 307 :param order_dir: 'asc' or 'desc' ordering direction
308 308 :returns: list of pull requests
309 309 """
310 310 pull_requests = self.get_all(
311 311 repo_name, source=source, statuses=statuses, opened_by=opened_by,
312 312 order_by=order_by, order_dir=order_dir)
313 313
314 314 _my = PullRequestModel().get_not_reviewed(user_id)
315 315 my_participation = []
316 316 for pr in pull_requests:
317 317 if pr in _my:
318 318 my_participation.append(pr)
319 319 _filtered_pull_requests = my_participation
320 320 if length:
321 321 return _filtered_pull_requests[offset:offset+length]
322 322 else:
323 323 return _filtered_pull_requests
324 324
325 325 def get_not_reviewed(self, user_id):
326 326 return [
327 327 x.pull_request for x in PullRequestReviewers.query().filter(
328 328 PullRequestReviewers.user_id == user_id).all()
329 329 ]
330 330
331 331 def _prepare_participating_query(self, user_id=None, statuses=None,
332 332 order_by=None, order_dir='desc'):
333 333 q = PullRequest.query()
334 334 if user_id:
335 335 reviewers_subquery = Session().query(
336 336 PullRequestReviewers.pull_request_id).filter(
337 337 PullRequestReviewers.user_id == user_id).subquery()
338 338 user_filter = or_(
339 339 PullRequest.user_id == user_id,
340 340 PullRequest.pull_request_id.in_(reviewers_subquery)
341 341 )
342 342 q = PullRequest.query().filter(user_filter)
343 343
344 344 # closed,opened
345 345 if statuses:
346 346 q = q.filter(PullRequest.status.in_(statuses))
347 347
348 348 if order_by:
349 349 order_map = {
350 350 'name_raw': PullRequest.pull_request_id,
351 351 'title': PullRequest.title,
352 352 'updated_on_raw': PullRequest.updated_on,
353 353 'target_repo': PullRequest.target_repo_id
354 354 }
355 355 if order_dir == 'asc':
356 356 q = q.order_by(order_map[order_by].asc())
357 357 else:
358 358 q = q.order_by(order_map[order_by].desc())
359 359
360 360 return q
361 361
362 362 def count_im_participating_in(self, user_id=None, statuses=None):
363 363 q = self._prepare_participating_query(user_id, statuses=statuses)
364 364 return q.count()
365 365
366 366 def get_im_participating_in(
367 367 self, user_id=None, statuses=None, offset=0,
368 368 length=None, order_by=None, order_dir='desc'):
369 369 """
370 370 Get all Pull requests that i'm participating in, or i have opened
371 371 """
372 372
373 373 q = self._prepare_participating_query(
374 374 user_id, statuses=statuses, order_by=order_by,
375 375 order_dir=order_dir)
376 376
377 377 if length:
378 378 pull_requests = q.limit(length).offset(offset).all()
379 379 else:
380 380 pull_requests = q.all()
381 381
382 382 return pull_requests
383 383
384 384 def get_versions(self, pull_request):
385 385 """
386 386 returns version of pull request sorted by ID descending
387 387 """
388 388 return PullRequestVersion.query()\
389 389 .filter(PullRequestVersion.pull_request == pull_request)\
390 390 .order_by(PullRequestVersion.pull_request_version_id.asc())\
391 391 .all()
392 392
393 393 def get_pr_version(self, pull_request_id, version=None):
394 394 at_version = None
395 395
396 396 if version and version == 'latest':
397 397 pull_request_ver = PullRequest.get(pull_request_id)
398 398 pull_request_obj = pull_request_ver
399 399 _org_pull_request_obj = pull_request_obj
400 400 at_version = 'latest'
401 401 elif version:
402 402 pull_request_ver = PullRequestVersion.get_or_404(version)
403 403 pull_request_obj = pull_request_ver
404 404 _org_pull_request_obj = pull_request_ver.pull_request
405 405 at_version = pull_request_ver.pull_request_version_id
406 406 else:
407 407 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
408 408 pull_request_id)
409 409
410 410 pull_request_display_obj = PullRequest.get_pr_display_object(
411 411 pull_request_obj, _org_pull_request_obj)
412 412
413 413 return _org_pull_request_obj, pull_request_obj, \
414 414 pull_request_display_obj, at_version
415 415
416 416 def create(self, created_by, source_repo, source_ref, target_repo,
417 417 target_ref, revisions, reviewers, title, description=None,
418 418 description_renderer=None,
419 419 reviewer_data=None, translator=None, auth_user=None):
420 420 translator = translator or get_current_request().translate
421 421
422 422 created_by_user = self._get_user(created_by)
423 423 auth_user = auth_user or created_by_user.AuthUser()
424 424 source_repo = self._get_repo(source_repo)
425 425 target_repo = self._get_repo(target_repo)
426 426
427 427 pull_request = PullRequest()
428 428 pull_request.source_repo = source_repo
429 429 pull_request.source_ref = source_ref
430 430 pull_request.target_repo = target_repo
431 431 pull_request.target_ref = target_ref
432 432 pull_request.revisions = revisions
433 433 pull_request.title = title
434 434 pull_request.description = description
435 435 pull_request.description_renderer = description_renderer
436 436 pull_request.author = created_by_user
437 437 pull_request.reviewer_data = reviewer_data
438 438 pull_request.pull_request_state = pull_request.STATE_CREATING
439 439 Session().add(pull_request)
440 440 Session().flush()
441 441
442 442 reviewer_ids = set()
443 443 # members / reviewers
444 444 for reviewer_object in reviewers:
445 445 user_id, reasons, mandatory, rules = reviewer_object
446 446 user = self._get_user(user_id)
447 447
448 448 # skip duplicates
449 449 if user.user_id in reviewer_ids:
450 450 continue
451 451
452 452 reviewer_ids.add(user.user_id)
453 453
454 454 reviewer = PullRequestReviewers()
455 455 reviewer.user = user
456 456 reviewer.pull_request = pull_request
457 457 reviewer.reasons = reasons
458 458 reviewer.mandatory = mandatory
459 459
460 460 # NOTE(marcink): pick only first rule for now
461 461 rule_id = list(rules)[0] if rules else None
462 462 rule = RepoReviewRule.get(rule_id) if rule_id else None
463 463 if rule:
464 464 review_group = rule.user_group_vote_rule(user_id)
465 465 # we check if this particular reviewer is member of a voting group
466 466 if review_group:
467 467 # NOTE(marcink):
468 468 # can be that user is member of more but we pick the first same,
469 469 # same as default reviewers algo
470 470 review_group = review_group[0]
471 471
472 472 rule_data = {
473 473 'rule_name':
474 474 rule.review_rule_name,
475 475 'rule_user_group_entry_id':
476 476 review_group.repo_review_rule_users_group_id,
477 477 'rule_user_group_name':
478 478 review_group.users_group.users_group_name,
479 479 'rule_user_group_members':
480 480 [x.user.username for x in review_group.users_group.members],
481 481 'rule_user_group_members_id':
482 482 [x.user.user_id for x in review_group.users_group.members],
483 483 }
484 484 # e.g {'vote_rule': -1, 'mandatory': True}
485 485 rule_data.update(review_group.rule_data())
486 486
487 487 reviewer.rule_data = rule_data
488 488
489 489 Session().add(reviewer)
490 490 Session().flush()
491 491
492 492 # Set approval status to "Under Review" for all commits which are
493 493 # part of this pull request.
494 494 ChangesetStatusModel().set_status(
495 495 repo=target_repo,
496 496 status=ChangesetStatus.STATUS_UNDER_REVIEW,
497 497 user=created_by_user,
498 498 pull_request=pull_request
499 499 )
500 500 # we commit early at this point. This has to do with a fact
501 501 # that before queries do some row-locking. And because of that
502 502 # we need to commit and finish transaction before below validate call
503 503 # that for large repos could be long resulting in long row locks
504 504 Session().commit()
505 505
506 506 # prepare workspace, and run initial merge simulation. Set state during that
507 507 # operation
508 508 pull_request = PullRequest.get(pull_request.pull_request_id)
509 509
510 510 # set as merging, for merge simulation, and if finished to created so we mark
511 511 # simulation is working fine
512 512 with pull_request.set_state(PullRequest.STATE_MERGING,
513 513 final_state=PullRequest.STATE_CREATED) as state_obj:
514 514 MergeCheck.validate(
515 515 pull_request, auth_user=auth_user, translator=translator)
516 516
517 517 self.notify_reviewers(pull_request, reviewer_ids)
518 518 self.trigger_pull_request_hook(
519 519 pull_request, created_by_user, 'create')
520 520
521 521 creation_data = pull_request.get_api_data(with_merge_state=False)
522 522 self._log_audit_action(
523 523 'repo.pull_request.create', {'data': creation_data},
524 524 auth_user, pull_request)
525 525
526 526 return pull_request
527 527
528 528 def trigger_pull_request_hook(self, pull_request, user, action, data=None):
529 529 pull_request = self.__get_pull_request(pull_request)
530 530 target_scm = pull_request.target_repo.scm_instance()
531 531 if action == 'create':
532 532 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
533 533 elif action == 'merge':
534 534 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
535 535 elif action == 'close':
536 536 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
537 537 elif action == 'review_status_change':
538 538 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
539 539 elif action == 'update':
540 540 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
541 541 elif action == 'comment':
542 542 # dummy hook ! for comment. We want this function to handle all cases
543 543 def trigger_hook(*args, **kwargs):
544 544 pass
545 545 comment = data['comment']
546 546 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
547 547 else:
548 548 return
549 549
550 550 trigger_hook(
551 551 username=user.username,
552 552 repo_name=pull_request.target_repo.repo_name,
553 553 repo_alias=target_scm.alias,
554 554 pull_request=pull_request,
555 555 data=data)
556 556
557 557 def _get_commit_ids(self, pull_request):
558 558 """
559 559 Return the commit ids of the merged pull request.
560 560
561 561 This method is not dealing correctly yet with the lack of autoupdates
562 562 nor with the implicit target updates.
563 563 For example: if a commit in the source repo is already in the target it
564 564 will be reported anyways.
565 565 """
566 566 merge_rev = pull_request.merge_rev
567 567 if merge_rev is None:
568 568 raise ValueError('This pull request was not merged yet')
569 569
570 570 commit_ids = list(pull_request.revisions)
571 571 if merge_rev not in commit_ids:
572 572 commit_ids.append(merge_rev)
573 573
574 574 return commit_ids
575 575
576 576 def merge_repo(self, pull_request, user, extras):
577 577 log.debug("Merging pull request %s", pull_request.pull_request_id)
578 578 extras['user_agent'] = 'internal-merge'
579 579 merge_state = self._merge_pull_request(pull_request, user, extras)
580 580 if merge_state.executed:
581 581 log.debug("Merge was successful, updating the pull request comments.")
582 582 self._comment_and_close_pr(pull_request, user, merge_state)
583 583
584 584 self._log_audit_action(
585 585 'repo.pull_request.merge',
586 586 {'merge_state': merge_state.__dict__},
587 587 user, pull_request)
588 588
589 589 else:
590 590 log.warn("Merge failed, not updating the pull request.")
591 591 return merge_state
592 592
593 593 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
594 594 target_vcs = pull_request.target_repo.scm_instance()
595 595 source_vcs = pull_request.source_repo.scm_instance()
596 596
597 597 message = safe_unicode(merge_msg or vcs_settings.MERGE_MESSAGE_TMPL).format(
598 598 pr_id=pull_request.pull_request_id,
599 599 pr_title=pull_request.title,
600 600 source_repo=source_vcs.name,
601 601 source_ref_name=pull_request.source_ref_parts.name,
602 602 target_repo=target_vcs.name,
603 603 target_ref_name=pull_request.target_ref_parts.name,
604 604 )
605 605
606 606 workspace_id = self._workspace_id(pull_request)
607 607 repo_id = pull_request.target_repo.repo_id
608 608 use_rebase = self._use_rebase_for_merging(pull_request)
609 609 close_branch = self._close_branch_before_merging(pull_request)
610 610
611 611 target_ref = self._refresh_reference(
612 612 pull_request.target_ref_parts, target_vcs)
613 613
614 614 callback_daemon, extras = prepare_callback_daemon(
615 615 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
616 616 host=vcs_settings.HOOKS_HOST,
617 617 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
618 618
619 619 with callback_daemon:
620 620 # TODO: johbo: Implement a clean way to run a config_override
621 621 # for a single call.
622 622 target_vcs.config.set(
623 623 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
624 624
625 625 user_name = user.short_contact
626 626 merge_state = target_vcs.merge(
627 627 repo_id, workspace_id, target_ref, source_vcs,
628 628 pull_request.source_ref_parts,
629 629 user_name=user_name, user_email=user.email,
630 630 message=message, use_rebase=use_rebase,
631 631 close_branch=close_branch)
632 632 return merge_state
633 633
634 634 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
635 635 pull_request.merge_rev = merge_state.merge_ref.commit_id
636 636 pull_request.updated_on = datetime.datetime.now()
637 637 close_msg = close_msg or 'Pull request merged and closed'
638 638
639 639 CommentsModel().create(
640 640 text=safe_unicode(close_msg),
641 641 repo=pull_request.target_repo.repo_id,
642 642 user=user.user_id,
643 643 pull_request=pull_request.pull_request_id,
644 644 f_path=None,
645 645 line_no=None,
646 646 closing_pr=True
647 647 )
648 648
649 649 Session().add(pull_request)
650 650 Session().flush()
651 651 # TODO: paris: replace invalidation with less radical solution
652 652 ScmModel().mark_for_invalidation(
653 653 pull_request.target_repo.repo_name)
654 654 self.trigger_pull_request_hook(pull_request, user, 'merge')
655 655
656 656 def has_valid_update_type(self, pull_request):
657 657 source_ref_type = pull_request.source_ref_parts.type
658 658 return source_ref_type in self.REF_TYPES
659 659
660 660 def update_commits(self, pull_request):
661 661 """
662 662 Get the updated list of commits for the pull request
663 663 and return the new pull request version and the list
664 664 of commits processed by this update action
665 665 """
666 666 pull_request = self.__get_pull_request(pull_request)
667 667 source_ref_type = pull_request.source_ref_parts.type
668 668 source_ref_name = pull_request.source_ref_parts.name
669 669 source_ref_id = pull_request.source_ref_parts.commit_id
670 670
671 671 target_ref_type = pull_request.target_ref_parts.type
672 672 target_ref_name = pull_request.target_ref_parts.name
673 673 target_ref_id = pull_request.target_ref_parts.commit_id
674 674
675 675 if not self.has_valid_update_type(pull_request):
676 676 log.debug("Skipping update of pull request %s due to ref type: %s",
677 677 pull_request, source_ref_type)
678 678 return UpdateResponse(
679 679 executed=False,
680 680 reason=UpdateFailureReason.WRONG_REF_TYPE,
681 681 old=pull_request, new=None, changes=None,
682 682 source_changed=False, target_changed=False)
683 683
684 684 # source repo
685 685 source_repo = pull_request.source_repo.scm_instance()
686 686
687 687 try:
688 688 source_commit = source_repo.get_commit(commit_id=source_ref_name)
689 689 except CommitDoesNotExistError:
690 690 return UpdateResponse(
691 691 executed=False,
692 692 reason=UpdateFailureReason.MISSING_SOURCE_REF,
693 693 old=pull_request, new=None, changes=None,
694 694 source_changed=False, target_changed=False)
695 695
696 696 source_changed = source_ref_id != source_commit.raw_id
697 697
698 698 # target repo
699 699 target_repo = pull_request.target_repo.scm_instance()
700 700
701 701 try:
702 702 target_commit = target_repo.get_commit(commit_id=target_ref_name)
703 703 except CommitDoesNotExistError:
704 704 return UpdateResponse(
705 705 executed=False,
706 706 reason=UpdateFailureReason.MISSING_TARGET_REF,
707 707 old=pull_request, new=None, changes=None,
708 708 source_changed=False, target_changed=False)
709 709 target_changed = target_ref_id != target_commit.raw_id
710 710
711 711 if not (source_changed or target_changed):
712 712 log.debug("Nothing changed in pull request %s", pull_request)
713 713 return UpdateResponse(
714 714 executed=False,
715 715 reason=UpdateFailureReason.NO_CHANGE,
716 716 old=pull_request, new=None, changes=None,
717 717 source_changed=target_changed, target_changed=source_changed)
718 718
719 719 change_in_found = 'target repo' if target_changed else 'source repo'
720 720 log.debug('Updating pull request because of change in %s detected',
721 721 change_in_found)
722 722
723 723 # Finally there is a need for an update, in case of source change
724 724 # we create a new version, else just an update
725 725 if source_changed:
726 726 pull_request_version = self._create_version_from_snapshot(pull_request)
727 727 self._link_comments_to_version(pull_request_version)
728 728 else:
729 729 try:
730 730 ver = pull_request.versions[-1]
731 731 except IndexError:
732 732 ver = None
733 733
734 734 pull_request.pull_request_version_id = \
735 735 ver.pull_request_version_id if ver else None
736 736 pull_request_version = pull_request
737 737
738 738 try:
739 739 if target_ref_type in self.REF_TYPES:
740 740 target_commit = target_repo.get_commit(target_ref_name)
741 741 else:
742 742 target_commit = target_repo.get_commit(target_ref_id)
743 743 except CommitDoesNotExistError:
744 744 return UpdateResponse(
745 745 executed=False,
746 746 reason=UpdateFailureReason.MISSING_TARGET_REF,
747 747 old=pull_request, new=None, changes=None,
748 748 source_changed=source_changed, target_changed=target_changed)
749 749
750 750 # re-compute commit ids
751 751 old_commit_ids = pull_request.revisions
752 752 pre_load = ["author", "date", "message", "branch"]
753 753 commit_ranges = target_repo.compare(
754 754 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
755 755 pre_load=pre_load)
756 756
757 757 ancestor = source_repo.get_common_ancestor(
758 758 source_commit.raw_id, target_commit.raw_id, target_repo)
759 759
760 760 pull_request.source_ref = '%s:%s:%s' % (
761 761 source_ref_type, source_ref_name, source_commit.raw_id)
762 762 pull_request.target_ref = '%s:%s:%s' % (
763 763 target_ref_type, target_ref_name, ancestor)
764 764
765 765 pull_request.revisions = [
766 766 commit.raw_id for commit in reversed(commit_ranges)]
767 767 pull_request.updated_on = datetime.datetime.now()
768 768 Session().add(pull_request)
769 769 new_commit_ids = pull_request.revisions
770 770
771 771 old_diff_data, new_diff_data = self._generate_update_diffs(
772 772 pull_request, pull_request_version)
773 773
774 774 # calculate commit and file changes
775 775 changes = self._calculate_commit_id_changes(
776 776 old_commit_ids, new_commit_ids)
777 777 file_changes = self._calculate_file_changes(
778 778 old_diff_data, new_diff_data)
779 779
780 780 # set comments as outdated if DIFFS changed
781 781 CommentsModel().outdate_comments(
782 782 pull_request, old_diff_data=old_diff_data,
783 783 new_diff_data=new_diff_data)
784 784
785 785 commit_changes = (changes.added or changes.removed)
786 786 file_node_changes = (
787 787 file_changes.added or file_changes.modified or file_changes.removed)
788 788 pr_has_changes = commit_changes or file_node_changes
789 789
790 790 # Add an automatic comment to the pull request, in case
791 791 # anything has changed
792 792 if pr_has_changes:
793 793 update_comment = CommentsModel().create(
794 794 text=self._render_update_message(changes, file_changes),
795 795 repo=pull_request.target_repo,
796 796 user=pull_request.author,
797 797 pull_request=pull_request,
798 798 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
799 799
800 800 # Update status to "Under Review" for added commits
801 801 for commit_id in changes.added:
802 802 ChangesetStatusModel().set_status(
803 803 repo=pull_request.source_repo,
804 804 status=ChangesetStatus.STATUS_UNDER_REVIEW,
805 805 comment=update_comment,
806 806 user=pull_request.author,
807 807 pull_request=pull_request,
808 808 revision=commit_id)
809 809
810 810 log.debug(
811 811 'Updated pull request %s, added_ids: %s, common_ids: %s, '
812 812 'removed_ids: %s', pull_request.pull_request_id,
813 813 changes.added, changes.common, changes.removed)
814 814 log.debug(
815 815 'Updated pull request with the following file changes: %s',
816 816 file_changes)
817 817
818 818 log.info(
819 819 "Updated pull request %s from commit %s to commit %s, "
820 820 "stored new version %s of this pull request.",
821 821 pull_request.pull_request_id, source_ref_id,
822 822 pull_request.source_ref_parts.commit_id,
823 823 pull_request_version.pull_request_version_id)
824 824 Session().commit()
825 825 self.trigger_pull_request_hook(pull_request, pull_request.author, 'update')
826 826
827 827 return UpdateResponse(
828 828 executed=True, reason=UpdateFailureReason.NONE,
829 829 old=pull_request, new=pull_request_version, changes=changes,
830 830 source_changed=source_changed, target_changed=target_changed)
831 831
832 832 def _create_version_from_snapshot(self, pull_request):
833 833 version = PullRequestVersion()
834 834 version.title = pull_request.title
835 835 version.description = pull_request.description
836 836 version.status = pull_request.status
837 837 version.pull_request_state = pull_request.pull_request_state
838 838 version.created_on = datetime.datetime.now()
839 839 version.updated_on = pull_request.updated_on
840 840 version.user_id = pull_request.user_id
841 841 version.source_repo = pull_request.source_repo
842 842 version.source_ref = pull_request.source_ref
843 843 version.target_repo = pull_request.target_repo
844 844 version.target_ref = pull_request.target_ref
845 845
846 846 version._last_merge_source_rev = pull_request._last_merge_source_rev
847 847 version._last_merge_target_rev = pull_request._last_merge_target_rev
848 848 version.last_merge_status = pull_request.last_merge_status
849 849 version.shadow_merge_ref = pull_request.shadow_merge_ref
850 850 version.merge_rev = pull_request.merge_rev
851 851 version.reviewer_data = pull_request.reviewer_data
852 852
853 853 version.revisions = pull_request.revisions
854 854 version.pull_request = pull_request
855 855 Session().add(version)
856 856 Session().flush()
857 857
858 858 return version
859 859
860 860 def _generate_update_diffs(self, pull_request, pull_request_version):
861 861
862 862 diff_context = (
863 863 self.DIFF_CONTEXT +
864 864 CommentsModel.needed_extra_diff_context())
865 865 hide_whitespace_changes = False
866 866 source_repo = pull_request_version.source_repo
867 867 source_ref_id = pull_request_version.source_ref_parts.commit_id
868 868 target_ref_id = pull_request_version.target_ref_parts.commit_id
869 869 old_diff = self._get_diff_from_pr_or_version(
870 870 source_repo, source_ref_id, target_ref_id,
871 871 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
872 872
873 873 source_repo = pull_request.source_repo
874 874 source_ref_id = pull_request.source_ref_parts.commit_id
875 875 target_ref_id = pull_request.target_ref_parts.commit_id
876 876
877 877 new_diff = self._get_diff_from_pr_or_version(
878 878 source_repo, source_ref_id, target_ref_id,
879 879 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
880 880
881 881 old_diff_data = diffs.DiffProcessor(old_diff)
882 882 old_diff_data.prepare()
883 883 new_diff_data = diffs.DiffProcessor(new_diff)
884 884 new_diff_data.prepare()
885 885
886 886 return old_diff_data, new_diff_data
887 887
888 888 def _link_comments_to_version(self, pull_request_version):
889 889 """
890 890 Link all unlinked comments of this pull request to the given version.
891 891
892 892 :param pull_request_version: The `PullRequestVersion` to which
893 893 the comments shall be linked.
894 894
895 895 """
896 896 pull_request = pull_request_version.pull_request
897 897 comments = ChangesetComment.query()\
898 898 .filter(
899 899 # TODO: johbo: Should we query for the repo at all here?
900 900 # Pending decision on how comments of PRs are to be related
901 901 # to either the source repo, the target repo or no repo at all.
902 902 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
903 903 ChangesetComment.pull_request == pull_request,
904 904 ChangesetComment.pull_request_version == None)\
905 905 .order_by(ChangesetComment.comment_id.asc())
906 906
907 907 # TODO: johbo: Find out why this breaks if it is done in a bulk
908 908 # operation.
909 909 for comment in comments:
910 910 comment.pull_request_version_id = (
911 911 pull_request_version.pull_request_version_id)
912 912 Session().add(comment)
913 913
914 914 def _calculate_commit_id_changes(self, old_ids, new_ids):
915 915 added = [x for x in new_ids if x not in old_ids]
916 916 common = [x for x in new_ids if x in old_ids]
917 917 removed = [x for x in old_ids if x not in new_ids]
918 918 total = new_ids
919 919 return ChangeTuple(added, common, removed, total)
920 920
921 921 def _calculate_file_changes(self, old_diff_data, new_diff_data):
922 922
923 923 old_files = OrderedDict()
924 924 for diff_data in old_diff_data.parsed_diff:
925 925 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
926 926
927 927 added_files = []
928 928 modified_files = []
929 929 removed_files = []
930 930 for diff_data in new_diff_data.parsed_diff:
931 931 new_filename = diff_data['filename']
932 932 new_hash = md5_safe(diff_data['raw_diff'])
933 933
934 934 old_hash = old_files.get(new_filename)
935 935 if not old_hash:
936 936 # file is not present in old diff, means it's added
937 937 added_files.append(new_filename)
938 938 else:
939 939 if new_hash != old_hash:
940 940 modified_files.append(new_filename)
941 941 # now remove a file from old, since we have seen it already
942 942 del old_files[new_filename]
943 943
944 944 # removed files is when there are present in old, but not in NEW,
945 945 # since we remove old files that are present in new diff, left-overs
946 946 # if any should be the removed files
947 947 removed_files.extend(old_files.keys())
948 948
949 949 return FileChangeTuple(added_files, modified_files, removed_files)
950 950
951 951 def _render_update_message(self, changes, file_changes):
952 952 """
953 953 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
954 954 so it's always looking the same disregarding on which default
955 955 renderer system is using.
956 956
957 957 :param changes: changes named tuple
958 958 :param file_changes: file changes named tuple
959 959
960 960 """
961 961 new_status = ChangesetStatus.get_status_lbl(
962 962 ChangesetStatus.STATUS_UNDER_REVIEW)
963 963
964 964 changed_files = (
965 965 file_changes.added + file_changes.modified + file_changes.removed)
966 966
967 967 params = {
968 968 'under_review_label': new_status,
969 969 'added_commits': changes.added,
970 970 'removed_commits': changes.removed,
971 971 'changed_files': changed_files,
972 972 'added_files': file_changes.added,
973 973 'modified_files': file_changes.modified,
974 974 'removed_files': file_changes.removed,
975 975 }
976 976 renderer = RstTemplateRenderer()
977 977 return renderer.render('pull_request_update.mako', **params)
978 978
979 979 def edit(self, pull_request, title, description, description_renderer, user):
980 980 pull_request = self.__get_pull_request(pull_request)
981 981 old_data = pull_request.get_api_data(with_merge_state=False)
982 982 if pull_request.is_closed():
983 983 raise ValueError('This pull request is closed')
984 984 if title:
985 985 pull_request.title = title
986 986 pull_request.description = description
987 987 pull_request.updated_on = datetime.datetime.now()
988 988 pull_request.description_renderer = description_renderer
989 989 Session().add(pull_request)
990 990 self._log_audit_action(
991 991 'repo.pull_request.edit', {'old_data': old_data},
992 992 user, pull_request)
993 993
994 994 def update_reviewers(self, pull_request, reviewer_data, user):
995 995 """
996 996 Update the reviewers in the pull request
997 997
998 998 :param pull_request: the pr to update
999 999 :param reviewer_data: list of tuples
1000 1000 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
1001 1001 """
1002 1002 pull_request = self.__get_pull_request(pull_request)
1003 1003 if pull_request.is_closed():
1004 1004 raise ValueError('This pull request is closed')
1005 1005
1006 1006 reviewers = {}
1007 1007 for user_id, reasons, mandatory, rules in reviewer_data:
1008 1008 if isinstance(user_id, (int, compat.string_types)):
1009 1009 user_id = self._get_user(user_id).user_id
1010 1010 reviewers[user_id] = {
1011 1011 'reasons': reasons, 'mandatory': mandatory}
1012 1012
1013 1013 reviewers_ids = set(reviewers.keys())
1014 1014 current_reviewers = PullRequestReviewers.query()\
1015 1015 .filter(PullRequestReviewers.pull_request ==
1016 1016 pull_request).all()
1017 1017 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1018 1018
1019 1019 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1020 1020 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1021 1021
1022 1022 log.debug("Adding %s reviewers", ids_to_add)
1023 1023 log.debug("Removing %s reviewers", ids_to_remove)
1024 1024 changed = False
1025 1025 added_audit_reviewers = []
1026 1026 removed_audit_reviewers = []
1027 1027
1028 1028 for uid in ids_to_add:
1029 1029 changed = True
1030 1030 _usr = self._get_user(uid)
1031 1031 reviewer = PullRequestReviewers()
1032 1032 reviewer.user = _usr
1033 1033 reviewer.pull_request = pull_request
1034 1034 reviewer.reasons = reviewers[uid]['reasons']
1035 1035 # NOTE(marcink): mandatory shouldn't be changed now
1036 1036 # reviewer.mandatory = reviewers[uid]['reasons']
1037 1037 Session().add(reviewer)
1038 1038 added_audit_reviewers.append(reviewer.get_dict())
1039 1039
1040 1040 for uid in ids_to_remove:
1041 1041 changed = True
1042 1042 # NOTE(marcink): we fetch "ALL" reviewers using .all(). This is an edge case
1043 1043 # that prevents and fixes cases that we added the same reviewer twice.
1044 1044 # this CAN happen due to the lack of DB checks
1045 1045 reviewers = PullRequestReviewers.query()\
1046 1046 .filter(PullRequestReviewers.user_id == uid,
1047 1047 PullRequestReviewers.pull_request == pull_request)\
1048 1048 .all()
1049 1049
1050 1050 for obj in reviewers:
1051 1051 added_audit_reviewers.append(obj.get_dict())
1052 1052 Session().delete(obj)
1053 1053
1054 1054 if changed:
1055 1055 Session().expire_all()
1056 1056 pull_request.updated_on = datetime.datetime.now()
1057 1057 Session().add(pull_request)
1058 1058
1059 1059 # finally store audit logs
1060 1060 for user_data in added_audit_reviewers:
1061 1061 self._log_audit_action(
1062 1062 'repo.pull_request.reviewer.add', {'data': user_data},
1063 1063 user, pull_request)
1064 1064 for user_data in removed_audit_reviewers:
1065 1065 self._log_audit_action(
1066 1066 'repo.pull_request.reviewer.delete', {'old_data': user_data},
1067 1067 user, pull_request)
1068 1068
1069 1069 self.notify_reviewers(pull_request, ids_to_add)
1070 1070 return ids_to_add, ids_to_remove
1071 1071
1072 1072 def get_url(self, pull_request, request=None, permalink=False):
1073 1073 if not request:
1074 1074 request = get_current_request()
1075 1075
1076 1076 if permalink:
1077 1077 return request.route_url(
1078 1078 'pull_requests_global',
1079 1079 pull_request_id=pull_request.pull_request_id,)
1080 1080 else:
1081 1081 return request.route_url('pullrequest_show',
1082 1082 repo_name=safe_str(pull_request.target_repo.repo_name),
1083 1083 pull_request_id=pull_request.pull_request_id,)
1084 1084
1085 1085 def get_shadow_clone_url(self, pull_request, request=None):
1086 1086 """
1087 1087 Returns qualified url pointing to the shadow repository. If this pull
1088 1088 request is closed there is no shadow repository and ``None`` will be
1089 1089 returned.
1090 1090 """
1091 1091 if pull_request.is_closed():
1092 1092 return None
1093 1093 else:
1094 1094 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1095 1095 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1096 1096
1097 1097 def notify_reviewers(self, pull_request, reviewers_ids):
1098 1098 # notification to reviewers
1099 1099 if not reviewers_ids:
1100 1100 return
1101 1101
1102 1102 log.debug('Notify following reviewers about pull-request %s', reviewers_ids)
1103 1103
1104 1104 pull_request_obj = pull_request
1105 1105 # get the current participants of this pull request
1106 1106 recipients = reviewers_ids
1107 1107 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1108 1108
1109 1109 pr_source_repo = pull_request_obj.source_repo
1110 1110 pr_target_repo = pull_request_obj.target_repo
1111 1111
1112 1112 pr_url = h.route_url('pullrequest_show',
1113 repo_name=pr_target_repo.repo_name,
1114 pull_request_id=pull_request_obj.pull_request_id,)
1113 repo_name=pr_target_repo.repo_name,
1114 pull_request_id=pull_request_obj.pull_request_id,)
1115 1115
1116 1116 # set some variables for email notification
1117 1117 pr_target_repo_url = h.route_url(
1118 1118 'repo_summary', repo_name=pr_target_repo.repo_name)
1119 1119
1120 1120 pr_source_repo_url = h.route_url(
1121 1121 'repo_summary', repo_name=pr_source_repo.repo_name)
1122 1122
1123 1123 # pull request specifics
1124 1124 pull_request_commits = [
1125 1125 (x.raw_id, x.message)
1126 1126 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1127 1127
1128 1128 kwargs = {
1129 1129 'user': pull_request.author,
1130 1130 'pull_request': pull_request_obj,
1131 1131 'pull_request_commits': pull_request_commits,
1132 1132
1133 1133 'pull_request_target_repo': pr_target_repo,
1134 1134 'pull_request_target_repo_url': pr_target_repo_url,
1135 1135
1136 1136 'pull_request_source_repo': pr_source_repo,
1137 1137 'pull_request_source_repo_url': pr_source_repo_url,
1138 1138
1139 1139 'pull_request_url': pr_url,
1140 1140 }
1141 1141
1142 1142 # pre-generate the subject for notification itself
1143 1143 (subject,
1144 1144 _h, _e, # we don't care about those
1145 1145 body_plaintext) = EmailNotificationModel().render_email(
1146 1146 notification_type, **kwargs)
1147 1147
1148 1148 # create notification objects, and emails
1149 1149 NotificationModel().create(
1150 1150 created_by=pull_request.author,
1151 1151 notification_subject=subject,
1152 1152 notification_body=body_plaintext,
1153 1153 notification_type=notification_type,
1154 1154 recipients=recipients,
1155 1155 email_kwargs=kwargs,
1156 1156 )
1157 1157
1158 1158 def delete(self, pull_request, user):
1159 1159 pull_request = self.__get_pull_request(pull_request)
1160 1160 old_data = pull_request.get_api_data(with_merge_state=False)
1161 1161 self._cleanup_merge_workspace(pull_request)
1162 1162 self._log_audit_action(
1163 1163 'repo.pull_request.delete', {'old_data': old_data},
1164 1164 user, pull_request)
1165 1165 Session().delete(pull_request)
1166 1166
1167 1167 def close_pull_request(self, pull_request, user):
1168 1168 pull_request = self.__get_pull_request(pull_request)
1169 1169 self._cleanup_merge_workspace(pull_request)
1170 1170 pull_request.status = PullRequest.STATUS_CLOSED
1171 1171 pull_request.updated_on = datetime.datetime.now()
1172 1172 Session().add(pull_request)
1173 1173 self.trigger_pull_request_hook(
1174 1174 pull_request, pull_request.author, 'close')
1175 1175
1176 1176 pr_data = pull_request.get_api_data(with_merge_state=False)
1177 1177 self._log_audit_action(
1178 1178 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1179 1179
1180 1180 def close_pull_request_with_comment(
1181 1181 self, pull_request, user, repo, message=None, auth_user=None):
1182 1182
1183 1183 pull_request_review_status = pull_request.calculated_review_status()
1184 1184
1185 1185 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1186 1186 # approved only if we have voting consent
1187 1187 status = ChangesetStatus.STATUS_APPROVED
1188 1188 else:
1189 1189 status = ChangesetStatus.STATUS_REJECTED
1190 1190 status_lbl = ChangesetStatus.get_status_lbl(status)
1191 1191
1192 1192 default_message = (
1193 1193 'Closing with status change {transition_icon} {status}.'
1194 1194 ).format(transition_icon='>', status=status_lbl)
1195 1195 text = message or default_message
1196 1196
1197 1197 # create a comment, and link it to new status
1198 1198 comment = CommentsModel().create(
1199 1199 text=text,
1200 1200 repo=repo.repo_id,
1201 1201 user=user.user_id,
1202 1202 pull_request=pull_request.pull_request_id,
1203 1203 status_change=status_lbl,
1204 1204 status_change_type=status,
1205 1205 closing_pr=True,
1206 1206 auth_user=auth_user,
1207 1207 )
1208 1208
1209 1209 # calculate old status before we change it
1210 1210 old_calculated_status = pull_request.calculated_review_status()
1211 1211 ChangesetStatusModel().set_status(
1212 1212 repo.repo_id,
1213 1213 status,
1214 1214 user.user_id,
1215 1215 comment=comment,
1216 1216 pull_request=pull_request.pull_request_id
1217 1217 )
1218 1218
1219 1219 Session().flush()
1220 1220 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1221 1221 # we now calculate the status of pull request again, and based on that
1222 1222 # calculation trigger status change. This might happen in cases
1223 1223 # that non-reviewer admin closes a pr, which means his vote doesn't
1224 1224 # change the status, while if he's a reviewer this might change it.
1225 1225 calculated_status = pull_request.calculated_review_status()
1226 1226 if old_calculated_status != calculated_status:
1227 1227 self.trigger_pull_request_hook(
1228 1228 pull_request, user, 'review_status_change',
1229 1229 data={'status': calculated_status})
1230 1230
1231 1231 # finally close the PR
1232 1232 PullRequestModel().close_pull_request(
1233 1233 pull_request.pull_request_id, user)
1234 1234
1235 1235 return comment, status
1236 1236
1237 1237 def merge_status(self, pull_request, translator=None,
1238 1238 force_shadow_repo_refresh=False):
1239 1239 _ = translator or get_current_request().translate
1240 1240
1241 1241 if not self._is_merge_enabled(pull_request):
1242 1242 return False, _('Server-side pull request merging is disabled.')
1243 1243 if pull_request.is_closed():
1244 1244 return False, _('This pull request is closed.')
1245 1245 merge_possible, msg = self._check_repo_requirements(
1246 1246 target=pull_request.target_repo, source=pull_request.source_repo,
1247 1247 translator=_)
1248 1248 if not merge_possible:
1249 1249 return merge_possible, msg
1250 1250
1251 1251 try:
1252 1252 resp = self._try_merge(
1253 1253 pull_request,
1254 1254 force_shadow_repo_refresh=force_shadow_repo_refresh)
1255 1255 log.debug("Merge response: %s", resp)
1256 1256 status = resp.possible, resp.merge_status_message
1257 1257 except NotImplementedError:
1258 1258 status = False, _('Pull request merging is not supported.')
1259 1259
1260 1260 return status
1261 1261
1262 1262 def _check_repo_requirements(self, target, source, translator):
1263 1263 """
1264 1264 Check if `target` and `source` have compatible requirements.
1265 1265
1266 1266 Currently this is just checking for largefiles.
1267 1267 """
1268 1268 _ = translator
1269 1269 target_has_largefiles = self._has_largefiles(target)
1270 1270 source_has_largefiles = self._has_largefiles(source)
1271 1271 merge_possible = True
1272 1272 message = u''
1273 1273
1274 1274 if target_has_largefiles != source_has_largefiles:
1275 1275 merge_possible = False
1276 1276 if source_has_largefiles:
1277 1277 message = _(
1278 1278 'Target repository large files support is disabled.')
1279 1279 else:
1280 1280 message = _(
1281 1281 'Source repository large files support is disabled.')
1282 1282
1283 1283 return merge_possible, message
1284 1284
1285 1285 def _has_largefiles(self, repo):
1286 1286 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1287 1287 'extensions', 'largefiles')
1288 1288 return largefiles_ui and largefiles_ui[0].active
1289 1289
1290 1290 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1291 1291 """
1292 1292 Try to merge the pull request and return the merge status.
1293 1293 """
1294 1294 log.debug(
1295 1295 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1296 1296 pull_request.pull_request_id, force_shadow_repo_refresh)
1297 1297 target_vcs = pull_request.target_repo.scm_instance()
1298 1298 # Refresh the target reference.
1299 1299 try:
1300 1300 target_ref = self._refresh_reference(
1301 1301 pull_request.target_ref_parts, target_vcs)
1302 1302 except CommitDoesNotExistError:
1303 1303 merge_state = MergeResponse(
1304 1304 False, False, None, MergeFailureReason.MISSING_TARGET_REF,
1305 1305 metadata={'target_ref': pull_request.target_ref_parts})
1306 1306 return merge_state
1307 1307
1308 1308 target_locked = pull_request.target_repo.locked
1309 1309 if target_locked and target_locked[0]:
1310 1310 locked_by = 'user:{}'.format(target_locked[0])
1311 1311 log.debug("The target repository is locked by %s.", locked_by)
1312 1312 merge_state = MergeResponse(
1313 1313 False, False, None, MergeFailureReason.TARGET_IS_LOCKED,
1314 1314 metadata={'locked_by': locked_by})
1315 1315 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1316 1316 pull_request, target_ref):
1317 1317 log.debug("Refreshing the merge status of the repository.")
1318 1318 merge_state = self._refresh_merge_state(
1319 1319 pull_request, target_vcs, target_ref)
1320 1320 else:
1321 1321 possible = pull_request.last_merge_status == MergeFailureReason.NONE
1322 1322 metadata = {
1323 1323 'target_ref': pull_request.target_ref_parts,
1324 1324 'source_ref': pull_request.source_ref_parts,
1325 1325 }
1326 1326 if not possible and target_ref.type == 'branch':
1327 1327 # NOTE(marcink): case for mercurial multiple heads on branch
1328 1328 heads = target_vcs._heads(target_ref.name)
1329 1329 if len(heads) != 1:
1330 1330 heads = '\n,'.join(target_vcs._heads(target_ref.name))
1331 1331 metadata.update({
1332 1332 'heads': heads
1333 1333 })
1334 1334 merge_state = MergeResponse(
1335 1335 possible, False, None, pull_request.last_merge_status, metadata=metadata)
1336 1336
1337 1337 return merge_state
1338 1338
1339 1339 def _refresh_reference(self, reference, vcs_repository):
1340 1340 if reference.type in self.UPDATABLE_REF_TYPES:
1341 1341 name_or_id = reference.name
1342 1342 else:
1343 1343 name_or_id = reference.commit_id
1344 1344
1345 1345 refreshed_commit = vcs_repository.get_commit(name_or_id)
1346 1346 refreshed_reference = Reference(
1347 1347 reference.type, reference.name, refreshed_commit.raw_id)
1348 1348 return refreshed_reference
1349 1349
1350 1350 def _needs_merge_state_refresh(self, pull_request, target_reference):
1351 1351 return not(
1352 1352 pull_request.revisions and
1353 1353 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1354 1354 target_reference.commit_id == pull_request._last_merge_target_rev)
1355 1355
1356 1356 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1357 1357 workspace_id = self._workspace_id(pull_request)
1358 1358 source_vcs = pull_request.source_repo.scm_instance()
1359 1359 repo_id = pull_request.target_repo.repo_id
1360 1360 use_rebase = self._use_rebase_for_merging(pull_request)
1361 1361 close_branch = self._close_branch_before_merging(pull_request)
1362 1362 merge_state = target_vcs.merge(
1363 1363 repo_id, workspace_id,
1364 1364 target_reference, source_vcs, pull_request.source_ref_parts,
1365 1365 dry_run=True, use_rebase=use_rebase,
1366 1366 close_branch=close_branch)
1367 1367
1368 1368 # Do not store the response if there was an unknown error.
1369 1369 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1370 1370 pull_request._last_merge_source_rev = \
1371 1371 pull_request.source_ref_parts.commit_id
1372 1372 pull_request._last_merge_target_rev = target_reference.commit_id
1373 1373 pull_request.last_merge_status = merge_state.failure_reason
1374 1374 pull_request.shadow_merge_ref = merge_state.merge_ref
1375 1375 Session().add(pull_request)
1376 1376 Session().commit()
1377 1377
1378 1378 return merge_state
1379 1379
1380 1380 def _workspace_id(self, pull_request):
1381 1381 workspace_id = 'pr-%s' % pull_request.pull_request_id
1382 1382 return workspace_id
1383 1383
1384 1384 def generate_repo_data(self, repo, commit_id=None, branch=None,
1385 1385 bookmark=None, translator=None):
1386 1386 from rhodecode.model.repo import RepoModel
1387 1387
1388 1388 all_refs, selected_ref = \
1389 1389 self._get_repo_pullrequest_sources(
1390 1390 repo.scm_instance(), commit_id=commit_id,
1391 1391 branch=branch, bookmark=bookmark, translator=translator)
1392 1392
1393 1393 refs_select2 = []
1394 1394 for element in all_refs:
1395 1395 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1396 1396 refs_select2.append({'text': element[1], 'children': children})
1397 1397
1398 1398 return {
1399 1399 'user': {
1400 1400 'user_id': repo.user.user_id,
1401 1401 'username': repo.user.username,
1402 1402 'firstname': repo.user.first_name,
1403 1403 'lastname': repo.user.last_name,
1404 1404 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1405 1405 },
1406 1406 'name': repo.repo_name,
1407 1407 'link': RepoModel().get_url(repo),
1408 1408 'description': h.chop_at_smart(repo.description_safe, '\n'),
1409 1409 'refs': {
1410 1410 'all_refs': all_refs,
1411 1411 'selected_ref': selected_ref,
1412 1412 'select2_refs': refs_select2
1413 1413 }
1414 1414 }
1415 1415
1416 1416 def generate_pullrequest_title(self, source, source_ref, target):
1417 1417 return u'{source}#{at_ref} to {target}'.format(
1418 1418 source=source,
1419 1419 at_ref=source_ref,
1420 1420 target=target,
1421 1421 )
1422 1422
1423 1423 def _cleanup_merge_workspace(self, pull_request):
1424 1424 # Merging related cleanup
1425 1425 repo_id = pull_request.target_repo.repo_id
1426 1426 target_scm = pull_request.target_repo.scm_instance()
1427 1427 workspace_id = self._workspace_id(pull_request)
1428 1428
1429 1429 try:
1430 1430 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1431 1431 except NotImplementedError:
1432 1432 pass
1433 1433
1434 1434 def _get_repo_pullrequest_sources(
1435 1435 self, repo, commit_id=None, branch=None, bookmark=None,
1436 1436 translator=None):
1437 1437 """
1438 1438 Return a structure with repo's interesting commits, suitable for
1439 1439 the selectors in pullrequest controller
1440 1440
1441 1441 :param commit_id: a commit that must be in the list somehow
1442 1442 and selected by default
1443 1443 :param branch: a branch that must be in the list and selected
1444 1444 by default - even if closed
1445 1445 :param bookmark: a bookmark that must be in the list and selected
1446 1446 """
1447 1447 _ = translator or get_current_request().translate
1448 1448
1449 1449 commit_id = safe_str(commit_id) if commit_id else None
1450 1450 branch = safe_unicode(branch) if branch else None
1451 1451 bookmark = safe_unicode(bookmark) if bookmark else None
1452 1452
1453 1453 selected = None
1454 1454
1455 1455 # order matters: first source that has commit_id in it will be selected
1456 1456 sources = []
1457 1457 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1458 1458 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1459 1459
1460 1460 if commit_id:
1461 1461 ref_commit = (h.short_id(commit_id), commit_id)
1462 1462 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1463 1463
1464 1464 sources.append(
1465 1465 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1466 1466 )
1467 1467
1468 1468 groups = []
1469 1469
1470 1470 for group_key, ref_list, group_name, match in sources:
1471 1471 group_refs = []
1472 1472 for ref_name, ref_id in ref_list:
1473 1473 ref_key = u'{}:{}:{}'.format(group_key, ref_name, ref_id)
1474 1474 group_refs.append((ref_key, ref_name))
1475 1475
1476 1476 if not selected:
1477 1477 if set([commit_id, match]) & set([ref_id, ref_name]):
1478 1478 selected = ref_key
1479 1479
1480 1480 if group_refs:
1481 1481 groups.append((group_refs, group_name))
1482 1482
1483 1483 if not selected:
1484 1484 ref = commit_id or branch or bookmark
1485 1485 if ref:
1486 1486 raise CommitDoesNotExistError(
1487 1487 u'No commit refs could be found matching: {}'.format(ref))
1488 1488 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1489 1489 selected = u'branch:{}:{}'.format(
1490 1490 safe_unicode(repo.DEFAULT_BRANCH_NAME),
1491 1491 safe_unicode(repo.branches[repo.DEFAULT_BRANCH_NAME])
1492 1492 )
1493 1493 elif repo.commit_ids:
1494 1494 # make the user select in this case
1495 1495 selected = None
1496 1496 else:
1497 1497 raise EmptyRepositoryError()
1498 1498 return groups, selected
1499 1499
1500 1500 def get_diff(self, source_repo, source_ref_id, target_ref_id,
1501 1501 hide_whitespace_changes, diff_context):
1502 1502
1503 1503 return self._get_diff_from_pr_or_version(
1504 1504 source_repo, source_ref_id, target_ref_id,
1505 1505 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1506 1506
1507 1507 def _get_diff_from_pr_or_version(
1508 1508 self, source_repo, source_ref_id, target_ref_id,
1509 1509 hide_whitespace_changes, diff_context):
1510 1510
1511 1511 target_commit = source_repo.get_commit(
1512 1512 commit_id=safe_str(target_ref_id))
1513 1513 source_commit = source_repo.get_commit(
1514 1514 commit_id=safe_str(source_ref_id))
1515 1515 if isinstance(source_repo, Repository):
1516 1516 vcs_repo = source_repo.scm_instance()
1517 1517 else:
1518 1518 vcs_repo = source_repo
1519 1519
1520 1520 # TODO: johbo: In the context of an update, we cannot reach
1521 1521 # the old commit anymore with our normal mechanisms. It needs
1522 1522 # some sort of special support in the vcs layer to avoid this
1523 1523 # workaround.
1524 1524 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1525 1525 vcs_repo.alias == 'git'):
1526 1526 source_commit.raw_id = safe_str(source_ref_id)
1527 1527
1528 1528 log.debug('calculating diff between '
1529 1529 'source_ref:%s and target_ref:%s for repo `%s`',
1530 1530 target_ref_id, source_ref_id,
1531 1531 safe_unicode(vcs_repo.path))
1532 1532
1533 1533 vcs_diff = vcs_repo.get_diff(
1534 1534 commit1=target_commit, commit2=source_commit,
1535 1535 ignore_whitespace=hide_whitespace_changes, context=diff_context)
1536 1536 return vcs_diff
1537 1537
1538 1538 def _is_merge_enabled(self, pull_request):
1539 1539 return self._get_general_setting(
1540 1540 pull_request, 'rhodecode_pr_merge_enabled')
1541 1541
1542 1542 def _use_rebase_for_merging(self, pull_request):
1543 1543 repo_type = pull_request.target_repo.repo_type
1544 1544 if repo_type == 'hg':
1545 1545 return self._get_general_setting(
1546 1546 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1547 1547 elif repo_type == 'git':
1548 1548 return self._get_general_setting(
1549 1549 pull_request, 'rhodecode_git_use_rebase_for_merging')
1550 1550
1551 1551 return False
1552 1552
1553 1553 def _close_branch_before_merging(self, pull_request):
1554 1554 repo_type = pull_request.target_repo.repo_type
1555 1555 if repo_type == 'hg':
1556 1556 return self._get_general_setting(
1557 1557 pull_request, 'rhodecode_hg_close_branch_before_merging')
1558 1558 elif repo_type == 'git':
1559 1559 return self._get_general_setting(
1560 1560 pull_request, 'rhodecode_git_close_branch_before_merging')
1561 1561
1562 1562 return False
1563 1563
1564 1564 def _get_general_setting(self, pull_request, settings_key, default=False):
1565 1565 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1566 1566 settings = settings_model.get_general_settings()
1567 1567 return settings.get(settings_key, default)
1568 1568
1569 1569 def _log_audit_action(self, action, action_data, user, pull_request):
1570 1570 audit_logger.store(
1571 1571 action=action,
1572 1572 action_data=action_data,
1573 1573 user=user,
1574 1574 repo=pull_request.target_repo)
1575 1575
1576 1576 def get_reviewer_functions(self):
1577 1577 """
1578 1578 Fetches functions for validation and fetching default reviewers.
1579 1579 If available we use the EE package, else we fallback to CE
1580 1580 package functions
1581 1581 """
1582 1582 try:
1583 1583 from rc_reviewers.utils import get_default_reviewers_data
1584 1584 from rc_reviewers.utils import validate_default_reviewers
1585 1585 except ImportError:
1586 1586 from rhodecode.apps.repository.utils import get_default_reviewers_data
1587 1587 from rhodecode.apps.repository.utils import validate_default_reviewers
1588 1588
1589 1589 return get_default_reviewers_data, validate_default_reviewers
1590 1590
1591 1591
1592 1592 class MergeCheck(object):
1593 1593 """
1594 1594 Perform Merge Checks and returns a check object which stores information
1595 1595 about merge errors, and merge conditions
1596 1596 """
1597 1597 TODO_CHECK = 'todo'
1598 1598 PERM_CHECK = 'perm'
1599 1599 REVIEW_CHECK = 'review'
1600 1600 MERGE_CHECK = 'merge'
1601 1601
1602 1602 def __init__(self):
1603 1603 self.review_status = None
1604 1604 self.merge_possible = None
1605 1605 self.merge_msg = ''
1606 1606 self.failed = None
1607 1607 self.errors = []
1608 1608 self.error_details = OrderedDict()
1609 1609
1610 1610 def push_error(self, error_type, message, error_key, details):
1611 1611 self.failed = True
1612 1612 self.errors.append([error_type, message])
1613 1613 self.error_details[error_key] = dict(
1614 1614 details=details,
1615 1615 error_type=error_type,
1616 1616 message=message
1617 1617 )
1618 1618
1619 1619 @classmethod
1620 1620 def validate(cls, pull_request, auth_user, translator, fail_early=False,
1621 1621 force_shadow_repo_refresh=False):
1622 1622 _ = translator
1623 1623 merge_check = cls()
1624 1624
1625 1625 # permissions to merge
1626 1626 user_allowed_to_merge = PullRequestModel().check_user_merge(
1627 1627 pull_request, auth_user)
1628 1628 if not user_allowed_to_merge:
1629 1629 log.debug("MergeCheck: cannot merge, approval is pending.")
1630 1630
1631 1631 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
1632 1632 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1633 1633 if fail_early:
1634 1634 return merge_check
1635 1635
1636 1636 # permission to merge into the target branch
1637 1637 target_commit_id = pull_request.target_ref_parts.commit_id
1638 1638 if pull_request.target_ref_parts.type == 'branch':
1639 1639 branch_name = pull_request.target_ref_parts.name
1640 1640 else:
1641 1641 # for mercurial we can always figure out the branch from the commit
1642 1642 # in case of bookmark
1643 1643 target_commit = pull_request.target_repo.get_commit(target_commit_id)
1644 1644 branch_name = target_commit.branch
1645 1645
1646 1646 rule, branch_perm = auth_user.get_rule_and_branch_permission(
1647 1647 pull_request.target_repo.repo_name, branch_name)
1648 1648 if branch_perm and branch_perm == 'branch.none':
1649 1649 msg = _('Target branch `{}` changes rejected by rule {}.').format(
1650 1650 branch_name, rule)
1651 1651 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1652 1652 if fail_early:
1653 1653 return merge_check
1654 1654
1655 1655 # review status, must be always present
1656 1656 review_status = pull_request.calculated_review_status()
1657 1657 merge_check.review_status = review_status
1658 1658
1659 1659 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1660 1660 if not status_approved:
1661 1661 log.debug("MergeCheck: cannot merge, approval is pending.")
1662 1662
1663 1663 msg = _('Pull request reviewer approval is pending.')
1664 1664
1665 1665 merge_check.push_error('warning', msg, cls.REVIEW_CHECK, review_status)
1666 1666
1667 1667 if fail_early:
1668 1668 return merge_check
1669 1669
1670 1670 # left over TODOs
1671 1671 todos = CommentsModel().get_pull_request_unresolved_todos(pull_request)
1672 1672 if todos:
1673 1673 log.debug("MergeCheck: cannot merge, {} "
1674 1674 "unresolved TODOs left.".format(len(todos)))
1675 1675
1676 1676 if len(todos) == 1:
1677 1677 msg = _('Cannot merge, {} TODO still not resolved.').format(
1678 1678 len(todos))
1679 1679 else:
1680 1680 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1681 1681 len(todos))
1682 1682
1683 1683 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1684 1684
1685 1685 if fail_early:
1686 1686 return merge_check
1687 1687
1688 1688 # merge possible, here is the filesystem simulation + shadow repo
1689 1689 merge_status, msg = PullRequestModel().merge_status(
1690 1690 pull_request, translator=translator,
1691 1691 force_shadow_repo_refresh=force_shadow_repo_refresh)
1692 1692 merge_check.merge_possible = merge_status
1693 1693 merge_check.merge_msg = msg
1694 1694 if not merge_status:
1695 1695 log.debug("MergeCheck: cannot merge, pull request merge not possible.")
1696 1696 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1697 1697
1698 1698 if fail_early:
1699 1699 return merge_check
1700 1700
1701 1701 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1702 1702 return merge_check
1703 1703
1704 1704 @classmethod
1705 1705 def get_merge_conditions(cls, pull_request, translator):
1706 1706 _ = translator
1707 1707 merge_details = {}
1708 1708
1709 1709 model = PullRequestModel()
1710 1710 use_rebase = model._use_rebase_for_merging(pull_request)
1711 1711
1712 1712 if use_rebase:
1713 1713 merge_details['merge_strategy'] = dict(
1714 1714 details={},
1715 1715 message=_('Merge strategy: rebase')
1716 1716 )
1717 1717 else:
1718 1718 merge_details['merge_strategy'] = dict(
1719 1719 details={},
1720 1720 message=_('Merge strategy: explicit merge commit')
1721 1721 )
1722 1722
1723 1723 close_branch = model._close_branch_before_merging(pull_request)
1724 1724 if close_branch:
1725 1725 repo_type = pull_request.target_repo.repo_type
1726 1726 close_msg = ''
1727 1727 if repo_type == 'hg':
1728 1728 close_msg = _('Source branch will be closed after merge.')
1729 1729 elif repo_type == 'git':
1730 1730 close_msg = _('Source branch will be deleted after merge.')
1731 1731
1732 1732 merge_details['close_branch'] = dict(
1733 1733 details={},
1734 1734 message=close_msg
1735 1735 )
1736 1736
1737 1737 return merge_details
1738 1738
1739 1739
1740 1740 ChangeTuple = collections.namedtuple(
1741 1741 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1742 1742
1743 1743 FileChangeTuple = collections.namedtuple(
1744 1744 'FileChangeTuple', ['added', 'modified', 'removed'])
@@ -1,1005 +1,1007 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 users model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import datetime
28 28 import ipaddress
29 29
30 30 from pyramid.threadlocal import get_current_request
31 31 from sqlalchemy.exc import DatabaseError
32 32
33 33 from rhodecode import events
34 34 from rhodecode.lib.user_log_filter import user_log_filter
35 35 from rhodecode.lib.utils2 import (
36 36 safe_unicode, get_current_rhodecode_user, action_logger_generic,
37 37 AttributeDict, str2bool)
38 38 from rhodecode.lib.exceptions import (
39 39 DefaultUserException, UserOwnsReposException, UserOwnsRepoGroupsException,
40 40 UserOwnsUserGroupsException, NotAllowedToCreateUserError, UserOwnsArtifactsException)
41 41 from rhodecode.lib.caching_query import FromCache
42 42 from rhodecode.model import BaseModel
43 43 from rhodecode.model.auth_token import AuthTokenModel
44 44 from rhodecode.model.db import (
45 45 _hash_key, true, false, or_, joinedload, User, UserToPerm,
46 46 UserEmailMap, UserIpMap, UserLog)
47 47 from rhodecode.model.meta import Session
48 48 from rhodecode.model.repo_group import RepoGroupModel
49 49
50 50
51 51 log = logging.getLogger(__name__)
52 52
53 53
54 54 class UserModel(BaseModel):
55 55 cls = User
56 56
57 57 def get(self, user_id, cache=False):
58 58 user = self.sa.query(User)
59 59 if cache:
60 60 user = user.options(
61 61 FromCache("sql_cache_short", "get_user_%s" % user_id))
62 62 return user.get(user_id)
63 63
64 64 def get_user(self, user):
65 65 return self._get_user(user)
66 66
67 67 def _serialize_user(self, user):
68 68 import rhodecode.lib.helpers as h
69 69
70 70 return {
71 71 'id': user.user_id,
72 72 'first_name': user.first_name,
73 73 'last_name': user.last_name,
74 74 'username': user.username,
75 75 'email': user.email,
76 76 'icon_link': h.gravatar_url(user.email, 30),
77 77 'profile_link': h.link_to_user(user),
78 78 'value_display': h.escape(h.person(user)),
79 79 'value': user.username,
80 80 'value_type': 'user',
81 81 'active': user.active,
82 82 }
83 83
84 84 def get_users(self, name_contains=None, limit=20, only_active=True):
85 85
86 86 query = self.sa.query(User)
87 87 if only_active:
88 88 query = query.filter(User.active == true())
89 89
90 90 if name_contains:
91 91 ilike_expression = u'%{}%'.format(safe_unicode(name_contains))
92 92 query = query.filter(
93 93 or_(
94 94 User.name.ilike(ilike_expression),
95 95 User.lastname.ilike(ilike_expression),
96 96 User.username.ilike(ilike_expression)
97 97 )
98 98 )
99 99 query = query.limit(limit)
100 100 users = query.all()
101 101
102 102 _users = [
103 103 self._serialize_user(user) for user in users
104 104 ]
105 105 return _users
106 106
107 107 def get_by_username(self, username, cache=False, case_insensitive=False):
108 108
109 109 if case_insensitive:
110 110 user = self.sa.query(User).filter(User.username.ilike(username))
111 111 else:
112 112 user = self.sa.query(User)\
113 113 .filter(User.username == username)
114 114 if cache:
115 115 name_key = _hash_key(username)
116 116 user = user.options(
117 117 FromCache("sql_cache_short", "get_user_%s" % name_key))
118 118 return user.scalar()
119 119
120 120 def get_by_email(self, email, cache=False, case_insensitive=False):
121 121 return User.get_by_email(email, case_insensitive, cache)
122 122
123 123 def get_by_auth_token(self, auth_token, cache=False):
124 124 return User.get_by_auth_token(auth_token, cache)
125 125
126 126 def get_active_user_count(self, cache=False):
127 127 qry = User.query().filter(
128 128 User.active == true()).filter(
129 129 User.username != User.DEFAULT_USER)
130 130 if cache:
131 131 qry = qry.options(
132 132 FromCache("sql_cache_short", "get_active_users"))
133 133 return qry.count()
134 134
135 135 def create(self, form_data, cur_user=None):
136 136 if not cur_user:
137 137 cur_user = getattr(get_current_rhodecode_user(), 'username', None)
138 138
139 139 user_data = {
140 140 'username': form_data['username'],
141 141 'password': form_data['password'],
142 142 'email': form_data['email'],
143 143 'firstname': form_data['firstname'],
144 144 'lastname': form_data['lastname'],
145 145 'active': form_data['active'],
146 146 'extern_type': form_data['extern_type'],
147 147 'extern_name': form_data['extern_name'],
148 148 'admin': False,
149 149 'cur_user': cur_user
150 150 }
151 151
152 152 if 'create_repo_group' in form_data:
153 153 user_data['create_repo_group'] = str2bool(
154 154 form_data.get('create_repo_group'))
155 155
156 156 try:
157 157 if form_data.get('password_change'):
158 158 user_data['force_password_change'] = True
159 159 return UserModel().create_or_update(**user_data)
160 160 except Exception:
161 161 log.error(traceback.format_exc())
162 162 raise
163 163
164 164 def update_user(self, user, skip_attrs=None, **kwargs):
165 165 from rhodecode.lib.auth import get_crypt_password
166 166
167 167 user = self._get_user(user)
168 168 if user.username == User.DEFAULT_USER:
169 169 raise DefaultUserException(
170 170 "You can't edit this user (`%(username)s`) since it's "
171 171 "crucial for entire application" % {
172 172 'username': user.username})
173 173
174 174 # first store only defaults
175 175 user_attrs = {
176 176 'updating_user_id': user.user_id,
177 177 'username': user.username,
178 178 'password': user.password,
179 179 'email': user.email,
180 180 'firstname': user.name,
181 181 'lastname': user.lastname,
182 182 'description': user.description,
183 183 'active': user.active,
184 184 'admin': user.admin,
185 185 'extern_name': user.extern_name,
186 186 'extern_type': user.extern_type,
187 187 'language': user.user_data.get('language')
188 188 }
189 189
190 190 # in case there's new_password, that comes from form, use it to
191 191 # store password
192 192 if kwargs.get('new_password'):
193 193 kwargs['password'] = kwargs['new_password']
194 194
195 195 # cleanups, my_account password change form
196 196 kwargs.pop('current_password', None)
197 197 kwargs.pop('new_password', None)
198 198
199 199 # cleanups, user edit password change form
200 200 kwargs.pop('password_confirmation', None)
201 201 kwargs.pop('password_change', None)
202 202
203 203 # create repo group on user creation
204 204 kwargs.pop('create_repo_group', None)
205 205
206 206 # legacy forms send name, which is the firstname
207 207 firstname = kwargs.pop('name', None)
208 208 if firstname:
209 209 kwargs['firstname'] = firstname
210 210
211 211 for k, v in kwargs.items():
212 212 # skip if we don't want to update this
213 213 if skip_attrs and k in skip_attrs:
214 214 continue
215 215
216 216 user_attrs[k] = v
217 217
218 218 try:
219 219 return self.create_or_update(**user_attrs)
220 220 except Exception:
221 221 log.error(traceback.format_exc())
222 222 raise
223 223
224 224 def create_or_update(
225 225 self, username, password, email, firstname='', lastname='',
226 226 active=True, admin=False, extern_type=None, extern_name=None,
227 227 cur_user=None, plugin=None, force_password_change=False,
228 228 allow_to_create_user=True, create_repo_group=None,
229 229 updating_user_id=None, language=None, description='',
230 230 strict_creation_check=True):
231 231 """
232 232 Creates a new instance if not found, or updates current one
233 233
234 234 :param username:
235 235 :param password:
236 236 :param email:
237 237 :param firstname:
238 238 :param lastname:
239 239 :param active:
240 240 :param admin:
241 241 :param extern_type:
242 242 :param extern_name:
243 243 :param cur_user:
244 244 :param plugin: optional plugin this method was called from
245 245 :param force_password_change: toggles new or existing user flag
246 246 for password change
247 247 :param allow_to_create_user: Defines if the method can actually create
248 248 new users
249 249 :param create_repo_group: Defines if the method should also
250 250 create an repo group with user name, and owner
251 251 :param updating_user_id: if we set it up this is the user we want to
252 252 update this allows to editing username.
253 253 :param language: language of user from interface.
254 254 :param description: user description
255 255 :param strict_creation_check: checks for allowed creation license wise etc.
256 256
257 257 :returns: new User object with injected `is_new_user` attribute.
258 258 """
259 259
260 260 if not cur_user:
261 261 cur_user = getattr(get_current_rhodecode_user(), 'username', None)
262 262
263 263 from rhodecode.lib.auth import (
264 264 get_crypt_password, check_password, generate_auth_token)
265 265 from rhodecode.lib.hooks_base import (
266 266 log_create_user, check_allowed_create_user)
267 267
268 268 def _password_change(new_user, password):
269 269 old_password = new_user.password or ''
270 270 # empty password
271 271 if not old_password:
272 272 return False
273 273
274 274 # password check is only needed for RhodeCode internal auth calls
275 275 # in case it's a plugin we don't care
276 276 if not plugin:
277 277
278 278 # first check if we gave crypted password back, and if it
279 279 # matches it's not password change
280 280 if new_user.password == password:
281 281 return False
282 282
283 283 password_match = check_password(password, old_password)
284 284 if not password_match:
285 285 return True
286 286
287 287 return False
288 288
289 289 # read settings on default personal repo group creation
290 290 if create_repo_group is None:
291 291 default_create_repo_group = RepoGroupModel()\
292 292 .get_default_create_personal_repo_group()
293 293 create_repo_group = default_create_repo_group
294 294
295 295 user_data = {
296 296 'username': username,
297 297 'password': password,
298 298 'email': email,
299 299 'firstname': firstname,
300 300 'lastname': lastname,
301 301 'active': active,
302 302 'admin': admin
303 303 }
304 304
305 305 if updating_user_id:
306 306 log.debug('Checking for existing account in RhodeCode '
307 307 'database with user_id `%s` ', updating_user_id)
308 308 user = User.get(updating_user_id)
309 309 else:
310 310 log.debug('Checking for existing account in RhodeCode '
311 311 'database with username `%s` ', username)
312 312 user = User.get_by_username(username, case_insensitive=True)
313 313
314 314 if user is None:
315 315 # we check internal flag if this method is actually allowed to
316 316 # create new user
317 317 if not allow_to_create_user:
318 318 msg = ('Method wants to create new user, but it is not '
319 319 'allowed to do so')
320 320 log.warning(msg)
321 321 raise NotAllowedToCreateUserError(msg)
322 322
323 323 log.debug('Creating new user %s', username)
324 324
325 325 # only if we create user that is active
326 326 new_active_user = active
327 327 if new_active_user and strict_creation_check:
328 328 # raises UserCreationError if it's not allowed for any reason to
329 329 # create new active user, this also executes pre-create hooks
330 330 check_allowed_create_user(user_data, cur_user, strict_check=True)
331 331 events.trigger(events.UserPreCreate(user_data))
332 332 new_user = User()
333 333 edit = False
334 334 else:
335 335 log.debug('updating user `%s`', username)
336 336 events.trigger(events.UserPreUpdate(user, user_data))
337 337 new_user = user
338 338 edit = True
339 339
340 340 # we're not allowed to edit default user
341 341 if user.username == User.DEFAULT_USER:
342 342 raise DefaultUserException(
343 343 "You can't edit this user (`%(username)s`) since it's "
344 344 "crucial for entire application"
345 345 % {'username': user.username})
346 346
347 347 # inject special attribute that will tell us if User is new or old
348 348 new_user.is_new_user = not edit
349 349 # for users that didn's specify auth type, we use RhodeCode built in
350 350 from rhodecode.authentication.plugins import auth_rhodecode
351 351 extern_name = extern_name or auth_rhodecode.RhodeCodeAuthPlugin.uid
352 352 extern_type = extern_type or auth_rhodecode.RhodeCodeAuthPlugin.uid
353 353
354 354 try:
355 355 new_user.username = username
356 356 new_user.admin = admin
357 357 new_user.email = email
358 358 new_user.active = active
359 359 new_user.extern_name = safe_unicode(extern_name)
360 360 new_user.extern_type = safe_unicode(extern_type)
361 361 new_user.name = firstname
362 362 new_user.lastname = lastname
363 363 new_user.description = description
364 364
365 365 # set password only if creating an user or password is changed
366 366 if not edit or _password_change(new_user, password):
367 367 reason = 'new password' if edit else 'new user'
368 368 log.debug('Updating password reason=>%s', reason)
369 369 new_user.password = get_crypt_password(password) if password else None
370 370
371 371 if force_password_change:
372 372 new_user.update_userdata(force_password_change=True)
373 373 if language:
374 374 new_user.update_userdata(language=language)
375 375 new_user.update_userdata(notification_status=True)
376 376
377 377 self.sa.add(new_user)
378 378
379 379 if not edit and create_repo_group:
380 380 RepoGroupModel().create_personal_repo_group(
381 381 new_user, commit_early=False)
382 382
383 383 if not edit:
384 384 # add the RSS token
385 385 self.add_auth_token(
386 386 user=username, lifetime_minutes=-1,
387 387 role=self.auth_token_role.ROLE_FEED,
388 388 description=u'Generated feed token')
389 389
390 390 kwargs = new_user.get_dict()
391 391 # backward compat, require api_keys present
392 392 kwargs['api_keys'] = kwargs['auth_tokens']
393 393 log_create_user(created_by=cur_user, **kwargs)
394 394 events.trigger(events.UserPostCreate(user_data))
395 395 return new_user
396 396 except (DatabaseError,):
397 397 log.error(traceback.format_exc())
398 398 raise
399 399
400 400 def create_registration(self, form_data,
401 401 extern_name='rhodecode', extern_type='rhodecode'):
402 402 from rhodecode.model.notification import NotificationModel
403 403 from rhodecode.model.notification import EmailNotificationModel
404 404
405 405 try:
406 406 form_data['admin'] = False
407 407 form_data['extern_name'] = extern_name
408 408 form_data['extern_type'] = extern_type
409 409 new_user = self.create(form_data)
410 410
411 411 self.sa.add(new_user)
412 412 self.sa.flush()
413 413
414 414 user_data = new_user.get_dict()
415 415 kwargs = {
416 416 # use SQLALCHEMY safe dump of user data
417 417 'user': AttributeDict(user_data),
418 418 'date': datetime.datetime.now()
419 419 }
420 420 notification_type = EmailNotificationModel.TYPE_REGISTRATION
421 421 # pre-generate the subject for notification itself
422 422 (subject,
423 423 _h, _e, # we don't care about those
424 424 body_plaintext) = EmailNotificationModel().render_email(
425 425 notification_type, **kwargs)
426 426
427 427 # create notification objects, and emails
428 428 NotificationModel().create(
429 429 created_by=new_user,
430 430 notification_subject=subject,
431 431 notification_body=body_plaintext,
432 432 notification_type=notification_type,
433 433 recipients=None, # all admins
434 434 email_kwargs=kwargs,
435 435 )
436 436
437 437 return new_user
438 438 except Exception:
439 439 log.error(traceback.format_exc())
440 440 raise
441 441
442 442 def _handle_user_repos(self, username, repositories, handle_mode=None):
443 443 _superadmin = self.cls.get_first_super_admin()
444 444 left_overs = True
445 445
446 446 from rhodecode.model.repo import RepoModel
447 447
448 448 if handle_mode == 'detach':
449 449 for obj in repositories:
450 450 obj.user = _superadmin
451 451 # set description we know why we super admin now owns
452 452 # additional repositories that were orphaned !
453 453 obj.description += ' \n::detached repository from deleted user: %s' % (username,)
454 454 self.sa.add(obj)
455 455 left_overs = False
456 456 elif handle_mode == 'delete':
457 457 for obj in repositories:
458 458 RepoModel().delete(obj, forks='detach')
459 459 left_overs = False
460 460
461 461 # if nothing is done we have left overs left
462 462 return left_overs
463 463
464 464 def _handle_user_repo_groups(self, username, repository_groups,
465 465 handle_mode=None):
466 466 _superadmin = self.cls.get_first_super_admin()
467 467 left_overs = True
468 468
469 469 from rhodecode.model.repo_group import RepoGroupModel
470 470
471 471 if handle_mode == 'detach':
472 472 for r in repository_groups:
473 473 r.user = _superadmin
474 474 # set description we know why we super admin now owns
475 475 # additional repositories that were orphaned !
476 476 r.group_description += ' \n::detached repository group from deleted user: %s' % (username,)
477 477 r.personal = False
478 478 self.sa.add(r)
479 479 left_overs = False
480 480 elif handle_mode == 'delete':
481 481 for r in repository_groups:
482 482 RepoGroupModel().delete(r)
483 483 left_overs = False
484 484
485 485 # if nothing is done we have left overs left
486 486 return left_overs
487 487
488 488 def _handle_user_user_groups(self, username, user_groups, handle_mode=None):
489 489 _superadmin = self.cls.get_first_super_admin()
490 490 left_overs = True
491 491
492 492 from rhodecode.model.user_group import UserGroupModel
493 493
494 494 if handle_mode == 'detach':
495 495 for r in user_groups:
496 496 for user_user_group_to_perm in r.user_user_group_to_perm:
497 497 if user_user_group_to_perm.user.username == username:
498 498 user_user_group_to_perm.user = _superadmin
499 499 r.user = _superadmin
500 500 # set description we know why we super admin now owns
501 501 # additional repositories that were orphaned !
502 502 r.user_group_description += ' \n::detached user group from deleted user: %s' % (username,)
503 503 self.sa.add(r)
504 504 left_overs = False
505 505 elif handle_mode == 'delete':
506 506 for r in user_groups:
507 507 UserGroupModel().delete(r)
508 508 left_overs = False
509 509
510 510 # if nothing is done we have left overs left
511 511 return left_overs
512 512
513 513 def _handle_user_artifacts(self, username, artifacts, handle_mode=None):
514 514 _superadmin = self.cls.get_first_super_admin()
515 515 left_overs = True
516 516
517 517 if handle_mode == 'detach':
518 518 for a in artifacts:
519 519 a.upload_user = _superadmin
520 520 # set description we know why we super admin now owns
521 521 # additional artifacts that were orphaned !
522 522 a.file_description += ' \n::detached artifact from deleted user: %s' % (username,)
523 523 self.sa.add(a)
524 524 left_overs = False
525 525 elif handle_mode == 'delete':
526 526 from rhodecode.apps.file_store import utils as store_utils
527 527 storage = store_utils.get_file_storage(self.request.registry.settings)
528 528 for a in artifacts:
529 529 file_uid = a.file_uid
530 530 storage.delete(file_uid)
531 531 self.sa.delete(a)
532 532
533 533 left_overs = False
534 534
535 535 # if nothing is done we have left overs left
536 536 return left_overs
537 537
538 538 def delete(self, user, cur_user=None, handle_repos=None,
539 539 handle_repo_groups=None, handle_user_groups=None, handle_artifacts=None):
540 540 from rhodecode.lib.hooks_base import log_delete_user
541 541
542 542 if not cur_user:
543 543 cur_user = getattr(get_current_rhodecode_user(), 'username', None)
544 544 user = self._get_user(user)
545 545
546 546 try:
547 547 if user.username == User.DEFAULT_USER:
548 548 raise DefaultUserException(
549 549 u"You can't remove this user since it's"
550 550 u" crucial for entire application")
551 551
552 552 left_overs = self._handle_user_repos(
553 553 user.username, user.repositories, handle_repos)
554 554 if left_overs and user.repositories:
555 555 repos = [x.repo_name for x in user.repositories]
556 556 raise UserOwnsReposException(
557 557 u'user "%(username)s" still owns %(len_repos)s repositories and cannot be '
558 558 u'removed. Switch owners or remove those repositories:%(list_repos)s'
559 559 % {'username': user.username, 'len_repos': len(repos),
560 560 'list_repos': ', '.join(repos)})
561 561
562 562 left_overs = self._handle_user_repo_groups(
563 563 user.username, user.repository_groups, handle_repo_groups)
564 564 if left_overs and user.repository_groups:
565 565 repo_groups = [x.group_name for x in user.repository_groups]
566 566 raise UserOwnsRepoGroupsException(
567 567 u'user "%(username)s" still owns %(len_repo_groups)s repository groups and cannot be '
568 568 u'removed. Switch owners or remove those repository groups:%(list_repo_groups)s'
569 569 % {'username': user.username, 'len_repo_groups': len(repo_groups),
570 570 'list_repo_groups': ', '.join(repo_groups)})
571 571
572 572 left_overs = self._handle_user_user_groups(
573 573 user.username, user.user_groups, handle_user_groups)
574 574 if left_overs and user.user_groups:
575 575 user_groups = [x.users_group_name for x in user.user_groups]
576 576 raise UserOwnsUserGroupsException(
577 577 u'user "%s" still owns %s user groups and cannot be '
578 578 u'removed. Switch owners or remove those user groups:%s'
579 579 % (user.username, len(user_groups), ', '.join(user_groups)))
580 580
581 581 left_overs = self._handle_user_artifacts(
582 582 user.username, user.artifacts, handle_artifacts)
583 583 if left_overs and user.artifacts:
584 584 artifacts = [x.file_uid for x in user.artifacts]
585 585 raise UserOwnsArtifactsException(
586 586 u'user "%s" still owns %s artifacts and cannot be '
587 587 u'removed. Switch owners or remove those artifacts:%s'
588 588 % (user.username, len(artifacts), ', '.join(artifacts)))
589 589
590 590 user_data = user.get_dict() # fetch user data before expire
591 591
592 592 # we might change the user data with detach/delete, make sure
593 593 # the object is marked as expired before actually deleting !
594 594 self.sa.expire(user)
595 595 self.sa.delete(user)
596 596
597 597 log_delete_user(deleted_by=cur_user, **user_data)
598 598 except Exception:
599 599 log.error(traceback.format_exc())
600 600 raise
601 601
602 602 def reset_password_link(self, data, pwd_reset_url):
603 603 from rhodecode.lib.celerylib import tasks, run_task
604 604 from rhodecode.model.notification import EmailNotificationModel
605 605 user_email = data['email']
606 606 try:
607 607 user = User.get_by_email(user_email)
608 608 if user:
609 609 log.debug('password reset user found %s', user)
610 610
611 611 email_kwargs = {
612 612 'password_reset_url': pwd_reset_url,
613 613 'user': user,
614 614 'email': user_email,
615 'date': datetime.datetime.now()
615 'date': datetime.datetime.now(),
616 'first_admin_email': User.get_first_super_admin().email
616 617 }
617 618
618 619 (subject, headers, email_body,
619 620 email_body_plaintext) = EmailNotificationModel().render_email(
620 621 EmailNotificationModel.TYPE_PASSWORD_RESET, **email_kwargs)
621 622
622 623 recipients = [user_email]
623 624
624 625 action_logger_generic(
625 626 'sending password reset email to user: {}'.format(
626 627 user), namespace='security.password_reset')
627 628
628 629 run_task(tasks.send_email, recipients, subject,
629 630 email_body_plaintext, email_body)
630 631
631 632 else:
632 633 log.debug("password reset email %s not found", user_email)
633 634 except Exception:
634 635 log.error(traceback.format_exc())
635 636 return False
636 637
637 638 return True
638 639
639 640 def reset_password(self, data):
640 641 from rhodecode.lib.celerylib import tasks, run_task
641 642 from rhodecode.model.notification import EmailNotificationModel
642 643 from rhodecode.lib import auth
643 644 user_email = data['email']
644 645 pre_db = True
645 646 try:
646 647 user = User.get_by_email(user_email)
647 648 new_passwd = auth.PasswordGenerator().gen_password(
648 649 12, auth.PasswordGenerator.ALPHABETS_BIG_SMALL)
649 650 if user:
650 651 user.password = auth.get_crypt_password(new_passwd)
651 652 # also force this user to reset his password !
652 653 user.update_userdata(force_password_change=True)
653 654
654 655 Session().add(user)
655 656
656 657 # now delete the token in question
657 658 UserApiKeys = AuthTokenModel.cls
658 659 UserApiKeys().query().filter(
659 660 UserApiKeys.api_key == data['token']).delete()
660 661
661 662 Session().commit()
662 663 log.info('successfully reset password for `%s`', user_email)
663 664
664 665 if new_passwd is None:
665 666 raise Exception('unable to generate new password')
666 667
667 668 pre_db = False
668 669
669 670 email_kwargs = {
670 671 'new_password': new_passwd,
671 672 'user': user,
672 673 'email': user_email,
673 'date': datetime.datetime.now()
674 'date': datetime.datetime.now(),
675 'first_admin_email': User.get_first_super_admin().email
674 676 }
675 677
676 678 (subject, headers, email_body,
677 679 email_body_plaintext) = EmailNotificationModel().render_email(
678 680 EmailNotificationModel.TYPE_PASSWORD_RESET_CONFIRMATION,
679 681 **email_kwargs)
680 682
681 683 recipients = [user_email]
682 684
683 685 action_logger_generic(
684 686 'sent new password to user: {} with email: {}'.format(
685 687 user, user_email), namespace='security.password_reset')
686 688
687 689 run_task(tasks.send_email, recipients, subject,
688 690 email_body_plaintext, email_body)
689 691
690 692 except Exception:
691 693 log.error('Failed to update user password')
692 694 log.error(traceback.format_exc())
693 695 if pre_db:
694 696 # we rollback only if local db stuff fails. If it goes into
695 697 # run_task, we're pass rollback state this wouldn't work then
696 698 Session().rollback()
697 699
698 700 return True
699 701
700 702 def fill_data(self, auth_user, user_id=None, api_key=None, username=None):
701 703 """
702 704 Fetches auth_user by user_id,or api_key if present.
703 705 Fills auth_user attributes with those taken from database.
704 706 Additionally set's is_authenitated if lookup fails
705 707 present in database
706 708
707 709 :param auth_user: instance of user to set attributes
708 710 :param user_id: user id to fetch by
709 711 :param api_key: api key to fetch by
710 712 :param username: username to fetch by
711 713 """
712 714 def token_obfuscate(token):
713 715 if token:
714 716 return token[:4] + "****"
715 717
716 718 if user_id is None and api_key is None and username is None:
717 719 raise Exception('You need to pass user_id, api_key or username')
718 720
719 721 log.debug(
720 722 'AuthUser: fill data execution based on: '
721 723 'user_id:%s api_key:%s username:%s', user_id, api_key, username)
722 724 try:
723 725 dbuser = None
724 726 if user_id:
725 727 dbuser = self.get(user_id)
726 728 elif api_key:
727 729 dbuser = self.get_by_auth_token(api_key)
728 730 elif username:
729 731 dbuser = self.get_by_username(username)
730 732
731 733 if not dbuser:
732 734 log.warning(
733 735 'Unable to lookup user by id:%s api_key:%s username:%s',
734 736 user_id, token_obfuscate(api_key), username)
735 737 return False
736 738 if not dbuser.active:
737 739 log.debug('User `%s:%s` is inactive, skipping fill data',
738 740 username, user_id)
739 741 return False
740 742
741 743 log.debug('AuthUser: filling found user:%s data', dbuser)
742 744
743 745 attrs = {
744 746 'user_id': dbuser.user_id,
745 747 'username': dbuser.username,
746 748 'name': dbuser.name,
747 749 'first_name': dbuser.first_name,
748 750 'firstname': dbuser.firstname,
749 751 'last_name': dbuser.last_name,
750 752 'lastname': dbuser.lastname,
751 753 'admin': dbuser.admin,
752 754 'active': dbuser.active,
753 755
754 756 'email': dbuser.email,
755 757 'emails': dbuser.emails_cached(),
756 758 'short_contact': dbuser.short_contact,
757 759 'full_contact': dbuser.full_contact,
758 760 'full_name': dbuser.full_name,
759 761 'full_name_or_username': dbuser.full_name_or_username,
760 762
761 763 '_api_key': dbuser._api_key,
762 764 '_user_data': dbuser._user_data,
763 765
764 766 'created_on': dbuser.created_on,
765 767 'extern_name': dbuser.extern_name,
766 768 'extern_type': dbuser.extern_type,
767 769
768 770 'inherit_default_permissions': dbuser.inherit_default_permissions,
769 771
770 772 'language': dbuser.language,
771 773 'last_activity': dbuser.last_activity,
772 774 'last_login': dbuser.last_login,
773 775 'password': dbuser.password,
774 776 }
775 777 auth_user.__dict__.update(attrs)
776 778 except Exception:
777 779 log.error(traceback.format_exc())
778 780 auth_user.is_authenticated = False
779 781 return False
780 782
781 783 return True
782 784
783 785 def has_perm(self, user, perm):
784 786 perm = self._get_perm(perm)
785 787 user = self._get_user(user)
786 788
787 789 return UserToPerm.query().filter(UserToPerm.user == user)\
788 790 .filter(UserToPerm.permission == perm).scalar() is not None
789 791
790 792 def grant_perm(self, user, perm):
791 793 """
792 794 Grant user global permissions
793 795
794 796 :param user:
795 797 :param perm:
796 798 """
797 799 user = self._get_user(user)
798 800 perm = self._get_perm(perm)
799 801 # if this permission is already granted skip it
800 802 _perm = UserToPerm.query()\
801 803 .filter(UserToPerm.user == user)\
802 804 .filter(UserToPerm.permission == perm)\
803 805 .scalar()
804 806 if _perm:
805 807 return
806 808 new = UserToPerm()
807 809 new.user = user
808 810 new.permission = perm
809 811 self.sa.add(new)
810 812 return new
811 813
812 814 def revoke_perm(self, user, perm):
813 815 """
814 816 Revoke users global permissions
815 817
816 818 :param user:
817 819 :param perm:
818 820 """
819 821 user = self._get_user(user)
820 822 perm = self._get_perm(perm)
821 823
822 824 obj = UserToPerm.query()\
823 825 .filter(UserToPerm.user == user)\
824 826 .filter(UserToPerm.permission == perm)\
825 827 .scalar()
826 828 if obj:
827 829 self.sa.delete(obj)
828 830
829 831 def add_extra_email(self, user, email):
830 832 """
831 833 Adds email address to UserEmailMap
832 834
833 835 :param user:
834 836 :param email:
835 837 """
836 838
837 839 user = self._get_user(user)
838 840
839 841 obj = UserEmailMap()
840 842 obj.user = user
841 843 obj.email = email
842 844 self.sa.add(obj)
843 845 return obj
844 846
845 847 def delete_extra_email(self, user, email_id):
846 848 """
847 849 Removes email address from UserEmailMap
848 850
849 851 :param user:
850 852 :param email_id:
851 853 """
852 854 user = self._get_user(user)
853 855 obj = UserEmailMap.query().get(email_id)
854 856 if obj and obj.user_id == user.user_id:
855 857 self.sa.delete(obj)
856 858
857 859 def parse_ip_range(self, ip_range):
858 860 ip_list = []
859 861
860 862 def make_unique(value):
861 863 seen = []
862 864 return [c for c in value if not (c in seen or seen.append(c))]
863 865
864 866 # firsts split by commas
865 867 for ip_range in ip_range.split(','):
866 868 if not ip_range:
867 869 continue
868 870 ip_range = ip_range.strip()
869 871 if '-' in ip_range:
870 872 start_ip, end_ip = ip_range.split('-', 1)
871 873 start_ip = ipaddress.ip_address(safe_unicode(start_ip.strip()))
872 874 end_ip = ipaddress.ip_address(safe_unicode(end_ip.strip()))
873 875 parsed_ip_range = []
874 876
875 877 for index in xrange(int(start_ip), int(end_ip) + 1):
876 878 new_ip = ipaddress.ip_address(index)
877 879 parsed_ip_range.append(str(new_ip))
878 880 ip_list.extend(parsed_ip_range)
879 881 else:
880 882 ip_list.append(ip_range)
881 883
882 884 return make_unique(ip_list)
883 885
884 886 def add_extra_ip(self, user, ip, description=None):
885 887 """
886 888 Adds ip address to UserIpMap
887 889
888 890 :param user:
889 891 :param ip:
890 892 """
891 893
892 894 user = self._get_user(user)
893 895 obj = UserIpMap()
894 896 obj.user = user
895 897 obj.ip_addr = ip
896 898 obj.description = description
897 899 self.sa.add(obj)
898 900 return obj
899 901
900 902 auth_token_role = AuthTokenModel.cls
901 903
902 904 def add_auth_token(self, user, lifetime_minutes, role, description=u'',
903 905 scope_callback=None):
904 906 """
905 907 Add AuthToken for user.
906 908
907 909 :param user: username/user_id
908 910 :param lifetime_minutes: in minutes the lifetime for token, -1 equals no limit
909 911 :param role: one of AuthTokenModel.cls.ROLE_*
910 912 :param description: optional string description
911 913 """
912 914
913 915 token = AuthTokenModel().create(
914 916 user, description, lifetime_minutes, role)
915 917 if scope_callback and callable(scope_callback):
916 918 # call the callback if we provide, used to attach scope for EE edition
917 919 scope_callback(token)
918 920 return token
919 921
920 922 def delete_extra_ip(self, user, ip_id):
921 923 """
922 924 Removes ip address from UserIpMap
923 925
924 926 :param user:
925 927 :param ip_id:
926 928 """
927 929 user = self._get_user(user)
928 930 obj = UserIpMap.query().get(ip_id)
929 931 if obj and obj.user_id == user.user_id:
930 932 self.sa.delete(obj)
931 933
932 934 def get_accounts_in_creation_order(self, current_user=None):
933 935 """
934 936 Get accounts in order of creation for deactivation for license limits
935 937
936 938 pick currently logged in user, and append to the list in position 0
937 939 pick all super-admins in order of creation date and add it to the list
938 940 pick all other accounts in order of creation and add it to the list.
939 941
940 942 Based on that list, the last accounts can be disabled as they are
941 943 created at the end and don't include any of the super admins as well
942 944 as the current user.
943 945
944 946 :param current_user: optionally current user running this operation
945 947 """
946 948
947 949 if not current_user:
948 950 current_user = get_current_rhodecode_user()
949 951 active_super_admins = [
950 952 x.user_id for x in User.query()
951 953 .filter(User.user_id != current_user.user_id)
952 954 .filter(User.active == true())
953 955 .filter(User.admin == true())
954 956 .order_by(User.created_on.asc())]
955 957
956 958 active_regular_users = [
957 959 x.user_id for x in User.query()
958 960 .filter(User.user_id != current_user.user_id)
959 961 .filter(User.active == true())
960 962 .filter(User.admin == false())
961 963 .order_by(User.created_on.asc())]
962 964
963 965 list_of_accounts = [current_user.user_id]
964 966 list_of_accounts += active_super_admins
965 967 list_of_accounts += active_regular_users
966 968
967 969 return list_of_accounts
968 970
969 971 def deactivate_last_users(self, expected_users, current_user=None):
970 972 """
971 973 Deactivate accounts that are over the license limits.
972 974 Algorithm of which accounts to disabled is based on the formula:
973 975
974 976 Get current user, then super admins in creation order, then regular
975 977 active users in creation order.
976 978
977 979 Using that list we mark all accounts from the end of it as inactive.
978 980 This way we block only latest created accounts.
979 981
980 982 :param expected_users: list of users in special order, we deactivate
981 983 the end N amount of users from that list
982 984 """
983 985
984 986 list_of_accounts = self.get_accounts_in_creation_order(
985 987 current_user=current_user)
986 988
987 989 for acc_id in list_of_accounts[expected_users + 1:]:
988 990 user = User.get(acc_id)
989 991 log.info('Deactivating account %s for license unlock', user)
990 992 user.active = False
991 993 Session().add(user)
992 994 Session().commit()
993 995
994 996 return
995 997
996 998 def get_user_log(self, user, filter_term):
997 999 user_log = UserLog.query()\
998 1000 .filter(or_(UserLog.user_id == user.user_id,
999 1001 UserLog.username == user.username))\
1000 1002 .options(joinedload(UserLog.user))\
1001 1003 .options(joinedload(UserLog.repository))\
1002 1004 .order_by(UserLog.action_date.desc())
1003 1005
1004 1006 user_log = user_log_filter(user_log, filter_term)
1005 1007 return user_log
@@ -1,385 +1,387 b''
1 1
2 2 /******************************************************************************
3 3 * *
4 4 * DO NOT CHANGE THIS FILE MANUALLY *
5 5 * *
6 6 * *
7 7 * This file is automatically generated when the app starts up with *
8 8 * generate_js_files = true *
9 9 * *
10 10 * To add a route here pass jsroute=True to the route definition in the app *
11 11 * *
12 12 ******************************************************************************/
13 13 function registerRCRoutes() {
14 14 // routes registration
15 15 pyroutes.register('favicon', '/favicon.ico', []);
16 16 pyroutes.register('robots', '/robots.txt', []);
17 17 pyroutes.register('auth_home', '/_admin/auth*traverse', []);
18 18 pyroutes.register('global_integrations_new', '/_admin/integrations/new', []);
19 19 pyroutes.register('global_integrations_home', '/_admin/integrations', []);
20 20 pyroutes.register('global_integrations_list', '/_admin/integrations/%(integration)s', ['integration']);
21 21 pyroutes.register('global_integrations_create', '/_admin/integrations/%(integration)s/new', ['integration']);
22 22 pyroutes.register('global_integrations_edit', '/_admin/integrations/%(integration)s/%(integration_id)s', ['integration', 'integration_id']);
23 23 pyroutes.register('repo_group_integrations_home', '/%(repo_group_name)s/_settings/integrations', ['repo_group_name']);
24 24 pyroutes.register('repo_group_integrations_new', '/%(repo_group_name)s/_settings/integrations/new', ['repo_group_name']);
25 25 pyroutes.register('repo_group_integrations_list', '/%(repo_group_name)s/_settings/integrations/%(integration)s', ['repo_group_name', 'integration']);
26 26 pyroutes.register('repo_group_integrations_create', '/%(repo_group_name)s/_settings/integrations/%(integration)s/new', ['repo_group_name', 'integration']);
27 27 pyroutes.register('repo_group_integrations_edit', '/%(repo_group_name)s/_settings/integrations/%(integration)s/%(integration_id)s', ['repo_group_name', 'integration', 'integration_id']);
28 28 pyroutes.register('repo_integrations_home', '/%(repo_name)s/settings/integrations', ['repo_name']);
29 29 pyroutes.register('repo_integrations_new', '/%(repo_name)s/settings/integrations/new', ['repo_name']);
30 30 pyroutes.register('repo_integrations_list', '/%(repo_name)s/settings/integrations/%(integration)s', ['repo_name', 'integration']);
31 31 pyroutes.register('repo_integrations_create', '/%(repo_name)s/settings/integrations/%(integration)s/new', ['repo_name', 'integration']);
32 32 pyroutes.register('repo_integrations_edit', '/%(repo_name)s/settings/integrations/%(integration)s/%(integration_id)s', ['repo_name', 'integration', 'integration_id']);
33 33 pyroutes.register('hovercard_user', '/_hovercard/user/%(user_id)s', ['user_id']);
34 34 pyroutes.register('hovercard_user_group', '/_hovercard/user_group/%(user_group_id)s', ['user_group_id']);
35 35 pyroutes.register('hovercard_repo_commit', '/_hovercard/commit/%(repo_name)s/%(commit_id)s', ['repo_name', 'commit_id']);
36 36 pyroutes.register('ops_ping', '/_admin/ops/ping', []);
37 37 pyroutes.register('ops_error_test', '/_admin/ops/error', []);
38 38 pyroutes.register('ops_redirect_test', '/_admin/ops/redirect', []);
39 39 pyroutes.register('ops_ping_legacy', '/_admin/ping', []);
40 40 pyroutes.register('ops_error_test_legacy', '/_admin/error_test', []);
41 41 pyroutes.register('admin_home', '/_admin', []);
42 42 pyroutes.register('admin_audit_logs', '/_admin/audit_logs', []);
43 43 pyroutes.register('admin_audit_log_entry', '/_admin/audit_logs/%(audit_log_id)s', ['audit_log_id']);
44 44 pyroutes.register('pull_requests_global_0', '/_admin/pull_requests/%(pull_request_id)s', ['pull_request_id']);
45 45 pyroutes.register('pull_requests_global_1', '/_admin/pull-requests/%(pull_request_id)s', ['pull_request_id']);
46 46 pyroutes.register('pull_requests_global', '/_admin/pull-request/%(pull_request_id)s', ['pull_request_id']);
47 47 pyroutes.register('admin_settings_open_source', '/_admin/settings/open_source', []);
48 48 pyroutes.register('admin_settings_vcs_svn_generate_cfg', '/_admin/settings/vcs/svn_generate_cfg', []);
49 49 pyroutes.register('admin_settings_system', '/_admin/settings/system', []);
50 50 pyroutes.register('admin_settings_system_update', '/_admin/settings/system/updates', []);
51 51 pyroutes.register('admin_settings_exception_tracker', '/_admin/settings/exceptions', []);
52 52 pyroutes.register('admin_settings_exception_tracker_delete_all', '/_admin/settings/exceptions/delete', []);
53 53 pyroutes.register('admin_settings_exception_tracker_show', '/_admin/settings/exceptions/%(exception_id)s', ['exception_id']);
54 54 pyroutes.register('admin_settings_exception_tracker_delete', '/_admin/settings/exceptions/%(exception_id)s/delete', ['exception_id']);
55 55 pyroutes.register('admin_settings_sessions', '/_admin/settings/sessions', []);
56 56 pyroutes.register('admin_settings_sessions_cleanup', '/_admin/settings/sessions/cleanup', []);
57 57 pyroutes.register('admin_settings_process_management', '/_admin/settings/process_management', []);
58 58 pyroutes.register('admin_settings_process_management_data', '/_admin/settings/process_management/data', []);
59 59 pyroutes.register('admin_settings_process_management_signal', '/_admin/settings/process_management/signal', []);
60 60 pyroutes.register('admin_settings_process_management_master_signal', '/_admin/settings/process_management/master_signal', []);
61 61 pyroutes.register('admin_defaults_repositories', '/_admin/defaults/repositories', []);
62 62 pyroutes.register('admin_defaults_repositories_update', '/_admin/defaults/repositories/update', []);
63 63 pyroutes.register('admin_settings', '/_admin/settings', []);
64 64 pyroutes.register('admin_settings_update', '/_admin/settings/update', []);
65 65 pyroutes.register('admin_settings_global', '/_admin/settings/global', []);
66 66 pyroutes.register('admin_settings_global_update', '/_admin/settings/global/update', []);
67 67 pyroutes.register('admin_settings_vcs', '/_admin/settings/vcs', []);
68 68 pyroutes.register('admin_settings_vcs_update', '/_admin/settings/vcs/update', []);
69 69 pyroutes.register('admin_settings_vcs_svn_pattern_delete', '/_admin/settings/vcs/svn_pattern_delete', []);
70 70 pyroutes.register('admin_settings_mapping', '/_admin/settings/mapping', []);
71 71 pyroutes.register('admin_settings_mapping_update', '/_admin/settings/mapping/update', []);
72 72 pyroutes.register('admin_settings_visual', '/_admin/settings/visual', []);
73 73 pyroutes.register('admin_settings_visual_update', '/_admin/settings/visual/update', []);
74 74 pyroutes.register('admin_settings_issuetracker', '/_admin/settings/issue-tracker', []);
75 75 pyroutes.register('admin_settings_issuetracker_update', '/_admin/settings/issue-tracker/update', []);
76 76 pyroutes.register('admin_settings_issuetracker_test', '/_admin/settings/issue-tracker/test', []);
77 77 pyroutes.register('admin_settings_issuetracker_delete', '/_admin/settings/issue-tracker/delete', []);
78 78 pyroutes.register('admin_settings_email', '/_admin/settings/email', []);
79 79 pyroutes.register('admin_settings_email_update', '/_admin/settings/email/update', []);
80 80 pyroutes.register('admin_settings_hooks', '/_admin/settings/hooks', []);
81 81 pyroutes.register('admin_settings_hooks_update', '/_admin/settings/hooks/update', []);
82 82 pyroutes.register('admin_settings_hooks_delete', '/_admin/settings/hooks/delete', []);
83 83 pyroutes.register('admin_settings_search', '/_admin/settings/search', []);
84 84 pyroutes.register('admin_settings_labs', '/_admin/settings/labs', []);
85 85 pyroutes.register('admin_settings_labs_update', '/_admin/settings/labs/update', []);
86 86 pyroutes.register('admin_permissions_application', '/_admin/permissions/application', []);
87 87 pyroutes.register('admin_permissions_application_update', '/_admin/permissions/application/update', []);
88 88 pyroutes.register('admin_permissions_global', '/_admin/permissions/global', []);
89 89 pyroutes.register('admin_permissions_global_update', '/_admin/permissions/global/update', []);
90 90 pyroutes.register('admin_permissions_object', '/_admin/permissions/object', []);
91 91 pyroutes.register('admin_permissions_object_update', '/_admin/permissions/object/update', []);
92 92 pyroutes.register('admin_permissions_ips', '/_admin/permissions/ips', []);
93 93 pyroutes.register('admin_permissions_overview', '/_admin/permissions/overview', []);
94 94 pyroutes.register('admin_permissions_auth_token_access', '/_admin/permissions/auth_token_access', []);
95 95 pyroutes.register('admin_permissions_ssh_keys', '/_admin/permissions/ssh_keys', []);
96 96 pyroutes.register('admin_permissions_ssh_keys_data', '/_admin/permissions/ssh_keys/data', []);
97 97 pyroutes.register('admin_permissions_ssh_keys_update', '/_admin/permissions/ssh_keys/update', []);
98 98 pyroutes.register('users', '/_admin/users', []);
99 99 pyroutes.register('users_data', '/_admin/users_data', []);
100 100 pyroutes.register('users_create', '/_admin/users/create', []);
101 101 pyroutes.register('users_new', '/_admin/users/new', []);
102 102 pyroutes.register('user_edit', '/_admin/users/%(user_id)s/edit', ['user_id']);
103 103 pyroutes.register('user_edit_advanced', '/_admin/users/%(user_id)s/edit/advanced', ['user_id']);
104 104 pyroutes.register('user_edit_global_perms', '/_admin/users/%(user_id)s/edit/global_permissions', ['user_id']);
105 105 pyroutes.register('user_edit_global_perms_update', '/_admin/users/%(user_id)s/edit/global_permissions/update', ['user_id']);
106 106 pyroutes.register('user_update', '/_admin/users/%(user_id)s/update', ['user_id']);
107 107 pyroutes.register('user_delete', '/_admin/users/%(user_id)s/delete', ['user_id']);
108 108 pyroutes.register('user_enable_force_password_reset', '/_admin/users/%(user_id)s/password_reset_enable', ['user_id']);
109 109 pyroutes.register('user_disable_force_password_reset', '/_admin/users/%(user_id)s/password_reset_disable', ['user_id']);
110 110 pyroutes.register('user_create_personal_repo_group', '/_admin/users/%(user_id)s/create_repo_group', ['user_id']);
111 111 pyroutes.register('edit_user_auth_tokens_delete', '/_admin/users/%(user_id)s/edit/auth_tokens/delete', ['user_id']);
112 112 pyroutes.register('edit_user_ssh_keys', '/_admin/users/%(user_id)s/edit/ssh_keys', ['user_id']);
113 113 pyroutes.register('edit_user_ssh_keys_generate_keypair', '/_admin/users/%(user_id)s/edit/ssh_keys/generate', ['user_id']);
114 114 pyroutes.register('edit_user_ssh_keys_add', '/_admin/users/%(user_id)s/edit/ssh_keys/new', ['user_id']);
115 115 pyroutes.register('edit_user_ssh_keys_delete', '/_admin/users/%(user_id)s/edit/ssh_keys/delete', ['user_id']);
116 116 pyroutes.register('edit_user_emails', '/_admin/users/%(user_id)s/edit/emails', ['user_id']);
117 117 pyroutes.register('edit_user_emails_add', '/_admin/users/%(user_id)s/edit/emails/new', ['user_id']);
118 118 pyroutes.register('edit_user_emails_delete', '/_admin/users/%(user_id)s/edit/emails/delete', ['user_id']);
119 119 pyroutes.register('edit_user_ips', '/_admin/users/%(user_id)s/edit/ips', ['user_id']);
120 120 pyroutes.register('edit_user_ips_add', '/_admin/users/%(user_id)s/edit/ips/new', ['user_id']);
121 121 pyroutes.register('edit_user_ips_delete', '/_admin/users/%(user_id)s/edit/ips/delete', ['user_id']);
122 122 pyroutes.register('edit_user_perms_summary', '/_admin/users/%(user_id)s/edit/permissions_summary', ['user_id']);
123 123 pyroutes.register('edit_user_perms_summary_json', '/_admin/users/%(user_id)s/edit/permissions_summary/json', ['user_id']);
124 124 pyroutes.register('edit_user_groups_management', '/_admin/users/%(user_id)s/edit/groups_management', ['user_id']);
125 125 pyroutes.register('edit_user_groups_management_updates', '/_admin/users/%(user_id)s/edit/edit_user_groups_management/updates', ['user_id']);
126 126 pyroutes.register('edit_user_audit_logs', '/_admin/users/%(user_id)s/edit/audit', ['user_id']);
127 127 pyroutes.register('edit_user_audit_logs_download', '/_admin/users/%(user_id)s/edit/audit/download', ['user_id']);
128 128 pyroutes.register('edit_user_caches', '/_admin/users/%(user_id)s/edit/caches', ['user_id']);
129 129 pyroutes.register('edit_user_caches_update', '/_admin/users/%(user_id)s/edit/caches/update', ['user_id']);
130 130 pyroutes.register('user_groups', '/_admin/user_groups', []);
131 131 pyroutes.register('user_groups_data', '/_admin/user_groups_data', []);
132 132 pyroutes.register('user_groups_new', '/_admin/user_groups/new', []);
133 133 pyroutes.register('user_groups_create', '/_admin/user_groups/create', []);
134 134 pyroutes.register('repos', '/_admin/repos', []);
135 135 pyroutes.register('repo_new', '/_admin/repos/new', []);
136 136 pyroutes.register('repo_create', '/_admin/repos/create', []);
137 137 pyroutes.register('repo_groups', '/_admin/repo_groups', []);
138 138 pyroutes.register('repo_groups_data', '/_admin/repo_groups_data', []);
139 139 pyroutes.register('repo_group_new', '/_admin/repo_group/new', []);
140 140 pyroutes.register('repo_group_create', '/_admin/repo_group/create', []);
141 141 pyroutes.register('channelstream_connect', '/_admin/channelstream/connect', []);
142 142 pyroutes.register('channelstream_subscribe', '/_admin/channelstream/subscribe', []);
143 143 pyroutes.register('channelstream_proxy', '/_channelstream', []);
144 144 pyroutes.register('upload_file', '/_file_store/upload', []);
145 145 pyroutes.register('download_file', '/_file_store/download/%(fid)s', ['fid']);
146 146 pyroutes.register('download_file_by_token', '/_file_store/token-download/%(_auth_token)s/%(fid)s', ['_auth_token', 'fid']);
147 147 pyroutes.register('logout', '/_admin/logout', []);
148 148 pyroutes.register('reset_password', '/_admin/password_reset', []);
149 149 pyroutes.register('reset_password_confirmation', '/_admin/password_reset_confirmation', []);
150 150 pyroutes.register('home', '/', []);
151 151 pyroutes.register('user_autocomplete_data', '/_users', []);
152 152 pyroutes.register('user_group_autocomplete_data', '/_user_groups', []);
153 153 pyroutes.register('repo_list_data', '/_repos', []);
154 154 pyroutes.register('repo_group_list_data', '/_repo_groups', []);
155 155 pyroutes.register('goto_switcher_data', '/_goto_data', []);
156 156 pyroutes.register('markup_preview', '/_markup_preview', []);
157 157 pyroutes.register('file_preview', '/_file_preview', []);
158 158 pyroutes.register('store_user_session_value', '/_store_session_attr', []);
159 159 pyroutes.register('journal', '/_admin/journal', []);
160 160 pyroutes.register('journal_rss', '/_admin/journal/rss', []);
161 161 pyroutes.register('journal_atom', '/_admin/journal/atom', []);
162 162 pyroutes.register('journal_public', '/_admin/public_journal', []);
163 163 pyroutes.register('journal_public_atom', '/_admin/public_journal/atom', []);
164 164 pyroutes.register('journal_public_atom_old', '/_admin/public_journal_atom', []);
165 165 pyroutes.register('journal_public_rss', '/_admin/public_journal/rss', []);
166 166 pyroutes.register('journal_public_rss_old', '/_admin/public_journal_rss', []);
167 167 pyroutes.register('toggle_following', '/_admin/toggle_following', []);
168 168 pyroutes.register('repo_creating', '/%(repo_name)s/repo_creating', ['repo_name']);
169 169 pyroutes.register('repo_creating_check', '/%(repo_name)s/repo_creating_check', ['repo_name']);
170 170 pyroutes.register('repo_summary_explicit', '/%(repo_name)s/summary', ['repo_name']);
171 171 pyroutes.register('repo_summary_commits', '/%(repo_name)s/summary-commits', ['repo_name']);
172 172 pyroutes.register('repo_commit', '/%(repo_name)s/changeset/%(commit_id)s', ['repo_name', 'commit_id']);
173 173 pyroutes.register('repo_commit_children', '/%(repo_name)s/changeset_children/%(commit_id)s', ['repo_name', 'commit_id']);
174 174 pyroutes.register('repo_commit_parents', '/%(repo_name)s/changeset_parents/%(commit_id)s', ['repo_name', 'commit_id']);
175 175 pyroutes.register('repo_commit_raw', '/%(repo_name)s/changeset-diff/%(commit_id)s', ['repo_name', 'commit_id']);
176 176 pyroutes.register('repo_commit_patch', '/%(repo_name)s/changeset-patch/%(commit_id)s', ['repo_name', 'commit_id']);
177 177 pyroutes.register('repo_commit_download', '/%(repo_name)s/changeset-download/%(commit_id)s', ['repo_name', 'commit_id']);
178 178 pyroutes.register('repo_commit_data', '/%(repo_name)s/changeset-data/%(commit_id)s', ['repo_name', 'commit_id']);
179 179 pyroutes.register('repo_commit_comment_create', '/%(repo_name)s/changeset/%(commit_id)s/comment/create', ['repo_name', 'commit_id']);
180 180 pyroutes.register('repo_commit_comment_preview', '/%(repo_name)s/changeset/%(commit_id)s/comment/preview', ['repo_name', 'commit_id']);
181 181 pyroutes.register('repo_commit_comment_attachment_upload', '/%(repo_name)s/changeset/%(commit_id)s/comment/attachment_upload', ['repo_name', 'commit_id']);
182 182 pyroutes.register('repo_commit_comment_delete', '/%(repo_name)s/changeset/%(commit_id)s/comment/%(comment_id)s/delete', ['repo_name', 'commit_id', 'comment_id']);
183 183 pyroutes.register('repo_commit_raw_deprecated', '/%(repo_name)s/raw-changeset/%(commit_id)s', ['repo_name', 'commit_id']);
184 184 pyroutes.register('repo_archivefile', '/%(repo_name)s/archive/%(fname)s', ['repo_name', 'fname']);
185 185 pyroutes.register('repo_files_diff', '/%(repo_name)s/diff/%(f_path)s', ['repo_name', 'f_path']);
186 186 pyroutes.register('repo_files_diff_2way_redirect', '/%(repo_name)s/diff-2way/%(f_path)s', ['repo_name', 'f_path']);
187 187 pyroutes.register('repo_files', '/%(repo_name)s/files/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
188 188 pyroutes.register('repo_files:default_path', '/%(repo_name)s/files/%(commit_id)s/', ['repo_name', 'commit_id']);
189 189 pyroutes.register('repo_files:default_commit', '/%(repo_name)s/files', ['repo_name']);
190 190 pyroutes.register('repo_files:rendered', '/%(repo_name)s/render/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
191 191 pyroutes.register('repo_files:annotated', '/%(repo_name)s/annotate/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
192 192 pyroutes.register('repo_files:annotated_previous', '/%(repo_name)s/annotate-previous/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
193 193 pyroutes.register('repo_nodetree_full', '/%(repo_name)s/nodetree_full/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
194 194 pyroutes.register('repo_nodetree_full:default_path', '/%(repo_name)s/nodetree_full/%(commit_id)s/', ['repo_name', 'commit_id']);
195 195 pyroutes.register('repo_files_nodelist', '/%(repo_name)s/nodelist/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
196 196 pyroutes.register('repo_file_raw', '/%(repo_name)s/raw/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
197 197 pyroutes.register('repo_file_download', '/%(repo_name)s/download/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
198 198 pyroutes.register('repo_file_download:legacy', '/%(repo_name)s/rawfile/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
199 199 pyroutes.register('repo_file_history', '/%(repo_name)s/history/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
200 200 pyroutes.register('repo_file_authors', '/%(repo_name)s/authors/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
201 201 pyroutes.register('repo_files_remove_file', '/%(repo_name)s/remove_file/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
202 202 pyroutes.register('repo_files_delete_file', '/%(repo_name)s/delete_file/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
203 203 pyroutes.register('repo_files_edit_file', '/%(repo_name)s/edit_file/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
204 204 pyroutes.register('repo_files_update_file', '/%(repo_name)s/update_file/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
205 205 pyroutes.register('repo_files_add_file', '/%(repo_name)s/add_file/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
206 206 pyroutes.register('repo_files_upload_file', '/%(repo_name)s/upload_file/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
207 207 pyroutes.register('repo_files_create_file', '/%(repo_name)s/create_file/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
208 208 pyroutes.register('repo_refs_data', '/%(repo_name)s/refs-data', ['repo_name']);
209 209 pyroutes.register('repo_refs_changelog_data', '/%(repo_name)s/refs-data-changelog', ['repo_name']);
210 210 pyroutes.register('repo_stats', '/%(repo_name)s/repo_stats/%(commit_id)s', ['repo_name', 'commit_id']);
211 211 pyroutes.register('repo_commits', '/%(repo_name)s/commits', ['repo_name']);
212 212 pyroutes.register('repo_commits_file', '/%(repo_name)s/commits/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
213 213 pyroutes.register('repo_commits_elements', '/%(repo_name)s/commits_elements', ['repo_name']);
214 214 pyroutes.register('repo_commits_elements_file', '/%(repo_name)s/commits_elements/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
215 215 pyroutes.register('repo_changelog', '/%(repo_name)s/changelog', ['repo_name']);
216 216 pyroutes.register('repo_changelog_file', '/%(repo_name)s/changelog/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
217 217 pyroutes.register('repo_compare_select', '/%(repo_name)s/compare', ['repo_name']);
218 218 pyroutes.register('repo_compare', '/%(repo_name)s/compare/%(source_ref_type)s@%(source_ref)s...%(target_ref_type)s@%(target_ref)s', ['repo_name', 'source_ref_type', 'source_ref', 'target_ref_type', 'target_ref']);
219 219 pyroutes.register('tags_home', '/%(repo_name)s/tags', ['repo_name']);
220 220 pyroutes.register('branches_home', '/%(repo_name)s/branches', ['repo_name']);
221 221 pyroutes.register('bookmarks_home', '/%(repo_name)s/bookmarks', ['repo_name']);
222 222 pyroutes.register('repo_fork_new', '/%(repo_name)s/fork', ['repo_name']);
223 223 pyroutes.register('repo_fork_create', '/%(repo_name)s/fork/create', ['repo_name']);
224 224 pyroutes.register('repo_forks_show_all', '/%(repo_name)s/forks', ['repo_name']);
225 225 pyroutes.register('repo_forks_data', '/%(repo_name)s/forks/data', ['repo_name']);
226 226 pyroutes.register('pullrequest_show', '/%(repo_name)s/pull-request/%(pull_request_id)s', ['repo_name', 'pull_request_id']);
227 227 pyroutes.register('pullrequest_show_all', '/%(repo_name)s/pull-request', ['repo_name']);
228 228 pyroutes.register('pullrequest_show_all_data', '/%(repo_name)s/pull-request-data', ['repo_name']);
229 229 pyroutes.register('pullrequest_repo_refs', '/%(repo_name)s/pull-request/refs/%(target_repo_name)s', ['repo_name', 'target_repo_name']);
230 230 pyroutes.register('pullrequest_repo_targets', '/%(repo_name)s/pull-request/repo-targets', ['repo_name']);
231 231 pyroutes.register('pullrequest_new', '/%(repo_name)s/pull-request/new', ['repo_name']);
232 232 pyroutes.register('pullrequest_create', '/%(repo_name)s/pull-request/create', ['repo_name']);
233 233 pyroutes.register('pullrequest_update', '/%(repo_name)s/pull-request/%(pull_request_id)s/update', ['repo_name', 'pull_request_id']);
234 234 pyroutes.register('pullrequest_merge', '/%(repo_name)s/pull-request/%(pull_request_id)s/merge', ['repo_name', 'pull_request_id']);
235 235 pyroutes.register('pullrequest_delete', '/%(repo_name)s/pull-request/%(pull_request_id)s/delete', ['repo_name', 'pull_request_id']);
236 236 pyroutes.register('pullrequest_comment_create', '/%(repo_name)s/pull-request/%(pull_request_id)s/comment', ['repo_name', 'pull_request_id']);
237 237 pyroutes.register('pullrequest_comment_delete', '/%(repo_name)s/pull-request/%(pull_request_id)s/comment/%(comment_id)s/delete', ['repo_name', 'pull_request_id', 'comment_id']);
238 238 pyroutes.register('edit_repo', '/%(repo_name)s/settings', ['repo_name']);
239 239 pyroutes.register('edit_repo_advanced', '/%(repo_name)s/settings/advanced', ['repo_name']);
240 240 pyroutes.register('edit_repo_advanced_archive', '/%(repo_name)s/settings/advanced/archive', ['repo_name']);
241 241 pyroutes.register('edit_repo_advanced_delete', '/%(repo_name)s/settings/advanced/delete', ['repo_name']);
242 242 pyroutes.register('edit_repo_advanced_locking', '/%(repo_name)s/settings/advanced/locking', ['repo_name']);
243 243 pyroutes.register('edit_repo_advanced_journal', '/%(repo_name)s/settings/advanced/journal', ['repo_name']);
244 244 pyroutes.register('edit_repo_advanced_fork', '/%(repo_name)s/settings/advanced/fork', ['repo_name']);
245 245 pyroutes.register('edit_repo_advanced_hooks', '/%(repo_name)s/settings/advanced/hooks', ['repo_name']);
246 246 pyroutes.register('edit_repo_caches', '/%(repo_name)s/settings/caches', ['repo_name']);
247 247 pyroutes.register('edit_repo_perms', '/%(repo_name)s/settings/permissions', ['repo_name']);
248 248 pyroutes.register('edit_repo_perms_set_private', '/%(repo_name)s/settings/permissions/set_private', ['repo_name']);
249 249 pyroutes.register('edit_repo_maintenance', '/%(repo_name)s/settings/maintenance', ['repo_name']);
250 250 pyroutes.register('edit_repo_maintenance_execute', '/%(repo_name)s/settings/maintenance/execute', ['repo_name']);
251 251 pyroutes.register('edit_repo_fields', '/%(repo_name)s/settings/fields', ['repo_name']);
252 252 pyroutes.register('edit_repo_fields_create', '/%(repo_name)s/settings/fields/create', ['repo_name']);
253 253 pyroutes.register('edit_repo_fields_delete', '/%(repo_name)s/settings/fields/%(field_id)s/delete', ['repo_name', 'field_id']);
254 254 pyroutes.register('repo_edit_toggle_locking', '/%(repo_name)s/settings/toggle_locking', ['repo_name']);
255 255 pyroutes.register('edit_repo_remote', '/%(repo_name)s/settings/remote', ['repo_name']);
256 256 pyroutes.register('edit_repo_remote_pull', '/%(repo_name)s/settings/remote/pull', ['repo_name']);
257 257 pyroutes.register('edit_repo_statistics', '/%(repo_name)s/settings/statistics', ['repo_name']);
258 258 pyroutes.register('edit_repo_statistics_reset', '/%(repo_name)s/settings/statistics/update', ['repo_name']);
259 259 pyroutes.register('edit_repo_issuetracker', '/%(repo_name)s/settings/issue_trackers', ['repo_name']);
260 260 pyroutes.register('edit_repo_issuetracker_test', '/%(repo_name)s/settings/issue_trackers/test', ['repo_name']);
261 261 pyroutes.register('edit_repo_issuetracker_delete', '/%(repo_name)s/settings/issue_trackers/delete', ['repo_name']);
262 262 pyroutes.register('edit_repo_issuetracker_update', '/%(repo_name)s/settings/issue_trackers/update', ['repo_name']);
263 263 pyroutes.register('edit_repo_vcs', '/%(repo_name)s/settings/vcs', ['repo_name']);
264 264 pyroutes.register('edit_repo_vcs_update', '/%(repo_name)s/settings/vcs/update', ['repo_name']);
265 265 pyroutes.register('edit_repo_vcs_svn_pattern_delete', '/%(repo_name)s/settings/vcs/svn_pattern/delete', ['repo_name']);
266 266 pyroutes.register('repo_reviewers', '/%(repo_name)s/settings/review/rules', ['repo_name']);
267 267 pyroutes.register('repo_default_reviewers_data', '/%(repo_name)s/settings/review/default-reviewers', ['repo_name']);
268 268 pyroutes.register('edit_repo_strip', '/%(repo_name)s/settings/strip', ['repo_name']);
269 269 pyroutes.register('strip_check', '/%(repo_name)s/settings/strip_check', ['repo_name']);
270 270 pyroutes.register('strip_execute', '/%(repo_name)s/settings/strip_execute', ['repo_name']);
271 271 pyroutes.register('edit_repo_audit_logs', '/%(repo_name)s/settings/audit_logs', ['repo_name']);
272 272 pyroutes.register('rss_feed_home', '/%(repo_name)s/feed-rss', ['repo_name']);
273 273 pyroutes.register('atom_feed_home', '/%(repo_name)s/feed-atom', ['repo_name']);
274 274 pyroutes.register('rss_feed_home_old', '/%(repo_name)s/feed/rss', ['repo_name']);
275 275 pyroutes.register('atom_feed_home_old', '/%(repo_name)s/feed/atom', ['repo_name']);
276 276 pyroutes.register('repo_summary', '/%(repo_name)s', ['repo_name']);
277 277 pyroutes.register('repo_summary_slash', '/%(repo_name)s/', ['repo_name']);
278 278 pyroutes.register('edit_repo_group', '/%(repo_group_name)s/_edit', ['repo_group_name']);
279 279 pyroutes.register('edit_repo_group_advanced', '/%(repo_group_name)s/_settings/advanced', ['repo_group_name']);
280 280 pyroutes.register('edit_repo_group_advanced_delete', '/%(repo_group_name)s/_settings/advanced/delete', ['repo_group_name']);
281 281 pyroutes.register('edit_repo_group_perms', '/%(repo_group_name)s/_settings/permissions', ['repo_group_name']);
282 282 pyroutes.register('edit_repo_group_perms_update', '/%(repo_group_name)s/_settings/permissions/update', ['repo_group_name']);
283 283 pyroutes.register('repo_group_home', '/%(repo_group_name)s', ['repo_group_name']);
284 284 pyroutes.register('repo_group_home_slash', '/%(repo_group_name)s/', ['repo_group_name']);
285 285 pyroutes.register('user_group_members_data', '/_admin/user_groups/%(user_group_id)s/members', ['user_group_id']);
286 286 pyroutes.register('edit_user_group_perms_summary', '/_admin/user_groups/%(user_group_id)s/edit/permissions_summary', ['user_group_id']);
287 287 pyroutes.register('edit_user_group_perms_summary_json', '/_admin/user_groups/%(user_group_id)s/edit/permissions_summary/json', ['user_group_id']);
288 288 pyroutes.register('edit_user_group', '/_admin/user_groups/%(user_group_id)s/edit', ['user_group_id']);
289 289 pyroutes.register('user_groups_update', '/_admin/user_groups/%(user_group_id)s/update', ['user_group_id']);
290 290 pyroutes.register('edit_user_group_global_perms', '/_admin/user_groups/%(user_group_id)s/edit/global_permissions', ['user_group_id']);
291 291 pyroutes.register('edit_user_group_global_perms_update', '/_admin/user_groups/%(user_group_id)s/edit/global_permissions/update', ['user_group_id']);
292 292 pyroutes.register('edit_user_group_perms', '/_admin/user_groups/%(user_group_id)s/edit/permissions', ['user_group_id']);
293 293 pyroutes.register('edit_user_group_perms_update', '/_admin/user_groups/%(user_group_id)s/edit/permissions/update', ['user_group_id']);
294 294 pyroutes.register('edit_user_group_advanced', '/_admin/user_groups/%(user_group_id)s/edit/advanced', ['user_group_id']);
295 295 pyroutes.register('edit_user_group_advanced_sync', '/_admin/user_groups/%(user_group_id)s/edit/advanced/sync', ['user_group_id']);
296 296 pyroutes.register('user_groups_delete', '/_admin/user_groups/%(user_group_id)s/delete', ['user_group_id']);
297 297 pyroutes.register('search', '/_admin/search', []);
298 298 pyroutes.register('search_repo', '/%(repo_name)s/_search', ['repo_name']);
299 299 pyroutes.register('search_repo_alt', '/%(repo_name)s/search', ['repo_name']);
300 300 pyroutes.register('search_repo_group', '/%(repo_group_name)s/_search', ['repo_group_name']);
301 301 pyroutes.register('user_profile', '/_profiles/%(username)s', ['username']);
302 302 pyroutes.register('user_group_profile', '/_profile_user_group/%(user_group_name)s', ['user_group_name']);
303 303 pyroutes.register('my_account_profile', '/_admin/my_account/profile', []);
304 304 pyroutes.register('my_account_edit', '/_admin/my_account/edit', []);
305 305 pyroutes.register('my_account_update', '/_admin/my_account/update', []);
306 306 pyroutes.register('my_account_password', '/_admin/my_account/password', []);
307 307 pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []);
308 308 pyroutes.register('my_account_auth_tokens_delete', '/_admin/my_account/auth_tokens/delete', []);
309 309 pyroutes.register('my_account_ssh_keys', '/_admin/my_account/ssh_keys', []);
310 310 pyroutes.register('my_account_ssh_keys_generate', '/_admin/my_account/ssh_keys/generate', []);
311 311 pyroutes.register('my_account_ssh_keys_add', '/_admin/my_account/ssh_keys/new', []);
312 312 pyroutes.register('my_account_ssh_keys_delete', '/_admin/my_account/ssh_keys/delete', []);
313 313 pyroutes.register('my_account_user_group_membership', '/_admin/my_account/user_group_membership', []);
314 314 pyroutes.register('my_account_emails', '/_admin/my_account/emails', []);
315 315 pyroutes.register('my_account_emails_add', '/_admin/my_account/emails/new', []);
316 316 pyroutes.register('my_account_emails_delete', '/_admin/my_account/emails/delete', []);
317 317 pyroutes.register('my_account_repos', '/_admin/my_account/repos', []);
318 318 pyroutes.register('my_account_watched', '/_admin/my_account/watched', []);
319 319 pyroutes.register('my_account_bookmarks', '/_admin/my_account/bookmarks', []);
320 320 pyroutes.register('my_account_bookmarks_update', '/_admin/my_account/bookmarks/update', []);
321 321 pyroutes.register('my_account_goto_bookmark', '/_admin/my_account/bookmark/%(bookmark_id)s', ['bookmark_id']);
322 322 pyroutes.register('my_account_perms', '/_admin/my_account/perms', []);
323 323 pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []);
324 324 pyroutes.register('my_account_notifications_toggle_visibility', '/_admin/my_account/toggle_visibility', []);
325 325 pyroutes.register('my_account_pullrequests', '/_admin/my_account/pull_requests', []);
326 326 pyroutes.register('my_account_pullrequests_data', '/_admin/my_account/pull_requests/data', []);
327 327 pyroutes.register('notifications_show_all', '/_admin/notifications', []);
328 328 pyroutes.register('notifications_mark_all_read', '/_admin/notifications/mark_all_read', []);
329 329 pyroutes.register('notifications_show', '/_admin/notifications/%(notification_id)s', ['notification_id']);
330 330 pyroutes.register('notifications_update', '/_admin/notifications/%(notification_id)s/update', ['notification_id']);
331 331 pyroutes.register('notifications_delete', '/_admin/notifications/%(notification_id)s/delete', ['notification_id']);
332 332 pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []);
333 333 pyroutes.register('gists_show', '/_admin/gists', []);
334 334 pyroutes.register('gists_new', '/_admin/gists/new', []);
335 335 pyroutes.register('gists_create', '/_admin/gists/create', []);
336 336 pyroutes.register('gist_show', '/_admin/gists/%(gist_id)s', ['gist_id']);
337 337 pyroutes.register('gist_delete', '/_admin/gists/%(gist_id)s/delete', ['gist_id']);
338 338 pyroutes.register('gist_edit', '/_admin/gists/%(gist_id)s/edit', ['gist_id']);
339 339 pyroutes.register('gist_edit_check_revision', '/_admin/gists/%(gist_id)s/edit/check_revision', ['gist_id']);
340 340 pyroutes.register('gist_update', '/_admin/gists/%(gist_id)s/update', ['gist_id']);
341 341 pyroutes.register('gist_show_rev', '/_admin/gists/%(gist_id)s/%(revision)s', ['gist_id', 'revision']);
342 342 pyroutes.register('gist_show_formatted', '/_admin/gists/%(gist_id)s/%(revision)s/%(format)s', ['gist_id', 'revision', 'format']);
343 343 pyroutes.register('gist_show_formatted_path', '/_admin/gists/%(gist_id)s/%(revision)s/%(format)s/%(f_path)s', ['gist_id', 'revision', 'format', 'f_path']);
344 344 pyroutes.register('debug_style_home', '/_admin/debug_style', []);
345 pyroutes.register('debug_style_email', '/_admin/debug_style/email/%(email_id)s', ['email_id']);
346 pyroutes.register('debug_style_email_plain_rendered', '/_admin/debug_style/email-rendered/%(email_id)s', ['email_id']);
345 347 pyroutes.register('debug_style_template', '/_admin/debug_style/t/%(t_path)s', ['t_path']);
346 348 pyroutes.register('apiv2', '/_admin/api', []);
347 349 pyroutes.register('admin_settings_license', '/_admin/settings/license', []);
348 350 pyroutes.register('admin_settings_license_unlock', '/_admin/settings/license_unlock', []);
349 351 pyroutes.register('login', '/_admin/login', []);
350 352 pyroutes.register('register', '/_admin/register', []);
351 353 pyroutes.register('repo_reviewers_review_rule_new', '/%(repo_name)s/settings/review/rules/new', ['repo_name']);
352 354 pyroutes.register('repo_reviewers_review_rule_edit', '/%(repo_name)s/settings/review/rules/%(rule_id)s', ['repo_name', 'rule_id']);
353 355 pyroutes.register('repo_reviewers_review_rule_delete', '/%(repo_name)s/settings/review/rules/%(rule_id)s/delete', ['repo_name', 'rule_id']);
354 356 pyroutes.register('plugin_admin_chat', '/_admin/plugin_admin_chat/%(action)s', ['action']);
355 357 pyroutes.register('edit_user_auth_tokens', '/_admin/users/%(user_id)s/edit/auth_tokens', ['user_id']);
356 358 pyroutes.register('edit_user_auth_tokens_add', '/_admin/users/%(user_id)s/edit/auth_tokens/new', ['user_id']);
357 359 pyroutes.register('admin_settings_scheduler_show_tasks', '/_admin/settings/scheduler/_tasks', []);
358 360 pyroutes.register('admin_settings_scheduler_show_all', '/_admin/settings/scheduler', []);
359 361 pyroutes.register('admin_settings_scheduler_new', '/_admin/settings/scheduler/new', []);
360 362 pyroutes.register('admin_settings_scheduler_create', '/_admin/settings/scheduler/create', []);
361 363 pyroutes.register('admin_settings_scheduler_edit', '/_admin/settings/scheduler/%(schedule_id)s', ['schedule_id']);
362 364 pyroutes.register('admin_settings_scheduler_update', '/_admin/settings/scheduler/%(schedule_id)s/update', ['schedule_id']);
363 365 pyroutes.register('admin_settings_scheduler_delete', '/_admin/settings/scheduler/%(schedule_id)s/delete', ['schedule_id']);
364 366 pyroutes.register('admin_settings_scheduler_execute', '/_admin/settings/scheduler/%(schedule_id)s/execute', ['schedule_id']);
365 367 pyroutes.register('admin_settings_automation', '/_admin/settings/automation', []);
366 368 pyroutes.register('admin_settings_automation_update', '/_admin/settings/automation/%(entry_id)s/update', ['entry_id']);
367 369 pyroutes.register('admin_permissions_branch', '/_admin/permissions/branch', []);
368 370 pyroutes.register('admin_permissions_branch_update', '/_admin/permissions/branch/update', []);
369 371 pyroutes.register('my_account_auth_tokens', '/_admin/my_account/auth_tokens', []);
370 372 pyroutes.register('my_account_auth_tokens_add', '/_admin/my_account/auth_tokens/new', []);
371 373 pyroutes.register('my_account_external_identity', '/_admin/my_account/external-identity', []);
372 374 pyroutes.register('my_account_external_identity_delete', '/_admin/my_account/external-identity/delete', []);
373 375 pyroutes.register('repo_artifacts_list', '/%(repo_name)s/artifacts', ['repo_name']);
374 376 pyroutes.register('repo_artifacts_data', '/%(repo_name)s/artifacts_data', ['repo_name']);
375 377 pyroutes.register('repo_artifacts_new', '/%(repo_name)s/artifacts/new', ['repo_name']);
376 378 pyroutes.register('repo_artifacts_get', '/%(repo_name)s/artifacts/download/%(uid)s', ['repo_name', 'uid']);
377 379 pyroutes.register('repo_artifacts_store', '/%(repo_name)s/artifacts/store', ['repo_name']);
378 380 pyroutes.register('repo_artifacts_info', '/%(repo_name)s/artifacts/info/%(uid)s', ['repo_name', 'uid']);
379 381 pyroutes.register('repo_artifacts_delete', '/%(repo_name)s/artifacts/delete/%(uid)s', ['repo_name', 'uid']);
380 382 pyroutes.register('repo_automation', '/%(repo_name)s/settings/automation', ['repo_name']);
381 383 pyroutes.register('repo_automation_update', '/%(repo_name)s/settings/automation/%(entry_id)s/update', ['repo_name', 'entry_id']);
382 384 pyroutes.register('edit_repo_remote_push', '/%(repo_name)s/settings/remote/push', ['repo_name']);
383 385 pyroutes.register('edit_repo_perms_branch', '/%(repo_name)s/settings/branch_permissions', ['repo_name']);
384 386 pyroutes.register('edit_repo_perms_branch_delete', '/%(repo_name)s/settings/branch_permissions/%(rule_id)s/delete', ['repo_name', 'rule_id']);
385 387 }
@@ -1,79 +1,80 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="/base/base.mako"/>
3 3
4 4 <%def name="title()">
5 5 ${_('Debug Style')}
6 6 %if c.rhodecode_name:
7 7 &middot; ${h.branding(c.rhodecode_name)}
8 8 %endif
9 9 </%def>
10 10
11 11 <%def name="breadcrumbs_links()">
12 12 ${_('Style')}
13 13 </%def>
14 14
15 15 <%def name="menu_bar_nav()">
16 16 ${self.menu_items(active='debug_style')}
17 17 </%def>
18 18
19 19
20 20 <%def name="main()">
21 21 <div id="style-page">
22 22 ${self.real_main()}
23 23 </div>
24 24 </%def>
25 25
26 26 <%def name="real_main()">
27 27 <div class="box">
28 28 <div class="title">
29 29 ${self.breadcrumbs()}
30 30 </div>
31 31
32 32 <div class='sidebar-col-wrapper'>
33 33 ##main
34 34 ${self.sidebar()}
35 35
36 36 <div class="main-content">
37 37 <h2>Examples of styled elements</h2>
38 38 <p>Taken based on the examples from Bootstrap, form elements based
39 39 on our current markup.</p>
40 40 <p>
41 41 The objective of this section is to have a comprehensive style guide which out
42 42 lines any and all elements used throughout the application, as a reference for
43 43 both existing developers and as a training tool for future hires.
44 44 </p>
45 45 </div>
46 46 </div>
47 47 </div>
48 48 </%def>
49 49
50 50
51 51 <%def name="sidebar()">
52 52 <div class="sidebar">
53 53 <ul class="nav nav-pills nav-stacked">
54 54 <li class="${'active' if c.active=='index' else ''}"><a href="${h.route_path('debug_style_home')}">${_('Index')}</a></li>
55 <li class="${'active' if c.active=='emails' else ''}"><a href="${h.route_path('debug_style_template', t_path='emails.html')}">${_('Emails')}</a></li>
55 56 <li class="${'active' if c.active=='typography' else ''}"><a href="${h.route_path('debug_style_template', t_path='typography.html')}">${_('Typography')}</a></li>
56 57 <li class="${'active' if c.active=='forms' else ''}"><a href="${h.route_path('debug_style_template', t_path='forms.html')}">${_('Forms')}</a></li>
57 58 <li class="${'active' if c.active=='buttons' else ''}"><a href="${h.route_path('debug_style_template', t_path='buttons.html')}">${_('Buttons')}</a></li>
58 59 <li class="${'active' if c.active=='labels' else ''}"><a href="${h.route_path('debug_style_template', t_path='labels.html')}">${_('Labels')}</a></li>
59 60 <li class="${'active' if c.active=='alerts' else ''}"><a href="${h.route_path('debug_style_template', t_path='alerts.html')}">${_('Alerts')}</a></li>
60 61 <li class="${'active' if c.active=='tables' else ''}"><a href="${h.route_path('debug_style_template', t_path='tables.html')}">${_('Tables')}</a></li>
61 62 <li class="${'active' if c.active=='tables-wide' else ''}"><a href="${h.route_path('debug_style_template', t_path='tables-wide.html')}">${_('Tables wide')}</a></li>
62 63 <li class="${'active' if c.active=='collapsable-content' else ''}"><a href="${h.route_path('debug_style_template', t_path='collapsable-content.html')}">${_('Collapsable Content')}</a></li>
63 64 <li class="${'active' if c.active=='icons' else ''}"><a href="${h.route_path('debug_style_template', t_path='icons.html')}">${_('Icons')}</a></li>
64 65 <li class="${'active' if c.active=='layout-form-sidebar' else ''}"><a href="${h.route_path('debug_style_template', t_path='layout-form-sidebar.html')}">${_('Layout form with sidebar')}</a></li>
65 66 <li class="${'active' if c.active=='login' else ''}"><a href="${h.route_path('debug_style_template', t_path='login.html')}">${_('Login')}</a></li>
66 67 <li class="${'active' if c.active=='login2' else ''}"><a href="${h.route_path('debug_style_template', t_path='login2.html')}">${_('Login 2')}</a></li>
67 68 <li class="${'active' if c.active=='code-block' else ''}"><a href="${h.route_path('debug_style_template', t_path='code-block.html')}">${_('Code blocks')}</a></li>
68 69
69 70 <li class="divider"><strong>Experimental</strong></li>
70 71 <li class="${'active' if c.active=='panels' else ''}"><a href="${h.route_path('debug_style_template', t_path='panels.html')}">${_('Panels')}</a></li>
71 72
72 73 <li class="divider"><strong>Depreciated</strong></li>
73 74 <li class="${'active' if c.active=='form-elements' else ''}"><a href="${h.route_path('debug_style_template', t_path='form-elements.html')}">${_('Form elements')}</a></li>
74 75 <li class="${'active' if c.active=='form-elements-small' else ''}"><a href="${h.route_path('debug_style_template', t_path='form-elements-small.html')}">${_('Form elements small')}</a></li>
75 76 <li class="${'active' if c.active=='form-inline' else ''}"><a href="${h.route_path('debug_style_template', t_path='form-inline.html')}">${_('Form inline elements')}</a></li>
76 77 <li class="${'active' if c.active=='form-vertical' else ''}"><a href="${h.route_path('debug_style_template', t_path='form-vertical.html')}">${_('Form vertical')}</a></li>
77 78 </ul>
78 79 </div>
79 80 </%def> No newline at end of file
@@ -1,142 +1,525 b''
1 1 ## -*- coding: utf-8 -*-
2 2
3 3 ## helpers
4 4 <%def name="tag_button(text, tag_type=None)">
5 <%
6 color_scheme = {
7 'default': 'border:1px solid #979797;color:#666666;background-color:#f9f9f9',
8 'approved': 'border:1px solid #0ac878;color:#0ac878;background-color:#f9f9f9',
9 'rejected': 'border:1px solid #e85e4d;color:#e85e4d;background-color:#f9f9f9',
10 'under_review': 'border:1px solid #ffc854;color:#ffc854;background-color:#f9f9f9',
11 }
12 %>
13 <pre style="display:inline;border-radius:2px;font-size:12px;padding:.2em;${color_scheme.get(tag_type, color_scheme['default'])}">${text}</pre>
5 <%
6 color_scheme = {
7 'default': 'border:1px solid #979797;color:#666666;background-color:#f9f9f9',
8 'approved': 'border:1px solid #0ac878;color:#0ac878;background-color:#f9f9f9',
9 'rejected': 'border:1px solid #e85e4d;color:#e85e4d;background-color:#f9f9f9',
10 'under_review': 'border:1px solid #ffc854;color:#ffc854;background-color:#f9f9f9',
11 }
12
13 css_style = ';'.join([
14 'display:inline',
15 'border-radius:2px',
16 'font-size:12px',
17 'padding:.2em',
18 ])
19
20 %>
21 <pre style="${css_style}; ${color_scheme.get(tag_type, color_scheme['default'])}">${text}</pre>
14 22 </%def>
15 23
16 24 <%def name="status_text(text, tag_type=None)">
17 25 <%
18 26 color_scheme = {
19 27 'default': 'color:#666666',
20 28 'approved': 'color:#0ac878',
21 29 'rejected': 'color:#e85e4d',
22 30 'under_review': 'color:#ffc854',
23 31 }
24 32 %>
25 33 <span style="font-weight:bold;font-size:12px;padding:.2em;${color_scheme.get(tag_type, color_scheme['default'])}">${text}</span>
26 34 </%def>
27 35
36 <%def name="gravatar_img(email, size=16)">
37 <%
38 css_style = ';'.join([
39 'padding: 0',
40 'margin: -4px 0',
41 'border-radius: 50%',
42 'box-sizing: content-box',
43 'display: inline',
44 'line-height: 1em',
45 'min-width: 16px',
46 'min-height: 16px',
47 ])
48 %>
49
50 <img alt="gravatar" style="${css_style}" src="${h.gravatar_url(email, size)}" height="${size}" width="${size}">
51 </%def>
52
53 <%def name="link_css()">\
54 <%
55 css_style = ';'.join([
56 'color:#427cc9',
57 'text-decoration:none',
58 'cursor:pointer'
59 ])
60 %>\
61 ${css_style}\
62 </%def>
63
28 64 ## Constants
29 65 <%
30 66 text_regular = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;"
31 67 text_monospace = "'Menlo', 'Liberation Mono', 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace;"
32 68
33 69 %>
34 70
35 71 ## headers we additionally can set for email
36 72 <%def name="headers()" filter="n,trim"></%def>
37 73
38 <%def name="plaintext_footer()">
39 ${_('This is a notification from RhodeCode. %(instance_url)s') % {'instance_url': instance_url}}
74 <%def name="plaintext_footer()" filter="trim">
75 ${_('This is a notification from RhodeCode.')} ${instance_url}
40 76 </%def>
41 77
42 78 <%def name="body_plaintext()" filter="n,trim">
43 79 ## this example is not called itself but overridden in each template
44 80 ## the plaintext_footer should be at the bottom of both html and text emails
45 81 ${self.plaintext_footer()}
46 82 </%def>
47 83
48 84 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
49 85 <html xmlns="http://www.w3.org/1999/xhtml">
50 86 <head>
51 87 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
52 88 <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
53 89 <title>${self.subject()}</title>
54 90 <style type="text/css">
55 /* Based on The MailChimp Reset INLINE: Yes. */
56 #outlook a {padding:0;} /* Force Outlook to provide a "view in browser" menu link. */
57 body{width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; font-family: ${text_regular|n}}
58 /* Prevent Webkit and Windows Mobile platforms from changing default font sizes.*/
59 .ExternalClass {width:100%;} /* Force Hotmail to display emails at full width */
60 .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;}
61 /* Forces Hotmail to display normal line spacing. More on that: http://www.emailonacid.com/forum/viewthread/43/ */
62 #backgroundTable {margin:0; padding:0; line-height: 100% !important;}
91 /* Based on The MailChimp Reset INLINE: Yes. */
92 #outlook a {
93 padding: 0;
94 }
95
96 /* Force Outlook to provide a "view in browser" menu link. */
97 body {
98 width: 100% !important;
99 -webkit-text-size-adjust: 100%;
100 -ms-text-size-adjust: 100%;
101 margin: 0;
102 padding: 0;
103 font-family: ${text_regular|n}
104 }
105
106 /* Prevent Webkit and Windows Mobile platforms from changing default font sizes.*/
107 .ExternalClass {
108 width: 100%;
109 }
110
111 /* Force Hotmail to display emails at full width */
112 .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {
113 line-height: 100%;
114 }
115
116 /* Forces Hotmail to display normal line spacing. More on that: http://www.emailonacid.com/forum/viewthread/43/ */
117 #backgroundTable {
118 margin: 0;
119 padding: 0;
120 line-height: 100% !important;
121 }
122
63 123 /* End reset */
64 124
65 125 /* defaults for images*/
66 img {outline:none; text-decoration:none; -ms-interpolation-mode: bicubic;}
67 a img {border:none;}
68 .image_fix {display:block;}
126 img {
127 outline: none;
128 text-decoration: none;
129 -ms-interpolation-mode: bicubic;
130 }
131
132 a img {
133 border: none;
134 }
135
136 .image_fix {
137 display: block;
138 }
139
140 body {
141 line-height: 1.2em;
142 }
143
144 p {
145 margin: 0 0 20px;
146 }
147
148 h1, h2, h3, h4, h5, h6 {
149 color: #323232 !important;
150 }
151
152 a {
153 color: #427cc9;
154 text-decoration: none;
155 outline: none;
156 cursor: pointer;
157 }
158
159 a:focus {
160 outline: none;
161 }
162
163 a:hover {
164 color: #305b91;
165 }
69 166
70 body {line-height:1.2em;}
71 p {margin: 0 0 20px;}
72 h1, h2, h3, h4, h5, h6 {color:#323232!important;}
73 a {color:#427cc9;text-decoration:none;outline:none;cursor:pointer;}
74 a:focus {outline:none;}
75 a:hover {color: #305b91;}
76 h1 a, h2 a, h3 a, h4 a, h5 a, h6 a {color:#427cc9!important;text-decoration:none!important;}
77 h1 a:active, h2 a:active, h3 a:active, h4 a:active, h5 a:active, h6 a:active {color: #305b91!important;}
78 h1 a:visited, h2 a:visited, h3 a:visited, h4 a:visited, h5 a:visited, h6 a:visited {color: #305b91!important;}
79 table {font-size:13px;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
80 table td {padding:.65em 1em .65em 0;border-collapse:collapse;vertical-align:top;text-align:left;}
81 input {display:inline;border-radius:2px;border-style:solid;border: 1px solid #dbd9da;padding:.5em;}
82 input:focus {outline: 1px solid #979797}
167 h1 a, h2 a, h3 a, h4 a, h5 a, h6 a {
168 color: #427cc9 !important;
169 text-decoration: none !important;
170 }
171
172 h1 a:active, h2 a:active, h3 a:active, h4 a:active, h5 a:active, h6 a:active {
173 color: #305b91 !important;
174 }
175
176 h1 a:visited, h2 a:visited, h3 a:visited, h4 a:visited, h5 a:visited, h6 a:visited {
177 color: #305b91 !important;
178 }
179
180 table {
181 font-size: 13px;
182 border-collapse: collapse;
183 mso-table-lspace: 0pt;
184 mso-table-rspace: 0pt;
185 }
186
187 table td {
188 padding: .65em 1em .65em 0;
189 border-collapse: collapse;
190 vertical-align: top;
191 text-align: left;
192 }
193
194 input {
195 display: inline;
196 border-radius: 2px;
197 border: 1px solid #dbd9da;
198 padding: .5em;
199 }
200
201 input:focus {
202 outline: 1px solid #979797
203 }
204
83 205 @media only screen and (-webkit-min-device-pixel-ratio: 2) {
84 /* Put your iPhone 4g styles in here */
206 /* Put your iPhone 4g styles in here */
85 207 }
86 208
87 209 /* Android targeting */
88 210 @media only screen and (-webkit-device-pixel-ratio:.75){
89 211 /* Put CSS for low density (ldpi) Android layouts in here */
90 212 }
91 213 @media only screen and (-webkit-device-pixel-ratio:1){
92 214 /* Put CSS for medium density (mdpi) Android layouts in here */
93 215 }
94 216 @media only screen and (-webkit-device-pixel-ratio:1.5){
95 217 /* Put CSS for high density (hdpi) Android layouts in here */
96 218 }
97 219 /* end Android targeting */
98 220
221 /** MARKDOWN styling **/
222 div.markdown-block {
223 clear: both;
224 overflow: hidden;
225 margin: 0;
226 padding: 3px 5px 3px
227 }
228
229 div.markdown-block h1, div.markdown-block h2, div.markdown-block h3, div.markdown-block h4, div.markdown-block h5, div.markdown-block h6 {
230 border-bottom: none !important;
231 padding: 0 !important;
232 overflow: visible !important
233 }
234
235 div.markdown-block h1, div.markdown-block h2 {
236 border-bottom: 1px #e6e5e5 solid !important
237 }
238
239 div.markdown-block h1 {
240 font-size: 32px;
241 margin: 15px 0 15px 0 !important;
242 padding-bottom: 5px !important
243 }
244
245 div.markdown-block h2 {
246 font-size: 24px !important;
247 margin: 34px 0 10px 0 !important;
248 padding-top: 15px !important;
249 padding-bottom: 8px !important
250 }
251
252 div.markdown-block h3 {
253 font-size: 18px !important;
254 margin: 30px 0 8px 0 !important;
255 padding-bottom: 2px !important
256 }
257
258 div.markdown-block h4 {
259 font-size: 13px !important;
260 margin: 18px 0 3px 0 !important
261 }
262
263 div.markdown-block h5 {
264 font-size: 12px !important;
265 margin: 15px 0 3px 0 !important
266 }
267
268 div.markdown-block h6 {
269 font-size: 12px;
270 color: #777777;
271 margin: 15px 0 3px 0 !important
272 }
273
274 div.markdown-block hr {
275 border: 0;
276 color: #e6e5e5;
277 background-color: #e6e5e5;
278 height: 3px;
279 margin-bottom: 13px
280 }
281
282 div.markdown-block ol, div.markdown-block ul, div.markdown-block p, div.markdown-block blockquote, div.markdown-block dl, div.markdown-block li, div.markdown-block table {
283 margin: 3px 0 13px 0 !important;
284 color: #424242 !important;
285 font-size: 13px !important;
286 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
287 font-weight: normal !important;
288 overflow: visible !important;
289 line-height: 140% !important
290 }
291
292 div.markdown-block pre {
293 margin: 3px 0 13px 0 !important;
294 padding: .5em;
295 color: #424242 !important;
296 font-size: 13px !important;
297 overflow: visible !important;
298 line-height: 140% !important;
299 background-color: #F5F5F5
300 }
301
302 div.markdown-block img {
303 border-style: none;
304 background-color: #fff;
305 padding-right: 20px;
306 max-width: 100%
307 }
308
309 div.markdown-block strong {
310 font-weight: 600;
311 margin: 0
312 }
313
314 div.markdown-block ul.checkbox, div.markdown-block ol.checkbox {
315 padding-left: 20px !important;
316 margin-top: 0 !important;
317 margin-bottom: 18px !important
318 }
319
320 div.markdown-block ul, div.markdown-block ol {
321 padding-left: 30px !important;
322 margin-top: 0 !important;
323 margin-bottom: 18px !important
324 }
325
326 div.markdown-block ul.checkbox li, div.markdown-block ol.checkbox li {
327 list-style: none !important;
328 margin: 6px !important;
329 padding: 0 !important
330 }
331
332 div.markdown-block ul li, div.markdown-block ol li {
333 list-style: disc !important;
334 margin: 6px !important;
335 padding: 0 !important
336 }
337
338 div.markdown-block ol li {
339 list-style: decimal !important
340 }
341
342 div.markdown-block #message {
343 -webkit-border-radius: 2px;
344 -moz-border-radius: 2px;
345 border-radius: 2px;
346 border: 1px solid #dbd9da;
347 display: block;
348 width: 100%;
349 height: 60px;
350 margin: 6px 0
351 }
352
353 div.markdown-block button, div.markdown-block #ws {
354 font-size: 13px;
355 padding: 4px 6px;
356 -webkit-border-radius: 2px;
357 -moz-border-radius: 2px;
358 border-radius: 2px;
359 border: 1px solid #dbd9da;
360 background-color: #eeeeee
361 }
362
363 div.markdown-block code, div.markdown-block pre, div.markdown-block #ws, div.markdown-block #message {
364 font-family: 'Menlo', 'Liberation Mono', 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace;
365 font-size: 11px;
366 -webkit-border-radius: 2px;
367 -moz-border-radius: 2px;
368 border-radius: 2px;
369 background-color: white;
370 color: #7E7F7F
371 }
372
373 div.markdown-block code {
374 border: 1px solid #eeeeee;
375 margin: 0 2px;
376 padding: 0 5px
377 }
378
379 div.markdown-block pre {
380 border: 1px solid #dbd9da;
381 overflow: auto;
382 padding: .5em;
383 background-color: #F5F5F5
384 }
385
386 div.markdown-block pre > code {
387 border: 0;
388 margin: 0;
389 padding: 0
390 }
391
392 div.rst-block {
393 clear: both;
394 overflow: hidden;
395 margin: 0;
396 padding: 3px 5px 3px
397 }
398
399 div.rst-block h2 {
400 font-weight: normal
401 }
402
403 div.rst-block h1, div.rst-block h2, div.rst-block h3, div.rst-block h4, div.rst-block h5, div.rst-block h6 {
404 border-bottom: 0 !important;
405 margin: 0 !important;
406 padding: 0 !important;
407 line-height: 1.5em !important
408 }
409
410 div.rst-block h1:first-child {
411 padding-top: .25em !important
412 }
413
414 div.rst-block h2, div.rst-block h3 {
415 margin: 1em 0 !important
416 }
417
418 div.rst-block h1, div.rst-block h2 {
419 border-bottom: 1px #e6e5e5 solid !important
420 }
421
422 div.rst-block h2 {
423 margin-top: 1.5em !important;
424 padding-top: .5em !important
425 }
426
427 div.rst-block p {
428 color: black !important;
429 margin: 1em 0 !important;
430 line-height: 1.5em !important
431 }
432
433 div.rst-block ul {
434 list-style: disc !important;
435 margin: 1em 0 1em 2em !important;
436 clear: both
437 }
438
439 div.rst-block ol {
440 list-style: decimal;
441 margin: 1em 0 1em 2em !important
442 }
443
444 div.rst-block pre, div.rst-block code {
445 font: 12px "Bitstream Vera Sans Mono", "Courier", monospace
446 }
447
448 div.rst-block code {
449 font-size: 12px !important;
450 background-color: ghostWhite !important;
451 color: #444 !important;
452 padding: 0 .2em !important;
453 border: 1px solid #dedede !important
454 }
455
456 div.rst-block pre code {
457 padding: 0 !important;
458 font-size: 12px !important;
459 background-color: #eee !important;
460 border: none !important
461 }
462
463 div.rst-block pre {
464 margin: 1em 0;
465 padding: 15px;
466 border: 1px solid #eeeeee;
467 -webkit-border-radius: 2px;
468 -moz-border-radius: 2px;
469 border-radius: 2px;
470 overflow: auto;
471 font-size: 12px;
472 color: #444;
473 background-color: #F5F5F5
474 }
475
476
99 477 </style>
100 478
101 479 <!-- Targeting Windows Mobile -->
102 480 <!--[if IEMobile 7]>
103 481 <style type="text/css">
104 482
105 483 </style>
106 484 <![endif]-->
107 485
108 486 <!--[if gte mso 9]>
109 <style>
110 /* Target Outlook 2007 and 2010 */
111 </style>
487 <style>
488 /* Target Outlook 2007 and 2010 */
489 </style>
112 490 <![endif]-->
113 491 </head>
114 492 <body>
115 493 <!-- Wrapper/Container Table: Use a wrapper table to control the width and the background color consistently of your email. Use this approach instead of setting attributes on the body tag. -->
116 494 <table cellpadding="0" cellspacing="0" border="0" id="backgroundTable" align="left" style="margin:1%;width:97%;padding:0;font-family:sans-serif;font-weight:100;border:1px solid #dbd9da">
117 495 <tr>
118 <td valign="top" style="padding:0;">
496 <td valign="top" style="padding:0;">
119 497 <table cellpadding="0" cellspacing="0" border="0" align="left" width="100%">
120 <tr><td style="width:100%;padding:7px;background-color:#202020" valign="top">
121 <a style="color:#eeeeee;text-decoration:none;" href="${instance_url}">
122 ${_('RhodeCode')}
123 % if rhodecode_instance_name:
124 - ${rhodecode_instance_name}
125 % endif
126 </a>
127 </td></tr>
128 <tr><td style="padding:15px;" valign="top">${self.body()}</td></tr>
498 <tr>
499 <td style="width:100%;padding:10px 15px;background-color:#202020" valign="top">
500 <a style="color:#eeeeee;text-decoration:none;" href="${instance_url}">
501 ${_('RhodeCode')}
502 % if rhodecode_instance_name:
503 - ${rhodecode_instance_name}
504 % endif
505 </a>
506 </td>
507 </tr>
508 <tr>
509 <td style="padding:15px;" valign="top">${self.body()}</td>
510 </tr>
129 511 </table>
130 512 </td>
131 513 </tr>
132 514 </table>
133 515 <!-- End of wrapper table -->
134 516
135 517 <div style="clear: both"></div>
136 <p>
137 <a style="margin-top:15px;margin-left:1%;font-weight:100;font-size:11px;color:#666666;text-decoration:none;font-family:${text_monospace} " href="${instance_url}">
138 ${self.plaintext_footer()}
139 </a>
140 </p>
518 <div style="margin-left:1%;font-weight:100;font-size:11px;color:#666666;text-decoration:none;font-family:${text_monospace}">
519 ${_('This is a notification from RhodeCode.')}
520 <a style="font-weight:100;font-size:11px;color:#666666;text-decoration:none;font-family:${text_monospace}" href="${instance_url}">
521 ${instance_url}
522 </a>
523 </div>
141 524 </body>
142 525 </html>
@@ -1,108 +1,161 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 3 <%namespace name="base" file="base.mako"/>
4 4
5 5 ## EMAIL SUBJECT
6 6 <%def name="subject()" filter="n,trim,whitespace_filter">
7 7 <%
8 8 data = {
9 'user': h.person(user),
9 'user': '@'+h.person(user),
10 10 'repo_name': repo_name,
11 'commit_id': h.show_id(commit),
12 11 'status': status_change,
13 12 'comment_file': comment_file,
14 13 'comment_line': comment_line,
15 14 'comment_type': comment_type,
15
16 'commit_id': h.show_id(commit),
16 17 }
17 18 %>
18 ${_('[mention]') if mention else ''} \
19
19 20
20 21 % if comment_file:
21 ${_('{user} left a {comment_type} on file `{comment_file}` in commit `{commit_id}`').format(**data)} ${_('in the {repo_name} repository').format(**data) |n}
22 ${(_('[mention]') if mention else '')} ${_('{user} left a {comment_type} on file `{comment_file}` in commit `{commit_id}`').format(**data)} ${_('in the `{repo_name}` repository').format(**data) |n}
22 23 % else:
23 24 % if status_change:
24 ${_('[status: {status}] {user} left a {comment_type} on commit `{commit_id}`').format(**data) |n} ${_('in the {repo_name} repository').format(**data) |n}
25 ${(_('[mention]') if mention else '')} ${_('[status: {status}] {user} left a {comment_type} on commit `{commit_id}`').format(**data) |n} ${_('in the `{repo_name}` repository').format(**data) |n}
25 26 % else:
26 ${_('{user} left a {comment_type} on commit `{commit_id}`').format(**data) |n} ${_('in the {repo_name} repository').format(**data) |n}
27 ${(_('[mention]') if mention else '')} ${_('{user} left a {comment_type} on commit `{commit_id}`').format(**data) |n} ${_('in the `{repo_name}` repository').format(**data) |n}
27 28 % endif
28 29 % endif
29 30
30 31 </%def>
31 32
32 33 ## PLAINTEXT VERSION OF BODY
33 34 <%def name="body_plaintext()" filter="n,trim">
34 35 <%
35 36 data = {
36 37 'user': h.person(user),
37 38 'repo_name': repo_name,
38 'commit_id': h.show_id(commit),
39 39 'status': status_change,
40 40 'comment_file': comment_file,
41 41 'comment_line': comment_line,
42 42 'comment_type': comment_type,
43
44 'commit_id': h.show_id(commit),
43 45 }
44 46 %>
45 ${self.subject()}
46 47
47 48 * ${_('Comment link')}: ${commit_comment_url}
48 49
50 %if status_change:
51 * ${_('Commit status')}: ${_('Status was changed to')}: *${status_change}*
52
53 %endif
49 54 * ${_('Commit')}: ${h.show_id(commit)}
50 55
56 * ${_('Commit message')}: ${commit.message}
57
51 58 %if comment_file:
52 59 * ${_('File: {comment_file} on line {comment_line}').format(**data)}
60
53 61 %endif
62 % if comment_type == 'todo':
63 ${_('`TODO` comment')}:
64 % else:
65 ${_('`Note` comment')}:
66 % endif
67
68 ${comment_body |n, trim}
54 69
55 70 ---
56
57 %if status_change:
58 ${_('Commit status was changed to')}: *${status_change}*
59 %endif
60
61 ${comment_body|n}
62
63 71 ${self.plaintext_footer()}
64 72 </%def>
65 73
66 74
67 75 <%
68 76 data = {
69 77 'user': h.person(user),
70 'repo': commit_target_repo,
71 'repo_name': repo_name,
72 'commit_id': h.show_id(commit),
73 78 'comment_file': comment_file,
74 79 'comment_line': comment_line,
75 80 'comment_type': comment_type,
81 'renderer_type': renderer_type or 'plain',
82
83 'repo': commit_target_repo_url,
84 'repo_name': repo_name,
85 'commit_id': h.show_id(commit),
76 86 }
77 87 %>
78 <table style="text-align:left;vertical-align:middle;">
79 <tr><td colspan="2" style="width:100%;padding-bottom:15px;border-bottom:1px solid #dbd9da;">
88
89 <table style="text-align:left;vertical-align:middle;width: 100%">
90 <tr>
91 <td style="width:100%;border-bottom:1px solid #dbd9da;">
80 92
81 % if comment_file:
82 <h4><a href="${commit_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('{user} left a {comment_type} on file `{comment_file}` in commit `{commit_id}`').format(**data)}</a> ${_('in the {repo} repository').format(**data) |n}</h4>
83 % else:
84 <h4><a href="${commit_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('{user} left a {comment_type} on commit `{commit_id}`').format(**data) |n}</a> ${_('in the {repo} repository').format(**data) |n}</h4>
85 % endif
86 </td></tr>
93 <h4 style="margin: 0">
94 <div style="margin-bottom: 4px; color:#7E7F7F">
95 @${h.person(user.username)}
96 </div>
97 ${_('left a')}
98 <a href="${commit_comment_url}" style="${base.link_css()}">
99 % if comment_file:
100 ${_('{comment_type} on file `{comment_file}` in commit.').format(**data)}
101 % else:
102 ${_('{comment_type} on commit.').format(**data) |n}
103 % endif
104 </a>
105 <div style="margin-top: 10px"></div>
106 ${_('Commit')} <code>${data['commit_id']}</code> ${_('of repository')}: ${data['repo_name']}
107 </h4>
87 108
88 <tr><td style="padding-right:20px;padding-top:15px;">${_('Commit')}</td><td style="padding-top:15px;"><a href="${commit_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${h.show_id(commit)}</a></td></tr>
89 <tr><td style="padding-right:20px;">${_('Description')}</td><td style="white-space:pre-wrap">${h.urlify_commit_message(commit.message, repo_name)}</td></tr>
109 </td>
110 </tr>
111
112 </table>
113
114 <table style="text-align:left;vertical-align:middle;width: 100%">
115
116 ## spacing def
117 <tr>
118 <td style="width: 130px"></td>
119 <td></td>
120 </tr>
90 121
91 122 % if status_change:
92 123 <tr>
93 <td style="padding-right:20px;">${_('Status')}</td>
124 <td style="padding-right:20px;">${_('Commit Status')}:</td>
94 125 <td>
95 ${_('The commit status was changed to')}: ${base.status_text(status_change, tag_type=status_change_type)}
126 ${_('Status was changed to')}: ${base.status_text(status_change, tag_type=status_change_type)}
96 127 </td>
97 128 </tr>
98 129 % endif
130
99 131 <tr>
100 <td style="padding-right:20px;">
132 <td style="padding-right:20px;">${_('Commit')}:</td>
133 <td>
134 <a href="${commit_comment_url}" style="${base.link_css()}">${h.show_id(commit)}</a>
135 </td>
136 </tr>
137 <tr>
138 <td style="padding-right:20px;">${_('Commit message')}:</td>
139 <td style="white-space:pre-wrap">${h.urlify_commit_message(commit.message, repo_name)}</td>
140 </tr>
141
142 % if comment_file:
143 <tr>
144 <td style="padding-right:20px;">${_('File')}:</td>
145 <td><a href="${commit_comment_url}" style="${base.link_css()}">${_('`{comment_file}` on line {comment_line}').format(**data)}</a></td>
146 </tr>
147 % endif
148
149 <tr style="background-image: linear-gradient(to right, black 33%, rgba(255,255,255,0) 0%);background-position: bottom;background-size: 3px 1px;background-repeat: repeat-x;">
150 <td colspan="2" style="padding-right:20px;">
101 151 % if comment_type == 'todo':
102 ${(_('TODO comment on line: {comment_line}') if comment_file else _('TODO comment')).format(**data)}
152 ${_('`TODO` comment')}:
103 153 % else:
104 ${(_('Note comment on line: {comment_line}') if comment_file else _('Note comment')).format(**data)}
154 ${_('`Note` comment')}:
105 155 % endif
106 156 </td>
107 <td style="line-height:1.2em;white-space:pre-wrap">${h.render(comment_body, renderer=renderer_type, mentions=True)}</td></tr>
157 </tr>
158
159 <td colspan="2" style="background: #F7F7F7">${h.render(comment_body, renderer=data['renderer_type'], mentions=True)}</td>
160 </tr>
108 161 </table>
@@ -1,13 +1,20 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 <%namespace name="base" file="base.mako"/>
3 4
4 5 <%def name="subject()" filter="n,trim,whitespace_filter">
5 6 RhodeCode test email: ${h.format_date(date)}
6 7 </%def>
7 8
8 9 ## plain text version of the email. Empty by default
9 10 <%def name="body_plaintext()" filter="n,trim">
10 Test Email from RhodeCode version: ${rhodecode_version}, sent by: ${user}
11 Test Email from RhodeCode version: ${rhodecode_version}
12 Email sent by: ${h.person(user)}
13
14 ---
15 ${self.plaintext_footer()}
11 16 </%def>
12 17
13 ${body_plaintext()} No newline at end of file
18 Test Email from RhodeCode version: ${rhodecode_version}
19 <br/><br/>
20 Email sent by: <strong>${h.person(user)}</strong>
@@ -1,21 +1,21 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 3
4 4 <%def name="subject()" filter="n,trim,whitespace_filter">
5 5 </%def>
6 6
7 7
8 8 ## plain text version of the email. Empty by default
9 9 <%def name="body_plaintext()" filter="n,trim">
10 10 ${body}
11 11
12 ---
12 13 ${self.plaintext_footer()}
13 14 </%def>
14 15
15 16 ## BODY GOES BELOW
16 17 <table style="text-align:left;vertical-align:top;">
17 <tr><td style="padding-right:20px;padding-top:15px;white-space:pre-wrap">${body}</td></tr>
18 <tr>
19 <td style="padding-right:20px;padding-top:15px;white-space:pre-wrap">${body}</td>
20 </tr>
18 21 </table>
19 <p><a style="margin-top:15px;margin-left:1%;font-family:sans-serif;font-weight:100;font-size:11px;display:block;color:#666666;text-decoration:none;" href="${instance_url}">
20 ${self.plaintext_footer()}
21 </a></p> No newline at end of file
@@ -1,33 +1,37 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 <%namespace name="base" file="base.mako"/>
3 4
4 5 <%def name="subject()" filter="n,trim,whitespace_filter">
5 6 RhodeCode Password reset
6 7 </%def>
7 8
8 9 ## plain text version of the email. Empty by default
9 10 <%def name="body_plaintext()" filter="n,trim">
10 Hi ${user.username},
11 Hello ${user.username},
11 12
12 There was a request to reset your password using the email address ${email} on ${h.format_date(date)}
13 On ${h.format_date(date)} there was a request to reset your password using the email address `${email}`
13 14
14 *If you didn't do this, please contact your RhodeCode administrator.*
15 *If you did not request a password reset, please contact your RhodeCode administrator at: ${first_admin_email}*
15 16
16 17 You can continue, and generate new password by clicking following URL:
17 18 ${password_reset_url}
18 19
19 20 This link will be active for 10 minutes.
21
22 ---
20 23 ${self.plaintext_footer()}
21 24 </%def>
22 25
23 26 ## BODY GOES BELOW
24 27 <p>
25 28 Hello ${user.username},
26 29 </p><p>
27 There was a request to reset your password using the email address ${email} on ${h.format_date(date)}
28 <br/>
29 <strong>If you did not request a password reset, please contact your RhodeCode administrator.</strong>
30 On ${h.format_date(date)} there was a request to reset your password using the email address `${email}`
31 <br/><br/>
32 <strong>If you did not request a password reset, please contact your RhodeCode administrator at: ${first_admin_email}.</strong>
30 33 </p><p>
31 <a href="${password_reset_url}">${_('Generate new password here')}.</a>
32 This link will be active for 10 minutes.
34 You can continue, and generate new password by clicking following URL:<br/><br/>
35 <a href="${password_reset_url}" style="${base.link_css()}">${password_reset_url}</a>
36 <br/><br/>This link will be active for 10 minutes.
33 37 </p>
@@ -1,29 +1,31 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 <%namespace name="base" file="base.mako"/>
3 4
4 5 <%def name="subject()" filter="n,trim,whitespace_filter">
5 6 Your new RhodeCode password
6 7 </%def>
7 8
8 9 ## plain text version of the email. Empty by default
9 10 <%def name="body_plaintext()" filter="n,trim">
10 Hi ${user.username},
11 Hello ${user.username},
11 12
12 Below is your new access password for RhodeCode.
13 Below is your new access password for RhodeCode requested via password reset link.
13 14
14 *If you didn't do this, please contact your RhodeCode administrator.*
15 *If you did not request a password reset, please contact your RhodeCode administrator at: ${first_admin_email}.*
15 16
16 password: ${new_password}
17 new password: ${new_password}
17 18
19 ---
18 20 ${self.plaintext_footer()}
19 21 </%def>
20 22
21 23 ## BODY GOES BELOW
22 24 <p>
23 25 Hello ${user.username},
24 26 </p><p>
25 Below is your new access password for RhodeCode.
26 <br/>
27 <strong>If you didn't request a new password, please contact your RhodeCode administrator.</strong>
27 Below is your new access password for RhodeCode requested via password reset link.
28 <br/><br/>
29 <strong>If you did not request a password reset, please contact your RhodeCode administrator at: ${first_admin_email}.</strong>
28 30 </p>
29 <p>password: <pre>${new_password}</pre>
31 <p>new password: <code>${new_password}</code>
@@ -1,114 +1,191 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 3 <%namespace name="base" file="base.mako"/>
4 4
5 5 ## EMAIL SUBJECT
6 6 <%def name="subject()" filter="n,trim,whitespace_filter">
7 7 <%
8 8 data = {
9 'user': h.person(user),
10 'pr_title': pull_request.title,
11 'pr_id': pull_request.pull_request_id,
9 'user': '@'+h.person(user),
10 'repo_name': repo_name,
12 11 'status': status_change,
13 12 'comment_file': comment_file,
14 13 'comment_line': comment_line,
15 14 'comment_type': comment_type,
15
16 'pr_title': pull_request.title,
17 'pr_id': pull_request.pull_request_id,
16 18 }
17 19 %>
18 20
19 ${(_('[mention]') if mention else '')} \
20 21
21 22 % if comment_file:
22 ${_('{user} left a {comment_type} on file `{comment_file}` in pull request #{pr_id} "{pr_title}"').format(**data) |n}
23 ${(_('[mention]') if mention else '')} ${_('{user} left a {comment_type} on file `{comment_file}` in pull request !{pr_id}: "{pr_title}"').format(**data) |n}
23 24 % else:
24 25 % if status_change:
25 ${_('[status: {status}] {user} left a {comment_type} on pull request #{pr_id} "{pr_title}"').format(**data) |n}
26 ${(_('[mention]') if mention else '')} ${_('[status: {status}] {user} left a {comment_type} on pull request !{pr_id}: "{pr_title}"').format(**data) |n}
26 27 % else:
27 ${_('{user} left a {comment_type} on pull request #{pr_id} "{pr_title}"').format(**data) |n}
28 ${(_('[mention]') if mention else '')} ${_('{user} left a {comment_type} on pull request !{pr_id}: "{pr_title}"').format(**data) |n}
28 29 % endif
29 30 % endif
31
30 32 </%def>
31 33
32 34 ## PLAINTEXT VERSION OF BODY
33 35 <%def name="body_plaintext()" filter="n,trim">
34 36 <%
35 37 data = {
36 38 'user': h.person(user),
37 'pr_title': pull_request.title,
38 'pr_id': pull_request.pull_request_id,
39 'repo_name': repo_name,
39 40 'status': status_change,
40 41 'comment_file': comment_file,
41 42 'comment_line': comment_line,
42 43 'comment_type': comment_type,
44
45 'pr_title': pull_request.title,
46 'pr_id': pull_request.pull_request_id,
47 'source_ref_type': pull_request.source_ref_parts.type,
48 'source_ref_name': pull_request.source_ref_parts.name,
49 'target_ref_type': pull_request.target_ref_parts.type,
50 'target_ref_name': pull_request.target_ref_parts.name,
51 'source_repo': pull_request_source_repo.repo_name,
52 'target_repo': pull_request_target_repo.repo_name,
53 'source_repo_url': pull_request_source_repo_url,
54 'target_repo_url': pull_request_target_repo_url,
43 55 }
44 56 %>
45 ${self.subject()}
57
58 ${h.literal(_('Pull request !{pr_id}: `{pr_title}`').format(**data))}
59
60 * ${h.literal(_('Commit flow: {source_ref_type}:{source_ref_name} of {source_repo_url} into {target_ref_type}:{target_ref_name} of {target_repo_url}').format(**data))}
46 61
47 62 * ${_('Comment link')}: ${pr_comment_url}
48 63
49 * ${_('Source repository')}: ${pr_source_repo_url}
64 %if status_change and not closing_pr:
65 * ${_('{user} submitted pull request !{pr_id} status: *{status}*').format(**data)}
50 66
67 %elif status_change and closing_pr:
68 * ${_('{user} submitted pull request !{pr_id} status: *{status} and closed*').format(**data)}
69
70 %endif
51 71 %if comment_file:
52 * ${_('File: {comment_file} on line {comment_line}').format(comment_file=comment_file, comment_line=comment_line)}
72 * ${_('File: {comment_file} on line {comment_line}').format(**data)}
73
53 74 %endif
75 % if comment_type == 'todo':
76 ${_('`TODO` comment')}:
77 % else:
78 ${_('`Note` comment')}:
79 % endif
80
81 ${comment_body |n, trim}
54 82
55 83 ---
56
57 %if status_change and not closing_pr:
58 ${_('{user} submitted pull request #{pr_id} status: *{status}*').format(**data)}
59 %elif status_change and closing_pr:
60 ${_('{user} submitted pull request #{pr_id} status: *{status} and closed*').format(**data)}
61 %endif
62
63 ${comment_body |n}
64
65 84 ${self.plaintext_footer()}
66 85 </%def>
67 86
68 87
69 88 <%
70 89 data = {
71 90 'user': h.person(user),
72 'pr_title': pull_request.title,
73 'pr_id': pull_request.pull_request_id,
74 'status': status_change,
75 91 'comment_file': comment_file,
76 92 'comment_line': comment_line,
77 93 'comment_type': comment_type,
94 'renderer_type': renderer_type or 'plain',
95
96 'pr_title': pull_request.title,
97 'pr_id': pull_request.pull_request_id,
98 'status': status_change,
99 'source_ref_type': pull_request.source_ref_parts.type,
100 'source_ref_name': pull_request.source_ref_parts.name,
101 'target_ref_type': pull_request.target_ref_parts.type,
102 'target_ref_name': pull_request.target_ref_parts.name,
103 'source_repo': pull_request_source_repo.repo_name,
104 'target_repo': pull_request_target_repo.repo_name,
105 'source_repo_url': h.link_to(pull_request_source_repo.repo_name, pull_request_source_repo_url),
106 'target_repo_url': h.link_to(pull_request_target_repo.repo_name, pull_request_target_repo_url),
78 107 }
79 108 %>
80 <table style="text-align:left;vertical-align:middle;">
81 <tr><td colspan="2" style="width:100%;padding-bottom:15px;border-bottom:1px solid #dbd9da;">
109
110 <table style="text-align:left;vertical-align:middle;width: 100%">
111 <tr>
112 <td style="width:100%;border-bottom:1px solid #dbd9da;">
82 113
83 % if comment_file:
84 <h4><a href="${pr_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('{user} left a {comment_type} on file `{comment_file}` in pull request #{pr_id} "{pr_title}"').format(**data) |n}</a></h4>
85 % else:
86 <h4><a href="${pr_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('{user} left a {comment_type} on pull request #{pr_id} "{pr_title}"').format(**data) |n}</a></h4>
87 % endif
114 <h4 style="margin: 0">
115 <div style="margin-bottom: 4px; color:#7E7F7F">
116 @${h.person(user.username)}
117 </div>
118 ${_('left a')}
119 <a href="${pr_comment_url}" style="${base.link_css()}">
120 % if comment_file:
121 ${_('{comment_type} on file `{comment_file}` in pull request.').format(**data)}
122 % else:
123 ${_('{comment_type} on pull request.').format(**data) |n}
124 % endif
125 </a>
126 <div style="margin-top: 10px"></div>
127 ${_('Pull request')} <code>!${data['pr_id']}: ${data['pr_title']}</code>
128 </h4>
88 129
89 </td></tr>
90 <tr><td style="padding-right:20px;padding-top:15px;">${_('Source')}</td><td style="padding-top:15px;"><a style="color:#427cc9;text-decoration:none;cursor:pointer" href="${pr_source_repo_url}">${pr_source_repo.repo_name}</a></td></tr>
130 </td>
131 </tr>
132
133 </table>
134
135 <table style="text-align:left;vertical-align:middle;width: 100%">
136
137 ## spacing def
138 <tr>
139 <td style="width: 130px"></td>
140 <td></td>
141 </tr>
91 142
92 143 % if status_change:
144 <tr>
145 <td style="padding-right:20px;">${_('Review Status')}:</td>
146 <td>
147 % if closing_pr:
148 ${_('Closed pull request with status')}: ${base.status_text(status_change, tag_type=status_change_type)}
149 % else:
150 ${_('Submitted review status')}: ${base.status_text(status_change, tag_type=status_change_type)}
151 % endif
152 </td>
153 </tr>
154 % endif
155
156 <tr>
157 <td style="padding-right:20px;line-height:20px;">${_('Commit Flow')}:</td>
158 <td style="line-height:20px;">
159 ${base.tag_button('{}:{}'.format(data['source_ref_type'], pull_request.source_ref_parts.name))} ${_('of')} ${data['source_repo_url']}
160 &rarr;
161 ${base.tag_button('{}:{}'.format(data['target_ref_type'], pull_request.target_ref_parts.name))} ${_('of')} ${data['target_repo_url']}
162 </td>
163 </tr>
164 <tr>
165 <td style="padding-right:20px;">${_('Pull request')}:</td>
166 <td>
167 <a href="${pull_request_url}" style="${base.link_css()}">
168 !${pull_request.pull_request_id}
169 </a>
170 </td>
171 </tr>
172 % if comment_file:
93 173 <tr>
94 <td style="padding-right:20px;">${_('Status')}</td>
95 <td>
96 % if closing_pr:
97 ${_('Closed pull request with status')}: ${base.status_text(status_change, tag_type=status_change_type)}
98 % else:
99 ${_('Submitted review status')}: ${base.status_text(status_change, tag_type=status_change_type)}
100 % endif
101 </td>
174 <td style="padding-right:20px;">${_('File')}:</td>
175 <td><a href="${pr_comment_url}" style="${base.link_css()}">${_('`{comment_file}` on line {comment_line}').format(**data)}</a></td>
102 176 </tr>
103 177 % endif
104 <tr>
105 <td style="padding-right:20px;">
178
179 <tr style="background-image: linear-gradient(to right, black 33%, rgba(255,255,255,0) 0%);background-position: bottom;background-size: 3px 1px;background-repeat: repeat-x;">
180 <td colspan="2" style="padding-right:20px;">
106 181 % if comment_type == 'todo':
107 ${(_('TODO comment on line: {comment_line}') if comment_file else _('TODO comment')).format(**data)}
182 ${_('`TODO` comment')}:
108 183 % else:
109 ${(_('Note comment on line: {comment_line}') if comment_file else _('Note comment')).format(**data)}
184 ${_('`Note` comment')}:
110 185 % endif
111 186 </td>
112 <td style="line-height:1.2em;white-space:pre-wrap">${h.render(comment_body, renderer=renderer_type, mentions=True)}</td>
187 </tr>
188
189 <td colspan="2" style="background: #F7F7F7">${h.render(comment_body, renderer=data['renderer_type'], mentions=True)}</td>
113 190 </tr>
114 191 </table>
@@ -1,85 +1,144 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 3 <%namespace name="base" file="base.mako"/>
4 4
5 ## EMAIL SUBJECT
5 6 <%def name="subject()" filter="n,trim,whitespace_filter">
6 7 <%
7 8 data = {
8 'user': h.person(user),
9 'user': '@'+h.person(user),
9 10 'pr_id': pull_request.pull_request_id,
10 11 'pr_title': pull_request.title,
11 12 }
12 13 %>
13 14
14 ${_('%(user)s wants you to review pull request #%(pr_id)s: "%(pr_title)s"') % data |n}
15 ${_('{user} requested a pull request review. !{pr_id}: "{pr_title}"').format(**data) |n}
15 16 </%def>
16 17
17
18 ## PLAINTEXT VERSION OF BODY
18 19 <%def name="body_plaintext()" filter="n,trim">
19 20 <%
20 21 data = {
21 22 'user': h.person(user),
22 23 'pr_id': pull_request.pull_request_id,
23 24 'pr_title': pull_request.title,
24 25 'source_ref_type': pull_request.source_ref_parts.type,
25 26 'source_ref_name': pull_request.source_ref_parts.name,
26 27 'target_ref_type': pull_request.target_ref_parts.type,
27 28 'target_ref_name': pull_request.target_ref_parts.name,
28 'repo_url': pull_request_source_repo_url
29 'repo_url': pull_request_source_repo_url,
30 'source_repo': pull_request_source_repo.repo_name,
31 'target_repo': pull_request_target_repo.repo_name,
32 'source_repo_url': pull_request_source_repo_url,
33 'target_repo_url': pull_request_target_repo_url,
29 34 }
30 35 %>
31 ${self.subject()}
32 36
37 ${h.literal(_('Pull request !{pr_id}: `{pr_title}`').format(**data))}
33 38
34 ${h.literal(_('Pull request from %(source_ref_type)s:%(source_ref_name)s of %(repo_url)s into %(target_ref_type)s:%(target_ref_name)s') % data)}
39 * ${h.literal(_('Commit flow: {source_ref_type}:{source_ref_name} of {source_repo_url} into {target_ref_type}:{target_ref_name} of {target_repo_url}').format(**data))}
35 40
36
37 * ${_('Link')}: ${pull_request_url}
41 * ${_('Pull Request link')}: ${pull_request_url}
38 42
39 43 * ${_('Title')}: ${pull_request.title}
40 44
41 45 * ${_('Description')}:
42 46
43 ${pull_request.description}
47 ${pull_request.description | trim}
44 48
45 49
46 50 * ${_ungettext('Commit (%(num)s)', 'Commits (%(num)s)', len(pull_request_commits) ) % {'num': len(pull_request_commits)}}:
47 51
48 52 % for commit_id, message in pull_request_commits:
49 - ${h.short_id(commit_id)}
50 ${h.chop_at_smart(message, '\n', suffix_if_chopped='...')}
53 - ${h.short_id(commit_id)}
54 ${h.chop_at_smart(message, '\n', suffix_if_chopped='...')}
51 55
52 56 % endfor
53 57
58 ---
54 59 ${self.plaintext_footer()}
55 60 </%def>
56 61 <%
57 62 data = {
58 63 'user': h.person(user),
59 64 'pr_id': pull_request.pull_request_id,
60 65 'pr_title': pull_request.title,
61 66 'source_ref_type': pull_request.source_ref_parts.type,
62 67 'source_ref_name': pull_request.source_ref_parts.name,
63 68 'target_ref_type': pull_request.target_ref_parts.type,
64 69 'target_ref_name': pull_request.target_ref_parts.name,
65 70 'repo_url': pull_request_source_repo_url,
71 'source_repo': pull_request_source_repo.repo_name,
72 'target_repo': pull_request_target_repo.repo_name,
66 73 'source_repo_url': h.link_to(pull_request_source_repo.repo_name, pull_request_source_repo_url),
67 'target_repo_url': h.link_to(pull_request_target_repo.repo_name, pull_request_target_repo_url)
74 'target_repo_url': h.link_to(pull_request_target_repo.repo_name, pull_request_target_repo_url),
68 75 }
69 76 %>
70 <table style="text-align:left;vertical-align:middle;">
71 <tr><td colspan="2" style="width:100%;padding-bottom:15px;border-bottom:1px solid #dbd9da;"><h4><a href="${pull_request_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('%(user)s wants you to review pull request #%(pr_id)s: "%(pr_title)s".') % data }</a></h4></td></tr>
72 <tr><td style="padding-right:20px;padding-top:15px;">${_('Title')}</td><td style="padding-top:15px;">${pull_request.title}</td></tr>
73 <tr><td style="padding-right:20px;">${_('Source')}</td><td>${base.tag_button(pull_request.source_ref_parts.name)} ${h.literal(_('%(source_ref_type)s of %(source_repo_url)s') % data)}</td></tr>
74 <tr><td style="padding-right:20px;">${_('Target')}</td><td>${base.tag_button(pull_request.target_ref_parts.name)} ${h.literal(_('%(target_ref_type)s of %(target_repo_url)s') % data)}</td></tr>
75 <tr><td style="padding-right:20px;">${_('Description')}</td><td style="white-space:pre-wrap">${pull_request.description}</td></tr>
76 <tr><td style="padding-right:20px;">${_ungettext('%(num)s Commit', '%(num)s Commits', len(pull_request_commits)) % {'num': len(pull_request_commits)}}</td>
77 <td><ol style="margin:0 0 0 1em;padding:0;text-align:left;">
78 % for commit_id, message in pull_request_commits:
79 <li style="margin:0 0 1em;"><pre style="margin:0 0 .5em">${h.short_id(commit_id)}</pre>
80 ${h.chop_at_smart(message, '\n', suffix_if_chopped='...')}
81 </li>
82 % endfor
83 </ol></td>
77
78 <table style="text-align:left;vertical-align:middle;width: 100%">
79 <tr>
80 <td style="width:100%;border-bottom:1px solid #dbd9da;">
81
82 <h4 style="margin: 0">
83 <div style="margin-bottom: 4px; color:#7E7F7F">
84 @${h.person(user.username)}
85 </div>
86 ${_('requested a')}
87 <a href="${pull_request_url}" style="${base.link_css()}">
88 ${_('pull request review.').format(**data) }
89 </a>
90 <div style="margin-top: 10px"></div>
91 ${_('Pull request')} <code>!${data['pr_id']}: ${data['pr_title']}</code>
92 </h4>
93
94 </td>
95 </tr>
96
97 </table>
98
99 <table style="text-align:left;vertical-align:middle;width: 100%">
100 ## spacing def
101 <tr>
102 <td style="width: 130px"></td>
103 <td></td>
104 </tr>
105
106 <tr>
107 <td style="padding-right:20px;line-height:20px;">${_('Commit Flow')}:</td>
108 <td style="line-height:20px;">
109 ${base.tag_button('{}:{}'.format(data['source_ref_type'], pull_request.source_ref_parts.name))} ${_('of')} ${data['source_repo_url']}
110 &rarr;
111 ${base.tag_button('{}:{}'.format(data['target_ref_type'], pull_request.target_ref_parts.name))} ${_('of')} ${data['target_repo_url']}
112 </td>
113 </tr>
114
115 <tr>
116 <td style="padding-right:20px;">${_('Pull request')}:</td>
117 <td>
118 <a href="${pull_request_url}" style="${base.link_css()}">
119 !${pull_request.pull_request_id}
120 </a>
121 </td>
122 </tr>
123 <tr>
124 <td style="padding-right:20px;">${_('Description')}:</td>
125 <td style="white-space:pre-wrap"><code>${pull_request.description | trim}</code></td>
126 </tr>
127 <tr>
128 <td style="padding-right:20px;">${_ungettext('Commit (%(num)s)', 'Commits (%(num)s)', len(pull_request_commits)) % {'num': len(pull_request_commits)}}:</td>
129 <td></td>
130 </tr>
131
132 <tr>
133 <td colspan="2">
134 <ol style="margin:0 0 0 1em;padding:0;text-align:left;">
135 % for commit_id, message in pull_request_commits:
136 <li style="margin:0 0 1em;">
137 <pre style="margin:0 0 .5em"><a href="${h.route_path('repo_commit', repo_name=pull_request_source_repo.repo_name, commit_id=commit_id)}" style="${base.link_css()}">${h.short_id(commit_id)}</a></pre>
138 ${h.chop_at_smart(message, '\n', suffix_if_chopped='...')}
139 </li>
140 % endfor
141 </ol>
142 </td>
84 143 </tr>
85 144 </table>
@@ -1,21 +1,22 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 3
4 4 <%def name="subject()" filter="n,trim,whitespace_filter">
5 5 Test "Subject" ${_('hello "world"')|n}
6 6 </%def>
7 7
8 8 <%def name="headers()" filter="n,trim">
9 9 X=Y
10 10 </%def>
11 11
12 12 ## plain text version of the email. Empty by default
13 13 <%def name="body_plaintext()" filter="n,trim">
14 14 Email Plaintext Body
15 15 </%def>
16 16
17 17 ## BODY GOES BELOW
18 <b>Email Body</b>
19
20 ${h.short_id('0' * 40)}
21 ${_('Translation')} No newline at end of file
18 <strong>Email Body</strong>
19 <br/>
20 <br/>
21 `h.short_id()`: ${h.short_id('0' * 40)}<br/>
22 ${_('Translation String')}<br/>
@@ -1,27 +1,59 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 <%namespace name="base" file="base.mako"/>
3 4
4 5 <%def name="subject()" filter="n,trim,whitespace_filter">
5 6 RhodeCode new user registration: ${user.username}
6 7 </%def>
7 8
8 9 <%def name="body_plaintext()" filter="n,trim">
9 10
10 11 A new user `${user.username}` has registered on ${h.format_date(date)}
11 12
12 13 - Username: ${user.username}
13 14 - Full Name: ${user.first_name} ${user.last_name}
14 15 - Email: ${user.email}
15 16 - Profile link: ${h.route_url('user_profile', username=user.username)}
16 17
18 ---
17 19 ${self.plaintext_footer()}
18 20 </%def>
19 21
20 ## BODY GOES BELOW
21 <table style="text-align:left;vertical-align:middle;">
22 <tr><td colspan="2" style="width:100%;padding-bottom:15px;border-bottom:1px solid #dbd9da;"><h4><a href="${h.route_url('user_profile', username=user.username)}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('New user %(user)s has registered on %(date)s') % {'user': user.username, 'date': h.format_date(date)}}</a></h4></td></tr>
23 <tr><td style="padding-right:20px;padding-top:20px;">${_('Username')}</td><td style="line-height:1;padding-top:20px;"><img style="margin-bottom:-5px;text-align:left;border:1px solid #dbd9da" src="${h.gravatar_url(user.email, 16)}" height="16" width="16">&nbsp;${user.username}</td></tr>
24 <tr><td style="padding-right:20px;">${_('Full Name')}</td><td>${user.first_name} ${user.last_name}</td></tr>
25 <tr><td style="padding-right:20px;">${_('Email')}</td><td>${user.email}</td></tr>
26 <tr><td style="padding-right:20px;">${_('Profile')}</td><td><a href="${h.route_url('user_profile', username=user.username)}">${h.route_url('user_profile', username=user.username)}</a></td></tr>
27 </table> No newline at end of file
22
23 <table style="text-align:left;vertical-align:middle;width: 100%">
24 <tr>
25 <td style="width:100%;border-bottom:1px solid #dbd9da;">
26 <h4 style="margin: 0">
27 <a href="${h.route_url('user_profile', username=user.username)}" style="${base.link_css()}">
28 ${_('New user {user} has registered on {date}').format(user=user.username, date=h.format_date(date))}
29 </a>
30 </h4>
31 </td>
32 </tr>
33 </table>
34
35 <table style="text-align:left;vertical-align:middle;width: 100%">
36 ## spacing def
37 <tr>
38 <td style="width: 130px"></td>
39 <td></td>
40 </tr>
41 <tr>
42 <td style="padding-right:20px;padding-top:20px;">${_('Username')}:</td>
43 <td style="line-height:1;padding-top:20px;">${user.username}</td>
44 </tr>
45 <tr>
46 <td style="padding-right:20px;">${_('Full Name')}:</td>
47 <td>${user.first_name} ${user.last_name}</td>
48 </tr>
49 <tr>
50 <td style="padding-right:20px;">${_('Email')}:</td>
51 <td>${user.email}</td>
52 </tr>
53 <tr>
54 <td style="padding-right:20px;">${_('Profile')}:</td>
55 <td>
56 <a href="${h.route_url('user_profile', username=user.username)}">${h.route_url('user_profile', username=user.username)}</a>
57 </td>
58 </tr>
59 </table>
@@ -1,143 +1,138 b''
1 import collections
2 1 # -*- coding: utf-8 -*-
3 2
4 3 # Copyright (C) 2010-2019 RhodeCode GmbH
5 4 #
6 5 # This program is free software: you can redistribute it and/or modify
7 6 # it under the terms of the GNU Affero General Public License, version 3
8 7 # (only), as published by the Free Software Foundation.
9 8 #
10 9 # This program is distributed in the hope that it will be useful,
11 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 12 # GNU General Public License for more details.
14 13 #
15 14 # You should have received a copy of the GNU Affero General Public License
16 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
17 16 #
18 17 # This program is dual-licensed. If you wish to learn more about the
19 18 # RhodeCode Enterprise Edition, including its added features, Support services,
20 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
21 20
22 21 import pytest
22 import collections
23 23
24 24 from rhodecode.lib.partial_renderer import PyramidPartialRenderer
25 25 from rhodecode.lib.utils2 import AttributeDict
26 from rhodecode.model.db import User
26 27 from rhodecode.model.notification import EmailNotificationModel
27 28
28 29
29 30 def test_get_template_obj(app, request_stub):
30 31 template = EmailNotificationModel().get_renderer(
31 32 EmailNotificationModel.TYPE_TEST, request_stub)
32 33 assert isinstance(template, PyramidPartialRenderer)
33 34
34 35
35 36 def test_render_email(app, http_host_only_stub):
36 37 kwargs = {}
37 38 subject, headers, body, body_plaintext = EmailNotificationModel().render_email(
38 39 EmailNotificationModel.TYPE_TEST, **kwargs)
39 40
40 41 # subject
41 42 assert subject == 'Test "Subject" hello "world"'
42 43
43 44 # headers
44 45 assert headers == 'X=Y'
45 46
46 47 # body plaintext
47 48 assert body_plaintext == 'Email Plaintext Body'
48 49
49 50 # body
50 notification_footer = 'This is a notification from RhodeCode. http://%s/' \
51 % http_host_only_stub
52 assert notification_footer in body
51 notification_footer1 = 'This is a notification from RhodeCode.'
52 notification_footer2 = 'http://{}/'.format(http_host_only_stub)
53 assert notification_footer1 in body
54 assert notification_footer2 in body
53 55 assert 'Email Body' in body
54 56
55 57
56 58 def test_render_pr_email(app, user_admin):
57
58 ref = collections.namedtuple('Ref',
59 'name, type')(
60 'fxies123', 'book'
61 )
59 ref = collections.namedtuple(
60 'Ref', 'name, type')('fxies123', 'book')
62 61
63 62 pr = collections.namedtuple('PullRequest',
64 63 'pull_request_id, title, description, source_ref_parts, source_ref_name, target_ref_parts, target_ref_name')(
65 64 200, 'Example Pull Request', 'Desc of PR', ref, 'bookmark', ref, 'Branch')
66 65
67 source_repo = target_repo = collections.namedtuple('Repo',
68 'type, repo_name')(
69 'hg', 'pull_request_1')
66 source_repo = target_repo = collections.namedtuple(
67 'Repo', 'type, repo_name')('hg', 'pull_request_1')
70 68
71 69 kwargs = {
72 'user': '<marcin@rhodecode.com> Marcin Kuzminski',
70 'user': User.get_first_super_admin(),
73 71 'pull_request': pr,
74 72 'pull_request_commits': [],
75 73
76 74 'pull_request_target_repo': target_repo,
77 75 'pull_request_target_repo_url': 'x',
78 76
79 77 'pull_request_source_repo': source_repo,
80 78 'pull_request_source_repo_url': 'x',
81 79
82 80 'pull_request_url': 'http://localhost/pr1',
83 81 }
84 82
85 83 subject, headers, body, body_plaintext = EmailNotificationModel().render_email(
86 84 EmailNotificationModel.TYPE_PULL_REQUEST, **kwargs)
87 85
88 86 # subject
89 assert subject == 'Marcin Kuzminski wants you to review pull request #200: "Example Pull Request"'
87 assert subject == '@test_admin (RhodeCode Admin) requested a pull request review. !200: "Example Pull Request"'
90 88
91 89
92 90 @pytest.mark.parametrize('mention', [
93 91 True,
94 92 False
95 93 ])
96 94 @pytest.mark.parametrize('email_type', [
97 95 EmailNotificationModel.TYPE_COMMIT_COMMENT,
98 96 EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
99 97 ])
100 98 def test_render_comment_subject_no_newlines(app, mention, email_type):
101 ref = collections.namedtuple('Ref',
102 'name, type')(
103 'fxies123', 'book'
104 )
99 ref = collections.namedtuple(
100 'Ref', 'name, type')('fxies123', 'book')
105 101
106 102 pr = collections.namedtuple('PullRequest',
107 103 'pull_request_id, title, description, source_ref_parts, source_ref_name, target_ref_parts, target_ref_name')(
108 104 200, 'Example Pull Request', 'Desc of PR', ref, 'bookmark', ref, 'Branch')
109 105
110 source_repo = target_repo = collections.namedtuple('Repo',
111 'type, repo_name')(
112 'hg', 'pull_request_1')
106 source_repo = target_repo = collections.namedtuple(
107 'Repo', 'type, repo_name')('hg', 'pull_request_1')
113 108
114 109 kwargs = {
115 'user': '<marcin@rhodecode.com> Marcin Kuzminski',
110 'user': User.get_first_super_admin(),
116 111 'commit': AttributeDict(raw_id='a'*40, message='Commit message'),
117 112 'status_change': 'approved',
118 'commit_target_repo': AttributeDict(),
113 'commit_target_repo_url': 'http://foo.example.com/#comment1',
119 114 'repo_name': 'test-repo',
120 115 'comment_file': 'test-file.py',
121 116 'comment_line': 'n100',
122 117 'comment_type': 'note',
123 118 'commit_comment_url': 'http://comment-url',
124 119 'instance_url': 'http://rc-instance',
125 120 'comment_body': 'hello world',
126 121 'mention': mention,
127 122
128 123 'pr_comment_url': 'http://comment-url',
129 'pr_source_repo': AttributeDict(repo_name='foobar'),
130 'pr_source_repo_url': 'http://soirce-repo/url',
131 124 'pull_request': pr,
132 125 'pull_request_commits': [],
133 126
134 127 'pull_request_target_repo': target_repo,
135 128 'pull_request_target_repo_url': 'x',
136 129
137 130 'pull_request_source_repo': source_repo,
138 131 'pull_request_source_repo_url': 'x',
132
133 'pull_request_url': 'http://code.rc.com/_pr/123'
139 134 }
140 135 subject, headers, body, body_plaintext = EmailNotificationModel().render_email(
141 136 email_type, **kwargs)
142 137
143 138 assert '\n' not in subject
General Comments 0
You need to be logged in to leave comments. Login now