| Index: appengine/monorail/services/config_svc.py
|
| diff --git a/appengine/monorail/services/config_svc.py b/appengine/monorail/services/config_svc.py
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..1cd27a2ecceed23695bf3efffd4c1ac9a3cc5646
|
| --- /dev/null
|
| +++ b/appengine/monorail/services/config_svc.py
|
| @@ -0,0 +1,1348 @@
|
| +# Copyright 2016 The Chromium Authors. All rights reserved.
|
| +# Use of this source code is govered by a BSD-style
|
| +# license that can be found in the LICENSE file or at
|
| +# https://developers.google.com/open-source/licenses/bsd
|
| +
|
| +"""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.
|
| +"""
|
| +
|
| +import collections
|
| +import logging
|
| +
|
| +from google.appengine.api import memcache
|
| +
|
| +import settings
|
| +from framework import sql
|
| +from proto import tracker_pb2
|
| +from services import caches
|
| +from tracker import tracker_bizobj
|
| +
|
| +
|
| +TEMPLATE_TABLE_NAME = 'Template'
|
| +TEMPLATE2LABEL_TABLE_NAME = 'Template2Label'
|
| +TEMPLATE2ADMIN_TABLE_NAME = 'Template2Admin'
|
| +TEMPLATE2COMPONENT_TABLE_NAME = 'Template2Component'
|
| +TEMPLATE2FIELDVALUE_TABLE_NAME = 'Template2FieldValue'
|
| +PROJECTISSUECONFIG_TABLE_NAME = 'ProjectIssueConfig'
|
| +LABELDEF_TABLE_NAME = 'LabelDef'
|
| +FIELDDEF_TABLE_NAME = 'FieldDef'
|
| +FIELDDEF2ADMIN_TABLE_NAME = 'FieldDef2Admin'
|
| +COMPONENTDEF_TABLE_NAME = 'ComponentDef'
|
| +COMPONENT2ADMIN_TABLE_NAME = 'Component2Admin'
|
| +COMPONENT2CC_TABLE_NAME = 'Component2Cc'
|
| +STATUSDEF_TABLE_NAME = 'StatusDef'
|
| +
|
| +TEMPLATE_COLS = [
|
| + 'id', 'project_id', 'name', 'content', 'summary', 'summary_must_be_edited',
|
| + 'owner_id', 'status', 'members_only', 'owner_defaults_to_member',
|
| + 'component_required']
|
| +TEMPLATE2LABEL_COLS = ['template_id', 'label']
|
| +TEMPLATE2COMPONENT_COLS = ['template_id', 'component_id']
|
| +TEMPLATE2ADMIN_COLS = ['template_id', 'admin_id']
|
| +TEMPLATE2FIELDVALUE_COLS = [
|
| + 'template_id', 'field_id', 'int_value', 'str_value', 'user_id']
|
| +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', '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_multivalued',
|
| + 'min_value', 'max_value', 'regex', 'needs_member', 'needs_perm',
|
| + 'grants_perm', 'notify_on', 'docstring', 'is_deleted']
|
| +FIELDDEF2ADMIN_COLS = ['field_id', 'admin_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']
|
| +
|
| +NOTIFY_ON_ENUM = ['never', 'any_comment']
|
| +
|
| +
|
| +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.
|
| + """
|
| +
|
| + def __init__(self, cache_manager, config_service):
|
| + super(LabelRowTwoLevelCache, self).__init__(
|
| + cache_manager, 'project', 'label_rows:', None)
|
| + self.config_service = config_service
|
| +
|
| + 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:
|
| + result_dict[project_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."""
|
| + label_def_rows = self.config_service.labeldef_tbl.Select(
|
| + cnxn, cols=LABELDEF_COLS, project_id=keys,
|
| + order_by=[('rank DESC', []), ('label DESC', [])])
|
| + label_rows_dict = self._DeserializeLabelRows(label_def_rows)
|
| +
|
| + # Make sure that every requested project is represented in the result
|
| + for project_id in keys:
|
| + label_rows_dict.setdefault(project_id, [])
|
| +
|
| + return label_rows_dict
|
| +
|
| +
|
| +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(agable): Actually process the rest of the items.
|
| + for (field_id, project_id, rank, field_name, _field_type, _applicable_type,
|
| + _applicable_predicate, _is_required, _is_multivalued, _min_value,
|
| + _max_value, _regex, _needs_member, _needs_perm, _grants_perm,
|
| + _notify_on, docstring, _is_deleted) 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,
|
| + 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
|
| + if custom_issue_entry_url is not None:
|
| + config.custom_issue_entry_url = custom_issue_entry_url
|
| +
|
| + return config
|
| +
|
| + def _UnpackTemplate(self, template_row):
|
| + """Partially construct a template object using info from a DB row."""
|
| + (template_id, project_id, name, content, summary,
|
| + summary_must_be_edited, owner_id, status,
|
| + members_only, owner_defaults_to_member, component_required) = template_row
|
| + template = tracker_pb2.TemplateDef()
|
| + template.template_id = template_id
|
| + template.name = name
|
| + template.content = content
|
| + template.summary = summary
|
| + template.summary_must_be_edited = bool(
|
| + summary_must_be_edited)
|
| + template.owner_id = owner_id or 0
|
| + template.status = status
|
| + template.members_only = bool(members_only)
|
| + template.owner_defaults_to_member = bool(owner_defaults_to_member)
|
| + template.component_required = bool(component_required)
|
| +
|
| + return template, project_id
|
| +
|
| + 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_multivalued,
|
| + min_value, max_value, regex, needs_member, needs_perm,
|
| + grants_perm, notify_on_str, docstring, is_deleted) = fielddef_row
|
| + if notify_on_str == 'any_comment':
|
| + notify_on = tracker_pb2.NotifyTriggers.ANY_COMMENT
|
| + else:
|
| + notify_on = tracker_pb2.NotifyTriggers.NEVER
|
| +
|
| + return tracker_bizobj.MakeFieldDef(
|
| + field_id, project_id, field_name,
|
| + tracker_pb2.FieldTypes(field_type.upper()), applic_type, applic_pred,
|
| + is_required, is_multivalued, min_value, max_value, regex,
|
| + needs_member, needs_perm, grants_perm, notify_on, docstring,
|
| + is_deleted)
|
| +
|
| + def _UnpackComponentDef(
|
| + self, cd_row, component2admin_rows, component2cc_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, modifier_id)
|
| +
|
| + return cd
|
| +
|
| + def _DeserializeIssueConfigs(
|
| + self, config_rows, template_rows, template2label_rows,
|
| + template2component_rows, template2admin_rows, template2fieldvalue_rows,
|
| + statusdef_rows, labeldef_rows, fielddef_rows, fielddef2admin_rows,
|
| + componentdef_rows, component2admin_rows, component2cc_rows):
|
| + """Convert the given row tuples into a dict of ProjectIssueConfig PBs."""
|
| + result_dict = {}
|
| + template_dict = {}
|
| + fielddef_dict = {}
|
| +
|
| + for config_row in config_rows:
|
| + config = self._UnpackProjectIssueConfig(config_row)
|
| + result_dict[config.project_id] = config
|
| +
|
| + for template_row in template_rows:
|
| + template, project_id = self._UnpackTemplate(template_row)
|
| + if project_id in result_dict:
|
| + result_dict[project_id].templates.append(template)
|
| + template_dict[template.template_id] = template
|
| +
|
| + for template2label_row in template2label_rows:
|
| + template_id, label = template2label_row
|
| + template = template_dict.get(template_id)
|
| + if template:
|
| + template.labels.append(label)
|
| +
|
| + for template2component_row in template2component_rows:
|
| + template_id, component_id = template2component_row
|
| + template = template_dict.get(template_id)
|
| + if template:
|
| + template.component_ids.append(component_id)
|
| +
|
| + for template2admin_row in template2admin_rows:
|
| + template_id, admin_id = template2admin_row
|
| + template = template_dict.get(template_id)
|
| + if template:
|
| + template.admin_ids.append(admin_id)
|
| +
|
| + for fv_row in template2fieldvalue_rows:
|
| + template_id, field_id, int_value, str_value, user_id = fv_row
|
| + fv = tracker_bizobj.MakeFieldValue(
|
| + field_id, int_value, str_value, user_id, False)
|
| + template = template_dict.get(template_id)
|
| + if template:
|
| + template.field_values.append(fv)
|
| +
|
| + 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 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 cd_row in componentdef_rows:
|
| + cd = self._UnpackComponentDef(
|
| + cd_row, component2admin_rows, component2cc_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)
|
| + template_rows = self.config_service.template_tbl.Select(
|
| + cnxn, cols=TEMPLATE_COLS, project_id=project_ids,
|
| + order_by=[('name', [])])
|
| + template_ids = [row[0] for row in template_rows]
|
| + template2label_rows = self.config_service.template2label_tbl.Select(
|
| + cnxn, cols=TEMPLATE2LABEL_COLS, template_id=template_ids)
|
| + template2component_rows = self.config_service.template2component_tbl.Select(
|
| + cnxn, cols=TEMPLATE2COMPONENT_COLS, template_id=template_ids)
|
| + template2admin_rows = self.config_service.template2admin_tbl.Select(
|
| + cnxn, cols=TEMPLATE2ADMIN_COLS, template_id=template_ids)
|
| + template2fv_rows = self.config_service.template2fieldvalue_tbl.Select(
|
| + cnxn, cols=TEMPLATE2FIELDVALUE_COLS, template_id=template_ids)
|
| + logging.info('t2fv is %r', template2fv_rows)
|
| + 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', [])])
|
| + # 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 = self.config_service.fielddef2admin_tbl.Select(
|
| + cnxn, cols=FIELDDEF2ADMIN_COLS, field_id=field_ids)
|
| + componentdef_rows = self.config_service.componentdef_tbl.Select(
|
| + cnxn, cols=COMPONENTDEF_COLS, project_id=project_ids,
|
| + order_by=[('LOWER(path)', [])])
|
| + component_ids = [cd_row[0] for cd_row in componentdef_rows]
|
| + 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)
|
| +
|
| + retrieved_dict = self._DeserializeIssueConfigs(
|
| + config_rows, template_rows, template2label_rows,
|
| + template2component_rows, template2admin_rows,
|
| + template2fv_rows, statusdef_rows, labeldef_rows,
|
| + fielddef_rows, fielddef2admin_rows, componentdef_rows,
|
| + component2admin_rows, component2cc_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.template_tbl = sql.SQLTableManager(TEMPLATE_TABLE_NAME)
|
| + self.template2label_tbl = sql.SQLTableManager(TEMPLATE2LABEL_TABLE_NAME)
|
| + self.template2component_tbl = sql.SQLTableManager(
|
| + TEMPLATE2COMPONENT_TABLE_NAME)
|
| + self.template2admin_tbl = sql.SQLTableManager(TEMPLATE2ADMIN_TABLE_NAME)
|
| + self.template2fieldvalue_tbl = sql.SQLTableManager(
|
| + TEMPLATE2FIELDVALUE_TABLE_NAME)
|
| + 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.componentdef_tbl = sql.SQLTableManager(COMPONENTDEF_TABLE_NAME)
|
| + self.component2admin_tbl = sql.SQLTableManager(COMPONENT2ADMIN_TABLE_NAME)
|
| + self.component2cc_tbl = sql.SQLTableManager(COMPONENT2CC_TABLE_NAME)
|
| +
|
| + self.config_2lc = ConfigTwoLevelCache(cache_manager, self)
|
| + self.label_row_2lc = LabelRowTwoLevelCache(cache_manager, self)
|
| + self.label_cache = cache_manager.MakeCache('project')
|
| + self.status_row_2lc = StatusRowTwoLevelCache(cache_manager, self)
|
| + self.status_cache = cache_manager.MakeCache('project')
|
| + self.field_row_2lc = FieldRowTwoLevelCache(cache_manager, self)
|
| + self.field_cache = cache_manager.MakeCache('project')
|
| +
|
| + ### Label lookups
|
| +
|
| + def GetLabelDefRows(self, cnxn, project_id):
|
| + """Get SQL result rows for all labels used in the specified project."""
|
| + pids_to_label_rows, misses = self.label_row_2lc.GetAll(cnxn, [project_id])
|
| + assert not misses
|
| + return pids_to_label_rows[project_id]
|
| +
|
| + 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.iteritems()}
|
| +
|
| + return label_id_to_name, label_name_to_id
|
| +
|
| + def _EnsureLabelCacheEntry(self, cnxn, project_id):
|
| + """Make sure that self.label_cache has an entry for project_id."""
|
| + if not self.label_cache.HasItem(project_id):
|
| + def_rows = self.GetLabelDefRows(cnxn, project_id)
|
| + 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)
|
| + return label_id_to_name.get(label_id)
|
| +
|
| + def LookupLabelID(self, cnxn, project_id, label, autocreate=True):
|
| + """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.
|
| +
|
| + 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)
|
| + if label.lower() in label_name_to_id:
|
| + return label_name_to_id[label.lower()]
|
| +
|
| + 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.iteritems()
|
| + 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.iteritems()}
|
| + 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):
|
| + """Get several project issue config objects."""
|
| + config_dict, missed_ids = self.config_2lc.GetAll(
|
| + cnxn, project_ids, use_cache=use_cache)
|
| + assert not missed_ids
|
| + 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 TemplatesWithComponent(self, cnxn, component_id, config):
|
| + """Returns all templates with the specified component.
|
| +
|
| + Args:
|
| + cnxn: connection to SQL database.
|
| + component_id: int component id.
|
| + config: ProjectIssueConfig instance.
|
| + """
|
| + template2component_rows = self.template2component_tbl.Select(
|
| + cnxn, cols=['template_id'], component_id=component_id)
|
| + template_ids = [r[0] for r in template2component_rows]
|
| + return [t for t in config.templates if t.template_id in template_ids]
|
| +
|
| + 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,
|
| + custom_issue_entry_url=config.custom_issue_entry_url,
|
| + commit=False)
|
| +
|
| + self._UpdateTemplates(cnxn, config)
|
| + self._UpdateWellKnownLabels(cnxn, config)
|
| + self._UpdateWellKnownStatuses(cnxn, config)
|
| + cnxn.Commit()
|
| +
|
| + def _UpdateTemplates(self, cnxn, config):
|
| + """Update the templates part of a project's issue configuration.
|
| +
|
| + Args:
|
| + cnxn: connection to SQL database.
|
| + config: ProjectIssueConfig PB to update in the DB.
|
| + """
|
| + # Delete dependent rows of existing templates. It is all rewritten below.
|
| + template_id_rows = self.template_tbl.Select(
|
| + cnxn, cols=['id'], project_id=config.project_id)
|
| + template_ids = [row[0] for row in template_id_rows]
|
| + self.template2label_tbl.Delete(
|
| + cnxn, template_id=template_ids, commit=False)
|
| + self.template2component_tbl.Delete(
|
| + cnxn, template_id=template_ids, commit=False)
|
| + self.template2admin_tbl.Delete(
|
| + cnxn, template_id=template_ids, commit=False)
|
| + self.template2fieldvalue_tbl.Delete(
|
| + cnxn, template_id=template_ids, commit=False)
|
| + self.template_tbl.Delete(
|
| + cnxn, project_id=config.project_id, commit=False)
|
| +
|
| + # Now, update existing ones and add new ones.
|
| + template_rows = []
|
| + for template in config.templates:
|
| + row = (template.template_id,
|
| + config.project_id,
|
| + template.name,
|
| + template.content,
|
| + template.summary,
|
| + template.summary_must_be_edited,
|
| + template.owner_id or None,
|
| + template.status,
|
| + template.members_only,
|
| + template.owner_defaults_to_member,
|
| + template.component_required)
|
| + template_rows.append(row)
|
| +
|
| + # Maybe first insert ones that have a template_id and then insert new ones
|
| + # separately.
|
| + generated_ids = self.template_tbl.InsertRows(
|
| + cnxn, TEMPLATE_COLS, template_rows, replace=True, commit=False,
|
| + return_generated_ids=True)
|
| + logging.info('generated_ids is %r', generated_ids)
|
| + for template in config.templates:
|
| + if not template.template_id:
|
| + # Get IDs from the back of the list because the original template IDs
|
| + # have already been added to template_rows.
|
| + template.template_id = generated_ids.pop()
|
| +
|
| + template2label_rows = []
|
| + template2component_rows = []
|
| + template2admin_rows = []
|
| + template2fieldvalue_rows = []
|
| + for template in config.templates:
|
| + for label in template.labels:
|
| + if label:
|
| + template2label_rows.append((template.template_id, label))
|
| + for component_id in template.component_ids:
|
| + template2component_rows.append((template.template_id, component_id))
|
| + for admin_id in template.admin_ids:
|
| + template2admin_rows.append((template.template_id, admin_id))
|
| + for fv in template.field_values:
|
| + template2fieldvalue_rows.append(
|
| + (template.template_id, fv.field_id, fv.int_value, fv.str_value,
|
| + fv.user_id or None))
|
| +
|
| + self.template2label_tbl.InsertRows(
|
| + cnxn, TEMPLATE2LABEL_COLS, template2label_rows, ignore=True,
|
| + commit=False)
|
| + self.template2component_tbl.InsertRows(
|
| + cnxn, TEMPLATE2COMPONENT_COLS, template2component_rows, commit=False)
|
| + self.template2admin_tbl.InsertRows(
|
| + cnxn, TEMPLATE2ADMIN_COLS, template2admin_rows, commit=False)
|
| + self.template2fieldvalue_tbl.InsertRows(
|
| + cnxn, TEMPLATE2FIELDVALUE_COLS, template2fieldvalue_rows, commit=False)
|
| +
|
| + 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 = []
|
| + for rank, wkl in enumerate(config.well_known_labels):
|
| + # 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 UpdateConfig(
|
| + self, cnxn, project, well_known_statuses=None,
|
| + statuses_offer_merge=None, well_known_labels=None,
|
| + excl_label_prefixes=None, templates=None,
|
| + default_template_for_developers=None, default_template_for_users=None,
|
| + list_prefs=None, restrict_to_known=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.
|
| + templates: List of PBs for issue templates.
|
| + 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.
|
| +
|
| + 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 templates is not None:
|
| + project_config.templates = templates
|
| +
|
| + 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, x_attr, y_attr = list_prefs
|
| + project_config.default_col_spec = default_col_spec
|
| + project_config.default_sort_spec = default_sort_spec
|
| + project_config.default_x_attr = x_attr
|
| + project_config.default_y_attr = y_attr
|
| +
|
| + 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)
|
| + template_id_rows = self.template_tbl.Select(
|
| + cnxn, cols=['id'], project_id=project_id)
|
| + template_ids = [row[0] for row in template_id_rows]
|
| + self.template2label_tbl.Delete(cnxn, template_id=template_ids)
|
| + self.template2component_tbl.Delete(cnxn, template_id=template_ids)
|
| + self.template_tbl.Delete(cnxn, project_id=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])
|
| +
|
| + ### Custom field definitions
|
| +
|
| + def CreateFieldDef(
|
| + self, cnxn, project_id, field_name, field_type_str, applic_type,
|
| + applic_pred, is_required, is_multivalued,
|
| + min_value, max_value, regex, needs_member, needs_perm,
|
| + grants_perm, notify_on, docstring, admin_ids):
|
| + """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_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.
|
| + docstring: string describing this field.
|
| + admin_ids: list of additional user IDs who can edit this field def.
|
| +
|
| + 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_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],
|
| + docstring=docstring, commit=False)
|
| + self.fielddef2admin_tbl.InsertRows(
|
| + cnxn, FIELDDEF2ADMIN_COLS,
|
| + [(field_id, admin_id) for admin_id in admin_ids],
|
| + commit=False)
|
| + cnxn.Commit()
|
| + self.config_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.iteritems()}
|
| +
|
| + 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 SoftDeleteFieldDef(self, cnxn, project_id, field_id):
|
| + """Mark the specified field as deleted, it will be reaped later."""
|
| + self.fielddef_tbl.Update(cnxn, {'is_deleted': True}, id=field_id)
|
| + 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_multivalued=None, min_value=None, max_value=None, regex=None,
|
| + needs_member=None, needs_perm=None, grants_perm=None, notify_on=None,
|
| + docstring=None, admin_ids=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_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 docstring is not None:
|
| + new_values['docstring'] = docstring
|
| +
|
| + self.fielddef_tbl.Update(cnxn, new_values, id=field_id, commit=False)
|
| + 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)
|
| + 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):
|
| + """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.
|
| +
|
| + 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)
|
| + 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):
|
| + """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)
|
| +
|
| + 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.component2cc_tbl.Delete(
|
| + cnxn, component_id=component_id, commit=False)
|
| + self.component2admin_tbl.Delete(
|
| + cnxn, component_id=component_id, commit=False)
|
| + self.componentdef_tbl.Delete(cnxn, 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:')
|
| + 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)
|
| +
|
| + 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:')
|
| + memcache.delete_multi([str(project_id)], key_prefix='label_rows:')
|
| + memcache.delete_multi([str(project_id)], key_prefix='status_rows:')
|
| + memcache.delete_multi([str(project_id)], key_prefix='field_rows:')
|
| +
|
| +
|
| +class Error(Exception):
|
| + """Base class for errors from this module."""
|
| + pass
|
| +
|
| +
|
| +class NoSuchComponentException(Error):
|
| + """No component with the specified name exists."""
|
| + pass
|
| +
|
| +
|
| +class InvalidComponentNameException(Error):
|
| + """The component name is invalid."""
|
| + pass
|
|
|