Show More
@@ -0,0 +1,26 b'' | |||||
|
1 | <div tal:define="css_class css_class|field.widget.css_class; | |||
|
2 | style style|field.widget.style; | |||
|
3 | oid oid|field.oid; | |||
|
4 | inline getattr(field.widget, 'inline', False)" | |||
|
5 | tal:omit-tag="not inline"> | |||
|
6 | ${field.start_sequence()} | |||
|
7 | <div tal:repeat="choice values | field.widget.values" | |||
|
8 | tal:omit-tag="inline" | |||
|
9 | class="checkbox"> | |||
|
10 | <div tal:define="(value, title, help_block) choice"> | |||
|
11 | <input tal:attributes="checked value in cstruct; | |||
|
12 | class css_class; | |||
|
13 | style style" | |||
|
14 | type="checkbox" | |||
|
15 | name="checkbox" | |||
|
16 | value="${value}" | |||
|
17 | id="${oid}-${repeat.choice.index}"/> | |||
|
18 | <label for="${oid}-${repeat.choice.index}" | |||
|
19 | tal:attributes="class inline and 'checkbox-inline'"> | |||
|
20 | ${title} | |||
|
21 | </label> | |||
|
22 | <p tal:condition="help_block" class="help-block">${help_block}</p> | |||
|
23 | </div> | |||
|
24 | </div> | |||
|
25 | ${field.end_sequence()} | |||
|
26 | </div> |
@@ -75,4 +75,5 b' from rhodecode.events.pullrequest import' | |||||
75 | PullRequestReviewEvent, |
|
75 | PullRequestReviewEvent, | |
76 | PullRequestMergeEvent, |
|
76 | PullRequestMergeEvent, | |
77 | PullRequestCloseEvent, |
|
77 | PullRequestCloseEvent, | |
|
78 | PullRequestCommentEvent, | |||
78 | ) |
|
79 | ) |
@@ -44,6 +44,9 b' class RhodecodeEvent(object):' | |||||
44 | self._request = request |
|
44 | self._request = request | |
45 | self.utc_timestamp = datetime.datetime.utcnow() |
|
45 | self.utc_timestamp = datetime.datetime.utcnow() | |
46 |
|
46 | |||
|
47 | def __repr__(self): | |||
|
48 | return '<%s:(%s)>' % (self.__class__.__name__, self.name) | |||
|
49 | ||||
47 | def get_request(self): |
|
50 | def get_request(self): | |
48 | if self._request: |
|
51 | if self._request: | |
49 | return self._request |
|
52 | return self._request | |
@@ -116,3 +119,4 b' class RhodeCodeIntegrationEvent(Rhodecod' | |||||
116 | """ |
|
119 | """ | |
117 | Special subclass for Integration events |
|
120 | Special subclass for Integration events | |
118 | """ |
|
121 | """ | |
|
122 | description = '' |
@@ -77,6 +77,7 b' class PullRequestCreateEvent(PullRequest' | |||||
77 | """ |
|
77 | """ | |
78 | name = 'pullrequest-create' |
|
78 | name = 'pullrequest-create' | |
79 | display_name = lazy_ugettext('pullrequest created') |
|
79 | display_name = lazy_ugettext('pullrequest created') | |
|
80 | description = lazy_ugettext('Event triggered after pull request was created') | |||
80 |
|
81 | |||
81 |
|
82 | |||
82 | class PullRequestCloseEvent(PullRequestEvent): |
|
83 | class PullRequestCloseEvent(PullRequestEvent): | |
@@ -86,6 +87,7 b' class PullRequestCloseEvent(PullRequestE' | |||||
86 | """ |
|
87 | """ | |
87 | name = 'pullrequest-close' |
|
88 | name = 'pullrequest-close' | |
88 | display_name = lazy_ugettext('pullrequest closed') |
|
89 | display_name = lazy_ugettext('pullrequest closed') | |
|
90 | description = lazy_ugettext('Event triggered after pull request was closed') | |||
89 |
|
91 | |||
90 |
|
92 | |||
91 | class PullRequestUpdateEvent(PullRequestEvent): |
|
93 | class PullRequestUpdateEvent(PullRequestEvent): | |
@@ -95,6 +97,7 b' class PullRequestUpdateEvent(PullRequest' | |||||
95 | """ |
|
97 | """ | |
96 | name = 'pullrequest-update' |
|
98 | name = 'pullrequest-update' | |
97 | display_name = lazy_ugettext('pullrequest commits updated') |
|
99 | display_name = lazy_ugettext('pullrequest commits updated') | |
|
100 | description = lazy_ugettext('Event triggered after pull requests was updated') | |||
98 |
|
101 | |||
99 |
|
102 | |||
100 | class PullRequestReviewEvent(PullRequestEvent): |
|
103 | class PullRequestReviewEvent(PullRequestEvent): | |
@@ -104,6 +107,8 b' class PullRequestReviewEvent(PullRequest' | |||||
104 | """ |
|
107 | """ | |
105 | name = 'pullrequest-review' |
|
108 | name = 'pullrequest-review' | |
106 | display_name = lazy_ugettext('pullrequest review changed') |
|
109 | display_name = lazy_ugettext('pullrequest review changed') | |
|
110 | description = lazy_ugettext('Event triggered after a review status of a ' | |||
|
111 | 'pull requests has changed to other.') | |||
107 |
|
112 | |||
108 | def __init__(self, pullrequest, status): |
|
113 | def __init__(self, pullrequest, status): | |
109 | super(PullRequestReviewEvent, self).__init__(pullrequest) |
|
114 | super(PullRequestReviewEvent, self).__init__(pullrequest) | |
@@ -117,6 +122,8 b' class PullRequestMergeEvent(PullRequestE' | |||||
117 | """ |
|
122 | """ | |
118 | name = 'pullrequest-merge' |
|
123 | name = 'pullrequest-merge' | |
119 | display_name = lazy_ugettext('pullrequest merged') |
|
124 | display_name = lazy_ugettext('pullrequest merged') | |
|
125 | description = lazy_ugettext('Event triggered after a successful merge operation ' | |||
|
126 | 'was executed on a pull request') | |||
120 |
|
127 | |||
121 |
|
128 | |||
122 | class PullRequestCommentEvent(PullRequestEvent): |
|
129 | class PullRequestCommentEvent(PullRequestEvent): | |
@@ -126,6 +133,8 b' class PullRequestCommentEvent(PullReques' | |||||
126 | """ |
|
133 | """ | |
127 | name = 'pullrequest-comment' |
|
134 | name = 'pullrequest-comment' | |
128 | display_name = lazy_ugettext('pullrequest commented') |
|
135 | display_name = lazy_ugettext('pullrequest commented') | |
|
136 | description = lazy_ugettext('Event triggered after a comment was made on a code ' | |||
|
137 | 'in the pull request') | |||
129 |
|
138 | |||
130 | def __init__(self, pullrequest, comment): |
|
139 | def __init__(self, pullrequest, comment): | |
131 | super(PullRequestCommentEvent, self).__init__(pullrequest) |
|
140 | super(PullRequestCommentEvent, self).__init__(pullrequest) |
@@ -186,13 +186,33 b' class RepoCommitCommentEvent(RepoEvent):' | |||||
186 | An instance of this class is emitted as an :term:`event` after a comment is made |
|
186 | An instance of this class is emitted as an :term:`event` after a comment is made | |
187 | on repository commit. |
|
187 | on repository commit. | |
188 | """ |
|
188 | """ | |
|
189 | ||||
|
190 | name = 'repo-commit-comment' | |||
|
191 | display_name = lazy_ugettext('repository commit comment') | |||
|
192 | description = lazy_ugettext('Event triggered after a comment was made ' | |||
|
193 | 'on commit inside a repository') | |||
|
194 | ||||
189 | def __init__(self, repo, commit, comment): |
|
195 | def __init__(self, repo, commit, comment): | |
190 | super(RepoCommitCommentEvent, self).__init__(repo) |
|
196 | super(RepoCommitCommentEvent, self).__init__(repo) | |
191 | self.commit = commit |
|
197 | self.commit = commit | |
192 | self.comment = comment |
|
198 | self.comment = comment | |
193 |
|
199 | |||
194 | name = 'repo-commit-comment' |
|
200 | def as_dict(self): | |
195 | display_name = lazy_ugettext('repository commit comment') |
|
201 | data = super(RepoCommitCommentEvent, self).as_dict() | |
|
202 | data['commit'] = { | |||
|
203 | 'commit_id': self.commit.raw_id, | |||
|
204 | 'commit_message': self.commit.message, | |||
|
205 | 'commit_branch': self.commit.branch, | |||
|
206 | } | |||
|
207 | ||||
|
208 | data['comment'] = { | |||
|
209 | 'comment_id': self.comment.comment_id, | |||
|
210 | 'comment_text': self.comment.text, | |||
|
211 | 'comment_type': self.comment.comment_type, | |||
|
212 | 'comment_f_path': self.comment.f_path, | |||
|
213 | 'comment_line_no': self.comment.line_no, | |||
|
214 | } | |||
|
215 | return data | |||
196 |
|
216 | |||
197 |
|
217 | |||
198 | class RepoPreCreateEvent(RepoEvent): |
|
218 | class RepoPreCreateEvent(RepoEvent): | |
@@ -202,6 +222,7 b' class RepoPreCreateEvent(RepoEvent):' | |||||
202 | """ |
|
222 | """ | |
203 | name = 'repo-pre-create' |
|
223 | name = 'repo-pre-create' | |
204 | display_name = lazy_ugettext('repository pre create') |
|
224 | display_name = lazy_ugettext('repository pre create') | |
|
225 | description = lazy_ugettext('Event triggered before repository is created') | |||
205 |
|
226 | |||
206 |
|
227 | |||
207 | class RepoCreateEvent(RepoEvent): |
|
228 | class RepoCreateEvent(RepoEvent): | |
@@ -211,6 +232,7 b' class RepoCreateEvent(RepoEvent):' | |||||
211 | """ |
|
232 | """ | |
212 | name = 'repo-create' |
|
233 | name = 'repo-create' | |
213 | display_name = lazy_ugettext('repository created') |
|
234 | display_name = lazy_ugettext('repository created') | |
|
235 | description = lazy_ugettext('Event triggered after repository was created') | |||
214 |
|
236 | |||
215 |
|
237 | |||
216 | class RepoPreDeleteEvent(RepoEvent): |
|
238 | class RepoPreDeleteEvent(RepoEvent): | |
@@ -220,6 +242,7 b' class RepoPreDeleteEvent(RepoEvent):' | |||||
220 | """ |
|
242 | """ | |
221 | name = 'repo-pre-delete' |
|
243 | name = 'repo-pre-delete' | |
222 | display_name = lazy_ugettext('repository pre delete') |
|
244 | display_name = lazy_ugettext('repository pre delete') | |
|
245 | description = lazy_ugettext('Event triggered before a repository is deleted') | |||
223 |
|
246 | |||
224 |
|
247 | |||
225 | class RepoDeleteEvent(RepoEvent): |
|
248 | class RepoDeleteEvent(RepoEvent): | |
@@ -229,6 +252,7 b' class RepoDeleteEvent(RepoEvent):' | |||||
229 | """ |
|
252 | """ | |
230 | name = 'repo-delete' |
|
253 | name = 'repo-delete' | |
231 | display_name = lazy_ugettext('repository deleted') |
|
254 | display_name = lazy_ugettext('repository deleted') | |
|
255 | description = lazy_ugettext('Event triggered after repository was deleted') | |||
232 |
|
256 | |||
233 |
|
257 | |||
234 | class RepoVCSEvent(RepoEvent): |
|
258 | class RepoVCSEvent(RepoEvent): | |
@@ -269,6 +293,7 b' class RepoPrePullEvent(RepoVCSEvent):' | |||||
269 | """ |
|
293 | """ | |
270 | name = 'repo-pre-pull' |
|
294 | name = 'repo-pre-pull' | |
271 | display_name = lazy_ugettext('repository pre pull') |
|
295 | display_name = lazy_ugettext('repository pre pull') | |
|
296 | description = lazy_ugettext('Event triggered before repository code is pulled') | |||
272 |
|
297 | |||
273 |
|
298 | |||
274 | class RepoPullEvent(RepoVCSEvent): |
|
299 | class RepoPullEvent(RepoVCSEvent): | |
@@ -278,6 +303,7 b' class RepoPullEvent(RepoVCSEvent):' | |||||
278 | """ |
|
303 | """ | |
279 | name = 'repo-pull' |
|
304 | name = 'repo-pull' | |
280 | display_name = lazy_ugettext('repository pull') |
|
305 | display_name = lazy_ugettext('repository pull') | |
|
306 | description = lazy_ugettext('Event triggered after repository code was pulled') | |||
281 |
|
307 | |||
282 |
|
308 | |||
283 | class RepoPrePushEvent(RepoVCSEvent): |
|
309 | class RepoPrePushEvent(RepoVCSEvent): | |
@@ -287,6 +313,8 b' class RepoPrePushEvent(RepoVCSEvent):' | |||||
287 | """ |
|
313 | """ | |
288 | name = 'repo-pre-push' |
|
314 | name = 'repo-pre-push' | |
289 | display_name = lazy_ugettext('repository pre push') |
|
315 | display_name = lazy_ugettext('repository pre push') | |
|
316 | description = lazy_ugettext('Event triggered before the code is ' | |||
|
317 | 'pushed to a repository') | |||
290 |
|
318 | |||
291 |
|
319 | |||
292 | class RepoPushEvent(RepoVCSEvent): |
|
320 | class RepoPushEvent(RepoVCSEvent): | |
@@ -298,6 +326,8 b' class RepoPushEvent(RepoVCSEvent):' | |||||
298 | """ |
|
326 | """ | |
299 | name = 'repo-push' |
|
327 | name = 'repo-push' | |
300 | display_name = lazy_ugettext('repository push') |
|
328 | display_name = lazy_ugettext('repository push') | |
|
329 | description = lazy_ugettext('Event triggered after the code was ' | |||
|
330 | 'pushed to a repository') | |||
301 |
|
331 | |||
302 | def __init__(self, repo_name, pushed_commit_ids, extras): |
|
332 | def __init__(self, repo_name, pushed_commit_ids, extras): | |
303 | super(RepoPushEvent, self).__init__(repo_name, extras) |
|
333 | super(RepoPushEvent, self).__init__(repo_name, extras) |
@@ -60,6 +60,7 b' class RepoGroupCreateEvent(RepoGroupEven' | |||||
60 | """ |
|
60 | """ | |
61 | name = 'repo-group-create' |
|
61 | name = 'repo-group-create' | |
62 | display_name = lazy_ugettext('repository group created') |
|
62 | display_name = lazy_ugettext('repository group created') | |
|
63 | description = lazy_ugettext('Event triggered after a repository group was created') | |||
63 |
|
64 | |||
64 |
|
65 | |||
65 | class RepoGroupDeleteEvent(RepoGroupEvent): |
|
66 | class RepoGroupDeleteEvent(RepoGroupEvent): | |
@@ -69,6 +70,7 b' class RepoGroupDeleteEvent(RepoGroupEven' | |||||
69 | """ |
|
70 | """ | |
70 | name = 'repo-group-delete' |
|
71 | name = 'repo-group-delete' | |
71 | display_name = lazy_ugettext('repository group deleted') |
|
72 | display_name = lazy_ugettext('repository group deleted') | |
|
73 | description = lazy_ugettext('Event triggered after a repository group was deleted') | |||
72 |
|
74 | |||
73 |
|
75 | |||
74 | class RepoGroupUpdateEvent(RepoGroupEvent): |
|
76 | class RepoGroupUpdateEvent(RepoGroupEvent): | |
@@ -78,3 +80,4 b' class RepoGroupUpdateEvent(RepoGroupEven' | |||||
78 | """ |
|
80 | """ | |
79 | name = 'repo-group-update' |
|
81 | name = 'repo-group-update' | |
80 | display_name = lazy_ugettext('repository group update') |
|
82 | display_name = lazy_ugettext('repository group update') | |
|
83 | description = lazy_ugettext('Event triggered after a repository group was updated') |
@@ -125,6 +125,19 b' class IntegrationTypeBase(object):' | |||||
125 | """ |
|
125 | """ | |
126 | return colander.Schema() |
|
126 | return colander.Schema() | |
127 |
|
127 | |||
|
128 | def event_enabled(self, event): | |||
|
129 | """ | |||
|
130 | Checks if submitted event is enabled based on the plugin settings | |||
|
131 | :param event: | |||
|
132 | :return: bool | |||
|
133 | """ | |||
|
134 | allowed_events = self.settings['events'] | |||
|
135 | if event.name not in allowed_events: | |||
|
136 | log.debug('event ignored: %r event %s not in allowed set of events %s', | |||
|
137 | event, event.name, allowed_events) | |||
|
138 | return False | |||
|
139 | return True | |||
|
140 | ||||
128 |
|
141 | |||
129 | class EEIntegration(IntegrationTypeBase): |
|
142 | class EEIntegration(IntegrationTypeBase): | |
130 | description = 'Integration available in RhodeCode EE edition.' |
|
143 | description = 'Integration available in RhodeCode EE edition.' | |
@@ -139,31 +152,58 b' class EEIntegration(IntegrationTypeBase)' | |||||
139 | # Helpers # |
|
152 | # Helpers # | |
140 | # updating this required to update the `common_vars` as well. |
|
153 | # updating this required to update the `common_vars` as well. | |
141 | WEBHOOK_URL_VARS = [ |
|
154 | WEBHOOK_URL_VARS = [ | |
|
155 | # GENERAL | |||
|
156 | ('General', [ | |||
142 | ('event_name', 'Unique name of the event type, e.g pullrequest-update'), |
|
157 | ('event_name', 'Unique name of the event type, e.g pullrequest-update'), | |
143 | ('repo_name', 'Full name of the repository'), |
|
158 | ('repo_name', 'Full name of the repository'), | |
144 | ('repo_type', 'VCS type of repository'), |
|
159 | ('repo_type', 'VCS type of repository'), | |
145 | ('repo_id', 'Unique id of repository'), |
|
160 | ('repo_id', 'Unique id of repository'), | |
146 | ('repo_url', 'Repository url'), |
|
161 | ('repo_url', 'Repository url'), | |
|
162 | ] | |||
|
163 | ), | |||
147 | # extra repo fields |
|
164 | # extra repo fields | |
|
165 | ('Repository', [ | |||
148 | ('extra:<extra_key_name>', 'Extra repo variables, read from its settings.'), |
|
166 | ('extra:<extra_key_name>', 'Extra repo variables, read from its settings.'), | |
149 |
|
167 | ] | ||
|
168 | ), | |||
150 | # special attrs below that we handle, using multi-call |
|
169 | # special attrs below that we handle, using multi-call | |
|
170 | ('Commit push - Multicalls', [ | |||
151 | ('branch', 'Name of each branch submitted, if any.'), |
|
171 | ('branch', 'Name of each branch submitted, if any.'), | |
152 | ('branch_head', 'Head ID of pushed branch (full sha of last commit), if any.'), |
|
172 | ('branch_head', 'Head ID of pushed branch (full sha of last commit), if any.'), | |
153 | ('commit_id', 'ID (full sha) of each commit submitted, if any.'), |
|
173 | ('commit_id', 'ID (full sha) of each commit submitted, if any.'), | |
154 |
|
174 | ] | ||
|
175 | ), | |||
155 | # pr events vars |
|
176 | # pr events vars | |
|
177 | ('Pull request', [ | |||
156 | ('pull_request_id', 'Unique ID of the pull request.'), |
|
178 | ('pull_request_id', 'Unique ID of the pull request.'), | |
157 | ('pull_request_title', 'Title of the pull request.'), |
|
179 | ('pull_request_title', 'Title of the pull request.'), | |
158 | ('pull_request_url', 'Pull request url.'), |
|
180 | ('pull_request_url', 'Pull request url.'), | |
159 | ('pull_request_shadow_url', 'Pull request shadow repo clone url.'), |
|
181 | ('pull_request_shadow_url', 'Pull request shadow repo clone url.'), | |
160 | ('pull_request_commits_uid', 'Calculated UID of all commits inside the PR. ' |
|
182 | ('pull_request_commits_uid', 'Calculated UID of all commits inside the PR. ' | |
161 | 'Changes after PR update'), |
|
183 | 'Changes after PR update'), | |
|
184 | ] | |||
|
185 | ), | |||
|
186 | # commit comment event vars | |||
|
187 | ('Commit comment', [ | |||
|
188 | ('commit_comment_id', 'Unique ID of the comment made on a commit.'), | |||
|
189 | ('commit_comment_text', 'Text of commit comment.'), | |||
|
190 | ('commit_comment_type', 'Type of comment, e.g note/todo.'), | |||
162 |
|
191 | |||
|
192 | ('commit_comment_f_path', 'Optionally path of file for inline comments.'), | |||
|
193 | ('commit_comment_line_no', 'Line number of the file: eg o10, or n200'), | |||
|
194 | ||||
|
195 | ('commit_comment_commit_id', 'Commit id that comment was left at.'), | |||
|
196 | ('commit_comment_commit_branch', 'Commit branch that comment was left at'), | |||
|
197 | ('commit_comment_commit_message', 'Commit message that comment was left at'), | |||
|
198 | ] | |||
|
199 | ), | |||
163 | # user who triggers the call |
|
200 | # user who triggers the call | |
|
201 | ('Caller', [ | |||
164 | ('username', 'User who triggered the call.'), |
|
202 | ('username', 'User who triggered the call.'), | |
165 | ('user_id', 'User id who triggered the call.'), |
|
203 | ('user_id', 'User id who triggered the call.'), | |
166 | ] |
|
204 | ] | |
|
205 | ), | |||
|
206 | ] | |||
167 |
|
207 | |||
168 | # common vars for url template used for CI plugins. Shared with webhook |
|
208 | # common vars for url template used for CI plugins. Shared with webhook | |
169 | CI_URL_VARS = WEBHOOK_URL_VARS |
|
209 | CI_URL_VARS = WEBHOOK_URL_VARS | |
@@ -271,6 +311,26 b' class WebhookDataHandler(CommitParsingDa' | |||||
271 |
|
311 | |||
272 | return url_calls |
|
312 | return url_calls | |
273 |
|
313 | |||
|
314 | def repo_commit_comment_handler(self, event, data): | |||
|
315 | url = self.get_base_parsed_template(data) | |||
|
316 | log.debug('register %s call(%s) to url %s', self.name, event, url) | |||
|
317 | comment_vars = [ | |||
|
318 | ('commit_comment_id', data['comment']['comment_id']), | |||
|
319 | ('commit_comment_text', data['comment']['comment_text']), | |||
|
320 | ('commit_comment_type', data['comment']['comment_type']), | |||
|
321 | ||||
|
322 | ('commit_comment_f_path', data['comment']['comment_f_path']), | |||
|
323 | ('commit_comment_line_no', data['comment']['comment_line_no']), | |||
|
324 | ||||
|
325 | ('commit_comment_commit_id', data['commit']['commit_id']), | |||
|
326 | ('commit_comment_commit_branch', data['commit']['commit_branch']), | |||
|
327 | ('commit_comment_commit_message', data['commit']['commit_message']), | |||
|
328 | ] | |||
|
329 | for k, v in comment_vars: | |||
|
330 | url = UrlTmpl(url).safe_substitute(**{k: v}) | |||
|
331 | ||||
|
332 | return [(url, self.headers, data)] | |||
|
333 | ||||
274 | def repo_create_event_handler(self, event, data): |
|
334 | def repo_create_event_handler(self, event, data): | |
275 | url = self.get_base_parsed_template(data) |
|
335 | url = self.get_base_parsed_template(data) | |
276 | log.debug('register %s call(%s) to url %s', self.name, event, url) |
|
336 | log.debug('register %s call(%s) to url %s', self.name, event, url) | |
@@ -298,12 +358,13 b' class WebhookDataHandler(CommitParsingDa' | |||||
298 | return self.repo_push_event_handler(event, data) |
|
358 | return self.repo_push_event_handler(event, data) | |
299 | elif isinstance(event, events.RepoCreateEvent): |
|
359 | elif isinstance(event, events.RepoCreateEvent): | |
300 | return self.repo_create_event_handler(event, data) |
|
360 | return self.repo_create_event_handler(event, data) | |
|
361 | elif isinstance(event, events.RepoCommitCommentEvent): | |||
|
362 | return self.repo_commit_comment_handler(event, data) | |||
301 | elif isinstance(event, events.PullRequestEvent): |
|
363 | elif isinstance(event, events.PullRequestEvent): | |
302 | return self.pull_request_event_handler(event, data) |
|
364 | return self.pull_request_event_handler(event, data) | |
303 | else: |
|
365 | else: | |
304 | raise ValueError( |
|
366 | raise ValueError( | |
305 | 'event type `%s` not in supported list: %s' % ( |
|
367 | 'event type `{}` has no handler defined'.format(event.__class__)) | |
306 | event.__class__, events)) |
|
|||
307 |
|
368 | |||
308 |
|
369 | |||
309 | def get_auth(settings): |
|
370 | def get_auth(settings): | |
@@ -320,9 +381,13 b' def get_web_token(settings):' | |||||
320 |
|
381 | |||
321 |
|
382 | |||
322 | def get_url_vars(url_vars): |
|
383 | def get_url_vars(url_vars): | |
323 | return '\n'.join( |
|
384 | items = [] | |
324 | '{} - {}'.format('${' + key + '}', explanation) |
|
385 | ||
325 | for key, explanation in url_vars) |
|
386 | for section, section_items in url_vars: | |
|
387 | items.append('\n*{}*'.format(section)) | |||
|
388 | for key, explanation in section_items: | |||
|
389 | items.append(' {} - {}'.format('${' + key + '}', explanation)) | |||
|
390 | return '\n'.join(items) | |||
326 |
|
391 | |||
327 |
|
392 | |||
328 | def render_with_traceback(template, *args, **kwargs): |
|
393 | def render_with_traceback(template, *args, **kwargs): |
@@ -19,13 +19,14 b'' | |||||
19 | # and proprietary license terms, please see https://rhodecode.com/licenses/ |
|
19 | # and proprietary license terms, please see https://rhodecode.com/licenses/ | |
20 |
|
20 | |||
21 | from __future__ import unicode_literals |
|
21 | from __future__ import unicode_literals | |
22 | import deform |
|
|||
23 | import logging |
|
22 | import logging | |
|
23 | ||||
24 | import colander |
|
24 | import colander | |
25 |
|
25 | import deform.widget | ||
26 | from mako.template import Template |
|
26 | from mako.template import Template | |
27 |
|
27 | |||
28 | from rhodecode import events |
|
28 | from rhodecode import events | |
|
29 | from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc | |||
29 | from rhodecode.translation import _ |
|
30 | from rhodecode.translation import _ | |
30 | from rhodecode.lib.celerylib import run_task |
|
31 | from rhodecode.lib.celerylib import run_task | |
31 | from rhodecode.lib.celerylib import tasks |
|
32 | from rhodecode.lib.celerylib import tasks | |
@@ -174,6 +175,10 b' class EmailIntegrationType(IntegrationTy' | |||||
174 | display_name = _('Email') |
|
175 | display_name = _('Email') | |
175 | description = _('Send repo push summaries to a list of recipients via email') |
|
176 | description = _('Send repo push summaries to a list of recipients via email') | |
176 |
|
177 | |||
|
178 | valid_events = [ | |||
|
179 | events.RepoPushEvent | |||
|
180 | ] | |||
|
181 | ||||
177 | @classmethod |
|
182 | @classmethod | |
178 | def icon(cls): |
|
183 | def icon(cls): | |
179 | return ''' |
|
184 | return ''' | |
@@ -240,19 +245,45 b' class EmailIntegrationType(IntegrationTy' | |||||
240 |
|
245 | |||
241 | def settings_schema(self): |
|
246 | def settings_schema(self): | |
242 | schema = EmailSettingsSchema() |
|
247 | schema = EmailSettingsSchema() | |
|
248 | schema.add(colander.SchemaNode( | |||
|
249 | colander.Set(), | |||
|
250 | widget=CheckboxChoiceWidgetDesc( | |||
|
251 | values=sorted( | |||
|
252 | [(e.name, e.display_name, e.description) for e in self.valid_events] | |||
|
253 | ), | |||
|
254 | ), | |||
|
255 | description="List of events activated for this integration", | |||
|
256 | name='events' | |||
|
257 | )) | |||
243 | return schema |
|
258 | return schema | |
244 |
|
259 | |||
245 | def send_event(self, event): |
|
260 | def send_event(self, event): | |
246 | data = event.as_dict() |
|
261 | log.debug('handling event %s with integration %s', event.name, self) | |
247 | log.debug('got event: %r', event) |
|
262 | ||
|
263 | if event.__class__ not in self.valid_events: | |||
|
264 | log.debug('event %r not present in valid event list (%s)', event, self.valid_events) | |||
|
265 | return | |||
|
266 | ||||
|
267 | if not self.event_enabled(event): | |||
|
268 | # NOTE(marcink): for legacy reasons we're skipping this check... | |||
|
269 | # since the email event haven't had any settings... | |||
|
270 | pass | |||
248 |
|
271 | |||
|
272 | handler = EmailEventHandler(self.settings) | |||
|
273 | handler(event, event_data=event.as_dict()) | |||
|
274 | ||||
|
275 | ||||
|
276 | class EmailEventHandler(object): | |||
|
277 | def __init__(self, integration_settings): | |||
|
278 | self.integration_settings = integration_settings | |||
|
279 | ||||
|
280 | def __call__(self, event, event_data): | |||
249 | if isinstance(event, events.RepoPushEvent): |
|
281 | if isinstance(event, events.RepoPushEvent): | |
250 |
repo_push_handler( |
|
282 | self.repo_push_handler(event, event_data) | |
251 | else: |
|
283 | else: | |
252 | log.debug('ignoring event: %r', event) |
|
284 | log.debug('ignoring event: %r', event) | |
253 |
|
285 | |||
254 |
|
286 | def repo_push_handler(self, event, data): | ||
255 | def repo_push_handler(data, settings): |
|
|||
256 | commit_num = len(data['push']['commits']) |
|
287 | commit_num = len(data['push']['commits']) | |
257 | server_url = data['server_url'] |
|
288 | server_url = data['server_url'] | |
258 |
|
289 | |||
@@ -292,7 +323,8 b' def repo_push_handler(data, settings):' | |||||
292 | subject=subject, |
|
323 | subject=subject, | |
293 | instance_url=server_url) |
|
324 | instance_url=server_url) | |
294 |
|
325 | |||
295 |
|
|
326 | recipients = self.integration_settings['recipients'] | |
|
327 | for email_address in recipients: | |||
296 | run_task( |
|
328 | run_task( | |
297 | tasks.send_email, email_address, subject, |
|
329 | tasks.send_email, email_address, subject, | |
298 | email_body_plaintext, email_body_html) |
|
330 | email_body_plaintext, email_body_html) |
@@ -26,6 +26,7 b' import colander' | |||||
26 | import textwrap |
|
26 | import textwrap | |
27 | from mako.template import Template |
|
27 | from mako.template import Template | |
28 | from rhodecode import events |
|
28 | from rhodecode import events | |
|
29 | from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc | |||
29 | from rhodecode.translation import _ |
|
30 | from rhodecode.translation import _ | |
30 | from rhodecode.lib import helpers as h |
|
31 | from rhodecode.lib import helpers as h | |
31 | from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask |
|
32 | from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask | |
@@ -119,13 +120,10 b' class HipchatIntegrationType(Integration' | |||||
119 |
|
120 | |||
120 | def send_event(self, event): |
|
121 | def send_event(self, event): | |
121 | if event.__class__ not in self.valid_events: |
|
122 | if event.__class__ not in self.valid_events: | |
122 |
log.debug('event |
|
123 | log.debug('event %r not present in valid event list (%s)', event, self.valid_events) | |
123 | return |
|
124 | return | |
124 |
|
125 | |||
125 | allowed_events = self.settings['events'] |
|
126 | if not self.event_enabled(event): | |
126 | if event.name not in allowed_events: |
|
|||
127 | log.debug('event ignored: %r event %s not in allowed events %s', |
|
|||
128 | event, event.name, allowed_events) |
|
|||
129 | return |
|
127 | return | |
130 |
|
128 | |||
131 | data = event.as_dict() |
|
129 | data = event.as_dict() | |
@@ -133,8 +131,6 b' class HipchatIntegrationType(Integration' | |||||
133 | text = '<b>%s<b> caused a <b>%s</b> event' % ( |
|
131 | text = '<b>%s<b> caused a <b>%s</b> event' % ( | |
134 | data['actor']['username'], event.name) |
|
132 | data['actor']['username'], event.name) | |
135 |
|
133 | |||
136 | log.debug('handling hipchat event for %s', event.name) |
|
|||
137 |
|
||||
138 | if isinstance(event, events.PullRequestCommentEvent): |
|
134 | if isinstance(event, events.PullRequestCommentEvent): | |
139 | text = self.format_pull_request_comment_event(event, data) |
|
135 | text = self.format_pull_request_comment_event(event, data) | |
140 | elif isinstance(event, events.PullRequestReviewEvent): |
|
136 | elif isinstance(event, events.PullRequestReviewEvent): | |
@@ -154,12 +150,12 b' class HipchatIntegrationType(Integration' | |||||
154 | schema = HipchatSettingsSchema() |
|
150 | schema = HipchatSettingsSchema() | |
155 | schema.add(colander.SchemaNode( |
|
151 | schema.add(colander.SchemaNode( | |
156 | colander.Set(), |
|
152 | colander.Set(), | |
157 |
widget= |
|
153 | widget=CheckboxChoiceWidgetDesc( | |
158 | values=sorted( |
|
154 | values=sorted( | |
159 | [(e.name, e.display_name) for e in self.valid_events] |
|
155 | [(e.name, e.display_name, e.description) for e in self.valid_events] | |
160 | ) |
|
|||
161 | ), |
|
156 | ), | |
162 | description="Events activated for this integration", |
|
157 | ), | |
|
158 | description="List of events activated for this integration", | |||
163 | name='events' |
|
159 | name='events' | |
164 | )) |
|
160 | )) | |
165 |
|
161 |
@@ -30,6 +30,7 b' import colander' | |||||
30 | from mako.template import Template |
|
30 | from mako.template import Template | |
31 |
|
31 | |||
32 | from rhodecode import events |
|
32 | from rhodecode import events | |
|
33 | from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc | |||
33 | from rhodecode.translation import _ |
|
34 | from rhodecode.translation import _ | |
34 | from rhodecode.lib import helpers as h |
|
35 | from rhodecode.lib import helpers as h | |
35 | from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask |
|
36 | from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask | |
@@ -134,14 +135,13 b' class SlackIntegrationType(IntegrationTy' | |||||
134 | ] |
|
135 | ] | |
135 |
|
136 | |||
136 | def send_event(self, event): |
|
137 | def send_event(self, event): | |
|
138 | log.debug('handling event %s with integration %s', event.name, self) | |||
|
139 | ||||
137 | if event.__class__ not in self.valid_events: |
|
140 | if event.__class__ not in self.valid_events: | |
138 |
log.debug('event |
|
141 | log.debug('event %r not present in valid event list (%s)', event, self.valid_events) | |
139 | return |
|
142 | return | |
140 |
|
143 | |||
141 | allowed_events = self.settings['events'] |
|
144 | if not self.event_enabled(event): | |
142 | if event.name not in allowed_events: |
|
|||
143 | log.debug('event ignored: %r event %s not in allowed events %s', |
|
|||
144 | event, event.name, allowed_events) |
|
|||
145 | return |
|
145 | return | |
146 |
|
146 | |||
147 | data = event.as_dict() |
|
147 | data = event.as_dict() | |
@@ -154,8 +154,6 b' class SlackIntegrationType(IntegrationTy' | |||||
154 | fields = None |
|
154 | fields = None | |
155 | overrides = None |
|
155 | overrides = None | |
156 |
|
156 | |||
157 | log.debug('handling slack event for %s', event.name) |
|
|||
158 |
|
||||
159 | if isinstance(event, events.PullRequestCommentEvent): |
|
157 | if isinstance(event, events.PullRequestCommentEvent): | |
160 | (title, text, fields, overrides) \ |
|
158 | (title, text, fields, overrides) \ | |
161 | = self.format_pull_request_comment_event(event, data) |
|
159 | = self.format_pull_request_comment_event(event, data) | |
@@ -176,12 +174,12 b' class SlackIntegrationType(IntegrationTy' | |||||
176 | schema = SlackSettingsSchema() |
|
174 | schema = SlackSettingsSchema() | |
177 | schema.add(colander.SchemaNode( |
|
175 | schema.add(colander.SchemaNode( | |
178 | colander.Set(), |
|
176 | colander.Set(), | |
179 |
widget= |
|
177 | widget=CheckboxChoiceWidgetDesc( | |
180 | values=sorted( |
|
178 | values=sorted( | |
181 | [(e.name, e.display_name) for e in self.valid_events] |
|
179 | [(e.name, e.display_name, e.description) for e in self.valid_events] | |
182 | ) |
|
|||
183 | ), |
|
180 | ), | |
184 | description="Events activated for this integration", |
|
181 | ), | |
|
182 | description="List of events activated for this integration", | |||
185 | name='events' |
|
183 | name='events' | |
186 | )) |
|
184 | )) | |
187 |
|
185 |
@@ -20,13 +20,14 b'' | |||||
20 |
|
20 | |||
21 | from __future__ import unicode_literals |
|
21 | from __future__ import unicode_literals | |
22 |
|
22 | |||
23 | import deform |
|
|||
24 | import deform.widget |
|
23 | import deform.widget | |
25 | import logging |
|
24 | import logging | |
26 | import colander |
|
25 | import colander | |
27 |
|
26 | |||
28 | import rhodecode |
|
27 | import rhodecode | |
29 | from rhodecode import events |
|
28 | from rhodecode import events | |
|
29 | from rhodecode.lib.colander_utils import strip_whitespace | |||
|
30 | from rhodecode.model.validation_schema.widgets import CheckboxChoiceWidgetDesc | |||
30 | from rhodecode.translation import _ |
|
31 | from rhodecode.translation import _ | |
31 | from rhodecode.integrations.types.base import ( |
|
32 | from rhodecode.integrations.types.base import ( | |
32 | IntegrationTypeBase, get_auth, get_web_token, get_url_vars, |
|
33 | IntegrationTypeBase, get_auth, get_web_token, get_url_vars, | |
@@ -53,11 +54,12 b' class WebhookSettingsSchema(colander.Sch' | |||||
53 | 'objects in data in such cases.'), |
|
54 | 'objects in data in such cases.'), | |
54 | missing=colander.required, |
|
55 | missing=colander.required, | |
55 | required=True, |
|
56 | required=True, | |
|
57 | preparer=strip_whitespace, | |||
56 | validator=colander.url, |
|
58 | validator=colander.url, | |
57 | widget=widgets.CodeMirrorWidget( |
|
59 | widget=widgets.CodeMirrorWidget( | |
58 | help_block_collapsable_name='Show url variables', |
|
60 | help_block_collapsable_name='Show url variables', | |
59 | help_block_collapsable=( |
|
61 | help_block_collapsable=( | |
60 | 'E.g http://my-serv/trigger_job/${{event_name}}' |
|
62 | 'E.g http://my-serv.com/trigger_job/${{event_name}}' | |
61 | '?PR_ID=${{pull_request_id}}' |
|
63 | '?PR_ID=${{pull_request_id}}' | |
62 | '\nFull list of vars:\n{}'.format(URL_VARS)), |
|
64 | '\nFull list of vars:\n{}'.format(URL_VARS)), | |
63 | codemirror_mode='text', |
|
65 | codemirror_mode='text', | |
@@ -146,34 +148,31 b' class WebhookIntegrationType(Integration' | |||||
146 | events.PullRequestCreateEvent, |
|
148 | events.PullRequestCreateEvent, | |
147 | events.RepoPushEvent, |
|
149 | events.RepoPushEvent, | |
148 | events.RepoCreateEvent, |
|
150 | events.RepoCreateEvent, | |
|
151 | events.RepoCommitCommentEvent, | |||
149 | ] |
|
152 | ] | |
150 |
|
153 | |||
151 | def settings_schema(self): |
|
154 | def settings_schema(self): | |
152 | schema = WebhookSettingsSchema() |
|
155 | schema = WebhookSettingsSchema() | |
153 | schema.add(colander.SchemaNode( |
|
156 | schema.add(colander.SchemaNode( | |
154 | colander.Set(), |
|
157 | colander.Set(), | |
155 |
widget= |
|
158 | widget=CheckboxChoiceWidgetDesc( | |
156 | values=sorted( |
|
159 | values=sorted( | |
157 | [(e.name, e.display_name) for e in self.valid_events] |
|
160 | [(e.name, e.display_name, e.description) for e in self.valid_events] | |
158 | ) |
|
|||
159 | ), |
|
161 | ), | |
160 | description="Events activated for this integration", |
|
162 | ), | |
|
163 | description="List of events activated for this integration", | |||
161 | name='events' |
|
164 | name='events' | |
162 | )) |
|
165 | )) | |
163 | return schema |
|
166 | return schema | |
164 |
|
167 | |||
165 | def send_event(self, event): |
|
168 | def send_event(self, event): | |
166 | log.debug( |
|
169 | log.debug('handling event %s with integration %s', event.name, self) | |
167 | 'handling event %s with Webhook integration %s', event.name, self) |
|
|||
168 |
|
170 | |||
169 | if event.__class__ not in self.valid_events: |
|
171 | if event.__class__ not in self.valid_events: | |
170 |
log.debug('event |
|
172 | log.debug('event %r not present in valid event list (%s)', event, self.valid_events) | |
171 | return |
|
173 | return | |
172 |
|
174 | |||
173 | allowed_events = self.settings['events'] |
|
175 | if not self.event_enabled(event): | |
174 | if event.name not in allowed_events: |
|
|||
175 | log.debug('event ignored: %r event %s not in allowed events %s', |
|
|||
176 | event, event.name, allowed_events) |
|
|||
177 | return |
|
176 | return | |
178 |
|
177 | |||
179 | data = event.as_dict() |
|
178 | data = event.as_dict() |
@@ -20,13 +20,40 b'' | |||||
20 |
|
20 | |||
21 | import logging |
|
21 | import logging | |
22 |
|
22 | |||
23 | import deform |
|
|||
24 | import deform.widget |
|
23 | import deform.widget | |
|
24 | from deform.widget import null, OptGroup, string_types | |||
|
25 | ||||
|
26 | log = logging.getLogger(__name__) | |||
25 |
|
27 | |||
26 |
|
28 | |||
27 | log = logging.getLogger(__name__) |
|
29 | def _normalize_choices(values): | |
|
30 | result = [] | |||
|
31 | for item in values: | |||
|
32 | if isinstance(item, OptGroup): | |||
|
33 | normalized_options = _normalize_choices(item.options) | |||
|
34 | result.append(OptGroup(item.label, *normalized_options)) | |||
|
35 | else: | |||
|
36 | value, description, help_block = item | |||
|
37 | if not isinstance(value, string_types): | |||
|
38 | value = str(value) | |||
|
39 | result.append((value, description, help_block)) | |||
|
40 | return result | |||
28 |
|
41 | |||
29 |
|
42 | |||
30 | class CodeMirrorWidget(deform.widget.TextAreaWidget): |
|
43 | class CodeMirrorWidget(deform.widget.TextAreaWidget): | |
31 | template = 'codemirror' |
|
44 | template = 'codemirror' | |
32 | requirements = (('deform', None), ('codemirror', None)) |
|
45 | requirements = (('deform', None), ('codemirror', None)) | |
|
46 | ||||
|
47 | ||||
|
48 | class CheckboxChoiceWidgetDesc(deform.widget.CheckboxChoiceWidget): | |||
|
49 | template = "checkbox_choice_desc" | |||
|
50 | ||||
|
51 | def serialize(self, field, cstruct, **kw): | |||
|
52 | if cstruct in (null, None): | |||
|
53 | cstruct = () | |||
|
54 | readonly = kw.get("readonly", self.readonly) | |||
|
55 | values = kw.get("values", self.values) | |||
|
56 | kw["values"] = _normalize_choices(values) | |||
|
57 | template = readonly and self.readonly_template or self.template | |||
|
58 | tmpl_values = self.get_template_values(field, cstruct, kw) | |||
|
59 | return field.renderer(template, **tmpl_values) |
@@ -22,4 +22,4 b'' | |||||
22 | </div> |
|
22 | </div> | |
23 | </div> |
|
23 | </div> | |
24 | ${field.end_sequence()} |
|
24 | ${field.end_sequence()} | |
25 | </div> No newline at end of file |
|
25 | </div> |
@@ -40,7 +40,7 b' from rhodecode.events import (' | |||||
40 | PullRequestUpdateEvent, |
|
40 | PullRequestUpdateEvent, | |
41 | PullRequestReviewEvent, |
|
41 | PullRequestReviewEvent, | |
42 | PullRequestMergeEvent, |
|
42 | PullRequestMergeEvent, | |
43 |
PullRequestCloseEvent |
|
43 | PullRequestCloseEvent | |
44 | ]) |
|
44 | ]) | |
45 | def test_pullrequest_events_serialized(EventClass, pr_util, config_stub): |
|
45 | def test_pullrequest_events_serialized(EventClass, pr_util, config_stub): | |
46 | pr = pr_util.create_pull_request() |
|
46 | pr = pr_util.create_pull_request() |
@@ -20,6 +20,7 b'' | |||||
20 |
|
20 | |||
21 | import pytest |
|
21 | import pytest | |
22 |
|
22 | |||
|
23 | from rhodecode.lib.utils2 import StrictAttributeDict | |||
23 | from rhodecode.tests.events.conftest import EventCatcher |
|
24 | from rhodecode.tests.events.conftest import EventCatcher | |
24 |
|
25 | |||
25 | from rhodecode.lib import hooks_base, utils2 |
|
26 | from rhodecode.lib import hooks_base, utils2 | |
@@ -28,7 +29,7 b' from rhodecode.events.repo import (' | |||||
28 | RepoPrePullEvent, RepoPullEvent, |
|
29 | RepoPrePullEvent, RepoPullEvent, | |
29 | RepoPrePushEvent, RepoPushEvent, |
|
30 | RepoPrePushEvent, RepoPushEvent, | |
30 | RepoPreCreateEvent, RepoCreateEvent, |
|
31 | RepoPreCreateEvent, RepoCreateEvent, | |
31 | RepoPreDeleteEvent, RepoDeleteEvent, |
|
32 | RepoPreDeleteEvent, RepoDeleteEvent, RepoCommitCommentEvent, | |
32 | ) |
|
33 | ) | |
33 |
|
34 | |||
34 |
|
35 | |||
@@ -121,3 +122,24 b' def test_push_fires_events(scm_extras):' | |||||
121 | hooks_base.post_pull(scm_extras) |
|
122 | hooks_base.post_pull(scm_extras) | |
122 | assert event_catcher.events_types == [RepoPullEvent] |
|
123 | assert event_catcher.events_types == [RepoPullEvent] | |
123 |
|
124 | |||
|
125 | ||||
|
126 | @pytest.mark.parametrize('EventClass', [RepoCommitCommentEvent]) | |||
|
127 | def test_repo_commit_event(config_stub, repo_stub, EventClass): | |||
|
128 | ||||
|
129 | commit = StrictAttributeDict({ | |||
|
130 | 'raw_id': 'raw_id', | |||
|
131 | 'message': 'message', | |||
|
132 | 'branch': 'branch', | |||
|
133 | }) | |||
|
134 | ||||
|
135 | comment = StrictAttributeDict({ | |||
|
136 | 'comment_id': 'comment_id', | |||
|
137 | 'text': 'text', | |||
|
138 | 'comment_type': 'comment_type', | |||
|
139 | 'f_path': 'f_path', | |||
|
140 | 'line_no': 'line_no', | |||
|
141 | }) | |||
|
142 | event = EventClass(repo=repo_stub, commit=commit, comment=comment) | |||
|
143 | data = event.as_dict() | |||
|
144 | assert data['commit']['commit_id'] | |||
|
145 | assert data['comment']['comment_id'] |
@@ -49,6 +49,7 b' class TestDeleteScopesDeletesIntegration' | |||||
49 |
|
49 | |||
50 | count = 1 |
|
50 | count = 1 | |
51 |
|
51 | |||
|
52 | ||||
52 | def counter(): |
|
53 | def counter(): | |
53 | global count |
|
54 | global count | |
54 | val = count |
|
55 | val = count |
@@ -52,8 +52,7 b' def test_webhook_parse_url_invalid_event' | |||||
52 | handler(event, {}) |
|
52 | handler(event, {}) | |
53 |
|
53 | |||
54 | err = str(err.value) |
|
54 | err = str(err.value) | |
55 | assert err.startswith( |
|
55 | assert err == "event type `<class 'rhodecode.events.repo.RepoDeleteEvent'>` has no handler defined" | |
56 | 'event type `%s` not in supported list' % event.__class__) |
|
|||
57 |
|
56 | |||
58 |
|
57 | |||
59 | @pytest.mark.parametrize('template,expected_urls', [ |
|
58 | @pytest.mark.parametrize('template,expected_urls', [ |
General Comments 0
You need to be logged in to leave comments.
Login now