diff --git a/twistedcaldav/stdconfig.py b/twistedcaldav/stdconfig.py index 8c41761b4..89f42b998 100644 --- a/twistedcaldav/stdconfig.py +++ b/twistedcaldav/stdconfig.py @@ -838,6 +838,17 @@ # How many results to return for principal-property-search REPORT requests "MaxPrincipalSearchReportResults": 500, + # + # Client fixes per user-agent match + # + "ClientFixes" : { + "ForceAttendeeTRANSP" : [ + "iOS/8\\.0(\\..*)?", + "iOS/8\\.1(\\..*)?", + "iOS/8\\.2(\\..*)?", + ], + }, + # # Localization # @@ -1458,6 +1469,19 @@ def _updateRejectClients(configDict, reloading=False): +def _updateClientFixes(configDict, reloading=False): + # + # Compile ClientFixes expressions for speed + # + try: + configDict.ClientFixesCompiled = {} + for key, expressions in configDict.ClientFixes.items(): + configDict.ClientFixesCompiled[key] = [re.compile("^{}$".format(x)) for x in expressions] + except re.error, e: + raise ConfigurationError("Invalid regular expression in ClientFixes: %s" % (e,)) + + + def _updateLogLevels(configDict, reloading=False): log.publisher.levels.clearLogLevels() @@ -1648,6 +1672,7 @@ def _updateCompliance(configDict, reloading=False): _postUpdateAugmentService, _updateACLs, _updateRejectClients, + _updateClientFixes, _updateLogLevels, _updateNotifications, _updateICalendar, diff --git a/twistedcaldav/storebridge.py b/twistedcaldav/storebridge.py index 79d70c19f..c7cda7ef8 100644 --- a/twistedcaldav/storebridge.py +++ b/twistedcaldav/storebridge.py @@ -49,7 +49,7 @@ from twistedcaldav.sharing import ( invitationBindStatusToXMLMap, invitationBindModeToXMLMap ) -from twistedcaldav.util import bestAcceptType +from twistedcaldav.util import bestAcceptType, matchClientFixes from twistedcaldav.vcard import Component as VCard, InvalidVCardDataError from txdav.base.propertystore.base import PropertyName from txdav.caldav.icalendarstore import ( @@ -60,7 +60,7 @@ InvalidPerUserDataMerge, AttendeeAllowedError, ResourceDeletedError, InvalidAttachmentOperation, ShareeAllowedError, DuplicatePrivateCommentsError, InvalidSplit, - AttachmentSizeTooLarge, UnknownTimezone) + AttachmentSizeTooLarge, UnknownTimezone, SetComponentOptions) from txdav.carddav.iaddressbookstore import ( KindChangeNotAllowedError, GroupWithUnsharedAddressNotAllowedError ) @@ -2892,8 +2892,18 @@ def http_PUT(self, request): "Can't parse calendar data: %s" % (str(e),) )) + # Look for client fixes + ua = request.headers.getHeader("User-Agent") + client_fix_transp = matchClientFixes(config, ua) + + # Setup options + options = { + SetComponentOptions.smartMerge: schedule_tag_match, + SetComponentOptions.clientFixTRANSP: client_fix_transp, + } + try: - response = (yield self.storeComponent(component, smart_merge=schedule_tag_match)) + response = (yield self.storeComponent(component, options=options)) except ResourceDeletedError: # This is OK - it just means the server deleted the resource during the PUT. We make it look # like the PUT succeeded. diff --git a/twistedcaldav/test/test_collectioncontents.py b/twistedcaldav/test/test_collectioncontents.py index da6ef7776..9d6ca141d 100644 --- a/twistedcaldav/test/test_collectioncontents.py +++ b/twistedcaldav/test/test_collectioncontents.py @@ -48,7 +48,7 @@ def _getFakeMemcacheProtocol(self): _getFakeMemcacheProtocol) # Need to not do implicit behavior during these tests - def _fakeDoImplicitScheduling(self, component, inserting, internal_state): + def _fakeDoImplicitScheduling(self, component, inserting, internal_state, options): return False, None, False, None self.patch(CalendarObject, "doImplicitScheduling", diff --git a/twistedcaldav/test/test_util.py b/twistedcaldav/test/test_util.py index df9ca42ee..a3426e833 100644 --- a/twistedcaldav/test/test_util.py +++ b/twistedcaldav/test/test_util.py @@ -16,8 +16,10 @@ from txweb2.http_headers import Headers +from twistedcaldav.config import ConfigDict +from twistedcaldav.stdconfig import _updateClientFixes +from twistedcaldav.util import bestAcceptType, userAgentProductTokens, matchClientFixes import twistedcaldav.test.util -from twistedcaldav.util import bestAcceptType class AcceptType(twistedcaldav.test.util.TestCase): """ @@ -142,3 +144,78 @@ def test_bestAcceptType(self): hdrs.addRawHeader(*hdr) check = bestAcceptType(hdrs.getHeader("accept"), allowedTypes) self.assertEqual(check, result, msg="Failed %s" % (title,)) + + + def test_userAgentProductTokens(self): + """ + Test that L{userAgentProductTokens} correctly parses a User-Agent header. + """ + for hdr, result in ( + # Valid syntax + ("Client/1.0", ["Client/1.0", ]), + ("Client/1.0 FooBar/2", ["Client/1.0", "FooBar/2", ]), + ("Client/1.0 (commentary here)", ["Client/1.0", ]), + ("Client/1.0 (FooBar/2)", ["Client/1.0", ]), + ("Client/1.0 (commentary here) FooBar/2", ["Client/1.0", "FooBar/2", ]), + ("Client/1.0 (commentary here) FooBar/2 (more commentary here) ", ["Client/1.0", "FooBar/2", ]), + + # Invalid syntax + ("Client/1.0 (commentary here FooBar/2", ["Client/1.0", ]), + ("Client/1.0 commentary here) FooBar/2", ["Client/1.0", "commentary", "here)", "FooBar/2", ]), + ): + self.assertEqual(userAgentProductTokens(hdr), result, msg="Mismatch: {}".format(hdr)) + + + def test_matchClientFixes(self): + """ + Test that L{matchClientFixes} correctly identifies clients with matching fix tokens. + """ + c = ConfigDict() + c.ClientFixes = { + "fix1": [ + "Client/1\\.0.*", + "Client/1\\.1(\\..*)?", + "Client/2", + ], + "fix2": [ + "Other/1\\.0.*", + ], + } + _updateClientFixes(c) + _updateClientFixes(c) + + # Valid matches + for ua in ( + "Client/1.0 FooBar/2", + "Client/1.0.1 FooBar/2", + "Client/1.0.1.1 FooBar/2", + "Client/1.1 FooBar/2", + "Client/1.1.1 FooBar/2", + "Client/2 FooBar/2", + ): + self.assertEqual( + matchClientFixes(c, ua), + set(("fix1",)), + msg="Did not match {}".format(ua), + ) + + # Valid non-matches + for ua in ( + "Client/1 FooBar/2", + "Client/1.10 FooBar/2", + "Client/2.0 FooBar/2", + "Client/2.0.1 FooBar/2", + "Client FooBar/2", + "Client/3 FooBar/2", + "Client/3.0 FooBar/2", + "Client/10 FooBar/2", + "Client/10.0 FooBar/2", + "Client/10.0.1 FooBar/2", + "Client/10.0.1 (Client/1.0) FooBar/2", + "Client/10.0.1 (foo Client/1.0 bar) FooBar/2", + ): + self.assertEqual( + matchClientFixes(c, ua), + set(), + msg="Incorrectly matched {}".format(ua), + ) diff --git a/twistedcaldav/util.py b/twistedcaldav/util.py index 7bdcdd366..50426a39e 100644 --- a/twistedcaldav/util.py +++ b/twistedcaldav/util.py @@ -18,6 +18,7 @@ import re import sys import base64 +import itertools from subprocess import Popen, PIPE, STDOUT from hashlib import md5, sha1 @@ -541,3 +542,57 @@ def bestAcceptType(accepts, allowedTypes): result_qval = qval return result + + + +def userAgentProductTokens(user_agent): + """ + Parse an HTTP User-Agent header to extract the product tokens and ignore + any parenthesized comment strings in the header. + + @param user_agent: text of User-Agent header value + @type user_agent: L{str} + + @return: list of product tokens extracted from the header + @rtype: L{list} + """ + + ua_hdr = user_agent.split() + ua_tokens = [] + comment = False + for token in ua_hdr: + if comment: + if token.endswith(")"): + comment = False + elif token.startswith("("): + if not token.endswith(")"): + comment = True + else: + ua_tokens.append(token) + + return ua_tokens + + + +def matchClientFixes(config, user_agent): + """ + Given a user-agent string, see if it matches any of the configured client fixes. + + @param config: the L{config} to match against. + @type config: L{ConfigDict} + @param user_agent: the HTTP User-Agent header value to test. + @type user_agent: L{str} + """ + + if len(config.ClientFixesCompiled) == 0 or not user_agent: + return set() + + ua_tokens = userAgentProductTokens(user_agent) + + client_fixes = set() + for fix, patterns in config.ClientFixesCompiled.items(): + for pattern, token in itertools.product(patterns, ua_tokens): + if pattern.match(token) is not None: + client_fixes.add(fix) + break + return client_fixes diff --git a/txdav/caldav/datastore/scheduling/icaldiff.py b/txdav/caldav/datastore/scheduling/icaldiff.py index 0818b3d7d..f7726eb4e 100644 --- a/txdav/caldav/datastore/scheduling/icaldiff.py +++ b/txdav/caldav/datastore/scheduling/icaldiff.py @@ -27,10 +27,6 @@ from txdav.caldav.datastore.scheduling.utils import normalizeCUAddr from txdav.caldav.datastore.scheduling.itip import iTipGenerator -""" -Class that handles diff'ing two calendar objects. -""" - __all__ = [ "iCalDiff", ] @@ -38,19 +34,32 @@ log = Logger() class iCalDiff(object): - - def __init__(self, oldcalendar, newcalendar, smart_merge): + """ + This object is used for doing comparisons between two calendar objects to + work out what the changes are, in order to determine whether a scheduling + operation might be needed. The behavior will varying based on whether the + change is being triggered by an Organizer or an Attendee. + """ + + def __init__(self, oldcalendar, newcalendar, smart_merge, forceTRANSP=False): """ - - @param oldcalendar: - @type oldcalendar: - @param newcalendar: - @type newcalendar: + Note that this object will always duplicate the calendar objects when doing + comparisons so as not to change the calendar objects passed in. + + @param oldcalendar: the existing calendar object to compare to + @type oldcalendar: L{Component} + @param newcalendar: the new calendar object + @type newcalendar: L{Component} + @param smart_merge: whether or not a "smart" CalDAV merge is done (If-Schedule-Tag-Match) + @type smart_merge: L{bool} + @param forceTRANSP: whether or not to apply a fix for clients failing to set TRANSP properly + @type forceTRANSP: L{bool} """ self.oldcalendar = oldcalendar self.newcalendar = newcalendar self.smart_merge = smart_merge + self.forceTRANSP = forceTRANSP def organizerDiff(self): @@ -501,6 +510,13 @@ def _transferAttendeeData(self, serverComponent, clientComponent, declines): replyNeeded = True + # Apply client fix only if the PARTSTAT was changed + if self.forceTRANSP: + if clientAttendee.parameterValue("PARTSTAT", "NEEDS-ACTION") in ("ACCEPTED", "TENTATIVE",): + clientComponent.replaceProperty(Property("TRANSP", "OPAQUE")) + else: + clientComponent.replaceProperty(Property("TRANSP", "TRANSPARENT")) + if serverAttendee.parameterValue("RSVP", "FALSE") != clientAttendee.parameterValue("RSVP", "FALSE"): if clientAttendee.parameterValue("RSVP", "FALSE") == "FALSE": try: diff --git a/txdav/caldav/datastore/scheduling/implicit.py b/txdav/caldav/datastore/scheduling/implicit.py index 9c0c8899e..2ff864308 100755 --- a/txdav/caldav/datastore/scheduling/implicit.py +++ b/txdav/caldav/datastore/scheduling/implicit.py @@ -37,6 +37,7 @@ from txdav.caldav.datastore.scheduling.utils import getCalendarObjectForRecord from txdav.caldav.datastore.scheduling.work import ScheduleReplyWork, \ ScheduleReplyCancelWork, ScheduleOrganizerWork, ScheduleOrganizerSendWork +from txdav.caldav.icalendarstore import SetComponentOptions import collections @@ -65,10 +66,11 @@ class ImplicitScheduler(object): STATUS_ORPHANED_CANCELLED_EVENT = 1 STATUS_ORPHANED_EVENT = 2 - def __init__(self, logItems=None): + def __init__(self, logItems=None, options=None): self.return_status = ImplicitScheduler.STATUS_OK self.logItems = logItems + self.options = options self.allowed_to_schedule = True self.suppress_refresh = False @@ -1683,7 +1685,11 @@ def isAttendeeChangeInsignificant(self): (caldav_namespace, "valid-attendee-change"), "Cannot use an event when not listed as an attendee in the organizer's copy", )) - differ = iCalDiff(oldcalendar, self.calendar, self.do_smart_merge) + + # Check for a required client fix + forceTRANSP = SetComponentOptions.value(self.options, SetComponentOptions.clientFixTRANSP) + + differ = iCalDiff(oldcalendar, self.calendar, self.do_smart_merge, forceTRANSP=forceTRANSP) return differ.attendeeMerge(self.attendee) diff --git a/txdav/caldav/datastore/scheduling/test/test_icaldiff.py b/txdav/caldav/datastore/scheduling/test/test_icaldiff.py index 2e16a34dc..497bf8dd8 100644 --- a/txdav/caldav/datastore/scheduling/test/test_icaldiff.py +++ b/txdav/caldav/datastore/scheduling/test/test_icaldiff.py @@ -5248,6 +5248,391 @@ def test_attendee_needs_action(self): self.assertEqual(got_rescheduled, rescheduled, msg="%s expected rescheduled: '%s', got: '%s'" % (description, rescheduled, got_rescheduled,)) + def test_attendee_merge_forceTRANSP(self): + + data = ( + ( + "#1.1 Simple component, no change", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:user2@example.com +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:user2@example.com +END:VEVENT +END:VCALENDAR +""", + "mailto:user2@example.com", + (True, False, (), """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:user2@example.com +ORGANIZER;CN=User 01:mailto:user1@example.com +END:VEVENT +END:VCALENDAR +""") + ), + ( + "#1.2 Simple component, change to ACCEPTED", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:user2@example.com +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:user2@example.com +END:VEVENT +END:VCALENDAR +""", + "mailto:user2@example.com", + (True, True, (None,), """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=ACCEPTED;X-CALENDARSERVER-DTSTAMP=XXXXXXXXTXXXXXXZ:mailto:user2@example.com +ORGANIZER;CN=User 01:mailto:user1@example.com +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR +""") + ), + ( + "#1.3 Simple component, change to DECLINED", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:user2@example.com +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=DECLINED:mailto:user2@example.com +END:VEVENT +END:VCALENDAR +""", + "mailto:user2@example.com", + (True, True, (None,), """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=DECLINED;X-CALENDARSERVER-DTSTAMP=XXXXXXXXTXXXXXXZ:mailto:user2@example.com +ORGANIZER;CN=User 01:mailto:user1@example.com +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR +""") + ), + ( + "#1.4 Simple component, change to TENTATIVE", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:user2@example.com +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=TENTATIVE:mailto:user2@example.com +END:VEVENT +END:VCALENDAR +""", + "mailto:user2@example.com", + (True, True, (None,), """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=TENTATIVE;X-CALENDARSERVER-DTSTAMP=XXXXXXXXTXXXXXXZ:mailto:user2@example.com +ORGANIZER;CN=User 01:mailto:user1@example.com +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR +""") + ), + ( + "#1.5 Simple component, change to ACCEPTED with TRANSP", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:user2@example.com +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=TENTATIVE:mailto:user2@example.com +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR +""", + "mailto:user2@example.com", + (True, True, (None,), """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=TENTATIVE;X-CALENDARSERVER-DTSTAMP=XXXXXXXXTXXXXXXZ:mailto:user2@example.com +ORGANIZER;CN=User 01:mailto:user1@example.com +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR +""") + ), + ( + "#1.6 Simple component, remain as ACCEPTED with TRANSP", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:user2@example.com +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:user2@example.com +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR +""", + "mailto:user2@example.com", + (True, False, (), """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:user2@example.com +ORGANIZER;CN=User 01:mailto:user1@example.com +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR +""") + ), + ( + "#1.7 Simple component, remain as ACCEPTED change TRANSP", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:user2@example.com +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:user2@example.com +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR +""", + "mailto:user2@example.com", + (True, False, (), """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:user2@example.com +ORGANIZER;CN=User 01:mailto:user1@example.com +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR +""") + ), + ( + "#1.8 Simple component, ACCEPTED->DECLINED with TRANSP", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:user2@example.com +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ORGANIZER;CN="User 01":mailto:user1@example.com +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=DECLINED:mailto:user2@example.com +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR +""", + "mailto:user2@example.com", + (True, True, (None,), """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//CALENDARSERVER.ORG//NONSGML Version 1//EN +BEGIN:VEVENT +UID:12345-67890 +DTSTART:20080601T120000Z +DTEND:20080601T130000Z +ATTENDEE:mailto:user1@example.com +ATTENDEE;PARTSTAT=DECLINED;X-CALENDARSERVER-DTSTAMP=XXXXXXXXTXXXXXXZ:mailto:user2@example.com +ORGANIZER;CN=User 01:mailto:user1@example.com +TRANSP:TRANSPARENT +END:VEVENT +END:VCALENDAR +""") + ), + ) + + for description, calendar1, calendar2, attendee, result in data: + differ = iCalDiff( + Component.fromString(calendar1), + Component.fromString(calendar2), + False, + forceTRANSP=True, + ) + diffResult = differ.attendeeMerge(attendee) + diffResult = ( + diffResult[0], + diffResult[1], + tuple(sorted(diffResult[2])), + re.sub( + "X-CALENDARSERVER-DTSTAMP=[^Z]+", + "X-CALENDARSERVER-DTSTAMP=XXXXXXXXTXXXXXX", + str(diffResult[3]).replace("\r", "").replace("\n ", "") + ) if diffResult[3] else None, + ) + self.assertEqual(diffResult, result, msg="%s: actual result: (%s)" % (description, ", ".join([str(i).replace("\r", "") for i in diffResult]),)) + + def test_organizer_smart_merge(self): data1 = ( diff --git a/txdav/caldav/datastore/sql.py b/txdav/caldav/datastore/sql.py index 359c5e8cd..ce3a2e502 100644 --- a/txdav/caldav/datastore/sql.py +++ b/txdav/caldav/datastore/sql.py @@ -78,7 +78,7 @@ InvalidDefaultCalendar, \ InvalidAttachmentOperation, DuplicatePrivateCommentsError, \ TimeRangeUpperLimit, TimeRangeLowerLimit, InvalidSplit, \ - AttachmentSizeTooLarge, UnknownTimezone + AttachmentSizeTooLarge, UnknownTimezone, SetComponentOptions from txdav.caldav.icalendarstore import QuotaExceeded from txdav.common.datastore.sql import CommonHome, CommonHomeChild, \ CommonObjectResource, ECALENDARTYPE @@ -2961,7 +2961,7 @@ def decorateHostedStatus(self, component): @inlineCallbacks - def doImplicitScheduling(self, component, inserting, internal_state, split_details=None): + def doImplicitScheduling(self, component, inserting, internal_state, options, split_details=None): new_component = None did_implicit_action = False @@ -2983,7 +2983,7 @@ def doImplicitScheduling(self, component, inserting, internal_state, split_detai user_uuid = self._parentCollection.viewerHome().uid() component = PerUserDataFilter(user_uuid).filter(component.duplicate()) - scheduler = ImplicitScheduler(logItems=self._txn.logItems) + scheduler = ImplicitScheduler(logItems=self._txn.logItems, options=options) # PUT do_implicit_action, is_scheduling_resource = (yield scheduler.testImplicitSchedulingPUT( @@ -3120,7 +3120,7 @@ def _lockUID(self, component, inserting, internal_state): @inlineCallbacks - def setComponent(self, component, inserting=False, smart_merge=False): + def setComponent(self, component, inserting=False, options=None): """ Public api for storing a component. This will do full data validation checks on the specified component. Scheduling will be done automatically. @@ -3133,7 +3133,7 @@ def setComponent(self, component, inserting=False, smart_merge=False): except InvalidICalendarDataError as e: raise InvalidComponentForStoreError(str(e)) try: - result = yield self._setComponentInternal(component, inserting, ComponentUpdateState.NORMAL, smart_merge) + result = yield self._setComponentInternal(component, inserting, ComponentUpdateState.NORMAL, options) except Exception: ex = Failure() @@ -3148,7 +3148,7 @@ def setComponent(self, component, inserting=False, smart_merge=False): @inlineCallbacks - def _setComponentInternal(self, component, inserting=False, internal_state=ComponentUpdateState.NORMAL, smart_merge=False, split_details=None): + def _setComponentInternal(self, component, inserting=False, internal_state=ComponentUpdateState.NORMAL, options=None, split_details=None): """ Setting the component internally to the store itself. This will bypass a whole bunch of data consistency checks on the assumption that those have been done prior to the component data being provided, provided the flag is set. @@ -3156,7 +3156,11 @@ def _setComponentInternal(self, component, inserting=False, internal_state=Compo """ self._componentChanged = False - self.schedule_tag_match = not self.calendar().isInbox() and internal_state == ComponentUpdateState.NORMAL and smart_merge + self.schedule_tag_match = ( + not self.calendar().isInbox() and + internal_state == ComponentUpdateState.NORMAL and + SetComponentOptions.value(options, SetComponentOptions.smartMerge) + ) schedule_state = None if internal_state in (ComponentUpdateState.SPLIT_OWNER, ComponentUpdateState.SPLIT_ATTENDEE,): @@ -3175,7 +3179,7 @@ def _setComponentInternal(self, component, inserting=False, internal_state=Compo # Do scheduling only for owner split if internal_state == ComponentUpdateState.SPLIT_OWNER: - yield self.doImplicitScheduling(component, inserting, internal_state, split_details) + yield self.doImplicitScheduling(component, inserting, internal_state, options, split_details) self.isScheduleObject = True self.processScheduleTags(component, inserting, internal_state) @@ -3211,7 +3215,7 @@ def _setComponentInternal(self, component, inserting=False, internal_state=Compo yield self.decorateHostedStatus(component) # Do scheduling - implicit_result = (yield self.doImplicitScheduling(component, inserting, internal_state)) + implicit_result = (yield self.doImplicitScheduling(component, inserting, internal_state, options)) if isinstance(implicit_result, int): if implicit_result == ImplicitScheduler.STATUS_ORPHANED_CANCELLED_EVENT: raise ResourceDeletedError("Resource created but immediately deleted by the server.") diff --git a/txdav/caldav/datastore/sql_external.py b/txdav/caldav/datastore/sql_external.py index 3bdd7dc4f..089edaf67 100644 --- a/txdav/caldav/datastore/sql_external.py +++ b/txdav/caldav/datastore/sql_external.py @@ -171,7 +171,7 @@ def _createInternal(cls, parent, name, component, internal_state, options=None, raise AssertionError("CalendarObjectExternal: not supported") - def _setComponentInternal(self, component, inserting=False, internal_state=ComponentUpdateState.NORMAL, smart_merge=False, split_details=None): + def _setComponentInternal(self, component, inserting=False, internal_state=ComponentUpdateState.NORMAL, options=None, split_details=None): raise AssertionError("CalendarObjectExternal: not supported") diff --git a/txdav/caldav/icalendarstore.py b/txdav/caldav/icalendarstore.py index 25b96fc58..3a6e2b625 100644 --- a/txdav/caldav/icalendarstore.py +++ b/txdav/caldav/icalendarstore.py @@ -950,3 +950,30 @@ class ComponentRemoveState(Names): NORMAL.description = "normal" NORMAL_NO_IMPLICIT.description = "normal-no-implicit" INTERNAL.description = "internal" + + + +class SetComponentOptions(Names): + """ + Constants for keys used in the L{ICalendarObject.setComponent} method's + C{options} dict. The definitions below define the constant key name and + the type used for the dict entry's value. + + @cvar smartMerge: Apply CalDAV smart merge to data (If-Schedule-Tag-Match) + Value: L{bool} + + @cvar clientFixTRANSP: Apply fix for clients not setting TRANSP. + Value: L{bool} + """ + + smartMerge = NamedConstant() + smartMerge.description = u"Smart Merge: CalDAV If-Schedule-Tag-Match behavior" + smartMerge.defaultValue = False + + clientFixTRANSP = NamedConstant() + clientFixTRANSP.description = u"Fix for clients not setting TRANSP" + clientFixTRANSP.defaultValue = False + + @staticmethod + def value(options, key): + return options.get(key, key.defaultValue) if options is not None else key.defaultValue diff --git a/txdav/carddav/datastore/sql.py b/txdav/carddav/datastore/sql.py index 2b93401ef..2cc60eb26 100644 --- a/txdav/carddav/datastore/sql.py +++ b/txdav/carddav/datastore/sql.py @@ -2041,7 +2041,7 @@ def external(self): @inlineCallbacks - def remove(self): + def remove(self, options=None): if self.owned(): yield self.unshare() # storebridge should already have done this @@ -2412,7 +2412,7 @@ def _lockUID(self, component, inserting): @inlineCallbacks - def setComponent(self, component, inserting=False): + def setComponent(self, component, inserting=False, options=None): if isinstance(component, str) or isinstance(component, unicode): component = self._componentClass.fromString(component) diff --git a/txdav/common/datastore/sql.py b/txdav/common/datastore/sql.py index 5367950be..fbb3c70e8 100644 --- a/txdav/common/datastore/sql.py +++ b/txdav/common/datastore/sql.py @@ -7217,7 +7217,7 @@ def lock(self, wait=True, txn=None): self._locked = True - def setComponent(self, component, inserting=False): + def setComponent(self, component, inserting=False, options=None): raise NotImplementedError @@ -7264,7 +7264,7 @@ def moveValidation(self, destination, name): @inlineCallbacks - def remove(self): + def remove(self, options=None): yield self._deleteQuery.on(self._txn, NoSuchObjectResourceError, resourceID=self._resourceID) yield self.properties()._removeResource()