Repository: pimutils/khal
Pull request #1271 (2023-06-15 22:38:36)

Better support for status and initial support for partstat

madduck: I can confirm that this works as intended. Will take discussion back to #580 though.
geier: config file now takes several addresses per account, but we expect that only one will match.
geier: @WhyNotHugo sorry for bombing you with review requests, feel free to ignore them.
geier: This PR doesn't cover yet formatting events by their status as it's a bit more difficult to get this looking the same in khal and ikhal. I'd prefer to merge this without that now, because rebasing against master is becoming tiresome (not especially this branch, but keeping several non-merged features branches around is getting tiring).
geier: @WhyNotHugo interesting. We should definitively support that, but probably only as a default. At least one of my caldav servers has an email address set that nobody sends email to.

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index e541488..5ea3a15 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -22,6 +22,13 @@ not released yet
 * NEW Add default alarms configuration option
 * FIX defaults for `default_event_duration` and `default_dayevent_duration`
   where mixed up, `default_dayevent_duration` is the default for all-day events
+* NEW event format option `status-symbol` which represents the status of an
+  event with a symbol (e.g. `✓` for confirmed, `✗` for cancelled, `?` for
+  tentative)
+* NEW event format option `partstat-symbol` which represents the participation
+  status of an event with a symbol (e.g. `✓` for accepted, `✗` for declined,
+  `?` for tentative); partication status is shown for the email addresses
+  configured for the event's calendar
 
 0.11.2
 ======
diff --git a/doc/source/usage.rst b/doc/source/usage.rst
index dfac789..c07ddf3 100644
--- a/doc/source/usage.rst
+++ b/doc/source/usage.rst
@@ -150,6 +150,12 @@ Several options are common to almost all of :program:`khal`'s commands
        The status of the event (if this event has one), something like
        `CONFIRMED` or `CANCELLED`.
 
+    status-symbol
+       The status of the event as a symbol, `✓` or `✗` or `?`.
+
+    partstat-symbol
+        The participation status of the event as a symbol, `✓` or `✗` or `?`.
+
    cancelled
        The string `CANCELLED` (plus one blank) if the event's status is
        cancelled, otherwise nothing.
diff --git a/khal/cli.py b/khal/cli.py
index ec6cbac..baf8c00 100644
--- a/khal/cli.py
+++ b/khal/cli.py
@@ -175,6 +175,7 @@ def build_collection(conf, selection):
                     'color': cal['color'],
                     'priority': cal['priority'],
                     'ctype': cal['type'],
