[CalendarServer-changes] [10795] CalendarServer/trunk/calendarserver/tools

source_changes at macosforge.org source_changes at macosforge.org
Thu Feb 21 19:12:21 PST 2013


Revision: 10795
          http://trac.calendarserver.org//changeset/10795
Author:   cdaboo at apple.com
Date:     2013-02-21 19:12:21 -0800 (Thu, 21 Feb 2013)
Log Message:
-----------
Detect double-bookings using calverify.

Modified Paths:
--------------
    CalendarServer/trunk/calendarserver/tools/calverify.py
    CalendarServer/trunk/calendarserver/tools/test/test_calverify.py

Modified: CalendarServer/trunk/calendarserver/tools/calverify.py
===================================================================
--- CalendarServer/trunk/calendarserver/tools/calverify.py	2013-02-22 03:09:29 UTC (rev 10794)
+++ CalendarServer/trunk/calendarserver/tools/calverify.py	2013-02-22 03:12:21 UTC (rev 10795)
@@ -15,6 +15,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 ##
+from twistedcaldav.directory.directory import DirectoryService
+from twistedcaldav.datafilters.peruserdata import PerUserDataFilter
 
 """
 This tool scans the calendar store to analyze organizer/attendee event
@@ -223,6 +225,7 @@
 --mismatch          : verify scheduling state.
 --missing           : display orphaned calendar homes - can be used.
                       with either --ical or --mismatch.
+--double            : detect double-bookings.
 
 --nuke PATH|RID     : remove specific calendar resources - can
                       only be used by itself. PATH is the full
@@ -249,11 +252,22 @@
 --details  : log extended details on each mismatch.
 --tzid     : timezone to adjust details to.
 
+Options for --double:
+
+--uuid     : only scan specified calendar homes. Can be a partial GUID
+             to scan all GUIDs with that as a prefix or "*" for all GUIDS
+             (that are marked as resources or locations in the directory).
+--tzid     : timezone to adjust details to.
+--summary  : report only which GUIDs have double-bookings - no details.
+--days     : number of days ahead to scan [DEFAULT: 365]
+
 CHANGES
 v8: Detects ORGANIZER or ATTENDEE properties with mailto: calendar user
     addresses for users that have valid directory records. Fix is to
     replace the value with a urn:uuid: form.
 
+v9: Detects double-bookings.
+
 """ % (VERSION,)
 
 
@@ -276,9 +290,11 @@
         ['nobase64', 'n', "Do not apply CALENDARSERVER-OLD-CUA base64 transform when fixing."],
         ['mismatch', 's', "Detect organizer/attendee mismatches."],
         ['missing', 'm', "Show 'orphaned' homes."],
+        ['double', 'd', "Detect double-bookings."],
         ['fix', 'x', "Fix problems."],
         ['verbose', 'v', "Verbose logging."],
         ['details', 'V', "Detailed logging."],
+        ['summary', 'S', "Summary of double-bookings."],
         ['tzid', 't', "Timezone to adjust displayed times to."],
     ]
 
@@ -286,7 +302,8 @@
         ['config', 'f', DEFAULT_CONFIG_FILE, "Specify caldavd.plist configuration path."],
         ['uuid', 'u', "", "Only check this user."],
         ['uid', 'U', "", "Only this event UID."],
-        ['nuke', 'e', "", "Remove event given its path"]
+        ['nuke', 'e', "", "Remove event given its path."],
+        ['days', 'T', "365", "Number of days for scanning events into the future."]
     ]
 
 
@@ -403,6 +420,18 @@
 
 
     @inlineCallbacks
+    def getMatchingHomeUIDs(self, uuid):
+        ch = schema.CALENDAR_HOME
+        kwds = {"uuid": uuid}
+        rows = (yield Select(
+            [ch.OWNER_UID, ],
+            From=ch,
+            Where=(ch.OWNER_UID.StartsWith(Parameter("uuid"))),
+        ).on(self.txn, **kwds))
+        returnValue(tuple([uid[0] for uid in rows]))
+
+
+    @inlineCallbacks
     def countHomeContents(self, uid):
         ch = schema.CALENDAR_HOME
         cb = schema.CALENDAR_BIND
@@ -716,6 +745,12 @@
             returnValue(False)
 
 
+    def logResult(self, key, value, total=None):
+        self.output.write("%s: %s\n" % (key, value,))
+        self.results[key] = value
+        self.addToSummary(key, value, total)
+
+
     def addToSummary(self, title, count, total=None):
         if total is not None:
             percent = safePercent(count, total),
