Merge branch 'development' into 'master'

Development

See merge request prakash.jain/cor-odoo!138
This commit is contained in:
prakash.jain 2021-03-01 00:28:51 -08:00
commit ce7b0a53fc
64 changed files with 4652 additions and 187 deletions

View File

@ -3,4 +3,4 @@
from . import controllers
from . import models
from . import wizard
#from . import report
from . import report

View File

@ -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': [

23
cor_custom/data/mail_data.xml Executable file
View File

@ -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>

View File

@ -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

View File

@ -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()

View File

@ -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})

View File

@ -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"""

View File

@ -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

View File

@ -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,))

View File

@ -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>

View File

@ -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>

View File

@ -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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_cor_custom_cor_custom access_consultant_timesheet_hrs_puser cor_custom.cor_custom consultant.timesheet.hrs model_cor_custom_cor_custom model_consultant_timesheet_hrs base.group_user project.group_project_user 1 1 1 1 0
3 access_consultant_timesheet_hrs_pmanager consultant.timesheet.hrs model_consultant_timesheet_hrs project.group_project_manager 1 1 1 1
4 access_project_consultant_hrs_puser project.consultant.hrs model_project_consultant_hrs project.group_project_user 1 0 0 0
5 access_project_consultant_hrs_pmanager project.consultant.hrs model_project_consultant_hrs project.group_project_manager 1 1 1 1
6 access_model_project_multi_budget_assign_puser project.multi.budget.assign model_project_multi_budget_assign project.group_project_user 1 0 0 0
7 access_model_project_multi_budget_assign_pmanager project.multi.budget.assign model_project_multi_budget_assign project.group_project_manager 1 1 1 1
8 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
9 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
10 access_project_consultant_hrs_report_puser project.consultant.hrs.report model_project_consultant_hrs_report project.group_project_user 1 0 0 0
11 access_project_consultant_hrs_report_pmanager project.consultant.hrs.report model_project_consultant_hrs_report project.group_project_manager 1 1 1 1
12 access_custom_project_tags_puser custom.project.tags model_custom_project_tags project.group_project_user 1 1 1 1
13

View File

@ -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;}
}

View File

@ -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 &gt; 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 &gt; 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>

9
cor_custom/views/assets.xml Executable file
View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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), '&amp;', ('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), '&amp;', ('pricing_type', '!=', 'fixed_rate'), ('bill_type', '!=', 'customer_task')], 'required': ['&amp;', ('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>

View File

@ -3,3 +3,4 @@
from . import project_create_sale_order
from . import crm_opportunity_to_quotation
from . import project_multi_budget_assign

View File

@ -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

View File

@ -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>

109
mail_outbound_static/README.rst Executable file
View File

@ -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.

View File

@ -0,0 +1,4 @@
# Copyright 2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
from . import models

View File

@ -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"],
}

74
mail_outbound_static/i18n/es.po Executable file
View File

@ -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á"

View File

@ -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 ""

View File

@ -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

View File

@ -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()

View File

@ -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>

View File

@ -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.

View File

View File

@ -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

View File

@ -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 senders 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
messages 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 messages 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 &lt;<a class="reference external" href="mailto:frederic.garbely&#64;braintec-group.com">frederic.garbely&#64;braintec-group.com</a>&gt;</li>
<li>Dave Lasley &lt;<a class="reference external" href="mailto:dave&#64;laslabs.com">dave&#64;laslabs.com</a>&gt;</li>
<li>Lorenzo Battistini &lt;<a class="reference external" href="https://github.com/eLBati">https://github.com/eLBati</a>&gt;</li>
<li>Katherine Zaoral &lt;<a class="reference external" href="mailto:kz&#64;adhoc.com.ar">kz&#64;adhoc.com.ar</a>&gt;</li>
<li>Juan José Scarafía &lt;<a class="reference external" href="mailto:jjs&#64;adhoc.com.ar">jjs&#64;adhoc.com.ar</a>&gt;</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>

View File

@ -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

View File

@ -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==--

View File

@ -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"

View File

@ -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>

View File

@ -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
)

View File

@ -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">

0
project_forecast/i18n/he_IL .po Normal file → Executable file
View File

View File

@ -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'

View File

@ -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

View File

@ -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>

View File

@ -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>

0
project_maintenance/i18n/he_IL.po Normal file → Executable file
View File

View File

@ -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,

View File

@ -3,3 +3,4 @@
from . import project_budget_hrs_analysis
from . import project_budget_amt_analysis
from . import project_timeline_report

View File

@ -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,))

View File

@ -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"

View File

@ -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,))

View File

@ -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"

View File

@ -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,))

View File

@ -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>

View File

@ -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>

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_project_budget_hrs_report_user project.budget.hrs.report.user model_project_budget_hrs_report project.group_project_user 1 0 0 0
6 access_project_budget_amt_report_user project.budget.amt.report.user model_project_budget_amt_report project.group_project_user 1 0 0 0
7 access_project_create_expense_user access_project_create_expense_project_user model_project_create_expense project.group_project_user 1 0 0 0
8 access_project_timeline_report_manager project.timeline.report model_project_timeline_report project.group_project_manager 1 1 1 1
9 access_project_timeline_report_user project.timeline.report model_project_timeline_report project.group_project_user 1 0 0 0

View File

@ -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 });
}
}
},
});
});

View File

@ -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;
},
});
});

View File

@ -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>

11
project_report/views/assets.xml Executable file
View File

@ -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>

View File

@ -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,

View File

@ -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')

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_project_project_puser project.project model_project_project project.group_project_user 1 1 0 0

View File

@ -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>