def _CreateNewEpisodesAndPosts(self, new_ep_dicts, new_ids): """Creates new episodes and posts within those episodes based on a list returned by _CheckCopySources. If an episode or post id does not exist in "new_ids", it is not created. The "new_ids" set is created by _CheckCopyTargets. """ tasks = [] for new_ep_dict in deepcopy(new_ep_dicts): ep_id = new_ep_dict['episode_id'] ph_ids = [ ph_id for ph_id in new_ep_dict.pop('photo_ids') if Post.ConstructPostId(ep_id, ph_id) in new_ids ] if ep_id in new_ids: tasks.append( gen.Task(Episode.CreateNew, self._client, **new_ep_dict)) for ph_id in ph_ids: post = Post.CreateFromKeywords(episode_id=ep_id, photo_id=ph_id) post.labels.remove(Post.UNSHARED) post.labels.remove(Post.REMOVED) tasks.append(gen.Task(post.Update, self._client)) yield tasks
def _Compare(episode_id1, photo_id1, episode_id2, photo_id2): result = cmp(episode_id1, episode_id2) if result == 0: result = cmp(photo_id1, photo_id2) post_id1 = Post.ConstructPostId(episode_id1, photo_id1) post_id2 = Post.ConstructPostId(episode_id2, photo_id2) self.assertEqual(cmp(post_id1, post_id2), result)
def testRenamePhotoLabelMigration(self): """Test migrator that renames HIDDEN label to REMOVED for older clients.""" ep_id, ph_ids = self._UploadOneEpisode(self._cookie, 2) response_dict = self._SendRequest('remove_photos', self._cookie, {'episodes': [{'episode_id': ep_id, 'photo_ids': ph_ids[:1]}, {'episode_id': ep_id, 'photo_ids': ph_ids[1:]}]}, version=Message.SUPPORT_MULTIPLE_IDENTITIES_PER_CONTACT) # Update USER_POST row to use HIDDEN label. post_id = Post.ConstructPostId(ep_id, ph_ids[0]) self._UpdateOrAllocateDBObject(UserPost, user_id=self._user.user_id, post_id=post_id, labels=[UserPost.HIDDEN]) response_dict = self._SendRequest('query_episodes', self._cookie, {'episodes': [{'episode_id': ep_id, 'get_attributes': True, 'get_photos': True}, {'episode_id': ep_id, 'get_photos': True}, {'episode_id': ep_id}]}, version=Message.SUPPORT_MULTIPLE_IDENTITIES_PER_CONTACT) labels = [ph_dict['labels'] for ep_dict in response_dict['episodes'] for ph_dict in ep_dict.get('photos', [])] self.assertEqual(labels, [[u'removed'], [u'removed'], [u'removed'], [u'removed']])
def _UnsharePost(episode_id, photo_id): """Add UNSHARED label to the given post and write it to the DB.""" post = Post.CreateFromKeywords(episode_id=episode_id, photo_id=photo_id) post.labels.add(Post.UNSHARED) post.labels.add(Post.REMOVED) yield gen.Task(post.Update, self._client)
def _Account(self): """Makes accounting changes: 1. For revived followers. 2. For new photos that were shared. """ # Get list of the ids of all photos that were added. photo_ids = [ Post.DeconstructPostId(id)[1] for id in self._new_ids if id.startswith(IdPrefix.Post) ] acc_accum = AccountingAccumulator() # Make accounting changes for any revived followers. yield acc_accum.ReviveFollowers(self._client, self._viewpoint_id, self._revive_follower_ids) # Make accounting changes for the new photos that were added. yield acc_accum.SharePhotos( self._client, self._user_id, self._viewpoint_id, photo_ids, [ follower.user_id for follower in self._followers if not follower.IsRemoved() ]) yield acc_accum.Apply(self._client)
def _Check(self): """Gathers pre-mutation information: 1. Queries for episodes. 2. Queries for user posts. 3. Checkpoints list of post ids that need to be removed. Validates the following: 1. Permission to remove photos from episodes. 2. Photos cannot be removed from shared viewpoints. """ ep_ph_ids_list = [(ep_dict['episode_id'], ep_dict['photo_ids']) for ep_dict in self._ep_dicts] self._ep_posts_list = yield self._CheckEpisodePostAccess( 'remove', self._client, self._user.user_id, ep_ph_ids_list) for episode, post in self._ep_posts_list: if episode.viewpoint_id != self._user.private_vp_id: raise PermissionError(INVALID_REMOVE_PHOTOS_VIEWPOINT, viewpoint_id=episode.viewpoint_id) if self._op.checkpoint is None: # Get subset of photos that need to be removed. self._remove_ids = set( Post.ConstructPostId(episode.episode_id, post.photo_id) for episode, posts in self._ep_posts_list for post in posts if not post.IsRemoved()) # Set checkpoint. # List of post ids to remove need to be check-pointed because they will change in the # UPDATE phase. If we fail after UPDATE, but before NOTIFY, we would not send correct # notifications on retry. yield self._op.SetCheckpoint(self._client, {'remove': list(self._remove_ids)}) else: self._remove_ids = set(self._op.checkpoint['remove'])
def _Account(self): """Makes accounting changes: 1. For unshared photos. """ acc_accum = AccountingAccumulator() # Make accounting changes for all unshared photos. tasks = [] for viewpoint_id, ep_dicts in self._unshares_dict.iteritems(): # Filter out any photos which were already removed. if len(self._already_removed_ids) > 0: ep_dicts = deepcopy(ep_dicts) for episode_id, photo_ids in ep_dicts.iteritems(): ep_dicts[episode_id] = [ photo_id for photo_id in photo_ids if Post.ConstructPostId(episode_id, photo_id) not in self._already_removed_ids ] viewpoint, followers = self._vp_followers_dict[viewpoint_id] tasks.append( acc_accum.Unshare(self._client, viewpoint, ep_dicts, [follower for follower in followers])) yield tasks yield acc_accum.Apply(self._client)
def _CreateTestPost(self, episode_id, photo_id, unshared=False, removed=False): """Create post for testing purposes.""" post = Post.CreateFromKeywords(episode_id=episode_id, photo_id=photo_id) if unshared: post.labels.add(Post.UNSHARED) if unshared or removed: post.labels.add(Post.REMOVED) self._RunAsync(post.Update, self._client)
def _TestHidePhotos(tester, user_cookie, request_dict): """Called by the ServiceTester in order to test hide_photos service API call.""" validator = tester.validator user_id, device_id = tester.GetIdsFromCookie(user_cookie) request_dict = deepcopy(request_dict) user = validator.GetModelObject(User, user_id) # Send hide_photos request. actual_dict = tester.SendRequest('hide_photos', user_cookie, request_dict) op_dict = tester._DeriveNotificationOpDict(user_id, device_id, request_dict) # Validate UserPost objects. ph_act_dict = {} for request_ep in request_dict['episodes']: episode_id = request_ep['episode_id'] episode = validator.GetModelObject(Episode, episode_id) for photo_id in request_ep['photo_ids']: post_id = Post.ConstructPostId(episode_id, photo_id) user_post = validator.GetModelObject(UserPost, DBKey(user_id, post_id), must_exist=False) # Gather set of photos that should have been hidden and will affect accounting. if episode.viewpoint_id == user.private_vp_id: if user_post is None or not user_post.IsHidden(): ph_act_dict.setdefault(episode.viewpoint_id, {}).setdefault(episode_id, []).append(photo_id) timestamp = op_dict[ 'op_timestamp'] if user_post is None else user_post.timestamp # Add HIDDEN label if not already there. if user_post is None: labels = [UserPost.HIDDEN] else: labels = user_post.labels if not user_post.IsHidden(): labels = labels.union([UserPost.HIDDEN]) validator.ValidateUpdateDBObject(UserPost, user_id=user_id, post_id=post_id, timestamp=timestamp, labels=labels) # Validate notification for the hide. invalidate = { 'episodes': [{ 'episode_id': request_ep['episode_id'], 'get_photos': True } for request_ep in request_dict['episodes']] } validator.ValidateNotification('hide_photos', user_id, op_dict, invalidate) return actual_dict
def _Update(self): """Updates the database: 1. Creates episode if it did not exist, or sets episode's location/placemark. 2. Creates posts that did not previously exist. 3. Creates photos that did not previously exist. 4. Updates photo MD5 values if they were given in a re-upload. """ # Set episode location/placemark. if self._set_location or self._set_placemark: for ph_dict in self._ph_dicts: if 'location' not in self._ep_dict and 'location' in ph_dict: self._ep_dict['location'] = ph_dict['location'] if 'placemark' not in self._ep_dict and 'placemark' in ph_dict: self._ep_dict['placemark'] = ph_dict['placemark'] # Create new episode if it did not exist at the beginning of the operation. if self._episode_id in self._new_ids: yield gen.Task(Episode.CreateNew, self._client, **self._ep_dict) # Update existing episode's location/placemark. elif self._set_location or self._set_placemark: yield gen.Task(self._episode.UpdateExisting, self._client, location=self._ep_dict.get('location', None), placemark=self._ep_dict.get('placemark', None)) # Create posts and photos that did not exist at the beginning of the operation. tasks = [] for ph_dict in self._ph_dicts: # Only create post, user_photo and photo if photo did not exist at the beginning of the operation. if ph_dict['photo_id'] in self._new_ids: # Create user photo record if asset keys were specified. asset_keys = ph_dict.pop('asset_keys', None) if asset_keys is not None: tasks.append(UserPhoto.CreateNew(self._client, user_id=self._user.user_id, photo_id=ph_dict['photo_id'], asset_keys=asset_keys)) tasks.append(Photo.CreateNew(self._client, **ph_dict)) tasks.append(Post.CreateNew(self._client, episode_id=self._episode_id, photo_id=ph_dict['photo_id'])) else: # Update the photo if any MD5 attributes need to be overwritten. This is allowed if the photo image # has not yet been uploaded. This can happen if the MD5 value has changed on the client due to an IOS # upgrade. md5_dict = {'photo_id': ph_dict['photo_id']} util.SetIfNotNone(md5_dict, 'tn_md5', ph_dict['tn_md5']) util.SetIfNotNone(md5_dict, 'med_md5', ph_dict['med_md5']) util.SetIfNotNone(md5_dict, 'full_md5', ph_dict['full_md5']) util.SetIfNotNone(md5_dict, 'orig_md5', ph_dict['orig_md5']) if md5_dict: yield Photo.UpdateExisting(self._client, **md5_dict) yield tasks
def _QueryAvailablePost(episode_id, photo_id): if available_posts_dict is not None: post = available_posts_dict.get( Post.ConstructPostId(episode_id, photo_id), None) else: post = yield gen.Task(Post.Query, client, episode_id, photo_id, col_names=None) if post.IsRemoved(): post = None raise gen.Return(post)
def _Account(self): """Makes accounting changes: 1. Decrease user accounting by size of removed photos. """ # Get list of the ids of all photos that were removed. photo_ids = [ Post.DeconstructPostId(post_id)[1] for post_id in self._remove_ids ] acc_accum = AccountingAccumulator() yield acc_accum.RemovePhotos(self._client, self._user.user_id, self._user.private_vp_id, photo_ids) yield acc_accum.Apply(self._client)
def _CreateExpectedPhotos(validator, user_id, device_id, episode_id, limit=None, start_key=None): """Return a set of photo dicts that contain all the photo metadata for photos in the episode with id "episode_id". """ photo_dicts = [] posts = validator.QueryModelObjects(Post, episode_id, limit=limit, start_key=start_key) for post in posts: post_dict = post._asdict() photo_dict = validator.GetModelObject(Photo, post.photo_id)._asdict() photo_dict.pop('share_seq', None) photo_dict.pop('client_data', None) # Do not return access URLs for posts which have been removed. if not post.IsRemoved(): obj_store = ObjectStore.GetInstance(ObjectStore.PHOTO) _AddPhotoUrls(obj_store, photo_dict) asset_keys = set() user_photo = validator.GetModelObject(UserPhoto, DBKey(user_id, post.photo_id), must_exist=False) if user_photo is not None and user_photo.asset_keys: asset_keys.update(user_photo.asset_keys) if asset_keys: photo_dict['asset_keys'] = list(asset_keys) photo_dicts.append(photo_dict) post_id = Post.ConstructPostId(episode_id, post.photo_id) user_post = validator.GetModelObject(UserPost, DBKey(user_id, post_id), must_exist=False) labels = post.labels.combine() if user_post is not None: # Union together post labels and user_post labels. labels = labels.union(user_post.labels.combine()) if len(labels) > 0: photo_dict['labels'] = list(labels) last_key = posts[-1].photo_id if len(posts) > 0 else None return (photo_dicts, last_key)
def _Account(self): """Makes accounting changes: 1. For new photos that were saved. """ # Get list of the ids of all photos that were added. photo_ids = [Post.DeconstructPostId(id)[1] for id in self._new_ids if id.startswith(IdPrefix.Post)] acc_accum = AccountingAccumulator() # Make accounting changes for the new photos that were added. yield acc_accum.SavePhotos(self._client, self._user.user_id, self._user.private_vp_id, photo_ids) yield acc_accum.Apply(self._client)
def _OnQueryPosts(posts): with util.ArrayBarrier(partial(_OnQueryMetadata, posts)) as b: for post in posts: with util.ArrayBarrier(b.Callback()) as metadata_b: post_id = Post.ConstructPostId(post.episode_id, post.photo_id) Photo.Query(client, hash_key=post.photo_id, col_names=None, callback=metadata_b.Callback()) UserPost.Query(client, hash_key=user_id, range_key=post_id, col_names=None, callback=metadata_b.Callback(), must_exist=False)
def _Check(self): """Gathers pre-mutation information: 1. Queries for user posts. Validates the following: 1. Permission to remove photos from episodes. """ ep_ph_ids_list = [(ep_dict['episode_id'], ep_dict['photo_ids']) for ep_dict in self._ep_dicts] yield self._CheckEpisodePostAccess('hide', self._client, self._user.user_id, ep_ph_ids_list) self._user_post_keys = [ DBKey(self._user.user_id, Post.ConstructPostId(ep_dict['episode_id'], photo_id)) for ep_dict in self._ep_dicts for photo_id in ep_dict['photo_ids'] ] self._user_posts = yield gen.Task(UserPost.BatchQuery, self._client, self._user_post_keys, None, must_exist=False)
def _AuthorizeUser(cls, client, episode_id, photo_id, write_access): """Checks that the current user (in Viewfinder context) user is authorized to access the given photo: 1. The photo must exist, and be in the given episode 2. The photo must not be unshared 3. If uploading the photo, the user must be the episode owner 4. A prospective user has access only to photos in the viewpoint specified in the cookie """ context = base.ViewfinderContext.current() if context is None or context.user is None: raise web.HTTPError(401, 'You are not logged in. Only users that have logged in can access this URL.') user_id = context.user.user_id post_id = Post.ConstructPostId(episode_id, photo_id) episode, post = yield [gen.Task(Episode.QueryIfVisible, client, user_id, episode_id, must_exist=False), gen.Task(Post.Query, client, episode_id, photo_id, None, must_exist=False)] if episode is None or post is None: raise web.HTTPError(404, 'Photo was not found or you do not have permission to view it.') if write_access and episode.user_id != user_id: raise web.HTTPError(403, 'You do not have permission to upload this photo; it is not owned by you.') if post.IsUnshared(): raise web.HTTPError(403, 'This photo can no longer be viewed; it was unshared.') # BUGBUG(Andy): The 1.5 client has a bug where it always passes in the library episode id # when trying to fetch a photo, even if the photo is part of a conversation. This results # in 403 errors when a user tries to sync to their library. For now, I'm disabling this # check. Once 2.0 has established itself, I'll re-enable the check. #if post.IsRemoved(): # raise web.HTTPError(403, 'This photo can no longer be viewed; it was removed.') if not context.CanViewViewpoint(episode.viewpoint_id): # Always allow system viewpoints to be accessed by a prospective user. viewpoint = yield gen.Task(Viewpoint.Query, client, episode.viewpoint_id, None) if not viewpoint.IsSystem(): raise web.HTTPError(403, 'You do not have permission to view this photo. ' 'To see it, you must register an account.')
def _CheckCopyTargets(cls, action, client, user_id, viewpoint_id, target_ep_dicts): """Compiles a list of target episode and post ids that do not exist or are removed. These episodes and posts will not be copied as part of the operation. Returns the set of target episode and post ids that will be (re)created by the caller. """ # Gather db keys for all target episodes and posts. episode_keys = [] post_keys = [] for ep_dict in target_ep_dicts: episode_keys.append(DBKey(ep_dict['episode_id'], None)) for photo_id in ep_dict['photo_ids']: post_keys.append(DBKey(ep_dict['episode_id'], photo_id)) # Query for all episodes and posts in parallel and in batches. episodes, posts = yield [ gen.Task(Episode.BatchQuery, client, episode_keys, None, must_exist=False), gen.Task(Post.BatchQuery, client, post_keys, None, must_exist=False) ] # If a viewable post already exists, don't add it to the set to copy. new_ids = set() post_iter = iter(posts) for ep_dict, episode in zip(target_ep_dicts, episodes): if episode is None: # Add the episode id to the set to copy. new_ids.add(ep_dict['episode_id']) else: # Only owner user should get this far, since we check that new episode id contains the user's device id. assert episode.user_id == user_id, (episode, user_id) # Enforce sharing *tree* - no sharing acyclic graph allowed! if episode.parent_ep_id != ep_dict['parent_ep_id']: raise InvalidRequestError( 'Cannot %s to episode "%s". It was created from a different parent episode.' % (action, ep_dict['episode_id'])) # Cannot share into episodes which are not in the target viewpoint. if episode.viewpoint_id != viewpoint_id: raise InvalidRequestError( 'Cannot %s to episode "%s". It is not in viewpoint "%s".' % (action, episode.episode_id, viewpoint_id)) for photo_id in ep_dict['photo_ids']: post = next(post_iter) # If the post does not exist or is removed, add it to the new list. if post is None or post.IsRemoved(): new_ids.add( Post.ConstructPostId(ep_dict['episode_id'], photo_id)) raise gen.Return(new_ids)
def QueryPosts(cls, client, episode_id, user_id, callback, limit=None, excl_start_key=None, base_results=None): """Queries posts (up to 'limit' total) for the specified 'episode_id', viewable by 'user_id'. The query is for posts starting with (but excluding) 'excl_start_key'. The photo metadata for each post relation are in turn queried and the post and photo metadata are combined into a single dict. The callback is invoked with the array of combined post/photo metadata, and the last queried post sort-key. The 'base_results' argument allows this method to be re-entrant. 'limit' can be satisfied completely if a user is querying an episode they own with nothing archived or deleted. However, in cases where an episode hasn't been fully shared, or has many photos archived or deleted by the requesting user, QueryPosts needs to be re-invoked possibly many times to query 'limit' posts or reach the end of the episode. """ def _OnQueryMetadata(posts, results): """Constructs the photo metadata to return. The "check_label" argument is used to determine whether to use the old permissions model or the new one. If "check_label" is true, then only return a photo if a label is present. Otherwise, the photo is part of an episode created by the new sharing functionality, and the user automatically has access to all photos in that episode. """ ph_dicts = base_results or [] for post, (photo, user_post) in zip(posts, results): ph_dict = photo._asdict() labels = post.labels.combine() if user_post is not None: labels = labels.union(user_post.labels.combine()) if len(labels) > 0: ph_dict['labels'] = list(labels) ph_dicts.append(ph_dict) last_key = posts[-1].photo_id if len(posts) > 0 else None if last_key is not None and len(ph_dicts) < limit: Episode.QueryPosts(client, episode_id, user_id, callback, limit=limit, excl_start_key=last_key, base_results=ph_dicts) else: callback((ph_dicts, last_key)) def _OnQueryPosts(posts): with util.ArrayBarrier(partial(_OnQueryMetadata, posts)) as b: for post in posts: with util.ArrayBarrier(b.Callback()) as metadata_b: post_id = Post.ConstructPostId(post.episode_id, post.photo_id) Photo.Query(client, hash_key=post.photo_id, col_names=None, callback=metadata_b.Callback()) UserPost.Query(client, hash_key=user_id, range_key=post_id, col_names=None, callback=metadata_b.Callback(), must_exist=False) # Query the posts with limit & excl_start_key. Post.RangeQuery(client, hash_key=episode_id, range_desc=None, limit=limit, col_names=None, callback=_OnQueryPosts, excl_start_key=excl_start_key)
def _RoundTripPostId(original_episode_id, original_photo_id): post_id = Post.ConstructPostId(original_episode_id, original_photo_id) new_episode_id, new_photo_id = Post.DeconstructPostId(post_id) self.assertEqual(original_episode_id, new_episode_id) self.assertEqual(original_photo_id, new_photo_id)
def _GatherUnshares(self, episode, posts): """Recursively accumulates the set of viewpoints, episodes, and posts that are affected by an unshare operation rooted at the specified episodes and posts. Adds the unshare information to "unshares_dict" in the format described in the UnshareOperation._Unshare docstring. Also gets list of ids of posts that have already been removed. Accounting will not be decremented again for these posts, since remove_photos already did it. This method does not mutate the database; it just gathers the information necessary to do so. Because episodes can be shared multiple times, and can even circle back to a previous viewpoint, it is necessary to traverse the entire sharing tree before concluding that all episodes to unshare have been discovered. If too many episodes have been traversed, raises a PermissionError. This protects the server from unshares of photos that have gone massively viral. """ @gen.coroutine def _ProcessChildEpisode(child_episode, photo_ids_to_unshare): """For each child episode, query for the set of posts to unshare from each (might be a subset of parent posts). Recurse into the child episode to gather more unshares. """ post_keys = [ DBKey(child_episode.episode_id, ph_id) for ph_id in photo_ids_to_unshare ] child_posts = yield gen.Task(Post.BatchQuery, self._client, post_keys, None, must_exist=False) yield gen.Task(self._GatherUnshares, child_episode, child_posts) # Check whether episode traversal limit has been exceeded. self._num_unshares += 1 if self._num_unshares > UnshareOperation._UNSHARE_LIMIT: raise PermissionError( 'These photos cannot be unshared because they have already been shared too widely.' ) # Get posts that were previously gathered. vp_dict = self._unshares_dict.get(episode.viewpoint_id, None) if vp_dict is not None and episode.episode_id in vp_dict: photo_ids_to_unshare = OrderedDict.fromkeys( vp_dict[episode.episode_id]) else: photo_ids_to_unshare = OrderedDict() # Add photo ids to unshare. Don't include posts that have already been unshared, but do # include posts that have been removed (so that we add the UNSHARED label). for post in posts: if post is not None and not post.IsUnshared(): photo_ids_to_unshare[post.photo_id] = None if post.IsRemoved(): # Post has already been removed, so accounting should not be adjusted again for it. self._already_removed_ids.add( Post.ConstructPostId(post.episode_id, post.photo_id)) if len(photo_ids_to_unshare) > 0: self._unshares_dict.setdefault( episode.viewpoint_id, {})[episode.episode_id] = list(photo_ids_to_unshare) # Recursively descend into children of the episode, looking for additional branches of the sharing tree. # Use secondary index to find children of "episode". query_expr = ('episode.parent_ep_id={id}', {'id': episode.episode_id}) child_episodes = yield gen.Task(Episode.IndexQuery, self._client, query_expr, None) yield [ _ProcessChildEpisode(child_episode, photo_ids_to_unshare) for child_episode in child_episodes ]
def _QueryEpisodesForArchive(client, obj_store, user_id, episode_ids): """Queries posts from the specified episodes. """ def _MakePhotoDict(post, photo, user_post, user_photo): ph_dict = photo.MakeMetadataDict(post, user_post, user_photo) # Do not return access URLs for posts which have been removed. if not post.IsRemoved(): ph_dict['full_get_url'] = photo_store.GeneratePhotoUrl( obj_store, ph_dict['photo_id'], '.f') return ph_dict # Get all requested episodes, along with posts for each episode. episode_keys = [db_client.DBKey(ep_id, None) for ep_id in episode_ids] post_tasks = [] for ep_id in episode_ids: post_tasks.append( gen.Task(Post.RangeQuery, client, ep_id, None, None, None, excl_start_key=None)) episodes, posts_list = yield [ gen.Task(Episode.BatchQuery, client, episode_keys, None, must_exist=False), gen.Multi(post_tasks) ] # Get viewpoint records for all viewpoints containing episodes. viewpoint_keys = [ db_client.DBKey(viewpoint_id, None) for viewpoint_id in set( ep.viewpoint_id for ep in episodes if ep is not None) ] # Get follower records for all viewpoints containing episodes, along with photo and user post objects. follower_keys = [ db_client.DBKey(user_id, db_key.hash_key) for db_key in viewpoint_keys ] all_posts = [ post for posts in posts_list if posts is not None for post in posts ] photo_keys = [db_client.DBKey(post.photo_id, None) for post in all_posts] user_post_keys = [ db_client.DBKey(user_id, Post.ConstructPostId(post.episode_id, post.photo_id)) for post in all_posts ] if user_id: # TODO(ben): we can probably skip this for the web view user_photo_task = gen.Task( UserPhoto.BatchQuery, client, [db_client.DBKey(user_id, post.photo_id) for post in all_posts], None, must_exist=False) else: user_photo_task = util.GenConstant(None) viewpoints, followers, photos, user_posts, user_photos = yield [ gen.Task(Viewpoint.BatchQuery, client, viewpoint_keys, None, must_exist=False), gen.Task(Follower.BatchQuery, client, follower_keys, None, must_exist=False), gen.Task(Photo.BatchQuery, client, photo_keys, None), gen.Task(UserPost.BatchQuery, client, user_post_keys, None, must_exist=False), user_photo_task, ] # Get set of viewpoint ids to which the current user has access. viewable_viewpoint_ids = set( viewpoint.viewpoint_id for viewpoint, follower in zip(viewpoints, followers) if _CanViewViewpointContent(viewpoint, follower)) response_dict = {'episodes': []} for ep_id, episode, posts in zip(episode_ids, episodes, posts_list): # Gather list of (post, photo, user_post) tuples for this episode. photo_info_list = [] for post in posts: photo = photos.pop(0) user_post = user_posts.pop(0) user_photo = user_photos.pop( 0) if user_photos is not None else None assert photo.photo_id == post.photo_id, (episode, post, photo) if user_photo: assert user_photo.photo_id == photo.photo_id assert user_photo.user_id == user_id photo_info_list.append((post, photo, user_post, user_photo)) if episode is not None and episode.viewpoint_id in viewable_viewpoint_ids: response_ep_dict = {'episode_id': ep_id} response_ep_dict.update(episode._asdict()) response_ep_dict['photos'] = [ _MakePhotoDict(photo, post, user_post, user_photo) for photo, post, user_post, user_photo in photo_info_list ] if len(photo_info_list) > 0: response_ep_dict['last_key'] = photo_info_list[-1][0].photo_id response_dict['episodes'].append(response_ep_dict) raise gen.Return(response_dict)