[IMP] [TEST] website_forum: security fixes + tests
authorssh-odoo <ssh@openerp.com>
Fri, 19 Sep 2014 07:25:19 +0000 (12:55 +0530)
committerThibault Delavallée <tde@openerp.com>
Thu, 23 Oct 2014 09:36:03 +0000 (11:36 +0200)
- fixed voting, karma check could be avoided
- fixed posting comments, now correctly checking karma (not for
notifications)
- fixed bootstraping of users, now not allowed to ask questions by default;
added validation email that gives the first karma points required to
participate
- added tests

addons/website_forum/__init__.py
addons/website_forum/controllers/main.py
addons/website_forum/data/forum_data.xml
addons/website_forum/models/forum.py
addons/website_forum/models/res_users.py
addons/website_forum/static/src/js/website_forum.js
addons/website_forum/tests/__init__.py [new file with mode: 0644]
addons/website_forum/tests/common.py [new file with mode: 0644]
addons/website_forum/tests/test_forum.py [new file with mode: 0644]
addons/website_forum/views/forum.xml
addons/website_forum/views/website_forum.xml

index bde83af..dc8bc32 100644 (file)
@@ -2,3 +2,4 @@
 
 import controllers
 import models
+import tests
index 08e03d9..a17dc6b 100644 (file)
@@ -1,9 +1,7 @@
 # -*- coding: utf-8 -*-
 
-from datetime import datetime
 import werkzeug.urls
 import werkzeug.wrappers
-import re
 import simplejson
 
 from openerp import tools
@@ -13,7 +11,6 @@ from openerp.addons.web.controllers.main import login_redirect
 from openerp.addons.web.http import request
 from openerp.addons.website.controllers.main import Website as controllers
 from openerp.addons.website.models.website import slug
-from openerp.tools.translate import _
 
 controllers = controllers()
 
@@ -35,12 +32,15 @@ class WebsiteForum(http.Controller):
 
     def _prepare_forum_values(self, forum=None, **kwargs):
         user = request.registry['res.users'].browse(request.cr, request.uid, request.uid, context=request.context)
-        values = {'user': user,
-                  'is_public_user': user.id == request.website.user_id.id,
-                  'notifications': self._get_notifications(),
-                  'header': kwargs.get('header', dict()),
-                  'searches': kwargs.get('searches', dict()),
-                  }
+        values = {
+            'user': user,
+            'is_public_user': user.id == request.website.user_id.id,
+            'notifications': self._get_notifications(),
+            'header': kwargs.get('header', dict()),
+            'searches': kwargs.get('searches', dict()),
+            'validation_email_sent': request.session.get('validation_email_sent', False),
+            'validation_email_done': request.session.get('validation_email_done', False),
+        }
         if forum:
             values['forum'] = forum
         elif kwargs.get('forum_id'):
@@ -48,6 +48,34 @@ class WebsiteForum(http.Controller):
         values.update(kwargs)
         return values
 
+    # User and validation
+    # --------------------------------------------------
+
+    @http.route('/forum/send_validation_email', type='json', auth='user', website=True)
+    def send_validation_email(self, forum_id=None, **kwargs):
+        request.registry['res.users'].send_forum_validation_email(request.cr, request.uid, request.uid, forum_id=forum_id, context=request.context)
+        request.session['validation_email_sent'] = True
+        return True
+
+    @http.route('/forum/validate_email', type='http', auth='public', website=True)
+    def validate_email(self, token, id, email, forum_id=None, **kwargs):
+        if forum_id:
+            try:
+                forum_id = int(forum_id)
+            except ValueError:
+                forum_id = None
+        done = request.registry['res.users'].process_forum_validation_token(request.cr, request.uid, token, int(id), email, forum_id=forum_id, context=request.context)
+        if done:
+            request.session['validation_email_done'] = True
+        if forum_id:
+            return request.redirect("/forum/%s" % int(forum_id))
+        return request.redirect('/forum')
+
+    @http.route('/forum/validate_email/close', type='json', auth='public', website=True)
+    def validate_email_done(self):
+        request.session['validation_email_done'] = False
+        return True
+
     # Forum
     # --------------------------------------------------
 
@@ -298,10 +326,12 @@ class WebsiteForum(http.Controller):
         cr, uid, context = request.cr, request.uid, request.context
         if kwargs.get('comment') and post.forum_id.id == forum.id:
             # TDE FIXME: check that post_id is the question or one of its answers
-            request.registry['forum.post']._post_comment(
-                cr, uid, post,
+            request.registry['forum.post'].message_post(
+                cr, uid, post.id,
                 body=kwargs.get('comment'),
-                context=context)
+                type='comment',
+                subtype='mt_comment',
+                context=dict(context, mail_create_nosubcribe=True))
         return werkzeug.utils.redirect("/forum/%s/question/%s" % (slug(forum), slug(question)))
 
     @http.route('/forum/<model("forum.forum"):forum>/post/<model("forum.post"):post>/toggle_correct', type='json', auth="public", website=True)
index 61bb7ee..379e746 100644 (file)
             <field name="name">too localized</field>
         </record>
 
