imip-agent

imipweb/data.py

1292:8a08870781bb
2017-10-06 Paul Boddie Remove SENT-BY from attributes where the sender matches the user. Simplify get_timestamp, making it use get_time.
     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             format_datetime(self.get_start_point())    91             )    92     93     def get_form_date(self, dt, attr=None):    94         return FormDate(    95             format_datetime(to_date(dt)),    96             isinstance(dt, datetime) and str(dt.hour) or None,    97             isinstance(dt, datetime) and str(dt.minute) or None,    98             isinstance(dt, datetime) and str(dt.second) or None,    99             attr and attr.get("TZID") or None,   100             dt, attr   101             )   102    103 class FormPeriod(RecurringPeriod):   104    105     "A period whose information originates from a form."   106    107     def __init__(self, start, end, end_enabled=True, times_enabled=True,   108                  tzid=None, origin=None, replaced=False, recurrenceid=None):   109         self.start = start   110         self.end = end   111         self.end_enabled = end_enabled   112         self.times_enabled = times_enabled   113         self.tzid = tzid   114         self.origin = origin   115         self.replaced = replaced   116         self.recurrenceid = recurrenceid   117    118     def as_tuple(self):   119         return self.start, self.end, self.end_enabled, self.times_enabled, self.tzid, self.origin, self.replaced, self.recurrenceid   120    121     def __repr__(self):   122         return "FormPeriod%r" % (self.as_tuple(),)   123    124     def is_changed(self):   125         return not self.recurrenceid or format_datetime(self.get_start_point()) != self.recurrenceid   126    127     def as_event_period(self, index=None):   128    129         """   130         Return a converted version of this object as an event period suitable   131         for iCalendar usage. If 'index' is indicated, include it in any error   132         raised in the conversion process.   133         """   134    135         dtstart, dtstart_attr = self.get_start_item()   136         if not dtstart:   137             if index is not None:   138                 raise PeriodError(("dtstart", index))   139             else:   140                 raise PeriodError("dtstart")   141    142         dtend, dtend_attr = self.get_end_item()   143         if not dtend:   144             if index is not None:   145                 raise PeriodError(("dtend", index))   146             else:   147                 raise PeriodError("dtend")   148    149         if dtstart > dtend:   150             if index is not None:   151                 raise PeriodError(("dtstart", index), ("dtend", index))   152             else:   153                 raise PeriodError("dtstart", "dtend")   154    155         return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid,   156                            self.origin, dtstart_attr, dtend_attr,   157                            self.start, self.end, self.replaced)   158    159     # Period data methods.   160    161     def get_start(self):   162         return self.start and self.start.as_datetime(self.times_enabled) or None   163    164     def get_end(self):   165    166         # Handle specified end datetimes.   167    168         if self.end_enabled:   169             dtend = self.end.as_datetime(self.times_enabled)   170             if not dtend:   171                 return None   172    173         # Handle same day times.   174    175         elif self.times_enabled:   176             formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid)   177             dtend = formdate.as_datetime(self.times_enabled)   178             if not dtend:   179                 return None   180    181         # Otherwise, treat the end date as the start date. Datetimes are   182         # handled by making the event occupy the rest of the day.   183    184         else:   185             dtstart, dtstart_attr = self.get_start_item()   186             if dtstart:   187                 if isinstance(dtstart, datetime):   188                     dtend = get_end_of_day(dtstart, dtstart_attr["TZID"])   189                 else:   190                     dtend = dtstart   191             else:   192                 return None   193    194         return dtend   195    196     def get_start_attr(self):   197         return self.start and self.start.get_attributes(self.times_enabled) or {}   198    199     def get_end_attr(self):   200         return self.end and self.end.get_attributes(self.times_enabled) or {}   201    202     # Form data methods.   203    204     def get_form_start(self):   205         return self.start   206    207     def get_form_end(self):   208         return self.end   209    210     def as_form_period(self):   211         return self   212    213 class FormDate:   214    215     "Date information originating from form information."   216    217     def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None):   218         self.date = date   219         self.hour = hour   220         self.minute = minute   221         self.second = second   222         self.tzid = tzid   223         self.dt = dt   224         self.attr = attr   225    226     def as_tuple(self):   227         return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr   228    229     def __repr__(self):   230         return "FormDate%r" % (self.as_tuple(),)   231    232     def get_component(self, value):   233         return (value or "").rjust(2, "0")[:2]   234    235     def get_hour(self):   236         return self.get_component(self.hour)   237    238     def get_minute(self):   239         return self.get_component(self.minute)   240    241     def get_second(self):   242         return self.get_component(self.second)   243    244     def get_date_string(self):   245         return self.date or ""   246    247     def get_datetime_string(self):   248         if not self.date:   249             return ""   250    251         hour = self.hour; minute = self.minute; second = self.second   252    253         if hour or minute or second:   254             time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second)))   255         else:   256             time = ""   257                258         return "%s%s" % (self.date, time)   259    260     def get_tzid(self):   261         return self.tzid   262    263     def as_datetime(self, with_time=True):   264    265         "Return a datetime for this object."   266    267         # Return any original datetime details.   268    269         if self.dt:   270             return self.dt   271    272         # Otherwise, construct a datetime.   273    274         s, attr = self.as_datetime_item(with_time)   275         if s:   276             return get_datetime(s, attr)   277         else:   278             return None   279    280     def as_datetime_item(self, with_time=True):   281    282         """   283         Return a (datetime string, attr) tuple for the datetime information   284         provided by this object, where both tuple elements will be None if no   285         suitable date or datetime information exists.   286         """   287    288         s = None   289         if with_time:   290             s = self.get_datetime_string()   291             attr = self.get_attributes(True)   292         if not s:   293             s = self.get_date_string()   294             attr = self.get_attributes(False)   295         if not s:   296             return None, None   297         return s, attr   298    299     def get_attributes(self, with_time=True):   300    301         "Return attributes for the date or datetime represented by this object."   302    303         if with_time:   304             return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"}   305         else:   306             return {"VALUE" : "DATE"}   307    308 def event_period_from_period(period):   309    310     """   311     Convert a 'period' to one suitable for use in an iCalendar representation.   312     In an "event period" representation, the end day of any date-level event is   313     encoded as the "day after" the last day actually involved in the event.   314     """   315    316     if isinstance(period, EventPeriod):   317         return period   318     elif isinstance(period, FormPeriod):   319         return period.as_event_period()   320     else:   321         dtstart, dtstart_attr = period.get_start_item()   322         dtend, dtend_attr = period.get_end_item()   323         if not isinstance(period, RecurringPeriod):   324             dtend = end_date_to_calendar(dtend)   325         return EventPeriod(dtstart, dtend, period.tzid, period.origin, dtstart_attr, dtend_attr)   326    327 def form_period_from_period(period):   328    329     """   330     Convert a 'period' into a representation usable in a user-editable form.   331     In a "form period" representation, the end day of any date-level event is   332     presented in a "natural" form, not the iCalendar "day after" form.   333     """   334    335     if isinstance(period, EventPeriod):   336         return period.as_form_period()   337     elif isinstance(period, FormPeriod):   338         return period   339     else:   340         return event_period_from_period(period).as_form_period()   341    342    343    344 # Form period processing.   345    346 def get_existing_periods(periods, still_to_remove):   347    348     """   349     Find all periods that existed before editing, given 'periods', applying   350     the periods in 'still_to_remove' and producing retained, replaced and   351     to-remove collections containing these existing periods.   352     """   353    354     retained = []   355     replaced = []   356     to_remove = []   357    358     for p in periods:   359         p = form_period_from_period(p)   360         if p.recurrenceid:   361             if p.replaced:   362                 replaced.append(p)   363             elif p in still_to_remove:   364                 to_remove.append(p)   365             else:   366                 retained.append(p)   367    368     return retained, replaced, to_remove   369    370 def get_new_periods(periods):   371    372     "Return all periods introduced during editing, given 'periods'."   373    374     new = []   375     for p in periods:   376         fp = form_period_from_period(p)   377         if not fp.recurrenceid:   378             new.append(p)   379     return new   380    381 def get_changed_periods(periods):   382    383     "Return changed and unchanged periods, given 'periods'."   384    385     changed = []   386     unchanged = []   387    388     for p in periods:   389         fp = form_period_from_period(p)   390         if fp.is_changed():   391             changed.append(p)   392         else:   393             unchanged.append(p)   394    395     return changed, unchanged   396    397 def classify_periods(periods, still_to_remove):   398    399     """   400     From the recurrence 'periods', given details of those 'still_to_remove',   401     return a tuple containing collections of new, changed, unchanged, replaced   402     and to-be-removed periods.   403     """   404    405     retained, replaced, to_remove = get_existing_periods(periods, still_to_remove)   406    407     # Filter new periods with the existing period information.   408    409     new = set(get_new_periods(periods))   410    411     new.difference_update(retained)   412     new.difference_update(replaced)   413     new.difference_update(to_remove)   414    415     # Divide retained periods into changed and unchanged collections.   416    417     changed, unchanged = get_changed_periods(retained)   418    419     return list(new), changed, unchanged, replaced, to_remove   420    421    422    423 # Form field extraction and serialisation.   424    425 def get_date_control_inputs(args, name, tzid_name=None):   426    427     """   428     Return a tuple of date control inputs taken from 'args' for field names   429     starting with 'name'.   430    431     If 'tzid_name' is specified, the time zone information will be acquired   432     from fields starting with 'tzid_name' instead of 'name'.   433     """   434    435     return args.get("%s-date" % name, []), \   436            args.get("%s-hour" % name, []), \   437            args.get("%s-minute" % name, []), \   438            args.get("%s-second" % name, []), \   439            args.get("%s-tzid" % (tzid_name or name), [])   440    441 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None):   442    443     """   444     Return a form date object representing fields taken from 'args' starting   445     with 'name'.   446    447     If 'multiple' is set to a true value, many date objects will be returned   448     corresponding to a collection of datetimes.   449    450     If 'tzid_name' is specified, the time zone information will be acquired   451     from fields starting with 'tzid_name' instead of 'name'.   452    453     If 'tzid' is specified, it will provide the time zone where no explicit   454     time zone information is indicated in the field data.   455     """   456    457     dates, hours, minutes, seconds, tzids = get_date_control_inputs(args, name, tzid_name)   458    459     # Handle absent values by employing None values.   460    461     field_values = map(None, dates, hours, minutes, seconds, tzids)   462    463     if not field_values and not multiple:   464         all_values = FormDate()   465     else:   466         all_values = []   467         for date, hour, minute, second, tzid_field in field_values:   468             value = FormDate(date, hour, minute, second, tzid_field or tzid)   469    470             # Return a single value or append to a collection of all values.   471    472             if not multiple:   473                 return value   474             else:   475                 all_values.append(value)   476    477     return all_values   478    479 def set_date_control_values(formdates, args, name, tzid_name=None):   480    481     """   482     Using the values of the given 'formdates', replace form fields in 'args'   483     starting with 'name'.   484    485     If 'tzid_name' is specified, the time zone information will be stored in   486     fields starting with 'tzid_name' instead of 'name'.   487     """   488    489     args["%s-date" % name] = []   490     args["%s-hour" % name] = []   491     args["%s-minute" % name] = []   492     args["%s-second" % name] = []   493     args["%s-tzid" % (tzid_name or name)] = []   494    495     for d in formdates:   496         args["%s-date" % name].append(d and d.date or "")   497         args["%s-hour" % name].append(d and d.hour or "")   498         args["%s-minute" % name].append(d and d.minute or "")   499         args["%s-second" % name].append(d and d.second or "")   500         args["%s-tzid" % (tzid_name or name)].append(d and d.tzid or "")   501    502 def get_period_control_values(args, start_name, end_name,   503                               end_enabled_name, times_enabled_name,   504                               origin=None, origin_name=None,   505                               replaced_name=None, recurrenceid_name=None,   506                               tzid=None):   507    508     """   509     Return period values from fields found in 'args' prefixed with the given   510     'start_name' (for start dates), 'end_name' (for end dates),   511     'end_enabled_name' (to enable end dates for periods), 'times_enabled_name'   512     (to enable times for periods).   513    514     If 'origin' is specified, a single period with the given origin is   515     returned. If 'origin_name' is specified, fields containing the name will   516     provide origin information, fields containing 'replaced_name' will indicate   517     periods that are replaced, and fields containing 'recurrenceid_name' will   518     indicate periods that have existing recurrence details from an event.   519    520     If 'tzid' is specified, it will provide the time zone where no explicit   521     time zone information is indicated in the field data.   522     """   523    524     # Get the end datetime and time presence settings.   525    526     all_end_enabled = args.get(end_enabled_name, [])   527     all_times_enabled = args.get(times_enabled_name, [])   528    529     # Get the origins of period data and whether the periods are replaced.   530    531     if origin:   532         all_origins = [origin]   533     else:   534         all_origins = origin_name and args.get(origin_name, []) or []   535    536     all_replaced = replaced_name and args.get(replaced_name, []) or []   537     all_recurrenceids = recurrenceid_name and args.get(recurrenceid_name, []) or []   538    539     # Get the start and end datetimes.   540    541     all_starts = get_date_control_values(args, start_name, True, tzid=tzid)   542     all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid)   543    544     # Construct period objects for each start, end, origin combination.   545    546     periods = []   547    548     for index, (start, end, found_origin, recurrenceid) in \   549         enumerate(map(None, all_starts, all_ends, all_origins, all_recurrenceids)):   550    551         # Obtain period settings from separate controls.   552    553         end_enabled = str(index) in all_end_enabled   554         times_enabled = str(index) in all_times_enabled   555         replaced = str(index) in all_replaced   556    557         period = FormPeriod(start, end, end_enabled, times_enabled, tzid,   558                             found_origin or origin, replaced, recurrenceid)   559         periods.append(period)   560    561     # Return a single period if a single origin was specified.   562    563     if origin:   564         return periods[0]   565     else:   566         return periods   567    568 def set_period_control_values(periods, args, start_name, end_name,   569                               end_enabled_name, times_enabled_name,   570                               origin_name=None, replaced_name=None,   571                               recurrenceid_name=None):   572    573     """   574     Using the given 'periods', replace form fields in 'args' prefixed with the   575     given 'start_name' (for start dates), 'end_name' (for end dates),   576     'end_enabled_name' (to enable end dates for periods), 'times_enabled_name'   577     (to enable times for periods).   578    579     If 'origin_name' is specified, fields containing the name will provide   580     origin information, fields containing 'replaced_name' will indicate periods   581     that are replaced, and fields containing 'recurrenceid_name' will indicate   582     periods that have existing recurrence details from an event.   583     """   584    585     # Record period settings separately.   586    587     args[end_enabled_name] = []   588     args[times_enabled_name] = []   589    590     # Record origin and replacement information if naming is defined.   591    592     if origin_name:   593         args[origin_name] = []   594    595     if replaced_name:   596         args[replaced_name] = []   597    598     if recurrenceid_name:   599         args[recurrenceid_name] = []   600    601     all_starts = []   602     all_ends = []   603    604     for index, period in enumerate(periods):   605    606         # Encode period settings in controls.   607    608         if period.end_enabled:   609             args[end_enabled_name].append(str(index))   610         if period.times_enabled:   611             args[times_enabled_name].append(str(index))   612    613         # Add origin information where controls are present to record it.   614    615         if origin_name:   616             args[origin_name].append(period.origin or "")   617    618         # Add replacement information where controls are present to record it.   619    620         if replaced_name and period.replaced:   621             args[replaced_name].append(str(index))   622    623         # Add recurrence identifiers where controls are present to record it.   624    625         if recurrenceid_name:   626             args[recurrenceid_name].append(period.recurrenceid or "")   627    628         # Collect form date information for addition below.   629    630         all_starts.append(period.get_form_start())   631         all_ends.append(period.get_form_end())   632    633     # Set the controls for the dates.   634    635     set_date_control_values(all_starts, args, start_name)   636     set_date_control_values(all_ends, args, end_name, tzid_name=start_name)   637    638    639    640 # Utilities.   641    642 def filter_duplicates(l):   643    644     """   645     Return collection 'l' filtered for duplicate values, retaining the given   646     element ordering.   647     """   648    649     s = set()   650     f = []   651    652     for value in l:   653         if value not in s:   654             s.add(value)   655             f.append(value)   656    657     return f   658    659 def remove_from_collection(l, indexes, fn):   660    661     """   662     Remove from collection 'l' all values present at the given 'indexes' where   663     'fn' applied to each referenced value returns a true value. Values where   664     'fn' returns a false value are added to a list of deferred removals which is   665     returned.   666     """   667    668     still_to_remove = []   669     correction = 0   670    671     for i in indexes:   672         try:   673             i = int(i) - correction   674             value = l[i]   675         except (IndexError, ValueError):   676             continue   677    678         if fn(value):   679             del l[i]   680             correction += 1   681         else:   682             still_to_remove.append(value)   683    684     return still_to_remove   685    686 # vim: tabstop=4 expandtab shiftwidth=4