diff --git a/addons/account/account.py b/addons/account/account.py index 93d3dfb5d4a..fcb4c783e12 100644 --- a/addons/account/account.py +++ b/addons/account/account.py @@ -714,7 +714,7 @@ class account_journal(osv.osv): _name = "account.journal" _description = "Journal" _columns = { - 'with_last_closing_balance' : fields.boolean('Opening With Last Closing Balance'), + 'with_last_closing_balance': fields.boolean('Opening With Last Closing Balance', help="For cash or bank journal, this option should be unchecked when the starting balance should always set to 0 for new documents."), 'name': fields.char('Journal Name', required=True), 'code': fields.char('Code', size=5, required=True, help="The code will be displayed on reports."), 'type': fields.selection([('sale', 'Sale'),('sale_refund','Sale Refund'), ('purchase', 'Purchase'), ('purchase_refund','Purchase Refund'), ('cash', 'Cash'), ('bank', 'Bank and Checks'), ('general', 'General'), ('situation', 'Opening/Closing Situation')], 'Type', size=32, required=True, @@ -745,7 +745,7 @@ class account_journal(osv.osv): _defaults = { 'cash_control' : False, - 'with_last_closing_balance' : False, + 'with_last_closing_balance' : True, 'user_id': lambda self, cr, uid, context: uid, 'company_id': lambda self, cr, uid, c: self.pool.get('res.users').browse(cr, uid, uid, c).company_id.id, } diff --git a/addons/account/account_view.xml b/addons/account/account_view.xml index 1d340001c55..934475c4e15 100644 --- a/addons/account/account_view.xml +++ b/addons/account/account_view.xml @@ -439,7 +439,7 @@ - + diff --git a/addons/account/report/account_invoice_report_view.xml b/addons/account/report/account_invoice_report_view.xml index 35cb43467f2..5ec370ff9a7 100644 --- a/addons/account/report/account_invoice_report_view.xml +++ b/addons/account/report/account_invoice_report_view.xml @@ -15,44 +15,63 @@ + + + By Salespersons + account.invoice.report + [] + + {'group_by': ['date:month', 'user_id']} + + + By Product + account.invoice.report + [] + + {'group_by': ['date:month', 'product_id'], 'set_visible':True, 'residual_invisible':True} + + + By Product Category + account.invoice.report + [] + + {'group_by': ['date:month', 'categ_id'], 'residual_invisible':True} + + + By Refund + account.invoice.report + [('type', '=', 'out_refund')] + + {'group_by': ['date:month', 'user_id']} + + + By Country + account.invoice.report + [] + + {'group_by': ['date:month', 'country_id']} + + account.invoice.report.search account.invoice.report - + - - - + + + - - + + - - + + - - - - - - - - - - - - - - - - - - diff --git a/addons/crm/crm_lead_view.xml b/addons/crm/crm_lead_view.xml index e02f43473ac..62c5e14deed 100644 --- a/addons/crm/crm_lead_view.xml +++ b/addons/crm/crm_lead_view.xml @@ -222,13 +222,13 @@ - + - + @@ -326,44 +326,31 @@ - + - + + - - - - + + - - + - - - - - - - - - - + + @@ -523,7 +510,7 @@ - + @@ -531,7 +518,7 @@ - + @@ -551,7 +538,7 @@ - + @@ -561,34 +548,21 @@ domain="[('probability', '=', 100), ('stage_id.fold', '=', True)]"/> + + - - - + - - - - - - - - - - - - + diff --git a/addons/crm/crm_phonecall_view.xml b/addons/crm/crm_phonecall_view.xml index 87e4d1d293a..47825a5d30c 100644 --- a/addons/crm/crm_phonecall_view.xml +++ b/addons/crm/crm_phonecall_view.xml @@ -170,29 +170,29 @@ crm.phonecall - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - + + + + + + + diff --git a/addons/crm/report/crm_lead_report_view.xml b/addons/crm/report/crm_lead_report_view.xml index bbe375fb2a1..7aa75021d32 100644 --- a/addons/crm/report/crm_lead_report_view.xml +++ b/addons/crm/report/crm_lead_report_view.xml @@ -2,8 +2,7 @@ - - + crm.lead.report.graph crm.lead.report @@ -38,8 +37,23 @@ - + + + By Salespersons + crm.lead.report + [] + + {'group_by': ['create_date:month', user_id']} + + + By Country + crm.lead.report + [] + + {'group_by': ['create_date:month', 'country_id']} + + crm.lead.report.select crm.lead.report @@ -54,11 +68,6 @@ domain="[('probability', '=', 100), ('stage_id.on_change', '=', 1)]"/> - - - - @@ -79,26 +88,6 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/addons/crm_partner_assign/crm_lead_view.xml b/addons/crm_partner_assign/crm_lead_view.xml index d47e9ac5cbc..aefc5aef000 100644 --- a/addons/crm_partner_assign/crm_lead_view.xml +++ b/addons/crm_partner_assign/crm_lead_view.xml @@ -59,13 +59,10 @@ crm.lead - - - - + + - + @@ -131,10 +128,10 @@ crm.lead - - + + - + diff --git a/addons/hr_recruitment/hr_recruitment_view.xml b/addons/hr_recruitment/hr_recruitment_view.xml index 1e81d1555d4..2b33d4916a2 100644 --- a/addons/hr_recruitment/hr_recruitment_view.xml +++ b/addons/hr_recruitment/hr_recruitment_view.xml @@ -193,14 +193,13 @@ - - - + - + + @@ -210,15 +209,9 @@ - - - - - - diff --git a/addons/hr_recruitment/report/hr_recruitment_report_view.xml b/addons/hr_recruitment/report/hr_recruitment_report_view.xml index 4d15481070f..45276d7631c 100644 --- a/addons/hr_recruitment/report/hr_recruitment_report_view.xml +++ b/addons/hr_recruitment/report/hr_recruitment_report_view.xml @@ -12,6 +12,29 @@ + + + By Recruiter + hr.recruitment.report + [] + + {'group_by': ['date_create:month', 'user_id']} + + + By Job + hr.recruitment.report + [] + + {'group_by': ['date_create:month', 'job_id']} + + + By Department + hr.recruitment.report + [] + + {'group_by': ['date_create:month', 'department_id']} + + hr.recruitment.report.search hr.recruitment.report @@ -20,11 +43,13 @@ - + + + @@ -32,21 +57,6 @@ - - - - - - - - - - - - - - - @@ -56,7 +66,7 @@ hr.recruitment.report form graph - {'group_by_no_leaf':1,'group_by':[]} + {'search_default_year': 1, 'group_by_no_leaf':1,'group_by':[]} diff --git a/addons/project/project_view.xml b/addons/project/project_view.xml index 6a13ca00fff..38decfdd429 100644 --- a/addons/project/project_view.xml +++ b/addons/project/project_view.xml @@ -18,29 +18,24 @@ - - - - - - - - + + + + + + - - - + @@ -190,15 +185,14 @@ - + + + - - - + + - - - + diff --git a/addons/project/report/project_report.py b/addons/project/report/project_report.py index 97e52766e5a..4a38e57ad9a 100644 --- a/addons/project/report/project_report.py +++ b/addons/project/report/project_report.py @@ -30,6 +30,7 @@ class report_project_task_user(osv.osv): _columns = { 'name': fields.char('Task Summary', readonly=True), 'user_id': fields.many2one('res.users', 'Assigned To', readonly=True), + 'reviewer_id': fields.many2one('res.users', 'Reviewer', readonly=True), 'date_start': fields.date('Assignation Date', readonly=True), 'no_of_days': fields.integer('# of Days', size=128, readonly=True), 'date_end': fields.date('Ending Date', readonly=True), @@ -71,6 +72,7 @@ class report_project_task_user(osv.osv): -- sum(cast(to_char(date_trunc('day',t.date_end) - date_trunc('day',t.date_start),'DD') as int)) as no_of_days, abs((extract('epoch' from (t.write_date-t.date_start)))/(3600*24)) as no_of_days, t.user_id, + t.reviewer_id, progress as progress, t.project_id, t.effective_hours as hours_effective, @@ -103,6 +105,7 @@ class report_project_task_user(osv.osv): date_deadline, date_last_stage_update, t.user_id, + t.reviewer_id, t.project_id, t.priority, name, diff --git a/addons/project/report/project_report_view.xml b/addons/project/report/project_report_view.xml index e2e68b67116..7df229a79e4 100644 --- a/addons/project/report/project_report_view.xml +++ b/addons/project/report/project_report_view.xml @@ -21,6 +21,22 @@ + + + By Responsible + report.project.task.user + [] + + {'group_by': ['project_id', 'user_id']} + + + By Reviewer + report.project.task.user + [] + + {'group_by': ['project_id', 'reviewer_id']} + + report.project.task.user.search report.project.task.user @@ -34,25 +50,14 @@ - - - + + + - - - - - - - - - - - diff --git a/addons/project_issue/project_issue_view.xml b/addons/project_issue/project_issue_view.xml index 86fbd4ac912..14ff935d826 100644 --- a/addons/project_issue/project_issue_view.xml +++ b/addons/project_issue/project_issue_view.xml @@ -142,24 +142,23 @@ - - - - - + + + + + + - - - + diff --git a/addons/project_issue/report/project_issue_report_view.xml b/addons/project_issue/report/project_issue_report_view.xml index 0a9c0448c85..5585915f265 100644 --- a/addons/project_issue/report/project_issue_report_view.xml +++ b/addons/project_issue/report/project_issue_report_view.xml @@ -13,6 +13,22 @@ + + + By Responsible + project.issue.report + [] + + {'group_by': ['project_id', 'user_id']} + + + By Reviewer + project.issue.report + [] + + {'group_by': ['project_id', 'reviewer_id']} + + project.issue.report.select project.issue.report @@ -24,25 +40,10 @@ + - - - - - - - - - - - - - - - - - + + diff --git a/addons/project_timesheet/project_timesheet_view.xml b/addons/project_timesheet/project_timesheet_view.xml index 086986e7462..368d7728d12 100644 --- a/addons/project_timesheet/project_timesheet_view.xml +++ b/addons/project_timesheet/project_timesheet_view.xml @@ -41,8 +41,8 @@ project.project - - + + diff --git a/addons/purchase/purchase_view.xml b/addons/purchase/purchase_view.xml index 5062ecc3503..2f27bfae1f2 100644 --- a/addons/purchase/purchase_view.xml +++ b/addons/purchase/purchase_view.xml @@ -305,18 +305,16 @@ - - - - - + + + + + - - @@ -330,20 +328,18 @@ - - - - - - - + + + + + + + - - diff --git a/addons/purchase/report/purchase_report_view.xml b/addons/purchase/report/purchase_report_view.xml index 37778328bcb..34ba24f7bf6 100644 --- a/addons/purchase/report/purchase_report_view.xml +++ b/addons/purchase/report/purchase_report_view.xml @@ -36,22 +36,6 @@ - - - - - - - - - - - - - - - - diff --git a/addons/sale/report/sale_report_view.xml b/addons/sale/report/sale_report_view.xml index 65695350dd1..46e5be6fec2 100644 --- a/addons/sale/report/sale_report_view.xml +++ b/addons/sale/report/sale_report_view.xml @@ -14,6 +14,29 @@ + + + By Salespersons + sale.report + [] + + {'group_by': ['date:month', 'user_id']} + + + By Salesteam + sale.report + [] + + {'group_by': ['date:month', 'section_id']} + + + By Product + sale.report + [] + + {'group_by': ['date:month', 'product_id']} + + sale.report.search sale.report @@ -21,10 +44,12 @@ - - + - + + + + @@ -32,20 +57,6 @@ - - - - - - - - - - - - - - @@ -57,7 +68,7 @@ graph - {'search_default_Sales':1, 'group_by_no_leaf':1,'group_by':[]} + {'search_default_Sales':1, 'search_default_year': 1, 'group_by_no_leaf':1,'group_by':[]} This report performs analysis on your quotations and sales orders. Analysis check your sales revenues and sort it by different group criteria (salesman, partner, product, etc.) Use this report to perform analysis on sales not having invoiced yet. If you want to analyse your turnover, you should use the Invoice Analysis report in the Accounting application. diff --git a/addons/sale/sale_view.xml b/addons/sale/sale_view.xml index f61c518918e..13e5d952006 100644 --- a/addons/sale/sale_view.xml +++ b/addons/sale/sale_view.xml @@ -246,24 +246,22 @@ - - - - - - - - + + + + + + + + - - - - - + + + diff --git a/addons/sale_stock/report/sale_report_view.xml b/addons/sale_stock/report/sale_report_view.xml index 5e5ab042e9a..ca433dff087 100644 --- a/addons/sale_stock/report/sale_report_view.xml +++ b/addons/sale_stock/report/sale_report_view.xml @@ -10,9 +10,6 @@ - - - diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index be7c96f1c1e..b74efc9c452 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -49,6 +49,9 @@ else: env = jinja2.Environment(loader=loader, autoescape=True) env.filters["json"] = simplejson.dumps +# 1 week cache for asset bundles as advised by Google Page Speed +BUNDLE_MAXAGE = 60 * 60 * 24 * 7 + #---------------------------------------------------------- # OpenERP Web helpers #---------------------------------------------------------- @@ -551,37 +554,31 @@ class Home(http.Controller): def login(self, db, login, key, redirect="/web", **kw): return login_and_redirect(db, login, key, redirect_url=redirect) - @http.route('/web/js/', type='http', auth="public") - def js_bundle(self, xmlid, **kw): - # manifest backward compatible mode, to be removed - values = {'manifest_list': manifest_list} + @http.route([ + '/web/js/', + '/web/js//', + ], type='http', auth='public') + def js_bundle(self, xmlid, version=None, **kw): try: - assets_html = request.render(xmlid, lazy=False, qcontext=values) + bundle = AssetsBundle(xmlid) except QWebTemplateNotFound: return request.not_found() - bundle = AssetsBundle(xmlid, assets_html, debug=request.debug) - response = request.make_response( - bundle.js(), [('Content-Type', 'application/javascript')]) + response = request.make_response(bundle.js(), [('Content-Type', 'application/javascript')]) + return make_conditional(response, bundle.last_modified, max_age=BUNDLE_MAXAGE) - # TODO: check that we don't do weird lazy overriding of __call__ which break body-removal - return make_conditional( - response, bundle.last_modified, bundle.checksum, max_age=60*60*24) - - @http.route('/web/css/', type='http', auth='public') - def css_bundle(self, xmlid, **kw): - values = {'manifest_list': manifest_list} # manifest backward compatible mode, to be removed + @http.route([ + '/web/css/', + '/web/css//', + ], type='http', auth='public') + def css_bundle(self, xmlid, version=None, **kw): try: - assets_html = request.render(xmlid, lazy=False, qcontext=values) + bundle = AssetsBundle(xmlid) except QWebTemplateNotFound: return request.not_found() - bundle = AssetsBundle(xmlid, assets_html, debug=request.debug) - response = request.make_response( - bundle.css(), [('Content-Type', 'text/css')]) - - return make_conditional( - response, bundle.last_modified, bundle.checksum, max_age=60*60*24) + response = request.make_response(bundle.css(), [('Content-Type', 'text/css')]) + return make_conditional(response, bundle.last_modified, max_age=BUNDLE_MAXAGE) class WebClient(http.Controller): diff --git a/addons/web/static/src/js/tour.js b/addons/web/static/src/js/tour.js index 961fd63f4cd..032a54a5cc4 100644 --- a/addons/web/static/src/js/tour.js +++ b/addons/web/static/src/js/tour.js @@ -75,15 +75,22 @@ var Tour = { if (!tour) { Tour.error(null, "Can't run '"+tour_id+"' (tour undefined)"); } + console.log("Tour '"+tour_id+"' Begin from run method"); + var state = Tour.getState(); + if (state) { + if (state.mode === "test") { + Tour.error(false, "An other running tour has been detected all tours are now killed."); + } else { + Tour.endTour(); + } + } this.time = new Date().getTime(); if (tour.path && !window.location.href.match(new RegExp("("+Tour.getLang()+")?"+tour.path+"#?$", "i"))) { var href = Tour.getLang()+tour.path; - console.log("Tour '"+tour_id+"' Begin from run method (redirection to "+href+")"); Tour.saveState(tour.id, mode || tour.mode, -1, 0); $(document).one("ajaxStop", Tour.running); window.location.href = href; } else { - console.log("Tour '"+tour_id+"' Begin from run method"); Tour.saveState(tour.id, mode || tour.mode, 0, 0); Tour.running(); } @@ -358,6 +365,7 @@ var Tour = { clearTimeout(Tour.timer); clearTimeout(Tour.testtimer); Tour.closePopover(); + console.log("Tour reset"); }, running: function () { var state = Tour.getState(); @@ -398,6 +406,8 @@ var Tour = { }; function checkNext () { + if (!Tour.getState()) return; + Tour.autoTogglePopover(); clearTimeout(Tour.timer); @@ -476,6 +486,8 @@ var Tour = { clearTimeout(Tour.testtimer); function autoStep () { + if (!Tour.getState()) return; + if (!step) return; if (step.autoComplete) { @@ -502,6 +514,7 @@ var Tour = { // trigger after for step like: mouseenter, next step click on button display with mouseenter setTimeout(function () { + if (!Tour.getState()) return; $element.trigger($.Event("mouseup", { srcElement: $element[0] })); $element.trigger($.Event("mouseleave", { srcElement: $element[0] })); }, 1000); @@ -518,6 +531,7 @@ var Tour = { $element.html(step.sampleText); } setTimeout(function () { + if (!Tour.getState()) return; $element.trigger($.Event("keyup", { srcElement: $element })); $element.trigger($.Event("change", { srcElement: $element })); }, self.defaultDelay<<1); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index e8ab40d9b3d..984937698ce 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -23,9 +23,6 @@ - @@ -85,6 +82,9 @@ + diff --git a/addons/web_tests/tests/test_ui.py b/addons/web_tests/tests/test_ui.py index fd644341092..f1577d1d156 100644 --- a/addons/web_tests/tests/test_ui.py +++ b/addons/web_tests/tests/test_ui.py @@ -11,6 +11,6 @@ class TestUi(openerp.tests.HttpCase): def test_03_js_public(self): self.phantom_js('/',"console.log('ok')","console") def test_04_js_admin(self): - self.phantom_js('/web',"console.log('ok')","openerp.client.action_manager.inner_widget.views.form", login='admin') + self.phantom_js('/web',"console.log('ok')","openerp.client.action_manager.inner_widget", login='admin') # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/addons/website/static/src/js/website.tour.js b/addons/website/static/src/js/website.tour.js index 6c0919b4c5c..8cb816a409e 100644 --- a/addons/website/static/src/js/website.tour.js +++ b/addons/website/static/src/js/website.tour.js @@ -12,7 +12,6 @@ window.openerp.website.EditorBar.include({ } var $menuItem = $($.parseHTML('
  • '+tour.name+'
  • ')); $menuItem.click(function () { - openerp.Tour.reset(); openerp.Tour.run(tour.id); }); menu.append($menuItem); diff --git a/addons/website_forum/models/forum.py b/addons/website_forum/models/forum.py index 900eb4e0fe3..43d10c58917 100644 --- a/addons/website_forum/models/forum.py +++ b/addons/website_forum/models/forum.py @@ -278,6 +278,7 @@ class Post(osv.Model): 'closed_date': fields.datetime('Closed on', readonly=True), # karma 'karma_ask': fields.function(_get_post_karma_rights, string='Karma to ask', type='integer', multi='_get_post_karma_rights'), + 'karma_answer': fields.function(_get_post_karma_rights, string='Karma to answer', type='integer', multi='_get_post_karma_rights'), 'karma_accept': fields.function(_get_post_karma_rights, string='Karma to accept this answer', type='integer', multi='_get_post_karma_rights'), 'karma_edit': fields.function(_get_post_karma_rights, string='Karma to edit', type='integer', multi='_get_post_karma_rights'), 'karma_close': fields.function(_get_post_karma_rights, string='Karma to close', type='integer', multi='_get_post_karma_rights'), @@ -288,6 +289,7 @@ class Post(osv.Model): 'karma_comment_convert': fields.function(_get_post_karma_rights, string='karma to convert as a comment', type='integer', multi='_get_post_karma_rights'), # access rights 'can_ask': fields.function(_get_post_karma_rights, string='Can Ask', type='boolean', multi='_get_post_karma_rights'), + 'can_answer': fields.function(_get_post_karma_rights, string='Can Answer', type='boolean', multi='_get_post_karma_rights'), 'can_accept': fields.function(_get_post_karma_rights, string='Can Accept', type='boolean', multi='_get_post_karma_rights'), 'can_edit': fields.function(_get_post_karma_rights, string='Can Edit', type='boolean', multi='_get_post_karma_rights'), 'can_close': fields.function(_get_post_karma_rights, string='Can Close', type='boolean', multi='_get_post_karma_rights'), diff --git a/addons/website_forum/views/website_forum.xml b/addons/website_forum/views/website_forum.xml index 73fcbef0fd6..15a1314eb9d 100644 --- a/addons/website_forum/views/website_forum.xml +++ b/addons/website_forum/views/website_forum.xml @@ -592,9 +592,12 @@
  • - Edit + + + + + +
  • diff --git a/openerp/addons/base/ir/ir_qweb.py b/openerp/addons/base/ir/ir_qweb.py index badc169ee82..aa58ef6e05b 100644 --- a/openerp/addons/base/ir/ir_qweb.py +++ b/openerp/addons/base/ir/ir_qweb.py @@ -9,9 +9,13 @@ import math import os import re import sys +import textwrap +import uuid import xml # FIXME use lxml and etree import itertools import lxml.html +import werkzeug +from subprocess import Popen, PIPE from urlparse import urlparse import babel @@ -20,14 +24,13 @@ from PIL import Image import openerp.http import openerp.tools -import openerp.tools.func +from openerp.tools.func import lazy_property import openerp.tools.lru from openerp.http import request from openerp.tools.safe_eval import safe_eval as eval from openerp.osv import osv, orm, fields from openerp.tools import html_escape as escape from openerp.tools.translate import _ -from openerp import SUPERUSER_ID _logger = logging.getLogger(__name__) @@ -414,16 +417,15 @@ class QWeb(orm.AbstractModel): def render_tag_call_assets(self, element, template_attributes, generated_attributes, qwebcontext): """ This special 't-call' tag can be used in order to aggregate/minify javascript and css assets""" - name = template_attributes['call-assets'] - - # Backward compatibility hack for manifest usage - qwebcontext['manifest_list'] = openerp.addons.web.controllers.main.manifest_list - - d = qwebcontext.copy() - d.context['inherit_branding'] = False - content = self.render_tag_call( - element, {'call': name}, generated_attributes, d) - bundle = AssetsBundle(name, html=content) + if element.childNodes: + # An asset bundle is rendered in two differents contexts (when genereting html and + # when generating the bundle itself) so they must be qwebcontext free + # even '0' variable is forbidden + template = qwebcontext.get('__template__') + raise QWebException("t-call-assets cannot contain children nodes", template=template) + xmlid = template_attributes['call-assets'] + cr, uid, context = [getattr(qwebcontext, attr) for attr in ('cr', 'uid', 'context')] + bundle = AssetsBundle(xmlid, cr=cr, uid=uid, context=context, registry=self.pool) css = self.get_attr_bool(template_attributes.get('css'), default=True) js = self.get_attr_bool(template_attributes.get('js'), default=True) return bundle.to_html(css=css, js=js, debug=bool(qwebcontext.get('debug'))) @@ -999,36 +1001,67 @@ def get_field_type(column, options): """ return options.get('widget', column._type) +class AssetError(Exception): + pass +class AssetNotFound(AssetError): + pass + class AssetsBundle(object): + # Sass installation: + # + # sudo gem install sass compass bootstrap-sass + # + # If the following error is encountered: + # 'ERROR: Cannot load compass.' + # Use this: + # sudo gem install compass --pre + cmd_sass = ['sass', '--stdin', '-t', 'compressed', '--unix-newlines', '--compass', '-r', 'bootstrap-sass'] cache = openerp.tools.lru.LRU(32) rx_css_import = re.compile("(@import[^;{]+;?)", re.M) + rx_sass_import = re.compile("""(@import\s?['"]([^'"]+)['"])""") + rx_css_split = re.compile("\/\*\! ([a-f0-9-]+) \*\/") - def __init__(self, xmlid, html=None, debug=False): - self.debug = debug + def __init__(self, xmlid, debug=False, cr=None, uid=None, context=None, registry=None): self.xmlid = xmlid + self.cr = request.cr if cr is None else cr + self.uid = request.uid if uid is None else uid + self.context = request.context if context is None else context + self.registry = request.registry if registry is None else registry self.javascripts = [] self.stylesheets = [] + self.css_errors = [] self.remains = [] self._checksum = None - if html: - self.parse(html) - def parse(self, html): - fragments = lxml.html.fragments_fromstring(html) + context = self.context.copy() + context['inherit_branding'] = False + self.html = self.registry['ir.ui.view'].render(self.cr, self.uid, xmlid, context=context) + self.parse() + + def parse(self): + fragments = lxml.html.fragments_fromstring(self.html) for el in fragments: if isinstance(el, basestring): self.remains.append(el) elif isinstance(el, lxml.html.HtmlElement): - src = el.get('src') - href = el.get('href') + src = el.get('src', '') + href = el.get('href', '') + atype = el.get('type') + media = el.get('media') if el.tag == 'style': - self.stylesheets.append(StylesheetAsset(source=el.text)) + if atype == 'text/sass' or src.endswith('.sass'): + self.stylesheets.append(SassAsset(self, inline=el.text, media=media)) + else: + self.stylesheets.append(StylesheetAsset(self, inline=el.text, media=media)) elif el.tag == 'link' and el.get('rel') == 'stylesheet' and self.can_aggregate(href): - self.stylesheets.append(StylesheetAsset(url=href)) + if href.endswith('.sass') or atype == 'text/sass': + self.stylesheets.append(SassAsset(self, url=href, media=media)) + else: + self.stylesheets.append(StylesheetAsset(self, url=href, media=media)) elif el.tag == 'script' and not src: - self.javascripts.append(JavascriptAsset(source=el.text)) + self.javascripts.append(JavascriptAsset(self, inline=el.text)) elif el.tag == 'script' and self.can_aggregate(src): - self.javascripts.append(JavascriptAsset(url=src)) + self.javascripts.append(JavascriptAsset(self, url=src)) else: self.remains.append(lxml.html.tostring(el)) else: @@ -1041,10 +1074,13 @@ class AssetsBundle(object): def can_aggregate(self, url): return not urlparse(url).netloc and not url.startswith(('/web/css', '/web/js')) - def to_html(self, sep='\n ', css=True, js=True, debug=False): + def to_html(self, sep=None, css=True, js=True, debug=False): + if sep is None: + sep = '\n ' response = [] if debug: - if css: + if css and self.stylesheets: + self.compile_sass() for style in self.stylesheets: response.append(style.to_html()) if js: @@ -1052,41 +1088,56 @@ class AssetsBundle(object): response.append(jscript.to_html()) else: if css and self.stylesheets: - response.append('' % self.xmlid) - if js and self.javascripts: - response.append('' % self.xmlid) + response.append('' % (self.xmlid, self.version)) + if js: + response.append('' % (self.xmlid, self.version)) response.extend(self.remains) return sep + sep.join(response) - @openerp.tools.func.lazy_property + @lazy_property def last_modified(self): + """Returns last modified date of linked files""" return max(itertools.chain( (asset.last_modified for asset in self.javascripts), (asset.last_modified for asset in self.stylesheets), - [datetime.datetime(1970, 1, 1)], )) - @openerp.tools.func.lazy_property + @lazy_property + def version(self): + return self.checksum[0:7] + + @lazy_property def checksum(self): - checksum = hashlib.new('sha1') - for asset in itertools.chain(self.javascripts, self.stylesheets): - checksum.update(asset.content.encode("utf-8")) - return checksum.hexdigest() + """ + Not really a full checksum. + We compute a SHA1 on the rendered bundle + max linked files last_modified date + """ + check = self.html + str(self.last_modified) + return hashlib.sha1(check).hexdigest() def js(self): - key = 'js_' + self.checksum + key = 'js_%s' % self.xmlid + if key in self.cache and self.cache[key][0] != self.version: + # Invalidate cache on version mismach + self.cache.pop(key) if key not in self.cache: content =';\n'.join(asset.minify() for asset in self.javascripts) - self.cache[key] = content - if self.debug: - return "/*\n%s\n*/\n" % '\n'.join( - [asset.url for asset in self.javascripts if asset.url]) + self.cache[key] - return self.cache[key] + self.cache[key] = (self.version, content) + return self.cache[key][1] def css(self): - key = 'css_' + self.checksum + key = 'css_%s' % self.xmlid + if key in self.cache and self.cache[key][0] != self.version: + # Invalidate cache on version mismach + self.cache.pop(key) if key not in self.cache: + self.compile_sass() content = '\n'.join(asset.minify() for asset in self.stylesheets) + + if self.css_errors: + msg = '\n'.join(self.css_errors) + content += self.css_message(msg.replace('\n', '\\A ')) + # move up all @import rules to the top matches = [] def push(matchobj): @@ -1097,89 +1148,200 @@ class AssetsBundle(object): matches.append(content) content = u'\n'.join(matches) - self.cache[key] = content - if self.debug: - return "/*\n%s\n*/\n" % '\n'.join( - [asset.url for asset in self.javascripts if asset.url]) + self.cache[key] - return self.cache[key] + if self.css_errors: + return content + self.cache[key] = (self.version, content) + + return self.cache[key][1] + + def css_message(self, message): + return """ + body:before { + background: #ffc; + width: 100%%; + font-size: 14px; + font-family: monospace; + white-space: pre; + content: "%s"; + } + """ % message.replace('"', '\\"') + + def compile_sass(self): + """ + Checks if the bundle contains any sass content, then compiles it to css. + Css compilation is done at the bundle level and not in the assets + because they are potentially interdependant. + """ + sass = [asset for asset in self.stylesheets if isinstance(asset, SassAsset)] + if not sass: + return + source = '\n'.join([asset.get_source() for asset in sass]) + + # move up all @import rules to the top and exclude file imports + imports = [] + def push(matchobj): + ref = matchobj.group(2) + line = '@import "%s"' % ref + if '.' not in ref and line not in imports and not ref.startswith(('.', '/', '~')): + imports.append(line) + return '' + source = re.sub(self.rx_sass_import, push, source) + imports.append(source) + source = u'\n'.join(imports) + + try: + compiler = Popen(self.cmd_sass, stdin=PIPE, stdout=PIPE, stderr=PIPE) + except Exception: + msg = "Could not find 'sass' program needed to compile sass/scss files" + _logger.error(msg) + self.css_errors.append(msg) + return + result = compiler.communicate(input=source.encode('utf-8')) + if compiler.returncode: + error = self.get_sass_error(result[1], source=source) + _logger.warning(error) + self.css_errors.append(error) + return + compiled = result[0].strip().decode('utf8') + fragments = self.rx_css_split.split(compiled)[1:] + while fragments: + asset_id = fragments.pop(0) + asset = next(asset for asset in sass if asset.id == asset_id) + asset._content = fragments.pop(0) + + def get_sass_error(self, stderr, source=None): + # TODO: try to find out which asset the error belongs to + error = stderr.split('Load paths')[0].replace(' Use --trace for backtrace.', '') + error += "This error occured while compiling the bundle '%s' containing:" % self.xmlid + for asset in self.stylesheets: + if isinstance(asset, SassAsset): + error += '\n - %s' % (asset.url if asset.url else '') + return error class WebAsset(object): - def __init__(self, source=None, url=None): - self.source = source + html_url = '%s' + + def __init__(self, bundle, inline=None, url=None): + self.id = str(uuid.uuid4()) + self.bundle = bundle + self.inline = inline self.url = url - self._irattach = None + self.cr = bundle.cr + self.uid = bundle.uid + self.registry = bundle.registry + self.context = bundle.context self._content = None - self.filename = None - self.last_modified = None - if source: - self.last_modified = datetime.datetime(1970, 1, 1) - if url: - module = filter(None, self.url.split('/'))[0] + self._filename = None + self._ir_attach = None + name = '' if inline else url + self.name = "%s defined in bundle '%s'" % (name, bundle.xmlid) + if not inline and not url: + raise Exception("An asset should either be inlined or url linked") + + def stat(self): + if not (self.inline or self._filename or self._ir_attach): + addon = filter(None, self.url.split('/'))[0] try: # Test url against modules static assets - mpath = openerp.http.addons_manifest[module]['addons_path'] - self.filename = mpath + self.url.replace('/', os.path.sep) - self.last_modified = datetime.datetime.fromtimestamp(os.path.getmtime(self.filename)) + mpath = openerp.http.addons_manifest[addon]['addons_path'] + self._filename = mpath + self.url.replace('/', os.path.sep) except Exception: try: # Test url against ir.attachments + fields = ['__last_update', 'datas', 'mimetype'] domain = [('type', '=', 'binary'), ('url', '=', self.url)] - attach = request.registry['ir.attachment'].search_read(request.cr, SUPERUSER_ID, domain, ['__last_update', 'datas', 'mimetype'], context=request.context) - self._irattach = attach[0] - server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT - try: - self.last_modified = datetime.datetime.strptime(attach[0]['__last_update'], server_format + '.%f') - except ValueError: - self.last_modified = datetime.datetime.strptime(attach[0]['__last_update'], server_format) + ira = self.registry['ir.attachment'] + attach = ira.search_read(self.cr, self.uid, domain, fields, context=self.context) + self._ir_attach = attach[0] except Exception: - raise KeyError("Could not find asset '%s' for '%s' addon" % (self.url, module)) + raise AssetNotFound("Could not find %s" % self.name) - @openerp.tools.func.lazy_property + def to_html(): + raise NotImplementedError() + + @lazy_property + def last_modified(self): + try: + self.stat() + if self._filename: + return datetime.datetime.fromtimestamp(os.path.getmtime(self._filename)) + elif self._ir_attach: + server_format = openerp.tools.misc.DEFAULT_SERVER_DATETIME_FORMAT + last_update = self._ir_attach['__last_update'] + try: + return datetime.datetime.strptime(last_update, server_format + '.%f') + except ValueError: + return datetime.datetime.strptime(last_update, server_format) + except Exception: + pass + return datetime.datetime(1970, 1, 1) + + @property def content(self): - if self.source: - return self.source - if self._irattach: - return self._irattach['datas'].decode('base64') - return self.get_content() + if not self._content: + self._content = self.inline or self._fetch_content() + return self._content - def get_content(self): - with open(self.filename, 'rb') as fp: - return fp.read().decode('utf-8') + def _fetch_content(self): + """ Fetch content from file or database""" + try: + self.stat() + if self._filename: + with open(self._filename, 'rb') as fp: + return fp.read().decode('utf-8') + else: + return self._ir_attach['datas'].decode('base64') + except UnicodeDecodeError: + raise AssetError('%s is not utf-8 encoded.' % self.name) + except IOError: + raise AssetNotFound('File %s does not exist.' % self.name) + except: + raise AssetError('Could not get content for %s.' % self.name) def minify(self): return self.content + def with_header(self, content=None): + if content is None: + content = self.content + return '\n/* %s */\n%s' % (self.name, content) + class JavascriptAsset(WebAsset): def minify(self): - return rjsmin(self.content) + return self.with_header(rjsmin(self.content)) + + def _fetch_content(self): + try: + return super(JavascriptAsset, self)._fetch_content() + except AssetError, e: + return "console.error(%s);" % json.dumps(e.message) def to_html(self): if self.url: - return '' % self.url + return '' % (self.html_url % self.url) else: - return '' % self.source + return '' % self.with_header() class StylesheetAsset(WebAsset): rx_import = re.compile(r"""@import\s+('|")(?!'|"|/|https?://)""", re.U) rx_url = re.compile(r"""url\s*\(\s*('|"|)(?!'|"|/|https?://|data:)""", re.U) rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U) + rx_charset = re.compile(r'(@charset "[^"]+";)', re.U) - def _get_content(self): - with open(self.filename, 'rb') as fp: - firstline = fp.readline() - m = re.match(r'@charset "([^"]+)";', firstline) - if m: - encoding = m.group(1) - else: - encoding = "utf-8" - # "reinject" first line as it's not @charset - fp.seek(0) + def __init__(self, *args, **kw): + self.media = kw.pop('media', None) + super(StylesheetAsset, self).__init__(*args, **kw) - return fp.read().decode(encoding) + @property + def content(self): + content = super(StylesheetAsset, self).content + if self.media: + content = '@media %s { %s }' % (self.media, content) + return content - def get_content(self): - content = self._get_content() - if self.url: + def _fetch_content(self): + try: + content = super(StylesheetAsset, self)._fetch_content() web_dir = os.path.dirname(self.url) content = self.rx_import.sub( @@ -1191,6 +1353,12 @@ class StylesheetAsset(WebAsset): r"url(\1%s/" % (web_dir,), content, ) + + # remove charset declarations, we only support utf-8 + content = self.rx_charset.sub('', content) + except AssetError, e: + self.bundle.css_errors.append(e.message) + return '' return content def minify(self): @@ -1201,13 +1369,61 @@ class StylesheetAsset(WebAsset): # space content = re.sub(r'\s+', ' ', content) content = re.sub(r' *([{}]) *', r'\1', content) - return content + return self.with_header(content) + + def to_html(self): + media = (' media="%s"' % werkzeug.utils.escape(self.media)) if self.media else '' + if self.url: + href = self.html_url % self.url + return '' % (href, media) + else: + return '' % (media, self.with_header()) + +class SassAsset(StylesheetAsset): + html_url = '%s.css' + rx_indent = re.compile(r'^( +|\t+)', re.M) + indent = None + reindent = ' ' + + def minify(self): + return self.with_header() def to_html(self): if self.url: - return '' % self.url - else: - return '' % self.source + ira = self.registry['ir.attachment'] + url = self.html_url % self.url + domain = [('type', '=', 'binary'), ('url', '=', self.url)] + ira_id = ira.search(self.cr, self.uid, domain, context=self.context) + if ira_id: + # TODO: update only if needed + ira.write(self.cr, openerp.SUPERUSER_ID, [ira_id], {'datas': self.content}, context=self.context) + else: + ira.create(self.cr, openerp.SUPERUSER_ID, dict( + datas=self.content.encode('utf8').encode('base64'), + mimetype='text/css', + type='binary', + name=url, + url=url, + ), context=self.context) + return super(SassAsset, self).to_html() + + def get_source(self): + content = textwrap.dedent(self.inline or self._fetch_content()) + + def fix_indent(m): + ind = m.group() + if self.indent is None: + self.indent = ind + if self.indent == self.reindent: + # Don't reindent the file if identation is the final one (reindent) + raise StopIteration() + return ind.replace(self.indent, self.reindent) + + try: + content = self.rx_indent.sub(fix_indent, content) + except StopIteration: + pass + return "/*! %s */\n%s" % (self.id, content) def rjsmin(script): """ Minify js with a clever regex. diff --git a/openerp/addons/base/ir/ir_ui_view.py b/openerp/addons/base/ir/ir_ui_view.py index 1ef5466a58f..b5231808b5e 100644 --- a/openerp/addons/base/ir/ir_ui_view.py +++ b/openerp/addons/base/ir/ir_ui_view.py @@ -519,7 +519,7 @@ class view(osv.osv): if context is None: context = {} if root_id is None: root_id = source_id - sql_inherit = self.pool['ir.ui.view'].get_inheriting_views_arch(cr, uid, source_id, model, context=context) + sql_inherit = self.get_inheriting_views_arch(cr, uid, source_id, model, context=context) for (specs, view_id) in sql_inherit: specs_tree = etree.fromstring(specs.encode('utf-8')) if context.get('inherit_branding'): @@ -986,10 +986,15 @@ class view(osv.osv): ) qcontext.update(values) - # TODO: remove this as soon as the following branch is merged - # lp:~openerp-dev/openerp-web/trunk-module-closure-style-msh - from openerp.addons.web.controllers.main import module_boot - qcontext['modules'] = simplejson.dumps(module_boot()) if request else None + # TODO: This helper can be used by any template that wants to embedd the backend. + # It is currently necessary because the ir.ui.view bundle inheritance does not + # match the module dependency graph. + def get_modules_order(): + if request: + from openerp.addons.web.controllers.main import module_boot + return simplejson.dumps(module_boot()) + return '[]' + qcontext['get_modules_order'] = get_modules_order def loader(name): return self.read_template(cr, uid, name, context=context)