blob: 8ad829d1cdf820303b6c508a5b3e34b97448de1b [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"""Classes and functions for persistence of issue tracker configuration.
6
7This module provides functions to get, update, create, and (in some
8cases) delete each type of business object. It provides a logical
9persistence layer on top of an SQL database.
10
11Business objects are described in tracker_pb2.py and tracker_bizobj.py.
12"""
13from __future__ import print_function
14from __future__ import division
15from __future__ import absolute_import
16
17import collections
18import logging
19
20from google.appengine.api import memcache
21
22import settings
23from framework import exceptions
24from framework import framework_constants
25from framework import sql
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010026from mrproto import tracker_pb2
Copybara854996b2021-09-07 19:36:02 +000027from services import caches
28from services import project_svc
29from tracker import tracker_bizobj
30from tracker import tracker_constants
31
32
33PROJECTISSUECONFIG_TABLE_NAME = 'ProjectIssueConfig'
34LABELDEF_TABLE_NAME = 'LabelDef'
35FIELDDEF_TABLE_NAME = 'FieldDef'
36FIELDDEF2ADMIN_TABLE_NAME = 'FieldDef2Admin'
37FIELDDEF2EDITOR_TABLE_NAME = 'FieldDef2Editor'
38COMPONENTDEF_TABLE_NAME = 'ComponentDef'
39COMPONENT2ADMIN_TABLE_NAME = 'Component2Admin'
40COMPONENT2CC_TABLE_NAME = 'Component2Cc'
41COMPONENT2LABEL_TABLE_NAME = 'Component2Label'
42STATUSDEF_TABLE_NAME = 'StatusDef'
43APPROVALDEF2APPROVER_TABLE_NAME = 'ApprovalDef2Approver'
44APPROVALDEF2SURVEY_TABLE_NAME = 'ApprovalDef2Survey'
45
46PROJECTISSUECONFIG_COLS = [
47 'project_id', 'statuses_offer_merge', 'exclusive_label_prefixes',
48 'default_template_for_developers', 'default_template_for_users',
49 'default_col_spec', 'default_sort_spec', 'default_x_attr',
50 'default_y_attr', 'member_default_query', 'custom_issue_entry_url']
51STATUSDEF_COLS = [
52 'id', 'project_id', 'rank', 'status', 'means_open', 'docstring',
53 'deprecated']
54LABELDEF_COLS = [
55 'id', 'project_id', 'rank', 'label', 'docstring', 'deprecated']
56FIELDDEF_COLS = [
57 'id', 'project_id', 'rank', 'field_name', 'field_type', 'applicable_type',
58 'applicable_predicate', 'is_required', 'is_niche', 'is_multivalued',
59 'min_value', 'max_value', 'regex', 'needs_member', 'needs_perm',
60 'grants_perm', 'notify_on', 'date_action', 'docstring', 'is_deleted',
61 'approval_id', 'is_phase_field', 'is_restricted_field'
62]
63FIELDDEF2ADMIN_COLS = ['field_id', 'admin_id']
64FIELDDEF2EDITOR_COLS = ['field_id', 'editor_id']
65COMPONENTDEF_COLS = ['id', 'project_id', 'path', 'docstring', 'deprecated',
66 'created', 'creator_id', 'modified', 'modifier_id']
67COMPONENT2ADMIN_COLS = ['component_id', 'admin_id']
68COMPONENT2CC_COLS = ['component_id', 'cc_id']
69COMPONENT2LABEL_COLS = ['component_id', 'label_id']
70APPROVALDEF2APPROVER_COLS = ['approval_id', 'approver_id', 'project_id']
71APPROVALDEF2SURVEY_COLS = ['approval_id', 'survey', 'project_id']
72
73NOTIFY_ON_ENUM = ['never', 'any_comment']
74DATE_ACTION_ENUM = ['no_action', 'ping_owner_only', 'ping_participants']
75
76# Some projects have tons of label rows, so we retrieve them in shards
77# to avoid huge DB results or exceeding the memcache size limit.
78LABEL_ROW_SHARDS = 10
79
80
81class LabelRowTwoLevelCache(caches.AbstractTwoLevelCache):
82 """Class to manage RAM and memcache for label rows.
83
84 Label rows exist for every label used in a project, even those labels
85 that were added to issues in an ad hoc way without being defined in the
86 config ahead of time.
87
88 The set of all labels in a project can be very large, so we shard them
89 into 10 parts so that each part can be cached in memcache with < 1MB.
90 """
91
92 def __init__(self, cache_manager, config_service):
93 super(LabelRowTwoLevelCache, self).__init__(
94 cache_manager, 'project', 'label_rows:', None)
95 self.config_service = config_service
96
97 def _MakeCache(self, cache_manager, kind, max_size=None):
98 """Make the RAM cache and registier it with the cache_manager."""
99 return caches.ShardedRamCache(
100 cache_manager, kind, max_size=max_size, num_shards=LABEL_ROW_SHARDS)
101
102 def _DeserializeLabelRows(self, label_def_rows):
103 """Convert DB result rows into a dict {project_id: [row, ...]}."""
104 result_dict = collections.defaultdict(list)
105 for label_id, project_id, rank, label, docstr, deprecated in label_def_rows:
106 shard_id = label_id % LABEL_ROW_SHARDS
107 result_dict[(project_id, shard_id)].append(
108 (label_id, project_id, rank, label, docstr, deprecated))
109
110 return result_dict
111
112 def FetchItems(self, cnxn, keys):
113 """On RAM and memcache miss, hit the database."""
114 # Make sure that every requested project is represented in the result
115 label_rows_dict = {}
116 for key in keys:
117 label_rows_dict.setdefault(key, [])
118
119 for project_id, shard_id in keys:
120 shard_clause = [('id %% %s = %s', [LABEL_ROW_SHARDS, shard_id])]
121
122 label_def_rows = self.config_service.labeldef_tbl.Select(
123 cnxn, cols=LABELDEF_COLS, project_id=project_id,
124 where=shard_clause)
125 label_rows_dict.update(self._DeserializeLabelRows(label_def_rows))
126
127 for rows_in_shard in label_rows_dict.values():
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100128 rows_in_shard.sort(key=lambda row: (row[2] or 0, row[3]), reverse=True)
Copybara854996b2021-09-07 19:36:02 +0000129
130 return label_rows_dict
131
132 def InvalidateKeys(self, cnxn, project_ids):
133 """Drop the given keys from both RAM and memcache."""
134 self.cache.InvalidateKeys(cnxn, project_ids)
135 memcache.delete_multi(
136 [
137 self._KeyToStr((project_id, shard_id))
138 for project_id in project_ids
139 for shard_id in range(0, LABEL_ROW_SHARDS)
140 ],
141 seconds=5,
142 key_prefix=self.prefix,
143 namespace=settings.memcache_namespace)
144
145 def InvalidateAllKeys(self, cnxn, project_ids):
146 """Drop the given keys from memcache and invalidate all keys in RAM.
147
148 Useful for avoiding inserting many rows into the Invalidate table when
149 invalidating a large group of keys all at once. Only use when necessary.
150 """
151 self.cache.InvalidateAll(cnxn)
152 memcache.delete_multi(
153 [
154 self._KeyToStr((project_id, shard_id))
155 for project_id in project_ids
156 for shard_id in range(0, LABEL_ROW_SHARDS)
157 ],
158 seconds=5,
159 key_prefix=self.prefix,
160 namespace=settings.memcache_namespace)
161
162 def _KeyToStr(self, key):
163 """Convert our tuple IDs to strings for use as memcache keys."""
164 project_id, shard_id = key
165 return '%d-%d' % (project_id, shard_id)
166
167 def _StrToKey(self, key_str):
168 """Convert memcache keys back to the tuples that we use as IDs."""
169 project_id_str, shard_id_str = key_str.split('-')
170 return int(project_id_str), int(shard_id_str)
171
172
173class StatusRowTwoLevelCache(caches.AbstractTwoLevelCache):
174 """Class to manage RAM and memcache for status rows."""
175
176 def __init__(self, cache_manager, config_service):
177 super(StatusRowTwoLevelCache, self).__init__(
178 cache_manager, 'project', 'status_rows:', None)
179 self.config_service = config_service
180
181 def _DeserializeStatusRows(self, def_rows):
182 """Convert status definition rows into {project_id: [row, ...]}."""
183 result_dict = collections.defaultdict(list)
184 for (status_id, project_id, rank, status,
185 means_open, docstr, deprecated) in def_rows:
186 result_dict[project_id].append(
187 (status_id, project_id, rank, status, means_open, docstr, deprecated))
188
189 return result_dict
190
191 def FetchItems(self, cnxn, keys):
192 """On cache miss, get status definition rows from the DB."""
193 status_def_rows = self.config_service.statusdef_tbl.Select(
194 cnxn, cols=STATUSDEF_COLS, project_id=keys,
195 order_by=[('rank DESC', []), ('status DESC', [])])
196 status_rows_dict = self._DeserializeStatusRows(status_def_rows)
197
198 # Make sure that every requested project is represented in the result
199 for project_id in keys:
200 status_rows_dict.setdefault(project_id, [])
201
202 return status_rows_dict
203
204
205class FieldRowTwoLevelCache(caches.AbstractTwoLevelCache):
206 """Class to manage RAM and memcache for field rows.
207
208 Field rows exist for every field used in a project, since they cannot be
209 created through ad-hoc means.
210 """
211
212 def __init__(self, cache_manager, config_service):
213 super(FieldRowTwoLevelCache, self).__init__(
214 cache_manager, 'project', 'field_rows:', None)
215 self.config_service = config_service
216
217 def _DeserializeFieldRows(self, field_def_rows):
218 """Convert DB result rows into a dict {project_id: [row, ...]}."""
219 result_dict = collections.defaultdict(list)
220 # TODO: Actually process the rest of the items.
221 for (field_id, project_id, rank, field_name, _field_type, _applicable_type,
222 _applicable_predicate, _is_required, _is_niche, _is_multivalued,
223 _min_value, _max_value, _regex, _needs_member, _needs_perm,
224 _grants_perm, _notify_on, _date_action, docstring, _is_deleted,
225 _approval_id, _is_phase_field, _is_restricted_field) in field_def_rows:
226 result_dict[project_id].append(
227 (field_id, project_id, rank, field_name, docstring))
228
229 return result_dict
230
231 def FetchItems(self, cnxn, keys):
232 """On RAM and memcache miss, hit the database."""
233 field_def_rows = self.config_service.fielddef_tbl.Select(
234 cnxn, cols=FIELDDEF_COLS, project_id=keys,
235 order_by=[('rank DESC', []), ('field_name DESC', [])])
236 field_rows_dict = self._DeserializeFieldRows(field_def_rows)
237
238 # Make sure that every requested project is represented in the result
239 for project_id in keys:
240 field_rows_dict.setdefault(project_id, [])
241
242 return field_rows_dict
243
244
245class ConfigTwoLevelCache(caches.AbstractTwoLevelCache):
246 """Class to manage RAM and memcache for IssueProjectConfig PBs."""
247
248 def __init__(self, cache_manager, config_service):
249 super(ConfigTwoLevelCache, self).__init__(
250 cache_manager, 'project', 'config:', tracker_pb2.ProjectIssueConfig)
251 self.config_service = config_service
252
253 def _UnpackProjectIssueConfig(self, config_row):
254 """Partially construct a config object using info from a DB row."""
255 (project_id, statuses_offer_merge, exclusive_label_prefixes,
256 default_template_for_developers, default_template_for_users,
257 default_col_spec, default_sort_spec, default_x_attr, default_y_attr,
258 member_default_query, custom_issue_entry_url) = config_row
259 config = tracker_pb2.ProjectIssueConfig()
260 config.project_id = project_id
261 config.statuses_offer_merge.extend(statuses_offer_merge.split())
262 config.exclusive_label_prefixes.extend(exclusive_label_prefixes.split())
263 config.default_template_for_developers = default_template_for_developers
264 config.default_template_for_users = default_template_for_users
265 config.default_col_spec = default_col_spec
266 config.default_sort_spec = default_sort_spec
267 config.default_x_attr = default_x_attr
268 config.default_y_attr = default_y_attr
269 config.member_default_query = member_default_query
270 if custom_issue_entry_url is not None:
271 config.custom_issue_entry_url = custom_issue_entry_url
272
273 return config
274
275 def _UnpackFieldDef(self, fielddef_row):
276 """Partially construct a FieldDef object using info from a DB row."""
277 (
278 field_id, project_id, _rank, field_name, field_type, applic_type,
279 applic_pred, is_required, is_niche, is_multivalued, min_value,
280 max_value, regex, needs_member, needs_perm, grants_perm, notify_on_str,
281 date_action_str, docstring, is_deleted, approval_id, is_phase_field,
282 is_restricted_field) = fielddef_row
283 if notify_on_str == 'any_comment':
284 notify_on = tracker_pb2.NotifyTriggers.ANY_COMMENT
285 else:
286 notify_on = tracker_pb2.NotifyTriggers.NEVER
287 try:
288 date_action = DATE_ACTION_ENUM.index(date_action_str)
289 except ValueError:
290 date_action = DATE_ACTION_ENUM.index('no_action')
291
292 return tracker_bizobj.MakeFieldDef(
293 field_id, project_id, field_name,
294 tracker_pb2.FieldTypes(field_type.upper()), applic_type, applic_pred,
295 is_required, is_niche, is_multivalued, min_value, max_value, regex,
296 needs_member, needs_perm, grants_perm, notify_on, date_action,
297 docstring, is_deleted, approval_id, is_phase_field, is_restricted_field)
298
299 def _UnpackComponentDef(
300 self, cd_row, component2admin_rows, component2cc_rows,
301 component2label_rows):
302 """Partially construct a FieldDef object using info from a DB row."""
303 (component_id, project_id, path, docstring, deprecated, created,
304 creator_id, modified, modifier_id) = cd_row
305 cd = tracker_bizobj.MakeComponentDef(
306 component_id, project_id, path, docstring, deprecated,
307 [admin_id for comp_id, admin_id in component2admin_rows
308 if comp_id == component_id],
309 [cc_id for comp_id, cc_id in component2cc_rows
310 if comp_id == component_id],
311 created, creator_id,
312 modified=modified, modifier_id=modifier_id,
313 label_ids=[label_id for comp_id, label_id in component2label_rows
314 if comp_id == component_id])
315
316 return cd
317
318 def _DeserializeIssueConfigs(
319 self, config_rows, statusdef_rows, labeldef_rows, fielddef_rows,
320 fielddef2admin_rows, fielddef2editor_rows, componentdef_rows,
321 component2admin_rows, component2cc_rows, component2label_rows,
322 approvaldef2approver_rows, approvaldef2survey_rows):
323 """Convert the given row tuples into a dict of ProjectIssueConfig PBs."""
324 result_dict = {}
325 fielddef_dict = {}
326 approvaldef_dict = {}
327
328 for config_row in config_rows:
329 config = self._UnpackProjectIssueConfig(config_row)
330 result_dict[config.project_id] = config
331
332 for statusdef_row in statusdef_rows:
333 (_, project_id, _rank, status,
334 means_open, docstring, deprecated) = statusdef_row
335 if project_id in result_dict:
336 wks = tracker_pb2.StatusDef(
337 status=status, means_open=bool(means_open),
338 status_docstring=docstring or '', deprecated=bool(deprecated))
339 result_dict[project_id].well_known_statuses.append(wks)
340
341 for labeldef_row in labeldef_rows:
342 _, project_id, _rank, label, docstring, deprecated = labeldef_row
343 if project_id in result_dict:
344 wkl = tracker_pb2.LabelDef(
345 label=label, label_docstring=docstring or '',
346 deprecated=bool(deprecated))
347 result_dict[project_id].well_known_labels.append(wkl)
348
349 for approver_row in approvaldef2approver_rows:
350 approval_id, approver_id, project_id = approver_row
351 if project_id in result_dict:
352 approval_def = approvaldef_dict.get(approval_id)
353 if approval_def is None:
354 approval_def = tracker_pb2.ApprovalDef(
355 approval_id=approval_id)
356 result_dict[project_id].approval_defs.append(approval_def)
357 approvaldef_dict[approval_id] = approval_def
358 approval_def.approver_ids.append(approver_id)
359
360 for survey_row in approvaldef2survey_rows:
361 approval_id, survey, project_id = survey_row
362 if project_id in result_dict:
363 approval_def = approvaldef_dict.get(approval_id)
364 if approval_def is None:
365 approval_def = tracker_pb2.ApprovalDef(
366 approval_id=approval_id)
367 result_dict[project_id].approval_defs.append(approval_def)
368 approvaldef_dict[approval_id] = approval_def
369 approval_def.survey = survey
370
371 for fd_row in fielddef_rows:
372 fd = self._UnpackFieldDef(fd_row)
373 result_dict[fd.project_id].field_defs.append(fd)
374 fielddef_dict[fd.field_id] = fd
375
376 for fd2admin_row in fielddef2admin_rows:
377 field_id, admin_id = fd2admin_row
378 fd = fielddef_dict.get(field_id)
379 if fd:
380 fd.admin_ids.append(admin_id)
381
382 for fd2editor_row in fielddef2editor_rows:
383 field_id, editor_id = fd2editor_row
384 fd = fielddef_dict.get(field_id)
385 if fd:
386 fd.editor_ids.append(editor_id)
387
388 for cd_row in componentdef_rows:
389 cd = self._UnpackComponentDef(
390 cd_row, component2admin_rows, component2cc_rows, component2label_rows)
391 result_dict[cd.project_id].component_defs.append(cd)
392
393 return result_dict
394
395 def _FetchConfigs(self, cnxn, project_ids):
396 """On RAM and memcache miss, hit the database."""
397 config_rows = self.config_service.projectissueconfig_tbl.Select(
398 cnxn, cols=PROJECTISSUECONFIG_COLS, project_id=project_ids)
399 statusdef_rows = self.config_service.statusdef_tbl.Select(
400 cnxn, cols=STATUSDEF_COLS, project_id=project_ids,
401 where=[('rank IS NOT NULL', [])], order_by=[('rank', [])])
402
403 labeldef_rows = self.config_service.labeldef_tbl.Select(
404 cnxn, cols=LABELDEF_COLS, project_id=project_ids,
405 where=[('rank IS NOT NULL', [])], order_by=[('rank', [])])
406
407 approver_rows = self.config_service.approvaldef2approver_tbl.Select(
408 cnxn, cols=APPROVALDEF2APPROVER_COLS, project_id=project_ids)
409 survey_rows = self.config_service.approvaldef2survey_tbl.Select(
410 cnxn, cols=APPROVALDEF2SURVEY_COLS, project_id=project_ids)
411
412 # TODO(jrobbins): For now, sort by field name, but someday allow admins
413 # to adjust the rank to group and order field definitions logically.
414 fielddef_rows = self.config_service.fielddef_tbl.Select(
415 cnxn, cols=FIELDDEF_COLS, project_id=project_ids,
416 order_by=[('field_name', [])])
417 field_ids = [row[0] for row in fielddef_rows]
418 fielddef2admin_rows = []
419 fielddef2editor_rows = []
420 if field_ids:
421 fielddef2admin_rows = self.config_service.fielddef2admin_tbl.Select(
422 cnxn, cols=FIELDDEF2ADMIN_COLS, field_id=field_ids)
423 fielddef2editor_rows = self.config_service.fielddef2editor_tbl.Select(
424 cnxn, cols=FIELDDEF2EDITOR_COLS, field_id=field_ids)
425
426 componentdef_rows = self.config_service.componentdef_tbl.Select(
427 cnxn, cols=COMPONENTDEF_COLS, project_id=project_ids,
428 is_deleted=False, order_by=[('path', [])])
429 component_ids = [cd_row[0] for cd_row in componentdef_rows]
430 component2admin_rows = []
431 component2cc_rows = []
432 component2label_rows = []
433 if component_ids:
434 component2admin_rows = self.config_service.component2admin_tbl.Select(
435 cnxn, cols=COMPONENT2ADMIN_COLS, component_id=component_ids)
436 component2cc_rows = self.config_service.component2cc_tbl.Select(
437 cnxn, cols=COMPONENT2CC_COLS, component_id=component_ids)
438 component2label_rows = self.config_service.component2label_tbl.Select(
439 cnxn, cols=COMPONENT2LABEL_COLS, component_id=component_ids)
440
441 retrieved_dict = self._DeserializeIssueConfigs(
442 config_rows, statusdef_rows, labeldef_rows, fielddef_rows,
443 fielddef2admin_rows, fielddef2editor_rows, componentdef_rows,
444 component2admin_rows, component2cc_rows, component2label_rows,
445 approver_rows, survey_rows)
446 return retrieved_dict
447
448 def FetchItems(self, cnxn, keys):
449 """On RAM and memcache miss, hit the database."""
450 retrieved_dict = self._FetchConfigs(cnxn, keys)
451
452 # Any projects which don't have stored configs should use a default
453 # config instead.
454 for project_id in keys:
455 if project_id not in retrieved_dict:
456 config = tracker_bizobj.MakeDefaultProjectIssueConfig(project_id)
457 retrieved_dict[project_id] = config
458
459 return retrieved_dict
460
461
462class ConfigService(object):
463 """The persistence layer for Monorail's issue tracker configuration data."""
464
465 def __init__(self, cache_manager):
466 """Initialize this object so that it is ready to use.
467
468 Args:
469 cache_manager: manages local caches with distributed invalidation.
470 """
471 self.projectissueconfig_tbl = sql.SQLTableManager(
472 PROJECTISSUECONFIG_TABLE_NAME)
473 self.statusdef_tbl = sql.SQLTableManager(STATUSDEF_TABLE_NAME)
474 self.labeldef_tbl = sql.SQLTableManager(LABELDEF_TABLE_NAME)
475 self.fielddef_tbl = sql.SQLTableManager(FIELDDEF_TABLE_NAME)
476 self.fielddef2admin_tbl = sql.SQLTableManager(FIELDDEF2ADMIN_TABLE_NAME)
477 self.fielddef2editor_tbl = sql.SQLTableManager(FIELDDEF2EDITOR_TABLE_NAME)
478 self.componentdef_tbl = sql.SQLTableManager(COMPONENTDEF_TABLE_NAME)
479 self.component2admin_tbl = sql.SQLTableManager(COMPONENT2ADMIN_TABLE_NAME)
480 self.component2cc_tbl = sql.SQLTableManager(COMPONENT2CC_TABLE_NAME)
481 self.component2label_tbl = sql.SQLTableManager(COMPONENT2LABEL_TABLE_NAME)
482 self.approvaldef2approver_tbl = sql.SQLTableManager(
483 APPROVALDEF2APPROVER_TABLE_NAME)
484 self.approvaldef2survey_tbl = sql.SQLTableManager(
485 APPROVALDEF2SURVEY_TABLE_NAME)
486
487 self.config_2lc = ConfigTwoLevelCache(cache_manager, self)
488 self.label_row_2lc = LabelRowTwoLevelCache(cache_manager, self)
489 self.label_cache = caches.RamCache(cache_manager, 'project')
490 self.status_row_2lc = StatusRowTwoLevelCache(cache_manager, self)
491 self.status_cache = caches.RamCache(cache_manager, 'project')
492 self.field_row_2lc = FieldRowTwoLevelCache(cache_manager, self)
493 self.field_cache = caches.RamCache(cache_manager, 'project')
494
495 ### Label lookups
496
497 def GetLabelDefRows(self, cnxn, project_id, use_cache=True):
498 """Get SQL result rows for all labels used in the specified project."""
499 result = []
500 for shard_id in range(0, LABEL_ROW_SHARDS):
501 key = (project_id, shard_id)
502 pids_to_label_rows_shard, _misses = self.label_row_2lc.GetAll(
503 cnxn, [key], use_cache=use_cache)
504 result.extend(pids_to_label_rows_shard[key])
505 # Sort in python to reduce DB load and integrate results from shards.
506 # row[2] is rank, row[3] is label name.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100507 result.sort(key=lambda row: (row[2] or 0, row[3]), reverse=True)
Copybara854996b2021-09-07 19:36:02 +0000508 return result
509
510 def GetLabelDefRowsAnyProject(self, cnxn, where=None):
511 """Get all LabelDef rows for the whole site. Used in whole-site search."""
512 # TODO(jrobbins): maybe add caching for these too.
513 label_def_rows = self.labeldef_tbl.Select(
514 cnxn, cols=LABELDEF_COLS, where=where,
515 order_by=[('rank DESC', []), ('label DESC', [])])
516 return label_def_rows
517
518 def _DeserializeLabels(self, def_rows):
519 """Convert label defs into bi-directional mappings of names and IDs."""
520 label_id_to_name = {
521 label_id: label for
522 label_id, _pid, _rank, label, _doc, _deprecated
523 in def_rows}
524 label_name_to_id = {
525 label.lower(): label_id
526 for label_id, label in label_id_to_name.items()}
527
528 return label_id_to_name, label_name_to_id
529
530 def _EnsureLabelCacheEntry(self, cnxn, project_id, use_cache=True):
531 """Make sure that self.label_cache has an entry for project_id."""
532 if not use_cache or not self.label_cache.HasItem(project_id):
533 def_rows = self.GetLabelDefRows(cnxn, project_id, use_cache=use_cache)
534 self.label_cache.CacheItem(project_id, self._DeserializeLabels(def_rows))
535
536 def LookupLabel(self, cnxn, project_id, label_id):
537 """Lookup a label string given the label_id.
538
539 Args:
540 cnxn: connection to SQL database.
541 project_id: int ID of the project where the label is defined or used.
542 label_id: int label ID.
543
544 Returns:
545 Label name string for the given label_id, or None.
546 """
547 self._EnsureLabelCacheEntry(cnxn, project_id)
548 label_id_to_name, _label_name_to_id = self.label_cache.GetItem(
549 project_id)
550 if label_id in label_id_to_name:
551 return label_id_to_name[label_id]
552
553 logging.info('Label %r not found. Getting fresh from DB.', label_id)
554 self._EnsureLabelCacheEntry(cnxn, project_id, use_cache=False)
555 label_id_to_name, _label_name_to_id = self.label_cache.GetItem(
556 project_id)
557 return label_id_to_name.get(label_id)
558
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100559 def LookupLabelID(
560 self, cnxn, project_id, label, autocreate=True, case_sensitive=False):
Copybara854996b2021-09-07 19:36:02 +0000561 """Look up a label ID, optionally interning it.
562
563 Args:
564 cnxn: connection to SQL database.
565 project_id: int ID of the project where the statuses are defined.
566 label: label string.
567 autocreate: if not already in the DB, store it and generate a new ID.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100568 case_sensitive: if label lookup is case sensivite
Copybara854996b2021-09-07 19:36:02 +0000569
570 Returns:
571 The label ID for the given label string.
572 """
573 self._EnsureLabelCacheEntry(cnxn, project_id)
574 _label_id_to_name, label_name_to_id = self.label_cache.GetItem(
575 project_id)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100576
577 label_lower = label.lower() if not case_sensitive else label
578 if label_lower in label_name_to_id:
579 return label_name_to_id[label_lower]
580
581 if not case_sensitive:
582 where = [('LOWER(label) = %s', [label_lower])]
583 else:
584 where = [('label = %s', [label])]
Copybara854996b2021-09-07 19:36:02 +0000585
586 # Double check that the label does not already exist in the DB.
587 rows = self.labeldef_tbl.Select(
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100588 cnxn, cols=['id'], project_id=project_id, where=where, limit=1)
Copybara854996b2021-09-07 19:36:02 +0000589 logging.info('Double checking for %r gave %r', label, rows)
590 if rows:
591 self.label_row_2lc.cache.LocalInvalidate(project_id)
592 self.label_cache.LocalInvalidate(project_id)
593 return rows[0][0]
594
595 if autocreate:
596 logging.info('No label %r is known in project %d, so intern it.',
597 label, project_id)
598 label_id = self.labeldef_tbl.InsertRow(
599 cnxn, project_id=project_id, label=label)
600 self.label_row_2lc.InvalidateKeys(cnxn, [project_id])
601 self.label_cache.Invalidate(cnxn, project_id)
602 return label_id
603
604 return None # It was not found and we don't want to create it.
605
606 def LookupLabelIDs(self, cnxn, project_id, labels, autocreate=False):
607 """Look up several label IDs.
608
609 Args:
610 cnxn: connection to SQL database.
611 project_id: int ID of the project where the statuses are defined.
612 labels: list of label strings.
613 autocreate: if not already in the DB, store it and generate a new ID.
614
615 Returns:
616 Returns a list of int label IDs for the given label strings.
617 """
618 result = []
619 for lab in labels:
620 label_id = self.LookupLabelID(
621 cnxn, project_id, lab, autocreate=autocreate)
622 if label_id is not None:
623 result.append(label_id)
624
625 return result
626
627 def LookupIDsOfLabelsMatching(self, cnxn, project_id, regex):
628 """Look up the IDs of all labels in a project that match the regex.
629
630 Args:
631 cnxn: connection to SQL database.
632 project_id: int ID of the project where the statuses are defined.
633 regex: regular expression object to match against the label strings.
634
635 Returns:
636 List of label IDs for labels that match the regex.
637 """
638 self._EnsureLabelCacheEntry(cnxn, project_id)
639 label_id_to_name, _label_name_to_id = self.label_cache.GetItem(
640 project_id)
641 result = [label_id for label_id, label in label_id_to_name.items()
642 if regex.match(label)]
643
644 return result
645
646 def LookupLabelIDsAnyProject(self, cnxn, label):
647 """Return the IDs of labels with the given name in any project.
648
649 Args:
650 cnxn: connection to SQL database.
651 label: string label to look up. Case sensitive.
652
653 Returns:
654 A list of int label IDs of all labels matching the given string.
655 """
656 # TODO(jrobbins): maybe add caching for these too.
657 label_id_rows = self.labeldef_tbl.Select(
658 cnxn, cols=['id'], label=label)
659 label_ids = [row[0] for row in label_id_rows]
660 return label_ids
661
662 def LookupIDsOfLabelsMatchingAnyProject(self, cnxn, regex):
663 """Return the IDs of matching labels in any project."""
664 label_rows = self.labeldef_tbl.Select(
665 cnxn, cols=['id', 'label'])
666 matching_ids = [
667 label_id for label_id, label in label_rows if regex.match(label)]
668 return matching_ids
669
670 ### Status lookups
671
672 def GetStatusDefRows(self, cnxn, project_id):
673 """Return a list of status definition rows for the specified project."""
674 pids_to_status_rows, misses = self.status_row_2lc.GetAll(
675 cnxn, [project_id])
676 assert not misses
677 return pids_to_status_rows[project_id]
678
679 def GetStatusDefRowsAnyProject(self, cnxn):
680 """Return all status definition rows on the whole site."""
681 # TODO(jrobbins): maybe add caching for these too.
682 status_def_rows = self.statusdef_tbl.Select(
683 cnxn, cols=STATUSDEF_COLS,
684 order_by=[('rank DESC', []), ('status DESC', [])])
685 return status_def_rows
686
687 def _DeserializeStatuses(self, def_rows):
688 """Convert status defs into bi-directional mappings of names and IDs."""
689 status_id_to_name = {
690 status_id: status
691 for (status_id, _pid, _rank, status, _means_open,
692 _doc, _deprecated) in def_rows}
693 status_name_to_id = {
694 status.lower(): status_id
695 for status_id, status in status_id_to_name.items()}
696 closed_status_ids = [
697 status_id
698 for (status_id, _pid, _rank, _status, means_open,
699 _doc, _deprecated) in def_rows
700 if means_open == 0] # Only 0 means closed. NULL/None means open.
701
702 return status_id_to_name, status_name_to_id, closed_status_ids
703
704 def _EnsureStatusCacheEntry(self, cnxn, project_id):
705 """Make sure that self.status_cache has an entry for project_id."""
706 if not self.status_cache.HasItem(project_id):
707 def_rows = self.GetStatusDefRows(cnxn, project_id)
708 self.status_cache.CacheItem(
709 project_id, self._DeserializeStatuses(def_rows))
710
711 def LookupStatus(self, cnxn, project_id, status_id):
712 """Look up a status string for the given status ID.
713
714 Args:
715 cnxn: connection to SQL database.
716 project_id: int ID of the project where the statuses are defined.
717 status_id: int ID of the status value.
718
719 Returns:
720 A status string, or None.
721 """
722 if status_id == 0:
723 return ''
724
725 self._EnsureStatusCacheEntry(cnxn, project_id)
726 (status_id_to_name, _status_name_to_id,
727 _closed_status_ids) = self.status_cache.GetItem(project_id)
728
729 return status_id_to_name.get(status_id)
730
731 def LookupStatusID(self, cnxn, project_id, status, autocreate=True):
732 """Look up a status ID for the given status string.
733
734 Args:
735 cnxn: connection to SQL database.
736 project_id: int ID of the project where the statuses are defined.
737 status: status string.
738 autocreate: if not already in the DB, store it and generate a new ID.
739
740 Returns:
741 The status ID for the given status string, or None.
742 """
743 if not status:
744 return None
745
746 self._EnsureStatusCacheEntry(cnxn, project_id)
747 (_status_id_to_name, status_name_to_id,
748 _closed_status_ids) = self.status_cache.GetItem(project_id)
749 if status.lower() in status_name_to_id:
750 return status_name_to_id[status.lower()]
751
752 if autocreate:
753 logging.info('No status %r is known in project %d, so intern it.',
754 status, project_id)
755 status_id = self.statusdef_tbl.InsertRow(
756 cnxn, project_id=project_id, status=status)
757 self.status_row_2lc.InvalidateKeys(cnxn, [project_id])
758 self.status_cache.Invalidate(cnxn, project_id)
759 return status_id
760
761 return None # It was not found and we don't want to create it.
762
763 def LookupStatusIDs(self, cnxn, project_id, statuses):
764 """Look up several status IDs for the given status strings.
765
766 Args:
767 cnxn: connection to SQL database.
768 project_id: int ID of the project where the statuses are defined.
769 statuses: list of status strings.
770
771 Returns:
772 A list of int status IDs.
773 """
774 result = []
775 for stat in statuses:
776 status_id = self.LookupStatusID(cnxn, project_id, stat, autocreate=False)
777 if status_id:
778 result.append(status_id)
779
780 return result
781
782 def LookupClosedStatusIDs(self, cnxn, project_id):
783 """Return the IDs of closed statuses defined in the given project."""
784 self._EnsureStatusCacheEntry(cnxn, project_id)
785 (_status_id_to_name, _status_name_to_id,
786 closed_status_ids) = self.status_cache.GetItem(project_id)
787
788 return closed_status_ids
789
790 def LookupClosedStatusIDsAnyProject(self, cnxn):
791 """Return the IDs of closed statuses defined in any project."""
792 status_id_rows = self.statusdef_tbl.Select(
793 cnxn, cols=['id'], means_open=False)
794 status_ids = [row[0] for row in status_id_rows]
795 return status_ids
796
797 def LookupStatusIDsAnyProject(self, cnxn, status):
798 """Return the IDs of statues with the given name in any project."""
799 status_id_rows = self.statusdef_tbl.Select(
800 cnxn, cols=['id'], status=status)
801 status_ids = [row[0] for row in status_id_rows]
802 return status_ids
803
804 # TODO(jrobbins): regex matching for status values.
805
806 ### Issue tracker configuration objects
807
808 def GetProjectConfigs(self, cnxn, project_ids, use_cache=True):
809 # type: (MonorailConnection, Collection[int], Optional[bool])
810 # -> Mapping[int, ProjectConfig]
811 """Get several project issue config objects."""
812 config_dict, missed_ids = self.config_2lc.GetAll(
813 cnxn, project_ids, use_cache=use_cache)
814 if missed_ids:
815 raise exceptions.NoSuchProjectException()
816 return config_dict
817
818 def GetProjectConfig(self, cnxn, project_id, use_cache=True):
819 """Load a ProjectIssueConfig for the specified project from the database.
820
821 Args:
822 cnxn: connection to SQL database.
823 project_id: int ID of the current project.
824 use_cache: if False, always hit the database.
825
826 Returns:
827 A ProjectIssueConfig describing how the issue tracker in the specified
828 project is configured. Projects only have a stored ProjectIssueConfig if
829 a project owner has edited the configuration. Other projects use a
830 default configuration.
831 """
832 config_dict = self.GetProjectConfigs(
833 cnxn, [project_id], use_cache=use_cache)
834 return config_dict[project_id]
835
836 def StoreConfig(self, cnxn, config):
837 """Update an issue config in the database.
838
839 Args:
840 cnxn: connection to SQL database.
841 config: ProjectIssueConfig PB to update.
842 """
843 # TODO(jrobbins): Convert default template index values into foreign
844 # key references. Updating an entire config might require (1) adding
845 # new templates, (2) updating the config with new foreign key values,
846 # and finally (3) deleting only the specific templates that should be
847 # deleted.
848 self.projectissueconfig_tbl.InsertRow(
849 cnxn, replace=True,
850 project_id=config.project_id,
851 statuses_offer_merge=' '.join(config.statuses_offer_merge),
852 exclusive_label_prefixes=' '.join(config.exclusive_label_prefixes),
853 default_template_for_developers=config.default_template_for_developers,
854 default_template_for_users=config.default_template_for_users,
855 default_col_spec=config.default_col_spec,
856 default_sort_spec=config.default_sort_spec,
857 default_x_attr=config.default_x_attr,
858 default_y_attr=config.default_y_attr,
859 member_default_query=config.member_default_query,
860 custom_issue_entry_url=config.custom_issue_entry_url,
861 commit=False)
862
863 self._UpdateWellKnownLabels(cnxn, config)
864 self._UpdateWellKnownStatuses(cnxn, config)
865 self._UpdateApprovals(cnxn, config)
866 cnxn.Commit()
867
868 def _UpdateWellKnownLabels(self, cnxn, config):
869 """Update the labels part of a project's issue configuration.
870
871 Args:
872 cnxn: connection to SQL database.
873 config: ProjectIssueConfig PB to update in the DB.
874 """
875 update_labeldef_rows = []
876 new_labeldef_rows = []
877 labels_seen = set()
878 for rank, wkl in enumerate(config.well_known_labels):
879 # Prevent duplicate key errors
880 if wkl.label in labels_seen:
881 raise exceptions.InputException('Defined label "%s" twice' % wkl.label)
882 labels_seen.add(wkl.label)
883 # We must specify label ID when replacing, otherwise a new ID is made.
884 label_id = self.LookupLabelID(
885 cnxn, config.project_id, wkl.label, autocreate=False)
886 if label_id:
887 row = (label_id, config.project_id, rank, wkl.label,
888 wkl.label_docstring, wkl.deprecated)
889 update_labeldef_rows.append(row)
890 else:
891 row = (
892 config.project_id, rank, wkl.label, wkl.label_docstring,
893 wkl.deprecated)
894 new_labeldef_rows.append(row)
895
896 self.labeldef_tbl.Update(
897 cnxn, {'rank': None}, project_id=config.project_id, commit=False)
898 self.labeldef_tbl.InsertRows(
899 cnxn, LABELDEF_COLS, update_labeldef_rows, replace=True, commit=False)
900 self.labeldef_tbl.InsertRows(
901 cnxn, LABELDEF_COLS[1:], new_labeldef_rows, commit=False)
902 self.label_row_2lc.InvalidateKeys(cnxn, [config.project_id])
903 self.label_cache.Invalidate(cnxn, config.project_id)
904
905 def _UpdateWellKnownStatuses(self, cnxn, config):
906 """Update the status part of a project's issue configuration.
907
908 Args:
909 cnxn: connection to SQL database.
910 config: ProjectIssueConfig PB to update in the DB.
911 """
912 update_statusdef_rows = []
913 new_statusdef_rows = []
914 for rank, wks in enumerate(config.well_known_statuses):
915 # We must specify label ID when replacing, otherwise a new ID is made.
916 status_id = self.LookupStatusID(cnxn, config.project_id, wks.status,
917 autocreate=False)
918 if status_id is not None:
919 row = (status_id, config.project_id, rank, wks.status,
920 bool(wks.means_open), wks.status_docstring, wks.deprecated)
921 update_statusdef_rows.append(row)
922 else:
923 row = (config.project_id, rank, wks.status,
924 bool(wks.means_open), wks.status_docstring, wks.deprecated)
925 new_statusdef_rows.append(row)
926
927 self.statusdef_tbl.Update(
928 cnxn, {'rank': None}, project_id=config.project_id, commit=False)
929 self.statusdef_tbl.InsertRows(
930 cnxn, STATUSDEF_COLS, update_statusdef_rows, replace=True,
931 commit=False)
932 self.statusdef_tbl.InsertRows(
933 cnxn, STATUSDEF_COLS[1:], new_statusdef_rows, commit=False)
934 self.status_row_2lc.InvalidateKeys(cnxn, [config.project_id])
935 self.status_cache.Invalidate(cnxn, config.project_id)
936
937 def _UpdateApprovals(self, cnxn, config):
938 """Update the approvals part of a project's issue configuration.
939
940 Args:
941 cnxn: connection to SQL database.
942 config: ProjectIssueConfig PB to update in the DB.
943 """
944 ids_to_field_def = {fd.field_id: fd for fd in config.field_defs}
945 for approval_def in config.approval_defs:
946 try:
947 approval_fd = ids_to_field_def[approval_def.approval_id]
948 if approval_fd.field_type != tracker_pb2.FieldTypes.APPROVAL_TYPE:
949 raise exceptions.InvalidFieldTypeException()
950 except KeyError:
951 raise exceptions.NoSuchFieldDefException()
952
953 self.approvaldef2approver_tbl.Delete(
954 cnxn, approval_id=approval_def.approval_id, commit=False)
955
956 self.approvaldef2approver_tbl.InsertRows(
957 cnxn, APPROVALDEF2APPROVER_COLS,
958 [(approval_def.approval_id, approver_id, config.project_id) for
959 approver_id in approval_def.approver_ids],
960 commit=False)
961
962 self.approvaldef2survey_tbl.Delete(
963 cnxn, approval_id=approval_def.approval_id, commit=False)
964 self.approvaldef2survey_tbl.InsertRow(
965 cnxn, approval_id=approval_def.approval_id,
966 survey=approval_def.survey, project_id=config.project_id,
967 commit=False)
968
969 def UpdateConfig(
970 self, cnxn, project, well_known_statuses=None,
971 statuses_offer_merge=None, well_known_labels=None,
972 excl_label_prefixes=None, default_template_for_developers=None,
973 default_template_for_users=None, list_prefs=None, restrict_to_known=None,
974 approval_defs=None):
975 """Update project's issue tracker configuration with the given info.
976
977 Args:
978 cnxn: connection to SQL database.
979 project: the project in which to update the issue tracker config.
980 well_known_statuses: [(status_name, docstring, means_open, deprecated),..]
981 statuses_offer_merge: list of status values that trigger UI to merge.
982 well_known_labels: [(label_name, docstring, deprecated),...]
983 excl_label_prefixes: list of prefix strings. Each issue should
984 have only one label with each of these prefixed.
985 default_template_for_developers: int ID of template to use for devs.
986 default_template_for_users: int ID of template to use for non-members.
987 list_prefs: defaults for columns and sorting.
988 restrict_to_known: optional bool to allow project owners
989 to limit issue status and label values to only the well-known ones.
990 approval_defs: [(approval_id, approver_ids, survey), ..]
991
992 Returns:
993 The updated ProjectIssueConfig PB.
994 """
995 project_id = project.project_id
996 project_config = self.GetProjectConfig(cnxn, project_id, use_cache=False)
997
998 if well_known_statuses is not None:
999 tracker_bizobj.SetConfigStatuses(project_config, well_known_statuses)
1000
1001 if statuses_offer_merge is not None:
1002 project_config.statuses_offer_merge = statuses_offer_merge
1003
1004 if well_known_labels is not None:
1005 tracker_bizobj.SetConfigLabels(project_config, well_known_labels)
1006
1007 if excl_label_prefixes is not None:
1008 project_config.exclusive_label_prefixes = excl_label_prefixes
1009
1010 if approval_defs is not None:
1011 tracker_bizobj.SetConfigApprovals(project_config, approval_defs)
1012
1013 if default_template_for_developers is not None:
1014 project_config.default_template_for_developers = (
1015 default_template_for_developers)
1016 if default_template_for_users is not None:
1017 project_config.default_template_for_users = default_template_for_users
1018
1019 if list_prefs:
1020 (default_col_spec, default_sort_spec, default_x_attr, default_y_attr,
1021 member_default_query) = list_prefs
1022 project_config.default_col_spec = default_col_spec
1023 project_config.default_col_spec = default_col_spec
1024 project_config.default_sort_spec = default_sort_spec
1025 project_config.default_x_attr = default_x_attr
1026 project_config.default_y_attr = default_y_attr
1027 project_config.member_default_query = member_default_query
1028
1029 if restrict_to_known is not None:
1030 project_config.restrict_to_known = restrict_to_known
1031
1032 self.StoreConfig(cnxn, project_config)
1033 self.config_2lc.InvalidateKeys(cnxn, [project_id])
1034 self.InvalidateMemcacheForEntireProject(project_id)
1035 # Invalidate all issue caches in all frontends to clear out
1036 # sorting.art_values_cache which now has wrong sort orders.
1037 cache_manager = self.config_2lc.cache.cache_manager
1038 cache_manager.StoreInvalidateAll(cnxn, 'issue')
1039
1040 return project_config
1041
1042 def ExpungeConfig(self, cnxn, project_id):
1043 """Completely delete the specified project config from the database."""
1044 logging.info('expunging the config for %r', project_id)
1045 self.statusdef_tbl.Delete(cnxn, project_id=project_id)
1046 self.labeldef_tbl.Delete(cnxn, project_id=project_id)
1047 self.projectissueconfig_tbl.Delete(cnxn, project_id=project_id)
1048
1049 self.config_2lc.InvalidateKeys(cnxn, [project_id])
1050
1051 def ExpungeUsersInConfigs(self, cnxn, user_ids, limit=None):
1052 """Wipes specified users from the configs system.
1053
1054 This method will not commit the operation. This method will
1055 not make changes to in-memory data.
1056 """
1057 self.component2admin_tbl.Delete(
1058 cnxn, admin_id=user_ids, commit=False, limit=limit)
1059 self.component2cc_tbl.Delete(
1060 cnxn, cc_id=user_ids, commit=False, limit=limit)
1061 self.componentdef_tbl.Update(
1062 cnxn, {'creator_id': framework_constants.DELETED_USER_ID},
1063 creator_id=user_ids, commit=False, limit=limit)
1064 self.componentdef_tbl.Update(
1065 cnxn, {'modifier_id': framework_constants.DELETED_USER_ID},
1066 modifier_id=user_ids, commit=False, limit=limit)
1067 self.fielddef2admin_tbl.Delete(
1068 cnxn, admin_id=user_ids, commit=False, limit=limit)
1069 self.fielddef2editor_tbl.Delete(
1070 cnxn, editor_id=user_ids, commit=False, limit=limit)
1071 self.approvaldef2approver_tbl.Delete(
1072 cnxn, approver_id=user_ids, commit=False, limit=limit)
1073
1074 ### Custom field definitions
1075
1076 def CreateFieldDef(
1077 self,
1078 cnxn,
1079 project_id,
1080 field_name,
1081 field_type_str,
1082 applic_type,
1083 applic_pred,
1084 is_required,
1085 is_niche,
1086 is_multivalued,
1087 min_value,
1088 max_value,
1089 regex,
1090 needs_member,
1091 needs_perm,
1092 grants_perm,
1093 notify_on,
1094 date_action_str,
1095 docstring,
1096 admin_ids,
1097 editor_ids,
1098 approval_id=None,
1099 is_phase_field=False,
1100 is_restricted_field=False):
1101 """Create a new field definition with the given info.
1102
1103 Args:
1104 cnxn: connection to SQL database.
1105 project_id: int ID of the current project.
1106 field_name: name of the new custom field.
1107 field_type_str: string identifying the type of the custom field.
1108 applic_type: string specifying issue type the field is applicable to.
1109 applic_pred: string condition to test if the field is applicable.
1110 is_required: True if the field should be required on issues.
1111 is_niche: True if the field is not initially offered for editing, so users
1112 must click to reveal such special-purpose or experimental fields.
1113 is_multivalued: True if the field can occur multiple times on one issue.
1114 min_value: optional validation for int_type fields.
1115 max_value: optional validation for int_type fields.
1116 regex: optional validation for str_type fields.
1117 needs_member: optional validation for user_type fields.
1118 needs_perm: optional validation for user_type fields.
1119 grants_perm: optional string for perm to grant any user named in field.
1120 notify_on: int enum of when to notify users named in field.
1121 date_action_str: string saying who to notify when a date arrives.
1122 docstring: string describing this field.
1123 admin_ids: list of additional user IDs who can edit this field def.
1124 editor_ids: list of additional user IDs
1125 who can edit a restricted field value.
1126 approval_id: field_id of approval field this field belongs to.
1127 is_phase_field: True if field should only be associated with issue phases.
1128 is_restricted_field: True if field has its edition restricted.
1129
1130 Returns:
1131 Integer field_id of the new field definition.
1132 """
1133 field_id = self.fielddef_tbl.InsertRow(
1134 cnxn,
1135 project_id=project_id,
1136 field_name=field_name,
1137 field_type=field_type_str,
1138 applicable_type=applic_type,
1139 applicable_predicate=applic_pred,
1140 is_required=is_required,
1141 is_niche=is_niche,
1142 is_multivalued=is_multivalued,
1143 min_value=min_value,
1144 max_value=max_value,
1145 regex=regex,
1146 needs_member=needs_member,
1147 needs_perm=needs_perm,
1148 grants_perm=grants_perm,
1149 notify_on=NOTIFY_ON_ENUM[notify_on],
1150 date_action=date_action_str,
1151 docstring=docstring,
1152 approval_id=approval_id,
1153 is_phase_field=is_phase_field,
1154 is_restricted_field=is_restricted_field,
1155 commit=False)
1156 self.fielddef2admin_tbl.InsertRows(
1157 cnxn, FIELDDEF2ADMIN_COLS,
1158 [(field_id, admin_id) for admin_id in admin_ids],
1159 commit=False)
1160 self.fielddef2editor_tbl.InsertRows(
1161 cnxn,
1162 FIELDDEF2EDITOR_COLS,
1163 [(field_id, editor_id) for editor_id in editor_ids],
1164 commit=False)
1165 cnxn.Commit()
1166 self.config_2lc.InvalidateKeys(cnxn, [project_id])
1167 self.field_row_2lc.InvalidateKeys(cnxn, [project_id])
1168 self.InvalidateMemcacheForEntireProject(project_id)
1169 return field_id
1170
1171 def _DeserializeFields(self, def_rows):
1172 """Convert field defs into bi-directional mappings of names and IDs."""
1173 field_id_to_name = {
1174 field_id: field
1175 for field_id, _pid, _rank, field, _doc in def_rows}
1176 field_name_to_id = {
1177 field.lower(): field_id
1178 for field_id, field in field_id_to_name.items()}
1179
1180 return field_id_to_name, field_name_to_id
1181
1182 def GetFieldDefRows(self, cnxn, project_id):
1183 """Get SQL result rows for all fields used in the specified project."""
1184 pids_to_field_rows, misses = self.field_row_2lc.GetAll(cnxn, [project_id])
1185 assert not misses
1186 return pids_to_field_rows[project_id]
1187
1188 def _EnsureFieldCacheEntry(self, cnxn, project_id):
1189 """Make sure that self.field_cache has an entry for project_id."""
1190 if not self.field_cache.HasItem(project_id):
1191 def_rows = self.GetFieldDefRows(cnxn, project_id)
1192 self.field_cache.CacheItem(
1193 project_id, self._DeserializeFields(def_rows))
1194
1195 def LookupField(self, cnxn, project_id, field_id):
1196 """Lookup a field string given the field_id.
1197
1198 Args:
1199 cnxn: connection to SQL database.
1200 project_id: int ID of the project where the label is defined or used.
1201 field_id: int field ID.
1202
1203 Returns:
1204 Field name string for the given field_id, or None.
1205 """
1206 self._EnsureFieldCacheEntry(cnxn, project_id)
1207 field_id_to_name, _field_name_to_id = self.field_cache.GetItem(
1208 project_id)
1209 return field_id_to_name.get(field_id)
1210
1211 def LookupFieldID(self, cnxn, project_id, field):
1212 """Look up a field ID.
1213
1214 Args:
1215 cnxn: connection to SQL database.
1216 project_id: int ID of the project where the fields are defined.
1217 field: field string.
1218
1219 Returns:
1220 The field ID for the given field string.
1221 """
1222 self._EnsureFieldCacheEntry(cnxn, project_id)
1223 _field_id_to_name, field_name_to_id = self.field_cache.GetItem(
1224 project_id)
1225 return field_name_to_id.get(field.lower())
1226
1227 def SoftDeleteFieldDefs(self, cnxn, project_id, field_ids):
1228 """Mark the specified field as deleted, it will be reaped later."""
1229 self.fielddef_tbl.Update(cnxn, {'is_deleted': True}, id=field_ids)
1230 self.config_2lc.InvalidateKeys(cnxn, [project_id])
1231 self.InvalidateMemcacheForEntireProject(project_id)
1232
1233 # TODO(jrobbins): GC deleted field defs after field values are gone.
1234
1235 def UpdateFieldDef(
1236 self,
1237 cnxn,
1238 project_id,
1239 field_id,
1240 field_name=None,
1241 applicable_type=None,
1242 applicable_predicate=None,
1243 is_required=None,
1244 is_niche=None,
1245 is_multivalued=None,
1246 min_value=None,
1247 max_value=None,
1248 regex=None,
1249 needs_member=None,
1250 needs_perm=None,
1251 grants_perm=None,
1252 notify_on=None,
1253 date_action=None,
1254 docstring=None,
1255 admin_ids=None,
1256 editor_ids=None,
1257 is_restricted_field=None):
1258 """Update the specified field definition."""
1259 new_values = {}
1260 if field_name is not None:
1261 new_values['field_name'] = field_name
1262 if applicable_type is not None:
1263 new_values['applicable_type'] = applicable_type
1264 if applicable_predicate is not None:
1265 new_values['applicable_predicate'] = applicable_predicate
1266 if is_required is not None:
1267 new_values['is_required'] = bool(is_required)
1268 if is_niche is not None:
1269 new_values['is_niche'] = bool(is_niche)
1270 if is_multivalued is not None:
1271 new_values['is_multivalued'] = bool(is_multivalued)
1272 if min_value is not None:
1273 new_values['min_value'] = min_value
1274 if max_value is not None:
1275 new_values['max_value'] = max_value
1276 if regex is not None:
1277 new_values['regex'] = regex
1278 if needs_member is not None:
1279 new_values['needs_member'] = needs_member
1280 if needs_perm is not None:
1281 new_values['needs_perm'] = needs_perm
1282 if grants_perm is not None:
1283 new_values['grants_perm'] = grants_perm
1284 if notify_on is not None:
1285 new_values['notify_on'] = NOTIFY_ON_ENUM[notify_on]
1286 if date_action is not None:
1287 new_values['date_action'] = date_action
1288 if docstring is not None:
1289 new_values['docstring'] = docstring
1290 if is_restricted_field is not None:
1291 new_values['is_restricted_field'] = is_restricted_field
1292
1293 self.fielddef_tbl.Update(cnxn, new_values, id=field_id, commit=False)
1294 if admin_ids is not None:
1295 self.fielddef2admin_tbl.Delete(cnxn, field_id=field_id, commit=False)
1296 self.fielddef2admin_tbl.InsertRows(
1297 cnxn,
1298 FIELDDEF2ADMIN_COLS, [(field_id, admin_id) for admin_id in admin_ids],
1299 commit=False)
1300 if editor_ids is not None:
1301 self.fielddef2editor_tbl.Delete(cnxn, field_id=field_id, commit=False)
1302 self.fielddef2editor_tbl.InsertRows(
1303 cnxn,
1304 FIELDDEF2EDITOR_COLS,
1305 [(field_id, editor_id) for editor_id in editor_ids],
1306 commit=False)
1307 cnxn.Commit()
1308 self.config_2lc.InvalidateKeys(cnxn, [project_id])
1309 self.InvalidateMemcacheForEntireProject(project_id)
1310
1311 ### Component definitions
1312
1313 def FindMatchingComponentIDsAnyProject(self, cnxn, path_list, exact=True):
1314 """Look up component IDs across projects.
1315
1316 Args:
1317 cnxn: connection to SQL database.
1318 path_list: list of component path prefixes.
1319 exact: set to False to include all components which have one of the
1320 given paths as their ancestor, instead of exact matches.
1321
1322 Returns:
1323 A list of component IDs of component's whose paths match path_list.
1324 """
1325 or_terms = []
1326 args = []
1327 for path in path_list:
1328 or_terms.append('path = %s')
1329 args.append(path)
1330
1331 if not exact:
1332 for path in path_list:
1333 or_terms.append('path LIKE %s')
1334 args.append(path + '>%')
1335
1336 cond_str = '(' + ' OR '.join(or_terms) + ')'
1337 rows = self.componentdef_tbl.Select(
1338 cnxn, cols=['id'], where=[(cond_str, args)])
1339 return [row[0] for row in rows]
1340
1341 def CreateComponentDef(
1342 self, cnxn, project_id, path, docstring, deprecated, admin_ids, cc_ids,
1343 created, creator_id, label_ids):
1344 """Create a new component definition with the given info.
1345
1346 Args:
1347 cnxn: connection to SQL database.
1348 project_id: int ID of the current project.
1349 path: string pathname of the new component.
1350 docstring: string describing this field.
1351 deprecated: whether or not this should be autocompleted
1352 admin_ids: list of int IDs of users who can administer.
1353 cc_ids: list of int IDs of users to notify when an issue in
1354 this component is updated.
1355 created: timestamp this component was created at.
1356 creator_id: int ID of user who created this component.
1357 label_ids: list of int IDs of labels to add when an issue is
1358 in this component.
1359
1360 Returns:
1361 Integer component_id of the new component definition.
1362 """
1363 component_id = self.componentdef_tbl.InsertRow(
1364 cnxn, project_id=project_id, path=path, docstring=docstring,
1365 deprecated=deprecated, created=created, creator_id=creator_id,
1366 commit=False)
1367 self.component2admin_tbl.InsertRows(
1368 cnxn, COMPONENT2ADMIN_COLS,
1369 [(component_id, admin_id) for admin_id in admin_ids],
1370 commit=False)
1371 self.component2cc_tbl.InsertRows(
1372 cnxn, COMPONENT2CC_COLS,
1373 [(component_id, cc_id) for cc_id in cc_ids],
1374 commit=False)
1375 self.component2label_tbl.InsertRows(
1376 cnxn, COMPONENT2LABEL_COLS,
1377 [(component_id, label_id) for label_id in label_ids],
1378 commit=False)
1379 cnxn.Commit()
1380 self.config_2lc.InvalidateKeys(cnxn, [project_id])
1381 self.InvalidateMemcacheForEntireProject(project_id)
1382 return component_id
1383
1384 def UpdateComponentDef(
1385 self, cnxn, project_id, component_id, path=None, docstring=None,
1386 deprecated=None, admin_ids=None, cc_ids=None, created=None,
1387 creator_id=None, modified=None, modifier_id=None,
1388 label_ids=None):
1389 """Update the specified component definition."""
1390 new_values = {}
1391 if path is not None:
1392 assert path
1393 new_values['path'] = path
1394 if docstring is not None:
1395 new_values['docstring'] = docstring
1396 if deprecated is not None:
1397 new_values['deprecated'] = deprecated
1398 if created is not None:
1399 new_values['created'] = created
1400 if creator_id is not None:
1401 new_values['creator_id'] = creator_id
1402 if modified is not None:
1403 new_values['modified'] = modified
1404 if modifier_id is not None:
1405 new_values['modifier_id'] = modifier_id
1406
1407 if admin_ids is not None:
1408 self.component2admin_tbl.Delete(
1409 cnxn, component_id=component_id, commit=False)
1410 self.component2admin_tbl.InsertRows(
1411 cnxn, COMPONENT2ADMIN_COLS,
1412 [(component_id, admin_id) for admin_id in admin_ids],
1413 commit=False)
1414
1415 if cc_ids is not None:
1416 self.component2cc_tbl.Delete(
1417 cnxn, component_id=component_id, commit=False)
1418 self.component2cc_tbl.InsertRows(
1419 cnxn, COMPONENT2CC_COLS,
1420 [(component_id, cc_id) for cc_id in cc_ids],
1421 commit=False)
1422
1423 if label_ids is not None:
1424 self.component2label_tbl.Delete(
1425 cnxn, component_id=component_id, commit=False)
1426 self.component2label_tbl.InsertRows(
1427 cnxn, COMPONENT2LABEL_COLS,
1428 [(component_id, label_id) for label_id in label_ids],
1429 commit=False)
1430
1431 self.componentdef_tbl.Update(
1432 cnxn, new_values, id=component_id, commit=False)
1433 cnxn.Commit()
1434 self.config_2lc.InvalidateKeys(cnxn, [project_id])
1435 self.InvalidateMemcacheForEntireProject(project_id)
1436
1437 def DeleteComponentDef(self, cnxn, project_id, component_id):
1438 """Delete the specified component definition."""
1439 self.componentdef_tbl.Update(
1440 cnxn, {'is_deleted': True}, id=component_id, commit=False)
1441
1442 cnxn.Commit()
1443 self.config_2lc.InvalidateKeys(cnxn, [project_id])
1444 self.InvalidateMemcacheForEntireProject(project_id)
1445
1446 ### Memcache management
1447
1448 def InvalidateMemcache(self, issues, key_prefix=''):
1449 """Delete the memcache entries for issues and their project-shard pairs."""
1450 memcache.delete_multi(
1451 [str(issue.issue_id) for issue in issues], key_prefix='issue:',
1452 seconds=5, namespace=settings.memcache_namespace)
1453 project_shards = set(
1454 (issue.project_id, issue.issue_id % settings.num_logical_shards)
1455 for issue in issues)
1456 self._InvalidateMemcacheShards(project_shards, key_prefix=key_prefix)
1457
1458 def _InvalidateMemcacheShards(self, project_shards, key_prefix=''):
1459 """Delete the memcache entries for the given project-shard pairs.
1460
1461 Deleting these rows does not delete the actual cached search results
1462 but it does mean that they will be considered stale and thus not used.
1463
1464 Args:
1465 project_shards: list of (pid, sid) pairs.
1466 key_prefix: string to pass as memcache key prefix.
1467 """
1468 cache_entries = ['%d;%d' % ps for ps in project_shards]
1469 # Whenever any project is invalidated, also invalidate the 'all'
1470 # entry that is used in site-wide searches.
1471 shard_id_set = {sid for _pid, sid in project_shards}
1472 cache_entries.extend(('all;%d' % sid) for sid in shard_id_set)
1473
1474 memcache.delete_multi(
1475 cache_entries, key_prefix=key_prefix,
1476 namespace=settings.memcache_namespace)
1477
1478 def InvalidateMemcacheForEntireProject(self, project_id):
1479 """Delete the memcache entries for all searches in a project."""
1480 project_shards = set((project_id, shard_id)
1481 for shard_id in range(settings.num_logical_shards))
1482 self._InvalidateMemcacheShards(project_shards)
1483 memcache.delete_multi(
1484 [str(project_id)], key_prefix='config:',
1485 namespace=settings.memcache_namespace)
1486 memcache.delete_multi(
1487 [str(project_id)], key_prefix='label_rows:',
1488 namespace=settings.memcache_namespace)
1489 memcache.delete_multi(
1490 [str(project_id)], key_prefix='status_rows:',
1491 namespace=settings.memcache_namespace)
1492 memcache.delete_multi(
1493 [str(project_id)], key_prefix='field_rows:',
1494 namespace=settings.memcache_namespace)
1495
1496 def UsersInvolvedInConfig(self, config, project_templates):
1497 """Return a set of all user IDs referenced in the ProjectIssueConfig."""
1498 result = set()
1499 for template in project_templates:
1500 result.update(tracker_bizobj.UsersInvolvedInTemplate(template))
1501 for field in config.field_defs:
1502 result.update(field.admin_ids)
1503 result.update(field.editor_ids)
1504 # TODO(jrobbins): add component owners, auto-cc, and admins.
1505 return result