diff --git a/addons/website_forum/controllers/website_forum.py b/addons/website_forum/controllers/website_forum.py index 20c01b85043..3715aee917e 100644 --- a/addons/website_forum/controllers/website_forum.py +++ b/addons/website_forum/controllers/website_forum.py @@ -1,25 +1,23 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import json +import logging +from datetime import datetime + import lxml import requests -import logging import werkzeug.exceptions import werkzeug.urls import werkzeug.wrappers -from datetime import datetime - -from odoo import http, tools, _ -from odoo.exceptions import AccessError +from odoo import _, http, tools from odoo.addons.http_routing.models.ir_http import slug +from odoo.addons.portal.controllers.portal import _build_url_w_params from odoo.addons.website.models.ir_http import sitemap_qs2dom from odoo.addons.website_profile.controllers.main import WebsiteProfile -from odoo.addons.portal.controllers.portal import _build_url_w_params - -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from odoo.http import request - +from odoo.osv import expression _logger = logging.getLogger(__name__) @@ -167,6 +165,9 @@ class WebsiteForum(WebsiteProfile): values = self._prepare_user_values(forum=forum, header={'is_guidelines': True, 'is_karma': True}, **post) return request.render("website_forum.faq_karma", values) + # Tags + # -------------------------------------------------- + @http.route('/forum/get_tags', type='http', auth="public", methods=['GET'], website=True, sitemap=False) def tag_read(self, forum_id, query='', limit=25, **post): data = request.env['forum.tag'].search_read( @@ -179,31 +180,63 @@ class WebsiteForum(WebsiteProfile): headers=[("Content-Type", "application/json")] ) - @http.route(['/forum//tag', '/forum//tag/'], type='http', auth="public", website=True, sitemap=False) - def tags(self, forum, tag_char=None, **post): - # build the list of tag first char, with their value as tag_char param Ex : [('All', 'all'), ('C', 'c'), ('G', 'g'), ('Z', z)] - first_char_tag = forum.get_tags_first_char() - first_char_list = [(t, t.lower()) for t in first_char_tag if t.isalnum()] - first_char_list.insert(0, (_('All'), 'all')) + @http.route(['/forum//tag', + '/forum//tag/', + ], type='http', auth="public", website=True, sitemap=False) + def tags(self, forum, tag_char='', filters='all', search='', **post): + """Render a list of tags matching filters and search parameters. - active_char_tag = tag_char and tag_char.lower() or 'all' + :param forum: Forum + :param string tag_char: Only tags starting with a single character `tag_char` + :param filters: One of 'all'|'followed'|'most_used'|'unused'. + Can be combined with `search` and `tag_char`. + :param string search: Search query using "forum_tags_only" `search_type` + :param dict post: additional options passed to `_prepare_user_values` + """ + if not isinstance(tag_char, str) or len(tag_char) > 1 or (tag_char and not tag_char.isalpha()): + # So that further development does not miss this. Users shouldn't see it with normal usage. + raise werkzeug.exceptions.BadRequest(_('Bad "tag_char" value "%(tag_char)s"', tag_char=tag_char)) - # generate domain for searched tags - domain = [('forum_id', '=', forum.id), ('posts_count', '>', 0)] - order_by = 'name' - if active_char_tag and active_char_tag != 'all': - domain.append(('name', '=ilike', tools.escape_psql(active_char_tag) + '%')) - order_by = 'posts_count DESC' - tags = request.env['forum.tag'].search(domain, limit=None, order=order_by) - # prepare values and render template + domain = [('forum_id', '=', forum.id), ('posts_count', '=' if filters == "unused" else '>', 0)] + if filters == 'followed' and not request.env.user._is_public(): + domain = expression.AND([domain, [('message_is_follower', '=', True)]]) + + # Build tags result without using tag_char to build pager, then return tags matching it values = self._prepare_user_values(forum=forum, searches={'tags': True}, **post) + tags = request.env["forum.tag"] + + order = 'posts_count DESC' if tag_char else 'name' + + if search: + values.update(search=search) + search_domain = domain if filters in ('all', 'followed') else None + __, details, __ = request.website._search_with_fuzzy( + 'forum_tags_only', search, limit=None, order=order, options={'forum': forum, 'domain': search_domain}, + ) + tags = details[0].get('results', tags) + + if filters in ('unused', 'most_used'): + filter_tags = forum.tag_most_used_ids if filters == 'most_used' else forum.tag_unused_ids + tags = tags & filter_tags if tags else filter_tags + elif filters in ('all', 'followed'): + if not search: + tags = request.env['forum.tag'].search(domain, limit=None, order=order) + else: + raise werkzeug.exceptions.BadRequest(_('Bad "filters" value "%(filters)s".', filters=filters)) + + first_char_tag = forum._get_tags_first_char(tags=tags) + first_char_list = [(t, t.lower()) for t in first_char_tag if t.isalnum()] + first_char_list.insert(0, (_('All'), '')) + if tag_char: + tags = tags.filtered(lambda t: t.name.startswith((tag_char.lower(), tag_char.upper()))) values.update({ - 'tags': tags, + 'active_char_tag': tag_char.lower(), 'pager_tag_chars': first_char_list, - 'active_char_tag': active_char_tag, + 'search_count': len(tags) if search else None, + 'tags': tags, }) - return request.render("website_forum.tag", values) + return request.render("website_forum.forum_index_tags", values) # Questions # -------------------------------------------------- diff --git a/addons/website_forum/models/forum_forum.py b/addons/website_forum/models/forum_forum.py index f1306d2d8ba..d65f27a2732 100644 --- a/addons/website_forum/models/forum_forum.py +++ b/addons/website_forum/models/forum_forum.py @@ -2,12 +2,16 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import textwrap +from collections import defaultdict +from operator import itemgetter from markupsafe import Markup from odoo import _, api, fields, models -from odoo.tools.translate import html_translate from odoo.addons.http_routing.models.ir_http import slug +from odoo.tools.translate import html_translate + +MOST_USED_TAGS_COUNT = 5 # Number of tags to track as "most used" to display on frontend class Forum(models.Model): @@ -20,7 +24,7 @@ class Forum(models.Model): 'website.multi.mixin', 'website.searchable.mixin', ] - _order = "sequence" + _order = "sequence, id" def _get_default_welcome_message(self): return Markup(""" @@ -136,6 +140,11 @@ class Forum(models.Model): has_pending_post = fields.Boolean(string='Has pending post', compute='_compute_has_pending_post') can_moderate = fields.Boolean(string="Is a moderator", compute="_compute_can_moderate") + # tags + tag_ids = fields.One2many('forum.tag', 'forum_id', string='Tags') + tag_most_used_ids = fields.One2many('forum.tag', string="Most used tags", compute='_compute_tag_ids_usage') + tag_unused_ids = fields.One2many('forum.tag', string="Unused tags", compute='_compute_tag_ids_usage') + @api.depends_context('uid') def _compute_has_pending_post(self): domain = [ @@ -156,6 +165,35 @@ class Forum(models.Model): for forum in self: forum.can_moderate = self.env.user.karma >= forum.karma_moderate + @api.depends('post_ids', 'post_ids.tag_ids', 'post_ids.tag_ids.posts_count', 'tag_ids') + def _compute_tag_ids_usage(self): + forums_without_tags = self.filtered(lambda f: not f.tag_ids) + forums_without_tags.tag_most_used_ids = forums_without_tags.tag_unused_ids = False + forums_with_tags = self - forums_without_tags + if not forums_with_tags: + return + + tags_data = self.env['forum.tag'].search_read( + [('forum_id', 'in', forums_with_tags.ids)], + fields=['id', 'forum_id', 'posts_count'], + order='forum_id, posts_count DESC, name, id', + ) + current_forum_id = tags_data[0]['forum_id'][0] + forum_tags = defaultdict(lambda: {'most_used_ids': [], 'unused_ids': []}) + + for tag_data in tags_data: + tag_id, tag_forum_id, posts_count = itemgetter('id', 'forum_id', 'posts_count')(tag_data) + if tag_forum_id[0] != current_forum_id: + current_forum_id = tag_forum_id[0] + if not posts_count: # Could be 0 or None + forum_tags[current_forum_id]['unused_ids'].append(tag_id) + elif len(forum_tags[current_forum_id]['most_used_ids']) < MOST_USED_TAGS_COUNT: + forum_tags[current_forum_id]['most_used_ids'].append(tag_id) + + for forum in forums_with_tags: + forum.tag_most_used_ids = self.env['forum.tag'].browse(forum_tags[forum.id]['most_used_ids']) + forum.tag_unused_ids = self.env['forum.tag'].browse(forum_tags[forum.id]['unused_ids']) + @api.depends('description') def _compute_teaser(self): for forum in self: @@ -276,10 +314,13 @@ class Forum(models.Model): post_tags.insert(0, [6, 0, existing_keep]) return post_tags - def get_tags_first_char(self): - """ get set of first letter of forum tags """ - tags = self.env['forum.tag'].search([('forum_id', '=', self.id), ('posts_count', '>', 0)]) - return sorted(set([tag.name[0].upper() for tag in tags if len(tag.name)])) + def _get_tags_first_char(self, tags=None): + """Get set of first letter of forum tags. + + :param tags: tags recordset to further filter forum's tags that are also in these tags. + """ + tag_ids = self.tag_ids if tags is None else (self.tag_ids & tags) + return sorted({tag.name[0].upper() for tag in tag_ids if len(tag.name)}) # ---------------------------------------------------------------------- # WEBSITE diff --git a/addons/website_forum/models/forum_tag.py b/addons/website_forum/models/forum_tag.py index 8806e60639f..677c830e1cc 100644 --- a/addons/website_forum/models/forum_tag.py +++ b/addons/website_forum/models/forum_tag.py @@ -2,21 +2,26 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ +from odoo.addons.http_routing.models.ir_http import slug, unslug from odoo.exceptions import AccessError class Tags(models.Model): _name = "forum.tag" _description = "Forum Tag" - _inherit = ['mail.thread', 'website.seo.metadata'] + _inherit = [ + 'mail.thread', + 'website.searchable.mixin', + 'website.seo.metadata', + ] name = fields.Char('Name', required=True) - forum_id = fields.Many2one('forum.forum', string='Forum', required=True) + forum_id = fields.Many2one('forum.forum', string='Forum', required=True, index=True) post_ids = fields.Many2many( 'forum.post', 'forum_tag_rel', 'forum_tag_id', 'forum_id', string='Posts', domain=[('state', '=', 'active')]) posts_count = fields.Integer('Number of Posts', compute='_compute_posts_count', store=True) - + website_url = fields.Char("Link to questions with the tag", compute='_compute_website_url') _sql_constraints = [ ('name_uniq', 'unique (name, forum_id)', "Tag name already exists!"), ] @@ -26,6 +31,11 @@ class Tags(models.Model): for tag in self: tag.posts_count = len(tag.post_ids) # state filter is in field domain + @api.depends("forum_id", "forum_id.name", "name") + def _compute_website_url(self): + for tag in self: + tag.website_url = f'/forum/{slug(tag.forum_id)}/tag/{slug(tag)}/questions' + @api.model_create_multi def create(self, vals_list): for vals in vals_list: @@ -33,3 +43,30 @@ class Tags(models.Model): if self.env.user.karma < forum.karma_tag_create and not self.env.is_admin(): raise AccessError(_('%d karma required to create a new Tag.', forum.karma_tag_create)) return super(Tags, self.with_context(mail_create_nolog=True, mail_create_nosubscribe=True)).create(vals_list) + + # ---------------------------------------------------------------------- + # WEBSITE + # ---------------------------------------------------------------------- + + @api.model + def _search_get_detail(self, website, order, options): + search_fields = ['name'] + fetch_fields = ['id', 'name', 'website_url'] + mapping = { + 'name': {'name': 'name', 'type': 'text', 'match': True}, + 'website_url': {'name': 'website_url', 'type': 'text', 'truncate': False}, + } + base_domain = [] + if forum := options.get("forum"): + forum_ids = (unslug(forum)[1],) if isinstance(forum, str) else forum.ids + search_domain = options.get("domain") + base_domain = [search_domain if search_domain is not None else [('forum_id', 'in', forum_ids)]] + return { + 'model': 'forum.tag', + 'base_domain': base_domain, + 'search_fields': search_fields, + 'fetch_fields': fetch_fields, + 'mapping': mapping, + 'icon': 'fa-tag', + 'order': ','.join(filter(lambda f: 'is_published' not in f, order.split(','))), + } diff --git a/addons/website_forum/models/website.py b/addons/website_forum/models/website.py index 106c0021898..8d193db4d55 100644 --- a/addons/website_forum/models/website.py +++ b/addons/website_forum/models/website.py @@ -38,6 +38,8 @@ class Website(models.Model): result.append(self.env['forum.forum']._search_get_detail(self, order, options)) if search_type in ['forums', 'forum_posts_only', 'all']: result.append(self.env['forum.post']._search_get_detail(self, order, options)) + if search_type in ['forums', 'forum_tags_only', 'all']: + result.append(self.env['forum.tag']._search_get_detail(self, order, options)) return result def _update_forum_count(self): diff --git a/addons/website_forum/tests/common.py b/addons/website_forum/tests/common.py index 5f6c8f7ee31..a53fba76075 100644 --- a/addons/website_forum/tests/common.py +++ b/addons/website_forum/tests/common.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.tests import common +from odoo.tools import mute_logger KARMA = { 'ask': 5, 'ans': 10, @@ -104,3 +105,14 @@ class TestForumCommon(common.TransactionCase): cls.website_2 = cls.env['website'].create({ 'name': 'Second Website on same company', }) + + @mute_logger("odoo.models.unlink") + def _activate_tags_for_counts(self): + self.env['forum.tag'].search([]).unlink() + self.tags = self.env['forum.tag'].create( + [ + {'forum_id': forum_id.id, 'name': f'Test Tag {tag_idx}'} + for forum_id in self.forum | self.base_forum + for tag_idx in range(1, 8) + ] + ) diff --git a/addons/website_forum/tests/test_forum_internals.py b/addons/website_forum/tests/test_forum_internals.py index 88384c90454..3a4575af2a2 100644 --- a/addons/website_forum/tests/test_forum_internals.py +++ b/addons/website_forum/tests/test_forum_internals.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.website_forum.models.forum_forum import MOST_USED_TAGS_COUNT from odoo.addons.website_forum.tests.common import KARMA, TestForumCommon from odoo.tests import tagged, users @@ -107,3 +108,40 @@ class TestTags(TestForumCommon): self.assertEqual(len(food_tags), 2, "One Food tag should have been created in each forum.") self.assertIn(forum_1, food_tags.forum_id, "One Food tag should have been created for forum 1.") self.assertIn(forum_2, food_tags.forum_id, "One Food tag should have been created for forum 2.") + + def test_tags_usage_most_used(self): + self._activate_tags_for_counts() + posts_per_tag = [(tag_id, post_count) for tag_id, post_count in zip(self.base_forum.tag_ids, range(2, 9))] + vals_list = [ + {'forum_id': self.base_forum.id, 'name': 'A post', 'content': 'A content', 'tag_ids': [tag_id.id]} + for tag_id, post_count in posts_per_tag + for __ in range(post_count) + ] + self.env['forum.post'].create(vals_list) + self.env['forum.tag'].flush_model() + + self.assertListEqual( + self.base_forum.tag_most_used_ids.ids, + [tag_id.id for tag_id, _ in reversed(posts_per_tag[-MOST_USED_TAGS_COUNT:])], + ) + self.assertEqual(self.base_forum.tag_unused_ids, self.env['forum.tag']) + + def test_tags_usage_unused(self): + self._activate_tags_for_counts() + used_tag = self.forum.tag_ids[5] + self.env['forum.post'].create( + [ + {'forum_id': tag_id.forum_id.id, 'tag_ids': [tag_id.id], 'name': 'A post', 'content': 'A content'} + for tag_id in (used_tag | self.base_forum.tag_ids) + ] + ) + self.env['forum.tag'].flush_model() + + # trigger batch compute + __ = (self.forum | self.base_forum).tag_most_used_ids + + self.assertEqual(self.forum.tag_most_used_ids, used_tag) + self.assertEqual(self.forum.tag_unused_ids, self.forum.tag_ids - used_tag) + + self.assertEqual(self.base_forum.tag_most_used_ids, self.base_forum.tag_ids[:MOST_USED_TAGS_COUNT]) + self.assertEqual(self.base_forum.tag_unused_ids, self.env['forum.tag']) diff --git a/addons/website_forum/tests/test_forum_tag.py b/addons/website_forum/tests/test_forum_tag.py index 41d1c40a5a7..11d5849cdc3 100644 --- a/addons/website_forum/tests/test_forum_tag.py +++ b/addons/website_forum/tests/test_forum_tag.py @@ -1,35 +1,27 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import Command from odoo.addons.website_forum.tests.common import TestForumCommon class TestForumTag(TestForumCommon): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.env['forum.tag'].search([]).unlink() - cls.tags = cls.env['forum.tag'].create( - [{'forum_id': cls.forum.id, 'name': f'Test Tag {tag_idx}'} for tag_idx in range(1, 3)] - ) - cls.env['forum.post'].create( - [{'name': 'Posting about tag 1', 'forum_id': cls.forum.id, 'tag_ids': [[6, 0, [cls.tags[0].id]]]}] - ) - def _check_tags_post_counts(self, tags, expected_counts): self.assertEqual(tags.mapped('posts_count'), expected_counts) def test_tag_posts_count(self): - self._check_tags_post_counts(self.tags, [1, 0]) + self._activate_tags_for_counts() + test_tags = self.tags[:2] + self._check_tags_post_counts(test_tags, [0, 0]) post_tag_1 = self.env['forum.post'].create( - [{'name': 'Posting about tag 1 again', 'forum_id': self.forum.id, 'tag_ids': [[6, 0, [self.tags[0].id]]]}] + [{'name': 'Posting about tag 1', 'forum_id': self.forum.id, 'tag_ids': [Command.set([test_tags[0].id])]}] ) - self._check_tags_post_counts(self.tags, [2, 0]) + self._check_tags_post_counts(test_tags, [1, 0]) post_tags = self.env['forum.post'].create( - [{'name': 'Posting about tag 2 now', 'forum_id': self.forum.id, 'tag_ids': [[6, 0, self.tags.ids]]}] + [{'name': 'Posting with both tags now', 'forum_id': self.forum.id, 'tag_ids': [Command.set(test_tags.ids)]}] ) - self._check_tags_post_counts(self.tags, [3, 1]) + self._check_tags_post_counts(test_tags, [2, 1]) post_tag_1.active = False - self._check_tags_post_counts(self.tags, [2, 1]) + self._check_tags_post_counts(test_tags, [1, 1]) post_tags.close(None) - self._check_tags_post_counts(self.tags, [1, 0]) + self._check_tags_post_counts(test_tags, [0, 0]) diff --git a/addons/website_forum/views/forum_forum_templates.xml b/addons/website_forum/views/forum_forum_templates.xml index 0f09913d908..910ad23d7fb 100644 --- a/addons/website_forum/views/forum_forum_templates.xml +++ b/addons/website_forum/views/forum_forum_templates.xml @@ -43,7 +43,10 @@ - + + posts + + @@ -189,9 +192,12 @@ @@ -200,7 +206,9 @@ @@ -224,15 +232,17 @@ - + + + tags - - There are no tags matching the selected filter. + + There are no tags matching the selected filter and search. - + There are no tags matching the selected search. - + There are no tags being used in this forum. diff --git a/addons/website_forum/views/forum_forum_templates_layout.xml b/addons/website_forum/views/forum_forum_templates_layout.xml index 0a7385ca867..28588ae1f83 100644 --- a/addons/website_forum/views/forum_forum_templates_layout.xml +++ b/addons/website_forum/views/forum_forum_templates_layout.xml @@ -153,12 +153,16 @@ - - - - - - + + + + + + + + + + @@ -274,7 +278,7 @@ @@ -350,33 +353,28 @@