diff --git a/addons/account/res_config.py b/addons/account/res_config.py
index bf28e900378..8980a31927e 100644
--- a/addons/account/res_config.py
+++ b/addons/account/res_config.py
@@ -105,6 +105,9 @@ class account_config_settings(osv.osv_memory):
'module_account_followup': fields.boolean('Manage customer payment follow-ups',
help='This allows to automate letters for unpaid invoices, with multi-level recalls.\n'
'-This installs the module account_followup.'),
+ 'module_product_email_template': fields.boolean('Send products tools and information at the invoice confirmation',
+ help='With this module, link your products to a template to send complete information and tools to your customer.\n'
+ 'For instance when invoicing a training, the training agenda and materials will automatically be send to your customers.'),
'group_proforma_invoices': fields.boolean('Allow pro-forma invoices',
implied_group='account.group_proforma_invoices',
help="Allows you to put invoices in pro-forma state."),
diff --git a/addons/account/res_config_view.xml b/addons/account/res_config_view.xml
index 2ef3f058071..8a5b1978ecb 100644
--- a/addons/account/res_config_view.xml
+++ b/addons/account/res_config_view.xml
@@ -183,6 +183,10 @@
+
+
+
+
diff --git a/addons/base_action_rule/__openerp__.py b/addons/base_action_rule/__openerp__.py
index 973567c613f..5dd4b057514 100644
--- a/addons/base_action_rule/__openerp__.py
+++ b/addons/base_action_rule/__openerp__.py
@@ -35,7 +35,7 @@ trigger an automatic reminder email.
""",
'author': 'OpenERP SA',
'website': 'http://www.openerp.com',
- 'depends': ['base', 'mail'],
+ 'depends': ['base', 'resource', 'mail'],
'data': [
'base_action_rule_view.xml',
'security/ir.model.access.csv',
diff --git a/addons/base_action_rule/base_action_rule.py b/addons/base_action_rule/base_action_rule.py
index 0d98030e800..58728a77aef 100644
--- a/addons/base_action_rule/base_action_rule.py
+++ b/addons/base_action_rule/base_action_rule.py
@@ -78,6 +78,11 @@ class base_action_rule(osv.osv):
"trigger date, like sending a reminder 15 minutes before a meeting."),
'trg_date_range_type': fields.selection([('minutes', 'Minutes'), ('hour', 'Hours'),
('day', 'Days'), ('month', 'Months')], 'Delay type'),
+ 'trg_date_calendar_id': fields.many2one(
+ 'resource.calendar', 'Use Calendar',
+ help='When calculating a day-based timed condition, it is possible to use a calendar to compute the date based on working days.',
+ ondelete='set null',
+ ),
'act_user_id': fields.many2one('res.users', 'Set Responsible'),
'act_followers': fields.many2many("res.partner", string="Add Followers"),
'server_action_ids': fields.many2many('ir.actions.server', string='Server Actions',
@@ -241,6 +246,18 @@ class base_action_rule(osv.osv):
data.update({'model': model.model})
return {'value': data}
+ def _check_delay(self, cr, uid, action, record, record_dt, context=None):
+ if action.trg_date_calendar_id and action.trg_date_range_type == 'day':
+ start_dt = get_datetime(record_dt)
+ action_dt = self.pool['resource.calendar'].schedule_days_get_date(
+ cr, uid, action.trg_date_calendar_id.id, action.trg_date_range,
+ day_date=start_dt, compute_leaves=True, context=context
+ )
+ else:
+ delay = DATE_RANGE_FUNCTION[action.trg_date_range_type](action.trg_date_range)
+ action_dt = get_datetime(record_dt) + delay
+ return action_dt
+
def _check(self, cr, uid, automatic=False, use_new_cursor=False, context=None):
""" This Function is called by scheduler. """
context = context or {}
@@ -267,14 +284,12 @@ class base_action_rule(osv.osv):
else:
get_record_dt = lambda record: record[date_field]
- delay = DATE_RANGE_FUNCTION[action.trg_date_range_type](action.trg_date_range)
-
# process action on the records that should be executed
for record in model.browse(cr, uid, record_ids, context=context):
record_dt = get_record_dt(record)
if not record_dt:
continue
- action_dt = get_datetime(record_dt) + delay
+ action_dt = self._check_delay(cr, uid, action, record, record_dt, context=context)
if last_run and (last_run <= action_dt < now) or (action_dt < now):
try:
self._process(cr, uid, action, [record.id], context=context)
diff --git a/addons/base_action_rule/base_action_rule_view.xml b/addons/base_action_rule/base_action_rule_view.xml
index c2262adde39..a4341d403ca 100644
--- a/addons/base_action_rule/base_action_rule_view.xml
+++ b/addons/base_action_rule/base_action_rule_view.xml
@@ -43,6 +43,8 @@
+
Select when the action must be run, and add filters and/or timing conditions.
diff --git a/addons/calendar/calendar.py b/addons/calendar/calendar.py
index 38f4e30a764..f0862601611 100644
--- a/addons/calendar/calendar.py
+++ b/addons/calendar/calendar.py
@@ -2,7 +2,7 @@
##############################################################################
#
# OpenERP, Open Source Business Applications
-# Copyright (c) 2011 OpenERP S.A.
+# Copyright (c) 2011-2014 OpenERP S.A.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
@@ -100,8 +100,15 @@ class calendar_attendee(osv.Model):
result[id][name] = attdata.event_id.date_deadline
return result
+ STATE_SELECTION = [
+ ('needsAction', 'Needs Action'),
+ ('tentative', 'Uncertain'),
+ ('declined', 'Declined'),
+ ('accepted', 'Accepted'),
+ ]
+
_columns = {
- 'state': fields.selection([('needsAction', 'Needs Action'), ('tentative', 'Uncertain'), ('declined', 'Declined'), ('accepted', 'Accepted')], 'Status', readonly=True, help="Status of the attendee's participation"),
+ 'state': fields.selection(STATE_SELECTION, 'Status', readonly=True, help="Status of the attendee's participation"),
'cn': fields.function(_compute_data, string='Common name', type="char", multi='cn', store=True),
'partner_id': fields.many2one('res.partner', 'Contact', readonly="True"),
'email': fields.char('Email', help="Email of Invited Person"),
@@ -799,7 +806,7 @@ class calendar_event(osv.Model):
'state': fields.selection([('draft', 'Unconfirmed'), ('open', 'Confirmed')], string='Status', readonly=True, track_visibility='onchange'),
'name': fields.char('Meeting Subject', required=True, states={'done': [('readonly', True)]}),
'is_attendee': fields.function(_compute, string='Attendee', type="boolean", multi='attendee'),
- 'attendee_status': fields.function(_compute, string='Attendee Status', type="selection", multi='attendee'),
+ 'attendee_status': fields.function(_compute, string='Attendee Status', type="selection", selection=calendar_attendee.STATE_SELECTION, multi='attendee'),
'display_time': fields.function(_compute, string='Event Time', type="char", multi='attendee'),
'date': fields.datetime('Date', states={'done': [('readonly', True)]}, required=True, track_visibility='onchange'),
'date_deadline': fields.datetime('End Date', states={'done': [('readonly', True)]}, required=True,),
@@ -824,14 +831,14 @@ class calendar_event(osv.Model):
'fr': fields.boolean('Fri'),
'sa': fields.boolean('Sat'),
'su': fields.boolean('Sun'),
- 'month_by': fields.selection([('date', 'Date of month'), ('day', 'Day of month')], 'Option'),
+ 'month_by': fields.selection([('date', 'Date of month'), ('day', 'Day of month')], 'Option', oldname='select1'),
'day': fields.integer('Date of month'),
'week_list': fields.selection([('MO', 'Monday'), ('TU', 'Tuesday'), ('WE', 'Wednesday'), ('TH', 'Thursday'), ('FR', 'Friday'), ('SA', 'Saturday'), ('SU', 'Sunday')], 'Weekday'),
'byday': fields.selection([('1', 'First'), ('2', 'Second'), ('3', 'Third'), ('4', 'Fourth'), ('5', 'Fifth'), ('-1', 'Last')], 'By day'),
'end_date': fields.date('Repeat Until'),
'allday': fields.boolean('All Day', states={'done': [('readonly', True)]}),
'user_id': fields.many2one('res.users', 'Responsible', states={'done': [('readonly', True)]}),
- 'color_partner_id': fields.related('user_id', 'partner_id', 'id', type="int", string="colorize", store=False), # Color of creator
+ 'color_partner_id': fields.related('user_id', 'partner_id', 'id', type="integer", string="colorize", store=False), # Color of creator
'active': fields.boolean('Active', help="If the active field is set to true, it will allow you to hide the event alarm information without removing it."),
'categ_ids': fields.many2many('calendar.event.type', 'meeting_category_rel', 'event_id', 'type_id', 'Tags'),
'attendee_ids': fields.one2many('calendar.attendee', 'event_id', 'Attendees', ondelete='cascade'),
diff --git a/addons/crm/crm_lead.py b/addons/crm/crm_lead.py
index 770b32d84fd..f0ea2623dac 100644
--- a/addons/crm/crm_lead.py
+++ b/addons/crm/crm_lead.py
@@ -761,24 +761,6 @@ class crm_lead(format_address, osv.osv):
)
return partner_id
- def _lead_set_partner(self, cr, uid, lead, partner_id, context=None):
- """
- Assign a partner to a lead.
-
- :param object lead: browse record of the lead to process
- :param int partner_id: identifier of the partner to assign
- :return bool: True if the partner has properly been assigned
- """
- res = False
- res_partner = self.pool.get('res.partner')
- if partner_id:
- res_partner.write(cr, uid, partner_id, {'section_id': lead.section_id and lead.section_id.id or False})
- contact_id = res_partner.address_get(cr, uid, [partner_id])['default']
- res = lead.write({'partner_id': partner_id}, context=context)
- message = _("Partner set to %s." % (lead.partner_id.name))
- self.message_post(cr, uid, [lead.id], body=message, context=context)
- return res
-
def handle_partner_assignation(self, cr, uid, ids, action='create', partner_id=False, context=None):
"""
Handle partner assignation during a lead conversion.
@@ -792,13 +774,16 @@ class crm_lead(format_address, osv.osv):
"""
#TODO this is a duplication of the handle_partner_assignation method of crm_phonecall
partner_ids = {}
- # If a partner_id is given, force this partner for all elements
- force_partner_id = partner_id
for lead in self.browse(cr, uid, ids, context=context):
# If the action is set to 'create' and no partner_id is set, create a new one
- if action == 'create':
- partner_id = force_partner_id or self._create_lead_partner(cr, uid, lead, context)
- self._lead_set_partner(cr, uid, lead, partner_id, context=context)
+ if lead.partner_id:
+ partner_ids[lead.id] = lead.partner_id.id
+ continue
+ if not partner_id and action == 'create':
+ partner_id = self._create_lead_partner(cr, uid, lead, context)
+ self.pool['res.partner'].write(cr, uid, partner_id, {'section_id': lead.section_id and lead.section_id.id or False})
+ if partner_id:
+ lead.write({'partner_id': partner_id}, context=context)
partner_ids[lead.id] = partner_id
return partner_ids
diff --git a/addons/crm/crm_view.xml b/addons/crm/crm_view.xml
index 8cc4b03cb41..5fa93f60d0d 100644
--- a/addons/crm/crm_view.xml
+++ b/addons/crm/crm_view.xml
@@ -316,9 +316,6 @@
groups="base.group_no_one" sequence="15"
parent="base.menu_base_config"/>
-
-
-
diff --git a/addons/crm/test/lead2opportunity_assign_salesmen.yml b/addons/crm/test/lead2opportunity_assign_salesmen.yml
index 5a750c94b72..de88a96cb50 100644
--- a/addons/crm/test/lead2opportunity_assign_salesmen.yml
+++ b/addons/crm/test/lead2opportunity_assign_salesmen.yml
@@ -66,7 +66,7 @@
-
!python {model: crm.lead2opportunity.partner.mass}: |
context.update({'active_model': 'crm.lead', 'active_ids': [ref("test_crm_lead_01"), ref("test_crm_lead_02"), ref("test_crm_lead_03"), ref("test_crm_lead_04"), ref("test_crm_lead_05"), ref("test_crm_lead_06")], 'active_id': ref("test_crm_lead_01")})
- id = self.create(cr, uid, {'user_ids': [(6, 0, [ref('test_res_user_01'), ref('test_res_user_02'), ref('test_res_user_03'), ref('test_res_user_04')])], 'section_id': ref('crm.section_sales_department')}, context=context)
+ id = self.create(cr, uid, {'user_ids': [(6, 0, [ref('test_res_user_01'), ref('test_res_user_02'), ref('test_res_user_03'), ref('test_res_user_04')])], 'section_id': ref('crm.section_sales_department'), 'deduplicate': False}, context=context)
self.mass_convert(cr, uid, [id], context=context)
-
The leads should now be opps with a salesman and a salesteam. Also, salesmen should have been assigned following a round-robin method.
diff --git a/addons/crm/wizard/crm_lead_to_opportunity.py b/addons/crm/wizard/crm_lead_to_opportunity.py
index 8ce2aa1c6c7..21a553a59ff 100644
--- a/addons/crm/wizard/crm_lead_to_opportunity.py
+++ b/addons/crm/wizard/crm_lead_to_opportunity.py
@@ -41,6 +41,22 @@ class crm_lead2opportunity_partner(osv.osv_memory):
def onchange_action(self, cr, uid, ids, action, context=None):
return {'value': {'partner_id': False if action != 'exist' else self._find_matching_partner(cr, uid, context=context)}}
+ def _get_duplicated_leads(self, cr, uid, partner_id, email, context=None):
+ lead_obj = self.pool.get('crm.lead')
+ results = []
+ if partner_id:
+ # Search for opportunities that have the same partner and that arent done or cancelled
+ ids = lead_obj.search(cr, uid, [('partner_id', '=', partner_id), '|', ('probability', '=', False), ('probability', '<', '100')])
+ for id in ids:
+ results.append(id)
+ email = re.findall(r'([^ ,<@]+@[^> ,]+)', email or '')
+ if email:
+ ids = lead_obj.search(cr, uid, [('email_from', '=ilike', email[0]), '|', ('probability', '=', False), ('probability', '<', '100')])
+ for id in ids:
+ results.append(id)
+ return list(set(results))
+
+
def default_get(self, cr, uid, fields, context=None):
"""
Default get for name, opportunity_ids.
@@ -51,24 +67,15 @@ class crm_lead2opportunity_partner(osv.osv_memory):
res = super(crm_lead2opportunity_partner, self).default_get(cr, uid, fields, context=context)
if context.get('active_id'):
- tomerge = set([int(context['active_id'])])
+ tomerge = [int(context['active_id'])]
email = False
partner_id = res.get('partner_id')
lead = lead_obj.browse(cr, uid, int(context['active_id']), context=context)
#TOFIX: use mail.mail_message.to_mail
- email = re.findall(r'([^ ,<@]+@[^> ,]+)', lead.email_from or '')
-
- if partner_id:
- # Search for opportunities that have the same partner and that arent done or cancelled
- ids = lead_obj.search(cr, uid, [('partner_id', '=', partner_id), '|', ('probability', '=', False), ('probability', '<', '100')])
- for id in ids:
- tomerge.add(id)
- if email:
- ids = lead_obj.search(cr, uid, [('email_from', '=ilike', email[0]), '|', ('probability', '=', False), ('probability', '<', '100')])
- for id in ids:
- tomerge.add(id)
+ tomerge.extend(self._get_duplicated_leads(cr, uid, partner_id, email))
+ tomerge = list(set(tomerge))
if 'action' in fields:
res.update({'action' : partner_id and 'exist' or 'create'})
@@ -77,7 +84,7 @@ class crm_lead2opportunity_partner(osv.osv_memory):
if 'name' in fields:
res.update({'name' : len(tomerge) >= 2 and 'merge' or 'convert'})
if 'opportunity_ids' in fields and len(tomerge) >= 2:
- res.update({'opportunity_ids': list(tomerge)})
+ res.update({'opportunity_ids': tomerge})
if lead.user_id:
res.update({'user_id': lead.user_id.id})
if lead.section_id:
@@ -116,11 +123,11 @@ class crm_lead2opportunity_partner(osv.osv_memory):
context = {}
lead = self.pool.get('crm.lead')
res = False
- partner_ids_map = self._create_partner(cr, uid, ids, context=context)
lead_ids = vals.get('lead_ids', [])
team_id = vals.get('section_id', False)
+ data = self.browse(cr, uid, ids, context=context)[0]
for lead_id in lead_ids:
- partner_id = partner_ids_map.get(lead_id, False)
+ partner_id = self._create_partner(cr, uid, lead_id, data.action, data.partner_id, context=context)
# FIXME: cannot pass user_ids as the salesman allocation only works in batch
res = lead.convert_opportunity(cr, uid, [lead_id], partner_id, [], team_id, context=context)
# FIXME: must perform salesman allocation in batch separately here
@@ -152,7 +159,7 @@ class crm_lead2opportunity_partner(osv.osv_memory):
return self.pool.get('crm.lead').redirect_opportunity_view(cr, uid, lead_ids[0], context=context)
- def _create_partner(self, cr, uid, ids, context=None):
+ def _create_partner(self, cr, uid, lead_id, action, partner_id, context=None):
"""
Create partner based on action.
:return dict: dictionary organized as followed: {lead_id: partner_assigned_id}
@@ -163,10 +170,14 @@ class crm_lead2opportunity_partner(osv.osv_memory):
if context is None:
context = {}
lead = self.pool.get('crm.lead')
- lead_ids = context.get('active_ids', [])
- data = self.browse(cr, uid, ids, context=context)[0]
- partner_id = data.partner_id and data.partner_id.id or False
- return lead.handle_partner_assignation(cr, uid, lead_ids, data.action, partner_id, context=context)
+ if action == 'each_exist_or_create':
+ ctx = dict(context)
+ ctx['active_id'] = lead_id
+ partner_id = self._find_matching_partner(cr, uid, context=ctx)
+ action = 'create'
+ print partner_id
+ res = lead.handle_partner_assignation(cr, uid, [lead_id], action, partner_id, context=context)
+ return res.get(lead_id)
class crm_lead2opportunity_mass_convert(osv.osv_memory):
_name = 'crm.lead2opportunity.partner.mass'
@@ -176,6 +187,15 @@ class crm_lead2opportunity_mass_convert(osv.osv_memory):
_columns = {
'user_ids': fields.many2many('res.users', string='Salesmen'),
'section_id': fields.many2one('crm.case.section', 'Sales Team'),
+ 'deduplicate': fields.boolean('Apply deduplication', help='Merge with existing leads/opportunities of each partner'),
+ 'action': fields.selection([
+ ('each_exist_or_create', 'Use existing partner or create'),
+ ('nothing', 'Do not link to a customer')
+ ], 'Related Customer', required=True),
+ }
+
+ _defaults = {
+ 'deduplicate': True,
}
def default_get(self, cr, uid, fields, context=None):
@@ -184,13 +204,37 @@ class crm_lead2opportunity_mass_convert(osv.osv_memory):
# avoid forcing the partner of the first lead as default
res['partner_id'] = False
if 'action' in fields:
- res['action'] = 'create'
+ res['action'] = 'each_exist_or_create'
if 'name' in fields:
res['name'] = 'convert'
if 'opportunity_ids' in fields:
res['opportunity_ids'] = False
return res
+ def on_change_action(self, cr, uid, ids, action, context=None):
+ vals = {}
+ if action != 'exist':
+ vals = {'value': {'partner_id': False}}
+ return vals
+
+ def on_change_deduplicate(self, cr, uid, ids, deduplicate, context=None):
+ if context is None:
+ context = {}
+ active_leads = self.pool['crm.lead'].browse(cr, uid, context['active_ids'], context=context)
+ partner_ids = [(lead.partner_id.id, lead.partner_id and lead.partner_id.email or lead.email_from) for lead in active_leads]
+ partners_duplicated_leads = {}
+ for partner_id, email in partner_ids:
+ duplicated_leads = self._get_duplicated_leads(cr, uid, partner_id, email)
+ if len(duplicated_leads) > 1:
+ partners_duplicated_leads.setdefault((partner_id, email), []).extend(duplicated_leads)
+ leads_with_duplicates = []
+ for lead in active_leads:
+ lead_tuple = (lead.partner_id.id, lead.partner_id.email if lead.partner_id else lead.email_from)
+ if len(partners_duplicated_leads.get(lead_tuple, [])) > 1:
+ leads_with_duplicates.append(lead.id)
+ return {'value': {'opportunity_ids': leads_with_duplicates}}
+
+
def _convert_opportunity(self, cr, uid, ids, vals, context=None):
"""
When "massively" (more than one at a time) converting leads to
@@ -208,6 +252,21 @@ class crm_lead2opportunity_mass_convert(osv.osv_memory):
return super(crm_lead2opportunity_mass_convert, self)._convert_opportunity(cr, uid, ids, vals, context=context)
def mass_convert(self, cr, uid, ids, context=None):
- return self.action_apply(cr, uid, ids, context=context)
+ data = self.browse(cr, uid, ids, context=context)[0]
+ ctx = dict(context)
+ if data.name == 'convert' and data.deduplicate:
+ merged_lead_ids = []
+ remaining_lead_ids = []
+ for lead in self.pool['crm.lead'].browse(cr, uid, context.get('active_ids', []), context=context):
+ duplicated_lead_ids = self._get_duplicated_leads(cr, uid, lead.partner_id.id, lead.partner_id and lead.partner_id.email or lead.email_from)
+ if len(duplicated_lead_ids) > 1:
+ lead_id = self.pool.get('crm.lead').merge_opportunity(cr, uid, duplicated_lead_ids, False, False, context=context)
+ merged_lead_ids.extend(duplicated_lead_ids)
+ remaining_lead_ids.append(lead_id)
+ active_ids = set(context.get('active_ids', []))
+ active_ids = active_ids.difference(merged_lead_ids)
+ active_ids = active_ids.union(remaining_lead_ids)
+ ctx['active_ids'] = list(active_ids)
+ return self.action_apply(cr, uid, ids, context=ctx)
# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
diff --git a/addons/crm/wizard/crm_lead_to_opportunity_view.xml b/addons/crm/wizard/crm_lead_to_opportunity_view.xml
index 09506c3ed81..58fbe244ba4 100644
--- a/addons/crm/wizard/crm_lead_to_opportunity_view.xml
+++ b/addons/crm/wizard/crm_lead_to_opportunity_view.xml
@@ -52,21 +52,17 @@
+
+
+ Online Training
+
+ 500
+ 900
+ service
+
+
+
+
+
+
+
diff --git a/addons/product_email_template/models/__init__.py b/addons/product_email_template/models/__init__.py
new file mode 100644
index 00000000000..2edb825574d
--- /dev/null
+++ b/addons/product_email_template/models/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+import product
+import invoice
diff --git a/addons/product_email_template/models/invoice.py b/addons/product_email_template/models/invoice.py
new file mode 100644
index 00000000000..d9e82e42012
--- /dev/null
+++ b/addons/product_email_template/models/invoice.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+
+from openerp.osv import osv
+
+
+class account_invoice(osv.Model):
+ _inherit = 'account.invoice'
+
+ def invoice_validate_send_email(self, cr, uid, ids, context=None):
+ Composer = self.pool['mail.compose.message']
+ for invoice in self.browse(cr, uid, ids, context=context):
+ # send template only on customer invoice
+ if invoice.type != 'out_invoice':
+ continue
+ # subscribe the partner to the invoice
+ if invoice.partner_id.id not in invoice.message_follower_ids:
+ self.message_subscribe(cr, uid, [invoice.id], [invoice.partner_id.id], context=context)
+ for line in invoice.invoice_line:
+ if line.product_id.email_template_id:
+ # CLEANME: should define and use a clean API: message_post with a template
+ composer_id = Composer.create(cr, uid, {
+ 'model': 'account.invoice',
+ 'res_id': invoice.id,
+ 'template_id': line.product_id.email_template_id.id,
+ 'composition_mode': 'comment',
+ }, context=context)
+ template_values = Composer.onchange_template_id(
+ cr, uid, composer_id, line.product_id.email_template_id.id, 'comment', 'account.invoice', invoice.id
+ )['value']
+ template_values['attachment_ids'] = [(4, id) for id in template_values.get('attachment_ids', '[]')]
+ Composer.write(cr, uid, [composer_id], template_values, context=context)
+ Composer.send_mail(cr, uid, [composer_id], context=context)
+ return True
+
+ def invoice_validate(self, cr, uid, ids, context=None):
+ res = super(account_invoice, self).invoice_validate(cr, uid, ids, context=context)
+ self.invoice_validate_send_email(cr, uid, ids, context=context)
+ return res
diff --git a/addons/product_email_template/models/product.py b/addons/product_email_template/models/product.py
new file mode 100644
index 00000000000..cafdef7ab9d
--- /dev/null
+++ b/addons/product_email_template/models/product.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+
+from openerp.osv import fields, osv
+
+
+class product_template(osv.Model):
+ """ Product Template inheritance to add an optional email.template to a
+ product.template. When validating an invoice, an email will be send to the
+ customer based on this template. The customer will receive an email for each
+ product linked to an email template. """
+ _inherit = "product.template"
+
+ _columns = {
+ 'email_template_id': fields.many2one(
+ 'email.template', 'Product Email Template',
+ help='When validating an invoice, an email will be send to the customer'
+ 'based on this template. The customer will receive an email for each'
+ 'product linked to an email template.'),
+ }
diff --git a/addons/product_email_template/views/email_template_view.xml b/addons/product_email_template/views/email_template_view.xml
new file mode 100644
index 00000000000..5b4600b9719
--- /dev/null
+++ b/addons/product_email_template/views/email_template_view.xml
@@ -0,0 +1,24 @@
+
+
+
+
+ email.template.form.simplified
+ email.template
+ 100
+
+
+
+
+
+
diff --git a/addons/product_email_template/views/product_view.xml b/addons/product_email_template/views/product_view.xml
new file mode 100644
index 00000000000..4d036fb8de0
--- /dev/null
+++ b/addons/product_email_template/views/product_view.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ product.normal.procurement.locations.inherit
+ product.product
+
+
+
+
+
+
+
+
+
diff --git a/addons/resource/resource.py b/addons/resource/resource.py
index 212a7dd6828..cbaf15147d1 100644
--- a/addons/resource/resource.py
+++ b/addons/resource/resource.py
@@ -2,7 +2,7 @@
##############################################################################
#
# OpenERP, Open Source Management Solution
-# Copyright (C) 2004-2009 Tiny SPRL ().
+# Copyright (C) 2004-TODAY OpenERP SA (http://www.openerp.com)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
@@ -19,304 +19,613 @@
#
##############################################################################
-import pytz
-from datetime import datetime, timedelta
+import datetime
from dateutil import rrule
-import math
+from dateutil.relativedelta import relativedelta
+from operator import itemgetter
+
from faces import *
+from openerp import tools
from openerp.osv import fields, osv
from openerp.tools.float_utils import float_compare
from openerp.tools.translate import _
-from itertools import groupby
-from operator import itemgetter
-
class resource_calendar(osv.osv):
+ """ Calendar model for a resource. It has
+
+ - attendance_ids: list of resource.calendar.attendance that are a working
+ interval in a given weekday.
+ - leave_ids: list of leaves linked to this calendar. A leave can be general
+ or linked to a specific resource, depending on its resource_id.
+
+ All methods in this class use intervals. An interval is a tuple holding
+ (begin_datetime, end_datetime). A list of intervals is therefore a list of
+ tuples, holding several intervals of work or leaves. """
_name = "resource.calendar"
_description = "Resource Calendar"
+
_columns = {
- 'name' : fields.char("Name", size=64, required=True),
- 'company_id' : fields.many2one('res.company', 'Company', required=False),
- 'attendance_ids' : fields.one2many('resource.calendar.attendance', 'calendar_id', 'Working Time'),
- 'manager' : fields.many2one('res.users', 'Workgroup Manager'),
+ 'name': fields.char("Name", size=64, required=True),
+ 'company_id': fields.many2one('res.company', 'Company', required=False),
+ 'attendance_ids': fields.one2many('resource.calendar.attendance', 'calendar_id', 'Working Time'),
+ 'manager': fields.many2one('res.users', 'Workgroup Manager'),
+ 'leave_ids': fields.one2many(
+ 'resource.calendar.leaves', 'calendar_id', 'Leaves',
+ help=''
+ ),
}
_defaults = {
'company_id': lambda self, cr, uid, context: self.pool.get('res.company')._company_default_get(cr, uid, 'resource.calendar', context=context)
}
- def working_hours_on_day(self, cr, uid, resource_calendar_id, day, context=None):
- """Calculates the Working Total Hours based on Resource Calendar and
- given working day (datetime object).
+ # --------------------------------------------------
+ # Utility methods
+ # --------------------------------------------------
- @param resource_calendar_id: resource.calendar browse record
- @param day: datetime object
+ def interval_clean(self, intervals):
+ """ Utility method that sorts and removes overlapping inside datetime
+ intervals. The intervals are sorted based on increasing starting datetime.
+ Overlapping intervals are merged into a single one.
- @return: returns the working hours (as float) men should work on the given day if is in the attendance_ids of the resource_calendar_id (i.e if that day is a working day), returns 0.0 otherwise
- """
- res = 0.0
- for working_day in resource_calendar_id.attendance_ids:
- if (int(working_day.dayofweek) + 1) == day.isoweekday():
- res += working_day.hour_to - working_day.hour_from
- return res
+ :param list intervals: list of intervals; each interval is a tuple
+ (datetime_from, datetime_to)
+ :return list cleaned: list of sorted intervals without overlap """
+ intervals = sorted(intervals, key=itemgetter(0)) # sort on first datetime
+ cleaned = []
+ working_interval = None
+ while intervals:
+ current_interval = intervals.pop(0)
+ if not working_interval: # init
+ working_interval = [current_interval[0], current_interval[1]]
+ elif working_interval[1] < current_interval[0]: # interval is disjoint
+ cleaned.append(tuple(working_interval))
+ working_interval = [current_interval[0], current_interval[1]]
+ elif working_interval[1] < current_interval[1]: # union of greater intervals
+ working_interval[1] = current_interval[1]
+ if working_interval: # handle void lists
+ cleaned.append(tuple(working_interval))
+ return cleaned
- def _get_leaves(self, cr, uid, id, resource):
- """Private Method to Calculate resource Leaves days
+ def interval_remove_leaves(self, interval, leave_intervals):
+ """ Utility method that remove leave intervals from a base interval:
- @param id: resource calendar id
- @param resource: resource id for which leaves will ew calculated
+ - clean the leave intervals, to have an ordered list of not-overlapping
+ intervals
+ - initiate the current interval to be the base interval
+ - for each leave interval:
- @return : returns the list of dates, where resource on leave in
- resource.calendar.leaves object (e.g.['%Y-%m-%d', '%Y-%m-%d'])
- """
- resource_cal_leaves = self.pool.get('resource.calendar.leaves')
- dt_leave = []
- resource_leave_ids = resource_cal_leaves.search(cr, uid, [('calendar_id','=',id), '|', ('resource_id','=',False), ('resource_id','=',resource)])
- #res_leaves = resource_cal_leaves.read(cr, uid, resource_leave_ids, ['date_from', 'date_to'])
- res_leaves = resource_cal_leaves.browse(cr, uid, resource_leave_ids)
+ - finishing before the current interval: skip, go to next
+ - beginning after the current interval: skip and get out of the loop
+ because we are outside range (leaves are ordered)
+ - beginning within the current interval: close the current interval
+ and begin a new current interval that begins at the end of the leave
+ interval
+ - ending within the current interval: update the current interval begin
+ to match the leave interval ending
- for leave in res_leaves:
- dtf = datetime.strptime(leave.date_from, '%Y-%m-%d %H:%M:%S')
- dtt = datetime.strptime(leave.date_to, '%Y-%m-%d %H:%M:%S')
- no = dtt - dtf
- [dt_leave.append((dtf + timedelta(days=x)).strftime('%Y-%m-%d')) for x in range(int(no.days + 1))]
- dt_leave.sort()
-
- return dt_leave
-
- def interval_min_get(self, cr, uid, id, dt_from, hours, resource=False):
- """
- Calculates the working Schedule from supplied from date to till hours
- will be satisfied based or resource calendar id. If resource is also
- given then it will consider the resource leave also and than will
- calculates resource working schedule
-
- @param dt_from: datetime object, start of working scheduled
- @param hours: float, total number working hours needed scheduled from
- start date
- @param resource : Optional Resource id, if supplied than resource leaves
- will also taken into consideration for calculating working
- schedule.
- @return : List datetime object of working schedule based on supplies
- params
- """
- if not id:
- td = int(hours)*3
- return [(dt_from - timedelta(hours=td), dt_from)]
- dt_leave = self._get_leaves(cr, uid, id, resource)
- dt_leave.reverse()
- todo = hours
- result = []
- maxrecur = 100
- current_hour = dt_from.hour
- while float_compare(todo, 0, 4) and maxrecur:
- cr.execute("select hour_from,hour_to from resource_calendar_attendance where dayofweek='%s' and calendar_id=%s order by hour_from desc", (dt_from.weekday(),id))
- for (hour_from,hour_to) in cr.fetchall():
- leave_flag = False
- if (hour_fromtodo:
- hour_from = m-todo
- dt_check = dt_from.strftime('%Y-%m-%d')
- for leave in dt_leave:
- if dt_check == leave:
- dt_check = datetime.strptime(dt_check, '%Y-%m-%d') + timedelta(days=1)
- leave_flag = True
- if leave_flag:
- break
- else:
- d1 = datetime(dt_from.year, dt_from.month, dt_from.day, int(math.floor(hour_from)), int((hour_from%1) * 60))
- d2 = datetime(dt_from.year, dt_from.month, dt_from.day, int(math.floor(m)), int((m%1) * 60))
- result.append((d1, d2))
- current_hour = hour_from
- todo -= (m-hour_from)
- dt_from -= timedelta(days=1)
- current_hour = 24
- maxrecur -= 1
- result.reverse()
- return result
-
- # def interval_get(self, cr, uid, id, dt_from, hours, resource=False, byday=True):
- def interval_get_multi(self, cr, uid, date_and_hours_by_cal, resource=False, byday=True):
- def group(lst, key):
- lst.sort(key=itemgetter(key))
- grouped = groupby(lst, itemgetter(key))
- return dict([(k, [v for v in itr]) for k, itr in grouped])
- # END group
-
- cr.execute("select calendar_id, dayofweek, hour_from, hour_to from resource_calendar_attendance order by hour_from")
- hour_res = cr.dictfetchall()
- hours_by_cal = group(hour_res, 'calendar_id')
-
- results = {}
-
- for d, hours, id in date_and_hours_by_cal:
- dt_from = datetime.strptime(d, '%Y-%m-%d %H:%M:%S')
- if not id:
- td = int(hours)*3
- results[(d, hours, id)] = [(dt_from, dt_from + timedelta(hours=td))]
+ :param tuple interval: a tuple (beginning datetime, ending datetime) that
+ is the base interval from which the leave intervals
+ will be removed
+ :param list leave_intervals: a list of tuples (beginning datetime, ending datetime)
+ that are intervals to remove from the base interval
+ :return list intervals: a list of tuples (begin datetime, end datetime)
+ that are the remaining valid intervals """
+ if not interval:
+ return interval
+ if leave_intervals is None:
+ leave_intervals = []
+ intervals = []
+ leave_intervals = self.interval_clean(leave_intervals)
+ current_interval = [interval[0], interval[1]]
+ for leave in leave_intervals:
+ if leave[1] <= current_interval[0]:
continue
+ if leave[0] >= current_interval[1]:
+ break
+ if current_interval[0] < leave[0] < current_interval[1]:
+ current_interval[1] = leave[0]
+ intervals.append((current_interval[0], current_interval[1]))
+ current_interval = [leave[1], interval[1]]
+ # if current_interval[0] <= leave[1] <= current_interval[1]:
+ if current_interval[0] <= leave[1]:
+ current_interval[0] = leave[1]
+ if current_interval and current_interval[0] < interval[1]: # remove intervals moved outside base interval due to leaves
+ intervals.append((current_interval[0], current_interval[1]))
+ return intervals
- dt_leave = self._get_leaves(cr, uid, id, resource)
- todo = hours
- result = []
- maxrecur = 100
- current_hour = dt_from.hour
- while float_compare(todo, 0, 4) and maxrecur:
- for (hour_from,hour_to) in [(item['hour_from'], item['hour_to']) for item in hours_by_cal[id] if item['dayofweek'] == str(dt_from.weekday())]:
- leave_flag = False
- if (hour_to>current_hour) and float_compare(todo, 0, 4):
- m = max(hour_from, current_hour)
- if (hour_to-m)>todo:
- hour_to = m+todo
- dt_check = dt_from.strftime('%Y-%m-%d')
- for leave in dt_leave:
- if dt_check == leave:
- dt_check = datetime.strptime(dt_check, '%Y-%m-%d') + timedelta(days=1)
- leave_flag = True
- if leave_flag:
- break
- else:
- d1 = datetime(dt_from.year, dt_from.month, dt_from.day) + timedelta(hours=int(math.floor(m)), minutes=int((m%1) * 60))
- d2 = datetime(dt_from.year, dt_from.month, dt_from.day) + timedelta(hours=int(math.floor(hour_to)), minutes=int((hour_to%1) * 60))
- result.append((d1, d2))
- current_hour = hour_to
- todo -= (hour_to - m)
- dt_from += timedelta(days=1)
- current_hour = 0
- maxrecur -= 1
- results[(d, hours, id)] = result
+ def interval_schedule_hours(self, intervals, hour, remove_at_end=True):
+ """ Schedule hours in intervals. The last matching interval is truncated
+ to match the specified hours.
+
+ It is possible to truncate the last interval at its beginning or ending.
+ However this does nothing on the given interval order that should be
+ submitted accordingly.
+
+ :param list intervals: a list of tuples (beginning datetime, ending datetime)
+ :param int/float hours: number of hours to schedule. It will be converted
+ into a timedelta, but should be submitted as an
+ int or float.
+ :param boolean remove_at_end: remove extra hours at the end of the last
+ matching interval. Otherwise, do it at the
+ beginning.
+
+ :return list results: a list of intervals. If the number of hours to schedule
+ is greater than the possible scheduling in the intervals, no extra-scheduling
+ is done, and results == intervals. """
+ results = []
+ res = datetime.timedelta()
+ limit = datetime.timedelta(hours=hour)
+ for interval in intervals:
+ res += interval[1] - interval[0]
+ if res > limit and remove_at_end:
+ interval = (interval[0], interval[1] + relativedelta(seconds=(limit-res).total_seconds()))
+ elif res > limit:
+ interval = (interval[0] + relativedelta(seconds=(res-limit).total_seconds()), interval[1])
+ results.append(interval)
+ if res > limit:
+ break
return results
- def interval_get(self, cr, uid, id, dt_from, hours, resource=False, byday=True):
- """Calculates Resource Working Internal Timing Based on Resource Calendar.
+ # --------------------------------------------------
+ # Date and hours computation
+ # --------------------------------------------------
- @param dt_from: start resource schedule calculation.
- @param hours : total number of working hours to be scheduled.
- @param resource: optional resource id, If supplied it will take care of
- resource leave while scheduling.
- @param byday: boolean flag bit enforce day wise scheduling
+ def get_attendances_for_weekdays(self, cr, uid, id, weekdays, context=None):
+ """ Given a list of weekdays, return matching resource.calendar.attendance"""
+ calendar = self.browse(cr, uid, id, context=None)
+ return [att for att in calendar.attendance_ids if int(att.dayofweek) in weekdays]
- @return : list of scheduled working timing based on resource calendar.
+ def get_weekdays(self, cr, uid, id, default_weekdays=None, context=None):
+ """ Return the list of weekdays that contain at least one working interval.
+ If no id is given (no calendar), return default weekdays. """
+ if id is None:
+ return default_weekdays if default_weekdays is not None else [0, 1, 2, 3, 4]
+ calendar = self.browse(cr, uid, id, context=None)
+ weekdays = set()
+ for attendance in calendar.attendance_ids:
+ weekdays.add(int(attendance.dayofweek))
+ return list(weekdays)
+
+ def get_next_day(self, cr, uid, id, day_date, context=None):
+ """ Get following date of day_date, based on resource.calendar. If no
+ calendar is provided, just return the next day.
+
+ :param int id: id of a resource.calendar. If not given, simply add one day
+ to the submitted date.
+ :param date day_date: current day as a date
+
+ :return date: next day of calendar, or just next day """
+ if not id:
+ return day_date + relativedelta(days=1)
+ weekdays = self.get_weekdays(cr, uid, id, context)
+
+ base_index = -1
+ for weekday in weekdays:
+ if weekday > day_date.weekday():
+ break
+ base_index += 1
+
+ new_index = (base_index + 1) % len(weekdays)
+ days = (weekdays[new_index] - day_date.weekday())
+ if days < 0:
+ days = 7 + days
+
+ return day_date + relativedelta(days=days)
+
+ def get_previous_day(self, cr, uid, id, day_date, context=None):
+ """ Get previous date of day_date, based on resource.calendar. If no
+ calendar is provided, just return the previous day.
+
+ :param int id: id of a resource.calendar. If not given, simply remove
+ one day from the submitted date.
+ :param date day_date: current day as a date
+
+ :return date: previous day of calendar, or just previous day """
+ if not id:
+ return day_date + relativedelta(days=-1)
+ weekdays = self.get_weekdays(cr, uid, id, context)
+ weekdays.reverse()
+
+ base_index = -1
+ for weekday in weekdays:
+ if weekday < day_date.weekday():
+ break
+ base_index += 1
+
+ new_index = (base_index + 1) % len(weekdays)
+ days = (weekdays[new_index] - day_date.weekday())
+ if days > 0:
+ days = days - 7
+
+ return day_date + relativedelta(days=days)
+
+ def get_leave_intervals(self, cr, uid, id, resource_id=None,
+ start_datetime=None, end_datetime=None,
+ context=None):
+ """Get the leaves of the calendar. Leaves can be filtered on the resource,
+ the start datetime or the end datetime.
+
+ :param int resource_id: the id of the resource to take into account when
+ computing the leaves. If not set, only general
+ leaves are computed. If set, generic and
+ specific leaves are computed.
+ :param datetime start_datetime: if provided, do not take into account leaves
+ ending before this date.
+ :param datetime end_datetime: if provided, do not take into account leaves
+ beginning after this date.
+
+ :return list leaves: list of tuples (start_datetime, end_datetime) of
+ leave intervals
"""
- res = self.interval_get_multi(cr, uid, [(dt_from.strftime('%Y-%m-%d %H:%M:%S'), hours, id)], resource, byday)[(dt_from.strftime('%Y-%m-%d %H:%M:%S'), hours, id)]
+ resource_calendar = self.browse(cr, uid, id, context=context)
+ leaves = []
+ for leave in resource_calendar.leave_ids:
+ if leave.resource_id and not resource_id == leave.resource_id.id:
+ continue
+ date_from = datetime.datetime.strptime(leave.date_from, tools.DEFAULT_SERVER_DATETIME_FORMAT)
+ if end_datetime and date_from > end_datetime:
+ continue
+ date_to = datetime.datetime.strptime(leave.date_to, tools.DEFAULT_SERVER_DATETIME_FORMAT)
+ if start_datetime and date_to < start_datetime:
+ continue
+ leaves.append((date_from, date_to))
+ return leaves
+
+ def get_working_intervals_of_day(self, cr, uid, id, start_dt=None, end_dt=None,
+ leaves=None, compute_leaves=False, resource_id=None,
+ default_interval=None, context=None):
+ """ Get the working intervals of the day based on calendar. This method
+ handle leaves that come directly from the leaves parameter or can be computed.
+
+ :param int id: resource.calendar id; take the first one if is a list
+ :param datetime start_dt: datetime object that is the beginning hours
+ for the working intervals computation; any
+ working interval beginning before start_dt
+ will be truncated. If not set, set to end_dt
+ or today() if no end_dt at 00.00.00.
+ :param datetime end_dt: datetime object that is the ending hour
+ for the working intervals computation; any
+ working interval ending after end_dt
+ will be truncated. If not set, set to start_dt()
+ at 23.59.59.
+ :param list leaves: a list of tuples(start_datetime, end_datetime) that
+ represent leaves.
+ :param boolean compute_leaves: if set and if leaves is None, compute the
+ leaves based on calendar and resource.
+ If leaves is None and compute_leaves false
+ no leaves are taken into account.
+ :param int resource_id: the id of the resource to take into account when
+ computing the leaves. If not set, only general
+ leaves are computed. If set, generic and
+ specific leaves are computed.
+ :param tuple default_interval: if no id, try to return a default working
+ day using default_interval[0] as beginning
+ hour, and default_interval[1] as ending hour.
+ Example: default_interval = (8, 16).
+ Otherwise, a void list of working intervals
+ is returned when id is None.
+
+ :return list intervals: a list of tuples (start_datetime, end_datetime)
+ of work intervals """
+ if isinstance(id, (list, tuple)):
+ id = id[0]
+
+ # Computes start_dt, end_dt (with default values if not set) + off-interval work limits
+ work_limits = []
+ if start_dt is None and end_dt is not None:
+ start_dt = end_dt.replace(hour=0, minute=0, second=0)
+ elif start_dt is None:
+ start_dt = datetime.datetime.now().replace(hour=0, minute=0, second=0)
+ else:
+ work_limits.append((start_dt.replace(hour=0, minute=0, second=0), start_dt))
+ if end_dt is None:
+ end_dt = start_dt.replace(hour=23, minute=59, second=59)
+ else:
+ work_limits.append((end_dt, end_dt.replace(hour=23, minute=59, second=59)))
+ assert start_dt.date() == end_dt.date(), 'get_working_intervals_of_day is restricted to one day'
+
+ intervals = []
+ work_dt = start_dt.replace(hour=0, minute=0, second=0)
+
+ # no calendar: try to use the default_interval, then return directly
+ if id is None:
+ if default_interval:
+ intervals.append((start_dt.replace(hour=default_interval[0]), start_dt.replace(hour=default_interval[1])))
+ return intervals
+
+ working_intervals = []
+ for calendar_working_day in self.get_attendances_for_weekdays(cr, uid, id, [start_dt.weekday()], context):
+ working_interval = (
+ work_dt.replace(hour=int(calendar_working_day.hour_from)),
+ work_dt.replace(hour=int(calendar_working_day.hour_to))
+ )
+ working_intervals += self.interval_remove_leaves(working_interval, work_limits)
+
+ # find leave intervals
+ if leaves is None and compute_leaves:
+ leaves = self.get_leave_intervals(cr, uid, id, resource_id=resource_id, context=None)
+
+ # filter according to leaves
+ for interval in working_intervals:
+ work_intervals = self.interval_remove_leaves(interval, leaves)
+ intervals += work_intervals
+
+ return intervals
+
+ def get_working_hours_of_date(self, cr, uid, id, start_dt=None, end_dt=None,
+ leaves=None, compute_leaves=False, resource_id=None,
+ default_interval=None, context=None):
+ """ Get the working hours of the day based on calendar. This method uses
+ get_working_intervals_of_day to have the work intervals of the day. It
+ then calculates the number of hours contained in those intervals. """
+ res = datetime.timedelta()
+ intervals = self.get_working_intervals_of_day(
+ cr, uid, id,
+ start_dt, end_dt, leaves,
+ compute_leaves, resource_id,
+ default_interval, context)
+ for interval in intervals:
+ res += interval[1] - interval[0]
+ return (res.total_seconds() / 3600.0)
+
+ def get_working_hours(self, cr, uid, id, start_dt, end_dt, compute_leaves=False,
+ resource_id=None, default_interval=None, context=None):
+ hours = 0.0
+ for day in rrule.rrule(rrule.DAILY, dtstart=start_dt,
+ until=end_dt + datetime.timedelta(days=1),
+ byweekday=self.get_weekdays(cr, uid, id, context=context)):
+ hours += self.get_working_hours_of_date(
+ cr, uid, id, start_dt=day,
+ compute_leaves=compute_leaves, resource_id=resource_id,
+ default_interval=default_interval,
+ context=context)
+ return hours
+
+ # --------------------------------------------------
+ # Hours scheduling
+ # --------------------------------------------------
+
+ def _schedule_hours(self, cr, uid, id, hours, day_dt=None,
+ compute_leaves=False, resource_id=None,
+ default_interval=None, context=None):
+ """ Schedule hours of work, using a calendar and an optional resource to
+ compute working and leave days. This method can be used backwards, i.e.
+ scheduling days before a deadline.
+
+ :param int hours: number of hours to schedule. Use a negative number to
+ compute a backwards scheduling.
+ :param datetime day_dt: reference date to compute working days. If days is
+ > 0 date is the starting date. If days is < 0
+ date is the ending date.
+ :param boolean compute_leaves: if set, compute the leaves based on calendar
+ and resource. Otherwise no leaves are taken
+ into account.
+ :param int resource_id: the id of the resource to take into account when
+ computing the leaves. If not set, only general
+ leaves are computed. If set, generic and
+ specific leaves are computed.
+ :param tuple default_interval: if no id, try to return a default working
+ day using default_interval[0] as beginning
+ hour, and default_interval[1] as ending hour.
+ Example: default_interval = (8, 16).
+ Otherwise, a void list of working intervals
+ is returned when id is None.
+
+ :return tuple (datetime, intervals): datetime is the beginning/ending date
+ of the schedulign; intervals are the
+ working intervals of the scheduling.
+
+ Note: Why not using rrule.rrule ? Because rrule does not seem to allow
+ getting back in time.
+ """
+ if day_dt is None:
+ day_dt = datetime.datetime.now()
+ backwards = (hours < 0)
+ hours = abs(hours)
+ intervals = []
+ remaining_hours = hours * 1.0
+ iterations = 0
+ current_datetime = day_dt
+
+ call_args = dict(compute_leaves=compute_leaves, resource_id=resource_id, default_interval=default_interval, context=context)
+
+ while float_compare(remaining_hours, 0.0, precision_digits=2) in (1, 0) and iterations < 1000:
+ if backwards:
+ call_args['end_dt'] = current_datetime
+ else:
+ call_args['start_dt'] = current_datetime
+
+ working_intervals = self.get_working_intervals_of_day(cr, uid, id, **call_args)
+
+ if id is None and not working_interval: # no calendar -> consider working 8 hours
+ remaining_hours -= 8.0
+ elif working_intervals:
+ if backwards:
+ working_intervals.reverse()
+ new_working_intervals = self.interval_schedule_hours(working_intervals, remaining_hours, not backwards)
+ if backwards:
+ new_working_intervals.reverse()
+
+ res = datetime.timedelta()
+ for interval in working_intervals:
+ res += interval[1] - interval[0]
+ remaining_hours -= (res.total_seconds() / 3600.0)
+ if backwards:
+ intervals = new_working_intervals + intervals
+ else:
+ intervals = intervals + new_working_intervals
+ # get next day
+ if backwards:
+ current_datetime = datetime.datetime.combine(self.get_previous_day(cr, uid, id, current_datetime, context), datetime.time(23, 59, 59))
+ else:
+ current_datetime = datetime.datetime.combine(self.get_next_day(cr, uid, id, current_datetime, context), datetime.time())
+ # avoid infinite loops
+ iterations += 1
+
+ return intervals
+
+ def schedule_hours_get_date(self, cr, uid, id, hours, day_dt=None,
+ compute_leaves=False, resource_id=None,
+ default_interval=None, context=None):
+ """ Wrapper on _schedule_hours: return the beginning/ending datetime of
+ an hours scheduling. """
+ res = self._schedule_hours(cr, uid, id, hours, day_dt, compute_leaves, resource_id, default_interval, context)
+ return res and res[0][0] or False
+
+ def schedule_hours(self, cr, uid, id, hours, day_dt=None,
+ compute_leaves=False, resource_id=None,
+ default_interval=None, context=None):
+ """ Wrapper on _schedule_hours: return the working intervals of an hours
+ scheduling. """
+ return self._schedule_hours(cr, uid, id, hours, day_dt, compute_leaves, resource_id, default_interval, context)
+
+ # --------------------------------------------------
+ # Days scheduling
+ # --------------------------------------------------
+
+ def _schedule_days(self, cr, uid, id, days, day_date=None, compute_leaves=False,
+ resource_id=None, default_interval=None, context=None):
+ """Schedule days of work, using a calendar and an optional resource to
+ compute working and leave days. This method can be used backwards, i.e.
+ scheduling days before a deadline.
+
+ :param int days: number of days to schedule. Use a negative number to
+ compute a backwards scheduling.
+ :param date day_date: reference date to compute working days. If days is > 0
+ date is the starting date. If days is < 0 date is the
+ ending date.
+ :param boolean compute_leaves: if set, compute the leaves based on calendar
+ and resource. Otherwise no leaves are taken
+ into account.
+ :param int resource_id: the id of the resource to take into account when
+ computing the leaves. If not set, only general
+ leaves are computed. If set, generic and
+ specific leaves are computed.
+ :param tuple default_interval: if no id, try to return a default working
+ day using default_interval[0] as beginning
+ hour, and default_interval[1] as ending hour.
+ Example: default_interval = (8, 16).
+ Otherwise, a void list of working intervals
+ is returned when id is None.
+
+ :return tuple (datetime, intervals): datetime is the beginning/ending date
+ of the schedulign; intervals are the
+ working intervals of the scheduling.
+
+ Implementation note: rrule.rrule is not used because rrule it des not seem
+ to allow getting back in time.
+ """
+ if day_date is None:
+ day_date = datetime.datetime.now()
+ backwards = (days < 0)
+ days = abs(days)
+ intervals = []
+ planned_days = 0
+ iterations = 0
+ if backwards:
+ current_datetime = day_date.replace(hour=23, minute=59, second=59)
+ else:
+ current_datetime = day_date.replace(hour=0, minute=0, second=0)
+
+ while planned_days < days and iterations < 1000:
+ working_intervals = self.get_working_intervals_of_day(
+ cr, uid, id, current_datetime,
+ compute_leaves=compute_leaves, resource_id=resource_id,
+ default_interval=default_interval,
+ context=context)
+ if id is None or working_intervals: # no calendar -> no working hours, but day is considered as worked
+ planned_days += 1
+ intervals += working_intervals
+ # get next day
+ if backwards:
+ current_datetime = self.get_previous_day(cr, uid, id, current_datetime, context)
+ else:
+ current_datetime = self.get_next_day(cr, uid, id, current_datetime, context)
+ # avoid infinite loops
+ iterations += 1
+
+ return intervals
+
+ def schedule_days_get_date(self, cr, uid, id, days, day_date=None, compute_leaves=False,
+ resource_id=None, default_interval=None, context=None):
+ """ Wrapper on _schedule_days: return the beginning/ending datetime of
+ a days scheduling. """
+ res = self._schedule_days(cr, uid, id, days, day_date, compute_leaves, resource_id, default_interval, context)
+ return res and res[-1][1] or False
+
+ def schedule_days(self, cr, uid, id, days, day_date=None, compute_leaves=False,
+ resource_id=None, default_interval=None, context=None):
+ """ Wrapper on _schedule_days: return the working intervals of a days
+ scheduling. """
+ return self._schedule_days(cr, uid, id, days, day_date, compute_leaves, resource_id, default_interval, context)
+
+ # --------------------------------------------------
+ # Compatibility / to clean / to remove
+ # --------------------------------------------------
+
+ def working_hours_on_day(self, cr, uid, resource_calendar_id, day, context=None):
+ """ Used in hr_payroll/hr_payroll.py
+
+ :deprecated: OpenERP saas-3. Use get_working_hours_of_date instead. Note:
+ since saas-3, take hour/minutes into account, not just the whole day."""
+ if isinstance(day, datetime.datetime):
+ day = day.replace(hour=0, minute=0)
+ return self.get_working_hours_of_date(cr, uid, resource_calendar_id.id, start_dt=day, context=None)
+
+ def interval_min_get(self, cr, uid, id, dt_from, hours, resource=False):
+ """ Schedule hours backwards. Used in mrp_operations/mrp_operations.py.
+
+ :deprecated: OpenERP saas-3. Use schedule_hours instead. Note: since
+ saas-3, counts leave hours instead of all-day leaves."""
+ return self.schedule_hours(
+ cr, uid, id, hours * -1.0,
+ day_dt=dt_from.replace(minute=0, second=0),
+ compute_leaves=True, resource_id=resource,
+ default_interval=(8, 16)
+ )
+
+ def interval_get_multi(self, cr, uid, date_and_hours_by_cal, resource=False, byday=True):
+ """ Used in mrp_operations/mrp_operations.py (default parameters) and in
+ interval_get()
+
+ :deprecated: OpenERP saas-3. Use schedule_hours instead. Note:
+ Byday was not used. Since saas-3, counts Leave hours instead of all-day leaves."""
+ res = {}
+ for dt_str, hours, calendar_id in date_and_hours_by_cal:
+ result = self.schedule_hours(
+ cr, uid, calendar_id, hours,
+ day_dt=datetime.datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S').replace(minute=0, second=0),
+ compute_leaves=True, resource_id=resource,
+ default_interval=(8, 16)
+ )
+ res[(dt_str, hours, calendar_id)] = result
+ return res
+
+ def interval_get(self, cr, uid, id, dt_from, hours, resource=False, byday=True):
+ """ Unifier of interval_get_multi. Used in: mrp_operations/mrp_operations.py,
+ crm/crm_lead.py (res given).
+
+ :deprecated: OpenERP saas-3. Use get_working_hours instead."""
+ res = self.interval_get_multi(
+ cr, uid, [(dt_from.strftime('%Y-%m-%d %H:%M:%S'), hours, id)], resource, byday)[(dt_from.strftime('%Y-%m-%d %H:%M:%S'), hours, id)]
return res
def interval_hours_get(self, cr, uid, id, dt_from, dt_to, resource=False):
- """ Calculates the Total Working hours based on given start_date to
- end_date, If resource id is supplied that it will consider the source
- leaves also in calculating the hours.
+ """ Unused wrapper.
- @param dt_from : date start to calculate hours
- @param dt_end : date end to calculate hours
- @param resource: optional resource id, If given resource leave will be
- considered.
-
- @return : Total number of working hours based dt_from and dt_end and
- resource if supplied.
- """
+ :deprecated: OpenERP saas-3. Use get_working_hours instead."""
return self._interval_hours_get(cr, uid, id, dt_from, dt_to, resource_id=resource)
def _interval_hours_get(self, cr, uid, id, dt_from, dt_to, resource_id=False, timezone_from_uid=None, exclude_leaves=True, context=None):
- """ Calculates the Total Working hours based on given start_date to
- end_date, If resource id is supplied that it will consider the source
- leaves also in calculating the hours.
+ """ Computes working hours between two dates, taking always same hour/minuts.
- @param dt_from : date start to calculate hours
- @param dt_end : date end to calculate hours
- @param resource_id: optional resource id, If given resource leave will be
- considered.
- @param timezone_from_uid: optional uid, if given we will considerer
- working hours in that user timezone
- @param exclude_leaves: optionnal, if set to True (default) we will exclude
- resource leaves from working hours
- @param context: current request context
- @return : Total number of working hours based dt_from and dt_end and
- resource if supplied.
- """
- utc_tz = pytz.timezone('UTC')
- local_tz = utc_tz
-
- if timezone_from_uid:
- users_obj = self.pool.get('res.users')
- user_timezone = users_obj.browse(cr, uid, timezone_from_uid, context=context).partner_id.tz
- if user_timezone:
- try:
- local_tz = pytz.timezone(user_timezone)
- except pytz.UnknownTimeZoneError:
- pass # fallback to UTC as local timezone
-
- def utc_to_local_zone(naive_datetime):
- utc_dt = utc_tz.localize(naive_datetime, is_dst=False)
- return utc_dt.astimezone(local_tz)
-
- def float_time_convert(float_val):
- factor = float_val < 0 and -1 or 1
- val = abs(float_val)
- return (factor * int(math.floor(val)), int(round((val % 1) * 60)))
-
- # Get slots hours per day
- # {day_of_week: [(8, 12), (13, 17), ...], ...}
- hours_range_per_weekday = {}
- if id:
- cr.execute("select dayofweek, hour_from,hour_to from resource_calendar_attendance where calendar_id=%s order by hour_from", (id,))
- for weekday, hour_from, hour_to in cr.fetchall():
- weekday = int(weekday)
- hours_range_per_weekday.setdefault(weekday, [])
- hours_range_per_weekday[weekday].append((hour_from, hour_to))
- else:
- # considering default working hours (Monday -> Friday, 8 -> 12, 13 -> 17)
- for weekday in range(5):
- hours_range_per_weekday[weekday] = [(8, 12), (13, 17)]
-
- ## Interval between dt_from - dt_to
- ##
- ## dt_from dt_to
- ## =============|==================|============
- ## [ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ]
- ##
- ## [ : start of range
- ## ] : end of range
- ##
- ## case 1: range end before interval start (skip)
- ## case 2: range overlap interval start (fit start to internal)
- ## case 3: range within interval
- ## case 4: range overlap interval end (fit end to interval)
- ## case 5: range start after interval end (skip)
-
- interval_start = utc_to_local_zone(dt_from)
- interval_end = utc_to_local_zone(dt_to)
- hours_timedelta = timedelta()
-
- # Get leaves for requested resource
- dt_leaves = set([])
- if exclude_leaves and id:
- dt_leaves = set(self._get_leaves(cr, uid, id, resource=resource_id))
-
- for day in rrule.rrule(rrule.DAILY, dtstart=interval_start,
- until=interval_end+timedelta(days=1),
- byweekday=hours_range_per_weekday.keys()):
- if exclude_leaves and day.strftime('%Y-%m-%d') in dt_leaves:
- # XXX: futher improve leave management to allow for partial day leave
- continue
- for (range_from, range_to) in hours_range_per_weekday.get(day.weekday(), []):
- range_from_hour, range_from_min = float_time_convert(range_from)
- range_to_hour, range_to_min = float_time_convert(range_to)
- daytime_start = local_tz.localize(day.replace(hour=range_from_hour, minute=range_from_min, second=0, tzinfo=None))
- daytime_end = local_tz.localize(day.replace(hour=range_to_hour, minute=range_to_min, second=0, tzinfo=None))
-
- # case 1 & 5: time range out of interval
- if daytime_end < interval_start or daytime_start > interval_end:
- continue
- # case 2 & 4: adjust start, end to fit within interval
- daytime_start = max(daytime_start, interval_start)
- daytime_end = min(daytime_end, interval_end)
-
- # case 2+, 4+, 3
- hours_timedelta += (daytime_end - daytime_start)
-
- # return timedelta converted to hours
- return (hours_timedelta.days * 24.0 + hours_timedelta.seconds / 3600.0)
+ :deprecated: OpenERP saas-3. Use get_working_hours instead. Note: since saas-3,
+ now resets hour/minuts. Now counts leave hours instead of all-day leaves."""
+ return self.get_working_hours(
+ cr, uid, id, dt_from, dt_to,
+ compute_leaves=(not exclude_leaves), resource_id=resource_id,
+ default_interval=(8, 16), context=context)
class resource_calendar_attendance(osv.osv):
@@ -374,6 +683,8 @@ class resource_resource(osv.osv):
def generate_resources(self, cr, uid, user_ids, calendar_id, context=None):
"""
Return a list of Resource Class objects for the resources allocated to the phase.
+
+ NOTE: Used in project/project.py
"""
resource_objs = {}
user_pool = self.pool.get('res.users')
@@ -401,6 +712,8 @@ class resource_resource(osv.osv):
@param calendar_id : working calendar of the project
@param resource_id : resource working on phase/task
@param resource_calendar : working calendar of the resource
+
+ NOTE: used in project/project.py, and in generate_resources
"""
resource_calendar_leaves_pool = self.pool.get('resource.calendar.leaves')
leave_list = []
@@ -415,10 +728,10 @@ class resource_resource(osv.osv):
], context=context)
leaves = resource_calendar_leaves_pool.read(cr, uid, leave_ids, ['date_from', 'date_to'], context=context)
for i in range(len(leaves)):
- dt_start = datetime.strptime(leaves[i]['date_from'], '%Y-%m-%d %H:%M:%S')
- dt_end = datetime.strptime(leaves[i]['date_to'], '%Y-%m-%d %H:%M:%S')
+ dt_start = datetime.datetime.strptime(leaves[i]['date_from'], '%Y-%m-%d %H:%M:%S')
+ dt_end = datetime.datetime.strptime(leaves[i]['date_to'], '%Y-%m-%d %H:%M:%S')
no = dt_end - dt_start
- [leave_list.append((dt_start + timedelta(days=x)).strftime('%Y-%m-%d')) for x in range(int(no.days + 1))]
+ [leave_list.append((dt_start + datetime.timedelta(days=x)).strftime('%Y-%m-%d')) for x in range(int(no.days + 1))]
leave_list.sort()
return leave_list
@@ -426,6 +739,8 @@ class resource_resource(osv.osv):
"""
Change the format of working calendar from 'Openerp' format to bring it into 'Faces' format.
@param calendar_id : working calendar of the project
+
+ NOTE: used in project/project.py
"""
if not calendar_id:
# Calendar is not specified: working days: 24/7
diff --git a/addons/resource/resource_view.xml b/addons/resource/resource_view.xml
index 8954adae80a..74f71ecfeea 100644
--- a/addons/resource/resource_view.xml
+++ b/addons/resource/resource_view.xml
@@ -60,12 +60,13 @@
@@ -245,13 +246,17 @@
+
Resource Leavesresource.calendar.leavestree,form,calendar
+
-
+
+
+
diff --git a/addons/resource/test/resource.yml b/addons/resource/test/resource.yml
index 28604518b7b..b085c0734c4 100644
--- a/addons/resource/test/resource.yml
+++ b/addons/resource/test/resource.yml
@@ -26,7 +26,7 @@
dt = now - timedelta(days=now.weekday())
for resource in resources:
result = calendar_pool.working_hours_on_day(cr, uid, resource.calendar_id, dt, context)
- assert result == 9.0, 'Wrong calculation of day work hour availability of the Resource.'
+ assert result == 9.0, 'Wrong calculation of day work hour availability of the Resource (found %d).' % result
-
Now, resource "Developer" drafted leave on Thursday in this week.
-
diff --git a/addons/resource/tests/__init__.py b/addons/resource/tests/__init__.py
new file mode 100644
index 00000000000..f22158c04b8
--- /dev/null
+++ b/addons/resource/tests/__init__.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Business Applications
+# Copyright (c) 2013-TODAY OpenERP S.A.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+
+from openerp.addons.resource.tests import test_resource
+
+checks = [
+ test_resource,
+]
+
+# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
diff --git a/addons/resource/tests/common.py b/addons/resource/tests/common.py
new file mode 100644
index 00000000000..74b91abde2e
--- /dev/null
+++ b/addons/resource/tests/common.py
@@ -0,0 +1,123 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Business Applications
+# Copyright (c) 2013-TODAY OpenERP S.A.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+
+from datetime import datetime
+
+from openerp.tests import common
+
+
+class TestResourceCommon(common.TransactionCase):
+
+ def setUp(self):
+ super(TestResourceCommon, self).setUp()
+ cr, uid = self.cr, self.uid
+ if not hasattr(self, 'context'):
+ self.context = {}
+
+ # Usefull models
+ self.resource_resource = self.registry('resource.resource')
+ self.resource_calendar = self.registry('resource.calendar')
+ self.resource_attendance = self.registry('resource.calendar.attendance')
+ self.resource_leaves = self.registry('resource.calendar.leaves')
+
+ # Some demo data
+ self.date1 = datetime.strptime('2013-02-12 09:08:07', '%Y-%m-%d %H:%M:%S') # weekday() returns 1, isoweekday() returns 2
+ self.date2 = datetime.strptime('2013-02-15 10:11:12', '%Y-%m-%d %H:%M:%S') # weekday() returns 4, isoweekday() returns 5
+ # Leave1: 19/02/2013, from 9 to 12, is a day 1
+ self.leave1_start = datetime.strptime('2013-02-19 09:00:00', '%Y-%m-%d %H:%M:%S')
+ self.leave1_end = datetime.strptime('2013-02-19 12:00:00', '%Y-%m-%d %H:%M:%S')
+ # Leave2: 22/02/2013, from 9 to 15, is a day 4
+ self.leave2_start = datetime.strptime('2013-02-22 09:00:00', '%Y-%m-%d %H:%M:%S')
+ self.leave2_end = datetime.strptime('2013-02-22 15:00:00', '%Y-%m-%d %H:%M:%S')
+ # Leave3: 25/02/2013 (day0) -> 01/03/2013 (day4)
+ self.leave3_start = datetime.strptime('2013-02-25 13:00:00', '%Y-%m-%d %H:%M:%S')
+ self.leave3_end = datetime.strptime('2013-03-01 11:30:00', '%Y-%m-%d %H:%M:%S')
+
+ # Resource data
+ # Calendar working days: 1 (8-16 -> 8hours), 4 (8-13, 16-23 -> 12hours)
+ self.calendar_id = self.resource_calendar.create(
+ cr, uid, {
+ 'name': 'TestCalendar',
+ }
+ )
+ self.att1_id = self.resource_attendance.create(
+ cr, uid, {
+ 'name': 'Att1',
+ 'dayofweek': '1',
+ 'hour_from': 8,
+ 'hour_to': 16,
+ 'calendar_id': self.calendar_id,
+ }
+ )
+ self.att2_id = self.resource_attendance.create(
+ cr, uid, {
+ 'name': 'Att2',
+ 'dayofweek': '4',
+ 'hour_from': 8,
+ 'hour_to': 13,
+ 'calendar_id': self.calendar_id,
+ }
+ )
+ self.att3_id = self.resource_attendance.create(
+ cr, uid, {
+ 'name': 'Att3',
+ 'dayofweek': '4',
+ 'hour_from': 16,
+ 'hour_to': 23,
+ 'calendar_id': self.calendar_id,
+ }
+ )
+ self.resource1_id = self.resource_resource.create(
+ cr, uid, {
+ 'name': 'TestResource1',
+ 'resource_type': 'user',
+ 'time_efficiency': 150.0,
+ 'calendar_id': self.calendar_id,
+ }
+ )
+ self.leave1_id = self.resource_leaves.create(
+ cr, uid, {
+ 'name': 'GenericLeave',
+ 'calendar_id': self.calendar_id,
+ 'date_from': self.leave1_start,
+ 'date_to': self.leave1_end,
+ }
+ )
+ self.leave2_id = self.resource_leaves.create(
+ cr, uid, {
+ 'name': 'ResourceLeave',
+ 'calendar_id': self.calendar_id,
+ 'resource_id': self.resource1_id,
+ 'date_from': self.leave2_start,
+ 'date_to': self.leave2_end,
+ }
+ )
+ self.leave3_id = self.resource_leaves.create(
+ cr, uid, {
+ 'name': 'ResourceLeave2',
+ 'calendar_id': self.calendar_id,
+ 'resource_id': self.resource1_id,
+ 'date_from': self.leave3_start,
+ 'date_to': self.leave3_end,
+ }
+ )
+ # Some browse data
+ self.calendar = self.resource_calendar.browse(cr, uid, self.calendar_id)
diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py
new file mode 100644
index 00000000000..346317449d3
--- /dev/null
+++ b/addons/resource/tests/test_resource.py
@@ -0,0 +1,445 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# OpenERP, Open Source Business Applications
+# Copyright (c) 2013-TODAY OpenERP S.A.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+##############################################################################
+
+from datetime import datetime, timedelta
+from dateutil.relativedelta import relativedelta
+
+from openerp.addons.resource.tests.common import TestResourceCommon
+
+
+class TestResource(TestResourceCommon):
+
+ def test_00_intervals(self):
+ intervals = [
+ (
+ datetime.strptime('2013-02-04 09:00:00', '%Y-%m-%d %H:%M:%S'),
+ datetime.strptime('2013-02-04 11:00:00', '%Y-%m-%d %H:%M:%S')
+ ), (
+ datetime.strptime('2013-02-04 08:00:00', '%Y-%m-%d %H:%M:%S'),
+ datetime.strptime('2013-02-04 12:00:00', '%Y-%m-%d %H:%M:%S')
+ ), (
+ datetime.strptime('2013-02-04 11:00:00', '%Y-%m-%d %H:%M:%S'),
+ datetime.strptime('2013-02-04 14:00:00', '%Y-%m-%d %H:%M:%S')
+ ), (
+ datetime.strptime('2013-02-04 17:00:00', '%Y-%m-%d %H:%M:%S'),
+ datetime.strptime('2013-02-04 21:00:00', '%Y-%m-%d %H:%M:%S')
+ ), (
+ datetime.strptime('2013-02-03 08:00:00', '%Y-%m-%d %H:%M:%S'),
+ datetime.strptime('2013-02-03 10:00:00', '%Y-%m-%d %H:%M:%S')
+ ), (
+ datetime.strptime('2013-02-04 18:00:00', '%Y-%m-%d %H:%M:%S'),
+ datetime.strptime('2013-02-04 19:00:00', '%Y-%m-%d %H:%M:%S')
+ )
+ ]
+
+ # Test: interval cleaning
+ cleaned_intervals = self.resource_calendar.interval_clean(intervals)
+ self.assertEqual(len(cleaned_intervals), 3, 'resource_calendar: wrong interval cleaning')
+ # First interval: 03, unchanged
+ self.assertEqual(cleaned_intervals[0][0], datetime.strptime('2013-02-03 08:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning')
+ self.assertEqual(cleaned_intervals[0][1], datetime.strptime('2013-02-03 10:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning')
+ # Second intreval: 04, 08-14, combining 08-12 and 11-14, 09-11 being inside 08-12
+ self.assertEqual(cleaned_intervals[1][0], datetime.strptime('2013-02-04 08:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning')
+ self.assertEqual(cleaned_intervals[1][1], datetime.strptime('2013-02-04 14:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning')
+ # Third interval: 04, 17-21, 18-19 being inside 17-21
+ self.assertEqual(cleaned_intervals[2][0], datetime.strptime('2013-02-04 17:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning')
+ self.assertEqual(cleaned_intervals[2][1], datetime.strptime('2013-02-04 21:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong interval cleaning')
+
+ # Test: disjoint removal
+ working_interval = (datetime.strptime('2013-02-04 08:00:00', '%Y-%m-%d %H:%M:%S'), datetime.strptime('2013-02-04 18:00:00', '%Y-%m-%d %H:%M:%S'))
+ result = self.resource_calendar.interval_remove_leaves(working_interval, intervals)
+ self.assertEqual(len(result), 1, 'resource_calendar: wrong leave removal from interval')
+ # First interval: 04, 14-17
+ self.assertEqual(result[0][0], datetime.strptime('2013-02-04 14:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval')
+ self.assertEqual(result[0][1], datetime.strptime('2013-02-04 17:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval')
+
+ # Test: schedule hours on intervals
+ result = self.resource_calendar.interval_schedule_hours(cleaned_intervals, 5.5)
+ self.assertEqual(len(result), 2, 'resource_calendar: wrong hours scheduling in interval')
+ # First interval: 03, 8-10 untouches
+ self.assertEqual(result[0][0], datetime.strptime('2013-02-03 08:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval')
+ self.assertEqual(result[0][1], datetime.strptime('2013-02-03 10:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval')
+ # First interval: 04, 08-11:30
+ self.assertEqual(result[1][0], datetime.strptime('2013-02-04 08:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval')
+ self.assertEqual(result[1][1], datetime.strptime('2013-02-04 11:30:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval')
+
+ # Test: schedule hours on intervals, backwards
+ cleaned_intervals.reverse()
+ result = self.resource_calendar.interval_schedule_hours(cleaned_intervals, 5.5, remove_at_end=False)
+ self.assertEqual(len(result), 2, 'resource_calendar: wrong hours scheduling in interval')
+ # First interval: 03, 8-10 untouches
+ self.assertEqual(result[0][0], datetime.strptime('2013-02-04 17:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval')
+ self.assertEqual(result[0][1], datetime.strptime('2013-02-04 21:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval')
+ # First interval: 04, 08-11:30
+ self.assertEqual(result[1][0], datetime.strptime('2013-02-04 12:30:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval')
+ self.assertEqual(result[1][1], datetime.strptime('2013-02-04 14:00:00', '%Y-%m-%d %H:%M:%S'), 'resource_calendar: wrong leave removal from interval')
+
+ def test_10_calendar_basics(self):
+ """ Testing basic method of resource.calendar """
+ cr, uid = self.cr, self.uid
+
+ # --------------------------------------------------
+ # Test1: get_next_day
+ # --------------------------------------------------
+
+ # Test: next day: next day after day1 is day4
+ date = self.resource_calendar.get_next_day(cr, uid, self.calendar_id, day_date=self.date1.date())
+ self.assertEqual(date, self.date2.date(), 'resource_calendar: wrong next day computing')
+
+ # Test: next day: next day after day4 is (day1+7)
+ date = self.resource_calendar.get_next_day(cr, uid, self.calendar_id, day_date=self.date2.date())
+ self.assertEqual(date, self.date1.date() + relativedelta(days=7), 'resource_calendar: wrong next day computing')
+
+ # Test: next day: next day after day4+1 is (day1+7)
+ date = self.resource_calendar.get_next_day(cr, uid, self.calendar_id, day_date=self.date2.date() + relativedelta(days=1))
+ self.assertEqual(date, self.date1.date() + relativedelta(days=7), 'resource_calendar: wrong next day computing')
+
+ # Test: next day: next day after day1-1 is day1
+ date = self.resource_calendar.get_next_day(cr, uid, self.calendar_id, day_date=self.date1.date() + relativedelta(days=-1))
+ self.assertEqual(date, self.date1.date(), 'resource_calendar: wrong next day computing')
+
+ # --------------------------------------------------
+ # Test2: get_previous_day
+ # --------------------------------------------------
+
+ # Test: previous day: previous day before day1 is (day4-7)
+ date = self.resource_calendar.get_previous_day(cr, uid, self.calendar_id, day_date=self.date1.date())
+ self.assertEqual(date, self.date2.date() + relativedelta(days=-7), 'resource_calendar: wrong previous day computing')
+
+ # Test: previous day: previous day before day4 is day1
+ date = self.resource_calendar.get_previous_day(cr, uid, self.calendar_id, day_date=self.date2.date())
+ self.assertEqual(date, self.date1.date(), 'resource_calendar: wrong previous day computing')
+
+ # Test: previous day: previous day before day4+1 is day4
+ date = self.resource_calendar.get_previous_day(cr, uid, self.calendar_id, day_date=self.date2.date() + relativedelta(days=1))
+ self.assertEqual(date, self.date2.date(), 'resource_calendar: wrong previous day computing')
+
+ # Test: previous day: previous day before day1-1 is (day4-7)
+ date = self.resource_calendar.get_previous_day(cr, uid, self.calendar_id, day_date=self.date1.date() + relativedelta(days=-1))
+ self.assertEqual(date, self.date2.date() + relativedelta(days=-7), 'resource_calendar: wrong previous day computing')
+
+ # --------------------------------------------------
+ # Test3: misc
+ # --------------------------------------------------
+
+ weekdays = self.resource_calendar.get_weekdays(cr, uid, self.calendar_id)
+ self.assertEqual(weekdays, [1, 4], 'resource_calendar: wrong weekdays computing')
+
+ attendances = self.resource_calendar.get_attendances_for_weekdays(cr, uid, self.calendar_id, [2, 3, 4, 5])
+ self.assertEqual(set([att.id for att in attendances]), set([self.att2_id, self.att3_id]),
+ 'resource_calendar: wrong attendances filtering by weekdays computing')
+
+ def test_20_calendar_working_intervals(self):
+ """ Testing working intervals computing method of resource.calendar """
+ cr, uid = self.cr, self.uid
+ _format = '%Y-%m-%d %H:%M:%S'
+
+ # Test: day0 without leaves: 1 interval
+ intervals = self.resource_calendar.get_working_intervals_of_day(cr, uid, self.calendar_id, start_dt=self.date1)
+ self.assertEqual(len(intervals), 1, 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[0][0], datetime.strptime('2013-02-12 09:08:07', _format), 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[0][1], datetime.strptime('2013-02-12 16:00:00', _format), 'resource_calendar: wrong working intervals')
+
+ # Test: day3 without leaves: 2 interval
+ intervals = self.resource_calendar.get_working_intervals_of_day(cr, uid, self.calendar_id, start_dt=self.date2)
+ self.assertEqual(len(intervals), 2, 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[0][0], datetime.strptime('2013-02-15 10:11:12', _format), 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[0][1], datetime.strptime('2013-02-15 13:00:00', _format), 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[1][0], datetime.strptime('2013-02-15 16:00:00', _format), 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[1][1], datetime.strptime('2013-02-15 23:00:00', _format), 'resource_calendar: wrong working intervals')
+
+ # Test: day0 with leaves outside range: 1 interval
+ intervals = self.resource_calendar.get_working_intervals_of_day(cr, uid, self.calendar_id, start_dt=self.date1.replace(hour=0), compute_leaves=True)
+ self.assertEqual(len(intervals), 1, 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[0][0], datetime.strptime('2013-02-12 08:00:00', _format), 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[0][1], datetime.strptime('2013-02-12 16:00:00', _format), 'resource_calendar: wrong working intervals')
+
+ # Test: day0 with leaves: 2 intervals because of leave between 9 ans 12, ending at 15:45:30
+ intervals = self.resource_calendar.get_working_intervals_of_day(cr, uid, self.calendar_id,
+ start_dt=self.date1.replace(hour=8) + relativedelta(days=7),
+ end_dt=self.date1.replace(hour=15, minute=45, second=30) + relativedelta(days=7),
+ compute_leaves=True)
+ self.assertEqual(len(intervals), 2, 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[0][0], datetime.strptime('2013-02-19 08:08:07', _format), 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[0][1], datetime.strptime('2013-02-19 09:00:00', _format), 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[1][0], datetime.strptime('2013-02-19 12:00:00', _format), 'resource_calendar: wrong working intervals')
+ self.assertEqual(intervals[1][1], datetime.strptime('2013-02-19 15:45:30', _format), 'resource_calendar: wrong working intervals')
+
+ def test_30_calendar_working_days(self):
+ """ Testing calendar hours computation on a working day """
+ cr, uid = self.cr, self.uid
+ _format = '%Y-%m-%d %H:%M:%S'
+
+ # Test: day1, beginning at 10:30 -> work from 10:30 (arrival) until 16:00
+ intervals = self.resource_calendar.get_working_intervals_of_day(cr, uid, self.calendar_id, start_dt=self.date1.replace(hour=10, minute=30, second=0))
+ self.assertEqual(len(intervals), 1, 'resource_calendar: wrong working interval / day computing')
+ self.assertEqual(intervals[0][0], datetime.strptime('2013-02-12 10:30:00', _format), 'resource_calendar: wrong working interval / day computing')
+ self.assertEqual(intervals[0][1], datetime.strptime('2013-02-12 16:00:00', _format), 'resource_calendar: wrong working interval / day computing')
+ # Test: hour computation for same interval, should give 5.5
+ wh = self.resource_calendar.get_working_hours_of_date(cr, uid, self.calendar_id, start_dt=self.date1.replace(hour=10, minute=30, second=0))
+ self.assertEqual(wh, 5.5, 'resource_calendar: wrong working interval / day time computing')
+
+ # Test: day1+7 on leave, without leave computation
+ intervals = self.resource_calendar.get_working_intervals_of_day(
+ cr, uid, self.calendar_id,
+ start_dt=self.date1.replace(hour=7, minute=0, second=0) + relativedelta(days=7)
+ )
+ # Result: day1 (08->16)
+ self.assertEqual(len(intervals), 1, 'resource_calendar: wrong working interval/day computing')
+ self.assertEqual(intervals[0][0], datetime.strptime('2013-02-19 08:00:00', _format), 'resource_calendar: wrong working interval / day computing')
+ self.assertEqual(intervals[0][1], datetime.strptime('2013-02-19 16:00:00', _format), 'resource_calendar: wrong working interval / day computing')
+
+ # Test: day1+7 on leave, with generic leave computation
+ intervals = self.resource_calendar.get_working_intervals_of_day(
+ cr, uid, self.calendar_id,
+ start_dt=self.date1.replace(hour=7, minute=0, second=0) + relativedelta(days=7),
+ compute_leaves=True
+ )
+ # Result: day1 (08->09 + 12->16)
+ self.assertEqual(len(intervals), 2, 'resource_calendar: wrong working interval/day computing')
+ self.assertEqual(intervals[0][0], datetime.strptime('2013-02-19 08:00:00', _format), 'resource_calendar: wrong working interval / day computing')
+ self.assertEqual(intervals[0][1], datetime.strptime('2013-02-19 09:00:00', _format), 'resource_calendar: wrong working interval / day computing')
+ self.assertEqual(intervals[1][0], datetime.strptime('2013-02-19 12:00:00', _format), 'resource_calendar: wrong working interval / day computing')
+ self.assertEqual(intervals[1][1], datetime.strptime('2013-02-19 16:00:00', _format), 'resource_calendar: wrong working interval / day computing')
+
+ # Test: day1+14 on leave, with generic leave computation
+ intervals = self.resource_calendar.get_working_intervals_of_day(
+ cr, uid, self.calendar_id,
+ start_dt=self.date1.replace(hour=7, minute=0, second=0) + relativedelta(days=14),
+ compute_leaves=True
+ )
+ # Result: day1 (08->16)
+ self.assertEqual(len(intervals), 1, 'resource_calendar: wrong working interval/day computing')
+ self.assertEqual(intervals[0][0], datetime.strptime('2013-02-26 08:00:00', _format), 'resource_calendar: wrong working interval / day computing')
+ self.assertEqual(intervals[0][1], datetime.strptime('2013-02-26 16:00:00', _format), 'resource_calendar: wrong working interval / day computing')
+
+ # Test: day1+14 on leave, with resource leave computation
+ intervals = self.resource_calendar.get_working_intervals_of_day(
+ cr, uid, self.calendar_id,
+ start_dt=self.date1.replace(hour=7, minute=0, second=0) + relativedelta(days=14),
+ compute_leaves=True,
+ resource_id=self.resource1_id
+ )
+ # Result: nothing, because on leave
+ self.assertEqual(len(intervals), 0, 'resource_calendar: wrong working interval/day computing')
+
+ def test_40_calendar_hours_scheduling(self):
+ """ Testing calendar hours scheduling """
+ cr, uid = self.cr, self.uid
+ _format = '%Y-%m-%d %H:%M:%S'
+
+ # --------------------------------------------------
+ # Test0: schedule hours backwards (old interval_min_get)
+ # Done without calendar
+ # --------------------------------------------------
+
+ # Done without calendar
+ # res = self.resource_calendar.interval_min_get(cr, uid, None, self.date1, 40, resource=False)
+ # res: (datetime.datetime(2013, 2, 7, 9, 8, 7), datetime.datetime(2013, 2, 12, 9, 8, 7))
+
+ # --------------------------------------------------
+ # Test1: schedule hours backwards (old interval_min_get)
+ # --------------------------------------------------
+
+ # res = self.resource_calendar.interval_min_get(cr, uid, self.calendar_id, self.date1, 40, resource=False)
+ # (datetime.datetime(2013, 1, 29, 9, 0), datetime.datetime(2013, 1, 29, 16, 0))
+ # (datetime.datetime(2013, 2, 1, 8, 0), datetime.datetime(2013, 2, 1, 13, 0))
+ # (datetime.datetime(2013, 2, 1, 16, 0), datetime.datetime(2013, 2, 1, 23, 0))
+ # (datetime.datetime(2013, 2, 5, 8, 0), datetime.datetime(2013, 2, 5, 16, 0))
+ # (datetime.datetime(2013, 2, 8, 8, 0), datetime.datetime(2013, 2, 8, 13, 0))
+ # (datetime.datetime(2013, 2, 8, 16, 0), datetime.datetime(2013, 2, 8, 23, 0))
+ # (datetime.datetime(2013, 2, 12, 8, 0), datetime.datetime(2013, 2, 12, 9, 0))
+
+ res = self.resource_calendar.schedule_hours(cr, uid, self.calendar_id, -40, day_dt=self.date1.replace(minute=0, second=0))
+ # current day, limited at 09:00 because of day_dt specified -> 1 hour
+ self.assertEqual(res[-1][0], datetime.strptime('2013-02-12 08:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[-1][1], datetime.strptime('2013-02-12 09:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ # previous days: 5+7 hours / 8 hours / 5+7 hours -> 32 hours
+ self.assertEqual(res[-2][0], datetime.strptime('2013-02-08 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[-2][1], datetime.strptime('2013-02-08 23:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[-3][0], datetime.strptime('2013-02-08 08:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[-3][1], datetime.strptime('2013-02-08 13:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[-4][0], datetime.strptime('2013-02-05 08:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[-4][1], datetime.strptime('2013-02-05 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[-5][0], datetime.strptime('2013-02-01 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[-5][1], datetime.strptime('2013-02-01 23:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[-6][0], datetime.strptime('2013-02-01 08:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[-6][1], datetime.strptime('2013-02-01 13:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ # 7 hours remaining
+ self.assertEqual(res[-7][0], datetime.strptime('2013-01-29 09:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[-7][1], datetime.strptime('2013-01-29 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ # Compute scheduled hours
+ td = timedelta()
+ for item in res:
+ td += item[1] - item[0]
+ self.assertEqual(td.total_seconds() / 3600.0, 40.0, 'resource_calendar: wrong hours scheduling')
+
+ # --------------------------------------------------
+ # Test2: schedule hours forward (old interval_get)
+ # --------------------------------------------------
+
+ # res = self.resource_calendar.interval_get(cr, uid, self.calendar_id, self.date1, 40, resource=False, byday=True)
+ # (datetime.datetime(2013, 2, 12, 9, 0), datetime.datetime(2013, 2, 12, 16, 0))
+ # (datetime.datetime(2013, 2, 15, 8, 0), datetime.datetime(2013, 2, 15, 13, 0))
+ # (datetime.datetime(2013, 2, 15, 16, 0), datetime.datetime(2013, 2, 15, 23, 0))
+ # (datetime.datetime(2013, 2, 22, 8, 0), datetime.datetime(2013, 2, 22, 13, 0))
+ # (datetime.datetime(2013, 2, 22, 16, 0), datetime.datetime(2013, 2, 22, 23, 0))
+ # (datetime.datetime(2013, 2, 26, 8, 0), datetime.datetime(2013, 2, 26, 16, 0))
+ # (datetime.datetime(2013, 3, 1, 8, 0), datetime.datetime(2013, 3, 1, 9, 0))
+
+ res = self.resource_calendar.schedule_hours(
+ cr, uid, self.calendar_id, 40,
+ day_dt=self.date1.replace(minute=0, second=0)
+ )
+ self.assertEqual(res[0][0], datetime.strptime('2013-02-12 09:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[0][1], datetime.strptime('2013-02-12 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[1][0], datetime.strptime('2013-02-15 08:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[1][1], datetime.strptime('2013-02-15 13:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[2][0], datetime.strptime('2013-02-15 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[2][1], datetime.strptime('2013-02-15 23:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[3][0], datetime.strptime('2013-02-19 08:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[3][1], datetime.strptime('2013-02-19 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[4][0], datetime.strptime('2013-02-22 08:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[4][1], datetime.strptime('2013-02-22 13:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[5][0], datetime.strptime('2013-02-22 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[5][1], datetime.strptime('2013-02-22 23:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[6][0], datetime.strptime('2013-02-26 08:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[6][1], datetime.strptime('2013-02-26 09:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ td = timedelta()
+ for item in res:
+ td += item[1] - item[0]
+ self.assertEqual(td.total_seconds() / 3600.0, 40.0, 'resource_calendar: wrong hours scheduling')
+
+ # res = self.resource_calendar.interval_get(cr, uid, self.calendar_id, self.date1, 40, resource=self.resource1_id, byday=True)
+ # (datetime.datetime(2013, 2, 12, 9, 0), datetime.datetime(2013, 2, 12, 16, 0))
+ # (datetime.datetime(2013, 2, 15, 8, 0), datetime.datetime(2013, 2, 15, 13, 0))
+ # (datetime.datetime(2013, 2, 15, 16, 0), datetime.datetime(2013, 2, 15, 23, 0))
+ # (datetime.datetime(2013, 3, 1, 8, 0), datetime.datetime(2013, 3, 1, 13, 0))
+ # (datetime.datetime(2013, 3, 1, 16, 0), datetime.datetime(2013, 3, 1, 23, 0))
+ # (datetime.datetime(2013, 3, 5, 8, 0), datetime.datetime(2013, 3, 5, 16, 0))
+ # (datetime.datetime(2013, 3, 8, 8, 0), datetime.datetime(2013, 3, 8, 9, 0))
+
+ res = self.resource_calendar.schedule_hours(
+ cr, uid, self.calendar_id, 40,
+ day_dt=self.date1.replace(minute=0, second=0),
+ compute_leaves=True,
+ resource_id=self.resource1_id
+ )
+ self.assertEqual(res[0][0], datetime.strptime('2013-02-12 09:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[0][1], datetime.strptime('2013-02-12 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[1][0], datetime.strptime('2013-02-15 08:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[1][1], datetime.strptime('2013-02-15 13:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[2][0], datetime.strptime('2013-02-15 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[2][1], datetime.strptime('2013-02-15 23:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[3][0], datetime.strptime('2013-02-19 08:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[3][1], datetime.strptime('2013-02-19 09:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[4][0], datetime.strptime('2013-02-19 12:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[4][1], datetime.strptime('2013-02-19 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[5][0], datetime.strptime('2013-02-22 08:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[5][1], datetime.strptime('2013-02-22 09:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[6][0], datetime.strptime('2013-02-22 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[6][1], datetime.strptime('2013-02-22 23:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[7][0], datetime.strptime('2013-03-01 11:30:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[7][1], datetime.strptime('2013-03-01 13:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[8][0], datetime.strptime('2013-03-01 16:00:00', _format), 'resource_calendar: wrong hours scheduling')
+ self.assertEqual(res[8][1], datetime.strptime('2013-03-01 22:30:00', _format), 'resource_calendar: wrong hours scheduling')
+ td = timedelta()
+ for item in res:
+ td += item[1] - item[0]
+ self.assertEqual(td.total_seconds() / 3600.0, 40.0, 'resource_calendar: wrong hours scheduling')
+
+ # --------------------------------------------------
+ # Test3: working hours (old _interval_hours_get)
+ # --------------------------------------------------
+
+ # old API: resource without leaves
+ # res: 2 weeks -> 40 hours
+ res = self.resource_calendar._interval_hours_get(
+ cr, uid, self.calendar_id,
+ self.date1.replace(hour=6, minute=0),
+ self.date2.replace(hour=23, minute=0) + relativedelta(days=7),
+ resource_id=self.resource1_id, exclude_leaves=True)
+ self.assertEqual(res, 40.0, 'resource_calendar: wrong _interval_hours_get compatibility computation')
+
+ # new API: resource without leaves
+ # res: 2 weeks -> 40 hours
+ res = self.resource_calendar.get_working_hours(
+ cr, uid, self.calendar_id,
+ self.date1.replace(hour=6, minute=0),
+ self.date2.replace(hour=23, minute=0) + relativedelta(days=7),
+ compute_leaves=False, resource_id=self.resource1_id)
+ self.assertEqual(res, 40.0, 'resource_calendar: wrong get_working_hours computation')
+
+ # old API: resource and leaves
+ # res: 2 weeks -> 40 hours - (3+4) leave hours
+ res = self.resource_calendar._interval_hours_get(
+ cr, uid, self.calendar_id,
+ self.date1.replace(hour=6, minute=0),
+ self.date2.replace(hour=23, minute=0) + relativedelta(days=7),
+ resource_id=self.resource1_id, exclude_leaves=False)
+ self.assertEqual(res, 33.0, 'resource_calendar: wrong _interval_hours_get compatibility computation')
+
+ # new API: resource and leaves
+ # res: 2 weeks -> 40 hours - (3+4) leave hours
+ res = self.resource_calendar.get_working_hours(
+ cr, uid, self.calendar_id,
+ self.date1.replace(hour=6, minute=0),
+ self.date2.replace(hour=23, minute=0) + relativedelta(days=7),
+ compute_leaves=True, resource_id=self.resource1_id)
+ self.assertEqual(res, 33.0, 'resource_calendar: wrong get_working_hours computation')
+
+ # --------------------------------------------------
+ # Test4: misc
+ # --------------------------------------------------
+
+ # Test without calendar and default_interval
+ res = self.resource_calendar.get_working_hours(
+ cr, uid, None,
+ self.date1.replace(hour=6, minute=0),
+ self.date2.replace(hour=23, minute=0),
+ compute_leaves=True, resource_id=self.resource1_id,
+ default_interval=(8, 16))
+ self.assertEqual(res, 32.0, 'resource_calendar: wrong get_working_hours computation')
+
+ def test_50_calendar_schedule_days(self):
+ """ Testing calendar days scheduling """
+ cr, uid = self.cr, self.uid
+ _format = '%Y-%m-%d %H:%M:%S'
+
+ # --------------------------------------------------
+ # Test1: with calendar
+ # --------------------------------------------------
+
+ res = self.resource_calendar.schedule_days_get_date(cr, uid, self.calendar_id, 5, day_date=self.date1)
+ self.assertEqual(res.date(), datetime.strptime('2013-02-26 00:0:00', _format).date(), 'resource_calendar: wrong days scheduling')
+
+ res = self.resource_calendar.schedule_days_get_date(
+ cr, uid, self.calendar_id, 5, day_date=self.date1,
+ compute_leaves=True, resource_id=self.resource1_id)
+ self.assertEqual(res.date(), datetime.strptime('2013-03-01 00:0:00', _format).date(), 'resource_calendar: wrong days scheduling')
+
+ # --------------------------------------------------
+ # Test2: misc
+ # --------------------------------------------------
+
+ # Without calendar, should only count days -> 12 -> 16, 5 days with default intervals
+ res = self.resource_calendar.schedule_days_get_date(cr, uid, None, 5, day_date=self.date1, default_interval=(8, 16))
+ self.assertEqual(res, datetime.strptime('2013-02-16 16:00:00', _format), 'resource_calendar: wrong days scheduling')