Merge branch 'development' into 'master'
Development See merge request prakash.jain/cor-odoo!138
This commit is contained in:
commit
ce7b0a53fc
|
@ -3,4 +3,4 @@
|
|||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
#from . import report
|
||||
from . import report
|
|
@ -20,23 +20,28 @@
|
|||
'version': '0.1',
|
||||
|
||||
# any module necessary for this one to work correctly
|
||||
'depends': ['base', 'crm', 'sale_timesheet', 'analytic', 'hr'],
|
||||
'depends': ['base', 'crm', 'sale_timesheet', 'analytic', 'hr', 'hr_timesheet', 'web'],
|
||||
|
||||
# always loaded
|
||||
'data': [
|
||||
#'security/ir.model.access.csv',
|
||||
#'security/cor_custom_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'security/cor_custom_security.xml',
|
||||
'views/crm_view.xml',
|
||||
'views/sale_views.xml',
|
||||
'views/project_view.xml',
|
||||
#'views/project_hours_view.xml',
|
||||
'views/hr_employee_views.xml',
|
||||
'views/hr_timesheet_templates.xml',
|
||||
'views/analytic_view.xml',
|
||||
'report/project_hours_report_view.xml',
|
||||
'report/project_profitability_report_analysis_views.xml',
|
||||
'views/views.xml',
|
||||
'views/templates.xml',
|
||||
'views/assets.xml',
|
||||
'data/mail_data.xml',
|
||||
#'views/menu_show_view.xml',
|
||||
'wizard/project_create_sale_order_views.xml',
|
||||
'wizard/project_multi_budget_assign_view.xml',
|
||||
],
|
||||
# only loaded in demonstration mode
|
||||
'demo': [
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<template inherit_id="mail.message_notification_email" id="inherit_message_notification_email">
|
||||
<data>
|
||||
<xpath expr="//t[1]/div[1]/p[1]" position="replace"/>
|
||||
</data>
|
||||
</template>
|
||||
|
||||
<template inherit_id="mail.mail_notification_borders" id="inherit_mail_notification_borders">
|
||||
<data>
|
||||
<xpath expr="//t[1]/div[1]/table[1]/tbody[1]/tr[4]" position="replace"/>
|
||||
</data>
|
||||
</template>
|
||||
|
||||
<template inherit_id="mail.mail_notification_light" id="inherit_mail_notification_light">
|
||||
<data>
|
||||
<xpath expr="//t[1]/table[1]/tr[2]" position="replace"/>
|
||||
</data>
|
||||
</template>
|
||||
|
||||
</data>
|
||||
</odoo>
|
|
@ -3,6 +3,7 @@
|
|||
from . import crm_lead
|
||||
from . import models
|
||||
from . import project
|
||||
from . import project_hours
|
||||
from . import project_overview
|
||||
from . import analytic
|
||||
from . import product
|
||||
|
|
|
@ -1,17 +1,95 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details
|
||||
|
||||
from odoo import api, exceptions, fields, models, _
|
||||
from odoo import api, exceptions, fields, models, tools, _
|
||||
from odoo.exceptions import UserError, AccessError, ValidationError
|
||||
from odoo.osv import expression
|
||||
import math
|
||||
from datetime import datetime, time, timedelta
|
||||
from odoo.tools.float_utils import float_round
|
||||
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, ustr
|
||||
import dateutil.parser
|
||||
|
||||
|
||||
class AccountAnalyticLine(models.Model):
|
||||
_inherit = 'account.analytic.line'
|
||||
|
||||
start_time = fields.Float(string='Start Time', digits=(16, 2))
|
||||
end_time = fields.Float(string='End Time', digits=(16, 2))
|
||||
unit_amount = fields.Float('Duration', default=0.0)
|
||||
parent_project = fields.Many2one('project.project', related='project_id.parent_project', string='Parent Project')
|
||||
|
||||
|
||||
def _default_start_datetime(self):
|
||||
return fields.Datetime.to_string(datetime.combine(fields.Datetime.now(), datetime.min.time()))
|
||||
|
||||
def _default_end_datetime(self):
|
||||
return fields.Datetime.to_string(datetime.combine(fields.Datetime.now(), datetime.max.time()))
|
||||
|
||||
start_datetime = fields.Datetime("Start Time", required=True)
|
||||
end_datetime = fields.Datetime("End Time", required=True)
|
||||
|
||||
|
||||
# @api.onchange('project_id')
|
||||
# def _onchange_parent_project_id(self):
|
||||
# if self.project_id:
|
||||
# parent_project = self.env['project.project'].search([('sub_project', '=', self.project_id.id)], limit=1)
|
||||
# if parent_project:
|
||||
# self.parent_project = parent_project.id
|
||||
# else:
|
||||
# self.parent_project = False
|
||||
# else:
|
||||
# self.parent_project = False
|
||||
|
||||
|
||||
@api.onchange('employee_id')
|
||||
def _onchange_employee_id(self):
|
||||
domain = []
|
||||
if self.employee_id and self.employee_id.user_id:
|
||||
manager_id = self.env['project.project'].search(
|
||||
[('user_id', '=', self.employee_id.user_id.id), ('allow_timesheets', '=', True)]).ids
|
||||
emp_project_ids = self.env['project.project'].search(
|
||||
[('privacy_visibility', 'in', ('employees', 'portal')), ('allow_timesheets', '=', True)]).ids
|
||||
project_ids = self.env['project.project'].search(
|
||||
[('privacy_visibility', '=', 'followers'), ('allow_timesheets', '=', True),
|
||||
('allowed_internal_user_ids', 'in', self.employee_id.user_id.id)]).ids
|
||||
consul_ids = self.env['project.sale.line.employee.map'].search([('employee_id', '=', self.employee_id.id)])
|
||||
consul_project_ids = [val.project_id.id for val in consul_ids]
|
||||
emp_all_project_ids = manager_id + emp_project_ids + project_ids + consul_project_ids
|
||||
domain = [('id', 'in', list(set(emp_all_project_ids)))]
|
||||
result = {
|
||||
'domain': {'project_id': domain},
|
||||
}
|
||||
return result
|
||||
|
||||
@api.onchange('project_id')
|
||||
def _onchange_project_id(self):
|
||||
domain = [] if not self.project_id else [('project_id', '=', self.project_id.id)]
|
||||
cuser = []
|
||||
manager_id = []
|
||||
user_ids = []
|
||||
consul_project_ids = []
|
||||
if not self.project_id:
|
||||
cuser = self.env['hr.employee'].search([('user_id', '=', self.env.user.id)]).ids
|
||||
if self.project_id:
|
||||
consul_ids = self.env['project.sale.line.employee.map'].search([('project_id', '=', self.project_id.id)])
|
||||
consul_project_ids = [val.employee_id.id for val in consul_ids]
|
||||
if self.project_id and self.project_id.user_id:
|
||||
manager_id = self.env['hr.employee'].search([('user_id', '=', self.project_id.user_id.id)]).ids
|
||||
if self.project_id and self.project_id.privacy_visibility in ('employees', 'portal'):
|
||||
user_ids = self.env['hr.employee'].search([('user_id', '!=', False)]).ids
|
||||
if self.project_id and self.project_id.privacy_visibility == 'followers':
|
||||
user_ids = self.env['hr.employee'].search(
|
||||
[('user_id', 'in', self.project_id.allowed_internal_user_ids.ids)]).ids
|
||||
project_all_emp_ids = cuser + manager_id + user_ids + consul_project_ids
|
||||
result = {
|
||||
'domain': {'task_id': domain, 'employee_id': [('id', 'in', project_all_emp_ids)]},
|
||||
}
|
||||
if self.project_id != self.task_id.project_id:
|
||||
# reset task when changing project
|
||||
self.task_id = False
|
||||
return result
|
||||
|
||||
_sql_constraints = [
|
||||
('check_start_time_lower_than_24', 'CHECK(start_time <= 24)', 'You cannot have a start hour greater than 24'),
|
||||
('check_start_time_positive', 'CHECK(start_time >= 0)', 'Start hour must be a positive number'),
|
||||
|
@ -34,25 +112,83 @@ class AccountAnalyticLine(models.Model):
|
|||
raise ValidationError(_("End time cannot be earlier than Start time"))
|
||||
self.unit_amount = res
|
||||
|
||||
@api.onchange('start_datetime', 'end_datetime')
|
||||
def _onchange_start_end_date_time(self):
|
||||
if self.start_datetime and self.end_datetime:
|
||||
total_hours = (self.end_datetime - self.start_datetime).total_seconds() / 3600
|
||||
self.unit_amount = total_hours
|
||||
|
||||
# @api.onchange('start_time', 'end_time')
|
||||
# def _onchange_start_end_time(self):
|
||||
# if self.start_time > 0:
|
||||
# dt = datetime.strptime('26 Sep 2012', '%d %b %Y')
|
||||
# print('HHHHHHHH', dt)
|
||||
# newdatetime = dt.replace(hour=11, minute=59)
|
||||
# print('GGGGGGGGGGGGG', newdatetime)
|
||||
|
||||
@api.model
|
||||
def export_data(self, fields):
|
||||
index = range(len(fields))
|
||||
fields_name = dict(zip(fields, index))
|
||||
res = super(AccountAnalyticLine, self).export_data(fields)
|
||||
for index, val in enumerate(res['datas']):
|
||||
#print("task_id", fields_name)
|
||||
if fields_name.get('date') and fields_name.get('date') >= 0:
|
||||
tdateindex = fields_name.get('date')
|
||||
tdate = res['datas'][index][tdateindex]
|
||||
if tdate:
|
||||
res['datas'][index][tdateindex] = datetime.strftime(tdate, "%d/%m/%Y")
|
||||
if fields_name.get('start_datetime') and fields_name.get('start_datetime') >= 0:
|
||||
start_datetime_index = fields_name.get('start_datetime')
|
||||
start_datetime = res['datas'][index][start_datetime_index]
|
||||
if start_datetime:
|
||||
res['datas'][index][start_datetime_index] = datetime.strftime(start_datetime, "%d/%m/%Y %H:%M")
|
||||
if fields_name.get('end_datetime') and fields_name.get('end_datetime') >= 0:
|
||||
end_datetime_index = fields_name.get('end_datetime')
|
||||
end_datetime= res['datas'][index][end_datetime_index]
|
||||
if end_datetime:
|
||||
res['datas'][index][end_datetime_index] = datetime.strftime(end_datetime, "%d/%m/%Y %H:%M")
|
||||
if fields_name.get('task_id') and fields_name.get('task_id') >= 0:
|
||||
taskindex = fields_name.get('task_id')
|
||||
ttask = res['datas'][index][taskindex]
|
||||
if type(ttask) == bool:
|
||||
res['datas'][index][taskindex] = ''
|
||||
if fields_name.get('start_time') and fields_name.get('start_time') >= 0:
|
||||
starttimeindex = fields_name.get('start_time')
|
||||
starttime = float(res['datas'][index][starttimeindex])
|
||||
if starttime:
|
||||
start_time = tools.format_duration(starttime)
|
||||
res['datas'][index][starttimeindex] = start_time
|
||||
if fields_name.get('end_time') and fields_name.get('end_time') >= 0:
|
||||
endtimeindex = fields_name.get('end_time')
|
||||
endtime = float(res['datas'][index][endtimeindex])
|
||||
if endtime:
|
||||
end_time = tools.format_duration(endtime)
|
||||
res['datas'][index][endtimeindex] = end_time
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
if vals.get('unit_amount') == 0.0:
|
||||
raise ValidationError(_("Your can not fill 0.0 hour entry"))
|
||||
"""if vals.get('employee_id') and vals.get('project_id'):
|
||||
project = self.env['project.project'].search([('id', '=', vals.get('project_id'))])
|
||||
if project:
|
||||
for rec in project.sale_line_employee_ids:
|
||||
if rec.employee_id.id == vals.get('employee_id'):
|
||||
remain_hour = rec.budgeted_qty - rec.timesheet_hour
|
||||
if vals.get('unit_amount') > remain_hour:
|
||||
raise ValidationError(_("Your can not fill entry more than Budgeted hours"))"""
|
||||
if vals.get('unit_amount') >= 24.0:
|
||||
raise ValidationError(_("Your can not fill more than 24.0 hour entry"))
|
||||
value = super(AccountAnalyticLine, self).create(vals)
|
||||
if value and value.project_id:
|
||||
value.project_id._onchange_calculate_timesheet_hours()
|
||||
value.project_id._compute_calc()
|
||||
return value
|
||||
|
||||
|
||||
|
||||
def write(self, vals):
|
||||
if vals.get('unit_amount') == 0.0:
|
||||
if vals.get('unit_amount') == 0.0:
|
||||
raise ValidationError(_("Your can not fill 0.0 hour entry"))
|
||||
return super().write(vals)
|
||||
res = super(AccountAnalyticLine, self).write(vals)
|
||||
if self.project_id:
|
||||
self.project_id._onchange_calculate_timesheet_hours()
|
||||
return res
|
||||
|
||||
@api.onchange('start_datetime', 'end_datetime')
|
||||
def _onchange_start_end_date(self):
|
||||
if self.start_datetime:
|
||||
self.date = self.start_datetime.date()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError, AccessError, ValidationError
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
|
@ -13,7 +14,7 @@ class Project(models.Model):
|
|||
help='When billing tasks individually, a Sales Order will be created from each task. It is perfect if you would like to bill different services to different clients at different rates. \n When billing the whole project, a Sales Order will be created from the project instead. This option is better if you would like to bill all the tasks of a given project to a specific client either at a fixed rate, or at an employee rate.')
|
||||
|
||||
pricing_type = fields.Selection([
|
||||
('fixed_rate', 'Fixed rate'),
|
||||
('fixed_rate', 'Project that have no time limit'),
|
||||
('employee_rate', 'Consultant rate')
|
||||
], string="Pricing", default="fixed_rate",
|
||||
help='The fixed rate is perfect if you bill a service at a fixed rate per hour or day worked regardless of the consultant who performed it. The consultant rate is preferable if your employees deliver the same service at a different rate. For instance, junior and senior consultants would deliver the same service (= consultancy), but at a different rate because of their level of seniority.')
|
||||
|
@ -38,16 +39,118 @@ class Project(models.Model):
|
|||
" them or by someone of their company.")
|
||||
allow_billable = fields.Boolean("Billable", default=True, help="Invoice your time and material from tasks.")
|
||||
|
||||
budgeted_hours = fields.Float(string='Total Budgeted Hours', digits=(16, 2))
|
||||
date_start = fields.Date(string='Start Date')
|
||||
date = fields.Date(string='End Date', index=True, tracking=True)
|
||||
timesheet_hour = fields.Float(string='Total Timesheet Hours', compute='_compute_calc', store=True)
|
||||
budgeted_hours = fields.Float(string='Total Budgeted Hours', compute='_compute_calc')
|
||||
budgeted_hours2 = fields.Float(string='Total Budgeted Hours')
|
||||
budgeted_revenue = fields.Float(string='Budgeted Revenue', digits=(16, 2))
|
||||
expenses_per = fields.Float(string='Expenses (%)', digits=(16, 2))
|
||||
expenses_amt = fields.Float(string='Expenses Amount', digits=(16, 2))
|
||||
cost = fields.Float("Sum of Cost", compute='onchange_compute_values', store=True)
|
||||
hourly_rate = fields.Float("Sum of Hourly rate", compute='onchange_compute_values', store=True)
|
||||
budgeted_hour_week = fields.Float("Budgeted Hours(per week)", compute='onchange_compute_values', store=True)
|
||||
cost = fields.Float("Total Revenue", compute='onchange_compute_values', store=True)
|
||||
consultant_cost = fields.Float("Actual Cost", compute='_compute_calc')
|
||||
actual_revenue = fields.Float("Actual Revenue", compute='_compute_calc')
|
||||
other_expenses = fields.Float(string='Other Expenses', related='expenses_amt')
|
||||
total_expenses = fields.Float(string='Total Expenses', digits=(16, 2), compute='_compute_calc', store=True)
|
||||
hourly_rate = fields.Float("Hourly Rate", default=0.0)
|
||||
hourly_rate2 = fields.Float("Hourly Ratee")
|
||||
budgeted_hour_week = fields.Float("Budgeted Hours(per week)", compute='onchange_compute_values', store=True)
|
||||
profit_amt = fields.Float(string='Profit Amount', digits=(16, 2), compute='_compute_calc', store=True)
|
||||
profit_per = fields.Float(string='Porfit Percentage', digits=(16, 2), compute='_compute_calc', store=True)
|
||||
hour_distribution = fields.Selection([
|
||||
('Manual', 'Manual'),
|
||||
('Percentage', 'Percentage'),
|
||||
], string="Hours Distribution", default="Manual")
|
||||
manager_per = fields.Float(string='Manager %', digits=(16, 2))
|
||||
employee_per = fields.Float(string='Employee %', digits=(16, 2), compute='compute_percentage_hours', store=True)
|
||||
|
||||
manager_hour = fields.Float(string='Manager Hour')
|
||||
employee_hour = fields.Float(string='Employee Hour')
|
||||
|
||||
consultant_timesheet_hrs = fields.One2many('consultant.timesheet.hrs', 'project_id', "Timesheet Hrs",
|
||||
copy=False, help="Consultant timesheet hours")
|
||||
project_cons_hrs = fields.One2many('project.consultant.hrs', 'project_id', 'Consultant Allocation', copy=False)
|
||||
comment = fields.Text(string='Comment')
|
||||
tag_ids = fields.Many2many('custom.project.tags', string='Tags')
|
||||
|
||||
@api.onchange('allowed_internal_user_ids')
|
||||
def onchange_add_allowed_internal_users(self):
|
||||
user_list = []
|
||||
consultant_list = []
|
||||
employee_obj = []
|
||||
if self.allowed_internal_user_ids:
|
||||
user_list = self.allowed_internal_user_ids.ids
|
||||
if self.sale_line_employee_ids:
|
||||
for consultant in self.sale_line_employee_ids:
|
||||
consultant_list.append(consultant.employee_id.user_id.id)
|
||||
users = (set(user_list)) - (set(consultant_list))
|
||||
for record in list(users):
|
||||
emp_obj = self.env['hr.employee'].search([('user_id', '=', record)]).id
|
||||
if emp_obj:
|
||||
employee_obj.append(emp_obj)
|
||||
if employee_obj:
|
||||
for employee in employee_obj:
|
||||
if self._origin.id:
|
||||
self.sale_line_employee_ids.create({'project_id': self._origin.id,
|
||||
'employee_id': employee})
|
||||
|
||||
def _onchange_calculate_timesheet_hours(self):
|
||||
self.consultant_timesheet_hrs = [(6, 0, False)]
|
||||
if self._origin.id:
|
||||
self._cr.execute('''SELECT project_id, employee_id, SUM(unit_amount) FROM account_analytic_line
|
||||
where project_id = %(project_id)s
|
||||
GROUP BY project_id, employee_id''',
|
||||
{'project_id': self._origin.id})
|
||||
res = self._cr.fetchall()
|
||||
if res:
|
||||
for rec in res:
|
||||
self.consultant_timesheet_hrs.create({'project_id': rec[0],
|
||||
'employee_id': rec[1],
|
||||
'timesheet_hour': rec[2]})
|
||||
|
||||
@api.depends('cost', 'expenses_amt', 'budgeted_revenue')
|
||||
def _compute_calc(self):
|
||||
for record in self:
|
||||
consultant_cost = 0.0
|
||||
actual_revenue = 0.0
|
||||
hour = 0.0
|
||||
timesheet_hour = 0.0
|
||||
for rec in record.sale_line_employee_ids:
|
||||
consultant_cost = consultant_cost + rec.consultant_cost
|
||||
actual_revenue = actual_revenue + rec.actual_revenue
|
||||
hour = hour + rec.budgeted_qty
|
||||
timesheet_hour = timesheet_hour + rec.timesheet_hour
|
||||
if record.pricing_type == 'fixed_rate':
|
||||
for rec in record.consultant_timesheet_hrs:
|
||||
consultant_cost = consultant_cost + rec.consultant_cost
|
||||
timesheet_hour = timesheet_hour + rec.timesheet_hour
|
||||
record.consultant_cost = consultant_cost
|
||||
if record.project_type == 'hours_in_consultant':
|
||||
record.actual_revenue = actual_revenue
|
||||
else:
|
||||
record.actual_revenue = record.hourly_rate * timesheet_hour
|
||||
record.budgeted_hours = hour
|
||||
record.timesheet_hour = timesheet_hour
|
||||
total_exp = record.consultant_cost + record.expenses_amt
|
||||
record.total_expenses = total_exp
|
||||
profit_amt = record.budgeted_revenue - total_exp
|
||||
record.profit_amt = profit_amt
|
||||
if record.profit_amt > 0 and record.budgeted_revenue > 0:
|
||||
record.profit_per = (record.profit_amt / record.budgeted_revenue) * 100
|
||||
if record.project_type == 'hours_in_consultant' and record.budgeted_hours > 0.0:
|
||||
record.hourly_rate = (record.budgeted_revenue / record.budgeted_hours)
|
||||
# if record.project_type == 'hours_no_limit' and record.budgeted_hours2 > 0.0:
|
||||
# record.hourly_rate = (record.budgeted_revenue / record.budgeted_hours2)
|
||||
|
||||
@api.depends('manager_per', 'hour_distribution')
|
||||
def compute_percentage_hours(self):
|
||||
for record in self:
|
||||
if record.manager_per > 100:
|
||||
raise ValidationError(_("Percentage should be less than or equal to 100"))
|
||||
if record.manager_per > 0.0:
|
||||
record.employee_per = 100 - record.manager_per
|
||||
# if self.manager_per > 100:
|
||||
# raise ValidationError(_("Percentage should be less than or equal to 100"))
|
||||
|
||||
@api.onchange('budgeted_revenue', 'expenses_per')
|
||||
def onchange_expenses_per(self):
|
||||
|
@ -55,52 +158,112 @@ class Project(models.Model):
|
|||
expense_amount = self.budgeted_revenue * (self.expenses_per / 100)
|
||||
self.expenses_amt = expense_amount
|
||||
|
||||
@api.depends('cost', 'expenses_amt', 'budgeted_revenue')
|
||||
def _compute_calc(self):
|
||||
for record in self:
|
||||
total_exp = record.cost + record.expenses_amt
|
||||
record.total_expenses = total_exp
|
||||
profit_amt = record.budgeted_revenue - total_exp
|
||||
record.profit_amt = profit_amt
|
||||
if record.profit_amt > 0 and record.budgeted_revenue > 0:
|
||||
record.profit_per = (record.profit_amt / record.budgeted_revenue) * 100
|
||||
@api.onchange('budgeted_revenue', 'sale_line_employee_ids')
|
||||
def onchange_budgeted_hour(self):
|
||||
self.sale_line_employee_ids._compute_total_cost()
|
||||
hour = 0.0
|
||||
for rec in self.sale_line_employee_ids:
|
||||
hour = hour + rec.budgeted_qty
|
||||
self.budgeted_hours = hour
|
||||
if self.project_type == 'hours_in_consultant' and self.budgeted_hours > 0.0:
|
||||
self.hourly_rate = (self.budgeted_revenue / self.budgeted_hours)
|
||||
|
||||
@api.depends('sale_line_employee_ids')
|
||||
def onchange_compute_values(self):
|
||||
for record in self:
|
||||
cost = 0.0
|
||||
budgeted_hour_week = 0.0
|
||||
for rec in record.sale_line_employee_ids:
|
||||
cost = cost + rec.cost
|
||||
budgeted_hour_week = budgeted_hour_week + rec.budgeted_hour_week
|
||||
if record.project_type == 'hours_in_consultant':
|
||||
val = 0.0
|
||||
cost = 0.0
|
||||
budgeted_hour_week = 0.0
|
||||
for rec in record.sale_line_employee_ids:
|
||||
val = val + rec.budgeted_qty
|
||||
cost = cost + rec.cost
|
||||
budgeted_hour_week = budgeted_hour_week + rec.budgeted_hour_week
|
||||
record.budgeted_hours = val
|
||||
record.cost = cost
|
||||
if val > 0.0:
|
||||
record.hourly_rate = (cost/val)
|
||||
record.budgeted_hour_week = budgeted_hour_week
|
||||
record.budgeted_hour_week = budgeted_hour_week
|
||||
|
||||
# @api.depends('pricing_type')
|
||||
def _compute_consultant_timesheet_hour(self):
|
||||
for val in self:
|
||||
val.consultant_timesheet_hrs = False
|
||||
if val._origin.id:
|
||||
self._cr.execute('''SELECT project_id, employee_id, SUM(unit_amount) FROM account_analytic_line where project_id = 149
|
||||
GROUP BY project_id, employee_id''')
|
||||
res = self._cr.fetchone()
|
||||
if res:
|
||||
create_timesheet = val.consultant_timesheet_hrs.create({'project_id': res[0],
|
||||
'employee_id': res[1],
|
||||
'timesheet_hour': res[2]})
|
||||
else:
|
||||
val.consultant_timesheet_hrs = False
|
||||
# val.consultant_timesheet_hrs = False
|
||||
|
||||
def action_view_custom_project_consultant_hrs_report(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("cor_custom.action_project_consultant_hrs_report")
|
||||
action['context'] = {'search_default_project_id': self.id, 'search_default_project': 1,
|
||||
'search_default_consultant': 1, 'search_default_group_by_hours_type': 1,
|
||||
'default_res_model': 'project.consultant.hrs.report'}
|
||||
return action
|
||||
|
||||
class ProjectConsultantTimesheetHrs(models.Model):
|
||||
_name = 'consultant.timesheet.hrs'
|
||||
_description = 'Project Consultant Timesheet Hrs'
|
||||
|
||||
project_id = fields.Many2one('project.project', "Project")
|
||||
employee_id = fields.Many2one('hr.employee', string="Consultant")
|
||||
employee_price = fields.Float(string="Consultant Price", default=0.0)
|
||||
timesheet_hour = fields.Float("Timesheet Hour", default=0.0)
|
||||
consultant_cost = fields.Float("Actual Cost", compute='_compute_consultant_timesheet_cost', default=0.0)
|
||||
|
||||
@api.onchange('employee_id')
|
||||
def onchange_employee_price(self):
|
||||
if self.employee_id:
|
||||
self.employee_price = self.employee_id.timesheet_cost
|
||||
else:
|
||||
self.employee_price = 0.0
|
||||
|
||||
def _compute_consultant_timesheet_cost(self):
|
||||
for val in self:
|
||||
# if val.employee_id.timesheet_cost:
|
||||
# val.employee_price = val.employee_id.timesheet_cost
|
||||
# else:
|
||||
# val.employee_price = 0.0
|
||||
if val.timesheet_hour and val.employee_id.timesheet_cost:
|
||||
val.consultant_cost = val.timesheet_hour * val.employee_id.timesheet_cost
|
||||
else:
|
||||
val.consultant_cost = 0.0
|
||||
|
||||
|
||||
class InheritProjectProductEmployeeMap(models.Model):
|
||||
_inherit = 'project.sale.line.employee.map'
|
||||
|
||||
employee_price = fields.Monetary(string="Consultant Price", related="employee_id.timesheet_cost", readonly=True)
|
||||
budgeted_qty = fields.Float(string='Budgeted Hours', store=True)
|
||||
budgeted_qty = fields.Float(string='Budgeted Hours', digits=(16, 2))
|
||||
budgeted_uom = fields.Many2one('uom.uom', string='Budgeted UOM', related='sale_line_id.product_uom', readonly=True)
|
||||
# budgeted_uom = fields.Many2one('uom.uom', string='Budgeted UOM', related='timesheet_product_id.uom_id', readonly=True)
|
||||
timesheet_hour = fields.Float("Timesheet Hour", compute='_compute_timesheet_hour', default=0.0)
|
||||
employee_price = fields.Monetary(string="Consultant Price")
|
||||
budgeted_hour_week = fields.Float("Budgeted Hours per week", compute='_compute_budgeted_hour_week')
|
||||
price_unit = fields.Float("Hourly rate")
|
||||
price_unit = fields.Float("Hourly Rate")
|
||||
currency_id = fields.Many2one('res.currency', string="Currency", compute='_compute_price_unit', store=True,
|
||||
readonly=False)
|
||||
sale_line_id = fields.Many2one('sale.order.line', "Service", domain=[('is_service', '=', True)])
|
||||
cost = fields.Float("Cost", compute='_compute_total_cost')
|
||||
cost = fields.Float("Cost", default=0.0, store=True)
|
||||
consultant_cost = fields.Float("Actual Cost", compute='_compute_timesheet_hour', default=0.0)
|
||||
actual_revenue = fields.Float("Actual Revenue", compute='_compute_timesheet_hour', default=0.0)
|
||||
|
||||
hour_distribution = fields.Selection(related='project_id.hour_distribution')
|
||||
role = fields.Selection([('Manager', 'Manager'),
|
||||
('Employee', 'Employee'), ], string="Role", default="Employee")
|
||||
distribution_per = fields.Float("%")
|
||||
|
||||
@api.onchange('employee_id')
|
||||
def onchange_employee_price(self):
|
||||
if self.employee_id:
|
||||
self.employee_price = self.employee_id.timesheet_cost
|
||||
else:
|
||||
self.employee_price = 0.0
|
||||
|
||||
def _compute_timesheet_hour(self):
|
||||
for val in self:
|
||||
self._cr.execute('''SELECT project_id, employee_id, SUM(unit_amount) FROM account_analytic_line
|
||||
self._cr.execute('''SELECT project_id, employee_id, SUM(unit_amount) FROM account_analytic_line
|
||||
where project_id = %(project_id)s and employee_id = %(employee_id)s
|
||||
GROUP BY project_id, employee_id''',
|
||||
{'project_id': val.project_id._origin.id, 'employee_id': val.employee_id.id, })
|
||||
|
@ -109,6 +272,8 @@ class InheritProjectProductEmployeeMap(models.Model):
|
|||
val.timesheet_hour = res[2]
|
||||
else:
|
||||
val.timesheet_hour = 0.0
|
||||
val.consultant_cost = val.timesheet_hour * val.employee_price
|
||||
val.actual_revenue = val.timesheet_hour * val.price_unit
|
||||
|
||||
def _compute_budgeted_hour_week(self):
|
||||
for val in self:
|
||||
|
@ -117,9 +282,28 @@ class InheritProjectProductEmployeeMap(models.Model):
|
|||
else:
|
||||
val.budgeted_hour_week = 0
|
||||
|
||||
@api.onchange('project_id.budgeted_revenue', 'price_unit', 'distribution_per', 'employee_id', 'role')
|
||||
def _compute_total_cost(self):
|
||||
for val in self:
|
||||
val.cost = val.budgeted_qty * val.price_unit
|
||||
if val.project_id.project_type == 'hours_in_consultant':
|
||||
if val.hour_distribution == 'Percentage':
|
||||
if val.role == 'Manager':
|
||||
val.cost = val.project_id.budgeted_revenue * (val.project_id.manager_per / 100) * (
|
||||
val.distribution_per / 100)
|
||||
else:
|
||||
val.cost = val.project_id.budgeted_revenue * (val.project_id.employee_per / 100) * (
|
||||
val.distribution_per / 100)
|
||||
if val.price_unit > 0.0:
|
||||
val.budgeted_qty = val.cost / val.price_unit
|
||||
# val.cost = val.budgeted_qty * val.price_unit
|
||||
# val.consultant_cost = val.timesheet_hour * val.employee_price
|
||||
# self.project_id.onchange_budgeted_hour()
|
||||
|
||||
@api.onchange('price_unit', 'budgeted_qty')
|
||||
def _calculate_total_cost(self):
|
||||
if self.project_id.project_type == 'hours_in_consultant':
|
||||
if self.hour_distribution == 'Manual':
|
||||
self.cost = self.price_unit * self.budgeted_qty
|
||||
|
||||
@api.depends('sale_line_id', 'sale_line_id.price_unit', 'timesheet_product_id')
|
||||
def _compute_price_unit(self):
|
||||
|
@ -131,5 +315,30 @@ class InheritProjectProductEmployeeMap(models.Model):
|
|||
line.price_unit = line.timesheet_product_id.lst_price
|
||||
line.currency_id = line.timesheet_product_id.currency_id
|
||||
else:
|
||||
#line.price_unit = 0
|
||||
# line.price_unit = 0
|
||||
line.currency_id = False
|
||||
|
||||
|
||||
class CustomProjectTags(models.Model):
|
||||
""" Tags of project's tasks """
|
||||
_name = "custom.project.tags"
|
||||
_description = "Project Tag"
|
||||
|
||||
name = fields.Char('Name', required=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_uniq', 'unique (name)', "Tag name already exists!"),
|
||||
]
|
||||
|
||||
|
||||
class InheritProjectTask(models.Model):
|
||||
_inherit = 'project.task'
|
||||
|
||||
start_date = fields.Date(string='Start Date')
|
||||
|
||||
def action_done_task(self):
|
||||
stage_id = self.env['project.task.type'].search([('is_closed', '=', True)], limit=1)
|
||||
for stage in self:
|
||||
stage.write({'stage_id': stage_id.id,
|
||||
'active': False})
|
||||
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError, AccessError, ValidationError
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
#class Project(models.Model):
|
||||
#_inherit = 'project.project'
|
||||
|
||||
#project_cons_hrs = fields.One2many('project.consultant.hrs', 'project_id', 'Project Hours')
|
||||
|
||||
class ProjectConsultantHrs(models.Model):
|
||||
_name = 'project.consultant.hrs'
|
||||
_description = 'Project Consultant Hours'
|
||||
_order = 'employee_id, end_date desc'
|
||||
|
||||
project_id = fields.Many2one('project.project', string="Project", required=True)
|
||||
employee_id = fields.Many2one('hr.employee', string="Consultant", required=True)
|
||||
start_date = fields.Date('Start Date', required=True)
|
||||
end_date = fields.Date('End Date', required=True)
|
||||
percentage = fields.Float("Budgeted Percentage (%)")
|
||||
budgeted_hours = fields.Float("Budgeted Hours for period", compute='_compute_budgeted_hours', store=True)
|
||||
actual_percentage = fields.Float("Actual Percentage (%)", compute='_compute_actual_calc', store=True)
|
||||
actual_hours = fields.Float("Actual Hours for period", compute='_compute_actual_calc', store=True)
|
||||
#hours_type = fields.Selection([('Budgeted Hours', 'Budgeted Hours'), ('Actual Hours', 'Actual Hours')], default="Budgeted Hours", string="Type")
|
||||
|
||||
@api.constrains('start_date', 'end_date')
|
||||
def _check_dates(self):
|
||||
for rec in self:
|
||||
if rec.end_date < rec.start_date:
|
||||
raise ValidationError(_('Start date must be earlier than end date.'))
|
||||
|
||||
@api.constrains('employee_id', 'percentage')
|
||||
def _check_percent(self):
|
||||
for val in self:
|
||||
if val.employee_id and val.percentage:
|
||||
rec = val.search([('employee_id','=',val.employee_id.id),('project_id','=',val.project_id.id)])
|
||||
per = [r.percentage for r in rec]
|
||||
if sum(per) > 100:
|
||||
raise ValidationError(_('Consultant total percentage should not be greater than 100'))
|
||||
if (val.percentage <= 0.0 or val.percentage > 100.0):
|
||||
raise ValidationError(_('Percentage must be between 1 and 100.'))
|
||||
|
||||
@api.constrains('start_date', 'end_date')
|
||||
def _check_date(self):
|
||||
for val in self:
|
||||
#if 'project_id' in self.env.context:
|
||||
if not val.project_id.date_start or not val.project_id.date:
|
||||
raise UserError(_('Project start date and end date should not be blank'))
|
||||
if val.project_id.date_start and val.start_date:
|
||||
if not (val.project_id.date_start <= val.start_date <= val.project_id.date):
|
||||
raise ValidationError("Start date should be between project start date and End Date")
|
||||
if val.end_date:
|
||||
if not (val.project_id.date_start <= val.end_date <= val.project_id.date):
|
||||
raise ValidationError("End date should be between project start date and End Date")
|
||||
domain = [
|
||||
('start_date', '<=', val.end_date),
|
||||
('end_date', '>=', val.start_date),
|
||||
('project_id', '=', val.project_id.id),
|
||||
('employee_id', '=', val.employee_id.id),
|
||||
('id', '!=', val.id)
|
||||
]
|
||||
res = self.search_count(domain)
|
||||
if res > 0:
|
||||
raise ValidationError(_('Same Consultant can not have 2 same date that overlaps on same day!'))
|
||||
|
||||
@api.depends('project_id', 'employee_id', 'project_id.sale_line_employee_ids', 'percentage')
|
||||
def _compute_budgeted_hours(self):
|
||||
for val in self:
|
||||
if val.project_id and val.employee_id and val.percentage > 0:
|
||||
budgeted = 0
|
||||
for emp in val.project_id.sale_line_employee_ids:
|
||||
if emp.employee_id.id == val.employee_id.id:
|
||||
budgeted = emp.budgeted_qty
|
||||
val.budgeted_hours = (budgeted * val.percentage / 100)
|
||||
else:
|
||||
val.budgeted_hours = 0
|
||||
|
||||
@api.depends('project_id', 'start_date', 'end_date', 'project_id.timesheet_ids', 'project_id.timesheet_ids.date', 'project_id.timesheet_ids.project_id',
|
||||
'project_id.timesheet_ids.employee_id', 'project_id.timesheet_ids.start_time', 'project_id.timesheet_ids.end_time', 'project_id.timesheet_ids.unit_amount')
|
||||
def _compute_actual_calc(self):
|
||||
Timesheet = self.env['account.analytic.line']
|
||||
for val in self:
|
||||
if val.project_id and val.employee_id and val.start_date and val.end_date:
|
||||
domain = [
|
||||
('project_id','=',val.project_id.id),
|
||||
('employee_id', '=', val.employee_id.id),
|
||||
('start_datetime', '>=', val.start_date),
|
||||
('end_datetime', '<=', val.end_date)
|
||||
]
|
||||
timesheets = Timesheet.search(domain)
|
||||
val.actual_hours = sum(timesheet.unit_amount for timesheet in timesheets)
|
||||
#budgeted = 0
|
||||
#for emp in val.project_id.sale_line_employee_ids:
|
||||
# if emp.employee_id.id == val.employee_id.id:
|
||||
# budgeted = emp.budgeted_qty
|
||||
res = sum(timesheet.unit_amount for timesheet in timesheets)
|
||||
val.actual_hours = res
|
||||
#budgeted_period = (budgeted * val.percentage / 100)
|
||||
if val.budgeted_hours > 0:
|
||||
val.actual_percentage = (res/val.budgeted_hours) * 100
|
||||
else:
|
||||
val.actual_percentage = 0
|
||||
else:
|
||||
val.actual_hours = 0
|
||||
val.actual_percentage = 0
|
||||
|
||||
@api.onchange('start_date')
|
||||
def onchange_start_date(self):
|
||||
if self.start_date:
|
||||
self.end_date = self.start_date + relativedelta(days=6)
|
||||
|
||||
@api.onchange('employee_id')
|
||||
def onchange_employee_id(self):
|
||||
res = {}
|
||||
emp_ids = [emp.employee_id.id for emp in self.project_id.sale_line_employee_ids]
|
||||
res['domain'] = {'employee_id': [('id', 'in', emp_ids)]}
|
||||
if 'project_id' in self.env.context:
|
||||
#if not self.env.context['project_id']:
|
||||
#raise UserError(_('Please save record before add a line'))
|
||||
if not emp_ids:
|
||||
raise UserError(_('Please add consultant at Invoicing tab before add a line'))
|
||||
res['domain'] = {'employee_id': [('id','in',emp_ids)]}
|
||||
return res
|
||||
|
||||
"""@api.onchange('employee_id', 'percentage')
|
||||
def onchange_employee_id(self):
|
||||
if self.employee_id and self.percentage > 0:
|
||||
cons = [emp.budgeted_qty if emp.employee_id.id == self.employee_id.id else 0 for emp in self.project_id.sale_line_employee_ids]
|
||||
budgeted = cons and cons[0] or 0
|
||||
if budgeted > 0:
|
||||
#record.employee_per = 100 - record.manager_per
|
||||
self.budgeted_hours = (budgeted * self.percentage / 100)
|
||||
else:
|
||||
self.budgeted_hours = 0"""
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import project_profitability_report_analysis
|
||||
#from . import project_profitability_report_analysis
|
||||
from . import project_hours_report
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, tools
|
||||
|
||||
|
||||
class BudgetHrsAnalysis(models.Model):
|
||||
|
||||
_name = "project.consultant.hrs.report"
|
||||
_description = "Project consultant hours analysis report"
|
||||
_rec_name = "project_id"
|
||||
#_order = 'project_id desc, hours_type asc, end_date desc, employee_id'
|
||||
_auto = False
|
||||
|
||||
project_id = fields.Many2one('project.project', string='Project', readonly=True)
|
||||
employee_id = fields.Many2one('hr.employee', string='Consultant', readonly=True)
|
||||
start_date = fields.Date(string='Start Date', readonly=True)
|
||||
end_date = fields.Date(string='End Date', readonly=True)
|
||||
hours_type = fields.Char(string="Hours Type", readonly=True)
|
||||
hours = fields.Float("Hours", digits=(16, 2), readonly=True, group_operator="sum")
|
||||
percentage = fields.Float("Percentage (%)")
|
||||
#roworder = fields.Integer("Row Order")
|
||||
#ptime = fields.Char(string="Time", readonly=True)
|
||||
|
||||
@api.depends('project_id', 'employee_id')
|
||||
def name_get(self):
|
||||
res = []
|
||||
for record in self:
|
||||
name = record.project_id and record.project_id.name or ''
|
||||
if record.employee_id.name:
|
||||
name = record.employee_id.name + '/' + name + '/' + record.hours_type
|
||||
res.append((record.id, name))
|
||||
return res
|
||||
|
||||
"""def _generate_order_by(self, order_spec, query):
|
||||
order_by = super(BudgetHrsAnalysis, self)._generate_order_by(order_spec, query)
|
||||
print("order by>>>>>>>>>>>>>>", order_by)
|
||||
return order_by"""
|
||||
|
||||
def init(self):
|
||||
'''Create the view'''
|
||||
tools.drop_view_if_exists(self._cr, self._table)
|
||||
self._cr.execute("""
|
||||
CREATE OR REPLACE VIEW %s AS (
|
||||
SELECT ROW_NUMBER() OVER() as id, project_id, start_date, end_date, employee_id, hours_type, hours, percentage
|
||||
from (
|
||||
select project_id as project_id, start_date as start_date, end_date as end_date,
|
||||
employee_id as employee_id, 'Budgeted Hours for period' as hours_type, budgeted_hours as hours,
|
||||
percentage as percentage
|
||||
from project_consultant_hrs ph
|
||||
union
|
||||
select project_id as project_id, start_date as start_date, end_date as end_date,
|
||||
employee_id as employee_id, 'Actual Hours for period' as hours_type, actual_hours as hours,
|
||||
actual_percentage as percentage
|
||||
from project_consultant_hrs ph
|
||||
) as res order by
|
||||
project_id desc,
|
||||
hours_type,
|
||||
employee_id, end_date, start_date
|
||||
)""" % (self._table,))
|
||||
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_project_consultant_hrs_report_form" model="ir.ui.view">
|
||||
<field name="name">project.consultant.hrs.report.form</field>
|
||||
<field name="model">project.consultant.hrs.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Consultant Allocation" create="false" edit="false" delete="0">
|
||||
<group>
|
||||
<field name="project_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="hours_type"/>
|
||||
<field name="hours"/>
|
||||
<field name="percentage"/>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_project_consultant_hrs_report_tree" model="ir.ui.view">
|
||||
<field name="name">project.consultant.hrs.report.tree</field>
|
||||
<field name="model">project.consultant.hrs.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Consultant Allocation" create="false" edit="false" delete="0">
|
||||
<field name="project_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="hours_type"/>
|
||||
<field name="hours"/>
|
||||
<field name="percentage"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_project_consultant_hrs_report_search" model="ir.ui.view">
|
||||
<field name="name">project.consultant.hrs.report.search</field>
|
||||
<field name="model">project.consultant.hrs.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Consultant Allocation">
|
||||
<field name="project_id"/>
|
||||
<field name="employee_id"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Project" name="project" domain="[]" context="{'group_by':'project_id'}"/>
|
||||
<filter string="Consultant" name="consultant" domain="[]" context="{'group_by':'employee_id'}"/>
|
||||
<filter string="Start Date" name="sdate" domain="[]" context="{'group_by':'start_date'}"/>
|
||||
<filter string="End Date" name="edate" domain="[]" context="{'group_by':'end_date'}"/>
|
||||
<filter string="Hours type" name="group_by_hours_type" context="{'group_by':'hours_type'}"/>
|
||||
<!--<filter string="Time" name="ptime" domain="[]" context="{'group_by':'ptime'}"/>-->
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_consultant_hrs_report_graph" model="ir.ui.view">
|
||||
<field name="name">project.consultant.hrs.report.graph</field>
|
||||
<field name="model">project.consultant.hrs.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Consultant Allocation" type="bar" stacked="False" sample="1" disable_linking="1">
|
||||
<field name="project_id" type="row"/>
|
||||
<field name="hours" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_consultant_hrs_report_calendar" model="ir.ui.view">
|
||||
<field name="name">project.consultant.hrs.report.calendar</field>
|
||||
<field name="model">project.consultant.hrs.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<calendar string="Consultant Allocation"
|
||||
date_start="start_date"
|
||||
date_stop="end_date" create="0" quick_add="False"
|
||||
mode="month">
|
||||
<field name="project_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="hours"/>
|
||||
<field name="percentage"/>
|
||||
</calendar>
|
||||
</field>
|
||||
</record>
|
||||
<!-- create="0" quick_add="False" color="project_id" -->
|
||||
|
||||
<record id="action_project_consultant_hrs_report" model="ir.actions.act_window">
|
||||
<field name="name">Consultant Allocation</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">project.consultant.hrs.report</field>
|
||||
<field name="view_mode">tree,calendar,graph</field>
|
||||
<field name="search_view_id" ref="view_project_consultant_hrs_report_search"/>
|
||||
<field name="context">{
|
||||
'search_default_project': 1,
|
||||
'search_default_consultant': 1,
|
||||
'search_default_group_by_hours_type': 1,
|
||||
'default_res_model': 'project.consultant.hrs.report'
|
||||
}
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem name="View Allocation" id="menu_main_cor_consult_allocation"
|
||||
action="cor_custom.action_project_consultant_hrs_report" sequence="53"
|
||||
groups="base.group_no_one,project.group_project_user"/>
|
||||
|
||||
<record id="project_consul_hours_report_view_form" model="ir.ui.view">
|
||||
<field name="name">Project Consul Report Hours</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<div name="button_box" position="inside">
|
||||
<button class="oe_stat_button" type="object" name="action_view_custom_project_consultant_hrs_report"
|
||||
icon="fa-tasks"
|
||||
attrs="{'invisible':['|',('pricing_type','!=','employee_rate'),('project_type','!=','hours_in_consultant')]}"
|
||||
string="View Allocation" widget="statinfo">
|
||||
</button>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
|
@ -0,0 +1,148 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Note using code overriding menu in code (not working) so these below features will add using ODOO UI -->
|
||||
|
||||
<!--<record id="group_show_hr_discuss_group" model="res.groups">
|
||||
<field name="name">Show Discuss Menu</field>
|
||||
<field name="comment">Show Discuss Menu related user group</field>
|
||||
</record>
|
||||
|
||||
<record id="group_show_hr_contact_group" model="res.groups">
|
||||
<field name="name">Show Contact Menu</field>
|
||||
<field name="comment">Show Contact Menu related user group</field>
|
||||
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="group_show_hr_menu_group" model="res.groups">
|
||||
<field name="name">Show HR Menu</field>
|
||||
<field name="comment">Show HR Menu related user group</field>
|
||||
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>-->
|
||||
|
||||
<!--<record id="project.group_project_manager" model="res.groups">
|
||||
<field name="name">Manager</field>
|
||||
<field name="category_id" ref="base.module_category_services_project"/>
|
||||
<field name="implied_ids" eval="[(4, ref('project.group_project_user'))]"/>
|
||||
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record>-->
|
||||
|
||||
<!-- <record id="group_project_cor_admin" model="res.groups">
|
||||
<field name="name">Cor Custom</field>
|
||||
<field name="category_id" ref="base.module_category_services_project"/>
|
||||
<field name="implied_ids" eval="[(4, ref('project.group_project_manager'))]"/>
|
||||
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||
</record> -->
|
||||
|
||||
|
||||
<function name="write" model="ir.model.data">
|
||||
<function name="search" model="ir.model.data">
|
||||
<value eval="[('module','=','hr_timesheet'),('name','=','timesheet_line_rule_user')]"/>
|
||||
</function>
|
||||
<value eval="{'noupdate': False}" />
|
||||
</function>
|
||||
|
||||
|
||||
<record id="hr_timesheet.timesheet_line_rule_user" model="ir.rule">
|
||||
<field name="name">account.analytic.line.timesheet.user</field>
|
||||
<field name="model_id" ref="analytic.model_account_analytic_line"/>
|
||||
<field name="domain_force">[
|
||||
('user_id', '=', user.id),
|
||||
('project_id', '!=', False),
|
||||
'|', '|','|',
|
||||
('project_id.privacy_visibility', '!=', 'followers'),
|
||||
('project_id.allowed_internal_user_ids', 'in', user.ids),
|
||||
('task_id.allowed_user_ids', 'in', user.ids),
|
||||
('project_id.sale_line_employee_ids.employee_id.user_id', 'in', user.ids),
|
||||
|
||||
]</field>
|
||||
<field name="groups" eval="[(4, ref('hr_timesheet.group_hr_timesheet_user'))]"/>
|
||||
</record>
|
||||
|
||||
|
||||
<function name="write" model="ir.model.data">
|
||||
<function name="search" model="ir.model.data">
|
||||
<value eval="[('module','=','hr_timesheet'),('name','=','timesheet_line_rule_approver')]"/>
|
||||
</function>
|
||||
<value eval="{'noupdate': False}" />
|
||||
</function>
|
||||
|
||||
<record id="hr_timesheet.timesheet_line_rule_approver" model="ir.rule">
|
||||
<field name="name">account.analytic.line.timesheet.approver</field>
|
||||
<field name="model_id" ref="analytic.model_account_analytic_line" />
|
||||
<field name="domain_force">[
|
||||
('project_id', '!=', False),
|
||||
'|','|',
|
||||
('project_id.privacy_visibility', '!=', 'followers'),
|
||||
('project_id.allowed_internal_user_ids', 'in', user.ids),
|
||||
('project_id.sale_line_employee_ids.employee_id.user_id', 'in', user.ids),
|
||||
]</field>
|
||||
<field name="groups" eval="[(4, ref('hr_timesheet.group_hr_timesheet_approver'))]" />
|
||||
</record>
|
||||
|
||||
<!-- <function name="write" model="ir.model.data">
|
||||
<function name="search" model="ir.model.data">
|
||||
<value eval="[('module','=','project'),('name','=','project_project_manager_rule')]"/>
|
||||
</function>
|
||||
<value eval="{'noupdate': False}" />
|
||||
</function>
|
||||
|
||||
<record model="ir.rule" id="project.project_project_manager_rule">
|
||||
<field name="name">Project: project manager: see all</field>
|
||||
<field name="model_id" ref="project.model_project_project"/>
|
||||
<field name="domain_force">[('user_id','=',user.id)]</field>
|
||||
<field name="groups" eval="[(4,ref('project.group_project_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<function name="write" model="ir.model.data">
|
||||
<function name="search" model="ir.model.data">
|
||||
<value eval="[('module','=','project'),('name','=','project_manager_all_project_tasks_rule')]"/>
|
||||
</function>
|
||||
<value eval="{'noupdate': False}" />
|
||||
</function>
|
||||
|
||||
|
||||
<record model="ir.rule" id="project.project_manager_all_project_tasks_rule">
|
||||
<field name="name">Project/Task: project manager: see own project</field>
|
||||
<field name="model_id" ref="project.model_project_task"/>
|
||||
<field name="domain_force">[('project_id.user_id','=',user.id)]</field>
|
||||
<field name="groups" eval="[(4,ref('project.group_project_manager'))]"/>
|
||||
</record> -->
|
||||
|
||||
<!-- <record model="ir.rule" id="project_project_all_admin_rule">
|
||||
<field name="name">Project: project manager: see all</field>
|
||||
<field name="model_id" ref="model_project_project"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4,ref('group_project_cor_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.rule" id="project_project_all_project_tasks_admin_rule">
|
||||
<field name="name">Project/Task: project manager: see all</field>
|
||||
<field name="model_id" ref="model_project_task"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4,ref('group_project_cor_admin'))]"/>
|
||||
</record> -->
|
||||
|
||||
<record model="ir.rule" id="project_view_consultant_user_own_rule">
|
||||
<field name="name">Project: view Consultant: Own User</field>
|
||||
<field name="model_id" ref="model_project_consultant_hrs_report"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4,ref('project.group_project_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.rule" id="project_view_consultant_manager_rrule">
|
||||
<field name="name">Project: view Consultant: Manager User</field>
|
||||
<field name="model_id" ref="model_project_consultant_hrs_report"/>
|
||||
<field name="domain_force">[(1,'=',1)]</field>
|
||||
<field name="groups" eval="[(4,ref('project.group_project_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- [('project_id.user_id','=',user.id)]
|
||||
<record model="ir.rule" id="project_view_consultant_admin_rrule">
|
||||
<field name="name">Project: view Consultant: all User</field>
|
||||
<field name="model_id" ref="model_project_consultant_hrs_report"/>
|
||||
<field name="domain_force">[(1,'=',1)]</field>
|
||||
<field name="groups" eval="[(4,ref('group_project_cor_admin'))]"/>
|
||||
</record>-->
|
||||
|
||||
</odoo>
|
|
@ -1,2 +1,13 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_cor_custom_cor_custom,cor_custom.cor_custom,model_cor_custom_cor_custom,base.group_user,1,1,1,1
|
||||
access_consultant_timesheet_hrs_puser,consultant.timesheet.hrs,model_consultant_timesheet_hrs,project.group_project_user,1,1,1,0
|
||||
access_consultant_timesheet_hrs_pmanager,consultant.timesheet.hrs,model_consultant_timesheet_hrs,project.group_project_manager,1,1,1,1
|
||||
access_project_consultant_hrs_puser,project.consultant.hrs,model_project_consultant_hrs,project.group_project_user,1,0,0,0
|
||||
access_project_consultant_hrs_pmanager,project.consultant.hrs,model_project_consultant_hrs,project.group_project_manager,1,1,1,1
|
||||
access_model_project_multi_budget_assign_puser,project.multi.budget.assign,model_project_multi_budget_assign,project.group_project_user,1,0,0,0
|
||||
access_model_project_multi_budget_assign_pmanager,project.multi.budget.assign,model_project_multi_budget_assign,project.group_project_manager,1,1,1,1
|
||||
access_model_project_multi_budget_assign_line_puser,project.multi.budget.assign.line,model_project_multi_budget_assign_line,project.group_project_user,1,0,0,0
|
||||
access_model_project_multi_budget_assign_line_pmanager,project.multi.budget.assign.line,model_project_multi_budget_assign_line,project.group_project_manager,1,1,1,1
|
||||
access_project_consultant_hrs_report_puser,project.consultant.hrs.report,model_project_consultant_hrs_report,project.group_project_user,1,0,0,0
|
||||
access_project_consultant_hrs_report_pmanager,project.consultant.hrs.report,model_project_consultant_hrs_report,project.group_project_manager,1,1,1,1
|
||||
access_custom_project_tags_puser,custom.project.tags,model_custom_project_tags,project.group_project_user,1,1,1,1
|
||||
|
||||
|
|
|
|
@ -0,0 +1,17 @@
|
|||
[lang="he_IL"] .o_main_navbar{
|
||||
ul.o_menu_apps {
|
||||
float:right;
|
||||
}
|
||||
.o_main_navbar > .o_menu_brand {
|
||||
float: right;
|
||||
}
|
||||
ul.o_menu_sections {
|
||||
float:right;
|
||||
}
|
||||
ul.o_menu_systray {
|
||||
float:right;
|
||||
margin-right: 540px;
|
||||
}
|
||||
.o_main_navbar > ul > li {
|
||||
float: right;}
|
||||
}
|
|
@ -15,28 +15,194 @@
|
|||
</field>
|
||||
</record>-->
|
||||
|
||||
<record id="hr_timesheet_line_tree_inherit1" model="ir.ui.view">
|
||||
<record id="view_hr_task_timesheet_inherit_time" model="ir.ui.view">
|
||||
<field name="name">Start and End time task timesheets</field>
|
||||
<field name="model">project.task</field>
|
||||
<field name="inherit_id" ref="hr_timesheet.view_task_form2_inherited"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='timesheet_ids']//tree//field[@name='name']" position="after">
|
||||
<field name="start_time" widget="float_time" invisible="1"/>
|
||||
<field name="end_time" widget="float_time" invisible="1"/>
|
||||
<field name="start_datetime" string="Start date"/>
|
||||
<field name="end_datetime" string="End date"/>
|
||||
</xpath>
|
||||
<!--<xpath expr="//field[@name='timesheet_ids']//tree//field[@name='unit_amount']" position="after">
|
||||
<field name="parent_project"/>
|
||||
</xpath>-->
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_account_analytic_line_inherit1" model="ir.ui.view">
|
||||
<field name="name">Analytic line</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="inherit_id" ref="analytic.view_account_analytic_line_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='unit_amount']" position="replace">
|
||||
<field name="unit_amount" string="Quantity"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!--<record id="hr_timesheet_line_tree_inherit1" model="ir.ui.view">
|
||||
<field name="name">account.analytic.line.tree.hr_timesheet_inherit1</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="inherit_id" ref="hr_timesheet.hr_timesheet_line_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="start_time" widget="float_time"/>
|
||||
<field name="end_time" widget="float_time"/>
|
||||
<field name="start_time" widget="float_time" invisible="1"/>
|
||||
<field name="end_time" widget="float_time" invisible="1"/>
|
||||
<field name="start_datetime" string="Start date"/>
|
||||
<field name="end_datetime" string="End date"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!--<record id="timesheet_view_tree_user_inherit1" model="ir.ui.view">
|
||||
<field name="name">account.analytic.line.tree.hr_timesheet_user_inherit1</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="inherit_id" ref="hr_timesheet.timesheet_view_tree_user"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="start_time"/>
|
||||
<field name="end_time"/>
|
||||
<xpath expr="//field[@name='unit_amount']" position="after">
|
||||
<field name="parent_project"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>-->
|
||||
|
||||
<record id="hr_timesheet.hr_timesheet_line_tree" model="ir.ui.view">
|
||||
<field name="name">account.analytic.line.tree.hr_timesheet</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree editable="top" string="Timesheet Activities" sample="1">
|
||||
<field name="date" readonly="1" invisible="1"/>
|
||||
<field name="employee_id" invisible="1"/>
|
||||
<field name="project_id" required="1" options="{'no_create_edit': True}"
|
||||
context="{'form_view_ref': 'project.project_project_view_form_simplified',}"/>
|
||||
<field name="task_id" optional="show" options="{'no_create_edit': True, 'no_open': True}"
|
||||
widget="task_with_hours" context="{'default_project_id': project_id}"
|
||||
domain="[('project_id', '=', project_id)]"/>
|
||||
<field name="name" optional="show" required="0"/>
|
||||
<field name="start_time" widget="float_time" invisible="1"/><!--custom-->
|
||||
<field name="end_time" widget="float_time" invisible="1"/><!--custom-->
|
||||
<field name="start_datetime" string="Start Time"/><!--custom-->
|
||||
<field name="end_datetime" string="End Time"/><!--custom-->
|
||||
<field name="unit_amount" optional="show" widget="timesheet_uom" sum="Total"
|
||||
decoration-danger="unit_amount > 24"/>
|
||||
<field name="parent_project" options="{'no_open': True, 'no_create': True, 'no_create_edit': True}"/><!--custom-->
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="user_id" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Overide all timesheet action -->
|
||||
<record id="hr_timesheet.timesheet_action_all" model="ir.actions.act_window">
|
||||
<field name="name">All Timesheets</field>
|
||||
<field name="res_model">account.analytic.line</field>
|
||||
<field name="view_mode">tree,form,pivot,kanban,calendar</field>
|
||||
<field name="search_view_id" ref="hr_timesheet.hr_timesheet_line_search"/>
|
||||
<field name="domain">[('project_id', '!=', False)]</field>
|
||||
<field name="context">{
|
||||
'search_default_week':1,
|
||||
}
|
||||
</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No activities found. Let's start a new one!
|
||||
</p>
|
||||
<p>
|
||||
Track your working hours by projects every day and invoice this time to your customers.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_timesheet_line_calendar" model="ir.ui.view">
|
||||
<field name="name">account.analytic.line.calendar</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<calendar string="Timesheet" date_start="start_datetime" date_stop="end_datetime" date_delay="unit_amount" color="project_id"
|
||||
form_view_id="%(hr_timesheet.hr_timesheet_line_form)d" event_open_popup="true" quick_add="False">
|
||||
<field name="project_id"/>
|
||||
<field name="name" />
|
||||
</calendar>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="timesheet_action_view_all_calendar" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="4"/>
|
||||
<field name="view_mode">calendar</field>
|
||||
<field name="view_id" ref="view_hr_timesheet_line_calendar"/>
|
||||
<field name="act_window_id" ref="hr_timesheet.timesheet_action_all"/>
|
||||
</record>
|
||||
|
||||
<!-- Overide my timesheet action -->
|
||||
|
||||
<record id="hr_timesheet.act_hr_timesheet_line" model="ir.actions.act_window">
|
||||
<field name="name">My Timesheets</field>
|
||||
<field name="res_model">account.analytic.line</field>
|
||||
<field name="view_mode">calendar,tree,form,kanban</field>
|
||||
<field name="domain">[('project_id', '!=', False), ('user_id', '=', uid)]</field>
|
||||
<field name="context">{
|
||||
"search_default_week":1,
|
||||
}
|
||||
</field>
|
||||
<field name="search_view_id" ref="hr_timesheet.hr_timesheet_line_my_timesheet_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No activities found. Let's start a new one!
|
||||
</p>
|
||||
<p>
|
||||
Track your working hours by projects every day and invoice this time to your customers.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_timesheet.hr_timesheet_line_form" model="ir.ui.view">
|
||||
<field name="name">account.analytic.line.form</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="priority">1</field>
|
||||
<field name="inherit_id" eval="False"/>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Analytic Entry">
|
||||
<sheet string="Analytic Entry">
|
||||
<group>
|
||||
<field name="name" required="0"/>
|
||||
</group>
|
||||
<group>
|
||||
<group>
|
||||
<field name="project_id" required="1"
|
||||
context="{'form_view_ref': 'project.project_project_view_form_simplified',}"/>
|
||||
<field name="task_id" widget="task_with_hours" context="{'default_project_id': project_id}"
|
||||
domain="[('project_id', '=', project_id)]"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="date" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<!--<field name="amount"/>-->
|
||||
<field name="amount" invisible="1"/>
|
||||
<field name="start_time" widget="float_time" invisible="1"/><!--custom-->
|
||||
<field name="end_time" widget="float_time" invisible="1"/><!--custom-->
|
||||
<field name="start_datetime" string="Start Time"/><!--custom-->
|
||||
<field name="end_datetime" string="End Time"/><!--custom-->
|
||||
<field name="unit_amount" widget="timesheet_uom" decoration-danger="unit_amount > 24"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_my_timesheet_line_calendar" model="ir.ui.view">
|
||||
<field name="name">account.analytic.line.calendar</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<calendar string="Timesheet" date_start="start_datetime" date_stop="end_datetime" date_delay="unit_amount" color="project_id"
|
||||
form_view_id="%(hr_timesheet.hr_timesheet_line_form)d" event_open_popup="true" quick_add="False">
|
||||
<field name="project_id"/>
|
||||
<field name="name"/>
|
||||
<field name="start_datetime" string="Start date"/>
|
||||
<field name="end_datetime" string="End date"/>
|
||||
</calendar>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="timesheet_action_view_my_calendar" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="3"/>
|
||||
<field name="view_mode">calendar</field>
|
||||
<field name="view_id" ref="view_hr_my_timesheet_line_calendar"/>
|
||||
<field name="act_window_id" ref="hr_timesheet.act_hr_timesheet_line"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="assets_frontend" name="COR assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<link rel="stylesheet" href="/cor_custom/static/src/css/rtl_css_direction.css"/>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
|
@ -9,6 +9,9 @@
|
|||
<xpath expr="//field[@name='resource_calendar_id']" position="after">
|
||||
<field name="budgeted_hour_week"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='user_id']" position="replace">
|
||||
<field name="user_id" string="Related User" domain="[('share', '=', False)]" required="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_project_consultant_hrs_form" model="ir.ui.view">
|
||||
<field name="name">project.consultant.hrs.form</field>
|
||||
<field name="model">project.consultant.hrs</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Consultant Allocation" create="false" edit="false" delete="false">
|
||||
<group>
|
||||
<field name="project_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="percentage"/>
|
||||
<field name="budgeted_hours"/>
|
||||
<field name="actual_percentage"/>
|
||||
<field name="actual_hours"/>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_project_consultant_hrs_tree" model="ir.ui.view">
|
||||
<field name="name">project.consultant.hrs.tree</field>
|
||||
<field name="model">project.consultant.hrs</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Consultant Allocation" create="false" edit="false" delete="false">
|
||||
<field name="project_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="percentage"/>
|
||||
<field name="budgeted_hours"/>
|
||||
<field name="actual_percentage"/>
|
||||
<field name="actual_hours"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_project_consultant_hrs_search" model="ir.ui.view">
|
||||
<field name="name">project.consultant.hrs.search</field>
|
||||
<field name="model">project.consultant.hrs</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Consultant Allocation">
|
||||
<field name="project_id"/>
|
||||
<field name="employee_id"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Project" name="project" domain="[]" context="{'group_by':'project_id'}"/>
|
||||
<filter string="Consultant" name="consultant" domain="[]" context="{'group_by':'employee_id'}"/>
|
||||
<filter string="Start Date" name="sdate" domain="[]" context="{'group_by':'start_date'}"/>
|
||||
<filter string="End Date" name="edate" domain="[]" context="{'group_by':'end_date'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_project_consultant_hrs" model="ir.actions.act_window">
|
||||
<field name="name">Consultant Allocation</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">project.consultant.hrs</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="view_project_consultant_hrs_search"/>
|
||||
<field name="context">{
|
||||
'search_default_project': 1,
|
||||
'search_default_consultant': 1,
|
||||
}
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!--<record id="project_consul_hours_view_form" model="ir.ui.view">
|
||||
<field name="name">Project Consul Hours</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<div name="button_box" position="inside">
|
||||
<button class="oe_stat_button" type="action" name="cor_custom.action_project_consultant_hrs" icon="fa-tasks"
|
||||
attrs="{'invisible':['|',('pricing_type','!=','employee_rate'),('project_type','!=','hours_in_consultant')]}" string="View Allocation" widget="statinfo">
|
||||
</button>
|
||||
</div>
|
||||
</field>
|
||||
</record>-->
|
||||
|
||||
</odoo>
|
|
@ -6,17 +6,39 @@
|
|||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<div name="button_box" position="inside">
|
||||
<!--<div name="button_box" position="inside">
|
||||
<button class="oe_stat_button" type="object" name="action_view_account_analytic_line" icon="fa-usd"
|
||||
attrs="{'invisible': [('allow_billable','=',False)]}" string="Cost/Revenue" widget="statinfo">
|
||||
</button>
|
||||
</div>
|
||||
</div>-->
|
||||
<xpath expr="//field[@name='user_id']" position="replace">
|
||||
<field name="user_id" string="Project Manager" widget="many2one_avatar_user" required="0"
|
||||
attrs="{'readonly':[('active','=',False)]}" domain="[('share', '=', False)]"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='partner_id']" position="replace">
|
||||
<field name="partner_id" string="Client" required="0"/>
|
||||
<field name="date_start"/>
|
||||
<field name="date"/>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='settings']" position="after">
|
||||
<page string="Consultant Allocation"
|
||||
attrs="{'invisible':['|',('pricing_type','!=','employee_rate'),('project_type','!=','hours_in_consultant')]}">
|
||||
<field name="project_cons_hrs">
|
||||
<tree editable="top">
|
||||
<field name="project_id" invisible="1"/>
|
||||
<field name="employee_id" options="{'no_create_edit': True, 'no_quick_create': True}"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="percentage"/>
|
||||
<field name="budgeted_hours"/>
|
||||
<field name="actual_percentage"/>
|
||||
<field name="actual_hours"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='privacy_visibility']" position="before">
|
||||
<field name="tag_ids" widget="many2many_tags"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
@ -27,6 +49,10 @@
|
|||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="sale_timesheet.project_project_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='action_view_timesheet']" position="replace">
|
||||
<button string="Project Overview" class="oe_stat_button" type="object" name="action_view_timesheet"
|
||||
icon="fa-puzzle-piece" attrs="{'invisible': [('allow_billable', '=', False)]}" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='billing_employee_rate']" position="replace">
|
||||
<page name="billing_employee_rate" string="Invoicing"
|
||||
attrs="{'invisible': [('allow_billable', '=', False)]}">
|
||||
|
@ -40,22 +66,35 @@
|
|||
<field name="project_type" attrs="{'invisible': [('pricing_type', '=', 'fixed_rate')]}"
|
||||
widget="radio"/>
|
||||
<field name="budgeted_revenue"
|
||||
attrs="{'invisible': [('pricing_type','=','fixed_rate')], 'required': [('pricing_type','!=','fixed_rate')]}"/>
|
||||
attrs="{'invisible': [('pricing_type', '=', 'fixed_rate')], 'required': [('pricing_type','!=','fixed_rate')]}"/>
|
||||
<field name="comment"/>
|
||||
<!-- attrs="{'invisible': [('pricing_type','=','fixed_rate')], 'required': [('pricing_type','!=','fixed_rate')]}"-->
|
||||
|
||||
<field name="hour_distribution" widget="radio"
|
||||
attrs="{'invisible': ['|',('project_type','!=','hours_in_consultant'),('pricing_type','=','fixed_rate')]}"/>
|
||||
<field name="manager_per" attrs="{'invisible': [('hour_distribution','!=','Percentage')]}"/>
|
||||
<field name="employee_per" attrs="{'invisible': [('hour_distribution','!=','Percentage')]}"/>
|
||||
<field name="expenses_per" attrs="{'invisible': [('pricing_type','=','fixed_rate')]}"/>
|
||||
<field name="expenses_amt" attrs="{'invisible': [('pricing_type','=','fixed_rate')]}"/>
|
||||
<div class="o_td_label"
|
||||
attrs="{'invisible': ['|', '|', ('allow_timesheets', '=', False), ('sale_order_id', '!=', False), '&', ('pricing_type', '!=', 'fixed_rate'), ('bill_type', '!=', 'customer_task')]}">
|
||||
<label for="timesheet_product_id" string="Default Service"
|
||||
<!--<label for="timesheet_product_id" string="Default Service"
|
||||
attrs="{'invisible': [('bill_type', '!=', 'customer_task')]}"/>
|
||||
<label for="timesheet_product_id" string="Service"
|
||||
attrs="{'invisible': [('bill_type', '=', 'customer_task')]}"/>
|
||||
attrs="{'invisible': [('bill_type', '=', 'customer_task')]}"/>-->
|
||||
</div>
|
||||
<field name="timesheet_product_id" nolabel="1"
|
||||
<!--<field name="timesheet_product_id" nolabel="1"
|
||||
attrs="{'invisible': ['|', '|', ('allow_timesheets', '=', False), ('sale_order_id', '!=', False), '&', ('pricing_type', '!=', 'fixed_rate'), ('bill_type', '!=', 'customer_task')], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}"
|
||||
context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet', 'default_service_type': 'timesheet'}"/>-->
|
||||
<field name="timesheet_product_id" nolabel="1"
|
||||
invisible="1"
|
||||
context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet', 'default_service_type': 'timesheet'}"/>
|
||||
<field name="sale_order_id" invisible="1"/>
|
||||
<field name="sale_line_id" string="Default Sales Order Item"
|
||||
<!--<field name="sale_line_id" string="Default Sales Order Item"
|
||||
attrs="{'invisible': ['|', '|', ('sale_order_id', '=', False), ('bill_type', '!=', 'customer_project'), ('pricing_type', '!=', 'fixed_rate')], 'readonly': [('sale_order_id', '=', False)]}"
|
||||
options="{'no_create': True, 'no_edit': True, 'delete': False}"/>-->
|
||||
<field name="sale_line_id" string="Default Sales Order Item"
|
||||
invisible="1"
|
||||
options="{'no_create': True, 'no_edit': True, 'delete': False}"/>
|
||||
</group>
|
||||
<field name="sale_line_employee_ids"
|
||||
|
@ -63,42 +102,66 @@
|
|||
<tree editable="top">
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="project_id" invisible="1"/>
|
||||
<field name="hour_distribution" invisible="1"/>
|
||||
<field name="employee_id" options="{'no_create': True}" string="Consultant Name"/>
|
||||
<field name="role"
|
||||
attrs="{'required': [('hour_distribution','=','Percentage')]}"/>
|
||||
<field name="distribution_per"
|
||||
attrs="{'invisible': [('hour_distribution','!=','Percentage')], 'required': [('hour_distribution','=','Percentage')]}"/>
|
||||
<!--<field name="sale_line_id" options="{'no_create': True}"
|
||||
attrs="{'column_invisible': [('parent.sale_order_id', '=', False)]}"
|
||||
domain="[('order_id','=',parent.sale_order_id), ('is_service', '=', True)]"/>
|
||||
<field name="price_unit" widget="monetary" options="{'currency_field': 'currency_id'}"
|
||||
attrs="{'readonly': [('parent.sale_order_id', '!=', False)]}"/>-->
|
||||
<field name="price_unit" readonly="0"/>
|
||||
<field name="price_unit"
|
||||
attrs="{'readonly': [('parent.project_type', '=', 'hours_no_limit')]}"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="budgeted_qty"
|
||||
attrs="{'readonly': [('parent.project_type', '=', 'hours_no_limit')]}"/>
|
||||
<field name="cost"/>
|
||||
<field name="timesheet_hour"/>
|
||||
<field name="budgeted_hour_week"/>
|
||||
<!--<field name="timesheet_product_id"
|
||||
attrs="{'column_invisible': [('parent.sale_order_id', '!=', False)]}"/>-->
|
||||
<!--<field name="budgeted_uom"
|
||||
attrs="{'column_invisible': [('parent.sale_order_id', '=', False)]}"/>-->
|
||||
<!--<field name="budgeted_qty"
|
||||
attrs="{'column_invisible': [('parent.sale_order_id', '=', False)]}"/>-->
|
||||
<!--<field name="employee_price" widget="monetary" options="{'currency_field': 'currency_id'}"/>-->
|
||||
<field name="employee_price"/>
|
||||
<field name="timesheet_hour"/>
|
||||
<field name="consultant_cost"/>
|
||||
<field name="actual_revenue"/>
|
||||
<field name="budgeted_hour_week" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
<group attrs="{'invisible': [('pricing_type','=','fixed_rate')]}">
|
||||
<group>
|
||||
<field name="budgeted_hours"
|
||||
attrs="{'invisible': [('pricing_type','=','fixed_rate')], 'readonly': [('project_type','=','hours_in_consultant')],
|
||||
'required': [('pricing_type','!=','fixed_rate')]}"/>
|
||||
<!--<field name="consultant_timesheet_hrs" readonly="1" attrs="{'invisible': [('pricing_type','!=','fixed_rate')]}">-->
|
||||
<field name="consultant_timesheet_hrs" readonly="1" invisible="1">
|
||||
<tree editable="top">
|
||||
<field name="project_id" invisible="1"/>
|
||||
<field name="employee_id" invisible="0"/>
|
||||
<field name="timesheet_hour" invisible="0"/>
|
||||
<field name="employee_price" invisible="0"/>
|
||||
<field name="consultant_cost" invisible="0"/>
|
||||
</tree>
|
||||
</field>
|
||||
<group>
|
||||
<group attrs="{'invisible': [('pricing_type','=','fixed_rate')]}">
|
||||
<!--<field name="cost"/>-->
|
||||
<field name="actual_revenue"/>
|
||||
<field name="consultant_cost"/>
|
||||
<field name="other_expenses" readonly="1"/>
|
||||
<field name="total_expenses"/>
|
||||
<field name="profit_amt"/>
|
||||
<field name="profit_per"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="cost"/>
|
||||
<field name="hourly_rate"/>
|
||||
<field name="budgeted_hour_week"/>
|
||||
<group attrs="{'invisible': [('pricing_type','=','fixed_rate')]}">
|
||||
<field name="budgeted_hours"
|
||||
attrs="{'invisible': [('project_type','!=','hours_in_consultant')]}"/>
|
||||
<field name="budgeted_hours2" attrs="{'invisible': [('project_type','!=','hours_no_limit')],
|
||||
'required': [('project_type','=','hours_no_limit')]}"/>
|
||||
<field name="timesheet_hour"/>
|
||||
<field name="hourly_rate" attrs="{'readonly': [('project_type','!=','hours_no_limit')]}"/>
|
||||
<field name="budgeted_hour_week" invisible="1"/>
|
||||
</group>
|
||||
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
|
@ -107,6 +170,18 @@
|
|||
</record>
|
||||
|
||||
|
||||
<record id="inherit_view_project_tree" model="ir.ui.view">
|
||||
<field name="name">project.project.tree.inherit</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.view_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='partner_id']" position="after">
|
||||
<field name="sub_project" widget="many2many_tags"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="project.open_view_project_all" model="ir.actions.act_window">
|
||||
<field name="name">Projects</field>
|
||||
<field name="res_model">project.project</field>
|
||||
|
@ -114,6 +189,7 @@
|
|||
<field name="view_mode">tree,kanban,form</field>
|
||||
<field name="view_id" ref="project.view_project"/>
|
||||
<field name="search_view_id" ref="project.view_project_project_filter"/>
|
||||
<field name="domain">[('is_sub_project', '=', False)]</field>
|
||||
<field name="target">main</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
|
@ -134,6 +210,100 @@
|
|||
<xpath expr="//field[@name='partner_id']" position="replace">
|
||||
<field name="partner_id" class="o_task_customer_field" string="Client"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='date_deadline']" position="before">
|
||||
<field name="start_date"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="inherit_view_task_tree2" model="ir.ui.view">
|
||||
<field name="name">project.task.tree.inherit</field>
|
||||
<field name="model">project.task</field>
|
||||
<field name="inherit_id" ref="project.view_task_tree2"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='date_deadline']" position="attributes">
|
||||
<attribute name="invisible">1</attribute>
|
||||
</xpath>
|
||||
<field name="date_deadline" optional="hide" widget="remaining_days"
|
||||
attrs="{'invisible': [('is_closed', '=', True)]}"/>
|
||||
<xpath expr="//field[@name='name']" position="before">
|
||||
<field name="date_deadline"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='effective_hours']" position="before">
|
||||
<field name="start_date"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='effective_hours']" position="before">
|
||||
<field name="planned_hours"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='effective_hours']" position="after">
|
||||
<field name="remaining_hours"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Done Task action -->
|
||||
<record id="project_task_server_action_batch_done" model="ir.actions.server">
|
||||
<field name="name">Done</field>
|
||||
<field name="model_id" ref="project.model_project_task"/>
|
||||
<field name="binding_model_id" ref="project.model_project_task"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
if records:
|
||||
action = records.action_done_task()
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tags -->
|
||||
<record model="ir.ui.view" id="custom_project_tags_search_view">
|
||||
<field name="name">Tags</field>
|
||||
<field name="model">custom.project.tags</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Tags">
|
||||
<field name="name"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="custom_project_tags_form_view">
|
||||
<field name="name">Tags</field>
|
||||
<field name="model">custom.project.tags</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Tags">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="custom_project_tags_tree_view">
|
||||
<field name="name">Tags</field>
|
||||
<field name="model">custom.project.tags</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Tags" editable="bottom" sample="1">
|
||||
<field name="name"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="custom_project_tags_action" model="ir.actions.act_window">
|
||||
<field name="name">Project Tags</field>
|
||||
<field name="res_model">custom.project.tags</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No tags found. Let's create one!
|
||||
</p>
|
||||
<p>
|
||||
Tags are perfect to categorize your tasks.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
<menuitem action="custom_project_tags_action" id="menu_custom_project_tags_act"
|
||||
parent="project.menu_project_config" sequence="9"/>
|
||||
|
||||
<menuitem name="Tasks" id="menu_main_cor_tasks"
|
||||
action="project.action_view_all_task" sequence="52"
|
||||
groups="base.group_no_one,project.group_project_user"/>
|
||||
</odoo>
|
||||
|
|
|
@ -3,3 +3,4 @@
|
|||
|
||||
from . import project_create_sale_order
|
||||
from . import crm_opportunity_to_quotation
|
||||
from . import project_multi_budget_assign
|
||||
|
|
|
@ -0,0 +1,138 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
class ProjectMultiBudgetAssign(models.TransientModel):
|
||||
_name = 'project.multi.budget.assign'
|
||||
_description = 'Project multi budget assign'
|
||||
|
||||
project_id = fields.Many2one('project.project', string="Project", required=True)
|
||||
project_start_date = fields.Date('Project Start Date', related='project_id.date_start')
|
||||
project_end_date = fields.Date('Project End Date', related='project_id.date')
|
||||
start_date = fields.Date('Start Date', required=True, default=fields.Date.context_today)
|
||||
end_date = fields.Date('End Date', required=True)
|
||||
project_multi_line = fields.One2many('project.multi.budget.assign.line', 'line_id', 'Consultant Details')
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super(ProjectMultiBudgetAssign, self).default_get(fields)
|
||||
res_id = self._context.get('active_id')
|
||||
record = self.env['project.project'].browse(res_id)
|
||||
lines = []
|
||||
for rec in record.sale_line_employee_ids:
|
||||
cons = {}
|
||||
all_cons = self.env['project.consultant.hrs'].search([('employee_id','=',rec.employee_id.id),('project_id','=',rec.project_id.id)])
|
||||
all_per = [v.percentage for v in all_cons]
|
||||
if sum(all_per) < 100:
|
||||
cons.update({'employee_id': rec.employee_id.id, 'emp_map_id':rec.id, 'project_id':res_id})
|
||||
lines.append((0, 0, cons))
|
||||
if record and (not record.pricing_type == 'employee_rate' or not record.project_type == 'hours_in_consultant'):
|
||||
raise ValidationError(_('Not applicable for this project type '))
|
||||
if not lines:
|
||||
raise ValidationError(_('Not applicable, Please assign or check consultant'))
|
||||
if record and not record.date_start or not record.date:
|
||||
raise UserError(_('Project start date and end date should not be blank'))
|
||||
res.update({'project_id':res_id, 'project_multi_line': lines})
|
||||
return res
|
||||
|
||||
@api.constrains('start_date', 'end_date')
|
||||
def _check_dates(self):
|
||||
for rec in self:
|
||||
if rec.end_date < rec.start_date:
|
||||
raise ValidationError(_('Start date must be earlier than end date.'))
|
||||
|
||||
@api.onchange('start_date')
|
||||
def onchange_start_date(self):
|
||||
if self.start_date:
|
||||
self.end_date = self.start_date + relativedelta(days=6)
|
||||
|
||||
def action_create_budgeted_hrs(self):
|
||||
record = self.env['project.consultant.hrs']
|
||||
res_id = self._context.get('active_id')
|
||||
for val in self:
|
||||
for line in val.project_multi_line:
|
||||
record.create({'project_id':res_id,
|
||||
'employee_id':line.employee_id.id,
|
||||
'start_date':val.start_date,
|
||||
'end_date':val.end_date,
|
||||
'budgeted_hours':line.budgeted_hours,
|
||||
'percentage':line.percentage})
|
||||
return True
|
||||
|
||||
|
||||
class ProjectMultiBudgetAssignLine(models.TransientModel):
|
||||
_name = 'project.multi.budget.assign.line'
|
||||
_description = 'Project multi budget assign line'
|
||||
|
||||
"""domain = lambda self: self._get_employee_id_domain(),
|
||||
@api.model
|
||||
def _get_employee_id_domain(self):
|
||||
project_id = self._context.get('active_id')
|
||||
record = self.env['project.project'].browse(project_id)
|
||||
emp_ids = [emp.employee_id.id for emp in record.sale_line_employee_ids]
|
||||
return [('id','in', emp_ids)]"""
|
||||
|
||||
line_id = fields.Many2one('project.multi.budget.assign', string="Line ID", required=True)
|
||||
project_id = fields.Many2one('project.project', string="Project", required=True)
|
||||
emp_map_id = fields.Many2one('project.sale.line.employee.map', string="Consultant Map", required=True)
|
||||
employee_id = fields.Many2one('hr.employee', string="Consultant", required=True)
|
||||
budgeted_qty = fields.Float(string='Budgeted Hours', related="emp_map_id.budgeted_qty")
|
||||
all_percentage = fields.Float("Total Allocated %", compute='_compute_allocation')
|
||||
rem_percentage = fields.Float("Total Unallocated %", compute='_compute_allocation')
|
||||
percentage = fields.Float("Percentage %")
|
||||
remaining = fields.Float("Remaining Percentage %")
|
||||
budgeted_hours = fields.Float("Budgeted Hours for period", compute='_compute_budgeted_hours', store=True)
|
||||
|
||||
@api.onchange('percentage')
|
||||
def onchange_employee_id(self):
|
||||
res = self.rem_percentage - self.percentage
|
||||
if res < 0:
|
||||
raise ValidationError(_('Remaining percentage should not be negative'))
|
||||
self.remaining = res
|
||||
|
||||
@api.depends('project_id', 'employee_id')
|
||||
def _compute_allocation(self):
|
||||
for val in self:
|
||||
if val.project_id and val.employee_id:
|
||||
cons = self.env['project.consultant.hrs'].search([('employee_id','=',val.employee_id.id),('project_id','=',val.project_id.id)])
|
||||
all_per = 0
|
||||
for res in cons:
|
||||
all_per += res.percentage
|
||||
#res = all_per - 100
|
||||
val.all_percentage = all_per
|
||||
val.rem_percentage = 100 - all_per
|
||||
val.remaining = 100 - all_per
|
||||
else:
|
||||
val.all_percentage = 0
|
||||
val.rem_percentage = 0
|
||||
val.remaining = 0
|
||||
|
||||
@api.depends('project_id', 'employee_id', 'project_id.sale_line_employee_ids', 'percentage')
|
||||
def _compute_budgeted_hours(self):
|
||||
for val in self:
|
||||
if val.project_id and val.employee_id and val.percentage > 0:
|
||||
budgeted = 0
|
||||
for emp in val.project_id.sale_line_employee_ids:
|
||||
if emp.employee_id.id == val.employee_id.id:
|
||||
budgeted = emp.budgeted_qty
|
||||
val.budgeted_hours = (budgeted * val.percentage / 100)
|
||||
else:
|
||||
val.budgeted_hours = 0
|
||||
|
||||
"""@api.onchange('employee_id', 'percentage')
|
||||
def onchange_employee_id(self):
|
||||
#res = {}
|
||||
project_id = self._context.get('active_id')
|
||||
record = self.env['project.project'].browse(project_id)
|
||||
#emp_ids = [emp.employee_id.id for emp in record.sale_line_employee_ids]
|
||||
#res['domain'] = {'employee_id': [('id', 'in', emp_ids)]}
|
||||
if self.employee_id and self.percentage > 0:
|
||||
print("self.employee_id", self.employee_id)
|
||||
cons = [emp.budgeted_qty if emp.employee_id.id == self.employee_id.id else 0 for emp in record.sale_line_employee_ids]
|
||||
budgeted = cons and cons[0] or 0
|
||||
if budgeted > 0:
|
||||
self.budgeted_hours = (budgeted * self.percentage / 100)
|
||||
else:
|
||||
self.budgeted_hours = 0"""
|
||||
#return res
|
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="project_multi_budget_assign_view" model="ir.ui.view">
|
||||
<field name="name">Project Multi Budget</field>
|
||||
<field name="model">project.multi.budget.assign</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Project Budget Assign">
|
||||
<group col="4" colspan="2">
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="project_id" readonly="1" force_save="1"/>
|
||||
<field name="project_start_date"/>
|
||||
<field name="project_end_date"/>
|
||||
<field name="project_multi_line" nolabel="1" colspan="4">
|
||||
<tree editable="top" create="false">
|
||||
<field name="project_id" invisible="1"/>
|
||||
<field name="emp_map_id" invisible="1"/>
|
||||
<field name="employee_id" readonly="1" force_save="1" options="{'no_create_edit': True, 'no_quick_create': True}"/>
|
||||
<field name="budgeted_qty"/>
|
||||
<field name="all_percentage"/>
|
||||
<field name="rem_percentage"/>
|
||||
<field name="percentage"/>
|
||||
<field name="budgeted_hours"/>
|
||||
<field name="remaining" readonly="1" force_save="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Confirm" name="action_create_budgeted_hrs" type="object" default_focus="1"
|
||||
class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_multi_budget_assign" model="ir.actions.act_window">
|
||||
<field name="name">Consultant Budget Hours Assign</field>
|
||||
<field name="res_model">project.multi.budget.assign</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="project_multi_budget_assign_view"/>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="project.model_project_project"/>
|
||||
<field name="binding_view_types">form</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
|
@ -0,0 +1,109 @@
|
|||
====================
|
||||
Mail Outbound Static
|
||||
====================
|
||||
|
||||
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
|
||||
:target: https://odoo-community.org/page/development-status
|
||||
:alt: Beta
|
||||
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
|
||||
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
|
||||
:alt: License: LGPL-3
|
||||
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github
|
||||
:target: https://github.com/OCA/social/tree/13.0/mail_outbound_static
|
||||
:alt: OCA/social
|
||||
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
|
||||
:target: https://translation.odoo-community.org/projects/social-13-0/social-13-0-mail_outbound_static
|
||||
:alt: Translate me on Weblate
|
||||
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
|
||||
:target: https://runbot.odoo-community.org/runbot/205/13.0
|
||||
:alt: Try me on Runbot
|
||||
|
||||
|badge1| |badge2| |badge3| |badge4| |badge5|
|
||||
|
||||
This module brings Odoo outbound emails in to strict compliance with RFC-2822
|
||||
by allowing for a dynamically configured From header, with the sender's e-mail
|
||||
being appended into the proper Sender header instead. To accomplish this we:
|
||||
|
||||
* Add a domain whitelist field in the mail server model. This one represent an
|
||||
allowed Domains list separated by commas. If there is not given SMTP server
|
||||
it will let us to search the proper mail server to be used to send the messages
|
||||
where the message 'From' email domain match with the domain whitelist. If
|
||||
there is not mail server that matches then will use the default mail server to
|
||||
send the message.
|
||||
|
||||
* Add a Email From field that will let us to email from a specific address taking
|
||||
into account this conditions:
|
||||
|
||||
1) If the sender domain match with the domain whitelist then the original
|
||||
message's 'From' will remain as it is and will not be changed because the
|
||||
mail server is able to send in the name of the sender domain.
|
||||
|
||||
2) If the original message's 'From' does not match with the domain whitelist
|
||||
then the email From is replaced with the Email From field value.
|
||||
|
||||
* Add compatibility to define the smtp information in Odoo config file. Both
|
||||
smtp_from and smtp_whitelist_domain values will be used if there is not mail
|
||||
server configured in the system.
|
||||
|
||||
**Table of contents**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
* Navigate to an Outbound Email Server
|
||||
* Set the `Email From` option to an email address
|
||||
* Set the `Domain Whitelist` option with the domain whitelist
|
||||
|
||||
Bug Tracker
|
||||
===========
|
||||
|
||||
Bugs are tracked on `GitHub Issues <https://github.com/OCA/social/issues>`_.
|
||||
In case of trouble, please check there if your issue has already been reported.
|
||||
If you spotted it first, help us smashing it by providing a detailed and welcomed
|
||||
`feedback <https://github.com/OCA/social/issues/new?body=module:%20mail_outbound_static%0Aversion:%2013.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
|
||||
|
||||
Do not contact contributors directly about support or help with technical issues.
|
||||
|
||||
Credits
|
||||
=======
|
||||
|
||||
Authors
|
||||
~~~~~~~
|
||||
|
||||
* brain-tec AG
|
||||
* LasLabs
|
||||
* Adhoc SA
|
||||
|
||||
Contributors
|
||||
~~~~~~~~~~~~
|
||||
|
||||
* Frédéric Garbely <frederic.garbely@braintec-group.com>
|
||||
* Dave Lasley <dave@laslabs.com>
|
||||
* Lorenzo Battistini <https://github.com/eLBati>
|
||||
* Katherine Zaoral <kz@adhoc.com.ar>
|
||||
* Juan José Scarafía <jjs@adhoc.com.ar>
|
||||
|
||||
Maintainers
|
||||
~~~~~~~~~~~
|
||||
|
||||
This module is maintained by the OCA.
|
||||
|
||||
.. image:: https://odoo-community.org/logo.png
|
||||
:alt: Odoo Community Association
|
||||
:target: https://odoo-community.org
|
||||
|
||||
OCA, or the Odoo Community Association, is a nonprofit organization whose
|
||||
mission is to support the collaborative development of Odoo features and
|
||||
promote its widespread use.
|
||||
|
||||
This module is part of the `OCA/social <https://github.com/OCA/social/tree/13.0/mail_outbound_static>`_ project on GitHub.
|
||||
|
||||
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
|
|
@ -0,0 +1,4 @@
|
|||
# Copyright 2017 LasLabs Inc.
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||
|
||||
from . import models
|
|
@ -0,0 +1,16 @@
|
|||
# Copyright 2016-2017 LasLabs Inc.
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||
|
||||
{
|
||||
"name": "Mail Outbound Static",
|
||||
"summary": "Allows you to configure the from header for a mail server.",
|
||||
"version": "13.0.2.0.0",
|
||||
"category": "Discuss",
|
||||
"website": "https://github.com/OCA/social",
|
||||
"author": "brain-tec AG, LasLabs, Adhoc SA, Odoo Community Association (OCA)",
|
||||
"license": "LGPL-3",
|
||||
"application": False,
|
||||
"installable": True,
|
||||
"depends": ["base"],
|
||||
"data": ["views/ir_mail_server_view.xml"],
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * mail_outbound_static
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 13.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2020-09-03 12:53+0000\n"
|
||||
"PO-Revision-Date: 2020-09-03 12:53+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: code:addons/mail_outbound_static/models/ir_mail_server.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"%s is not a valid domain. Please define a list of valid domains separated by "
|
||||
"comma"
|
||||
msgstr ""
|
||||
"%s no es un dominio válido. Por favor defina una lista de dominios validos "
|
||||
"separados por comas"
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: model:ir.model.fields,help:mail_outbound_static.field_ir_mail_server__domain_whitelist
|
||||
msgid ""
|
||||
"Allowed Domains list separated by commas. If there is not given SMTP server "
|
||||
"it will let us to search the proper mail server to be used to sent the "
|
||||
"messages where the message 'From' email domain match with the domain "
|
||||
"whitelist."
|
||||
msgstr ""
|
||||
"Lista de dominios permitidos separados por comas. Si no se ha seleccionado "
|
||||
"un servidor SMTP nos permitirá seleccionar el servidor de mail apropiado "
|
||||
"para enviar los mensajes donde el dominio del email del 'De' coincida con la "
|
||||
"lista blanca de dominios."
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: model:ir.model.fields,field_description:mail_outbound_static.field_ir_mail_server__domain_whitelist
|
||||
msgid "Domain Whitelist"
|
||||
msgstr "Lista blanca de dominios"
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: model:ir.model.fields,field_description:mail_outbound_static.field_ir_mail_server__smtp_from
|
||||
msgid "Email From"
|
||||
msgstr "Email De"
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: model:ir.model,name:mail_outbound_static.model_ir_mail_server
|
||||
msgid "Mail Server"
|
||||
msgstr "Servidor de correo"
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: code:addons/mail_outbound_static/models/ir_mail_server.py:0
|
||||
#, python-format
|
||||
msgid "Not a valid Email From"
|
||||
msgstr "No es un Email De válido"
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: model:ir.model.fields,help:mail_outbound_static.field_ir_mail_server__smtp_from
|
||||
msgid ""
|
||||
"Set this in order to email from a specific address. If the original "
|
||||
"message's 'From' does not match with the domain whitelist then it is "
|
||||
"replaced with this value. If does match with the domain whitelist then the "
|
||||
"original message's 'From' will not change"
|
||||
msgstr ""
|
||||
"Definalo para usar un dirección de correo 'De' especifica. Si el 'De' del "
|
||||
"mensaje original no coincide con la lista blanca de dominios entonces este "
|
||||
"será remplazado con este valor. Si coincide con la lista blanca de dominios "
|
||||
"entonces el 'De' del mensajee original no cambiará"
|
|
@ -0,0 +1,61 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * mail_outbound_static
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 13.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: code:addons/mail_outbound_static/models/ir_mail_server.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"%s is not a valid domain. Please define a list of valid domains separated by"
|
||||
" comma"
|
||||
msgstr ""
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: model:ir.model.fields,help:mail_outbound_static.field_ir_mail_server__domain_whitelist
|
||||
msgid ""
|
||||
"Allowed Domains list separated by commas. If there is not given SMTP server "
|
||||
"it will let us to search the proper mail server to be used to sent the "
|
||||
"messages where the message 'From' email domain match with the domain "
|
||||
"whitelist."
|
||||
msgstr ""
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: model:ir.model.fields,field_description:mail_outbound_static.field_ir_mail_server__domain_whitelist
|
||||
msgid "Domain Whitelist"
|
||||
msgstr ""
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: model:ir.model.fields,field_description:mail_outbound_static.field_ir_mail_server__smtp_from
|
||||
msgid "Email From"
|
||||
msgstr ""
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: model:ir.model,name:mail_outbound_static.model_ir_mail_server
|
||||
msgid "Mail Server"
|
||||
msgstr ""
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: code:addons/mail_outbound_static/models/ir_mail_server.py:0
|
||||
#, python-format
|
||||
msgid "Not a valid Email From"
|
||||
msgstr ""
|
||||
|
||||
#. module: mail_outbound_static
|
||||
#: model:ir.model.fields,help:mail_outbound_static.field_ir_mail_server__smtp_from
|
||||
msgid ""
|
||||
"Set this in order to email from a specific address. If the original "
|
||||
"message's 'From' does not match with the domain whitelist then it is "
|
||||
"replaced with this value. If does match with the domain whitelist then the "
|
||||
"original message's 'From' will not change"
|
||||
msgstr ""
|
|
@ -0,0 +1,4 @@
|
|||
# Copyright 2017 LasLabs Inc.
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||
|
||||
from . import ir_mail_server
|
|
@ -0,0 +1,146 @@
|
|||
# Copyright 2017 LasLabs Inc.
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||
|
||||
import re
|
||||
from email.utils import formataddr, parseaddr
|
||||
|
||||
from odoo import _, api, fields, models, tools
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class IrMailServer(models.Model):
|
||||
|
||||
_inherit = "ir.mail_server"
|
||||
|
||||
smtp_from = fields.Char(
|
||||
string="Email From",
|
||||
help="Set this in order to email from a specific address."
|
||||
" If the original message's 'From' does not match with the domain"
|
||||
" whitelist then it is replaced with this value. If does match with the"
|
||||
" domain whitelist then the original message's 'From' will not change",
|
||||
)
|
||||
domain_whitelist = fields.Char(
|
||||
help="Allowed Domains list separated by commas. If there is not given"
|
||||
" SMTP server it will let us to search the proper mail server to be"
|
||||
" used to sent the messages where the message 'From' email domain"
|
||||
" match with the domain whitelist."
|
||||
)
|
||||
|
||||
@api.constrains("domain_whitelist")
|
||||
def check_valid_domain_whitelist(self):
|
||||
if self.domain_whitelist:
|
||||
domains = list(self.domain_whitelist.split(","))
|
||||
for domain in domains:
|
||||
if not self._is_valid_domain(domain):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"%s is not a valid domain. Please define a list of"
|
||||
" valid domains separated by comma"
|
||||
)
|
||||
% (domain)
|
||||
)
|
||||
|
||||
@api.constrains("smtp_from")
|
||||
def check_valid_smtp_from(self):
|
||||
if self.smtp_from:
|
||||
match = re.match(
|
||||
r"^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\."
|
||||
r"[a-z]{2,4})$",
|
||||
self.smtp_from,
|
||||
)
|
||||
if match is None:
|
||||
raise ValidationError(_("Not a valid Email From"))
|
||||
|
||||
def _is_valid_domain(self, domain_name):
|
||||
domain_regex = (
|
||||
r"(([\da-zA-Z])([_\w-]{,62})\.){,127}(([\da-zA-Z])"
|
||||
r"[_\w-]{,61})?([\da-zA-Z]\.((xn\-\-[a-zA-Z\d]+)|([a-zA-Z\d]{2,})))"
|
||||
)
|
||||
domain_regex = "{}$".format(domain_regex)
|
||||
valid_domain_name_regex = re.compile(domain_regex, re.IGNORECASE)
|
||||
domain_name = domain_name.lower().strip()
|
||||
return True if re.match(valid_domain_name_regex, domain_name) else False
|
||||
|
||||
@api.model
|
||||
def _get_domain_whitelist(self, domain_whitelist_string):
|
||||
res = domain_whitelist_string.split(",") if domain_whitelist_string else []
|
||||
res = [item.strip() for item in res]
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def send_email(
|
||||
self, message, mail_server_id=None, smtp_server=None, *args, **kwargs
|
||||
):
|
||||
# Get email_from and name_from
|
||||
if message["From"].count("<") > 1:
|
||||
split_from = message["From"].rsplit(" <", 1)
|
||||
name_from = split_from[0]
|
||||
email_from = split_from[-1].replace(">", "")
|
||||
else:
|
||||
name_from, email_from = parseaddr(message["From"])
|
||||
|
||||
email_domain = email_from.split("@")[1]
|
||||
|
||||
# Replicate logic from core to get mail server
|
||||
# Get proper mail server to use
|
||||
if not smtp_server and not mail_server_id:
|
||||
mail_server_id = self._get_mail_sever(email_domain)
|
||||
|
||||
# If not mail sever defined use smtp_from defined in odoo config
|
||||
if mail_server_id:
|
||||
mail_server = self.sudo().browse(mail_server_id)
|
||||
domain_whitelist = mail_server.domain_whitelist
|
||||
smtp_from = mail_server.smtp_from
|
||||
else:
|
||||
domain_whitelist = tools.config.get("smtp_domain_whitelist")
|
||||
smtp_from = tools.config.get("smtp_from")
|
||||
|
||||
domain_whitelist = self._get_domain_whitelist(domain_whitelist)
|
||||
|
||||
# Replace the From only if needed
|
||||
if smtp_from and (not domain_whitelist or email_domain not in domain_whitelist):
|
||||
email_from = formataddr((name_from, smtp_from))
|
||||
message.replace_header("From", email_from)
|
||||
bounce_alias = (
|
||||
self.env["ir.config_parameter"].sudo().get_param("mail.bounce.alias")
|
||||
)
|
||||
if not bounce_alias:
|
||||
# then, bounce handling is disabled and we want
|
||||
# Return-Path = From
|
||||
if "Return-Path" in message:
|
||||
message.replace_header("Return-Path", email_from)
|
||||
else:
|
||||
message.add_header("Return-Path", email_from)
|
||||
|
||||
return super(IrMailServer, self).send_email(
|
||||
message, mail_server_id, smtp_server, *args, **kwargs
|
||||
)
|
||||
|
||||
@tools.ormcache("email_domain")
|
||||
def _get_mail_sever(self, email_domain):
|
||||
""" return the mail server id that match with the domain_whitelist
|
||||
If not match then return the default mail server id available one """
|
||||
mail_server_id = None
|
||||
for item in self.sudo().search(
|
||||
[("domain_whitelist", "!=", False)], order="sequence"
|
||||
):
|
||||
domain_whitelist = self._get_domain_whitelist(item.domain_whitelist)
|
||||
if email_domain in domain_whitelist:
|
||||
mail_server_id = item.id
|
||||
break
|
||||
if not mail_server_id:
|
||||
mail_server_id = self.sudo().search([], order="sequence", limit=1).id
|
||||
return mail_server_id
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
self.clear_caches()
|
||||
return super().create(values)
|
||||
|
||||
def write(self, values):
|
||||
self.clear_caches()
|
||||
return super().write(values)
|
||||
|
||||
def unlink(self):
|
||||
self.clear_caches()
|
||||
return super().unlink()
|
|
@ -0,0 +1,5 @@
|
|||
* Frédéric Garbely <frederic.garbely@braintec-group.com>
|
||||
* Dave Lasley <dave@laslabs.com>
|
||||
* Lorenzo Battistini <https://github.com/eLBati>
|
||||
* Katherine Zaoral <kz@adhoc.com.ar>
|
||||
* Juan José Scarafía <jjs@adhoc.com.ar>
|
|
@ -0,0 +1,24 @@
|
|||
This module brings Odoo outbound emails in to strict compliance with RFC-2822
|
||||
by allowing for a dynamically configured From header, with the sender's e-mail
|
||||
being appended into the proper Sender header instead. To accomplish this we:
|
||||
|
||||
* Add a domain whitelist field in the mail server model. This one represent an
|
||||
allowed Domains list separated by commas. If there is not given SMTP server
|
||||
it will let us to search the proper mail server to be used to send the messages
|
||||
where the message 'From' email domain match with the domain whitelist. If
|
||||
there is not mail server that matches then will use the default mail server to
|
||||
send the message.
|
||||
|
||||
* Add a Email From field that will let us to email from a specific address taking
|
||||
into account this conditions:
|
||||
|
||||
1) If the sender domain match with the domain whitelist then the original
|
||||
message's 'From' will remain as it is and will not be changed because the
|
||||
mail server is able to send in the name of the sender domain.
|
||||
|
||||
2) If the original message's 'From' does not match with the domain whitelist
|
||||
then the email From is replaced with the Email From field value.
|
||||
|
||||
* Add compatibility to define the smtp information in Odoo config file. Both
|
||||
smtp_from and smtp_whitelist_domain values will be used if there is not mail
|
||||
server configured in the system.
|
|
@ -0,0 +1,3 @@
|
|||
* Navigate to an Outbound Email Server
|
||||
* Set the `Email From` option to an email address
|
||||
* Set the `Domain Whitelist` option with the domain whitelist
|
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
|
@ -0,0 +1,456 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
|
||||
<title>Mail Outbound Static</title>
|
||||
<style type="text/css">
|
||||
|
||||
/*
|
||||
:Author: David Goodger (goodger@python.org)
|
||||
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
|
||||
:Copyright: This stylesheet has been placed in the public domain.
|
||||
|
||||
Default cascading style sheet for the HTML output of Docutils.
|
||||
|
||||
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
|
||||
customize this style sheet.
|
||||
*/
|
||||
|
||||
/* used to remove borders from tables and images */
|
||||
.borderless, table.borderless td, table.borderless th {
|
||||
border: 0 }
|
||||
|
||||
table.borderless td, table.borderless th {
|
||||
/* Override padding for "table.docutils td" with "! important".
|
||||
The right padding separates the table cells. */
|
||||
padding: 0 0.5em 0 0 ! important }
|
||||
|
||||
.first {
|
||||
/* Override more specific margin styles with "! important". */
|
||||
margin-top: 0 ! important }
|
||||
|
||||
.last, .with-subtitle {
|
||||
margin-bottom: 0 ! important }
|
||||
|
||||
.hidden {
|
||||
display: none }
|
||||
|
||||
.subscript {
|
||||
vertical-align: sub;
|
||||
font-size: smaller }
|
||||
|
||||
.superscript {
|
||||
vertical-align: super;
|
||||
font-size: smaller }
|
||||
|
||||
a.toc-backref {
|
||||
text-decoration: none ;
|
||||
color: black }
|
||||
|
||||
blockquote.epigraph {
|
||||
margin: 2em 5em ; }
|
||||
|
||||
dl.docutils dd {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Uncomment (and remove this text!) to get bold-faced definition list terms
|
||||
dl.docutils dt {
|
||||
font-weight: bold }
|
||||
*/
|
||||
|
||||
div.abstract {
|
||||
margin: 2em 5em }
|
||||
|
||||
div.abstract p.topic-title {
|
||||
font-weight: bold ;
|
||||
text-align: center }
|
||||
|
||||
div.admonition, div.attention, div.caution, div.danger, div.error,
|
||||
div.hint, div.important, div.note, div.tip, div.warning {
|
||||
margin: 2em ;
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.admonition p.admonition-title, div.hint p.admonition-title,
|
||||
div.important p.admonition-title, div.note p.admonition-title,
|
||||
div.tip p.admonition-title {
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
div.attention p.admonition-title, div.caution p.admonition-title,
|
||||
div.danger p.admonition-title, div.error p.admonition-title,
|
||||
div.warning p.admonition-title, .code .error {
|
||||
color: red ;
|
||||
font-weight: bold ;
|
||||
font-family: sans-serif }
|
||||
|
||||
/* Uncomment (and remove this text!) to get reduced vertical space in
|
||||
compound paragraphs.
|
||||
div.compound .compound-first, div.compound .compound-middle {
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
div.compound .compound-last, div.compound .compound-middle {
|
||||
margin-top: 0.5em }
|
||||
*/
|
||||
|
||||
div.dedication {
|
||||
margin: 2em 5em ;
|
||||
text-align: center ;
|
||||
font-style: italic }
|
||||
|
||||
div.dedication p.topic-title {
|
||||
font-weight: bold ;
|
||||
font-style: normal }
|
||||
|
||||
div.figure {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
div.footer, div.header {
|
||||
clear: both;
|
||||
font-size: smaller }
|
||||
|
||||
div.line-block {
|
||||
display: block ;
|
||||
margin-top: 1em ;
|
||||
margin-bottom: 1em }
|
||||
|
||||
div.line-block div.line-block {
|
||||
margin-top: 0 ;
|
||||
margin-bottom: 0 ;
|
||||
margin-left: 1.5em }
|
||||
|
||||
div.sidebar {
|
||||
margin: 0 0 0.5em 1em ;
|
||||
border: medium outset ;
|
||||
padding: 1em ;
|
||||
background-color: #ffffee ;
|
||||
width: 40% ;
|
||||
float: right ;
|
||||
clear: right }
|
||||
|
||||
div.sidebar p.rubric {
|
||||
font-family: sans-serif ;
|
||||
font-size: medium }
|
||||
|
||||
div.system-messages {
|
||||
margin: 5em }
|
||||
|
||||
div.system-messages h1 {
|
||||
color: red }
|
||||
|
||||
div.system-message {
|
||||
border: medium outset ;
|
||||
padding: 1em }
|
||||
|
||||
div.system-message p.system-message-title {
|
||||
color: red ;
|
||||
font-weight: bold }
|
||||
|
||||
div.topic {
|
||||
margin: 2em }
|
||||
|
||||
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
|
||||
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
|
||||
margin-top: 0.4em }
|
||||
|
||||
h1.title {
|
||||
text-align: center }
|
||||
|
||||
h2.subtitle {
|
||||
text-align: center }
|
||||
|
||||
hr.docutils {
|
||||
width: 75% }
|
||||
|
||||
img.align-left, .figure.align-left, object.align-left, table.align-left {
|
||||
clear: left ;
|
||||
float: left ;
|
||||
margin-right: 1em }
|
||||
|
||||
img.align-right, .figure.align-right, object.align-right, table.align-right {
|
||||
clear: right ;
|
||||
float: right ;
|
||||
margin-left: 1em }
|
||||
|
||||
img.align-center, .figure.align-center, object.align-center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
table.align-center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left }
|
||||
|
||||
.align-center {
|
||||
clear: both ;
|
||||
text-align: center }
|
||||
|
||||
.align-right {
|
||||
text-align: right }
|
||||
|
||||
/* reset inner alignment in figures */
|
||||
div.align-right {
|
||||
text-align: inherit }
|
||||
|
||||
/* div.align-center * { */
|
||||
/* text-align: left } */
|
||||
|
||||
.align-top {
|
||||
vertical-align: top }
|
||||
|
||||
.align-middle {
|
||||
vertical-align: middle }
|
||||
|
||||
.align-bottom {
|
||||
vertical-align: bottom }
|
||||
|
||||
ol.simple, ul.simple {
|
||||
margin-bottom: 1em }
|
||||
|
||||
ol.arabic {
|
||||
list-style: decimal }
|
||||
|
||||
ol.loweralpha {
|
||||
list-style: lower-alpha }
|
||||
|
||||
ol.upperalpha {
|
||||
list-style: upper-alpha }
|
||||
|
||||
ol.lowerroman {
|
||||
list-style: lower-roman }
|
||||
|
||||
ol.upperroman {
|
||||
list-style: upper-roman }
|
||||
|
||||
p.attribution {
|
||||
text-align: right ;
|
||||
margin-left: 50% }
|
||||
|
||||
p.caption {
|
||||
font-style: italic }
|
||||
|
||||
p.credits {
|
||||
font-style: italic ;
|
||||
font-size: smaller }
|
||||
|
||||
p.label {
|
||||
white-space: nowrap }
|
||||
|
||||
p.rubric {
|
||||
font-weight: bold ;
|
||||
font-size: larger ;
|
||||
color: maroon ;
|
||||
text-align: center }
|
||||
|
||||
p.sidebar-title {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold ;
|
||||
font-size: larger }
|
||||
|
||||
p.sidebar-subtitle {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
p.topic-title {
|
||||
font-weight: bold }
|
||||
|
||||
pre.address {
|
||||
margin-bottom: 0 ;
|
||||
margin-top: 0 ;
|
||||
font: inherit }
|
||||
|
||||
pre.literal-block, pre.doctest-block, pre.math, pre.code {
|
||||
margin-left: 2em ;
|
||||
margin-right: 2em }
|
||||
|
||||
pre.code .ln { color: grey; } /* line numbers */
|
||||
pre.code, code { background-color: #eeeeee }
|
||||
pre.code .comment, code .comment { color: #5C6576 }
|
||||
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
|
||||
pre.code .literal.string, code .literal.string { color: #0C5404 }
|
||||
pre.code .name.builtin, code .name.builtin { color: #352B84 }
|
||||
pre.code .deleted, code .deleted { background-color: #DEB0A1}
|
||||
pre.code .inserted, code .inserted { background-color: #A3D289}
|
||||
|
||||
span.classifier {
|
||||
font-family: sans-serif ;
|
||||
font-style: oblique }
|
||||
|
||||
span.classifier-delimiter {
|
||||
font-family: sans-serif ;
|
||||
font-weight: bold }
|
||||
|
||||
span.interpreted {
|
||||
font-family: sans-serif }
|
||||
|
||||
span.option {
|
||||
white-space: nowrap }
|
||||
|
||||
span.pre {
|
||||
white-space: pre }
|
||||
|
||||
span.problematic {
|
||||
color: red }
|
||||
|
||||
span.section-subtitle {
|
||||
/* font-size relative to parent (h1..h6 element) */
|
||||
font-size: 80% }
|
||||
|
||||
table.citation {
|
||||
border-left: solid 1px gray;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docinfo {
|
||||
margin: 2em 4em }
|
||||
|
||||
table.docutils {
|
||||
margin-top: 0.5em ;
|
||||
margin-bottom: 0.5em }
|
||||
|
||||
table.footnote {
|
||||
border-left: solid 1px black;
|
||||
margin-left: 1px }
|
||||
|
||||
table.docutils td, table.docutils th,
|
||||
table.docinfo td, table.docinfo th {
|
||||
padding-left: 0.5em ;
|
||||
padding-right: 0.5em ;
|
||||
vertical-align: top }
|
||||
|
||||
table.docutils th.field-name, table.docinfo th.docinfo-name {
|
||||
font-weight: bold ;
|
||||
text-align: left ;
|
||||
white-space: nowrap ;
|
||||
padding-left: 0 }
|
||||
|
||||
/* "booktabs" style (no vertical lines) */
|
||||
table.docutils.booktabs {
|
||||
border: 0px;
|
||||
border-top: 2px solid;
|
||||
border-bottom: 2px solid;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.docutils.booktabs * {
|
||||
border: 0px;
|
||||
}
|
||||
table.docutils.booktabs th {
|
||||
border-bottom: thin solid;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
|
||||
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
|
||||
font-size: 100% }
|
||||
|
||||
ul.auto-toc {
|
||||
list-style-type: none }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="document" id="mail-outbound-static">
|
||||
<h1 class="title">Mail Outbound Static</h1>
|
||||
|
||||
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
|
||||
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/lgpl-3.0-standalone.html"><img alt="License: LGPL-3" src="https://img.shields.io/badge/licence-LGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/social/tree/13.0/mail_outbound_static"><img alt="OCA/social" src="https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/social-13-0/social-13-0-mail_outbound_static"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/205/13.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
|
||||
<p>This module brings Odoo outbound emails in to strict compliance with RFC-2822
|
||||
by allowing for a dynamically configured From header, with the sender’s e-mail
|
||||
being appended into the proper Sender header instead. To accomplish this we:</p>
|
||||
<ul class="simple">
|
||||
<li>Add a domain whitelist field in the mail server model. This one represent an
|
||||
allowed Domains list separated by commas. If there is not given SMTP server
|
||||
it will let us to search the proper mail server to be used to send the messages
|
||||
where the message ‘From’ email domain match with the domain whitelist. If
|
||||
there is not mail server that matches then will use the default mail server to
|
||||
send the message.</li>
|
||||
<li>Add a Email From field that will let us to email from a specific address taking
|
||||
into account this conditions:<ol class="arabic">
|
||||
<li>If the sender domain match with the domain whitelist then the original
|
||||
message’s ‘From’ will remain as it is and will not be changed because the
|
||||
mail server is able to send in the name of the sender domain.</li>
|
||||
<li>If the original message’s ‘From’ does not match with the domain whitelist
|
||||
then the email From is replaced with the Email From field value.</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>Add compatibility to define the smtp information in Odoo config file. Both
|
||||
smtp_from and smtp_whitelist_domain values will be used if there is not mail
|
||||
server configured in the system.</li>
|
||||
</ul>
|
||||
<p><strong>Table of contents</strong></p>
|
||||
<div class="contents local topic" id="contents">
|
||||
<ul class="simple">
|
||||
<li><a class="reference internal" href="#usage" id="id1">Usage</a></li>
|
||||
<li><a class="reference internal" href="#bug-tracker" id="id2">Bug Tracker</a></li>
|
||||
<li><a class="reference internal" href="#credits" id="id3">Credits</a><ul>
|
||||
<li><a class="reference internal" href="#authors" id="id4">Authors</a></li>
|
||||
<li><a class="reference internal" href="#contributors" id="id5">Contributors</a></li>
|
||||
<li><a class="reference internal" href="#maintainers" id="id6">Maintainers</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="usage">
|
||||
<h1><a class="toc-backref" href="#id1">Usage</a></h1>
|
||||
<ul class="simple">
|
||||
<li>Navigate to an Outbound Email Server</li>
|
||||
<li>Set the <cite>Email From</cite> option to an email address</li>
|
||||
<li>Set the <cite>Domain Whitelist</cite> option with the domain whitelist</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="bug-tracker">
|
||||
<h1><a class="toc-backref" href="#id2">Bug Tracker</a></h1>
|
||||
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/social/issues">GitHub Issues</a>.
|
||||
In case of trouble, please check there if your issue has already been reported.
|
||||
If you spotted it first, help us smashing it by providing a detailed and welcomed
|
||||
<a class="reference external" href="https://github.com/OCA/social/issues/new?body=module:%20mail_outbound_static%0Aversion:%2013.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
|
||||
<p>Do not contact contributors directly about support or help with technical issues.</p>
|
||||
</div>
|
||||
<div class="section" id="credits">
|
||||
<h1><a class="toc-backref" href="#id3">Credits</a></h1>
|
||||
<div class="section" id="authors">
|
||||
<h2><a class="toc-backref" href="#id4">Authors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>brain-tec AG</li>
|
||||
<li>LasLabs</li>
|
||||
<li>Adhoc SA</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="contributors">
|
||||
<h2><a class="toc-backref" href="#id5">Contributors</a></h2>
|
||||
<ul class="simple">
|
||||
<li>Frédéric Garbely <<a class="reference external" href="mailto:frederic.garbely@braintec-group.com">frederic.garbely@braintec-group.com</a>></li>
|
||||
<li>Dave Lasley <<a class="reference external" href="mailto:dave@laslabs.com">dave@laslabs.com</a>></li>
|
||||
<li>Lorenzo Battistini <<a class="reference external" href="https://github.com/eLBati">https://github.com/eLBati</a>></li>
|
||||
<li>Katherine Zaoral <<a class="reference external" href="mailto:kz@adhoc.com.ar">kz@adhoc.com.ar</a>></li>
|
||||
<li>Juan José Scarafía <<a class="reference external" href="mailto:jjs@adhoc.com.ar">jjs@adhoc.com.ar</a>></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
<h2><a class="toc-backref" href="#id6">Maintainers</a></h2>
|
||||
<p>This module is maintained by the OCA.</p>
|
||||
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
|
||||
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
|
||||
mission is to support the collaborative development of Odoo features and
|
||||
promote its widespread use.</p>
|
||||
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/social/tree/13.0/mail_outbound_static">OCA/social</a> project on GitHub.</p>
|
||||
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,4 @@
|
|||
# Copyright 2017 LasLabs Inc.
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||
|
||||
from . import test_ir_mail_server
|
|
@ -0,0 +1,70 @@
|
|||
Delivered-To: test@gmail.com
|
||||
Received: by 10.74.138.167 with SMTP id m36csp7226976ooj;
|
||||
Tue, 12 Sep 2017 10:37:56 -0700 (PDT)
|
||||
X-Google-Smtp-Source: AOwi7QDKSb3BE6lIhVXub9wcPA/HxFKKpnNPconNr9f1L35SVw+EIm8itVQkbOdAW6TohImypmrF
|
||||
X-Received: by 10.28.158.208 with SMTP id h199mr250060wme.47.1505237876258;
|
||||
Tue, 12 Sep 2017 10:37:56 -0700 (PDT)
|
||||
ARC-Seal: i=1; a=rsa-sha256; t=1505237876; cv=none;
|
||||
d=google.com; s=arc-20160816;
|
||||
b=E2B6KUxHOJQk1YrT12BpitEMCgkxyqEXcFlwPWKjA/i/Xyvlh+09spNOF4VPmD/ZJm
|
||||
5lkY6hYyxvIH2RpRPeZVPkRIYhaEASkMIygdJu9Gd4weBdO2rd8iP/zSGHYyAmO/hLN2
|
||||
64hXtKexrWnO/YNWlpfhAo1kiwgSRVnZx55EopbWP49cy7BzKfwr1kHN0T9A5Lw1w+BW
|
||||
ZrXdCX6LRxHS2USKHb76PAVt0bhwsM/ZznBauR2zNKYcPxAWzdpN/vK3BDmWUdqbbSaB
|
||||
BOKINjuI9EmWynogDZE7Riu+sbc5QafE3owla1/2d0Bogp9FLtJe0YyQeW2qLvZKcmlI
|
||||
ftSQ==
|
||||
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816;
|
||||
h=to:list-archive:list-unsubscribe:list-subscribe:precedence
|
||||
:list-post:list-id:date:reply-to:from:subject:references:message-id
|
||||
:mime-version:arc-authentication-results;
|
||||
bh=DwvSiw5K7ryb4S8O/8HcIaGhJqbOxcXKsnPAr63iQZ4=;
|
||||
b=U0Ac9Rqvv+tfqO9fCx+F79oZknn3rOv9N9ekViEuL5DtjpJxKDDkO1xw//sV3eRILT
|
||||
nqGuxd2yQXwC4U+WAwraBwoLC3ScHb/9gWtzlrLCgv6WbNE7HZi5g6L8c0LWRN24cIe9
|
||||
AOdc/8fOdGoaL8yajrGEHgMz9B2KMltA9tZyxFOeKsyODxJ6iWjXcG1BSQTxERwosV3h
|
||||
ch8AznQr7xLLvc/u9VTEqC5ome3RqsxKRxOGenEqIbCOr11sxwpZQdQcNR6faNRom3+2
|
||||
6gz++4tVIV9cqYX1j9eEU/ufoUzBJ6Uzm0jMGZZQOHAF+YX3tZUEsPmc75PsvRCAIWby
|
||||
urMg==
|
||||
ARC-Authentication-Results: i=1; mx.google.com;
|
||||
spf=pass (google.com: domain of postmaster-odoo@odoo-community.org designates 2a01:4f8:a0:430d::2 as permitted sender) smtp.mailfrom=postmaster-odoo@odoo-community.org;
|
||||
dmarc=fail (p=QUARANTINE sp=QUARANTINE dis=QUARANTINE) header.from=laslabs.com
|
||||
Return-Path: <postmaster-odoo@odoo-community.org>
|
||||
Received: from odoo-community.org (odoo-community.org. [2a01:4f8:a0:430d::2])
|
||||
by mx.google.com with ESMTP id j72si1795626wmg.60.2017.09.12.10.37.55
|
||||
for <test@gmail.com>;
|
||||
Tue, 12 Sep 2017 10:37:56 -0700 (PDT)
|
||||
Received-SPF: pass (google.com: domain of postmaster-odoo@odoo-community.org designates 2a01:4f8:a0:430d::2 as permitted sender) client-ip=2a01:4f8:a0:430d::2;
|
||||
Authentication-Results: mx.google.com;
|
||||
spf=pass (google.com: domain of postmaster-odoo@odoo-community.org designates 2a01:4f8:a0:430d::2 as permitted sender) smtp.mailfrom=postmaster-odoo@odoo-community.org;
|
||||
dmarc=fail (p=QUARANTINE sp=QUARANTINE dis=QUARANTINE) header.from=laslabs.com
|
||||
Received: from odoo.odoo-community.org (localhost.localdomain [127.0.0.1])
|
||||
by odoo-community.org (Postfix) with ESMTP id DB5DC2EC2277;
|
||||
Tue, 12 Sep 2017 19:37:53 +0200 (CEST)
|
||||
Content-Type: multipart/mixed; boundary="===============7439524030966430607=="
|
||||
MIME-Version: 1.0
|
||||
Message-Id: <A1AAECD6-2A36-45E1-A0F8-4266F178D52C@laslabs.com>
|
||||
references: <0db43737-b846-4890-6801-44ff9617e3b3@camptocamp.com>
|
||||
Subject: Re: OCA Code sprint: sprint topics
|
||||
From: Dave Lasley <test@laslabs.com>
|
||||
Reply-To: "Odoo Community Association \(OCA\) Contributors"
|
||||
<contributors@odoo-community.org>
|
||||
Date: Tue, 12 Sep 2017 17:37:53 -0000
|
||||
List-Id: contributors.odoo-community.org
|
||||
List-Post: <mailto:contributors@odoo-community.org>
|
||||
Precedence: list
|
||||
X-Auto-Response-Suppress: OOF
|
||||
List-Subscribe: <https://odoo-community.org/groups>
|
||||
List-Unsubscribe: <https://odoo-community.org/groups?unsubscribe>
|
||||
List-Archive: <https://odoo-community.org/groups/contributors-15>
|
||||
To: "Contributors" <contributors@odoo-community.org>
|
||||
|
||||
--===============7439524030966430607==
|
||||
Content-Type: multipart/alternative;
|
||||
boundary="===============8317593469411551167=="
|
||||
MIME-Version: 1.0
|
||||
|
||||
--===============8317593469411551167==
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
MIME-Version: 1.0
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
VGhpcyBpcyBhIGZha2UsIHRlc3QgbWVzc2FnZQ==
|
||||
--===============7439524030966430607==--
|
|
@ -0,0 +1,329 @@
|
|||
# Copyright 2017 LasLabs Inc.
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from email import message_from_string
|
||||
|
||||
from mock import MagicMock
|
||||
|
||||
import odoo.tools as tools
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestIrMailServer(TransactionCase):
|
||||
def setUp(self):
|
||||
super(TestIrMailServer, self).setUp()
|
||||
self.email_from = "derp@example.com"
|
||||
self.email_from_another = "another@example.com"
|
||||
self.Model = self.env["ir.mail_server"]
|
||||
self.parameter_model = self.env["ir.config_parameter"]
|
||||
self._delete_mail_servers()
|
||||
self.Model.create(
|
||||
{
|
||||
"name": "localhost",
|
||||
"smtp_host": "localhost",
|
||||
"smtp_from": self.email_from,
|
||||
}
|
||||
)
|
||||
message_file = os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)), "test.msg"
|
||||
)
|
||||
with open(message_file, "r") as fh:
|
||||
self.message = message_from_string(fh.read())
|
||||
|
||||
def _init_mail_server_domain_whilelist_based(self):
|
||||
self._delete_mail_servers()
|
||||
self.mail_server_domainone = self.Model.create(
|
||||
{
|
||||
"name": "sandbox domainone",
|
||||
"smtp_host": "localhost",
|
||||
"smtp_from": "notifications@domainone.com",
|
||||
"domain_whitelist": "domainone.com",
|
||||
}
|
||||
)
|
||||
self.mail_server_domaintwo = self.Model.create(
|
||||
{
|
||||
"name": "sandbox domaintwo",
|
||||
"smtp_host": "localhost",
|
||||
"smtp_from": "hola@domaintwo.com",
|
||||
"domain_whitelist": "domaintwo.com",
|
||||
}
|
||||
)
|
||||
self.mail_server_domainthree = self.Model.create(
|
||||
{
|
||||
"name": "sandbox domainthree",
|
||||
"smtp_host": "localhost",
|
||||
"smtp_from": "notifications@domainthree.com",
|
||||
"domain_whitelist": "domainthree.com,domainmulti.com",
|
||||
}
|
||||
)
|
||||
|
||||
def _skip_test(self, reason):
|
||||
_logger.warn(reason)
|
||||
self.skipTest(reason)
|
||||
|
||||
def _delete_mail_servers(self):
|
||||
""" Delete all available mail servers """
|
||||
all_mail_servers = self.Model.search([])
|
||||
if all_mail_servers:
|
||||
all_mail_servers.unlink()
|
||||
self.assertFalse(self.Model.search([]))
|
||||
|
||||
def _send_mail(self, message=None, mail_server_id=None, smtp_server=None):
|
||||
if message is None:
|
||||
message = self.message
|
||||
connect = MagicMock()
|
||||
thread = threading.currentThread()
|
||||
thread.testing = False
|
||||
try:
|
||||
self.Model._patch_method("connect", connect)
|
||||
try:
|
||||
self.Model.send_email(message, mail_server_id, smtp_server)
|
||||
finally:
|
||||
self.Model._revert_method("connect")
|
||||
finally:
|
||||
thread.testing = True
|
||||
send_from, send_to, message_string = connect().sendmail.call_args[0]
|
||||
return message_from_string(message_string)
|
||||
|
||||
def test_send_email_injects_from_no_canonical(self):
|
||||
"""It should inject the FROM header correctly when no canonical name.
|
||||
"""
|
||||
self.message.replace_header("From", "test@example.com")
|
||||
message = self._send_mail()
|
||||
self.assertEqual(message["From"], self.email_from)
|
||||
|
||||
def test_send_email_injects_from_with_canonical(self):
|
||||
"""It should inject the FROM header correctly with a canonical name.
|
||||
|
||||
Note that there is an extra `<` in the canonical name to test for
|
||||
proper handling in the split.
|
||||
"""
|
||||
user = "Test < User"
|
||||
self.message.replace_header("From", "%s <test@example.com>" % user)
|
||||
bounce_parameter = self.parameter_model.search(
|
||||
[("key", "=", "mail.bounce.alias")]
|
||||
)
|
||||
if bounce_parameter:
|
||||
# Remove mail.bounce.alias to test Return-Path
|
||||
bounce_parameter.unlink()
|
||||
# Also check passing mail_server_id
|
||||
mail_server_id = self.Model.sudo().search([], order="sequence", limit=1)[0].id
|
||||
message = self._send_mail(mail_server_id=mail_server_id)
|
||||
self.assertEqual(message["From"], '"{}" <{}>'.format(user, self.email_from))
|
||||
self.assertEqual(
|
||||
message["Return-Path"], '"{}" <{}>'.format(user, self.email_from)
|
||||
)
|
||||
|
||||
def test_01_from_outgoing_server_domainone(self):
|
||||
self._init_mail_server_domain_whilelist_based()
|
||||
domain = "domainone.com"
|
||||
email_from = "Mitchell Admin <admin@%s>" % domain
|
||||
expected_mail_server = self.mail_server_domainone
|
||||
|
||||
self.message.replace_header("From", email_from)
|
||||
message = self._send_mail()
|
||||
self.assertEqual(message["From"], email_from)
|
||||
|
||||
used_mail_server = self.Model._get_mail_sever(domain)
|
||||
used_mail_server = self.Model.browse(used_mail_server)
|
||||
self.assertEqual(
|
||||
used_mail_server,
|
||||
expected_mail_server,
|
||||
"It using %s but we expect to use %s"
|
||||
% (used_mail_server.name, expected_mail_server.name),
|
||||
)
|
||||
|
||||
def test_02_from_outgoing_server_domaintwo(self):
|
||||
self._init_mail_server_domain_whilelist_based()
|
||||
domain = "domaintwo.com"
|
||||
email_from = "Mitchell Admin <admin@%s>" % domain
|
||||
expected_mail_server = self.mail_server_domaintwo
|
||||
|
||||
self.message.replace_header("From", email_from)
|
||||
message = self._send_mail()
|
||||
self.assertEqual(message["From"], email_from)
|
||||
|
||||
used_mail_server = self.Model._get_mail_sever(domain)
|
||||
used_mail_server = self.Model.browse(used_mail_server)
|
||||
self.assertEqual(
|
||||
used_mail_server,
|
||||
expected_mail_server,
|
||||
"It using %s but we expect to use %s"
|
||||
% (used_mail_server.name, expected_mail_server.name),
|
||||
)
|
||||
|
||||
def test_03_from_outgoing_server_another(self):
|
||||
self._init_mail_server_domain_whilelist_based()
|
||||
domain = "example.com"
|
||||
email_from = "Mitchell Admin <admin@%s>" % domain
|
||||
expected_mail_server = self.mail_server_domainone
|
||||
|
||||
self.message.replace_header("From", email_from)
|
||||
message = self._send_mail()
|
||||
self.assertEqual(
|
||||
message["From"], "Mitchell Admin <%s>" % expected_mail_server.smtp_from
|
||||
)
|
||||
|
||||
used_mail_server = self.Model._get_mail_sever(domain)
|
||||
used_mail_server = self.Model.browse(used_mail_server)
|
||||
self.assertEqual(
|
||||
used_mail_server,
|
||||
expected_mail_server,
|
||||
"It using %s but we expect to use %s"
|
||||
% (used_mail_server.name, expected_mail_server.name),
|
||||
)
|
||||
|
||||
def test_04_from_outgoing_server_none_use_config(self):
|
||||
self._init_mail_server_domain_whilelist_based()
|
||||
domain = "example.com"
|
||||
email_from = "Mitchell Admin <admin@%s>" % domain
|
||||
|
||||
self._delete_mail_servers()
|
||||
|
||||
# Find config values
|
||||
config_smtp_from = tools.config.get("smtp_from")
|
||||
config_smtp_domain_whitelist = tools.config.get("smtp_domain_whitelist")
|
||||
if not config_smtp_from or not config_smtp_domain_whitelist:
|
||||
self._skip_test(
|
||||
"Cannot test transactions because there is not either smtp_from"
|
||||
" or smtp_domain_whitelist."
|
||||
)
|
||||
|
||||
self.message.replace_header("From", email_from)
|
||||
message = self._send_mail()
|
||||
self.assertEqual(message["From"], "Mitchell Admin <%s>" % config_smtp_from)
|
||||
|
||||
used_mail_server = self.Model._get_mail_sever("example.com")
|
||||
used_mail_server = self.Model.browse(used_mail_server)
|
||||
self.assertFalse(
|
||||
used_mail_server, "using this mail server %s" % (used_mail_server.name)
|
||||
)
|
||||
|
||||
def test_05_from_outgoing_server_none_same_domain(self):
|
||||
self._init_mail_server_domain_whilelist_based()
|
||||
|
||||
# Find config values
|
||||
config_smtp_from = tools.config.get("smtp_from")
|
||||
config_smtp_domain_whitelist = domain = tools.config.get(
|
||||
"smtp_domain_whitelist"
|
||||
)
|
||||
if not config_smtp_from or not config_smtp_domain_whitelist:
|
||||
self._skip_test(
|
||||
"Cannot test transactions because there is not either smtp_from"
|
||||
" or smtp_domain_whitelist."
|
||||
)
|
||||
|
||||
email_from = "Mitchell Admin <admin@%s>" % domain
|
||||
|
||||
self._delete_mail_servers()
|
||||
|
||||
self.message.replace_header("From", email_from)
|
||||
message = self._send_mail()
|
||||
self.assertEqual(message["From"], email_from)
|
||||
|
||||
used_mail_server = self.Model._get_mail_sever(domain)
|
||||
used_mail_server = self.Model.browse(used_mail_server)
|
||||
self.assertFalse(used_mail_server)
|
||||
|
||||
def test_06_from_outgoing_server_no_name_from(self):
|
||||
self._init_mail_server_domain_whilelist_based()
|
||||
domain = "example.com"
|
||||
email_from = "test@%s" % domain
|
||||
expected_mail_server = self.mail_server_domainone
|
||||
|
||||
self.message.replace_header("From", email_from)
|
||||
message = self._send_mail()
|
||||
self.assertEqual(message["From"], expected_mail_server.smtp_from)
|
||||
|
||||
used_mail_server = self.Model._get_mail_sever(domain)
|
||||
used_mail_server = self.Model.browse(used_mail_server)
|
||||
self.assertEqual(
|
||||
used_mail_server,
|
||||
expected_mail_server,
|
||||
"It using %s but we expect to use %s"
|
||||
% (used_mail_server.name, expected_mail_server.name),
|
||||
)
|
||||
|
||||
def test_07_from_outgoing_server_multidomain_1(self):
|
||||
self._init_mail_server_domain_whilelist_based()
|
||||
domain = "domainthree.com"
|
||||
email_from = "Mitchell Admin <admin@%s>" % domain
|
||||
expected_mail_server = self.mail_server_domainthree
|
||||
|
||||
self.message.replace_header("From", email_from)
|
||||
message = self._send_mail()
|
||||
self.assertEqual(message["From"], email_from)
|
||||
|
||||
used_mail_server = self.Model._get_mail_sever(domain)
|
||||
used_mail_server = self.Model.browse(used_mail_server)
|
||||
self.assertEqual(
|
||||
used_mail_server,
|
||||
expected_mail_server,
|
||||
"It using %s but we expect to use %s"
|
||||
% (used_mail_server.name, expected_mail_server.name),
|
||||
)
|
||||
|
||||
def test_08_from_outgoing_server_multidomain_3(self):
|
||||
self._init_mail_server_domain_whilelist_based()
|
||||
domain = "domainmulti.com"
|
||||
email_from = "test@%s" % domain
|
||||
expected_mail_server = self.mail_server_domainthree
|
||||
|
||||
self.message.replace_header("From", email_from)
|
||||
message = self._send_mail()
|
||||
self.assertEqual(message["From"], email_from)
|
||||
|
||||
used_mail_server = self.Model._get_mail_sever(domain)
|
||||
used_mail_server = self.Model.browse(used_mail_server)
|
||||
self.assertEqual(
|
||||
used_mail_server,
|
||||
expected_mail_server,
|
||||
"It using %s but we expect to use %s"
|
||||
% (used_mail_server.name, expected_mail_server.name),
|
||||
)
|
||||
|
||||
def test_09_not_valid_domain_whitelist(self):
|
||||
self._init_mail_server_domain_whilelist_based()
|
||||
mail_server = self.mail_server_domainone
|
||||
mail_server.domain_whitelist = "example.com"
|
||||
error_msg = (
|
||||
"%s is not a valid domain. Please define a list of valid"
|
||||
" domains separated by comma"
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValidationError, error_msg % "asdasd"):
|
||||
mail_server.domain_whitelist = "asdasd"
|
||||
|
||||
with self.assertRaisesRegex(ValidationError, error_msg % "asdasd"):
|
||||
mail_server.domain_whitelist = "example.com, asdasd"
|
||||
|
||||
with self.assertRaisesRegex(ValidationError, error_msg % "invalid"):
|
||||
mail_server.domain_whitelist = "example.com; invalid"
|
||||
|
||||
with self.assertRaisesRegex(ValidationError, error_msg % ";"):
|
||||
mail_server.domain_whitelist = ";"
|
||||
|
||||
with self.assertRaisesRegex(ValidationError, error_msg % "."):
|
||||
mail_server.domain_whitelist = "hola.com,."
|
||||
|
||||
def test_10_not_valid_smtp_from(self):
|
||||
self._init_mail_server_domain_whilelist_based()
|
||||
mail_server = self.mail_server_domainone
|
||||
error_msg = "Not a valid Email From"
|
||||
|
||||
with self.assertRaisesRegex(ValidationError, error_msg):
|
||||
mail_server.smtp_from = "asdasd"
|
||||
|
||||
with self.assertRaisesRegex(ValidationError, error_msg):
|
||||
mail_server.smtp_from = "example.com"
|
||||
|
||||
with self.assertRaisesRegex(ValidationError, error_msg):
|
||||
mail_server.smtp_from = "."
|
||||
|
||||
mail_server.smtp_from = "notifications@test.com"
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!--
|
||||
Copyright 2017 LasLabs Inc.
|
||||
License LGPL-3 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="ir_mail_server_form" model="ir.ui.view">
|
||||
<field name="name">IR Mail Server - From Address</field>
|
||||
<field name="model">ir.mail_server</field>
|
||||
<field name="inherit_id" ref="base.ir_mail_server_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='smtp_pass']" position="after">
|
||||
<field name="domain_whitelist" />
|
||||
<field name="smtp_from" widget="email" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
|
@ -10,7 +10,7 @@ import uuid
|
|||
from math import ceil
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError, AccessError
|
||||
from odoo.exceptions import UserError, AccessError, ValidationError
|
||||
from odoo.osv import expression
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
from odoo.tools import format_time
|
||||
|
@ -78,7 +78,7 @@ class Planning(models.Model):
|
|||
# template dummy fields (only for UI purpose)
|
||||
template_creation = fields.Boolean("Save as a Template", default=False, store=False, inverse='_inverse_template_creation')
|
||||
template_autocomplete_ids = fields.Many2many('planning.slot.template', store=False, compute='_compute_template_autocomplete_ids')
|
||||
template_id = fields.Many2one('planning.slot.template', string='Planning Templates', store=False)
|
||||
template_id = fields.Many2one('planning.slot.template', string='Planning Templates', store=True)
|
||||
|
||||
# Recurring (`repeat_` fields are none stored, only used for UI purpose)
|
||||
recurrency_id = fields.Many2one('planning.recurrency', readonly=True, index=True, ondelete="set null", copy=False)
|
||||
|
@ -92,6 +92,25 @@ class Planning(models.Model):
|
|||
('check_allocated_hours_positive', 'CHECK(allocated_hours >= 0)', 'You cannot have negative shift'),
|
||||
]
|
||||
|
||||
@api.constrains('start_datetime', 'end_datetime', 'employee_id')
|
||||
def _check_same_time_valid(self):
|
||||
if self.ids:
|
||||
self.flush(['start_datetime', 'end_datetime', 'employee_id'])
|
||||
query = """
|
||||
SELECT S1.id,count(*) FROM
|
||||
planning_slot S1, planning_slot S2
|
||||
WHERE
|
||||
S1.start_datetime < S2.end_datetime and S1.end_datetime > S2.start_datetime and S1.id <> S2.id and S1.employee_id = S2.employee_id
|
||||
GROUP BY S1.id;
|
||||
"""
|
||||
self.env.cr.execute(query, (tuple(self.ids),))
|
||||
overlap_mapping = dict(self.env.cr.fetchall())
|
||||
for slot in self:
|
||||
if overlap_mapping.get(slot.id, 0) >= 1:
|
||||
raise ValidationError(_('Planning already assigned for this employee at the same time.'))
|
||||
|
||||
|
||||
|
||||
@api.depends('employee_id')
|
||||
def _compute_planning_slot_company_id(self):
|
||||
if self.employee_id:
|
||||
|
@ -302,8 +321,8 @@ class Planning(models.Model):
|
|||
)
|
||||
else:
|
||||
name = '%s - %s %s' % (
|
||||
start_datetime.date(),
|
||||
end_datetime.date(),
|
||||
datetime.strftime(start_datetime.date(), "%d/%m/%Y"),
|
||||
datetime.strftime(end_datetime.date(), "%d/%m/%Y"),
|
||||
name
|
||||
)
|
||||
|
||||
|
|
|
@ -181,8 +181,8 @@
|
|||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
<ul class="pl-1 mb-0">
|
||||
<li><strong>Start Date: </strong> <t t-esc="userTimezoneStartDate.format('YYYY-MM-DD hh:mm:ss A')"/></li>
|
||||
<li><strong>Stop Date: </strong> <t t-esc="userTimezoneStopDate.format('YYYY-MM-DD hh:mm:ss A')"/></li>
|
||||
<li><strong>Start Date: </strong> <t t-esc="userTimezoneStartDate.format('DD-MM-YYYY hh:mm:ss A')"/></li>
|
||||
<li><strong>Stop Date: </strong> <t t-esc="userTimezoneStopDate.format('DD-MM-YYYY hh:mm:ss A')"/></li>
|
||||
<li id="allocated_hours"><strong>Allocated Hours: </strong> <t t-esc="'' + Math.floor(allocated_hours) + ':' + ((allocated_hours % 1) * 60 >= 10 ? (allocated_hours % 1) * 60 : '0'+(allocated_hours % 1) * 60)"/></li>
|
||||
</ul>
|
||||
<t t-if="publication_warning">
|
||||
|
|
|
@ -15,6 +15,11 @@ class Project(models.Model):
|
|||
raise UserError(_('You cannot delete a project containing plannings. You can either delete all the project\'s forecasts and then delete the project or simply deactivate the project.'))
|
||||
return super(Project, self).unlink()
|
||||
|
||||
def action_view_project_forecast_action_from_project(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("project_forecast.project_forecast_action_from_project")
|
||||
action['context'] = {'default_project_id': self.id, 'search_default_project_id': [self.id]}
|
||||
return action
|
||||
|
||||
|
||||
class Task(models.Model):
|
||||
_inherit = 'project.task'
|
||||
|
|
|
@ -16,12 +16,46 @@ class PlanningShift(models.Model):
|
|||
_inherit = 'planning.slot'
|
||||
|
||||
project_id = fields.Many2one('project.project', string="Project", domain="[('company_id', '=', company_id), ('allow_forecast', '=', True)]", check_company=True)
|
||||
budgeted_hours = fields.Float("Budgeted hours", default=0, compute='_compute_budgeted_hours', store=True)
|
||||
rem_all_hours = fields.Float("Remaining Allocated hours", default=0, compute='_compute_budgeted_hours', store=True)
|
||||
task_id = fields.Many2one('project.task', string="Task", domain="[('company_id', '=', company_id), ('project_id', '=', project_id)]", check_company=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('project_required_if_task', "CHECK( (task_id IS NOT NULL AND project_id IS NOT NULL) OR (task_id IS NULL) )", "If the planning is linked to a task, the project must be set too."),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
||||
if 'budgeted_hours' in fields:
|
||||
fields.remove('budgeted_hours')
|
||||
if 'rem_all_hours' in fields:
|
||||
fields.remove('rem_all_hours')
|
||||
return super(PlanningShift, self).read_group(domain, fields, groupby, offset, limit, orderby, lazy)
|
||||
|
||||
|
||||
@api.depends('project_id', 'employee_id')
|
||||
def _compute_budgeted_hours(self):
|
||||
for slot in self:
|
||||
if slot.project_id and slot.employee_id:
|
||||
if slot.project_id.project_type == 'hours_no_limit':
|
||||
slot.budgeted_hours = slot.project_id.budgeted_hours
|
||||
rec = slot.search([('project_id','=',slot.project_id.id)])
|
||||
all_hours = 0
|
||||
for r in rec:
|
||||
all_hours += r.allocated_hours
|
||||
slot.rem_all_hours = slot.budgeted_hours - all_hours
|
||||
if slot.project_id.project_type == 'hours_in_consultant':
|
||||
cons = self.env['project.sale.line.employee.map'].search([('employee_id','=',slot.employee_id.id),('project_id','=',slot.project_id.id)], limit=1)
|
||||
slot.budgeted_hours = cons and cons.budgeted_qty or 0
|
||||
rec = slot.search([('employee_id', '=', slot.employee_id.id),('project_id', '=', slot.project_id.id)])
|
||||
all_hours = 0
|
||||
for r in rec:
|
||||
all_hours += r.allocated_hours
|
||||
slot.rem_all_hours = slot.budgeted_hours - all_hours
|
||||
else:
|
||||
slot.budgeted_hours = 0
|
||||
slot.rem_all_hours = 0
|
||||
|
||||
@api.onchange('task_id')
|
||||
def _onchange_task_id(self):
|
||||
if not self.project_id:
|
||||
|
@ -29,7 +63,7 @@ class PlanningShift(models.Model):
|
|||
else:
|
||||
self.task_id.project_id = self.project_id
|
||||
|
||||
@api.onchange('employee_id')
|
||||
"""@api.onchange('employee_id')
|
||||
def _onchange_employee_id(self):
|
||||
domain = []
|
||||
if self.employee_id and not self.employee_id.user_id:
|
||||
|
@ -46,18 +80,33 @@ class PlanningShift(models.Model):
|
|||
project_ids = self.env['project.project'].search(
|
||||
[('privacy_visibility', '=', 'followers'), ('allow_forecast', '=', True),
|
||||
('allowed_internal_user_ids', 'in', self.employee_id.user_id.id)]).ids
|
||||
emp_all_project_ids = manager_id + emp_project_ids + project_ids
|
||||
consul_ids = self.env['project.sale.line.employee.map'].search([('employee_id', '=', self.employee_id.id)])
|
||||
consul_project_ids = [val.project_id.id for val in consul_ids]
|
||||
emp_all_project_ids = manager_id + emp_project_ids + project_ids + consul_project_ids
|
||||
domain = [('id', 'in', list(set(emp_all_project_ids)))]
|
||||
result = {
|
||||
'domain': {'project_id': domain},
|
||||
}
|
||||
return result
|
||||
return result"""
|
||||
|
||||
@api.onchange('project_id')
|
||||
def _onchange_project_id(self):
|
||||
domain = [] if not self.project_id else [('project_id', '=', self.project_id.id)]
|
||||
manager_id = []
|
||||
user_ids = []
|
||||
consul_project_ids = []
|
||||
if self.project_id:
|
||||
consul_ids = self.env['project.sale.line.employee.map'].search([('project_id', '=', self.project_id.id)])
|
||||
consul_project_ids = [val.employee_id.id for val in consul_ids]
|
||||
if self.project_id and self.project_id.user_id:
|
||||
manager_id = self.env['hr.employee'].search([('user_id', '=', self.project_id.user_id.id)]).ids
|
||||
if self.project_id and self.project_id.privacy_visibility in ('employees', 'portal'):
|
||||
user_ids = self.env['hr.employee'].search([('user_id', '!=', False)]).ids
|
||||
if self.project_id and self.project_id.privacy_visibility == 'followers':
|
||||
user_ids = self.env['hr.employee'].search([('user_id', 'in', self.project_id.allowed_internal_user_ids.ids)]).ids
|
||||
project_all_emp_ids = manager_id + user_ids + consul_project_ids
|
||||
result = {
|
||||
'domain': {'task_id': domain},
|
||||
'domain': {'task_id': domain, 'employee_id': [('id', 'in', project_all_emp_ids)]},
|
||||
}
|
||||
if self.project_id != self.task_id.project_id:
|
||||
# reset task when changing project
|
||||
|
|
|
@ -7,9 +7,13 @@
|
|||
<field name="model">planning.slot</field>
|
||||
<field name="inherit_id" ref="planning.planning_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='role_id']" position="after">
|
||||
<field name="project_id" optional="hide"/>
|
||||
<field name="task_id" optional="hide"/>
|
||||
<xpath expr="//field[@name='employee_id']" position="before">
|
||||
<field name="project_id"/> <!-- optional="hide" -->
|
||||
<field name="task_id"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='allocated_hours']" position="after">
|
||||
<field name="budgeted_hours"/>
|
||||
<field name="rem_all_hours"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
@ -19,10 +23,14 @@
|
|||
<field name="model">planning.slot</field>
|
||||
<field name="inherit_id" ref="planning.planning_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='role_id']" position="after">
|
||||
<xpath expr="//field[@name='employee_id']" position="before">
|
||||
<field name="project_id" context="{'default_allow_forecast': True}"/>
|
||||
<field name="task_id" attrs="{'invisible': [('project_id', '=', False)]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='employee_id']" position="after">
|
||||
<field name="budgeted_hours"/>
|
||||
<field name="rem_all_hours"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
<field name="allow_forecast"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('o_project_kanban_boxes')]" position="inside">
|
||||
<a t-if="record.allow_forecast.raw_value" class="o_project_kanban_box" name="%(project_forecast_action_from_project)d" type="action">
|
||||
<a t-if="record.allow_forecast.raw_value" class="o_project_kanban_box" name="action_view_project_forecast_action_from_project" type="object">
|
||||
<div>
|
||||
<span class="o_label">Planning</span>
|
||||
</div>
|
||||
|
@ -43,8 +43,8 @@
|
|||
<field name="allow_forecast"/>
|
||||
</xpath>
|
||||
<div name="button_box" position="inside">
|
||||
<button class="oe_stat_button" type="action" attrs="{'invisible':[('allow_forecast', '=', False)]}"
|
||||
name="%(project_forecast_action_from_project)d" icon="fa-tasks">
|
||||
<button class="oe_stat_button" type="object" attrs="{'invisible':[('allow_forecast', '=', False)]}"
|
||||
name="action_view_project_forecast_action_from_project" icon="fa-tasks">
|
||||
<span>Planning</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -10,14 +10,19 @@
|
|||
'author': "SunArc Technologies",
|
||||
'website': "http://www.sunarctechnologies.com",
|
||||
'depends': [
|
||||
'cor_custom'
|
||||
'cor_custom','sub_project'
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'wizard/project_create_expenses_views.xml',
|
||||
'views/project_view.xml',
|
||||
'views/assets.xml',
|
||||
#'views/project_view.xml',
|
||||
'report/project_budget_hrs_analysis_views.xml',
|
||||
'report/project_budget_amt_analysis_views.xml',
|
||||
'report/project_timeline_report_views.xml',
|
||||
],
|
||||
'qweb': [
|
||||
"static/src/xml/base.xml",
|
||||
],
|
||||
'auto_install': False,
|
||||
'installable': True,
|
||||
|
|
|
@ -3,3 +3,4 @@
|
|||
|
||||
from . import project_budget_hrs_analysis
|
||||
from . import project_budget_amt_analysis
|
||||
from . import project_timeline_report
|
||||
|
|
|
@ -13,10 +13,24 @@ class BudgetAmtAnalysis(models.Model):
|
|||
|
||||
#analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', readonly=True)
|
||||
project_id = fields.Many2one('project.project', string='Project', readonly=True)
|
||||
parent_project = fields.Many2one('project.project', string='Parent Project', readonly=True)
|
||||
#is_sub_project = fields.Boolean("Is Sub Project", readonly=True)
|
||||
#sub_project = fields.Many2one('project.project', string='Sub Project', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Client', readonly=True)
|
||||
amount_type = fields.Char(string="Amount Type")
|
||||
revenue = fields.Float("Revenue", digits=(16, 2), readonly=True, group_operator="sum")
|
||||
#employee_id = fields.Many2one('hr.employee', string='Employee', readonly=True)
|
||||
pricing_type = fields.Selection([
|
||||
('fixed_rate', 'Fixed rate'),
|
||||
('employee_rate', 'Consultant rate')
|
||||
], string="Pricing", readonly=True)
|
||||
project_type = fields.Selection([
|
||||
('hours_in_consultant', 'Hours are budgeted according to a consultant'),
|
||||
('hours_no_limit', 'Total hours are budgeted without division to consultant'),
|
||||
], string="Project Type", readonly=True)
|
||||
employee_id = fields.Many2one('hr.employee', string='Consultant', readonly=True)
|
||||
start_date = fields.Date(string='Start Date', readonly=True)
|
||||
end_date = fields.Date(string='End Date', readonly=True)
|
||||
timesheet_date = fields.Date(string='Timesheet Date', readonly=True)
|
||||
#budgeted_revenue = fields.Float("Budgeted Revenue", digits=(16, 2), readonly=True, group_operator="sum")
|
||||
#actual_revenue = fields.Float("Actual Revenue", digits=(16, 2), readonly=True, group_operator="sum")
|
||||
|
||||
|
@ -25,35 +39,125 @@ class BudgetAmtAnalysis(models.Model):
|
|||
tools.drop_view_if_exists(self._cr, self._table)
|
||||
self._cr.execute("""
|
||||
CREATE OR REPLACE VIEW %s AS (
|
||||
SELECT ROW_NUMBER() OVER() as id, project_id, parentproject as parent_project, start_date, end_date, timesheet_date, partner_id, employee_id, amount_type, pricing_type, project_type, revenue from (
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
pro.date_start AS start_date,
|
||||
pro.date AS end_date,
|
||||
pro.partner_id AS partner_id,
|
||||
pro_emp.employee_id AS employee_id,
|
||||
'Budgeted Revenue' as amount_type,
|
||||
pro.pricing_type as pricing_type,
|
||||
pro.project_type as project_type,
|
||||
null::date AS timesheet_date,
|
||||
pro_emp.cost AS revenue
|
||||
FROM project_project pro
|
||||
Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id
|
||||
WHERE PRO.active = 't' and pro.pricing_type = 'employee_rate' and pro.project_type = 'hours_in_consultant'
|
||||
union
|
||||
SELECT
|
||||
row_number() OVER() AS id,
|
||||
PRO.id AS project_id,
|
||||
PRO.create_date AS create_date,
|
||||
PRO.partner_id AS partner_id,
|
||||
'Budgeted Revenue' as amount_type,
|
||||
PRO.budgeted_revenue AS revenue
|
||||
--SO.amount_total AS actual_revenue
|
||||
FROM project_project PRO
|
||||
LEFT JOIN sale_order SO ON PRO.sale_order_id = SO.id
|
||||
LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id
|
||||
LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id
|
||||
WHERE PRO.active = 't' and PRO.pricing_type!='fixed_rate'
|
||||
group by Pro.id, PRO.partner_id, Pro.budgeted_revenue, so.amount_total
|
||||
union
|
||||
SELECT
|
||||
row_number() OVER() AS id,
|
||||
PRO.id AS project_id,
|
||||
PRO.create_date AS create_date,
|
||||
PRO.partner_id AS partner_id,
|
||||
'Actual Revenue' as amount_type,
|
||||
SO.amount_total AS revenue
|
||||
FROM project_project PRO
|
||||
LEFT JOIN sale_order SO ON PRO.sale_order_id = SO.id
|
||||
LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id
|
||||
LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id
|
||||
WHERE PRO.active = 't' and PRO.pricing_type!='fixed_rate'
|
||||
group by Pro.id, PRO.partner_id, Pro.budgeted_revenue, so.amount_total
|
||||
order by create_date desc, project_id, amount_type desc
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
pro.date_start AS start_date,
|
||||
pro.date AS end_date,
|
||||
pro.partner_id AS partner_id,
|
||||
AAL.employee_id AS employee_id,
|
||||
'Actual Revenue' as amount_type,
|
||||
pro.pricing_type as pricing_type,
|
||||
pro.project_type as project_type,
|
||||
null::date AS timesheet_date,
|
||||
(AAL.unit_amount * pro_emp.employee_price) AS revenue
|
||||
--(AAL.unit_amount * pro_emp.price_unit) AS revenue
|
||||
FROM project_project PRO
|
||||
Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id
|
||||
LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id
|
||||
LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id and AAL.employee_id = pro_emp.employee_id
|
||||
WHERE PRO.active = 't' and PRO.pricing_type = 'employee_rate' and PRO.project_type = 'hours_in_consultant'
|
||||
union
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
pro.date_start AS start_date,
|
||||
pro.date AS end_date,
|
||||
pro.partner_id AS partner_id,
|
||||
null::int AS employee_id,
|
||||
'Budgeted Revenue' as amount_type,
|
||||
pro.pricing_type as pricing_type,
|
||||
pro.project_type as project_type,
|
||||
null::date AS timesheet_date,
|
||||
pro.budgeted_revenue AS revenue
|
||||
FROM project_project pro
|
||||
--Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id
|
||||
WHERE PRO.active = 't' and pro.pricing_type = 'employee_rate' and pro.project_type = 'hours_no_limit'
|
||||
union
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
pro.date_start AS start_date,
|
||||
pro.date AS end_date,
|
||||
pro.partner_id AS partner_id,
|
||||
null::int AS employee_id,
|
||||
'Actual Revenue' as amount_type,
|
||||
pro.pricing_type as pricing_type,
|
||||
pro.project_type as project_type,
|
||||
null::date AS timesheet_date,
|
||||
(pro.hourly_rate * pro.timesheet_hour) AS revenue
|
||||
--(pro.hourly_rate * sum(AAL.unit_amount)) AS revenue
|
||||
--(AAL.unit_amount) AS revenue
|
||||
--(AAL.unit_amount * pro_emp.price_unit) AS revenue
|
||||
FROM project_project PRO
|
||||
--Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id
|
||||
--LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id
|
||||
--LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id
|
||||
--and AAL.employee_id = pro_emp.employee_id
|
||||
WHERE PRO.active = 't' and PRO.pricing_type = 'employee_rate' and PRO.project_type = 'hours_no_limit'
|
||||
--group by pro.id
|
||||
union
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
pro.date_start AS start_date,
|
||||
pro.date AS end_date,
|
||||
pro.partner_id AS partner_id,
|
||||
AAL.employee_id AS employee_id,
|
||||
'Actual Cost' as amount_type,
|
||||
pro.pricing_type as pricing_type,
|
||||
pro.project_type as project_type,
|
||||
AAL.date AS timesheet_date,
|
||||
(AAL.unit_amount * pro_emp.price_unit) AS revenue
|
||||
--(AAL.amount * -1) AS revenue
|
||||
--(AAL.unit_amount * pro_emp.employee_price) AS revenue
|
||||
FROM project_project PRO
|
||||
Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id
|
||||
LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id
|
||||
LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id and AAL.employee_id = pro_emp.employee_id
|
||||
WHERE PRO.active = 't' and PRO.pricing_type = 'employee_rate'
|
||||
--and PRO.project_type = 'hours_in_consultant'
|
||||
union
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
pro.date_start AS start_date,
|
||||
pro.date AS end_date,
|
||||
pro.partner_id AS partner_id,
|
||||
AAL.employee_id AS employee_id,
|
||||
'Actual Cost' as amount_type,
|
||||
pro.pricing_type as pricing_type,
|
||||
pro.project_type as project_type,
|
||||
AAL.date AS timesheet_date,
|
||||
(AAL.amount * -1) AS revenue
|
||||
--(AAL.unit_amount * pro_emp.employee_price) AS revenue
|
||||
FROM project_project PRO
|
||||
LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id
|
||||
LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id
|
||||
--and AAL.employee_id = pro_emp.employee_id
|
||||
WHERE PRO.active = 't' and PRO.pricing_type = 'fixed_rate')
|
||||
as res
|
||||
order by
|
||||
project_id,
|
||||
amount_type desc
|
||||
--group by Pro.id, PRO.partner_id, Pro.budgeted_revenue, AAL.amount
|
||||
)""" % (self._table,))
|
||||
|
||||
|
||||
|
|
|
@ -26,6 +26,24 @@
|
|||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_budget_amt_report_view_tree" model="ir.ui.view">
|
||||
<field name="name">project.budget.amt.report.tree</field>
|
||||
<field name="model">project.budget.amt.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Budget Analysis" create="false" edit="false" delete="false">
|
||||
<field name="project_id"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="timesheet_date" optional="hide"/>
|
||||
<field name="amount_type"/>
|
||||
<field name="revenue"/>
|
||||
<field name="parent_project" optional="hide"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
|
||||
<record id="project_budget_amt_report_view_search" model="ir.ui.view">
|
||||
<field name="name">project.budget.amt.report.search</field>
|
||||
|
@ -34,12 +52,22 @@
|
|||
<search string="Budget Analysis">
|
||||
<field name="project_id"/>
|
||||
<field name="partner_id" filter_domain="[('partner_id', 'child_of', self)]"/>
|
||||
<!--<field name="employee_id"/>-->
|
||||
<field name="employee_id"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="timesheet_date"/>
|
||||
<filter string="Fixed rate" name="fixed" domain="[('pricing_type','=','fixed_rate')]"/>
|
||||
<filter string="Hours are budgeted according to a consultant" name="fixed" domain="[('project_type','=','hours_in_consultant')]"/>
|
||||
<filter string="Total hours are budgeted without division to consultant" name="limit" domain="[('project_type','=','hours_no_limit')]"/>
|
||||
<!--<filter string="Sub Project" name="subproject" domain="[('is_sub_project','=',True)]"/>-->
|
||||
<group expand="1" string="Group By">
|
||||
<filter string="Project" name="group_by_project" context="{'group_by':'project_id'}"/>
|
||||
<filter string="Customer" name="group_by_partner_id" context="{'group_by':'partner_id'}"/>
|
||||
<filter string="Client" name="group_by_partner_id" context="{'group_by':'partner_id'}"/>
|
||||
<filter string="Consultant" name="group_by_employee_id" context="{'group_by':'employee_id'}"/>
|
||||
<filter string="Start Date" name="sdate" domain="[]" context="{'group_by':'start_date'}"/>
|
||||
<filter string="End Date" name="edate" domain="[]" context="{'group_by':'end_date'}"/>
|
||||
<filter string="Timesheet Date" name="tdate" domain="[]" context="{'group_by':'timesheet_date'}"/>
|
||||
<filter string="Amount type" name="group_by_amount_type" context="{'group_by':'amount_type'}"/>
|
||||
<!--<filter string="Consultant" name="group_by_employee_id" context="{'group_by':'employee_id'}"/>-->
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
|
@ -48,9 +76,9 @@
|
|||
<record id="project_budget_amt_report_view_action" model="ir.actions.act_window">
|
||||
<field name="name">Projects Revenue Acutal Vs Budget</field>
|
||||
<field name="res_model">project.budget.amt.report</field>
|
||||
<field name="view_mode">pivot,graph</field>
|
||||
<field name="view_mode">graph,tree,pivot</field>
|
||||
<field name="search_view_id" ref="project_budget_amt_report_view_search"/>
|
||||
<field name="context">{'search_default_group_by_project': 1,'search_default_group_by_amount_type': 1}</field>
|
||||
<field name="context">{'search_default_group_by_project': 1,'search_default_group_by_amount_type': 1, 'default_res_model': 'project.budget.amt.report'}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_project_budget_report_amt_analysis"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, tools
|
||||
from odoo import fields, models, tools, api
|
||||
|
||||
|
||||
class BudgetHrsAnalysis(models.Model):
|
||||
|
@ -13,51 +13,156 @@ class BudgetHrsAnalysis(models.Model):
|
|||
|
||||
#analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', readonly=True)
|
||||
project_id = fields.Many2one('project.project', string='Project', readonly=True)
|
||||
parent_project = fields.Many2one('project.project', string='Parent Project', readonly=True)
|
||||
#is_sub_project = fields.Boolean("Is Sub Project", readonly=True)
|
||||
#sub_project = fields.Many2one('project.project', string='Sub Project', readonly=True)
|
||||
start_date = fields.Date(string='Start Date', readonly=True)
|
||||
end_date = fields.Date(string='End Date', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Client', readonly=True)
|
||||
employee_id = fields.Many2one('hr.employee', string='Employee', readonly=True)
|
||||
hours_type = fields.Char(string="Hours Type")
|
||||
hours = fields.Float("Hours", digits=(16, 2), readonly=True, group_operator="sum")
|
||||
employee_id = fields.Many2one('hr.employee', string='Consultant', readonly=True)
|
||||
hours_type = fields.Char(string="Hours Type", readonly=True)
|
||||
hours = fields.Float("Number of Hours", digits=(16, 2), readonly=True, group_operator="sum")
|
||||
timeline = fields.Float("Timeline", digits=(16, 2), readonly=True, group_operator="sum")
|
||||
pricing_type = fields.Selection([
|
||||
('fixed_rate', 'Fixed rate'),
|
||||
('employee_rate', 'Consultant rate')
|
||||
], string="Pricing", readonly=True)
|
||||
project_type = fields.Selection([
|
||||
('hours_in_consultant', 'Hours are budgeted according to a consultant'),
|
||||
('hours_no_limit', 'Total hours are budgeted without division to consultant'),
|
||||
], string="Project Type", readonly=True)
|
||||
#timesheet_date = fields.Date(string='Timesheet Date', readonly=True)
|
||||
#budgeted_hours = fields.Float("Budgeted Hours", digits=(16, 2), readonly=True, group_operator="sum")
|
||||
#actual_hours = fields.Float("Actual Hours", digits=(16, 2), readonly=True, group_operator="sum")
|
||||
|
||||
"""@api.model
|
||||
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
||||
res = super().read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
|
||||
for a in res:
|
||||
print("A",a)
|
||||
return res"""
|
||||
|
||||
def init(self):
|
||||
'''Create the view'''
|
||||
tools.drop_view_if_exists(self._cr, self._table)
|
||||
self._cr.execute("""
|
||||
CREATE OR REPLACE VIEW %s AS (
|
||||
SELECT row_number() OVER() AS id,
|
||||
PRO.id AS project_id,
|
||||
PRO.create_date AS create_date,
|
||||
PRO.partner_id AS partner_id,
|
||||
Pro_emp.employee_id AS employee_id,
|
||||
'Budgeted Hours' as hours_type,
|
||||
CASE
|
||||
WHEN Pro_emp.id is null THEN Pro.budgeted_hours
|
||||
ELSE Pro_emp.budgeted_qty
|
||||
END AS hours
|
||||
FROM project_project PRO
|
||||
Left JOIN project_sale_line_employee_map Pro_emp ON Pro_emp.project_id = Pro.id
|
||||
LEFT JOIN account_analytic_account AA ON Pro.analytic_account_id = AA.id
|
||||
LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = Pro.id
|
||||
and AAL.employee_id = Pro_emp.employee_id
|
||||
Where PRO.active = 't' and PRO.pricing_type!='fixed_rate'
|
||||
group by Pro.id, Pro_emp.id, PRO.partner_id, Pro_emp.employee_id, AAL.unit_amount
|
||||
Union
|
||||
SELECT row_number() OVER() AS id,
|
||||
PRO.id AS project_id,
|
||||
PRO.create_date AS create_date,
|
||||
PRO.partner_id AS partner_id,
|
||||
Pro_emp.employee_id AS employee_id,
|
||||
'Actual Hours' as hours_type,
|
||||
sum(AAL.unit_amount) AS hours
|
||||
FROM project_project PRO
|
||||
Left JOIN project_sale_line_employee_map Pro_emp ON Pro_emp.project_id = Pro.id
|
||||
LEFT JOIN account_analytic_account AA ON Pro.analytic_account_id = AA.id
|
||||
LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = Pro.id
|
||||
and AAL.employee_id = Pro_emp.employee_id
|
||||
Where PRO.active = 't' and PRO.pricing_type!='fixed_rate'
|
||||
group by Pro.id, Pro_emp.id, PRO.partner_id, Pro_emp.employee_id, AAL.unit_amount
|
||||
order by create_date desc, project_id, employee_id, hours_type desc
|
||||
SELECT
|
||||
ROW_NUMBER() OVER() as id,
|
||||
project_id,
|
||||
parentproject as parent_project,
|
||||
startdate as start_date,
|
||||
enddate as end_date,
|
||||
pricing_type as pricing_type,
|
||||
project_type as project_type,
|
||||
partner_id,
|
||||
employee_id,
|
||||
hours_type,
|
||||
hours,
|
||||
timeline
|
||||
from (
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
pro_emp.employee_id AS employee_id,
|
||||
date_start AS startdate,
|
||||
date AS enddate,
|
||||
'Budgeted' as hours_type,
|
||||
pro_emp.budgeted_qty as hours,
|
||||
pro_emp.budgeted_qty/8 as timeline,
|
||||
--(date - date_start) as timeline,
|
||||
pro.*
|
||||
FROM
|
||||
project_project PRO
|
||||
Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id
|
||||
Where
|
||||
pro.active = 't'
|
||||
and PRO.pricing_type = 'employee_rate' and PRO.project_type = 'hours_in_consultant'
|
||||
Union all
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
null::int AS employee_id,
|
||||
date_start AS startdate,
|
||||
date AS enddate,
|
||||
'Budgeted' as hours_type,
|
||||
pro.budgeted_hours2 as hours,
|
||||
--(date - date_start) as timeline,
|
||||
pro.budgeted_hours2/8 as timeline,
|
||||
pro.*
|
||||
FROM
|
||||
project_project PRO
|
||||
--Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id
|
||||
Where
|
||||
pro.active = 't'
|
||||
and PRO.pricing_type = 'employee_rate' and PRO.project_type = 'hours_no_limit'
|
||||
Union all
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
AAL.employee_id AS employee_id,
|
||||
coalesce(pro.date_start, (select min(al.start_datetime::date) from account_analytic_line as al where pro.id=al.project_id)) AS startdate,
|
||||
(select max(al.end_datetime::date) from account_analytic_line as al) as enddate,
|
||||
'Actual' as hours_type,
|
||||
unit_amount as hours,
|
||||
unit_amount/8 as timeline,
|
||||
--(select max(al.end_datetime::date) from account_analytic_line as al) -
|
||||
--coalesce(pro.date_start, (select min(al.start_datetime::date) from account_analytic_line as al where pro.id=al.project_id)) AS timeline,
|
||||
pro.*
|
||||
FROM project_project PRO
|
||||
Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id
|
||||
LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id
|
||||
LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id and AAL.employee_id = pro_emp.employee_id
|
||||
Where
|
||||
PRO.active = 't'
|
||||
and PRO.pricing_type = 'employee_rate' and PRO.project_type = 'hours_in_consultant' and AAL.employee_id is not null
|
||||
Union all
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
AAL.employee_id AS employee_id,
|
||||
coalesce(pro.date_start, (select min(al.start_datetime::date) from account_analytic_line as al where pro.id=al.project_id)) AS startdate,
|
||||
(select max(al.end_datetime::date) from account_analytic_line as al) as enddate,
|
||||
'Actual' as hours_type,
|
||||
unit_amount as hours,
|
||||
unit_amount/8 as timeline,
|
||||
--(select max(al.end_datetime::date) from account_analytic_line as al) -
|
||||
--(select min(al.start_datetime::date) from account_analytic_line as al where pro.id=al.project_id) AS timeline,
|
||||
pro.*
|
||||
FROM project_project PRO
|
||||
LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id
|
||||
LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id
|
||||
Where
|
||||
PRO.active = 't'
|
||||
and PRO.pricing_type = 'employee_rate' and PRO.project_type = 'hours_no_limit' and AAL.employee_id is not null
|
||||
Union all
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
AAL.employee_id AS employee_id,
|
||||
pro.date_start AS startdate,
|
||||
(select max(al.end_datetime::date) from account_analytic_line as al) as enddate,
|
||||
'Actual' as hours_type,
|
||||
unit_amount as hours,
|
||||
unit_amount/8 as timeline,
|
||||
--(select max(al.end_datetime::date) from account_analytic_line as al) - pro.date_start AS timeline,
|
||||
--DATE_PART('day', AGE(select max(al.end_datetime::date) from account_analytic_line, pro.date_start)) AS timeline,
|
||||
pro.*
|
||||
FROM project_project PRO
|
||||
LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id
|
||||
LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id
|
||||
Where
|
||||
PRO.active = 't'
|
||||
and PRO.pricing_type = 'fixed_rate'
|
||||
) as res
|
||||
order by
|
||||
project_id desc,
|
||||
start_date,
|
||||
end_date,
|
||||
pricing_type,
|
||||
project_type,
|
||||
hours_type asc,
|
||||
employee_id
|
||||
)""" % (self._table,))
|
||||
|
||||
|
||||
|
|
|
@ -25,6 +25,24 @@
|
|||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_budget_hrs_report_view_tree" model="ir.ui.view">
|
||||
<field name="name">project.budget.hrs.report.tree</field>
|
||||
<field name="model">project.budget.hrs.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Budget Analysis" create="false" edit="false" delete="false">
|
||||
<field name="project_id"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="employee_id"/>
|
||||
<!--<field name="timesheet_date" optional="hide"/>-->
|
||||
<field name="hours_type"/>
|
||||
<field name="hours"/>
|
||||
<field name="timeline"/>
|
||||
<field name="parent_project" optional="hide"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="project_budget_hrs_report_view_search" model="ir.ui.view">
|
||||
<field name="name">project.budget.hrs.report.search</field>
|
||||
|
@ -34,10 +52,20 @@
|
|||
<field name="project_id"/>
|
||||
<field name="partner_id" filter_domain="[('partner_id', 'child_of', self)]"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<!--<field name="timesheet_date"/>-->
|
||||
<filter string="Fixed rate" name="fixed" domain="[('pricing_type','=','fixed_rate')]"/>
|
||||
<filter string="Hours are budgeted according to a consultant" name="cons" domain="[('project_type','=','hours_in_consultant')]"/>
|
||||
<filter string="Total hours are budgeted without division to consultant" name="limit" domain="[('project_type','=','hours_no_limit')]"/>
|
||||
<!--<filter string="Sub Project" name="subproject" domain="[('is_sub_project','=',True)]"/>-->
|
||||
<group expand="1" string="Group By">
|
||||
<filter string="Project" name="group_by_project" context="{'group_by':'project_id'}"/>
|
||||
<filter string="Customer" name="group_by_partner_id" context="{'group_by':'partner_id'}"/>
|
||||
<filter string="Client" name="group_by_partner_id" context="{'group_by':'partner_id'}"/>
|
||||
<filter string="Consultant" name="group_by_employee_id" context="{'group_by':'employee_id'}"/>
|
||||
<filter string="Start Date" name="sdate" domain="[]" context="{'group_by':'start_date:day'}"/>
|
||||
<filter string="End Date" name="edate" domain="[]" context="{'group_by':'end_date:day'}"/>
|
||||
<!--<filter string="Timesheet Date" name="tdate" domain="[]" context="{'group_by':'timesheet_date:day'}"/>-->
|
||||
<filter string="Hours type" name="group_by_hours_type" context="{'group_by':'hours_type'}"/>
|
||||
</group>
|
||||
</search>
|
||||
|
@ -48,9 +76,9 @@
|
|||
<field name="name">Projects Hours Acutal Vs Budget</field>
|
||||
<!--<field name="name">Projects Revenue Acutal Vs Budget</field>-->
|
||||
<field name="res_model">project.budget.hrs.report</field>
|
||||
<field name="view_mode">pivot,graph</field>
|
||||
<field name="view_mode">graph,tree,pivot</field>
|
||||
<field name="search_view_id" ref="project_budget_hrs_report_view_search"/>
|
||||
<field name="context">{'search_default_group_by_project': 1,'search_default_group_by_hours_type': 1}</field>
|
||||
<field name="context">{'search_default_group_by_project': 1,'search_default_sdate': 1,'search_default_edate': 1,'search_default_group_by_hours_type': 1, 'default_res_model': 'project.budget.hrs.report'}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_project_budget_report_hrs_analysis"
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, tools, api
|
||||
|
||||
|
||||
class ProjectTimelineReport(models.Model):
|
||||
|
||||
_name = "project.timeline.report"
|
||||
_description = "Project Timeline Report"
|
||||
#_order = 'project_id'
|
||||
_auto = False
|
||||
|
||||
#analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', readonly=True)
|
||||
project_id = fields.Many2one('project.project', string='Project', readonly=True)
|
||||
parent_project = fields.Many2one('project.project', string='Parent Project', readonly=True)
|
||||
#is_sub_project = fields.Boolean("Is Sub Project", readonly=True)
|
||||
#sub_project = fields.Many2one('project.project', string='Sub Project', readonly=True)
|
||||
start_date = fields.Date(string='Start Date', readonly=True)
|
||||
end_date = fields.Date(string='End Date', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Client', readonly=True)
|
||||
#employee_id = fields.Many2one('hr.employee', string='Consultant', readonly=True)
|
||||
timeline_type = fields.Char(string="Timeline Type", readonly=True)
|
||||
timeline = fields.Float("Timeline", digits=(16, 2), readonly=True, group_operator="sum")
|
||||
hours = fields.Float("Number of Hours", digits=(16, 2), readonly=True, group_operator="sum")
|
||||
pricing_type = fields.Selection([
|
||||
('fixed_rate', 'Fixed rate'),
|
||||
('employee_rate', 'Consultant rate')
|
||||
], string="Pricing", readonly=True)
|
||||
project_type = fields.Selection([
|
||||
('hours_in_consultant', 'Hours are budgeted according to a consultant'),
|
||||
('hours_no_limit', 'Total hours are budgeted without division to consultant'),
|
||||
], string="Project Type", readonly=True)
|
||||
|
||||
def init(self):
|
||||
'''Create the view'''
|
||||
tools.drop_view_if_exists(self._cr, self._table)
|
||||
self._cr.execute("""
|
||||
CREATE OR REPLACE VIEW %s AS (
|
||||
SELECT
|
||||
ROW_NUMBER() OVER() as id,
|
||||
project_id,
|
||||
parentproject as parent_project,
|
||||
startdate as start_date,
|
||||
enddate as end_date,
|
||||
pricing_type as pricing_type,
|
||||
project_type as project_type,
|
||||
partner_id,
|
||||
timeline_type,
|
||||
hours,
|
||||
timeline from (
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
date_start AS startdate,
|
||||
date AS enddate,
|
||||
'Budgeted' as timeline_type,
|
||||
pro.budgeted_hours2 as hours,
|
||||
(date - date_start) as timeline,
|
||||
--DATE_PART('day', AGE(date, date_start)) AS timeline,
|
||||
pro.*
|
||||
FROM
|
||||
project_project PRO
|
||||
Where
|
||||
pro.active = 't'
|
||||
Union all
|
||||
SELECT
|
||||
pro.id AS project_id,
|
||||
(select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject,
|
||||
(select min(al.start_datetime::date) from account_analytic_line as al where pro.id=al.project_id limit 1) as startdate,
|
||||
(select max(al.end_datetime::date) from account_analytic_line as al where pro.id=al.project_id) as enddate,
|
||||
'Actual' as timeline_type,
|
||||
sum(unit_amount) as hours,
|
||||
((select max(al.end_datetime::date) from account_analytic_line as al where pro.id=al.project_id) - (select min(al.start_datetime::date) from account_analytic_line as al where pro.id=al.project_id)) AS timeline,
|
||||
--DATE_PART('day', AGE((select max(al.end_datetime::date) from account_analytic_line as al where pro.id=al.project_id), (select min(al.start_datetime::date) from account_analytic_line as al where pro.id=al.project_id))) AS timeline,
|
||||
pro.*
|
||||
FROM project_project PRO
|
||||
LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id
|
||||
LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id
|
||||
Where
|
||||
PRO.active = 't'
|
||||
group by pro.id
|
||||
) as res
|
||||
order by
|
||||
project_id desc,
|
||||
--start_date,
|
||||
--end_date desc,
|
||||
--pricing_type,
|
||||
--project_type,
|
||||
timeline_type desc
|
||||
--employee_id
|
||||
)""" % (self._table,))
|
||||
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="project_timeline_report_view_pivot" model="ir.ui.view">
|
||||
<field name="name">project.timeline.report.pivot</field>
|
||||
<field name="model">project.timeline.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Budget Analysis" disable_linking="True" sample="1"> <!-- display_quantity="true" -->
|
||||
<field name="project_id" type="col"/>
|
||||
<field name="timeline_type" type="col"/>
|
||||
<field name="timeline" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_timeline_report_view_graph" model="ir.ui.view">
|
||||
<field name="name">project.timeline.report.graph</field>
|
||||
<field name="model">project.timeline.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Budget Analysis" type="bar" stacked="False" sample="1" disable_linking="1">
|
||||
<field name="project_id" type="row"/>
|
||||
<field name="timeline_type" type="row"/>
|
||||
<field name="timeline" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_timeline_report_view_tree" model="ir.ui.view">
|
||||
<field name="name">project.timeline.report.tree</field>
|
||||
<field name="model">project.timeline.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Budget Analysis" create="false" edit="false" delete="false">
|
||||
<field name="project_id"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<!--<field name="employee_id"/>-->
|
||||
<!--<field name="timesheet_date" optional="hide"/>-->
|
||||
<field name="timeline_type"/>
|
||||
<field name="hours"/>
|
||||
<field name="timeline"/>
|
||||
<field name="parent_project" optional="hide"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="project_timeline_report_view_search" model="ir.ui.view">
|
||||
<field name="name">project.timeline.report.search</field>
|
||||
<field name="model">project.timeline.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Budget Analysis">
|
||||
<field name="project_id"/>
|
||||
<field name="partner_id" filter_domain="[('partner_id', 'child_of', self)]"/>
|
||||
<!--<field name="employee_id"/>-->
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<!--<field name="timesheet_date"/>-->
|
||||
<filter string="Fixed rate" name="fixed" domain="[('pricing_type','=','fixed_rate')]"/>
|
||||
<filter string="Hours are budgeted according to a consultant" name="cons" domain="[('project_type','=','hours_in_consultant')]"/>
|
||||
<filter string="Total hours are budgeted without division to consultant" name="limit" domain="[('project_type','=','hours_no_limit')]"/>
|
||||
<!--<filter string="Sub Project" name="subproject" domain="[('is_sub_project','=',True)]"/>-->
|
||||
<group expand="1" string="Group By">
|
||||
<filter string="Project" name="group_by_project" context="{'group_by':'project_id'}"/>
|
||||
<filter string="Client" name="group_by_partner_id" context="{'group_by':'partner_id'}"/>
|
||||
<!--<filter string="Consultant" name="group_by_employee_id" context="{'group_by':'employee_id'}"/>-->
|
||||
<filter string="Start Date" name="sdate" domain="[]" context="{'group_by':'start_date:day'}"/>
|
||||
<filter string="End Date" name="edate" domain="[]" context="{'group_by':'end_date:day'}"/>
|
||||
<!--<filter string="Timesheet Date" name="tdate" domain="[]" context="{'group_by':'timesheet_date:day'}"/>-->
|
||||
<filter string="Hours type" name="group_by_timeline_type" context="{'group_by':'timeline_type'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_timeline_report_view_action" model="ir.actions.act_window">
|
||||
<field name="name">Projects Timeline Acutal Vs Budget</field>
|
||||
<field name="res_model">project.timeline.report</field>
|
||||
<field name="view_mode">graph,tree,pivot</field>
|
||||
<field name="search_view_id" ref="project_timeline_report_view_search"/>
|
||||
<field name="context">{'search_default_group_by_project': 1,'search_default_sdate': 1,'search_default_edate': 1,'search_default_group_by_timeline_type': 1, 'default_res_model':'project.timeline.report'}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_project_timeline_report"
|
||||
parent="project.menu_project_report"
|
||||
action="project_timeline_report_view_action"
|
||||
name="Projects Timeline Acutal Vs Budget"
|
||||
sequence="60"/>
|
||||
|
||||
</odoo>
|
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rng:grammar xmlns:rng="http://relaxng.org/ns/structure/1.0"
|
||||
xmlns:a="http://relaxng.org/ns/annotation/1.0"
|
||||
datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
|
||||
<!-- Handling of element overloading when inheriting from a base
|
||||
template
|
||||
-->
|
||||
<rng:include href="common.rng"/>
|
||||
<rng:define name="graph">
|
||||
<rng:element name="graph">
|
||||
<rng:optional><rng:attribute name="string" /></rng:optional>
|
||||
<rng:optional>
|
||||
<rng:attribute name="type">
|
||||
<rng:choice>
|
||||
<rng:value>bar</rng:value>
|
||||
<rng:value>horizontalBar</rng:value>
|
||||
<rng:value>pie</rng:value>
|
||||
<rng:value>line</rng:value>
|
||||
<rng:value>pivot</rng:value>
|
||||
</rng:choice>
|
||||
</rng:attribute>
|
||||
</rng:optional>
|
||||
<rng:optional><rng:attribute name="js_class"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="stacked"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="order"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="orientation"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="interval"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="disable_linking"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="sample"/></rng:optional>
|
||||
<rng:zeroOrMore>
|
||||
<rng:ref name="field"/>
|
||||
</rng:zeroOrMore>
|
||||
</rng:element>
|
||||
</rng:define>
|
||||
<rng:start>
|
||||
<rng:choice>
|
||||
<rng:ref name="graph" />
|
||||
</rng:choice>
|
||||
</rng:start>
|
||||
</rng:grammar>
|
|
@ -5,3 +5,5 @@ access_project_create_expense_manager,access_project_create_expense_project_mana
|
|||
access_project_budget_hrs_report_user,project.budget.hrs.report.user,model_project_budget_hrs_report,project.group_project_user,1,0,0,0
|
||||
access_project_budget_amt_report_user,project.budget.amt.report.user,model_project_budget_amt_report,project.group_project_user,1,0,0,0
|
||||
access_project_create_expense_user,access_project_create_expense_project_user,model_project_create_expense,project.group_project_user,1,0,0,0
|
||||
access_project_timeline_report_manager,project.timeline.report,model_project_timeline_report,project.group_project_manager,1,1,1,1
|
||||
access_project_timeline_report_user,project.timeline.report,model_project_timeline_report,project.group_project_user,1,0,0,0
|
|
|
@ -0,0 +1,67 @@
|
|||
odoo.define("project_report.GraphController", function(require) {
|
||||
var GraphController = require("web.GraphController");
|
||||
|
||||
GraphController.include({
|
||||
|
||||
init: function(parent, model, renderer, params) {
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
/**
|
||||
* Makes sure that the buttons in the control panel matches the current
|
||||
* state (so, correct active buttons and stuff like that).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
updateButtons: function () {
|
||||
if (!this.$buttons) {
|
||||
return;
|
||||
}
|
||||
var state = this.model.get();
|
||||
this.$buttons.find('.o_graph_button').removeClass('active');
|
||||
this.$buttons
|
||||
.find('.o_graph_button[data-mode="' + state.mode + '"]')
|
||||
.addClass('active');
|
||||
this.$buttons
|
||||
.find('.o_graph_button[data-mode="stack"]')
|
||||
.data('stacked', state.stacked)
|
||||
.toggleClass('active', state.stacked)
|
||||
.toggleClass('o_hidden', state.mode !== 'bar' && state.mode !== 'horizontalBar');
|
||||
this.$buttons
|
||||
.find('.o_graph_button[data-order]')
|
||||
.toggleClass('o_hidden', state.mode === 'pie' || !!Object.keys(state.timeRanges).length)
|
||||
.filter('.o_graph_button[data-order="' + state.orderBy + '"]')
|
||||
.toggleClass('active', !!state.orderBy);
|
||||
|
||||
if (this.withButtons) {
|
||||
this._attachDropdownComponents();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Do what need to be done when a button from the control panel is clicked.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onButtonClick: function (ev) {
|
||||
var $target = $(ev.target);
|
||||
if ($target.hasClass('o_graph_button')) {
|
||||
if (_.contains(['bar', 'horizontalBar', 'line', 'pie'], $target.data('mode'))) {
|
||||
this.update({ mode: $target.data('mode') });
|
||||
} else if ($target.data('mode') === 'stack') {
|
||||
this.update({ stacked: !$target.data('stacked') });
|
||||
} else if (['asc', 'desc'].includes($target.data('order'))) {
|
||||
const order = $target.data('order');
|
||||
const state = this.model.get();
|
||||
this.update({ orderBy: state.orderBy === order ? false : order });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
});
|
|
@ -0,0 +1,869 @@
|
|||
odoo.define("project_report.GraphRenderer", function(require) {
|
||||
var GraphRenderer = require("web.GraphRenderer");
|
||||
var AbstractRenderer = require('web.AbstractRenderer');
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var dataComparisonUtils = require('web.dataComparisonUtils');
|
||||
var fieldUtils = require('web.field_utils');
|
||||
|
||||
var _t = core._t;
|
||||
var DateClasses = dataComparisonUtils.DateClasses;
|
||||
var qweb = core.qweb;
|
||||
|
||||
var CHART_TYPES = ['pie', 'bar', 'horizontalBar', 'line'];
|
||||
// #d62728 into #794dd6
|
||||
var COLORS = ["#1f77b4", "#ff7f0e", "#aec7e8", "#ffbb78", "#2ca02c", "#98df8a", "#794dd6",
|
||||
"#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2",
|
||||
"#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"];
|
||||
var COLOR_NB = COLORS.length;
|
||||
|
||||
function hexToRGBA(hex, opacity) {
|
||||
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
var rgb = result.slice(1, 4).map(function (n) {
|
||||
return parseInt(n, 16);
|
||||
}).join(',');
|
||||
return 'rgba(' + rgb + ',' + opacity + ')';
|
||||
}
|
||||
|
||||
// used to format values in tooltips and yAxes.
|
||||
var FORMAT_OPTIONS = {
|
||||
// allow to decide if utils.human_number should be used
|
||||
humanReadable: function (value) {
|
||||
return Math.abs(value) >= 1000;
|
||||
},
|
||||
// with the choices below, 1236 is represented by 1.24k
|
||||
minDigits: 1,
|
||||
decimals: 2,
|
||||
// avoid comma separators for thousands in numbers when human_number is used
|
||||
formatterCallback: function (str) {
|
||||
return str;
|
||||
},
|
||||
};
|
||||
|
||||
var NO_DATA = [_t('No data')];
|
||||
NO_DATA.isNoData = true;
|
||||
|
||||
// Hide top legend when too many items for device size
|
||||
var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class);
|
||||
|
||||
|
||||
GraphRenderer.include({
|
||||
init: function(parent, state, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this.context = state.context;
|
||||
this.resModel = this.context && this.context.default_res_model;
|
||||
//if (this.resModel == 'project.budget.hrs.report') {
|
||||
// this.title = 'Time Line';
|
||||
// }
|
||||
},
|
||||
|
||||
_formatValue: function (value) {
|
||||
//this._super.apply(this, arguments);
|
||||
var measureField = this.fields[this.state.measure];
|
||||
var formatter = fieldUtils.format.float;
|
||||
var formatedValue = formatter(value, measureField, FORMAT_OPTIONS);
|
||||
return formatedValue;
|
||||
},
|
||||
/**
|
||||
* Used any time we need a new color in our charts.
|
||||
*
|
||||
* @private
|
||||
* @param {number} index
|
||||
* @returns {string} a color in HEX format
|
||||
*/
|
||||
_getColor: function (index) {
|
||||
return COLORS[index % COLOR_NB];
|
||||
},
|
||||
/**
|
||||
* Determines the initial section of the labels array
|
||||
* over a dataset has to be completed. The section only depends
|
||||
* on the datasets origins.
|
||||
*
|
||||
* @private
|
||||
* @param {number} originIndex
|
||||
* @param {number} defaultLength
|
||||
* @returns {number}
|
||||
*/
|
||||
_getDatasetDataLength: function (originIndex, defaultLength) {
|
||||
if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode) && this.state.comparisonFieldIndex === 0) {
|
||||
return this.dateClasses.dateSets[originIndex].length;
|
||||
}
|
||||
return defaultLength;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines over which label is the data point
|
||||
*
|
||||
* @private
|
||||
* @param {Object} dataPt
|
||||
* @returns {Array}
|
||||
*/
|
||||
_getLabel: function (dataPt) {
|
||||
var i = this.state.comparisonFieldIndex;
|
||||
if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) {
|
||||
if (i === 0) {
|
||||
return [this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i])];
|
||||
} else {
|
||||
return dataPt.labels.slice(0, 1);
|
||||
}
|
||||
} else if (i === 0) {
|
||||
return Array.prototype.concat.apply([], [
|
||||
this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i]),
|
||||
dataPt.labels.slice(i+1)
|
||||
]);
|
||||
} else {
|
||||
return dataPt.labels;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns an object used to style chart elements independently from the datasets.
|
||||
*
|
||||
* @private
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getElementOptions: function () {
|
||||
var elementOptions = {};
|
||||
if (this.state.mode === 'bar' || this.state.mode === 'horizontalBar') {
|
||||
elementOptions.rectangle = {borderWidth: 1};
|
||||
} else if (this.state.mode === 'line') {
|
||||
elementOptions.line = {
|
||||
tension: 0,
|
||||
fill: false,
|
||||
};
|
||||
}
|
||||
return elementOptions;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines to which dataset belong the data point
|
||||
*
|
||||
* @private
|
||||
* @param {Object} dataPt
|
||||
* @returns {string}
|
||||
*/
|
||||
_getDatasetLabel: function (dataPt) {
|
||||
if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) {
|
||||
// ([origin] + second to last groupBys) or measure
|
||||
var datasetLabel = dataPt.labels.slice(1).join("/");
|
||||
if (this.state.origins.length > 1) {
|
||||
datasetLabel = this.state.origins[dataPt.originIndex] +
|
||||
(datasetLabel ? ('/' + datasetLabel) : '');
|
||||
}
|
||||
datasetLabel = datasetLabel || this.fields[this.state.measure].string;
|
||||
return datasetLabel;
|
||||
}
|
||||
return this.state.origins[dataPt.originIndex];
|
||||
},
|
||||
|
||||
|
||||
_filterDataPoints: function () {
|
||||
var dataPoints = [];
|
||||
if (_.contains(['bar', 'horizontalBar', 'pie'], this.state.mode)) {
|
||||
dataPoints = this.state.dataPoints.filter(function (dataPt) {
|
||||
return dataPt.count > 0;
|
||||
});
|
||||
} else if (this.state.mode === 'line') {
|
||||
var counts = 0;
|
||||
this.state.dataPoints.forEach(function (dataPt) {
|
||||
if (dataPt.labels[0] !== _t("Undefined")) {
|
||||
dataPoints.push(dataPt);
|
||||
}
|
||||
counts += dataPt.count;
|
||||
});
|
||||
// data points with zero count might have been created on purpose
|
||||
// we only remove them if there are no data point with positive count
|
||||
if (counts === 0) {
|
||||
dataPoints = [];
|
||||
}
|
||||
}
|
||||
return dataPoints;
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Returns the options used to generate the chart legend.
|
||||
*
|
||||
* @private
|
||||
* @param {Number} datasetsCount
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getLegendOptions: function (datasetsCount) {
|
||||
var legendOptions = {
|
||||
display: datasetsCount <= MAX_LEGEND_LENGTH,
|
||||
// position: this.state.mode === 'pie' ? 'right' : 'top',
|
||||
position: 'top',
|
||||
onHover: this._onlegendTooltipHover.bind(this),
|
||||
onLeave: this._onLegendTootipLeave.bind(this),
|
||||
};
|
||||
var self = this;
|
||||
if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) {
|
||||
var referenceColor;
|
||||
if (this.state.mode === 'bar' || this.state.mode === 'horizontalBar') {
|
||||
referenceColor = 'backgroundColor';
|
||||
}
|
||||
else {
|
||||
referenceColor = 'borderColor';
|
||||
}
|
||||
legendOptions.labels = {
|
||||
generateLabels: function (chart) {
|
||||
var data = chart.data;
|
||||
return data.datasets.map(function (dataset, i) {
|
||||
var r = dataset[referenceColor];
|
||||
if ((dataset[referenceColor] instanceof Array) && (dataset[referenceColor].length>=1)) {
|
||||
//var res = dataset[referenceColor].filter(item => item != 'red');
|
||||
var res = r.filter(item => item != 'red');
|
||||
if (res.length>=1) {
|
||||
r = res[0]
|
||||
}
|
||||
/* else{
|
||||
dataset[referenceColor] = dataset[referenceColor][0]
|
||||
} */
|
||||
}
|
||||
var label_res = dataset.label;
|
||||
if ((self.resModel == 'project.budget.hrs.report') || (self.resModel == 'project.timeline.report')) {
|
||||
var measure_value = self.fields[self.state.measure].string.split(" ").splice(-1);
|
||||
label_res = label_res.replace("Actual", "Actual " + measure_value).replace("Budgeted", "Budgeted " + measure_value);
|
||||
}
|
||||
return {
|
||||
text: self._shortenLabel(label_res),
|
||||
fullText: label_res,
|
||||
fillStyle: r,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
lineCap: dataset.borderCapStyle,
|
||||
lineDash: dataset.borderDash,
|
||||
lineDashOffset: dataset.borderDashOffset,
|
||||
lineJoin: dataset.borderJoinStyle,
|
||||
lineWidth: dataset.borderWidth,
|
||||
strokeStyle: r,
|
||||
pointStyle: dataset.pointStyle,
|
||||
datasetIndex: i,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
} else {
|
||||
legendOptions.labels = {
|
||||
generateLabels: function (chart) {
|
||||
var data = chart.data;
|
||||
var metaData = data.datasets.map(function (dataset, index) {
|
||||
return chart.getDatasetMeta(index).data;
|
||||
});
|
||||
return data.labels.map(function (label, i) {
|
||||
var hidden = metaData.reduce(
|
||||
function (hidden, data) {
|
||||
if (data[i]) {
|
||||
hidden = hidden || data[i].hidden;
|
||||
}
|
||||
return hidden;
|
||||
},
|
||||
false
|
||||
);
|
||||
var fullText = self._relabelling(label);
|
||||
var text = self._shortenLabel(fullText);
|
||||
return {
|
||||
text: text,
|
||||
fullText: fullText,
|
||||
fillStyle: label.isNoData ? '#d3d3d3' : self._getColor(i),
|
||||
hidden: hidden,
|
||||
index: i,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
return legendOptions;
|
||||
},
|
||||
|
||||
/**
|
||||
* Extracts the important information from a tooltipItem generated by Charts.js
|
||||
* (a tooltip item corresponds to a line (different from measure name) of a tooltip)
|
||||
*
|
||||
* @private
|
||||
* @param {Object} item
|
||||
* @param {Object} data
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getTooltipItemContent: function (item, data) {
|
||||
var dataset = data.datasets[item.datasetIndex];
|
||||
var label = data.labels[item.index];
|
||||
var value;
|
||||
var boxColor;
|
||||
if (this.state.mode === 'bar') {
|
||||
label = this._relabelling(label, dataset.originIndex);
|
||||
if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) {
|
||||
label = label + "/" + dataset.label;
|
||||
}
|
||||
value = this._formatValue(item.yLabel);
|
||||
boxColor = dataset.backgroundColor;
|
||||
var r = dataset.backgroundColor;
|
||||
if ((dataset.backgroundColor instanceof Array) && (dataset.backgroundColor.length>=1)) {
|
||||
boxColor = dataset.backgroundColor[item.index]
|
||||
}
|
||||
} else if (this.state.mode === 'horizontalBar') {
|
||||
label = this._relabelling(label, dataset.originIndex);
|
||||
if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) {
|
||||
label = label + "/" + dataset.label;
|
||||
}
|
||||
value = this._formatValue(item.xLabel);
|
||||
boxColor = dataset.backgroundColor;
|
||||
if ((dataset.backgroundColor instanceof Array) && (dataset.backgroundColor.length>=1)) {
|
||||
boxColor = dataset.backgroundColor[item.index]
|
||||
}
|
||||
}
|
||||
else if (this.state.mode === 'line') {
|
||||
label = this._relabelling(label, dataset.originIndex);
|
||||
if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) {
|
||||
label = label + "/" + dataset.label;
|
||||
}
|
||||
value = this._formatValue(item.yLabel);
|
||||
boxColor = dataset.borderColor;
|
||||
} else {
|
||||
if (label.isNoData) {
|
||||
value = this._formatValue(0);
|
||||
} else {
|
||||
value = this._formatValue(dataset.data[item.index]);
|
||||
}
|
||||
label = this._relabelling(label, dataset.originIndex);
|
||||
if (this.state.origins.length > 1) {
|
||||
label = dataset.label + "/" + label;
|
||||
}
|
||||
boxColor = dataset.backgroundColor[item.index];
|
||||
}
|
||||
return {
|
||||
label: label,
|
||||
value: value,
|
||||
boxColor: boxColor,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* This function extracts the information from the data points in tooltipModel.dataPoints
|
||||
* (corresponding to datapoints over a given label determined by the mouse position)
|
||||
* that will be displayed in a custom tooltip.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} tooltipModel see chartjs documentation
|
||||
* @return {Object[]}
|
||||
*/
|
||||
_getTooltipItems: function (tooltipModel) {
|
||||
var self = this;
|
||||
var data = this.chart.config.data;
|
||||
var orderedItems = tooltipModel.dataPoints.sort(function (dPt1, dPt2) {
|
||||
return dPt2.yLabel - dPt1.yLabel;
|
||||
});
|
||||
return orderedItems.reduce(
|
||||
function (acc, item) {
|
||||
if (item.value > 0) {
|
||||
acc.push(self._getTooltipItemContent(item, data));
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
},
|
||||
|
||||
_isRedirectionEnabled: function () {
|
||||
return !this.disableLinking &&
|
||||
(this.state.mode === 'bar' || this.state.mode === 'horizontalBar' || this.state.mode === 'pie');
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine how to relabel a label according to a given origin.
|
||||
* The idea is that the getLabel function is in general not invertible but
|
||||
* it is when restricted to the set of dataPoints coming from a same origin.
|
||||
|
||||
* @private
|
||||
* @param {Array} label
|
||||
* @param {Array} originIndex
|
||||
* @returns {string}
|
||||
*/
|
||||
_relabelling: function (label, originIndex) {
|
||||
if (label.isNoData) {
|
||||
return label[0];
|
||||
}
|
||||
var i = this.state.comparisonFieldIndex;
|
||||
if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode) && i === 0) {
|
||||
// here label is an array of length 1 and contains a number
|
||||
return this.dateClasses.representative(label, originIndex) || '';
|
||||
} else if (this.state.mode === 'pie' && i === 0) {
|
||||
// here label is an array of length at least one containing string or numbers
|
||||
var labelCopy = label.slice(0);
|
||||
if (originIndex !== undefined) {
|
||||
labelCopy.splice(i, 1, this.dateClasses.representative(label[i], originIndex));
|
||||
} else {
|
||||
labelCopy.splice(i, 1, this.dateClasses.dateClassMembers(label[i]));
|
||||
}
|
||||
return labelCopy.join('/');
|
||||
}
|
||||
// here label is an array containing strings or numbers.
|
||||
return label.join('/') || _t('Total');
|
||||
},
|
||||
|
||||
async _renderView() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
}
|
||||
this.$el.empty();
|
||||
if (!_.contains(CHART_TYPES, this.state.mode)) {
|
||||
this.trigger_up('warning', {
|
||||
title: _t('Invalid mode for chart'),
|
||||
message: _t('Cannot render chart with mode : ') + this.state.mode
|
||||
});
|
||||
}
|
||||
var dataPoints = this._filterDataPoints();
|
||||
dataPoints = this._sortDataPoints(dataPoints);
|
||||
if (this.isInDOM) {
|
||||
this._renderTitle();
|
||||
|
||||
// detect if some pathologies are still present after the filtering
|
||||
if (this.state.mode === 'pie') {
|
||||
const someNegative = dataPoints.some(dataPt => dataPt.value < 0);
|
||||
const somePositive = dataPoints.some(dataPt => dataPt.value > 0);
|
||||
if (someNegative && somePositive) {
|
||||
const context = {
|
||||
title: _t("Invalid data"),
|
||||
description: [
|
||||
_t("Pie chart cannot mix positive and negative numbers. "),
|
||||
_t("Try to change your domain to only display positive results")
|
||||
].join("")
|
||||
};
|
||||
this._renderNoContentHelper(context);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.isSample && !this.isEmbedded) {
|
||||
this._renderNoContentHelper();
|
||||
}
|
||||
|
||||
// only render the graph if the widget is already in the DOM (this
|
||||
// happens typically after an update), otherwise, it will be
|
||||
// rendered when the widget will be attached to the DOM (see
|
||||
// 'on_attach_callback')
|
||||
var $canvasContainer = $('<div/>', {class: 'o_graph_canvas_container'});
|
||||
var $canvas = $('<canvas/>').attr('id', this.chartId);
|
||||
$canvasContainer.append($canvas);
|
||||
this.$el.append($canvasContainer);
|
||||
|
||||
var i = this.state.comparisonFieldIndex;
|
||||
if (i === 0) {
|
||||
this.dateClasses = this._getDateClasses(dataPoints);
|
||||
}
|
||||
if (this.state.mode === 'bar') {
|
||||
this._renderBarChart(dataPoints);
|
||||
} else if (this.state.mode === 'horizontalBar') {
|
||||
this._renderhorizontalBarChart(dataPoints);
|
||||
}
|
||||
else if (this.state.mode === 'line') {
|
||||
this._renderLineChart(dataPoints);
|
||||
} else if (this.state.mode === 'pie') {
|
||||
this._renderPieChart(dataPoints);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_getScaleHBarOptions: function () {
|
||||
var self = this;
|
||||
if (_.contains(['horizontalBar'], this.state.mode)) {
|
||||
return {
|
||||
xAxes: [{
|
||||
type: 'linear',
|
||||
scaleLabel: {
|
||||
display: !this.isEmbedded,
|
||||
labelString: this.fields[this.state.measure].string,
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
callback: this._formatValue.bind(this),
|
||||
suggestedMax: 0,
|
||||
suggestedMin: 0,
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
type: 'category',
|
||||
scaleLabel: {
|
||||
display: this.state.processedGroupBy.length && !this.isEmbedded,
|
||||
labelString: this.state.processedGroupBy.length ?
|
||||
this.fields[this.state.processedGroupBy[0].split(':')[0]].string : '',
|
||||
},
|
||||
ticks: {
|
||||
// don't use bind: callback is called with 'index' as second parameter
|
||||
// with value labels.indexOf(label)!
|
||||
callback: function (label) {
|
||||
return self._relabelling(label);
|
||||
},
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
_animationOptions: function () {
|
||||
var animationOptions = {};
|
||||
var GraphVal = this;
|
||||
if (this.state.mode === 'bar') { // && this.resModel == 'project.budget.hrs.report'
|
||||
var animationOptions = {
|
||||
duration: "1",
|
||||
"onComplete": function() {
|
||||
var chartInstance = this.chart,
|
||||
ctx = chartInstance.ctx;
|
||||
ctx.font = Chart.helpers.fontString(12, 'bold', "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif");
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
this.data.datasets.forEach(function(dataset, i) {
|
||||
var meta = chartInstance.controller.getDatasetMeta(i);
|
||||
meta.data.forEach(function(bar, index) {
|
||||
var data = dataset.data[index];
|
||||
var value;
|
||||
if(!!data && chartInstance.isDatasetVisible(i)){
|
||||
value = GraphVal._formatValue(data);
|
||||
ctx.fillText(value, bar._model.x, bar._model.y - 5);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
return animationOptions;
|
||||
},
|
||||
|
||||
_animation_HBar_Options: function () {
|
||||
var animationHBarOptions = {};
|
||||
var GraphVal = this;
|
||||
if (this.state.mode === 'horizontalBar') { // && this.resModel == 'project.budget.hrs.report'
|
||||
var animationHBarOptions = {
|
||||
duration: "1",
|
||||
"onComplete": function() {
|
||||
var chartInstance = this.chart,
|
||||
ctx = chartInstance.ctx;
|
||||
ctx.font = Chart.helpers.fontString(12, 'bold', "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif");
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'bottom';
|
||||
this.data.datasets.forEach(function(dataset, i) {
|
||||
var meta = chartInstance.controller.getDatasetMeta(i);
|
||||
meta.data.forEach(function(bar, index) {
|
||||
var data = dataset.data[index];
|
||||
//var barWidth = bar._model.x - bar._model.base;
|
||||
//var centerX = bar._model.base + barWidth / 2;
|
||||
//console.log("data", JSON.stringify(data), chartInstance.isDatasetVisible(i), !chartInstance.isDatasetVisible(i))
|
||||
var value;
|
||||
if(!!data && chartInstance.isDatasetVisible(i)){
|
||||
value = GraphVal._formatValue(data);
|
||||
//ctx.fillStyle = '#FFF';
|
||||
ctx.fillText(value, bar._model.x, bar._model.y + 5);
|
||||
//ctx.fillText(value, centerX, bar._model.y + 4);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
return animationHBarOptions;
|
||||
},
|
||||
|
||||
_prepareOptions: function (datasetsCount) {
|
||||
const options = {
|
||||
maintainAspectRatio: false,
|
||||
scales: this._getScaleOptions(),
|
||||
legend: this._getLegendOptions(datasetsCount),
|
||||
tooltips: this._getTooltipOptions(),
|
||||
elements: this._getElementOptions(),
|
||||
animation: this._animationOptions(),
|
||||
};
|
||||
if (this._isRedirectionEnabled()) {
|
||||
options.onClick = this._onGraphClicked.bind(this);
|
||||
}
|
||||
return options;
|
||||
},
|
||||
|
||||
_renderBarChart: function (dataPoints) {
|
||||
var self = this;
|
||||
|
||||
// prepare data
|
||||
//console.log("datappppppppppp", dataPoints);
|
||||
var data = this._prepareData(dataPoints);
|
||||
|
||||
// this.title = 'Time Line';
|
||||
if ((self.resModel == 'project.budget.hrs.report') || (self.resModel == 'project.timeline.report')) {
|
||||
var groupedData = _.groupBy(data.datasets, x => x.label.replace('Actual', '').replace('Budgeted', '').split("/")[0]);
|
||||
//var groupedData = _.groupBy(data.datasets, x => x.label.replace('Actual Hours', '').replace('Budgeted Hours', ''));
|
||||
_.map(groupedData, (y) => {
|
||||
if (y.length > 1) {
|
||||
var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]);
|
||||
var maxA = zipped_1.map((a) => {
|
||||
if ((!!a[0] && !!a[1]) && a[0] > a[1]) {
|
||||
return 'red';
|
||||
} else if ((!!a[0] && !!a[1]) && a[0] < a[1]) {
|
||||
return self._getColor(0);
|
||||
}
|
||||
else {
|
||||
return self._getColor(0);
|
||||
}
|
||||
});
|
||||
y[0].backgroundColor = maxA;
|
||||
y[1].backgroundColor = self._getColor(1);
|
||||
//var zipped_2 = _.zip.apply(null, [y[1].data, y[0].data]);
|
||||
/*var maxB = zipped_2.map((a) => {
|
||||
if ((!!a[0] && !!a[1]) && a[0] > a[1]) {
|
||||
return self._getColor(1);
|
||||
} else if ((!!a[0] && !!a[1]) && a[0] < a[1]) {
|
||||
return 'red';
|
||||
}
|
||||
else {
|
||||
return self._getColor(1);
|
||||
}
|
||||
});
|
||||
y[1].backgroundColor = maxB;*/
|
||||
}
|
||||
});
|
||||
}
|
||||
if (self.resModel == 'project.budget.amt.report') {
|
||||
var groupedData2 = _.groupBy(data.datasets, x => x.label.replace('Actual Cost', '').replace('Budgeted Revenue', ''));
|
||||
_.map(groupedData2, (y) => {
|
||||
if (y.length > 1) {
|
||||
var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]);
|
||||
var maxA = zipped_1.map((a) => {
|
||||
if ((!!a[0] && !!a[1]) && a[0] > a[1]) {
|
||||
return 'red';
|
||||
} else if ((!!a[0] && !!a[1]) && a[0] < a[1]) {
|
||||
return self._getColor(0);
|
||||
}
|
||||
else {
|
||||
return self._getColor(0);
|
||||
}
|
||||
});
|
||||
y[0].backgroundColor = maxA;
|
||||
y[1].backgroundColor = self._getColor(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (self.resModel == 'project.consultant.hrs.report') {
|
||||
var groupedData3 = _.groupBy(data.datasets, x => x.label.replace('Actual Hours for period', '').replace('Budgeted Hours for period', ''));
|
||||
_.map(groupedData3, (y) => {
|
||||
if (y.length > 1) {
|
||||
var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]);
|
||||
var maxA = zipped_1.map((a) => {
|
||||
if ((!!a[0] && !!a[1]) && a[0] > a[1]) {
|
||||
return 'red';
|
||||
} else if ((!!a[0] && !!a[1]) && a[0] < a[1]) {
|
||||
return self._getColor(0);
|
||||
}
|
||||
else {
|
||||
return self._getColor(0);
|
||||
}
|
||||
});
|
||||
y[0].backgroundColor = maxA;
|
||||
y[1].backgroundColor = self._getColor(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
data.datasets.forEach(function (dataset, index) {
|
||||
// used when stacked
|
||||
dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined;
|
||||
// set dataset color
|
||||
/* if (self.state.stacked && dataset.label.includes("Actual")) {
|
||||
dataset.stack = 1;
|
||||
}*/
|
||||
if ((self.resModel == 'project.budget.hrs.report') || (self.resModel == 'project.timeline.report')) {
|
||||
//if (dataset.label.indexOf("Actual Hours") === -1 && dataset.label.toLowerCase().indexOf("Budgeted Hours") === -1) {
|
||||
if (dataset.label.indexOf("Actual") === -1 && dataset.label.toLowerCase().indexOf("Budgeted") === -1) {
|
||||
var color = self._getColor(index);
|
||||
dataset.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
else if (self.resModel == 'project.budget.amt.report') {
|
||||
if (dataset.label.indexOf("Actual Cost") === -1 && dataset.label.toLowerCase().indexOf("Budgeted Revenue") === -1) {
|
||||
var color = self._getColor(index);
|
||||
dataset.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
else if (self.resModel == 'project.consultant.hrs.report') {
|
||||
if (dataset.label.indexOf("Actual Hours for period") === -1 && dataset.label.toLowerCase().indexOf("Budgeted Hours for period") === -1) {
|
||||
var color = self._getColor(index);
|
||||
dataset.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
else {
|
||||
var color = self._getColor(index);
|
||||
dataset.backgroundColor = color;
|
||||
}
|
||||
});
|
||||
// prepare options
|
||||
var options = this._prepareOptions(data.datasets.length);
|
||||
// create chart
|
||||
var ctx = document.getElementById(this.chartId);
|
||||
this.chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: data,
|
||||
options: options,
|
||||
});
|
||||
},
|
||||
|
||||
_prepare_HBar_Options: function (datasetsCount) {
|
||||
const options = {
|
||||
maintainAspectRatio: false,
|
||||
scales: this._getScaleHBarOptions(),
|
||||
legend: this._getLegendOptions(datasetsCount),
|
||||
tooltips: this._getTooltipOptions(),
|
||||
elements: this._getElementOptions(),
|
||||
animation: this._animation_HBar_Options(),
|
||||
};
|
||||
if (this._isRedirectionEnabled()) {
|
||||
options.onClick = this._onGraphClicked.bind(this);
|
||||
}
|
||||
return options;
|
||||
},
|
||||
|
||||
|
||||
_renderhorizontalBarChart: function (dataPoints) {
|
||||
var self = this;
|
||||
|
||||
// prepare data
|
||||
var data = this._prepareData(dataPoints);
|
||||
|
||||
//console.log("datappppppppppp", dataPoints);
|
||||
|
||||
if ((self.resModel == 'project.budget.hrs.report') || (self.resModel == 'project.timeline.report')) {
|
||||
//var groupedData = _.groupBy(data.datasets, x => x.label.replace('Actual Hours', '').replace('Budgeted Hours', '').split("/")[0]);
|
||||
var groupedData = _.groupBy(data.datasets, x => x.label.replace('Actual', '').replace('Budgeted', '').split("/")[0]);
|
||||
_.map(groupedData, (y) => {
|
||||
if (y.length > 1) {
|
||||
var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]);
|
||||
var maxA = zipped_1.map((a) => {
|
||||
if ((!!a[0] && !!a[1]) && a[0] > a[1]) {
|
||||
return 'red';
|
||||
} else if ((!!a[0] && !!a[1]) && a[0] < a[1]) {
|
||||
return self._getColor(0);
|
||||
}
|
||||
else {
|
||||
return self._getColor(0);
|
||||
}
|
||||
});
|
||||
y[0].backgroundColor = maxA;
|
||||
y[1].backgroundColor = self._getColor(1);
|
||||
var zipped_2 = _.zip.apply(null, [y[1].data, y[0].data]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (self.resModel == 'project.budget.amt.report') {
|
||||
var groupedData2 = _.groupBy(data.datasets, x => x.label.replace('Actual Cost', '').replace('Budgeted Revenue', ''));
|
||||
_.map(groupedData2, (y) => {
|
||||
if (y.length > 1) {
|
||||
var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]);
|
||||
var maxA = zipped_1.map((a) => {
|
||||
if ((!!a[0] && !!a[1]) && a[0] > a[1]) {
|
||||
return 'red';
|
||||
} else if ((!!a[0] && !!a[1]) && a[0] < a[1]) {
|
||||
return self._getColor(0);
|
||||
}
|
||||
else {
|
||||
return self._getColor(0);
|
||||
}
|
||||
});
|
||||
y[0].backgroundColor = maxA;
|
||||
y[1].backgroundColor = self._getColor(1);
|
||||
//var zipped_2 = _.zip.apply(null, [y[1].data, y[0].data]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (self.resModel == 'project.consultant.hrs.report') {
|
||||
var groupedData3 = _.groupBy(data.datasets, x => x.label.replace('Actual Hours for period', '').replace('Budgeted Hours for period', ''));
|
||||
_.map(groupedData3, (y) => {
|
||||
if (y.length > 1) {
|
||||
var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]);
|
||||
var maxA = zipped_1.map((a) => {
|
||||
if ((!!a[0] && !!a[1]) && a[0] > a[1]) {
|
||||
return 'red';
|
||||
} else if ((!!a[0] && !!a[1]) && a[0] < a[1]) {
|
||||
return self._getColor(0);
|
||||
}
|
||||
else {
|
||||
return self._getColor(0);
|
||||
}
|
||||
});
|
||||
y[0].backgroundColor = maxA;
|
||||
y[1].backgroundColor = self._getColor(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
data.datasets.forEach(function (dataset, index) {
|
||||
// used when stacked
|
||||
dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined;
|
||||
// set dataset color
|
||||
/*if (self.state.stacked && dataset.label.includes("Actual")) {
|
||||
dataset.stack = 1;
|
||||
}*/
|
||||
if ((self.resModel == 'project.budget.hrs.report') || (self.resModel == 'project.timeline.report')) {
|
||||
if (dataset.label.indexOf("Actual") === -1 && dataset.label.toLowerCase().indexOf("Budgeted") === -1) {
|
||||
var color = self._getColor(index);
|
||||
dataset.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
else if (self.resModel == 'project.budget.amt.report') {
|
||||
if (dataset.label.indexOf("Actual Cost") === -1 && dataset.label.toLowerCase().indexOf("Budgeted Revenue") === -1) {
|
||||
var color = self._getColor(index);
|
||||
dataset.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
else if (self.resModel == 'project.consultant.hrs.report') {
|
||||
if (dataset.label.indexOf("Actual Hours for period") === -1 && dataset.label.toLowerCase().indexOf("Budgeted Hours for period") === -1) {
|
||||
var color = self._getColor(index);
|
||||
dataset.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
else {
|
||||
var color = self._getColor(index);
|
||||
dataset.backgroundColor = color;
|
||||
}
|
||||
});
|
||||
|
||||
// prepare options
|
||||
var options = this._prepare_HBar_Options(data.datasets.length);
|
||||
// create chart
|
||||
var ctx = document.getElementById(this.chartId);
|
||||
this.chart = new Chart(ctx, {
|
||||
type: 'horizontalBar',
|
||||
data: data,
|
||||
options: options,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Sort datapoints according to the current order (ASC or DESC).
|
||||
*
|
||||
* Note: this should be moved to the model at some point.
|
||||
*
|
||||
* @private
|
||||
* @param {Object[]} dataPoints
|
||||
* @returns {Object[]} sorted dataPoints if orderby set on state
|
||||
*/
|
||||
_sortDataPoints(dataPoints) {
|
||||
if (!Object.keys(this.state.timeRanges).length && this.state.orderBy &&
|
||||
['bar', 'horizontalBar', 'line'].includes(this.state.mode) && this.state.groupBy.length) {
|
||||
// group data by their x-axis value, and then sort datapoints
|
||||
// based on the sum of values by group in ascending/descending order
|
||||
const groupByFieldName = this.state.groupBy[0].split(':')[0];
|
||||
const groupedByMany2One = this.fields[groupByFieldName].type === 'many2one';
|
||||
const groupedDataPoints = {};
|
||||
dataPoints.forEach(function (dataPoint) {
|
||||
const key = groupedByMany2One ? dataPoint.resId : dataPoint.labels[0];
|
||||
groupedDataPoints[key] = groupedDataPoints[key] || [];
|
||||
groupedDataPoints[key].push(dataPoint);
|
||||
});
|
||||
dataPoints = _.sortBy(groupedDataPoints, function (group) {
|
||||
return group.reduce((sum, dataPoint) => sum + dataPoint.value, 0);
|
||||
});
|
||||
dataPoints = dataPoints.flat();
|
||||
if (this.state.orderBy === 'desc') {
|
||||
dataPoints = dataPoints.reverse('value');
|
||||
}
|
||||
}
|
||||
return dataPoints;
|
||||
},
|
||||
|
||||
|
||||
});
|
||||
});
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
|
||||
<t t-name="GraphView.buttons">
|
||||
<div class="btn-group" role="toolbar" aria-label="Main actions"/>
|
||||
<div class="btn-group" role="toolbar" aria-label="Change graph">
|
||||
<button class="btn btn-secondary fa fa-bar-chart-o o_graph_button" title="Bar Chart" aria-label="Bar Chart" data-mode="bar"/>
|
||||
<button class="btn btn-secondary fa fa-bars o_graph_button" title="Horizontal Bar Chart" aria-label="Horizontal Bar Chart" data-mode="horizontalBar"/>
|
||||
<button class="btn btn-secondary fa fa-area-chart o_graph_button" title="Line Chart" aria-label="Line Chart" data-mode="line"/>
|
||||
<button class="btn btn-secondary fa fa-pie-chart o_graph_button" title="Pie Chart" aria-label="Pie Chart" data-mode="pie"/>
|
||||
</div>
|
||||
<div class="btn-group" role="toolbar" aria-label="Change graph">
|
||||
<button class="btn btn-secondary fa fa-database o_graph_button" title="Stacked" aria-label="Stacked" data-mode="stack"/>
|
||||
</div>
|
||||
<div class="btn-group" role="toolbar" aria-label="Sort graph">
|
||||
<button class="btn btn-secondary fa fa-sort-amount-desc o_graph_button" title="Descending" aria-label="Descending" data-order="desc"/>
|
||||
<button class="btn btn-secondary fa fa-sort-amount-asc o_graph_button" title="Ascending" aria-label="Ascending" data-order="asc"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
|
||||
<template id="assets_backend" inherit_id="web.assets_backend" priority="99">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/project_report/static/src/js/graph_controller.js" />
|
||||
<script type="text/javascript" src="/project_report/static/src/js/graph_renderer.js" />
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
|
@ -8,7 +8,8 @@
|
|||
'website': '',
|
||||
'license': '',
|
||||
'depends': ['base', 'project'],
|
||||
'data': ['views/sub_project.xml'],
|
||||
'data': ['views/sub_project.xml',
|
||||
'security/ir.model.access.csv'],
|
||||
'demo': [''],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
|
|
|
@ -3,7 +3,32 @@ from odoo import api, fields, models
|
|||
|
||||
class SubProject(models.Model):
|
||||
_inherit = "project.project"
|
||||
_description = "Sub Project"
|
||||
# _description = "Sub Project"
|
||||
|
||||
is_sub_project = fields.Boolean("Is Sub Project")
|
||||
parent_project = fields.Many2one('project.project', string='Parent Project')
|
||||
sub_project = fields.Many2many('project.project', 'project_subproject_rel', 'project_id', 'id',
|
||||
domain="[('is_sub_project', '=', True),('parent_project', '=', False)]", string='Sub Project')
|
||||
parent_project = fields.Many2one('project.project', domain="[('is_sub_project', '=', False)]", string='Parent Project')
|
||||
|
||||
@api.onchange('parent_project', 'sub_project')
|
||||
def onchange_parent_project_sub_project(self):
|
||||
user_list = []
|
||||
if self.parent_project.sub_project:
|
||||
user_list = self.parent_project.sub_project.ids
|
||||
user_list.append(self._origin.id)
|
||||
self.parent_project.sub_project = user_list
|
||||
if self.sub_project:
|
||||
for rec in self.sub_project:
|
||||
#print('AAAAAAAAAAA', rec, rec._origin.id)
|
||||
sub_project = self.env['project.project'].search([('id', '=', rec._origin.id)], limit=1)
|
||||
#print('BBBBBBBBBBB', sub_project, self._origin.id)
|
||||
sub_project.write({'parent_project': self._origin.id})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class InheritProjectTask(models.Model):
|
||||
_inherit = 'project.task'
|
||||
|
||||
sub_project = fields.Many2many('project.project', related='project_id.sub_project', string='Sub Project')
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_project_project_puser,project.project,model_project_project,project.group_project_user,1,1,0,0
|
|
|
@ -8,19 +8,96 @@
|
|||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<data>
|
||||
<xpath expr="//page[@name='settings']" position="after">
|
||||
<page name="sub_project" string="Sub Project">
|
||||
<xpath expr="//field[@name='allowed_internal_user_ids']" position="after">
|
||||
<field name="is_sub_project" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='allowed_internal_user_ids']" position="after">
|
||||
<field name="parent_project" attrs="{'invisible': [('is_sub_project', '=', False)]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='allowed_internal_user_ids']" position="after">
|
||||
<field name="sub_project" widget="many2many_tags" attrs="{'invisible': [('is_sub_project', '=', True)]}"
|
||||
options="{'no_open': True, 'no_create': True, 'no_create_edit': True}"/>
|
||||
</xpath>
|
||||
<!--<xpath expr="//page[@name='settings']" position="after">
|
||||
<page name="sub_project" string="Sub Project"
|
||||
attrs="{'invisible': [('is_sub_project', '=', True)]}">
|
||||
<group>
|
||||
<field name="is_sub_project"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="parent_project" attrs="{'invisible': [('is_sub_project', '=', False)], 'required': [('is_sub_project', '=', True)]}"
|
||||
<field name="sub_project" widget="many2many_tags"
|
||||
options="{'no_open': True, 'no_create': True, 'no_create_edit': True}"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</xpath>-->
|
||||
|
||||
</data>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="inherit2_view_task_tree2" model="ir.ui.view">
|
||||
<field name="name">project.task.tree.inherit</field>
|
||||
<field name="model">project.task</field>
|
||||
<field name="inherit_id" ref="project.view_task_tree2"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='stage_id']" position="before">
|
||||
<field name="sub_project" widget="many2many_tags"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!--<record id="inherit_view_sub_project_tree" model="ir.ui.view">
|
||||
<field name="name">project.project.tree.inherit</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.view_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='partner_id']" position="after">
|
||||
</xpath>
|
||||
</field>
|
||||
</record>-->
|
||||
|
||||
<record id="view_sub_project" model="ir.ui.view">
|
||||
<field name="name">project.sub.project.tree</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-muted="active == False" string="Projects" delete="0" multi_edit="1" sample="1">
|
||||
<field name="sequence" optional="show" widget="handle"/>
|
||||
<field name="message_needaction" invisible="1"/>
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="name" string="Name" class="font-weight-bold"/>
|
||||
<field name="user_id" optional="show" string="Project Manager" widget="many2one_avatar_user"/>
|
||||
<field name="partner_id" optional="show" string="Customer"/>
|
||||
<!-- custom field -->
|
||||
<field name="parent_project"/>
|
||||
<!-- -->
|
||||
<field name="analytic_account_id" optional="hide"/>
|
||||
<field name="privacy_visibility" optional="hide"/>
|
||||
<field name="subtask_project_id" optional="hide"/>
|
||||
<field name="label_tasks" optional="hide"/>
|
||||
<field name="company_id" optional="show" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- SUB PROJECT VIEW-->
|
||||
<record id="action_sub_project" model="ir.actions.act_window">
|
||||
<field name="name">Sub Project</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">project.project</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_id" ref="view_sub_project"/>
|
||||
<!--<field name="search_view_id" ref="sale_order_view_search_inherit_sale"/>-->
|
||||
<field name="context">{'default_is_sub_project': True}</field>
|
||||
<field name="domain">[('is_sub_project', '=', True)]</field>
|
||||
<!--<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new sub project
|
||||
</p>
|
||||
</field>-->
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_sub_project"
|
||||
name="Sub Project"
|
||||
action="action_sub_project"
|
||||
parent="project.menu_main_pm"
|
||||
sequence="2"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
Loading…
Reference in New Issue