Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add favorites to Outlook calendar #184

Open
wants to merge 21 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion outlook/indico_outlook/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def _update_calendar_entry(entry, settings):
data.update(update)
# the API expects the field to be named 'body', contrarily to our usage
data['body'] = data.pop('description')
elif entry.action == OutlookAction.remove:
elif entry.action in {OutlookAction.remove, OutlookAction.force_remove}:
method = 'DELETE'
data = None
else:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Add new action

Revision ID: 636d44cb7a7c
Revises: 6093a83228a7
Create Date: 2024-11-15 20:50:19.477580

"""

from alembic import op


# revision identifiers, used by Alembic.
revision = '636d44cb7a7c'
down_revision = '6093a83228a7'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.execute('ALTER TABLE plugin_outlook.queue DROP CONSTRAINT ck_queue_valid_enum_action')
op.execute('ALTER TABLE plugin_outlook.queue ADD CONSTRAINT ck_queue_valid_enum_action CHECK (action IN (1, 2, 3, 4, 5))')


def downgrade() -> None:
op.execute('ALTER TABLE plugin_outlook.queue DROP CONSTRAINT ck_queue_valid_enum_action')
op.execute('ALTER TABLE plugin_outlook.queue ADD CONSTRAINT ck_queue_valid_enum_action CHECK (action IN (1, 2, 3, 4))')
1 change: 1 addition & 0 deletions outlook/indico_outlook/models/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class OutlookAction(IndicoIntEnum):
add = 1
update = 2
remove = 3
force_remove = 4


class OutlookQueueEntry(db.Model):
Expand Down
179 changes: 166 additions & 13 deletions outlook/indico_outlook/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from datetime import timedelta

from flask import g
from sqlalchemy.orm import subqueryload
from wtforms.fields import BooleanField, FloatField, IntegerField, SelectField, URLField
from wtforms.fields.simple import StringField
from wtforms.validators import URL, DataRequired, NumberRange
Expand All @@ -20,6 +21,7 @@
from indico.modules.events import Event
from indico.modules.events.registration.models.registrations import RegistrationState
from indico.modules.users import ExtraUserPreferences
from indico.util.date_time import now_utc
from indico.web.forms.base import IndicoForm
from indico.web.forms.fields import IndicoPasswordField, MultipleItemsField, TimeDeltaField
from indico.web.forms.validators import HiddenUnless
Expand All @@ -28,7 +30,7 @@
from indico_outlook import _
from indico_outlook.calendar import update_calendar
from indico_outlook.models.queue import OutlookAction, OutlookQueueEntry
from indico_outlook.util import get_participating_users, is_event_excluded, latest_actions_only
from indico_outlook.util import get_registered_users, is_event_excluded, latest_actions_only


_status_choices = [('free', _('Free')),
Expand Down Expand Up @@ -62,7 +64,26 @@ class OutlookUserPreferences(ExtraUserPreferences):
'outlook_active': BooleanField(
_('Sync with Outlook'),
widget=SwitchWidget(),
description=_('Add Indico events in which I participate to my Outlook calendar'),
description=_('Add Indico events to my Outlook calendar'),
),
'outlook_registered': BooleanField(
_('Sync event registrations with Outlook'),
[HiddenUnless('extra_outlook_active', preserve_data=True)],
widget=SwitchWidget(),
description=_("Add to my Outlook calendar events I'm registered for"),
),
'outlook_favorite_events': BooleanField(
_('Sync favorite events with Outlook'),
[HiddenUnless('extra_outlook_active', preserve_data=True)],
widget=SwitchWidget(),
description=_('Add to my Outlook calendar events that I mark as favorite'),
),
'outlook_favorite_categories': BooleanField(
_('Sync favorite categories with Outlook'),
[HiddenUnless('extra_outlook_active', preserve_data=True)],
widget=SwitchWidget(),
description=_('Add to my Outlook calendar all events in categories (and their subcategories) '
'I mark as favorite'),
),
'outlook_status': SelectField(
_('Outlook entry status'),
Expand Down Expand Up @@ -112,6 +133,9 @@ def load(self):
default_reminder_minutes = OutlookPlugin.settings.get('reminder_minutes')
return {
'outlook_active': OutlookPlugin.user_settings.get(self.user, 'enabled'),
'outlook_registered': OutlookPlugin.user_settings.get(self.user, 'registered'),
'outlook_favorite_events': OutlookPlugin.user_settings.get(self.user, 'favorite_events'),
'outlook_favorite_categories': OutlookPlugin.user_settings.get(self.user, 'favorite_categories'),
'outlook_status': OutlookPlugin.user_settings.get(self.user, 'status', default_status),
'outlook_reminder': OutlookPlugin.user_settings.get(self.user, 'reminder', default_reminder),
'outlook_reminder_minutes': OutlookPlugin.user_settings.get(self.user,
Expand All @@ -122,6 +146,9 @@ def load(self):
def save(self, data):
OutlookPlugin.user_settings.set_multi(self.user, {
'enabled': data['outlook_active'],
'registered': data['outlook_registered'],
'favorite_events': data['outlook_favorite_events'],
'favorite_categories': data['outlook_favorite_categories'],
'status': data['outlook_status'],
'reminder': data['outlook_reminder'],
'reminder_minutes': data['outlook_reminder_minutes'],
Expand Down Expand Up @@ -151,7 +178,10 @@ class OutlookPlugin(IndicoPlugin):
'max_event_duration': TimedeltaConverter
}
default_user_settings = {
'enabled': True, # XXX: if the default value ever changes, adapt `get_participating_users`!
'enabled': True, # XXX: if the default value ever changes, adapt `get_registered_users`!
'registered': True,
'favorite_events': True,
'favorite_categories': True,
'status': None,
'reminder': True,
'reminder_minutes': 15,
Expand All @@ -167,9 +197,15 @@ def init(self):
self.connect(signals.event.registration.registration_deleted, self.event_registration_deleted)
self.connect(signals.event.updated, self.event_updated)
self.connect(signals.event.times_changed, self.event_times_changed, sender=Event)
self.connect(signals.event.created, self.event_created)
self.connect(signals.event.restored, self.event_created)
self.connect(signals.event.deleted, self.event_deleted)
self.connect(signals.core.after_process, self._apply_changes)
self.connect(signals.users.merged, self._merge_users)
self.connect(signals.users.favorite_event_added, self.favorite_event_added)
self.connect(signals.users.favorite_event_removed, self.favorite_event_removed)
self.connect(signals.users.favorite_category_added, self.favorite_category_added)
self.connect(signals.users.favorite_category_removed, self.favorite_category_removed)

def _extend_indico_cli(self, sender, **kwargs):
@cli_command()
Expand All @@ -181,8 +217,66 @@ def outlook():
def extend_user_preferences(self, user, **kwargs):
return OutlookUserPreferences

def _user_tracks_registered_events(self, user):
return OutlookPlugin.user_settings.get(user, 'registered',
OutlookPlugin.default_user_settings['registered'])

def _user_tracks_favorite_events(self, user):
return OutlookPlugin.user_settings.get(user, 'favorite_events',
OutlookPlugin.default_user_settings['favorite_events'])

def _user_tracks_favorite_categories(self, user):
return OutlookPlugin.user_settings.get(user, 'favorite_categories',
OutlookPlugin.default_user_settings['favorite_categories'])

def favorite_event_added(self, user, event, **kwargs):
if not self._user_tracks_favorite_events(user):
return
self._record_change(event, user, OutlookAction.add)
self.logger.info('Favorite event added: updating %s in %r', user, event)

def favorite_event_removed(self, user, event, **kwargs):
if not self._user_tracks_favorite_events(user):
return
self._record_change(event, user, OutlookAction.remove)
self.logger.info('Favorite event removed: updating %s in %r', user, event)

def favorite_category_added(self, user, category, **kwargs):
if not self._user_tracks_favorite_categories(user):
return

query = (Event.query
.filter(Event.is_visible_in(category.id),
Event.start_dt > now_utc(),
~Event.is_deleted)
.options(subqueryload('acl_entries'))
.order_by(Event.start_dt, Event.id))
events = [e for e in query if e.can_access(user)]
for event in events:
self._record_change(event, user, OutlookAction.add)
self.logger.info('Favorite category added: user %s added event %r', user, event)

self.logger.info('Favorite category added: updating %s in %r', user, category)

def favorite_category_removed(self, user, category, **kwargs):
if not self._user_tracks_favorite_categories(user):
return

query = (Event.query
.filter(Event.is_visible_in(category.id),
Event.start_dt > now_utc(),
~Event.is_deleted)
.options(subqueryload('acl_entries'))
.order_by(Event.start_dt, Event.id))
events = [e for e in query if e.can_access(user)]
for event in events:
self._record_change(event, user, OutlookAction.remove)
self.logger.info('Favorite category added: user %s added event %r', user, event)

self.logger.info('Favorite category removed: updating %s in %r', user, category)

def event_registration_state_changed(self, registration, **kwargs):
if not registration.user:
if not registration.user or not self._user_tracks_registered_events(registration.user):
return
if registration.state == RegistrationState.complete:
event = registration.registration_form.event
Expand All @@ -194,7 +288,7 @@ def event_registration_state_changed(self, registration, **kwargs):
self.logger.info('Registration withdrawn: removing %s in %r', registration.user, event)

def event_registration_deleted(self, registration, **kwargs):
if registration.user:
if registration.user and self._user_tracks_registered_events(registration.user):
event = registration.registration_form.event
self._record_change(event, registration.user, OutlookAction.remove)
self.logger.info('Registration removed: removing %s in %r', registration.user, event)
Expand All @@ -203,34 +297,93 @@ def event_registration_form_deleted(self, registration_form, **kwargs):
"""In this case we will emit "remove" actions for all participants in `registration_form`"""
event = registration_form.event
for registration in registration_form.active_registrations:
if not registration.user:
if not (registration.user and self._user_tracks_registered_events(registration.user)):
continue
self._record_change(event, registration.user, OutlookAction.remove)
self.logger.info('Registration removed (form deleted): removing %s in %s', registration.user, event)

def event_updated(self, event, changes, **kwargs):
if not changes.keys() & {'title', 'description', 'location_data'}:
if not changes.keys() & {'title', 'description', 'location_data', 'start_dt', 'end_dt', 'duration'}:
return
for user in get_participating_users(event):

users_to_update = set()
# Registered users need to be informed about changes
for user in get_registered_users(event):
if self._user_tracks_registered_events(user):
users_to_update.add(user)

# Users that have marked the event as favorite too
for user in event.favorite_of:
if self._user_tracks_favorite_events(user):
users_to_update.add(user)

for category in reversed(event.category.chain_query.all()):
for user in category.favorite_of:
if self._user_tracks_favorite_categories(user) and event.can_access(user):
users_to_update.add(user)
# Stop once we reach the visibility horizon of the event
if category is event.category.real_visibility_horizon:
break

for user in users_to_update:
self.logger.info('Event data change: updating %s in %r', user, event)
self._record_change(event, user, OutlookAction.update)

def event_times_changed(self, sender, obj, **kwargs):
event = obj
for user in get_participating_users(event):
self.logger.info('Event time change: updating %s in %r', user, event)
self._record_change(event, user, OutlookAction.update)
changes = kwargs['changes']
del kwargs['changes']

self.logger.info('Event time change: updating %r: %r', event, changes)
self.event_updated(event, changes, **kwargs)

def event_created(self, event, **kwargs):
self.logger.info('Event created: %r', event)
# Piggyback on the event_updated handler to avoid duplicating the logic
self.event_updated(event, {'title': event.title}, **kwargs)

def event_deleted(self, event, **kwargs):
for user in get_participating_users(event):
users_to_update = set()
for user in get_registered_users(event):
if self._user_tracks_registered_events(user):
users_to_update.add(user)
for user in event.favorite_of:
if self._user_tracks_favorite_events(user):
users_to_update.add(user)
for category in reversed(event.category.chain_query.all()):
for user in category.favorite_of:
if self._user_tracks_favorite_categories(user) and event.can_access(user):
users_to_update.add(user)
# Stop once we reach the visibility horizon of the event
if category is event.category.real_visibility_horizon:
break

for user in users_to_update:
self.logger.info('Event deletion: removing %s in %r', user, event)
self._record_change(event, user, OutlookAction.remove)
self._record_change(event, user, OutlookAction.force_remove)

def _record_change(self, event, user, action):
if is_event_excluded(event):
return
if 'outlook_changes' not in g:
g.outlook_changes = []

if action == OutlookAction.remove:
# Only remove an event if the user *really* shouldn't have it in their calendar

if user in get_registered_users(event) and self._user_tracks_registered_events(user):
return
if user in event.favorite_of and self._user_tracks_favorite_events(user):
return
for category in reversed(event.category.chain_query.all()):
if user in category.favorite_of \
and self._user_tracks_favorite_categories(user) \
and event.can_access(user):
return
# Stop once we reach the visibility horizon of the event
if category is event.category.real_visibility_horizon:
break

g.outlook_changes.append((event, user, action))

def _apply_changes(self, sender, **kwargs):
Expand Down
2 changes: 1 addition & 1 deletion outlook/indico_outlook/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def is_event_excluded(event):
return event.duration > OutlookPlugin.settings.get('max_event_duration') or event.end_dt <= now_utc()


def get_participating_users(event):
def get_registered_users(event):
"""Return participating users of an event who did not disable calendar updates."""
registrations = (Registration.query
.filter(Registration.is_active,
Expand Down