blob: 471a513275fd8762488852040c83f5fa8a3c0b5c [file] [log] [blame]
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file or at
# https://developers.google.com/open-source/licenses/bsd
"""A class that provides persistence for Monorail's additional features.
Business objects are described in tracker_pb2.py, features_pb2.py, and
tracker_bizobj.py.
"""
from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
import collections
import logging
import re
import time
import settings
from features import features_constants
from features import filterrules_helpers
from framework import exceptions
from framework import framework_bizobj
from framework import framework_constants
from framework import sql
from proto import features_pb2
from services import caches
from services import config_svc
from tracker import tracker_bizobj
from tracker import tracker_constants
QUICKEDITHISTORY_TABLE_NAME = 'QuickEditHistory'
QUICKEDITMOSTRECENT_TABLE_NAME = 'QuickEditMostRecent'
SAVEDQUERY_TABLE_NAME = 'SavedQuery'
PROJECT2SAVEDQUERY_TABLE_NAME = 'Project2SavedQuery'
SAVEDQUERYEXECUTESINPROJECT_TABLE_NAME = 'SavedQueryExecutesInProject'
USER2SAVEDQUERY_TABLE_NAME = 'User2SavedQuery'
FILTERRULE_TABLE_NAME = 'FilterRule'
HOTLIST_TABLE_NAME = 'Hotlist'
HOTLIST2ISSUE_TABLE_NAME = 'Hotlist2Issue'
HOTLIST2USER_TABLE_NAME = 'Hotlist2User'
QUICKEDITHISTORY_COLS = [
'user_id', 'project_id', 'slot_num', 'command', 'comment']
QUICKEDITMOSTRECENT_COLS = ['user_id', 'project_id', 'slot_num']
SAVEDQUERY_COLS = ['id', 'name', 'base_query_id', 'query']
PROJECT2SAVEDQUERY_COLS = ['project_id', 'rank', 'query_id']
SAVEDQUERYEXECUTESINPROJECT_COLS = ['query_id', 'project_id']
USER2SAVEDQUERY_COLS = ['user_id', 'rank', 'query_id', 'subscription_mode']
FILTERRULE_COLS = ['project_id', 'rank', 'predicate', 'consequence']
HOTLIST_COLS = [
'id', 'name', 'summary', 'description', 'is_private', 'default_col_spec']
HOTLIST_ABBR_COLS = ['id', 'name', 'summary', 'is_private']
HOTLIST2ISSUE_COLS = [
'hotlist_id', 'issue_id', 'rank', 'adder_id', 'added', 'note']
HOTLIST2USER_COLS = ['hotlist_id', 'user_id', 'role_name']
# Regex for parsing one action in the filter rule consequence storage syntax.
CONSEQUENCE_RE = re.compile(
r'(default_status:(?P<default_status>[-.\w]+))|'
r'(default_owner_id:(?P<default_owner_id>\d+))|'
r'(add_cc_id:(?P<add_cc_id>\d+))|'
r'(add_label:(?P<add_label>[-.\w]+))|'
r'(add_notify:(?P<add_notify>[-.@\w]+))|'
r'(warning:(?P<warning>.+))|' # Warnings consume the rest of the string.
r'(error:(?P<error>.+))' # Errors consume the rest of the string.
)
class HotlistTwoLevelCache(caches.AbstractTwoLevelCache):
"""Class to manage both RAM and memcache for Hotlist PBs."""
def __init__(self, cachemanager, features_service):
super(HotlistTwoLevelCache, self).__init__(
cachemanager, 'hotlist', 'hotlist:', features_pb2.Hotlist)
self.features_service = features_service
def _DeserializeHotlists(
self, hotlist_rows, issue_rows, role_rows):
"""Convert database rows into a dictionary of Hotlist PB keyed by ID.
Args:
hotlist_rows: a list of hotlist rows from HOTLIST_TABLE_NAME.
issue_rows: a list of issue rows from HOTLIST2ISSUE_TABLE_NAME,
ordered by rank DESC, issue_id.
role_rows: a list of role rows from HOTLIST2USER_TABLE_NAME.
Returns:
a dict mapping hotlist_id to hotlist PB"""
hotlist_dict = {}
for hotlist_row in hotlist_rows:
(hotlist_id, hotlist_name, summary, description, is_private,
default_col_spec) = hotlist_row
hotlist = features_pb2.MakeHotlist(
hotlist_name, hotlist_id=hotlist_id, summary=summary,
description=description, is_private=bool(is_private),
default_col_spec=default_col_spec)
hotlist_dict[hotlist_id] = hotlist
for (hotlist_id, issue_id, rank, adder_id, added, note) in issue_rows:
hotlist = hotlist_dict.get(hotlist_id)
if hotlist:
hotlist.items.append(
features_pb2.MakeHotlistItem(issue_id=issue_id, rank=rank,
adder_id=adder_id , date_added=added,
note=note))
else:
logging.warn('hotlist %d not found', hotlist_id)
for (hotlist_id, user_id, role_name) in role_rows:
hotlist = hotlist_dict.get(hotlist_id)
if not hotlist:
logging.warn('hotlist %d not found', hotlist_id)
elif role_name == 'owner':
hotlist.owner_ids.append(user_id)
elif role_name == 'editor':
hotlist.editor_ids.append(user_id)
elif role_name == 'follower':
hotlist.follower_ids.append(user_id)
else:
logging.info('unknown role name %s', role_name)
return hotlist_dict
def FetchItems(self, cnxn, keys):
"""On RAM and memcache miss, hit the database to get missing hotlists."""
hotlist_rows = self.features_service.hotlist_tbl.Select(
cnxn, cols=HOTLIST_COLS, is_deleted=False, id=keys)
issue_rows = self.features_service.hotlist2issue_tbl.Select(
cnxn, cols=HOTLIST2ISSUE_COLS, hotlist_id=keys,
order_by=[('rank DESC', []), ('issue_id', [])])
role_rows = self.features_service.hotlist2user_tbl.Select(
cnxn, cols=HOTLIST2USER_COLS, hotlist_id=keys)
retrieved_dict = self._DeserializeHotlists(
hotlist_rows, issue_rows, role_rows)
return retrieved_dict
class HotlistIDTwoLevelCache(caches.AbstractTwoLevelCache):
"""Class to manage both RAM and memcache for hotlist_ids.
Keys for this cache are tuples (hotlist_name.lower(), owner_id).
This cache should be used to fetch hotlist_ids owned by users or
to check if a user owns a hotlist with a certain name, so the
hotlist_names in keys will always be in lowercase.
"""
def __init__(self, cachemanager, features_service):
super(HotlistIDTwoLevelCache, self).__init__(
cachemanager, 'hotlist_id', 'hotlist_id:', int,
max_size=settings.issue_cache_max_size)
self.features_service = features_service
def _MakeCache(self, cache_manager, kind, max_size=None):
"""Override normal RamCache creation with ValueCentricRamCache."""
return caches.ValueCentricRamCache(cache_manager, kind, max_size=max_size)
def _KeyToStr(self, key):
"""This cache uses pairs of (str, int) as keys. Convert them to strings."""
return '%s,%d' % key
def _StrToKey(self, key_str):
"""This cache uses pairs of (str, int) as keys.
Convert them from strings.
"""
hotlist_name_str, owner_id_str = key_str.split(',')
return (hotlist_name_str, int(owner_id_str))
def _DeserializeHotlistIDs(
self, hotlist_rows, owner_rows, wanted_names_for_owners):
"""Convert database rows into a dictionary of hotlist_ids keyed by (
hotlist_name, owner_id).
Args:
hotlist_rows: a list of hotlist rows [id, name] from HOTLIST for
with names we are interested in.
owner_rows: a list of role rows [hotlist_id, uwer_id] from HOTLIST2USER
for owners that we are interested in that own hotlists with names that
we are interested in.
wanted_names_for_owners: a dict of
{owner_id: [hotlist_name.lower(), ...], ...}
so we know which (hotlist_name, owner_id) keys to return.
Returns:
A dict mapping (hotlist_name.lower(), owner_id) keys to hotlist_id values.
"""
hotlist_ids_dict = {}
if not hotlist_rows or not owner_rows:
return hotlist_ids_dict
hotlist_to_owner_id = {}
# Note: owner_rows contains hotlist owners that we are interested in, but
# may not own hotlists with names we are interested in.
for (hotlist_id, user_id) in owner_rows:
found_owner_id = hotlist_to_owner_id.get(hotlist_id)
if found_owner_id:
logging.warn(
'hotlist %d has more than one owner: %d, %d',
hotlist_id, user_id, found_owner_id)
hotlist_to_owner_id[hotlist_id] = user_id
# Note: hotlist_rows hotlists found in the owner_rows that have names
# we're interested in.
# We use wanted_names_for_owners to filter out hotlists in hotlist_rows
# that have a (hotlist_name, owner_id) pair we are not interested in.
for (hotlist_id, hotlist_name) in hotlist_rows:
owner_id = hotlist_to_owner_id.get(hotlist_id)
if owner_id:
if hotlist_name.lower() in wanted_names_for_owners.get(owner_id, []):
hotlist_ids_dict[(hotlist_name.lower(), owner_id)] = hotlist_id
return hotlist_ids_dict
def FetchItems(self, cnxn, keys):
"""On RAM and memcache miss, hit the database."""
hotlist_names, _owner_ids = zip(*keys)
# Keys may contain [(name1, user1), (name1, user2)] so we cast this to
# a set to make sure 'name1' is not repeated.
hotlist_names_set = set(hotlist_names)
# Pass this dict to _DeserializeHotlistIDs so it knows what hotlist names
# we're interested in for each owner.
wanted_names_for_owner = collections.defaultdict(list)
for hotlist_name, owner_id in keys:
wanted_names_for_owner[owner_id].append(hotlist_name.lower())
role_rows = self.features_service.hotlist2user_tbl.Select(
cnxn, cols=['hotlist_id', 'user_id'],
user_id=wanted_names_for_owner.keys(), role_name='owner')
hotlist_ids = [row[0] for row in role_rows]
hotlist_rows = self.features_service.hotlist_tbl.Select(
cnxn, cols=['id', 'name'], id=hotlist_ids, is_deleted=False,
where=[('LOWER(name) IN (%s)' % sql.PlaceHolders(hotlist_names_set),
[name.lower() for name in hotlist_names_set])])
return self._DeserializeHotlistIDs(
hotlist_rows, role_rows, wanted_names_for_owner)
class FeaturesService(object):
"""The persistence layer for servlets in the features directory."""
def __init__(self, cache_manager, config_service):
"""Initialize this object so that it is ready to use.
Args:
cache_manager: local cache with distributed invalidation.
config_service: an instance of ConfigService.
"""
self.quickedithistory_tbl = sql.SQLTableManager(QUICKEDITHISTORY_TABLE_NAME)
self.quickeditmostrecent_tbl = sql.SQLTableManager(
QUICKEDITMOSTRECENT_TABLE_NAME)
self.savedquery_tbl = sql.SQLTableManager(SAVEDQUERY_TABLE_NAME)
self.project2savedquery_tbl = sql.SQLTableManager(
PROJECT2SAVEDQUERY_TABLE_NAME)
self.savedqueryexecutesinproject_tbl = sql.SQLTableManager(
SAVEDQUERYEXECUTESINPROJECT_TABLE_NAME)
self.user2savedquery_tbl = sql.SQLTableManager(USER2SAVEDQUERY_TABLE_NAME)
self.filterrule_tbl = sql.SQLTableManager(FILTERRULE_TABLE_NAME)
self.hotlist_tbl = sql.SQLTableManager(HOTLIST_TABLE_NAME)
self.hotlist2issue_tbl = sql.SQLTableManager(HOTLIST2ISSUE_TABLE_NAME)
self.hotlist2user_tbl = sql.SQLTableManager(HOTLIST2USER_TABLE_NAME)
self.saved_query_cache = caches.RamCache(
cache_manager, 'user', max_size=1000)
self.canned_query_cache = caches.RamCache(
cache_manager, 'project', max_size=1000)
self.hotlist_2lc = HotlistTwoLevelCache(cache_manager, self)
self.hotlist_id_2lc = HotlistIDTwoLevelCache(cache_manager, self)
self.hotlist_user_to_ids = caches.RamCache(cache_manager, 'hotlist')
self.config_service = config_service
### QuickEdit command history
def GetRecentCommands(self, cnxn, user_id, project_id):
"""Return recent command items for the "Redo" menu.
Args:
cnxn: Connection to SQL database.
user_id: int ID of the current user.
project_id: int ID of the current project.
Returns:
A pair (cmd_slots, recent_slot_num). cmd_slots is a list of
3-tuples that can be used to populate the "Redo" menu of the
quick-edit dialog. recent_slot_num indicates which of those
slots should initially populate the command and comment fields.
"""
# Always start with the standard 5 commands.
history = tracker_constants.DEFAULT_RECENT_COMMANDS[:]
# If the user has modified any, then overwrite some standard ones.
history_rows = self.quickedithistory_tbl.Select(
cnxn, cols=['slot_num', 'command', 'comment'],
user_id=user_id, project_id=project_id)
for slot_num, command, comment in history_rows:
if slot_num < len(history):
history[slot_num - 1] = (command, comment)
slots = []
for idx, (command, comment) in enumerate(history):
slots.append((idx + 1, command, comment))
recent_slot_num = self.quickeditmostrecent_tbl.SelectValue(
cnxn, 'slot_num', default=1, user_id=user_id, project_id=project_id)
return slots, recent_slot_num
def StoreRecentCommand(
self, cnxn, user_id, project_id, slot_num, command, comment):
"""Store the given command and comment in the user's command history."""
self.quickedithistory_tbl.InsertRow(
cnxn, replace=True, user_id=user_id, project_id=project_id,
slot_num=slot_num, command=command, comment=comment)
self.quickeditmostrecent_tbl.InsertRow(
cnxn, replace=True, user_id=user_id, project_id=project_id,
slot_num=slot_num)
def ExpungeQuickEditHistory(self, cnxn, project_id):
"""Completely delete every users' quick edit history for this project."""
self.quickeditmostrecent_tbl.Delete(cnxn, project_id=project_id)
self.quickedithistory_tbl.Delete(cnxn, project_id=project_id)
def ExpungeQuickEditsByUsers(self, cnxn, user_ids, limit=None):
"""Completely delete every given users' quick edits.
This method will not commit the operations. This method will
not make changes to in-memory data.
"""
commit = False
self.quickeditmostrecent_tbl.Delete(
cnxn, user_id=user_ids, commit=commit, limit=limit)
self.quickedithistory_tbl.Delete(
cnxn, user_id=user_ids, commit=commit, limit=limit)
### Saved User and Project Queries
def GetSavedQueries(self, cnxn, query_ids):
"""Retrieve the specified SaveQuery PBs."""
# TODO(jrobbins): RAM cache
if not query_ids:
return {}
saved_queries = {}
savedquery_rows = self.savedquery_tbl.Select(
cnxn, cols=SAVEDQUERY_COLS, id=query_ids)
for saved_query_tuple in savedquery_rows:
qid, name, base_id, query = saved_query_tuple
saved_queries[qid] = tracker_bizobj.MakeSavedQuery(
qid, name, base_id, query)
sqeip_rows = self.savedqueryexecutesinproject_tbl.Select(
cnxn, cols=SAVEDQUERYEXECUTESINPROJECT_COLS, query_id=query_ids)
for query_id, project_id in sqeip_rows:
saved_queries[query_id].executes_in_project_ids.append(project_id)
return saved_queries
def GetSavedQuery(self, cnxn, query_id):
"""Retrieve the specified SaveQuery PB."""
saved_queries = self.GetSavedQueries(cnxn, [query_id])
return saved_queries.get(query_id)
def _GetUsersSavedQueriesDict(self, cnxn, user_ids):
"""Return a dict of all SavedQuery PBs for the specified users."""
results_dict, missed_uids = self.saved_query_cache.GetAll(user_ids)
if missed_uids:
savedquery_rows = self.user2savedquery_tbl.Select(
cnxn, cols=SAVEDQUERY_COLS + ['user_id', 'subscription_mode'],
left_joins=[('SavedQuery ON query_id = id', [])],
order_by=[('rank', [])], user_id=missed_uids)
sqeip_dict = {}
if savedquery_rows:
query_ids = {row[0] for row in savedquery_rows}
sqeip_rows = self.savedqueryexecutesinproject_tbl.Select(
cnxn, cols=SAVEDQUERYEXECUTESINPROJECT_COLS, query_id=query_ids)
for qid, pid in sqeip_rows:
sqeip_dict.setdefault(qid, []).append(pid)
for saved_query_tuple in savedquery_rows:
query_id, name, base_id, query, uid, sub_mode = saved_query_tuple
sq = tracker_bizobj.MakeSavedQuery(
query_id, name, base_id, query, subscription_mode=sub_mode,
executes_in_project_ids=sqeip_dict.get(query_id, []))
results_dict.setdefault(uid, []).append(sq)
self.saved_query_cache.CacheAll(results_dict)
return results_dict
# TODO(jrobbins): change this termonology to "canned query" rather than
# "saved" throughout the application.
def GetSavedQueriesByUserID(self, cnxn, user_id):
"""Return a list of SavedQuery PBs for the specified user."""
saved_queries_dict = self._GetUsersSavedQueriesDict(cnxn, [user_id])
saved_queries = saved_queries_dict.get(user_id, [])
return saved_queries[:]
def GetCannedQueriesForProjects(self, cnxn, project_ids):
"""Return a dict {project_id: [saved_query]} for the specified projects."""
results_dict, missed_pids = self.canned_query_cache.GetAll(project_ids)
if missed_pids:
cannedquery_rows = self.project2savedquery_tbl.Select(
cnxn, cols=['project_id'] + SAVEDQUERY_COLS,
left_joins=[('SavedQuery ON query_id = id', [])],
order_by=[('rank', [])], project_id=project_ids)
for cq_row in cannedquery_rows:
project_id = cq_row[0]
canned_query_tuple = cq_row[1:]
results_dict.setdefault(project_id ,[]).append(
tracker_bizobj.MakeSavedQuery(*canned_query_tuple))
self.canned_query_cache.CacheAll(results_dict)
return results_dict
def GetCannedQueriesByProjectID(self, cnxn, project_id):
"""Return the list of SavedQueries for the specified project."""
project_ids_to_canned_queries = self.GetCannedQueriesForProjects(
cnxn, [project_id])
return project_ids_to_canned_queries.get(project_id, [])
def _UpdateSavedQueries(self, cnxn, saved_queries, commit=True):
"""Store the given SavedQueries to the DB."""
savedquery_rows = [
(sq.query_id or None, sq.name, sq.base_query_id, sq.query)
for sq in saved_queries]
existing_query_ids = [sq.query_id for sq in saved_queries if sq.query_id]
if existing_query_ids:
self.savedquery_tbl.Delete(cnxn, id=existing_query_ids, commit=commit)
generated_ids = self.savedquery_tbl.InsertRows(
cnxn, SAVEDQUERY_COLS, savedquery_rows, commit=commit,
return_generated_ids=True)
if generated_ids:
logging.info('generated_ids are %r', generated_ids)
for sq in saved_queries:
generated_id = generated_ids.pop(0)
if not sq.query_id:
sq.query_id = generated_id
def UpdateCannedQueries(self, cnxn, project_id, canned_queries):
"""Update the canned queries for a project.
Args:
cnxn: connection to SQL database.
project_id: int project ID of the project that contains these queries.
canned_queries: list of SavedQuery PBs to update.
"""
self.project2savedquery_tbl.Delete(
cnxn, project_id=project_id, commit=False)
self._UpdateSavedQueries(cnxn, canned_queries, commit=False)
project2savedquery_rows = [
(project_id, rank, sq.query_id)
for rank, sq in enumerate(canned_queries)]
self.project2savedquery_tbl.InsertRows(
cnxn, PROJECT2SAVEDQUERY_COLS, project2savedquery_rows,
commit=False)
cnxn.Commit()
self.canned_query_cache.Invalidate(cnxn, project_id)
def UpdateUserSavedQueries(self, cnxn, user_id, saved_queries):
"""Store the given saved_queries for the given user."""
saved_query_ids = [sq.query_id for sq in saved_queries if sq.query_id]
self.savedqueryexecutesinproject_tbl.Delete(
cnxn, query_id=saved_query_ids, commit=False)
self.user2savedquery_tbl.Delete(cnxn, user_id=user_id, commit=False)
self._UpdateSavedQueries(cnxn, saved_queries, commit=False)
user2savedquery_rows = []
for rank, sq in enumerate(saved_queries):
user2savedquery_rows.append(
(user_id, rank, sq.query_id, sq.subscription_mode or 'noemail'))
self.user2savedquery_tbl.InsertRows(
cnxn, USER2SAVEDQUERY_COLS, user2savedquery_rows, commit=False)
sqeip_rows = []
for sq in saved_queries:
for pid in sq.executes_in_project_ids:
sqeip_rows.append((sq.query_id, pid))
self.savedqueryexecutesinproject_tbl.InsertRows(
cnxn, SAVEDQUERYEXECUTESINPROJECT_COLS, sqeip_rows, commit=False)
cnxn.Commit()
self.saved_query_cache.Invalidate(cnxn, user_id)
### Subscriptions
def GetSubscriptionsInProjects(self, cnxn, project_ids):
"""Return all saved queries for users that have any subscription there.
Args:
cnxn: Connection to SQL database.
project_ids: list of int project IDs that contain the modified issues.
Returns:
A dict {user_id: all_saved_queries, ...} for all users that have any
subscription in any of the specified projects.
"""
sqeip_join_str = (
'SavedQueryExecutesInProject ON '
'SavedQueryExecutesInProject.query_id = User2SavedQuery.query_id')
user_join_str = (
'User ON '
'User.user_id = User2SavedQuery.user_id')
now = int(time.time())
absence_threshold = now - settings.subscription_timeout_secs
where = [
('(User.banned IS NULL OR User.banned = %s)', ['']),
('User.last_visit_timestamp >= %s', [absence_threshold]),
('(User.email_bounce_timestamp IS NULL OR '
'User.email_bounce_timestamp = %s)', [0]),
]
# TODO(jrobbins): cache this since it rarely changes.
subscriber_rows = self.user2savedquery_tbl.Select(
cnxn, cols=['User2SavedQuery.user_id'], distinct=True,
joins=[(sqeip_join_str, []), (user_join_str, [])],
subscription_mode='immediate', project_id=project_ids,
where=where)
subscriber_ids = [row[0] for row in subscriber_rows]
logging.info('subscribers relevant to projects %r are %r',
project_ids, subscriber_ids)
user_ids_to_saved_queries = self._GetUsersSavedQueriesDict(
cnxn, subscriber_ids)
return user_ids_to_saved_queries
def ExpungeSavedQueriesExecuteInProject(self, cnxn, project_id):
"""Remove any references from saved queries to projects in the database."""
self.savedqueryexecutesinproject_tbl.Delete(cnxn, project_id=project_id)
savedquery_rows = self.project2savedquery_tbl.Select(
cnxn, cols=['query_id'], project_id=project_id)
savedquery_ids = [row[0] for row in savedquery_rows]
self.project2savedquery_tbl.Delete(cnxn, project_id=project_id)
self.savedquery_tbl.Delete(cnxn, id=savedquery_ids)
def ExpungeSavedQueriesByUsers(self, cnxn, user_ids, limit=None):
"""Completely delete every given users' saved queries.
This method will not commit the operations. This method will
not make changes to in-memory data.
"""
commit = False
savedquery_rows = self.user2savedquery_tbl.Select(
cnxn, cols=['query_id'], user_id=user_ids, limit=limit)
savedquery_ids = [row[0] for row in savedquery_rows]
self.user2savedquery_tbl.Delete(
cnxn, query_id=savedquery_ids, commit=commit)
self.savedqueryexecutesinproject_tbl.Delete(
cnxn, query_id=savedquery_ids, commit=commit)
self.savedquery_tbl.Delete(cnxn, id=savedquery_ids, commit=commit)
### Filter rules
def _DeserializeFilterRules(self, filterrule_rows):
"""Convert the given DB row tuples into PBs."""
result_dict = collections.defaultdict(list)
for filterrule_row in sorted(filterrule_rows):
project_id, _rank, predicate, consequence = filterrule_row
(default_status, default_owner_id, add_cc_ids, add_labels,
add_notify, warning, error) = self._DeserializeRuleConsequence(
consequence)
rule = filterrules_helpers.MakeRule(
predicate, default_status=default_status,
default_owner_id=default_owner_id, add_cc_ids=add_cc_ids,
add_labels=add_labels, add_notify=add_notify, warning=warning,
error=error)
result_dict[project_id].append(rule)
return result_dict
def _DeserializeRuleConsequence(self, consequence):
"""Decode the THEN-part of a filter rule."""
(default_status, default_owner_id, add_cc_ids, add_labels,
add_notify, warning, error) = None, None, [], [], [], None, None
for match in CONSEQUENCE_RE.finditer(consequence):
if match.group('default_status'):
default_status = match.group('default_status')
elif match.group('default_owner_id'):
default_owner_id = int(match.group('default_owner_id'))
elif match.group('add_cc_id'):
add_cc_ids.append(int(match.group('add_cc_id')))
elif match.group('add_label'):
add_labels.append(match.group('add_label'))
elif match.group('add_notify'):
add_notify.append(match.group('add_notify'))
elif match.group('warning'):
warning = match.group('warning')
elif match.group('error'):
error = match.group('error')
return (default_status, default_owner_id, add_cc_ids, add_labels,
add_notify, warning, error)
def _GetFilterRulesByProjectIDs(self, cnxn, project_ids):
"""Return {project_id: [FilterRule, ...]} for the specified projects."""
# TODO(jrobbins): caching
filterrule_rows = self.filterrule_tbl.Select(
cnxn, cols=FILTERRULE_COLS, project_id=project_ids)
return self._DeserializeFilterRules(filterrule_rows)
def GetFilterRules(self, cnxn, project_id):
"""Return a list of FilterRule PBs for the specified project."""
rules_by_project_id = self._GetFilterRulesByProjectIDs(cnxn, [project_id])
return rules_by_project_id[project_id]
def _SerializeRuleConsequence(self, rule):
"""Put all actions of a filter rule into one string."""
assignments = []
for add_lab in rule.add_labels:
assignments.append('add_label:%s' % add_lab)
if rule.default_status:
assignments.append('default_status:%s' % rule.default_status)
if rule.default_owner_id:
assignments.append('default_owner_id:%d' % rule.default_owner_id)
for add_cc_id in rule.add_cc_ids:
assignments.append('add_cc_id:%d' % add_cc_id)
for add_notify in rule.add_notify_addrs:
assignments.append('add_notify:%s' % add_notify)
if rule.warning:
assignments.append('warning:%s' % rule.warning)
if rule.error:
assignments.append('error:%s' % rule.error)
return ' '.join(assignments)
def UpdateFilterRules(self, cnxn, project_id, rules):
"""Update the filter rules part of a project's issue configuration.
Args:
cnxn: connection to SQL database.
project_id: int ID of the current project.
rules: a list of FilterRule PBs.
"""
rows = []
for rank, rule in enumerate(rules):
predicate = rule.predicate
consequence = self._SerializeRuleConsequence(rule)
if predicate and consequence:
rows.append((project_id, rank, predicate, consequence))
self.filterrule_tbl.Delete(cnxn, project_id=project_id)
self.filterrule_tbl.InsertRows(cnxn, FILTERRULE_COLS, rows)
def ExpungeFilterRules(self, cnxn, project_id):
"""Completely destroy filter rule info for the specified project."""
self.filterrule_tbl.Delete(cnxn, project_id=project_id)
def ExpungeFilterRulesByUser(self, cnxn, user_ids_by_email):
"""Wipes any Filter Rules containing the given users.
This method will not commit the operation. This method will not make
changes to in-memory data.
Args:
cnxn: connection to SQL database.
user_ids_by_email: dict of {email: user_id ..} of all users we want to
expunge
Returns:
Dictionary of {project_id: [(predicate, consequence), ..]} for Filter
Rules that will be deleted for containing the given emails.
"""
deleted_project_rules_dict = collections.defaultdict(list)
if user_ids_by_email:
deleted_rows = []
emails = user_ids_by_email.keys()
all_rules_rows = self.filterrule_tbl.Select(cnxn, FILTERRULE_COLS)
logging.info('Fetched all filter rules: %s' % (all_rules_rows,))
for rule_row in all_rules_rows:
project_id, _rank, predicate, consequence = rule_row
if any(email in predicate for email in emails):
deleted_rows.append(rule_row)
continue
if any(
(('add_notify:%s' % email) in consequence or
('add_cc_id:%s' % user_id) in consequence or
('default_owner_id:%s' % user_id) in consequence)
for email, user_id in user_ids_by_email.iteritems()):
deleted_rows.append(rule_row)
continue
for deleted_row in deleted_rows:
project_id, rank, predicate, consequence = deleted_row
self.filterrule_tbl.Delete(
cnxn, project_id=project_id, rank=rank, predicate=predicate,
consequence=consequence, commit=False)
deleted_project_rules_dict = self._DeserializeFilterRules(deleted_rows)
return deleted_project_rules_dict
### Creating hotlists
def CreateHotlist(
self, cnxn, name, summary, description, owner_ids, editor_ids,
issue_ids=None, is_private=None, default_col_spec=None, ts=None):
# type: (MonorailConnection, string, string, string, Collection[int],
# Optional[Collection[int]], Optional[Boolean], Optional[string],
# Optional[int] -> int
"""Create and store a Hotlist with the given attributes.
Args:
cnxn: connection to SQL database.
name: a valid hotlist name.
summary: one-line explanation of the hotlist.
description: one-page explanation of the hotlist.
owner_ids: a list of user IDs for the hotlist owners.
editor_ids: a list of user IDs for the hotlist editors.
issue_ids: a list of issue IDs for the hotlist issues.
is_private: True if the hotlist can only be viewed by owners and editors.
default_col_spec: the default columns that show in list view.
ts: a timestamp for when this hotlist was created.
Returns:
The int id of the new hotlist.
Raises:
InputException: if the hotlist name is invalid.
HotlistAlreadyExists: if any of the owners already own a hotlist with
the same name.
UnownedHotlistException: if owner_ids is empty.
"""
# TODO(crbug.com/monorail/7677): These checks should be done in the
# the business layer.
# Remove when calls from non-business layer code are removed.
if not owner_ids: # Should never happen.
logging.error('Attempt to create unowned Hotlist: name:%r', name)
raise UnownedHotlistException()
if not framework_bizobj.IsValidHotlistName(name):
raise exceptions.InputException(
'%s is not a valid name for a Hotlist' % name)
if self.LookupHotlistIDs(cnxn, [name], owner_ids):
raise HotlistAlreadyExists()
# TODO(crbug.com/monorail/7677): We are not setting a
# default default_col_spec in v3.
if default_col_spec is None:
default_col_spec = features_constants.DEFAULT_COL_SPEC
hotlist_item_fields = [
(issue_id, rank*100, owner_ids[0], ts, '') for
rank, issue_id in enumerate(issue_ids or [])]
hotlist = features_pb2.MakeHotlist(
name, hotlist_item_fields=hotlist_item_fields, summary=summary,
description=description, is_private=is_private, owner_ids=owner_ids,
editor_ids=editor_ids, default_col_spec=default_col_spec)
hotlist.hotlist_id = self._InsertHotlist(cnxn, hotlist)
return hotlist
def UpdateHotlist(
self, cnxn, hotlist_id, name=None, summary=None, description=None,
is_private=None, default_col_spec=None, owner_id=None,
add_editor_ids=None):
"""Update the DB with the given hotlist information."""
# Note: If something is None, it does not get changed to None,
# it just does not get updated.
hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
if not hotlist:
raise NoSuchHotlistException()
delta = {}
if name is not None:
delta['name'] = name
if summary is not None:
delta['summary'] = summary
if description is not None:
delta['description'] = description
if is_private is not None:
delta['is_private'] = is_private
if default_col_spec is not None:
delta['default_col_spec'] = default_col_spec
self.hotlist_tbl.Update(cnxn, delta, id=hotlist_id, commit=False)
insert_rows = []
if owner_id is not None:
insert_rows.append((hotlist_id, owner_id, 'owner'))
self.hotlist2user_tbl.Delete(
cnxn, hotlist_id=hotlist_id, role='owner', commit=False)
if add_editor_ids:
insert_rows.extend(
[(hotlist_id, user_id, 'editor') for user_id in add_editor_ids])
if insert_rows:
self.hotlist2user_tbl.InsertRows(
cnxn, HOTLIST2USER_COLS, insert_rows, commit=False)
cnxn.Commit()
self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
if not hotlist.owner_ids: # Should never happen.
logging.warn('Modifying unowned Hotlist: id:%r, name:%r',
hotlist_id, hotlist.name)
elif hotlist.name:
self.hotlist_id_2lc.InvalidateKeys(
cnxn, [(hotlist.name.lower(), owner_id) for
owner_id in hotlist.owner_ids])
# Update the hotlist PB in RAM
if name is not None:
hotlist.name = name
if summary is not None:
hotlist.summary = summary
if description is not None:
hotlist.description = description
if is_private is not None:
hotlist.is_private = is_private
if default_col_spec is not None:
hotlist.default_col_spec = default_col_spec
if owner_id is not None:
hotlist.owner_ids = [owner_id]
if add_editor_ids:
hotlist.editor_ids.extend(add_editor_ids)
def RemoveHotlistEditors(self, cnxn, hotlist_id, remove_editor_ids):
# type: MonorailConnection, int, Collection[int]
"""Remove given editors from the specified hotlist.
Args:
cnxn: MonorailConnection object.
hotlist_id: int ID of the Hotlist we want to update.
remove_editor_ids: collection of existing hotlist editor User IDs
that we want to remove from the hotlist.
Raises:
NoSuchHotlistException: if the hotlist is not found.
InputException: if there are not editors to remove.
"""
if not remove_editor_ids:
raise exceptions.InputException
hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
if not hotlist:
raise NoSuchHotlistException()
self.hotlist2user_tbl.Delete(
cnxn, hotlist_id=hotlist_id, user_id=remove_editor_ids)
self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
# Update in-memory data
for remove_id in remove_editor_ids:
hotlist.editor_ids.remove(remove_id)
def UpdateHotlistIssues(
self,
cnxn, # type: sql.MonorailConnection
hotlist_id, # type: int
updated_items, # type: Collection[features_pb2.HotlistItem]
remove_issue_ids, # type: Collection[int]
issue_svc, # type: issue_svc.IssueService
chart_svc, # type: chart_svc.ChartService
commit=True # type: Optional[bool]
):
# type: (...) -> None
"""Update the Issues in a Hotlist.
This method removes the given remove_issue_ids from a Hotlist then
updates or adds the HotlistItems found in updated_items. HotlistItems
in updated_items may exist in the hotlist and just need to be updated
or they may be new items that should be added to the Hotlist.
Args:
cnxn: MonorailConnection object.
hotlist_id: int ID of the Hotlist to update.
updated_items: Collection of HotlistItems that either already exist in
the hotlist and need to be updated or needed to be added to the hotlist.
remove_issue_ids: Collection of Issue IDs that should be removed from the
hotlist.
issue_svc: IssueService object.
chart_svc: ChartService object.
Raises:
NoSuchHotlistException if a hotlist with the given ID is not found.
InputException if no changes were given.
"""
if not updated_items and not remove_issue_ids:
raise exceptions.InputException('No changes to make')
hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
if not hotlist:
raise NoSuchHotlistException()
# Used to hold the updated Hotlist.items to use when updating
# the in-memory hotlist.
all_hotlist_items = list(hotlist.items)
# Used to hold ids of issues affected by this change for storing
# Issue Snapshots.
affected_issue_ids = set()
if remove_issue_ids:
affected_issue_ids.update(remove_issue_ids)
self.hotlist2issue_tbl.Delete(
cnxn, hotlist_id=hotlist_id, issue_id=remove_issue_ids, commit=False)
all_hotlist_items = filter(
lambda item: item.issue_id not in remove_issue_ids, all_hotlist_items)
if updated_items:
updated_issue_ids = [item.issue_id for item in updated_items]
affected_issue_ids.update(updated_issue_ids)
self.hotlist2issue_tbl.Delete(
cnxn, hotlist_id=hotlist_id, issue_id=updated_issue_ids, commit=False)
insert_rows = []
for item in updated_items:
insert_rows.append(
(
hotlist_id, item.issue_id, item.rank, item.adder_id,
item.date_added, item.note))
self.hotlist2issue_tbl.InsertRows(
cnxn, cols=HOTLIST2ISSUE_COLS, row_values=insert_rows, commit=False)
all_hotlist_items = filter(
lambda item: item.issue_id not in updated_issue_ids,
all_hotlist_items)
all_hotlist_items.extend(updated_items)
if commit:
cnxn.Commit()
self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
# Update in-memory hotlist items.
hotlist.items = sorted(all_hotlist_items, key=lambda item: item.rank)
issues = issue_svc.GetIssues(cnxn, list(affected_issue_ids))
chart_svc.StoreIssueSnapshots(cnxn, issues, commit=commit)
# TODO(crbug/monorail/7104): {Add|Remove}IssuesToHotlists both call
# UpdateHotlistItems to add/remove issues from a hotlist.
# UpdateHotlistItemsFields is called by methods for reranking existing issues
# and updating HotlistItem notes.
# (1) We are removing notes from HotlistItems. crbug/monorail/####
# (2) our v3 AddHotlistItems will allow for inserting new issues to
# non-last ranks of a hotlist. So there could be some shared code
# for the reranking path and the adding issues path.
# UpdateHotlistIssues will be handling adding, removing, and reranking issues.
# {Add|Remove}IssueToHotlists, UpdateHotlistItems, UpdateHotlistItemFields
# should be removed, once all methods are updated to call UpdateHotlistIssues.
def AddIssueToHotlists(self, cnxn, hotlist_ids, issue_tuple, issue_svc,
chart_svc, commit=True):
"""Add a single issue, specified in the issue_tuple, to the given hotlists.
Args:
cnxn: connection to SQL database.
hotlist_ids: a list of hotlist_ids to add the issues to.
issue_tuple: (issue_id, user_id, ts, note) of the issue to be added.
issue_svc: an instance of IssueService.
chart_svc: an instance of ChartService.
"""
self.AddIssuesToHotlists(cnxn, hotlist_ids, [issue_tuple], issue_svc,
chart_svc, commit=commit)
def AddIssuesToHotlists(self, cnxn, hotlist_ids, added_tuples, issue_svc,
chart_svc, commit=True):
"""Add the issues given in the added_tuples list to the given hotlists.
Args:
cnxn: connection to SQL database.
hotlist_ids: a list of hotlist_ids to add the issues to.
added_tuples: a list of (issue_id, user_id, ts, note)
for issues to be added.
issue_svc: an instance of IssueService.
chart_svc: an instance of ChartService.
"""
for hotlist_id in hotlist_ids:
self.UpdateHotlistItems(cnxn, hotlist_id, [], added_tuples, commit=commit)
issues = issue_svc.GetIssues(cnxn,
[added_tuple[0] for added_tuple in added_tuples])
chart_svc.StoreIssueSnapshots(cnxn, issues, commit=commit)
def RemoveIssuesFromHotlists(self, cnxn, hotlist_ids, issue_ids, issue_svc,
chart_svc, commit=True):
"""Remove the issues given in issue_ids from the given hotlists.
Args:
cnxn: connection to SQL database.
hotlist_ids: a list of hotlist ids to remove the issues from.
issue_ids: a list of issue_ids to be removed.
issue_svc: an instance of IssueService.
chart_svc: an instance of ChartService.
"""
for hotlist_id in hotlist_ids:
self.UpdateHotlistItems(cnxn, hotlist_id, issue_ids, [], commit=commit)
issues = issue_svc.GetIssues(cnxn, issue_ids)
chart_svc.StoreIssueSnapshots(cnxn, issues, commit=commit)
def UpdateHotlistItems(
self, cnxn, hotlist_id, remove, added_tuples, commit=True):
"""Updates a hotlist's list of hotlistissues.
Args:
cnxn: connection to SQL database.
hotlist_id: the ID of the hotlist to update.
remove: a list of issue_ids for be removed.
added_tuples: a list of (issue_id, user_id, ts, note)
for issues to be added.
"""
hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
if not hotlist:
raise NoSuchHotlistException()
# adding new Hotlistissues, ignoring pairs where issue_id is already in
# hotlist's iid_rank_pairs
current_issues_ids = {
item.issue_id for item in hotlist.items}
self.hotlist2issue_tbl.Delete(
cnxn, hotlist_id=hotlist_id,
issue_id=[remove_id for remove_id in remove
if remove_id in current_issues_ids],
commit=False)
if hotlist.items:
items_sorted = sorted(hotlist.items, key=lambda item: item.rank)
rank_base = items_sorted[-1].rank + 10
else:
rank_base = 1
insert_rows = [
(hotlist_id, issue_id, rank*10 + rank_base, user_id, ts, note)
for (rank, (issue_id, user_id, ts, note)) in enumerate(added_tuples)
if issue_id not in current_issues_ids]
self.hotlist2issue_tbl.InsertRows(
cnxn, cols=HOTLIST2ISSUE_COLS, row_values=insert_rows, commit=commit)
self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
# removing an issue that was never in the hotlist would not cause any
# problems.
items = [
item for item in hotlist.items if
item.issue_id not in remove]
new_hotlist_items = [
features_pb2.MakeHotlistItem(issue_id, rank, user_id, ts, note)
for (_hid, issue_id, rank, user_id, ts, note) in insert_rows]
items.extend(new_hotlist_items)
hotlist.items = items
def UpdateHotlistItemsFields(
self, cnxn, hotlist_id, new_ranks=None, new_notes=None, commit=True):
"""Updates rankings or notes of hotlistissues.
Args:
cnxn: connection to SQL database.
hotlist_id: the ID of the hotlist to update.
new_ranks : This should be a dictionary of {issue_id: rank}.
new_notes: This should be a diciontary of {issue_id: note}.
commit: set to False to skip the DB commit and do it in the caller.
"""
hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
if not hotlist:
raise NoSuchHotlistException()
if new_ranks is None:
new_ranks = {}
if new_notes is None:
new_notes = {}
issue_ids = []
insert_rows = []
# Update the hotlist PB in RAM
for hotlist_item in hotlist.items:
item_updated = False
if hotlist_item.issue_id in new_ranks:
# Update rank before adding it to insert_rows
hotlist_item.rank = new_ranks[hotlist_item.issue_id]
item_updated = True
if hotlist_item.issue_id in new_notes:
# Update note before adding it to insert_rows
hotlist_item.note = new_notes[hotlist_item.issue_id]
item_updated = True
if item_updated:
issue_ids.append(hotlist_item.issue_id)
insert_rows.append((
hotlist_id, hotlist_item.issue_id, hotlist_item.rank,
hotlist_item.adder_id, hotlist_item.date_added, hotlist_item.note))
hotlist.items = sorted(hotlist.items, key=lambda item: item.rank)
self.hotlist2issue_tbl.Delete(
cnxn, hotlist_id=hotlist_id, issue_id=issue_ids, commit=False)
self.hotlist2issue_tbl.InsertRows(
cnxn, cols=HOTLIST2ISSUE_COLS , row_values=insert_rows, commit=commit)
self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
def _InsertHotlist(self, cnxn, hotlist):
"""Insert the given hotlist into the database."""
hotlist_id = self.hotlist_tbl.InsertRow(
cnxn, name=hotlist.name, summary=hotlist.summary,
description=hotlist.description, is_private=hotlist.is_private,
default_col_spec=hotlist.default_col_spec)
logging.info('stored hotlist was given id %d', hotlist_id)
self.hotlist2issue_tbl.InsertRows(
cnxn, HOTLIST2ISSUE_COLS,
[(hotlist_id, issue.issue_id, issue.rank,
issue.adder_id, issue.date_added, issue.note)
for issue in hotlist.items],
commit=False)
self.hotlist2user_tbl.InsertRows(
cnxn, HOTLIST2USER_COLS,
[(hotlist_id, user_id, 'owner')
for user_id in hotlist.owner_ids] +
[(hotlist_id, user_id, 'editor')
for user_id in hotlist.editor_ids] +
[(hotlist_id, user_id, 'follower')
for user_id in hotlist.follower_ids])
self.hotlist_user_to_ids.InvalidateKeys(cnxn, hotlist.owner_ids)
return hotlist_id
def TransferHotlistOwnership(
self, cnxn, hotlist, new_owner_id, remain_editor, commit=True):
"""Transfers ownership of a hotlist to a new owner."""
new_editor_ids = hotlist.editor_ids
if remain_editor:
new_editor_ids.extend(hotlist.owner_ids)
if new_owner_id in new_editor_ids:
new_editor_ids.remove(new_owner_id)
new_follower_ids = hotlist.follower_ids
if new_owner_id in new_follower_ids:
new_follower_ids.remove(new_owner_id)
self.UpdateHotlistRoles(
cnxn, hotlist.hotlist_id, [new_owner_id], new_editor_ids,
new_follower_ids, commit=commit)
### Lookup hotlist IDs
def LookupHotlistIDs(self, cnxn, hotlist_names, owner_ids):
"""Return a dict of (name, owner_id) mapped to hotlist_id for all hotlists
with one of the given names and any of the given owners. Hotlists that
match multiple owners will be in the dict multiple times."""
id_dict, _missed_keys = self.hotlist_id_2lc.GetAll(
cnxn, [(name.lower(), owner_id)
for name in hotlist_names for owner_id in owner_ids])
return id_dict
def LookupUserHotlists(self, cnxn, user_ids):
"""Return a dict of {user_id: [hotlist_id,...]} for all user_ids."""
id_dict, missed_ids = self.hotlist_user_to_ids.GetAll(user_ids)
if missed_ids:
retrieved_dict = {user_id: [] for user_id in missed_ids}
id_rows = self.hotlist2user_tbl.Select(
cnxn, cols=['user_id', 'hotlist_id'], user_id=user_ids,
left_joins=[('Hotlist ON hotlist_id = id', [])],
where=[('Hotlist.is_deleted = %s', [False])])
for (user_id, hotlist_id) in id_rows:
retrieved_dict[user_id].append(hotlist_id)
self.hotlist_user_to_ids.CacheAll(retrieved_dict)
id_dict.update(retrieved_dict)
return id_dict
def LookupIssueHotlists(self, cnxn, issue_ids):
"""Return a dict of {issue_id: [hotlist_id,...]} for all issue_ids."""
# TODO(jojwang): create hotlist_issue_to_ids cache
retrieved_dict = {issue_id: [] for issue_id in issue_ids}
id_rows = self.hotlist2issue_tbl.Select(
cnxn, cols=['hotlist_id', 'issue_id'], issue_id=issue_ids,
left_joins=[('Hotlist ON hotlist_id = id', [])],
where=[('Hotlist.is_deleted = %s', [False])])
for hotlist_id, issue_id in id_rows:
retrieved_dict[issue_id].append(hotlist_id)
return retrieved_dict
def GetProjectIDsFromHotlist(self, cnxn, hotlist_id):
project_id_rows = self.hotlist2issue_tbl.Select(cnxn,
cols=['Issue.project_id'], hotlist_id=hotlist_id, distinct=True,
left_joins=[('Issue ON issue_id = id', [])])
return [row[0] for row in project_id_rows]
### Get hotlists
def GetHotlists(self, cnxn, hotlist_ids, use_cache=True):
"""Returns dict of {hotlist_id: hotlist PB}."""
hotlists_dict, missed_ids = self.hotlist_2lc.GetAll(
cnxn, hotlist_ids, use_cache=use_cache)
if missed_ids:
raise NoSuchHotlistException()
return hotlists_dict
def GetHotlistsByUserID(self, cnxn, user_id, use_cache=True):
"""Get a list of hotlist PBs for a given user."""
hotlist_id_dict = self.LookupUserHotlists(cnxn, [user_id])
hotlists = self.GetHotlists(
cnxn, hotlist_id_dict.get(user_id, []), use_cache=use_cache)
return list(hotlists.values())
def GetHotlistsByIssueID(self, cnxn, issue_id, use_cache=True):
"""Get a list of hotlist PBs for a given issue."""
hotlist_id_dict = self.LookupIssueHotlists(cnxn, [issue_id])
hotlists = self.GetHotlists(
cnxn, hotlist_id_dict.get(issue_id, []), use_cache=use_cache)
return list(hotlists.values())
def GetHotlist(self, cnxn, hotlist_id, use_cache=True):
"""Returns hotlist PB."""
hotlist_dict = self.GetHotlists(cnxn, [hotlist_id], use_cache=use_cache)
return hotlist_dict[hotlist_id]
def GetHotlistsByID(self, cnxn, hotlist_ids, use_cache=True):
"""Load all the Hotlist PBs for the given hotlists.
Args:
cnxn: connection to SQL database.
hotlist_ids: list of hotlist ids.
use_cache: specifiy False to force database query.
Returns:
A dict mapping ids to the corresponding Hotlist protocol buffers and
a list of any hotlist_ids that were not found.
"""
hotlists_dict, missed_ids = self.hotlist_2lc.GetAll(
cnxn, hotlist_ids, use_cache=use_cache)
return hotlists_dict, missed_ids
def GetHotlistByID(self, cnxn, hotlist_id, use_cache=True):
"""Load the specified hotlist from the database, None if does not exist."""
hotlist_dict, _ = self.GetHotlistsByID(
cnxn, [hotlist_id], use_cache=use_cache)
return hotlist_dict.get(hotlist_id)
def UpdateHotlistRoles(
self, cnxn, hotlist_id, owner_ids, editor_ids, follower_ids, commit=True):
""""Store the hotlist's roles in the DB."""
# This will be a newly contructed object, not from the cache and not
# shared with any other thread.
hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
if not hotlist:
raise NoSuchHotlistException()
self.hotlist2user_tbl.Delete(
cnxn, hotlist_id=hotlist_id, commit=False)
insert_rows = [(hotlist_id, user_id, 'owner') for user_id in owner_ids]
insert_rows.extend(
[(hotlist_id, user_id, 'editor') for user_id in editor_ids])
insert_rows.extend(
[(hotlist_id, user_id, 'follower') for user_id in follower_ids])
self.hotlist2user_tbl.InsertRows(
cnxn, HOTLIST2USER_COLS, insert_rows, commit=False)
if commit:
cnxn.Commit()
self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
self.hotlist_user_to_ids.InvalidateKeys(cnxn, hotlist.owner_ids)
hotlist.owner_ids = owner_ids
hotlist.editor_ids = editor_ids
hotlist.follower_ids = follower_ids
def DeleteHotlist(self, cnxn, hotlist_id, commit=True):
hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
if not hotlist:
raise NoSuchHotlistException()
# Fetch all associated project IDs in order to invalidate their cache.
project_ids = self.GetProjectIDsFromHotlist(cnxn, hotlist_id)
delta = {'is_deleted': True}
self.hotlist_tbl.Update(cnxn, delta, id=hotlist_id, commit=commit)
self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
self.hotlist_user_to_ids.InvalidateKeys(cnxn, hotlist.owner_ids)
self.hotlist_user_to_ids.InvalidateKeys(cnxn, hotlist.editor_ids)
if not hotlist.owner_ids: # Should never happen.
logging.warn('Soft-deleting unowned Hotlist: id:%r, name:%r',
hotlist_id, hotlist.name)
elif hotlist.name:
self.hotlist_id_2lc.InvalidateKeys(
cnxn, [(hotlist.name.lower(), owner_id) for
owner_id in hotlist.owner_ids])
for project_id in project_ids:
self.config_service.InvalidateMemcacheForEntireProject(project_id)
def ExpungeHotlists(
self, cnxn, hotlist_ids, star_svc, user_svc, chart_svc, commit=True):
"""Wipes the given hotlists from the DB tables.
This method will only do cache invalidation if commit is set to True.
Args:
cnxn: connection to SQL database.
hotlist_ids: the ID of the hotlists to Expunge.
star_svc: an instance of a HotlistStarService.
user_svc: an instance of a UserService.
chart_svc: an instance of a ChartService.
commit: set to False to skip the DB commit and do it in the caller.
"""
hotlists_by_id = self.GetHotlists(cnxn, hotlist_ids)
for hotlist_id in hotlist_ids:
star_svc.ExpungeStars(cnxn, hotlist_id, commit=commit)
chart_svc.ExpungeHotlistsFromIssueSnapshots(
cnxn, hotlist_ids, commit=commit)
user_svc.ExpungeHotlistsFromHistory(cnxn, hotlist_ids, commit=commit)
self.hotlist2user_tbl.Delete(cnxn, hotlist_id=hotlist_ids, commit=commit)
self.hotlist2issue_tbl.Delete(cnxn, hotlist_id=hotlist_ids, commit=commit)
self.hotlist_tbl.Delete(cnxn, id=hotlist_ids, commit=commit)
# Invalidate cache for deleted hotlists.
self.hotlist_2lc.InvalidateKeys(cnxn, hotlist_ids)
users_to_invalidate = set()
for hotlist in hotlists_by_id.values():
users_to_invalidate.update(
hotlist.owner_ids + hotlist.editor_ids + hotlist.follower_ids)
self.hotlist_id_2lc.InvalidateKeys(
cnxn, [(hotlist.name, owner_id) for owner_id in hotlist.owner_ids])
self.hotlist_user_to_ids.InvalidateKeys(cnxn, list(users_to_invalidate))
hotlist_project_ids = set()
for hotlist_id in hotlist_ids:
hotlist_project_ids.update(self.GetProjectIDsFromHotlist(
cnxn, hotlist_id))
for project_id in hotlist_project_ids:
self.config_service.InvalidateMemcacheForEntireProject(project_id)
def ExpungeUsersInHotlists(
self, cnxn, user_ids, star_svc, user_svc, chart_svc):
"""Wipes the given users and any hotlists they owned from the
hotlists system.
This method will not commit the operation. This method will not make
changes to in-memory data.
"""
# Transfer hotlist ownership to editors, if possible.
hotlist_ids_by_user_id = self.LookupUserHotlists(cnxn, user_ids)
hotlist_ids = [hotlist_id for hotlist_ids in hotlist_ids_by_user_id.values()
for hotlist_id in hotlist_ids]
hotlists_by_id, missed = self.GetHotlistsByID(
cnxn, list(set(hotlist_ids)), use_cache=False)
logging.info('Missed hotlists: %s', missed)
hotlists_to_delete = []
for hotlist_id, hotlist in hotlists_by_id.items():
# One of the users to be deleted is an owner of hotlist.
if not set(hotlist.owner_ids).isdisjoint(user_ids):
hotlists_to_delete.append(hotlist_id)
candidate_new_owners = [user_id for user_id in hotlist.editor_ids
if user_id not in user_ids]
for candidate_id in candidate_new_owners:
if not self.LookupHotlistIDs(cnxn, [hotlist.name], [candidate_id]):
self.TransferHotlistOwnership(
cnxn, hotlist, candidate_id, False, commit=False)
# Hotlist transferred successfully. No need to delete it.
hotlists_to_delete.remove(hotlist_id)
break
# Delete users
self.hotlist2user_tbl.Delete(cnxn, user_id=user_ids, commit=False)
self.hotlist2issue_tbl.Update(
cnxn, {'adder_id': framework_constants.DELETED_USER_ID},
adder_id=user_ids, commit=False)
user_svc.ExpungeUsersHotlistsHistory(cnxn, user_ids, commit=False)
# Delete hotlists
if hotlists_to_delete:
self.ExpungeHotlists(
cnxn, hotlists_to_delete, star_svc, user_svc, chart_svc, commit=False)
class HotlistAlreadyExists(Exception):
"""Tried to create a hotlist with the same name as another hotlist
with the same owner."""
pass
class NoSuchHotlistException(Exception):
"""The requested hotlist was not found."""
pass
class UnownedHotlistException(Exception):
"""Tried to create a hotlist with no owner."""
pass