imip-agent

imipweb/data.py

1253:333740ca50b6
2017-09-12 Paul Boddie Consider period replacement status when comparing form periods.
     1 #!/usr/bin/env python     2      3 """     4 Web interface data abstractions.     5      6 Copyright (C) 2014, 2015, 2017 Paul Boddie <paul@boddie.org.uk>     7      8 This program is free software; you can redistribute it and/or modify it under     9 the terms of the GNU General Public License as published by the Free Software    10 Foundation; either version 3 of the License, or (at your option) any later    11 version.    12     13 This program is distributed in the hope that it will be useful, but WITHOUT    14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS    15 FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more    16 details.    17     18 You should have received a copy of the GNU General Public License along with    19 this program.  If not, see <http://www.gnu.org/licenses/>.    20 """    21     22 from datetime import datetime, timedelta    23 from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \    24                             format_datetime, get_datetime, get_end_of_day, \    25                             to_date    26 from imiptools.period import RecurringPeriod    27     28 class PeriodError(Exception):    29     pass    30     31 class EventPeriod(RecurringPeriod):    32     33     """    34     A simple period plus attribute details, compatible with RecurringPeriod, and    35     intended to represent information obtained from an iCalendar resource.    36     """    37     38     def __init__(self, start, end, tzid=None, origin=None, start_attr=None,    39                  end_attr=None, form_start=None, form_end=None, replaced=False):    40     41         """    42         Initialise a period with the given 'start' and 'end' datetimes, together    43         with optional 'start_attr' and 'end_attr' metadata, 'form_start' and    44         'form_end' values provided as textual input, and with an optional    45         'origin' indicating the kind of period this object describes.    46         """    47     48         RecurringPeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr)    49         self.form_start = form_start    50         self.form_end = form_end    51         self.replaced = replaced    52     53     def as_tuple(self):    54         return self.start, self.end, self.tzid, self.origin, self.start_attr, \    55                self.end_attr, self.form_start, self.form_end, self.replaced    56     57     def __repr__(self):    58         return "EventPeriod%r" % (self.as_tuple(),)    59     60     def as_event_period(self):    61         return self    62     63     def get_start_item(self):    64         return self.get_start(), self.get_start_attr()    65     66     def get_end_item(self):    67         return self.get_end(), self.get_end_attr()    68     69     # Form data compatibility methods.    70     71     def get_form_start(self):    72         if not self.form_start:    73             self.form_start = self.get_form_date(self.get_start(), self.start_attr)    74         return self.form_start    75     76     def get_form_end(self):    77         if not self.form_end:    78             self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr)    79         return self.form_end    80     81     def as_form_period(self):    82         return FormPeriod(    83             self.get_form_start(),    84             self.get_form_end(),    85             isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1),    86             isinstance(self.start, datetime) or isinstance(self.end, datetime),    87             self.tzid,    88             self.origin,    89             self.replaced and True or False    90             )    91     92     def get_form_date(self, dt, attr=None):    93         return FormDate(    94             format_datetime(to_date(dt)),    95             isinstance(dt, datetime) and str(dt.hour) or None,    96             isinstance(dt, datetime) and str(dt.minute) or None,    97             isinstance(dt, datetime) and str(dt.second) or None,    98             attr and attr.get("TZID") or None,    99             dt, attr   100             )   101    102 class FormPeriod(RecurringPeriod):   103    104     "A period whose information originates from a form."   105    106     def __init__(self, start, end, end_enabled=True, times_enabled=True,   107                  tzid=None, origin=None, replaced=False):   108         self.start = start   109         self.end = end   110         self.end_enabled = end_enabled   111         self.times_enabled = times_enabled   112         self.tzid = tzid   113         self.origin = origin   114         self.replaced = replaced   115    116     def as_tuple(self):   117         return self.start, self.end, self.end_enabled, self.times_enabled, self.tzid, self.origin, self.replaced   118    119     def __repr__(self):   120         return "FormPeriod%r" % (self.as_tuple(),)   121    122     def __cmp__(self, other):   123         result = RecurringPeriod.__cmp__(self, other)   124         if result:   125             return result   126         other = form_period_from_period(other)   127         return cmp(self.replaced, other.replaced)   128    129     def as_event_period(self, index=None):   130    131         """   132         Return a converted version of this object as an event period suitable   133         for iCalendar usage. If 'index' is indicated, include it in any error   134         raised in the conversion process.   135         """   136    137         dtstart, dtstart_attr = self.get_start_item()   138         if not dtstart:   139             if index is not None:   140                 raise PeriodError(("dtstart", index))   141             else:   142                 raise PeriodError("dtstart")   143    144         dtend, dtend_attr = self.get_end_item()   145         if not dtend:   146             if index is not None:   147                 raise PeriodError(("dtend", index))   148             else:   149                 raise PeriodError("dtend")   150    151         if dtstart > dtend:   152             if index is not None:   153                 raise PeriodError(("dtstart", index), ("dtend", index))   154             else:   155                 raise PeriodError("dtstart", "dtend")   156    157         return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid,   158                            self.origin, dtstart_attr, dtend_attr,   159                            self.start, self.end, self.replaced)   160    161     # Period data methods.   162    163     def get_start(self):   164         return self.start and self.start.as_datetime(self.times_enabled) or None   165    166     def get_end(self):   167    168         # Handle specified end datetimes.   169    170         if self.end_enabled:   171             dtend = self.end.as_datetime(self.times_enabled)   172             if not dtend:   173                 return None   174    175         # Handle same day times.   176    177         elif self.times_enabled:   178             formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid)   179             dtend = formdate.as_datetime(self.times_enabled)   180             if not dtend:   181                 return None   182    183         # Otherwise, treat the end date as the start date. Datetimes are   184         # handled by making the event occupy the rest of the day.   185    186         else:   187             dtstart, dtstart_attr = self.get_start_item()   188             if dtstart:   189                 if isinstance(dtstart, datetime):   190                     dtend = get_end_of_day(dtstart, dtstart_attr["TZID"])   191                 else:   192                     dtend = dtstart   193             else:   194                 return None   195    196         return dtend   197    198     def get_start_attr(self):   199         return self.start and self.start.get_attributes(self.times_enabled) or {}   200    201     def get_end_attr(self):   202         return self.end and self.end.get_attributes(self.times_enabled) or {}   203    204     # Form data methods.   205    206     def get_form_start(self):   207         return self.start   208    209     def get_form_end(self):   210         return self.end   211    212     def as_form_period(self):   213         return self   214    215 class FormDate:   216    217     "Date information originating from form information."   218    219     def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None):   220         self.date = date   221         self.hour = hour   222         self.minute = minute   223         self.second = second   224         self.tzid = tzid   225         self.dt = dt   226         self.attr = attr   227    228     def as_tuple(self):   229         return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr   230    231     def __repr__(self):   232         return "FormDate%r" % (self.as_tuple(),)   233    234     def get_component(self, value):   235         return (value or "").rjust(2, "0")[:2]   236    237     def get_hour(self):   238         return self.get_component(self.hour)   239    240     def get_minute(self):   241         return self.get_component(self.minute)   242    243     def get_second(self):   244         return self.get_component(self.second)   245    246     def get_date_string(self):   247         return self.date or ""   248    249     def get_datetime_string(self):   250         if not self.date:   251             return ""   252    253         hour = self.hour; minute = self.minute; second = self.second   254    255         if hour or minute or second:   256             time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second)))   257         else:   258             time = ""   259                260         return "%s%s" % (self.date, time)   261    262     def get_tzid(self):   263         return self.tzid   264    265     def as_datetime(self, with_time=True):   266    267         "Return a datetime for this object."   268    269         # Return any original datetime details.   270    271         if self.dt:   272             return self.dt   273    274         # Otherwise, construct a datetime.   275    276         s, attr = self.as_datetime_item(with_time)   277         if s:   278             return get_datetime(s, attr)   279         else:   280             return None   281    282     def as_datetime_item(self, with_time=True):   283    284         """   285         Return a (datetime string, attr) tuple for the datetime information   286         provided by this object, where both tuple elements will be None if no   287         suitable date or datetime information exists.   288         """   289    290         s = None   291         if with_time:   292             s = self.get_datetime_string()   293             attr = self.get_attributes(True)   294         if not s:   295             s = self.get_date_string()   296             attr = self.get_attributes(False)   297         if not s:   298             return None, None   299         return s, attr   300    301     def get_attributes(self, with_time=True):   302    303         "Return attributes for the date or datetime represented by this object."   304    305         if with_time:   306             return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"}   307         else:   308             return {"VALUE" : "DATE"}   309    310 def event_period_from_period(period):   311    312     """   313     Convert a 'period' to one suitable for use in an iCalendar representation.   314     In an "event period" representation, the end day of any date-level event is   315     encoded as the "day after" the last day actually involved in the event.   316     """   317    318     if isinstance(period, EventPeriod):   319         return period   320     elif isinstance(period, FormPeriod):   321         return period.as_event_period()   322     else:   323         dtstart, dtstart_attr = period.get_start_item()   324         dtend, dtend_attr = period.get_end_item()   325         if not isinstance(period, RecurringPeriod):   326             dtend = end_date_to_calendar(dtend)   327         return EventPeriod(dtstart, dtend, period.tzid, period.origin, dtstart_attr, dtend_attr)   328    329 def form_period_from_period(period):   330    331     """   332     Convert a 'period' into a representation usable in a user-editable form.   333     In a "form period" representation, the end day of any date-level event is   334     presented in a "natural" form, not the iCalendar "day after" form.   335     """   336    337     if isinstance(period, EventPeriod):   338         return period.as_form_period()   339     elif isinstance(period, FormPeriod):   340         return period   341     else:   342         return event_period_from_period(period).as_form_period()   343    344    345    346 # Form field extraction and serialisation.   347    348 def get_date_control_inputs(args, name, tzid_name=None):   349    350     """   351     Return a tuple of date control inputs taken from 'args' for field names   352     starting with 'name'.   353    354     If 'tzid_name' is specified, the time zone information will be acquired   355     from fields starting with 'tzid_name' instead of 'name'.   356     """   357    358     return args.get("%s-date" % name, []), \   359            args.get("%s-hour" % name, []), \   360            args.get("%s-minute" % name, []), \   361            args.get("%s-second" % name, []), \   362            args.get("%s-tzid" % (tzid_name or name), [])   363    364 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None):   365    366     """   367     Return a form date object representing fields taken from 'args' starting   368     with 'name'.   369    370     If 'multiple' is set to a true value, many date objects will be returned   371     corresponding to a collection of datetimes.   372    373     If 'tzid_name' is specified, the time zone information will be acquired   374     from fields starting with 'tzid_name' instead of 'name'.   375    376     If 'tzid' is specified, it will provide the time zone where no explicit   377     time zone information is indicated in the field data.   378     """   379    380     dates, hours, minutes, seconds, tzids = get_date_control_inputs(args, name, tzid_name)   381    382     # Handle absent values by employing None values.   383    384     field_values = map(None, dates, hours, minutes, seconds, tzids)   385    386     if not field_values and not multiple:   387         all_values = FormDate()   388     else:   389         all_values = []   390         for date, hour, minute, second, tzid_field in field_values:   391             value = FormDate(date, hour, minute, second, tzid_field or tzid)   392    393             # Return a single value or append to a collection of all values.   394    395             if not multiple:   396                 return value   397             else:   398                 all_values.append(value)   399    400     return all_values   401    402 def set_date_control_values(formdates, args, name, tzid_name=None):   403    404     """   405     Using the values of the given 'formdates', replace form fields in 'args'   406     starting with 'name'.   407    408     If 'tzid_name' is specified, the time zone information will be stored in   409     fields starting with 'tzid_name' instead of 'name'.   410     """   411    412     args["%s-date" % name] = []   413     args["%s-hour" % name] = []   414     args["%s-minute" % name] = []   415     args["%s-second" % name] = []   416     args["%s-tzid" % (tzid_name or name)] = []   417    418     for d in formdates:   419         args["%s-date" % name].append(d and d.date or "")   420         args["%s-hour" % name].append(d and d.hour or "")   421         args["%s-minute" % name].append(d and d.minute or "")   422         args["%s-second" % name].append(d and d.second or "")   423         args["%s-tzid" % (tzid_name or name)].append(d and d.tzid or "")   424    425 def get_period_control_values(args, start_name, end_name,   426                               end_enabled_name, times_enabled_name,   427                               origin=None, origin_name=None,   428                               replaced_name=None, tzid=None):   429    430     """   431     Return period values from fields found in 'args' prefixed with the given   432     'start_name' (for start dates), 'end_name' (for end dates),   433     'end_enabled_name' (to enable end dates for periods), 'times_enabled_name'   434     (to enable times for periods).   435    436     If 'origin' is specified, a single period with the given origin is   437     returned. If 'origin_name' is specified, fields containing the name will   438     provide origin information, and fields containing 'replaced_name' will   439     indicate periods that are replaced.   440    441     If 'tzid' is specified, it will provide the time zone where no explicit   442     time zone information is indicated in the field data.   443     """   444    445     # Get the end datetime and time presence settings.   446    447     all_end_enabled = args.get(end_enabled_name, [])   448     all_times_enabled = args.get(times_enabled_name, [])   449    450     # Get the origins of period data and whether the periods are replaced.   451    452     if origin:   453         all_origins = [origin]   454     else:   455         all_origins = origin_name and args.get(origin_name, []) or []   456    457     all_replaced = replaced_name and args.get(replaced_name, []) or []   458    459     # Get the start and end datetimes.   460    461     all_starts = get_date_control_values(args, start_name, True, tzid=tzid)   462     all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid)   463    464     # Construct period objects for each start, end, origin combination.   465    466     periods = []   467    468     for index, (start, end, found_origin) in \   469         enumerate(map(None, all_starts, all_ends, all_origins)):   470    471         # Obtain period settings from separate controls.   472    473         end_enabled = str(index) in all_end_enabled   474         times_enabled = str(index) in all_times_enabled   475         replaced = str(index) in all_replaced   476    477         period = FormPeriod(start, end, end_enabled, times_enabled, tzid,   478                             found_origin or origin, replaced)   479         periods.append(period)   480    481     # Return a single period if a single origin was specified.   482    483     if origin:   484         return periods[0]   485     else:   486         return periods   487    488 def set_period_control_values(periods, args, start_name, end_name,   489                               end_enabled_name, times_enabled_name,   490                               origin_name=None, replaced_name=None):   491    492     """   493     Using the given 'periods', replace form fields in 'args' prefixed with the   494     given 'start_name' (for start dates), 'end_name' (for end dates),   495     'end_enabled_name' (to enable end dates for periods), 'times_enabled_name'   496     (to enable times for periods).   497    498     If 'origin_name' is specified, fields containing the name will provide   499     origin information, and fields containing 'replaced_name' will indicate   500     periods that are replaced.   501     """   502    503     # Record period settings separately.   504    505     args[end_enabled_name] = []   506     args[times_enabled_name] = []   507    508     # Record origin and replacement information if naming is defined.   509    510     if origin_name:   511         args[origin_name] = []   512    513     if replaced_name:   514         args[replaced_name] = []   515    516     all_starts = []   517     all_ends = []   518    519     for index, period in enumerate(periods):   520    521         # Encode period settings in controls.   522    523         if period.end_enabled:   524             args[end_enabled_name].append(str(index))   525         if period.times_enabled:   526             args[times_enabled_name].append(str(index))   527    528         # Add origin information where controls are present to record it.   529    530         if origin_name:   531             args[origin_name].append(period.origin or "")   532    533         # Add replacement information where controls are present to record it.   534    535         if replaced_name and period.replaced:   536             args[replaced_name].append(str(index))   537    538         # Collect form date information for addition below.   539    540         all_starts.append(period.get_form_start())   541         all_ends.append(period.get_form_end())   542    543     # Set the controls for the dates.   544    545     set_date_control_values(all_starts, args, start_name)   546     set_date_control_values(all_ends, args, end_name, tzid_name=start_name)   547    548 # vim: tabstop=4 expandtab shiftwidth=4