ForgeDiscussion/forgediscussion/forum_main.py (306 lines of code) (raw):

# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. import logging import six.moves.urllib.request import six.moves.urllib.parse import six.moves.urllib.error import json # Non-stdlib imports from tg import tmpl_context as c, app_globals as g from tg import request from tg import expose, redirect, flash, validate, jsonify from tg.decorators import with_trailing_slash from bson import ObjectId from ming import schema # Pyforge-specific imports from allura import model as M from allura.app import Application, ConfigOption, SitemapEntry, DefaultAdminController from allura.lib import helpers as h from allura.lib.decorators import require_post from allura.lib.security import require_access, has_access from allura.lib.utils import JSONForExport # Local imports from forgediscussion import model as DM from forgediscussion import utils from forgediscussion import version from .controllers import RootController, RootRestController from .widgets.admin import OptionsAdmin, AddForum log = logging.getLogger(__name__) class W: options_admin = OptionsAdmin() add_forum = AddForum() class ForgeDiscussionApp(Application): __version__ = version.__version__ permissions = ['configure', 'read', 'unmoderated_post', 'post', 'moderate', 'admin'] permissions_desc = { 'configure': 'Create new forums.', 'read': 'View posts.', 'admin': 'Set permissions. Edit forum properties.', } config_options = Application.config_options + [ ConfigOption('PostingPolicy', schema.OneOf('ApproveOnceModerated', 'ModerateAll'), 'ApproveOnceModerated'), ConfigOption('AllowEmailPosting', bool, True) ] PostClass = DM.ForumPost AttachmentClass = DM.ForumAttachment searchable = True exportable = True tool_label = 'Discussion' tool_description = """ Discussion forums are a place to talk about any topics related to your project. You may set up multiple forums within the Discussion tool. """ default_mount_label = 'Discussion' default_mount_point = 'discussion' ordinal = 7 icons = { 24: 'images/forums_24.png', 32: 'images/forums_32.png', 48: 'images/forums_48.png' } def __init__(self, project, config): Application.__init__(self, project, config) self.root = RootController() self.api_root = RootRestController() self.admin = ForumAdminController(self) def has_access(self, user, topic): f = DM.Forum.query.get(shortname=topic.replace('.', '/'), app_config_id=self.config._id) return has_access(f, 'post', user=user) def handle_message(self, topic, message): log.info('Message from %s (%s)', topic, self.config.options.mount_point) log.info('Headers are: %s', message['headers']) shortname = six.moves.urllib.parse.unquote_plus(topic.replace('.', '/')) forum = DM.Forum.query.get( shortname=shortname, app_config_id=self.config._id) if forum is None: log.error("Error looking up forum: %r", shortname) return self.handle_artifact_message(forum, message) def main_menu(self): '''Apps should provide their entries to be added to the main nav :return: a list of :class:`SitemapEntries <allura.app.SitemapEntry>` ''' return [SitemapEntry( self.config.options.mount_label, '.')] @property @h.exceptionless([], log) def sitemap(self): menu_id = self.config.options.mount_label with h.push_config(c, app=self): return [ SitemapEntry(menu_id, '.')[self.sidebar_menu()]] def sitemap_xml(self): """ Used for generating sitemap.xml. If the root page has default content, omit it from the sitemap.xml. Assumes :attr:`main_menu` will return an entry pointing to the root page. :return: a list of :class:`SitemapEntries <allura.app.SitemapEntry>` """ if self.should_noindex(): return [] return self.main_menu() def should_noindex(self): forums = self.forums for forum in forums: post = DM.ForumPost.query.get( discussion_id=forum._id, status='ok', deleted=False, ) if post: return False return True @property def forums(self): return DM.Forum.query.find(dict(app_config_id=self.config._id)).all() @property def top_forums(self): return self.subforums_of(None) def subforums_of(self, parent_id): return DM.Forum.query.find(dict( app_config_id=self.config._id, parent_id=parent_id, )).all() def admin_menu(self): admin_url = c.project.url() + 'admin/' + \ self.config.options.mount_point + '/' links = [] if has_access(self, 'configure'): links.append(SitemapEntry('Forums', admin_url + 'forums')) links += super().admin_menu() return links def sidebar_menu(self): try: l = [] moderate_link = None forum_links = [] forums = DM.Forum.query.find(dict( app_config_id=c.app.config._id, parent_id=None, deleted=False)) for f in forums: if has_access(f, 'read'): if f.url() in request.url and h.has_access(f, 'moderate'): num_moderate = DM.ForumPost.query.find({ 'discussion_id': f._id, 'status': {'$ne': 'ok'}, 'deleted': False, }).count() moderate_link = SitemapEntry( 'Moderate', "%smoderate/" % f.url(), ui_icon=g.icons['moderate'], small=num_moderate) forum_links.append( SitemapEntry(f.name, f.url(), small=f.num_topics)) url = c.app.url + 'create_topic/' url = h.urlquote( url + c.forum.shortname if getattr(c, 'forum', None) and c.forum else url) l.append( SitemapEntry('Create Topic', url, ui_icon=g.icons['add'])) if has_access(c.app, 'configure'): l.append(SitemapEntry('Add Forum', c.app.url + 'new_forum', ui_icon=g.icons['conversation'])) l.append(SitemapEntry('Admin Forums', c.project.url() + 'admin/' + self.config.options.mount_point + '/forums', ui_icon=g.icons['admin'])) if moderate_link: l.append(moderate_link) # if we are in a thread and not anonymous, provide placeholder # links to use in js if '/thread/' in request.url and c.user not in (None, M.User.anonymous()): l.append(SitemapEntry( 'Mark as Spam', 'flag_as_spam', ui_icon=g.icons['flag'], className='sidebar_thread_spam')) l.append(SitemapEntry('Stats Graph', c.app.url + 'stats', ui_icon=g.icons['stats'])) if forum_links: l.append(SitemapEntry('Forums')) l = l + forum_links l.append(SitemapEntry('Help')) l.append( SitemapEntry('Formatting Help', '/nf/markdown_syntax', extra_html_attrs={'target': '_blank'})) return l except Exception: # pragma no cover log.exception('sidebar_menu') return [] def install(self, project): 'Set up any default permissions and roles here' # Don't call super install here, as that sets up discussion for a tool # Setup permissions role_admin = M.ProjectRole.by_name('Admin')._id role_developer = M.ProjectRole.by_name('Developer')._id role_auth = M.ProjectRole.by_name('*authenticated')._id role_anon = M.ProjectRole.by_name('*anonymous')._id self.config.acl = [ M.ACE.allow(role_anon, 'read'), M.ACE.allow(role_auth, 'post'), M.ACE.allow(role_auth, 'unmoderated_post'), M.ACE.allow(role_developer, 'moderate'), M.ACE.allow(role_admin, 'configure'), M.ACE.allow(role_admin, 'admin'), ] utils.create_forum(self, new_forum=dict( shortname='general', create='on', name='General Discussion', description='Forum about anything you want to talk about.', parent='', members_only=False, anon_posts=False, monitoring_email=None)) def uninstall(self, project): "Remove all the tool's artifacts from the database" DM.Forum.query.remove(dict(app_config_id=self.config._id)) DM.ForumThread.query.remove(dict(app_config_id=self.config._id)) DM.ForumPost.query.remove(dict(app_config_id=self.config._id)) super().uninstall(project) def bulk_export(self, f, export_path='', with_attachments=False): f.write('{"forums": [') forums = list(DM.Forum.query.find(dict(app_config_id=self.config._id))) if with_attachments: JSONEncoder = JSONForExport for forum in forums: self.export_attachments(forum.threads, export_path) else: JSONEncoder = jsonify.JSONEncoder for i, forum in enumerate(forums): if i > 0: f.write(',') json.dump(forum, f, cls=JSONEncoder, indent=2) f.write(']}') def export_attachments(self, threads, export_path): for thread in threads: for post in thread.query_posts(status='ok'): post_path = self.get_attachment_export_path( export_path, str(thread.artifact._id), thread._id, post.slug ) self.save_attachments(post_path, post.attachments) class ForumAdminController(DefaultAdminController): def _check_security(self): require_access(self.app, 'admin') @with_trailing_slash def index(self, **kw): redirect('forums') @expose('jinja:forgediscussion:templates/discussionforums/admin_options.html') def options(self): c.options_admin = W.options_admin return dict(app=self.app, form_value=dict( PostingPolicy=self.app.config.options.get('PostingPolicy'), AllowEmailPosting=self.app.config.options.get('AllowEmailPosting', True))) @expose('jinja:forgediscussion:templates/discussionforums/admin_forums.html') def forums(self, add_forum=None, **kw): c.add_forum = W.add_forum return dict(app=self.app, allow_config=has_access(self.app, 'configure')) @h.vardec @expose() @require_post() def update_forums(self, forum=None, **kw): if forum is None: forum = [] mount_point = self.app.config.options['mount_point'] def set_value(forum, name, val): if getattr(forum, name, None) != val: M.AuditLog.log('{}: {} - set option "{}" {} => {}'.format( mount_point, forum.name, name, getattr(forum, name, None), val)) setattr(forum, name, val) for f in forum: forum = DM.Forum.query.get(_id=ObjectId(str(f['id']))) if f.get('delete'): forum.deleted = True M.AuditLog.log('deleted forum "{}" from {}'.format( forum.name, self.app.config.options['mount_point'])) elif f.get('undelete'): forum.deleted = False M.AuditLog.log('undeleted forum "{}" from {}'.format( forum.name, self.app.config.options['mount_point'])) else: if '.' in f['shortname'] or '/' in f['shortname'] or ' ' in f['shortname']: flash('Shortname cannot contain space . or /', 'error') redirect('.') set_value(forum, 'name', f['name']) set_value(forum, 'shortname', f['shortname']) set_value(forum, 'description', f['description']) set_value(forum, 'monitoring_email', f['monitoring_email']) if 'members_only' in f: if 'anon_posts' in f: flash( 'You cannot have anonymous posts in a members only forum.', 'warning') set_value(forum, 'anon_posts', False) del f['anon_posts'] set_value(forum, 'members_only', True) else: set_value(forum, 'members_only', False) if 'anon_posts' in f: set_value(forum, 'anon_posts', True) else: set_value(forum, 'anon_posts', False) role_anon = M.ProjectRole.anonymous()._id if forum.members_only: role_developer = M.ProjectRole.by_name('Developer')._id forum.acl = [ M.ACE.allow(role_developer, M.ALL_PERMISSIONS), M.DENY_ALL] elif forum.anon_posts: forum.acl = [M.ACE.allow(role_anon, 'post')] else: forum.acl = [] flash('Forums updated') redirect(six.ensure_text(request.referer or '/')) @h.vardec @expose() @require_post() @validate(form=W.add_forum, error_handler=forums) def add_forum(self, add_forum=None, **kw): f = utils.create_forum(self.app, add_forum) redirect(f.url())