2 #@+node:@file pcalendar.py
5 #@+node:<< Copyright >>
6 ############################################################################
7 # Copyright (C) 2005, 2006, 2007, 2008 by Reithinger GmbH
10 # This file is part of faces.
12 # faces is free software; you can redistribute it and/or modify
13 # it under the terms of the GNU General Public License as published by
14 # the Free Software Foundation; either version 2 of the License, or
15 # (at your option) any later version.
17 # faces is distributed in the hope that it will be useful,
18 # but WITHOUT ANY WARRANTY; without even the implied warranty of
19 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 # GNU General Public License for more details.
22 # You should have received a copy of the GNU General Public License
23 # along with this program; if not, write to the
24 # Free Software Foundation, Inc.,
25 # 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
26 ############################################################################
28 #@-node:<< Copyright >>
31 This module contains all classes and functions for the project plan calendar
43 TIME_RANGE_PATTERN
= re
.compile("(\\d+):(\\d+)\\s*-\\s*(\\d+):(\\d+)")
44 TIME_DELTA_PATTERN
= re
.compile("([-+]?\\d+(\\.\\d+)?)([dwmyMH])")
46 DEFAULT_MINIMUM_TIME_UNIT
= 15
47 DEFAULT_WORKING_DAYS_PER_WEEK
= 5
48 DEFAULT_WORKING_DAYS_PER_MONTH
= 20
49 DEFAULT_WORKING_DAYS_PER_YEAR
= 200
50 DEFAULT_WORKING_HOURS_PER_DAY
= 8
52 DEFAULT_WORKING_TIMES
= ( (8 * 60, 12 * 60 ),
54 DEFAULT_WORKING_DAYS
= { 0 : DEFAULT_WORKING_TIMES
,
55 1 : DEFAULT_WORKING_TIMES
,
56 2 : DEFAULT_WORKING_TIMES
,
57 3 : DEFAULT_WORKING_TIMES
,
58 4 : DEFAULT_WORKING_TIMES
,
66 def to_time_range(src
):
68 converts a string to a timerange, i.e
70 from, to are ints, specifing the minutes since midnight
75 mo
= TIME_RANGE_PATTERN
.match(src
)
77 raise ValueError("%s is no time range" % src
)
79 from_time
= int(mo
.group(1)) * 60 + int(mo
.group(2))
80 to_time
= int(mo
.group(3)) * 60 + int(mo
.group(4))
81 return from_time
, to_time
86 a tolerant conversion function to convert different strings
90 #to get the original value for wrappers
91 new
= getattr(src
, "_value", src
)
94 new
= getattr(src
, "_value", src
)
96 if isinstance(src
, _WorkingDateBase
):
97 src
= src
.to_datetime()
99 if isinstance(src
, datetime
.datetime
):
104 formats
= [ "%x %H:%M",
122 conv
= time
.strptime(src
, f
)
123 return datetime
.datetime(*conv
[0:-3])
127 raise TypeError("'%s' (%s) is not a datetime" % (src
, str(type(src
))))
132 converts a string of the day abreviations mon, tue, wed,
133 thu, fri, sat, sun to a dir with correct weekday indices.
135 convert_to_days('mon, tue, thu') results in
139 tokens
= src
.split(",")
149 "sun" : 6 } [ lower(t
.strip()) ]
152 raise ValueError("%s is not a day" % (t
))
156 #@+node:_add_to_time_spans
157 def _add_to_time_spans(src
, to_add
, is_free
):
158 if not isinstance(to_add
, (tuple, list)):
162 for start
, end
, f
in src
:
163 tmp
.append((start
, True, f
))
164 tmp
.append((end
, False, f
))
167 if isinstance(v
, (tuple, list)):
168 start
= to_datetime(v
[0])
169 end
= to_datetime(v
[1])
171 start
= to_datetime(v
)
172 end
= start
.replace(hour
=0, minute
=0) + datetime
.timedelta(1)
174 tmp
.append((start
, start
<= end
, is_free
))
175 tmp
.append((end
, start
> end
, is_free
))
186 for date
, is_start
, is_free
in tmp
:
189 if not free_count
and not work_count
:
195 if free_count
: sequence
.append((last
, date
, True))
200 assert(free_count
> 0)
202 if not free_count
and not work_count
:
203 sequence
.append((last
, date
, True))
205 assert(work_count
> 0)
207 if not work_count
: sequence
.append((last
, date
, False))
208 if free_count
: last
= date
210 return tuple(sequence
)
211 #@-node:_add_to_time_spans
213 def to_timedelta(src
, cal
=None, is_duration
=False):
215 converts a string to a datetime.timedelta. If cal is specified
216 it will be used for getting the working times. if is_duration=True
217 working times will not be considered. Valid units are
226 cal
= cal
or _default_calendar
227 if isinstance(src
, datetime
.timedelta
):
228 return datetime
.timedelta(src
.days
, seconds
=src
.seconds
, calendar
=cal
)
230 if isinstance(src
, (long, int, float)):
231 src
= "%sM" % str(src
)
233 if not isinstance(src
, basestring
):
234 raise ValueError("%s is not a duration" % (repr(src
)))
244 d_p_w
= cal
.working_days_per_week
245 d_p_m
= cal
.working_days_per_month
246 d_p_y
= cal
.working_days_per_year
247 d_w_h
= cal
.working_hours_per_day
249 def convert_minutes(minutes
):
250 minutes
= int(minutes
)
252 minutes
= minutes
% 60
254 hours
= hours
% d_w_h
255 return [ days
, 0, 0, 0, minutes
, hours
]
257 def convert_days(value
):
264 minutes
= round(value
)
265 return [ days
, 0, 0, 0, minutes
, hours
]
267 sum_args
= [ 0, 0, 0, 0, 0, 0 ]
269 split
= src
.split(" ")
271 mo
= TIME_DELTA_PATTERN
.match(s
)
273 raise ValueError(src
+
274 " is not a valid duration: valid"
275 " units are: d w m y M H")
278 val
= float(mo
.group(1))
281 args
= convert_days(val
)
283 args
= convert_days(val
* d_p_w
)
285 args
= convert_days(val
* d_p_m
)
287 args
= convert_days(val
* d_p_y
)
289 args
= convert_minutes(val
)
291 args
= convert_minutes(val
* 60)
293 sum_args
= [ a
+ b
for a
, b
in zip(sum_args
, args
) ]
295 sum_args
= tuple(sum_args
)
296 return datetime
.timedelta(*sum_args
)
298 #@+node:timedelta_to_str
299 def timedelta_to_str(delta
, format
, cal
=None, is_duration
=False):
300 cal
= cal
or _default_calendar
307 d_p_w
= cal
.working_days_per_week
308 d_p_m
= cal
.working_days_per_month
309 d_p_y
= cal
.working_days_per_year
310 d_w_h
= cal
.working_hours_per_day
312 has_years
= format
.find("%y") > -1
313 has_minutes
= format
.find("%M") > -1
314 has_hours
= format
.find("%H") > -1 or has_minutes
315 has_days
= format
.find("%d") > -1
316 has_weeks
= format
.find("%w") > -1
317 has_months
= format
.find("%m") > -1
323 minutes
= delta
.seconds
/ 60
325 def rebase(d_r
, cond1
, cond2
, letter
, divisor
):
327 if not cond1
: return d_r
334 result
= re
.sub("{[^{]*?%" + letter
+ "[^}]*?}", "", result
)
336 result
= result
.replace("%" + letter
, str(val
))
339 result
= result
.replace("%" + letter
,
340 locale
.format("%.2f",
341 (float(days
) / divisor
)))
343 return (days
, result
)
345 d_r
= rebase(d_r
, has_years
, has_months
or has_weeks
or has_days
, "y", d_p_y
)
346 d_r
= rebase(d_r
, has_months
, has_weeks
or has_days
, "m", d_p_m
)
347 d_r
= rebase(d_r
, has_weeks
, has_days
, "w", d_p_w
)
351 minutes
+= days
* d_w_h
* 60
356 result
= re
.sub("{[^{]*?%d[^}]*?}", "", result
)
358 result
= result
.replace("%d", str(days
))
360 result
= result
.replace("%d",
361 "%.2f" % (days
+ float(minutes
)
368 result
= re
.sub("{[^{]*?%H[^}]*?}", "", result
)
370 result
= result
.replace("%H", str(val
))
373 result
= result
.replace("%H", "%.2f" % (float(minutes
) / 60))
376 result
= re
.sub("{[^{]*?%M[^}]*?}", "", result
)
378 result
= result
.replace("%M", str(minutes
))
380 result
= result
.replace("{", "")
381 result
= result
.replace("}", "")
382 return result
.strip()
383 #@-node:timedelta_to_str
385 def strftime(dt
, format
):
387 an extended version of strftime, that introduces some new
391 %IB full month name appropriate to iso week
392 %ib abbreviated month name appropriate to iso week
393 %im month as decimal number appropriate to iso week
395 iso
= dt
.isocalendar()
396 if iso
[0] != dt
.year
:
397 iso_date
= dt
.replace(day
=1, month
=1)
399 .replace("%IB", iso_date
.strftime("%B"))\
400 .replace("%ib", iso_date
.strftime("%b"))\
401 .replace("%im", iso_date
.strftime("%m"))
404 .replace("%IB", "%B")\
405 .replace("%ib", "%b")\
406 .replace("%im", "%m")
409 .replace("%IW", str(iso
[1]))\
410 .replace("%IY", str(iso
[0]))\
412 return dt
.strftime(format
)
415 def union(*calendars
):
417 returns a calendar that unifies all working times
419 #@ << check arguments >>
420 #@+node:<< check arguments >>
421 if len(calendars
) == 1:
422 calendars
= calendars
[0]
424 #@-node:<< check arguments >>
426 #@ << intersect vacations >>
427 #@+node:<< intersect vacations >>
430 for start
, end
, is_free
in c
.time_spans
:
432 free_time
.append((start
, False))
433 free_time
.append((end
, True))
435 count
= len(calendars
)
439 for date
, is_end
in free_time
:
442 time_spans
.append((start
, date
, True))
447 #@-node:<< intersect vacations >>
449 #@ << unify extra worktime >>
450 #@+node:<< unify extra worktime >>
452 for start
, end
, is_free
in c
.time_spans
:
454 time_spans
= _add_to_time_spans(time_spans
, start
, end
)
456 #@-node:<< unify extra worktime >>
458 #@ << unify working times >>
459 #@+node:<< unify working times >>
461 for d
in range(0, 7):
464 for start
, end
in c
.working_times
.get(d
, []):
465 times
.append((start
, False))
466 times
.append((end
, True))
472 for time
, is_end
in times
:
474 if not start
: start
= time
479 ti
.append((start
, time
))
483 working_times
[d
] = ti
484 #@-node:<< unify working times >>
486 #@ << create result calendar >>
487 #@+node:<< create result calendar >>
489 result
.working_times
= working_times
490 result
.time_spans
= time_spans
491 result
._recalc
_working
_time
()
492 result
._build
_mapping
()
494 #@-node:<< create result calendar >>
499 #@+node:class _CalendarItem
500 class _CalendarItem(int):
501 #@ << class _CalendarItem declarations >>
502 #@+node:<< class _CalendarItem declarations >>
507 #@-node:<< class _CalendarItem declarations >>
511 def __new__(cls
, val
):
513 return int.__new
__(cls
, val
)
514 except OverflowError:
515 return int.__new
__(cls
, sys
.maxint
)
518 def round(self
, round_up
=True):
519 m_t_u
= self
.calendar
.minimum_time_unit
522 base
= (minutes
/ m_t_u
) * m_t_u
525 round_up
= round_up
and minutes
> 0 or minutes
> m_t_u
/ 2
526 if round_up
: base
+= m_t_u
527 return self
.__class
__(base
)
530 #@-node:class _CalendarItem
531 #@+node:class _Minutes
532 class _Minutes(_CalendarItem
):
533 #@ << class _Minutes declarations >>
534 #@+node:<< class _Minutes declarations >>
536 STR_FORMAT
= "{%dd}{ %HH}{ %MM}"
539 #@-node:<< class _Minutes declarations >>
543 def __new__(cls
, src
=0, is_duration
=False):
545 converts a timedelta in working minutes.
547 if isinstance(src
, cls
) or type(src
) is int:
548 return _CalendarItem
.__new
__(cls
, src
)
551 if not isinstance(src
, datetime
.timedelta
):
552 src
= to_timedelta(src
, cal
, is_duration
)
554 d_w_h
= is_duration
and 24 or cal
.working_hours_per_day
555 src
= src
.days
* d_w_h
* 60 + src
.seconds
/ 60
556 return _CalendarItem
.__new
__(cls
, src
)
559 def __cmp__(self
, other
):
560 return cmp(int(self
), int(self
.__class
__(other
)))
563 def __add__(self
, other
):
565 return self
.__class
__(int(self
) + int(self
.__class
__(other
)))
567 return NotImplemented
570 def __sub__(self
, other
):
572 return self
.__class
__(int(self
) - int(self
.__class
__(other
)))
574 return NotImplemented
577 def to_timedelta(self
, is_duration
=False):
578 d_w_h
= is_duration
and 24 or self
.calendar
.working_hours_per_day
581 minutes
= minutes
% 60
583 hours
= hours
% d_w_h
584 return datetime
.timedelta(days
, hours
=hours
, minutes
=minutes
)
588 def strftime(self
, format
=None, is_duration
=False):
589 td
= self
.to_timedelta(is_duration
)
590 return timedelta_to_str(td
, format
or self
.STR_FORMAT
,
591 self
.calendar
, is_duration
)
595 #@-node:class _Minutes
596 #@+node:class _WorkingDateBase
597 class _WorkingDateBase(_CalendarItem
):
599 A daytetime which has only valid values within the
600 workingtimes of a specific calendar
602 #@ << class _WorkingDateBase declarations >>
603 #@+node:<< class _WorkingDateBase declarations >>
605 STR_FORMAT
= "%x %H:%M"
610 #@-node:<< class _WorkingDateBase declarations >>
614 def __new__(cls
, src
):
615 #cls.__bases__[0] is the base of
616 #the calendar specific StartDate and EndDate
618 if isinstance(src
, cls
.__bases
__[0]) or type(src
) in (int, float):
619 return _CalendarItem
.__new
__(cls
, src
)
621 src
= cls
.calendar
.from_datetime(to_datetime(src
))
622 return _CalendarItem
.__new
__(cls
, src
)
626 return self
.strftime()
629 def to_datetime(self
):
630 return self
.to_starttime()
633 def to_starttime(self
):
634 return self
.calendar
.to_starttime(self
)
637 def to_endtime(self
):
638 return self
.calendar
.to_endtime(self
)
641 def __cmp__(self
, other
):
642 return cmp(int(self
), int(self
.__class
__(other
)))
645 def __add__(self
, other
):
647 return self
.__class
__(int(self
) + int(self
._minutes
(other
)))
648 except ValueError, e
:
651 return NotImplemented
654 def __sub__(self
, other
):
655 if isinstance(other
, (datetime
.timedelta
, str, _Minutes
)):
657 other
= self
._minutes
(other
)
661 if isinstance(other
, self
._minutes
):
662 return self
.__class
__(int(self
) - int(other
))
665 return self
._minutes
(int(self
) - int(self
.__class
__(other
)))
667 return NotImplemented
670 def strftime(self
, format
=None):
671 return strftime(self
.to_datetime(), format
or self
.STR_FORMAT
)
674 #@-node:class _WorkingDateBase
675 #@+node:class Calendar
676 class Calendar(object):
678 A calendar to specify working times and vacations.
679 The calendars epoch start at 1.1.1979
681 #@ << declarations >>
682 #@+node:<< declarations >>
683 # january the first must be a monday
684 EPOCH
= datetime
.datetime(1979, 1, 1)
685 minimum_time_unit
= DEFAULT_MINIMUM_TIME_UNIT
686 working_days_per_week
= DEFAULT_WORKING_DAYS_PER_WEEK
687 working_days_per_month
= DEFAULT_WORKING_DAYS_PER_MONTH
688 working_days_per_year
= DEFAULT_WORKING_DAYS_PER_YEAR
689 working_hours_per_day
= DEFAULT_WORKING_HOURS_PER_DAY
693 #@-node:<< declarations >>
699 self
._dt
_num
_can
= ()
700 self
._num
_dt
_can
= ()
701 self
.working_times
= { }
702 self
._recalc
_working
_time
()
706 def __or__(self
, other
):
707 if isinstance(other
, Calendar
):
708 return union(self
, other
)
710 return NotImplemented
716 result
.working_times
= self
.working_times
.copy()
717 result
.time_spans
= self
.time_spans
718 result
._recalc
_working
_time
()
719 result
._build
_mapping
()
723 #@+node:set_working_days
724 def set_working_days(self
, day_range
, trange
, *further_tranges
):
726 Sets the working days of an calendar
727 day_range is a string of day abbreviations like 'mon, tue'
728 trange and further_tranges is a time range string like
731 time_ranges
= [ trange
] + list(further_tranges
)
732 time_ranges
= filter(bool, map(to_time_range
, time_ranges
))
733 days
= _to_days(day_range
)
735 for k
in days
.keys():
736 self
.working_times
[k
] = time_ranges
738 self
._recalc
_working
_time
()
739 self
._build
_mapping
()
740 #@-node:set_working_days
742 def set_vacation(self
, value
):
745 value is either a datetime literal or
746 a sequence of items that can be
747 a datetime literals and or pair of datetime literals
749 self
.time_spans
= _add_to_time_spans(self
.time_spans
, value
, True)
750 self
._build
_mapping
()
752 #@+node:set_extra_work
753 def set_extra_work(self
, value
):
755 Sets extra working time
756 value is either a datetime literal or
757 a sequence of items that can be
758 a datetime literals and or pair of datetime literals
760 self
.time_spans
= _add_to_time_spans(self
.time_spans
, value
, False)
761 self
._build
_mapping
()
762 #@-node:set_extra_work
763 #@+node:from_datetime
764 def from_datetime(self
, value
):
765 assert(isinstance(value
, datetime
.datetime
))
767 delta
= value
- self
.EPOCH
769 minutes
= delta
.seconds
/ 60
771 #calculate the weektime
773 wtime
= self
.week_time
* weeks
775 #calculate the daytime
777 dtime
= sum(self
.day_times
[:days
])
779 #calculate the minute time
780 slots
= self
.working_times
.get(days
, DEFAULT_WORKING_DAYS
[days
])
782 for start
, end
in slots
:
787 mtime
+= minutes
- start
790 result
= wtime
+ dtime
+ mtime
792 #map exceptional timespans
793 dt_num_can
= self
._dt
_num
_can
794 pos
= bisect
.bisect(dt_num_can
, (value
,)) - 1
796 start
, end
, nstart
, nend
, cend
= dt_num_can
[pos
]
799 delta
= value
- start
800 delta
= delta
.days
* 24 * 60 + delta
.seconds
/ 60
801 result
= nstart
+ delta
805 result
+= (nend
- cend
) # == (result - cend) + nend
808 #@-node:from_datetime
810 def split_time(self
, value
):
811 #map exceptional timespans
812 num_dt_can
= self
._num
_dt
_can
813 pos
= bisect
.bisect(num_dt_can
, (value
, sys
.maxint
)) - 1
815 nstart
, nend
, start
, end
, cend
= num_dt_can
[pos
]
817 value
= start
+ datetime
.timedelta(minutes
=value
- nstart
)
818 delta
= value
- self
.EPOCH
819 return delta
.days
/ 7, delta
.days
% 7, delta
.seconds
/ 60, -1
821 value
+= (cend
- nend
) # (value - nend + cend)
823 #calculate the weeks since the epoch
824 weeks
= value
/ self
.week_time
825 value
%= self
.week_time
827 #calculate the remaining days
829 for day_time
in self
.day_times
:
830 if value
< day_time
: break
834 #calculate the remaining minutes
836 slots
= self
.working_times
.get(days
, DEFAULT_WORKING_DAYS
[days
])
838 for start
, end
in slots
:
841 minutes
= start
+ value
848 return weeks
, days
, minutes
, index
851 def to_starttime(self
, value
):
852 weeks
, days
, minutes
, index
= self
.split_time(value
)
853 return self
.EPOCH
+ datetime
.timedelta(weeks
=weeks
,
858 def to_endtime(self
, value
):
859 return self
.to_starttime(value
- 1) + datetime
.timedelta(minutes
=1)
861 #@+node:get_working_times
862 def get_working_times(self
, day
):
863 return self
.working_times
.get(day
, DEFAULT_WORKING_DAYS
[day
])
864 #@-node:get_working_times
865 #@+node:_build_mapping
866 def _build_mapping(self
):
867 self
._dt
_num
_can
= self
._num
_dt
_can
= ()
871 delta
= self
.Minutes()
872 for start
, end
, is_free
in self
.time_spans
:
873 cstart
= self
.StartDate(start
)
874 cend
= self
.EndDate(end
)
875 nstart
= cstart
+ delta
879 d
= d
.days
* 24 * 60 + d
.seconds
/ 60
884 delta
+= (nend
- nstart
) - (cend
- cstart
)
885 dt_num_can
.append((start
, end
, nstart
, nend
, cend
))
886 num_dt_can
.append((nstart
, nend
, start
, end
, cend
))
888 self
._dt
_num
_can
= tuple(dt_num_can
)
889 self
._num
_dt
_can
= tuple(num_dt_can
)
890 #@-node:_build_mapping
891 #@+node:_recalc_working_time
892 def _recalc_working_time(self
):
893 def slot_sum_time(day
):
894 slots
= self
.working_times
.get(day
, DEFAULT_WORKING_DAYS
[day
])
895 return sum(map(lambda slot
: slot
[1] - slot
[0], slots
))
897 self
.day_times
= map(slot_sum_time
, range(0, 7))
898 self
.week_time
= sum(self
.day_times
)
899 #@-node:_recalc_working_time
900 #@+node:_make_classes
901 def _make_classes(self
):
902 #ensure that the clases are instance specific
903 class minutes(_Minutes
):
907 class db(_WorkingDateBase
):
912 class wdt(db
): __slots__
= ()
916 def to_datetime(self
):
917 return self
.to_endtime()
919 self
.Minutes
, self
.StartDate
, self
.EndDate
= minutes
, wdt
, edt
920 self
.WorkingDate
= self
.StartDate
921 #@-node:_make_classes
925 _default_calendar
= Calendar()
927 WorkingDate
= _default_calendar
.WorkingDate
928 StartDate
= _default_calendar
.StartDate
929 EndDate
= _default_calendar
.EndDate
930 Minutes
= _default_calendar
.Minutes
931 #@-node:class Calendar
934 if __name__
== '__main__':
937 start
= EndDate("10.1.2005")
938 print "start", start
.strftime(), type(start
)
940 delay
= Minutes("4H")
941 print "delay", delay
, delay
.strftime()
943 print "Start", cal
.StartDate
is StartDate
944 print "base", cal
.StartDate
.__bases
__[0] == StartDate
.__bases
__[0]
945 print "type", type(start
)
947 print "convert start"
948 start2
= cal
.StartDate(start
)
951 start3
= cal
.StartDate("10.1.2005")
952 print "start2", start2
.strftime(), type(start2
)
953 #@-node:@file pcalendar.py