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