@@ -735,7 +770,7 @@
         table.setDefaultColumnFormats(
             (
                 tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.LEFT_JUSTIFY),
-                tables.Table.ColumnFormat("%d", tables.Table.ColumnFormat.RIGHT_JUSTIFY),
+                tables.Table.ColumnFormat("%s", tables.Table.ColumnFormat.RIGHT_JUSTIFY),
                 tables.Table.ColumnFormat("%.1f%%", tables.Table.ColumnFormat.RIGHT_JUSTIFY),
             )
         )
@@ -809,7 +844,7 @@
 
 class OrphansService(CalVerifyService):
     """
-    Service which removes specific events.
+    Service which detects orphaned calendar homes.
     """
 
     def title(self):
@@ -877,9 +912,8 @@
             ))
 
         self.output.write("\n")
-        self.output.write("Homes without a matching directory record (total=%d):\n" % (len(missing),))
+        self.logResult("Homes without a matching directory record", len(missing), uids_len)
         table.printTable(os=self.output)
-        self.addToSummary("Homes without a matching directory record", len(missing), uids_len)
 
         # Print table of results
         table = tables.Table()
@@ -892,9 +926,8 @@
             ))
 
         self.output.write("\n")
-        self.output.write("Homes not hosted on this server (total=%d):\n" % (len(wrong_server),))
+        self.logResult("Homes not hosted on this server", len(wrong_server), uids_len)
         table.printTable(os=self.output)
-        self.addToSummary("Homes not hosted on this server", len(wrong_server), uids_len)
 
         # Print table of results
         table = tables.Table()
@@ -907,9 +940,8 @@
             ))
 
         self.output.write("\n")
-        self.output.write("Homes without an enabled directory record (total=%d):\n" % (len(disabled),))
+        self.logResult("Homes without an enabled directory record", len(disabled), uids_len)
         table.printTable(os=self.output)
-        self.addToSummary("Homes without an enabled directory record", len(disabled), uids_len)
 
         self.printSummary()
 
@@ -960,9 +992,7 @@
             self.output.write("%s time: %.1fs\n" % (descriptor, time.time() - t,))
 
         self.total = len(rows)
-        self.output.write("Number of events to process: %s\n" % (len(rows,)))
-        self.results["Number of events to process"] = len(rows)
-        self.addToSummary("Number of events to process", self.total)
+        self.logResult("Number of events to process", self.total)
         self.addSummaryBreak()
 
         yield self.calendarDataCheck(rows)
@@ -1042,12 +1072,10 @@
             ))
 
         self.output.write("\n")
-        self.output.write("Bad iCalendar data (total=%d):\n" % (len(results_bad),))
+        self.logResult("Bad iCalendar data", len(results_bad), total)
+        self.results["Bad iCalendar data"] = results_bad
         table.printTable(os=self.output)
 
