blob: b2edce3da2f73180b0b362f734eaac9c11de9c25 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2016 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
Copybara854996b2021-09-07 19:36:02 +00004
5"""A class that provides persistence for Monorail's additional features.
6
7Business objects are described in tracker_pb2.py, features_pb2.py, and
8tracker_bizobj.py.
9"""
10from __future__ import print_function
11from __future__ import division
12from __future__ import absolute_import
13
14import collections
15import logging
16import re
17import time
18
19import settings
20
21from features import features_constants
22from features import filterrules_helpers
23from framework import exceptions
24from framework import framework_bizobj
25from framework import framework_constants
26from framework import sql
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010027from mrproto import features_pb2
Copybara854996b2021-09-07 19:36:02 +000028from services import caches
29from services import config_svc
30from tracker import tracker_bizobj
31from tracker import tracker_constants
32
33QUICKEDITHISTORY_TABLE_NAME = 'QuickEditHistory'
34QUICKEDITMOSTRECENT_TABLE_NAME = 'QuickEditMostRecent'
35SAVEDQUERY_TABLE_NAME = 'SavedQuery'
36PROJECT2SAVEDQUERY_TABLE_NAME = 'Project2SavedQuery'
37SAVEDQUERYEXECUTESINPROJECT_TABLE_NAME = 'SavedQueryExecutesInProject'
38USER2SAVEDQUERY_TABLE_NAME = 'User2SavedQuery'
39FILTERRULE_TABLE_NAME = 'FilterRule'
40HOTLIST_TABLE_NAME = 'Hotlist'
41HOTLIST2ISSUE_TABLE_NAME = 'Hotlist2Issue'
42HOTLIST2USER_TABLE_NAME = 'Hotlist2User'
43
44
45QUICKEDITHISTORY_COLS = [
46 'user_id', 'project_id', 'slot_num', 'command', 'comment']
47QUICKEDITMOSTRECENT_COLS = ['user_id', 'project_id', 'slot_num']
48SAVEDQUERY_COLS = ['id', 'name', 'base_query_id', 'query']
49PROJECT2SAVEDQUERY_COLS = ['project_id', 'rank', 'query_id']
50SAVEDQUERYEXECUTESINPROJECT_COLS = ['query_id', 'project_id']
51USER2SAVEDQUERY_COLS = ['user_id', 'rank', 'query_id', 'subscription_mode']
52FILTERRULE_COLS = ['project_id', 'rank', 'predicate', 'consequence']
53HOTLIST_COLS = [
54 'id', 'name', 'summary', 'description', 'is_private', 'default_col_spec']
55HOTLIST_ABBR_COLS = ['id', 'name', 'summary', 'is_private']
56HOTLIST2ISSUE_COLS = [
57 'hotlist_id', 'issue_id', 'rank', 'adder_id', 'added', 'note']
58HOTLIST2USER_COLS = ['hotlist_id', 'user_id', 'role_name']
59
60
61# Regex for parsing one action in the filter rule consequence storage syntax.
62CONSEQUENCE_RE = re.compile(
63 r'(default_status:(?P<default_status>[-.\w]+))|'
64 r'(default_owner_id:(?P<default_owner_id>\d+))|'
65 r'(add_cc_id:(?P<add_cc_id>\d+))|'
66 r'(add_label:(?P<add_label>[-.\w]+))|'
67 r'(add_notify:(?P<add_notify>[-.@\w]+))|'
68 r'(warning:(?P<warning>.+))|' # Warnings consume the rest of the string.
69 r'(error:(?P<error>.+))' # Errors consume the rest of the string.
70 )
71
72class HotlistTwoLevelCache(caches.AbstractTwoLevelCache):
73 """Class to manage both RAM and memcache for Hotlist PBs."""
74
75 def __init__(self, cachemanager, features_service):
76 super(HotlistTwoLevelCache, self).__init__(
77 cachemanager, 'hotlist', 'hotlist:', features_pb2.Hotlist)
78 self.features_service = features_service
79
80 def _DeserializeHotlists(
81 self, hotlist_rows, issue_rows, role_rows):
82 """Convert database rows into a dictionary of Hotlist PB keyed by ID.
83
84 Args:
85 hotlist_rows: a list of hotlist rows from HOTLIST_TABLE_NAME.
86 issue_rows: a list of issue rows from HOTLIST2ISSUE_TABLE_NAME,
87 ordered by rank DESC, issue_id.
88 role_rows: a list of role rows from HOTLIST2USER_TABLE_NAME.
89
90 Returns:
91 a dict mapping hotlist_id to hotlist PB"""
92 hotlist_dict = {}
93
94 for hotlist_row in hotlist_rows:
95 (hotlist_id, hotlist_name, summary, description, is_private,
96 default_col_spec) = hotlist_row
97 hotlist = features_pb2.MakeHotlist(
98 hotlist_name, hotlist_id=hotlist_id, summary=summary,
99 description=description, is_private=bool(is_private),
100 default_col_spec=default_col_spec)
101 hotlist_dict[hotlist_id] = hotlist
102
103 for (hotlist_id, issue_id, rank, adder_id, added, note) in issue_rows:
104 hotlist = hotlist_dict.get(hotlist_id)
105 if hotlist:
106 hotlist.items.append(
107 features_pb2.MakeHotlistItem(issue_id=issue_id, rank=rank,
108 adder_id=adder_id , date_added=added,
109 note=note))
110 else:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100111 logging.warning('hotlist %d not found', hotlist_id)
Copybara854996b2021-09-07 19:36:02 +0000112
113 for (hotlist_id, user_id, role_name) in role_rows:
114 hotlist = hotlist_dict.get(hotlist_id)
115 if not hotlist:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100116 logging.warning('hotlist %d not found', hotlist_id)
Copybara854996b2021-09-07 19:36:02 +0000117 elif role_name == 'owner':
118 hotlist.owner_ids.append(user_id)
119 elif role_name == 'editor':
120 hotlist.editor_ids.append(user_id)
121 elif role_name == 'follower':
122 hotlist.follower_ids.append(user_id)
123 else:
124 logging.info('unknown role name %s', role_name)
125
126 return hotlist_dict
127
128 def FetchItems(self, cnxn, keys):
129 """On RAM and memcache miss, hit the database to get missing hotlists."""
130 hotlist_rows = self.features_service.hotlist_tbl.Select(
131 cnxn, cols=HOTLIST_COLS, is_deleted=False, id=keys)
132 issue_rows = self.features_service.hotlist2issue_tbl.Select(
133 cnxn, cols=HOTLIST2ISSUE_COLS, hotlist_id=keys,
134 order_by=[('rank DESC', []), ('issue_id', [])])
135 role_rows = self.features_service.hotlist2user_tbl.Select(
136 cnxn, cols=HOTLIST2USER_COLS, hotlist_id=keys)
137 retrieved_dict = self._DeserializeHotlists(
138 hotlist_rows, issue_rows, role_rows)
139 return retrieved_dict
140
141
142class HotlistIDTwoLevelCache(caches.AbstractTwoLevelCache):
143 """Class to manage both RAM and memcache for hotlist_ids.
144
145 Keys for this cache are tuples (hotlist_name.lower(), owner_id).
146 This cache should be used to fetch hotlist_ids owned by users or
147 to check if a user owns a hotlist with a certain name, so the
148 hotlist_names in keys will always be in lowercase.
149 """
150
151 def __init__(self, cachemanager, features_service):
152 super(HotlistIDTwoLevelCache, self).__init__(
153 cachemanager, 'hotlist_id', 'hotlist_id:', int,
154 max_size=settings.issue_cache_max_size)
155 self.features_service = features_service
156
157 def _MakeCache(self, cache_manager, kind, max_size=None):
158 """Override normal RamCache creation with ValueCentricRamCache."""
159 return caches.ValueCentricRamCache(cache_manager, kind, max_size=max_size)
160
161 def _KeyToStr(self, key):
162 """This cache uses pairs of (str, int) as keys. Convert them to strings."""
163 return '%s,%d' % key
164
165 def _StrToKey(self, key_str):
166 """This cache uses pairs of (str, int) as keys.
167 Convert them from strings.
168 """
169 hotlist_name_str, owner_id_str = key_str.split(',')
170 return (hotlist_name_str, int(owner_id_str))
171
172 def _DeserializeHotlistIDs(
173 self, hotlist_rows, owner_rows, wanted_names_for_owners):
174 """Convert database rows into a dictionary of hotlist_ids keyed by (
175 hotlist_name, owner_id).
176
177 Args:
178 hotlist_rows: a list of hotlist rows [id, name] from HOTLIST for
179 with names we are interested in.
180 owner_rows: a list of role rows [hotlist_id, uwer_id] from HOTLIST2USER
181 for owners that we are interested in that own hotlists with names that
182 we are interested in.
183 wanted_names_for_owners: a dict of
184 {owner_id: [hotlist_name.lower(), ...], ...}
185 so we know which (hotlist_name, owner_id) keys to return.
186
187 Returns:
188 A dict mapping (hotlist_name.lower(), owner_id) keys to hotlist_id values.
189 """
190 hotlist_ids_dict = {}
191 if not hotlist_rows or not owner_rows:
192 return hotlist_ids_dict
193
194 hotlist_to_owner_id = {}
195
196 # Note: owner_rows contains hotlist owners that we are interested in, but
197 # may not own hotlists with names we are interested in.
198 for (hotlist_id, user_id) in owner_rows:
199 found_owner_id = hotlist_to_owner_id.get(hotlist_id)
200 if found_owner_id:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100201 logging.warning(
202 'hotlist %d has more than one owner: %d, %d', hotlist_id, user_id,
203 found_owner_id)
Copybara854996b2021-09-07 19:36:02 +0000204 hotlist_to_owner_id[hotlist_id] = user_id
205
206 # Note: hotlist_rows hotlists found in the owner_rows that have names
207 # we're interested in.
208 # We use wanted_names_for_owners to filter out hotlists in hotlist_rows
209 # that have a (hotlist_name, owner_id) pair we are not interested in.
210 for (hotlist_id, hotlist_name) in hotlist_rows:
211 owner_id = hotlist_to_owner_id.get(hotlist_id)
212 if owner_id:
213 if hotlist_name.lower() in wanted_names_for_owners.get(owner_id, []):
214 hotlist_ids_dict[(hotlist_name.lower(), owner_id)] = hotlist_id
215
216 return hotlist_ids_dict
217
218 def FetchItems(self, cnxn, keys):
219 """On RAM and memcache miss, hit the database."""
220 hotlist_names, _owner_ids = zip(*keys)
221 # Keys may contain [(name1, user1), (name1, user2)] so we cast this to
222 # a set to make sure 'name1' is not repeated.
223 hotlist_names_set = set(hotlist_names)
224 # Pass this dict to _DeserializeHotlistIDs so it knows what hotlist names
225 # we're interested in for each owner.
226 wanted_names_for_owner = collections.defaultdict(list)
227 for hotlist_name, owner_id in keys:
228 wanted_names_for_owner[owner_id].append(hotlist_name.lower())
229
230 role_rows = self.features_service.hotlist2user_tbl.Select(
231 cnxn, cols=['hotlist_id', 'user_id'],
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100232 user_id=sorted(wanted_names_for_owner.keys()), role_name='owner')
Copybara854996b2021-09-07 19:36:02 +0000233
234 hotlist_ids = [row[0] for row in role_rows]
235 hotlist_rows = self.features_service.hotlist_tbl.Select(
236 cnxn, cols=['id', 'name'], id=hotlist_ids, is_deleted=False,
237 where=[('LOWER(name) IN (%s)' % sql.PlaceHolders(hotlist_names_set),
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100238 [name.lower() for name in sorted(hotlist_names_set)])])
Copybara854996b2021-09-07 19:36:02 +0000239
240 return self._DeserializeHotlistIDs(
241 hotlist_rows, role_rows, wanted_names_for_owner)
242
243
244class FeaturesService(object):
245 """The persistence layer for servlets in the features directory."""
246
247 def __init__(self, cache_manager, config_service):
248 """Initialize this object so that it is ready to use.
249
250 Args:
251 cache_manager: local cache with distributed invalidation.
252 config_service: an instance of ConfigService.
253 """
254 self.quickedithistory_tbl = sql.SQLTableManager(QUICKEDITHISTORY_TABLE_NAME)
255 self.quickeditmostrecent_tbl = sql.SQLTableManager(
256 QUICKEDITMOSTRECENT_TABLE_NAME)
257
258 self.savedquery_tbl = sql.SQLTableManager(SAVEDQUERY_TABLE_NAME)
259 self.project2savedquery_tbl = sql.SQLTableManager(
260 PROJECT2SAVEDQUERY_TABLE_NAME)
261 self.savedqueryexecutesinproject_tbl = sql.SQLTableManager(
262 SAVEDQUERYEXECUTESINPROJECT_TABLE_NAME)
263 self.user2savedquery_tbl = sql.SQLTableManager(USER2SAVEDQUERY_TABLE_NAME)
264
265 self.filterrule_tbl = sql.SQLTableManager(FILTERRULE_TABLE_NAME)
266
267 self.hotlist_tbl = sql.SQLTableManager(HOTLIST_TABLE_NAME)
268 self.hotlist2issue_tbl = sql.SQLTableManager(HOTLIST2ISSUE_TABLE_NAME)
269 self.hotlist2user_tbl = sql.SQLTableManager(HOTLIST2USER_TABLE_NAME)
270
271 self.saved_query_cache = caches.RamCache(
272 cache_manager, 'user', max_size=1000)
273 self.canned_query_cache = caches.RamCache(
274 cache_manager, 'project', max_size=1000)
275
276 self.hotlist_2lc = HotlistTwoLevelCache(cache_manager, self)
277 self.hotlist_id_2lc = HotlistIDTwoLevelCache(cache_manager, self)
278 self.hotlist_user_to_ids = caches.RamCache(cache_manager, 'hotlist')
279
280 self.config_service = config_service
281
282 ### QuickEdit command history
283
284 def GetRecentCommands(self, cnxn, user_id, project_id):
285 """Return recent command items for the "Redo" menu.
286
287 Args:
288 cnxn: Connection to SQL database.
289 user_id: int ID of the current user.
290 project_id: int ID of the current project.
291
292 Returns:
293 A pair (cmd_slots, recent_slot_num). cmd_slots is a list of
294 3-tuples that can be used to populate the "Redo" menu of the
295 quick-edit dialog. recent_slot_num indicates which of those
296 slots should initially populate the command and comment fields.
297 """
298 # Always start with the standard 5 commands.
299 history = tracker_constants.DEFAULT_RECENT_COMMANDS[:]
300 # If the user has modified any, then overwrite some standard ones.
301 history_rows = self.quickedithistory_tbl.Select(
302 cnxn, cols=['slot_num', 'command', 'comment'],
303 user_id=user_id, project_id=project_id)
304 for slot_num, command, comment in history_rows:
305 if slot_num < len(history):
306 history[slot_num - 1] = (command, comment)
307
308 slots = []
309 for idx, (command, comment) in enumerate(history):
310 slots.append((idx + 1, command, comment))
311
312 recent_slot_num = self.quickeditmostrecent_tbl.SelectValue(
313 cnxn, 'slot_num', default=1, user_id=user_id, project_id=project_id)
314
315 return slots, recent_slot_num
316
317 def StoreRecentCommand(
318 self, cnxn, user_id, project_id, slot_num, command, comment):
319 """Store the given command and comment in the user's command history."""
320 self.quickedithistory_tbl.InsertRow(
321 cnxn, replace=True, user_id=user_id, project_id=project_id,
322 slot_num=slot_num, command=command, comment=comment)
323 self.quickeditmostrecent_tbl.InsertRow(
324 cnxn, replace=True, user_id=user_id, project_id=project_id,
325 slot_num=slot_num)
326
327 def ExpungeQuickEditHistory(self, cnxn, project_id):
328 """Completely delete every users' quick edit history for this project."""
329 self.quickeditmostrecent_tbl.Delete(cnxn, project_id=project_id)
330 self.quickedithistory_tbl.Delete(cnxn, project_id=project_id)
331
332 def ExpungeQuickEditsByUsers(self, cnxn, user_ids, limit=None):
333 """Completely delete every given users' quick edits.
334
335 This method will not commit the operations. This method will
336 not make changes to in-memory data.
337 """
338 commit = False
339 self.quickeditmostrecent_tbl.Delete(
340 cnxn, user_id=user_ids, commit=commit, limit=limit)
341 self.quickedithistory_tbl.Delete(
342 cnxn, user_id=user_ids, commit=commit, limit=limit)
343
344 ### Saved User and Project Queries
345
346 def GetSavedQueries(self, cnxn, query_ids):
347 """Retrieve the specified SaveQuery PBs."""
348 # TODO(jrobbins): RAM cache
349 if not query_ids:
350 return {}
351 saved_queries = {}
352 savedquery_rows = self.savedquery_tbl.Select(
353 cnxn, cols=SAVEDQUERY_COLS, id=query_ids)
354 for saved_query_tuple in savedquery_rows:
355 qid, name, base_id, query = saved_query_tuple
356 saved_queries[qid] = tracker_bizobj.MakeSavedQuery(
357 qid, name, base_id, query)
358
359 sqeip_rows = self.savedqueryexecutesinproject_tbl.Select(
360 cnxn, cols=SAVEDQUERYEXECUTESINPROJECT_COLS, query_id=query_ids)
361 for query_id, project_id in sqeip_rows:
362 saved_queries[query_id].executes_in_project_ids.append(project_id)
363
364 return saved_queries
365
366 def GetSavedQuery(self, cnxn, query_id):
367 """Retrieve the specified SaveQuery PB."""
368 saved_queries = self.GetSavedQueries(cnxn, [query_id])
369 return saved_queries.get(query_id)
370
371 def _GetUsersSavedQueriesDict(self, cnxn, user_ids):
372 """Return a dict of all SavedQuery PBs for the specified users."""
373 results_dict, missed_uids = self.saved_query_cache.GetAll(user_ids)
374
375 if missed_uids:
376 savedquery_rows = self.user2savedquery_tbl.Select(
377 cnxn, cols=SAVEDQUERY_COLS + ['user_id', 'subscription_mode'],
378 left_joins=[('SavedQuery ON query_id = id', [])],
379 order_by=[('rank', [])], user_id=missed_uids)
380 sqeip_dict = {}
381 if savedquery_rows:
382 query_ids = {row[0] for row in savedquery_rows}
383 sqeip_rows = self.savedqueryexecutesinproject_tbl.Select(
384 cnxn, cols=SAVEDQUERYEXECUTESINPROJECT_COLS, query_id=query_ids)
385 for qid, pid in sqeip_rows:
386 sqeip_dict.setdefault(qid, []).append(pid)
387
388 for saved_query_tuple in savedquery_rows:
389 query_id, name, base_id, query, uid, sub_mode = saved_query_tuple
390 sq = tracker_bizobj.MakeSavedQuery(
391 query_id, name, base_id, query, subscription_mode=sub_mode,
392 executes_in_project_ids=sqeip_dict.get(query_id, []))
393 results_dict.setdefault(uid, []).append(sq)
394
395 self.saved_query_cache.CacheAll(results_dict)
396 return results_dict
397
398 # TODO(jrobbins): change this termonology to "canned query" rather than
399 # "saved" throughout the application.
400 def GetSavedQueriesByUserID(self, cnxn, user_id):
401 """Return a list of SavedQuery PBs for the specified user."""
402 saved_queries_dict = self._GetUsersSavedQueriesDict(cnxn, [user_id])
403 saved_queries = saved_queries_dict.get(user_id, [])
404 return saved_queries[:]
405
406 def GetCannedQueriesForProjects(self, cnxn, project_ids):
407 """Return a dict {project_id: [saved_query]} for the specified projects."""
408 results_dict, missed_pids = self.canned_query_cache.GetAll(project_ids)
409
410 if missed_pids:
411 cannedquery_rows = self.project2savedquery_tbl.Select(
412 cnxn, cols=['project_id'] + SAVEDQUERY_COLS,
413 left_joins=[('SavedQuery ON query_id = id', [])],
414 order_by=[('rank', [])], project_id=project_ids)
415
416 for cq_row in cannedquery_rows:
417 project_id = cq_row[0]
418 canned_query_tuple = cq_row[1:]
419 results_dict.setdefault(project_id ,[]).append(
420 tracker_bizobj.MakeSavedQuery(*canned_query_tuple))
421
422 self.canned_query_cache.CacheAll(results_dict)
423 return results_dict
424
425 def GetCannedQueriesByProjectID(self, cnxn, project_id):
426 """Return the list of SavedQueries for the specified project."""
427 project_ids_to_canned_queries = self.GetCannedQueriesForProjects(
428 cnxn, [project_id])
429 return project_ids_to_canned_queries.get(project_id, [])
430
431 def _UpdateSavedQueries(self, cnxn, saved_queries, commit=True):
432 """Store the given SavedQueries to the DB."""
433 savedquery_rows = [
434 (sq.query_id or None, sq.name, sq.base_query_id, sq.query)
435 for sq in saved_queries]
436 existing_query_ids = [sq.query_id for sq in saved_queries if sq.query_id]
437 if existing_query_ids:
438 self.savedquery_tbl.Delete(cnxn, id=existing_query_ids, commit=commit)
439
440 generated_ids = self.savedquery_tbl.InsertRows(
441 cnxn, SAVEDQUERY_COLS, savedquery_rows, commit=commit,
442 return_generated_ids=True)
443 if generated_ids:
444 logging.info('generated_ids are %r', generated_ids)
445 for sq in saved_queries:
446 generated_id = generated_ids.pop(0)
447 if not sq.query_id:
448 sq.query_id = generated_id
449
450 def UpdateCannedQueries(self, cnxn, project_id, canned_queries):
451 """Update the canned queries for a project.
452
453 Args:
454 cnxn: connection to SQL database.
455 project_id: int project ID of the project that contains these queries.
456 canned_queries: list of SavedQuery PBs to update.
457 """
458 self.project2savedquery_tbl.Delete(
459 cnxn, project_id=project_id, commit=False)
460 self._UpdateSavedQueries(cnxn, canned_queries, commit=False)
461 project2savedquery_rows = [
462 (project_id, rank, sq.query_id)
463 for rank, sq in enumerate(canned_queries)]
464 self.project2savedquery_tbl.InsertRows(
465 cnxn, PROJECT2SAVEDQUERY_COLS, project2savedquery_rows,
466 commit=False)
467 cnxn.Commit()
468
469 self.canned_query_cache.Invalidate(cnxn, project_id)
470
471 def UpdateUserSavedQueries(self, cnxn, user_id, saved_queries):
472 """Store the given saved_queries for the given user."""
473 saved_query_ids = [sq.query_id for sq in saved_queries if sq.query_id]
474 self.savedqueryexecutesinproject_tbl.Delete(
475 cnxn, query_id=saved_query_ids, commit=False)
476 self.user2savedquery_tbl.Delete(cnxn, user_id=user_id, commit=False)
477
478 self._UpdateSavedQueries(cnxn, saved_queries, commit=False)
479 user2savedquery_rows = []
480 for rank, sq in enumerate(saved_queries):
481 user2savedquery_rows.append(
482 (user_id, rank, sq.query_id, sq.subscription_mode or 'noemail'))
483
484 self.user2savedquery_tbl.InsertRows(
485 cnxn, USER2SAVEDQUERY_COLS, user2savedquery_rows, commit=False)
486
487 sqeip_rows = []
488 for sq in saved_queries:
489 for pid in sq.executes_in_project_ids:
490 sqeip_rows.append((sq.query_id, pid))
491
492 self.savedqueryexecutesinproject_tbl.InsertRows(
493 cnxn, SAVEDQUERYEXECUTESINPROJECT_COLS, sqeip_rows, commit=False)
494 cnxn.Commit()
495
496 self.saved_query_cache.Invalidate(cnxn, user_id)
497
498 ### Subscriptions
499
500 def GetSubscriptionsInProjects(self, cnxn, project_ids):
501 """Return all saved queries for users that have any subscription there.
502
503 Args:
504 cnxn: Connection to SQL database.
505 project_ids: list of int project IDs that contain the modified issues.
506
507 Returns:
508 A dict {user_id: all_saved_queries, ...} for all users that have any
509 subscription in any of the specified projects.
510 """
511 sqeip_join_str = (
512 'SavedQueryExecutesInProject ON '
513 'SavedQueryExecutesInProject.query_id = User2SavedQuery.query_id')
514 user_join_str = (
515 'User ON '
516 'User.user_id = User2SavedQuery.user_id')
517 now = int(time.time())
518 absence_threshold = now - settings.subscription_timeout_secs
519 where = [
520 ('(User.banned IS NULL OR User.banned = %s)', ['']),
521 ('User.last_visit_timestamp >= %s', [absence_threshold]),
522 ('(User.email_bounce_timestamp IS NULL OR '
523 'User.email_bounce_timestamp = %s)', [0]),
524 ]
525 # TODO(jrobbins): cache this since it rarely changes.
526 subscriber_rows = self.user2savedquery_tbl.Select(
527 cnxn, cols=['User2SavedQuery.user_id'], distinct=True,
528 joins=[(sqeip_join_str, []), (user_join_str, [])],
529 subscription_mode='immediate', project_id=project_ids,
530 where=where)
531 subscriber_ids = [row[0] for row in subscriber_rows]
532 logging.info('subscribers relevant to projects %r are %r',
533 project_ids, subscriber_ids)
534 user_ids_to_saved_queries = self._GetUsersSavedQueriesDict(
535 cnxn, subscriber_ids)
536 return user_ids_to_saved_queries
537
538 def ExpungeSavedQueriesExecuteInProject(self, cnxn, project_id):
539 """Remove any references from saved queries to projects in the database."""
540 self.savedqueryexecutesinproject_tbl.Delete(cnxn, project_id=project_id)
541
542 savedquery_rows = self.project2savedquery_tbl.Select(
543 cnxn, cols=['query_id'], project_id=project_id)
544 savedquery_ids = [row[0] for row in savedquery_rows]
545 self.project2savedquery_tbl.Delete(cnxn, project_id=project_id)
546 self.savedquery_tbl.Delete(cnxn, id=savedquery_ids)
547
548 def ExpungeSavedQueriesByUsers(self, cnxn, user_ids, limit=None):
549 """Completely delete every given users' saved queries.
550
551 This method will not commit the operations. This method will
552 not make changes to in-memory data.
553 """
554 commit = False
555 savedquery_rows = self.user2savedquery_tbl.Select(
556 cnxn, cols=['query_id'], user_id=user_ids, limit=limit)
557 savedquery_ids = [row[0] for row in savedquery_rows]
558 self.user2savedquery_tbl.Delete(
559 cnxn, query_id=savedquery_ids, commit=commit)
560 self.savedqueryexecutesinproject_tbl.Delete(
561 cnxn, query_id=savedquery_ids, commit=commit)
562 self.savedquery_tbl.Delete(cnxn, id=savedquery_ids, commit=commit)
563
564
565 ### Filter rules
566
567 def _DeserializeFilterRules(self, filterrule_rows):
568 """Convert the given DB row tuples into PBs."""
569 result_dict = collections.defaultdict(list)
570
571 for filterrule_row in sorted(filterrule_rows):
572 project_id, _rank, predicate, consequence = filterrule_row
573 (default_status, default_owner_id, add_cc_ids, add_labels,
574 add_notify, warning, error) = self._DeserializeRuleConsequence(
575 consequence)
576 rule = filterrules_helpers.MakeRule(
577 predicate, default_status=default_status,
578 default_owner_id=default_owner_id, add_cc_ids=add_cc_ids,
579 add_labels=add_labels, add_notify=add_notify, warning=warning,
580 error=error)
581 result_dict[project_id].append(rule)
582
583 return result_dict
584
585 def _DeserializeRuleConsequence(self, consequence):
586 """Decode the THEN-part of a filter rule."""
587 (default_status, default_owner_id, add_cc_ids, add_labels,
588 add_notify, warning, error) = None, None, [], [], [], None, None
589 for match in CONSEQUENCE_RE.finditer(consequence):
590 if match.group('default_status'):
591 default_status = match.group('default_status')
592 elif match.group('default_owner_id'):
593 default_owner_id = int(match.group('default_owner_id'))
594 elif match.group('add_cc_id'):
595 add_cc_ids.append(int(match.group('add_cc_id')))
596 elif match.group('add_label'):
597 add_labels.append(match.group('add_label'))
598 elif match.group('add_notify'):
599 add_notify.append(match.group('add_notify'))
600 elif match.group('warning'):
601 warning = match.group('warning')
602 elif match.group('error'):
603 error = match.group('error')
604
605 return (default_status, default_owner_id, add_cc_ids, add_labels,
606 add_notify, warning, error)
607
608 def _GetFilterRulesByProjectIDs(self, cnxn, project_ids):
609 """Return {project_id: [FilterRule, ...]} for the specified projects."""
610 # TODO(jrobbins): caching
611 filterrule_rows = self.filterrule_tbl.Select(
612 cnxn, cols=FILTERRULE_COLS, project_id=project_ids)
613 return self._DeserializeFilterRules(filterrule_rows)
614
615 def GetFilterRules(self, cnxn, project_id):
616 """Return a list of FilterRule PBs for the specified project."""
617 rules_by_project_id = self._GetFilterRulesByProjectIDs(cnxn, [project_id])
618 return rules_by_project_id[project_id]
619
620 def _SerializeRuleConsequence(self, rule):
621 """Put all actions of a filter rule into one string."""
622 assignments = []
623 for add_lab in rule.add_labels:
624 assignments.append('add_label:%s' % add_lab)
625 if rule.default_status:
626 assignments.append('default_status:%s' % rule.default_status)
627 if rule.default_owner_id:
628 assignments.append('default_owner_id:%d' % rule.default_owner_id)
629 for add_cc_id in rule.add_cc_ids:
630 assignments.append('add_cc_id:%d' % add_cc_id)
631 for add_notify in rule.add_notify_addrs:
632 assignments.append('add_notify:%s' % add_notify)
633 if rule.warning:
634 assignments.append('warning:%s' % rule.warning)
635 if rule.error:
636 assignments.append('error:%s' % rule.error)
637
638 return ' '.join(assignments)
639
640 def UpdateFilterRules(self, cnxn, project_id, rules):
641 """Update the filter rules part of a project's issue configuration.
642
643 Args:
644 cnxn: connection to SQL database.
645 project_id: int ID of the current project.
646 rules: a list of FilterRule PBs.
647 """
648 rows = []
649 for rank, rule in enumerate(rules):
650 predicate = rule.predicate
651 consequence = self._SerializeRuleConsequence(rule)
652 if predicate and consequence:
653 rows.append((project_id, rank, predicate, consequence))
654
655 self.filterrule_tbl.Delete(cnxn, project_id=project_id)
656 self.filterrule_tbl.InsertRows(cnxn, FILTERRULE_COLS, rows)
657
658 def ExpungeFilterRules(self, cnxn, project_id):
659 """Completely destroy filter rule info for the specified project."""
660 self.filterrule_tbl.Delete(cnxn, project_id=project_id)
661
662 def ExpungeFilterRulesByUser(self, cnxn, user_ids_by_email):
663 """Wipes any Filter Rules containing the given users.
664
665 This method will not commit the operation. This method will not make
666 changes to in-memory data.
667 Args:
668 cnxn: connection to SQL database.
669 user_ids_by_email: dict of {email: user_id ..} of all users we want to
670 expunge
671
672 Returns:
673 Dictionary of {project_id: [(predicate, consequence), ..]} for Filter
674 Rules that will be deleted for containing the given emails.
675 """
676 deleted_project_rules_dict = collections.defaultdict(list)
677 if user_ids_by_email:
678 deleted_rows = []
679 emails = user_ids_by_email.keys()
680 all_rules_rows = self.filterrule_tbl.Select(cnxn, FILTERRULE_COLS)
681 logging.info('Fetched all filter rules: %s' % (all_rules_rows,))
682 for rule_row in all_rules_rows:
683 project_id, _rank, predicate, consequence = rule_row
684 if any(email in predicate for email in emails):
685 deleted_rows.append(rule_row)
686 continue
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100687 if any((('add_notify:%s' % email) in consequence or
688 ('add_cc_id:%s' % user_id) in consequence or
689 ('default_owner_id:%s' % user_id) in consequence)
690 for email, user_id in user_ids_by_email.items()):
Copybara854996b2021-09-07 19:36:02 +0000691 deleted_rows.append(rule_row)
692 continue
693
694 for deleted_row in deleted_rows:
695 project_id, rank, predicate, consequence = deleted_row
696 self.filterrule_tbl.Delete(
697 cnxn, project_id=project_id, rank=rank, predicate=predicate,
698 consequence=consequence, commit=False)
699 deleted_project_rules_dict = self._DeserializeFilterRules(deleted_rows)
700
701 return deleted_project_rules_dict
702
703 ### Creating hotlists
704
705 def CreateHotlist(
706 self, cnxn, name, summary, description, owner_ids, editor_ids,
707 issue_ids=None, is_private=None, default_col_spec=None, ts=None):
708 # type: (MonorailConnection, string, string, string, Collection[int],
709 # Optional[Collection[int]], Optional[Boolean], Optional[string],
710 # Optional[int] -> int
711 """Create and store a Hotlist with the given attributes.
712
713 Args:
714 cnxn: connection to SQL database.
715 name: a valid hotlist name.
716 summary: one-line explanation of the hotlist.
717 description: one-page explanation of the hotlist.
718 owner_ids: a list of user IDs for the hotlist owners.
719 editor_ids: a list of user IDs for the hotlist editors.
720 issue_ids: a list of issue IDs for the hotlist issues.
721 is_private: True if the hotlist can only be viewed by owners and editors.
722 default_col_spec: the default columns that show in list view.
723 ts: a timestamp for when this hotlist was created.
724
725 Returns:
726 The int id of the new hotlist.
727
728 Raises:
729 InputException: if the hotlist name is invalid.
730 HotlistAlreadyExists: if any of the owners already own a hotlist with
731 the same name.
732 UnownedHotlistException: if owner_ids is empty.
733 """
734 # TODO(crbug.com/monorail/7677): These checks should be done in the
735 # the business layer.
736 # Remove when calls from non-business layer code are removed.
737 if not owner_ids: # Should never happen.
738 logging.error('Attempt to create unowned Hotlist: name:%r', name)
739 raise UnownedHotlistException()
740 if not framework_bizobj.IsValidHotlistName(name):
741 raise exceptions.InputException(
742 '%s is not a valid name for a Hotlist' % name)
743 if self.LookupHotlistIDs(cnxn, [name], owner_ids):
744 raise HotlistAlreadyExists()
745 # TODO(crbug.com/monorail/7677): We are not setting a
746 # default default_col_spec in v3.
747 if default_col_spec is None:
748 default_col_spec = features_constants.DEFAULT_COL_SPEC
749
750 hotlist_item_fields = [
751 (issue_id, rank*100, owner_ids[0], ts, '') for
752 rank, issue_id in enumerate(issue_ids or [])]
753 hotlist = features_pb2.MakeHotlist(
754 name, hotlist_item_fields=hotlist_item_fields, summary=summary,
755 description=description, is_private=is_private, owner_ids=owner_ids,
756 editor_ids=editor_ids, default_col_spec=default_col_spec)
757 hotlist.hotlist_id = self._InsertHotlist(cnxn, hotlist)
758 return hotlist
759
760 def UpdateHotlist(
761 self, cnxn, hotlist_id, name=None, summary=None, description=None,
762 is_private=None, default_col_spec=None, owner_id=None,
763 add_editor_ids=None):
764 """Update the DB with the given hotlist information."""
765 # Note: If something is None, it does not get changed to None,
766 # it just does not get updated.
767 hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
768 if not hotlist:
769 raise NoSuchHotlistException()
770
771 delta = {}
772 if name is not None:
773 delta['name'] = name
774 if summary is not None:
775 delta['summary'] = summary
776 if description is not None:
777 delta['description'] = description
778 if is_private is not None:
779 delta['is_private'] = is_private
780 if default_col_spec is not None:
781 delta['default_col_spec'] = default_col_spec
782
783 self.hotlist_tbl.Update(cnxn, delta, id=hotlist_id, commit=False)
784 insert_rows = []
785 if owner_id is not None:
786 insert_rows.append((hotlist_id, owner_id, 'owner'))
787 self.hotlist2user_tbl.Delete(
788 cnxn, hotlist_id=hotlist_id, role='owner', commit=False)
789 if add_editor_ids:
790 insert_rows.extend(
791 [(hotlist_id, user_id, 'editor') for user_id in add_editor_ids])
792 if insert_rows:
793 self.hotlist2user_tbl.InsertRows(
794 cnxn, HOTLIST2USER_COLS, insert_rows, commit=False)
795
796 cnxn.Commit()
797
798 self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
799 if not hotlist.owner_ids: # Should never happen.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100800 logging.warning(
801 'Modifying unowned Hotlist: id:%r, name:%r', hotlist_id, hotlist.name)
Copybara854996b2021-09-07 19:36:02 +0000802 elif hotlist.name:
803 self.hotlist_id_2lc.InvalidateKeys(
804 cnxn, [(hotlist.name.lower(), owner_id) for
805 owner_id in hotlist.owner_ids])
806
807 # Update the hotlist PB in RAM
808 if name is not None:
809 hotlist.name = name
810 if summary is not None:
811 hotlist.summary = summary
812 if description is not None:
813 hotlist.description = description
814 if is_private is not None:
815 hotlist.is_private = is_private
816 if default_col_spec is not None:
817 hotlist.default_col_spec = default_col_spec
818 if owner_id is not None:
819 hotlist.owner_ids = [owner_id]
820 if add_editor_ids:
821 hotlist.editor_ids.extend(add_editor_ids)
822
823 def RemoveHotlistEditors(self, cnxn, hotlist_id, remove_editor_ids):
824 # type: MonorailConnection, int, Collection[int]
825 """Remove given editors from the specified hotlist.
826
827 Args:
828 cnxn: MonorailConnection object.
829 hotlist_id: int ID of the Hotlist we want to update.
830 remove_editor_ids: collection of existing hotlist editor User IDs
831 that we want to remove from the hotlist.
832
833 Raises:
834 NoSuchHotlistException: if the hotlist is not found.
835 InputException: if there are not editors to remove.
836 """
837 if not remove_editor_ids:
838 raise exceptions.InputException
839 hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
840 if not hotlist:
841 raise NoSuchHotlistException()
842
843 self.hotlist2user_tbl.Delete(
844 cnxn, hotlist_id=hotlist_id, user_id=remove_editor_ids)
845 self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
846
847 # Update in-memory data
848 for remove_id in remove_editor_ids:
849 hotlist.editor_ids.remove(remove_id)
850
851 def UpdateHotlistIssues(
852 self,
853 cnxn, # type: sql.MonorailConnection
854 hotlist_id, # type: int
855 updated_items, # type: Collection[features_pb2.HotlistItem]
856 remove_issue_ids, # type: Collection[int]
857 issue_svc, # type: issue_svc.IssueService
858 chart_svc, # type: chart_svc.ChartService
859 commit=True # type: Optional[bool]
860 ):
861 # type: (...) -> None
862 """Update the Issues in a Hotlist.
863 This method removes the given remove_issue_ids from a Hotlist then
864 updates or adds the HotlistItems found in updated_items. HotlistItems
865 in updated_items may exist in the hotlist and just need to be updated
866 or they may be new items that should be added to the Hotlist.
867
868 Args:
869 cnxn: MonorailConnection object.
870 hotlist_id: int ID of the Hotlist to update.
871 updated_items: Collection of HotlistItems that either already exist in
872 the hotlist and need to be updated or needed to be added to the hotlist.
873 remove_issue_ids: Collection of Issue IDs that should be removed from the
874 hotlist.
875 issue_svc: IssueService object.
876 chart_svc: ChartService object.
877
878 Raises:
879 NoSuchHotlistException if a hotlist with the given ID is not found.
880 InputException if no changes were given.
881 """
882 if not updated_items and not remove_issue_ids:
883 raise exceptions.InputException('No changes to make')
884
885 hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
886 if not hotlist:
887 raise NoSuchHotlistException()
888
889 # Used to hold the updated Hotlist.items to use when updating
890 # the in-memory hotlist.
891 all_hotlist_items = list(hotlist.items)
892
893 # Used to hold ids of issues affected by this change for storing
894 # Issue Snapshots.
895 affected_issue_ids = set()
896
897 if remove_issue_ids:
898 affected_issue_ids.update(remove_issue_ids)
899 self.hotlist2issue_tbl.Delete(
900 cnxn, hotlist_id=hotlist_id, issue_id=remove_issue_ids, commit=False)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100901 all_hotlist_items = list(
902 filter(
903 lambda item: item.issue_id not in remove_issue_ids,
904 all_hotlist_items))
Copybara854996b2021-09-07 19:36:02 +0000905
906 if updated_items:
907 updated_issue_ids = [item.issue_id for item in updated_items]
908 affected_issue_ids.update(updated_issue_ids)
909 self.hotlist2issue_tbl.Delete(
910 cnxn, hotlist_id=hotlist_id, issue_id=updated_issue_ids, commit=False)
911 insert_rows = []
912 for item in updated_items:
913 insert_rows.append(
914 (
915 hotlist_id, item.issue_id, item.rank, item.adder_id,
916 item.date_added, item.note))
917 self.hotlist2issue_tbl.InsertRows(
918 cnxn, cols=HOTLIST2ISSUE_COLS, row_values=insert_rows, commit=False)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100919 all_hotlist_items = list(
920 filter(
921 lambda item: item.issue_id not in updated_issue_ids,
922 all_hotlist_items))
Copybara854996b2021-09-07 19:36:02 +0000923 all_hotlist_items.extend(updated_items)
924
925 if commit:
926 cnxn.Commit()
927 self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
928
929 # Update in-memory hotlist items.
930 hotlist.items = sorted(all_hotlist_items, key=lambda item: item.rank)
931
932 issues = issue_svc.GetIssues(cnxn, list(affected_issue_ids))
933 chart_svc.StoreIssueSnapshots(cnxn, issues, commit=commit)
934
935 # TODO(crbug/monorail/7104): {Add|Remove}IssuesToHotlists both call
936 # UpdateHotlistItems to add/remove issues from a hotlist.
937 # UpdateHotlistItemsFields is called by methods for reranking existing issues
938 # and updating HotlistItem notes.
939 # (1) We are removing notes from HotlistItems. crbug/monorail/####
940 # (2) our v3 AddHotlistItems will allow for inserting new issues to
941 # non-last ranks of a hotlist. So there could be some shared code
942 # for the reranking path and the adding issues path.
943 # UpdateHotlistIssues will be handling adding, removing, and reranking issues.
944 # {Add|Remove}IssueToHotlists, UpdateHotlistItems, UpdateHotlistItemFields
945 # should be removed, once all methods are updated to call UpdateHotlistIssues.
946
947 def AddIssueToHotlists(self, cnxn, hotlist_ids, issue_tuple, issue_svc,
948 chart_svc, commit=True):
949 """Add a single issue, specified in the issue_tuple, to the given hotlists.
950
951 Args:
952 cnxn: connection to SQL database.
953 hotlist_ids: a list of hotlist_ids to add the issues to.
954 issue_tuple: (issue_id, user_id, ts, note) of the issue to be added.
955 issue_svc: an instance of IssueService.
956 chart_svc: an instance of ChartService.
957 """
958 self.AddIssuesToHotlists(cnxn, hotlist_ids, [issue_tuple], issue_svc,
959 chart_svc, commit=commit)
960
961 def AddIssuesToHotlists(self, cnxn, hotlist_ids, added_tuples, issue_svc,
962 chart_svc, commit=True):
963 """Add the issues given in the added_tuples list to the given hotlists.
964
965 Args:
966 cnxn: connection to SQL database.
967 hotlist_ids: a list of hotlist_ids to add the issues to.
968 added_tuples: a list of (issue_id, user_id, ts, note)
969 for issues to be added.
970 issue_svc: an instance of IssueService.
971 chart_svc: an instance of ChartService.
972 """
973 for hotlist_id in hotlist_ids:
974 self.UpdateHotlistItems(cnxn, hotlist_id, [], added_tuples, commit=commit)
975
976 issues = issue_svc.GetIssues(cnxn,
977 [added_tuple[0] for added_tuple in added_tuples])
978 chart_svc.StoreIssueSnapshots(cnxn, issues, commit=commit)
979
980 def RemoveIssuesFromHotlists(self, cnxn, hotlist_ids, issue_ids, issue_svc,
981 chart_svc, commit=True):
982 """Remove the issues given in issue_ids from the given hotlists.
983
984 Args:
985 cnxn: connection to SQL database.
986 hotlist_ids: a list of hotlist ids to remove the issues from.
987 issue_ids: a list of issue_ids to be removed.
988 issue_svc: an instance of IssueService.
989 chart_svc: an instance of ChartService.
990 """
991 for hotlist_id in hotlist_ids:
992 self.UpdateHotlistItems(cnxn, hotlist_id, issue_ids, [], commit=commit)
993
994 issues = issue_svc.GetIssues(cnxn, issue_ids)
995 chart_svc.StoreIssueSnapshots(cnxn, issues, commit=commit)
996
997 def UpdateHotlistItems(
998 self, cnxn, hotlist_id, remove, added_tuples, commit=True):
999 """Updates a hotlist's list of hotlistissues.
1000
1001 Args:
1002 cnxn: connection to SQL database.
1003 hotlist_id: the ID of the hotlist to update.
1004 remove: a list of issue_ids for be removed.
1005 added_tuples: a list of (issue_id, user_id, ts, note)
1006 for issues to be added.
1007 """
1008 hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
1009 if not hotlist:
1010 raise NoSuchHotlistException()
1011
1012 # adding new Hotlistissues, ignoring pairs where issue_id is already in
1013 # hotlist's iid_rank_pairs
1014 current_issues_ids = {
1015 item.issue_id for item in hotlist.items}
1016
1017 self.hotlist2issue_tbl.Delete(
1018 cnxn, hotlist_id=hotlist_id,
1019 issue_id=[remove_id for remove_id in remove
1020 if remove_id in current_issues_ids],
1021 commit=False)
1022 if hotlist.items:
1023 items_sorted = sorted(hotlist.items, key=lambda item: item.rank)
1024 rank_base = items_sorted[-1].rank + 10
1025 else:
1026 rank_base = 1
1027 insert_rows = [
1028 (hotlist_id, issue_id, rank*10 + rank_base, user_id, ts, note)
1029 for (rank, (issue_id, user_id, ts, note)) in enumerate(added_tuples)
1030 if issue_id not in current_issues_ids]
1031 self.hotlist2issue_tbl.InsertRows(
1032 cnxn, cols=HOTLIST2ISSUE_COLS, row_values=insert_rows, commit=commit)
1033 self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
1034
1035 # removing an issue that was never in the hotlist would not cause any
1036 # problems.
1037 items = [
1038 item for item in hotlist.items if
1039 item.issue_id not in remove]
1040
1041 new_hotlist_items = [
1042 features_pb2.MakeHotlistItem(issue_id, rank, user_id, ts, note)
1043 for (_hid, issue_id, rank, user_id, ts, note) in insert_rows]
1044 items.extend(new_hotlist_items)
1045 hotlist.items = items
1046
1047 def UpdateHotlistItemsFields(
1048 self, cnxn, hotlist_id, new_ranks=None, new_notes=None, commit=True):
1049 """Updates rankings or notes of hotlistissues.
1050
1051 Args:
1052 cnxn: connection to SQL database.
1053 hotlist_id: the ID of the hotlist to update.
1054 new_ranks : This should be a dictionary of {issue_id: rank}.
1055 new_notes: This should be a diciontary of {issue_id: note}.
1056 commit: set to False to skip the DB commit and do it in the caller.
1057 """
1058 hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
1059 if not hotlist:
1060 raise NoSuchHotlistException()
1061 if new_ranks is None:
1062 new_ranks = {}
1063 if new_notes is None:
1064 new_notes = {}
1065 issue_ids = []
1066 insert_rows = []
1067
1068 # Update the hotlist PB in RAM
1069 for hotlist_item in hotlist.items:
1070 item_updated = False
1071 if hotlist_item.issue_id in new_ranks:
1072 # Update rank before adding it to insert_rows
1073 hotlist_item.rank = new_ranks[hotlist_item.issue_id]
1074 item_updated = True
1075 if hotlist_item.issue_id in new_notes:
1076 # Update note before adding it to insert_rows
1077 hotlist_item.note = new_notes[hotlist_item.issue_id]
1078 item_updated = True
1079 if item_updated:
1080 issue_ids.append(hotlist_item.issue_id)
1081 insert_rows.append((
1082 hotlist_id, hotlist_item.issue_id, hotlist_item.rank,
1083 hotlist_item.adder_id, hotlist_item.date_added, hotlist_item.note))
1084 hotlist.items = sorted(hotlist.items, key=lambda item: item.rank)
1085 self.hotlist2issue_tbl.Delete(
1086 cnxn, hotlist_id=hotlist_id, issue_id=issue_ids, commit=False)
1087
1088 self.hotlist2issue_tbl.InsertRows(
1089 cnxn, cols=HOTLIST2ISSUE_COLS , row_values=insert_rows, commit=commit)
1090 self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
1091
1092 def _InsertHotlist(self, cnxn, hotlist):
1093 """Insert the given hotlist into the database."""
1094 hotlist_id = self.hotlist_tbl.InsertRow(
1095 cnxn, name=hotlist.name, summary=hotlist.summary,
1096 description=hotlist.description, is_private=hotlist.is_private,
1097 default_col_spec=hotlist.default_col_spec)
1098 logging.info('stored hotlist was given id %d', hotlist_id)
1099
1100 self.hotlist2issue_tbl.InsertRows(
1101 cnxn, HOTLIST2ISSUE_COLS,
1102 [(hotlist_id, issue.issue_id, issue.rank,
1103 issue.adder_id, issue.date_added, issue.note)
1104 for issue in hotlist.items],
1105 commit=False)
1106 self.hotlist2user_tbl.InsertRows(
1107 cnxn, HOTLIST2USER_COLS,
1108 [(hotlist_id, user_id, 'owner')
1109 for user_id in hotlist.owner_ids] +
1110 [(hotlist_id, user_id, 'editor')
1111 for user_id in hotlist.editor_ids] +
1112 [(hotlist_id, user_id, 'follower')
1113 for user_id in hotlist.follower_ids])
1114
1115 self.hotlist_user_to_ids.InvalidateKeys(cnxn, hotlist.owner_ids)
1116
1117 return hotlist_id
1118
1119 def TransferHotlistOwnership(
1120 self, cnxn, hotlist, new_owner_id, remain_editor, commit=True):
1121 """Transfers ownership of a hotlist to a new owner."""
1122 new_editor_ids = hotlist.editor_ids
1123 if remain_editor:
1124 new_editor_ids.extend(hotlist.owner_ids)
1125 if new_owner_id in new_editor_ids:
1126 new_editor_ids.remove(new_owner_id)
1127 new_follower_ids = hotlist.follower_ids
1128 if new_owner_id in new_follower_ids:
1129 new_follower_ids.remove(new_owner_id)
1130 self.UpdateHotlistRoles(
1131 cnxn, hotlist.hotlist_id, [new_owner_id], new_editor_ids,
1132 new_follower_ids, commit=commit)
1133
1134 ### Lookup hotlist IDs
1135
1136 def LookupHotlistIDs(self, cnxn, hotlist_names, owner_ids):
1137 """Return a dict of (name, owner_id) mapped to hotlist_id for all hotlists
1138 with one of the given names and any of the given owners. Hotlists that
1139 match multiple owners will be in the dict multiple times."""
1140 id_dict, _missed_keys = self.hotlist_id_2lc.GetAll(
1141 cnxn, [(name.lower(), owner_id)
1142 for name in hotlist_names for owner_id in owner_ids])
1143 return id_dict
1144
1145 def LookupUserHotlists(self, cnxn, user_ids):
1146 """Return a dict of {user_id: [hotlist_id,...]} for all user_ids."""
1147 id_dict, missed_ids = self.hotlist_user_to_ids.GetAll(user_ids)
1148 if missed_ids:
1149 retrieved_dict = {user_id: [] for user_id in missed_ids}
1150 id_rows = self.hotlist2user_tbl.Select(
1151 cnxn, cols=['user_id', 'hotlist_id'], user_id=user_ids,
1152 left_joins=[('Hotlist ON hotlist_id = id', [])],
1153 where=[('Hotlist.is_deleted = %s', [False])])
1154 for (user_id, hotlist_id) in id_rows:
1155 retrieved_dict[user_id].append(hotlist_id)
1156 self.hotlist_user_to_ids.CacheAll(retrieved_dict)
1157 id_dict.update(retrieved_dict)
1158
1159 return id_dict
1160
1161 def LookupIssueHotlists(self, cnxn, issue_ids):
1162 """Return a dict of {issue_id: [hotlist_id,...]} for all issue_ids."""
1163 # TODO(jojwang): create hotlist_issue_to_ids cache
1164 retrieved_dict = {issue_id: [] for issue_id in issue_ids}
1165 id_rows = self.hotlist2issue_tbl.Select(
1166 cnxn, cols=['hotlist_id', 'issue_id'], issue_id=issue_ids,
1167 left_joins=[('Hotlist ON hotlist_id = id', [])],
1168 where=[('Hotlist.is_deleted = %s', [False])])
1169 for hotlist_id, issue_id in id_rows:
1170 retrieved_dict[issue_id].append(hotlist_id)
1171 return retrieved_dict
1172
1173 def GetProjectIDsFromHotlist(self, cnxn, hotlist_id):
1174 project_id_rows = self.hotlist2issue_tbl.Select(cnxn,
1175 cols=['Issue.project_id'], hotlist_id=hotlist_id, distinct=True,
1176 left_joins=[('Issue ON issue_id = id', [])])
1177 return [row[0] for row in project_id_rows]
1178
1179 ### Get hotlists
1180 def GetHotlists(self, cnxn, hotlist_ids, use_cache=True):
1181 """Returns dict of {hotlist_id: hotlist PB}."""
1182 hotlists_dict, missed_ids = self.hotlist_2lc.GetAll(
1183 cnxn, hotlist_ids, use_cache=use_cache)
1184
1185 if missed_ids:
1186 raise NoSuchHotlistException()
1187
1188 return hotlists_dict
1189
1190 def GetHotlistsByUserID(self, cnxn, user_id, use_cache=True):
1191 """Get a list of hotlist PBs for a given user."""
1192 hotlist_id_dict = self.LookupUserHotlists(cnxn, [user_id])
1193 hotlists = self.GetHotlists(
1194 cnxn, hotlist_id_dict.get(user_id, []), use_cache=use_cache)
1195 return list(hotlists.values())
1196
1197 def GetHotlistsByIssueID(self, cnxn, issue_id, use_cache=True):
1198 """Get a list of hotlist PBs for a given issue."""
1199 hotlist_id_dict = self.LookupIssueHotlists(cnxn, [issue_id])
1200 hotlists = self.GetHotlists(
1201 cnxn, hotlist_id_dict.get(issue_id, []), use_cache=use_cache)
1202 return list(hotlists.values())
1203
1204 def GetHotlist(self, cnxn, hotlist_id, use_cache=True):
1205 """Returns hotlist PB."""
1206 hotlist_dict = self.GetHotlists(cnxn, [hotlist_id], use_cache=use_cache)
1207 return hotlist_dict[hotlist_id]
1208
1209 def GetHotlistsByID(self, cnxn, hotlist_ids, use_cache=True):
1210 """Load all the Hotlist PBs for the given hotlists.
1211
1212 Args:
1213 cnxn: connection to SQL database.
1214 hotlist_ids: list of hotlist ids.
1215 use_cache: specifiy False to force database query.
1216
1217 Returns:
1218 A dict mapping ids to the corresponding Hotlist protocol buffers and
1219 a list of any hotlist_ids that were not found.
1220 """
1221 hotlists_dict, missed_ids = self.hotlist_2lc.GetAll(
1222 cnxn, hotlist_ids, use_cache=use_cache)
1223 return hotlists_dict, missed_ids
1224
1225 def GetHotlistByID(self, cnxn, hotlist_id, use_cache=True):
1226 """Load the specified hotlist from the database, None if does not exist."""
1227 hotlist_dict, _ = self.GetHotlistsByID(
1228 cnxn, [hotlist_id], use_cache=use_cache)
1229 return hotlist_dict.get(hotlist_id)
1230
1231 def UpdateHotlistRoles(
1232 self, cnxn, hotlist_id, owner_ids, editor_ids, follower_ids, commit=True):
1233 """"Store the hotlist's roles in the DB."""
1234 # This will be a newly contructed object, not from the cache and not
1235 # shared with any other thread.
1236 hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
1237 if not hotlist:
1238 raise NoSuchHotlistException()
1239
1240 self.hotlist2user_tbl.Delete(
1241 cnxn, hotlist_id=hotlist_id, commit=False)
1242
1243 insert_rows = [(hotlist_id, user_id, 'owner') for user_id in owner_ids]
1244 insert_rows.extend(
1245 [(hotlist_id, user_id, 'editor') for user_id in editor_ids])
1246 insert_rows.extend(
1247 [(hotlist_id, user_id, 'follower') for user_id in follower_ids])
1248 self.hotlist2user_tbl.InsertRows(
1249 cnxn, HOTLIST2USER_COLS, insert_rows, commit=False)
1250
1251 if commit:
1252 cnxn.Commit()
1253 self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
1254 self.hotlist_user_to_ids.InvalidateKeys(cnxn, hotlist.owner_ids)
1255 hotlist.owner_ids = owner_ids
1256 hotlist.editor_ids = editor_ids
1257 hotlist.follower_ids = follower_ids
1258
1259 def DeleteHotlist(self, cnxn, hotlist_id, commit=True):
1260 hotlist = self.GetHotlist(cnxn, hotlist_id, use_cache=False)
1261 if not hotlist:
1262 raise NoSuchHotlistException()
1263
1264 # Fetch all associated project IDs in order to invalidate their cache.
1265 project_ids = self.GetProjectIDsFromHotlist(cnxn, hotlist_id)
1266
1267 delta = {'is_deleted': True}
1268 self.hotlist_tbl.Update(cnxn, delta, id=hotlist_id, commit=commit)
1269
1270 self.hotlist_2lc.InvalidateKeys(cnxn, [hotlist_id])
1271 self.hotlist_user_to_ids.InvalidateKeys(cnxn, hotlist.owner_ids)
1272 self.hotlist_user_to_ids.InvalidateKeys(cnxn, hotlist.editor_ids)
1273 if not hotlist.owner_ids: # Should never happen.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001274 logging.warning(
1275 'Soft-deleting unowned Hotlist: id:%r, name:%r', hotlist_id,
1276 hotlist.name)
Copybara854996b2021-09-07 19:36:02 +00001277 elif hotlist.name:
1278 self.hotlist_id_2lc.InvalidateKeys(
1279 cnxn, [(hotlist.name.lower(), owner_id) for
1280 owner_id in hotlist.owner_ids])
1281
1282 for project_id in project_ids:
1283 self.config_service.InvalidateMemcacheForEntireProject(project_id)
1284
1285 def ExpungeHotlists(
1286 self, cnxn, hotlist_ids, star_svc, user_svc, chart_svc, commit=True):
1287 """Wipes the given hotlists from the DB tables.
1288
1289 This method will only do cache invalidation if commit is set to True.
1290
1291 Args:
1292 cnxn: connection to SQL database.
1293 hotlist_ids: the ID of the hotlists to Expunge.
1294 star_svc: an instance of a HotlistStarService.
1295 user_svc: an instance of a UserService.
1296 chart_svc: an instance of a ChartService.
1297 commit: set to False to skip the DB commit and do it in the caller.
1298 """
1299
1300 hotlists_by_id = self.GetHotlists(cnxn, hotlist_ids)
1301
1302 for hotlist_id in hotlist_ids:
1303 star_svc.ExpungeStars(cnxn, hotlist_id, commit=commit)
1304 chart_svc.ExpungeHotlistsFromIssueSnapshots(
1305 cnxn, hotlist_ids, commit=commit)
1306 user_svc.ExpungeHotlistsFromHistory(cnxn, hotlist_ids, commit=commit)
1307 self.hotlist2user_tbl.Delete(cnxn, hotlist_id=hotlist_ids, commit=commit)
1308 self.hotlist2issue_tbl.Delete(cnxn, hotlist_id=hotlist_ids, commit=commit)
1309 self.hotlist_tbl.Delete(cnxn, id=hotlist_ids, commit=commit)
1310
1311 # Invalidate cache for deleted hotlists.
1312 self.hotlist_2lc.InvalidateKeys(cnxn, hotlist_ids)
1313 users_to_invalidate = set()
1314 for hotlist in hotlists_by_id.values():
1315 users_to_invalidate.update(
1316 hotlist.owner_ids + hotlist.editor_ids + hotlist.follower_ids)
1317 self.hotlist_id_2lc.InvalidateKeys(
1318 cnxn, [(hotlist.name, owner_id) for owner_id in hotlist.owner_ids])
1319 self.hotlist_user_to_ids.InvalidateKeys(cnxn, list(users_to_invalidate))
1320 hotlist_project_ids = set()
1321 for hotlist_id in hotlist_ids:
1322 hotlist_project_ids.update(self.GetProjectIDsFromHotlist(
1323 cnxn, hotlist_id))
1324 for project_id in hotlist_project_ids:
1325 self.config_service.InvalidateMemcacheForEntireProject(project_id)
1326
1327 def ExpungeUsersInHotlists(
1328 self, cnxn, user_ids, star_svc, user_svc, chart_svc):
1329 """Wipes the given users and any hotlists they owned from the
1330 hotlists system.
1331
1332 This method will not commit the operation. This method will not make
1333 changes to in-memory data.
1334 """
1335 # Transfer hotlist ownership to editors, if possible.
1336 hotlist_ids_by_user_id = self.LookupUserHotlists(cnxn, user_ids)
1337 hotlist_ids = [hotlist_id for hotlist_ids in hotlist_ids_by_user_id.values()
1338 for hotlist_id in hotlist_ids]
1339 hotlists_by_id, missed = self.GetHotlistsByID(
1340 cnxn, list(set(hotlist_ids)), use_cache=False)
1341 logging.info('Missed hotlists: %s', missed)
1342
1343 hotlists_to_delete = []
1344 for hotlist_id, hotlist in hotlists_by_id.items():
1345 # One of the users to be deleted is an owner of hotlist.
1346 if not set(hotlist.owner_ids).isdisjoint(user_ids):
1347 hotlists_to_delete.append(hotlist_id)
1348 candidate_new_owners = [user_id for user_id in hotlist.editor_ids
1349 if user_id not in user_ids]
1350 for candidate_id in candidate_new_owners:
1351 if not self.LookupHotlistIDs(cnxn, [hotlist.name], [candidate_id]):
1352 self.TransferHotlistOwnership(
1353 cnxn, hotlist, candidate_id, False, commit=False)
1354 # Hotlist transferred successfully. No need to delete it.
1355 hotlists_to_delete.remove(hotlist_id)
1356 break
1357
1358 # Delete users
1359 self.hotlist2user_tbl.Delete(cnxn, user_id=user_ids, commit=False)
1360 self.hotlist2issue_tbl.Update(
1361 cnxn, {'adder_id': framework_constants.DELETED_USER_ID},
1362 adder_id=user_ids, commit=False)
1363 user_svc.ExpungeUsersHotlistsHistory(cnxn, user_ids, commit=False)
1364 # Delete hotlists
1365 if hotlists_to_delete:
1366 self.ExpungeHotlists(
1367 cnxn, hotlists_to_delete, star_svc, user_svc, chart_svc, commit=False)
1368
1369
1370class HotlistAlreadyExists(Exception):
1371 """Tried to create a hotlist with the same name as another hotlist
1372 with the same owner."""
1373 pass
1374
1375
1376class NoSuchHotlistException(Exception):
1377 """The requested hotlist was not found."""
1378 pass
1379
1380
1381class UnownedHotlistException(Exception):
1382 """Tried to create a hotlist with no owner."""
1383 pass