| # Copyright 2016 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Classes and functions for persistence of issue tracker configuration. |
| |
| This module provides functions to get, update, create, and (in some |
| cases) delete each type of business object. It provides a logical |
| persistence layer on top of an SQL database. |
| |
| Business objects are described in tracker_pb2.py and tracker_bizobj.py. |
| """ |
| from __future__ import print_function |
| from __future__ import division |
| from __future__ import absolute_import |
| |
| import collections |
| import logging |
| |
| from google.appengine.api import memcache |
| |
| import settings |
| from framework import exceptions |
| from framework import framework_constants |
| from framework import sql |
| from mrproto import tracker_pb2 |
| from services import caches |
| from services import project_svc |
| from tracker import tracker_bizobj |
| from tracker import tracker_constants |
| |
| |
| PROJECTISSUECONFIG_TABLE_NAME = 'ProjectIssueConfig' |
| LABELDEF_TABLE_NAME = 'LabelDef' |
| FIELDDEF_TABLE_NAME = 'FieldDef' |
| FIELDDEF2ADMIN_TABLE_NAME = 'FieldDef2Admin' |
| FIELDDEF2EDITOR_TABLE_NAME = 'FieldDef2Editor' |
| COMPONENTDEF_TABLE_NAME = 'ComponentDef' |
| COMPONENT2ADMIN_TABLE_NAME = 'Component2Admin' |
| COMPONENT2CC_TABLE_NAME = 'Component2Cc' |
| COMPONENT2LABEL_TABLE_NAME = 'Component2Label' |
| STATUSDEF_TABLE_NAME = 'StatusDef' |
| APPROVALDEF2APPROVER_TABLE_NAME = 'ApprovalDef2Approver' |
| APPROVALDEF2SURVEY_TABLE_NAME = 'ApprovalDef2Survey' |
| |
| PROJECTISSUECONFIG_COLS = [ |
| 'project_id', 'statuses_offer_merge', 'exclusive_label_prefixes', |
| 'default_template_for_developers', 'default_template_for_users', |
| 'default_col_spec', 'default_sort_spec', 'default_x_attr', |
| 'default_y_attr', 'member_default_query', 'custom_issue_entry_url'] |
| STATUSDEF_COLS = [ |
| 'id', 'project_id', 'rank', 'status', 'means_open', 'docstring', |
| 'deprecated'] |
| LABELDEF_COLS = [ |
| 'id', 'project_id', 'rank', 'label', 'docstring', 'deprecated'] |
| FIELDDEF_COLS = [ |
| 'id', 'project_id', 'rank', 'field_name', 'field_type', 'applicable_type', |
| 'applicable_predicate', 'is_required', 'is_niche', 'is_multivalued', |
| 'min_value', 'max_value', 'regex', 'needs_member', 'needs_perm', |
| 'grants_perm', 'notify_on', 'date_action', 'docstring', 'is_deleted', |
| 'approval_id', 'is_phase_field', 'is_restricted_field' |
| ] |
| FIELDDEF2ADMIN_COLS = ['field_id', 'admin_id'] |
| FIELDDEF2EDITOR_COLS = ['field_id', 'editor_id'] |
| COMPONENTDEF_COLS = ['id', 'project_id', 'path', 'docstring', 'deprecated', |
| 'created', 'creator_id', 'modified', 'modifier_id'] |
| COMPONENT2ADMIN_COLS = ['component_id', 'admin_id'] |
| COMPONENT2CC_COLS = ['component_id', 'cc_id'] |
| COMPONENT2LABEL_COLS = ['component_id', 'label_id'] |
| APPROVALDEF2APPROVER_COLS = ['approval_id', 'approver_id', 'project_id'] |
| APPROVALDEF2SURVEY_COLS = ['approval_id', 'survey', 'project_id'] |
| |
| NOTIFY_ON_ENUM = ['never', 'any_comment'] |
| DATE_ACTION_ENUM = ['no_action', 'ping_owner_only', 'ping_participants'] |
| |
| # Some projects have tons of label rows, so we retrieve them in shards |
| # to avoid huge DB results or exceeding the memcache size limit. |
| LABEL_ROW_SHARDS = 10 |
| |
| |
| class LabelRowTwoLevelCache(caches.AbstractTwoLevelCache): |
| """Class to manage RAM and memcache for label rows. |
| |
| Label rows exist for every label used in a project, even those labels |
| that were added to issues in an ad hoc way without being defined in the |
| config ahead of time. |
| |
| The set of all labels in a project can be very large, so we shard them |
| into 10 parts so that each part can be cached in memcache with < 1MB. |
| """ |
| |
| def __init__(self, cache_manager, config_service): |
| super(LabelRowTwoLevelCache, self).__init__( |
| cache_manager, 'project', 'label_rows:', None) |
| self.config_service = config_service |
| |
| def _MakeCache(self, cache_manager, kind, max_size=None): |
| """Make the RAM cache and registier it with the cache_manager.""" |
| return caches.ShardedRamCache( |
| cache_manager, kind, max_size=max_size, num_shards=LABEL_ROW_SHARDS) |
| |
| def _DeserializeLabelRows(self, label_def_rows): |
| """Convert DB result rows into a dict {project_id: [row, ...]}.""" |
| result_dict = collections.defaultdict(list) |
| for label_id, project_id, rank, label, docstr, deprecated in label_def_rows: |
| shard_id = label_id % LABEL_ROW_SHARDS |
| result_dict[(project_id, shard_id)].append( |
| (label_id, project_id, rank, label, docstr, deprecated)) |
| |
| return result_dict |
| |
| def FetchItems(self, cnxn, keys): |
| """On RAM and memcache miss, hit the database.""" |
| # Make sure that every requested project is represented in the result |
| label_rows_dict = {} |
| for key in keys: |
| label_rows_dict.setdefault(key, []) |
| |
| for project_id, shard_id in keys: |
| shard_clause = [('id %% %s = %s', [LABEL_ROW_SHARDS, shard_id])] |
| |
| label_def_rows = self.config_service.labeldef_tbl.Select( |
| cnxn, cols=LABELDEF_COLS, project_id=project_id, |
| where=shard_clause) |
| label_rows_dict.update(self._DeserializeLabelRows(label_def_rows)) |
| |
| for rows_in_shard in label_rows_dict.values(): |
| rows_in_shard.sort(key=lambda row: (row[2] or 0, row[3]), reverse=True) |
| |
| return label_rows_dict |
| |
| def InvalidateKeys(self, cnxn, project_ids): |
| """Drop the given keys from both RAM and memcache.""" |
| self.cache.InvalidateKeys(cnxn, project_ids) |
| memcache.delete_multi( |
| [ |
| self._KeyToStr((project_id, shard_id)) |
| for project_id in project_ids |
| for shard_id in range(0, LABEL_ROW_SHARDS) |
| ], |
| seconds=5, |
| key_prefix=self.prefix, |
| namespace=settings.memcache_namespace) |
| |
| def InvalidateAllKeys(self, cnxn, project_ids): |
| """Drop the given keys from memcache and invalidate all keys in RAM. |
| |
| Useful for avoiding inserting many rows into the Invalidate table when |
| invalidating a large group of keys all at once. Only use when necessary. |
| """ |
| self.cache.InvalidateAll(cnxn) |
| memcache.delete_multi( |
| [ |
| self._KeyToStr((project_id, shard_id)) |
| for project_id in project_ids |
| for shard_id in range(0, LABEL_ROW_SHARDS) |
| ], |
| seconds=5, |
| key_prefix=self.prefix, |
| namespace=settings.memcache_namespace) |
| |
| def _KeyToStr(self, key): |
| """Convert our tuple IDs to strings for use as memcache keys.""" |
| project_id, shard_id = key |
| return '%d-%d' % (project_id, shard_id) |
| |
| def _StrToKey(self, key_str): |
| """Convert memcache keys back to the tuples that we use as IDs.""" |
| project_id_str, shard_id_str = key_str.split('-') |
| return int(project_id_str), int(shard_id_str) |
| |
| |
| class StatusRowTwoLevelCache(caches.AbstractTwoLevelCache): |
| """Class to manage RAM and memcache for status rows.""" |
| |
| def __init__(self, cache_manager, config_service): |
| super(StatusRowTwoLevelCache, self).__init__( |
| cache_manager, 'project', 'status_rows:', None) |
| self.config_service = config_service |
| |
| def _DeserializeStatusRows(self, def_rows): |
| """Convert status definition rows into {project_id: [row, ...]}.""" |
| result_dict = collections.defaultdict(list) |
| for (status_id, project_id, rank, status, |
| means_open, docstr, deprecated) in def_rows: |
| result_dict[project_id].append( |
| (status_id, project_id, rank, status, means_open, docstr, deprecated)) |
| |
| return result_dict |
| |
| def FetchItems(self, cnxn, keys): |
| """On cache miss, get status definition rows from the DB.""" |
| status_def_rows = self.config_service.statusdef_tbl.Select( |
| cnxn, cols=STATUSDEF_COLS, project_id=keys, |
| order_by=[('rank DESC', []), ('status DESC', [])]) |
| status_rows_dict = self._DeserializeStatusRows(status_def_rows) |
| |
| # Make sure that every requested project is represented in the result |
| for project_id in keys: |
| status_rows_dict.setdefault(project_id, []) |
| |
| return status_rows_dict |
| |
| |
| class FieldRowTwoLevelCache(caches.AbstractTwoLevelCache): |
| """Class to manage RAM and memcache for field rows. |
| |
| Field rows exist for every field used in a project, since they cannot be |
| created through ad-hoc means. |
| """ |
| |
| def __init__(self, cache_manager, config_service): |
| super(FieldRowTwoLevelCache, self).__init__( |
| cache_manager, 'project', 'field_rows:', None) |
| self.config_service = config_service |
| |
| def _DeserializeFieldRows(self, field_def_rows): |
| """Convert DB result rows into a dict {project_id: [row, ...]}.""" |
| result_dict = collections.defaultdict(list) |
| # TODO: Actually process the rest of the items. |
| for (field_id, project_id, rank, field_name, _field_type, _applicable_type, |
| _applicable_predicate, _is_required, _is_niche, _is_multivalued, |
| _min_value, _max_value, _regex, _needs_member, _needs_perm, |
| _grants_perm, _notify_on, _date_action, docstring, _is_deleted, |
| _approval_id, _is_phase_field, _is_restricted_field) in field_def_rows: |
| result_dict[project_id].append( |
| (field_id, project_id, rank, field_name, docstring)) |
| |
| return result_dict |
| |
| def FetchItems(self, cnxn, keys): |
| """On RAM and memcache miss, hit the database.""" |
| field_def_rows = self.config_service.fielddef_tbl.Select( |
| cnxn, cols=FIELDDEF_COLS, project_id=keys, |
| order_by=[('rank DESC', []), ('field_name DESC', [])]) |
| field_rows_dict = self._DeserializeFieldRows(field_def_rows) |
| |
| # Make sure that every requested project is represented in the result |
| for project_id in keys: |
| field_rows_dict.setdefault(project_id, []) |
| |
| return field_rows_dict |
| |
| |
| class ConfigTwoLevelCache(caches.AbstractTwoLevelCache): |
| """Class to manage RAM and memcache for IssueProjectConfig PBs.""" |
| |
| def __init__(self, cache_manager, config_service): |
| super(ConfigTwoLevelCache, self).__init__( |
| cache_manager, 'project', 'config:', tracker_pb2.ProjectIssueConfig) |
| self.config_service = config_service |
| |
| def _UnpackProjectIssueConfig(self, config_row): |
| """Partially construct a config object using info from a DB row.""" |
| (project_id, statuses_offer_merge, exclusive_label_prefixes, |
| default_template_for_developers, default_template_for_users, |
| default_col_spec, default_sort_spec, default_x_attr, default_y_attr, |
| member_default_query, custom_issue_entry_url) = config_row |
| config = tracker_pb2.ProjectIssueConfig() |
| config.project_id = project_id |
| config.statuses_offer_merge.extend(statuses_offer_merge.split()) |
| config.exclusive_label_prefixes.extend(exclusive_label_prefixes.split()) |
| config.default_template_for_developers = default_template_for_developers |
| config.default_template_for_users = default_template_for_users |
| config.default_col_spec = default_col_spec |
| config.default_sort_spec = default_sort_spec |
| config.default_x_attr = default_x_attr |
| config.default_y_attr = default_y_attr |
| config.member_default_query = member_default_query |
| if custom_issue_entry_url is not None: |
| config.custom_issue_entry_url = custom_issue_entry_url |
| |
| return config |
| |
| def _UnpackFieldDef(self, fielddef_row): |
| """Partially construct a FieldDef object using info from a DB row.""" |
| ( |
| field_id, project_id, _rank, field_name, field_type, applic_type, |
| applic_pred, is_required, is_niche, is_multivalued, min_value, |
| max_value, regex, needs_member, needs_perm, grants_perm, notify_on_str, |
| date_action_str, docstring, is_deleted, approval_id, is_phase_field, |
| is_restricted_field) = fielddef_row |
| if notify_on_str == 'any_comment': |
| notify_on = tracker_pb2.NotifyTriggers.ANY_COMMENT |
| else: |
| notify_on = tracker_pb2.NotifyTriggers.NEVER |
| try: |
| date_action = DATE_ACTION_ENUM.index(date_action_str) |
| except ValueError: |
| date_action = DATE_ACTION_ENUM.index('no_action') |
| |
| return tracker_bizobj.MakeFieldDef( |
| field_id, project_id, field_name, |
| tracker_pb2.FieldTypes(field_type.upper()), applic_type, applic_pred, |
| is_required, is_niche, is_multivalued, min_value, max_value, regex, |
| needs_member, needs_perm, grants_perm, notify_on, date_action, |
| docstring, is_deleted, approval_id, is_phase_field, is_restricted_field) |
| |
| def _UnpackComponentDef( |
| self, cd_row, component2admin_rows, component2cc_rows, |
| component2label_rows): |
| """Partially construct a FieldDef object using info from a DB row.""" |
| (component_id, project_id, path, docstring, deprecated, created, |
| creator_id, modified, modifier_id) = cd_row |
| cd = tracker_bizobj.MakeComponentDef( |
| component_id, project_id, path, docstring, deprecated, |
| [admin_id for comp_id, admin_id in component2admin_rows |
| if comp_id == component_id], |
| [cc_id for comp_id, cc_id in component2cc_rows |
| if comp_id == component_id], |
| created, creator_id, |
| modified=modified, modifier_id=modifier_id, |
| label_ids=[label_id for comp_id, label_id in component2label_rows |
| if comp_id == component_id]) |
| |
| return cd |
| |
| def _DeserializeIssueConfigs( |
| self, config_rows, statusdef_rows, labeldef_rows, fielddef_rows, |
| fielddef2admin_rows, fielddef2editor_rows, componentdef_rows, |
| component2admin_rows, component2cc_rows, component2label_rows, |
| approvaldef2approver_rows, approvaldef2survey_rows): |
| """Convert the given row tuples into a dict of ProjectIssueConfig PBs.""" |
| result_dict = {} |
| fielddef_dict = {} |
| approvaldef_dict = {} |
| |
| for config_row in config_rows: |
| config = self._UnpackProjectIssueConfig(config_row) |
| result_dict[config.project_id] = config |
| |
| for statusdef_row in statusdef_rows: |
| (_, project_id, _rank, status, |
| means_open, docstring, deprecated) = statusdef_row |
| if project_id in result_dict: |
| wks = tracker_pb2.StatusDef( |
| status=status, means_open=bool(means_open), |
| status_docstring=docstring or '', deprecated=bool(deprecated)) |
| result_dict[project_id].well_known_statuses.append(wks) |
| |
| for labeldef_row in labeldef_rows: |
| _, project_id, _rank, label, docstring, deprecated = labeldef_row |
| if project_id in result_dict: |
| wkl = tracker_pb2.LabelDef( |
| label=label, label_docstring=docstring or '', |
| deprecated=bool(deprecated)) |
| result_dict[project_id].well_known_labels.append(wkl) |
| |
| for approver_row in approvaldef2approver_rows: |
| approval_id, approver_id, project_id = approver_row |
| if project_id in result_dict: |
| approval_def = approvaldef_dict.get(approval_id) |
| if approval_def is None: |
| approval_def = tracker_pb2.ApprovalDef( |
| approval_id=approval_id) |
| result_dict[project_id].approval_defs.append(approval_def) |
| approvaldef_dict[approval_id] = approval_def |
| approval_def.approver_ids.append(approver_id) |
| |
| for survey_row in approvaldef2survey_rows: |
| approval_id, survey, project_id = survey_row |
| if project_id in result_dict: |
| approval_def = approvaldef_dict.get(approval_id) |
| if approval_def is None: |
| approval_def = tracker_pb2.ApprovalDef( |
| approval_id=approval_id) |
| result_dict[project_id].approval_defs.append(approval_def) |
| approvaldef_dict[approval_id] = approval_def |
| approval_def.survey = survey |
| |
| for fd_row in fielddef_rows: |
| fd = self._UnpackFieldDef(fd_row) |
| result_dict[fd.project_id].field_defs.append(fd) |
| fielddef_dict[fd.field_id] = fd |
| |
| for fd2admin_row in fielddef2admin_rows: |
| field_id, admin_id = fd2admin_row |
| fd = fielddef_dict.get(field_id) |
| if fd: |
| fd.admin_ids.append(admin_id) |
| |
| for fd2editor_row in fielddef2editor_rows: |
| field_id, editor_id = fd2editor_row |
| fd = fielddef_dict.get(field_id) |
| if fd: |
| fd.editor_ids.append(editor_id) |
| |
| for cd_row in componentdef_rows: |
| cd = self._UnpackComponentDef( |
| cd_row, component2admin_rows, component2cc_rows, component2label_rows) |
| result_dict[cd.project_id].component_defs.append(cd) |
| |
| return result_dict |
| |
| def _FetchConfigs(self, cnxn, project_ids): |
| """On RAM and memcache miss, hit the database.""" |
| config_rows = self.config_service.projectissueconfig_tbl.Select( |
| cnxn, cols=PROJECTISSUECONFIG_COLS, project_id=project_ids) |
| statusdef_rows = self.config_service.statusdef_tbl.Select( |
| cnxn, cols=STATUSDEF_COLS, project_id=project_ids, |
| where=[('rank IS NOT NULL', [])], order_by=[('rank', [])]) |
| |
| labeldef_rows = self.config_service.labeldef_tbl.Select( |
| cnxn, cols=LABELDEF_COLS, project_id=project_ids, |
| where=[('rank IS NOT NULL', [])], order_by=[('rank', [])]) |
| |
| approver_rows = self.config_service.approvaldef2approver_tbl.Select( |
| cnxn, cols=APPROVALDEF2APPROVER_COLS, project_id=project_ids) |
| survey_rows = self.config_service.approvaldef2survey_tbl.Select( |
| cnxn, cols=APPROVALDEF2SURVEY_COLS, project_id=project_ids) |
| |
| # TODO(jrobbins): For now, sort by field name, but someday allow admins |
| # to adjust the rank to group and order field definitions logically. |
| fielddef_rows = self.config_service.fielddef_tbl.Select( |
| cnxn, cols=FIELDDEF_COLS, project_id=project_ids, |
| order_by=[('field_name', [])]) |
| field_ids = [row[0] for row in fielddef_rows] |
| fielddef2admin_rows = [] |
| fielddef2editor_rows = [] |
| if field_ids: |
| fielddef2admin_rows = self.config_service.fielddef2admin_tbl.Select( |
| cnxn, cols=FIELDDEF2ADMIN_COLS, field_id=field_ids) |
| fielddef2editor_rows = self.config_service.fielddef2editor_tbl.Select( |
| cnxn, cols=FIELDDEF2EDITOR_COLS, field_id=field_ids) |
| |
| componentdef_rows = self.config_service.componentdef_tbl.Select( |
| cnxn, cols=COMPONENTDEF_COLS, project_id=project_ids, |
| is_deleted=False, order_by=[('path', [])]) |
| component_ids = [cd_row[0] for cd_row in componentdef_rows] |
| component2admin_rows = [] |
| component2cc_rows = [] |
| component2label_rows = [] |
| if component_ids: |
| component2admin_rows = self.config_service.component2admin_tbl.Select( |
| cnxn, cols=COMPONENT2ADMIN_COLS, component_id=component_ids) |
| component2cc_rows = self.config_service.component2cc_tbl.Select( |
| cnxn, cols=COMPONENT2CC_COLS, component_id=component_ids) |
| component2label_rows = self.config_service.component2label_tbl.Select( |
| cnxn, cols=COMPONENT2LABEL_COLS, component_id=component_ids) |
| |
| retrieved_dict = self._DeserializeIssueConfigs( |
| config_rows, statusdef_rows, labeldef_rows, fielddef_rows, |
| fielddef2admin_rows, fielddef2editor_rows, componentdef_rows, |
| component2admin_rows, component2cc_rows, component2label_rows, |
| approver_rows, survey_rows) |
| return retrieved_dict |
| |
| def FetchItems(self, cnxn, keys): |
| """On RAM and memcache miss, hit the database.""" |
| retrieved_dict = self._FetchConfigs(cnxn, keys) |
| |
| # Any projects which don't have stored configs should use a default |
| # config instead. |
| for project_id in keys: |
| if project_id not in retrieved_dict: |
| config = tracker_bizobj.MakeDefaultProjectIssueConfig(project_id) |
| retrieved_dict[project_id] = config |
| |
| return retrieved_dict |
| |
| |
| class ConfigService(object): |
| """The persistence layer for Monorail's issue tracker configuration data.""" |
| |
| def __init__(self, cache_manager): |
| """Initialize this object so that it is ready to use. |
| |
| Args: |
| cache_manager: manages local caches with distributed invalidation. |
| """ |
| self.projectissueconfig_tbl = sql.SQLTableManager( |
| PROJECTISSUECONFIG_TABLE_NAME) |
| self.statusdef_tbl = sql.SQLTableManager(STATUSDEF_TABLE_NAME) |
| self.labeldef_tbl = sql.SQLTableManager(LABELDEF_TABLE_NAME) |
| self.fielddef_tbl = sql.SQLTableManager(FIELDDEF_TABLE_NAME) |
| self.fielddef2admin_tbl = sql.SQLTableManager(FIELDDEF2ADMIN_TABLE_NAME) |
| self.fielddef2editor_tbl = sql.SQLTableManager(FIELDDEF2EDITOR_TABLE_NAME) |
| self.componentdef_tbl = sql.SQLTableManager(COMPONENTDEF_TABLE_NAME) |
| self.component2admin_tbl = sql.SQLTableManager(COMPONENT2ADMIN_TABLE_NAME) |
| self.component2cc_tbl = sql.SQLTableManager(COMPONENT2CC_TABLE_NAME) |
| self.component2label_tbl = sql.SQLTableManager(COMPONENT2LABEL_TABLE_NAME) |
| self.approvaldef2approver_tbl = sql.SQLTableManager( |
| APPROVALDEF2APPROVER_TABLE_NAME) |
| self.approvaldef2survey_tbl = sql.SQLTableManager( |
| APPROVALDEF2SURVEY_TABLE_NAME) |
| |
| self.config_2lc = ConfigTwoLevelCache(cache_manager, self) |
| self.label_row_2lc = LabelRowTwoLevelCache(cache_manager, self) |
| self.label_cache = caches.RamCache(cache_manager, 'project') |
| self.status_row_2lc = StatusRowTwoLevelCache(cache_manager, self) |
| self.status_cache = caches.RamCache(cache_manager, 'project') |
| self.field_row_2lc = FieldRowTwoLevelCache(cache_manager, self) |
| self.field_cache = caches.RamCache(cache_manager, 'project') |
| |
| ### Label lookups |
| |
| def GetLabelDefRows(self, cnxn, project_id, use_cache=True): |
| """Get SQL result rows for all labels used in the specified project.""" |
| result = [] |
| for shard_id in range(0, LABEL_ROW_SHARDS): |
| key = (project_id, shard_id) |
| pids_to_label_rows_shard, _misses = self.label_row_2lc.GetAll( |
| cnxn, [key], use_cache=use_cache) |
| result.extend(pids_to_label_rows_shard[key]) |
| # Sort in python to reduce DB load and integrate results from shards. |
| # row[2] is rank, row[3] is label name. |
| result.sort(key=lambda row: (row[2] or 0, row[3]), reverse=True) |
| return result |
| |
| def GetLabelDefRowsAnyProject(self, cnxn, where=None): |
| """Get all LabelDef rows for the whole site. Used in whole-site search.""" |
| # TODO(jrobbins): maybe add caching for these too. |
| label_def_rows = self.labeldef_tbl.Select( |
| cnxn, cols=LABELDEF_COLS, where=where, |
| order_by=[('rank DESC', []), ('label DESC', [])]) |
| return label_def_rows |
| |
| def _DeserializeLabels(self, def_rows): |
| """Convert label defs into bi-directional mappings of names and IDs.""" |
| label_id_to_name = { |
| label_id: label for |
| label_id, _pid, _rank, label, _doc, _deprecated |
| in def_rows} |
| label_name_to_id = { |
| label.lower(): label_id |
| for label_id, label in label_id_to_name.items()} |
| |
| return label_id_to_name, label_name_to_id |
| |
| def _EnsureLabelCacheEntry(self, cnxn, project_id, use_cache=True): |
| """Make sure that self.label_cache has an entry for project_id.""" |
| if not use_cache or not self.label_cache.HasItem(project_id): |
| def_rows = self.GetLabelDefRows(cnxn, project_id, use_cache=use_cache) |
| self.label_cache.CacheItem(project_id, self._DeserializeLabels(def_rows)) |
| |
| def LookupLabel(self, cnxn, project_id, label_id): |
| """Lookup a label string given the label_id. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the project where the label is defined or used. |
| label_id: int label ID. |
| |
| Returns: |
| Label name string for the given label_id, or None. |
| """ |
| self._EnsureLabelCacheEntry(cnxn, project_id) |
| label_id_to_name, _label_name_to_id = self.label_cache.GetItem( |
| project_id) |
| if label_id in label_id_to_name: |
| return label_id_to_name[label_id] |
| |
| logging.info('Label %r not found. Getting fresh from DB.', label_id) |
| self._EnsureLabelCacheEntry(cnxn, project_id, use_cache=False) |
| label_id_to_name, _label_name_to_id = self.label_cache.GetItem( |
| project_id) |
| return label_id_to_name.get(label_id) |
| |
| def LookupLabelID( |
| self, cnxn, project_id, label, autocreate=True, case_sensitive=False): |
| """Look up a label ID, optionally interning it. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the project where the statuses are defined. |
| label: label string. |
| autocreate: if not already in the DB, store it and generate a new ID. |
| case_sensitive: if label lookup is case sensivite |
| |
| Returns: |
| The label ID for the given label string. |
| """ |
| self._EnsureLabelCacheEntry(cnxn, project_id) |
| _label_id_to_name, label_name_to_id = self.label_cache.GetItem( |
| project_id) |
| |
| label_lower = label.lower() if not case_sensitive else label |
| if label_lower in label_name_to_id: |
| return label_name_to_id[label_lower] |
| |
| if not case_sensitive: |
| where = [('LOWER(label) = %s', [label_lower])] |
| else: |
| where = [('label = %s', [label])] |
| |
| # Double check that the label does not already exist in the DB. |
| rows = self.labeldef_tbl.Select( |
| cnxn, cols=['id'], project_id=project_id, where=where, limit=1) |
| logging.info('Double checking for %r gave %r', label, rows) |
| if rows: |
| self.label_row_2lc.cache.LocalInvalidate(project_id) |
| self.label_cache.LocalInvalidate(project_id) |
| return rows[0][0] |
| |
| if autocreate: |
| logging.info('No label %r is known in project %d, so intern it.', |
| label, project_id) |
| label_id = self.labeldef_tbl.InsertRow( |
| cnxn, project_id=project_id, label=label) |
| self.label_row_2lc.InvalidateKeys(cnxn, [project_id]) |
| self.label_cache.Invalidate(cnxn, project_id) |
| return label_id |
| |
| return None # It was not found and we don't want to create it. |
| |
| def LookupLabelIDs(self, cnxn, project_id, labels, autocreate=False): |
| """Look up several label IDs. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the project where the statuses are defined. |
| labels: list of label strings. |
| autocreate: if not already in the DB, store it and generate a new ID. |
| |
| Returns: |
| Returns a list of int label IDs for the given label strings. |
| """ |
| result = [] |
| for lab in labels: |
| label_id = self.LookupLabelID( |
| cnxn, project_id, lab, autocreate=autocreate) |
| if label_id is not None: |
| result.append(label_id) |
| |
| return result |
| |
| def LookupIDsOfLabelsMatching(self, cnxn, project_id, regex): |
| """Look up the IDs of all labels in a project that match the regex. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the project where the statuses are defined. |
| regex: regular expression object to match against the label strings. |
| |
| Returns: |
| List of label IDs for labels that match the regex. |
| """ |
| self._EnsureLabelCacheEntry(cnxn, project_id) |
| label_id_to_name, _label_name_to_id = self.label_cache.GetItem( |
| project_id) |
| result = [label_id for label_id, label in label_id_to_name.items() |
| if regex.match(label)] |
| |
| return result |
| |
| def LookupLabelIDsAnyProject(self, cnxn, label): |
| """Return the IDs of labels with the given name in any project. |
| |
| Args: |
| cnxn: connection to SQL database. |
| label: string label to look up. Case sensitive. |
| |
| Returns: |
| A list of int label IDs of all labels matching the given string. |
| """ |
| # TODO(jrobbins): maybe add caching for these too. |
| label_id_rows = self.labeldef_tbl.Select( |
| cnxn, cols=['id'], label=label) |
| label_ids = [row[0] for row in label_id_rows] |
| return label_ids |
| |
| def LookupIDsOfLabelsMatchingAnyProject(self, cnxn, regex): |
| """Return the IDs of matching labels in any project.""" |
| label_rows = self.labeldef_tbl.Select( |
| cnxn, cols=['id', 'label']) |
| matching_ids = [ |
| label_id for label_id, label in label_rows if regex.match(label)] |
| return matching_ids |
| |
| ### Status lookups |
| |
| def GetStatusDefRows(self, cnxn, project_id): |
| """Return a list of status definition rows for the specified project.""" |
| pids_to_status_rows, misses = self.status_row_2lc.GetAll( |
| cnxn, [project_id]) |
| assert not misses |
| return pids_to_status_rows[project_id] |
| |
| def GetStatusDefRowsAnyProject(self, cnxn): |
| """Return all status definition rows on the whole site.""" |
| # TODO(jrobbins): maybe add caching for these too. |
| status_def_rows = self.statusdef_tbl.Select( |
| cnxn, cols=STATUSDEF_COLS, |
| order_by=[('rank DESC', []), ('status DESC', [])]) |
| return status_def_rows |
| |
| def _DeserializeStatuses(self, def_rows): |
| """Convert status defs into bi-directional mappings of names and IDs.""" |
| status_id_to_name = { |
| status_id: status |
| for (status_id, _pid, _rank, status, _means_open, |
| _doc, _deprecated) in def_rows} |
| status_name_to_id = { |
| status.lower(): status_id |
| for status_id, status in status_id_to_name.items()} |
| closed_status_ids = [ |
| status_id |
| for (status_id, _pid, _rank, _status, means_open, |
| _doc, _deprecated) in def_rows |
| if means_open == 0] # Only 0 means closed. NULL/None means open. |
| |
| return status_id_to_name, status_name_to_id, closed_status_ids |
| |
| def _EnsureStatusCacheEntry(self, cnxn, project_id): |
| """Make sure that self.status_cache has an entry for project_id.""" |
| if not self.status_cache.HasItem(project_id): |
| def_rows = self.GetStatusDefRows(cnxn, project_id) |
| self.status_cache.CacheItem( |
| project_id, self._DeserializeStatuses(def_rows)) |
| |
| def LookupStatus(self, cnxn, project_id, status_id): |
| """Look up a status string for the given status ID. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the project where the statuses are defined. |
| status_id: int ID of the status value. |
| |
| Returns: |
| A status string, or None. |
| """ |
| if status_id == 0: |
| return '' |
| |
| self._EnsureStatusCacheEntry(cnxn, project_id) |
| (status_id_to_name, _status_name_to_id, |
| _closed_status_ids) = self.status_cache.GetItem(project_id) |
| |
| return status_id_to_name.get(status_id) |
| |
| def LookupStatusID(self, cnxn, project_id, status, autocreate=True): |
| """Look up a status ID for the given status string. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the project where the statuses are defined. |
| status: status string. |
| autocreate: if not already in the DB, store it and generate a new ID. |
| |
| Returns: |
| The status ID for the given status string, or None. |
| """ |
| if not status: |
| return None |
| |
| self._EnsureStatusCacheEntry(cnxn, project_id) |
| (_status_id_to_name, status_name_to_id, |
| _closed_status_ids) = self.status_cache.GetItem(project_id) |
| if status.lower() in status_name_to_id: |
| return status_name_to_id[status.lower()] |
| |
| if autocreate: |
| logging.info('No status %r is known in project %d, so intern it.', |
| status, project_id) |
| status_id = self.statusdef_tbl.InsertRow( |
| cnxn, project_id=project_id, status=status) |
| self.status_row_2lc.InvalidateKeys(cnxn, [project_id]) |
| self.status_cache.Invalidate(cnxn, project_id) |
| return status_id |
| |
| return None # It was not found and we don't want to create it. |
| |
| def LookupStatusIDs(self, cnxn, project_id, statuses): |
| """Look up several status IDs for the given status strings. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the project where the statuses are defined. |
| statuses: list of status strings. |
| |
| Returns: |
| A list of int status IDs. |
| """ |
| result = [] |
| for stat in statuses: |
| status_id = self.LookupStatusID(cnxn, project_id, stat, autocreate=False) |
| if status_id: |
| result.append(status_id) |
| |
| return result |
| |
| def LookupClosedStatusIDs(self, cnxn, project_id): |
| """Return the IDs of closed statuses defined in the given project.""" |
| self._EnsureStatusCacheEntry(cnxn, project_id) |
| (_status_id_to_name, _status_name_to_id, |
| closed_status_ids) = self.status_cache.GetItem(project_id) |
| |
| return closed_status_ids |
| |
| def LookupClosedStatusIDsAnyProject(self, cnxn): |
| """Return the IDs of closed statuses defined in any project.""" |
| status_id_rows = self.statusdef_tbl.Select( |
| cnxn, cols=['id'], means_open=False) |
| status_ids = [row[0] for row in status_id_rows] |
| return status_ids |
| |
| def LookupStatusIDsAnyProject(self, cnxn, status): |
| """Return the IDs of statues with the given name in any project.""" |
| status_id_rows = self.statusdef_tbl.Select( |
| cnxn, cols=['id'], status=status) |
| status_ids = [row[0] for row in status_id_rows] |
| return status_ids |
| |
| # TODO(jrobbins): regex matching for status values. |
| |
| ### Issue tracker configuration objects |
| |
| def GetProjectConfigs(self, cnxn, project_ids, use_cache=True): |
| # type: (MonorailConnection, Collection[int], Optional[bool]) |
| # -> Mapping[int, ProjectConfig] |
| """Get several project issue config objects.""" |
| config_dict, missed_ids = self.config_2lc.GetAll( |
| cnxn, project_ids, use_cache=use_cache) |
| if missed_ids: |
| raise exceptions.NoSuchProjectException() |
| return config_dict |
| |
| def GetProjectConfig(self, cnxn, project_id, use_cache=True): |
| """Load a ProjectIssueConfig for the specified project from the database. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the current project. |
| use_cache: if False, always hit the database. |
| |
| Returns: |
| A ProjectIssueConfig describing how the issue tracker in the specified |
| project is configured. Projects only have a stored ProjectIssueConfig if |
| a project owner has edited the configuration. Other projects use a |
| default configuration. |
| """ |
| config_dict = self.GetProjectConfigs( |
| cnxn, [project_id], use_cache=use_cache) |
| return config_dict[project_id] |
| |
| def StoreConfig(self, cnxn, config): |
| """Update an issue config in the database. |
| |
| Args: |
| cnxn: connection to SQL database. |
| config: ProjectIssueConfig PB to update. |
| """ |
| # TODO(jrobbins): Convert default template index values into foreign |
| # key references. Updating an entire config might require (1) adding |
| # new templates, (2) updating the config with new foreign key values, |
| # and finally (3) deleting only the specific templates that should be |
| # deleted. |
| self.projectissueconfig_tbl.InsertRow( |
| cnxn, replace=True, |
| project_id=config.project_id, |
| statuses_offer_merge=' '.join(config.statuses_offer_merge), |
| exclusive_label_prefixes=' '.join(config.exclusive_label_prefixes), |
| default_template_for_developers=config.default_template_for_developers, |
| default_template_for_users=config.default_template_for_users, |
| default_col_spec=config.default_col_spec, |
| default_sort_spec=config.default_sort_spec, |
| default_x_attr=config.default_x_attr, |
| default_y_attr=config.default_y_attr, |
| member_default_query=config.member_default_query, |
| custom_issue_entry_url=config.custom_issue_entry_url, |
| commit=False) |
| |
| self._UpdateWellKnownLabels(cnxn, config) |
| self._UpdateWellKnownStatuses(cnxn, config) |
| self._UpdateApprovals(cnxn, config) |
| cnxn.Commit() |
| |
| def _UpdateWellKnownLabels(self, cnxn, config): |
| """Update the labels part of a project's issue configuration. |
| |
| Args: |
| cnxn: connection to SQL database. |
| config: ProjectIssueConfig PB to update in the DB. |
| """ |
| update_labeldef_rows = [] |
| new_labeldef_rows = [] |
| labels_seen = set() |
| for rank, wkl in enumerate(config.well_known_labels): |
| # Prevent duplicate key errors |
| if wkl.label in labels_seen: |
| raise exceptions.InputException('Defined label "%s" twice' % wkl.label) |
| labels_seen.add(wkl.label) |
| # We must specify label ID when replacing, otherwise a new ID is made. |
| label_id = self.LookupLabelID( |
| cnxn, config.project_id, wkl.label, autocreate=False) |
| if label_id: |
| row = (label_id, config.project_id, rank, wkl.label, |
| wkl.label_docstring, wkl.deprecated) |
| update_labeldef_rows.append(row) |
| else: |
| row = ( |
| config.project_id, rank, wkl.label, wkl.label_docstring, |
| wkl.deprecated) |
| new_labeldef_rows.append(row) |
| |
| self.labeldef_tbl.Update( |
| cnxn, {'rank': None}, project_id=config.project_id, commit=False) |
| self.labeldef_tbl.InsertRows( |
| cnxn, LABELDEF_COLS, update_labeldef_rows, replace=True, commit=False) |
| self.labeldef_tbl.InsertRows( |
| cnxn, LABELDEF_COLS[1:], new_labeldef_rows, commit=False) |
| self.label_row_2lc.InvalidateKeys(cnxn, [config.project_id]) |
| self.label_cache.Invalidate(cnxn, config.project_id) |
| |
| def _UpdateWellKnownStatuses(self, cnxn, config): |
| """Update the status part of a project's issue configuration. |
| |
| Args: |
| cnxn: connection to SQL database. |
| config: ProjectIssueConfig PB to update in the DB. |
| """ |
| update_statusdef_rows = [] |
| new_statusdef_rows = [] |
| for rank, wks in enumerate(config.well_known_statuses): |
| # We must specify label ID when replacing, otherwise a new ID is made. |
| status_id = self.LookupStatusID(cnxn, config.project_id, wks.status, |
| autocreate=False) |
| if status_id is not None: |
| row = (status_id, config.project_id, rank, wks.status, |
| bool(wks.means_open), wks.status_docstring, wks.deprecated) |
| update_statusdef_rows.append(row) |
| else: |
| row = (config.project_id, rank, wks.status, |
| bool(wks.means_open), wks.status_docstring, wks.deprecated) |
| new_statusdef_rows.append(row) |
| |
| self.statusdef_tbl.Update( |
| cnxn, {'rank': None}, project_id=config.project_id, commit=False) |
| self.statusdef_tbl.InsertRows( |
| cnxn, STATUSDEF_COLS, update_statusdef_rows, replace=True, |
| commit=False) |
| self.statusdef_tbl.InsertRows( |
| cnxn, STATUSDEF_COLS[1:], new_statusdef_rows, commit=False) |
| self.status_row_2lc.InvalidateKeys(cnxn, [config.project_id]) |
| self.status_cache.Invalidate(cnxn, config.project_id) |
| |
| def _UpdateApprovals(self, cnxn, config): |
| """Update the approvals part of a project's issue configuration. |
| |
| Args: |
| cnxn: connection to SQL database. |
| config: ProjectIssueConfig PB to update in the DB. |
| """ |
| ids_to_field_def = {fd.field_id: fd for fd in config.field_defs} |
| for approval_def in config.approval_defs: |
| try: |
| approval_fd = ids_to_field_def[approval_def.approval_id] |
| if approval_fd.field_type != tracker_pb2.FieldTypes.APPROVAL_TYPE: |
| raise exceptions.InvalidFieldTypeException() |
| except KeyError: |
| raise exceptions.NoSuchFieldDefException() |
| |
| self.approvaldef2approver_tbl.Delete( |
| cnxn, approval_id=approval_def.approval_id, commit=False) |
| |
| self.approvaldef2approver_tbl.InsertRows( |
| cnxn, APPROVALDEF2APPROVER_COLS, |
| [(approval_def.approval_id, approver_id, config.project_id) for |
| approver_id in approval_def.approver_ids], |
| commit=False) |
| |
| self.approvaldef2survey_tbl.Delete( |
| cnxn, approval_id=approval_def.approval_id, commit=False) |
| self.approvaldef2survey_tbl.InsertRow( |
| cnxn, approval_id=approval_def.approval_id, |
| survey=approval_def.survey, project_id=config.project_id, |
| commit=False) |
| |
| def UpdateConfig( |
| self, cnxn, project, well_known_statuses=None, |
| statuses_offer_merge=None, well_known_labels=None, |
| excl_label_prefixes=None, default_template_for_developers=None, |
| default_template_for_users=None, list_prefs=None, restrict_to_known=None, |
| approval_defs=None): |
| """Update project's issue tracker configuration with the given info. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project: the project in which to update the issue tracker config. |
| well_known_statuses: [(status_name, docstring, means_open, deprecated),..] |
| statuses_offer_merge: list of status values that trigger UI to merge. |
| well_known_labels: [(label_name, docstring, deprecated),...] |
| excl_label_prefixes: list of prefix strings. Each issue should |
| have only one label with each of these prefixed. |
| default_template_for_developers: int ID of template to use for devs. |
| default_template_for_users: int ID of template to use for non-members. |
| list_prefs: defaults for columns and sorting. |
| restrict_to_known: optional bool to allow project owners |
| to limit issue status and label values to only the well-known ones. |
| approval_defs: [(approval_id, approver_ids, survey), ..] |
| |
| Returns: |
| The updated ProjectIssueConfig PB. |
| """ |
| project_id = project.project_id |
| project_config = self.GetProjectConfig(cnxn, project_id, use_cache=False) |
| |
| if well_known_statuses is not None: |
| tracker_bizobj.SetConfigStatuses(project_config, well_known_statuses) |
| |
| if statuses_offer_merge is not None: |
| project_config.statuses_offer_merge = statuses_offer_merge |
| |
| if well_known_labels is not None: |
| tracker_bizobj.SetConfigLabels(project_config, well_known_labels) |
| |
| if excl_label_prefixes is not None: |
| project_config.exclusive_label_prefixes = excl_label_prefixes |
| |
| if approval_defs is not None: |
| tracker_bizobj.SetConfigApprovals(project_config, approval_defs) |
| |
| if default_template_for_developers is not None: |
| project_config.default_template_for_developers = ( |
| default_template_for_developers) |
| if default_template_for_users is not None: |
| project_config.default_template_for_users = default_template_for_users |
| |
| if list_prefs: |
| (default_col_spec, default_sort_spec, default_x_attr, default_y_attr, |
| member_default_query) = list_prefs |
| project_config.default_col_spec = default_col_spec |
| project_config.default_col_spec = default_col_spec |
| project_config.default_sort_spec = default_sort_spec |
| project_config.default_x_attr = default_x_attr |
| project_config.default_y_attr = default_y_attr |
| project_config.member_default_query = member_default_query |
| |
| if restrict_to_known is not None: |
| project_config.restrict_to_known = restrict_to_known |
| |
| self.StoreConfig(cnxn, project_config) |
| self.config_2lc.InvalidateKeys(cnxn, [project_id]) |
| self.InvalidateMemcacheForEntireProject(project_id) |
| # Invalidate all issue caches in all frontends to clear out |
| # sorting.art_values_cache which now has wrong sort orders. |
| cache_manager = self.config_2lc.cache.cache_manager |
| cache_manager.StoreInvalidateAll(cnxn, 'issue') |
| |
| return project_config |
| |
| def ExpungeConfig(self, cnxn, project_id): |
| """Completely delete the specified project config from the database.""" |
| logging.info('expunging the config for %r', project_id) |
| self.statusdef_tbl.Delete(cnxn, project_id=project_id) |
| self.labeldef_tbl.Delete(cnxn, project_id=project_id) |
| self.projectissueconfig_tbl.Delete(cnxn, project_id=project_id) |
| |
| self.config_2lc.InvalidateKeys(cnxn, [project_id]) |
| |
| def ExpungeUsersInConfigs(self, cnxn, user_ids, limit=None): |
| """Wipes specified users from the configs system. |
| |
| This method will not commit the operation. This method will |
| not make changes to in-memory data. |
| """ |
| self.component2admin_tbl.Delete( |
| cnxn, admin_id=user_ids, commit=False, limit=limit) |
| self.component2cc_tbl.Delete( |
| cnxn, cc_id=user_ids, commit=False, limit=limit) |
| self.componentdef_tbl.Update( |
| cnxn, {'creator_id': framework_constants.DELETED_USER_ID}, |
| creator_id=user_ids, commit=False, limit=limit) |
| self.componentdef_tbl.Update( |
| cnxn, {'modifier_id': framework_constants.DELETED_USER_ID}, |
| modifier_id=user_ids, commit=False, limit=limit) |
| self.fielddef2admin_tbl.Delete( |
| cnxn, admin_id=user_ids, commit=False, limit=limit) |
| self.fielddef2editor_tbl.Delete( |
| cnxn, editor_id=user_ids, commit=False, limit=limit) |
| self.approvaldef2approver_tbl.Delete( |
| cnxn, approver_id=user_ids, commit=False, limit=limit) |
| |
| ### Custom field definitions |
| |
| def CreateFieldDef( |
| self, |
| cnxn, |
| project_id, |
| field_name, |
| field_type_str, |
| applic_type, |
| applic_pred, |
| is_required, |
| is_niche, |
| is_multivalued, |
| min_value, |
| max_value, |
| regex, |
| needs_member, |
| needs_perm, |
| grants_perm, |
| notify_on, |
| date_action_str, |
| docstring, |
| admin_ids, |
| editor_ids, |
| approval_id=None, |
| is_phase_field=False, |
| is_restricted_field=False): |
| """Create a new field definition with the given info. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the current project. |
| field_name: name of the new custom field. |
| field_type_str: string identifying the type of the custom field. |
| applic_type: string specifying issue type the field is applicable to. |
| applic_pred: string condition to test if the field is applicable. |
| is_required: True if the field should be required on issues. |
| is_niche: True if the field is not initially offered for editing, so users |
| must click to reveal such special-purpose or experimental fields. |
| is_multivalued: True if the field can occur multiple times on one issue. |
| min_value: optional validation for int_type fields. |
| max_value: optional validation for int_type fields. |
| regex: optional validation for str_type fields. |
| needs_member: optional validation for user_type fields. |
| needs_perm: optional validation for user_type fields. |
| grants_perm: optional string for perm to grant any user named in field. |
| notify_on: int enum of when to notify users named in field. |
| date_action_str: string saying who to notify when a date arrives. |
| docstring: string describing this field. |
| admin_ids: list of additional user IDs who can edit this field def. |
| editor_ids: list of additional user IDs |
| who can edit a restricted field value. |
| approval_id: field_id of approval field this field belongs to. |
| is_phase_field: True if field should only be associated with issue phases. |
| is_restricted_field: True if field has its edition restricted. |
| |
| Returns: |
| Integer field_id of the new field definition. |
| """ |
| field_id = self.fielddef_tbl.InsertRow( |
| cnxn, |
| project_id=project_id, |
| field_name=field_name, |
| field_type=field_type_str, |
| applicable_type=applic_type, |
| applicable_predicate=applic_pred, |
| is_required=is_required, |
| is_niche=is_niche, |
| is_multivalued=is_multivalued, |
| min_value=min_value, |
| max_value=max_value, |
| regex=regex, |
| needs_member=needs_member, |
| needs_perm=needs_perm, |
| grants_perm=grants_perm, |
| notify_on=NOTIFY_ON_ENUM[notify_on], |
| date_action=date_action_str, |
| docstring=docstring, |
| approval_id=approval_id, |
| is_phase_field=is_phase_field, |
| is_restricted_field=is_restricted_field, |
| commit=False) |
| self.fielddef2admin_tbl.InsertRows( |
| cnxn, FIELDDEF2ADMIN_COLS, |
| [(field_id, admin_id) for admin_id in admin_ids], |
| commit=False) |
| self.fielddef2editor_tbl.InsertRows( |
| cnxn, |
| FIELDDEF2EDITOR_COLS, |
| [(field_id, editor_id) for editor_id in editor_ids], |
| commit=False) |
| cnxn.Commit() |
| self.config_2lc.InvalidateKeys(cnxn, [project_id]) |
| self.field_row_2lc.InvalidateKeys(cnxn, [project_id]) |
| self.InvalidateMemcacheForEntireProject(project_id) |
| return field_id |
| |
| def _DeserializeFields(self, def_rows): |
| """Convert field defs into bi-directional mappings of names and IDs.""" |
| field_id_to_name = { |
| field_id: field |
| for field_id, _pid, _rank, field, _doc in def_rows} |
| field_name_to_id = { |
| field.lower(): field_id |
| for field_id, field in field_id_to_name.items()} |
| |
| return field_id_to_name, field_name_to_id |
| |
| def GetFieldDefRows(self, cnxn, project_id): |
| """Get SQL result rows for all fields used in the specified project.""" |
| pids_to_field_rows, misses = self.field_row_2lc.GetAll(cnxn, [project_id]) |
| assert not misses |
| return pids_to_field_rows[project_id] |
| |
| def _EnsureFieldCacheEntry(self, cnxn, project_id): |
| """Make sure that self.field_cache has an entry for project_id.""" |
| if not self.field_cache.HasItem(project_id): |
| def_rows = self.GetFieldDefRows(cnxn, project_id) |
| self.field_cache.CacheItem( |
| project_id, self._DeserializeFields(def_rows)) |
| |
| def LookupField(self, cnxn, project_id, field_id): |
| """Lookup a field string given the field_id. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the project where the label is defined or used. |
| field_id: int field ID. |
| |
| Returns: |
| Field name string for the given field_id, or None. |
| """ |
| self._EnsureFieldCacheEntry(cnxn, project_id) |
| field_id_to_name, _field_name_to_id = self.field_cache.GetItem( |
| project_id) |
| return field_id_to_name.get(field_id) |
| |
| def LookupFieldID(self, cnxn, project_id, field): |
| """Look up a field ID. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the project where the fields are defined. |
| field: field string. |
| |
| Returns: |
| The field ID for the given field string. |
| """ |
| self._EnsureFieldCacheEntry(cnxn, project_id) |
| _field_id_to_name, field_name_to_id = self.field_cache.GetItem( |
| project_id) |
| return field_name_to_id.get(field.lower()) |
| |
| def SoftDeleteFieldDefs(self, cnxn, project_id, field_ids): |
| """Mark the specified field as deleted, it will be reaped later.""" |
| self.fielddef_tbl.Update(cnxn, {'is_deleted': True}, id=field_ids) |
| self.config_2lc.InvalidateKeys(cnxn, [project_id]) |
| self.InvalidateMemcacheForEntireProject(project_id) |
| |
| # TODO(jrobbins): GC deleted field defs after field values are gone. |
| |
| def UpdateFieldDef( |
| self, |
| cnxn, |
| project_id, |
| field_id, |
| field_name=None, |
| applicable_type=None, |
| applicable_predicate=None, |
| is_required=None, |
| is_niche=None, |
| is_multivalued=None, |
| min_value=None, |
| max_value=None, |
| regex=None, |
| needs_member=None, |
| needs_perm=None, |
| grants_perm=None, |
| notify_on=None, |
| date_action=None, |
| docstring=None, |
| admin_ids=None, |
| editor_ids=None, |
| is_restricted_field=None): |
| """Update the specified field definition.""" |
| new_values = {} |
| if field_name is not None: |
| new_values['field_name'] = field_name |
| if applicable_type is not None: |
| new_values['applicable_type'] = applicable_type |
| if applicable_predicate is not None: |
| new_values['applicable_predicate'] = applicable_predicate |
| if is_required is not None: |
| new_values['is_required'] = bool(is_required) |
| if is_niche is not None: |
| new_values['is_niche'] = bool(is_niche) |
| if is_multivalued is not None: |
| new_values['is_multivalued'] = bool(is_multivalued) |
| if min_value is not None: |
| new_values['min_value'] = min_value |
| if max_value is not None: |
| new_values['max_value'] = max_value |
| if regex is not None: |
| new_values['regex'] = regex |
| if needs_member is not None: |
| new_values['needs_member'] = needs_member |
| if needs_perm is not None: |
| new_values['needs_perm'] = needs_perm |
| if grants_perm is not None: |
| new_values['grants_perm'] = grants_perm |
| if notify_on is not None: |
| new_values['notify_on'] = NOTIFY_ON_ENUM[notify_on] |
| if date_action is not None: |
| new_values['date_action'] = date_action |
| if docstring is not None: |
| new_values['docstring'] = docstring |
| if is_restricted_field is not None: |
| new_values['is_restricted_field'] = is_restricted_field |
| |
| self.fielddef_tbl.Update(cnxn, new_values, id=field_id, commit=False) |
| if admin_ids is not None: |
| self.fielddef2admin_tbl.Delete(cnxn, field_id=field_id, commit=False) |
| self.fielddef2admin_tbl.InsertRows( |
| cnxn, |
| FIELDDEF2ADMIN_COLS, [(field_id, admin_id) for admin_id in admin_ids], |
| commit=False) |
| if editor_ids is not None: |
| self.fielddef2editor_tbl.Delete(cnxn, field_id=field_id, commit=False) |
| self.fielddef2editor_tbl.InsertRows( |
| cnxn, |
| FIELDDEF2EDITOR_COLS, |
| [(field_id, editor_id) for editor_id in editor_ids], |
| commit=False) |
| cnxn.Commit() |
| self.config_2lc.InvalidateKeys(cnxn, [project_id]) |
| self.InvalidateMemcacheForEntireProject(project_id) |
| |
| ### Component definitions |
| |
| def FindMatchingComponentIDsAnyProject(self, cnxn, path_list, exact=True): |
| """Look up component IDs across projects. |
| |
| Args: |
| cnxn: connection to SQL database. |
| path_list: list of component path prefixes. |
| exact: set to False to include all components which have one of the |
| given paths as their ancestor, instead of exact matches. |
| |
| Returns: |
| A list of component IDs of component's whose paths match path_list. |
| """ |
| or_terms = [] |
| args = [] |
| for path in path_list: |
| or_terms.append('path = %s') |
| args.append(path) |
| |
| if not exact: |
| for path in path_list: |
| or_terms.append('path LIKE %s') |
| args.append(path + '>%') |
| |
| cond_str = '(' + ' OR '.join(or_terms) + ')' |
| rows = self.componentdef_tbl.Select( |
| cnxn, cols=['id'], where=[(cond_str, args)]) |
| return [row[0] for row in rows] |
| |
| def CreateComponentDef( |
| self, cnxn, project_id, path, docstring, deprecated, admin_ids, cc_ids, |
| created, creator_id, label_ids): |
| """Create a new component definition with the given info. |
| |
| Args: |
| cnxn: connection to SQL database. |
| project_id: int ID of the current project. |
| path: string pathname of the new component. |
| docstring: string describing this field. |
| deprecated: whether or not this should be autocompleted |
| admin_ids: list of int IDs of users who can administer. |
| cc_ids: list of int IDs of users to notify when an issue in |
| this component is updated. |
| created: timestamp this component was created at. |
| creator_id: int ID of user who created this component. |
| label_ids: list of int IDs of labels to add when an issue is |
| in this component. |
| |
| Returns: |
| Integer component_id of the new component definition. |
| """ |
| component_id = self.componentdef_tbl.InsertRow( |
| cnxn, project_id=project_id, path=path, docstring=docstring, |
| deprecated=deprecated, created=created, creator_id=creator_id, |
| commit=False) |
| self.component2admin_tbl.InsertRows( |
| cnxn, COMPONENT2ADMIN_COLS, |
| [(component_id, admin_id) for admin_id in admin_ids], |
| commit=False) |
| self.component2cc_tbl.InsertRows( |
| cnxn, COMPONENT2CC_COLS, |
| [(component_id, cc_id) for cc_id in cc_ids], |
| commit=False) |
| self.component2label_tbl.InsertRows( |
| cnxn, COMPONENT2LABEL_COLS, |
| [(component_id, label_id) for label_id in label_ids], |
| commit=False) |
| cnxn.Commit() |
| self.config_2lc.InvalidateKeys(cnxn, [project_id]) |
| self.InvalidateMemcacheForEntireProject(project_id) |
| return component_id |
| |
| def UpdateComponentDef( |
| self, cnxn, project_id, component_id, path=None, docstring=None, |
| deprecated=None, admin_ids=None, cc_ids=None, created=None, |
| creator_id=None, modified=None, modifier_id=None, |
| label_ids=None): |
| """Update the specified component definition.""" |
| new_values = {} |
| if path is not None: |
| assert path |
| new_values['path'] = path |
| if docstring is not None: |
| new_values['docstring'] = docstring |
| if deprecated is not None: |
| new_values['deprecated'] = deprecated |
| if created is not None: |
| new_values['created'] = created |
| if creator_id is not None: |
| new_values['creator_id'] = creator_id |
| if modified is not None: |
| new_values['modified'] = modified |
| if modifier_id is not None: |
| new_values['modifier_id'] = modifier_id |
| |
| if admin_ids is not None: |
| self.component2admin_tbl.Delete( |
| cnxn, component_id=component_id, commit=False) |
| self.component2admin_tbl.InsertRows( |
| cnxn, COMPONENT2ADMIN_COLS, |
| [(component_id, admin_id) for admin_id in admin_ids], |
| commit=False) |
| |
| if cc_ids is not None: |
| self.component2cc_tbl.Delete( |
| cnxn, component_id=component_id, commit=False) |
| self.component2cc_tbl.InsertRows( |
| cnxn, COMPONENT2CC_COLS, |
| [(component_id, cc_id) for cc_id in cc_ids], |
| commit=False) |
| |
| if label_ids is not None: |
| self.component2label_tbl.Delete( |
| cnxn, component_id=component_id, commit=False) |
| self.component2label_tbl.InsertRows( |
| cnxn, COMPONENT2LABEL_COLS, |
| [(component_id, label_id) for label_id in label_ids], |
| commit=False) |
| |
| self.componentdef_tbl.Update( |
| cnxn, new_values, id=component_id, commit=False) |
| cnxn.Commit() |
| self.config_2lc.InvalidateKeys(cnxn, [project_id]) |
| self.InvalidateMemcacheForEntireProject(project_id) |
| |
| def DeleteComponentDef(self, cnxn, project_id, component_id): |
| """Delete the specified component definition.""" |
| self.componentdef_tbl.Update( |
| cnxn, {'is_deleted': True}, id=component_id, commit=False) |
| |
| cnxn.Commit() |
| self.config_2lc.InvalidateKeys(cnxn, [project_id]) |
| self.InvalidateMemcacheForEntireProject(project_id) |
| |
| ### Memcache management |
| |
| def InvalidateMemcache(self, issues, key_prefix=''): |
| """Delete the memcache entries for issues and their project-shard pairs.""" |
| memcache.delete_multi( |
| [str(issue.issue_id) for issue in issues], key_prefix='issue:', |
| seconds=5, namespace=settings.memcache_namespace) |
| project_shards = set( |
| (issue.project_id, issue.issue_id % settings.num_logical_shards) |
| for issue in issues) |
| self._InvalidateMemcacheShards(project_shards, key_prefix=key_prefix) |
| |
| def _InvalidateMemcacheShards(self, project_shards, key_prefix=''): |
| """Delete the memcache entries for the given project-shard pairs. |
| |
| Deleting these rows does not delete the actual cached search results |
| but it does mean that they will be considered stale and thus not used. |
| |
| Args: |
| project_shards: list of (pid, sid) pairs. |
| key_prefix: string to pass as memcache key prefix. |
| """ |
| cache_entries = ['%d;%d' % ps for ps in project_shards] |
| # Whenever any project is invalidated, also invalidate the 'all' |
| # entry that is used in site-wide searches. |
| shard_id_set = {sid for _pid, sid in project_shards} |
| cache_entries.extend(('all;%d' % sid) for sid in shard_id_set) |
| |
| memcache.delete_multi( |
| cache_entries, key_prefix=key_prefix, |
| namespace=settings.memcache_namespace) |
| |
| def InvalidateMemcacheForEntireProject(self, project_id): |
| """Delete the memcache entries for all searches in a project.""" |
| project_shards = set((project_id, shard_id) |
| for shard_id in range(settings.num_logical_shards)) |
| self._InvalidateMemcacheShards(project_shards) |
| memcache.delete_multi( |
| [str(project_id)], key_prefix='config:', |
| namespace=settings.memcache_namespace) |
| memcache.delete_multi( |
| [str(project_id)], key_prefix='label_rows:', |
| namespace=settings.memcache_namespace) |
| memcache.delete_multi( |
| [str(project_id)], key_prefix='status_rows:', |
| namespace=settings.memcache_namespace) |
| memcache.delete_multi( |
| [str(project_id)], key_prefix='field_rows:', |
| namespace=settings.memcache_namespace) |
| |
| def UsersInvolvedInConfig(self, config, project_templates): |
| """Return a set of all user IDs referenced in the ProjectIssueConfig.""" |
| result = set() |
| for template in project_templates: |
| result.update(tracker_bizobj.UsersInvolvedInTemplate(template)) |
| for field in config.field_defs: |
| result.update(field.admin_ids) |
| result.update(field.editor_ids) |
| # TODO(jrobbins): add component owners, auto-cc, and admins. |
| return result |