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