-        self.results["Bad iCalendar data"] = results_bad
-        self.addToSummary("Bad iCalendar data", len(results_bad), total)
-
         if self.options["verbose"]:
             diff_time = time.time() - t
             self.output.write("Time: %.2f s  Average: %.1f ms/resource\n" % (
@@ -1243,7 +1271,7 @@
 
 class SchedulingMismatchService(CalVerifyService):
     """
-    Service which runs, exports the appropriate records, then stops the reactor.
+    Service which detects mismatched scheduled events.
     """
 
     metadata = {
@@ -1275,6 +1303,10 @@
         self.fixedAutoAccepts = []
 
 
+    def title(self):
+        return "Scheduling Mismatch Service"
+
+
     @inlineCallbacks
     def doAction(self):
 
@@ -1308,9 +1340,7 @@
             self.output.write("%s time: %.1fs\n" % (descriptor, time.time() - t,))
 
         self.total = len(rows)
-        self.output.write("Number of events to process: %s\n" % (len(rows,)))
-        self.results["Number of events to process"] = len(rows)
-        self.addToSummary("Number of events to process", self.total)
+        self.logResult("Number of events to process", self.total)
 
         # Split into organizer events and attendee events
         self.organized = []
@@ -1320,15 +1350,10 @@
         self.matched_attendee_to_organizer = collections.defaultdict(set)
         skipped, inboxes = self.buildResourceInfo(rows)
 
-        self.output.write("Number of organizer events to process: %s\n" % (len(self.organized),))
-        self.output.write("Number of attendee events to process: %s\n" % (len(self.attended,)))
-        self.results["Number of organizer events to process"] = len(self.organized)
-        self.results["Number of attendee events to process"] = len(self.attended)
-        self.results["Number of skipped events"] = skipped
-        self.results["Number of inbox events"] = inboxes
-        self.addToSummary("Number of organizer events to process", len(self.organized), self.total)
-        self.addToSummary("Number of attendee events to process", len(self.attended), self.total)
-        self.addToSummary("Number of skipped events", skipped, self.total)
+        self.logResult("Number of organizer events to process", len(self.organized), self.total)
+        self.logResult("Number of attendee events to process", len(self.attended), self.total)
+        self.logResult("Number of skipped events", skipped, self.total)
+        self.logResult("Number of inbox events", inboxes)
         self.addSummaryBreak()
 
         self.totalErrors = 0
@@ -1338,17 +1363,13 @@
         # Need to add fix summary information
         if self.fix:
             self.addSummaryBreak()
-            self.results["Fixed missing attendee events"] = self.fixAttendeesForOrganizerMissing
-            self.results["Fixed mismatched attendee events"] = self.fixAttendeesForOrganizerMismatch
-            self.results["Fixed missing organizer events"] = self.fixOrganizersForAttendeeMissing
-            self.results["Fixed mismatched organizer events"] = self.fixOrganizersForAttendeeMismatch
-            self.results["Fix failures"] = self.fixFailed
-            self.results["Fixed Auto-Accepts"] = self.fixedAutoAccepts
-            self.addToSummary("Fixed missing attendee events", self.fixAttendeesForOrganizerMissing)
-            self.addToSummary("Fixed mismatched attendee events", self.fixAttendeesForOrganizerMismatch)
-            self.addToSummary("Fixed missing organizer events", self.fixOrganizersForAttendeeMissing)
-            self.addToSummary("Fixed mismatched organizer events", self.fixOrganizersForAttendeeMismatch)
-            self.addToSummary("Fix failures", self.fixFailed)
+            self.logResult("Fixed missing attendee events", self.fixAttendeesForOrganizerMissing)
+            self.logResult("Fixed mismatched attendee events", self.fixAttendeesForOrganizerMismatch)
+            self.logResult("Fixed missing organizer events", self.fixOrganizersForAttendeeMissing)
+            self.logResult("Fixed mismatched organizer events", self.fixOrganizersForAttendeeMismatch)
+            self.logResult("Fix failures", self.fixFailed)
+            self.logResult("Fixed Auto-Accepts", len(self.fixedAutoAccepts))
+            self.results["Auto-Accepts"] = self.fixedAutoAccepts
 
             self.printAutoAccepts()
 
@@ -1437,7 +1458,6 @@
         # Test organized events
         t = time.time()
         for ctr, organizerEvent in enumerate(self.organized):
-
             if self.options["verbose"] and divmod(ctr, organizer_div)[1] == 0:
                 self.output.write(("\r%d of %d (%d%%) Missing: %d  Mismatched: %s" % (
                     ctr + 1,
@@ -1571,9 +1591,8 @@
             ))
 
         self.output.write("\n")
-        self.output.write("Events missing from Attendee's calendars (total=%d):\n" % (len(results_missing),))
+        self.logResult("Events missing from Attendee's calendars", len(results_missing), self.total)
         table.printTable(os=self.output)
-        self.addToSummary("Events missing from Attendee's calendars", len(results_missing), self.total)
         self.totalErrors += len(results_missing)
 
         # Print table of results
@@ -1597,9 +1616,8 @@
             ))
 
         self.output.write("\n")
-        self.output.write("Events mismatched between Organizer's and Attendee's calendars (total=%d):\n" % (len(results_mismatch),))
+        self.logResult("Events mismatched between Organizer's and Attendee's calendars", len(results_mismatch), self.total)
         table.printTable(os=self.output)
-        self.addToSummary("Events mismatched between Organizer's and Attendee's calendars", len(results_mismatch), self.total)
         self.totalErrors += len(results_mismatch)
 
 
@@ -1748,9 +1766,8 @@
             ))
 
         self.output.write("\n")
-        self.output.write("Attendee events mismatched in Organizer's calendar (total=%d):\n" % (len(mismatched),))
+        self.logResult("Attendee events mismatched in Organizer's calendar", len(mismatched), self.total)
         table.printTable(os=self.output)
-        self.addToSummary("Attendee events mismatched in Organizer's calendar", len(mismatched), self.total)
         self.totalErrors += len(mismatched)
 
 
@@ -1986,6 +2003,298 @@
 
 
 
