Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1 | # 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. |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 4 | |
| 5 | """A set of functions that provide persistence for projects. |
| 6 | |
| 7 | This module provides functions to get, update, create, and (in some |
| 8 | cases) delete each type of project business object. It provides |
| 9 | a logical persistence layer on top of the database. |
| 10 | |
| 11 | Business objects are described in project_pb2.py. |
| 12 | """ |
| 13 | from __future__ import print_function |
| 14 | from __future__ import division |
| 15 | from __future__ import absolute_import |
| 16 | |
| 17 | import collections |
| 18 | import logging |
| 19 | import time |
| 20 | |
| 21 | import settings |
| 22 | from framework import exceptions |
| 23 | from framework import framework_constants |
| 24 | from framework import framework_helpers |
| 25 | from framework import permissions |
| 26 | from framework import sql |
| 27 | from services import caches |
| 28 | from project import project_helpers |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 29 | from mrproto import project_pb2 |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 30 | |
| 31 | |
| 32 | PROJECT_TABLE_NAME = 'Project' |
| 33 | USER2PROJECT_TABLE_NAME = 'User2Project' |
| 34 | EXTRAPERM_TABLE_NAME = 'ExtraPerm' |
| 35 | MEMBERNOTES_TABLE_NAME = 'MemberNotes' |
| 36 | USERGROUPPROJECTS_TABLE_NAME = 'Group2Project' |
| 37 | AUTOCOMPLETEEXCLUSION_TABLE_NAME = 'AutocompleteExclusion' |
| 38 | |
| 39 | PROJECT_COLS = [ |
| 40 | 'project_id', 'project_name', 'summary', 'description', 'state', 'access', |
| 41 | 'read_only_reason', 'state_reason', 'delete_time', 'issue_notify_address', |
| 42 | 'attachment_bytes_used', 'attachment_quota', 'cached_content_timestamp', |
| 43 | 'recent_activity_timestamp', 'moved_to', 'process_inbound_email', |
| 44 | 'only_owners_remove_restrictions', 'only_owners_see_contributors', |
| 45 | 'revision_url_format', 'home_page', 'docs_url', 'source_url', 'logo_gcs_id', |
| 46 | 'logo_file_name', 'issue_notify_always_detailed' |
| 47 | ] |
| 48 | USER2PROJECT_COLS = ['project_id', 'user_id', 'role_name'] |
| 49 | EXTRAPERM_COLS = ['project_id', 'user_id', 'perm'] |
| 50 | MEMBERNOTES_COLS = ['project_id', 'user_id', 'notes'] |
| 51 | AUTOCOMPLETEEXCLUSION_COLS = [ |
| 52 | 'project_id', 'user_id', 'ac_exclude', 'no_expand'] |
| 53 | |
| 54 | RECENT_ACTIVITY_THRESHOLD = framework_constants.SECS_PER_HOUR |
| 55 | |
| 56 | |
| 57 | class ProjectTwoLevelCache(caches.AbstractTwoLevelCache): |
| 58 | """Class to manage both RAM and memcache for Project PBs.""" |
| 59 | |
| 60 | def __init__(self, cachemanager, project_service): |
| 61 | super(ProjectTwoLevelCache, self).__init__( |
| 62 | cachemanager, 'project', 'project:', project_pb2.Project) |
| 63 | self.project_service = project_service |
| 64 | |
| 65 | def _DeserializeProjects( |
| 66 | self, project_rows, role_rows, extraperm_rows): |
| 67 | """Convert database rows into a dictionary of Project PB keyed by ID.""" |
| 68 | project_dict = {} |
| 69 | |
| 70 | for project_row in project_rows: |
| 71 | ( |
| 72 | project_id, project_name, summary, description, state_name, |
| 73 | access_name, read_only_reason, state_reason, delete_time, |
| 74 | issue_notify_address, attachment_bytes_used, attachment_quota, cct, |
| 75 | recent_activity_timestamp, moved_to, process_inbound_email, oorr, |
| 76 | oosc, revision_url_format, home_page, docs_url, source_url, |
| 77 | logo_gcs_id, logo_file_name, |
| 78 | issue_notify_always_detailed) = project_row |
| 79 | project = project_pb2.Project() |
| 80 | project.project_id = project_id |
| 81 | project.project_name = project_name |
| 82 | project.summary = summary |
| 83 | project.description = description |
| 84 | project.state = project_pb2.ProjectState(state_name.upper()) |
| 85 | project.state_reason = state_reason or '' |
| 86 | project.access = project_pb2.ProjectAccess(access_name.upper()) |
| 87 | project.read_only_reason = read_only_reason or '' |
| 88 | project.issue_notify_address = issue_notify_address or '' |
| 89 | project.attachment_bytes_used = attachment_bytes_used or 0 |
| 90 | project.attachment_quota = attachment_quota |
| 91 | project.recent_activity = recent_activity_timestamp or 0 |
| 92 | project.cached_content_timestamp = cct or 0 |
| 93 | project.delete_time = delete_time or 0 |
| 94 | project.moved_to = moved_to or '' |
| 95 | project.process_inbound_email = bool(process_inbound_email) |
| 96 | project.only_owners_remove_restrictions = bool(oorr) |
| 97 | project.only_owners_see_contributors = bool(oosc) |
| 98 | project.revision_url_format = revision_url_format or '' |
| 99 | project.home_page = home_page or '' |
| 100 | project.docs_url = docs_url or '' |
| 101 | project.source_url = source_url or '' |
| 102 | project.logo_gcs_id = logo_gcs_id or '' |
| 103 | project.logo_file_name = logo_file_name or '' |
| 104 | project.issue_notify_always_detailed = bool(issue_notify_always_detailed) |
| 105 | project_dict[project_id] = project |
| 106 | |
| 107 | for project_id, user_id, role_name in role_rows: |
| 108 | project = project_dict[project_id] |
| 109 | if role_name == 'owner': |
| 110 | project.owner_ids.append(user_id) |
| 111 | elif role_name == 'committer': |
| 112 | project.committer_ids.append(user_id) |
| 113 | elif role_name == 'contributor': |
| 114 | project.contributor_ids.append(user_id) |
| 115 | |
| 116 | perms = {} |
| 117 | for project_id, user_id, perm in extraperm_rows: |
| 118 | perms.setdefault(project_id, {}).setdefault(user_id, []).append(perm) |
| 119 | |
| 120 | for project_id, perms_by_user in perms.items(): |
| 121 | project = project_dict[project_id] |
| 122 | for user_id, extra_perms in sorted(perms_by_user.items()): |
| 123 | project.extra_perms.append(project_pb2.Project.ExtraPerms( |
| 124 | member_id=user_id, perms=extra_perms)) |
| 125 | |
| 126 | return project_dict |
| 127 | |
| 128 | def FetchItems(self, cnxn, keys): |
| 129 | """On RAM and memcache miss, hit the database to get missing projects.""" |
| 130 | project_rows = self.project_service.project_tbl.Select( |
| 131 | cnxn, cols=PROJECT_COLS, project_id=keys) |
| 132 | role_rows = self.project_service.user2project_tbl.Select( |
| 133 | cnxn, cols=['project_id', 'user_id', 'role_name'], |
| 134 | project_id=keys) |
| 135 | extraperm_rows = self.project_service.extraperm_tbl.Select( |
| 136 | cnxn, cols=EXTRAPERM_COLS, project_id=keys) |
| 137 | retrieved_dict = self._DeserializeProjects( |
| 138 | project_rows, role_rows, extraperm_rows) |
| 139 | return retrieved_dict |
| 140 | |
| 141 | |
| 142 | class UserToProjectIdTwoLevelCache(caches.AbstractTwoLevelCache): |
| 143 | """Class to manage both RAM and memcache for project_ids. |
| 144 | |
| 145 | Keys for this cache are int, user_ids, which might correspond to a group. |
| 146 | This cache should be used to fetch a set of project_ids that the user_id |
| 147 | is a member of. |
| 148 | """ |
| 149 | |
| 150 | def __init__(self, cachemanager, project_service): |
| 151 | # type: cachemanager_svc.CacheManager, ProjectService -> None |
| 152 | super(UserToProjectIdTwoLevelCache, self).__init__( |
| 153 | cachemanager, 'project_id', 'project_id:', pb_class=None) |
| 154 | self.project_service = project_service |
| 155 | |
| 156 | # Store the last time the table was fetched for rate limit purposes. |
| 157 | self.last_fetched = 0 |
| 158 | |
| 159 | def FetchItems(self, cnxn, keys): |
| 160 | # type MonorailConnection, Collection[int] -> Mapping[int, Collection[int]] |
| 161 | """On RAM and memcache miss, hit the database to get missing user_ids.""" |
| 162 | |
| 163 | # Unlike with other caches, we fetch and store the entire table. |
| 164 | # Thus, for cache misses we limit the rate we re-fetch the table to 60s. |
| 165 | now = self._GetCurrentTime() |
| 166 | result_dict = collections.defaultdict(set) |
| 167 | |
| 168 | if (now - self.last_fetched) > 60: |
| 169 | project_to_user_rows = self.project_service.user2project_tbl.Select( |
| 170 | cnxn, cols=['project_id', 'user_id']) |
| 171 | self.last_fetched = now |
| 172 | # Cache the whole User2Project table. |
| 173 | for project_id, user_id in project_to_user_rows: |
| 174 | result_dict[user_id].add(project_id) |
| 175 | |
| 176 | # Assume any requested user missing from result is not in any project. |
| 177 | result_dict.update( |
| 178 | (user_id, set()) for user_id in keys if user_id not in result_dict) |
| 179 | |
| 180 | return result_dict |
| 181 | |
| 182 | def _GetCurrentTime(self): |
| 183 | """ Returns the current time. We made a separate method for this to make it |
| 184 | easier to unit test. This was a better solution than @mock.patch because |
| 185 | the test had several unrelated time.time() calls. Modifying those calls |
| 186 | would be more onerous, having to fix calls for this test. |
| 187 | """ |
| 188 | return time.time() |
| 189 | |
| 190 | |
| 191 | class ProjectService(object): |
| 192 | """The persistence layer for project data.""" |
| 193 | |
| 194 | def __init__(self, cache_manager): |
| 195 | """Initialize this module so that it is ready to use. |
| 196 | |
| 197 | Args: |
| 198 | cache_manager: local cache with distributed invalidation. |
| 199 | """ |
| 200 | self.project_tbl = sql.SQLTableManager(PROJECT_TABLE_NAME) |
| 201 | self.user2project_tbl = sql.SQLTableManager(USER2PROJECT_TABLE_NAME) |
| 202 | self.extraperm_tbl = sql.SQLTableManager(EXTRAPERM_TABLE_NAME) |
| 203 | self.membernotes_tbl = sql.SQLTableManager(MEMBERNOTES_TABLE_NAME) |
| 204 | self.usergroupprojects_tbl = sql.SQLTableManager( |
| 205 | USERGROUPPROJECTS_TABLE_NAME) |
| 206 | self.acexclusion_tbl = sql.SQLTableManager( |
| 207 | AUTOCOMPLETEEXCLUSION_TABLE_NAME) |
| 208 | |
| 209 | # Like a dictionary {project_id: project} |
| 210 | self.project_2lc = ProjectTwoLevelCache(cache_manager, self) |
| 211 | # A dictionary of user_id to a set of project ids. |
| 212 | # Mapping[int, Collection[int]] |
| 213 | self.user_to_project_2lc = UserToProjectIdTwoLevelCache(cache_manager, self) |
| 214 | |
| 215 | # The project name to ID cache can never be invalidated by individual |
| 216 | # project changes because it is keyed by strings instead of ints. In |
| 217 | # the case of rare operations like deleting a project (or a future |
| 218 | # project renaming feature), we just InvalidateAll(). |
| 219 | self.project_names_to_ids = caches.RamCache(cache_manager, 'project') |
| 220 | |
| 221 | ### Creating projects |
| 222 | |
| 223 | def CreateProject( |
| 224 | self, cnxn, project_name, owner_ids, committer_ids, contributor_ids, |
| 225 | summary, description, state=project_pb2.ProjectState.LIVE, |
| 226 | access=None, read_only_reason=None, home_page=None, docs_url=None, |
| 227 | source_url=None, logo_gcs_id=None, logo_file_name=None): |
| 228 | """Create and store a Project with the given attributes. |
| 229 | |
| 230 | Args: |
| 231 | cnxn: connection to SQL database. |
| 232 | project_name: a valid project name, all lower case. |
| 233 | owner_ids: a list of user IDs for the project owners. |
| 234 | committer_ids: a list of user IDs for the project members. |
| 235 | contributor_ids: a list of user IDs for the project contributors. |
| 236 | summary: one-line explanation of the project. |
| 237 | description: one-page explanation of the project. |
| 238 | state: a project state enum defined in project_pb2. |
| 239 | access: optional project access enum defined in project.proto. |
| 240 | read_only_reason: if given, provides a status message and marks |
| 241 | the project as read-only. |
| 242 | home_page: home page of the project |
| 243 | docs_url: url to redirect to for wiki/documentation links |
| 244 | source_url: url to redirect to for source browser links |
| 245 | logo_gcs_id: google storage object id of the project's logo |
| 246 | logo_file_name: uploaded file name of the project's logo |
| 247 | |
| 248 | Returns: |
| 249 | The int project_id of the new project. |
| 250 | |
| 251 | Raises: |
| 252 | ProjectAlreadyExists: if a project with that name already exists. |
| 253 | """ |
| 254 | assert project_helpers.IsValidProjectName(project_name) |
| 255 | if self.LookupProjectIDs(cnxn, [project_name]): |
| 256 | raise exceptions.ProjectAlreadyExists() |
| 257 | |
| 258 | project = project_pb2.MakeProject( |
| 259 | project_name, state=state, access=access, |
| 260 | description=description, summary=summary, |
| 261 | owner_ids=owner_ids, committer_ids=committer_ids, |
| 262 | contributor_ids=contributor_ids, read_only_reason=read_only_reason, |
| 263 | home_page=home_page, docs_url=docs_url, source_url=source_url, |
| 264 | logo_gcs_id=logo_gcs_id, logo_file_name=logo_file_name) |
| 265 | |
| 266 | project.project_id = self._InsertProject(cnxn, project) |
| 267 | return project.project_id |
| 268 | |
| 269 | def _InsertProject(self, cnxn, project): |
| 270 | """Insert the given project into the database.""" |
| 271 | # Note: project_id is not specified because it is auto_increment. |
| 272 | project_id = self.project_tbl.InsertRow( |
| 273 | cnxn, project_name=project.project_name, |
| 274 | summary=project.summary, description=project.description, |
| 275 | state=str(project.state), access=str(project.access), |
| 276 | home_page=project.home_page, docs_url=project.docs_url, |
| 277 | source_url=project.source_url, |
| 278 | logo_gcs_id=project.logo_gcs_id, logo_file_name=project.logo_file_name) |
| 279 | logging.info('stored project was given project_id %d', project_id) |
| 280 | |
| 281 | self.user2project_tbl.InsertRows( |
| 282 | cnxn, ['project_id', 'user_id', 'role_name'], |
| 283 | [(project_id, user_id, 'owner') |
| 284 | for user_id in project.owner_ids] + |
| 285 | [(project_id, user_id, 'committer') |
| 286 | for user_id in project.committer_ids] + |
| 287 | [(project_id, user_id, 'contributor') |
| 288 | for user_id in project.contributor_ids]) |
| 289 | |
| 290 | return project_id |
| 291 | |
| 292 | ### Lookup project names and IDs |
| 293 | |
| 294 | def LookupProjectIDs(self, cnxn, project_names): |
| 295 | """Return a list of project IDs for the specified projects.""" |
| 296 | id_dict, missed_names = self.project_names_to_ids.GetAll(project_names) |
| 297 | if missed_names: |
| 298 | rows = self.project_tbl.Select( |
| 299 | cnxn, cols=['project_name', 'project_id'], project_name=missed_names) |
| 300 | retrieved_dict = dict(rows) |
| 301 | self.project_names_to_ids.CacheAll(retrieved_dict) |
| 302 | id_dict.update(retrieved_dict) |
| 303 | |
| 304 | return id_dict |
| 305 | |
| 306 | def LookupProjectNames(self, cnxn, project_ids): |
| 307 | """Lookup the names of the projects with the given IDs.""" |
| 308 | projects_dict = self.GetProjects(cnxn, project_ids) |
| 309 | return {p.project_id: p.project_name |
| 310 | for p in projects_dict.values()} |
| 311 | |
| 312 | ### Retrieving projects |
| 313 | |
| 314 | def GetAllProjects(self, cnxn, use_cache=True): |
| 315 | """Return A dict mapping IDs to all live project PBs.""" |
| 316 | project_rows = self.project_tbl.Select( |
| 317 | cnxn, cols=['project_id'], state=project_pb2.ProjectState.LIVE) |
| 318 | project_ids = [row[0] for row in project_rows] |
| 319 | projects_dict = self.GetProjects(cnxn, project_ids, use_cache=use_cache) |
| 320 | |
| 321 | return projects_dict |
| 322 | |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 323 | def GetVisibleProjects( |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 324 | self, cnxn, logged_in_user, effective_ids, domain=None, use_cache=True): |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 325 | """Return all user visible project ids. |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 326 | |
| 327 | Args: |
| 328 | cnxn: connection to SQL database. |
| 329 | logged_in_user: protocol buffer of the logged in user. Can be None. |
| 330 | effective_ids: set of user IDs for this user. Can be None. |
| 331 | domain: optional string with HTTP request hostname. |
| 332 | use_cache: pass False to force database query to find Project protocol |
| 333 | buffers. |
| 334 | |
| 335 | Returns: |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 336 | A list of project ids of user visible projects sorted by the names |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 337 | of the projects. If host was provided, only projects with that host |
| 338 | as their branded domain will be returned. |
| 339 | """ |
| 340 | project_rows = self.project_tbl.Select( |
| 341 | cnxn, cols=['project_id'], state=project_pb2.ProjectState.LIVE) |
| 342 | project_ids = [row[0] for row in project_rows] |
| 343 | projects_dict = self.GetProjects(cnxn, project_ids, use_cache=use_cache) |
| 344 | projects_on_host = { |
| 345 | project_id: project for project_id, project in projects_dict.items() |
| 346 | if not framework_helpers.GetNeededDomain(project.project_name, domain)} |
| 347 | visible_projects = [] |
| 348 | for project in projects_on_host.values(): |
| 349 | if permissions.UserCanViewProject(logged_in_user, effective_ids, project): |
| 350 | visible_projects.append(project) |
| 351 | visible_projects.sort(key=lambda p: p.project_name) |
| 352 | |
| 353 | return [project.project_id for project in visible_projects] |
| 354 | |
| 355 | def GetProjects(self, cnxn, project_ids, use_cache=True): |
| 356 | """Load all the Project PBs for the given projects. |
| 357 | |
| 358 | Args: |
| 359 | cnxn: connection to SQL database. |
| 360 | project_ids: list of int project IDs |
| 361 | use_cache: pass False to force database query. |
| 362 | |
| 363 | Returns: |
| 364 | A dict mapping IDs to the corresponding Project protocol buffers. |
| 365 | |
| 366 | Raises: |
| 367 | NoSuchProjectException: if any of the projects was not found. |
| 368 | """ |
| 369 | project_dict, missed_ids = self.project_2lc.GetAll( |
| 370 | cnxn, project_ids, use_cache=use_cache) |
| 371 | |
| 372 | # Also, update the project name cache. |
| 373 | self.project_names_to_ids.CacheAll( |
| 374 | {p.project_name: p.project_id for p in project_dict.values()}) |
| 375 | |
| 376 | if missed_ids: |
| 377 | raise exceptions.NoSuchProjectException() |
| 378 | |
| 379 | return project_dict |
| 380 | |
| 381 | def GetProject(self, cnxn, project_id, use_cache=True): |
| 382 | """Load the specified project from the database.""" |
| 383 | project_id_dict = self.GetProjects(cnxn, [project_id], use_cache=use_cache) |
| 384 | return project_id_dict[project_id] |
| 385 | |
| 386 | def GetProjectsByName(self, cnxn, project_names, use_cache=True): |
| 387 | """Load all the Project PBs for the given projects. |
| 388 | |
| 389 | Args: |
| 390 | cnxn: connection to SQL database. |
| 391 | project_names: list of project names. |
| 392 | use_cache: specifify False to force database query. |
| 393 | |
| 394 | Returns: |
| 395 | A dict mapping names to the corresponding Project protocol buffers. |
| 396 | """ |
| 397 | project_ids = list(self.LookupProjectIDs(cnxn, project_names).values()) |
| 398 | projects = self.GetProjects(cnxn, project_ids, use_cache=use_cache) |
| 399 | return {p.project_name: p for p in projects.values()} |
| 400 | |
| 401 | def GetProjectByName(self, cnxn, project_name, use_cache=True): |
| 402 | """Load the specified project from the database, None if does not exist.""" |
| 403 | project_dict = self.GetProjectsByName( |
| 404 | cnxn, [project_name], use_cache=use_cache) |
| 405 | return project_dict.get(project_name) |
| 406 | |
| 407 | ### Deleting projects |
| 408 | |
| 409 | def ExpungeProject(self, cnxn, project_id): |
| 410 | """Wipes a project from the system.""" |
| 411 | logging.info('expunging project %r', project_id) |
| 412 | self.user2project_tbl.Delete(cnxn, project_id=project_id) |
| 413 | self.usergroupprojects_tbl.Delete(cnxn, project_id=project_id) |
| 414 | self.extraperm_tbl.Delete(cnxn, project_id=project_id) |
| 415 | self.membernotes_tbl.Delete(cnxn, project_id=project_id) |
| 416 | self.acexclusion_tbl.Delete(cnxn, project_id=project_id) |
| 417 | self.project_tbl.Delete(cnxn, project_id=project_id) |
| 418 | |
| 419 | ### Updating projects |
| 420 | |
| 421 | def UpdateProject( |
| 422 | self, |
| 423 | cnxn, |
| 424 | project_id, |
| 425 | summary=None, |
| 426 | description=None, |
| 427 | state=None, |
| 428 | state_reason=None, |
| 429 | access=None, |
| 430 | issue_notify_address=None, |
| 431 | attachment_bytes_used=None, |
| 432 | attachment_quota=None, |
| 433 | moved_to=None, |
| 434 | process_inbound_email=None, |
| 435 | only_owners_remove_restrictions=None, |
| 436 | read_only_reason=None, |
| 437 | cached_content_timestamp=None, |
| 438 | only_owners_see_contributors=None, |
| 439 | delete_time=None, |
| 440 | recent_activity=None, |
| 441 | revision_url_format=None, |
| 442 | home_page=None, |
| 443 | docs_url=None, |
| 444 | source_url=None, |
| 445 | logo_gcs_id=None, |
| 446 | logo_file_name=None, |
| 447 | issue_notify_always_detailed=None, |
| 448 | commit=True): |
| 449 | """Update the DB with the given project information.""" |
| 450 | exists = self.project_tbl.SelectValue( |
| 451 | cnxn, 'project_name', project_id=project_id) |
| 452 | if not exists: |
| 453 | raise exceptions.NoSuchProjectException() |
| 454 | |
| 455 | delta = {} |
| 456 | if summary is not None: |
| 457 | delta['summary'] = summary |
| 458 | if description is not None: |
| 459 | delta['description'] = description |
| 460 | if state is not None: |
| 461 | delta['state'] = str(state).lower() |
| 462 | if state is not None: |
| 463 | delta['state_reason'] = state_reason |
| 464 | if access is not None: |
| 465 | delta['access'] = str(access).lower() |
| 466 | if read_only_reason is not None: |
| 467 | delta['read_only_reason'] = read_only_reason |
| 468 | if issue_notify_address is not None: |
| 469 | delta['issue_notify_address'] = issue_notify_address |
| 470 | if attachment_bytes_used is not None: |
| 471 | delta['attachment_bytes_used'] = attachment_bytes_used |
| 472 | if attachment_quota is not None: |
| 473 | delta['attachment_quota'] = attachment_quota |
| 474 | if moved_to is not None: |
| 475 | delta['moved_to'] = moved_to |
| 476 | if process_inbound_email is not None: |
| 477 | delta['process_inbound_email'] = process_inbound_email |
| 478 | if only_owners_remove_restrictions is not None: |
| 479 | delta['only_owners_remove_restrictions'] = ( |
| 480 | only_owners_remove_restrictions) |
| 481 | if only_owners_see_contributors is not None: |
| 482 | delta['only_owners_see_contributors'] = only_owners_see_contributors |
| 483 | if delete_time is not None: |
| 484 | delta['delete_time'] = delete_time |
| 485 | if recent_activity is not None: |
| 486 | delta['recent_activity_timestamp'] = recent_activity |
| 487 | if revision_url_format is not None: |
| 488 | delta['revision_url_format'] = revision_url_format |
| 489 | if home_page is not None: |
| 490 | delta['home_page'] = home_page |
| 491 | if docs_url is not None: |
| 492 | delta['docs_url'] = docs_url |
| 493 | if source_url is not None: |
| 494 | delta['source_url'] = source_url |
| 495 | if logo_gcs_id is not None: |
| 496 | delta['logo_gcs_id'] = logo_gcs_id |
| 497 | if logo_file_name is not None: |
| 498 | delta['logo_file_name'] = logo_file_name |
| 499 | if issue_notify_always_detailed is not None: |
| 500 | delta['issue_notify_always_detailed'] = issue_notify_always_detailed |
| 501 | if cached_content_timestamp is not None: |
| 502 | delta['cached_content_timestamp'] = cached_content_timestamp |
| 503 | self.project_tbl.Update(cnxn, delta, project_id=project_id, commit=False) |
| 504 | self.project_2lc.InvalidateKeys(cnxn, [project_id]) |
| 505 | if commit: |
| 506 | cnxn.Commit() |
| 507 | |
| 508 | def UpdateCachedContentTimestamp(self, cnxn, project_id, now=None): |
| 509 | now = now or int(time.time()) |
| 510 | self.project_tbl.Update( |
| 511 | cnxn, {'cached_content_timestamp': now}, |
| 512 | project_id=project_id, commit=False) |
| 513 | return now |
| 514 | |
| 515 | def UpdateProjectRoles( |
| 516 | self, cnxn, project_id, owner_ids, committer_ids, contributor_ids, |
| 517 | now=None): |
| 518 | """Store the project's roles in the DB and set cached_content_timestamp.""" |
| 519 | exists = self.project_tbl.SelectValue( |
| 520 | cnxn, 'project_name', project_id=project_id) |
| 521 | if not exists: |
| 522 | raise exceptions.NoSuchProjectException() |
| 523 | |
| 524 | self.UpdateCachedContentTimestamp(cnxn, project_id, now=now) |
| 525 | |
| 526 | self.user2project_tbl.Delete( |
| 527 | cnxn, project_id=project_id, role_name='owner', commit=False) |
| 528 | self.user2project_tbl.Delete( |
| 529 | cnxn, project_id=project_id, role_name='committer', commit=False) |
| 530 | self.user2project_tbl.Delete( |
| 531 | cnxn, project_id=project_id, role_name='contributor', commit=False) |
| 532 | |
| 533 | self.user2project_tbl.InsertRows( |
| 534 | cnxn, ['project_id', 'user_id', 'role_name'], |
| 535 | [(project_id, user_id, 'owner') for user_id in owner_ids], |
| 536 | commit=False) |
| 537 | self.user2project_tbl.InsertRows( |
| 538 | cnxn, ['project_id', 'user_id', 'role_name'], |
| 539 | [(project_id, user_id, 'committer') |
| 540 | for user_id in committer_ids], commit=False) |
| 541 | |
| 542 | self.user2project_tbl.InsertRows( |
| 543 | cnxn, ['project_id', 'user_id', 'role_name'], |
| 544 | [(project_id, user_id, 'contributor') |
| 545 | for user_id in contributor_ids], commit=False) |
| 546 | |
| 547 | cnxn.Commit() |
| 548 | self.project_2lc.InvalidateKeys(cnxn, [project_id]) |
| 549 | updated_user_ids = owner_ids + committer_ids + contributor_ids |
| 550 | self.user_to_project_2lc.InvalidateKeys(cnxn, updated_user_ids) |
| 551 | |
| 552 | def MarkProjectDeletable(self, cnxn, project_id, config_service): |
| 553 | """Update the project's state to make it DELETABLE and free up the name. |
| 554 | |
| 555 | Args: |
| 556 | cnxn: connection to SQL database. |
| 557 | project_id: int ID of the project that will be deleted soon. |
| 558 | config_service: issue tracker configuration persistence service, needed |
| 559 | to invalidate cached issue tracker results. |
| 560 | """ |
| 561 | generated_name = 'DELETABLE_%d' % project_id |
| 562 | delta = {'project_name': generated_name, 'state': 'deletable'} |
| 563 | self.project_tbl.Update(cnxn, delta, project_id=project_id) |
| 564 | |
| 565 | self.project_2lc.InvalidateKeys(cnxn, [project_id]) |
| 566 | # We cannot invalidate a specific part of the name->proj cache by name, |
| 567 | # So, tell every job to just drop the whole cache. It should refill |
| 568 | # efficiently and incrementally from memcache. |
| 569 | self.project_2lc.InvalidateAllRamEntries(cnxn) |
| 570 | self.user_to_project_2lc.InvalidateAllRamEntries(cnxn) |
| 571 | config_service.InvalidateMemcacheForEntireProject(project_id) |
| 572 | |
| 573 | def UpdateRecentActivity(self, cnxn, project_id, now=None): |
| 574 | """Set the project's recent_activity to the current time.""" |
| 575 | now = now or int(time.time()) |
| 576 | project = self.GetProject(cnxn, project_id) |
| 577 | if now > project.recent_activity + RECENT_ACTIVITY_THRESHOLD: |
| 578 | self.UpdateProject(cnxn, project_id, recent_activity=now) |
| 579 | |
| 580 | ### Roles, memberships, and extra perms |
| 581 | |
| 582 | def GetUserRolesInAllProjects(self, cnxn, effective_ids): |
| 583 | """Return three sets of project IDs where the user has a role.""" |
| 584 | owned_project_ids = set() |
| 585 | membered_project_ids = set() |
| 586 | contrib_project_ids = set() |
| 587 | |
| 588 | rows = [] |
| 589 | if effective_ids: |
| 590 | rows = self.user2project_tbl.Select( |
| 591 | cnxn, cols=['project_id', 'role_name'], user_id=effective_ids) |
| 592 | |
| 593 | for project_id, role_name in rows: |
| 594 | if role_name == 'owner': |
| 595 | owned_project_ids.add(project_id) |
| 596 | elif role_name == 'committer': |
| 597 | membered_project_ids.add(project_id) |
| 598 | elif role_name == 'contributor': |
| 599 | contrib_project_ids.add(project_id) |
| 600 | else: |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 601 | logging.warning('Unexpected role name %r', role_name) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 602 | |
| 603 | return owned_project_ids, membered_project_ids, contrib_project_ids |
| 604 | |
| 605 | def GetProjectMemberships(self, cnxn, effective_ids, use_cache=True): |
| 606 | # type: MonorailConnection, Collection[int], Optional[bool] -> |
| 607 | # Mapping[int, Collection[int]] |
| 608 | """Return a list of project IDs where the user has a membership.""" |
| 609 | project_id_dict, missed_ids = self.user_to_project_2lc.GetAll( |
| 610 | cnxn, effective_ids, use_cache=use_cache) |
| 611 | |
| 612 | # Users that were missed are assumed to not have any projects. |
| 613 | assert not missed_ids |
| 614 | |
| 615 | return project_id_dict |
| 616 | |
| 617 | def UpdateExtraPerms( |
| 618 | self, cnxn, project_id, member_id, extra_perms, now=None): |
| 619 | """Load the project, update the member's extra perms, and store. |
| 620 | |
| 621 | Args: |
| 622 | cnxn: connection to SQL database. |
| 623 | project_id: int ID of the current project. |
| 624 | member_id: int user id of the user that was edited. |
| 625 | extra_perms: list of strings for perms that the member |
| 626 | should have over-and-above what their role gives them. |
| 627 | now: fake int(time.time()) value passed in during unit testing. |
| 628 | """ |
| 629 | # This will be a newly constructed object, not from the cache and not |
| 630 | # shared with any other thread. |
| 631 | project = self.GetProject(cnxn, project_id, use_cache=False) |
| 632 | |
| 633 | idx, member_extra_perms = permissions.FindExtraPerms(project, member_id) |
| 634 | if not member_extra_perms and not extra_perms: |
| 635 | return |
| 636 | if member_extra_perms and list(member_extra_perms.perms) == extra_perms: |
| 637 | return |
| 638 | # Either project is None or member_id is not a member of the project. |
| 639 | if idx is None: |
| 640 | return |
| 641 | |
| 642 | if member_extra_perms: |
| 643 | member_extra_perms.perms = extra_perms |
| 644 | else: |
| 645 | member_extra_perms = project_pb2.Project.ExtraPerms( |
| 646 | member_id=member_id, perms=extra_perms) |
| 647 | # Keep the list of extra_perms sorted by member id. |
| 648 | project.extra_perms.insert(idx, member_extra_perms) |
| 649 | |
| 650 | self.extraperm_tbl.Delete( |
| 651 | cnxn, project_id=project_id, user_id=member_id, commit=False) |
| 652 | self.extraperm_tbl.InsertRows( |
| 653 | cnxn, EXTRAPERM_COLS, |
| 654 | [(project_id, member_id, perm) for perm in extra_perms], |
| 655 | commit=False) |
| 656 | project.cached_content_timestamp = self.UpdateCachedContentTimestamp( |
| 657 | cnxn, project_id, now=now) |
| 658 | cnxn.Commit() |
| 659 | |
| 660 | self.project_2lc.InvalidateKeys(cnxn, [project_id]) |
| 661 | |
| 662 | ### Project Commitments |
| 663 | |
| 664 | def GetProjectCommitments(self, cnxn, project_id): |
| 665 | """Get the project commitments (notes) from the DB. |
| 666 | |
| 667 | Args: |
| 668 | cnxn: connection to SQL database. |
| 669 | project_id: int project ID. |
| 670 | |
| 671 | Returns: |
| 672 | A the specified project's ProjectCommitments instance, or an empty one, |
| 673 | if the project doesn't exist, or has not documented member |
| 674 | commitments. |
| 675 | """ |
| 676 | # Get the notes. Don't get the project_id column |
| 677 | # since we already know that value. |
| 678 | notes_rows = self.membernotes_tbl.Select( |
| 679 | cnxn, cols=['user_id', 'notes'], project_id=project_id) |
| 680 | notes_dict = dict(notes_rows) |
| 681 | |
| 682 | project_commitments = project_pb2.ProjectCommitments() |
| 683 | project_commitments.project_id = project_id |
| 684 | for user_id in notes_dict.keys(): |
| 685 | commitment = project_pb2.ProjectCommitments.MemberCommitment( |
| 686 | member_id=user_id, |
| 687 | notes=notes_dict.get(user_id, '')) |
| 688 | project_commitments.commitments.append(commitment) |
| 689 | |
| 690 | return project_commitments |
| 691 | |
| 692 | def _StoreProjectCommitments(self, cnxn, project_commitments): |
| 693 | """Store an updated set of project commitments in the DB. |
| 694 | |
| 695 | Args: |
| 696 | cnxn: connection to SQL database. |
| 697 | project_commitments: ProjectCommitments PB |
| 698 | """ |
| 699 | project_id = project_commitments.project_id |
| 700 | notes_rows = [] |
| 701 | for commitment in project_commitments.commitments: |
| 702 | notes_rows.append( |
| 703 | (project_id, commitment.member_id, commitment.notes)) |
| 704 | |
| 705 | # TODO(jrobbins): this should be in a transaction. |
| 706 | self.membernotes_tbl.Delete(cnxn, project_id=project_id) |
| 707 | self.membernotes_tbl.InsertRows( |
| 708 | cnxn, MEMBERNOTES_COLS, notes_rows, ignore=True) |
| 709 | |
| 710 | def UpdateCommitments(self, cnxn, project_id, member_id, notes): |
| 711 | """Update the member's commitments in the specified project. |
| 712 | |
| 713 | Args: |
| 714 | cnxn: connection to SQL database. |
| 715 | project_id: int ID of the current project. |
| 716 | member_id: int user ID of the user that was edited. |
| 717 | notes: further notes on the member's expected involvment |
| 718 | in the project. |
| 719 | """ |
| 720 | project_commitments = self.GetProjectCommitments(cnxn, project_id) |
| 721 | |
| 722 | commitment = None |
| 723 | for c in project_commitments.commitments: |
| 724 | if c.member_id == member_id: |
| 725 | commitment = c |
| 726 | break |
| 727 | else: |
| 728 | commitment = project_pb2.ProjectCommitments.MemberCommitment( |
| 729 | member_id=member_id) |
| 730 | project_commitments.commitments.append(commitment) |
| 731 | |
| 732 | dirty = False |
| 733 | |
| 734 | if commitment.notes != notes: |
| 735 | commitment.notes = notes |
| 736 | dirty = True |
| 737 | |
| 738 | if dirty: |
| 739 | self._StoreProjectCommitments(cnxn, project_commitments) |
| 740 | |
| 741 | def GetProjectAutocompleteExclusion(self, cnxn, project_id): |
| 742 | """Get user ids who are excluded from autocomplete list. |
| 743 | |
| 744 | Args: |
| 745 | cnxn: connection to SQL database. |
| 746 | project_id: int ID of the current project. |
| 747 | |
| 748 | Returns: |
| 749 | A pair containing: a list of user IDs who are excluded from the |
| 750 | autocomplete list for given project, and a list of group IDs to |
| 751 | not expand. |
| 752 | """ |
| 753 | ac_exclusion_rows = self.acexclusion_tbl.Select( |
| 754 | cnxn, cols=['user_id'], project_id=project_id, ac_exclude=True) |
| 755 | ac_exclusion_ids = [row[0] for row in ac_exclusion_rows] |
| 756 | no_expand_rows = self.acexclusion_tbl.Select( |
| 757 | cnxn, cols=['user_id'], project_id=project_id, no_expand=True) |
| 758 | no_expand_ids = [row[0] for row in no_expand_rows] |
| 759 | return ac_exclusion_ids, no_expand_ids |
| 760 | |
| 761 | def UpdateProjectAutocompleteExclusion( |
| 762 | self, cnxn, project_id, member_id, ac_exclude, no_expand): |
| 763 | """Update autocomplete exclusion for given user. |
| 764 | |
| 765 | Args: |
| 766 | cnxn: connection to SQL database. |
| 767 | project_id: int ID of the current project. |
| 768 | member_id: int user ID of the user that was edited. |
| 769 | ac_exclude: Whether this user should be excluded. |
| 770 | no_expand: Whether this group should not be expanded. |
| 771 | """ |
| 772 | if ac_exclude or no_expand: |
| 773 | self.acexclusion_tbl.InsertRows( |
| 774 | cnxn, AUTOCOMPLETEEXCLUSION_COLS, |
| 775 | [(project_id, member_id, ac_exclude, no_expand)], |
| 776 | replace=True) |
| 777 | else: |
| 778 | self.acexclusion_tbl.Delete( |
| 779 | cnxn, project_id=project_id, user_id=member_id) |
| 780 | |
| 781 | self.UpdateCachedContentTimestamp(cnxn, project_id) |
| 782 | cnxn.Commit() |
| 783 | |
| 784 | self.project_2lc.InvalidateKeys(cnxn, [project_id]) |
| 785 | |
| 786 | def ExpungeUsersInProjects(self, cnxn, user_ids, limit=None): |
| 787 | """Wipes the given users from the projects system. |
| 788 | |
| 789 | This method will not commit the operation. This method will |
| 790 | not make changes to in-memory data. |
| 791 | """ |
| 792 | self.extraperm_tbl.Delete(cnxn, user_id=user_ids, limit=limit, commit=False) |
| 793 | self.acexclusion_tbl.Delete( |
| 794 | cnxn, user_id=user_ids, limit=limit, commit=False) |
| 795 | self.membernotes_tbl.Delete( |
| 796 | cnxn, user_id=user_ids, limit=limit, commit=False) |
| 797 | self.user2project_tbl.Delete( |
| 798 | cnxn, user_id=user_ids, limit=limit, commit=False) |