+                    'addresses': cal['addresses'],
                 }
         collection = khalendar.CalendarCollection(
             calendars=props,
diff --git a/khal/custom_types.py b/khal/custom_types.py
index 9700007..328ed36 100644
--- a/khal/custom_types.py
+++ b/khal/custom_types.py
@@ -12,6 +12,7 @@ class CalendarConfiguration(TypedDict):
     color: str
     priority: int
     ctype: str
+    addresses: str
 
 
 class LocaleConfiguration(TypedDict):
diff --git a/khal/khalendar/event.py b/khal/khalendar/event.py
index e1258e2..9f73395 100644
--- a/khal/khalendar/event.py
+++ b/khal/khalendar/event.py
@@ -68,7 +68,8 @@ class Event:
                  color: Optional[str] = None,
                  start: Optional[dt.datetime] = None,
                  end: Optional[dt.datetime] = None,
-                 ) -> None:
+                 addresses: Optional[List[str]] =None,
+                 ):
         """
         :param start: start datetime of this event instance
         :param end: end datetime of this event instance
@@ -87,6 +88,7 @@ class Event:
         self.color = color
         self._start: dt.datetime
         self._end: dt.datetime
+        self.addresses = addresses if addresses else []
 
         if start is None:
             self._start = self._vevents[self.ref]['DTSTART'].dt
@@ -286,7 +288,12 @@ class Event:
                 'range': '\N{Left right arrow}',
                 'range_end': '\N{Rightwards arrow to bar}',
                 'range_start': '\N{Rightwards arrow from bar}',
-                'right_arrow': '\N{Rightwards arrow}'
+                'right_arrow': '\N{Rightwards arrow}',
+                'cancelled': '\N{Cross mark}',
+                'confirmed': '\N{Heavy check mark}',
+                'tentative': '?',
+                'declined': '\N{Cross mark}',
+                'accepted': '\N{Heavy check mark}',
             }
         else:
             return {
@@ -295,7 +302,12 @@ class Event:
                 'range': '<->',
                 'range_end': '->|',
                 'range_start': '|->',
-                'right_arrow': '->'
+                'right_arrow': '->',
+                'cancelled': 'X',
+                'confirmed': 'V',
+                'tentative': '?',
+                'declined': 'X',
+                'accepted': 'V',
             }
 
     @property
@@ -554,6 +566,31 @@ class Event:
             alarmstr = ''
         return alarmstr
 
+    @property
+    def _status_str(self) -> str:
+        if self.status == 'CANCELLED':
+            statusstr = self.symbol_strings['cancelled']
+        elif self.status == 'TENTATIVE':
+            statusstr = self.symbol_strings['tentative']
+        elif self.status == 'CONFIRMED':
+            statusstr = self.symbol_strings['confirmed']
+        else:
+            statusstr = ''
+        return statusstr
+
+    @property
+    def _partstat_str(self) -> str:
+        partstat = self.partstat
+        if partstat == 'ACCEPTED':
+            partstatstr = self.symbol_strings['accepted']
+        elif partstat == 'TENTATIVE':
+            partstatstr = self.symbol_strings['tentative']
+        elif partstat == 'DECLINED':
+            partstatstr = self.symbol_strings['declined']
+        else:
+            partstatstr = ''
+        return partstatstr
+
     def attributes(
             self,
             relative_to: Union[Tuple[dt.date, dt.date], dt.date],
@@ -684,6 +721,8 @@ class Event:
         attributes["repeat-symbol"] = self._recur_str
         attributes["repeat-pattern"] = self.recurpattern
         attributes["alarm-symbol"] = self._alarm_str
+        attributes["status-symbol"] = self._status_str
+        attributes["partstat-symbol"] = self._partstat_str
         attributes["title"] = self.summary
         attributes["organizer"] = self.organizer.strip()
         attributes["description"] = self.description.strip()
@@ -766,6 +805,14 @@ class Event:
     def status(self) -> str:
         return self._vevents[self.ref].get('STATUS', '')
 
+    @property
+    def partstat(self) -> Optional[str]:
+        for attendee in self._vevents[self.ref].get('ATTENDEE', []):
+            for address in self.addresses:
+                if attendee == 'mailto:' + address:
+                    return attendee.params.get('PARTSTAT', '')
+        return None
+
 
 class DatetimeEvent(Event):
     pass
diff --git a/khal/khalendar/khalendar.py b/khal/khalendar/khalendar.py
index fc22f28..21c85ae 100644
--- a/khal/khalendar/khalendar.py
+++ b/khal/khalendar/khalendar.py
@@ -284,6 +284,7 @@ class CalendarCollection:
             ref=ref,
             color=self._calendars[calendar]['color'],
             readonly=self._calendars[calendar]['readonly'],
+            addresses=self._calendars[calendar]['addresses'],
         )
         return event
 
diff --git a/khal/settings/khal.spec b/khal/settings/khal.spec
index dc9ae1a..676d510 100644
--- a/khal/settings/khal.spec
+++ b/khal/settings/khal.spec
@@ -71,6 +71,11 @@ readonly = boolean(default=False)
 # *calendars* subsection will be used.
 type = option('calendar', 'birthdays', 'discover', default='calendar')
 
+# All email addresses associated with this account, separated by commas.
+# For now it is only used to check what participation status ("PARTSTAT")
+# belongs to the user.
+addresses = force_list(default='')
+
 [sqlite]
 # khal stores its internal caching database here, by default this will be in the *$XDG_DATA_HOME/khal/khal.db* (this will most likely be *~/.local/share/khal/khal.db*).
 path = expand_db_path(default=None)


Test patch:
diff --git a/tests/configs/small.conf b/tests/configs/small.conf
index 9981df2..37271e9 100644
--- a/tests/configs/small.conf
+++ b/tests/configs/small.conf
@@ -8,3 +8,4 @@
   [[work]]
     path = ~/.calendars/work/
     readonly = True
+    addresses = user@example.com
diff --git a/tests/conftest.py b/tests/conftest.py
index 10bc52b..07b5f11 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -56,6 +56,7 @@ def coll_vdirs(tmpdir) -> CollVdirType:
             color='dark blue',
             priority=10,
             ctype='calendar',
+            addresses='user@example.com',
         )
         vdirs[name] = Vdir(path, '.ics')
     coll = CalendarCollection(calendars=calendars, dbpath=':memory:', locale=LOCALE_BERLIN)
@@ -71,7 +72,8 @@ def coll_vdirs_birthday(tmpdir):
         os.makedirs(path, mode=0o770)
         readonly = True if name == 'a_calendar' else False
         calendars[name] = {'name': name, 'path': path, 'color': 'dark blue',
-                           'readonly': readonly, 'unicode_symbols': True, 'ctype': 'birthdays'}
+                           'readonly': readonly, 'unicode_symbols': True, 'ctype': 'birthdays',
+                           'addresses': 'user@example.com'}
         vdirs[name] = Vdir(path, '.vcf')
     coll = CalendarCollection(calendars=calendars, dbpath=':memory:', locale=LOCALE_BERLIN)
     coll.default_calendar_name = cal1
diff --git a/tests/event_test.py b/tests/event_test.py
index 6dad949..d20b90e 100644
--- a/tests/event_test.py
+++ b/tests/event_test.py
@@ -326,6 +326,15 @@ def test_event_rd():
     assert event.recurring is True
 
 
+def test_status_confirmed():
+    event = Event.fromString(_get_text('event_dt_status_confirmed'), **EVENT_KWARGS)
+    assert event.status == 'CONFIRMED'
+    FORMAT_CALENDAR = ('{calendar-color}{status-symbol}{start-end-time-style} ({calendar}) '
+                       '{title} [{location}]{repeat-symbol}')
+
+    assert human_formatter(FORMAT_CALENDAR)(event.attributes(dt.date(2014, 4, 9))) == \
+        '✔09:30-10:30 (foobar) An Event []\x1b[0m'
+
 def test_event_d_long():
     event_d_long = _get_text('event_d_long')
     event = Event.fromString(event_d_long, **EVENT_KWARGS)
@@ -686,3 +695,37 @@ def test_parameters_description():
     assert event.description == (
         'Hey, \n\nJust setting aside some dedicated time to talk about redacted.'
     )
+
+def test_partstat():
+    FORMAT_CALENDAR = (
+        '{calendar-color}{partstat-symbol}{status-symbol}{start-end-time-style} ({calendar}) '
+        '{title} [{location}]{repeat-symbol}'
+    )
+
+    event = Event.fromString(
+        _get_text('event_dt_partstat'), addresses=['jdoe@example.com'], **EVENT_KWARGS)
+    assert event.partstat == 'ACCEPTED'
+    assert human_formatter(FORMAT_CALENDAR)(event.attributes(dt.date(2014, 4, 9))) == \
+        '✔09:30-10:30 (foobar) An Event []\x1b[0m'
+
+    event = Event.fromString(
+        _get_text('event_dt_partstat'), addresses=['another@example.com'], **EVENT_KWARGS)
+    assert event.partstat == 'DECLINED'
+    assert human_formatter(FORMAT_CALENDAR)(event.attributes(dt.date(2014, 4, 9))) == \
+        '❌09:30-10:30 (foobar) An Event []\x1b[0m'
+
+    event = Event.fromString(
+        _get_text('event_dt_partstat'), addresses=['jqpublic@example.com'], **EVENT_KWARGS)
+    assert event.partstat == 'ACCEPTED'
+    assert human_formatter(FORMAT_CALENDAR)(event.attributes(dt.date(2014, 4, 9))) == \
+        '✔09:30-10:30 (foobar) An Event []\x1b[0m'
+
+@pytest.mark.xfail
+def test_partstat_deligated():
+    event = Event.fromString(
+        _get_text('event_dt_partstat'), addresses=['hcabot@example.com'], **EVENT_KWARGS)
+    assert event.partstat == 'ACCEPTED'
+
+    event = Event.fromString(
+        _get_text('event_dt_partstat'), addresses=['iamboss@example.com'], **EVENT_KWARGS)
+    assert event.partstat == 'ACCEPTED'
diff --git a/tests/ics/event_dt_partstat.ics b/tests/ics/event_dt_partstat.ics
new file mode 100644
index 0000000..617f1c0
--- /dev/null
+++ b/tests/ics/event_dt_partstat.ics
@@ -0,0 +1,22 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN
+BEGIN:VEVENT
+SUMMARY:An Event
+DTSTART;TZID=Europe/Berlin:20140409T093000
+DTEND;TZID=Europe/Berlin:20140409T103000
+DTSTAMP:20140401T234817Z
+UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU
+ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;DELEGATED-FROM=
+  "mailto:iamboss@example.com";CN=Henry Cabot:mailto:hcabot@
+  example.com
+ATTENDEE;ROLE=NON-PARTICIPANT;PARTSTAT=DELEGATED;DELEGATED-TO=
+  "mailto:hcabot@example.com";CN=The Big Cheese:mailto:iamboss
+  @example.com
+ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Jane Doe
+  :mailto:jdoe@example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@example.com
+ATTENDEE;PARTSTAT=DECLINED:mailto:another@example.com
+ATTENDEE;PARTSTAT=TENTATIVE:mailto:tent@example.com
+END:VEVENT
+END:VCALENDAR
diff --git a/tests/ics/event_dt_status_confirmed.ics b/tests/ics/event_dt_status_confirmed.ics
new file mode 100644
index 0000000..0fddc31
--- /dev/null
+++ b/tests/ics/event_dt_status_confirmed.ics
@@ -0,0 +1,12 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN
+BEGIN:VEVENT
+SUMMARY:An Event
+DTSTART;TZID=Europe/Berlin:20140409T093000
+DTEND;TZID=Europe/Berlin:20140409T103000
+DTSTAMP:20140401T234817Z
+UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU
+STATUS:CONFIRMED
+END:VEVENT
+END:VCALENDAR
diff --git a/tests/settings_test.py b/tests/settings_test.py
index 290f1ae..a1d6799 100644
--- a/tests/settings_test.py
+++ b/tests/settings_test.py
@@ -40,10 +40,14 @@ class TestSettings:
         )
         comp_config = {
             'calendars': {
-                'home': {'path': os.path.expanduser('~/.calendars/home/'),
-                         'readonly': False, 'color': None, 'priority': 10, 'type': 'calendar'},
-                'work': {'path': os.path.expanduser('~/.calendars/work/'),
-                         'readonly': False, 'color': None, 'priority': 10, 'type': 'calendar'},
+                'home': {
+                    'path': os.path.expanduser('~/.calendars/home/'), 'readonly': False,
+                    'color': None, 'priority': 10, 'type': 'calendar', 'addresses': [''],
+                },
+                'work': {
+                    'path': os.path.expanduser('~/.calendars/work/'), 'readonly': False,
+                    'color': None, 'priority': 10, 'type': 'calendar', 'addresses': [''],
+                },
             },
             'sqlite': {'path': os.path.expanduser('~/.local/share/khal/khal.db')},
             'locale': LOCALE_BERLIN,
@@ -81,10 +85,10 @@ class TestSettings:
             'calendars': {
                 'home': {'path': os.path.expanduser('~/.calendars/home/'),
                          'color': 'dark green', 'readonly': False, 'priority': 20,
-                         'type': 'calendar'},
+                         'type': 'calendar', 'addresses': ['']},
                 'work': {'path': os.path.expanduser('~/.calendars/work/'),
                          'readonly': True, 'color': None, 'priority': 10,
-                         'type': 'calendar'}},
+                         'type': 'calendar', 'addresses': ['user@example.com']}},
             'sqlite': {'path': os.path.expanduser('~/.local/share/khal/khal.db')},
             'locale': {
                 'local_timezone': get_localzone(),