From 6139011d05335b9e755551e45788a9fdc48d4426 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 3 Oct 2016 15:05:04 +0200 Subject: [PATCH 1/8] Update main README --- README.md | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 095b531d..58af3ba1 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,38 @@ CMS Features for Odoo Website ============================= -This repository includes advanced CMS features for the Odoo website builder: +Includes modules that add advanced CMS functionalities to Odoo. + +Main features +------------- + +* Separation of concerns between ``page`` (content) and ``view`` (presentation) +* Page types (simple, news, you-own-type) +* Reusable views (create a CMS view and reuse it on your CMS pages) +* Publish media and categorize it +* Automatic navigation and site hierarchy +* Meaningful URLs (read: "speaking URLs") +* Manage redirects within page backend +* Protect your content (set permissions on each page rather than view) +* Full text search + +Roadmap +------- + +* Improve frontend forms (manage media, reuse backend forms) +* Independent translations branches (skip odoo translation mechanism on demand via configuration) +* Edit permissions control / sharing facilities / notifications, etc +* Simplified interface for managing users and groups +* Publication workflow management +* Content history / versioning +* Full text search using PG built-in search engine (see fts modules) +* Shorter URLs (drop path ids for instance) +* Performance fixes/tuning (use parent_left/right for instance) +* Introduce portlets for sidebar elements +* Add "collections" to fetch contents from the whole website (eg: "News from 2016") +* Improve test coverage +* Default theme -* Different content for translations [//]: # (addons) This part will be replaced when running the oca-gen-addons-table script from OCA/maintainer-tools. From 2f44050910561df463b52e8c55ee6dfeff3f64f9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 3 Oct 2016 15:05:33 +0200 Subject: [PATCH 2/8] [add] website_cms + website_cms_search --- website_cms/.gitignore | 5 + website_cms/AUTHORS | 1 + website_cms/CHANGES.rst | 8 + website_cms/README.rst | 113 +++ website_cms/ROADMAP.rst | 3 + website_cms/__init__.py | 6 + website_cms/__openerp__.py | 45 ++ website_cms/controllers/__init__.py | 2 + website_cms/controllers/form.py | 262 +++++++ website_cms/controllers/main.py | 155 ++++ website_cms/data/form_settings.xml | 18 + website_cms/data/media_categories.xml | 53 ++ website_cms/data/page_types.xml | 18 + website_cms/docs/Makefile | 225 ++++++ website_cms/docs/conf.py | 380 ++++++++++ website_cms/docs/guides/advanced_listing.rst | 7 + website_cms/docs/guides/code_overview.rst | 265 +++++++ website_cms/docs/guides/navigation.rst | 84 +++ website_cms/docs/guides/permissions.rst | 7 + website_cms/docs/guides/redirects.rst | 7 + website_cms/docs/guides/routing.rst | 7 + website_cms/docs/guides/status_msg.png | Bin 0 -> 3402 bytes website_cms/docs/guides/template_basics.rst | 209 ++++++ website_cms/docs/guides/usage.rst | 5 + website_cms/docs/index.rst | 31 + website_cms/docs/requirements.txt | 4 + .../9.0.1.0.1/post-update_cms_page_table.py | 19 + .../9.0.1.0.2/post-update_old_pages_path.py | 25 + website_cms/models/__init__.py | 14 + website_cms/models/cms_media.py | 313 +++++++++ website_cms/models/cms_page.py | 660 ++++++++++++++++++ website_cms/models/cms_tag.py | 23 + website_cms/models/ir_attachment.py | 92 +++ website_cms/models/ir_ui_view.py | 44 ++ website_cms/models/website.py | 244 +++++++ website_cms/models/website_menu.py | 18 + website_cms/models/website_mixins.py | 179 +++++ website_cms/models/website_redirect_mixin.py | 134 ++++ website_cms/models/website_security_mixin.py | 232 ++++++ website_cms/security/groups.xml | 24 + website_cms/security/ir.model.access.csv | 13 + website_cms/static/src/js/website_cms.js | 68 ++ website_cms/templates/assets.xml | 16 + website_cms/templates/form.xml | 134 ++++ website_cms/templates/layout.xml | 35 + website_cms/templates/menu.xml | 48 ++ website_cms/templates/misc.xml | 165 +++++ website_cms/templates/page.xml | 107 +++ website_cms/templates/sidebar.xml | 74 ++ website_cms/tests/__init__.py | 2 + website_cms/tests/test_media.py | 89 +++ website_cms/tests/test_page.py | 342 +++++++++ website_cms/utils.py | 54 ++ website_cms/views/cms_media.xml | 119 ++++ website_cms/views/cms_media_category.xml | 57 ++ website_cms/views/cms_page.xml | 207 ++++++ website_cms/views/cms_redirect.xml | 62 ++ website_cms/views/menuitems.xml | 107 +++ website_cms/views/website_menu.xml | 31 + website_cms_search/.gitignore | 5 + website_cms_search/README.rst | 6 + website_cms_search/__init__.py | 5 + website_cms_search/__openerp__.py | 21 + website_cms_search/controllers/__init__.py | 1 + website_cms_search/controllers/main.py | 93 +++ website_cms_search/templates/search.xml | 70 ++ website_cms_search/tests/__init__.py | 0 67 files changed, 5872 insertions(+) create mode 100644 website_cms/.gitignore create mode 100644 website_cms/AUTHORS create mode 100644 website_cms/CHANGES.rst create mode 100644 website_cms/README.rst create mode 100755 website_cms/ROADMAP.rst create mode 100644 website_cms/__init__.py create mode 100644 website_cms/__openerp__.py create mode 100644 website_cms/controllers/__init__.py create mode 100644 website_cms/controllers/form.py create mode 100644 website_cms/controllers/main.py create mode 100644 website_cms/data/form_settings.xml create mode 100644 website_cms/data/media_categories.xml create mode 100644 website_cms/data/page_types.xml create mode 100644 website_cms/docs/Makefile create mode 100644 website_cms/docs/conf.py create mode 100644 website_cms/docs/guides/advanced_listing.rst create mode 100644 website_cms/docs/guides/code_overview.rst create mode 100644 website_cms/docs/guides/navigation.rst create mode 100644 website_cms/docs/guides/permissions.rst create mode 100644 website_cms/docs/guides/redirects.rst create mode 100644 website_cms/docs/guides/routing.rst create mode 100644 website_cms/docs/guides/status_msg.png create mode 100644 website_cms/docs/guides/template_basics.rst create mode 100644 website_cms/docs/guides/usage.rst create mode 100644 website_cms/docs/index.rst create mode 100644 website_cms/docs/requirements.txt create mode 100644 website_cms/migrations/9.0.1.0.1/post-update_cms_page_table.py create mode 100644 website_cms/migrations/9.0.1.0.2/post-update_old_pages_path.py create mode 100644 website_cms/models/__init__.py create mode 100644 website_cms/models/cms_media.py create mode 100644 website_cms/models/cms_page.py create mode 100644 website_cms/models/cms_tag.py create mode 100644 website_cms/models/ir_attachment.py create mode 100644 website_cms/models/ir_ui_view.py create mode 100644 website_cms/models/website.py create mode 100644 website_cms/models/website_menu.py create mode 100644 website_cms/models/website_mixins.py create mode 100644 website_cms/models/website_redirect_mixin.py create mode 100644 website_cms/models/website_security_mixin.py create mode 100644 website_cms/security/groups.xml create mode 100644 website_cms/security/ir.model.access.csv create mode 100644 website_cms/static/src/js/website_cms.js create mode 100644 website_cms/templates/assets.xml create mode 100644 website_cms/templates/form.xml create mode 100644 website_cms/templates/layout.xml create mode 100644 website_cms/templates/menu.xml create mode 100644 website_cms/templates/misc.xml create mode 100644 website_cms/templates/page.xml create mode 100644 website_cms/templates/sidebar.xml create mode 100644 website_cms/tests/__init__.py create mode 100644 website_cms/tests/test_media.py create mode 100644 website_cms/tests/test_page.py create mode 100644 website_cms/utils.py create mode 100644 website_cms/views/cms_media.xml create mode 100644 website_cms/views/cms_media_category.xml create mode 100644 website_cms/views/cms_page.xml create mode 100644 website_cms/views/cms_redirect.xml create mode 100644 website_cms/views/menuitems.xml create mode 100644 website_cms/views/website_menu.xml create mode 100644 website_cms_search/.gitignore create mode 100644 website_cms_search/README.rst create mode 100644 website_cms_search/__init__.py create mode 100644 website_cms_search/__openerp__.py create mode 100644 website_cms_search/controllers/__init__.py create mode 100644 website_cms_search/controllers/main.py create mode 100644 website_cms_search/templates/search.xml create mode 100644 website_cms_search/tests/__init__.py diff --git a/website_cms/.gitignore b/website_cms/.gitignore new file mode 100644 index 00000000..41ed37fe --- /dev/null +++ b/website_cms/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +*.pyc +*.pyo +*.~* +*.~ diff --git a/website_cms/AUTHORS b/website_cms/AUTHORS new file mode 100644 index 00000000..bc05080f --- /dev/null +++ b/website_cms/AUTHORS @@ -0,0 +1 @@ +* Simone Orsi at Camptocamp diff --git a/website_cms/CHANGES.rst b/website_cms/CHANGES.rst new file mode 100644 index 00000000..38a2def6 --- /dev/null +++ b/website_cms/CHANGES.rst @@ -0,0 +1,8 @@ +CHANGELOG +========= + +9.0.1.0.1 (Unreleased) +---------------------- + +* 1st release + diff --git a/website_cms/README.rst b/website_cms/README.rst new file mode 100644 index 00000000..29750bc8 --- /dev/null +++ b/website_cms/README.rst @@ -0,0 +1,113 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :alt: License + +Website CMS +=========== + +This is a module that adds real CMS features to your Odoo website. + +Documentation: TODO + +Premise +------- + +Odoo has no real "Content Management System" features. +You cannot manage contents, since - beside blogs and blogposts - you don't have any real "web content". +You can design nice pages on the fly using the website builder - which is indeed a powerful and awesome feature - +but that's not the only thing a CMS should be able to do. + +Yes, you have "pages" but right now an Odoo page is just a template +with some specific characteristics (flags, and so on) plus a fixed route "/page". +So, every page's URL in the website will be something like "/page/my-nice-article-about-something". +The same goes for blogs and blogposts: you will always have URLs like "/blog/my-nice-blog-1/posts/my-interesting-post-1". + +With this limitations you cannot organize contents (a page, an attachment, etc) in sections and sub sections, +hence you cannot build a hierarchy that will allow to expose contents in a meaningful way, for you and your visitors. + +The goal of this module is to provide base capabilities for creating and managing web contents. + +Note that this module does not provide a whole theme, +but gives you the right tools to create yours. +So you should build your own theme to expose your content, navigation, etc. + + +Main features +------------- + +* Separation of concerns between ``page`` (content) and ``view`` (presentation) + + A page is the content you publish and you can assign a view to it. + A view is just an Odoo template that present your content. + +* Page types + + We define 2 basic types: `Simple` and `News`. You can add more + and list, present, search pages in different ways based on types. + +* Reusable views + + Create your own views according to your website design and reuse them when needed. + +* Publish media and categorize it + + A ``media`` could be everything: an image, a video, a link to an image or a video. + You can categorize it rely on auto-categorization based on mimetypes. + Preview images are loaded automatically but you can customize them. + +* Automatic navigation and site hierarchy + + A page can contain other pages, acting like a folder. By nesting pages you create a hierarchy. + This can be used to show global and contextual navigation without having to + customize menu links every now and then. + +* Meaningful URLs + + Pages' URLs are built using their hierarchy. + For instance, if you have a section `cars` that contains a sub section `sport` + that contains a page 'Porche' the final URL will be `/cms/cars/sport/porche`. + +* Manage redirects within page backend + + You can force a page to redirect to another page or to an external link, + and you can choose which kind of redirect status code to use (301, 302, etc) + +* Protect your content + + On each page you can decide who can view it (edit permission yet to come). + This happens at page level not view's, so that you can have different pages + presented with same view but with different permissions. + +* Full text search + + The extra module ``website_cms_search`` adds features for doing full text searches. + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. + + +Credits +======= + +Contributors +------------ + +Read the `contributors list`_ + +.. _contributors list: ./AUTHORS + +Maintainer +---------- + +.. image:: http://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: http://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. + +To contribute to this module, please visit http://odoo-community.org. diff --git a/website_cms/ROADMAP.rst b/website_cms/ROADMAP.rst new file mode 100755 index 00000000..e1027b42 --- /dev/null +++ b/website_cms/ROADMAP.rst @@ -0,0 +1,3 @@ +/// add missing from docs and talk + +* listing: if not published you do not see YOUR un-published content diff --git a/website_cms/__init__.py b/website_cms/__init__.py new file mode 100644 index 00000000..b7f3e955 --- /dev/null +++ b/website_cms/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# © +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import models +from . import controllers diff --git a/website_cms/__openerp__.py b/website_cms/__openerp__.py new file mode 100644 index 00000000..65423c9c --- /dev/null +++ b/website_cms/__openerp__.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# © +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Website CMS", + "summary": "CMS features", + "version": "1.0.2", + "category": "Website", + "website": "https://odoo-community.org/", + "author": "Simone Orsi - Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "installable": True, + 'application': False, + "depends": [ + "base", + 'website', + ], + "data": [ + # security + 'security/groups.xml', + 'security/ir.model.access.csv', + # data + "data/page_types.xml", + "data/media_categories.xml", + # "data/form_settings.xml", + # views + "views/menuitems.xml", + "views/cms_page.xml", + "views/cms_redirect.xml", + "views/cms_media.xml", + "views/cms_media_category.xml", + 'views/website_menu.xml', + # templates + "templates/assets.xml", + "templates/misc.xml", + "templates/layout.xml", + "templates/menu.xml", + "templates/page.xml", + "templates/sidebar.xml", + "templates/form.xml", + ], + 'external_dependencies': { + 'python': ['requests', ], + }, +} diff --git a/website_cms/controllers/__init__.py b/website_cms/controllers/__init__.py new file mode 100644 index 00000000..b26463d6 --- /dev/null +++ b/website_cms/controllers/__init__.py @@ -0,0 +1,2 @@ +from . import main +from . import form diff --git a/website_cms/controllers/form.py b/website_cms/controllers/form.py new file mode 100644 index 00000000..21c3afce --- /dev/null +++ b/website_cms/controllers/form.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- + +import base64 +import json + +from openerp import http +from openerp.http import request +import werkzeug +from werkzeug.exceptions import Forbidden +from openerp.tools.translate import _ + + +from .main import ContextAwareMixin + + +class PageFormMixin(ContextAwareMixin): + """CMS page Form controller.""" + + form_name = '' + form_title = '' + form_mode = '' + form_fields = ('name', 'description', 'tag_ids', ) + form_file_fields = ('image', ) + action_status = 'success' + status_message_success = '' + + def get_template(self, main_object, **kw): + """Override to force template.""" + return self.template + + def get_render_values(self, main_object, parent=None, **kw): + """Override to preload values.""" + _super = super(PageFormMixin, self) + values = _super.get_render_values(main_object, **kw) + + base_url = '/cms' + if main_object: + base_url = main_object.website_url + + name = request.params.get('name') or kw.get('name') + values.update({ + 'name': name, + 'form_action': base_url + '/' + self.form_name, + }) + + values.update(self.load_defaults(main_object, **kw)) + + # make sure we do not allow website builder Form + values['editable'] = values['translatable'] = False + # XXX: we should handle this + # values['errors'] = [] + # values['status_message'] = '' + values['view'] = self + values['cms_form_mode'] = self.form_mode + return values + + def load_defaults(self, main_object, **kw): + """Override to load default values.""" + defaults = {} + if not main_object: + return defaults + for fname in self.form_fields: + value = getattr(main_object, fname) + custom_handler = getattr(self, '_load_default_' + fname, None) + if custom_handler: + value = custom_handler(main_object, value, **kw) + defaults[fname] = value + + for fname in self.form_file_fields: + defaults['has_' + fname] = bool(getattr(main_object, fname)) + return defaults + + def _load_default_tag_ids(self, main_object, value, **kw): + tags = [dict(id=tag.id, name=tag.name) for tag in value] + return json.dumps(tags) + + def extract_values(self, request, main_object, **kw): + """Override to manipulate POST values.""" + # TODO: sanitize user input and add validation! + errors = [] + values = {} + valid_fields = self.form_fields + self.form_file_fields + form_values = { + k: v for k, v in kw.iteritems() + if k in valid_fields + } + for fname in valid_fields: + value = form_values.get(fname) + custom_handler = getattr(self, '_extract_' + fname, None) + if custom_handler: + value = custom_handler(value, errors, form_values) + if fname in form_values: + # a custom handler could pop a field + # to discard it from submission, ie: keep an image as it is + values[fname] = value + return values, errors + + def _extract_image(self, field_value, errors, form_values): + if form_values.get('keep_image') == 'yes': + # prevent discarding image + form_values.pop('image') + return None + if hasattr(field_value, 'read'): + image_content = field_value.read() + value = base64.encodestring(image_content) + else: + value = field_value.split(',')[-1] + return value + + def _extract_tag_ids(self, field_value, errors, form_values): + if field_value: + return request.env['cms.tag']._tag_to_write_vals(tags=field_value) + return None + + def add_status_message(self, status_message): + """Inject status message in session.""" + try: + request.session['status_message'].append(status_message) + except KeyError: + request.session['status_message'] = [status_message, ] + + def before_post_action(self, parent=None, main_object=None, **kw): + """Perform actions before form handling.""" + # # cleanup status messages + if 'status_message' in request.session: + del request.session['status_message'] + + def after_post_action(self, parent=None, main_object=None, **kw): + """Perform actions after form handling.""" + # add status message if any + status_message = getattr(self, + 'status_message_' + self.action_status, None) + if status_message: + self.add_status_message(status_message) + + # def get_fields(self, writable_fields): + # authorized = request.env['ir.model'].search( + # [('model', '=', self.model)]).get_authorized_fields() + # return [ + # self.prepare_field(x) for x in authorized + # if x in writable_fields + # ] + + # def prepare_field(self, field_info): + # return field_info + + +class CreatePage(http.Controller, PageFormMixin): + """CMS page create controller.""" + + form_name = 'add-page' + form_title = _('Add page') + form_mode = 'create' + template = 'website_cms.page_form' + status_message_success = { + 'type': 'info', + 'title': 'Info:', + 'msg': _(u'Page created.'), + } + + def load_defaults(self, main_object, **kw): + """Override to preload values.""" + defaults = {} + if main_object: + defaults['parent_id'] = main_object.id + defaults['form_action'] = \ + main_object.website_url + '/' + self.form_name + for fname in ('type_id', 'view_id'): + fvalue = getattr(main_object, 'sub_page_' + fname) + defaults[fname] = fvalue and fvalue.id or False + + return defaults + + @http.route([ + '/cms/add-page', + '/cms//add-page', + '/cms///add-page', + ], type='http', auth='user', methods=['GET', 'POST'], website=True) + def add(self, parent=None, **kw): + """Handle page add view and form submit.""" + if request.httprequest.method == 'GET': + return self.render(parent, **kw) + + elif request.httprequest.method == 'POST': + self.before_post_action(parent=parent, **kw) + # handle form submission + values = self.load_defaults(parent, **kw) + # TODO: handle errors + _values, errors = self.extract_values(request, parent, **kw) + values.update(_values) + new_page = request.env['cms.page'].create(values) + url = new_page.website_url + '?enable_editor=1' + self.after_post_action(main_object=new_page, **kw) + return werkzeug.utils.redirect(url) + + def after_post_action(self, parent=None, main_object=None, **kw): + """Add extra msg in case description is missing.""" + super(CreatePage, self).after_post_action() + if not main_object.description: + msg = { + 'type': 'warning', + 'title': 'Note:', + 'msg': _(u'No description for this page yet. ' + u'You see this because you can edit this page. ' + u'A brief description can be useful ' + u'to show a summary of this content ' + u'in many views (like listing or homepage).'), + } + self.add_status_message(msg) + + +class EditPage(http.Controller, PageFormMixin): + """CMS page edit controller.""" + + form_name = 'edit-page' + form_title = _('Edit page') + form_mode = 'write' + template = 'website_cms.page_form' + status_message_success = { + 'type': 'info', + 'title': 'Info:', + 'msg': _(u'Page updated.'), + } + + def _check_security(self, main_object): + if request.website and \ + not request.website.cms_can_edit(main_object): + raise Forbidden(_(u'You are not allowed to edit this page!')) + + @http.route([ + '/cms//edit-page', + '/cms///edit-page', + ], type='http', auth='user', methods=['GET', 'POST'], website=True) + def edit(self, main_object, **kw): + """Handle page edit view and form submit.""" + if request.httprequest.method == 'GET': + # render form + return self.render(main_object, **kw) + elif request.httprequest.method == 'POST': + self._check_security(main_object) + self.before_post_action(main_object=main_object, **kw) + # handle form submission + # TODO: handle errors + values, errors = self.extract_values(request, main_object, **kw) + main_object.write(values) + self.after_post_action(main_object=main_object, **kw) + return http.local_redirect(main_object.website_url) + + def after_post_action(self, parent=None, main_object=None, **kw): + """Add extra msg in case description is missing.""" + super(EditPage, self).after_post_action() + if not main_object.description: + msg = { + 'type': 'warning', + 'title': 'Note:', + 'msg': _(u'No description for this page yet. ' + u'You see this because you can edit this page. ' + u'A brief description can be useful ' + u'to show a summary of this content ' + u'in many views (like listing or homepage).'), + } + self.add_status_message(msg) diff --git a/website_cms/controllers/main.py b/website_cms/controllers/main.py new file mode 100644 index 00000000..387f18cd --- /dev/null +++ b/website_cms/controllers/main.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- + +import json + +from openerp import http +from openerp.http import request +import werkzeug +from werkzeug.exceptions import NotFound +# from openerp.tools.translate import _ + + +class ContextAwareMixin(object): + """`Context` aware mixin klass. + + The `context` in this case is what odoo calls `main_object`. + """ + + # default template + template = '' + + def get_template(self, main_object, **kw): + """Retrieve rendering template.""" + template = self.template + + if getattr(main_object, 'view_id', None): + template = main_object.view_id.key + + if getattr(main_object, 'default_view_item_id', None): + view_item = main_object.default_view_item_id + if view_item.view_id: + template = view_item.view_id.key + + if not template: + raise NotImplementedError("You must provide a template!") + return template + + def get_render_values(self, main_object, **kw): + """Retrieve rendering values. + + Essentially we need 2 items: ``main_object`` and ``parent``. + + The main_object by default is the item being traversed. + In other words: if you traverse the path to a page + that page will be the main_object. + + The parent - if any - is always the parent of the item being traversed. + + For instance: + + /cms/page-1/page-2 + + in this case, `page-2` is the main_object and `page-1` the parent. + """ + parent = None + if getattr(main_object, 'parent_id', None): + # get the parent if any + parent = main_object.parent_id + + if getattr(main_object, 'default_view_item_id', None): + # get a default item if any + main_object = main_object.default_view_item_id + + # std check into `ir.ui.view._prepare_qcontext` + # editable = request.website.is_publisher() + editable = self._can_edit(main_object) + lang = request.env.context.get('lang') + translatable = editable \ + and lang != request.website.default_lang_code + editable = not translatable and editable + + kw.update({ + 'main_object': main_object, + 'parent': parent, + # override values from std qcontext + 'editable': editable, + 'translatable': translatable, + }) + return kw + + def _can_edit(self, main_object): + """Override this to protect the view or the item by raising errors.""" + return request.website.cms_can_edit(main_object) + + def render(self, main_object, **kw): + """Retrieve parameters for rendering and render view template.""" + return request.website.render( + self.get_template(main_object, **kw), + self.get_render_values(main_object, **kw), + ) + + +# `secure_model` is our converter that checks security +# see `website.security.mixin`. +PAGE_VIEW_ROUTES = [ + '/cms/', + '/cms//', + '/cms//page/', + '/cms///page/', + '/cms//media/', + '/cms//media//page/', + '/cms///media/', + '/cms///media//page/', +] + + +class PageViewController(http.Controller, ContextAwareMixin): + """CMS page view controller.""" + + template = 'website_cms.page_default' + + @http.route(PAGE_VIEW_ROUTES, type='http', auth='public', website=True) + def view_page(self, main_object, **kw): + """Handle a `page` route. + + Many optional arguments come from `kw` based on routing match above. + """ + site = request.website + # check published + # XXX: this is weird since it should be done by `website` module itself. + # Alternatively we can put this in our `secure model` route handler. + if not site.is_publisher() and not main_object.website_published: + raise NotFound + + if main_object.has_redirect(): + data = main_object.get_redirect_data() + redirect = werkzeug.utils.redirect(data.url, data.status) + return redirect + if 'edit_translations' in kw: + # for some reasons here we get an empty string + # as value, and this breaks translation editor initialization :( + kw['edit_translations'] = True + + # handle translations switch + if site and 'edit_translations' not in kw \ + and not site.default_lang_code == request.lang \ + and not site.is_publisher(): + # check if there's any translation for current page in request lang + if request.lang not in main_object.get_translations(): + raise NotFound + return self.render(main_object, **kw) + + +class TagsController(http.Controller): + """CMS tags controller.""" + + @http.route('/cms/get_tags', type='http', + auth="public", methods=['GET'], website=True) + def tags_search(self, q='', l=25, **post): + """Search CMS tags.""" + data = request.env['cms.tag'].search_read( + domain=[('name', '=ilike', (q or '') + "%")], + fields=['id', 'name'], + limit=int(l), + ) + return json.dumps(data) diff --git a/website_cms/data/form_settings.xml b/website_cms/data/form_settings.xml new file mode 100644 index 00000000..e22b6bdf --- /dev/null +++ b/website_cms/data/form_settings.xml @@ -0,0 +1,18 @@ + + + + + + True + Edit page + + + + diff --git a/website_cms/data/media_categories.xml b/website_cms/data/media_categories.xml new file mode 100644 index 00000000..120d4405 --- /dev/null +++ b/website_cms/data/media_categories.xml @@ -0,0 +1,53 @@ + + + + + + + + Document + fa fa-file-o + +text/plain +application/pdf +application/msword +application/vnd.openxmlformats-officedocument.wordprocessingml.document +application/vnd.ms-powerpointtd +application/vnd.openxmlformats-officedocument.presentationml.slideshow +application/vnd.openxmlformats-officedocument.presentationml.presentation + + + + + + Image + fa fa-file-image-o + + + + + Audio + fa fa-file-audio-o + + + + + Video + fa fa-file-video-o + + + + + Archive + fa fa-file-archive-o + +application/x-gzip +application/zip +application/x-compressed-zip +application/x-tar + + + + + + diff --git a/website_cms/data/page_types.xml b/website_cms/data/page_types.xml new file mode 100644 index 00000000..f702cf46 --- /dev/null +++ b/website_cms/data/page_types.xml @@ -0,0 +1,18 @@ + + + + + + + + Simple + + + + + News + + + + + diff --git a/website_cms/docs/Makefile b/website_cms/docs/Makefile new file mode 100644 index 00000000..c7242188 --- /dev/null +++ b/website_cms/docs/Makefile @@ -0,0 +1,225 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " epub3 to make an epub3" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + @echo " dummy to check syntax errors of document sources" + +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* + +.PHONY: html +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +.PHONY: dirhtml +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +.PHONY: singlehtml +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +.PHONY: pickle +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +.PHONY: json +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +.PHONY: htmlhelp +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +.PHONY: qthelp +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/WebsiteCMS.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/WebsiteCMS.qhc" + +.PHONY: applehelp +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +.PHONY: devhelp +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/WebsiteCMS" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/WebsiteCMS" + @echo "# devhelp" + +.PHONY: epub +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +.PHONY: epub3 +epub3: + $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 + @echo + @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." + +.PHONY: latex +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +.PHONY: latexpdf +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: latexpdfja +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: text +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +.PHONY: man +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +.PHONY: texinfo +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +.PHONY: info +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +.PHONY: gettext +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +.PHONY: changes +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +.PHONY: linkcheck +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +.PHONY: doctest +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +.PHONY: coverage +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +.PHONY: xml +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +.PHONY: pseudoxml +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + +.PHONY: dummy +dummy: + $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy + @echo + @echo "Build finished. Dummy builder generates no files." diff --git a/website_cms/docs/conf.py b/website_cms/docs/conf.py new file mode 100644 index 00000000..24e88caf --- /dev/null +++ b/website_cms/docs/conf.py @@ -0,0 +1,380 @@ +# -*- coding: utf-8 -*- +# +# Website CMS documentation build configuration file, created by +# sphinx-quickstart on Thu Sep 1 14:46:45 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# flake8: noqa + +import os +import sys +import sphinx_bootstrap_theme +sys.path.insert(0, os.path.abspath('.')) + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) +sys.path.append(os.path.abspath('_themes')) + +if os.environ.get('TRAVIS_BUILD_DIR') and os.environ.get('VERSION'): + # build from travis + odoo_folder = 'odoo-' + os.environ.get('VERSION') + odoo_root = os.path.join(os.environ['HOME'], odoo_folder) + sphinxodoo_root_path = os.path.abspath(odoo_root) + sphinxodoo_addons_path = [ + os.path.abspath(os.path.join(odoo_root, 'openerp', 'addons')), + os.path.abspath(os.path.join(odoo_root, 'addons')), + os.path.abspath(os.environ['TRAVIS_BUILD_DIR']), + ] + build_path = os.environ['TRAVIS_BUILD_DIR'] + addons = [x for x in os.listdir(build_path) + if not x.startswith('.') and + os.path.isdir(os.path.join(build_path, x))] + sphinxodoo_addons = addons + sys.path.append(os.environ['TRAVIS_BUILD_DIR']) +else: + # build from a buildout + odoo_root = '../../../odoo' + sys.path.append(os.path.abspath(os.path.join(odoo_root, 'openerp'))) + sys.path.append(os.path.abspath(os.path.join(odoo_root, 'addons'))) + sys.path.append(os.path.abspath('../..')) + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', + 'sphinx.ext.githubpages', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +# +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Website CMS' +copyright = u'2016, Camptocamp SA' +author = u'Camptocamp SA' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = u'1.0.1' +# The full version, including alpha/beta/rc tags. +release = u'1.0.1' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# +# today = '' +# +# Else, today_fmt is used as the format for a strftime call. +# +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +# +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. +# " v documentation" by default. +# +# html_title = u'Website CMS v1.0.1' + +# A shorter title for the navigation bar. Default is the same as html_title. +# +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# +# html_logo = None + +# The name of an image file (relative to this directory) to use as a favicon of +# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# +# html_extra_path = [] + +# If not None, a 'Last updated on:' timestamp is inserted at every page +# bottom, using the given strftime format. +# The empty string is equivalent to '%b %d, %Y'. +# +# html_last_updated_fmt = None + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# +# html_additional_pages = {} + +# If false, no module index is generated. +# +# html_domain_indices = True + +# If false, no index is generated. +# +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' +# +# html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# 'ja' uses this config value. +# 'zh' user can custom change `jieba` dictionary path. +# +# html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +# +# html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'WebsiteCMSdoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'WebsiteCMS.tex', u'Website CMS Documentation', + u'Camptocamp SA', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# +# latex_use_parts = False + +# If true, show page references after internal links. +# +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# +# latex_appendices = [] + +# It false, will not define \strong, \code, itleref, \crossref ... but only +# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added +# packages. +# +# latex_keep_old_macro_names = True + +# If false, no module index is generated. +# +# latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'websitecms', u'Website CMS Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +# +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'WebsiteCMS', u'Website CMS Documentation', + author, 'WebsiteCMS', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +# +# texinfo_appendices = [] + +# If false, no module index is generated. +# +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# +# texinfo_no_detailmenu = False + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} diff --git a/website_cms/docs/guides/advanced_listing.rst b/website_cms/docs/guides/advanced_listing.rst new file mode 100644 index 00000000..c21f2cea --- /dev/null +++ b/website_cms/docs/guides/advanced_listing.rst @@ -0,0 +1,7 @@ +.. _advanced_listing: + +################ +Advanced listing +################ + +TODO diff --git a/website_cms/docs/guides/code_overview.rst b/website_cms/docs/guides/code_overview.rst new file mode 100644 index 00000000..5b4c5f34 --- /dev/null +++ b/website_cms/docs/guides/code_overview.rst @@ -0,0 +1,265 @@ +.. _code-overview: + +############# +Code Overview +############# + +Here is an overview of models and their behavior. + + +******** +CMS Page +******** + +:py:class:`~website_cms.models.cms_page.CMSPage` is the main protagonist of this system. It's the object that you use to create website contents and organize it hierarchically. + +A page can contain other pages and media. You can assign a view to it an you can preset default values for contained pages (view and type). + +The visual content of the page depends on the view: if the view exposes its html body (like the default one), then when you open the page in frontend you will see the html body rendered and you can edit it via website builder. + +If the view is a listing view (like the default listing view) you won't see the html content but you'll see the listing of the sub pages. + +Overall this means that the page behavior and content is indipendent from its presentation. + +Fields +====== + +Main fields +----------- + +* ``name`` is the title of the page. Used for slug generation. +* ``description`` is a short summary of the content. Useful for listings, search results, etc +* ``body`` contains the HTML content of the page (what you edit via website builder) +* ``parent_id`` m2o to parent page +* ``children_ids`` o2m to children pages +* ``media_ids`` o2m to `cms.media` objects +* ``type_id`` m2o to `cms.page.type` object. You can filter pages or use different views based on it. Defaults to `website_cms.default_page_type`. +* ``view_id`` m2o to `ir.ui.view` object. This is the view that is used to display the page. +* ``sub_page_type_id`` m2o to `cms.page.type` object. Set default page type for children pages. You can use this to pre-configure a website section behavior. +* ``sub_page_view_id`` m2o to `ir.ui.view` object. Set default page view for children pages. You can use this to pre-configure a website section look an feel. +* ``list_types_ids`` m2m to `cms.page.type`. Useful to force listed page types. +* ``nav_include`` whether to include or not the page into main navigation building (see ``website.get_nav_pages``). +* ``path`` is a computed field that matches the hierarchy of a page (eg: /A/B/C). + +Helper methods +============== + +Get the root page +----------------- + +The root page is the upper anchestor of a hierarchy of pages. + +.. code-block:: python + + >>> page.get_root() + + +List sub pages +--------------- + +A page can contain pages, this is how you can list them. + +.. code-block:: python + + >>> page.get_listing() + +By default it: + +* order by sequence (position inside the parent) +* include only published items, unless you are into `website_cms.cms_manager` group +* if `page.list_types_ids` is valued it returns only sub pages matching that types + +To learn how to tweak filtering take a look at :doc:`advanced_listing` section. + + +Mixins and extra features +========================= + +The CMS page model rely on several mixins. Namely: + +``website.seo.metadata`` + Standard from `web` module. Provides metadata for SEO tuning. + +``website.published.mixin`` + Standard from `web` module. Provides basic publishing features. + +``website.image.mixin`` (needs improvements) + New from `website_cms`. Provides image the following image fields: + * ``image`` full size image + * ``image_medium`` medium size image + * ``image_thumb`` thumb size image + +``website.orderable.mixin`` + New from `website_cms`. Provides ``sequence`` field used to sort pages. + +``website.coremetadata.mixin`` + New from `website_cms`. Provides core metadata. + Exposes core fields: + * ``create_date`` + * ``create_uid`` + * ``write_date`` + * ``write_uid`` + Adds extra fields: + * ``published_date`` + * ``published_uid`` + +``website.security.mixin`` + New from `website_cms`. Provides per-content security control. + By using the field ``view_group_ids`` you can decide which group can view the page. + View permission per-user and edit permission are missing as of today. + See :doc:`permissions` for further info. + +``website.redirect.mixin`` + New from `website_cms`. Provides ability to make a page redirect to another CMS page, an Odoo page (`ir.ui.view` item with `page=True`) or an external link. + See :doc:`redirects` for further info. + + +********* +CMS Media +********* + +:py:class:`~website_cms.models.cms_media.CMSMedia` is an extension of Odoo attachments. + +A media can be whatever file you want or an URL pointing to whatever web resource you want. + +You can add media to a page from the quick link in the page backend view or from the media menu by selecting the page you want to assign the media to. + +If you assign it to a page, you will be able to list all the media of that page easily. +A typical use case is a gallery: you could create a page "Gallery" and then you could add all the images as media into it. + +Unlike attachments a media: + +* can be published/unpublished indipendently from its related resource; +* is auto-categorized based on its mimetype (see `CMS Media Category`_ section); +* can be categorized manually; +* has a preview image +* automatically loads preview images from uploaded images, linked images, youtube videos. + +Fields +====== + +Main fields +----------- + +* ``name`` is the title of the media. Used for slug generation; +* ``description`` is a short summary of the content. Useful for listings, search results, etc; +* ``page_id`` m2o to a page; +* ``category_id`` m2o to `cms.media.category`, populated automatically; +* ``force_category_id`` m2o to `cms.media.category`, to be populated manually; +* ``lang_id`` m2o to `res.lang` model (for filtering media by language); +* ``icon`` a text field that should contain a css class for fontawesome (or services alike) that can be used to present the media in small listing etc. + + +Helper methods +============== + +Is an image? +------------ + +Determine if the media is an image by checking its mimetype. + +.. code-block:: python + + >>> media.is_image() + +Is a video? +----------- + +Determine if the media is a video by checking its mimetype. + +.. code-block:: python + + >>> media.is_video() + +Mixins and extra features +========================= + +The CMS media model rely on several mixins. Namely: + +``ir.attachment`` + Well, not a real mixin but it inherits all the standard ir.attachment features. + +``website.published.mixin`` + Standard from `web` module. Provides basic publishing features. + +``website.image.mixin`` (needs improvements) + New from `website_cms`. Provides image the following image fields: + * ``image`` full size image + * ``image_medium`` medium size image + * ``image_thumb`` thumb size image + +``website.orderable.mixin`` + New from `website_cms`. Provides ``sequence`` field used to sort pages. + +``website.coremetadata.mixin`` + New from `website_cms`. Provides core metadata. + Exposes core fields: + + * ``create_date`` + * ``create_uid`` + * ``write_date`` + * ``write_uid`` + + Adds extra fields: + + * ``published_date`` + * ``published_uid`` + +``website.security.mixin`` + New from `website_cms`. Provides per-content security control. + By using the field ``view_group_ids`` you can decide which group can view the page. + View permission per-user and edit permission are missing as of today. + See :doc:`permissions` for further info. + + +****************** +CMS Media Category +****************** + +:py:class:`~website_cms.models.cms_media.CMSMediaCategory` rappresent a media category. + +You can use media categories to categorize your media. +By default a media is auto-categorized by its mimetype. + +On each media category you can configure which mimetypes correspond to it. +For instance, the generic media category "Document" is defined as:: + + + Document + fa fa-file-o + + text/plain + application/pdf + application/msword + application/vnd.openxmlformats-officedocument.wordprocessingml.document + application/vnd.ms-powerpointtd + application/vnd.openxmlformats-officedocument.presentationml.slideshow + application/vnd.openxmlformats-officedocument.presentationml.presentation + + + +All the media matching one of the mimetypes described here will be automatically categorized as "Document". + +.. note:: As of today images and videos are forced to match image and video category. + +.. note:: You can always override auto-categorization by forcing the category on the media itself. + +Fields +====== + +Main fields +----------- + +* ``name`` is the title of the category. Used for slug generation; +* ``mimetypes`` multiline text field where to configure matching mimetypes; +* ``icon`` a text field that should contain a css class for fontawesome (or services alike) that can be used to present the category in small listing, filtering, etc; +* ``active`` you may want to show/hide categories when needed. You can use this field to filter which categories to show to your public without having to delete them. + + +Mixins and extra features +========================= + +The CMS category model rely on several mixins. Namely: + +``website.orderable.mixin`` + New from `website_cms`. Provides ``sequence`` field used to sort pages. diff --git a/website_cms/docs/guides/navigation.rst b/website_cms/docs/guides/navigation.rst new file mode 100644 index 00000000..d456a813 --- /dev/null +++ b/website_cms/docs/guides/navigation.rst @@ -0,0 +1,84 @@ +.. _navigation: + +########## +Navigation +########## + +Build your own nav menu +======================= + +The CMS page has a `Nav include` flag. When this flag is on the item will be included in the main navigation listing. + +.. warning:: + You won't find a ready-to-use menu into ``website_cms`` since its main goal is to give you the tool to do it yourself as you prefer. + + So, the following is just an example on how you can generate a main menu for your website. + +To get nav pages, just use the method ``website.get_nav_pages``. This method returns a hierarchical list of items ordered by position (``sequence`` field), in which every item (or sub item) as the following schema:: + + item = AttrDict({ + 'id': item.id, # database id of the page + 'name': item.name, # name of the page + 'url': item.website_url, # public url of the page + 'children': children, # list of children pages + 'website_published': item.website_published, # published/private page + }) + + +Knowing this, your menu's template could look like: + +.. code-block:: xml + + + +In this case, by passing ``max_depth=4``, we are generating a 4 levels hierarchy, but you can lower that value according to your needs. + +These are the parameters you can use to customize you nav: + +* ``max_depth`` to set menu levels, default: 3; +* ``publish`` to filter public state, possible values: + * ``None`` all pages; + * ``True`` all published pages (default); + * ``False`` all private pages; +* ``nav`` to filter on "Nav include" flag, possible values: + * ``None`` all pages; + * ``True`` all nav-included pages (default); + * ``False`` all not nav-included pages; +* ``type_ids`` to filter pages by a list of types' ids, default to ``website_cms.default_page_type`` id. + + diff --git a/website_cms/docs/guides/permissions.rst b/website_cms/docs/guides/permissions.rst new file mode 100644 index 00000000..438c522d --- /dev/null +++ b/website_cms/docs/guides/permissions.rst @@ -0,0 +1,7 @@ +.. _permissions: + +########### +Permissions +########### + +TODO diff --git a/website_cms/docs/guides/redirects.rst b/website_cms/docs/guides/redirects.rst new file mode 100644 index 00000000..8676d968 --- /dev/null +++ b/website_cms/docs/guides/redirects.rst @@ -0,0 +1,7 @@ +.. _redirects: + +######### +Redirects +######### + +TODO diff --git a/website_cms/docs/guides/routing.rst b/website_cms/docs/guides/routing.rst new file mode 100644 index 00000000..588b8d88 --- /dev/null +++ b/website_cms/docs/guides/routing.rst @@ -0,0 +1,7 @@ +.. _routing: + +####### +Routing +####### + +TODO diff --git a/website_cms/docs/guides/status_msg.png b/website_cms/docs/guides/status_msg.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcdc7943d053353dcc28e69e58008c8ba0b6092 GIT binary patch literal 3402 zcmcJSS5y;9x5p!30BM3CA|(PIdLRT0MLMX|P(6Tv^d=;L^de1Z2NaPGp-9U?5u}I| zDN=(Zp^8MLmk^O&LJNFheb0BT`*837VfO5YJu`b|*354wnZO^gT)BA#006M)>FSsP z05l%dz7_+J+AcnjfKUyczm}c_162_WE{Rm1C*Yn{fVq!bK(OOeSAaXx2jL3#cYf;X ziuCvJ3D~6VPy+y%f9dIHT7;n1r=twVgA4n&iI5Hl;5Tk|+2XGI%x;DT2t@OkZ4&sW zq)1LngC!!XVgg4JarJL&3ux=1lp5h-q!y)o3;*87F6M#X_e+wHSjU&J;lSk2b-a3v zIVBTG4E4--2t;nyaU)f8{Ft_!#(wI@VvS#W(I$EKEK+bgbBMT0aWD``*VCb@2Rd5O z0$``AIhQ6ck*Z#?;v}lEO8H;8iW}vo^0L@C-4sb}zxeo1H6N(47>X~NfH4k+_AhoJ zK;aQs*n~YtqDZ_F|BX)1`gy(j#Rh_aRjm+J>U7&KZ!o^Gih6~p6@Iz0mpt>*I!=lR zSlv2noE&voX?{0wW@Ubnw_t83vb2xLCVa+ulo(D?YV3~oik?BK=uJ@lbj~HPGHM)? zdoelfTmxalQ?+Aefs-0vd*ibFMS97m8rS}xT?1f))uy%DWJ`hN%rxFO=ozllf_u*d zsx-DGt4>%Gg)z2WwjpnS%SsQ(O&_MN6+=fNv9a^CP|i`7^gzaV;dHYEXD1n-(d`o; z>{G8;$U)I$-ECqao^)E3n-#f*eU_02r6tl?z_z|$nwbniEJ2^;OL6{-KVa!C=ec7wC{^;*yvQ zzTrM$O$Z8Ln*o+azY7sEkSa!Yk6PT26P!G`L(g_+7cb2Xsor`N(I%@8bh*EKG z0H3$Rn7vcRhj%4a`~E>iOzgo#GwZh4z};(nN2}&HX#leAhX_@B&cExN5wdCZxN(pd z`ja}>%$1d_x|Vk+bjmoG&Un+2l6FhL{?1gCt*?(@H?rT^MfNEp^P=p;L%T9pdtUze z%S*0Z8b3gUpt6@)8q6QfO&-Cg-}UP*_lfcMzpDF0FlLk1FSOG7u5oSOqK>kx)RZ_n z5`aP&*&UY7?cs|Tj$Fs3F)g3e>az=M$YTe#;LYsv`?XHl`60W`>RxBde@S5Lzfk6R zA|Cc0=Kmxy^mmH6?GI*3~VX9>*;Nf=e9s^vYC2O(XE1OO$;0-X79Fa!|^v`dKU zmpPZAJ7xPsT-iZRLsv!ylb-hzzF#i}Vemx;M*C)2IKI%C0@0&64c-D%jP#zVdB%;| znite-{czHobD8KnuMZjtiQQ;_A2N;X(l!M^-Yx>m`wIg!7~h4T@ne@!V=qvvt@E6>KTHL3eFSJ#vWR#wy^WZLw*`?9UlBeM$D zWfz2-6(++ng7cg#sw-WGDgwPLzrEe>6b!N#crd~mef1@Jz3PncNbQLH2)1-WsQnyg zpz$Z)P0eGYn`{OShkZA*Tp$jY>-<)WE*yJ62%9j%{ro|w-wvDs8xM!fDcZ9ti9I&e zwi65y2v3XuoFzNNzKs2V&B^u zIs8=lu%NJ&x_eyAq6qB3py0HpXs(Mq%GI?=3|oNm1)3kd>8y$>M)qp`U0t`H> zuLwiju7S85z?L)n=wGpzm2^9ukM$l8@9-AdVDdmU4%%vv;ej*!_;b0!D#!kgHvB;* zSurf=uA_R?;UA&xUmOv+(WTKWfuFvRPx+YXn^FiC*PBk&|iK?UtbtBk4&lSwZQEFSa(*k>suf?yQI!0?v0^vw|UHJK|vJeA9FW6~r+9&uf6d(*@ei3b zz|6u9cTh(Psrm4h>DTg`*_P~*<(i$y&$$gG zm2dsRPjwtE^8aj&VSao_djAEsI{|`!+@Fv`@>bu|(Gh^EQXH#U%er#sjYMZL3r;?- zb>-p45ITssAnwgc@pMmG@8Z}ct%k?#*L8uUCV7TQafy~j@C!YxeA2YB$;Z}#!b0tt zJ-(fks{AerL2ct$qXX^IwG^giuPYpJjxX#tJ~K4n8nt$O_$Ie{?3;&Sq@INT^4 zXuOFI@>gABQ)_x@ZLWD+!={GO?M0)rs3%{YwxZ5e`pZl^en9-k4!JkVktMlVBX)tR z8DQ~SGc(u?Jc9$rAMb^N)Pvd&l3`x%tZco&J=B^AgTukolIHM$wDUXsM&O3HOZBPO ziUQdOz+baT#SWU63OkDk=MNKec*@`JbK2V)c(HQd%N-uu5M^MtMY{L9Yu>tVtjKkU z(kN5cJ%6ny)fUaIZ`Z(&C{gmDVONIp&wogLHOdG4fi;Ubko@D*qA@Jb7(~gOCi;Z@ zb_1PV?PN#^xdCjDi;e?+##nQ~k;}@v+py z|7KH{LAGor1XUIEtw=^E$By<}TkZ!NF2Ovj7u>BZ!xE2E*9b3 zl#~qi$~GhSI5w^$i_@4RjAh}b-_8Erf0cTC!S>z z;B9Y%A5<{D@)Eq=m302Z!P1chRGo9?pb)`Qw7fpsFxT4M0-*w6sG@C3e~9#XT5Dq{ zLxti8$)Ka#=Uq1JQt6uL?pS + +
+ + + + +
+
+ + +The important part here is:: + + + + + + +and is exactly the piece of snippet that you can use in your custom view. + + +Default page content view with listing +-------------------------------------- + +``website.page_default_listing``: display page's title, description, HTML content and a bare listing of contents below. + +All the fields are editable if in edit mode and all the sub-pages contained in the current one will be displayed +with their preview image. + +You can select it via backend as "Page default listing". + + +Include page information +======================== + +The views mentioned above use basic markup for including page's info. +Here is a brief summary. + +Include title:: + +

+ +Include description:: + + +

+ + +Include HTML content:: + +
+ +Include preview image:: + + + + + + +Include basic listing:: + + + +
  • + + + +
  • +
    + +Include pills navigation, horizontal:: + + + +or vertical:: + + + + +.. note:: + + Be aware that + + - controlling "contenteditable" attribute based on "editable" variable is done to avoid browsers to make the item editable without Odoo editor. + + + +Management actions +------------------ + +If you want to show management actions (edit, publish/unpublish) in your view, you should include:: + + + +Note that this is already done by the template ``add_mgmt_actions``, which adds it above the ``
    `` element of Odoo layout. +If you want to move it elsewhere you can disable it like this:: + + + + + + +Status message +-------------- + +When performing an action like submitting an edit or create form of a page +you can add status message to show the status of the operation. You can add it like this:: + + + +The result looks like: + +.. image:: status_msg.png + +Note that this is already done by the template ``add_status_message``, which adds it above the ``
    `` element of Odoo layout. +If you want to move it elsewhere you can disable it like this:: + + + + + + +Debug info +---------- + +If you want to know more aboit the view you are currently using:: + + + + +.. warning:: this works only in debug mode + + +Create your own view +==================== + +A CMS view is just an Odoo template (``ir.ui.view`` model) with the flag ``cms_view`` on. + +So, first you define the template as usual and then you activate it with 3 simple lines:: + + + + + + + + +Activating the flag is required to make the view appear among available cms views on the cms page. + +The content of the template can be whatever you want and you can use one or more of the above mentioned templates into it. + +In the template among other variables you have: + +* ``main_object``: the current page instance +* ``parent``: the parent page if main object is child page diff --git a/website_cms/docs/guides/usage.rst b/website_cms/docs/guides/usage.rst new file mode 100644 index 00000000..5d07df84 --- /dev/null +++ b/website_cms/docs/guides/usage.rst @@ -0,0 +1,5 @@ +.. _usage: + +##### +Usage +##### diff --git a/website_cms/docs/index.rst b/website_cms/docs/index.rst new file mode 100644 index 00000000..38c60faf --- /dev/null +++ b/website_cms/docs/index.rst @@ -0,0 +1,31 @@ +.. Website CMS documentation master file, created by + sphinx-quickstart on Thu Sep 1 14:46:45 2016. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Website CMS's documentation! +======================================= + +Odoo module that adds real CMS features to your Odoo website. + + +***************** +Developer's guide +***************** + +.. toctree:: + :maxdepth: 2 + + guides/template_basics.rst + guides/code_overview.rst + guides/navigation.rst + guides/advanced_listing.rst + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/website_cms/docs/requirements.txt b/website_cms/docs/requirements.txt new file mode 100644 index 00000000..826d3c6b --- /dev/null +++ b/website_cms/docs/requirements.txt @@ -0,0 +1,4 @@ +sphinx +sphinx_bootstrap_theme +sphinx-intl +odoo-sphinx-autodoc diff --git a/website_cms/migrations/9.0.1.0.1/post-update_cms_page_table.py b/website_cms/migrations/9.0.1.0.1/post-update_cms_page_table.py new file mode 100644 index 00000000..efb65ad0 --- /dev/null +++ b/website_cms/migrations/9.0.1.0.1/post-update_cms_page_table.py @@ -0,0 +1,19 @@ +#-*- coding: utf-8 -*- + +import logging +from openerp.modules.registry import RegistryManager + + +def get_logger(): + name = '[website_cms.migration]' + return logging.getLogger(name) + + +def migrate(cr, version): + logger = get_logger() + registry = RegistryManager.get(cr.dbname) + ids = registry['cms.page'].search(cr, 1, []) + pages = registry['cms.page'].browse(cr, 1, ids) + for item in pages: + item.write({'path': item.build_path(item)}) + logger.info('Upgrade cms.page paths') diff --git a/website_cms/migrations/9.0.1.0.2/post-update_old_pages_path.py b/website_cms/migrations/9.0.1.0.2/post-update_old_pages_path.py new file mode 100644 index 00000000..193c6160 --- /dev/null +++ b/website_cms/migrations/9.0.1.0.2/post-update_old_pages_path.py @@ -0,0 +1,25 @@ +#-*- coding: utf-8 -*- + +# flake8: noqa + +import logging +from openerp.modules.registry import RegistryManager + + +def get_logger(): + """Return a logger.""" + name = '[website_cms.migration]' + return logging.getLogger(name) + + +def migrate(cr, version): + logger = get_logger() + # a bug in frontend page form was overriding path on edit + # let's fix db data + logger.info('Upgrade cms.page paths: START') + registry = RegistryManager.get(cr.dbname) + ids = registry['cms.page'].search(cr, 1, []) + pages = registry['cms.page'].browse(cr, 1, ids) + for item in pages: + item._compute_path() + logger.info('Upgrade cms.page paths: STOP') diff --git a/website_cms/models/__init__.py b/website_cms/models/__init__.py new file mode 100644 index 00000000..37fc3adf --- /dev/null +++ b/website_cms/models/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# © +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import website_mixins +from . import website_security_mixin +from . import website_redirect_mixin +from . import website +from . import website_menu +from . import ir_ui_view +from . import ir_attachment +from . import cms_page +from . import cms_media +from . import cms_tag diff --git a/website_cms/models/cms_media.py b/website_cms/models/cms_media.py new file mode 100644 index 00000000..b1e3fe02 --- /dev/null +++ b/website_cms/models/cms_media.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- + +# pylint: disable=E0401 +# pylint: disable=W0212 +from openerp import models +from openerp import fields +from openerp import api +# from openerp.tools.translate import _ +from openerp.addons.website.models.website import slug + +from openerp.addons.website_cms.utils import IMAGE_TYPES +from openerp.addons.website_cms.utils import VIDEO_TYPES +from openerp.addons.website_cms.utils import AUDIO_TYPES +from openerp.addons.website_cms.utils import download_image_from_url +from openerp.addons.website_cms.utils import guess_mimetype +from openerp.addons.website_cms.utils import AttrDict + +import urlparse + + +# XXX: we should move this stuff to website_cms +# and use services like http://noembed.com +EMBED_PATTERN = AttrDict({ + 'youtube': AttrDict({ + 'key': 'v', + 'url': 'https://www.youtube.com/embed/{vid}', + }), +}) +IMAGE_PREVIEW_PATTERN = AttrDict({ + 'youtube': AttrDict({ + 'key': 'v', + 'url': 'https://i.ytimg.com/vi/{vid}/hqdefault.jpg', + }), +}) + + +def to_embed_url(url, provider='youtube'): + """Simple function to convert video url to embed url. + + Example: + + >>> to_embed_url('https://www.youtube.com/watch?v=Z5cterm_nW0') + https://www.youtube.com/embed/Z5cterm_nW0 + + The best would be to make use of services like `oembed` + but we are taking this easy at the moment ;) + """ + pattern = EMBED_PATTERN.get(provider) + parsed = urlparse.urlparse(url) + qstring = urlparse.parse_qs(parsed.query) + if pattern and pattern.key in qstring: + return pattern.url.format(vid=qstring[pattern.key][0]) + return url + + +def to_preview_url(url, provider='youtube'): + """Simple function to convert video url to image preview url. + + Example: + + >>> to_preview_url('https://www.youtube.com/watch?v=Z5cterm_nW0') + https://i.ytimg.com/vi/Z5cterm_nW0/hqdefault.jpg + + The best would be to make use of services like `oembed` + but we are taking this easy at the moment ;) + """ + pattern = IMAGE_PREVIEW_PATTERN.get(provider) + parsed = urlparse.urlparse(url) + qstring = urlparse.parse_qs(parsed.query) + if pattern and pattern.key in qstring: + return pattern.url.format(vid=qstring[pattern.key][0]) + return url + + +class CMSMedia(models.Model): + """Model of a CMS media.""" + + _name = 'cms.media' + _description = 'CMS Media' + _order = 'sequence, id' + _inherit = ['ir.attachment', + 'website.published.mixin', + 'website.orderable.mixin', + 'website.coremetadata.mixin', + 'website.image.mixin', + 'website.security.mixin'] + + name = fields.Char( + 'Name', + required=True, + translate=True, + ) + description = fields.Text( + 'Description', + translate=True, + ) + lang_id = fields.Many2one( + string='Language', + comodel_name='res.lang', + domain=lambda self: self._domain_lang_id(), + select=True, + ) + page_id = fields.Many2one( + string='Page', + comodel_name='cms.page', + ) + category_id = fields.Many2one( + string='Category', + comodel_name='cms.media.category', + compute='_compute_category_id', + store=True, + readonly=True, + ) + force_category_id = fields.Many2one( + string='Force category', + comodel_name='cms.media.category', + domain=[('active', '=', True)], + ) + icon = fields.Char( + 'Icon', + compute='_compute_icon', + readonly=True, + ) + website_url = fields.Char( + 'Website URL', + compute='_website_url', + readonly=True, + ) + + @api.model + def _domain_lang_id(self): + """Return options for lang.""" + try: + website = self.env['website'].get_current_website() + except RuntimeError: + # *** RuntimeError: object unbound + website = None + if website: + languages = website.language_ids + else: + languages = self.env['res.lang'].search([]) + return [('id', 'in', languages.ids)] + + @api.multi + @api.depends('force_category_id', 'mimetype', 'url') + def _compute_category_id(self): + """Compute media category.""" + default = self.env.ref('website_cms.media_category_document') + for item in self: + if item.force_category_id: + item.category_id = item.force_category_id + continue + else: + guessed = self.guess_category(item.mimetype) + if guessed: + item.category_id = guessed + continue + item.category_id = default + + def guess_category(self, mimetype): + """Guess media category by mimetype.""" + xmlid = None + # look for real media first + if mimetype in IMAGE_TYPES: + xmlid = 'website_cms.media_category_image' + if mimetype in VIDEO_TYPES: + xmlid = 'website_cms.media_category_video' + if mimetype in AUDIO_TYPES: + xmlid = 'website_cms.media_category_audio' + if xmlid: + return self.env.ref(xmlid) + # fallback to search by mimetype + category_model = self.env['cms.media.category'] + cat = category_model.search([ + ('mimetypes', '=like', '%{}%'.format(mimetype)), + ('active', '=', True) + ]) + return cat and cat[0] or None + + @api.multi + @api.depends('category_id', 'mimetype', 'url') + def _compute_icon(self): + """Compute media icon.""" + for item in self: + item.icon = item.get_icon() + + @api.model + def get_icon(self, mimetype=None): + """Return a CSS class for icon. + + You can override this to provide different + icons for your media. + """ + # TODO: improve this default + # maybe using the same icon from media category (?) + return 'fa fa-file-o' + + @api.multi + def _website_url(self): + """Override method defined by `website.published.mixin`.""" + url_pattern = '/web/content/{model}/{ob_id}/{field_name}/{filename}' + for item in self: + url = item.url + if not url: + url = url_pattern.format( + model=item._name, + ob_id=item.id, + field_name='datas', + filename=item.datas_fname or 'download', + ) + item.website_url = url + + @api.multi + def update_published(self): + """Publish / Unpublish this page right away.""" + self.write({'website_published': not self.website_published}) + + @api.model + def create(self, vals): + """Override to keep link w/ page resource and handle url. + + `ir.attachment` have a weak relation w/ related resources. + We want the user to be able to select a page, + but on the same time we need to keep this in sync w/ + attachment machinery. There you go! + """ + if vals.get('page_id') is not None: + vals['res_id'] = vals.get('page_id') + vals['res_model'] = vals.get('page_id') and 'cms.page' + # TODO: use noembed service + url = vals.get('url') + if url and 'youtube' in url: + vals['url'] = to_embed_url(url) + return super(CMSMedia, self).create(vals) + + @api.multi + def write(self, vals): + """Override to keep link w/ page resource.""" + if vals.get('page_id') is not None: + vals['res_id'] = vals.get('page_id') + vals['res_model'] = vals.get('page_id') and 'cms.page' + # TODO: use noembed service + url = vals.get('url') + if url and 'youtube' in url: + vals['url'] = to_embed_url(url) + return super(CMSMedia, self).write(vals) + + @api.model + def is_image(self, mimetype=None): + """Whether this is an image.""" + mimetype = mimetype or self.mimetype + if mimetype: + return mimetype in IMAGE_TYPES + # mimetype is computed on create + if self.url: + return guess_mimetype(self.url) in IMAGE_TYPES + + @api.model + def is_video(self): + """Whether this is a video.""" + return self.mimetype in VIDEO_TYPES + + # TODO: we should use services like http://noembed.com + @api.multi + @api.onchange('url', 'datas') + def _preload_preview_image(self): + """Preload preview image based on url and mimetype.""" + for item in self: + # use _compute_mimetype from ir.attachment + # that unfortunately is used only on create + values = {} + for fname in ('datas_fname', 'url', 'datas'): + values[fname] = getattr(item, fname) + item.mimetype = self._compute_mimetype(values) + + if item.is_image(): + if item.url: + image_content = download_image_from_url(item.url) + else: + image_content = item.datas + item.image = image_content + if item.url and 'youtube' in item.url: + image_content = download_image_from_url( + to_preview_url(item.url)) + item.image = image_content + + +class CMSMediaCategory(models.Model): + """Model of a CMS media category.""" + + _name = 'cms.media.category' + _inherit = 'website.orderable.mixin' + _description = 'CMS Media Category' + + name = fields.Char( + 'Name', + required=True, + translate=True, + ) + mimetypes = fields.Text( + 'Mimetypes', + help=('Customize mimetypes associated ' + 'to this category.') + ) + icon = fields.Char( + 'Icon', + ) + active = fields.Boolean('Active?', default=True) + + @api.model + def public_slug(self): + """Used to generate relative URL for category.""" + return slug(self) diff --git a/website_cms/models/cms_page.py b/website_cms/models/cms_page.py new file mode 100644 index 00000000..30304136 --- /dev/null +++ b/website_cms/models/cms_page.py @@ -0,0 +1,660 @@ +# -*- coding: utf-8 -*- + +# pylint: disable=E0401 +# pylint: disable=W0212 +import math +from openerp.http import request +from openerp import models +from openerp import fields +from openerp import api +from openerp import exceptions +from openerp import tools +from openerp.tools.translate import _ +from openerp.tools.translate import html_translate +from openerp.addons.base.ir.ir_ui_view import keep_query +from openerp.addons.website.models.website import slug +from openerp.addons.website.models.website import unslug +from openerp.addons.website_cms.utils import AttrDict + + +def to_slug(item): + """Force usage of item.name.""" + value = (item.id, item.name) + return slug(value) + + +VIEW_DOMAIN = [ + ('type', '=', 'qweb'), + ('cms_view', '=', True), +] +SIDEBAR_VIEW_DOMAIN = [ + ('type', '=', 'qweb'), + ('cms_sidebar', '=', True), +] + + +class CMSPage(models.Model): + """Model of a CMS page.""" + + _name = 'cms.page' + _description = 'CMS page' + _order = 'sequence, id' + _inherit = ['website.seo.metadata', + 'website.published.mixin', + 'website.image.mixin', + 'website.orderable.mixin', + 'website.coremetadata.mixin', + 'website.security.mixin', + 'website.redirect.mixin'] + + name = fields.Char( + 'Name', + required=True, + translate=True, + ) + description = fields.Text( + 'Description', + translate=True, + ) + body = fields.Html( + 'HTML Body', + translate=html_translate, + sanitize=False + ) + parent_id = fields.Many2one( + string='Parent', + comodel_name='cms.page', + domain=lambda self: self._domain_parent_id(), + ) + children_ids = fields.One2many( + string='Children', + inverse_name='parent_id', + comodel_name='cms.page' + ) + related_ids = fields.Many2many( + string='Related pages', + comodel_name='cms.page', + relation='cms_page_related_rel', + column1='from_id', + column2='to_id', + ) + tag_ids = fields.Many2many( + string='Tags', + comodel_name='cms.tag', + relation='cms_page_related_tag_rel', + column1='page_id', + column2='tag_id', + ) + # XXX 2016-03-30: we are not using this anymore + # because we can use cms.media since a while. + # It might be useful for other purposes, + # let's keep it for a while. + attachment_ids = fields.One2many( + string='Attachments', + inverse_name='res_id', + comodel_name='ir.attachment' + ) + media_ids = fields.One2many( + string='Media items', + inverse_name='res_id', + comodel_name='cms.media' + ) + type_id = fields.Many2one( + string='Page type', + comodel_name='cms.page.type', + default=lambda self: self._default_type_id() + ) + view_id = fields.Many2one( + string='View', + comodel_name='ir.ui.view', + domain=lambda self: VIEW_DOMAIN, + default=lambda self: self._default_view_id() + ) + sub_page_type_id = fields.Many2one( + string='Default page type for sub pages', + comodel_name='cms.page.type', + help=(u"You can select a page type to be used " + u"by default for each contained page."), + ) + sub_page_view_id = fields.Many2one( + string='Default page view for sub pages', + comodel_name='ir.ui.view', + help=(u"You can select a view to be used " + u"by default for each contained page."), + domain=lambda self: VIEW_DOMAIN, + ) + list_types_ids = fields.Many2many( + string='Types to list', + comodel_name='cms.page.type', + help=(u"You can select types of page to be used " + u"in `listing` views."), + ) + sidebar_view_ids = fields.Many2many( + string='Sidebar views', + comodel_name='ir.ui.view', + help=(u"Each view linked here will be rendered in the sidebar."), + domain=lambda self: SIDEBAR_VIEW_DOMAIN, + ) + sidebar_content = fields.Html( + 'Sidebar HTML', + translate=html_translate, + sanitize=False, + help=(u"Each template that enables customization in the sidebar " + u"must use this field to store content."), + ) + # sidebar_inherit = fields.Boolean( + # 'Sidebar Inherit', + # help=(u"If turned on, you'll see the same sidebar " + # u"into each contained page."), + # ) + nav_include = fields.Boolean( + 'Nav include', + default=False, + help=(u"Decide if this item " + u"should be included in main navigation."), + ) + path = fields.Char( + string='Path', + compute='_compute_path', + readonly=True, + store=True, + copy=False, + oldname='hierarchy' + ) + default_view_item_id = fields.Many2one( + string='Default view item', + comodel_name='cms.page', + help=(u"Selet an item to be used as default view " + u"for current page. "), + ) + + @api.multi + def write(self, vals): + """Make sure to refresh website nav cache.""" + self.ensure_one() + if 'nav_include' in vals: + self.env['website'].clear_caches() + return super(CMSPage, self).write(vals) + + @api.model + def create(self, vals): + """Make sure to refresh website nav cache.""" + res = super(CMSPage, self).create(vals) + if 'nav_include' in vals: + self.env['website'].clear_caches() + return res + + @api.multi + def unlink(self): + """Override to prevent deletion of pages w/ media attached.""" + # look for attached media + # and prevent deletion + media = self.env['cms.media'].search([('res_id', 'in', self.ids)]) + if media: + msg = _(u"You are trying to delete a page " + u"that has media items attached to it!") + raise exceptions.Warning(msg) + return super(CMSPage, self).unlink() + + @api.model + def _domain_parent_id(self): + # make sure we don't put sub-pages into news + # or any other unforeseen type + page_type = self.env.ref('website_cms.default_page_type') + return [('type_id', '=', page_type.id)] + + @api.multi + @api.constrains('parent_id') + def _check_parent_id(self): + """Make sure we cannot have parent = self.""" + self.ensure_one() + if self.parent_id and self.parent_id.id == self.id: + raise exceptions.ValidationError( + _(u'You cannot set the parent of a page ' + u'equal to the page itself. Page: "%s"') % self.name + ) + + @api.model + def _default_type_id(self): + page_type = self.env.ref('website_cms.default_page_type') + return page_type and page_type.id or False + + @api.model + def _default_view_id(self): + page_view = self.env.ref('website_cms.page_default') + return page_view and page_view.id or False + + @api.model + def build_public_url(self, item): + """Walk trough page path to build its public URL.""" + # XXX: this could be expensive... think about it! + # We could store it but then we need to update + # all the pages below a certain parent + # if the parent name changes or if a parent is moved/changed. + # We have the same problem with path: check for comment there. + # In the end the url will work nonetheless because + # the slug contains the object id + # but the path in the url will be wrong. + # Also, not using parents name to build the path + # is bad because we miss the categorization + # provided by each parents, that's good for + # good URLs, and you allow ppl to not + # put in each page name/title a whole keyworded + # description of the content itself. + current = item + parts = [to_slug(current), ] + while current.parent_id: + parts.insert(0, to_slug(current.parent_id)) + current = current.parent_id + public_url = '/cms/' + '/'.join(parts) + return public_url + + @api.multi + def _website_url(self, field_name, arg): + """Override method defined by `website.published.mixin`.""" + res = {} + for item in self: + res[item.id] = self.build_public_url(item) + return res + + # XXX: how to update path for the whole hierarchy + # whenever and ancestor parent/name + # - no matter the level - is updated? + # Right now we are supporting explicitely 3 levels, + # but is not nice and it limits your hierarchy + # We could override the write + # and trigger updating of path for all the items + # in the same path: but how to get this??? + @api.multi + @api.depends('parent_id', + 'parent_id.parent_id', + 'parent_id.parent_id.parent_id', + 'parent_id.parent_id.parent_id.parent_id') + def _compute_path(self): + for item in self: + item.path = self.build_path(item) + + @property + @api.model + def hierarchy(self): + """Walk trough page hierarchy and get a list of items.""" + self.ensure_one() + if not self.parent_id: + return () + current = self + parts = [] + while current.parent_id: + parts.insert(0, current.parent_id) + current = current.parent_id + return parts + + @api.model + def build_path(self, item): + """Walk trough page hierarchy to build its nested name.""" + if not item.parent_id: + return '/' + parts = [x.name for x in item.hierarchy] + parts.insert(0, '') + return '/'.join(parts) + + @api.model + def full_path(self, item=None): + """Full path for this item: includes item part.""" + item = item or self + if not item.parent_id: + return item.path + item.name + return item.path + '/' + item.name + + @api.multi + def name_get(self): + """Format displayed name.""" + if not self.env.context.get('include_path'): + return super(CMSPage, self).name_get() + res = [] + for item in self: + res.append((item.id, + item.path.rstrip('/') + '/' + item.name)) + return res + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=100): + """Allow search in path too.""" + args = args or [] + domain = [] + if name and self.env.context.get('include_path'): + domain = [ + '|', + ('name', operator, name), + ('path', operator, name), + ] + items = self.search(domain + args, limit=limit) + return items.name_get() + + @api.model + def get_root(self, item=None, upper_level=0): + """Walk trough page path to find root ancestor. + + URL is made of items' slug so we can jump + at any level by looking at path parts. + + Use `upper_level` to stop walking at a precise + hierarchy level. + """ + item = item or self + # 1st bit is `/cms` + bits = item.website_url.split('/')[2:] + try: + _slug = bits[upper_level] + except IndexError: + # safely default to real root + _slug = bits[0] + _, page_id = unslug(_slug) + return self.browse(page_id) + + @api.multi + def update_published(self): + """Publish / Unpublish this page right away.""" + self.write({'website_published': not self.website_published}) + + @api.multi + def open_children(self): + """Action to open tree view of children pages.""" + self.ensure_one() + domain = [ + ('parent_id', '=', self.id), + ] + context = { + 'default_parent_id': self.id, + } + for k in ('type_id', 'view_id'): + fname = 'sub_page_' + k + value = getattr(self, fname) + if value: + context['default_' + k] = value.id + return { + 'name': 'Children', + 'type': 'ir.actions.act_window', + 'res_model': 'cms.page', + 'target': 'current', + 'view_type': 'form', + 'view_mode': 'tree,form', + 'domain': domain, + 'context': context, + } + + @api.multi + def open_media(self): + """Action to open tree view of contained media.""" + self.ensure_one() + domain = [ + ('page_id', '=', self.id), + ] + context = { + 'default_page_id': self.id, + } + return { + 'name': 'Media', + 'type': 'ir.actions.act_window', + 'res_model': 'cms.media', + 'target': 'current', + 'view_type': 'form', + 'view_mode': 'tree,form', + 'domain': domain, + 'context': context, + } + + @api.model + def get_listing(self, published=True, + nav=None, types_ids=None, + order=None, item=None, + path=None, types_ref=None, + incl_tags=False, tag_ids=None): + """Return items to be listed. + + Tweak filtering by: + + `published` to show published/unpublished items + "website_cms.cms_manager" group bypass this; + `nav` to show nav-included items; + `types_ids` to limit listing to specific page types; + `types_ref` to limit listing to specific page types; + by xmlid refs + `order` to override ordering by sequence; + `path` to search in a specific path instead of + just listing current item's children; + `incl_tags` to include items related by same tags; + `tag_ids` to filter upone specific tags. + + By default filter w/ `list_types_ids` if valued. + """ + item = item or self + base_domain = [] + # use specific `published` items but bypass it if manager + if published is not None and \ + not self.env.user.has_group('website_cms.cms_manager'): + base_domain.append(('website_published', '=', published)) + + if nav is not None: + base_domain.append(('nav_include', '=', nav)) + + if types_ref: + if isinstance(types_ref, basestring): + types_ref = (types_ref, ) + types_ids = [self.env.ref(x).id for x in types_ref] + + types_ids = types_ids or ( + item.list_types_ids and item.list_types_ids.ids) + + if types_ids: + base_domain.append( + ('type_id', 'in', types_ids) + ) + + hierarchy_domain = [] + if path is None: + hierarchy_domain.append(('parent_id', '=', item.id)) + else: + hierarchy_domain.append(('path', '=like', path + '%')) + + # handle domain for tags + tags_domain = [] + if incl_tags or tag_ids: + # we want to apply the same criterias for all items + # so we merge the base domain + tag_ids = tag_ids or item.tag_ids.ids + if tag_ids: + tags_domain = [ + ('tag_ids', 'in', tag_ids), + ] + base_domain + # plus we exclude the current item + # if we are looking up by path + if path is None: + tags_domain.append(('id', '!=', item.id, )) + + # prepare final domain + # XXX: this could be done better... + domain = [] + if tags_domain: + if len(base_domain + hierarchy_domain) > 1: + domain = ['|', '&', ] + \ + base_domain + hierarchy_domain + else: + domain = ['|', ] + \ + base_domain + hierarchy_domain + if len(tags_domain) > 1: + domain += ['&'] + tags_domain + else: + domain += tags_domain + else: + domain = base_domain + hierarchy_domain + + order = order or 'sequence asc' + pages = self.search(domain, order=order) + return pages + + def pager(self, total, page=1, step=10, + scope=5, base_url='', url_getter=None): + """Custom pager implementation.""" + # Compute Pager + page_count = int(math.ceil(float(total) / step)) + + page = max(1, min(int(page if str(page).isdigit() else 1), page_count)) + scope -= 1 + + pmin = max(page - int(math.floor(scope / 2)), 1) + pmax = min(pmin + scope, page_count) + + if pmax - pmin < scope: + pmin = pmax - scope if pmax - scope > 0 else 1 + + if not base_url: + # default to current page url, and drop /listing path if any + base_url = request.httprequest.path.split('/page')[0] + + qstring = keep_query() + + def get_url(page_nr): + if page_nr <= 1: + _url = base_url + else: + _url = "{}/page/{}".format(base_url, page_nr) + if qstring: + _url += '?' + qstring + return _url + + if url_getter is None: + url_getter = get_url + + page_prev = max(pmin, page - 1) + page_next = min(pmax, page + 1) + prev_url = url_getter(page_prev) + next_url = url_getter(page_next) + last_url = url_getter(pmax) + paginated = AttrDict({ + "items_count": total, + "need_nav": page_count > 1, + "page_count": page_count, + "has_prev": page > pmin, + "has_next": page < pmax, + "current": AttrDict({ + 'url': url_getter(page), + 'num': page, + }), + "page_prev": AttrDict({ + 'url': prev_url, + 'num': page_prev, + }), + "page_next": AttrDict({ + 'url': next_url, + 'num': page_next, + }), + "page_last": AttrDict({ + 'url': last_url, + 'num': pmax, + }), + "pages": [ + AttrDict({'url': url_getter(i), 'num': i}) + for i in xrange(pmin, pmax + 1) + ] + }) + return paginated + + def paginate(self, all_items, page=1, step=10): + """Prepare pagination.""" + total = len(all_items) + start = (page and page - 1) or 0 + step = step or 10 + items = all_items[start * step: (start * step) + step] + paginated = self.pager(total, page=page, step=step) + paginated['results'] = items + return paginated + + def get_paginated_listing(self, page=0, step=10, **kw): + """Get items for listing sliced for pagination.""" + all_items = self.get_listing(**kw) + return self.paginate(all_items, page=page, step=step) + + def get_paginated_media_listing(self, page=0, step=10, **kw): + """Get items for listing sliced for pagination.""" + # XXX: shoul we merge this w/ above listing? + all_items = self.get_media_listing(**kw) + return self.paginate(all_items, page=page, step=step) + + @api.model + def get_media_listing(self, published=True, + category=None, order=None, + item=None, path=None, + lang=None): + """Return items to be listed. + + Tweak filtering by: + + `published` to show published/unpublished items or both + `category` a category obj to limit listing to specific category + `lang` a lang obj or lang code to limit listing to specific language + `order` to override ordering by sequence + + # XXX: should we provide a path for media too? + `path` to search in a specific path instead of + just listing current item's children. + + By default filter w/ `list_types_ids` if valued. + """ + item = item or self + search_args = [] + if path is None: + search_args.append(('res_id', '=', item.id)) + else: + search_args.append(('path', '=like', path + '%')) + + # use specific `published` items but bypass it if manager + if published is not None and \ + not self.env.user.has_group('website_cms.cms_manager'): + search_args.append(('website_published', '=', published)) + + if category is not None: + search_args.append(('category_id', '=', category.id)) + + if isinstance(lang, basestring): + lang = self.env['res.lang'].search([('code', '=', lang)]) + + if lang: + search_args.append(('lang_id', '=', lang.id)) + + order = order or 'sequence asc' + media = self.env['cms.media'].search( + search_args, + order=order + ) + return media + + @api.model + def get_translations(self): + """Return translations for this page.""" + return self._get_translations(page_id=self.id) + + @tools.ormcache('page_id') + def _get_translations(self, page_id=None): + """Return all available translations for a page. + + We assume that a page is translated when the name is. + """ + query = """ + SELECT lang,value FROM ir_translation + WHERE res_id={page_id} + AND state='translated' + AND type='model' + AND name='cms.page,name' + """.format(page_id=page_id) + self.env.cr.execute(query) + res = self.env.cr.fetchall() + return dict(res) + + +class CMSPageType(models.Model): + """Model of a CMS page type.""" + + _name = 'cms.page.type' + _description = 'CMS page type' + + name = fields.Char('Name', translate=True) diff --git a/website_cms/models/cms_tag.py b/website_cms/models/cms_tag.py new file mode 100644 index 00000000..5f58e016 --- /dev/null +++ b/website_cms/models/cms_tag.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +# pylint: disable=E0401 +# pylint: disable=W0212 +from openerp import models +from openerp import fields + + +class CMSTag(models.Model): + """Model of a CMS tag.""" + + _name = 'cms.tag' + _description = 'Website tag mixin' + _inherit = ['website.tag.mixin', ] + _order = 'name' + + page_ids = fields.Many2many( + string='Related pages', + comodel_name='cms.page', + relation='cms_tag_related_page_rel', + column1='tag_id', + column2='page_id', + ) diff --git a/website_cms/models/ir_attachment.py b/website_cms/models/ir_attachment.py new file mode 100644 index 00000000..aa6584fa --- /dev/null +++ b/website_cms/models/ir_attachment.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import itertools + +from openerp import models +from openerp import SUPERUSER_ID + + +class IRAttachment(models.Model): + """Fix attachment search. + + `cms.media` inherit from `ir.attachment` + so the table model for searches is not `ir_attachment` + but `cms_media`. See `FIX THIS QUERY` line. + + Yes, we need to submit a patch + PR for this ;) + + https://github.com/odoo/odoo/blob/9.0/openerp/addons/base/ir/ir_attachment.py#L339 + """ + + _inherit = 'ir.attachment' + + def _search(self, cr, uid, args, offset=0, limit=None, order=None, + context=None, count=False, access_rights_uid=None): + # add res_field=False in domain if not present; the arg[0] trick below + # works for domain items and '&'/'|'/'!' operators too + if not any(arg[0] in ('id', 'res_field') for arg in args): + args.insert(0, ('res_field', '=', False)) + + ids = models.Model._search(self, cr, uid, args, offset=offset, + limit=limit, order=order, + context=context, count=False, + access_rights_uid=access_rights_uid) + if uid == SUPERUSER_ID: + # rules do not apply for the superuser + return len(ids) if count else ids + + if not ids: + return 0 if count else [] + + # Work with a set, as list.remove() is prohibitive for large lists of documents + # (takes 20+ seconds on a db with 100k docs during search_count()!) + orig_ids = ids + ids = set(ids) + + # For attachments, the permissions of the document they are attached to + # apply, so we must remove attachments for which the user cannot access + # the linked document. + # Use pure SQL rather than read() as it is about 50% faster + # for large dbs (100k+ docs), + # and the permissions are checked in super() and below anyway. + + # FIX THIS QUERY TO MAKE IT WORK + # W/ MODELS THAT INHERITS FROM ir.attachments + query = """SELECT id, res_model, res_id, public + FROM {} WHERE id = ANY(%s)""".format(self._table) + cr.execute(query, (list(ids),)) + targets = cr.dictfetchall() + model_attachments = {} + for target_dict in targets: + if not target_dict['res_model'] or target_dict['public']: + continue + # model_attachments = { 'model': { 'res_id': [id1,id2] } } + model_attachments.setdefault( + target_dict['res_model'], {}).setdefault( + target_dict['res_id'] or 0, set()).add(target_dict['id']) + + # To avoid multiple queries for each attachment found, checks are + # performed in batch as much as possible. + ima = self.pool.get('ir.model.access') + for model, targets in model_attachments.iteritems(): + if model not in self.pool: + continue + if not ima.check(cr, uid, model, 'read', False): + # remove all corresponding attachment ids + for attach_id in itertools.chain(*targets.values()): + ids.remove(attach_id) + continue # skip ir.rule processing, these ones are out already + + # filter ids according to what access rules permit + target_ids = targets.keys() + allowed_ids = [0] + self.pool[model].search( + cr, uid, [('id', 'in', target_ids)], context=context) + disallowed_ids = set(target_ids).difference(allowed_ids) + for res_id in disallowed_ids: + for attach_id in targets[res_id]: + ids.remove(attach_id) + + # sort result according to the original sort ordering + result = [id for id in orig_ids if id in ids] + return len(result) if count else list(result) diff --git a/website_cms/models/ir_ui_view.py b/website_cms/models/ir_ui_view.py new file mode 100644 index 00000000..c22f8a83 --- /dev/null +++ b/website_cms/models/ir_ui_view.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +from openerp import models +from openerp import fields +from openerp import api +from openerp.addons.web.http import request +from openerp.addons.website.models.website import url_for as url_for_orig + + +def url_for(path_or_uri, lang=None, main_object=None): + + if main_object and main_object._name == 'cms.page': + if lang and not lang == request.website.default_lang_code \ + and not request.params.get('edit_translations') \ + and not request.website.is_publisher(): + # avoid building URLs for not translated contents + # unless we are translating it + avail_transl = main_object.get_translations() + if lang not in avail_transl: + return '/' + lang + + return url_for_orig(path_or_uri, lang=lang) + + +class IRUIView(models.Model): + _inherit = "ir.ui.view" + + cms_view = fields.Boolean( + 'CMS view?', + help=u"This view will be available as a CMS view." + ) + cms_sidebar = fields.Boolean( + 'CMS sidebar?', + help=u"This view will be available as a CMS sidebar view." + ) + + @api.model + def _prepare_qcontext(self): + """Override to inject our custom rendering variables.""" + qcontext = super(IRUIView, self)._prepare_qcontext() + qcontext['url_for'] = url_for + qcontext['is_cms_manager'] = \ + self.env.user.has_group('website_cms.cms_manager') + return qcontext diff --git a/website_cms/models/website.py b/website_cms/models/website.py new file mode 100644 index 00000000..7ab4e6c9 --- /dev/null +++ b/website_cms/models/website.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- + +# pylint: disable=E0401 + +from openerp import models +# from openerp import fields +from openerp import api +from openerp import tools +from openerp.addons.web.http import request +from openerp.addons.website.models.website import unslug + +from openerp.addons.website_cms.utils import AttrDict + +import urlparse +import urllib + + +class Website(models.Model): + """Override website model.""" + + _inherit = "website" + + @api.model + @tools.ormcache('max_depth', 'pages', 'nav', 'type_ids', 'published') + def get_nav_pages(self, max_depth=3, pages=None, + nav=True, type_ids=None, + published=True): + """Return pages for navigation. + + Given a `max_depth` build a list containing + a hierarchy of menu and sub menu. + Only `cms.page` having these flags turned on: + * `website_published` + + By default consider only `nav_include` items. + You can alter this behavior by passing + `nav=False` to list contents non enabled for nav + or `nav=None` to ignore nav settings. + + * `type_ids`: filter pages by a list of types' ids + + By default consider only `website_cms.default_page_type` type. + + * `published`: filter pages by a publishing state + """ + type_ids = type_ids or [ + self.env.ref('website_cms.default_page_type').id, ] + result = [] + if pages is None: + search_args = [ + ('parent_id', '=', False), + ('type_id', 'in', type_ids) + ] + if published is not None: + search_args.append(('website_published', '=', published)) + + if nav is not None: + search_args.append(('nav_include', '=', nav)) + + sec_model = self.env['cms.page'] + pages = sec_model.search( + search_args, + order='sequence asc' + ) + for item in pages: + result.append( + self._build_page_item(item, + max_depth=max_depth, + nav=nav, + type_ids=type_ids, + published=published) + ) + return result + + @api.model + def _build_page_item(self, item, max_depth=3, + nav=True, type_ids=None, + published=True): + """Recursive method to build main menu items. + + Return a dict-like object containing: + * `name`: name of the page + * `url`: public url of the page + * `children`: list of children pages + * `nav`: nav_include filtering + * `type_ids`: filter pages by a list of types' ids + * `published`: filter pages by a publishing state + """ + depth = max_depth or 3 # safe default to avoid infinite recursion + sec_model = self.env['cms.page'] + # XXX: consider to define these args in the main method + search_args = [ + ('parent_id', '=', item.id), + ] + if published is not None: + search_args.append(('website_published', '=', published)) + if nav is not None: + search_args.append(('nav_include', '=', nav)) + if type_ids is not None: + search_args.append(('type_id', 'in', type_ids)) + subs = sec_model.search( + search_args, + order='sequence asc' + ) + while depth != 0: + depth -= 1 + children = [self._build_page_item(x, max_depth=depth) + for x in subs] + res = AttrDict({ + 'id': item.id, + 'name': item.name, + 'url': item.website_url, + 'children': children, + 'website_published': item.website_published, + }) + return res + + @api.model + def safe_image_url(self, record, field, size=None, check=True): + """Return image url if exists.""" + sudo_record = record.sudo() + if hasattr(sudo_record, field): + if not getattr(sudo_record, field) and check: + # no image here yet + return '' + return self.image_url(record, field, size=size) + return '' + + @api.model + def download_url(self, item, field_name, filename=''): + if not filename: + # XXX: we should calculate the filename from the field + # but in some cases we do not have a proxy for the value + # like for attachments, so we don't have a way + # to get the original name of the file. + filename = 'download' + url = '/web/content/{model}/{ob_id}/{field_name}/{filename}' + return url.format( + model=item._name, + ob_id=item.id, + field_name=field_name, + filename=filename, + ) + + @api.model + def get_media_categories(self, active=True): + """Return all available media categories.""" + return self.env['cms.media.category'].search( + [('active', '=', active)]) + + def get_alternate_languages(self, cr, uid, ids, + req=None, context=None, + main_object=None): + """Override to drop not available translations.""" + langs = super(Website, self).get_alternate_languages( + cr, uid, ids, req=req, context=context) + + avail_langs = None + if main_object and main_object._name == 'cms.page': + # avoid building URLs for not translated contents + avail_transl = main_object.get_translations() + avail_langs = [x.split('_')[0] for x in avail_transl.iterkeys()] + + if avail_langs is not None: + langs = [lg for lg in langs if lg['short'] in avail_langs] + return langs + + def safe_hasattr(self, item, attr): + """Return true if given item has given attr. + + The following does not work in templates: + + + + main_object.sidebar_view_ids => throws an error + since there's no safe "hasattr" when evaluating + + QWebException: "'NoneType' object is not callable" while evaluating + "main_object and getattr(main_object, 'sidebar_view_ids', None)" + + + evaluating `hasattr` or `getattr` in template fails :S + """ + return hasattr(item, attr) + + def referer_to_page(self): + """Translate HTTP REFERER to cms page if possible.""" + ref = request.httprequest.referrer + if not ref: + return None + parsed = urlparse.urlparse(ref) + if '/cms/' in parsed.path: + last_bit = parsed.path.split('/')[-1] + page_id = unslug(last_bit)[-1] + return request.env['cms.page'].browse(page_id) + return None + + def is_cms_page(self, obj): + """Check if given obj is a cms page.""" + return obj is not None and getattr(obj, '_name', None) == 'cms.page' + + def cms_add_link(self, main_object=None): + """Retrieve add cms page link.""" + # XXX: avoid adding sub pages inside news. + # In the future we might consider controlling this + # via cms.page.type configuration + + url = '/cms' + if self.is_cms_page(main_object): + news_type = request.env.ref('website_cms.news_page_type') + if main_object.type_id.id != news_type.id: + url = main_object.website_url + elif main_object.parent_id \ + and main_object.parent_id.type_id.id != news_type.id: + url = main_object.parent_id.website_url + return '{}/add-page'.format(url) + + def cms_edit_link(self, main_object=None): + """Retrieve edit cms page link.""" + if self.is_cms_page(main_object): + return '{}/edit-page'.format(main_object.website_url) + return '' + + def cms_edit_backend_link(self, main_object=None): + """Retrieve edit in backend cms page link.""" + base = "/web#return_label=Website" + data = { + 'view_type': 'form', + 'model': main_object._name, + 'id': main_object.id, + } + if self.is_cms_page(main_object): + data['action'] = self.env.ref('website_cms.action_cms_pages').id + qstring = urllib.urlencode(data) + return '{}&{}'.format(base, qstring) + + def cms_can_edit(self, main_object=None): + """Retrieve edit cms page link.""" + if self.is_cms_page(main_object): + is_manager = self.env.user.has_group('website_cms.cms_manager') + is_owner = main_object.create_uid.id == self.env.user.id + return is_owner or is_manager + return False diff --git a/website_cms/models/website_menu.py b/website_cms/models/website_menu.py new file mode 100644 index 00000000..9f349068 --- /dev/null +++ b/website_cms/models/website_menu.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +# pylint: disable=E0401 +# pylint: disable=R0903 + +from openerp import models +from openerp import fields + + +class WebsiteMenu(models.Model): + """Override website menu model.""" + + _inherit = "website.menu" + + cms_page_id = fields.Many2one( + string='CMS Page', + comodel_name='cms.page' + ) diff --git a/website_cms/models/website_mixins.py b/website_cms/models/website_mixins.py new file mode 100644 index 00000000..887889d4 --- /dev/null +++ b/website_cms/models/website_mixins.py @@ -0,0 +1,179 @@ +"""Website mixins.""" + +# -*- coding: utf-8 -*- + +# pylint: disable=E0401 +# pylint: disable=W0212 +# pylint: disable=R0903 +# pylint: disable=R0201 + + +from openerp import models +from openerp import fields +from openerp import api +from openerp.tools import image as image_tools + + +class WebsiteImageMixin(models.AbstractModel): + """Image mixin for website models. + + Provide fields: + + * `image` (full size image) + * `image_medium` (`image` resized) + * `image_thumb` (`image` resized) + """ + + _name = "website.image.mixin" + _description = "A mixin for providing image features" + + image = fields.Binary('Image', attachment=True) + image_medium = fields.Binary( + 'Medium', + compute="_get_image", + store=True, + attachment=True + ) + image_thumb = fields.Binary( + 'Thumbnail', + compute="_get_image", + store=True, + attachment=True + ) + + @api.depends('image') + @api.multi + def _get_image(self): + """Calculate resized images.""" + for record in self: + if record.image: + record.image_medium = \ + image_tools.image_resize_image_big(record.image) + record.image_thumb = \ + image_tools.image_resize_image_medium(record.image, + size=(128, 128)) + else: + record.image_medium = False + record.image_thumb = False + + +class WebsiteOrderableMixin(models.AbstractModel): + """Orderable mixin to allow sorting of objects. + + Add a sequence field that you can use for sorting items + in tree views. Add the field to a view like this: + + + + Default sequence is calculated as last one + 1. + """ + + _name = "website.orderable.mixin" + _description = "A mixin for providing sorting features" + _order = 'sequence, id' + + sequence = fields.Integer( + 'Sequence', + required=True, + default=lambda self: self._default_sequence() + ) + + @api.model + def _default_sequence(self): + last = self.search([], limit=1, order='sequence desc') + if not last: + return 0 + return last.sequence + 1 + + +class WebsiteCoreMetadataMixin(models.AbstractModel): + """Expose core fields to be usable in backend and frontend. + + Fields: + * `create_date` + * `create_uid` + * `write_date` + * `write_uid` + * `published_date` + * `published_uid` + """ + + _name = "website.coremetadata.mixin" + _description = "A mixin for exposing core metadata fields" + + create_date = fields.Datetime( + 'Created on', + select=True, + readonly=True, + ) + create_uid = fields.Many2one( + 'res.users', + 'Author', + select=True, + readonly=True, + ) + write_date = fields.Datetime( + 'Created on', + select=True, + readonly=True, + ) + write_uid = fields.Many2one( + 'res.users', + 'Last Contributor', + select=True, + readonly=True, + ) + published_date = fields.Datetime( + 'Published on', + ) + published_uid = fields.Many2one( + 'res.users', + 'Published by', + select=True, + readonly=True, + ) + + @api.multi + def write(self, vals): + """Update published date.""" + if vals.get('website_published'): + vals['published_date'] = fields.Datetime.now() + vals['published_uid'] = self.env.user.id + return super(WebsiteCoreMetadataMixin, self).write(vals) + + +class WebsiteTagMixin(models.AbstractModel): + """Base mixin for website tags.""" + + _name = 'website.tag.mixin' + _description = 'Website tag mixin' + _order = 'name' + + name = fields.Char('Name', required=True) + + _sql_constraints = [ + ('name_uniq', 'unique (name)', "Tag name already exists !"), + ] + + @api.model + def _tag_to_write_vals(self, tags=''): + """Convert string values to tag ids. + + Almost barely copied from `website_forum.forum`. + """ + vals = [] + existing_keep = [] + for tag in filter(None, tags.split(',')): + if tag.startswith('_'): # it's a new tag + # check that not already created meanwhile + # or maybe excluded by the limit on the search + tag_ids = self.search([('name', '=', tag[1:])]) + if tag_ids: + existing_keep.append(int(tag_ids[0])) + else: + if len(tag) and len(tag[1:].strip()): + vals.append((0, 0, {'name': tag[1:]})) + else: + existing_keep.append(int(tag)) + vals.insert(0, [6, 0, existing_keep]) + return vals diff --git a/website_cms/models/website_redirect_mixin.py b/website_cms/models/website_redirect_mixin.py new file mode 100644 index 00000000..e85f8bfb --- /dev/null +++ b/website_cms/models/website_redirect_mixin.py @@ -0,0 +1,134 @@ +"""Website mixins.""" + +# -*- coding: utf-8 -*- + +# pylint: disable=E0401 +# pylint: disable=W0212 +# pylint: disable=R0903 +# pylint: disable=R0201 + + +from openerp import models +from openerp import fields +from openerp import api +from openerp import _ +from openerp.addons.website_cms.utils import AttrDict + + +class WebsiteRedirectMixin(models.AbstractModel): + """Handle redirection for inheriting objects. + + Fields: + * `redirect_to_id` + """ + + _name = "website.redirect.mixin" + _description = "Website Redirect Mixin" + + redirect_to_id = fields.Many2one( + string='Redirect to', + comodel_name='cms.redirect', + help=(u"If valued, you will be redirected " + u"to selected item permanently. "), + domain=[('create_date', '=', False)] + ) + + @api.model + def has_redirect(self): + """Return true if we have a redirection.""" + return bool(self.redirect_to_id) + + @api.model + def get_redirect_data(self): + """Return redirection data.""" + if not self.redirect_to_id: + return None + return AttrDict({ + 'url': self.redirect_to_id.website_url, + 'status': int(self.redirect_to_id.status), + }) + + +class CMSLinkMixin(models.AbstractModel): + """A base mixin for a website model linking another model.""" + + _name = "cms.link.mixin" + _description = "CMS Link Mixin" + + cms_page_id = fields.Many2one( + string='CMS Page', + comodel_name='cms.page', + ) + view_id = fields.Many2one( + string='Odoo View', + comodel_name='ir.ui.view', + domain=[('page', '=', True)] + ) + url = fields.Char( + 'Custom URL', + ) + website_url = fields.Char( + string='Website URL', + compute='_compute_website_url', + readonly=True + ) + + @api.multi + def _compute_website_url(self): + """Compute redirect URL.""" + for item in self: + if item.url: + item.website_url = item.url + continue + if item.view_id: + item.website_url = '/page/{}'.format(item.view_id.key) + continue + if item.cms_page_id: + item.website_url = item.cms_page_id.website_url + continue + + @api.multi + def name_get(self): + """Format displayed name.""" + res = [] + for item in self: + name = [ + 'Go to >', + ] + if self.url: + name.append(item.url[:50]) + if self.view_id: + name.append('View:' + item.view_id.name) + if self.cms_page_id: + name.append('Page: ' + self.cms_page_id.name) + name.append('| Status: ' + self.status) + res.append((item.id, ' '.join(name))) + return res + + +class CMSRedirect(models.Model): + """Add some more features here. + + Fields: + * `redirect_to_id` + """ + + _name = "cms.redirect" + _inherit = "cms.link.mixin" + _description = "CMS Redirect record" + + name = fields.Char( + string='Description', + ) + status = fields.Selection( + string='Redirect HTTP Status', + default=u'301', + selection='_selection_status', + ) + + @api.model + def _selection_status(self): + return [ + (u'301', _(u"301 Moved Permanently")), + (u'307', _(u"307 Temporary Redirect")), + ] diff --git a/website_cms/models/website_security_mixin.py b/website_cms/models/website_security_mixin.py new file mode 100644 index 00000000..e75f0b8b --- /dev/null +++ b/website_cms/models/website_security_mixin.py @@ -0,0 +1,232 @@ +"""Website mixins.""" + +# -*- coding: utf-8 -*- + +# pylint: disable=E0401 +# pylint: disable=W0212 +# pylint: disable=R0903 +# pylint: disable=R0201 +# pylint: disable=R0913 +# pylint: disable=R0914 + + +from openerp import models +from openerp import fields +from openerp import api +from openerp.addons.website.models import ir_http +from openerp.http import request +from openerp import SUPERUSER_ID + +from werkzeug.exceptions import NotFound + + +class WebsiteSecurityMixin(models.AbstractModel): + """Provide basic logic for protecting website items. + + Features: TODO + * `foo` + * `bar` + """ + + _name = "website.security.mixin" + _description = "A mixin for protecting website content" + # admin groups that bypass security checks + _admin_groups = ( + 'base.group_website_publisher', + ) + + view_group_ids = fields.Many2many( + string='View Groups', + comodel_name='res.groups', + help=(u"Restrict `view` access to this item to specific groups. " + u"No group means anybody can see it.") + ) + + def _is_admin(self): + for gid in self._admin_groups: + if self.env.user.has_group(gid): + return True + return False + + def _check_user_groups(self, group_ids): + """Check wheter current user matches given groups.""" + return any([x in self.env.user.groups_id._ids + for x in group_ids]) + + def _bypass_check_permission(self): + """Bypass checking permissions. + + Bypass if: + + * you are super-user + * you are in a group listed in `_admin_groups` + """ + if self._uid == SUPERUSER_ID: + return True + admin_groups = [] + for k in self._admin_groups: + ref = self.env.ref(k) + if ref: + admin_groups.append(ref.id) + return self._check_user_groups(admin_groups) + + @api.model + def check_permission(self, obj=None, mode='view'): + """Check permission on given object. + + @param `obj`: the item to check. + If not `obj` is passed `self` will be used. + + @param `mode`: the permission mode to check. + """ + obj = obj or self + fname = mode + '_group_ids' + + if fname not in obj._model: + # nothing to check here + return True + + # obj comes w/ a temporary env, + # w/ uid beeing an instance of ir_http.RequestUID + # which is not suitable for std ORM operations. + # Let's make sure we get the right user here! + if request.session.uid: + _obj = obj.with_env(self.env(user=request.session.uid)) + else: + _obj = obj.with_env(request.env) + + if _obj._bypass_check_permission(): + return True + if not _obj[fname]: + # no groups, fine + return True + + check1 = _obj._check_user_groups(_obj[fname]._ids) + return check1 + + @api.model + def _search(self, args, offset=0, limit=None, + order=None, count=False, access_rights_uid=None): + """Implement security check based on `website_published` + and `view_group_ids`. + + The goal is to hide items that current user cannot see + in the frontend (like navigation, listings) + and, why not, even in the backend. + + Here if we find a contraint on any group + we filter out what the current user cannot view. + + Disclaimer: yes, we could use `ir.rule`s for this + but since we want the end user to select a group + easily without having to get in touch w/ rules, + we chosed this way. + + To use `ir.rule` we'd need to extend them + w/ a relation to real objects and put our hands + inside rules computation, and keep user settings + in sync with rules, etc, etc. + The computation of the domain for a rule, + applies only to groups associated to current user. + We want the other way around: apply permission check + to make sure that if a user does not belong + to a specific group, he/she cannot see the item. + Hence, you must define a global rule and add it + a new field for a groups, and then mess around + w/ domain computation. + + As of today, this is still a prototype + and we might change/improve this check ;) + + Finally, the `SecureModelConverter` base klass + is using `exists` to check if the item exists. + This method in turns does not check for security. + Hence, we are trying to use the same mechanism + for both cases. + """ + # if no editing rights, hide not published content + published_filter = any(['website_published' in x for x in args]) + if not self._is_admin() and not published_filter: + args.append(('website_published', '=', True)) + + res = super(WebsiteSecurityMixin, self)._search( + args, offset=offset, limit=limit, order=order, + count=count, access_rights_uid=access_rights_uid + ) + if 'view_group_ids' not in self._model: + return res + + if self._bypass_check_permission(): + return res + + # retrieve relation params + view_field = self._model._fields['view_group_ids'] + relation = view_field.relation + ob_col = view_field.column1 + group_col = view_field.column2 + + # search for all mapping w/ groups + query = ( + "SELECT {ob_col}, array_agg({group_col}) " + "FROM {relation} " + "GROUP BY {ob_col}" + ).format(ob_col=ob_col, + relation=relation, + group_col=group_col) + self.env.cr.execute(query) + tocheck = self.env.cr.fetchall() + + # finally exclude all the ids + # that do not match user's group + for oid, group_ids in tocheck: + if isinstance(res, list) and oid not in res\ + or isinstance(res, (int, long)) and oid != res: + continue + if not self._check_user_groups(group_ids): + res.remove(oid) + return res + + +class SecureModelConverter(ir_http.ModelConverter): + """A new model converter w/ security check. + + The base model converter is responsible of + converting a slug to a real browse object. + + We want to intercept this on demand and apply security check, + so that whichever model exposes `website.security.mixin` + capabilities and uses this route converter, + will be protected based on mixin behaviors. + + You can use it like this: + + @http.route(['/foo/']) + """ + + def to_python(self, value): + """Get python record and check it. + + If no permission here, just raise a NotFound! + """ + record = super(SecureModelConverter, self).to_python(value) + if isinstance(record, WebsiteSecurityMixin) \ + and not record.check_permission(mode='view'): + raise NotFound() + return record + + +class IRHTTP(models.AbstractModel): + """Override to add our model converter. + + The new model converter make sure to apply security checks. + + See `website.models.ir_http` for default implementation. + """ + + _inherit = 'ir.http' + + def _get_converters(self): + return dict( + super(IRHTTP, self)._get_converters(), + secure_model=SecureModelConverter, + ) diff --git a/website_cms/security/groups.xml b/website_cms/security/groups.xml new file mode 100644 index 00000000..46e0a0e5 --- /dev/null +++ b/website_cms/security/groups.xml @@ -0,0 +1,24 @@ + + + + + + CMS Management + + + + + CMS Manager + + + + + + + CMS Media Category Manager + + + + + + diff --git a/website_cms/security/ir.model.access.csv b/website_cms/security/ir.model.access.csv new file mode 100644 index 00000000..03d6ef17 --- /dev/null +++ b/website_cms/security/ir.model.access.csv @@ -0,0 +1,13 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_cms_page_public,access_cms_page public,model_cms_page,,1,0,0,0 +access_cms_page_designer,access_cms_page designer,model_cms_page,base.group_website_designer,1,1,1,1 +access_cms_page_type_public,access_cms_page_type public,model_cms_page_type,,1,0,0,0 +access_cms_page_type_mngr,access_cms_page_type manager,model_cms_page_type,website_cms.cms_manager,1,1,1,1 +access_cms_redirect_public,access_cms_redirect public,model_cms_redirect,,1,0,0,0 +access_cms_redirect_mngr,access_cms_redirect manager,model_cms_redirect,website_cms.cms_manager,1,1,1,1 +access_cms_media_public,access_cms_media public,model_cms_media,,1,0,0,0 +access_cms_media_mngr,access_cms_media manager,model_cms_media,website_cms.cms_manager,1,1,1,1 +access_cms_media_category_public,access_cms_media_category public,model_cms_media_category,,1,0,0,0 +access_cms_media_category_mngr,access_cms_media_category manager,model_cms_media_category,website_cms.cms_media_category_manager,1,1,1,1 +access_cms_tag_public,access_cms_tag public,model_cms_tag,,1,0,0,0 +access_cms_tag_mngr,access_cms_tag manager,model_cms_tag,website_cms.cms_manager,1,1,1,1 diff --git a/website_cms/static/src/js/website_cms.js b/website_cms/static/src/js/website_cms.js new file mode 100644 index 00000000..71ca92da --- /dev/null +++ b/website_cms/static/src/js/website_cms.js @@ -0,0 +1,68 @@ +odoo.define('website_cms.website_cms', function (require) { + 'use strict'; + + var ajax = require('web.ajax'); + var core = require('web.core'); + var website = require('website.website'); + + var _t = core._t; + + var lastsearch; + + $('input[name="tag_ids"].js_select2').select2({ + tags: true, + tokenSeparators: [",", " ", "_"], + maximumInputLength: 35, + minimumInputLength: 2, + maximumSelectionSize: 5, + lastsearch: [], + createSearchChoice: function (term) { + if ($(lastsearch).filter(function () { return this.text.localeCompare(term) === 0;}).length === 0) { + if ($('input[name="tag_ids"].js_select2').data('can-create')){ + // use `data-can-create` attribute to turn on/off tag creation + return { + id: "_" + $.trim(term), + text: $.trim(term) + ' *', + isNew: true, + } + } + } + }, + formatResult: function(term) { + if (term.isNew) { + return 'New ' + _.escape(term.text); + } + else { + return _.escape(term.text); + } + }, + ajax: { + url: '/cms/get_tags', + dataType: 'json', + data: function(term) { + return { + q: term, + l: 50 + }; + }, + results: function(data) { + var ret = []; + _.each(data, function(x) { + ret.push({ id: x.id, text: x.name, isNew: false }); + }); + lastsearch = ret; + return { results: ret }; + } + }, + // Take default tags from the input value + initSelection: function (element, callback) { + var data = []; + _.each(element.data('init-value'), function(x) { + data.push({ id: x.id, text: x.name, isNew: false }); + }); + element.val(''); + callback(data); + }, + }); + +}); diff --git a/website_cms/templates/assets.xml b/website_cms/templates/assets.xml new file mode 100644 index 00000000..15fdf250 --- /dev/null +++ b/website_cms/templates/assets.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/website_cms/templates/form.xml b/website_cms/templates/form.xml new file mode 100644 index 00000000..40cb80a1 --- /dev/null +++ b/website_cms/templates/form.xml @@ -0,0 +1,134 @@ + + + + + + + + + diff --git a/website_cms/templates/layout.xml b/website_cms/templates/layout.xml new file mode 100644 index 00000000..e3fafe55 --- /dev/null +++ b/website_cms/templates/layout.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/website_cms/templates/menu.xml b/website_cms/templates/menu.xml new file mode 100644 index 00000000..5a4f3d53 --- /dev/null +++ b/website_cms/templates/menu.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + diff --git a/website_cms/templates/misc.xml b/website_cms/templates/misc.xml new file mode 100644 index 00000000..e29dd6ed --- /dev/null +++ b/website_cms/templates/misc.xml @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website_cms/templates/page.xml b/website_cms/templates/page.xml new file mode 100644 index 00000000..a994f59a --- /dev/null +++ b/website_cms/templates/page.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website_cms/templates/sidebar.xml b/website_cms/templates/sidebar.xml new file mode 100644 index 00000000..3b008171 --- /dev/null +++ b/website_cms/templates/sidebar.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + diff --git a/website_cms/tests/__init__.py b/website_cms/tests/__init__.py new file mode 100644 index 00000000..94897382 --- /dev/null +++ b/website_cms/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_page +from . import test_media diff --git a/website_cms/tests/test_media.py b/website_cms/tests/test_media.py new file mode 100644 index 00000000..3e1f7d6e --- /dev/null +++ b/website_cms/tests/test_media.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +import logging +import urlparse +import time + +import lxml.html + +import openerp +import re + +_logger = logging.getLogger(__name__) + + +class TestMedia(openerp.tests.HttpCase): + """ Test suite crawling an openerp CMS instance and checking that all + internal links lead to a 200 response. + + If a username and a password are provided, authenticates the user before + starting the crawl + """ + + at_install = False + post_install = True + + def setUp(self): + super(TestMedia, self).setUp() + + self.cms_media = self.env['cms.media'] + # Media 1 + self.blob1_b64 = 'blob1'.encode('base64') + self.f1 = self.cms_media.create( + {'name': 'f1', 'datas': self.blob1_b64}) + # Media 1 + self.blob2_b64 = 'blob2'.encode('base64') + self.f2 = self.cms_media.create( + {'name': 'f2', + 'datas': self.blob1_b64, + 'datas_fname': 'f2.txt'}) + + self.all_files = [self.f1, self.f2] + + def tearDown(self): + super(TestMedia, self).tearDown() + self.registry('cms.media').unlink( + self.cr, 1, [x.id for x in self.all_files]) + + def test_url(self): + # no file name, default to /download + expected = ( + u'/web/content/cms.media/' + u'{}/datas/download').format(self.f1.id) + self.assertEqual(self.f1.website_url, expected) + + self.f1.datas_fname = 'foo.pdf' + self.f1.invalidate_cache() + expected = ( + u'/web/content/cms.media/' + u'{}/datas/foo.pdf').format(self.f1.id) + self.assertEqual(self.f1.website_url, expected) + + def test_download_file(self): + self.authenticate('admin', 'admin') + self.assertTrue(self.f2.exists()) + # resp = self.url_open(self.f2.website_url) + + # XXX: something really weird is going on here + # the former assertion on `exists` passes + # but when ir_http.binary_content is called + # to dispatch the file content + # `exists` returns false and you get a 404????? + # self.assertEqual(resp.getcode(), 201) + # # anon + # # import pdb;pdb.set_trace() + # resp = self.url_open(self.f2.website_url) + # # not published yet + # self.assertEqual(resp.getcode(), 404) + # # published + # # self.f2.website_published = True + # # self.f2.public = True + # # self.f2.invalidate_cache() + # # import pdb;pdb.set_trace() + # # autheticated + + # # published + # # self.f2.website_published = True + # # self.f2.public = True + # # self.f2.invalidate_cache() + + diff --git a/website_cms/tests/test_page.py b/website_cms/tests/test_page.py new file mode 100644 index 00000000..9e96d62c --- /dev/null +++ b/website_cms/tests/test_page.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- + +# import unittest +# from lxml import etree as ET, html +# from lxml.html import builder as h + + +from openerp.tests import common +from openerp import exceptions +# from openerp.addons.website.models.website import slug + + +class TestPage(common.TransactionCase): + + at_install = False + post_install = True + + def setUp(self): + super(TestPage, self).setUp() + self.model = self.env['cms.page'] + self.all_items = {} + self.all_pages = self.all_items.setdefault('cms.page', []) + self.page = self.create('cms.page', { + 'name': "Test Page", + 'sub_page_type_id': self.news_type.id, + 'website_published': True, + }) + # XXX: should find a way to have default type + # for sub page loaded w/out context + # but looks like we cannot retrieve that + # from parent neither w/ @api.depends + self.sub_page = self.create('cms.page', { + 'name': "Sub Page", + 'parent_id': self.page.id, + 'website_published': True, + }, default_type_id=self.page.sub_page_type_id.id) + self.subsub_page = self.create('cms.page', { + 'name': 'Sub Sub', + 'parent_id': self.sub_page.id, + 'website_published': True, + }, default_type_id=self.page.sub_page_type_id.id) + + def tearDown(self): + for model, items in self.all_items.iteritems(): + self.registry(model).unlink( + self.cr, 1, [x.id for x in items]) + + def create(self, model, values, **context): + ob = self.env[model] + item = ob.with_context(**context).create(values) + self.all_items.setdefault(model, []).append(item) + return item + + @property + def default_type(self): + return self.env.ref('website_cms.default_page_type') + + @property + def news_type(self): + return self.env.ref('website_cms.news_page_type') + + def test_page_default_values(self): + # check page types + self.assertEqual(self.page.type_id.id, + self.default_type.id) + self.assertEqual(self.sub_page.type_id.id, + self.news_type.id) + self.assertTrue( + self.sub_page.id in self.page.children_ids._ids) + + # published date + new = self.create('cms.page', {'name': 'New'}) + self.assertTrue(not new.published_date) + new.website_published = True + self.assertTrue(new.published_date) + self.all_pages.remove(new) + new.unlink() + + def test_page_parent_constrain(self): + # check parent to avoid recursive assignment + self.page.name = 'testing' + self.page.invalidate_cache() + with self.assertRaises(exceptions.ValidationError): + self.page.parent_id = self.page + + def test_hierarchy(self): + # check hierarchy and paths + self.assertEqual( + self.subsub_page.hierarchy, + [self.page, self.sub_page, ], + ) + + self.assertEqual( + self.page.id, self.sub_page.parent_id.id) + self.assertEqual( + self.page.path, '/') + self.assertEqual( + self.page.full_path(), '/Test Page') + self.assertEqual( + self.sub_page.path, '/Test Page') + self.assertEqual( + self.sub_page.full_path(), '/Test Page/Sub Page') + # check URLs + self.assertEqual( + self.page.website_url, + '/cms/test-page-%s' % self.page.id + ) + sub_url = '/cms/test-page-%s/sub-page-%s' % ( + self.page.id, self.sub_page.id) + self.assertEqual( + self.sub_page.website_url, + sub_url, + ) + self.assertEqual( + self.subsub_page.website_url, + sub_url + '/sub-sub-%s' % self.subsub_page.id + ) + + def test_get_root(self): + self.assertEqual(self.page.get_root(), + self.page) + self.assertEqual(self.sub_page.get_root(), + self.page) + self.assertEqual(self.subsub_page.get_root(), + self.page) + self.assertEqual(self.subsub_page.get_root(upper_level=1), + self.sub_page) + + def test_get_listing(self): + # search in root + root_listing = self.model.get_listing(path='/') + # all pages must be there + self.assertEqual(len(root_listing), + len(self.model.search([]))) + for page in self.all_pages: + self.assertTrue(page in root_listing) + + # and ordered by sequence + for pos, page in enumerate(self.all_pages): + self.assertEqual(root_listing[pos], page) + + # list a single page's content + page_listing = self.page.get_listing() + self.assertEqual(len(page_listing), 1) + + # create a news container + container = self.create('cms.page', { + 'name': 'News container', + 'parent_id': self.page.id, + 'sub_page_type_id': self.news_type.id, + 'website_published': True, + }) + # add some news to it + news = [] + for i in xrange(1, 6): + news.append( + self.create('cms.page', { + 'name': 'News %d' % i, + 'parent_id': container.id, + 'type_id': container.sub_page_type_id.id, + 'website_published': True, + }) + ) + + # check listing + root_listing = self.page.get_listing(path='/') + self.assertEqual(len(root_listing), len(self.all_pages)) + + # if no path provided: get direct children only + page_listing = self.page.get_listing() + self.assertEqual(len(page_listing), 2) + + # container's listing gets direct subpages only + container_listing = container.get_listing() + self.assertEqual(len(container_listing), len(news)) + + # ordered by sequence + for i, news_item in enumerate(news): + self.assertEqual(container_listing[i], news_item) + + # ordered by name + ordered_name = container.get_listing(order='name asc') + self.assertEqual( + ordered_name[0].name, + 'News 1' + ) + self.assertEqual( + ordered_name[4].name, + 'News 5' + ) + ordered_name = container.get_listing(order='name desc') + self.assertEqual( + ordered_name[0].name, + 'News 5' + ) + self.assertEqual( + ordered_name[4].name, + 'News 1' + ) + + # `website_cms.cms_manager` group bypasses published state + # let's drop it temporarely to avoid this. + cms_mngr_group = self.env.ref('website_cms.cms_manager') + if self.env.user.has_group('website_cms.cms_manager'): + self.env.user.write({'groups_id': [(3, cms_mngr_group.id)]}) + + # published/not published + news[3].website_published = False + news[4].website_published = False + # by default list only published + self.assertEqual(len(container.get_listing()), 3) + # but we can tweak filters + self.assertEqual(len(container.get_listing(published=False)), 2) + self.assertEqual(len(container.get_listing(published=None)), 5) + + # included in nav + news[1].nav_include = True + news[2].nav_include = True + self.assertEqual( + len(container.get_listing(nav=True, published=None)), 2 + ) + self.assertEqual( + len(container.get_listing(nav=False, published=None)), 3 + ) + # restore manager group + if not self.env.user.has_group('website_cms.cms_manager'): + self.env.user.write({'groups_id': [(4, cms_mngr_group.id)]}) + + # by type refs + root_listing = self.model.get_listing( + path='/', published=None, + types_ref='website_cms.news_page_type') + # by type ids + all_news = self.model.search( + [('type_id', '=', self.news_type.id)]) + self.assertEqual(len(root_listing), + len(all_news)) + root_listing = self.model.get_listing( + path='/', published=None, + types_ids=(self.default_type.id, )) + self.assertEqual(len(root_listing), + len(self.all_pages) - len(all_news)) + + def test_listing_with_tags(self): + # create some tags + tag1 = self.create('cms.tag', { + 'name': 'Tag 1', + }) + tag2 = self.create('cms.tag', { + 'name': 'Tag 2', + }) + tag3 = self.create('cms.tag', { + 'name': 'Tag 3', + }) + # create some other pages and tag them + page1 = self.create('cms.page', { + 'name': 'Page 1', + 'tag_ids': [ + (6, False, [tag1.id, tag2.id]) + ] + }) + page2 = self.create('cms.page', { + 'name': 'Page 2', + 'tag_ids': [ + (6, False, [tag2.id, ]) + ] + }) + page3 = self.create('cms.page', { + 'name': 'Page 3', + 'tag_ids': [ + (6, False, [tag3.id, ]) + ] + }) + main1 = self.create('cms.page', { + 'name': 'Main 1', + 'tag_ids': [ + (6, False, [tag1.id, ]) + ] + }) + main2 = self.create('cms.page', { + 'name': 'Main 2', + 'tag_ids': [ + (6, False, [tag2.id, tag3.id]) + ] + }) + # sub pages must be included too + sub_page1 = self.create('cms.page', { + 'name': 'Sub Page 1', + 'parent_id': main1.id, + }) + sub_page2 = self.create('cms.page', { + 'name': 'Sub Page 2', + 'parent_id': main2.id, + }) + # check listing including tags + # since we are listing straight from the main items + # we won't get the items itselves + listing = main1.get_listing(incl_tags=1) + self.assertEqual(len(listing), 2) + self.assertTrue(page1.id in [x.id for x in listing]) + self.assertTrue(sub_page1.id in [x.id for x in listing]) + + listing = main2.get_listing(incl_tags=1) + self.assertEqual(len(listing), 4) + self.assertTrue(page1.id in [x.id for x in listing]) + self.assertTrue(page2.id in [x.id for x in listing]) + self.assertTrue(page3.id in [x.id for x in listing]) + self.assertTrue(sub_page2.id in [x.id for x in listing]) + + # if we pass a root path we should get the main items too + listing = main1.get_listing(path='/', incl_tags=1) + self.assertEqual(len(listing), 10) + self.assertTrue(page1.id in [x.id for x in listing]) + self.assertTrue(main1.id in [x.id for x in listing]) + self.assertTrue(sub_page1.id in [x.id for x in listing]) + self.assertTrue(sub_page2.id in [x.id for x in listing]) + + # if we pass another path path + # we should get the main items too if in path + subsub_page1 = self.create('cms.page', { + 'name': 'Sub sub Page 1', + 'parent_id': sub_page1.id, + }) + listing = main2.get_listing(path=sub_page1.path, tag_ids=[tag1.id, ]) + self.assertEqual(len(listing), 4) + # because of tag 1 + self.assertTrue(page1.id in [x.id for x in listing]) + self.assertTrue(main1.id in [x.id for x in listing]) + # because of path + self.assertTrue(sub_page1.id in [x.id for x in listing]) + self.assertTrue(subsub_page1.id in [x.id for x in listing]) + + def test_permissions(self): + # TODO + pass + + def test_nav(self): + # this must work also when nav is cached + website = self.env['website'].search([])[0] + self.assertEqual(len(website.get_nav_pages()), 0) + self.page.nav_include = True + self.assertEqual(len(website.get_nav_pages()), 1) diff --git a/website_cms/utils.py b/website_cms/utils.py new file mode 100644 index 00000000..10493349 --- /dev/null +++ b/website_cms/utils.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- + +import base64 +import requests +import time +import mimetypes +MTYPES = mimetypes.types_map.values() +IMAGE_TYPES = [x for x in MTYPES if x.startswith('image/')] +AUDIO_TYPES = [x for x in MTYPES if x.startswith('audio/')] +VIDEO_TYPES = [x for x in MTYPES if x.startswith('video/')] + + +def guess_mimetype(filename_or_url): + """Pass me a filename (or URL) and I give you a mimetype.""" + return mimetypes.guess_type(filename_or_url)[0] + + +class AttrDict(dict): + """A smarter dict.""" + + def __getattr__(self, k): + """Well... get and return given attr.""" + return self[k] + + def __setattr__(self, k, v): + """Well... set given attr.""" + self[k] = v + + +def download_image_from_url(url): + """Download something from given url.""" + resp = requests.get(url, timeout=10) + if resp.status_code == 200: + return base64.encodestring(resp.content) + return None + + + +def timeit(method): + """Decorates methods to measure time.""" + + def timed(*args, **kw): + + print 'START', method.__name__ + ts = time.time() + result = method(*args, **kw) + te = time.time() + print 'STOP', method.__name__ + + print 'TIME %r (%r, %r) %2.2f sec' % \ + (method.__name__, args, kw, te - ts) + return result + + return timed diff --git a/website_cms/views/cms_media.xml b/website_cms/views/cms_media.xml new file mode 100644 index 00000000..dc7c0767 --- /dev/null +++ b/website_cms/views/cms_media.xml @@ -0,0 +1,119 @@ + + + + + CMS Media Form + cms.media + +
    + +
    + +
    + +
    +
    +
    +
    + + + CMS Media Tree + cms.media + + + + + + + + + + + + + + + + + + + + + CMS Media Search + cms.media + + + + + + + + + + + + + + + + + + + + +
    diff --git a/website_cms/views/cms_media_category.xml b/website_cms/views/cms_media_category.xml new file mode 100644 index 00000000..0f6bc928 --- /dev/null +++ b/website_cms/views/cms_media_category.xml @@ -0,0 +1,57 @@ + + + + + + + CMS Media Category form + cms.media.category + +
    + + + + + + + + + + +
    +
    +
    + + + + CMS Media Category tree + cms.media.category + + + + + + + + + + + + + CMS Media Category search + cms.media.category + + + + + + + + + + + +
    +
    + + diff --git a/website_cms/views/cms_page.xml b/website_cms/views/cms_page.xml new file mode 100644 index 00000000..bdd1b140 --- /dev/null +++ b/website_cms/views/cms_page.xml @@ -0,0 +1,207 @@ + + + + + + + CMS Page form + cms.page + +
    + +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + + + CMS Page tree + cms.page + + + + + + + + + + + + + + + + CMS Page search + cms.page + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + diff --git a/website_cms/views/cms_redirect.xml b/website_cms/views/cms_redirect.xml new file mode 100644 index 00000000..45af7955 --- /dev/null +++ b/website_cms/views/cms_redirect.xml @@ -0,0 +1,62 @@ + + + + + + + CMS Redirect form + cms.redirect + +
    + + + + + + + + + + + +
    +
    +
    + + + + CMS Redirect tree + cms.redirect + + + + + + + + + + + + + + + CMS Redirect search + cms.redirect + + + + + + + + + + + + + +
    +
    + + diff --git a/website_cms/views/menuitems.xml b/website_cms/views/menuitems.xml new file mode 100644 index 00000000..679a8cc3 --- /dev/null +++ b/website_cms/views/menuitems.xml @@ -0,0 +1,107 @@ + + + + + + + Pages + cms.page + form + tree,form + {'search_default_only_main':1} + +

    + Click to manage CMS pages. +

    +
    +
    + + + Redirects + cms.redirect + form + tree,form + +

    + Click to manage CMS redirects. +

    +
    +
    + + + Media + ir.actions.act_window + cms.media + form + + +

    + Click here to create new documents. +

    +

    + Also you will find here all the related documents and download it by clicking on any individual document. +

    +
    +
    + + + Media categories + ir.actions.act_window + cms.media.category + form + + +

    + Click here to create new categories. +

    +
    +
    + + + + + + + + + + + + +
    +
    + + diff --git a/website_cms/views/website_menu.xml b/website_cms/views/website_menu.xml new file mode 100644 index 00000000..4d802c6c --- /dev/null +++ b/website_cms/views/website_menu.xml @@ -0,0 +1,31 @@ + + + + + + + BWI website menu tree + website.menu + + + + + + + + + + + + BWI website menu search + website.menu + + + + + + + + + + diff --git a/website_cms_search/.gitignore b/website_cms_search/.gitignore new file mode 100644 index 00000000..41ed37fe --- /dev/null +++ b/website_cms_search/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +*.pyc +*.pyo +*.~* +*.~ diff --git a/website_cms_search/README.rst b/website_cms_search/README.rst new file mode 100644 index 00000000..f2b79239 --- /dev/null +++ b/website_cms_search/README.rst @@ -0,0 +1,6 @@ +======================= +Website CMS text search +======================= + +Add text search feature for `website_cms` pages. + diff --git a/website_cms_search/__init__.py b/website_cms_search/__init__.py new file mode 100644 index 00000000..6c96f6bf --- /dev/null +++ b/website_cms_search/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import controllers diff --git a/website_cms_search/__openerp__.py b/website_cms_search/__openerp__.py new file mode 100644 index 00000000..ab81d235 --- /dev/null +++ b/website_cms_search/__openerp__.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# © +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Website CMS Text Search", + "summary": "Add search facilitites for CMS pages (website_cms)", + "version": "1.0", + "category": "Website", + "website": "https://odoo-community.org/", + "author": ", Odoo Community Association (OCA)", + "license": "AGPL-3", + "installable": True, + 'application': False, + "depends": [ + "base", + 'website_cms', + ], + "data": [ + "templates/search.xml", + ], +} diff --git a/website_cms_search/controllers/__init__.py b/website_cms_search/controllers/__init__.py new file mode 100644 index 00000000..12a7e529 --- /dev/null +++ b/website_cms_search/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/website_cms_search/controllers/main.py b/website_cms_search/controllers/main.py new file mode 100644 index 00000000..245534e8 --- /dev/null +++ b/website_cms_search/controllers/main.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +from openerp import http +# from openerp.tools.translate import _ +from openerp.http import request + + +class SearchView(http.Controller): + """Controller for search view.""" + + _results_per_page = 10 + _min_search_len = 3 + _case_sensitive = False + + template = 'website_cms_search.page_search' + + @http.route([ + '/cms/search', + '/cms/search/page/', + ], type='http', auth="public", website=True) + def search(self, page=1, **kw): + """Search CMS pages.""" + values = { + 'has_results': False, + 'paginated': {}, + 'min_search_len_ok': True, + 'min_search_len': self._min_search_len, + } + search_text = kw.get('search_text', '') + if len(search_text) < self._min_search_len: + values['min_search_len_ok'] = False + else: + pages = self._search(search_text) + values['has_results'] = bool(pages) + page_model = request.env['cms.page'] + values['paginated'] = page_model.paginate( + pages, + page=page, + step=self._results_per_page) + return request.render(self.template, values) + + def _get_query(self, lang, case_sensitive=False): + """Build sql query.""" + is_default_lang = lang == request.website.default_lang_code + if is_default_lang: + sql_query = """ + SELECT id + FROM cms_page + WHERE + name {like} %s + OR description {like} %s + OR body {like} %s + """ + else: + sql_query = """ + SELECT p.id + FROM cms_page p, ir_translation tr + WHERE + tr.type='model' + AND tr.lang='{}' + AND tr.res_id=p.id + AND tr.name like 'cms.page,%%' + AND tr.state='translated' + """.format(lang) + if case_sensitive: + sql_query += """ AND tr.value {like} %s""" + else: + sql_query += """ AND lower(tr.value) {like} %s""" + like = case_sensitive and 'like' or 'ilike' + return sql_query.format(like=like) + + def _search(self, search_text): + """Do search.""" + lang = request.context.get('lang') + order = 'published_date desc' + case_sensitive = self._case_sensitive + sql_query = self._get_query(lang, case_sensitive=case_sensitive) + params = ['%{}%'.format(search_text) + for x in xrange(sql_query.count('%s'))] + request.env.cr.execute(sql_query, params) + res = request.env.cr.fetchall() + page_ids = tuple(set([x[0] for x in res])) + + # limit = self._results_per_page + # offset = (page - 1) * self._results_per_page + + page_model = request.env['cms.page'] + search_args = [ + ('id', 'in', page_ids), + ] + pages = page_model.search(search_args, + order=order) + return pages diff --git a/website_cms_search/templates/search.xml b/website_cms_search/templates/search.xml new file mode 100644 index 00000000..f5fba305 --- /dev/null +++ b/website_cms_search/templates/search.xml @@ -0,0 +1,70 @@ + + + + + + diff --git a/website_cms_search/tests/__init__.py b/website_cms_search/tests/__init__.py new file mode 100644 index 00000000..e69de29b From 239a24b0846d12c6bbfd7e3d2e58d7d2f3a41c50 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 3 Oct 2016 15:23:20 +0200 Subject: [PATCH 3/8] update roadmap and changelog --- website_cms/CHANGES.rst | 3 +-- website_cms/ROADMAP.rst | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/website_cms/CHANGES.rst b/website_cms/CHANGES.rst index 38a2def6..e4d9f78e 100644 --- a/website_cms/CHANGES.rst +++ b/website_cms/CHANGES.rst @@ -1,8 +1,7 @@ CHANGELOG ========= -9.0.1.0.1 (Unreleased) +9.0.1.0.2 (2016-10-03) ---------------------- * 1st release - diff --git a/website_cms/ROADMAP.rst b/website_cms/ROADMAP.rst index e1027b42..cf4f0eed 100755 --- a/website_cms/ROADMAP.rst +++ b/website_cms/ROADMAP.rst @@ -1,3 +1,16 @@ -/// add missing from docs and talk +Roadmap +------- -* listing: if not published you do not see YOUR un-published content +* Improve frontend forms (manage media, reuse backend forms) +* Independent translations branches (skip odoo translation mechanism on demand via configuration) +* Edit permissions control / sharing facilities / notifications, etc +* Simplified interface for managing users and groups +* Publication workflow management +* Content history / versioning +* Full text search using PG built-in search engine (see fts modules) +* Shorter URLs (drop path ids for instance) +* Performance fixes/tuning (use parent_left/right for instance) +* Introduce portlets for sidebar elements +* Add "collections" to fetch contents from the whole website (eg: "News from 2016") +* Improve test coverage +* Default theme From 4678eb2dbbc97d0ae2cd56737c7d5b6133ed2638 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 3 Oct 2016 15:55:41 +0200 Subject: [PATCH 4/8] PEP8 --- website_cms/controllers/main.py | 14 ++++--- .../9.0.1.0.1/post-update_cms_page_table.py | 2 +- website_cms/models/ir_attachment.py | 5 ++- website_cms/models/ir_ui_view.py | 2 +- website_cms/tests/test_media.py | 18 +-------- website_cms/utils.py | 37 +++++++++---------- 6 files changed, 33 insertions(+), 45 deletions(-) diff --git a/website_cms/controllers/main.py b/website_cms/controllers/main.py index 387f18cd..520e4fbf 100644 --- a/website_cms/controllers/main.py +++ b/website_cms/controllers/main.py @@ -96,10 +96,14 @@ def render(self, main_object, **kw): '/cms//', '/cms//page/', '/cms///page/', - '/cms//media/', - '/cms//media//page/', - '/cms///media/', - '/cms///media//page/', + '/cms/\ + /media/', + '/cms/\ + /media//page/', + '/cms//\ + /media/', + '/cms//\ + /media//page/', ] @@ -116,7 +120,7 @@ def view_page(self, main_object, **kw): """ site = request.website # check published - # XXX: this is weird since it should be done by `website` module itself. + # This is weird since it should be done by `website` module itself. # Alternatively we can put this in our `secure model` route handler. if not site.is_publisher() and not main_object.website_published: raise NotFound diff --git a/website_cms/migrations/9.0.1.0.1/post-update_cms_page_table.py b/website_cms/migrations/9.0.1.0.1/post-update_cms_page_table.py index efb65ad0..f3cb4057 100644 --- a/website_cms/migrations/9.0.1.0.1/post-update_cms_page_table.py +++ b/website_cms/migrations/9.0.1.0.1/post-update_cms_page_table.py @@ -1,4 +1,4 @@ -#-*- coding: utf-8 -*- +# -*- coding: utf-8 -*- import logging from openerp.modules.registry import RegistryManager diff --git a/website_cms/models/ir_attachment.py b/website_cms/models/ir_attachment.py index aa6584fa..d8dcfdfa 100644 --- a/website_cms/models/ir_attachment.py +++ b/website_cms/models/ir_attachment.py @@ -39,7 +39,8 @@ def _search(self, cr, uid, args, offset=0, limit=None, order=None, if not ids: return 0 if count else [] - # Work with a set, as list.remove() is prohibitive for large lists of documents + # Work with a set, as list.remove() is prohibitive + # for large lists of documents # (takes 20+ seconds on a db with 100k docs during search_count()!) orig_ids = ids ids = set(ids) @@ -76,7 +77,7 @@ def _search(self, cr, uid, args, offset=0, limit=None, order=None, # remove all corresponding attachment ids for attach_id in itertools.chain(*targets.values()): ids.remove(attach_id) - continue # skip ir.rule processing, these ones are out already + continue # skip ir.rule processing, these ones are out already # filter ids according to what access rules permit target_ids = targets.keys() diff --git a/website_cms/models/ir_ui_view.py b/website_cms/models/ir_ui_view.py index c22f8a83..2d75c7e6 100644 --- a/website_cms/models/ir_ui_view.py +++ b/website_cms/models/ir_ui_view.py @@ -8,7 +8,7 @@ def url_for(path_or_uri, lang=None, main_object=None): - + """Override to avoid building links for not translated contents.""" if main_object and main_object._name == 'cms.page': if lang and not lang == request.website.default_lang_code \ and not request.params.get('edit_translations') \ diff --git a/website_cms/tests/test_media.py b/website_cms/tests/test_media.py index 3e1f7d6e..2eeeda19 100644 --- a/website_cms/tests/test_media.py +++ b/website_cms/tests/test_media.py @@ -1,23 +1,9 @@ # -*- coding: utf-8 -*- -import logging -import urlparse -import time - -import lxml.html - import openerp -import re - -_logger = logging.getLogger(__name__) class TestMedia(openerp.tests.HttpCase): - """ Test suite crawling an openerp CMS instance and checking that all - internal links lead to a 200 response. - - If a username and a password are provided, authenticates the user before - starting the crawl - """ + """ Test cms.media behavior.""" at_install = False post_install = True @@ -85,5 +71,3 @@ def test_download_file(self): # # self.f2.website_published = True # # self.f2.public = True # # self.f2.invalidate_cache() - - diff --git a/website_cms/utils.py b/website_cms/utils.py index 10493349..8789b9b4 100644 --- a/website_cms/utils.py +++ b/website_cms/utils.py @@ -2,7 +2,6 @@ import base64 import requests -import time import mimetypes MTYPES = mimetypes.types_map.values() IMAGE_TYPES = [x for x in MTYPES if x.startswith('image/')] @@ -34,21 +33,21 @@ def download_image_from_url(url): return base64.encodestring(resp.content) return None - - -def timeit(method): - """Decorates methods to measure time.""" - - def timed(*args, **kw): - - print 'START', method.__name__ - ts = time.time() - result = method(*args, **kw) - te = time.time() - print 'STOP', method.__name__ - - print 'TIME %r (%r, %r) %2.2f sec' % \ - (method.__name__, args, kw, te - ts) - return result - - return timed +# USE ME WHEN NEEDED +# import time +# def timeit(method): +# """Decorate methods to measure time.""" +# +# def timed(*args, **kw): +# +# print 'START', method.__name__ +# ts = time.time() +# result = method(*args, **kw) +# te = time.time() +# print 'STOP', method.__name__ +# +# print 'TIME %r (%r, %r) %2.2f sec' % \ +# (method.__name__, args, kw, te - ts) +# return result +# +# return timed From 3da6e27c7440c966b660eaa1f0420cb420e86ce9 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Mon, 3 Oct 2016 18:05:37 +0200 Subject: [PATCH 5/8] [fix] module versions and author --- website_cms/__openerp__.py | 2 +- website_cms_search/__openerp__.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/website_cms/__openerp__.py b/website_cms/__openerp__.py index 65423c9c..e6d7f69d 100644 --- a/website_cms/__openerp__.py +++ b/website_cms/__openerp__.py @@ -4,7 +4,7 @@ { "name": "Website CMS", "summary": "CMS features", - "version": "1.0.2", + "version": "9.0.1.0.2", "category": "Website", "website": "https://odoo-community.org/", "author": "Simone Orsi - Camptocamp, Odoo Community Association (OCA)", diff --git a/website_cms_search/__openerp__.py b/website_cms_search/__openerp__.py index ab81d235..438ba905 100644 --- a/website_cms_search/__openerp__.py +++ b/website_cms_search/__openerp__.py @@ -4,10 +4,10 @@ { "name": "Website CMS Text Search", "summary": "Add search facilitites for CMS pages (website_cms)", - "version": "1.0", + "version": "9.0.1.0.0", "category": "Website", "website": "https://odoo-community.org/", - "author": ", Odoo Community Association (OCA)", + "author": "Simone Orsi - Camptocamp, Odoo Community Association (OCA)", "license": "AGPL-3", "installable": True, 'application': False, From 4f959385af39b95a7876b1d3a2791d061ffb4064 Mon Sep 17 00:00:00 2001 From: Jonathan Nemry Date: Tue, 4 Oct 2016 15:56:42 +0100 Subject: [PATCH 6/8] [ADD] website_cms_theme * provide a module to manage a theme by forms --- website_cms/templates/form.xml | 105 ++++++++++-------- website_cms_theme/__init__.py | 1 + website_cms_theme/__openerp__.py | 20 ++++ .../static/src/less/assets/varriables.less | 1 + .../static/src/less/layouts/form.less | 16 +++ website_cms_theme/views/assets.xml | 13 +++ 6 files changed, 111 insertions(+), 45 deletions(-) create mode 100644 website_cms_theme/__init__.py create mode 100644 website_cms_theme/__openerp__.py create mode 100644 website_cms_theme/static/src/less/assets/varriables.less create mode 100644 website_cms_theme/static/src/less/layouts/form.less create mode 100644 website_cms_theme/views/assets.xml diff --git a/website_cms/templates/form.xml b/website_cms/templates/form.xml index 40cb80a1..e316fdf3 100644 --- a/website_cms/templates/form.xml +++ b/website_cms/templates/form.xml @@ -17,11 +17,12 @@ We load each field explicitely here for now. -

    +

    -
    - - +
    +
    + +
    +
    + +
    -
    - - -

    - Description might be useful in many views, especially listing views - to show a brief summary of the content.
    - This image might not be used in the view of the item, - this will depend on you website design. -

    +
    +
    + +
    +
    + +

    + Description might be useful in many views, especially listing views + to show a brief summary of the content.
    + This image might not be used in the view of the item, + this will depend on you website design. +

    +
    - - -

    - This image could be used for landing pages and listing views - to present a content with a specific image.
    - This image might not be used in the view of the item, - this will depend on you website design. -

    +
    + +
    +
    + +

    + This image could be used for landing pages and listing views + to present a content with a specific image.
    + This image might not be used in the view of the item, + this will depend on you website design. +

    +
    @@ -109,17 +120,21 @@ We load each field explicitely here for now.
    - - - +
    + + +
    +
    + +
    -
    -
    +
    +
    Cancel
    diff --git a/website_cms_theme/__init__.py b/website_cms_theme/__init__.py new file mode 100644 index 00000000..7c68785e --- /dev/null +++ b/website_cms_theme/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- \ No newline at end of file diff --git a/website_cms_theme/__openerp__.py b/website_cms_theme/__openerp__.py new file mode 100644 index 00000000..7216f2af --- /dev/null +++ b/website_cms_theme/__openerp__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 ACSONE SA/NV () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + 'name': "Website CMS theme", + 'summary': """ + This module provides base theme for website cms + """, + 'author': 'ACSONE SA/NV', + 'website': "http://acsone.eu", + 'category': 'Website', + 'version': '8.0.1.0.0', + 'license': 'AGPL-3', + 'depends': [ + 'base', + ], + 'data': [ + 'views/assets.xml', + ], +} diff --git a/website_cms_theme/static/src/less/assets/varriables.less b/website_cms_theme/static/src/less/assets/varriables.less new file mode 100644 index 00000000..5cbf26ee --- /dev/null +++ b/website_cms_theme/static/src/less/assets/varriables.less @@ -0,0 +1 @@ +@form_background-color: #FAFAFA; diff --git a/website_cms_theme/static/src/less/layouts/form.less b/website_cms_theme/static/src/less/layouts/form.less new file mode 100644 index 00000000..9649a6a0 --- /dev/null +++ b/website_cms_theme/static/src/less/layouts/form.less @@ -0,0 +1,16 @@ +.o_website_cms_title{ + text-align: center; +} +.o_website_cms { + background-color: @form_background-color; + min-height: 500px; + width: 60%; + margin-left: 20%; + label{ + padding-top: 7px; + font-family: Helvetica Neue, Helvetica, Arial, sans-serif; + } + div{ + padding-top: 2%; + } +} \ No newline at end of file diff --git a/website_cms_theme/views/assets.xml b/website_cms_theme/views/assets.xml new file mode 100644 index 00000000..00a43c5d --- /dev/null +++ b/website_cms_theme/views/assets.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file From e81c2a76aff3ea1b17edb8cbac00a2e8a96301e8 Mon Sep 17 00:00:00 2001 From: Jonathan Nemry Date: Tue, 4 Oct 2016 16:03:49 +0100 Subject: [PATCH 7/8] [CHG] some pep8 issues --- website_cms/docs/conf.py | 5 +++-- .../migrations/9.0.1.0.2/post-update_old_pages_path.py | 2 +- website_cms_theme/__init__.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/website_cms/docs/conf.py b/website_cms/docs/conf.py index 24e88caf..9c0e0bd0 100644 --- a/website_cms/docs/conf.py +++ b/website_cms/docs/conf.py @@ -26,7 +26,7 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) sys.path.append(os.path.abspath('_themes')) if os.environ.get('TRAVIS_BUILD_DIR') and os.environ.get('VERSION'): @@ -183,7 +183,8 @@ # html_logo = None # The name of an image file (relative to this directory) to use as a favicon of -# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# the docs. This file should be a Windows icon file (.ico) being 16x16 or +# 32x32 # pixels large. # # html_favicon = None diff --git a/website_cms/migrations/9.0.1.0.2/post-update_old_pages_path.py b/website_cms/migrations/9.0.1.0.2/post-update_old_pages_path.py index 193c6160..4aed49aa 100644 --- a/website_cms/migrations/9.0.1.0.2/post-update_old_pages_path.py +++ b/website_cms/migrations/9.0.1.0.2/post-update_old_pages_path.py @@ -1,4 +1,4 @@ -#-*- coding: utf-8 -*- +# -*- coding: utf-8 -*- # flake8: noqa diff --git a/website_cms_theme/__init__.py b/website_cms_theme/__init__.py index 7c68785e..40a96afc 100644 --- a/website_cms_theme/__init__.py +++ b/website_cms_theme/__init__.py @@ -1 +1 @@ -# -*- coding: utf-8 -*- \ No newline at end of file +# -*- coding: utf-8 -*- From 89fa2465b42e73ebe3b3ec80837f4c0336583e08 Mon Sep 17 00:00:00 2001 From: Jonathan Nemry Date: Tue, 4 Oct 2016 16:30:12 +0100 Subject: [PATCH 8/8] [FIX] reviews --- website_cms_theme/__openerp__.py | 2 +- website_cms_theme/static/src/less/layouts/form.less | 2 +- website_cms_theme/views/assets.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/website_cms_theme/__openerp__.py b/website_cms_theme/__openerp__.py index 7216f2af..0d6482c8 100644 --- a/website_cms_theme/__openerp__.py +++ b/website_cms_theme/__openerp__.py @@ -9,7 +9,7 @@ 'author': 'ACSONE SA/NV', 'website': "http://acsone.eu", 'category': 'Website', - 'version': '8.0.1.0.0', + 'version': '9.0.1.0.0', 'license': 'AGPL-3', 'depends': [ 'base', diff --git a/website_cms_theme/static/src/less/layouts/form.less b/website_cms_theme/static/src/less/layouts/form.less index 9649a6a0..a8928ad7 100644 --- a/website_cms_theme/static/src/less/layouts/form.less +++ b/website_cms_theme/static/src/less/layouts/form.less @@ -13,4 +13,4 @@ div{ padding-top: 2%; } -} \ No newline at end of file +} diff --git a/website_cms_theme/views/assets.xml b/website_cms_theme/views/assets.xml index 00a43c5d..26e8bdfc 100644 --- a/website_cms_theme/views/assets.xml +++ b/website_cms_theme/views/assets.xml @@ -10,4 +10,4 @@ rel="stylesheet" type="text/less"/> - \ No newline at end of file +