+class DoubleBookingService(CalVerifyService):
+    """
+    Service which detects double-booked events.
+    """
+
+    def title(self):
+        return "Double Booking Service"
+
+
+    @inlineCallbacks
+    def doAction(self):
+
+        if self.options["fix"]:
+            self.output.write("\nFixing is not supported.\n")
+            returnValue(None)
+
+        self.output.write("\n---- Scanning calendar data ----\n")
+
+        self.now = PyCalendarDateTime.getNowUTC()
+        self.start = PyCalendarDateTime.getToday()
+        self.start.setDateOnly(False)
+        self.end = self.start.duplicate()
+        self.end.offsetYear(1)
+        self.fix = self.options["fix"]
+
+        self.tzid = PyCalendarTimezone(tzid=self.options["tzid"] if self.options["tzid"] else "America/Los_Angeles")
+
+        if self.options["verbose"] and self.options["summary"]:
+            ot = time.time()
+
+        # Check loop over uuid
+        UUIDDetails = collections.namedtuple("UUIDDetails", ("uuid", "rname", "auto", "doubled",))
+        self.uuid_details = []
+        if len(self.options["uuid"]) != 36:
+            self.txn = self.store.newTransaction()
+            if self.options["uuid"]:
+                homes = yield self.getMatchingHomeUIDs(self.options["uuid"])
+            else:
+                homes = yield self.getAllHomeUIDs()
+            yield self.txn.commit()
+            self.txn = None
+            uuids = []
+            for uuid in sorted(homes):
+                record = self.directoryService().recordWithGUID(uuid)
+                if record is not None and record.recordType in (DirectoryService.recordType_locations, DirectoryService.recordType_resources):
+                    uuids.append(uuid)
+        else:
+            uuids = [self.options["uuid"], ]
+
+        count = 0
+        for uuid in uuids:
+            self.results = {}
+            self.summary = []
+            self.total = 0
+            count += 1
+
+            record = self.directoryService().recordWithGUID(uuid)
+            if record is None:
+                continue
+            if not record.thisServer() or not record.enabledForCalendaring:
+                continue
+
+            rname = record.shortNames[0]
+            auto = record.autoSchedule
+
+            if len(uuids) > 1 and not self.options["summary"]:
+                self.output.write("\n\n-----------------------------\n")
+
+            self.txn = self.store.newTransaction()
+
+            if self.options["verbose"]:
+                t = time.time()
+            rows = yield self.getTimeRangeInfoWithUUID(uuid, self.start)
+            descriptor = "getTimeRangeInfoWithUUID"
+
+            yield self.txn.commit()
+            self.txn = None
+
+            if self.options["verbose"]:
+                if not self.options["summary"]:
+                    self.output.write("%s time: %.1fs\n" % (descriptor, time.time() - t,))
+                else:
+                    self.output.write("%s (%d/%d)" % (uuid, count, len(uuids),))
+                    self.output.flush()
+
+            self.total = len(rows)
+            if not self.options["summary"]:
+                self.logResult("UUID to process", uuid)
+                self.logResult("Record name", rname)
+                self.logResult("Auto-schedule", "True" if auto else "False")
+                self.addSummaryBreak()
+                self.logResult("Number of events to process", self.total)
+
+            if rows:
+                if not self.options["summary"]:
+                    self.addSummaryBreak()
+                doubled = yield self.doubleBookCheck(rows, uuid, self.start)
+            else:
+                doubled = False
+
+            self.uuid_details.append(UUIDDetails(uuid, rname, auto, doubled))
+
+            if not self.options["summary"]:
+                self.printSummary()
+            else:
+                self.output.write(" - %s\n" % ("Double-booked" if doubled else "OK",))
+                self.output.flush()
+
+        if self.options["summary"]:
+            table = tables.Table()
+            table.addHeader(("GUID", "Name", "Auto-Schedule", "Double-Booked",))
+            doubled = 0
+            for item in sorted(self.uuid_details):
+                table.addRow((
+                    item.uuid,
+                    item.rname,
+                    item.auto,
+                    item.doubled,
+                ))
+                if item.doubled:
+                    doubled += 1
+            table.addFooter(("Total", "", "", "%d of %d" % (doubled, len(self.uuid_details),),))
+            self.output.write("\n")
+            table.printTable(os=self.output)
+
+            if self.options["verbose"]:
+                self.output.write("%s time: %.1fs\n" % ("Summary", time.time() - ot,))
+
+
+    @inlineCallbacks
+    def getTimeRangeInfoWithUUID(self, uuid, start):
+        co = schema.CALENDAR_OBJECT
+        cb = schema.CALENDAR_BIND
+        ch = schema.CALENDAR_HOME
+        tr = schema.TIME_RANGE
+        kwds = {
+            "uuid": uuid,
+            "Start" : pyCalendarTodatetime(start),
+        }
+        rows = (yield Select(
+            [co.RESOURCE_ID, ],
+            From=ch.join(
+                cb, type="inner", on=(ch.RESOURCE_ID == cb.CALENDAR_HOME_RESOURCE_ID)).join(
+                co, type="inner", on=(cb.CALENDAR_RESOURCE_ID == co.CALENDAR_RESOURCE_ID).And(
+                    cb.BIND_MODE == _BIND_MODE_OWN).And(
+                    cb.CALENDAR_RESOURCE_NAME != "inbox").And(
+                    co.ORGANIZER != "")).join(
+                tr, type="left", on=(co.RESOURCE_ID == tr.CALENDAR_OBJECT_RESOURCE_ID)),
+            Where=(ch.OWNER_UID == Parameter("uuid")).And((tr.START_DATE >= Parameter("Start")).Or(co.RECURRANCE_MAX <= Parameter("Start"))),
+            Distinct=True,
+        ).on(self.txn, **kwds))
+        returnValue(tuple(rows))
+
+
+    @inlineCallbacks
+    def doubleBookCheck(self, rows, uuid, start):
+        """
+        Check each calendar resource by expanding instances within the next year, and looking for
+        any that overlap with status not CANCELLED and PARTSTAT ACCEPTED.
+        """
+
+        if not self.options["summary"]:
+            self.output.write("\n---- Checking instances for double-booking ----\n")
+        self.txn = self.store.newTransaction()
+
+        if self.options["verbose"]:
+            t = time.time()
+
+        InstanceDetails = collections.namedtuple("InstanceDetails", ("resid", "uid", "start", "end",))
+
+        end = start.duplicate()
+        end.offsetDay(int(self.options["days"]))
+        count = 0
+        total = len(rows)
+        total_instances = 0
+        booked_instances = 0
+        details = []
+        rjust = 10
+        for resid in rows:
+            resid = resid[0]
+            caldata = yield self.getCalendar(resid, self.fix)
+            if caldata is None:
+                if self.parseError:
+                    returnValue((False, self.parseError))
+                else:
+                    returnValue((True, "Nothing to scan"))
+
+            cal = Component(None, pycalendar=caldata)
+            cal = PerUserDataFilter(uuid).filter(cal)
+            uid = cal.resourceUID()
+            instances = cal.expandTimeRanges(end, start, ignoreInvalidInstances=True)
+            count += 1
+
+            for instance in instances.instances.values():
+                total_instances += 1
+
+                # See if it is CANCELLED or TRANSPARENT
+                if instance.component.propertyValue("STATUS") == "CANCELLED":
+                    continue
+                if instance.component.propertyValue("TRANSP") == "TRANSPARENT":
+                    continue
+
+                details.append(InstanceDetails(resid, uid, instance.start, instance.end,))
+                booked_instances += 1
+
+            if self.options["verbose"] and not self.options["summary"]:
+                if count == 1:
+                    self.output.write("Instances".rjust(rjust) + "Current".rjust(rjust) + "Total".rjust(rjust) + "Complete".rjust(rjust) + "\n")
+                if divmod(count, 100)[1] == 0:
+                    self.output.write((
+                        "\r" +
+                        ("%s" % total_instances).rjust(rjust) +
+                        ("%s" % count).rjust(rjust) +
+                        ("%s" % total).rjust(rjust) +
+                        ("%d%%" % safePercent(count, total)).rjust(rjust)
+                    ).ljust(80))
+                    self.output.flush()
+
+            # To avoid holding locks on all the rows scanned, commit every 100 resources
+            if divmod(count, 100)[1] == 0:
+                yield self.txn.commit()
+                self.txn = self.store.newTransaction()
+
+        yield self.txn.commit()
+        self.txn = None
+        if self.options["verbose"] and not self.options["summary"]:
+            self.output.write((
+                "\r" +
+                ("%s" % total_instances).rjust(rjust) +
+                ("%s" % count).rjust(rjust) +
+                ("%s" % total).rjust(rjust) +
+                ("%d%%" % safePercent(count, total)).rjust(rjust)
+            ).ljust(80) + "\n")
+
+        if not self.options["summary"]:
+            self.logResult("Number of instances in time-range", total_instances)
+            self.logResult("Number of booked instances", booked_instances)
+
+        # Now look for double-bookings
+        DoubleBookedDetails = collections.namedtuple("DoubleBookedDetails", ("resid1", "uid1", "resid2", "uid2", "start",))
+        double_booked = []
+        details.sort(key=lambda x: x.start)
+        current = details[0] if details else None
+        for next in details[1:]:
+            if current.end > next.start:
+                dt = next.start.duplicate()
+                dt.adjustTimezone(self.tzid)
+                double_booked.append(DoubleBookedDetails(current.resid, current.uid, next.resid, next.uid, dt,))
+            current = next
+
+        # Print table of results
+        if double_booked and not self.options["summary"]:
+            table = tables.Table()
+            table.addHeader(("RID #1", "UID #1", "RID #2", "UID #2", "Start",))
+            previous1 = None
+            previous2 = None
+            unique_events = 0
+            for item in sorted(double_booked):
+                if previous1 != item.resid1:
+                    unique_events += 1
+                resid1 = item.resid1 if previous1 != item.resid1 else "."
+                uid1 = item.uid1 if previous1 != item.resid1 else "."
+                resid2 = item.resid2 if previous2 != item.resid2 else "."
+                uid2 = item.uid2 if previous2 != item.resid2 else "."
+                table.addRow((
+                    resid1,
+                    uid1,
+                    resid2,
+                    uid2,
+                    item.start,
+                ))
+                previous1 = item.resid1
+                previous2 = item.resid2
+
+            self.output.write("\n")
+            self.logResult("Number of double-bookings", len(double_booked))
+            self.logResult("Number of unique double-bookings", unique_events)
+            table.printTable(os=self.output)
+
+        self.results["Double-bookings"] = double_booked
+
+        if self.options["verbose"] and not self.options["summary"]:
+            diff_time = time.time() - t
+            self.output.write("Time: %.2f s  Average: %.1f ms/resource\n" % (
+                diff_time,
+                safePercent(diff_time, total, 1000.0),
+            ))
+
+        returnValue(len(double_booked) != 0)
+
+
+
 def main(argv=sys.argv, stderr=sys.stderr, reactor=None):
 
     if reactor is None:
@@ -2014,6 +2323,8 @@
             return BadDataService(store, options, output, reactor, config)
         elif options["mismatch"]:
             return SchedulingMismatchService(store, options, output, reactor, config)
+        elif options["double"]:
+            return DoubleBookingService(store, options, output, reactor, config)
 
     utilityMain(options['config'], makeService, reactor)
 

Modified: CalendarServer/trunk/calendarserver/tools/test/test_calverify.py
===================================================================
--- CalendarServer/trunk/calendarserver/tools/test/test_calverify.py	2013-02-22 03:09:29 UTC (rev 10794)
+++ CalendarServer/trunk/calendarserver/tools/test/test_calverify.py	2013-02-22 03:12:21 UTC (rev 10795)
@@ -21,7 +21,7 @@
 from StringIO import StringIO
 from calendarserver.tap.util import getRootResource
 from calendarserver.tools.calverify import BadDataService, \
-    SchedulingMismatchService
+    SchedulingMismatchService, DoubleBookingService
 from pycalendar.datetime import PyCalendarDateTime
 from twisted.internet import reactor
 from twisted.internet.defer import inlineCallbacks, returnValue
@@ -1514,7 +1514,7 @@
         self.assertTrue("Fix remove" not in calverify.results)
         self.assertTrue("Fix remove" not in calverify.results)
         self.assertTrue("Fix failures" not in calverify.results)
-        self.assertTrue("Fixed Auto-Accepts" not in calverify.results)
+        self.assertTrue("Auto-Accepts" not in calverify.results)
 
         sync_token_new1 = (yield (yield self.calendarUnderTest(self.uuid1)).syncToken())
         sync_token_new2 = (yield (yield self.calendarUnderTest(self.uuid2)).syncToken())
@@ -1615,7 +1615,7 @@
         self.assertEqual(obj, None)
 
         self.assertEqual(calverify.results["Fix failures"], 0)
-        self.assertEqual(calverify.results["Fixed Auto-Accepts"], [])
+        self.assertEqual(calverify.results["Auto-Accepts"], [])
 
         sync_token_new1 = (yield (yield self.calendarUnderTest(self.uuid1)).syncToken())
         sync_token_new2 = (yield (yield self.calendarUnderTest(self.uuid2)).syncToken())
@@ -1639,7 +1639,7 @@
         self.assertTrue("Fix add inbox" not in calverify.results)
         self.assertTrue("Fix remove" not in calverify.results)
         self.assertTrue("Fix failures" not in calverify.results)
-        self.assertTrue("Fixed Auto-Accepts" not in calverify.results)
+        self.assertTrue("Auto-Accepts" not in calverify.results)
 
 
 
@@ -1777,7 +1777,7 @@
         self.assertTrue("Fix add inbox" not in calverify.results)
         self.assertTrue("Fix remove" not in calverify.results)
         self.assertTrue("Fix failures" not in calverify.results)
-        self.assertTrue("Fixed Auto-Accepts" not in calverify.results)
+        self.assertTrue("Auto-Accepts" not in calverify.results)
 
         sync_token_new1 = (yield (yield self.calendarUnderTest(self.uuid1)).syncToken())
         sync_token_newl1 = (yield (yield self.calendarUnderTest(self.uuidl1)).syncToken())
@@ -1839,7 +1839,7 @@
         self.assertTrue("Fix remove" not in calverify.results)
 
         self.assertEqual(calverify.results["Fix failures"], 0)
-        testResults = sorted(calverify.results["Fixed Auto-Accepts"], key=lambda x: x["uid"])
+        testResults = sorted(calverify.results["Auto-Accepts"], key=lambda x: x["uid"])
         self.assertEqual(testResults[0]["path"], "/calendars/__uids__/%s/calendar/mismatched_attendee.ics" % self.uuidl1)
         self.assertEqual(testResults[0]["uid"], "MISMATCH_ATTENDEE_ICS")
         self.assertEqual(testResults[0]["start"].getText(), "%s0307T031500" % (now,))
@@ -1866,4 +1866,344 @@
         self.assertTrue("Fix add inbox" not in calverify.results)
         self.assertTrue("Fix remove" not in calverify.results)
         self.assertTrue("Fix failures" not in calverify.results)
-        self.assertTrue("Fixed Auto-Accepts" not in calverify.results)
+        self.assertTrue("Auto-Accepts" not in calverify.results)
+
+
+
+class CalVerifyDoubleBooked(CalVerifyMismatchTestsBase):
+    """
+    Tests calverify for double-bookings.
+    """
+
+    # No overlap
+    INVITE_NO_OVERLAP_ICS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP_ICS
+TRANSP:OPAQUE
+SUMMARY:Ancient event
+DTSTART:%(year)s0307T100000Z
+DURATION:PT1H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {"year": now}
+
+    # Two overlapping
+    INVITE_NO_OVERLAP1_1_ICS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP1_1_ICS
+TRANSP:OPAQUE
+SUMMARY:Ancient event
+DTSTART:%(year)s0307T110000Z
+DURATION:PT2H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {"year": now}
+
+    INVITE_NO_OVERLAP1_2_ICS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP1_2_ICS
+TRANSP:OPAQUE
+SUMMARY:Ancient event
+DTSTART:%(year)s0307T120000Z
+DURATION:PT1H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {"year": now}
+
+    # Two overlapping with one transparent
+    INVITE_NO_OVERLAP2_1_ICS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP2_1_ICS
+TRANSP:OPAQUE
+SUMMARY:Ancient event
+DTSTART:%(year)s0307T140000Z
+DURATION:PT2H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {"year": now}
+
+    INVITE_NO_OVERLAP2_2_ICS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP2_2_ICS
+SUMMARY:Ancient event
+DTSTART:%(year)s0307T150000Z
+DURATION:PT1H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+TRANSP:TRANSPARENT
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {"year": now}
+
+    # Two overlapping with one cancelled
+    INVITE_NO_OVERLAP3_1_ICS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP3_1_ICS
+TRANSP:OPAQUE
+SUMMARY:Ancient event
+DTSTART:%(year)s0307T170000Z
+DURATION:PT2H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {"year": now}
+
+    INVITE_NO_OVERLAP3_2_ICS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP3_2_ICS
+SUMMARY:Ancient event
+DTSTART:%(year)s0307T180000Z
+DURATION:PT1H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+STATUS:CANCELLED
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {"year": now}
+
+    # Two overlapping recurring
+    INVITE_NO_OVERLAP4_1_ICS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP4_1_ICS
+TRANSP:OPAQUE
+SUMMARY:Ancient event
+DTSTART:%(year)s0308T120000Z
+DURATION:PT2H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+RRULE:FREQ=DAILY;COUNT=3
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {"year": now}
+
+    INVITE_NO_OVERLAP4_2_ICS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP4_2_ICS
+SUMMARY:Ancient event
+DTSTART:%(year)s0309T120000Z
+DURATION:PT1H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+RRULE:FREQ=DAILY;COUNT=2
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {"year": now}
+
+    # Two overlapping on one recurrence instance
+    INVITE_NO_OVERLAP5_1_ICS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP5_1_ICS
+TRANSP:OPAQUE
+SUMMARY:Ancient event
+DTSTART:%(year)s0312T120000Z
+DURATION:PT2H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+RRULE:FREQ=DAILY;COUNT=3
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {"year": now}
+
+    INVITE_NO_OVERLAP5_2_ICS = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//iCal 4.0.1//EN
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP5_2_ICS
+SUMMARY:Ancient event
+DTSTART:%(year)s0313T140000Z
+DURATION:PT1H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+RRULE:FREQ=DAILY;COUNT=2
+END:VEVENT
+BEGIN:VEVENT
+CREATED:20100303T181216Z
+UID:INVITE_NO_OVERLAP5_2_ICS
+SUMMARY:Ancient event
+RECURRENCE-ID:%(year)s0314T140000Z
+DTSTART:%(year)s0314T130000Z
+DURATION:PT1H
+DTSTAMP:20100303T181220Z
+SEQUENCE:2
+ORGANIZER:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:D46F3D71-04B7-43C2-A7B6-6F92F92E61D0
+ATTENDEE:urn:uuid:75EA36BE-F71B-40F9-81F9-CF59BF40CA8F
+END:VEVENT
+END:VCALENDAR
+""".replace("\n", "\r\n") % {"year": now}
+
+    requirements = {
+        CalVerifyMismatchTestsBase.uuid1 : {
+            "calendar" : {
+                 "invite1.ics"      : (INVITE_NO_OVERLAP_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite2.ics"      : (INVITE_NO_OVERLAP1_1_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite3.ics"      : (INVITE_NO_OVERLAP1_2_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite4.ics"      : (INVITE_NO_OVERLAP2_1_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite5.ics"      : (INVITE_NO_OVERLAP2_2_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite6.ics"      : (INVITE_NO_OVERLAP3_1_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite7.ics"      : (INVITE_NO_OVERLAP3_2_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite8.ics"      : (INVITE_NO_OVERLAP4_1_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite9.ics"      : (INVITE_NO_OVERLAP4_2_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite10.ics"     : (INVITE_NO_OVERLAP5_1_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite11.ics"     : (INVITE_NO_OVERLAP5_2_ICS, CalVerifyMismatchTestsBase.metadata,),
+           },
+           "inbox" : {},
+        },
+        CalVerifyMismatchTestsBase.uuid2 : {
+            "calendar" : {},
+            "inbox" : {},
+        },
+        CalVerifyMismatchTestsBase.uuid3 : {
+            "calendar" : {},
+            "inbox" : {},
+        },
+        CalVerifyMismatchTestsBase.uuidl1 : {
+            "calendar" : {
+                 "invite1.ics"      : (INVITE_NO_OVERLAP_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite2.ics"      : (INVITE_NO_OVERLAP1_1_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite3.ics"      : (INVITE_NO_OVERLAP1_2_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite4.ics"      : (INVITE_NO_OVERLAP2_1_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite5.ics"      : (INVITE_NO_OVERLAP2_2_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite6.ics"      : (INVITE_NO_OVERLAP3_1_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite7.ics"      : (INVITE_NO_OVERLAP3_2_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite8.ics"      : (INVITE_NO_OVERLAP4_1_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite9.ics"      : (INVITE_NO_OVERLAP4_2_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite10.ics"     : (INVITE_NO_OVERLAP5_1_ICS, CalVerifyMismatchTestsBase.metadata,),
+                 "invite11.ics"     : (INVITE_NO_OVERLAP5_2_ICS, CalVerifyMismatchTestsBase.metadata,),
+           },
+           "inbox" : {},
+        },
+    }
+
+    @inlineCallbacks
+    def test_scanDoubleBookingOnly(self):
+        """
+        CalVerifyService.doScan without fix for mismatches. Make sure it detects
+        as much as it can. Make sure sync-token is not changed.
+        """
+
+        sync_token_old1 = (yield (yield self.calendarUnderTest(self.uuid1)).syncToken())
+        sync_token_oldl1 = (yield (yield self.calendarUnderTest(self.uuidl1)).syncToken())
+        self.commit()
+
+        options = {
+            "ical": False,
+            "badcua": False,
+            "mismatch": False,
+            "nobase64": False,
+            "double": True,
+            "fix": False,
+            "verbose": False,
+            "details": False,
+            "summary": False,
+            "days": 365,
+            "uid": "",
+            "uuid": self.uuidl1,
+            "tzid": "utc",
+            "start": PyCalendarDateTime(now, 1, 1, 0, 0, 0),
+        }
+        output = StringIO()
+        calverify = DoubleBookingService(self._sqlCalendarStore, options, output, reactor, config)
+        yield calverify.doAction()
+
+        self.assertEqual(calverify.results["Number of events to process"], len(self.requirements[CalVerifyMismatchTestsBase.uuidl1]["calendar"]))
+        self.assertEqual(
+            [(i.uid1, i.uid2, str(i.start),) for i in calverify.results["Double-bookings"]],
+            [
+                ("INVITE_NO_OVERLAP1_1_ICS", "INVITE_NO_OVERLAP1_2_ICS", "%(year)s0307T120000Z" % {"year": now}),
+                ("INVITE_NO_OVERLAP4_1_ICS", "INVITE_NO_OVERLAP4_2_ICS", "%(year)s0309T120000Z" % {"year": now}),
+                ("INVITE_NO_OVERLAP4_1_ICS", "INVITE_NO_OVERLAP4_2_ICS", "%(year)s0310T120000Z" % {"year": now}),
+                ("INVITE_NO_OVERLAP5_1_ICS", "INVITE_NO_OVERLAP5_2_ICS", "%(year)s0314T130000Z" % {"year": now}),
+            ],
+        )
+        self.assertEqual(calverify.results["Number of double-bookings"], 4)
+        self.assertEqual(calverify.results["Number of unique double-bookings"], 3)
+
+        sync_token_new1 = (yield (yield self.calendarUnderTest(self.uuid1)).syncToken())
+        sync_token_newl1 = (yield (yield self.calendarUnderTest(self.uuidl1)).syncToken())
+        self.assertEqual(sync_token_old1, sync_token_new1)
+        self.assertEqual(sync_token_oldl1, sync_token_newl1)
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20130221/3b805e1a/attachment-0001.html>


More information about the calendarserver-changes mailing list