[MERGE] with trunk
[odoo/odoo.git] / addons / resource / resource.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 import pytz
23 from datetime import datetime, timedelta
24 from dateutil import rrule
25 import math
26 from faces import *
27 from openerp.osv import fields, osv
28 from openerp.tools.float_utils import float_compare
29 from openerp.tools.translate import _
30
31 from itertools import groupby
32 from operator import itemgetter
33
34
35 class resource_calendar(osv.osv):
36     _name = "resource.calendar"
37     _description = "Resource Calendar"
38     _columns = {
39         'name' : fields.char("Name", size=64, required=True),
40         'company_id' : fields.many2one('res.company', 'Company', required=False),
41         'attendance_ids' : fields.one2many('resource.calendar.attendance', 'calendar_id', 'Working Time'),
42         'manager' : fields.many2one('res.users', 'Workgroup Manager'),
43     }
44     _defaults = {
45         'company_id': lambda self, cr, uid, context: self.pool.get('res.company')._company_default_get(cr, uid, 'resource.calendar', context=context)
46     }
47
48     def working_hours_on_day(self, cr, uid, resource_calendar_id, day, context=None):
49         """Calculates the  Working Total Hours based on Resource Calendar and
50         given working day (datetime object).
51
52         @param resource_calendar_id: resource.calendar browse record
53         @param day: datetime object
54
55         @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
56         """
57         res = 0.0
58         for working_day in resource_calendar_id.attendance_ids:
59             if (int(working_day.dayofweek) + 1) == day.isoweekday():
60                 res += working_day.hour_to - working_day.hour_from
61         return res
62
63     def _get_leaves(self, cr, uid, id, resource):
64         """Private Method to Calculate resource Leaves days
65
66         @param id: resource calendar id
67         @param resource: resource id for which leaves will ew calculated
68
69         @return : returns the list of dates, where resource on leave in
70                   resource.calendar.leaves object (e.g.['%Y-%m-%d', '%Y-%m-%d'])
71         """
72         resource_cal_leaves = self.pool.get('resource.calendar.leaves')
73         dt_leave = []
74         resource_leave_ids = resource_cal_leaves.search(cr, uid, [('calendar_id','=',id), '|', ('resource_id','=',False), ('resource_id','=',resource)])
75         #res_leaves = resource_cal_leaves.read(cr, uid, resource_leave_ids, ['date_from', 'date_to'])
76         res_leaves = resource_cal_leaves.browse(cr, uid, resource_leave_ids)
77
78         for leave in res_leaves:
79             dtf = datetime.strptime(leave.date_from, '%Y-%m-%d %H:%M:%S')
80             dtt = datetime.strptime(leave.date_to, '%Y-%m-%d %H:%M:%S')
81             no = dtt - dtf
82             [dt_leave.append((dtf + timedelta(days=x)).strftime('%Y-%m-%d')) for x in range(int(no.days + 1))]
83             dt_leave.sort()
84
85         return dt_leave
86
87     def interval_min_get(self, cr, uid, id, dt_from, hours, resource=False):
88         """
89         Calculates the working Schedule from supplied from date to till hours
90         will be satisfied  based or resource calendar id. If resource is also
91         given then it will consider the resource leave also and than will
92         calculates resource working schedule
93
94         @param dt_from: datetime object, start of working scheduled
95         @param hours: float, total number working  hours needed scheduled from
96                       start date
97         @param resource : Optional Resource id, if supplied than resource leaves
98                         will also taken into consideration for calculating working
99                         schedule.
100         @return : List datetime object of working schedule based on supplies
101                   params
102         """
103         if not id:
104             td = int(hours)*3
105             return [(dt_from - timedelta(hours=td), dt_from)]
106         dt_leave = self._get_leaves(cr, uid, id, resource)
107         dt_leave.reverse()
108         todo = hours
109         result = []
110         maxrecur = 100
111         current_hour = dt_from.hour
112         while float_compare(todo, 0, 4) and maxrecur:
113             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))
114             for (hour_from,hour_to) in cr.fetchall():
115                 leave_flag  = False
116                 if (hour_from<current_hour) and float_compare(todo, 0, 4):
117                     m = min(hour_to, current_hour)
118                     if (m-hour_from)>todo:
119                         hour_from = m-todo
120                     dt_check = dt_from.strftime('%Y-%m-%d')
121                     for leave in dt_leave:
122                         if dt_check == leave:
123                             dt_check = datetime.strptime(dt_check, '%Y-%m-%d') + timedelta(days=1)
124                             leave_flag = True
125                     if leave_flag:
126                         break
127                     else:
128                         d1 = datetime(dt_from.year, dt_from.month, dt_from.day, int(math.floor(hour_from)), int((hour_from%1) * 60))
129                         d2 = datetime(dt_from.year, dt_from.month, dt_from.day, int(math.floor(m)), int((m%1) * 60))
130                         result.append((d1, d2))
131                         current_hour = hour_from
132                         todo -= (m-hour_from)
133             dt_from -= timedelta(days=1)
134             current_hour = 24
135             maxrecur -= 1
136         result.reverse()
137         return result
138
139     # def interval_get(self, cr, uid, id, dt_from, hours, resource=False, byday=True):
140     def interval_get_multi(self, cr, uid, date_and_hours_by_cal, resource=False, byday=True):
141         def group(lst, key):
142             lst.sort(key=itemgetter(key))
143             grouped = groupby(lst, itemgetter(key))
144             return dict([(k, [v for v in itr]) for k, itr in grouped])
145         # END group
146
147         cr.execute("select calendar_id, dayofweek, hour_from, hour_to from resource_calendar_attendance order by hour_from")
148         hour_res = cr.dictfetchall()
149         hours_by_cal = group(hour_res, 'calendar_id')
150
151         results = {}
152
153         for d, hours, id in date_and_hours_by_cal:
154             dt_from = datetime.strptime(d, '%Y-%m-%d %H:%M:%S')
155             if not id:
156                 td = int(hours)*3
157                 results[(d, hours, id)] = [(dt_from, dt_from + timedelta(hours=td))]
158                 continue
159
160             dt_leave = self._get_leaves(cr, uid, id, resource)
161             todo = hours
162             result = []
163             maxrecur = 100
164             current_hour = dt_from.hour
165             while float_compare(todo, 0, 4) and maxrecur:
166                 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())]:
167                     leave_flag  = False
168                     if (hour_to>current_hour) and float_compare(todo, 0, 4):
169                         m = max(hour_from, current_hour)
170                         if (hour_to-m)>todo:
171                             hour_to = m+todo
172                         dt_check = dt_from.strftime('%Y-%m-%d')
173                         for leave in dt_leave:
174                             if dt_check == leave:
175                                 dt_check = datetime.strptime(dt_check, '%Y-%m-%d') + timedelta(days=1)
176                                 leave_flag = True
177                         if leave_flag:
178                             break
179                         else:
180                             d1 = datetime(dt_from.year, dt_from.month, dt_from.day, int(math.floor(m)), int((m%1) * 60))
181                             d2 = datetime(dt_from.year, dt_from.month, dt_from.day, int(math.floor(hour_to)), int((hour_to%1) * 60))
182                             result.append((d1, d2))
183                             current_hour = hour_to
184                             todo -= (hour_to - m)
185                 dt_from += timedelta(days=1)
186                 current_hour = 0
187                 maxrecur -= 1
188             results[(d, hours, id)] = result
189         return results
190
191     def interval_get(self, cr, uid, id, dt_from, hours, resource=False, byday=True):
192         """Calculates Resource Working Internal Timing Based on Resource Calendar.
193
194         @param dt_from: start resource schedule calculation.
195         @param hours : total number of working hours to be scheduled.
196         @param resource: optional resource id, If supplied it will take care of
197                          resource leave while scheduling.
198         @param byday: boolean flag bit enforce day wise scheduling
199
200         @return :  list of scheduled working timing  based on resource calendar.
201         """
202         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)]
203         return res
204
205     def interval_hours_get(self, cr, uid, id, dt_from, dt_to, resource=False):
206         """ Calculates the Total Working hours based on given start_date to
207         end_date, If resource id is supplied that it will consider the source
208         leaves also in calculating the hours.
209
210         @param dt_from : date start to calculate hours
211         @param dt_end : date end to calculate hours
212         @param resource: optional resource id, If given resource leave will be
213                          considered.
214
215         @return : Total number of working hours based dt_from and dt_end and
216                   resource if supplied.
217         """
218         return self._interval_hours_get(cr, uid, id, dt_from, dt_to, resource_id=resource)
219
220     def _interval_hours_get(self, cr, uid, id, dt_from, dt_to, resource_id=False, timezone_from_uid=None, exclude_leaves=True, context=None):
221         """ Calculates the Total Working hours based on given start_date to
222         end_date, If resource id is supplied that it will consider the source
223         leaves also in calculating the hours.
224
225         @param dt_from : date start to calculate hours
226         @param dt_end : date end to calculate hours
227         @param resource_id: optional resource id, If given resource leave will be
228                          considered.
229         @param timezone_from_uid: optional uid, if given we will considerer
230                                   working hours in that user timezone
231         @param exclude_leaves: optionnal, if set to True (default) we will exclude
232                                resource leaves from working hours
233         @param context: current request context
234         @return : Total number of working hours based dt_from and dt_end and
235                   resource if supplied.
236         """
237         utc_tz = pytz.timezone('UTC')
238         local_tz = utc_tz
239
240         if timezone_from_uid:
241             users_obj = self.pool.get('res.users')
242             user_timezone = users_obj.browse(cr, uid, timezone_from_uid, context=context).partner_id.tz
243             if user_timezone:
244                 try:
245                     local_tz = pytz.timezone(user_timezone)
246                 except pytz.UnknownTimeZoneError:
247                     pass  # fallback to UTC as local timezone
248
249         def utc_to_local_zone(naive_datetime):
250             utc_dt = utc_tz.localize(naive_datetime, is_dst=False)
251             return utc_dt.astimezone(local_tz)
252
253         def float_time_convert(float_val):
254             factor = float_val < 0 and -1 or 1
255             val = abs(float_val)
256             return (factor * int(math.floor(val)), int(round((val % 1) * 60)))
257
258         # Get slots hours per day
259         # {day_of_week: [(8, 12), (13, 17), ...], ...}
260         hours_range_per_weekday = {}
261         if id:
262             cr.execute("select dayofweek, hour_from,hour_to from resource_calendar_attendance where calendar_id=%s order by hour_from", (id,))
263             for weekday, hour_from, hour_to in cr.fetchall():
264                 weekday = int(weekday)
265                 hours_range_per_weekday.setdefault(weekday, [])
266                 hours_range_per_weekday[weekday].append((hour_from, hour_to))
267         else:
268             # considering default working hours (Monday -> Friday, 8 -> 12, 13 -> 17)
269             for weekday in range(5):
270                 hours_range_per_weekday[weekday] = [(8, 12), (13, 17)]
271
272         ## Interval between dt_from - dt_to
273         ##
274         ##            dt_from            dt_to
275         ##  =============|==================|============
276         ##  [  1  ]   [  2  ]   [  3  ]  [  4  ]  [  5  ]
277         ##
278         ## [ : start of range
279         ## ] : end of range
280         ##
281         ## case 1: range end before interval start (skip)
282         ## case 2: range overlap interval start (fit start to internal)
283         ## case 3: range within interval
284         ## case 4: range overlap interval end (fit end to interval)
285         ## case 5: range start after interval end (skip)
286
287         interval_start = utc_to_local_zone(dt_from)
288         interval_end = utc_to_local_zone(dt_to)
289         hours_timedelta = timedelta()
290     
291         # Get leaves for requested resource
292         dt_leaves = set([])
293         if exclude_leaves and id:
294             dt_leaves = set(self._get_leaves(cr, uid, id, resource=resource_id))
295
296         for day in rrule.rrule(rrule.DAILY, dtstart=interval_start,
297                                until=interval_end+timedelta(days=1),
298                                byweekday=hours_range_per_weekday.keys()):
299             if exclude_leaves and day.strftime('%Y-%m-%d') in dt_leaves:
300                 # XXX: futher improve leave management to allow for partial day leave
301                 continue
302             for (range_from, range_to) in hours_range_per_weekday.get(day.weekday(), []):
303                 range_from_hour, range_from_min = float_time_convert(range_from)
304                 range_to_hour, range_to_min = float_time_convert(range_to)
305                 daytime_start = local_tz.localize(day.replace(hour=range_from_hour, minute=range_from_min, second=0, tzinfo=None))
306                 daytime_end = local_tz.localize(day.replace(hour=range_to_hour, minute=range_to_min, second=0, tzinfo=None))
307
308                 # case 1 & 5: time range out of interval
309                 if daytime_end < interval_start or daytime_start > interval_end:
310                     continue
311                 # case 2 & 4: adjust start, end to fit within interval
312                 daytime_start = max(daytime_start, interval_start)
313                 daytime_end = min(daytime_end, interval_end)
314                 
315                 # case 2+, 4+, 3
316                 hours_timedelta += (daytime_end - daytime_start)
317                 
318         # return timedelta converted to hours
319         return (hours_timedelta.days * 24.0 + hours_timedelta.seconds / 3600.0)
320
321
322 class resource_calendar_attendance(osv.osv):
323     _name = "resource.calendar.attendance"
324     _description = "Work Detail"
325
326     _columns = {
327         'name' : fields.char("Name", size=64, required=True),
328         'dayofweek': fields.selection([('0','Monday'),('1','Tuesday'),('2','Wednesday'),('3','Thursday'),('4','Friday'),('5','Saturday'),('6','Sunday')], 'Day of Week', required=True, select=True),
329         'date_from' : fields.date('Starting Date'),
330         'hour_from' : fields.float('Work from', required=True, help="Start and End time of working.", select=True),
331         'hour_to' : fields.float("Work to", required=True),
332         'calendar_id' : fields.many2one("resource.calendar", "Resource's Calendar", required=True),
333     }
334
335     _order = 'dayofweek, hour_from'
336
337     _defaults = {
338         'dayofweek' : '0'
339     }
340
341 def hours_time_string(hours):
342     """ convert a number of hours (float) into a string with format '%H:%M' """
343     minutes = int(round(hours * 60))
344     return "%02d:%02d" % divmod(minutes, 60)
345
346 class resource_resource(osv.osv):
347     _name = "resource.resource"
348     _description = "Resource Detail"
349     _columns = {
350         'name' : fields.char("Name", size=64, required=True),
351         'code': fields.char('Code', size=16),
352         'active' : fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the resource record without removing it."),
353         'company_id' : fields.many2one('res.company', 'Company'),
354         'resource_type': fields.selection([('user','Human'),('material','Material')], 'Resource Type', required=True),
355         'user_id' : fields.many2one('res.users', 'User', help='Related user name for the resource to manage its access.'),
356         'time_efficiency' : fields.float('Efficiency Factor', size=8, required=True, help="This field depict the efficiency of the resource to complete tasks. e.g  resource put alone on a phase of 5 days with 5 tasks assigned to him, will show a load of 100% for this phase by default, but if we put a efficiency of 200%, then his load will only be 50%."),
357         'calendar_id' : fields.many2one("resource.calendar", "Working Time", help="Define the schedule of resource"),
358     }
359     _defaults = {
360         'resource_type' : 'user',
361         'time_efficiency' : 1,
362         'active' : True,
363         'company_id': lambda self, cr, uid, context: self.pool.get('res.company')._company_default_get(cr, uid, 'resource.resource', context=context)
364     }
365
366
367     def copy(self, cr, uid, id, default=None, context=None):
368         if default is None:
369             default = {}
370         if not default.get('name', False):
371             default.update(name=_('%s (copy)') % (self.browse(cr, uid, id, context=context).name))
372         return super(resource_resource, self).copy(cr, uid, id, default, context)
373
374     def generate_resources(self, cr, uid, user_ids, calendar_id, context=None):
375         """
376         Return a list of  Resource Class objects for the resources allocated to the phase.
377         """
378         resource_objs = {}
379         user_pool = self.pool.get('res.users')
380         for user in user_pool.browse(cr, uid, user_ids, context=context):
381             resource_objs[user.id] = {
382                  'name' : user.name,
383                  'vacation': [],
384                  'efficiency': 1.0,
385             }
386
387             resource_ids = self.search(cr, uid, [('user_id', '=', user.id)], context=context)
388             if resource_ids:
389                 for resource in self.browse(cr, uid, resource_ids, context=context):
390                     resource_objs[user.id]['efficiency'] = resource.time_efficiency
391                     resource_cal = resource.calendar_id.id
392                     if resource_cal:
393                         leaves = self.compute_vacation(cr, uid, calendar_id, resource.id, resource_cal, context=context)
394                         resource_objs[user.id]['vacation'] += list(leaves)
395         return resource_objs
396
397     def compute_vacation(self, cr, uid, calendar_id, resource_id=False, resource_calendar=False, context=None):
398         """
399         Compute the vacation from the working calendar of the resource.
400
401         @param calendar_id : working calendar of the project
402         @param resource_id : resource working on phase/task
403         @param resource_calendar : working calendar of the resource
404         """
405         resource_calendar_leaves_pool = self.pool.get('resource.calendar.leaves')
406         leave_list = []
407         if resource_id:
408             leave_ids = resource_calendar_leaves_pool.search(cr, uid, ['|', ('calendar_id', '=', calendar_id),
409                                                                        ('calendar_id', '=', resource_calendar),
410                                                                        ('resource_id', '=', resource_id)
411                                                                       ], context=context)
412         else:
413             leave_ids = resource_calendar_leaves_pool.search(cr, uid, [('calendar_id', '=', calendar_id),
414                                                                       ('resource_id', '=', False)
415                                                                       ], context=context)
416         leaves = resource_calendar_leaves_pool.read(cr, uid, leave_ids, ['date_from', 'date_to'], context=context)
417         for i in range(len(leaves)):
418             dt_start = datetime.strptime(leaves[i]['date_from'], '%Y-%m-%d %H:%M:%S')
419             dt_end = datetime.strptime(leaves[i]['date_to'], '%Y-%m-%d %H:%M:%S')
420             no = dt_end - dt_start
421             [leave_list.append((dt_start + timedelta(days=x)).strftime('%Y-%m-%d')) for x in range(int(no.days + 1))]
422             leave_list.sort()
423         return leave_list
424
425     def compute_working_calendar(self, cr, uid, calendar_id=False, context=None):
426         """
427         Change the format of working calendar from 'Openerp' format to bring it into 'Faces' format.
428         @param calendar_id : working calendar of the project
429         """
430         if not calendar_id:
431             # Calendar is not specified: working days: 24/7
432             return [('fri', '8:0-12:0','13:0-17:0'), ('thu', '8:0-12:0','13:0-17:0'), ('wed', '8:0-12:0','13:0-17:0'),
433                    ('mon', '8:0-12:0','13:0-17:0'), ('tue', '8:0-12:0','13:0-17:0')]
434         resource_attendance_pool = self.pool.get('resource.calendar.attendance')
435         time_range = "8:00-8:00"
436         non_working = ""
437         week_days = {"0": "mon", "1": "tue", "2": "wed","3": "thu", "4": "fri", "5": "sat", "6": "sun"}
438         wk_days = {}
439         wk_time = {}
440         wktime_list = []
441         wktime_cal = []
442         week_ids = resource_attendance_pool.search(cr, uid, [('calendar_id', '=', calendar_id)], context=context)
443         weeks = resource_attendance_pool.read(cr, uid, week_ids, ['dayofweek', 'hour_from', 'hour_to'], context=context)
444         # Convert time formats into appropriate format required
445         # and create a list like [('mon', '8:00-12:00'), ('mon', '13:00-18:00')]
446         for week in weeks:
447             res_str = ""
448             day = None
449             if week_days.get(week['dayofweek'],False):
450                 day = week_days[week['dayofweek']]
451                 wk_days[week['dayofweek']] = week_days[week['dayofweek']]
452             else:
453                 raise osv.except_osv(_('Configuration Error!'),_('Make sure the Working time has been configured with proper week days!'))
454             hour_from_str = hours_time_string(week['hour_from'])
455             hour_to_str = hours_time_string(week['hour_to'])
456             res_str = hour_from_str + '-' + hour_to_str
457             wktime_list.append((day, res_str))
458         # Convert into format like [('mon', '8:00-12:00', '13:00-18:00')]
459         for item in wktime_list:
460             if wk_time.has_key(item[0]):
461                 wk_time[item[0]].append(item[1])
462             else:
463                 wk_time[item[0]] = [item[0]]
464                 wk_time[item[0]].append(item[1])
465         for k,v in wk_time.items():
466             wktime_cal.append(tuple(v))
467         # Add for the non-working days like: [('sat, sun', '8:00-8:00')]
468         for k, v in wk_days.items():
469             if week_days.has_key(k):
470                 week_days.pop(k)
471         for v in week_days.itervalues():
472             non_working += v + ','
473         if non_working:
474             wktime_cal.append((non_working[:-1], time_range))
475         return wktime_cal
476
477
478 class resource_calendar_leaves(osv.osv):
479     _name = "resource.calendar.leaves"
480     _description = "Leave Detail"
481     _columns = {
482         'name' : fields.char("Name", size=64),
483         'company_id' : fields.related('calendar_id','company_id',type='many2one',relation='res.company',string="Company", store=True, readonly=True),
484         'calendar_id' : fields.many2one("resource.calendar", "Working Time"),
485         'date_from' : fields.datetime('Start Date', required=True),
486         'date_to' : fields.datetime('End Date', required=True),
487         'resource_id' : fields.many2one("resource.resource", "Resource", help="If empty, this is a generic holiday for the company. If a resource is set, the holiday/leave is only for this resource"),
488     }
489
490     def check_dates(self, cr, uid, ids, context=None):
491          leave = self.read(cr, uid, ids[0], ['date_from', 'date_to'])
492          if leave['date_from'] and leave['date_to']:
493              if leave['date_from'] > leave['date_to']:
494                  return False
495          return True
496
497     _constraints = [
498         (check_dates, 'Error! leave start-date must be lower then leave end-date.', ['date_from', 'date_to'])
499     ]
500
501     def onchange_resource(self, cr, uid, ids, resource, context=None):
502         result = {}
503         if resource:
504             resource_pool = self.pool.get('resource.resource')
505             result['calendar_id'] = resource_pool.browse(cr, uid, resource, context=context).calendar_id.id
506             return {'value': result}
507         return {'value': {'calendar_id': []}}
508
509
510 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: