[FIX] project_issue, resource: fixed computation of hours / days to open (assign...
[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", required=True),
48         'company_id': fields.many2one('res.company', 'Company', required=False),
49         'attendance_ids': fields.one2many('resource.calendar.attendance', 'calendar_id', 'Working Time', copy=True),
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                 working_interval = (start_dt.replace(hour=default_interval[0], minute=0, second=0), start_dt.replace(hour=default_interval[1], minute=0, second=0))
333             intervals = self.interval_remove_leaves(working_interval, work_limits)
334             return intervals
335
336         working_intervals = []
337         for calendar_working_day in self.get_attendances_for_weekdays(cr, uid, id, [start_dt.weekday()], context):
338             working_interval = (
339                 work_dt.replace(hour=int(calendar_working_day.hour_from)),
340                 work_dt.replace(hour=int(calendar_working_day.hour_to))
341             )
342             working_intervals += self.interval_remove_leaves(working_interval, work_limits)
343
344         # find leave intervals
345         if leaves is None and compute_leaves:
346             leaves = self.get_leave_intervals(cr, uid, id, resource_id=resource_id, context=None)
347
348         # filter according to leaves
349         for interval in working_intervals:
350             work_intervals = self.interval_remove_leaves(interval, leaves)
351             intervals += work_intervals
352
353         return intervals
354
355     def get_working_hours_of_date(self, cr, uid, id, start_dt=None, end_dt=None,
356                                   leaves=None, compute_leaves=False, resource_id=None,
357                                   default_interval=None, context=None):
358         """ Get the working hours of the day based on calendar. This method uses
359         get_working_intervals_of_day to have the work intervals of the day. It
360         then calculates the number of hours contained in those intervals. """
361         res = datetime.timedelta()
362         intervals = self.get_working_intervals_of_day(
363             cr, uid, id,
364             start_dt, end_dt, leaves,
365             compute_leaves, resource_id,
366             default_interval, context)
367         for interval in intervals:
368             res += interval[1] - interval[0]
369         return seconds(res) / 3600.0
370
371     def get_working_hours(self, cr, uid, id, start_dt, end_dt, compute_leaves=False,
372                           resource_id=None, default_interval=None, context=None):
373         hours = 0.0
374         for day in rrule.rrule(rrule.DAILY, dtstart=start_dt,
375                                until=(end_dt + datetime.timedelta(days=1)).replace(hour=0, minute=0, second=0),
376                                byweekday=self.get_weekdays(cr, uid, id, context=context)):
377             day_start_dt = day.replace(hour=0, minute=0, second=0)
378             if start_dt and day.date() == start_dt.date():
379                 day_start_dt = start_dt
380             day_end_dt = day.replace(hour=23, minute=59, second=59)
381             if end_dt and day.date() == end_dt.date():
382                 day_end_dt = end_dt
383             hours += self.get_working_hours_of_date(
384                 cr, uid, id, start_dt=day_start_dt, end_dt=day_end_dt,
385                 compute_leaves=compute_leaves, resource_id=resource_id,
386                 default_interval=default_interval,
387                 context=context)
388         return hours
389
390     # --------------------------------------------------
391     # Hours scheduling
392     # --------------------------------------------------
393
394     def _schedule_hours(self, cr, uid, id, hours, day_dt=None,
395                         compute_leaves=False, resource_id=None,
396                         default_interval=None, context=None):
397         """ Schedule hours of work, using a calendar and an optional resource to
398         compute working and leave days. This method can be used backwards, i.e.
399         scheduling days before a deadline.
400
401         :param int hours: number of hours to schedule. Use a negative number to
402                           compute a backwards scheduling.
403         :param datetime day_dt: reference date to compute working days. If days is
404                                 > 0 date is the starting date. If days is < 0
405                                 date is the ending date.
406         :param boolean compute_leaves: if set, compute the leaves based on calendar
407                                        and resource. Otherwise no leaves are taken
408                                        into account.
409         :param int resource_id: the id of the resource to take into account when
410                                 computing the leaves. If not set, only general
411                                 leaves are computed. If set, generic and
412                                 specific leaves are computed.
413         :param tuple default_interval: if no id, try to return a default working
414                                        day using default_interval[0] as beginning
415                                        hour, and default_interval[1] as ending hour.
416                                        Example: default_interval = (8, 16).
417                                        Otherwise, a void list of working intervals
418                                        is returned when id is None.
419
420         :return tuple (datetime, intervals): datetime is the beginning/ending date
421                                              of the schedulign; intervals are the
422                                              working intervals of the scheduling.
423
424         Note: Why not using rrule.rrule ? Because rrule does not seem to allow
425         getting back in time.
426         """
427         if day_dt is None:
428             day_dt = datetime.datetime.now()
429         backwards = (hours < 0)
430         hours = abs(hours)
431         intervals = []
432         remaining_hours = hours * 1.0
433         iterations = 0
434         current_datetime = day_dt
435
436         call_args = dict(compute_leaves=compute_leaves, resource_id=resource_id, default_interval=default_interval, context=context)
437
438         while float_compare(remaining_hours, 0.0, precision_digits=2) in (1, 0) and iterations < 1000:
439             if backwards:
440                 call_args['end_dt'] = current_datetime
441             else:
442                 call_args['start_dt'] = current_datetime
443
444             working_intervals = self.get_working_intervals_of_day(cr, uid, id, **call_args)
445
446             if id is None and not working_intervals:  # no calendar -> consider working 8 hours
447                 remaining_hours -= 8.0
448             elif working_intervals:
449                 if backwards:
450                     working_intervals.reverse()
451                 new_working_intervals = self.interval_schedule_hours(working_intervals, remaining_hours, not backwards)
452                 if backwards:
453                     new_working_intervals.reverse()
454
455                 res = datetime.timedelta()
456                 for interval in working_intervals:
457                     res += interval[1] - interval[0]
458                 remaining_hours -= (seconds(res) / 3600.0)
459                 if backwards:
460                     intervals = new_working_intervals + intervals
461                 else:
462                     intervals = intervals + new_working_intervals
463             # get next day
464             if backwards:
465                 current_datetime = datetime.datetime.combine(self.get_previous_day(cr, uid, id, current_datetime, context), datetime.time(23, 59, 59))
466             else:
467                 current_datetime = datetime.datetime.combine(self.get_next_day(cr, uid, id, current_datetime, context), datetime.time())
468             # avoid infinite loops
469             iterations += 1
470
471         return intervals
472
473     def schedule_hours_get_date(self, cr, uid, id, hours, day_dt=None,
474                                 compute_leaves=False, resource_id=None,
475                                 default_interval=None, context=None):
476         """ Wrapper on _schedule_hours: return the beginning/ending datetime of
477         an hours scheduling. """
478         res = self._schedule_hours(cr, uid, id, hours, day_dt, compute_leaves, resource_id, default_interval, context)
479         return res and res[0][0] or False
480
481     def schedule_hours(self, cr, uid, id, hours, day_dt=None,
482                        compute_leaves=False, resource_id=None,
483                        default_interval=None, context=None):
484         """ Wrapper on _schedule_hours: return the working intervals of an hours
485         scheduling. """
486         return self._schedule_hours(cr, uid, id, hours, day_dt, compute_leaves, resource_id, default_interval, context)
487
488     # --------------------------------------------------
489     # Days scheduling
490     # --------------------------------------------------
491
492     def _schedule_days(self, cr, uid, id, days, day_date=None, compute_leaves=False,
493                        resource_id=None, default_interval=None, context=None):
494         """Schedule days of work, using a calendar and an optional resource to
495         compute working and leave days. This method can be used backwards, i.e.
496         scheduling days before a deadline.
497
498         :param int days: number of days to schedule. Use a negative number to
499                          compute a backwards scheduling.
500         :param date day_date: reference date to compute working days. If days is > 0
501                               date is the starting date. If days is < 0 date is the
502                               ending date.
503         :param boolean compute_leaves: if set, compute the leaves based on calendar
504                                        and resource. Otherwise no leaves are taken
505                                        into account.
506         :param int resource_id: the id of the resource to take into account when
507                                 computing the leaves. If not set, only general
508                                 leaves are computed. If set, generic and
509                                 specific leaves are computed.
510         :param tuple default_interval: if no id, try to return a default working
511                                        day using default_interval[0] as beginning
512                                        hour, and default_interval[1] as ending hour.
513                                        Example: default_interval = (8, 16).
514                                        Otherwise, a void list of working intervals
515                                        is returned when id is None.
516
517         :return tuple (datetime, intervals): datetime is the beginning/ending date
518                                              of the schedulign; intervals are the
519                                              working intervals of the scheduling.
520
521         Implementation note: rrule.rrule is not used because rrule it des not seem
522         to allow getting back in time.
523         """
524         if day_date is None:
525             day_date = datetime.datetime.now()
526         backwards = (days < 0)
527         days = abs(days)
528         intervals = []
529         planned_days = 0
530         iterations = 0
531         if backwards:
532             current_datetime = day_date.replace(hour=23, minute=59, second=59)
533         else:
534             current_datetime = day_date.replace(hour=0, minute=0, second=0)
535
536         while planned_days < days and iterations < 1000:
537             working_intervals = self.get_working_intervals_of_day(
538                 cr, uid, id, current_datetime,
539                 compute_leaves=compute_leaves, resource_id=resource_id,
540                 default_interval=default_interval,
541                 context=context)
542             if id is None or working_intervals:  # no calendar -> no working hours, but day is considered as worked
543                 planned_days += 1
544                 intervals += working_intervals
545             # get next day
546             if backwards:
547                 current_datetime = self.get_previous_day(cr, uid, id, current_datetime, context)
548             else:
549                 current_datetime = self.get_next_day(cr, uid, id, current_datetime, context)
550             # avoid infinite loops
551             iterations += 1
552
553         return intervals
554
555     def schedule_days_get_date(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 beginning/ending datetime of
558         a days scheduling. """
559         res = self._schedule_days(cr, uid, id, days, day_date, compute_leaves, resource_id, default_interval, context)
560         return res and res[-1][1] or False
561
562     def schedule_days(self, cr, uid, id, days, day_date=None, compute_leaves=False,
563                       resource_id=None, default_interval=None, context=None):
564         """ Wrapper on _schedule_days: return the working intervals of a days
565         scheduling. """
566         return self._schedule_days(cr, uid, id, days, day_date, compute_leaves, resource_id, default_interval, context)
567
568     # --------------------------------------------------
569     # Compatibility / to clean / to remove
570     # --------------------------------------------------
571
572     def working_hours_on_day(self, cr, uid, resource_calendar_id, day, context=None):
573         """ Used in hr_payroll/hr_payroll.py
574
575         :deprecated: OpenERP saas-3. Use get_working_hours_of_date instead. Note:
576         since saas-3, take hour/minutes into account, not just the whole day."""
577         if isinstance(day, datetime.datetime):
578             day = day.replace(hour=0, minute=0)
579         return self.get_working_hours_of_date(cr, uid, resource_calendar_id.id, start_dt=day, context=None)
580
581     def interval_min_get(self, cr, uid, id, dt_from, hours, resource=False):
582         """ Schedule hours backwards. Used in mrp_operations/mrp_operations.py.
583
584         :deprecated: OpenERP saas-3. Use schedule_hours instead. Note: since
585         saas-3, counts leave hours instead of all-day leaves."""
586         return self.schedule_hours(
587             cr, uid, id, hours * -1.0,
588             day_dt=dt_from.replace(minute=0, second=0),
589             compute_leaves=True, resource_id=resource,
590             default_interval=(8, 16)
591         )
592
593     def interval_get_multi(self, cr, uid, date_and_hours_by_cal, resource=False, byday=True):
594         """ Used in mrp_operations/mrp_operations.py (default parameters) and in
595         interval_get()
596
597         :deprecated: OpenERP saas-3. Use schedule_hours instead. Note:
598         Byday was not used. Since saas-3, counts Leave hours instead of all-day leaves."""
599         res = {}
600         for dt_str, hours, calendar_id in date_and_hours_by_cal:
601             result = self.schedule_hours(
602                 cr, uid, calendar_id, hours,
603                 day_dt=datetime.datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S').replace(minute=0, second=0),
604                 compute_leaves=True, resource_id=resource,
605                 default_interval=(8, 16)
606             )
607             res[(dt_str, hours, calendar_id)] = result
608         return res
609
610     def interval_get(self, cr, uid, id, dt_from, hours, resource=False, byday=True):
611         """ Unifier of interval_get_multi. Used in: mrp_operations/mrp_operations.py,
612         crm/crm_lead.py (res given).
613
614         :deprecated: OpenERP saas-3. Use get_working_hours instead."""
615         res = self.interval_get_multi(
616             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)]
617         return res
618
619     def interval_hours_get(self, cr, uid, id, dt_from, dt_to, resource=False):
620         """ Unused wrapper.
621
622         :deprecated: OpenERP saas-3. Use get_working_hours instead."""
623         return self._interval_hours_get(cr, uid, id, dt_from, dt_to, resource_id=resource)
624
625     def _interval_hours_get(self, cr, uid, id, dt_from, dt_to, resource_id=False, timezone_from_uid=None, exclude_leaves=True, context=None):
626         """ Computes working hours between two dates, taking always same hour/minuts.
627
628         :deprecated: OpenERP saas-3. Use get_working_hours instead. Note: since saas-3,
629         now resets hour/minuts. Now counts leave hours instead of all-day leaves."""
630         return self.get_working_hours(
631             cr, uid, id, dt_from, dt_to,
632             compute_leaves=(not exclude_leaves), resource_id=resource_id,
633             default_interval=(8, 16), context=context)
634
635
636 class resource_calendar_attendance(osv.osv):
637     _name = "resource.calendar.attendance"
638     _description = "Work Detail"
639
640     _columns = {
641         'name' : fields.char("Name", required=True),
642         'dayofweek': fields.selection([('0','Monday'),('1','Tuesday'),('2','Wednesday'),('3','Thursday'),('4','Friday'),('5','Saturday'),('6','Sunday')], 'Day of Week', required=True, select=True),
643         'date_from' : fields.date('Starting Date'),
644         'hour_from' : fields.float('Work from', required=True, help="Start and End time of working.", select=True),
645         'hour_to' : fields.float("Work to", required=True),
646         'calendar_id' : fields.many2one("resource.calendar", "Resource's Calendar", required=True),
647     }
648
649     _order = 'dayofweek, hour_from'
650
651     _defaults = {
652         'dayofweek' : '0'
653     }
654
655 def hours_time_string(hours):
656     """ convert a number of hours (float) into a string with format '%H:%M' """
657     minutes = int(round(hours * 60))
658     return "%02d:%02d" % divmod(minutes, 60)
659
660 class resource_resource(osv.osv):
661     _name = "resource.resource"
662     _description = "Resource Detail"
663     _columns = {
664         'name': fields.char("Name", required=True),
665         'code': fields.char('Code', size=16, copy=False),
666         '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."),
667         'company_id' : fields.many2one('res.company', 'Company'),
668         'resource_type': fields.selection([('user','Human'),('material','Material')], 'Resource Type', required=True),
669         'user_id' : fields.many2one('res.users', 'User', help='Related user name for the resource to manage its access.'),
670         '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%."),
671         'calendar_id' : fields.many2one("resource.calendar", "Working Time", help="Define the schedule of resource"),
672     }
673     _defaults = {
674         'resource_type' : 'user',
675         'time_efficiency' : 1,
676         'active' : True,
677         'company_id': lambda self, cr, uid, context: self.pool.get('res.company')._company_default_get(cr, uid, 'resource.resource', context=context)
678     }
679
680
681     def copy(self, cr, uid, id, default=None, context=None):
682         if default is None:
683             default = {}
684         if not default.get('name', False):
685             default.update(name=_('%s (copy)') % (self.browse(cr, uid, id, context=context).name))
686         return super(resource_resource, self).copy(cr, uid, id, default, context)
687
688     def generate_resources(self, cr, uid, user_ids, calendar_id, context=None):
689         """
690         Return a list of  Resource Class objects for the resources allocated to the phase.
691
692         NOTE: Used in project/project.py
693         """
694         resource_objs = {}
695         user_pool = self.pool.get('res.users')
696         for user in user_pool.browse(cr, uid, user_ids, context=context):
697             resource_objs[user.id] = {
698                  'name' : user.name,
699                  'vacation': [],
700                  'efficiency': 1.0,
701             }
702
703             resource_ids = self.search(cr, uid, [('user_id', '=', user.id)], context=context)
704             if resource_ids:
705                 for resource in self.browse(cr, uid, resource_ids, context=context):
706                     resource_objs[user.id]['efficiency'] = resource.time_efficiency
707                     resource_cal = resource.calendar_id.id
708                     if resource_cal:
709                         leaves = self.compute_vacation(cr, uid, calendar_id, resource.id, resource_cal, context=context)
710                         resource_objs[user.id]['vacation'] += list(leaves)
711         return resource_objs
712
713     def compute_vacation(self, cr, uid, calendar_id, resource_id=False, resource_calendar=False, context=None):
714         """
715         Compute the vacation from the working calendar of the resource.
716
717         @param calendar_id : working calendar of the project
718         @param resource_id : resource working on phase/task
719         @param resource_calendar : working calendar of the resource
720
721         NOTE: used in project/project.py, and in generate_resources
722         """
723         resource_calendar_leaves_pool = self.pool.get('resource.calendar.leaves')
724         leave_list = []
725         if resource_id:
726             leave_ids = resource_calendar_leaves_pool.search(cr, uid, ['|', ('calendar_id', '=', calendar_id),
727                                                                        ('calendar_id', '=', resource_calendar),
728                                                                        ('resource_id', '=', resource_id)
729                                                                       ], context=context)
730         else:
731             leave_ids = resource_calendar_leaves_pool.search(cr, uid, [('calendar_id', '=', calendar_id),
732                                                                       ('resource_id', '=', False)
733                                                                       ], context=context)
734         leaves = resource_calendar_leaves_pool.read(cr, uid, leave_ids, ['date_from', 'date_to'], context=context)
735         for i in range(len(leaves)):
736             dt_start = datetime.datetime.strptime(leaves[i]['date_from'], '%Y-%m-%d %H:%M:%S')
737             dt_end = datetime.datetime.strptime(leaves[i]['date_to'], '%Y-%m-%d %H:%M:%S')
738             no = dt_end - dt_start
739             [leave_list.append((dt_start + datetime.timedelta(days=x)).strftime('%Y-%m-%d')) for x in range(int(no.days + 1))]
740             leave_list.sort()
741         return leave_list
742
743     def compute_working_calendar(self, cr, uid, calendar_id=False, context=None):
744         """
745         Change the format of working calendar from 'Openerp' format to bring it into 'Faces' format.
746         @param calendar_id : working calendar of the project
747
748         NOTE: used in project/project.py
749         """
750         if not calendar_id:
751             # Calendar is not specified: working days: 24/7
752             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'),
753                    ('mon', '8:0-12:0','13:0-17:0'), ('tue', '8:0-12:0','13:0-17:0')]
754         resource_attendance_pool = self.pool.get('resource.calendar.attendance')
755         time_range = "8:00-8:00"
756         non_working = ""
757         week_days = {"0": "mon", "1": "tue", "2": "wed","3": "thu", "4": "fri", "5": "sat", "6": "sun"}
758         wk_days = {}
759         wk_time = {}
760         wktime_list = []
761         wktime_cal = []
762         week_ids = resource_attendance_pool.search(cr, uid, [('calendar_id', '=', calendar_id)], context=context)
763         weeks = resource_attendance_pool.read(cr, uid, week_ids, ['dayofweek', 'hour_from', 'hour_to'], context=context)
764         # Convert time formats into appropriate format required
765         # and create a list like [('mon', '8:00-12:00'), ('mon', '13:00-18:00')]
766         for week in weeks:
767             res_str = ""
768             day = None
769             if week_days.get(week['dayofweek'],False):
770                 day = week_days[week['dayofweek']]
771                 wk_days[week['dayofweek']] = week_days[week['dayofweek']]
772             else:
773                 raise osv.except_osv(_('Configuration Error!'),_('Make sure the Working time has been configured with proper week days!'))
774             hour_from_str = hours_time_string(week['hour_from'])
775             hour_to_str = hours_time_string(week['hour_to'])
776             res_str = hour_from_str + '-' + hour_to_str
777             wktime_list.append((day, res_str))
778         # Convert into format like [('mon', '8:00-12:00', '13:00-18:00')]
779         for item in wktime_list:
780             if wk_time.has_key(item[0]):
781                 wk_time[item[0]].append(item[1])
782             else:
783                 wk_time[item[0]] = [item[0]]
784                 wk_time[item[0]].append(item[1])
785         for k,v in wk_time.items():
786             wktime_cal.append(tuple(v))
787         # Add for the non-working days like: [('sat, sun', '8:00-8:00')]
788         for k, v in wk_days.items():
789             if week_days.has_key(k):
790                 week_days.pop(k)
791         for v in week_days.itervalues():
792             non_working += v + ','
793         if non_working:
794             wktime_cal.append((non_working[:-1], time_range))
795         return wktime_cal
796
797
798 class resource_calendar_leaves(osv.osv):
799     _name = "resource.calendar.leaves"
800     _description = "Leave Detail"
801     _columns = {
802         'name' : fields.char("Name"),
803         'company_id' : fields.related('calendar_id','company_id',type='many2one',relation='res.company',string="Company", store=True, readonly=True),
804         'calendar_id' : fields.many2one("resource.calendar", "Working Time"),
805         'date_from' : fields.datetime('Start Date', required=True),
806         'date_to' : fields.datetime('End Date', required=True),
807         '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"),
808     }
809
810     def check_dates(self, cr, uid, ids, context=None):
811         for leave in self.browse(cr, uid, ids, context=context):
812             if leave.date_from and leave.date_to and leave.date_from > leave.date_to:
813                 return False
814         return True
815
816     _constraints = [
817         (check_dates, 'Error! leave start-date must be lower then leave end-date.', ['date_from', 'date_to'])
818     ]
819
820     def onchange_resource(self, cr, uid, ids, resource, context=None):
821         result = {}
822         if resource:
823             resource_pool = self.pool.get('resource.resource')
824             result['calendar_id'] = resource_pool.browse(cr, uid, resource, context=context).calendar_id.id
825             return {'value': result}
826         return {'value': {'calendar_id': []}}
827
828 def seconds(td):
829     assert isinstance(td, datetime.timedelta)
830
831     return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10.**6
832
833 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: