[ADD] qweb view
Currently, non-trivial client interface customisations generally *have* to go through client actions (a limited amount can use complex HTML in the form, but it's not that common). This can require a significant amount of messing around with JS code, and doesn't integrate well with the rest of the system (primarily due to the loss of the control panel / search view). This change re-uses the existing `ir.ui.view[type=qweb]` as actual views (bound to a model) to provide for flexible views rendered synchronously server-side without needing javascript development. A few dynamic behaviours are also provided (on top of bootstrap's which should be usable out of the box): * an extension of `a[type="action"]` which allows for more detailed crafting of the action (not just accessing an action by id and by model, method) * a new `a[type="toggle"]` which provides lazy loading of sub-items (currently by calling a method which returns HTML to insert after the loading anchor) * simple fold/unfold (without lazy loading) can be performed through bootstrap closes odoo/odoo#28716
This commit is contained in:
@@ -5,7 +5,6 @@ from odoo import api, SUPERUSER_ID
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
from . import controllers
|
||||
from . import report
|
||||
|
||||
def uninstall_hook(cr, registry):
|
||||
|
||||
@@ -33,9 +33,6 @@ have real delivered quantities in sales orders.
|
||||
'demo': [
|
||||
'data/sale_service_demo.xml',
|
||||
],
|
||||
'qweb': [
|
||||
'static/src/xml/timesheet_plan.xml',
|
||||
],
|
||||
'auto_install': True,
|
||||
'uninstall_hook': 'uninstall_hook',
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
@@ -5,5 +5,6 @@ from . import account
|
||||
from . import account_invoice
|
||||
from . import product
|
||||
from . import project
|
||||
from . import project_overview
|
||||
from . import sale_order
|
||||
from . import project_sale_line_employee_map
|
||||
|
||||
+117
-133
@@ -1,13 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from ast import literal_eval
|
||||
import babel
|
||||
from dateutil.relativedelta import relativedelta
|
||||
import itertools
|
||||
import json
|
||||
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
from odoo import fields, _, models
|
||||
from odoo.tools import float_round
|
||||
|
||||
from odoo.addons.web.controllers.main import clean_action
|
||||
@@ -15,34 +12,30 @@ from odoo.addons.web.controllers.main import clean_action
|
||||
DEFAULT_MONTH_RANGE = 3
|
||||
|
||||
|
||||
class SaleTimesheetController(http.Controller):
|
||||
class Project(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
@http.route('/timesheet/plan', type='json', auth="user")
|
||||
def plan(self, domain):
|
||||
""" Get the HTML of the project plan for projects matching the given domain
|
||||
:param domain: a domain for project.project
|
||||
"""
|
||||
projects = request.env['project.project'].search(domain)
|
||||
values = self._plan_prepare_values(projects)
|
||||
view = request.env.ref('sale_timesheet.timesheet_plan')
|
||||
return {
|
||||
'html_content': view.render(values),
|
||||
'project_ids': projects.ids,
|
||||
'actions': self._plan_prepare_actions(projects, values),
|
||||
}
|
||||
|
||||
def _plan_prepare_values(self, projects):
|
||||
def _qweb_prepare_qcontext(self, view_id, domain):
|
||||
values = super()._qweb_prepare_qcontext(view_id, domain)
|
||||
|
||||
currency = request.env.user.company_id.currency_id
|
||||
uom_hour = request.env.ref('uom.product_uom_hour')
|
||||
projects = self.search(domain)
|
||||
values.update(projects._plan_prepare_values())
|
||||
values['actions'] = projects._plan_prepare_actions(values)
|
||||
|
||||
return values
|
||||
|
||||
def _plan_prepare_values(self):
|
||||
currency = self.env.user.company_id.currency_id
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
hour_rounding = uom_hour.rounding
|
||||
billable_types = ['non_billable', 'non_billable_project', 'billable_time', 'billable_fixed']
|
||||
|
||||
values = {
|
||||
'projects': projects,
|
||||
'projects': self,
|
||||
'currency': currency,
|
||||
'timesheet_domain': [('project_id', 'in', projects.ids)],
|
||||
'stat_buttons': self._plan_get_stat_button(projects),
|
||||
'timesheet_domain': [('project_id', 'in', self.ids)],
|
||||
'stat_buttons': self._plan_get_stat_button(),
|
||||
}
|
||||
|
||||
#
|
||||
@@ -60,8 +53,8 @@ class SaleTimesheetController(http.Controller):
|
||||
}
|
||||
|
||||
# hours (from timesheet) and rates (by billable type)
|
||||
dashboard_domain = [('project_id', 'in', projects.ids), ('timesheet_invoice_type', '!=', False)] # force billable type
|
||||
dashboard_data = request.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_invoice_type'], ['timesheet_invoice_type'])
|
||||
dashboard_domain = [('project_id', 'in', self.ids), ('timesheet_invoice_type', '!=', False)] # force billable type
|
||||
dashboard_data = self.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_invoice_type'], ['timesheet_invoice_type'])
|
||||
dashboard_total_hours = sum([data['unit_amount'] for data in dashboard_data])
|
||||
for data in dashboard_data:
|
||||
billable_type = data['timesheet_invoice_type']
|
||||
@@ -74,7 +67,7 @@ class SaleTimesheetController(http.Controller):
|
||||
|
||||
# profitability, using profitability SQL report
|
||||
profit = dict.fromkeys(['invoiced', 'to_invoice', 'cost', 'expense_cost', 'expense_amount_untaxed_invoiced', 'total'], 0.0)
|
||||
profitability_raw_data = request.env['project.profitability.report'].read_group([('project_id', 'in', projects.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_invoiced'], ['project_id'])
|
||||
profitability_raw_data = self.env['project.profitability.report'].read_group([('project_id', 'in', self.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_invoiced'], ['project_id'])
|
||||
for data in profitability_raw_data:
|
||||
profit['invoiced'] += data.get('amount_untaxed_invoiced', 0.0)
|
||||
profit['to_invoice'] += data.get('amount_untaxed_to_invoice', 0.0)
|
||||
@@ -89,14 +82,14 @@ class SaleTimesheetController(http.Controller):
|
||||
#
|
||||
# Time Repartition (per employee per billable types)
|
||||
#
|
||||
user_ids = request.env['project.task'].sudo().search_read([('project_id', 'in', projects.ids), ('user_id', '!=', False)], ['user_id'])
|
||||
user_ids = self.env['project.task'].sudo().search_read([('project_id', 'in', self.ids), ('user_id', '!=', False)], ['user_id'])
|
||||
user_ids = [user_id['user_id'][0] for user_id in user_ids]
|
||||
employee_ids = request.env['res.users'].sudo().search_read([('id', 'in', user_ids)], ['employee_ids'])
|
||||
employee_ids = self.env['res.users'].sudo().search_read([('id', 'in', user_ids)], ['employee_ids'])
|
||||
# flatten the list of list
|
||||
employee_ids = list(itertools.chain.from_iterable([employee_id['employee_ids'] for employee_id in employee_ids]))
|
||||
employees = request.env['hr.employee'].sudo().browse(employee_ids) | request.env['account.analytic.line'].search([('project_id', 'in', projects.ids)]).mapped('employee_id')
|
||||
repartition_domain = [('project_id', 'in', projects.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type
|
||||
repartition_data = request.env['account.analytic.line'].read_group(repartition_domain, ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False)
|
||||
employees = self.env['hr.employee'].sudo().browse(employee_ids) | self.env['account.analytic.line'].search([('project_id', 'in', self.ids)]).mapped('employee_id')
|
||||
repartition_domain = [('project_id', 'in', self.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type
|
||||
repartition_data = self.env['account.analytic.line'].read_group(repartition_domain, ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False)
|
||||
|
||||
# set repartition per type per employee
|
||||
repartition_employee = {}
|
||||
@@ -134,26 +127,26 @@ class SaleTimesheetController(http.Controller):
|
||||
#
|
||||
# Table grouped by SO / SOL / Employees
|
||||
#
|
||||
timesheet_forecast_table_rows = self._table_get_line_values(projects)
|
||||
timesheet_forecast_table_rows = self._table_get_line_values()
|
||||
if timesheet_forecast_table_rows:
|
||||
values['timesheet_forecast_table'] = timesheet_forecast_table_rows
|
||||
return values
|
||||
|
||||
def _table_get_line_values(self, projects):
|
||||
def _table_get_line_values(self):
|
||||
""" return the header and the rows informations of the table """
|
||||
if not projects:
|
||||
if not self:
|
||||
return False
|
||||
|
||||
uom_hour = request.env.ref('uom.product_uom_hour')
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
|
||||
# build SQL query and fetch raw data
|
||||
query, query_params = self._table_rows_sql_query(projects)
|
||||
request.env.cr.execute(query, query_params)
|
||||
raw_data = request.env.cr.dictfetchall()
|
||||
rows_employee = self._table_rows_get_employee_lines(projects, raw_data)
|
||||
default_row_vals = self._table_row_default(projects)
|
||||
query, query_params = self._table_rows_sql_query()
|
||||
self.env.cr.execute(query, query_params)
|
||||
raw_data = self.env.cr.dictfetchall()
|
||||
rows_employee = self._table_rows_get_employee_lines(raw_data)
|
||||
default_row_vals = self._table_row_default()
|
||||
|
||||
empty_line_ids, empty_order_ids = self._table_get_empty_so_lines(projects)
|
||||
empty_line_ids, empty_order_ids = self._table_get_empty_so_lines()
|
||||
|
||||
# extract row labels
|
||||
sale_line_ids = set()
|
||||
@@ -164,8 +157,8 @@ class SaleTimesheetController(http.Controller):
|
||||
if row[0]['sale_order_id']:
|
||||
sale_order_ids.add(row[0]['sale_order_id'])
|
||||
|
||||
sale_order_lines = request.env['sale.order.line'].sudo().browse(sale_line_ids | empty_line_ids)
|
||||
map_so_names = {so.id: so.name for so in request.env['sale.order'].sudo().browse(sale_order_ids | empty_order_ids)}
|
||||
sale_order_lines = self.env['sale.order.line'].sudo().browse(sale_line_ids | empty_line_ids)
|
||||
map_so_names = {so.id: so.name for so in self.env['sale.order'].sudo().browse(sale_order_ids | empty_order_ids)}
|
||||
map_sol = {sol.id: sol for sol in sale_order_lines}
|
||||
map_sol_names = {sol.id: sol.name.split('\n')[0] if sol.name else _('No Sales Order Line') for sol in sale_order_lines}
|
||||
map_sol_so = {sol.id: sol.order_id.id for sol in sale_order_lines}
|
||||
@@ -185,7 +178,7 @@ class SaleTimesheetController(http.Controller):
|
||||
# sale line row
|
||||
sale_line_row_key = (sale_order_id, sale_line_id)
|
||||
if sale_line_row_key not in rows_sale_line:
|
||||
sale_line = map_sol.get(sale_line_id, request.env['sale.order.line'])
|
||||
sale_line = map_sol.get(sale_line_id, self.env['sale.order.line'])
|
||||
is_milestone = sale_line.product_id.invoice_policy == 'delivery' and sale_line.product_id.service_type == 'manual' if sale_line else False
|
||||
rows_sale_line[sale_line_row_key] = [{'label': map_sol_names.get(sale_line.id) if sale_line else _('No Sales Order Line'), 'res_id': sale_line_id, 'res_model': 'sale.order.line', 'type': 'sale_order_line', 'is_milestone': is_milestone}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
|
||||
if not is_milestone:
|
||||
@@ -234,17 +227,16 @@ class SaleTimesheetController(http.Controller):
|
||||
|
||||
# complete table data
|
||||
return {
|
||||
'header': self._table_header(projects),
|
||||
'header': self._table_header(),
|
||||
'rows': timesheet_forecast_table_rows
|
||||
}
|
||||
|
||||
def _table_header(self, projects):
|
||||
def _table_header(self):
|
||||
initial_date = fields.Date.from_string(fields.Date.today())
|
||||
ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
|
||||
|
||||
def _to_short_month_name(date):
|
||||
month_index = fields.Date.from_string(date).month
|
||||
return babel.dates.get_month_names('abbreviated', locale=request.env.context.get('lang', 'en_US'))[month_index]
|
||||
return babel.dates.get_month_names('abbreviated', locale=self.env.context.get('lang', 'en_US'))[month_index]
|
||||
|
||||
header_names = [_('Name'), _('Before')] + [_to_short_month_name(date) for date in ts_months] + [_('Done'), _('Sold'), _('Remaining')]
|
||||
|
||||
@@ -258,11 +250,11 @@ class SaleTimesheetController(http.Controller):
|
||||
result[-1]['tooltip'] = _('What is still to deliver based on sold hours and hours already done. Equals to sold hours - done hours.')
|
||||
return result
|
||||
|
||||
def _table_row_default(self, projects):
|
||||
lenght = len(self._table_header(projects))
|
||||
def _table_row_default(self):
|
||||
lenght = len(self._table_header())
|
||||
return [0.0] * (lenght - 1) # before, M1, M2, M3, Done, Sold, Remaining
|
||||
|
||||
def _table_rows_sql_query(self, projects):
|
||||
def _table_rows_sql_query(self):
|
||||
initial_date = fields.Date.from_string(fields.Date.today())
|
||||
ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
|
||||
# build query
|
||||
@@ -284,19 +276,19 @@ class SaleTimesheetController(http.Controller):
|
||||
"""
|
||||
|
||||
last_ts_month = fields.Date.to_string(fields.Date.from_string(ts_months[-1]) + relativedelta(months=1))
|
||||
query_params = (tuple(projects.ids), last_ts_month)
|
||||
query_params = (tuple(self.ids), last_ts_month)
|
||||
return query, query_params
|
||||
|
||||
def _table_rows_get_employee_lines(self, projects, data_from_db):
|
||||
def _table_rows_get_employee_lines(self, data_from_db):
|
||||
initial_date = fields.Date.today()
|
||||
ts_months = sorted([initial_date - relativedelta(months=i, day=1) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
|
||||
default_row_vals = self._table_row_default(projects)
|
||||
default_row_vals = self._table_row_default()
|
||||
|
||||
# extract employee names
|
||||
employee_ids = set()
|
||||
for data in data_from_db:
|
||||
employee_ids.add(data['employee_id'])
|
||||
map_empl_names = {empl.id: empl.name for empl in request.env['hr.employee'].sudo().browse(employee_ids)}
|
||||
map_empl_names = {empl.id: empl.name for empl in self.env['hr.employee'].sudo().browse(employee_ids)}
|
||||
|
||||
# extract rows data for employee, sol and so rows
|
||||
rows_employee = {} # (so, sol, employee) -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
|
||||
@@ -326,29 +318,29 @@ class SaleTimesheetController(http.Controller):
|
||||
rows_employee[row_key][5] += data['number_hours']
|
||||
return rows_employee
|
||||
|
||||
def _table_get_empty_so_lines(self, projects):
|
||||
def _table_get_empty_so_lines(self):
|
||||
""" get the Sale Order Lines having no timesheet but having generated a task or a project """
|
||||
so_lines = projects.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense)
|
||||
so_lines = self.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense)
|
||||
return set(so_lines.ids), set(so_lines.mapped('order_id').ids)
|
||||
|
||||
# --------------------------------------------------
|
||||
# Actions: Stat buttons, ...
|
||||
# --------------------------------------------------
|
||||
|
||||
def _plan_prepare_actions(self, projects, values):
|
||||
def _plan_prepare_actions(self, values):
|
||||
actions = []
|
||||
if len(projects) == 1:
|
||||
if request.env.user.has_group('sales_team.group_sale_salesman'):
|
||||
if not projects.sale_line_id and not projects.tasks.mapped('sale_line_id'):
|
||||
if len(self) == 1:
|
||||
if self.env.user.has_group('sales_team.group_sale_salesman'):
|
||||
if not self.sale_line_id and not self.tasks.mapped('sale_line_id'):
|
||||
actions.append({
|
||||
'label': _("Create a Sales Order"),
|
||||
'type': 'action',
|
||||
'action_id': 'sale_timesheet.project_project_action_multi_create_sale_order',
|
||||
'context': json.dumps({'active_id': projects.id, 'active_model': 'project.project'}),
|
||||
'context': json.dumps({'active_id': self.id, 'active_model': 'project.project'}),
|
||||
})
|
||||
if request.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
|
||||
if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
|
||||
to_invoice_amount = values['dashboard']['profit'].get('to_invoice', False) # plan project only takes services SO line with timesheet into account
|
||||
sale_orders = projects.tasks.mapped('sale_line_id.order_id').filtered(lambda so: so.invoice_status == 'to invoice')
|
||||
sale_orders = self.tasks.mapped('sale_line_id.order_id').filtered(lambda so: so.invoice_status == 'to invoice')
|
||||
if to_invoice_amount and sale_orders:
|
||||
if len(sale_orders) == 1:
|
||||
actions.append({
|
||||
@@ -362,102 +354,94 @@ class SaleTimesheetController(http.Controller):
|
||||
'label': _("Create Invoice"),
|
||||
'type': 'action',
|
||||
'action_id': 'sale_timesheet.project_project_action_multi_create_invoice',
|
||||
'context': json.dumps({'active_id': projects.id, 'active_model': 'project.project'}),
|
||||
'context': json.dumps({'active_id': self.id, 'active_model': 'project.project'}),
|
||||
})
|
||||
return actions
|
||||
|
||||
def _plan_get_stat_button(self, projects):
|
||||
def _plan_get_stat_button(self):
|
||||
stat_buttons = []
|
||||
if len(projects) == 1:
|
||||
if len(self) == 1:
|
||||
edit_project = self.env.ref('project.edit_project')
|
||||
stat_buttons.append({
|
||||
'name': _('Project'),
|
||||
'res_model': 'project.project',
|
||||
'res_id': projects.id,
|
||||
'icon': 'fa fa-puzzle-piece',
|
||||
'action': _to_action_data('project.project', res_id=self.id,
|
||||
views=[[edit_project.id, 'form']])
|
||||
})
|
||||
ts_tree = self.env.ref('hr_timesheet.hr_timesheet_line_tree')
|
||||
ts_form = self.env.ref('hr_timesheet.hr_timesheet_line_form')
|
||||
stat_buttons.append({
|
||||
'name': _('Timesheets'),
|
||||
'res_model': 'account.analytic.line',
|
||||
'domain': [('project_id', 'in', projects.ids)],
|
||||
'icon': 'fa fa-calendar',
|
||||
'action': _to_action_data(
|
||||
'account.analytic.line',
|
||||
domain=[('project_id', 'in', self.ids)],
|
||||
views=[(ts_tree.id, 'list'), (ts_form.id, 'form')],
|
||||
)
|
||||
})
|
||||
|
||||
# if only one project, add it in the context as default value
|
||||
tasks_domain = [('project_id', 'in', self.ids)]
|
||||
tasks_context = self.env.context
|
||||
tasks_projects = self.env['project.task'].sudo().search(tasks_domain).mapped('project_id')
|
||||
if len(tasks_projects) == 1:
|
||||
tasks_context = {**tasks_context, 'default_project_id': tasks_projects.id}
|
||||
stat_buttons.append({
|
||||
'name': _('Tasks'),
|
||||
'count': sum(projects.mapped('task_count')),
|
||||
'res_model': 'project.task',
|
||||
'domain': [('project_id', 'in', projects.ids)],
|
||||
'count': sum(self.mapped('task_count')),
|
||||
'icon': 'fa fa-tasks',
|
||||
'action': _to_action_data(
|
||||
action=self.env.ref('project.action_view_task'),
|
||||
domain=tasks_domain,
|
||||
context=tasks_context
|
||||
)
|
||||
})
|
||||
if request.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
|
||||
sale_orders = projects.mapped('sale_line_id.order_id') | projects.mapped('tasks.sale_order_id')
|
||||
|
||||
if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
|
||||
sale_orders = self.mapped('sale_line_id.order_id') | self.mapped(
|
||||
'tasks.sale_order_id')
|
||||
if sale_orders:
|
||||
stat_buttons.append({
|
||||
'name': _('Sales Orders'),
|
||||
'count': len(sale_orders),
|
||||
'res_model': 'sale.order',
|
||||
'domain': [('id', 'in', sale_orders.ids)],
|
||||
'icon': 'fa fa-dollar',
|
||||
'action': _to_action_data(
|
||||
action=self.env.ref('sale.action_orders'),
|
||||
domain=[('id', 'in', sale_orders.ids)],
|
||||
context={'create': False, 'edit': False, 'delete': False}
|
||||
)
|
||||
})
|
||||
invoices = sale_orders.mapped('invoice_ids').filtered(lambda inv: inv.type == 'out_invoice')
|
||||
if invoices:
|
||||
stat_buttons.append({
|
||||
'name': _('Invoices'),
|
||||
'count': len(invoices),
|
||||
'res_model': 'account.invoice',
|
||||
'domain': [('id', 'in', invoices.ids), ('type', '=', 'out_invoice')],
|
||||
'icon': 'fa fa-pencil-square-o',
|
||||
'action': _to_action_data(
|
||||
action=self.env.ref('account.action_invoice_tree1'),
|
||||
domain=[('id', 'in', invoices.ids), ('type', '=', 'out_invoice')],
|
||||
context={'create': False, 'delete': False}
|
||||
)
|
||||
})
|
||||
return stat_buttons
|
||||
|
||||
@http.route('/timesheet/plan/action', type='json', auth="user")
|
||||
def plan_stat_button(self, domain=[], res_model='account.analytic.line', res_id=False):
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_id': False,
|
||||
'view_mode': 'tree,form',
|
||||
'view_type': 'list',
|
||||
'domain': domain,
|
||||
}
|
||||
if res_model == 'project.project':
|
||||
view_form_id = request.env.ref('project.edit_project').id
|
||||
action = {
|
||||
'name': _('Project'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': res_model,
|
||||
'view_mode': 'form',
|
||||
'view_type': 'form',
|
||||
'views': [[view_form_id, 'form']],
|
||||
'res_id': res_id,
|
||||
}
|
||||
elif res_model == 'account.analytic.line':
|
||||
ts_view_tree_id = request.env.ref('hr_timesheet.hr_timesheet_line_tree').id
|
||||
ts_view_form_id = request.env.ref('hr_timesheet.hr_timesheet_line_form').id
|
||||
action = {
|
||||
'name': _('Timesheets'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': res_model,
|
||||
'view_mode': 'tree,form',
|
||||
'view_type': 'form',
|
||||
'views': [[ts_view_tree_id, 'list'], [ts_view_form_id, 'form']],
|
||||
'domain': domain,
|
||||
}
|
||||
elif res_model == 'project.task':
|
||||
action = request.env.ref('project.action_view_task').read()[0]
|
||||
action.update({
|
||||
'name': _('Tasks'),
|
||||
'domain': domain,
|
||||
'context': dict(request.env.context), # erase original context to avoid default filter
|
||||
})
|
||||
# if only one project, add it in the context as default value
|
||||
tasks = request.env['project.task'].sudo().search(literal_eval(domain))
|
||||
if len(tasks.mapped('project_id')) == 1:
|
||||
action['context']['default_project_id'] = tasks.mapped('project_id')[0].id
|
||||
elif res_model == 'sale.order':
|
||||
action = clean_action(request.env.ref('sale.action_orders').read()[0])
|
||||
action['domain'] = domain
|
||||
action['context'] = {'create': False, 'edit': False, 'delete': False} # No CRUD operation when coming from overview
|
||||
elif res_model == 'account.invoice':
|
||||
action = clean_action(request.env.ref('account.action_invoice_tree1').read()[0])
|
||||
action['domain'] = domain
|
||||
action['context'] = {'create': False, 'delete': False} # only edition of invoice from overview
|
||||
return action
|
||||
|
||||
def _to_action_data(model=None, *, action=None, views=None, res_id=None, domain=None, context=None):
|
||||
# pass in either action or (model, views)
|
||||
if action:
|
||||
assert model is None and views is None
|
||||
act = clean_action(action.read()[0])
|
||||
model = act['res_model']
|
||||
views = act['views']
|
||||
# FIXME: search-view-id, possibly help?
|
||||
descr = {
|
||||
'data-model': model,
|
||||
'data-views': json.dumps(views),
|
||||
}
|
||||
if context is not None: # otherwise copy action's?
|
||||
descr['data-context'] = json.dumps(context)
|
||||
if res_id:
|
||||
descr['data-res-id'] = res_id
|
||||
elif domain:
|
||||
descr['data-domain'] = json.dumps(domain)
|
||||
return descr
|
||||
@@ -1,271 +0,0 @@
|
||||
odoo.define('sale_timesheet.ProjectPlan', function (require) {
|
||||
'use strict';
|
||||
|
||||
var AbstractAction = require('web.AbstractAction');
|
||||
var core = require('web.core');
|
||||
|
||||
var _t = core._t;
|
||||
var QWeb = core.qweb;
|
||||
|
||||
var ProjectPlan = AbstractAction.extend({
|
||||
custom_events: {
|
||||
search: '_onSearch',
|
||||
},
|
||||
events: {
|
||||
"click a[type='action']": "_onClickAction",
|
||||
"click .o_timesheet_plan_redirect": '_onRedirect',
|
||||
"click .oe_stat_button": "_onClickStatButton",
|
||||
"click .o_timesheet_plan_non_billable_task": "_onClickNonBillableTask",
|
||||
"click .o_timesheet_plan_sale_timesheet_people_time .progress-bar": '_onClickEmployeeProgressbar',
|
||||
},
|
||||
hasControlPanel: true,
|
||||
loadControlPanel: true,
|
||||
withSearchBar: true,
|
||||
searchMenuTypes: ['filter', 'favorite'],
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function (parent, action) {
|
||||
this._super.apply(this, arguments);
|
||||
this.action = action;
|
||||
this.context = action.context;
|
||||
this.action_manager = parent;
|
||||
this._title = action.name || _t('Overview');
|
||||
this.project_ids = [];
|
||||
|
||||
this.controlPanelParams.modelName = 'project.project';
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
// TODO: maybe not the correct way
|
||||
return self._controlPanel._reportNewQueryAndRender();
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
do_show: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._updateControlPanel();
|
||||
this.action_manager.do_push_state({
|
||||
action: this.action.id,
|
||||
active_id: this.action.context.active_id,
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refresh the DOM html
|
||||
*
|
||||
* @private
|
||||
* @param {string|html} dom
|
||||
*/
|
||||
_refreshPlan: function (dom) {
|
||||
this.$('.o_content').html(dom);
|
||||
},
|
||||
|
||||
/**
|
||||
* Call controller to get the html content
|
||||
*
|
||||
* @private
|
||||
* @param {string[]}
|
||||
* @returns {Deferred}
|
||||
*/
|
||||
_fetchPlan: function (domain) {
|
||||
var self = this;
|
||||
return this._rpc({
|
||||
route:"/timesheet/plan",
|
||||
params: {domain: domain},
|
||||
}).then(function(result){
|
||||
self._refreshPlan(result.html_content);
|
||||
self._updateControlPanel(result.actions);
|
||||
self.project_ids = result.project_ids;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_updateControlPanel: function (buttons) {
|
||||
// set actions button
|
||||
if (this.$buttons) {
|
||||
this.$buttons.off().remove();
|
||||
}
|
||||
buttons = buttons || [];
|
||||
this.$buttons = $(QWeb.render("project.plan.ControlButtons", {'buttons': buttons}));
|
||||
this.$buttons.on('click', '.o_timesheet_plan_btn_action', this._onClickControlButton.bind(this));
|
||||
// refresh control panel
|
||||
this.updateControlPanel({
|
||||
cp_content: {
|
||||
$buttons: this.$buttons,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate the action to execute based on the clicked target
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onClickAction: function (ev) {
|
||||
var $target = this.$(ev.currentTarget);
|
||||
|
||||
var action = false;
|
||||
if($target.attr('name')){ // classical case : name="action_id" type="action"
|
||||
action = $target.attr('name');
|
||||
} else { // custom case : build custom action dict
|
||||
action = {
|
||||
'name': _t('Timesheet'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'target': 'current',
|
||||
'res_model': 'account.analytic.line',
|
||||
};
|
||||
// find action views
|
||||
var views = [[false, 'pivot'], [false, 'list']];
|
||||
if($target.attr('views')){
|
||||
views = JSON.parse($target.attr('views').replace(/\'/g, '"'));
|
||||
}
|
||||
action.views = views;
|
||||
action.view_mode = _.map(views, function(view_array){return view_array[1];});
|
||||
// custom domain
|
||||
var domain = [];
|
||||
if($target.attr('domain')){
|
||||
domain = JSON.parse($target.attr('domain').replace(/\'/g, '"'));
|
||||
}
|
||||
action.domain = domain;
|
||||
}
|
||||
|
||||
// additionnal context
|
||||
var context = {
|
||||
active_id: this.action.context.active_id,
|
||||
active_ids: this.action.context.active_ids,
|
||||
active_model: this.action.context.active_model,
|
||||
};
|
||||
|
||||
if($target.attr('context')){
|
||||
var ctx_str = $target.attr('context').replace(/\'/g, '"');
|
||||
context = _.extend(context, JSON.parse(ctx_str));
|
||||
}
|
||||
|
||||
this.do_action(action, {
|
||||
additional_context: context
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Call the action of the action button from control panel, based on the data attribute on the button DOM
|
||||
*
|
||||
* @param {MouseEvent} event
|
||||
* @private
|
||||
*/
|
||||
_onClickControlButton: function (ev) {
|
||||
var $target = $(ev.target);
|
||||
var action_id = $target.data('action-id');
|
||||
var context = $target.data('context');
|
||||
|
||||
return this.do_action(action_id, {
|
||||
'additional_context': context,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onClickEmployeeProgressbar: function (event) {
|
||||
var domain = $(event.currentTarget).data('domain');
|
||||
this.do_action({
|
||||
name: 'Timesheets',
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: 'account.analytic.line',
|
||||
views: [[false, 'list'], [false, 'form']],
|
||||
view_type: 'list',
|
||||
view_mode: 'form',
|
||||
domain: domain || [],
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onClickStatButton: function (event) {
|
||||
var self = this;
|
||||
var data = $(event.currentTarget).data();
|
||||
var parameters = {
|
||||
domain: data.domain || [],
|
||||
res_model: data.resModel,
|
||||
};
|
||||
if (data.resId) {
|
||||
parameters.res_id = data.resId;
|
||||
}
|
||||
return this._rpc({
|
||||
route:"/timesheet/plan/action",
|
||||
params: parameters,
|
||||
}).then(function(action){
|
||||
self.do_action(action);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onClickNonBillableTask: function () {
|
||||
this.do_action({
|
||||
name: _t('Non Billable Tasks'),
|
||||
type: 'ir.actions.act_window',
|
||||
view_type: 'form',
|
||||
view_mode: 'form',
|
||||
res_model: 'project.task',
|
||||
views: [[false, 'list'], [false, 'form']],
|
||||
domain: [['project_id', 'in', this.project_ids || []], ['sale_line_id', '=', false]]
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onRedirect: function (event) {
|
||||
event.preventDefault();
|
||||
var $target = $(event.target);
|
||||
this.do_action({
|
||||
type: 'ir.actions.act_window',
|
||||
view_type: 'form',
|
||||
view_mode: 'form',
|
||||
res_model: $target.data('oe-model'),
|
||||
views: [[false, 'form']],
|
||||
res_id: $target.data('oe-id'),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* This client action has its own search view and already listen on it. If
|
||||
* we let the event pass through, it will be caught by the action manager
|
||||
* which will do its work. This may crash the web client, if the action
|
||||
* manager tries to notify the previous action.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} event
|
||||
*/
|
||||
_onSearch: function (event) {
|
||||
event.stopPropagation();
|
||||
this._fetchPlan(event.data.domain);
|
||||
},
|
||||
});
|
||||
|
||||
core.action_registry.add('timesheet.plan', ProjectPlan);
|
||||
|
||||
return ProjectPlan;
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="project.plan.ControlButtons">
|
||||
<div>
|
||||
<t t-foreach="buttons" t-as="button">
|
||||
<button type="button" class="btn btn-primary o_timesheet_plan_btn_action" t-att-title="button['label']" t-att-data-context="button['context']" t-att-data-action-id="button['action_id']">
|
||||
<t t-esc="button['label']"/>
|
||||
</button>
|
||||
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,94 +0,0 @@
|
||||
odoo.define('sale_timesheet.timesheet_plan_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ProjectPlan = require('sale_timesheet.ProjectPlan');
|
||||
var testUtils = require('web.test_utils');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
function createPlan(params) {
|
||||
var Parent = Widget.extend({
|
||||
do_push_state: function () {},
|
||||
});
|
||||
var parent = new Parent();
|
||||
testUtils.mock.addMockEnvironment(parent, params);
|
||||
var plan = new ProjectPlan(parent, params.action);
|
||||
var selector = params.debug ? 'body' : '#qunit-fixture';
|
||||
plan.appendTo($(selector));
|
||||
|
||||
plan.destroy = function () {
|
||||
delete plan.destroy;
|
||||
parent.destroy();
|
||||
};
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
QUnit.module('Timesheet Plan', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
'project.project': {
|
||||
fields: {
|
||||
account_id: {string: "Analytic account", type: "many2one", relation: "account.analytic.account"},
|
||||
},
|
||||
records: []
|
||||
},
|
||||
};
|
||||
}
|
||||
}, function () {
|
||||
|
||||
QUnit.test('basic timesheet plan rendering', function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
var plan = createPlan({
|
||||
archs: {
|
||||
'project.project,false,search': '<search></search>',
|
||||
},
|
||||
data: this.data,
|
||||
action: {},
|
||||
mockRPC: function (route) {
|
||||
assert.step(route);
|
||||
if (route === '/timesheet/plan') {
|
||||
return $.when({html_content: '<p>Banach-Tarski</p>'});
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
intercepts: {
|
||||
search: function () {
|
||||
// when the search event get up to the action manager,
|
||||
// bad things can happen
|
||||
throw new Error('Search event should not get out of client action');
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.verifySteps([
|
||||
"/web/dataset/call_kw/project.project",
|
||||
"/timesheet/plan"
|
||||
]);
|
||||
assert.strictEqual(plan.$('.o_content').text(), 'Banach-Tarski', 'should have rendered html content');
|
||||
assert.strictEqual(plan.$('.o_control_panel .breadcrumb-item').text(), 'Overview',
|
||||
'default title should be set');
|
||||
plan.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('timesheet action takes action name into account', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var plan = createPlan({
|
||||
archs: {
|
||||
'project.project,false,search': '<search></search>',
|
||||
},
|
||||
data: this.data,
|
||||
action: {name: 'Fibonacci'},
|
||||
mockRPC: function (route) {
|
||||
if (route === '/timesheet/plan') {
|
||||
return $.when({html_content: '<p>Banach-Tarski</p>'});
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
assert.strictEqual(plan.$('.o_control_panel .breadcrumb-item').text(), 'Fibonacci',
|
||||
'title should be set from the action name');
|
||||
plan.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,20 +3,24 @@
|
||||
|
||||
<template id="assets_backend" name="sale timesheet assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/sale_timesheet/static/src/js/timesheet_plan.js"></script>
|
||||
<link rel="stylesheet" type="text/scss" href="/sale_timesheet/static/src/scss/sale_timesheet.scss"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
<template id="qunit_suite" name="timesheet plan tests" inherit_id="web.qunit_suite">
|
||||
<xpath expr="//script[contains(@src, '/web/static/tests/views/kanban_tests.js')]" position="after">
|
||||
<script type="text/javascript" src="/sale_timesheet/static/tests/timesheet_plan_tests.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
<template id="timesheet_plan" name="Timesheet Plan">
|
||||
<record id="timesheet_plan" model="ir.ui.view">
|
||||
<field name="name">Timesheet Plan</field>
|
||||
<field name="type">qweb</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="arch" type="xml">
|
||||
<nav class="o_qweb_cp_buttons" t-if="actions">
|
||||
<button t-foreach="actions" t-as="action"
|
||||
type="action" class="btn btn-primary"
|
||||
t-att-name="action['action_id']"
|
||||
t-att-data-context="action.get('context')"
|
||||
>
|
||||
<t t-esc="action['label']"/>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="o_form_view o_form_readonly o_project_plan">
|
||||
<div class="o_form_sheet_bg">
|
||||
<div class="o_form_sheet o_timesheet_plan_content">
|
||||
@@ -25,7 +29,10 @@
|
||||
|
||||
<div class="o_timesheet_plan_stat_buttons oe_button_box">
|
||||
<t t-foreach="stat_buttons" t-as="stat_button">
|
||||
<button class="btn oe_stat_button" t-att-data-domain="stat_button.get('domain')" t-att-data-res-model="stat_button['res_model']" t-att-data-res-id="stat_button.get('res_id')">
|
||||
<a class="btn oe_stat_button"
|
||||
type="action"
|
||||
t-att="stat_button['action']"
|
||||
>
|
||||
<div t-attf-class="fa fa-fw o_button_icon #{stat_button['icon']}" role="img" aria-label="Statistics" title="Statistics"></div>
|
||||
<div class="o_field_widget o_stat_info o_readonly_modifier" t-att-title="stat_button['name']">
|
||||
<span class="o_stat_value" t-if="stat_button.get('count')">
|
||||
@@ -35,124 +42,127 @@
|
||||
<t t-esc="stat_button['name']"/>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<t t-set="display_cost" t-value="dashboard['profit']['expense_cost'] != 0.0"/>
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th rowspan="6">
|
||||
<a type="action" t-att-domain="json.dumps(timesheet_domain)" context="{'pivot_row_groupby': ['date:month'],'pivot_column_groupby': ['timesheet_invoice_type'], 'pivot_measures': ['unit_amount']}" tabindex="-1">Hours</a>
|
||||
</th>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th rowspan="6">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain)" data-context='{"pivot_row_groupby": ["date:month"],"pivot_column_groupby": ["timesheet_invoice_type"], "pivot_measures": ["unit_amount"]}' data-views='[[0, "pivot"], [0, "list"]]' tabindex="-1">Hours</a>
|
||||
</th>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['hours']['billable_time']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td>Timesheets</td>
|
||||
<th rowspan="6">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain)" data-context='{"pivot_row_groupby": ["date:month", "employee_id"], "pivot_column_groupby": ["timesheet_invoice_type"],"pivot_measures": ["unit_amount"]}' data-views='[[0, "pivot"], [0, "list"]]' tabindex="-1">Rates</a>
|
||||
</th>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['rates']['billable_time']"/> %
|
||||
</td>
|
||||
<td>Timesheets</td>
|
||||
<th rowspan="6">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain)" data-context='{"pivot_row_groupby": ["timesheet_invoice_id"], "pivot_column_groupby": ["date:month"],"pivot_measures": ["amount"]}' data-views='[[0, "pivot"], [0, "list"]]' tabindex="-1">Profitability</a>
|
||||
</th>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['invoiced']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td>Invoiced</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['hours']['billable_fixed']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td>Fixed</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['rates']['billable_fixed']"/> %
|
||||
</td>
|
||||
<td>Fixed</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['to_invoice']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td>To invoice</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['hours']['non_billable_project']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td>No task found</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['rates']['non_billable_project']"/> %
|
||||
</td>
|
||||
<td>No task found</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td>Timesheet costs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['hours']['non_billable']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td><a type="action"
|
||||
title="Non Billable Tasks"
|
||||
data-model="project.task"
|
||||
data-views='[[false, "list"], [false, "form"]]'
|
||||
t-att-data-domain="json.dumps([['project_id', 'in', projects.ids], ['sale_line_id', '=', False]])"
|
||||
>Non billable</a></td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['rates']['non_billable']"/> %
|
||||
</td>
|
||||
<td>Non billable</td>
|
||||
<t t-if="display_cost">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['hours']['billable_time']" t-options="{'widget': 'float_time'}"/>
|
||||
<t t-esc="dashboard['profit']['expense_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td>Timesheets</td>
|
||||
<th rowspan="6">
|
||||
<a type="action" t-att-domain="json.dumps(timesheet_domain)" context="{'pivot_row_groupby': ['date:month', 'employee_id'], 'pivot_column_groupby': ['timesheet_invoice_type'],'pivot_measures': ['unit_amount']}" tabindex="-1">Rates</a>
|
||||
</th>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['rates']['billable_time']"/> %
|
||||
</td>
|
||||
<td>Timesheets</td>
|
||||
<th rowspan="6">
|
||||
<a type="action" t-att-domain="json.dumps(timesheet_domain)" context="{'pivot_row_groupby': ['timesheet_invoice_id'], 'pivot_column_groupby': ['date:month'],'pivot_measures': ['amount']}" tabindex="-1">Profitability</a>
|
||||
</th>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['invoiced']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td>Invoiced</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['hours']['billable_fixed']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td>Fixed</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['rates']['billable_fixed']"/> %
|
||||
</td>
|
||||
<td>Fixed</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['to_invoice']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td>To invoice</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['hours']['non_billable_project']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td>No task found</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['rates']['non_billable_project']"/> %
|
||||
</td>
|
||||
<td>No task found</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td>Timesheet costs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['hours']['non_billable']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td><a class="o_timesheet_plan_non_billable_task">Non billable</a></td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['rates']['non_billable']"/> %
|
||||
</td>
|
||||
<td>Non billable</td>
|
||||
<t t-if="display_cost">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['expense_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td>Other costs</td>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td class="o_timesheet_plan_dashboard_total">
|
||||
<t t-esc="dashboard['profit']['total']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td><b>Total</b></td>
|
||||
</t>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Other costs</td>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td class="o_timesheet_plan_dashboard_total">
|
||||
<t t-esc="dashboard['hours']['total']" t-options="{'widget': 'float_time'}"/>
|
||||
<t t-esc="dashboard['profit']['total']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td><b>Total</b></td>
|
||||
</t>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_total">
|
||||
<t t-esc="dashboard['hours']['total']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td><b>Total</b></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<t t-if="display_cost">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['expense_amount_untaxed_invoiced']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td>Re-invoiced costs</td>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td></td>
|
||||
<td></td>
|
||||
<t t-if="display_cost">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['expense_amount_untaxed_invoiced']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td>Re-invoiced costs</td>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td></td>
|
||||
<td></td>
|
||||
</t>
|
||||
</tr>
|
||||
<tr>
|
||||
</t>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<t t-if="display_cost">
|
||||
<td class="o_timesheet_plan_dashboard_total">
|
||||
<t t-esc="dashboard['profit']['total']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td><b>Total</b></td>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<t t-if="display_cost">
|
||||
<td class="o_timesheet_plan_dashboard_total">
|
||||
<t t-esc="dashboard['profit']['total']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td><b>Total</b></td>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<td></td>
|
||||
<td></td>
|
||||
</t>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="o_timesheet_plan_sale_timesheet_people_time">
|
||||
@@ -163,79 +173,57 @@
|
||||
<t t-if="repartition_employee">
|
||||
<div class="float-right o_timesheet_plan_badge">
|
||||
<span class="badge badge-pill o_progress_billable_fixed">
|
||||
<a type="action" t-att-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','billable_fixed')])" tabindex="-1">Billable fixed</a>
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','billable_fixed')])" tabindex="-1">Billable fixed</a>
|
||||
</span>
|
||||
<span class="badge badge-pill o_progress_billable_time">
|
||||
<a type="action" t-att-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','billable_time')])" tabindex="-1">Billable time</a>
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','billable_time')])" tabindex="-1">Billable time</a>
|
||||
</span>
|
||||
<span class="badge badge-pill o_progress_non_billable_project">
|
||||
<a type="action" t-att-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable_project')])" tabindex="-1">No task found</a>
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable_project')])" tabindex="-1">No task found</a>
|
||||
</span>
|
||||
<span class="badge badge-pill o_progress_non_billable">
|
||||
<a type="action" t-att-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable')])" tabindex="-1">Non billable</a>
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable')])" tabindex="-1">Non billable</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<t t-foreach="repartition_employee" t-as="employee_id">
|
||||
<tr>
|
||||
<td style="width: 15%">
|
||||
<a type="action" t-att-domain="json.dumps(timesheet_domain)" t-att-context="{'search_default_employee_id': employee_id}" views="[[0, 'list']]" tabindex="-1">
|
||||
<t t-esc="repartition_employee[employee_id]['employee_name']"/>
|
||||
</a>
|
||||
</td>
|
||||
<td style="width: 10%">
|
||||
<t t-esc="repartition_employee[employee_id]['total']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td>
|
||||
<div t-if="repartition_employee_max" class="progress" t-att-style="'width: ' + str(repartition_employee[employee_id]['total'] / repartition_employee_max * 100) +'%'">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<t t-foreach="repartition_employee" t-as="employee_id">
|
||||
<t t-set="employee" t-value="repartition_employee[employee_id]"/>
|
||||
<tr>
|
||||
<td style="width: 15%">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain)" t-att-data-context="json.dumps({'search_default_employee_id': employee_id})" data-views="[[0, "list"]]" tabindex="-1">
|
||||
<t t-esc="employee['employee_name']"/>
|
||||
</a>
|
||||
</td>
|
||||
<td style="width: 10%">
|
||||
<t t-esc="employee['total']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td>
|
||||
<div t-if="repartition_employee_max" class="progress" t-attf-style="width: {{employee['total'] / repartition_employee_max * 100}}%">
|
||||
|
||||
<t t-set="total" t-value="repartition_employee[employee_id]['total'] or 1.0" />
|
||||
<t t-set="billable_fixed" t-value="repartition_employee[employee_id]['billable_fixed']" />
|
||||
<t t-set="billable_time" t-value="repartition_employee[employee_id]['billable_time']" />
|
||||
<t t-set="non_billable" t-value="repartition_employee[employee_id]['non_billable']" />
|
||||
<t t-set="non_billable_project" t-value="repartition_employee[employee_id]['non_billable_project']"/>
|
||||
|
||||
<t t-if="billable_fixed">
|
||||
<t t-set="billable_fixed_title"><t t-esc="billable_fixed" t-options="{'widget': 'float_time'}" /></t>
|
||||
<div class="progress-bar o_progress_billable_fixed"
|
||||
t-att-style="'width: ' + str(billable_fixed / total * 100) + '%'"
|
||||
t-att-title="billable_fixed_title"
|
||||
t-att-data-domain="repartition_employee[employee_id]['__domain_billable_fixed']">
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="billable_time">
|
||||
<t t-set="billable_time_title"><t t-esc="billable_time" t-options="{'widget': 'float_time'}" /></t>
|
||||
<div class="progress-bar o_progress_billable_time"
|
||||
t-att-style="'width: ' + str(billable_time / total * 100) + '%'"
|
||||
t-att-title="billable_time_title"
|
||||
t-att-data-domain="repartition_employee[employee_id]['__domain_billable_time']">
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="non_billable_project">
|
||||
<t t-set="non_billable_project_title"><t t-esc="non_billable_project" t-options="{'widget': 'float_time'}" /></t>
|
||||
<div class="progress-bar o_progress_non_billable_project"
|
||||
t-att-style="'width: ' + str(non_billable_project / total * 100) + '%'"
|
||||
t-att-title="non_billable_project_title"
|
||||
t-att-data-domain="repartition_employee[employee_id]['__domain_non_billable_project']">
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="non_billable">
|
||||
<t t-set="non_billable_title"><t t-esc="non_billable" t-options="{'widget': 'float_time'}" /></t>
|
||||
<div class="progress-bar o_progress_non_billable"
|
||||
t-att-style="'width: ' + str(non_billable / total * 100) + '%'"
|
||||
t-att-title="non_billable_title"
|
||||
t-att-data-domain="json.dumps(repartition_employee[employee_id]['__domain_non_billable'])">
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<t t-set="total" t-value="employee['total'] or 1.0" />
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Billable fixed</t>
|
||||
<t t-set="key">billable_fixed</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Billable time</t>
|
||||
<t t-set="key">billable_time</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">No task found</t>
|
||||
<t t-set="key">non_billable_project</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Non billable</t>
|
||||
<t t-set="key">non_billable</t>
|
||||
</t>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
@@ -243,64 +231,62 @@
|
||||
<div class="o_project_plan_project_timesheet_forecast">
|
||||
<t t-if="timesheet_forecast_table and timesheet_forecast_table['rows']">
|
||||
<h3 name="table_plan_title">Timesheets</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<t t-foreach="timesheet_forecast_table['header']" t-as="header_val">
|
||||
<th t-att-class="'o_right_bordered' if header_val_index in [5,10] else ''">
|
||||
<span t-att-title="header_val['tooltip']"><t t-esc="header_val['label']"/></span>
|
||||
</th>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-set="row_is_milestone" t-value="False"/>
|
||||
<t t-foreach="timesheet_forecast_table['rows']" t-as="row">
|
||||
<t t-set="row_type" t-value="row[0].get('type')"/>
|
||||
<t t-if="row_type == 'sale_order_line'">
|
||||
<t t-set="row_is_milestone" t-value="row[0].get('is_milestone')"/>
|
||||
</t>
|
||||
<tr t-att-class="'o_timesheet_forecast_' + row_type">
|
||||
<t t-foreach="row" t-as="row_value">
|
||||
<td t-att-class="'o_right_bordered' if row_value_index in [5,10] else '' + ' text-center' if row_value_index != 0 else ''">
|
||||
<t t-if="row_value_index == 0">
|
||||
<t t-if="row_type == 'sale_order'">
|
||||
<a t-att-data-oe-model="row_value['res_model']" t-att-data-oe-id="row_value['res_id']" t-att-class="'o_timesheet_plan_redirect' if row_value['res_id'] else ''">
|
||||
<t t-esc="row_value.get('label')"/>
|
||||
</a>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<t t-foreach="timesheet_forecast_table['header']" t-as="header_val">
|
||||
<th t-att-class="'o_right_bordered' if header_val_index in [5,10] else ''">
|
||||
<span t-att-title="header_val['tooltip']"><t t-esc="header_val['label']"/></span>
|
||||
</th>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-set="row_is_milestone" t-value="False"/>
|
||||
<t t-foreach="timesheet_forecast_table['rows']" t-as="row">
|
||||
<t t-set="row_type" t-value="row[0].get('type')"/>
|
||||
<t t-if="row_type == 'sale_order_line'">
|
||||
<t t-set="row_is_milestone" t-value="row[0].get('is_milestone')"/>
|
||||
</t>
|
||||
<tr t-att-class="'o_timesheet_forecast_' + row_type">
|
||||
<t t-foreach="row" t-as="row_value">
|
||||
<td t-att-class="'o_right_bordered' if row_value_index in [5,10] else '' + ' text-center' if row_value_index != 0 else ''">
|
||||
<t t-if="row_value_index == 0">
|
||||
<t t-if="row_type == 'sale_order'">
|
||||
<a type="action" t-att-data-model="row_value['res_model']" t-att-data-res-id="row_value['res_id']" t-att-class="'o_timesheet_plan_redirect' if row_value['res_id'] else ''">
|
||||
<t t-esc="row_value.get('label')"/>
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="row_type != 'sale_order'">
|
||||
<t t-if="not row_is_milestone">
|
||||
<span><t t-esc="row_value.get('label')"/></span>
|
||||
</t>
|
||||
<t t-if="row_type != 'sale_order'">
|
||||
<t t-if="not row_is_milestone">
|
||||
<span><t t-esc="row_value.get('label')"/></span>
|
||||
</t>
|
||||
<t t-if="row_is_milestone">
|
||||
<span><i><t t-esc="row_value.get('label')"/></i></span>
|
||||
</t>
|
||||
<t t-if="row_is_milestone">
|
||||
<span><i><t t-esc="row_value.get('label')"/></i></span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="row_value_index != 0">
|
||||
<t t-if="row_value_index < len(row)-2">
|
||||
<t t-if="row_is_milestone">
|
||||
<i t-att-class="'text-muted' if not row_value else ''"><t t-esc="row_value" t-options="{'widget': 'float_time'}"/></i>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-att-class="'text-muted' if not row_value else ''"><t t-esc="row_value" t-options="{'widget': 'float_time'}"/></span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="row_value_index != 0">
|
||||
<t t-if="row_value_index < len(row)-2">
|
||||
<t t-if="row_is_milestone">
|
||||
<i t-att-class="'text-muted' if not row_value else ''"><t t-esc="row_value" t-options="{'widget': 'float_time'}"/></i>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-if="not row_is_milestone and not row[0].get('type') == 'hr_employee'">
|
||||
<span t-att-class="'text-muted' if not row_value else ''"><t t-esc="row_value" t-options="{'widget': 'float_time'}"/></span>
|
||||
</t>
|
||||
<span t-att-class="'text-muted' if not row_value else ''"><t t-esc="row_value" t-options="{'widget': 'float_time'}"/></span>
|
||||
</t>
|
||||
</t>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<t t-else="">
|
||||
<t t-if="not row_is_milestone and not row[0].get('type') == 'hr_employee'">
|
||||
<span t-att-class="'text-muted' if not row_value else ''"><t t-esc="row_value" t-options="{'widget': 'float_time'}"/></span>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
@@ -308,6 +294,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<template id="progressbar" name="project overview progressbar segments">
|
||||
<t t-set="amount" t-value="employee[key]"/>
|
||||
<t t-if="amount">
|
||||
<t t-set="title"><t t-esc="label"/>: <t t-esc="amount" t-options="{'widget': 'float_time'}"/></t>
|
||||
<a t-attf-class="progress-bar o_progress_{{key}}"
|
||||
t-attf-style="width: {{amount / total * 100}}%"
|
||||
t-att-title="title"
|
||||
type="action" data-model="account.analytic.line"
|
||||
t-att-data-domain="json.dumps(employee['__domain_' + key])"
|
||||
/>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -105,11 +105,10 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_timesheet_action_client_timesheet_plan" model="ir.actions.client">
|
||||
<record id="project_timesheet_action_client_timesheet_plan" model="ir.actions.act_window">
|
||||
<field name="name">Overview</field>
|
||||
<field name="tag">timesheet.plan</field>
|
||||
<field name="res_model">project.project</field>
|
||||
<field name="params" eval="{}"/>
|
||||
<field name="view_mode">qweb</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
import babel.dates
|
||||
import pytz
|
||||
|
||||
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.tools import lazy
|
||||
|
||||
class IrActionsActWindowView(models.Model):
|
||||
_inherit = 'ir.actions.act_window.view'
|
||||
|
||||
view_mode = fields.Selection(selection_add=[('qweb', 'QWeb')])
|
||||
|
||||
class Base(models.AbstractModel):
|
||||
_inherit = 'base'
|
||||
@@ -78,3 +81,35 @@ class Base(models.AbstractModel):
|
||||
data[group_by_value][field_value] += 1
|
||||
|
||||
return data
|
||||
|
||||
##### qweb view hooks #####
|
||||
@api.model
|
||||
def qweb_render_view(self, view_id, domain):
|
||||
assert view_id
|
||||
return self.env['ir.qweb'].render(
|
||||
view_id, {
|
||||
**self.env['ir.ui.view']._prepare_qcontext(),
|
||||
**self._qweb_prepare_qcontext(view_id, domain),
|
||||
})
|
||||
|
||||
def _qweb_prepare_qcontext(self, view_id, domain):
|
||||
"""
|
||||
Base qcontext for rendering qweb views bound to this model
|
||||
"""
|
||||
return {
|
||||
'model': self,
|
||||
'domain': domain,
|
||||
# not necessarily necessary as env is already part of the
|
||||
# non-minimal qcontext
|
||||
'context': self.env.context,
|
||||
'records': lazy(self.search, domain),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
|
||||
r = super().fields_view_get(view_id, view_type, toolbar, submenu)
|
||||
# avoid leaking the raw (un-rendered) template, also avoids bloating
|
||||
# the response payload for no reason
|
||||
if r['type'] == 'qweb':
|
||||
r['arch'] = '<qweb/>'
|
||||
return r
|
||||
|
||||
@@ -421,39 +421,59 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
* When a user clicks on an <a> link with type="action", we need to actually
|
||||
* do the action. This kind of links is used a lot in no-content helpers.
|
||||
*
|
||||
* The <a> may have
|
||||
* - a data-method and data-model attribute, in that case the corresponding
|
||||
* rpc will be called. If that rpc returns an action it will be executed.
|
||||
* - a data-reload-on-close attribute, in that case the view will be
|
||||
* reloaded after the dialog has been closed.
|
||||
* * if the link has both data-model and data-method attributes, the
|
||||
* corresponding method is called, chained to any action it would
|
||||
* return. An optional data-reload-on-close (set to a non-falsy value)
|
||||
* also causes th underlying view to be reloaded after the dialog is
|
||||
* closed.
|
||||
* * if the link has a name attribute, invoke the action with that
|
||||
* identifier (see :class:`ActionManager.doAction` to not get the
|
||||
* details)
|
||||
* * otherwise an *action descriptor* is built from the link's data-
|
||||
* attributes (model, res-id, views, domain and context)
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param ev
|
||||
*/
|
||||
_onActionClicked: function (ev) {
|
||||
_onActionClicked: function (ev) { // FIXME: maybe this should also work on <button> tags?
|
||||
var $target = $(ev.currentTarget);
|
||||
var self = this;
|
||||
var model = $target.data('model');
|
||||
var method = $target.data('method');
|
||||
var data = $target.data();
|
||||
|
||||
if (method !== undefined && model !== undefined) {
|
||||
if (data.method !== undefined && data.model !== undefined) {
|
||||
var options = {};
|
||||
if ($target.data('reload-on-close')) {
|
||||
if (data.reloadOnClose) {
|
||||
options.on_close = function () {
|
||||
self.trigger_up('reload');
|
||||
};
|
||||
}
|
||||
this.dp.add(this._rpc({
|
||||
model: model,
|
||||
method: method,
|
||||
model: data.model,
|
||||
method: data.method,
|
||||
})).then(function (action) {
|
||||
if (action !== undefined) {
|
||||
self.do_action(action, options);
|
||||
}
|
||||
});
|
||||
} else if ($target.attr('name')) {
|
||||
this.do_action(
|
||||
$target.attr('name'),
|
||||
data.context && {additional_context: data.context}
|
||||
);
|
||||
} else {
|
||||
this.do_action($target.attr('name'));
|
||||
this.do_action({
|
||||
name: $target.attr('title') || _.str.strip($target.text()),
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: data.model || this.modelName,
|
||||
res_id: data.resId,
|
||||
target: 'current', // TODO: make customisable?
|
||||
views: data.views || (data.resId ? [[false, 'form']] : [[false, 'list'], [false, 'form']]),
|
||||
domain: data.domain || [],
|
||||
}, {
|
||||
additional_context: _.extend({}, data.context)
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* FIXME: this logic should be rethought
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Client-side implementation of a qweb view.
|
||||
*/
|
||||
odoo.define('web.qweb', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var AbstractView = require('web.AbstractView');
|
||||
var AbstractModel = require('web.AbstractModel');
|
||||
var AbstractRenderer = require('web.AbstractRenderer');
|
||||
var AbstractController = require('web.AbstractController');
|
||||
var registry = require('web.view_registry');
|
||||
|
||||
var _lt = core._lt;
|
||||
|
||||
/**
|
||||
* model
|
||||
*/
|
||||
var Model = AbstractModel.extend({
|
||||
/**
|
||||
* init
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._state = {
|
||||
viewId: false,
|
||||
modelName: false,
|
||||
body: '',
|
||||
context: {},
|
||||
domain: [],
|
||||
};
|
||||
},
|
||||
/**
|
||||
* fetches the rendered qweb view
|
||||
*/
|
||||
_fetch: function () {
|
||||
var state = this._state;
|
||||
return this._rpc({
|
||||
model: state.modelName,
|
||||
method: 'qweb_render_view',
|
||||
kwargs: {
|
||||
view_id: state.viewId,
|
||||
domain: state.domain,
|
||||
context: state.context
|
||||
}
|
||||
}).then(function (r) {
|
||||
state.body = r;
|
||||
return state.viewId;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* get
|
||||
*/
|
||||
get: function () {
|
||||
return this._state;
|
||||
},
|
||||
/**
|
||||
* load
|
||||
*/
|
||||
load: function (params) {
|
||||
_.extend(this._state, _.pick(params, ['viewId', 'modelName', 'domain', 'context']));
|
||||
|
||||
return this._fetch();
|
||||
},
|
||||
/**
|
||||
* reload
|
||||
*/
|
||||
reload: function (_id, _params) {
|
||||
// FIXME: update this._state or something?
|
||||
return this._fetch();
|
||||
}
|
||||
});
|
||||
/**
|
||||
* renderer
|
||||
*/
|
||||
var Renderer = AbstractRenderer.extend({
|
||||
/**
|
||||
* render
|
||||
*/
|
||||
_render: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
self.$el.html(self.state.body);
|
||||
});
|
||||
}
|
||||
});
|
||||
/**
|
||||
* controller
|
||||
*/
|
||||
var Controller = AbstractController.extend({
|
||||
events: _.extend({}, AbstractController.prototype.events, {
|
||||
'click [type="toggle"]': '_onLazyToggle',
|
||||
}),
|
||||
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._$buttons = $('<nav>').on('click', '[type="action"]', this.proxy('_onActionClicked'));
|
||||
},
|
||||
|
||||
renderButtons: function ($node) {
|
||||
this._super($node);
|
||||
$node.append(this._$buttons);
|
||||
},
|
||||
_update: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
// move control panel buttons from the view to the control panel
|
||||
// area
|
||||
var $cp_buttons = self.renderer.$('nav.o_qweb_cp_buttons');
|
||||
$cp_buttons.children().appendTo(self._$buttons.empty());
|
||||
$cp_buttons.remove();
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lazy toggle. Content is not remembered between unfolds.
|
||||
*/
|
||||
_onLazyToggle: function (e) {
|
||||
// TODO: add support for view (possibly action as well?)
|
||||
var $target = $(e.target);
|
||||
var $t = $target.closest('[data-model]');
|
||||
if (!($target.hasClass('fa-caret-down') || $target.hasClass('fa-caret-right'))) {
|
||||
$target = $t.find('.fa-caret-down, .fa-caret-right');
|
||||
}
|
||||
|
||||
var data = $t.data();
|
||||
if (this._fold($t)) {
|
||||
$target.removeClass('fa-caret-down').addClass('fa-caret-right');
|
||||
return;
|
||||
}
|
||||
|
||||
// NB: $.data() automatically parses json attributes, but does not
|
||||
// automatically parse lone float literals in data-*, so a
|
||||
// data-args (as a json object) is very convenient
|
||||
var args = data.args || _.omit(data, 'model', 'method', 'id');
|
||||
|
||||
return this._rpc({
|
||||
model: data.model,
|
||||
method: data.method,
|
||||
args: data.id ? [data.id] : undefined,
|
||||
kwargs: args // FIXME: context?
|
||||
}).then(function (s) {
|
||||
return $(s);
|
||||
}).then(function ($newcontent) {
|
||||
$t.data('children', $newcontent).after($newcontent);
|
||||
$target.removeClass('fa-caret-right').addClass('fa-caret-down');
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Attempts to fold the parameter, returns whether that happened.
|
||||
*/
|
||||
_fold: function ($el) {
|
||||
var $children = $el.data('children');
|
||||
if (!$children) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
$children.each(function (_i, e) {
|
||||
self._fold($(e));
|
||||
}).remove();
|
||||
$el.removeData('children');
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* view
|
||||
*/
|
||||
var QWebView = AbstractView.extend({
|
||||
display_name: _lt('Freedom View'),
|
||||
icon: 'fa-file-picture-o',
|
||||
viewType: 'qweb',
|
||||
// groupable?
|
||||
enableTimeRangeMenu: true,
|
||||
config: {
|
||||
Model: Model,
|
||||
Renderer: Renderer,
|
||||
Controller: Controller,
|
||||
},
|
||||
|
||||
/**
|
||||
* init method
|
||||
*/
|
||||
init: function (viewInfo, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this.loadParams.viewId = viewInfo.view_id;
|
||||
}
|
||||
});
|
||||
|
||||
registry.add('qweb', QWebView);
|
||||
return {
|
||||
View: QWebView,
|
||||
Controller: Controller,
|
||||
Renderer: Renderer,
|
||||
Model: Model
|
||||
};
|
||||
});
|
||||
@@ -182,3 +182,10 @@ div.o_boolean_toggle.custom-control.custom-checkbox {
|
||||
overflow-x: initial;
|
||||
}
|
||||
}
|
||||
|
||||
//== Action manager
|
||||
// ensure special links are styled as pointers even when they don't
|
||||
// have an href
|
||||
[type="action"], [type="toggle"] {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ var MockServer = Class.extend({
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string|Object} params.arch a string OR a parsed xml document
|
||||
* @param {Number} [params.view_id] the id of the arch's view
|
||||
* @param {string} params.model a model name (that should be in this.data)
|
||||
* @param {Object} params.toolbar the actions possible in the toolbar
|
||||
* @param {Object} [params.viewOptions] the view options set in the test (optional)
|
||||
@@ -78,6 +79,7 @@ var MockServer = Class.extend({
|
||||
fieldsViewGet: function (params) {
|
||||
var model = params.model;
|
||||
var toolbar = params.toolbar;
|
||||
var viewId = params.view_id;
|
||||
var viewOptions = params.viewOptions || {};
|
||||
if (!(model in this.data)) {
|
||||
throw new Error('Model ' + model + ' was not defined in mock server data');
|
||||
@@ -87,6 +89,9 @@ var MockServer = Class.extend({
|
||||
if (toolbar) {
|
||||
fvg.toolbar = toolbar;
|
||||
}
|
||||
if (viewId) {
|
||||
fvg.view_id = viewId;
|
||||
}
|
||||
return fvg;
|
||||
},
|
||||
/**
|
||||
@@ -506,12 +511,17 @@ var MockServer = Class.extend({
|
||||
}
|
||||
}
|
||||
var key = [model, viewID, viewType].join(',');
|
||||
var arch = self.archs[key];
|
||||
var arch = self.archs[key] || _.find(self.archs, function (_v, k) {
|
||||
var ka = k.split(',');
|
||||
viewID = parseInt(ka[1], 10);
|
||||
return ka[0] === model && ka[2] === viewType;
|
||||
});
|
||||
if (!arch) {
|
||||
throw new Error('No arch found for key ' + key);
|
||||
}
|
||||
views[viewType] = {
|
||||
arch: arch,
|
||||
view_id: viewID,
|
||||
model: model,
|
||||
viewOptions: {
|
||||
context: kwargs.context,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
odoo.define('web.qweb', function (require) {
|
||||
odoo.define('web.qweb_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var qwebPath = '/web/static/lib/qweb/';
|
||||
|
||||
@@ -43,7 +43,7 @@ odoo.define("base.abstract_controller_tests", function(require) {
|
||||
QUnit.test(
|
||||
'click on a a[type="action"] child triggers the correct action',
|
||||
function(assert) {
|
||||
assert.expect(3);
|
||||
assert.expect(7);
|
||||
|
||||
var html =
|
||||
"<div>" +
|
||||
@@ -51,6 +51,9 @@ odoo.define("base.abstract_controller_tests", function(require) {
|
||||
'<a name="a2" type="action" class="with-child">' +
|
||||
"<span>child</input>" +
|
||||
"</a>" +
|
||||
'<a type="action" data-model="foo" data-method="bar" class="method">method</a>' +
|
||||
'<a type="action" data-model="foo" data-res-id="42" class="descr">descr</a>' +
|
||||
'<a type="action" data-model="foo" class="descr2">descr2</a>' +
|
||||
"</div>";
|
||||
|
||||
var view = createView({
|
||||
@@ -59,14 +62,24 @@ odoo.define("base.abstract_controller_tests", function(require) {
|
||||
model: "test_model",
|
||||
arch: "<test/>",
|
||||
intercepts: {
|
||||
do_action: function(event) {
|
||||
assert.step(event.data.action);
|
||||
do_action: function (event) {
|
||||
assert.step(event.data.action.name || event.data.action);
|
||||
}
|
||||
},
|
||||
mockRPC: function (route, args) {
|
||||
if (args.model === 'foo' && args.method === 'bar') {
|
||||
assert.step("method");
|
||||
return $.when({name: 'method'});
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
testUtils.dom.click(view.$(".simple"));
|
||||
testUtils.dom.click(view.$(".with-child span"));
|
||||
assert.verifySteps(["a1", "a2"]);
|
||||
testUtils.dom.click(view.$(".method"));
|
||||
testUtils.dom.click(view.$(".descr"));
|
||||
testUtils.dom.click(view.$(".descr2"));
|
||||
assert.verifySteps(["a1", "a2", "method", "method", "descr", "descr2"]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
odoo.define('web.qweb_view_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var qweb = require('web.qweb');
|
||||
var utils = require('web.test_utils');
|
||||
|
||||
QUnit.module("Views", {
|
||||
|
||||
}, function () {
|
||||
QUnit.module("QWeb");
|
||||
QUnit.test("basic", function (assert) {
|
||||
assert.expect(13);
|
||||
var am = utils.createActionManager({
|
||||
data: {
|
||||
test: {
|
||||
fields: {},
|
||||
records: [],
|
||||
}
|
||||
},
|
||||
archs: {
|
||||
'test,5,qweb': '<div id="xxx"><t t-esc="ok"/></div>',
|
||||
'test,false,search': '<search/>'
|
||||
},
|
||||
mockRPC: function (route, args) {
|
||||
if (/^\/web\/dataset\/call_kw/.test(route)) {
|
||||
switch (_.str.sprintf('%(model)s.%(method)s', args)) {
|
||||
case 'test.qweb_render_view':
|
||||
assert.step('fetch');
|
||||
assert.equal(args.kwargs.view_id, 5);
|
||||
return $.when(
|
||||
'<div>foo' +
|
||||
'<div data-model="test" data-method="wheee" data-id="42" data-other="5">' +
|
||||
'<a type="toggle" class="fa fa-caret-right">Unfold</a>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
case 'test.wheee':
|
||||
assert.step('unfold');
|
||||
assert.deepEqual(args.args, [42]);
|
||||
assert.deepEqual(args.kwargs, {other: 5});
|
||||
return $.when('<div id="sub">ok</div>');
|
||||
}
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
try {
|
||||
var resolved = false;
|
||||
am.doAction({
|
||||
type: 'ir.actions.act_window',
|
||||
views: [[false, 'qweb']],
|
||||
res_model: 'test',
|
||||
}).then(function () { resolved = true; });
|
||||
assert.ok(resolved, "Action should have resolved synchronously");
|
||||
|
||||
var $content = am.$('.o_content');
|
||||
assert.ok(/^\s*foo/.test($content.text()));
|
||||
utils.dom.click($content.find('[type=toggle]'));
|
||||
assert.equal($content.find('div#sub').text(), 'ok', 'should have unfolded the sub-item');
|
||||
utils.dom.click($content.find('[type=toggle]'));
|
||||
assert.equal($content.find('div#sub').length, 0, "should have removed the sub-item");
|
||||
utils.dom.click($content.find('[type=toggle]'));
|
||||
|
||||
assert.verifySteps(['fetch', 'unfold', 'unfold']);
|
||||
} finally {
|
||||
am.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -318,6 +318,7 @@
|
||||
<script type="text/javascript" src="/web/static/src/js/views/calendar/calendar_quick_create.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/calendar/calendar_renderer.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/calendar/calendar_view.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/qweb/qweb_view.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/attach_document.js"></script>
|
||||
|
||||
<script type="text/javascript" src="/web/static/src/js/report/utils.js"/>
|
||||
@@ -550,6 +551,7 @@
|
||||
<script type="text/javascript" src="/web/static/tests/views/pivot_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/kanban_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/calendar_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/qweb_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/basic_model_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/abstract_view_banner_tests.js"></script>
|
||||
<script type="text/javascript" src="/web/static/tests/views/kanban_model_tests.js"></script>
|
||||
|
||||
@@ -549,6 +549,9 @@ registry.addJob(function (wysiwyg) {
|
||||
context: options.context,
|
||||
},
|
||||
}).then(function (template) {
|
||||
if (!/^<templates>/.test(template)) {
|
||||
template = _.str.sprintf('<templates>%s</templates>', template);
|
||||
}
|
||||
QWeb.add_template(template);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,9 @@ var ThemeCustomizeDialog = Dialog.extend({
|
||||
method: 'read_template',
|
||||
args: ['website.theme_customize'],
|
||||
}).then(function (data) {
|
||||
if (!/^<templates>/.test(data)) {
|
||||
data = _.str.sprintf('<templates>%s</templates>', data);
|
||||
}
|
||||
return core.qweb.add_template(data);
|
||||
});
|
||||
}
|
||||
|
||||
+38
-7
@@ -19,7 +19,7 @@ otherwise.
|
||||
only useful as a mnemonic/description of the view when looking for one in
|
||||
a list of some sort
|
||||
``model``
|
||||
the model linked to the view, if applicable (it doesn't for QWeb views)
|
||||
the model linked to the view, if applicable
|
||||
``priority``
|
||||
client programs can request views by ``id``, or by ``(model, type)``. For
|
||||
the latter, all the views for the right type and model will be searched,
|
||||
@@ -1817,14 +1817,45 @@ QWeb
|
||||
====
|
||||
|
||||
QWeb views are standard :ref:`reference/qweb` templates inside a view's
|
||||
``arch``. They don't have a specific root element.
|
||||
``arch``. They don't have a specific root element. Because QWeb views don't
|
||||
have a specific root element, their type must be specified explicitly (it can
|
||||
not be inferred from the root element of the ``arch`` field).
|
||||
|
||||
A QWeb view can only contain a single template\ [#template_inherit]_, and the
|
||||
template's name *must* match the view's complete (including module name)
|
||||
:term:`external id`.
|
||||
QWeb views have two use cases:
|
||||
|
||||
:ref:`reference/data/template` should be used as a shortcut to define QWeb
|
||||
views.
|
||||
* they can be used as frontend templates, in which case
|
||||
:ref:`reference/data/template` should be used as a shortcut.
|
||||
* they can be used as actual qweb views (opened inside an action), in which
|
||||
case they should be defined as regular view with an explicit ``type`` (it
|
||||
can not be inferred) and a model.
|
||||
|
||||
The main additions of qweb-as-view to the basic qweb-as-template are:
|
||||
|
||||
* qweb-as-view has a special case for a ``<nav>`` element bearing the CSS
|
||||
class ``o_qweb_cp_buttons``: its contents should be buttons and will be
|
||||
extracted and moved to the control panel's button area, the ``<nav>`` itself
|
||||
will be removed, this is a work-around to control panel views not existing
|
||||
yet
|
||||
* qweb-as-view rendering adds several items to the standard qweb rendering
|
||||
context:
|
||||
|
||||
``model``
|
||||
the model to which the qweb view is bound
|
||||
``domain``
|
||||
the domain provided by the search view
|
||||
``context``
|
||||
the context provided by the search view
|
||||
``records``
|
||||
a lazy proxy to ``model.search(domain)``, this can be used if you just
|
||||
want to iterate the records and not perform more complex operations
|
||||
(e.g. grouping)
|
||||
* qweb-as-view also provides additional rendering hooks:
|
||||
|
||||
- ``_qweb_prepare_context(view_id, domain)`` prepares the rendering context
|
||||
specific to qweb-as-view
|
||||
- ``qweb_render_view(view_id, domain)`` is the method called by the client
|
||||
and will call the context-preparation methods and ultimately
|
||||
``env['ir.qweb'].render()``.
|
||||
|
||||
.. [#backwards-compatibility] for backwards compatibility reasons
|
||||
.. [#hasclass] an extension function is added for simpler matching in QWeb
|
||||
|
||||
@@ -129,11 +129,11 @@ class IrQWeb(models.AbstractModel, QWeb):
|
||||
return view.inherit_id is not None
|
||||
|
||||
if isinstance(name, int) or is_child_view(name):
|
||||
for node in etree.fromstring(template):
|
||||
view = etree.fromstring(template)
|
||||
for node in view:
|
||||
if node.get('t-name'):
|
||||
node.set('t-name', str(name))
|
||||
return node.getparent()
|
||||
return None # trigger "template not found" in QWeb
|
||||
return view
|
||||
else:
|
||||
return template
|
||||
|
||||
|
||||
@@ -1161,8 +1161,7 @@ actual arch.
|
||||
arch = self.browse(view_id).read_combined(['arch'])['arch']
|
||||
arch_tree = etree.fromstring(arch)
|
||||
self.distribute_branding(arch_tree)
|
||||
root = E.templates(arch_tree)
|
||||
arch = etree.tostring(root, encoding='unicode')
|
||||
arch = etree.tostring(arch_tree, encoding='unicode')
|
||||
return arch
|
||||
|
||||
@api.model
|
||||
|
||||
@@ -358,20 +358,21 @@ class QWeb(object):
|
||||
except Exception as e:
|
||||
raise QWebException("load could not load template", name=template)
|
||||
|
||||
if document is not None:
|
||||
if isinstance(document, etree._Element):
|
||||
element = document
|
||||
document = etree.tostring(document)
|
||||
elif not document.strip().startswith('<') and os.path.exists(document):
|
||||
element = etree.parse(document).getroot()
|
||||
else:
|
||||
element = etree.fromstring(document)
|
||||
if document is None:
|
||||
raise QWebException("Template not found", name=template)
|
||||
|
||||
for node in element:
|
||||
if node.get('t-name') == str(template):
|
||||
return (node, document)
|
||||
if isinstance(document, etree._Element):
|
||||
element = document
|
||||
document = etree.tostring(document)
|
||||
elif not document.strip().startswith('<') and os.path.exists(document):
|
||||
element = etree.parse(document).getroot()
|
||||
else:
|
||||
element = etree.fromstring(document)
|
||||
|
||||
raise QWebException("Template not found", name=template)
|
||||
for node in element:
|
||||
if node.get('t-name') == str(template):
|
||||
return (node, document)
|
||||
return (element, document)
|
||||
|
||||
def load(self, template, options):
|
||||
""" Load a given template. """
|
||||
|
||||
Reference in New Issue
Block a user