blob: d15f5e08cbe5daa98ce54fc35b1b1c3bdc2cb96e [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"""Helper functions for custom field sevlets."""
7from __future__ import print_function
8from __future__ import division
9from __future__ import absolute_import
10
11import collections
12import itertools
13import logging
14import re
15
16from features import autolink_constants
17from framework import authdata
18from framework import exceptions
19from framework import framework_bizobj
20from framework import framework_constants
21from framework import permissions
22from framework import timestr
23from framework import validate
24from proto import tracker_pb2
25from services import config_svc
26from tracker import tracker_bizobj
27
28
29INVALID_USER_ID = -1
30
31ParsedFieldDef = collections.namedtuple(
32 'ParsedFieldDef',
33 'field_name, field_type_str, min_value, max_value, regex, '
34 'needs_member, needs_perm, grants_perm, notify_on, is_required, '
35 'is_niche, importance, is_multivalued, field_docstring, choices_text, '
36 'applicable_type, applicable_predicate, revised_labels, date_action_str, '
37 'approvers_str, survey, parent_approval_name, is_phase_field, '
38 'is_restricted_field')
39
40
41def ListApplicableFieldDefs(issues, config):
42 # type: (Sequence[proto.tracker_pb2.Issue],
43 # proto.tracker_pb2.ProjectIssueConfig) ->
44 # Sequence[proto.tracker_pb2.FieldDef]
45 """Return the applicable FieldDefs for the given issues. """
46 issue_labels = []
47 issue_approval_ids = []
48 for issue in issues:
49 issue_labels.extend(issue.labels)
50 issue_approval_ids.extend(
51 [approval.approval_id for approval in issue.approval_values])
52 labels_by_prefix = tracker_bizobj.LabelsByPrefix(list(set(issue_labels)), [])
53 types = set(labels_by_prefix.get('type', []))
54 types_lower = [t.lower() for t in types]
55 applicable_fds = []
56 for fd in config.field_defs:
57 if fd.is_deleted:
58 continue
59 if fd.field_id in issue_approval_ids:
60 applicable_fds.append(fd)
61 elif fd.field_type != tracker_pb2.FieldTypes.APPROVAL_TYPE and (
62 not fd.applicable_type or fd.applicable_type.lower() in types_lower):
63 applicable_fds.append(fd)
64 return applicable_fds
65
66
67def ParseFieldDefRequest(post_data, config):
68 """Parse the user's HTML form data to update a field definition."""
69 field_name = post_data.get('name', '')
70 field_type_str = post_data.get('field_type')
71 # TODO(jrobbins): once a min or max is set, it cannot be completely removed.
72 min_value_str = post_data.get('min_value')
73 try:
74 min_value = int(min_value_str)
75 except (ValueError, TypeError):
76 min_value = None
77 max_value_str = post_data.get('max_value')
78 try:
79 max_value = int(max_value_str)
80 except (ValueError, TypeError):
81 max_value = None
82 regex = post_data.get('regex')
83 needs_member = 'needs_member' in post_data
84 needs_perm = post_data.get('needs_perm', '').strip()
85 grants_perm = post_data.get('grants_perm', '').strip()
86 notify_on_str = post_data.get('notify_on')
87 if notify_on_str in config_svc.NOTIFY_ON_ENUM:
88 notify_on = config_svc.NOTIFY_ON_ENUM.index(notify_on_str)
89 else:
90 notify_on = 0
91 importance = post_data.get('importance')
92 is_required = (importance == 'required')
93 is_niche = (importance == 'niche')
94 is_multivalued = 'is_multivalued' in post_data
95 field_docstring = post_data.get('docstring', '')
96 choices_text = post_data.get('choices', '')
97 applicable_type = post_data.get('applicable_type', '')
98 applicable_predicate = '' # TODO(jrobbins): placeholder for future feature
99 revised_labels = _ParseChoicesIntoWellKnownLabels(
100 choices_text, field_name, config, field_type_str)
101 date_action_str = post_data.get('date_action')
102 approvers_str = post_data.get('approver_names', '').strip().rstrip(',')
103 survey = post_data.get('survey', '')
104 parent_approval_name = post_data.get('parent_approval_name', '')
105 # TODO(jojwang): monorail:3774, remove enum_type condition when
106 # phases can have labels.
107 is_phase_field = ('is_phase_field' in post_data) and (
108 field_type_str not in ['approval_type', 'enum_type'])
109 is_restricted_field = 'is_restricted_field' in post_data
110
111 return ParsedFieldDef(
112 field_name, field_type_str, min_value, max_value, regex, needs_member,
113 needs_perm, grants_perm, notify_on, is_required, is_niche, importance,
114 is_multivalued, field_docstring, choices_text, applicable_type,
115 applicable_predicate, revised_labels, date_action_str, approvers_str,
116 survey, parent_approval_name, is_phase_field, is_restricted_field)
117
118
119def _ParseChoicesIntoWellKnownLabels(
120 choices_text, field_name, config, field_type_str):
121 """Parse a field's possible choices and integrate them into the config.
122
123 Args:
124 choices_text: string with one label and optional docstring per line.
125 field_name: string name of the field definition being edited.
126 config: ProjectIssueConfig PB of the current project.
127 field_type_str: string name of the new field's type. None if an existing
128 field is being updated
129
130 Returns:
131 A revised list of labels that can be used to update the config.
132 """
133 fd = tracker_bizobj.FindFieldDef(field_name, config)
134 matches = framework_constants.IDENTIFIER_DOCSTRING_RE.findall(choices_text)
135 maskingFieldNames = []
136 # wkls should only be masked by the field if it is an enum_type.
137 if (field_type_str == 'enum_type') or (
138 fd and fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE):
139 maskingFieldNames.append(field_name.lower())
140
141 new_labels = [
142 ('%s-%s' % (field_name, label), choice_docstring.strip(), False)
143 for label, choice_docstring in matches]
144 kept_labels = [
145 (wkl.label, wkl.label_docstring, wkl.deprecated)
146 for wkl in config.well_known_labels
147 if not tracker_bizobj.LabelIsMaskedByField(
148 wkl.label, maskingFieldNames)]
149 revised_labels = kept_labels + new_labels
150 return revised_labels
151
152
153def ShiftEnumFieldsIntoLabels(
154 labels, labels_remove, field_val_strs, field_val_strs_remove, config):
155 """Look at the custom field values and treat enum fields as labels.
156
157 Args:
158 labels: list of labels to add/set on the issue.
159 labels_remove: list of labels to remove from the issue.
160 field_val_strs: {field_id: [val_str, ...]} of custom fields to add/set.
161 field_val_strs_remove: {field_id: [val_str, ...]} of custom fields to
162 remove.
163 config: ProjectIssueConfig PB including custom field definitions.
164
165 SIDE-EFFECT: the labels and labels_remove lists will be extended with
166 key-value labels corresponding to the enum field values. Those field
167 entries will be removed from field_val_strs and field_val_strs_remove.
168 """
169 for fd in config.field_defs:
170 if fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE:
171 continue
172
173 if fd.field_id in field_val_strs:
174 labels.extend(
175 '%s-%s' % (fd.field_name, val)
176 for val in field_val_strs[fd.field_id]
177 if val and val != '--')
178 del field_val_strs[fd.field_id]
179
180 if fd.field_id in field_val_strs_remove:
181 labels_remove.extend(
182 '%s-%s' % (fd.field_name, val)
183 for val in field_val_strs_remove[fd.field_id]
184 if val and val != '--')
185 del field_val_strs_remove[fd.field_id]
186
187
188def ReviseApprovals(approval_id, approver_ids, survey, config):
189 revised_approvals = [(
190 approval.approval_id, approval.approver_ids, approval.survey) for
191 approval in config.approval_defs if
192 approval.approval_id != approval_id]
193 revised_approvals.append((approval_id, approver_ids, survey))
194 return revised_approvals
195
196
197def ParseOneFieldValue(cnxn, user_service, fd, val_str):
198 """Make one FieldValue PB from the given user-supplied string."""
199 if fd.field_type == tracker_pb2.FieldTypes.INT_TYPE:
200 try:
201 return tracker_bizobj.MakeFieldValue(
202 fd.field_id, int(val_str), None, None, None, None, False)
203 except ValueError:
204 return None # TODO(jrobbins): should bounce
205
206 elif fd.field_type == tracker_pb2.FieldTypes.STR_TYPE:
207 return tracker_bizobj.MakeFieldValue(
208 fd.field_id, None, val_str, None, None, None, False)
209
210 elif fd.field_type == tracker_pb2.FieldTypes.USER_TYPE:
211 if val_str:
212 try:
213 user_id = user_service.LookupUserID(cnxn, val_str, autocreate=False)
214 except exceptions.NoSuchUserException:
215 # Set to invalid user ID to display error during the validation step.
216 user_id = INVALID_USER_ID
217 return tracker_bizobj.MakeFieldValue(
218 fd.field_id, None, None, user_id, None, None, False)
219 else:
220 return None
221
222 elif fd.field_type == tracker_pb2.FieldTypes.DATE_TYPE:
223 try:
224 timestamp = timestr.DateWidgetStrToTimestamp(val_str)
225 return tracker_bizobj.MakeFieldValue(
226 fd.field_id, None, None, None, timestamp, None, False)
227 except ValueError:
228 return None # TODO(jrobbins): should bounce
229
230 elif fd.field_type == tracker_pb2.FieldTypes.URL_TYPE:
231 val_str = FormatUrlFieldValue(val_str)
232 try:
233 return tracker_bizobj.MakeFieldValue(
234 fd.field_id, None, None, None, None, val_str, False)
235 except ValueError:
236 return None # TODO(jojwang): should bounce
237
238 else:
239 logging.error('Cant parse field with unexpected type %r', fd.field_type)
240 return None
241
242
243def ParseOnePhaseFieldValue(cnxn, user_service, fd, val_str, phase_ids):
244 """Return a list containing a FieldValue PB for each phase."""
245 phase_fvs = []
246 for phase_id in phase_ids:
247 # TODO(jojwang): monorail:3970, create the FieldValue once and find some
248 # proto2 CopyFrom() method to create a new one for each phase.
249 fv = ParseOneFieldValue(cnxn, user_service, fd, val_str)
250 if fv:
251 fv.phase_id = phase_id
252 phase_fvs.append(fv)
253
254 return phase_fvs
255
256
257def ParseFieldValues(cnxn, user_service, field_val_strs, phase_field_val_strs,
258 config, phase_ids_by_name=None):
259 """Return a list of FieldValue PBs based on the given dict of strings."""
260 field_values = []
261 for fd in config.field_defs:
262 if fd.is_phase_field and (
263 fd.field_id in phase_field_val_strs) and phase_ids_by_name:
264 fvs_by_phase_name = phase_field_val_strs.get(fd.field_id, {})
265 for phase_name, val_strs in fvs_by_phase_name.items():
266 phase_ids = phase_ids_by_name.get(phase_name)
267 if not phase_ids:
268 continue
269 for val_str in val_strs:
270 field_values.extend(
271 ParseOnePhaseFieldValue(
272 cnxn, user_service, fd, val_str, phase_ids=phase_ids))
273 # We do not save phase fields when there are no phases.
274 elif not fd.is_phase_field and (fd.field_id in field_val_strs):
275 for val_str in field_val_strs[fd.field_id]:
276 fv = ParseOneFieldValue(cnxn, user_service, fd, val_str)
277 if fv:
278 field_values.append(fv)
279
280 return field_values
281
282
283def ValidateCustomFieldValue(cnxn, project, services, field_def, field_val):
284 # type: (MonorailConnection, proto.tracker_pb2.Project, Services,
285 # proto.tracker_pb2.FieldDef, proto.tracker_pb2.FieldValue) -> str
286 """Validate one custom field value and return an error string or None.
287
288 Args:
289 cnxn: MonorailConnection object.
290 project: Project PB with info on the project the custom field belongs to.
291 services: Services object referencing services that can be queried.
292 field_def: FieldDef for the custom field we're validating against.
293 field_val: The value of the custom field.
294
295 Returns:
296 A string containing an error message if there was one.
297 """
298 if field_def.field_type == tracker_pb2.FieldTypes.INT_TYPE:
299 if (field_def.min_value is not None and
300 field_val.int_value < field_def.min_value):
301 return 'Value must be >= %d.' % field_def.min_value
302 if (field_def.max_value is not None and
303 field_val.int_value > field_def.max_value):
304 return 'Value must be <= %d.' % field_def.max_value
305
306 elif field_def.field_type == tracker_pb2.FieldTypes.STR_TYPE:
307 if field_def.regex and field_val.str_value:
308 try:
309 regex = re.compile(field_def.regex)
310 if not regex.match(field_val.str_value):
311 return 'Value must match regular expression: %s.' % field_def.regex
312 except re.error:
313 logging.info('Failed to process regex %r with value %r. Allowing.',
314 field_def.regex, field_val.str_value)
315 return None
316
317 elif field_def.field_type == tracker_pb2.FieldTypes.USER_TYPE:
318 field_val_user = services.user.GetUser(cnxn, field_val.user_id)
319 auth = authdata.AuthData.FromUser(cnxn, field_val_user, services)
320 if auth.user_pb.user_id == INVALID_USER_ID:
321 return 'User not found.'
322 if field_def.needs_member:
323 user_value_in_project = framework_bizobj.UserIsInProject(
324 project, auth.effective_ids)
325 if not user_value_in_project:
326 return 'User must be a member of the project.'
327 if field_def.needs_perm:
328 user_perms = permissions.GetPermissions(
329 auth.user_pb, auth.effective_ids, project)
330 has_perm = user_perms.CanUsePerm(
331 field_def.needs_perm, auth.effective_ids, project, [])
332 if not has_perm:
333 return 'User must have permission "%s".' % field_def.needs_perm
334 return None
335
336 elif field_def.field_type == tracker_pb2.FieldTypes.DATE_TYPE:
337 # TODO(jrobbins): date validation
338 pass
339
340 elif field_def.field_type == tracker_pb2.FieldTypes.URL_TYPE:
341 if field_val.url_value:
342 if not (validate.IsValidURL(field_val.url_value)
343 or autolink_constants.IS_A_SHORT_LINK_RE.match(
344 field_val.url_value)
345 or autolink_constants.IS_A_NUMERIC_SHORT_LINK_RE.match(
346 field_val.url_value)
347 or autolink_constants.IS_IMPLIED_LINK_RE.match(
348 field_val.url_value)):
349 return 'Value must be a valid url.'
350
351 return None
352
353def ValidateCustomFields(
354 cnxn, services, field_values, config, project, ezt_errors=None, issue=None):
355 # type: (MonorailConnection, Services,
356 # Collection[proto.tracker_pb2.FieldValue],
357 # proto.tracker_pb2.ProjectConfig, proto.tracker_pb2.Project,
358 # Optional[EZTError], Optional[proto.tracker_pb2.Issue]) ->
359 # Sequence[str]
360 """Validate given fields and report problems in error messages."""
361 fds_by_id = {fd.field_id: fd for fd in config.field_defs}
362 err_msgs = []
363
364 # Create a set of field_ids that have required values. If this set still
365 # contains items by the end of the function, there is an error.
366 required_fds = set()
367 if issue:
368 applicable_fds = ListApplicableFieldDefs([issue], config)
369
370 lower_field_names = [fd.field_name.lower() for fd in applicable_fds]
371 label_prefixes = tracker_bizobj.LabelsByPrefix(
372 list(set(issue.labels)), lower_field_names)
373
374 # Add applicable required fields to required_fds.
375 for fd in applicable_fds:
376 if not fd.is_required:
377 continue
378
379 if (fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and
380 fd.field_name.lower() in label_prefixes):
381 # Handle custom enum fields - they're a special case because their
382 # values are stored in labels instead of FieldValues.
383 continue
384
385 required_fds.add(fd.field_id)
386 # Ensure that every field value entered is valid. ie: That users exist.
387 for fv in field_values:
388 # Remove field_ids from the required set when found.
389 if fv.field_id in required_fds:
390 required_fds.remove(fv.field_id)
391
392 fd = fds_by_id.get(fv.field_id)
393 if fd:
394 err_msg = ValidateCustomFieldValue(cnxn, project, services, fd, fv)
395
396 if err_msg:
397 err_msgs.append('Error for %r: %s' % (fv, err_msg))
398 if ezt_errors:
399 ezt_errors.SetCustomFieldError(fv.field_id, err_msg)
400
401 # Add errors for any fields still left in the required set.
402 for field_id in required_fds:
403 fd = fds_by_id.get(field_id)
404 err_msg = '%s field is required.' % (fd.field_name)
405 err_msgs.append(err_msg)
406 if ezt_errors:
407 ezt_errors.SetCustomFieldError(field_id, err_msg)
408
409 return err_msgs
410
411
412def AssertCustomFieldsEditPerms(
413 mr, config, field_vals, field_vals_remove, fields_clear, labels,
414 labels_remove):
415 """Check permissions for any kind of custom field edition attempt."""
416 # TODO: When clearing phase_fields is possible, include it in this method.
417 field_ids = set()
418
419 for fv in field_vals:
420 field_ids.add(fv.field_id)
421 for fvr in field_vals_remove:
422 field_ids.add(fvr.field_id)
423 for fd_id in fields_clear:
424 field_ids.add(fd_id)
425
426 enum_fds_by_name = {
427 fd.field_name.lower(): fd.field_id
428 for fd in config.field_defs
429 if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and not fd.is_deleted
430 }
431 for label in itertools.chain(labels, labels_remove):
432 enum_field_name = tracker_bizobj.LabelIsMaskedByField(
433 label, enum_fds_by_name.keys())
434 if enum_field_name:
435 field_ids.add(enum_fds_by_name.get(enum_field_name))
436
437 fds_by_id = {fd.field_id: fd for fd in config.field_defs}
438 for field_id in field_ids:
439 fd = fds_by_id.get(field_id)
440 if fd:
441 assert permissions.CanEditValueForFieldDef(
442 mr.auth.effective_ids, mr.perms, mr.project,
443 fd), 'No permission to edit certain fields.'
444
445
446def ApplyRestrictedDefaultValues(
447 mr, config, field_vals, labels, template_field_vals, template_labels):
448 """Add default values of template fields that the user cannot edit.
449
450 This method can be called by servlets where restricted field values that
451 a user cannot edit are displayed but do not get returned when the user
452 submits the form (and also assumes that previous assertions ensure these
453 conditions). These missing default values still need to be passed to the
454 services layer when a 'write' is done so that these default values do
455 not get removed.
456
457 Args:
458 mr: MonorailRequest Object to hold info about the request and the user.
459 config: ProjectIssueConfig Object for the project.
460 field_vals: list of FieldValues that the user wants to save.
461 labels: list of labels that the user wants to save.
462 template_field_vals: list of FieldValues belonging to the template.
463 template_labels: list of labels belonging to the template.
464
465 Side Effect:
466 The default values of a template that the user cannot edit are added
467 to 'field_vals' and 'labels'.
468 """
469
470 fds_by_id = {fd.field_id: fd for fd in config.field_defs}
471 for fv in template_field_vals:
472 fd = fds_by_id.get(fv.field_id)
473 if fd and not permissions.CanEditValueForFieldDef(mr.auth.effective_ids,
474 mr.perms, mr.project, fd):
475 field_vals.append(fv)
476
477 fds_by_name = {
478 fd.field_name.lower(): fd
479 for fd in config.field_defs
480 if fd.field_type is tracker_pb2.FieldTypes.ENUM_TYPE and not fd.is_deleted
481 }
482 for label in template_labels:
483 enum_field_name = tracker_bizobj.LabelIsMaskedByField(
484 label, fds_by_name.keys())
485 if enum_field_name:
486 fd = fds_by_name.get(enum_field_name)
487 if fd and not permissions.CanEditValueForFieldDef(
488 mr.auth.effective_ids, mr.perms, mr.project, fd):
489 labels.append(label)
490
491
492def FormatUrlFieldValue(url_str):
493 """Check for and add 'https://' to a url string"""
494 if not url_str.startswith('http'):
495 return 'http://' + url_str
496 return url_str
497
498
499def ReviseFieldDefFromParsed(parsed, old_fd):
500 """Creates new FieldDef based on an original FieldDef and parsed FieldDef"""
501 if parsed.date_action_str in config_svc.DATE_ACTION_ENUM:
502 date_action = config_svc.DATE_ACTION_ENUM.index(parsed.date_action_str)
503 else:
504 date_action = 0
505 return tracker_bizobj.MakeFieldDef(
506 old_fd.field_id, old_fd.project_id, old_fd.field_name, old_fd.field_type,
507 parsed.applicable_type, parsed.applicable_predicate, parsed.is_required,
508 parsed.is_niche, parsed.is_multivalued, parsed.min_value,
509 parsed.max_value, parsed.regex, parsed.needs_member, parsed.needs_perm,
510 parsed.grants_perm, parsed.notify_on, date_action, parsed.field_docstring,
511 False, approval_id=old_fd.approval_id or None,
512 is_phase_field=old_fd.is_phase_field)
513
514
515def ParsedFieldDefAssertions(mr, parsed):
516 """Checks if new/updated FieldDef is not violating basic assertions.
517 If the assertions are violated, the errors
518 will be included in the mr.errors.
519
520 Args:
521 mr: MonorailRequest object used to hold
522 commonly info parsed from the request.
523 parsed: ParsedFieldDef object used to contain parsed info,
524 in this case regarding a custom field definition.
525 """
526 # TODO(crbug/monorail/7275): This method is meant to eventually
527 # do all assertion checkings (shared by create/update fieldDef)
528 # and assign all mr.errors values.
529 if (parsed.is_required and parsed.is_niche):
530 mr.errors.is_niche = 'A field cannot be both required and niche.'
531 if parsed.date_action_str is not None and (
532 parsed.date_action_str not in config_svc.DATE_ACTION_ENUM):
533 mr.errors.date_action = 'The date action should be either: ' + ', '.join(
534 config_svc.DATE_ACTION_ENUM) + '.'
535 if (parsed.min_value is not None and parsed.max_value is not None and
536 parsed.min_value > parsed.max_value):
537 mr.errors.min_value = 'Minimum value must be less than maximum.'
538 if parsed.regex:
539 try:
540 re.compile(parsed.regex)
541 except re.error:
542 mr.errors.regex = 'Invalid regular expression.'