+        <!-- Email template for email validation (for karma purpose) -->
+        <record id="validation_email" model="email.template">
+            <field name="name">Email Verification</field>
+            <field name="model_id" ref="base.model_res_users"/>
+            <field name="email_from"><![CDATA[${object.company_id.name} <${(object.company_id.email or user.email)|safe}>]]></field>
+            <field name="email_to">${object.email|safe}</field>
+            <field name="subject"><![CDATA[${object.company_id.name} Forums validation]]></field>
+            <field name="body_html"><![CDATA[
+<p>
+    Hello ${object.name},
+</p>
+<p>
+    You have been invited to validate your email in order to get access to "${object.company_id.name}" Q/A Forums.
+</p>
+<p>
+    To validate your email, please click on the following link:
+</p>
+<ul>
+    <li><a href="${ctx.get('token_url')}">Validate my account for "${object.company_id.name}" Q/A Forums</a></li>
+</ul>
+<p>
+    Thanks,
+</p>
+<pre>
+--
+${object.company_id.name or ''}
+${object.company_id.email or ''}
+${object.company_id.phone or ''}
+</pre>]]></field>
+        </record>
+
     </data>
 </openerp>
index 1bc9b69..a98b3ef 100644 (file)
@@ -1,16 +1,18 @@
 # -*- coding: utf-8 -*-
 
 from datetime import datetime
+import uuid
+from werkzeug.exceptions import Forbidden
 
 import openerp
-from openerp import tools
+from openerp import api, tools
 from openerp import SUPERUSER_ID
 from openerp.addons.website.models.website import slug
+from openerp.exceptions import Warning
 from openerp.osv import osv, fields
 from openerp.tools import html2plaintext
 from openerp.tools.translate import _
 
-from werkzeug.exceptions import Forbidden
 
 class KarmaError(Forbidden):
     """ Karma-related error, used for forum and posts. """
@@ -23,42 +25,48 @@ class Forum(osv.Model):
     _description = 'Forums'
     _inherit = ['mail.thread', 'website.seo.metadata']
 
+    def init(self, cr):
+        """ Add forum uuid for user email validation. """
+        forum_uuids = self.pool['ir.config_parameter'].search(cr, SUPERUSER_ID, [('key', '=', 'website_forum.uuid')])
+        if not forum_uuids:
+            self.pool['ir.config_parameter'].set_param(cr, SUPERUSER_ID, 'website_forum.uuid', str(uuid.uuid4()), ['base.group_system'])
+
     _columns = {
         'name': fields.char('Name', required=True, translate=True),
         'faq': fields.html('Guidelines'),
         'description': fields.html('Description'),
         # karma generation
-        'karma_gen_question_new': fields.integer('Karma earned for new questions'),
-        'karma_gen_question_upvote': fields.integer('Karma earned for upvoting a question'),
-        'karma_gen_question_downvote': fields.integer('Karma earned for downvoting a question'),
-        'karma_gen_answer_upvote': fields.integer('Karma earned for upvoting an answer'),
-        'karma_gen_answer_downvote': fields.integer('Karma earned for downvoting an answer'),
-        'karma_gen_answer_accept': fields.integer('Karma earned for accepting an anwer'),
-        'karma_gen_answer_accepted': fields.integer('Karma earned for having an answer accepted'),
-        'karma_gen_answer_flagged': fields.integer('Karma earned for having an answer flagged'),
+        'karma_gen_question_new': fields.integer('Asking a question'),
+        'karma_gen_question_upvote': fields.integer('Question upvoted'),
+        'karma_gen_question_downvote': fields.integer('Question downvoted'),
+        'karma_gen_answer_upvote': fields.integer('Answer upvoted'),
+        'karma_gen_answer_downvote': fields.integer('Answer downvoted'),
+        'karma_gen_answer_accept': fields.integer('Accepting an answer'),
+        'karma_gen_answer_accepted': fields.integer('Answer accepted'),
+        'karma_gen_answer_flagged': fields.integer('Answer flagged'),
         # karma-based actions
-        'karma_ask': fields.integer('Karma to ask a new question'),
-        'karma_answer': fields.integer('Karma to answer a question'),
-        'karma_edit_own': fields.integer('Karma to edit its own posts'),
-        'karma_edit_all': fields.integer('Karma to edit all posts'),
-        'karma_close_own': fields.integer('Karma to close its own posts'),
-        'karma_close_all': fields.integer('Karma to close all posts'),
-        'karma_unlink_own': fields.integer('Karma to delete its own posts'),
-        'karma_unlink_all': fields.integer('Karma to delete all posts'),
-        'karma_upvote': fields.integer('Karma to upvote'),
-        'karma_downvote': fields.integer('Karma to downvote'),
-        'karma_answer_accept_own': fields.integer('Karma to accept an answer on its own questions'),
-        'karma_answer_accept_all': fields.integer('Karma to accept an answers to all questions'),
-        'karma_editor_link_files': fields.integer('Karma for linking files (Editor)'),
-        'karma_editor_clickable_link': fields.integer('Karma for clickable links (Editor)'),
-        'karma_comment_own': fields.integer('Karma to comment its own posts'),
-        'karma_comment_all': fields.integer('Karma to comment all posts'),
-        'karma_comment_convert_own': fields.integer('Karma to convert its own answers to comments and vice versa'),
-        'karma_comment_convert_all': fields.integer('Karma to convert all answers to answers and vice versa'),
-        'karma_comment_unlink_own': fields.integer('Karma to unlink its own comments'),
-        'karma_comment_unlink_all': fields.integer('Karma to unlinnk all comments'),
-        'karma_retag': fields.integer('Karma to change question tags'),
-        'karma_flag': fields.integer('Karma to flag a post as offensive'),
+        'karma_ask': fields.integer('Ask a question'),
+        'karma_answer': fields.integer('Answer a question'),
+        'karma_edit_own': fields.integer('Edit its own posts'),
+        'karma_edit_all': fields.integer('Edit all posts'),
+        'karma_close_own': fields.integer('Close its own posts'),
+        'karma_close_all': fields.integer('Close all posts'),
+        'karma_unlink_own': fields.integer('Delete its own posts'),
+        'karma_unlink_all': fields.integer('Delete all posts'),
+        'karma_upvote': fields.integer('Upvote'),
+        'karma_downvote': fields.integer('Downvote'),
+        'karma_answer_accept_own': fields.integer('Accept an answer on its own questions'),
+        'karma_answer_accept_all': fields.integer('Accept an answer to all questions'),
+        'karma_editor_link_files': fields.integer('Linking files (Editor)'),
+        'karma_editor_clickable_link': fields.integer('Clickable links (Editor)'),
+        'karma_comment_own': fields.integer('Comment its own posts'),
+        'karma_comment_all': fields.integer('Comment all posts'),
+        'karma_comment_convert_own': fields.integer('Convert its own answers to comments and vice versa'),
+        'karma_comment_convert_all': fields.integer('Convert all answers to comments and vice versa'),
+        'karma_comment_unlink_own': fields.integer('Unlink its own comments'),
+        'karma_comment_unlink_all': fields.integer('Unlink all comments'),
+        'karma_retag': fields.integer('Change question tags'),
+        'karma_flag': fields.integer('Flag a post as offensive'),
     }
 
     def _get_default_faq(self, cr, uid, context=None):
