Merge remote-tracking branch 'odoo/master' into master-wmsstaging3-jco
[odoo/odoo.git] / addons / resource / resource.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-TODAY OpenERP SA (http://www.openerp.com)
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 datetime
23 from dateutil import rrule
24 from dateutil.relativedelta import relativedelta
25 from operator import itemgetter
26
27 from openerp import tools
28 from openerp.osv import fields, osv
29 from openerp.tools.float_utils import float_compare
30 from openerp.tools.translate import _
31
32 class resource_calendar(osv.osv):
33     """ Calendar model for a resource. It has
34
35      - attendance_ids: list of resource.calendar.attendance that are a working
36                        interval in a given weekday.
37      - leave_ids: list of leaves linked to this calendar. A leave can be general
38                   or linked to a specific resource, depending on its resource_id.
39
40     All methods in this class use intervals. An interval is a tuple holding
41     (begin_datetime, end_datetime). A list of intervals is therefore a list of
42     tuples, holding several intervals of work or leaves. """
43     _name = "resource.calendar"
44     _description = "Resource Calendar"
45
46     _columns = {
47         'name': fields.char("Name", size=64, required=True),
48         'company_id': fields.many2one('res.company', 'Company', required=False),
49         'attendance_ids': fields.one2many('resource.calendar.attendance', 'calendar_id', 'Working Time'),
50         'manager': fields.many2one('res.users', 'Workgroup Manager'),
51         'leave_ids': fields.one2many(
52             'resource.calendar.leaves', 'calendar_id', 'Leaves',
53             help=''
54         ),
55     }
56     _defaults = {
57         'company_id': lambda self, cr, uid, context: self.pool.get('res.company')._company_default_get(cr, uid, 'resource.calendar', context=context)
58     }
59
60     # --------------------------------------------------
61     # Utility methods
62     # --------------------------------------------------
63
64     def interval_clean(self, intervals):
65         """ Utility method that sorts and removes overlapping inside datetime
66         intervals. The intervals are sorted based on increasing starting datetime.
67         Overlapping intervals are merged into a single one.
68
69         :param list intervals: list of intervals; each interval is a tuple
70                                (datetime_from, datetime_to)
71         :return list cleaned: list of sorted intervals without overlap """
72         intervals = sorted(intervals, key=itemgetter(0))  # sort on first datetime
73         cleaned = []
74         working_interval = None
75         while intervals:
76             current_interval = intervals.pop(0)
77             if not working_interval:  # init
78                 working_interval = [current_interval[0], current_interval[1]]
79             elif working_interval[1] < current_interval[0]:  # interval is disjoint
80                 cleaned.append(tuple(working_interval))
81                 working_interval = [current_interval[0], current_interval[1]]
82             elif working_interval[1] < current_interval[1]:  # union of greater intervals
83                 working_interval[1] = current_interval[1]
84         if working_interval:  # handle void lists
85             cleaned.append(tuple(working_interval))
86         return cleaned
87
88     def interval_remove_leaves(self, interval, leave_intervals):
89         """ Utility method that remove leave intervals from a base interval:
90
91          - clean the leave intervals, to have an ordered list of not-overlapping
92            intervals
93          - initiate the current interval to be the base interval
94          - for each leave interval:
95
96           - finishing before the current interval: skip, go to next
97           - beginning after the current interval: skip and get out of the loop
98             because we are outside range (leaves are ordered)
99           - beginning within the current interval: close the current interval
100             and begin a new current interval that begins at the end of the leave
101             interval
102           - ending within the current interval: update the current interval begin
103             to match the leave interval ending
104
105         :param tuple interval: a tuple (beginning datetime, ending datetime) that
106                                is the base interval from which the leave intervals
107                                will be removed
108         :param list leave_intervals: a list of tuples (beginning datetime, ending datetime)
109                                     that are intervals to remove from the base interval
110         :return list intervals: a list of tuples (begin datetime, end datetime)
111                                 that are the remaining valid intervals """
112         if not interval:
113             return interval
114         if leave_intervals is None:
115             leave_intervals = []
116         intervals = []
117         leave_intervals = self.interval_clean(leave_intervals)
118         current_interval = [interval[0], interval[1]]
119         for leave in leave_intervals:
120             if leave[1] <= current_interval[0]:
121                 continue
122             if leave[0] >= current_interval[1]:
123                 break
124             if current_interval[0] < leave[0] < current_interval[1]:
125                 current_interval[1] = leave[0]
126                 intervals.append((current_interval[0], current_interval[1]))
127                 current_interval = [leave[1], interval[1]]
128             # if current_interval[0] <= leave[1] <= current_interval[1]:
129             if current_interval[0] <= leave[1]:
130                 current_interval[0] = leave[1]
131         if current_interval and current_interval[0] < interval[1]:  # remove intervals moved outside base interval due to leaves
132             intervals.append((current_interval[0], current_interval[1]))
133         return intervals
134
135     def interval_schedule_hours(self, intervals, hour, remove_at_end=True):
136         """ Schedule hours in intervals. The last matching interval is truncated
137         to match the specified hours.
138
139         It is possible to truncate the last interval at its beginning or ending.
140         However this does nothing on the given interval order that should be
141         submitted accordingly.
142
143         :param list intervals:  a list of tuples (beginning datetime, ending datetime)
144         :param int/float hours: number of hours to schedule. It will be converted
145                                 into a timedelta, but should be submitted as an
146                                 int or float.
147         :param boolean remove_at_end: remove extra hours at the end of the last
148                                       matching interval. Otherwise, do it at the
149                                       beginning.
150
151         :return list results: a list of intervals. If the number of hours to schedule
152         is greater than the possible scheduling in the intervals, no extra-scheduling
153         is done, and results == intervals. """
154         results = []
155         res = datetime.timedelta()
156         limit = datetime.timedelta(hours=hour)
157         for interval in intervals:
158             res += interval[1] - interval[0]
159             if res > limit and remove_at_end:
160                 interval = (interval[0], interval[1] + relativedelta(seconds=seconds(limit-res)))
161             elif res > limit:
162                 interval = (interval[0] + relativedelta(seconds=seconds(res-limit)), interval[1])
163             results.append(interval)
164             if res > limit:
165                 break
166         return results
167
168     # --------------------------------------------------
169     # Date and hours computation
170     # --------------------------------------------------
171
172     def get_attendances_for_weekdays(self, cr, uid, id, weekdays, context=None):
173         """ Given a list of weekdays, return matching resource.calendar.attendance"""
174         calendar = self.browse(cr, uid, id, context=None)
175         return [att for att in calendar.attendance_ids if int(att.dayofweek) in weekdays]
176
177     def get_weekdays(self, cr, uid, id, default_weekdays=None, context=None):
178         """ Return the list of weekdays that contain at least one working interval.
179         If no id is given (no calendar), return default weekdays. """
180         if id is None:
181             return default_weekdays if default_weekdays is not None else [0, 1, 2, 3, 4]
182         calendar = self.browse(cr, uid, id, context=None)
183         weekdays = set()
184         for attendance in calendar.attendance_ids:
185             weekdays.add(int(attendance.dayofweek))
186         return list(weekdays)
187
188     def get_next_day(self, cr, uid, id, day_date, context=None):
189         """ Get following date of day_date, based on resource.calendar. If no
190         calendar is provided, just return the next day.
191
192         :param int id: id of a resource.calendar. If not given, simply add one day
193                        to the submitted date.
194         :param date day_date: current day as a date
195
196         :return date: next day of calendar, or just next day """
197         if not id:
198             return day_date + relativedelta(days=1)
199         weekdays = self.get_weekdays(cr, uid, id, context)
200
201         base_index = -1
202         for weekday in weekdays:
203             if weekday > day_date.weekday():
204                 break
205             base_index += 1
206
207         new_index = (base_index + 1) % len(weekdays)
208         days = (weekdays[new_index] - day_date.weekday())
209         if days < 0:
210             days = 7 + days
211
212         return day_date + relativedelta(days=days)
213
214     def get_previous_day(self, cr, uid, id, day_date, context=None):
215         """ Get previous date of day_date, based on resource.calendar. If no
216         calendar is provided, just return the previous day.
217
218         :param int id: id of a resource.calendar. If not given, simply remove
219                        one day from the submitted date.
220         :param date day_date: current day as a date
221
222         :return date: previous day of calendar, or just previous day """
223         if not id:
224             return day_date + relativedelta(days=-1)
225         weekdays = self.get_weekdays(cr, uid, id, context)
226         weekdays.reverse()
227
228         base_index = -1
229         for weekday in weekdays:
230             if weekday < day_date.weekday():
231                 break
232             base_index += 1
233
234         new_index = (base_index + 1) % len(weekdays)
235         days = (weekdays[new_index] - day_date.weekday())
236         if days > 0:
237             days = days - 7
238
239         return day_date + relativedelta(days=days)
240
241     def get_leave_intervals(self, cr, uid, id, resource_id=None,
242                             start_datetime=None, end_datetime=None,
243                             context=None):
244         """Get the leaves of the calendar. Leaves can be filtered on the resource,
245         the start datetime or the end datetime.
246
247         :param int resource_id: the id of the resource to take into account when
248                                 computing the leaves. If not set, only general
249                                 leaves are computed. If set, generic and
250                                 specific leaves are computed.
251         :param datetime start_datetime: if provided, do not take into account leaves
252                                         ending before this date.
253         :param datetime end_datetime: if provided, do not take into account leaves
254                                         beginning after this date.
255
256         :return list leaves: list of tuples (start_datetime, end_datetime) of
257                              leave intervals
258         """
259         resource_calendar = self.browse(cr, uid, id, context=context)
260         leaves = []
261         for leave in resource_calendar.leave_ids:
262             if leave.resource_id and not resource_id == leave.resource_id.id:
263                 continue
264             date_from = datetime.datetime.strptime(leave.date_from, tools.DEFAULT_SERVER_DATETIME_FORMAT)
265             if end_datetime and date_from > end_datetime:
266                 continue
267             date_to = datetime.datetime.strptime(leave.date_to, tools.DEFAULT_SERVER_DATETIME_FORMAT)
268             if start_datetime and date_to < start_datetime:
269                 continue
270             leaves.append((date_from, date_to))
271         return leaves
272
273     def get_working_intervals_of_day(self, cr, uid, id, start_dt=None, end_dt=None,
274                                      leaves=None, compute_leaves=False, resource_id=None,
275                                      default_interval=None, context=None):
276         """ Get the working intervals of the day based on calendar. This method
277         handle leaves that come directly from the leaves parameter or can be computed.
278
279         :param int id: resource.calendar id; take the first one if is a list
280         :param datetime start_dt: datetime object that is the beginning hours
281                                   for the working intervals computation; any
282                                   working interval beginning before start_dt
283                                   will be truncated. If not set, set to end_dt
284                                   or today() if no end_dt at 00.00.00.
285         :param datetime end_dt: datetime object that is the ending hour
286                                 for the working intervals computation; any
287                                 working interval ending after end_dt
288                                 will be truncated. If not set, set to start_dt()
289                                 at 23.59.59.
290         :param list leaves: a list of tuples(start_datetime, end_datetime) that
291                             represent leaves.
292         :param boolean compute_leaves: if set and if leaves is None, compute the
293                                        leaves based on calendar and resource.
294                                        If leaves is None and compute_leaves false
295                                        no leaves are taken into account.
296         :param int resource_id: the id of the resource to take into account when
297                                 computing the leaves. If not set, only general
298                                 leaves are computed. If set, generic and
299                                 specific leaves are computed.
300         :param tuple default_interval: if no id, try to return a default working
301                                        day using default_interval[0] as beginning
302                                        hour, and default_interval[1] as ending hour.
303                                        Example: default_interval = (8, 16).
304                                        Otherwise, a void list of working intervals
305                                        is returned when id is None.
306
307         :return list intervals: a list of tuples (start_datetime, end_datetime)
308                                 of work intervals """
309         if isinstance(id, (list, tuple)):
310             id = id[0]
311
312         # Computes start_dt, end_dt (with default values if not set) + off-interval work limits
313         work_limits = []
314         if start_dt is None and end_dt is not None:
315             start_dt = end_dt.replace(hour=0, minute=0, second=0)
316         elif start_dt is None:
317             start_dt = datetime.datetime.now().replace(hour=0, minute=0, second=0)
318         else:
319             work_limits.append((start_dt.replace(hour=0, minute=0, second=0), start_dt))
320         if end_dt is None:
321             end_dt = start_dt.replace(hour=23, minute=59, second=59)
322         else:
323             work_limits.append((end_dt, end_dt.replace(hour=23, minute=59, second=59)))
324         assert start_dt.date() == end_dt.date(), 'get_working_intervals_of_day is restricted to one day'
325
326         intervals = []
327         work_dt = start_dt.replace(hour=0, minute=0, second=0)
328
329         # no calendar: try to use the default_interval, then return directly
330         if id is None:
331             if default_interval:
332                 intervals.append((start_dt.replace(hour=default_interval[0]), start_dt.replace(hour=default_interval[1])))
333             return intervals
334
335         working_intervals = []
336         for calendar_working_day in self.get_attendances_for_weekdays(cr, uid, id, [start_dt.weekday()], context):
337             working_interval = (
338                 work_dt.replace(hour=int(calendar_working_day.hour_from)),
339                 work_dt.replace(hour=int(calendar_working_day.hour_to))
340             )
341             working_intervals += self.interval_remove_leaves(working_interval, work_limits)
342
343         # find leave intervals
344         if leaves is None and compute_leaves:
345             leaves = self.get_leave_intervals(cr, uid, id, resource_id=resource_id, context=None)
346
347         # filter according to leaves
348         for interval in working_intervals:
349             work_intervals = self.interval_remove_leaves(interval, leaves)
350             intervals += work_intervals
351
352         return intervals
353
354     def get_working_hours_of_date(self, cr, uid, id, start_dt=None, end_dt=None,
355                                   leaves=None, compute_leaves=False, resource_id=None,
356                                   default_interval=None, context=None):
357         """ Get the working hours of the day based on calendar. This method uses
358         get_working_intervals_of_day to have the work intervals of the day. It
359         then calculates the number of hours contained in those intervals. """
360         res = datetime.timedelta()
361         intervals = self.get_working_intervals_of_day(
362             cr, uid, id,
363             start_dt, end_dt, leaves,
364             compute_leaves, resource_id,
365             default_interval, context)
366         for interval in intervals:
367             res += interval[1] - interval[0]
368         return seconds(res) / 3600.0
369
370     def get_working_hours(self, cr, uid, id, start_dt, end_dt, compute_leaves=False,
371                           resource_id=None, default_interval=None, context=None):
372         hours = 0.0
373         for day in rrule.rrule(rrule.DAILY, dtstart=start_dt,
374                                until=end_dt + datetime.timedelta(days=1),
375                                byweekday=self.get_weekdays(cr, uid, id, context=context)):
376             hours += self.get_working_hours_of_date(
377                 cr, uid, id, start_dt=day,
378                 compute_leaves=compute_leaves, resource_id=resource_id,
379                 default_interval=default_interval,
380                 context=context)
381         return hours
382
383     # --------------------------------------------------
384     # Hours scheduling
385     # --------------------------------------------------
386
387     def _schedule_hours(self, cr, uid, id, hours, day_dt=None,
388                         compute_leaves=False, resource_id=None,
389                         default_interval=None, context=None):
390         """ Schedule hours of work, using a calendar and an optional resource to
391         compute working and leave days. This method can be used backwards, i.e.
392         scheduling days before a deadline.
393
394         :param int hours: number of hours to schedule. Use a negative number to
395                           compute a backwards scheduling.
396         :param datetime day_dt: reference date to compute working days. If days is
397                                 > 0 date is the starting date. If days is < 0
398                                 date is the ending date.
399         :param boolean compute_leaves: if set, compute the leaves based on calendar
400                                        and resource. Otherwise no leaves are taken
401                                        into account.
402         :param int resource_id: the id of the resource to take into account when
403                                 computing the leaves. If not set, only general
404                                 leaves are computed. If set, generic and
405                                 specific leaves are computed.
406         :param tuple default_interval: if no id, try to return a default working
407                                        day using default_interval[0] as beginning
408                                        hour, and default_interval[1] as ending hour.
409                                        Example: default_interval = (8, 16).
410                                        Otherwise, a void list of working intervals
411                                        is returned when id is None.
412
413         :return tuple (datetime, intervals): datetime is the beginning/ending date
414                                              of the schedulign; intervals are the
415                                              working intervals of the scheduling.
416
417         Note: Why not using rrule.rrule ? Because rrule does not seem to allow
418         getting back in time.
419         """
420         if day_dt is None:
421             day_dt = datetime.datetime.now()
422         backwards = (hours < 0)
423         hours = abs(hours)
424         intervals = []
425         remaining_hours = hours * 1.0
426         iterations = 0
427         current_datetime = day_dt
428
429         call_args = dict(compute_leaves=compute_leaves, resource_id=resource_id, default_interval=default_interval, context=context)
430
431         while float_compare(remaining_hours, 0.0, precision_digits=2) in (1, 0) and iterations < 1000:
432             if backwards:
433                 call_args['end_dt'] = current_datetime
434             else:
435                 call_args['start_dt'] = current_datetime
436
437             working_intervals = self.get_working_intervals_of_day(cr, uid, id, **call_args)
438
439             if id is None and not working_intervals:  # no calendar -> consider working 8 hours
440                 remaining_hours -= 8.0
441             elif working_intervals:
442                 if backwards:
443                     working_intervals.reverse()
444                 new_working_intervals = self.interval_schedule_hours(working_intervals, remaining_hours, not backwards)
445                 if backwards:
446                     new_working_intervals.reverse()
447
448                 res = datetime.timedelta()
449                 for interval in working_intervals:
450                     res += interval[1] - interval[0]
451                 remaining_hours -= (seconds(res) / 3600.0)
452                 if backwards:
453                     intervals = new_working_intervals + intervals
454                 else:
455                     intervals = intervals + new_working_intervals
456             # get next day
457             if backwards:
458                 current_datetime = datetime.datetime.combine(self.get_previous_day(cr, uid, id, current_datetime, context), datetime.time(23, 59, 59))
459             else:
460                 current_datetime = datetime.datetime.combine(self.get_next_day(cr, uid, id, current_datetime, context), datetime.time())
461             # avoid infinite loops
462             iterations += 1
463
464         return intervals
465
466     def schedule_hours_get_date(self, cr, uid, id, hours, day_dt=None,
467                                 compute_leaves=False, resource_id=None,
468                                 default_interval=None, context=None):
469         """ Wrapper on _schedule_hours: return the beginning/ending datetime of
470         an hours scheduling. """
471         res = self._schedule_hours(cr, uid, id, hours, day_dt, compute_leaves, resource_id, default_interval, context)
472         return res and res[0][0] or False
473
474     def schedule_hours(self, cr, uid, id, hours, day_dt=None,
475                        compute_leaves=False, resource_id=None,
476                        default_interval=None, context=None):
477         """ Wrapper on _schedule_hours: return the working intervals of an hours
478         scheduling. """
479         return self._schedule_hours(cr, uid, id, hours, day_dt, compute_leaves, resource_id, default_interval, context)
480
481     # --------------------------------------------------
482     # Days scheduling
483     # --------------------------------------------------
484
485     def _schedule_days(self, cr, uid, id, days, day_date=None, compute_leaves=False,
486                        resource_id=None, default_interval=None, context=None):
487         """Schedule days of work, using a calendar and an optional resource to
488         compute working and leave days. This method can be used backwards, i.e.
489         scheduling days before a deadline.
490
491         :param int days: number of days to schedule. Use a negative number to
492                          compute a backwards scheduling.
493         :param date day_date: reference date to compute working days. If days is > 0
494                               date is the starting date. If days is < 0 date is the
495                               ending date.
496         :param boolean compute_leaves: if set, compute the leaves based on calendar
497                                        and resource. Otherwise no leaves are taken
498                                        into account.
499         :param int resource_id: the id of the resource to take into account when
500                                 computing the leaves. If not set, only general
501                                 leaves are computed. If set, generic and
502                                 specific leaves are computed.
503         :param tuple default_interval: if no id, try to return a default working
504                                        day using default_interval[0] as beginning
505                                        hour, and default_interval[1] as ending hour.
506                                        Example: default_interval = (8, 16).
507                                        Otherwise, a void list of working intervals
508                                        is returned when id is None.
509
510         :return tuple (datetime, intervals): datetime is the beginning/ending date
511                                              of the schedulign; intervals are the
512                                              working intervals of the scheduling.
513
514         Implementation note: rrule.rrule is not used because rrule it des not seem
515         to allow getting back in time.
516         """
517         if day_date is None:
518             day_date = datetime.datetime.now()
519         backwards = (days < 0)
520         days = abs(days)
521         intervals = []
522         planned_days = 0
523         iterations = 0
524         if backwards:
525             current_datetime = day_date.replace(hour=23, minute=59, second=59)
526         else:
527             current_datetime = day_date.replace(hour=0, minute=0, second=0)
528
529         while planned_days < days and iterations < 1000:
530             working_intervals = self.get_working_intervals_of_day(
531                 cr, uid, id, current_datetime,
532                 compute_leaves=compute_leaves, resource_id=resource_id,
533                 default_interval=default_interval,
534                 context=context)
535             if id is None or working_intervals:  # no calendar -> no working hours, but day is considered as worked
536                 planned_days += 1
537                 intervals += working_intervals
538             # get next day
539             if backwards:
540                 current_datetime = self.get_previous_day(cr, uid, id, current_datetime, context)
541             else:
542                 current_datetime = self.get_next_day(cr, uid, id, current_datetime, context)
543             # avoid infinite loops
544             iterations += 1
545
546         return intervals
547
548     def schedule_days_get_date(self, cr, uid, id, days, day_date=None, compute_leaves=False,
549                                resource_id=None, default_interval=None, context=None):
550         """ Wrapper on _schedule_days: return the beginning/ending datetime of
551         a days scheduling. """
552         res = self._schedule_days(cr, uid, id, days, day_date, compute_leaves, resource_id, default_interval, context)
553         return res and res[-1][1] or False
554
555     def schedule_days(self, cr, uid, id, days, day_date=None, compute_leaves=False,
556                       resource_id=None, default_interval=None, context=None):
557         """ Wrapper on _schedule_days: return the working intervals of a days
558         scheduling. """
559         return self._schedule_days(cr, uid, id, days, day_date, compute_leaves, resource_id, default_interval, context)
560
561     # --------------------------------------------------
562     # Compatibility / to clean / to remove
563     # --------------------------------------------------
564
565     def working_hours_on_day(self, cr, uid, resource_calendar_id, day, context=None):
566         """ Used in hr_payroll/hr_payroll.py
567
568         :deprecated: OpenERP saas-3. Use get_working_hours_of_date instead. Note:
569         since saas-3, take hour/minutes into account, not just the whole day."""
570         if isinstance(day, datetime.datetime):
571             day = day.replace(hour=0, minute=0)
572         return self.get_working_hours_of_date(cr, uid, resource_calendar_id.id, start_dt=day, context=None)
573
574     def interval_min_get(self, cr, uid, id, dt_from, hours, resource=False):
575         """ Schedule hours backwards. Used in mrp_operations/mrp_operations.py.
576
577         :deprecated: OpenERP saas-3. Use schedule_hours instead. Note: since
578         saas-3, counts leave hours instead of all-day leaves."""
579         return self.schedule_hours(
580             cr, uid, id, hours * -1.0,
581             day_dt=dt_from.replace(minute=0, second=0),
582             compute_leaves=True, resource_id=resource,
583             default_interval=(8, 16)
584         )
585
586     def interval_get_multi(self, cr, uid, date_and_hours_by_cal, resource=False, byday=True):
587         """ Used in mrp_operations/mrp_operations.py (default parameters) and in
588         interval_get()
589
590         :deprecated: OpenERP saas-3. Use schedule_hours instead. Note:
591         Byday was not used. Since saas-3, counts Leave hours instead of all-day leaves."""
592         res = {}
593         for dt_str, hours, calendar_id in date_and_hours_by_cal:
594             result = self.schedule_hours(
595                 cr, uid, calendar_id, hours,
596                 day_dt=datetime.datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S').replace(minute=0, second=0),
597                 compute_leaves=True, resource_id=resource,
598                 default_interval=(8, 16)
599             )
600             res[(dt_str, hours, calendar_id)] = result
601         return res
602
603     def interval_get(self, cr, uid, id, dt_from, hours, resource=False, byday=True):
604         """ Unifier of interval_get_multi. Used in: mrp_operations/mrp_operations.py,
605         crm/crm_lead.py (res given).
606
607         :deprecated: OpenERP saas-3. Use get_working_hours instead."""
608         res = self.interval_get_multi(
609             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)]
610         return res
611
612     def interval_hours_get(self, cr, uid, id, dt_from, dt_to, resource=False):
613         """ Unused wrapper.
614
615         :deprecated: OpenERP saas-3. Use get_working_hours instead."""
616         return self._interval_hours_get(cr, uid, id, dt_from, dt_to, resource_id=resource)
617
618     def _interval_hours_get(self, cr, uid, id, dt_from, dt_to, resource_id=False, timezone_from_uid=None, exclude_leaves=True, context=None):
619         """ Computes working hours between two dates, taking always same hour/minuts.
620
621         :deprecated: OpenERP saas-3. Use get_working_hours instead. Note: since saas-3,
622         now resets hour/minuts. Now counts leave hours instead of all-day leaves."""
623         return self.get_working_hours(
624             cr, uid, id, dt_from, dt_to,
625             compute_leaves=(not exclude_leaves), resource_id=resource_id,
626             default_interval=(8, 16), context=context)
627
628
629 class resource_calendar_attendance(osv.osv):
630     _name = "resource.calendar.attendance"
631     _description = "Work Detail"
632
633     _columns = {
634         'name' : fields.char("Name", size=64, required=True),
635         'dayofweek': fields.selection([('0','Monday'),('1','Tuesday'),('2','Wednesday'),('3','Thursday'),('4','Friday'),('5','Saturday'),('6','Sunday')], 'Day of Week', required=True, select=True),
636         'date_from' : fields.date('Starting Date'),
637         'hour_from' : fields.float('Work from', required=True, help="Start and End time of working.", select=True),
638         'hour_to' : fields.float("Work to", required=True),
639         'calendar_id' : fields.many2one("resource.calendar", "Resource's Calendar", required=True),
640     }
641
642     _order = 'dayofweek, hour_from'
643
644     _defaults = {
645         'dayofweek' : '0'
646     }
647
648 def hours_time_string(hours):
649     """ convert a number of hours (float) into a string with format '%H:%M' """
650     minutes = int(round(hours * 60))
651     return "%02d:%02d" % divmod(minutes, 60)
652
653 class resource_resource(osv.osv):
654     _name = "resource.resource"
655     _description = "Resource Detail"
656     _columns = {
657         'name' : fields.char("Name", size=64, required=True),
658         'code': fields.char('Code', size=16),
659         '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."),
660         'company_id' : fields.many2one('res.company', 'Company'),
661         'resource_type': fields.selection([('user','Human'),('material','Material')], 'Resource Type', required=True),
662         'user_id' : fields.many2one('res.users', 'User', help='Related user name for the resource to manage its access.'),
663         '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%."),
664         'calendar_id' : fields.many2one("resource.calendar", "Working Time", help="Define the schedule of resource"),
665     }
666     _defaults = {
667         'resource_type' : 'user',
668         'time_efficiency' : 1,
669         'active' : True,
670         'company_id': lambda self, cr, uid, context: self.pool.get('res.company')._company_default_get(cr, uid, 'resource.resource', context=context)
671     }
672
673
674     def copy(self, cr, uid, id, default=None, context=None):
675         if default is None:
676             default = {}
677         if not default.get('name', False):
678             default.update(name=_('%s (copy)') % (self.browse(cr, uid, id, context=context).name))
679         return super(resource_resource, self).copy(cr, uid, id, default, context)
680
681     def generate_resources(self, cr, uid, user_ids, calendar_id, context=None):
682         """
683         Return a list of  Resource Class objects for the resources allocated to the phase.
684
685         NOTE: Used in project/project.py
686         """
687         resource_objs = {}
688         user_pool = self.pool.get('res.users')
689         for user in user_pool.browse(cr, uid, user_ids, context=context):
690             resource_objs[user.id] = {
691                  'name' : user.name,
692                  'vacation': [],
693                  'efficiency': 1.0,
694             }
695
696             resource_ids = self.search(cr, uid, [('user_id', '=', user.id)], context=context)
697             if resource_ids:
698                 for resource in self.browse(cr, uid, resource_ids, context=context):
699                     resource_objs[user.id]['efficiency'] = resource.time_efficiency
700                     resource_cal = resource.calendar_id.id
701                     if resource_cal:
702                         leaves = self.compute_vacation(cr, uid, calendar_id, resource.id, resource_cal, context=context)
703                         resource_objs[user.id]['vacation'] += list(leaves)
704         return resource_objs
705
706     def compute_vacation(self, cr, uid, calendar_id, resource_id=False, resource_calendar=False, context=None):
707         """
708         Compute the vacation from the working calendar of the resource.
709
710         @param calendar_id : working calendar of the project
711         @param resource_id : resource working on phase/task
712         @param resource_calendar : working calendar of the resource
713
714         NOTE: used in project/project.py, and in generate_resources
715         """
716         resource_calendar_leaves_pool = self.pool.get('resource.calendar.leaves')
717         leave_list = []
718         if resource_id:
719             leave_ids = resource_calendar_leaves_pool.search(cr, uid, ['|', ('calendar_id', '=', calendar_id),
720                                                                        ('calendar_id', '=', resource_calendar),
721                                                                        ('resource_id', '=', resource_id)
722                                                                       ], context=context)
723         else:
724             leave_ids = resource_calendar_leaves_pool.search(cr, uid, [('calendar_id', '=', calendar_id),
725                                                                       ('resource_id', '=', False)
726                                                                       ], context=context)
727         leaves = resource_calendar_leaves_pool.read(cr, uid, leave_ids, ['date_from', 'date_to'], context=context)
728         for i in range(len(leaves)):
729             dt_start = datetime.datetime.strptime(leaves[i]['date_from'], '%Y-%m-%d %H:%M:%S')
730             dt_end = datetime.datetime.strptime(leaves[i]['date_to'], '%Y-%m-%d %H:%M:%S')
731             no = dt_end - dt_start
732             [leave_list.append((dt_start + datetime.timedelta(days=x)).strftime('%Y-%m-%d')) for x in range(int(no.days + 1))]
733             leave_list.sort()
734         return leave_list
735
736     def compute_working_calendar(self, cr, uid, calendar_id=False, context=None):
737         """
738         Change the format of working calendar from 'Openerp' format to bring it into 'Faces' format.
739         @param calendar_id : working calendar of the project
740
741         NOTE: used in project/project.py
742         """
743         if not calendar_id:
744             # Calendar is not specified: working days: 24/7
745             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'),
746                    ('mon', '8:0-12:0','13:0-17:0'), ('tue', '8:0-12:0','13:0-17:0')]
747         resource_attendance_pool = self.pool.get('resource.calendar.attendance')
748         time_range = "8:00-8:00"
749         non_working = ""
750         week_days = {"0": "mon", "1": "tue", "2": "wed","3": "thu", "4": "fri", "5": "sat", "6": "sun"}
751         wk_days = {}
752         wk_time = {}
753         wktime_list = []
754         wktime_cal = []
755         week_ids = resource_attendance_pool.search(cr, uid, [('calendar_id', '=', calendar_id)], context=context)
756         weeks = resource_attendance_pool.read(cr, uid, week_ids, ['dayofweek', 'hour_from', 'hour_to'], context=context)
757         # Convert time formats into appropriate format required
758         # and create a list like [('mon', '8:00-12:00'), ('mon', '13:00-18:00')]
759         for week in weeks:
760             res_str = ""
761             day = None
762             if week_days.get(week['dayofweek'],False):
763                 day = week_days[week['dayofweek']]
764                 wk_days[week['dayofweek']] = week_days[week['dayofweek']]
765             else:
766                 raise osv.except_osv(_('Configuration Error!'),_('Make sure the Working time has been configured with proper week days!'))
767             hour_from_str = hours_time_string(week['hour_from'])
768             hour_to_str = hours_time_string(week['hour_to'])
769             res_str = hour_from_str + '-' + hour_to_str
770             wktime_list.append((day, res_str))
771         # Convert into format like [('mon', '8:00-12:00', '13:00-18:00')]
772         for item in wktime_list:
773             if wk_time.has_key(item[0]):
774                 wk_time[item[0]].append(item[1])
775             else:
776                 wk_time[item[0]] = [item[0]]
777                 wk_time[item[0]].append(item[1])
778         for k,v in wk_time.items():
779             wktime_cal.append(tuple(v))
780         # Add for the non-working days like: [('sat, sun', '8:00-8:00')]
781         for k, v in wk_days.items():
782             if week_days.has_key(k):
783                 week_days.pop(k)
784         for v in week_days.itervalues():
785             non_working += v + ','
786         if non_working:
787             wktime_cal.append((non_working[:-1], time_range))
788         return wktime_cal
789
790
791 class resource_calendar_leaves(osv.osv):
792     _name = "resource.calendar.leaves"
793     _description = "Leave Detail"
794     _columns = {
795         'name' : fields.char("Name", size=64),
796         'company_id' : fields.related('calendar_id','company_id',type='many2one',relation='res.company',string="Company", store=True, readonly=True),
797         'calendar_id' : fields.many2one("resource.calendar", "Working Time"),
798         'date_from' : fields.datetime('Start Date', required=True),
799         'date_to' : fields.datetime('End Date', required=True),
800         '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"),
801     }
802
803     def check_dates(self, cr, uid, ids, context=None):
804          leave = self.read(cr, uid, ids[0], ['date_from', 'date_to'])
805          if leave['date_from'] and leave['date_to']:
806              if leave['date_from'] > leave['date_to']:
807                  return False
808          return True
809
810     _constraints = [
811         (check_dates, 'Error! leave start-date must be lower then leave end-date.', ['date_from', 'date_to'])
812     ]
813
814     def onchange_resource(self, cr, uid, ids, resource, context=None):
815         result = {}
816         if resource:
817             resource_pool = self.pool.get('resource.resource')
818             result['calendar_id'] = resource_pool.browse(cr, uid, resource, context=context).calendar_id.id
819             return {'value': result}
820         return {'value': {'calendar_id': []}}
821
822 def seconds(td):
823     assert isinstance(td, datetime.timedelta)
824
825     return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10.**6
826
827 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: