blob: 5c34f72f82f968e16e1717af8de2cfcfb9d411ae [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2016 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6"""Servlets for issue tracker configuration.
7
8These classes implement the Statuses, Labels and fields, Components, Rules, and
9Views subtabs under the Process tab. Unlike most servlet modules, this single
10file holds a base class and several related servlet classes.
11"""
12from __future__ import print_function
13from __future__ import division
14from __future__ import absolute_import
15
16import collections
17import itertools
18import logging
19import time
20
21import ezt
22
23from features import filterrules_helpers
24from features import filterrules_views
25from features import savedqueries_helpers
26from framework import authdata
27from framework import framework_bizobj
28from framework import framework_constants
29from framework import framework_helpers
30from framework import framework_views
31from framework import monorailrequest
32from framework import permissions
33from framework import servlet
34from framework import urls
35from proto import tracker_pb2
36from tracker import field_helpers
37from tracker import tracker_bizobj
38from tracker import tracker_constants
39from tracker import tracker_helpers
40from tracker import tracker_views
41
42
43class IssueAdminBase(servlet.Servlet):
44 """Base class for servlets allowing project owners to configure tracker."""
45
46 _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_PROCESS
47 _PROCESS_SUBTAB = None # specified in subclasses
48
49 def GatherPageData(self, mr):
50 """Build up a dictionary of data values to use when rendering the page.
51
52 Args:
53 mr: commonly used info parsed from the request.
54
55 Returns:
56 Dict of values used by EZT for rendering the page.
57 """
58 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
59 config_view = tracker_views.ConfigView(mr, self.services, config,
60 template=None, load_all_templates=True)
61 open_text, closed_text = tracker_views.StatusDefsAsText(config)
62 labels_text = tracker_views.LabelDefsAsText(config)
63
64 return {
65 'admin_tab_mode': self._PROCESS_SUBTAB,
66 'config': config_view,
67 'open_text': open_text,
68 'closed_text': closed_text,
69 'labels_text': labels_text,
70 }
71
72 def ProcessFormData(self, mr, post_data):
73 """Validate and store the contents of the issues tracker admin page.
74
75 Args:
76 mr: commonly used info parsed from the request.
77 post_data: HTML form data from the request.
78
79 Returns:
80 String URL to redirect the user to, or None if response was already sent.
81 """
82 page_url = self.ProcessSubtabForm(post_data, mr)
83
84 if page_url:
85 return framework_helpers.FormatAbsoluteURL(
86 mr, page_url, saved=1, ts=int(time.time()))
87
88
89class AdminStatuses(IssueAdminBase):
90 """Servlet allowing project owners to configure well-known statuses."""
91
92 _PAGE_TEMPLATE = 'tracker/admin-statuses-page.ezt'
93 _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_STATUSES
94
95 def ProcessSubtabForm(self, post_data, mr):
96 """Process the status definition section of the admin page.
97
98 Args:
99 post_data: HTML form data for the HTTP request being processed.
100 mr: commonly used info parsed from the request.
101
102 Returns:
103 The URL of the page to show after processing.
104 """
105 if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
106 raise permissions.PermissionException(
107 'Only project owners may edit the status definitions')
108
109 wks_open_text = post_data.get('predefinedopen', '')
110 wks_open_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(
111 wks_open_text)
112 wks_open_tuples = [
113 (status.lstrip('#'), docstring.strip(), True, status.startswith('#'))
114 for status, docstring in wks_open_matches]
115 if not wks_open_tuples:
116 mr.errors.open_statuses = 'A project cannot have zero open statuses'
117
118 wks_closed_text = post_data.get('predefinedclosed', '')
119 wks_closed_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(
120 wks_closed_text)
121 wks_closed_tuples = [
122 (status.lstrip('#'), docstring.strip(), False, status.startswith('#'))
123 for status, docstring in wks_closed_matches]
124 if not wks_closed_tuples:
125 mr.errors.closed_statuses = 'A project cannot have zero closed statuses'
126
127 statuses_offer_merge_text = post_data.get('statuses_offer_merge', '')
128 statuses_offer_merge = framework_constants.IDENTIFIER_RE.findall(
129 statuses_offer_merge_text)
130
131 if mr.errors.AnyErrors():
132 self.PleaseCorrect(
133 mr, open_text=wks_open_text, closed_text=wks_closed_text)
134 return
135
136 self.services.config.UpdateConfig(
137 mr.cnxn, mr.project, statuses_offer_merge=statuses_offer_merge,
138 well_known_statuses=wks_open_tuples + wks_closed_tuples)
139
140 # TODO(jrobbins): define a "strict" mode that affects only statuses.
141
142 return urls.ADMIN_STATUSES
143
144
145class AdminLabels(IssueAdminBase):
146 """Servlet allowing project owners to labels and fields."""
147
148 _PAGE_TEMPLATE = 'tracker/admin-labels-page.ezt'
149 _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_LABELS
150
151 def GatherPageData(self, mr):
152 """Build up a dictionary of data values to use when rendering the page.
153
154 Args:
155 mr: commonly used info parsed from the request.
156
157 Returns:
158 Dict of values used by EZT for rendering the page.
159 """
160 page_data = super(AdminLabels, self).GatherPageData(mr)
161 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
162 field_def_views = [
163 tracker_views.FieldDefView(fd, config)
164 # TODO(jrobbins): future field-level view restrictions.
165 for fd in config.field_defs
166 if not fd.is_deleted]
167 page_data.update({
168 'field_defs': field_def_views,
169 })
170 return page_data
171
172 def ProcessSubtabForm(self, post_data, mr):
173 """Process changes to labels and custom field definitions.
174
175 Args:
176 post_data: HTML form data for the HTTP request being processed.
177 mr: commonly used info parsed from the request.
178
179 Returns:
180 The URL of the page to show after processing.
181 """
182 if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
183 raise permissions.PermissionException(
184 'Only project owners may edit the label definitions')
185
186 wkl_text = post_data.get('predefinedlabels', '')
187 wkl_matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(wkl_text)
188 wkl_tuples = [
189 (label.lstrip('#'), docstring.strip(), label.startswith('#'))
190 for label, docstring in wkl_matches]
191 if not wkl_tuples:
192 mr.errors.label_defs = 'A project cannot have zero labels'
193 label_counter = collections.Counter(wkl[0].lower() for wkl in wkl_tuples)
194 for lab, count in label_counter.items():
195 if count > 1:
196 mr.errors.label_defs = 'Duplicate label: %s' % lab
197
198 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
199 field_names = [fd.field_name for fd in config.field_defs
200 if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE
201 and not fd.is_deleted]
202 masked_labels = tracker_helpers.LabelsMaskedByFields(config, field_names)
203 field_names_lower = [field_name.lower() for field_name in field_names]
204 for wkl in wkl_tuples:
205 conflict = tracker_bizobj.LabelIsMaskedByField(wkl[0], field_names_lower)
206 if conflict:
207 mr.errors.label_defs = (
208 'Label "%s" should be defined in enum "%s"' % (wkl[0], conflict))
209 wkl_tuples.extend([
210 (masked.name, masked.docstring, False) for masked in masked_labels])
211
212 excl_prefix_text = post_data.get('excl_prefixes', '')
213 excl_prefixes = framework_constants.IDENTIFIER_RE.findall(excl_prefix_text)
214
215 if mr.errors.AnyErrors():
216 self.PleaseCorrect(mr, labels_text=wkl_text)
217 return
218
219 self.services.config.UpdateConfig(
220 mr.cnxn, mr.project,
221 well_known_labels=wkl_tuples, excl_label_prefixes=excl_prefixes)
222
223 # TODO(jrobbins): define a "strict" mode that affects only labels.
224
225 return urls.ADMIN_LABELS
226
227
228class AdminTemplates(IssueAdminBase):
229 """Servlet allowing project owners to configure templates."""
230
231 _PAGE_TEMPLATE = 'tracker/admin-templates-page.ezt'
232 _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_TEMPLATES
233
234 def GatherPageData(self, mr):
235 """Build up a dictionary of data values to use when rendering the page.
236
237 Args:
238 mr: commonly used info parsed from the request.
239
240 Returns:
241 Dict of values used by EZT for rendering the page.
242 """
243 return super(AdminTemplates, self).GatherPageData(mr)
244
245 def ProcessSubtabForm(self, post_data, mr):
246 """Process changes to new issue templates.
247
248 Args:
249 post_data: HTML form data for the HTTP request being processed.
250 mr: commonly used info parsed from the request.
251
252 Returns:
253 The URL of the page to show after processing.
254 """
255 if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
256 raise permissions.PermissionException(
257 'Only project owners may edit the default templates')
258
259 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
260
261 templates = self.services.template.GetProjectTemplates(mr.cnxn,
262 config.project_id)
263 default_template_id_for_developers, default_template_id_for_users = (
264 self._ParseDefaultTemplateSelections(post_data, templates))
265 if default_template_id_for_developers or default_template_id_for_users:
266 self.services.config.UpdateConfig(
267 mr.cnxn, mr.project,
268 default_template_for_developers=default_template_id_for_developers,
269 default_template_for_users=default_template_id_for_users)
270
271 return urls.ADMIN_TEMPLATES
272
273 def _ParseDefaultTemplateSelections(self, post_data, templates):
274 """Parse the input for the default templates to offer users."""
275 def GetSelectedTemplateID(name):
276 """Find the ID of the template specified in post_data[name]."""
277 if name not in post_data:
278 return None
279 selected_template_name = post_data[name]
280 for template in templates:
281 if selected_template_name == template.name:
282 return template.template_id
283
284 logging.error('User somehow selected an invalid template: %r',
285 selected_template_name)
286 return None
287
288 return (GetSelectedTemplateID('default_template_for_developers'),
289 GetSelectedTemplateID('default_template_for_users'))
290
291
292class AdminComponents(IssueAdminBase):
293 """Servlet allowing project owners to view the list of components."""
294
295 _PAGE_TEMPLATE = 'tracker/admin-components-page.ezt'
296 _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_COMPONENTS
297
298 def GatherPageData(self, mr):
299 """Build up a dictionary of data values to use when rendering the page.
300
301 Args:
302 mr: commonly used info parsed from the request.
303
304 Returns:
305 Dict of values used by EZT for rendering the page.
306 """
307 page_data = super(AdminComponents, self).GatherPageData(mr)
308 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
309 users_by_id = framework_views.MakeAllUserViews(
310 mr.cnxn, self.services.user,
311 *[list(cd.admin_ids) + list(cd.cc_ids)
312 for cd in config.component_defs])
313 framework_views.RevealAllEmailsToMembers(
314 mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
315 component_def_views = [
316 tracker_views.ComponentDefView(mr.cnxn, self.services, cd, users_by_id)
317 # TODO(jrobbins): future component-level view restrictions.
318 for cd in config.component_defs]
319 for cd in component_def_views:
320 if mr.auth.email in [user.email for user in cd.admins]:
321 cd.classes += 'myadmin '
322 if mr.auth.email in [user.email for user in cd.cc]:
323 cd.classes += 'mycc '
324
325 page_data.update({
326 'component_defs': component_def_views,
327 'failed_perm': mr.GetParam('failed_perm'),
328 'failed_subcomp': mr.GetParam('failed_subcomp'),
329 'failed_templ': mr.GetParam('failed_templ'),
330 })
331 return page_data
332
333 def _GetComponentDefs(self, _mr, post_data, config):
334 """Get the config and component definitions from the request."""
335 component_defs = []
336 component_paths = post_data.get('delete_components').split(',')
337 for component_path in component_paths:
338 component_def = tracker_bizobj.FindComponentDef(component_path, config)
339 component_defs.append(component_def)
340 return component_defs
341
342 def _ProcessDeleteComponent(self, mr, component_def):
343 """Delete the specified component and its references."""
344 self.services.issue.DeleteComponentReferences(
345 mr.cnxn, component_def.component_id)
346 self.services.config.DeleteComponentDef(
347 mr.cnxn, mr.project_id, component_def.component_id)
348
349 def ProcessFormData(self, mr, post_data):
350 """Processes a POST command to delete components.
351
352 Args:
353 mr: commonly used info parsed from the request.
354 post_data: HTML form data from the request.
355
356 Returns:
357 String URL to redirect the user to, or None if response was already sent.
358 """
359 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
360 component_defs = self._GetComponentDefs(mr, post_data, config)
361 # Reverse the component_defs so that we start deleting from subcomponents.
362 component_defs.reverse()
363
364 # Collect errors.
365 perm_errors = []
366 subcomponents_errors = []
367 templates_errors = []
368 # Collect successes.
369 deleted_components = []
370
371 for component_def in component_defs:
372 allow_edit = permissions.CanEditComponentDef(
373 mr.auth.effective_ids, mr.perms, mr.project, component_def, config)
374 if not allow_edit:
375 perm_errors.append(component_def.path)
376
377 subcomponents = tracker_bizobj.FindDescendantComponents(
378 config, component_def)
379 if subcomponents:
380 subcomponents_errors.append(component_def.path)
381
382 templates = self.services.template.TemplatesWithComponent(
383 mr.cnxn, component_def.component_id)
384 if templates:
385 templates_errors.append(component_def.path)
386
387 allow_delete = allow_edit and not subcomponents and not templates
388 if allow_delete:
389 self._ProcessDeleteComponent(mr, component_def)
390 deleted_components.append(component_def.path)
391 # Refresh project config after the component deletion.
392 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
393
394 return framework_helpers.FormatAbsoluteURL(
395 mr, urls.ADMIN_COMPONENTS, ts=int(time.time()),
396 failed_perm=','.join(perm_errors),
397 failed_subcomp=','.join(subcomponents_errors),
398 failed_templ=','.join(templates_errors),
399 deleted=','.join(deleted_components))
400
401
402class AdminViews(IssueAdminBase):
403 """Servlet for project owners to set default columns, axes, and sorting."""
404
405 _PAGE_TEMPLATE = 'tracker/admin-views-page.ezt'
406 _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_VIEWS
407
408 def GatherPageData(self, mr):
409 """Build up a dictionary of data values to use when rendering the page.
410
411 Args:
412 mr: commonly used info parsed from the request.
413
414 Returns:
415 Dict of values used by EZT for rendering the page.
416 """
417 page_data = super(AdminViews, self).GatherPageData(mr)
418 with mr.profiler.Phase('getting canned queries'):
419 canned_queries = self.services.features.GetCannedQueriesByProjectID(
420 mr.cnxn, mr.project_id)
421 canned_query_views = [
422 savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
423 for idx, sq in enumerate(canned_queries)]
424
425 page_data.update({
426 'canned_queries': canned_query_views,
427 'new_query_indexes': list(range(
428 len(canned_queries) + 1, savedqueries_helpers.MAX_QUERIES + 1)),
429 'issue_notify': mr.project.issue_notify_address,
430 'max_queries': savedqueries_helpers.MAX_QUERIES,
431 })
432 return page_data
433
434 def ProcessSubtabForm(self, post_data, mr):
435 """Process the Views subtab.
436
437 Args:
438 post_data: HTML form data for the HTTP request being processed.
439 mr: commonly used info parsed from the request.
440
441 Returns:
442 The URL of the page to show after processing.
443 """
444 if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
445 raise permissions.PermissionException(
446 'Only project owners may edit the default views')
447 existing_queries = savedqueries_helpers.ParseSavedQueries(
448 mr.cnxn, post_data, self.services.project)
449 added_queries = savedqueries_helpers.ParseSavedQueries(
450 mr.cnxn, post_data, self.services.project, prefix='new_')
451 canned_queries = existing_queries + added_queries
452
453 list_prefs = _ParseListPreferences(post_data)
454
455 if mr.errors.AnyErrors():
456 self.PleaseCorrect(mr)
457 return
458
459 self.services.config.UpdateConfig(
460 mr.cnxn, mr.project, list_prefs=list_prefs)
461 self.services.features.UpdateCannedQueries(
462 mr.cnxn, mr.project_id, canned_queries)
463
464 return urls.ADMIN_VIEWS
465
466
467def _ParseListPreferences(post_data):
468 """Parse the part of a project admin form about artifact list preferences."""
469 default_col_spec = ''
470 if 'default_col_spec' in post_data:
471 default_col_spec = post_data['default_col_spec']
472 # Don't allow empty colum spec
473 if not default_col_spec:
474 default_col_spec = tracker_constants.DEFAULT_COL_SPEC
475 col_spec_words = monorailrequest.ParseColSpec(
476 default_col_spec, max_parts=framework_constants.MAX_COL_PARTS)
477 col_spec = ' '.join(word for word in col_spec_words)
478
479 default_sort_spec = ''
480 if 'default_sort_spec' in post_data:
481 default_sort_spec = post_data['default_sort_spec']
482 sort_spec_words = monorailrequest.ParseColSpec(default_sort_spec)
483 sort_spec = ' '.join(sort_spec_words)
484
485 x_attr_str = ''
486 if 'default_x_attr' in post_data:
487 x_attr_str = post_data['default_x_attr']
488 x_attr_words = monorailrequest.ParseColSpec(x_attr_str)
489 x_attr = ''
490 if x_attr_words:
491 x_attr = x_attr_words[0]
492
493 y_attr_str = ''
494 if 'default_y_attr' in post_data:
495 y_attr_str = post_data['default_y_attr']
496 y_attr_words = monorailrequest.ParseColSpec(y_attr_str)
497 y_attr = ''
498 if y_attr_words:
499 y_attr = y_attr_words[0]
500
501 member_default_query = ''
502 if 'member_default_query' in post_data:
503 member_default_query = post_data['member_default_query']
504
505 return col_spec, sort_spec, x_attr, y_attr, member_default_query
506
507
508class AdminRules(IssueAdminBase):
509 """Servlet allowing project owners to configure filter rules."""
510
511 _PAGE_TEMPLATE = 'tracker/admin-rules-page.ezt'
512 _PROCESS_SUBTAB = servlet.Servlet.PROCESS_TAB_RULES
513
514 def AssertBasePermission(self, mr):
515 """Check whether the user has any permission to visit this page.
516
517 Args:
518 mr: commonly used info parsed from the request.
519 """
520 super(AdminRules, self).AssertBasePermission(mr)
521 if not self.CheckPerm(mr, permissions.EDIT_PROJECT):
522 raise permissions.PermissionException(
523 'User is not allowed to administer this project')
524
525 def GatherPageData(self, mr):
526 """Build up a dictionary of data values to use when rendering the page.
527
528 Args:
529 mr: commonly used info parsed from the request.
530
531 Returns:
532 Dict of values used by EZT for rendering the page.
533 """
534 page_data = super(AdminRules, self).GatherPageData(mr)
535 rules = self.services.features.GetFilterRules(
536 mr.cnxn, mr.project_id)
537 users_by_id = framework_views.MakeAllUserViews(
538 mr.cnxn, self.services.user,
539 [rule.default_owner_id for rule in rules],
540 *[rule.add_cc_ids for rule in rules])
541 framework_views.RevealAllEmailsToMembers(
542 mr.cnxn, self.services, mr.auth, users_by_id, mr.project)
543 rule_views = [filterrules_views.RuleView(rule, users_by_id)
544 for rule in rules]
545
546 for idx, rule_view in enumerate(rule_views):
547 rule_view.idx = idx + 1 # EZT has no loop index, so we set idx.
548
549 page_data.update({
550 'rules': rule_views,
551 'new_rule_indexes': (
552 list(range(len(rules) + 1, filterrules_helpers.MAX_RULES + 1))),
553 'max_rules': filterrules_helpers.MAX_RULES,
554 })
555 return page_data
556
557 def ProcessSubtabForm(self, post_data, mr):
558 """Process the Rules subtab.
559
560 Args:
561 post_data: HTML form data for the HTTP request being processed.
562 mr: commonly used info parsed from the request.
563
564 Returns:
565 The URL of the page to show after processing.
566 """
567 old_rules = self.services.features.GetFilterRules(mr.cnxn, mr.project_id)
568 rules = filterrules_helpers.ParseRules(
569 mr.cnxn, post_data, self.services.user, mr.errors)
570 new_rules = filterrules_helpers.ParseRules(
571 mr.cnxn, post_data, self.services.user, mr.errors, prefix='new_')
572 rules.extend(new_rules)
573
574 if mr.errors.AnyErrors():
575 self.PleaseCorrect(mr)
576 return
577
578 config = self.services.features.UpdateFilterRules(
579 mr.cnxn, mr.project_id, rules)
580
581 if old_rules != rules:
582 logging.info('recomputing derived fields')
583 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
584 filterrules_helpers.RecomputeAllDerivedFields(
585 mr.cnxn, self.services, mr.project, config)
586
587 return urls.ADMIN_RULES