@@ -70,7 +78,7 @@ class Forum(osv.Model):
     _defaults = {
         'description': 'This community is for professionals and enthusiasts of our products and services.',
         'faq': _get_default_faq,
-        'karma_gen_question_new': 2,
+        'karma_gen_question_new': 0,  # set to null for anti spam protection
         'karma_gen_question_upvote': 5,
         'karma_gen_question_downvote': -2,
         'karma_gen_answer_upvote': 10,
@@ -78,8 +86,8 @@ class Forum(osv.Model):
         'karma_gen_answer_accept': 2,
         'karma_gen_answer_accepted': 15,
         'karma_gen_answer_flagged': -100,
-        'karma_ask': 0,
-        'karma_answer': 0,
+        'karma_ask': 3,  # set to not null for anti spam protection
+        'karma_answer': 3,  # set to not null for anti spam protection
         'karma_edit_own': 1,
         'karma_edit_all': 300,
         'karma_close_own': 100,
@@ -92,8 +100,8 @@ class Forum(osv.Model):
         'karma_answer_accept_all': 500,
         'karma_editor_link_files': 20,
         'karma_editor_clickable_link': 20,
-        'karma_comment_own': 1,
-        'karma_comment_all': 1,
+        'karma_comment_own': 3,
+        'karma_comment_all': 5,
         'karma_comment_convert_own': 50,
         'karma_comment_convert_all': 500,
         'karma_comment_unlink_own': 50,
@@ -393,13 +401,6 @@ class Post(osv.Model):
         return super(Post, self).unlink(cr, uid, ids, context=context)
 
     def vote(self, cr, uid, ids, upvote=True, context=None):
-        posts = self.browse(cr, uid, ids, context=context)
-
-        if upvote and any(not post.can_upvote for post in posts):
-            raise KarmaError('Not enough karma to upvote.')
-        elif not upvote and any(not post.can_downvote for post in posts):
-            raise KarmaError('Not enough karma to downvote.')
-
         Vote = self.pool['forum.post.vote']
         vote_ids = Vote.search(cr, uid, [('post_id', 'in', ids), ('user_id', '=', uid)], context=context)
         new_vote = '1' if upvote else '-1'
@@ -509,15 +510,18 @@ class Post(osv.Model):
         res_id = post.parent_id and "%s#answer-%s" % (post.parent_id.id, post.id) or post.id
         return "/forum/%s/question/%s" % (post.forum_id.id, res_id)
 
-    def _post_comment(self, cr, uid, post, body, context=None):
-        context = dict(context or {}, mail_create_nosubcribe=True)
-        if not post.can_comment:
-            raise KarmaError('Not enough karma to comment')
-        return self.message_post(cr, uid, post.id,
-                                 body=body,
-                                 type='comment',
-                                 subtype='mt_comment',
-                                 context=context)
+    @api.cr_uid_ids_context
+    def message_post(self, cr, uid, thread_id, type='notification', subtype=None, context=None, **kwargs):
+        if thread_id and type == 'comment':  # user comments have a restriction on karma
+            if isinstance(thread_id, (list, tuple)):
+                post_id = thread_id[0]
+            else:
+                post_id = thread_id
+            post = self.browse(cr, uid, post_id, context=context)
+            if not post.can_comment:
+                raise KarmaError('Not enough karma to comment')
+        return super(Post, self).message_post(cr, uid, thread_id, type=type, subtype=subtype, context=context, **kwargs)
+
 
 class PostReason(osv.Model):
     _name = "forum.post.reason"
@@ -557,6 +561,17 @@ class Vote(osv.Model):
     def create(self, cr, uid, vals, context=None):
         vote_id = super(Vote, self).create(cr, uid, vals, context=context)
         vote = self.browse(cr, uid, vote_id, context=context)
+
+        # own post check
+        if vote.user_id.id == vote.post_id.create_uid.id:
+            raise Warning('Not allowed to vote for its own post')
+        # karma check
+        if vote.vote == '1' and not vote.post_id.can_upvote:
+            raise KarmaError('Not enough karma to upvote.')
+        elif vote.vote == '-1' and not vote.post_id.can_downvote:
+            raise KarmaError('Not enough karma to downvote.')
+
+        # karma update
         if vote.post_id.parent_id:
             karma_value = self._get_karma_value('0', vote.vote, vote.forum_id.karma_gen_answer_upvote, vote.forum_id.karma_gen_answer_downvote)
         else:
@@ -567,6 +582,16 @@ class Vote(osv.Model):
     def write(self, cr, uid, ids, values, context=None):
         if 'vote' in values:
             for vote in self.browse(cr, uid, ids, context=context):
+                # own post check
+                if vote.user_id.id == vote.post_id.create_uid.id:
+                    raise Warning('Not allowed to vote for its own post')
+                # karma check
+                if (values['vote'] == '1' or vote.vote == '-1' and values['vote'] == '0') and not vote.post_id.can_upvote:
+                    raise KarmaError('Not enough karma to upvote.')
+                elif (values['vote'] == '-1' or vote.vote == '1' and values['vote'] == '0') and not vote.post_id.can_downvote:
+                    raise KarmaError('Not enough karma to downvote.')
+
+                # karma update
                 if vote.post_id.parent_id:
                     karma_value = self._get_karma_value(vote.vote, values['vote'], vote.forum_id.karma_gen_answer_upvote, vote.forum_id.karma_gen_answer_downvote)
                 else:
index fa59b81..651be98 100644 (file)
@@ -1,14 +1,22 @@
 # -*- coding: utf-8 -*-
 
+from datetime import datetime
+from urllib import urlencode
+
+import hashlib
+
+from openerp import SUPERUSER_ID
 from openerp.osv import osv, fields
 
+
 class Users(osv.Model):
     _inherit = 'res.users'
 
     def __init__(self, pool, cr):
-        init_res = super(Users, self).__init__(pool, cr) 
-        self.SELF_WRITEABLE_FIELDS = list(set(
-                self.SELF_WRITEABLE_FIELDS + \
+        init_res = super(Users, self).__init__(pool, cr)
+        self.SELF_WRITEABLE_FIELDS = list(
+            set(
+                self.SELF_WRITEABLE_FIELDS +
                 ['country_id', 'city', 'website', 'website_description', 'website_published']))
         return init_res
 
@@ -37,6 +45,50 @@ class Users(osv.Model):
         'karma': 0,
     }
 
+    def _generate_forum_token(self, cr, uid, user_id, email):
+        """Return a token for email validation. This token is valid for the day
+        and is a hash based on a (secret) uuid generated by the forum module,
+        the user_id, the email and currently the day (to be updated if necessary). """
+        forum_uuid = self.pool.get('ir.config_parameter').get_param(cr, SUPERUSER_ID, 'website_forum.uuid')
+        return hashlib.sha256('%s-%s-%s-%s' % (
+            datetime.now().replace(hour=0, minute=0, second=0, microsecond=0),
+            forum_uuid,
+            user_id,
+            email)).hexdigest()
+
+    def send_forum_validation_email(self, cr, uid, user_id, forum_id=None, context=None):
+        user = self.pool['res.users'].browse(cr, uid, user_id, context=context)
+        token = self._generate_forum_token(cr, uid, user_id, user.email)
+        activation_template_id = self.pool['ir.model.data'].xmlid_to_res_id(cr, uid, 'website_forum.validation_email')
+        if activation_template_id:
+            params = {
+                'token': token,
+                'id': user_id,
+                'email': user.email}
+            if forum_id:
+                params['forum_id'] = forum_id
+            base_url = self.pool['ir.config_parameter'].get_param(cr, uid, 'web.base.url')
+            token_url = base_url + '/forum/validate_email?%s' % urlencode(params)
+            tpl_ctx = dict(context, token_url=token_url)
+            self.pool['email.template'].send_mail(cr, SUPERUSER_ID, activation_template_id, user_id, force_send=True, context=tpl_ctx)
+        return True
+
+    def process_forum_validation_token(self, cr, uid, token, user_id, email, forum_id=None, context=None):
+        validation_token = self.pool['res.users']._generate_forum_token(cr, uid, user_id, email)
+        user = self.pool['res.users'].browse(cr, SUPERUSER_ID, user_id, context=context)
+        if token == validation_token and user.karma == 0:
+            karma = 3
+            if not forum_id:
+                forum_ids = self.pool['forum.forum'].search(cr, uid, [], limit=1, context=context)
+                if forum_ids:
+                    forum_id = forum_ids[0]
+            if forum_id:
+                forum = self.pool['forum.forum'].browse(cr, uid, forum_id, context=context)
+                # karma gained: karma to ask a question and have 2 downvotes
+                karma = forum.karma_ask + (-2 * forum.karma_gen_question_downvote)
+            return user.write({'karma': karma})
+        return False
+
     def add_karma(self, cr, uid, ids, karma, context=None):
         for user in self.browse(cr, uid, ids, context=context):
             self.write(cr, uid, [user.id], {'karma': user.karma + karma}, context=context)
index 9a00eb7..75cf37c 100644 (file)
@@ -6,8 +6,8 @@ $(document).ready(function () {
                 ev.preventDefault();
                 var $warning = $('<div class="alert alert-danger alert-dismissable oe_forum_alert" id="karma_alert">'+
                     '<button type="button" class="close notification_close" data-dismiss="alert" aria-hidden="true">&times;</button>'+
-                    karma + ' karma is required to perform this action. You can earn karma by answering questions or having '+
-                    'your answers upvoted by the community.</div>');
+                    karma + ' karma is required to perform this action. You can earn karma by having '+
+                            'your answers upvoted by the community.</div>');
                 var vote_alert = $(ev.currentTarget).parent().find("#vote_alert");
                 if (vote_alert.length == 0) {
                     $(ev.currentTarget).parent().append($warning);
@@ -50,7 +50,6 @@ $(document).ready(function () {
                         }
                     }
                 });
-            return true;
         });
 
         $('.accept_answer').not('.karma_required').on('click', function (ev) {
@@ -76,7 +75,6 @@ $(document).ready(function () {
                     }
                 }
             });
