class LiveUpdateController(RedditController): def __before__(self, event): RedditController.__before__(self) if event: try: c.liveupdate_event = LiveUpdateEvent._byID(event) except tdb_cassandra.NotFound: pass if not c.liveupdate_event: self.abort404() if c.user_is_loggedin: c.liveupdate_permissions = \ c.liveupdate_event.get_permissions(c.user) # revoke some permissions from everyone after closing if c.liveupdate_event.state != "live": c.liveupdate_permissions = (c.liveupdate_permissions .without("update") .without("close") ) if c.user_is_admin: c.liveupdate_permissions = ContributorPermissionSet.SUPERUSER else: c.liveupdate_permissions = ContributorPermissionSet.NONE if c.liveupdate_event.banned and not c.liveupdate_permissions: error_page = RedditError( title=_("this thread has been banned"), message="", image="subreddit-banned.png", ) request.environ["usable_error_content"] = error_page.render() self.abort403() if (c.liveupdate_event.nsfw and not c.over18 and request.host != g.media_domain and # embeds are special c.render_style == "html"): return self.intermediate_redirect("/over18", sr_path=False) @require_oauth2_scope("read") @validate( num=VLimit("limit", default=25, max_limit=100), after=VLiveUpdateID("after"), before=VLiveUpdateID("before"), count=VCount("count"), is_embed=VBoolean("is_embed", docs={"is_embed": "(internal use only)"}), style_sr=VSRByName("stylesr"), ) @api_doc( section=api_section.live, uri="/live/{thread}", supports_rss=True, notes=[paginated_listing.doc_note], ) def GET_listing(self, num, after, before, count, is_embed, style_sr): """Get a list of updates posted in this thread. See also: [/api/live/*thread*/update](#POST_api_live_{thread}_update). """ # preemptively record activity for clients that don't send pixel pings. # this won't capture their continued visit, but will at least show a # correct activity count for short lived connections. record_activity(c.liveupdate_event._id) reverse = False if before: reverse = True after = before query = LiveUpdateStream.query([c.liveupdate_event._id], count=num, reverse=reverse) if after: query.column_start = after builder = LiveUpdateBuilder(query=query, skip=True, reverse=reverse, num=num, count=count) listing = pages.LiveUpdateListing(builder) wrapped_listing = listing.listing() if c.user_is_loggedin: report_type = LiveUpdateReportsByAccount.get_report( c.user, c.liveupdate_event) else: report_type = None content = pages.LiveUpdateEventApp( event=c.liveupdate_event, listing=wrapped_listing, show_sidebar=not is_embed, report_type=report_type, ) c.js_preload.set_wrapped( "/live/" + c.liveupdate_event._id + "/about.json", Wrapped(c.liveupdate_event), ) c.js_preload.set_wrapped( "/live/" + c.liveupdate_event._id + ".json", wrapped_listing, ) if not is_embed: return pages.LiveUpdateEventAppPage( content=content, page_classes=['liveupdate-app'], ).render() else: # ensure we're off the cookie domain before allowing embedding if request.host != g.media_domain: abort(404) c.allow_framing = True # interstitial redirects and nsfw settings are funky on the media # domain. just disable nsfw embeds. if c.liveupdate_event.nsfw: embed_page = pages.LiveUpdateEventEmbed( content=pages.LiveUpdateNSFWEmbed(), ) request.environ["usable_error_content"] = embed_page.render() abort(403) embed_page = pages.LiveUpdateEventEmbed( content=content, page_classes=['liveupdate-app'], ) if style_sr and getattr(style_sr, "type", "private") != "private": c.can_apply_styles = True c.allow_styles = True embed_page.subreddit_stylesheet_url = \ Reddit.get_subreddit_stylesheet_url(style_sr) return embed_page.render() @require_oauth2_scope("read") @api_doc( section=api_section.live, uri="/live/{thread}/updates/{update_id}", ) def GET_focus(self, target): """Get details about a specific update in a live thread.""" try: target = uuid.UUID(target) except (TypeError, ValueError): self.abort404() try: update = LiveUpdateStream.get_update(c.liveupdate_event, target) except tdb_cassandra.NotFound: self.abort404() if update.deleted: self.abort404() query = FocusQuery([update]) builder = LiveUpdateBuilder( query=query, skip=True, reverse=True, num=1, count=0) listing = pages.LiveUpdateListing(builder) wrapped_listing = listing.listing() c.js_preload.set_wrapped( "/live/" + c.liveupdate_event._id + ".json", wrapped_listing, ) content = pages.LiveUpdateFocusApp( event=c.liveupdate_event, listing=wrapped_listing, ) return pages.LiveUpdateEventFocusPage( content=content, focused_update=update, page_classes=["liveupdate-focus"], ).render() @require_oauth2_scope("read") @api_doc( section=api_section.live, uri="/live/{thread}/about", ) def GET_about(self): """Get some basic information about the live thread. See also: [/api/live/*thread*/edit](#POST_api_live_{thread}_edit). """ if not is_api(): self.abort404() content = Wrapped(c.liveupdate_event) return pages.LiveUpdateEventPage(content=content).render() @require_oauth2_scope("read") @base_listing @api_doc( section=api_section.live, uri="/live/{thread}/discussions", supports_rss=True, ) def GET_discussions(self, num, after, reverse, count): """Get a list of reddit submissions linking to this thread.""" builder = url_links_builder( url="/live/" + c.liveupdate_event._id, num=num, after=after, reverse=reverse, count=count, ) listing = LinkListing(builder).listing() return pages.LiveUpdateEventPage( content=listing, ).render() def GET_edit(self): if not (c.liveupdate_permissions.allow("settings") or c.liveupdate_permissions.allow("close")): abort(403) return pages.LiveUpdateEventPage( content=pages.LiveUpdateEventConfiguration(), ).render() @require_oauth2_scope("livemanage") @validatedForm( VLiveUpdateContributorWithPermission("settings"), VModhash(), **EVENT_CONFIGURATION_VALIDATORS ) @api_doc( section=api_section.live, ) def POST_edit(self, form, jquery, title, description, resources, nsfw): """Configure the thread. Requires the `settings` permission for this thread. See also: [/live/*thread*/about.json](#GET_live_{thread}_about.json). """ if not is_event_configuration_valid(form): return changes = {} if title != c.liveupdate_event.title: changes["title"] = title if description != c.liveupdate_event.description: changes["description"] = description changes["description_html"] = safemarkdown(description, nofollow=True) or "" if resources != c.liveupdate_event.resources: changes["resources"] = resources changes["resources_html"] = safemarkdown(resources, nofollow=True) or "" if nsfw != c.liveupdate_event.nsfw: changes["nsfw"] = nsfw if changes: _broadcast(type="settings", payload=changes) c.liveupdate_event.title = title c.liveupdate_event.description = description c.liveupdate_event.resources = resources c.liveupdate_event.nsfw = nsfw c.liveupdate_event._commit() amqp.add_item("liveupdate_event_edited", json.dumps({ "event_fullname": c.liveupdate_event._fullname, "editor_fullname": c.user._fullname, })) form.set_html(".status", _("saved")) form.refresh() # TODO: pass listing params on @require_oauth2_scope("read") @api_doc( section=api_section.live, uri="/live/{thread}/contributors", ) def GET_contributors(self): """Get a list of users that contribute to this thread. See also: [/api/live/*thread*/invite_contributor] (#POST_api_live_{thread}_invite_contributor), and [/api/live/*thread*/rm_contributor] (#POST_api_live_{thread}_rm_contributor). """ editable = c.liveupdate_permissions.allow("manage") content = [pages.LinkBackToLiveUpdate()] contributors = c.liveupdate_event.contributors invites = LiveUpdateContributorInvitesByEvent.get_all(c.liveupdate_event) contributor_builder = LiveUpdateContributorBuilder( c.liveupdate_event, contributors, editable) contributor_listing = pages.LiveUpdateContributorListing( c.liveupdate_event, contributor_builder, has_invite=c.user_is_loggedin and c.user._id in invites, is_contributor=c.user_is_loggedin and c.user._id in contributors, ).listing() content.append(contributor_listing) if editable: invite_builder = LiveUpdateInvitedContributorBuilder( c.liveupdate_event, invites, editable) invite_listing = pages.LiveUpdateInvitedContributorListing( c.liveupdate_event, invite_builder, editable=editable, ).listing() content.append(invite_listing) return pages.LiveUpdateEventPage( content=PaneStack(content), ).render() @require_oauth2_scope("livemanage") @validatedForm( VLiveUpdateContributorWithPermission("manage"), VModhash(), user=VExistingUname("name"), type_and_perms=VLiveUpdatePermissions("type", "permissions"), ) @api_doc( section=api_section.live, ) def POST_invite_contributor(self, form, jquery, user, type_and_perms): """Invite another user to contribute to the thread. Requires the `manage` permission for this thread. If the recipient accepts the invite, they will be granted the permissions specified. See also: [/api/live/*thread*/accept_contributor_invite] (#POST_api_live_{thread}_accept_contributor_invite), and [/api/live/*thread*/rm_contributor_invite] (#POST_api_live_{thread}_rm_contributor_invite). """ if form.has_errors("name", errors.USER_DOESNT_EXIST, errors.NO_USER): return if form.has_errors("type", errors.INVALID_PERMISSION_TYPE): return if form.has_errors("permissions", errors.INVALID_PERMISSIONS): return type, permissions = type_and_perms invites = LiveUpdateContributorInvitesByEvent.get_all(c.liveupdate_event) if user._id in invites or user._id in c.liveupdate_event.contributors: c.errors.add(errors.LIVEUPDATE_ALREADY_CONTRIBUTOR, field="name") form.has_errors("name", errors.LIVEUPDATE_ALREADY_CONTRIBUTOR) return if len(invites) >= g.liveupdate_invite_quota: c.errors.add(errors.LIVEUPDATE_TOO_MANY_INVITES, field="name") form.has_errors("name", errors.LIVEUPDATE_TOO_MANY_INVITES) return LiveUpdateContributorInvitesByEvent.create( c.liveupdate_event, user, permissions) queries.add_contributor(c.liveupdate_event, user) # TODO: make this i18n-friendly when we have such a system for PMs send_system_message( user, subject="invitation to contribute to " + c.liveupdate_event.title, body=INVITE_MESSAGE % { "title": c.liveupdate_event.title, "url": "/live/" + c.liveupdate_event._id, }, ) amqp.add_item("new_liveupdate_contributor", json.dumps({ "event_fullname": c.liveupdate_event._fullname, "inviter_fullname": c.user._fullname, "invitee_fullname": user._fullname, })) # add the user to the table contributor = LiveUpdateContributor(user, permissions) user_row = pages.InvitedLiveUpdateContributorTableItem( contributor, c.liveupdate_event, editable=True) jquery(".liveupdate_contributor_invite-table").show( ).find("table").insert_table_rows(user_row) @require_oauth2_scope("livemanage") @validatedForm( VUser(), VModhash(), ) @api_doc( section=api_section.live, ) def POST_leave_contributor(self, form, jquery): """Abdicate contributorship of the thread. See also: [/api/live/*thread*/accept_contributor_invite] (#POST_api_live_{thread}_accept_contributor_invite), and [/api/live/*thread*/invite_contributor] (#POST_api_live_{thread}_invite_contributor). """ c.liveupdate_event.remove_contributor(c.user) queries.remove_contributor(c.liveupdate_event, c.user) @require_oauth2_scope("livemanage") @validatedForm( VLiveUpdateContributorWithPermission("manage"), VModhash(), user=VByName("id", thing_cls=Account), ) @api_doc( section=api_section.live, ) def POST_rm_contributor_invite(self, form, jquery, user): """Revoke an outstanding contributor invite. Requires the `manage` permission for this thread. See also: [/api/live/*thread*/invite_contributor] (#POST_api_live_{thread}_invite_contributor). """ LiveUpdateContributorInvitesByEvent.remove( c.liveupdate_event, user) queries.remove_contributor(c.liveupdate_event, user) @require_oauth2_scope("livemanage") @validatedForm( VUser(), VModhash(), ) @api_doc( section=api_section.live, ) def POST_accept_contributor_invite(self, form, jquery): """Accept a pending invitation to contribute to the thread. See also: [/api/live/*thread*/leave_contributor] (#POST_api_live_{thread}_leave_contributor). """ try: permissions = LiveUpdateContributorInvitesByEvent.get( c.liveupdate_event, c.user) except InviteNotFoundError: c.errors.add(errors.LIVEUPDATE_NO_INVITE_FOUND) form.set_error(errors.LIVEUPDATE_NO_INVITE_FOUND, None) return LiveUpdateContributorInvitesByEvent.remove( c.liveupdate_event, c.user) c.liveupdate_event.add_contributor(c.user, permissions) jquery.refresh() @require_oauth2_scope("livemanage") @validatedForm( VLiveUpdateContributorWithPermission("manage"), VModhash(), user=VExistingUname("name"), type_and_perms=VLiveUpdatePermissions("type", "permissions"), ) @api_doc( section=api_section.live, ) def POST_set_contributor_permissions(self, form, jquery, user, type_and_perms): """Change a contributor or contributor invite's permissions. Requires the `manage` permission for this thread. See also: [/api/live/*thread*/invite_contributor] (#POST_api_live_{thread}_invite_contributor) and [/api/live/*thread*/rm_contributor] (#POST_api_live_{thread}_rm_contributor). """ if form.has_errors("name", errors.USER_DOESNT_EXIST, errors.NO_USER): return if form.has_errors("type", errors.INVALID_PERMISSION_TYPE): return if form.has_errors("permissions", errors.INVALID_PERMISSIONS): return type, permissions = type_and_perms if type == "liveupdate_contributor": if user._id not in c.liveupdate_event.contributors: c.errors.add(errors.LIVEUPDATE_NOT_CONTRIBUTOR, field="user") form.has_errors("user", errors.LIVEUPDATE_NOT_CONTRIBUTOR) return c.liveupdate_event.update_contributor_permissions(user, permissions) elif type == "liveupdate_contributor_invite": try: LiveUpdateContributorInvitesByEvent.get( c.liveupdate_event, user) except InviteNotFoundError: c.errors.add(errors.LIVEUPDATE_NO_INVITE_FOUND, field="user") form.has_errors("user", errors.LIVEUPDATE_NO_INVITE_FOUND) return else: LiveUpdateContributorInvitesByEvent.update_invite_permissions( c.liveupdate_event, user, permissions) row = form.closest("tr") editor = row.find(".permissions").data("PermissionEditor") editor.onCommit(permissions.dumps()) @require_oauth2_scope("livemanage") @validatedForm( VLiveUpdateContributorWithPermission("manage"), VModhash(), user=VByName("id", thing_cls=Account), ) @api_doc( section=api_section.live, ) def POST_rm_contributor(self, form, jquery, user): """Revoke another user's contributorship. Requires the `manage` permission for this thread. See also: [/api/live/*thread*/invite_contributor] (#POST_api_live_{thread}_invite_contributor). """ c.liveupdate_event.remove_contributor(user) queries.remove_contributor(c.liveupdate_event, user) @require_oauth2_scope("submit") @validatedForm( VLiveUpdateContributorWithPermission("update"), VModhash(), text=VMarkdownLength("body", max_length=4096), ) @api_doc( section=api_section.live, ) def POST_update(self, form, jquery, text): """Post an update to the thread. Requires the `update` permission for this thread. See also: [/api/live/*thread*/strike_update] (#POST_api_live_{thread}_strike_update), and [/api/live/*thread*/delete_update] (#POST_api_live_{thread}_delete_update). """ if form.has_errors("body", errors.NO_TEXT, errors.TOO_LONG): return # create and store the new update update = LiveUpdate(data={ "author_id": c.user._id, "body": text, "_spam": c.user._spam, }) hooks.get_hook("liveupdate.update").call(update=update) LiveUpdateStream.add_update(c.liveupdate_event, update) # tell the world about our new update builder = LiveUpdateBuilder(None) wrapped = builder.wrap_items([update])[0] rendered = wrapped.render(style="api") _broadcast(type="update", payload=rendered) amqp.add_item("new_liveupdate_update", json.dumps({ "event_fullname": c.liveupdate_event._fullname, "author_fullname": c.user._fullname, "liveupdate_id": str(update._id), "body": text, })) liveupdate_events.update_event(update, context=c, request=request) # reset the submission form t = form.find("textarea") t.attr('rows', 3).html("").val("") @require_oauth2_scope("edit") @validatedForm( VModhash(), update=VLiveUpdate("id"), ) @api_doc( section=api_section.live, ) def POST_delete_update(self, form, jquery, update): """Delete an update from the thread. Requires that specified update must have been authored by the user or that you have the `edit` permission for this thread. See also: [/api/live/*thread*/update](#POST_api_live_{thread}_update). """ if form.has_errors("id", errors.NO_THING_ID): return if not (c.liveupdate_permissions.allow("edit") or (c.user_is_loggedin and update.author_id == c.user._id)): abort(403) update.deleted = True LiveUpdateStream.add_update(c.liveupdate_event, update) liveupdate_events.update_event(update, context=c, request=request) _broadcast(type="delete", payload=update._fullname) @require_oauth2_scope("edit") @validatedForm( VModhash(), update=VLiveUpdate("id"), ) @api_doc( section=api_section.live, ) def POST_strike_update(self, form, jquery, update): """Strike (mark incorrect and cross out) the content of an update. Requires that specified update must have been authored by the user or that you have the `edit` permission for this thread. See also: [/api/live/*thread*/update](#POST_api_live_{thread}_update). """ if form.has_errors("id", errors.NO_THING_ID): return if not (c.liveupdate_permissions.allow("edit") or (c.user_is_loggedin and update.author_id == c.user._id)): abort(403) update.stricken = True LiveUpdateStream.add_update(c.liveupdate_event, update) liveupdate_events.update_event( update, stricken=True, context=c, request=request ) _broadcast(type="strike", payload=update._fullname) @require_oauth2_scope("livemanage") @validatedForm( VLiveUpdateContributorWithPermission("close"), VModhash(), ) @api_doc( section=api_section.live, ) def POST_close_thread(self, form, jquery): """Permanently close the thread, disallowing future updates. Requires the `close` permission for this thread. """ close_event(c.liveupdate_event) liveupdate_events.close_event(context=c, request=request) form.refresh() @require_oauth2_scope("report") @validatedForm( VUser(), VModhash(), report_type=VOneOf("type", pages.REPORT_TYPES), ) @api_doc( section=api_section.live, ) def POST_report(self, form, jquery, report_type): """Report the thread for violating the rules of reddit.""" if form.has_errors("type", errors.INVALID_OPTION): return if c.user._spam or c.user.ignorereports: return already_reported = LiveUpdateReportsByAccount.get_report( c.user, c.liveupdate_event) if already_reported: self.abort403() LiveUpdateReportsByAccount.create( c.user, c.liveupdate_event, type=report_type) queries.report_event(c.liveupdate_event) liveupdate_events.report_event( report_type, context=c, request=request ) amqp.add_item("new_liveupdate_report", json.dumps({ "event_fullname": c.liveupdate_event._fullname, "reporter_fullname": c.user._fullname, "reason": report_type, })) try: default_subreddit = Subreddit._by_name(g.default_sr) except NotFound: pass else: not_yet_reported = g.ratelimitcache.add( "rl:lu_reported_" + str(c.liveupdate_event._id), 1, time=3600) if not_yet_reported: send_system_message( default_subreddit, subject="live thread reported", body=REPORTED_MESSAGE % { "title": c.liveupdate_event.title, "url": "/live/" + c.liveupdate_event._id, "reason": pages.REPORT_TYPES[report_type], }, ) @validatedForm( VAdmin(), VModhash(), ) def POST_approve(self, form, jquery): c.liveupdate_event.banned = False c.liveupdate_event._commit() queries.unreport_event(c.liveupdate_event) liveupdate_events.ban_event(context=c, request=request) @validatedForm( VAdmin(), VModhash(), ) def POST_ban(self, form, jquery): c.liveupdate_event.banned = True c.liveupdate_event.banned_by = c.user.name c.liveupdate_event._commit() queries.unreport_event(c.liveupdate_event) liveupdate_events.ban_event(context=c, request=request)
class MultiApiController(RedditController): on_validation_error = staticmethod(abort_with_error) def pre(self): set_extension(request.environ, "json") RedditController.pre(self) def _format_multi_list(self, multis, viewer, expand_srs): templ = LabeledMultiJsonTemplate(expand_srs) resp = [ templ.render(multi).finalize() for multi in multis if multi.can_view(viewer) ] return self.api_wrapper(resp) @require_oauth2_scope("read") @validate( user=VAccountByName("username"), expand_srs=VBoolean("expand_srs"), ) @api_doc(api_section.multis, uri="/api/multi/user/{username}") def GET_list_multis(self, user, expand_srs): """Fetch a list of public multis belonging to `username`""" multis = LabeledMulti.by_owner(user) return self._format_multi_list(multis, c.user, expand_srs) @require_oauth2_scope("read") @validate( sr=VSRByName('srname'), expand_srs=VBoolean("expand_srs"), ) def GET_list_sr_multis(self, sr, expand_srs): """Fetch a list of public multis belonging to subreddit `srname`""" multis = LabeledMulti.by_owner(sr) return self._format_multi_list(multis, c.user, expand_srs) @require_oauth2_scope("read") @validate(VUser(), expand_srs=VBoolean("expand_srs")) @api_doc(api_section.multis, uri="/api/multi/mine") def GET_my_multis(self, expand_srs): """Fetch a list of multis belonging to the current user.""" multis = LabeledMulti.by_owner(c.user) return self._format_multi_list(multis, c.user, expand_srs) def _format_multi(self, multi, expand_sr_info=False): multi_info = LabeledMultiJsonTemplate(expand_sr_info).render(multi) return self.api_wrapper(multi_info.finalize()) @require_oauth2_scope("read") @validate( multi=VMultiByPath("multipath", require_view=True), expand_srs=VBoolean("expand_srs"), ) @api_doc( api_section.multis, uri="/api/multi/{multipath}", uri_variants=['/api/filter/{filterpath}'], ) def GET_multi(self, multi, expand_srs): """Fetch a multi's data and subreddit list by name.""" return self._format_multi(multi, expand_srs) def _check_new_multi_path(self, path_info): if path_info['prefix'] == 'r': return self._check_sr_multi_path(path_info) return self._check_user_multi_path(path_info) def _check_user_multi_path(self, path_info): if path_info['owner'].lower() != c.user.name.lower(): raise RedditError('MULTI_CANNOT_EDIT', code=403, fields='multipath') return c.user def _check_sr_multi_path(self, path_info): try: sr = Subreddit._by_name(path_info['owner']) except NotFound: raise RedditError('SUBREDDIT_NOEXIST', code=404) if (not sr.is_moderator_with_perms(c.user, 'config') and not c.user_is_admin): raise RedditError('MULTI_CANNOT_EDIT', code=403, fields='multipath') return sr def _add_multi_srs(self, multi, sr_datas): srs = Subreddit._by_name(sr_data['name'] for sr_data in sr_datas) for sr in srs.itervalues(): if isinstance(sr, FakeSubreddit): raise RedditError('MULTI_SPECIAL_SUBREDDIT', msg_params={'path': sr.path}, code=400) sr_props = {} for sr_data in sr_datas: try: sr = srs[sr_data['name']] except KeyError: raise RedditError('SUBREDDIT_NOEXIST', code=400) else: # name is passed in via the API data format, but should not be # stored on the model. del sr_data['name'] sr_props[sr] = sr_data try: multi.add_srs(sr_props) except TooManySubredditsError as e: raise RedditError('MULTI_TOO_MANY_SUBREDDITS', code=409) return sr_props def _write_multi_data(self, multi, data): srs = data.pop('subreddits', None) if srs is not None: multi.clear_srs() try: self._add_multi_srs(multi, srs) except: multi._revert() raise if 'icon_name' in data: try: multi.set_icon_by_name(data.pop('icon_name')) except: multi._revert() raise for key, val in data.iteritems(): if key in WRITABLE_MULTI_FIELDS: setattr(multi, key, val) multi._commit() return multi @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), path_info=VMultiPath("multipath", required=False), data=VValidatedJSON("model", multi_json_spec), ) @api_doc(api_section.multis, extends=GET_multi) def POST_multi(self, path_info, data): """Create a multi. Responds with 409 Conflict if it already exists.""" if not path_info and "path" in data: path_info = VMultiPath("").run(data["path"]) elif 'display_name' in data: # if path not provided, create multi for user path = LabeledMulti.slugify(c.user, data['display_name']) path_info = VMultiPath("").run(path) if not path_info: raise RedditError('BAD_MULTI_PATH', code=400) owner = self._check_new_multi_path(path_info) try: LabeledMulti._byID(path_info['path']) except tdb_cassandra.NotFound: multi = LabeledMulti.create(path_info['path'], owner) response.status = 201 else: raise RedditError('MULTI_EXISTS', code=409, fields='multipath') self._write_multi_data(multi, data) return self._format_multi(multi) @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), path_info=VMultiPath("multipath"), data=VValidatedJSON("model", multi_json_spec), ) @api_doc(api_section.multis, extends=GET_multi) def PUT_multi(self, path_info, data): """Create or update a multi.""" owner = self._check_new_multi_path(path_info) try: multi = LabeledMulti._byID(path_info['path']) except tdb_cassandra.NotFound: multi = LabeledMulti.create(path_info['path'], owner) response.status = 201 self._write_multi_data(multi, data) return self._format_multi(multi) @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), multi=VMultiByPath("multipath", require_edit=True), ) @api_doc(api_section.multis, extends=GET_multi) def DELETE_multi(self, multi): """Delete a multi.""" multi.delete() def _copy_multi(self, from_multi, to_path_info, rename=False): """Copy a multi to a user account.""" to_owner = self._check_new_multi_path(to_path_info) # rename requires same owner if rename and from_multi.owner != to_owner: raise RedditError('MULTI_CANNOT_EDIT', code=400) try: LabeledMulti._byID(to_path_info['path']) except tdb_cassandra.NotFound: to_multi = LabeledMulti.copy(to_path_info['path'], from_multi, owner=to_owner) else: raise RedditError('MULTI_EXISTS', code=409, fields='multipath') return to_multi @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), from_multi=VMultiByPath("from", require_view=True, kinds='m'), to_path_info=VMultiPath( "to", required=False, docs={"to": "destination multireddit url path"}, ), display_name=VLength("display_name", max_length=MAX_DISP_NAME, empty_error=None), ) @api_doc( api_section.multis, uri="/api/multi/copy", ) def POST_multi_copy(self, from_multi, to_path_info, display_name): """Copy a multi. Responds with 409 Conflict if the target already exists. A "copied from ..." line will automatically be appended to the description. """ if not to_path_info: if display_name: # if path not provided, copy multi to same owner path = LabeledMulti.slugify(from_multi.owner, display_name) to_path_info = VMultiPath("").run(path) else: raise RedditError('BAD_MULTI_PATH', code=400) to_multi = self._copy_multi(from_multi, to_path_info) from_path = from_multi.path to_multi.copied_from = from_path if to_multi.description_md: to_multi.description_md += '\n\n' to_multi.description_md += _('copied from %(source)s') % { # force markdown linking since /user/foo is not autolinked 'source': '[%s](%s)' % (from_path, from_path) } to_multi.visibility = 'private' if display_name: to_multi.display_name = display_name to_multi._commit() return self._format_multi(to_multi) @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), from_multi=VMultiByPath("from", require_edit=True, kinds='m'), to_path_info=VMultiPath( "to", required=False, docs={"to": "destination multireddit url path"}, ), display_name=VLength("display_name", max_length=MAX_DISP_NAME, empty_error=None), ) @api_doc( api_section.multis, uri="/api/multi/rename", ) def POST_multi_rename(self, from_multi, to_path_info, display_name): """Rename a multi.""" if not to_path_info: if display_name: path = LabeledMulti.slugify(from_multi.owner, display_name) to_path_info = VMultiPath("").run(path) else: raise RedditError('BAD_MULTI_PATH', code=400) to_multi = self._copy_multi(from_multi, to_path_info, rename=True) if display_name: to_multi.display_name = display_name to_multi._commit() from_multi.delete() return self._format_multi(to_multi) def _get_multi_subreddit(self, multi, sr): resp = LabeledMultiJsonTemplate.sr_props(multi, [sr])[0] return self.api_wrapper(resp) @require_oauth2_scope("read") @validate( VUser(), multi=VMultiByPath("multipath", require_view=True), sr=VSRByName('srname'), ) @api_doc( api_section.multis, uri="/api/multi/{multipath}/r/{srname}", uri_variants=['/api/filter/{filterpath}/r/{srname}'], ) def GET_multi_subreddit(self, multi, sr): """Get data about a subreddit in a multi.""" return self._get_multi_subreddit(multi, sr) @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), multi=VMultiByPath("multipath", require_edit=True), sr_name=VSubredditName('srname', allow_language_srs=True), data=VValidatedJSON("model", multi_sr_data_json_spec), ) @api_doc(api_section.multis, extends=GET_multi_subreddit) def PUT_multi_subreddit(self, multi, sr_name, data): """Add a subreddit to a multi.""" new = not any(sr.name.lower() == sr_name.lower() for sr in multi.srs) data['name'] = sr_name sr_props = self._add_multi_srs(multi, [data]) sr = sr_props.items()[0][0] multi._commit() if new: response.status = 201 return self._get_multi_subreddit(multi, sr) @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), multi=VMultiByPath("multipath", require_edit=True), sr=VSRByName('srname'), ) @api_doc(api_section.multis, extends=GET_multi_subreddit) def DELETE_multi_subreddit(self, multi, sr): """Remove a subreddit from a multi.""" multi.del_srs(sr) multi._commit() def _format_multi_description(self, multi): resp = LabeledMultiDescriptionJsonTemplate().render(multi).finalize() return self.api_wrapper(resp) @require_oauth2_scope("read") @validate( VUser(), multi=VMultiByPath("multipath", require_view=True, kinds='m'), ) @api_doc( api_section.multis, uri="/api/multi/{multipath}/description", ) def GET_multi_description(self, multi): """Get a multi's description.""" return self._format_multi_description(multi) @require_oauth2_scope("read") @validate( VUser(), VModhash(), multi=VMultiByPath("multipath", require_edit=True, kinds='m'), data=VValidatedJSON('model', multi_description_json_spec), ) @api_doc(api_section.multis, extends=GET_multi_description) def PUT_multi_description(self, multi, data): """Change a multi's markdown description.""" multi.description_md = data['body_md'] multi._commit() return self._format_multi_description(multi)
class MultiApiController(RedditController): on_validation_error = staticmethod(abort_with_error) def pre(self): set_extension(request.environ, "json") RedditController.pre(self) @require_oauth2_scope("read") @validate(VUser()) @api_doc(api_section.multis, uri="/api/multi/mine") def GET_my_multis(self): """Fetch a list of multis belonging to the current user.""" multis = LabeledMulti.by_owner(c.user) wrapped = wrap_things(*multis) resp = [w.render() for w in wrapped] return self.api_wrapper(resp) def _format_multi(self, multi): resp = wrap_things(multi)[0].render() return self.api_wrapper(resp) @require_oauth2_scope("read") @validate(multi=VMultiByPath("multipath", require_view=True)) @api_doc( api_section.multis, uri="/api/multi/{multipath}", uri_variants=['/api/filter/{filterpath}'], ) def GET_multi(self, multi): """Fetch a multi's data and subreddit list by name.""" return self._format_multi(multi) def _check_new_multi_path(self, path_info): if path_info['username'].lower() != c.user.name.lower(): raise RedditError('MULTI_CANNOT_EDIT', code=403, fields='multipath') def _add_multi_srs(self, multi, sr_datas): srs = Subreddit._by_name(sr_data['name'] for sr_data in sr_datas) for sr in srs.itervalues(): if isinstance(sr, FakeSubreddit): raise RedditError('MULTI_SPECIAL_SUBREDDIT', msg_params={'path': sr.path}, code=400) sr_props = {} for sr_data in sr_datas: try: sr = srs[sr_data['name']] except KeyError: raise RedditError('SUBREDDIT_NOEXIST', code=400) else: # name is passed in via the API data format, but should not be # stored on the model. del sr_data['name'] sr_props[sr] = sr_data try: multi.add_srs(sr_props) except TooManySubredditsError as e: raise RedditError('MULTI_TOO_MANY_SUBREDDITS', code=409) return sr_props def _write_multi_data(self, multi, data): multi.visibility = data['visibility'] multi.clear_srs() try: self._add_multi_srs(multi, data['subreddits']) except: multi._revert() raise multi._commit() return multi @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), path_info=VMultiPath("multipath"), data=VValidatedJSON("model", multi_json_spec), ) @api_doc(api_section.multis, extends=GET_multi) def POST_multi(self, path_info, data): """Create a multi. Responds with 409 Conflict if it already exists.""" self._check_new_multi_path(path_info) try: LabeledMulti._byID(path_info['path']) except tdb_cassandra.NotFound: multi = LabeledMulti.create(path_info['path'], c.user) response.status = 201 else: raise RedditError('MULTI_EXISTS', code=409, fields='multipath') self._write_multi_data(multi, data) return self._format_multi(multi) @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), path_info=VMultiPath("multipath"), data=VValidatedJSON("model", multi_json_spec), ) @api_doc(api_section.multis, extends=GET_multi) def PUT_multi(self, path_info, data): """Create or update a multi.""" self._check_new_multi_path(path_info) try: multi = LabeledMulti._byID(path_info['path']) except tdb_cassandra.NotFound: multi = LabeledMulti.create(path_info['path'], c.user) response.status = 201 self._write_multi_data(multi, data) return self._format_multi(multi) @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), multi=VMultiByPath("multipath", require_edit=True), ) @api_doc(api_section.multis, extends=GET_multi) def DELETE_multi(self, multi): """Delete a multi.""" multi.delete() def _copy_multi(self, from_multi, to_path_info): self._check_new_multi_path(to_path_info) to_owner = Account._by_name(to_path_info['username']) try: LabeledMulti._byID(to_path_info['path']) except tdb_cassandra.NotFound: to_multi = LabeledMulti.copy(to_path_info['path'], from_multi, owner=to_owner) else: raise RedditError('MULTI_EXISTS', code=409, fields='multipath') return to_multi @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), from_multi=VMultiByPath("from", require_view=True, kinds='m'), to_path_info=VMultiPath( "to", docs={"to": "destination multireddit url path"}, ), ) @api_doc( api_section.multis, uri="/api/multi/{multipath}/copy", ) def POST_multi_copy(self, from_multi, to_path_info): """Copy a multi. Responds with 409 Conflict if the target already exists. A "copied from ..." line will automatically be appended to the description. """ to_multi = self._copy_multi(from_multi, to_path_info) from_path = from_multi.path to_multi.copied_from = from_path if to_multi.description_md: to_multi.description_md += '\n\n' to_multi.description_md += _('copied from %(source)s') % { # force markdown linking since /user/foo is not autolinked 'source': '[%s](%s)' % (from_path, from_path) } to_multi.visibility = 'private' to_multi._commit() return self._format_multi(to_multi) @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), from_multi=VMultiByPath("from", require_edit=True, kinds='m'), to_path_info=VMultiPath( "to", docs={"to": "destination multireddit url path"}, ), ) @api_doc( api_section.multis, uri="/api/multi/{multipath}/rename", ) def POST_multi_rename(self, from_multi, to_path_info): """Rename a multi.""" to_multi = self._copy_multi(from_multi, to_path_info) from_multi.delete() return self._format_multi(to_multi) def _get_multi_subreddit(self, multi, sr): resp = LabeledMultiJsonTemplate.sr_props(multi, [sr])[0] return self.api_wrapper(resp) @require_oauth2_scope("read") @validate( VUser(), multi=VMultiByPath("multipath", require_view=True), sr=VSRByName('srname'), ) @api_doc( api_section.multis, uri="/api/multi/{multipath}/r/{srname}", uri_variants=['/api/filter/{filterpath}/r/{srname}'], ) def GET_multi_subreddit(self, multi, sr): """Get data about a subreddit in a multi.""" return self._get_multi_subreddit(multi, sr) @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), multi=VMultiByPath("multipath", require_edit=True), sr_name=VSubredditName('srname', allow_language_srs=True), data=VValidatedJSON("model", multi_sr_data_json_spec), ) @api_doc(api_section.multis, extends=GET_multi_subreddit) def PUT_multi_subreddit(self, multi, sr_name, data): """Add a subreddit to a multi.""" new = not any(sr.name.lower() == sr_name.lower() for sr in multi.srs) data['name'] = sr_name sr_props = self._add_multi_srs(multi, [data]) sr = sr_props.items()[0][0] multi._commit() if new: response.status = 201 return self._get_multi_subreddit(multi, sr) @require_oauth2_scope("subscribe") @validate( VUser(), VModhash(), multi=VMultiByPath("multipath", require_edit=True), sr=VSRByName('srname'), ) @api_doc(api_section.multis, extends=GET_multi_subreddit) def DELETE_multi_subreddit(self, multi, sr): """Remove a subreddit from a multi.""" multi.del_srs(sr) multi._commit() def _format_multi_description(self, multi): resp = LabeledMultiDescriptionJsonTemplate().render(multi).finalize() return self.api_wrapper(resp) @require_oauth2_scope("read") @validate( VUser(), multi=VMultiByPath("multipath", require_view=True, kinds='m'), ) @api_doc( api_section.multis, uri="/api/multi/{multipath}/description", ) def GET_multi_description(self, multi): """Get a multi's description.""" return self._format_multi_description(multi) @require_oauth2_scope("read") @validate( VUser(), VModhash(), multi=VMultiByPath("multipath", require_edit=True, kinds='m'), data=VValidatedJSON('model', multi_description_json_spec), ) @api_doc(api_section.multis, extends=GET_multi_description) def PUT_multi_description(self, multi, data): """Change a multi's markdown description.""" multi.description_md = data['body_md'] multi._commit() return self._format_multi_description(multi)
class ModmailController(OAuth2OnlyController): def pre(self): # Set user_is_admin property on context, # normally set but this controller does not inherit # from RedditController super(ModmailController, self).pre() admin_usernames = [ name.lower() for name in g.live_config['modmail_admins'] ] c.user_is_admin = False if c.user_is_loggedin: c.user_is_admin = c.user.name.lower() in admin_usernames VNotInTimeout().run() def post(self): Session.remove() super(ModmailController, self).post() @require_oauth2_scope('modmail') @validate( srs=VSRByNames('entity', required=False), after=VModConversation('after', required=False), limit=VInt('limit', num_default=25), sort=VOneOf('sort', options=('recent', 'mod', 'user'), default='recent'), state=VOneOf('state', options=('new', 'inprogress', 'mod', 'notifications', 'archived', 'highlighted', 'all'), default='all'), ) def GET_conversations(self, srs, after, limit, sort, state): """Get conversations for logged in user or subreddits Querystring Params: entity -- name of the subreddit or a comma separated list of subreddit names (i.e. iama, pics etc) limit -- number of elements to retrieve (default: 25) after -- the id of the last item seen sort -- parameter on how to sort the results, choices: recent: max(last_user_update, last_mod_update) mod: last_mod_update user: last_user_update state -- this parameter lets users filter messages by state choices: new, inprogress, mod, notifications, archived, highlighted, all """ # Retrieve subreddits in question, if entities are passed # check if a user is a moderator for the passed entities. # If no entities are passed grab all subreddits the logged in # user moderates and has modmail permissions for modded_entities = {} modded_srs = c.user.moderated_subreddits('mail') modded_srs = {sr._fullname: sr for sr in modded_srs} if srs: for sr in srs.values(): if sr._fullname in modded_srs: modded_entities[sr._fullname] = sr else: return self.send_error(403, errors.BAD_SR_NAME, fields='entity') else: modded_entities = modded_srs if not modded_entities: return self.send_error(404, errors.SR_NOT_FOUND, fields='entity') # Retrieve conversations for given entities conversations = ModmailConversation.get_mod_conversations( modded_entities.values(), viewer=c.user, limit=limit, after=after, sort=sort, state=state) conversation_ids = [] conversations_dict = {} messages_dict = {} author_ids = [] # Extract author ids to query for all accounts at once for conversation in conversations: author_ids.extend(conversation.author_ids) author_ids.extend(conversation.mod_action_account_ids) # Query for associated account object of authors and serialize the # conversation in the correct context authors = self._try_get_byID(author_ids, Account, ignore_missing=True) for conversation in conversations: conversation_ids.append(conversation.id36) conversations_dict[ conversation.id36] = conversation.to_serializable( authors, modded_entities[conversation.owner_fullname], ) latest_message = conversation.messages[0] messages_dict[ latest_message.id36] = latest_message.to_serializable( modded_entities[conversation.owner_fullname], authors[latest_message.author_id], c.user, ) return simplejson.dumps({ 'viewerId': c.user._fullname, 'conversationIds': conversation_ids, 'conversations': conversations_dict, 'messages': messages_dict, }) @require_oauth2_scope('modmail') @validate( entity=VSRByName('srName'), subject=VLength('subject', max_length=100), body=VMarkdownLength('body'), is_author_hidden=VBoolean('isAuthorHidden', default=False), to=VModConvoRecipient('to', required=False), ) def POST_conversations(self, entity, subject, body, is_author_hidden, to): """Creates a new conversation for a particular SR This endpoint will create a ModmailConversation object as well as the first ModmailMessage within the ModmailConversation object. POST Params: srName -- the human readable name of the subreddit subject -- the subject of the first message in the conversation body -- the body of the first message in the conversation isAuthorHidden -- boolean on whether the mod name should be hidden (only mods can use this flag) to -- name of the user that a mod wants to create a convo with (only mods can use this flag) """ self._feature_enabled_check(entity) # make sure the user is not muted when creating a new conversation if entity.is_muted(c.user) and not c.user_is_admin: return self.send_error(400, errors.USER_MUTED) # validate post params if (errors.USER_BLOCKED, to) in c.errors: return self.send_error(400, errors.USER_BLOCKED, fields='to') elif (errors.USER_DOESNT_EXIST, to) in c.errors: return self.send_error(404, errors.USER_DOESNT_EXIST, fields='to') if to and not isinstance(to, Account): return self.send_error( 422, errors.NO_SR_TO_SR_MESSAGE, fields='to', ) # only mods can set a 'to' parameter if (not entity.is_moderator_with_perms(c.user, 'mail') and to): return self.send_error(403, errors.MOD_REQUIRED, fields='to') if to and entity.is_muted(to): return self.send_error( 400, errors.MUTED_FROM_SUBREDDIT, fields='to', ) try: conversation = ModmailConversation( entity, c.user, subject, body, is_author_hidden=is_author_hidden, to=to, ) except MustBeAModError: return self.send_error(403, errors.MOD_REQUIRED, fields='isAuthorHidden') except Exception as e: g.log.error('Failed to save conversation: {}'.format(e)) return self.send_error(500, errors.CONVERSATION_NOT_SAVED) # Create copy of the message in the legacy messaging system as well if to: message, inbox_rel = Message._new( c.user, to, subject, body, request.ip, sr=entity, from_sr=is_author_hidden, create_modmail=False, ) else: message, inbox_rel = Message._new( c.user, entity, subject, body, request.ip, create_modmail=False, ) queries.new_message(message, inbox_rel) conversation.set_legacy_first_message_id(message._id) # Get author associated account object for serialization # of the newly created conversation object authors = self._try_get_byID(conversation.author_ids, Account, ignore_missing=True) response.status_code = 201 serializable_convo = conversation.to_serializable(authors, entity, all_messages=True, current_user=c.user) messages = serializable_convo.pop('messages') mod_actions = serializable_convo.pop('modActions') g.events.new_modmail_event( 'ss.send_modmail_message', conversation, message=conversation.messages[0], msg_author=c.user, sr=entity, request=request, context=c, ) return simplejson.dumps({ 'conversation': serializable_convo, 'messages': messages, 'modActions': mod_actions, }) @require_oauth2_scope('modmail') @validate( conversation=VModConversation('conversation_id'), mark_read=VBoolean('markRead', default=False), ) def GET_mod_messages(self, conversation, mark_read): """Returns all messages for a given conversation id Url Params: conversation_id -- this is the id of the conversation you would like to grab messages for Querystring Param: markRead -- if passed the conversation will be marked read when the conversation is returned """ self._validate_vmodconversation() sr = self._try_get_subreddit_access(conversation, admin_override=True) authors = self._try_get_byID(list( set(conversation.author_ids) | set(conversation.mod_action_account_ids)), Account, ignore_missing=True) serializable_convo = conversation.to_serializable(authors, sr, all_messages=True, current_user=c.user) messages = serializable_convo.pop('messages') mod_actions = serializable_convo.pop('modActions') # Get participant user info for conversation try: userinfo = self._get_modmail_userinfo(conversation, sr=sr) except ValueError: userinfo = {} except NotFound: return self.send_error(404, errors.USER_DOESNT_EXIST) if mark_read: conversation.mark_read(c.user) g.events.new_modmail_event( 'ss.modmail_mark_thread', conversation, mark_type='read', request=request, context=c, ) return simplejson.dumps({ 'conversation': serializable_convo, 'messages': messages, 'modActions': mod_actions, 'user': userinfo, }) @require_oauth2_scope('modmail') def GET_modmail_enabled_srs(self): # sr_name, sr_icon, subsriber_count, most_recent_action modded_srs = c.user.moderated_subreddits('mail') enabled_srs = [ modded_sr for modded_sr in modded_srs if feature.is_enabled('new_modmail', subreddit=modded_sr.name) ] recent_convos = ModmailConversation.get_recent_convo_by_sr(enabled_srs) results = {} for sr in enabled_srs: results.update({ sr._fullname: { 'id': sr._fullname, 'name': sr.name, 'icon': sr.icon_img, 'subscribers': sr._ups, 'lastUpdated': recent_convos.get(sr._fullname), } }) return simplejson.dumps({'subreddits': results}) @require_oauth2_scope('modmail') @validate( conversation=VModConversation('conversation_id'), msg_body=VMarkdownLength('body'), is_author_hidden=VBoolean('isAuthorHidden', default=False), is_internal=VBoolean('isInternal', default=False), ) def POST_mod_messages(self, conversation, msg_body, is_author_hidden, is_internal): """Creates a new message for a particular ModmailConversation URL Params: conversation_id -- id of the conversation to post a new message to POST Params: body -- this is the message body isAuthorHidden -- boolean on whether to hide author, i.e. respond as the subreddit isInternal -- boolean to signify a moderator only message """ self._validate_vmodconversation() sr = Subreddit._by_fullname(conversation.owner_fullname) self._feature_enabled_check(sr) # make sure the user is not muted before posting a message if sr.is_muted(c.user): return self.send_error(400, errors.USER_MUTED) if conversation.is_internal and not is_internal: is_internal = True is_mod = sr.is_moderator(c.user) if not is_mod and is_author_hidden: return self.send_error( 403, errors.MOD_REQUIRED, fields='isAuthorHidden', ) elif not is_mod and is_internal: return self.send_error( 403, errors.MOD_REQUIRED, fields='isInternal', ) try: if not conversation.is_internal and not conversation.is_auto: participant = conversation.get_participant_account() if participant and sr.is_muted(participant): return self.send_error( 400, errors.MUTED_FROM_SUBREDDIT, ) except NotFound: pass try: new_message = conversation.add_message( c.user, msg_body, is_author_hidden=is_author_hidden, is_internal=is_internal, ) except: return self.send_error(500, errors.MODMAIL_MESSAGE_NOT_SAVED) # Add the message to the legacy messaging system as well (unless it's # an internal message on a non-internal conversation, since we have no # way to hide specific messages from the external participant) legacy_incompatible = is_internal and not conversation.is_internal if (conversation.legacy_first_message_id and not legacy_incompatible): first_message = Message._byID(conversation.legacy_first_message_id) subject = conversation.subject if not subject.startswith('re: '): subject = 're: ' + subject # Retrieve the participant to decide whether to send the message # to the sr or to the participant. If the currently logged in user # is the same as the participant then address the message to the # sr. recipient = sr if not is_internal: try: participant = ( ModmailConversationParticipant.get_participant( conversation.id)) is_participant = ( (c.user._id == participant.account_id) and not sr.is_moderator_with_perms(c.user, 'mail')) if not is_participant: recipient = Account._byID(participant.account_id) except NotFound: pass message, inbox_rel = Message._new( c.user, recipient, subject, msg_body, request.ip, parent=first_message, from_sr=is_author_hidden, create_modmail=False, ) queries.new_message(message, inbox_rel) serializable_convo = conversation.to_serializable( entity=sr, all_messages=True, current_user=c.user, ) messages = serializable_convo.pop('messages') g.events.new_modmail_event( 'ss.send_modmail_message', conversation, message=new_message, msg_author=c.user, sr=sr, request=request, context=c, ) response.status_code = 201 return simplejson.dumps({ 'conversation': serializable_convo, 'messages': messages, }) @require_oauth2_scope('modmail') @validate(conversation=VModConversation('conversation_id')) def POST_highlight(self, conversation): """Marks a conversation as highlighted.""" self._validate_vmodconversation() self._try_get_subreddit_access(conversation) conversation.add_action(c.user, 'highlighted') conversation.add_highlight() # Retrieve updated conversation to be returned updated_convo = self._get_updated_convo(conversation.id, c.user) g.events.new_modmail_event( 'ss.modmail_mark_thread', conversation, mark_type='highlight', request=request, context=c, ) return simplejson.dumps(updated_convo) @require_oauth2_scope('modmail') @validate(conversation=VModConversation('conversation_id')) def DELETE_highlight(self, conversation): """Removes a highlight from a conversation.""" self._validate_vmodconversation() self._try_get_subreddit_access(conversation) conversation.add_action(c.user, 'unhighlighted') conversation.remove_highlight() # Retrieve updated conversation to be returned updated_convo = self._get_updated_convo(conversation.id, c.user) g.events.new_modmail_event( 'ss.modmail_mark_thread', conversation, mark_type='unhighlight', request=request, context=c, ) return simplejson.dumps(updated_convo) @require_oauth2_scope('modmail') @validate(ids=VList('conversationIds')) def POST_unread(self, ids): """Marks conversations as unread for the user. Expects a list of conversation IDs. """ if not ids: return self.send_error(400, 'Must pass an id or list of ids.') try: ids = [int(id, base=36) for id in ids] except: return self.send_error(422, 'Must pass base 36 ids.') try: convos = self._get_conversation_access(ids) except ValueError: return self.send_error( 403, errors.INVALID_CONVERSATION_ID, fields='conversationIds', ) ModmailConversationUnreadState.mark_unread( c.user, [convo.id for convo in convos]) @require_oauth2_scope('modmail') @validate(ids=VList('conversationIds')) def POST_read(self, ids): """Marks a conversations as read for the user. Expects a list of conversation IDs. """ if not ids: return self.send_error(400, 'Must pass an id or list of ids.') try: ids = [int(id, base=36) for id in ids] except: return self.send_error(422, 'Must pass base 36 ids.') try: convos = self._get_conversation_access(ids) except ValueError: return self.send_error( 403, errors.INVALID_CONVERSATION_ID, fields='conversationIds', ) response.status_code = 204 ModmailConversationUnreadState.mark_read( c.user, [convo.id for convo in convos]) @require_oauth2_scope('modmail') @validate( ids=VList('conversationIds'), archive=VBoolean('archive', default=True), ) def POST_archive_status(self, ids, archive): try: convos = self._get_conversation_access( [int(id, base=36) for id in ids]) except ValueError: return self.send_error( 403, errors.INVALID_CONVERSATION_ID, fields='conversationIds', ) convo_ids = [] for convo in convos: if convo.is_internal: return self.send_error( 422, errors.CONVERSATION_NOT_ARCHIVABLE, fields='conversationIds', ) convo_ids.append(convo.id) if not archive: ModmailConversation.set_states( convo_ids, ModmailConversation.STATE['inprogress']) else: ModmailConversation.set_states( convo_ids, ModmailConversation.STATE['archived']) response.status_code = 204 @require_oauth2_scope('modmail') @validate(conversation=VModConversation('conversation_id')) def POST_archive(self, conversation): self._validate_vmodconversation() sr = Subreddit._by_fullname(conversation.owner_fullname) self._feature_enabled_check(sr) if sr.is_moderator_with_perms(c.user, 'mail'): if conversation.state == ModmailConversation.STATE['archived']: response.status_code = 204 return if conversation.is_internal: return self.send_error( 422, errors.CONVERSATION_NOT_ARCHIVABLE, ) conversation.add_action(c.user, 'archived') conversation.set_state('archived') updated_convo = self._get_updated_convo(conversation.id, c.user) g.events.new_modmail_event( 'ss.modmail_mark_thread', conversation, mark_type='archive', request=request, context=c, ) return simplejson.dumps(updated_convo) else: return self.send_error(403, errors.INVALID_MOD_PERMISSIONS) @require_oauth2_scope('modmail') @validate(conversation=VModConversation('conversation_id')) def POST_unarchive(self, conversation): self._validate_vmodconversation() sr = Subreddit._by_fullname(conversation.owner_fullname) self._feature_enabled_check(sr) if sr.is_moderator_with_perms(c.user, 'mail'): if conversation.state != ModmailConversation.STATE['archived']: response.status_code = 204 return if conversation.is_internal: return self.send_error( 422, errors.CONVERSATION_NOT_ARCHIVABLE, ) conversation.add_action(c.user, 'unarchived') conversation.set_state('inprogress') updated_convo = self._get_updated_convo(conversation.id, c.user) g.events.new_modmail_event( 'ss.modmail_mark_thread', conversation, mark_type='unarchive', request=request, context=c, ) return simplejson.dumps(updated_convo) else: return self.send_error(403, errors.INVALID_MOD_PERMISSIONS) @require_oauth2_scope('modmail') def GET_unread_convo_count(self): """Endpoint to retrieve the unread conversation count by category""" convo_counts = ModmailConversation.unread_convo_count(c.user) return simplejson.dumps(convo_counts) @require_oauth2_scope('modmail') @validate(conversation=VModConversation('conversation_id')) def GET_modmail_userinfo(self, conversation): # validate that the currently logged in user is a mod # of the subreddit associated with the conversation self._try_get_subreddit_access(conversation, admin_override=True) try: userinfo = self._get_modmail_userinfo(conversation) except (ValueError, NotFound): return self.send_error(404, errors.USER_DOESNT_EXIST) return simplejson.dumps(userinfo) @require_oauth2_scope('identity') @validate(conversation=VModConversation('conversation_id')) def POST_mute_participant(self, conversation): if conversation.is_internal or conversation.is_auto: return self.send_error(400, errors.CANT_RESTRICT_MODERATOR) sr = Subreddit._by_fullname(conversation.owner_fullname) try: participant = conversation.get_participant_account() except NotFound: return self.send_error(404, errors.USER_DOESNT_EXIST) if not sr.can_mute(c.user, participant): return self.send_error(400, errors.CANT_RESTRICT_MODERATOR) if not c.user_is_admin: if not sr.is_moderator_with_perms(c.user, 'access', 'mail'): return self.send_error(403, errors.INVALID_MOD_PERMISSIONS) if sr.use_quotas: sr_ratelimit = SimpleRateLimit( name="sr_muted_%s" % sr._id36, seconds=g.sr_quota_time, limit=g.sr_muted_quota, ) if not sr_ratelimit.record_and_check(): return self.send_error(403, errors.SUBREDDIT_RATELIMIT) # Add the mute record but only if successful create the # appropriate notifications, this prevents duplicate # notifications from being sent added = sr.add_muted(participant) if not added: return simplejson.dumps( self._convo_to_serializable(conversation, all_messages=True)) MutedAccountsBySubreddit.mute(sr, participant, c.user) permalink = conversation.make_permalink() # Create the appropriate objects to be displayed on the # mute moderation log, use the permalink to the new modmail # system ModAction.create(sr, c.user, 'muteuser', target=participant, description=permalink) sr.add_rel_note('muted', participant, permalink) # Add the muted mod action to the conversation conversation.add_action(c.user, 'muted', commit=True) result = self._get_updated_convo(conversation.id, c.user) result['user'] = self._get_modmail_userinfo(conversation, sr=sr) return simplejson.dumps(result) @require_oauth2_scope('identity') @validate(conversation=VModConversation('conversation_id')) def POST_unmute_participant(self, conversation): if conversation.is_internal or conversation.is_auto: return self.send_error(400, errors.CANT_RESTRICT_MODERATOR) sr = Subreddit._by_fullname(conversation.owner_fullname) try: participant = conversation.get_participant_account() except NotFound: abort(404, errors.USER_DOESNT_EXIST) if not c.user_is_admin: if not sr.is_moderator_with_perms(c.user, 'access', 'mail'): return self.send_error(403, errors.INVALID_MOD_PERMISSIONS) removed = sr.remove_muted(participant) if not removed: return simplejson.dumps( self._convo_to_serializable(conversation, all_messages=True)) MutedAccountsBySubreddit.unmute(sr, participant) ModAction.create(sr, c.user, 'unmuteuser', target=participant) conversation.add_action(c.user, 'unmuted', commit=True) result = self._get_updated_convo(conversation.id, c.user) result['user'] = self._get_modmail_userinfo(conversation, sr=sr) return simplejson.dumps(result) def _get_modmail_userinfo(self, conversation, sr=None): if conversation.is_internal: raise ValueError('Cannot get userinfo for internal conversations') if not sr: sr = Subreddit._by_fullname(conversation.owner_fullname) # Retrieve the participant associated with the conversation try: account = conversation.get_participant_account() if not account: raise ValueError('No account associated with convo') permatimeout = (account.in_timeout and account.days_remaining_in_timeout == 0) if account._deleted or permatimeout: raise ValueError('User info is inaccessible') except NotFound: raise NotFound('Unable to retrieve conversation participant') # Fetch the mute and ban status of the participant as it relates # to the subreddit associated with the conversation. mute_status = sr.is_muted(account) ban_status = sr.is_banned(account) # Parse the ban status and retrieve the length of the ban, # then output the data into a serialiazable dict ban_result = { 'isBanned': bool(ban_status), 'reason': '', 'endDate': None, 'isPermanent': False } if ban_status: ban_result['reason'] = getattr(ban_status, 'note', '') ban_duration = sr.get_tempbans('banned', account.name) ban_duration = ban_duration.get(account.name) if ban_duration: ban_result['endDate'] = ban_duration.isoformat() else: ban_result['isPermanent'] = True ban_result['endDate'] = None # Parse the mute status and retrieve the length of the ban, # then output the data into the serialiazable dict mute_result = { 'isMuted': bool(mute_status), 'endDate': None, 'reason': '' } if mute_status: mute_result['reason'] = getattr(mute_status, 'note', '') muted_items = sr.get_muted_items(account.name) mute_duration = muted_items.get(account.name) if mute_duration: mute_result['endDate'] = mute_duration.isoformat() # Retrieve the participants post and comment fullnames from cache post_fullnames = [] comment_fullnames = [] if not account._spam: post_fullnames = list(queries.get_submitted(account, 'new', 'all'))[:100] comment_fullnames = list( queries.get_comments(account, 'new', 'all'))[:100] # Retrieve the associated link objects for posts and comments # using the retrieve fullnames, afer the link objects are retrieved # create a serializable dict with the the necessary information from # the endpoint. lookup_fullnames = list(set(post_fullnames) | set(comment_fullnames)) posts = Thing._by_fullname(lookup_fullnames) serializable_posts = {} for fullname in post_fullnames: if len(serializable_posts) == 3: break post = posts[fullname] if post.sr_id == sr._id and not post._deleted: serializable_posts[fullname] = { 'title': post.title, 'permalink': post.make_permalink(sr, force_domain=True), 'date': post._date.isoformat(), } # Extract the users most recent comments associated with the # subreddit sr_comments = [] for fullname in comment_fullnames: if len(sr_comments) == 3: break comment = posts[fullname] if comment.sr_id == sr._id and not comment._deleted: sr_comments.append(comment) # Retrieve all associated link objects (combines lookup) comment_links = Link._byID( [sr_comment.link_id for sr_comment in sr_comments]) # Serialize all of the user's sr comments serializable_comments = {} for sr_comment in sr_comments: comment_link = comment_links[sr_comment.link_id] comment_body = sr_comment.body if len(comment_body) > 140: comment_body = '{:.140}...'.format(comment_body) serializable_comments[sr_comment._fullname] = { 'title': comment_link.title, 'comment': comment_body, 'permalink': sr_comment.make_permalink(comment_link, sr, force_domain=True), 'date': sr_comment._date.isoformat(), } return { 'id': account._fullname, 'name': account.name, 'created': account._date.isoformat(), 'banStatus': ban_result, 'isShadowBanned': account._spam, 'muteStatus': mute_result, 'recentComments': serializable_comments, 'recentPosts': serializable_posts, } def _get_updated_convo(self, convo_id, user): # Retrieve updated conversation to be returned updated_convo = ModmailConversation._byID(convo_id, current_user=user) return self._convo_to_serializable(updated_convo, all_messages=True) def _convo_to_serializable(self, conversation, all_messages=False): serialized_convo = conversation.to_serializable( all_messages=all_messages, current_user=c.user) messages = serialized_convo.pop('messages') mod_actions = serialized_convo.pop('modActions') return { 'conversations': serialized_convo, 'messages': messages, 'modActions': mod_actions, } def _validate_vmodconversation(self): if (errors.CONVERSATION_NOT_FOUND, 'conversation_id') in c.errors: return self.send_error(404, errors.CONVERSATION_NOT_FOUND) def _get_conversation_access(self, ids): validated_convos = [] conversations = ModmailConversation._byID(ids) # fetch all srs that a user has modmail permissions to # transform sr to be a dict with a key being the sr fullname # and the value being the sr object itself modded_srs = c.user.moderated_subreddits('mail') sr_by_fullname = { sr._fullname: sr for sr in modded_srs if feature.is_enabled('new_modmail', subreddit=sr.name) } for conversation in tup(conversations): if sr_by_fullname.get(conversation.owner_fullname): validated_convos.append(conversation) else: raise ValueError('Invalid conversation id(s).') return validated_convos def _try_get_byID(self, ids, thing_class, return_dict=True, ignore_missing=False): """Helper method to lookup objects by id for a given model or return a 404 if not found""" try: return thing_class._byID(ids, return_dict=return_dict, ignore_missing=ignore_missing) except NotFound: return self.send_error(404, errors.THING_NOT_FOUND, explanation='{} not found'.format( thing_class.__name__)) except: return self.send_error(422, 'Invalid request') def _try_get_subreddit_access(self, conversation, admin_override=False): sr = Subreddit._by_fullname(conversation.owner_fullname) self._feature_enabled_check(sr) if (not sr.is_moderator_with_perms(c.user, 'mail') and not (admin_override and c.user_is_admin)): return self.send_error( 403, errors.SUBREDDIT_NO_ACCESS, ) return sr def _feature_enabled_check(self, sr): if not feature.is_enabled('new_modmail', subreddit=sr.name): return self.send_error(403, errors.SR_FEATURE_NOT_ENABLED) def send_error(self, code, error, fields=None, explanation=None): abort( reddit_http_error( code=code or error.code, error_name=error, explanation=explanation, fields=tup(fields), ))