-            return true;
         });
 
         $('.favourite_question').on('click', function (ev) {
@@ -89,7 +87,6 @@ $(document).ready(function () {
                     $link.removeClass("forum_favourite_question")
                 }
             });
-            return true;
         });
 
         $('.comment_delete').on('click', function (ev) {
@@ -98,15 +95,29 @@ $(document).ready(function () {
             openerp.jsonRpc($link.data('href'), 'call', {}).then(function (data) {
                 $link.parents('.comment').first().remove();
             });
-            return true;
         });
 
         $('.notification_close').on('click', function (ev) {
             ev.preventDefault();
             var $link = $(ev.currentTarget);
             openerp.jsonRpc("/forum/notification_read", 'call', {
-                'notification_id': $link.attr("id")})
-            return true;
+                'notification_id': $link.attr("id")});
+        });
+
+        $('.send_validation_email').on('click', function (ev) {
+            ev.preventDefault();
+            var $link = $(ev.currentTarget);
+            openerp.jsonRpc("/forum/send_validation_email", 'call', {
+                'forum_id': $link.attr('forum-id'),
+            }).then(function (data) {
+                if (data) {
+                    $('button.validation_email_close').click();
+                }
+            });
+        });
+
+        $('.validated_email_close').on('click', function (ev) {
+            openerp.jsonRpc("/forum/validate_email/close", 'call', {});
         });
 
         if($('input.load_tags').length){
diff --git a/addons/website_forum/tests/__init__.py b/addons/website_forum/tests/__init__.py
new file mode 100644 (file)
index 0000000..d1c5a47
--- /dev/null
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+import common
+import test_forum
diff --git a/addons/website_forum/tests/common.py b/addons/website_forum/tests/common.py
new file mode 100644 (file)
index 0000000..324d676
--- /dev/null
@@ -0,0 +1,93 @@
+# -*- coding: utf-8 -*-
+
+from openerp.tests import common
+
+KARMA = {
+    'ask': 5, 'ans': 10,
+    'com_own': 5, 'com_all': 10,
+    'com_conv_all': 50,
+    'upv': 5, 'dwv': 10,
+    'edit_own': 10, 'edit_all': 20,
+    'close_own': 10, 'close_all': 20,
+    'unlink_own': 10, 'unlink_all': 20,
+    'gen_que_new': 1, 'gen_que_upv': 5, 'gen_que_dwv': -10,
+    'gen_ans_upv': 10, 'gen_ans_dwv': -20,
+}
+
+
+class TestForumCommon(common.TransactionCase):
+
+    def setUp(self):
+        super(TestForumCommon, self).setUp()
+
+        Forum = self.env['forum.forum']
+        Post = self.env['forum.post']
+
+        # Test users
+        TestUsersEnv = self.env['res.users'].with_context({'no_reset_password': True})
+        group_employee_id = self.ref('base.group_user')
+        group_portal_id = self.ref('base.group_portal')
+        group_public_id = self.ref('base.group_public')
+        self.user_employee = TestUsersEnv.create({
+            'name': 'Armande Employee',
+            'login': 'Armande',
+            'alias_name': 'armande',
+            'email': 'armande.employee@example.com',
+            'karma': 0,
+            'groups_id': [(6, 0, [group_employee_id])]
+        })
+        self.user_portal = TestUsersEnv.create({
+            'name': 'Beatrice Portal',
+            'login': 'Beatrice',
+            'alias_name': 'beatrice',
+            'email': 'beatrice.employee@example.com',
+            'karma': 0,
+            'groups_id': [(6, 0, [group_portal_id])]
+        })
+        self.user_public = TestUsersEnv.create({
+            'name': 'Cedric Public',
+            'login': 'Cedric',
+            'alias_name': 'cedric',
+            'email': 'cedric.employee@example.com',
+            'karma': 0,
+            'groups_id': [(6, 0, [group_public_id])]
+        })
+
+        # Test forum
+        self.forum = Forum.create({
+            'name': 'TestForum',
+            'karma_ask': KARMA['ask'],
+            'karma_answer': KARMA['ans'],
+            'karma_comment_own': KARMA['com_own'],
+            'karma_comment_all': KARMA['com_all'],
+            'karma_answer_accept_own': 9999,
+            'karma_answer_accept_all': 9999,
+            'karma_upvote': KARMA['upv'],
+            'karma_downvote': KARMA['dwv'],
+            'karma_edit_own': KARMA['edit_own'],
+            'karma_edit_all': KARMA['edit_all'],
+            'karma_close_own': KARMA['close_own'],
+            'karma_close_all': KARMA['close_all'],
+            'karma_unlink_own': KARMA['unlink_own'],
+            'karma_unlink_all': KARMA['unlink_all'],
+            'karma_comment_convert_all': KARMA['com_conv_all'],
+            'karma_gen_question_new': KARMA['gen_que_new'],
+            'karma_gen_question_upvote': KARMA['gen_que_upv'],
+            'karma_gen_question_downvote': KARMA['gen_que_dwv'],
+            'karma_gen_answer_upvote': KARMA['gen_ans_upv'],
+            'karma_gen_answer_downvote': KARMA['gen_ans_dwv'],
+            'karma_gen_answer_accept': 9999,
+            'karma_gen_answer_accepted': 9999,
+        })
+        self.post = Post.create({
+            'name': 'TestQuestion',
+            'content': 'I am not a bird.',
+            'forum_id': self.forum.id,
+            'tag_ids': [(0, 0, {'name': 'Tag0', 'forum_id': self.forum.id})]
+        })
+        self.answer = Post.create({
+            'name': 'TestAnswer',
+            'content': 'I am an anteater.',
+            'forum_id': self.forum.id,
+            'parent_id': self.post.id,
+        })
diff --git a/addons/website_forum/tests/test_forum.py b/addons/website_forum/tests/test_forum.py
new file mode 100644 (file)
index 0000000..7ca4c2a
--- /dev/null
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+
+from openerp.addons.website_forum.tests.common import KARMA, TestForumCommon
+from openerp.addons.website_forum.models.forum import KarmaError
+from openerp.exceptions import Warning, AccessError
+from openerp.tools import mute_logger
+
+
+class TestForum(TestForumCommon):
+
+    @mute_logger('openerp.addons.base.ir.ir_model', 'openerp.models')
+    def test_ask(self):
+        Post = self.env['forum.post']
+
+        # Public user asks a question: not allowed
+        with self.assertRaises(AccessError):
+            Post.sudo(self.user_public).create({
+                'name': " Question ?",
+                'forum_id': self.forum.id,
+            })
+
+        # Portal user asks a question with tags: not allowed, unsufficient karma
+        with self.assertRaises(KarmaError):
+            Post.sudo(self.user_portal).create({
+                'name': " Q_0",
+                'forum_id': self.forum.id,
+                'tag_ids': [(0, 0, {'name': 'Tag0', 'forum_id': self.forum.id})]
+            })
+
+        # Portal user asks a question with tags: ok if enough karma
+        self.user_portal.karma = KARMA['ask']
+        Post.sudo(self.user_portal).create({
+            'name': " Q0",
+            'forum_id': self.forum.id,
+            'tag_ids': [(0, 0, {'name': 'Tag0', 'forum_id': self.forum.id})]
+        })
+        self.assertEqual(self.user_portal.karma, KARMA['ask'] + KARMA['gen_que_new'], 'website_forum: wrong karma generation when asking question')
+
+    @mute_logger('openerp.addons.base.ir.ir_model', 'openerp.models')
+    def test_answer(self):
+        Post = self.env['forum.post']
+
+        # Answers its own question: not allowed, unsufficient karma
+        with self.assertRaises(KarmaError):
+            Post.sudo(self.user_employee).create({
+                'name': " A0",
+                'forum_id': self.forum.id,
+                'parent_id': self.post.id,
+            })
+
+        # Answers on question: ok if enough karma
+        self.user_employee.karma = KARMA['ans']
+        Post.sudo(self.user_employee).create({
+            'name': " A0",
+            'forum_id': self.forum.id,
+            'parent_id': self.post.id,
+        })
+        self.assertEqual(self.user_employee.karma, KARMA['ans'], 'website_forum: wrong karma generation when answering question')
+
+    @mute_logger('openerp.addons.base.ir.ir_model', 'openerp.models')
+    def test_vote_crash(self):
+        Post = self.env['forum.post']
+        self.user_employee.karma = KARMA['ans']
+        emp_answer = Post.sudo(self.user_employee).create({
+            'name': 'TestAnswer',
+            'forum_id': self.forum.id,
+            'parent_id': self.post.id})
+
+        # upvote its own post
+        with self.assertRaises(Warning):
+            emp_answer.vote(upvote=True)
+
+        # not enough karma
+        with self.assertRaises(KarmaError):
+            self.post.sudo(self.user_portal).vote(upvote=True)
+
+    def test_vote(self):
+        self.post.create_uid.karma = KARMA['ask']
+        self.user_portal.karma = KARMA['upv']
+        self.post.sudo(self.user_portal).vote(upvote=True)
+        self.assertEqual(self.post.create_uid.karma, KARMA['ask'] + KARMA['gen_que_upv'], 'website_forum: wrong karma generation of upvoted question author')
+
+    @mute_logger('openerp.addons.base.ir.ir_model', 'openerp.models')
+    def test_downvote_crash(self):
+        Post = self.env['forum.post']
+        self.user_employee.karma = KARMA['ans']
+        emp_answer = Post.sudo(self.user_employee).create({
+            'name': 'TestAnswer',
+            'forum_id': self.forum.id,
+            'parent_id': self.post.id})
+
+        # downvote its own post
+        with self.assertRaises(Warning):
+            emp_answer.vote(upvote=False)
+
+        # not enough karma
+        with self.assertRaises(KarmaError):
+            self.post.sudo(self.user_portal).vote(upvote=False)
+
+    def test_downvote(self):
+        self.post.create_uid.karma = 50
+        self.user_portal.karma = KARMA['dwv']
+        self.post.sudo(self.user_portal).vote(upvote=False)
+        self.assertEqual(self.post.create_uid.karma, 50 + KARMA['gen_que_dwv'], 'website_forum: wrong karma generation of downvoted question author')
+
+    def test_comment_crash(self):
+        with self.assertRaises(KarmaError):
+            self.post.sudo(self.user_portal).message_post(body='Should crash', type='comment')
+
+    def test_comment(self):
+        self.post.sudo(self.user_employee).message_post(body='Test0', type='notification')
+        self.user_employee.karma = KARMA['com_all']
+        self.post.sudo(self.user_employee).message_post(body='Test1', type='comment')
+        self.assertEqual(len(self.post.message_ids), 4, 'website_forum: wrong behavior of message_post')
+
+    def test_convert_answer_to_comment_crash(self):
+        Post = self.env['forum.post']
+
+        # converting a question does nothing
+        msg_ids = self.post.sudo(self.user_portal).convert_answer_to_comment()
+        self.assertEqual(msg_ids[0], False, 'website_forum: question to comment conversion failed')
+        self.assertEqual(Post.search([('name', '=', 'TestQuestion')])[0].forum_id.name, 'TestForum', 'website_forum: question to comment conversion failed')
+
+        with self.assertRaises(KarmaError):
+            self.answer.sudo(self.user_portal).convert_answer_to_comment()
+
+    def test_convert_answer_to_comment(self):
+        self.user_portal.karma = KARMA['com_conv_all']
+        post_author = self.answer.create_uid.partner_id
+        msg_ids = self.answer.sudo(self.user_portal).convert_answer_to_comment()
+        self.assertEqual(len(msg_ids), 1, 'website_forum: wrong answer to comment conversion')
+        msg = self.env['mail.message'].browse(msg_ids[0])
+        self.assertEqual(msg.author_id, post_author, 'website_forum: wrong answer to comment conversion')
+        self.assertIn('I am an anteater', msg.body, 'website_forum: wrong answer to comment conversion')
+
+    def test_edit_post_crash(self):
+        with self.assertRaises(KarmaError):
+            self.post.sudo(self.user_portal).write({'name': 'I am not your father.'})
+
+    def test_edit_post(self):
+        self.post.create_uid.karma = KARMA['edit_own']
+        self.post.write({'name': 'Actually I am your dog.'})
+        self.user_portal.karma = KARMA['edit_all']
+        self.post.sudo(self.user_portal).write({'name': 'Actually I am your cat.'})
+
+    def test_close_post_crash(self):
+        with self.assertRaises(KarmaError):
+            self.post.sudo(self.user_portal).close(None)
+
+    def test_close_post_own(self):
+        self.post.create_uid.karma = KARMA['close_own']
+        self.post.close(None)
+
+    def test_close_post_all(self):
+        self.user_portal.karma = KARMA['close_all']
+        self.post.sudo(self.user_portal).close(None)
+
+    def test_deactivate_post_crash(self):
+        with self.assertRaises(KarmaError):
+            self.post.sudo(self.user_portal).write({'active': False})
+
+    def test_deactivate_post_own(self):
+        self.post.create_uid.karma = KARMA['unlink_own']
+        self.post.write({'active': False})
+
+    def test_deactivate_post_all(self):
+        self.user_portal.karma = KARMA['unlink_all']
+        self.post.sudo(self.user_portal).write({'active': False})
+
+    def test_unlink_post_crash(self):
+        with self.assertRaises(KarmaError):
+            self.post.sudo(self.user_portal).unlink()
+
+    def test_unlink_post_own(self):
+        self.post.create_uid.karma = KARMA['unlink_own']
+        self.post.unlink()
+
+    def test_unlink_post_all(self):
+        self.user_portal.karma = KARMA['unlink_all']
+        self.post.sudo(self.user_portal).unlink()
index a2d7c09..d0bb3e1 100644 (file)
                     <sheet>
                         <group>
                             <field name="name"/>
-                            <field name="karma_ask"/>
-                            <field name="karma_edit_own"/>
-                            <field name="karma_edit_all"/>
-                            <field name="karma_close_own"/>
-                            <field name="karma_close_all"/>
-                            <field name="karma_unlink_own"/>
-                            <field name="karma_unlink_all"/>
-                        </group>
-                        <group>
-                            <field name="karma_upvote"/>
-                            <field name="karma_downvote"/>
-                            <field name="karma_answer_accept_own"/>
-                            <field name="karma_answer_accept_all"/>
-                            <field name="karma_editor_link_files"/>
-                            <field name="karma_editor_clickable_link"/>
-                            <field name="karma_comment_own"/>
-                            <field name="karma_comment_all"/>
-                            <field name="karma_comment_convert_own"/>
-                            <field name="karma_comment_convert_all"/>
-                            <field name="karma_comment_unlink_own"/>
-                            <field name="karma_comment_unlink_all"/>
                         </group>
+                        <notebook>
+                            <page string='Karma Gains'>
+                                <group>
+                                    <field name="karma_gen_question_new"/>
+                                    <field name="karma_gen_question_upvote"/>
+                                    <field name="karma_gen_question_downvote"/>
+                                    <field name="karma_gen_answer_upvote"/>
+                                    <field name="karma_gen_answer_downvote"/>
+                                    <field name="karma_gen_answer_accept"/>
+                                    <field name="karma_gen_answer_accepted"/>
+                                </group>
+                            </page>
+                            <page string='Karma Requirements'>
+                                <group>
+                                    <group>
+                                        <field name="karma_ask"/>
+                                        <field name="karma_upvote"/>
+                                        <field name="karma_downvote"/>
+                                        <field name="karma_edit_own"/>
+                                        <field name="karma_edit_all"/>
+                                        <field name="karma_close_own"/>
+                                        <field name="karma_close_all"/>
+                                        <field name="karma_unlink_own"/>
+                                        <field name="karma_unlink_all"/>
+                                    </group>
+                                    <group>
+                                        <field name="karma_answer_accept_own"/>
+                                        <field name="karma_answer_accept_all"/>
+                                        <field name="karma_comment_own"/>
+                                        <field name="karma_comment_all"/>
+                                        <field name="karma_comment_convert_own"/>
+                                        <field name="karma_comment_convert_all"/>
+                                        <field name="karma_comment_unlink_own"/>
+                                        <field name="karma_comment_unlink_all"/>
+                                    </group>
+                                </group>
+                            </page>
+                        </notebook>
                     </sheet>
                     <div class="oe_chatter">
                         <field name="message_follower_ids" widget="mail_followers" groups="base.group_user"/>
index f4f5591..565d348 100644 (file)
                         <div t-field="notification.body"/>
                         <a t-attf-href="/forum/#{ slug(forum) }/user/#{ user.id }#badges" class="fa fa-arrow-right">View Your Badges</a>
                     </div>
+                    <div t-if="not validation_email_sent and not is_public_user and user.karma == 0" class="alert alert-danger alert-dismissable">
+                        <button type="button" class="close validation_email_close" data-dismiss="alert" aria-hidden="true">&amp;times;</button>
+                        <div>
+                            <p>
+                                It appears your email has not been verified.
+                                <a class="send_validation_email" href="#" t-att-forum-id="forum.id">Click here to send a verification email allowing you to participate to the forum.</a>
+                            </p>
+                        </div>
+                    </div>
+                    <div t-if="validation_email_done" class="alert alert-success alert-dismissable">
+                        <button type="button" class="close validated_email_close" data-dismiss="alert" aria-hidden="true">&amp;times;</button>
+                        <div>
+                            <p>Congratulations! Your email has just been validated. You may now participate to our forums.</p>
+                        </div>
+                    </div>
                     <t t-raw="0"/>
                 </div>
                 <div class="col-sm-3" id="right-column">