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 Global Redirect plugin #179

Merged
merged 31 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8ce93c6
Global: Initial commit
ThiefMaster Aug 9, 2024
89963ab
Add ID mapping functionality
ThiefMaster Oct 18, 2024
2ec2b88
Add read-only mode
ThiefMaster Oct 23, 2024
1b15e90
Add email notifications
ThiefMaster Nov 22, 2024
250260f
Add global plugin to CI build choices
ThiefMaster Dec 5, 2024
a2f92d3
Update email notification text
ThiefMaster Dec 9, 2024
d68df63
Add event manager email notifications
ThiefMaster Dec 9, 2024
d27ce7f
Remove mention of post-migration email
ThiefMaster Dec 9, 2024
f1881f9
Fix accessing unlisted events
ThiefMaster Dec 9, 2024
98cbf96
Enforce read-only logic during creation/moving
ThiefMaster Dec 9, 2024
937f9fa
Rename plugin from global to global_redirect
ThiefMaster Dec 10, 2024
d6808f7
Update hatchling version
ThiefMaster Dec 10, 2024
575bc5b
Spam more event organizers
ThiefMaster Dec 11, 2024
7848988
Fix typo
ThiefMaster Dec 16, 2024
710d4f1
Set migration date
ThiefMaster Dec 16, 2024
f0eb27a
Prevent accidentally re-sending notifications
ThiefMaster Dec 16, 2024
dc92622
Record notifications in event/category log
ThiefMaster Dec 16, 2024
0a52d9d
Fix setting reply-to address
ThiefMaster Dec 18, 2024
19e8ddc
Use nicer From sender name
ThiefMaster Dec 18, 2024
5a32156
Happy new year 2025 :fireworks:
Jan 1, 2025
877279b
Fix typo
ThiefMaster Jan 13, 2025
c72fdde
Fix repr
ThiefMaster Jan 13, 2025
ad53736
Add email notification for events using Zoom
ThiefMaster Jan 15, 2025
5f6f46f
Add CLI to delete categories+events
ThiefMaster Jan 17, 2025
1f5f187
Make redirect type configurable
ThiefMaster Jan 17, 2025
80ee7a4
Fix deletion of migrated content
ThiefMaster Jan 19, 2025
f8ccc08
Fix redirect for paper file downloads
ThiefMaster Jan 19, 2025
058c03c
Fix handling URLs with invalid IDs
ThiefMaster Jan 19, 2025
ce4983f
Add image_id to id arg map
ThiefMaster Jan 20, 2025
443bbf9
Add command to un-migrate an event
ThiefMaster Jan 20, 2025
325520e
Clear memoized mappings after event un-migration
ThiefMaster Jan 20, 2025
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
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ on:
- conversion
- cronjobs_cern
- foundationsync
- global_redirect
- i18n_demo
- labotel
- outlook
Expand Down
Empty file.
11 changes: 11 additions & 0 deletions global_redirect/indico_global_redirect/blueprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# This file is part of the CERN Indico plugins.
# Copyright (C) 2014 - 2025 CERN
#
# The CERN Indico plugins are free software; you can redistribute
# them and/or modify them under the terms of the MIT License; see
# the LICENSE file for more details.

from indico.core.plugins import IndicoPluginBlueprint


blueprint = IndicoPluginBlueprint('global_redirect', __name__)
278 changes: 278 additions & 0 deletions global_redirect/indico_global_redirect/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
# This file is part of the CERN Indico plugins.
# Copyright (C) 2014 - 2025 CERN
#
# The CERN Indico plugins are free software; you can redistribute
# them and/or modify them under the terms of the MIT License; see
# the LICENSE file for more details.

import sys
import weakref
from collections import defaultdict
from datetime import datetime
from operator import attrgetter, itemgetter

import click
import yaml
from sqlalchemy.orm import subqueryload, undefer

from indico.cli.core import cli_group
from indico.core import signals
from indico.core.config import config
from indico.core.db import db
from indico.core.db.sqlalchemy.principals import PrincipalType
from indico.core.notifications import make_email, send_email
from indico.core.plugins import get_plugin_template_module
from indico.core.settings import SettingsProxyBase
from indico.modules.categories import Category, CategoryLogRealm
from indico.modules.categories.operations import delete_category
from indico.modules.events import Event, EventLogRealm
from indico.modules.logs import LogKind
from indico.util.console import verbose_iterator

from indico_global_redirect.models.id_map import GlobalIdMap


@cli_group(name='global')
def cli():
"""Manage the Global Redirect plugin."""


@cli.command()
@click.argument('mapping_file', type=click.File())
def load_mapping(mapping_file):
"""Import the ID mapping from YAML."""
if GlobalIdMap.query.has_rows():
click.secho('Mapping table is not empty', fg='yellow')
if not click.confirm('Continue anyway?'):
sys.exit(1)

click.echo('Loading mapping data (this may take a while)...')
mapping = yaml.safe_load(mapping_file)
for col, data in mapping.items():
click.echo(f'Processing {col}...')
for local_id, global_id in verbose_iterator(data.items(), len(data), get_id=itemgetter(0)):
GlobalIdMap.create(col, local_id, global_id)

click.echo('Import finished, committing data...')
db.session.commit()


@cli.command()
@click.argument('event_id', type=int)
@click.argument('category_id', type=int)
def demigrate_event(event_id, category_id):
"""Revert migration of an event.

This moves the event to a new category outside Global Indico and undeletes it.
"""
from indico_global_redirect.plugin import GlobalRedirectPlugin

global_cat = Category.get(GlobalRedirectPlugin.settings.get('global_category_id'))
event = Event.get(event_id)
if event is None:
click.secho('This event does not exist', fg='red')
sys.exit(1)
elif not event.is_deleted:
click.secho('This event is not deleted', fg='yellow')
sys.exit(1)
elif global_cat.id not in event.category.chain_ids:
click.secho('This event is not in Global Indico', fg='red')
sys.exit(1)

col = f'{Event.__table__.fullname}.{Event.id.name}'
mapping = GlobalIdMap.query.filter_by(col=col, local_id=event.id).one_or_none()
if mapping is None:
click.secho('This event has no Global Indico mapping', fg='red')
sys.exit(1)

target_category = Category.get(category_id, is_deleted=False)
if target_category is None:
click.secho('This category does not exist', fg='red')
sys.exit(1)
elif global_cat.id in target_category.chain_ids:
click.secho('This category is in Global Indico', fg='red')
sys.exit(1)

db.session.delete(mapping)
event.move(target_category)
event.restore('Reverted Global Indico migration')
GlobalRedirectPlugin.settings.set('mapping_cache_version',
GlobalRedirectPlugin.settings.get('mapping_cache_version') + 1)
signals.core.after_process.send()
db.session.commit()
click.secho(f'Event restored: "{event.title}"', fg='green')


@cli.command()
def delete_migrated():
"""Mark migrated events + categories as deleted."""
from indico_global_redirect.plugin import GlobalRedirectPlugin

remove_handler_modules = {
'indico.modules.rb', # cancels physical room bookings
'indico.modules.vc', # deletes zoom meetings
'indico_outlook.plugin', # removes event from people's CERN calendars
'indico_cern_access.plugin', # revokes CERN visitor cards
}
for rcv in list(signals.event.deleted.receivers.values()):
if isinstance(rcv, weakref.ref):
rcv = rcv()
if rcv.__module__ in remove_handler_modules:
signals.event.deleted.disconnect(rcv)

events = (
Event.query.join(GlobalIdMap, db.and_(GlobalIdMap.local_id == Event.id,
GlobalIdMap.col == 'events.events.id'))
.filter(~Event.is_deleted)
.all()
)
for event in verbose_iterator(events, len(events), get_id=attrgetter('id'), get_title=attrgetter('title')):
event.delete('Migrated to Indico Global')

global_cat_id = GlobalRedirectPlugin.settings.get('global_category_id')
categories = (
Category.query.join(GlobalIdMap, db.and_(GlobalIdMap.local_id == Category.id,
GlobalIdMap.col == 'categories.categories.id'))
.filter(~Category.is_deleted, Category.id != global_cat_id)
.all()
)
for cat in verbose_iterator(categories, len(categories), get_id=attrgetter('id'), get_title=attrgetter('title')):
delete_category(cat)

# make sure livesync picks up the event deletions
signals.core.after_process.send()
db.session.commit()


@cli.command()
def notify_category_managers():
"""Notify category managers about upcoming migration."""
from indico_global_redirect.plugin import GlobalRedirectPlugin

if not GlobalRedirectPlugin.settings.get('allow_cat_notifications'):
click.echo('Category notifications are disabled (maybe already sent?)')
return

SettingsProxyBase.allow_cache_outside_request = True # avoid re-querying site_title for every email
global_cat = Category.get(GlobalRedirectPlugin.settings.get('global_category_id'))
query = (global_cat.deep_children_query
.filter(~Category.is_deleted, Category.acl_entries.any())
.options(subqueryload(Category.acl_entries), undefer('chain_titles')))
managers = defaultdict(set)
managers_by_category = defaultdict(set)
for cat in query:
if not (cat_managers := {x.user for x in cat.acl_entries if x.full_access and x.type == PrincipalType.user}):
continue
for user in cat_managers:
managers[user].add(cat)
managers_by_category[cat].add(user)

for user, cats in managers.items():
group_acls = {
x.multipass_group_name
for cat in cats
for x in cat.acl_entries if x.type == PrincipalType.multipass_group
}
tpl = get_plugin_template_module('emails/cat_notification.txt', name=user.first_name, categories=cats,
group_acls=group_acls)
send_email(make_email(to_list={user.email}, template=tpl,
sender_address=f'Indico Team <{config.NO_REPLY_EMAIL}>',
reply_address='indico-team@cern.ch'))

for cat, users in managers_by_category.items():
cat.log(CategoryLogRealm.category, LogKind.other, 'Indico Global', 'Sent migration notifications',
data={'Recipient IDs': ', '.join(map(str, sorted(u.id for u in users))),
'Recipients': ', '.join(sorted(u.full_name for u in users))})

GlobalRedirectPlugin.settings.set('allow_cat_notifications', False)
db.session.commit()


@cli.command()
def notify_event_managers():
"""Notify event managers about upcoming migration."""
from indico_global_redirect.plugin import GlobalRedirectPlugin

if not GlobalRedirectPlugin.settings.get('allow_event_notifications'):
click.echo('Event notifications are disabled (maybe already sent?)')
return

SettingsProxyBase.allow_cache_outside_request = True # avoid re-querying site_title for every email
global_cat = Category.get(GlobalRedirectPlugin.settings.get('global_category_id'))
query = (Event.query
.filter(Event.category_chain_overlaps(global_cat.id),
~Event.is_deleted,
Event.acl_entries.any(),
Event.end_dt >= datetime(2024, 1, 1))
.options(subqueryload(Event.acl_entries)))
managers = defaultdict(set)
managers_by_event = defaultdict(set)
for event in query:
if not (evt_managers := {x.user for x in event.acl_entries if x.full_access and x.type == PrincipalType.user}):
continue
for user in evt_managers:
managers[user].add(event)
managers_by_event[event].add(user)

for user, events in managers.items():
group_acls = {
x.multipass_group_name
for evt in events
for x in evt.acl_entries if x.type == PrincipalType.multipass_group
}

tpl = get_plugin_template_module('emails/event_notification.txt', name=user.first_name, events=events,
group_acls=group_acls)
send_email(make_email(to_list={user.email}, template=tpl,
sender_address=f'Indico Team <{config.NO_REPLY_EMAIL}>',
reply_address='indico-team@cern.ch'))

for event, users in managers_by_event.items():
event.log(EventLogRealm.event, LogKind.other, 'Indico Global', 'Sent migration notifications',
data={'Recipient IDs': ', '.join(map(str, sorted(u.id for u in users))),
'Recipients': ', '.join(sorted(u.full_name for u in users))})

GlobalRedirectPlugin.settings.set('allow_event_notifications', False)
db.session.commit()


@cli.command()
def notify_event_managers_zoom():
"""Notify event managers w/ Zoom meetings about upcoming migration."""
from indico_global_redirect.plugin import GlobalRedirectPlugin

if not GlobalRedirectPlugin.settings.get('allow_event_notifications_zoom'):
click.echo('Zoom event notifications are disabled (maybe already sent?)')
return

SettingsProxyBase.allow_cache_outside_request = True # avoid re-querying site_title for every email
global_cat = Category.get(GlobalRedirectPlugin.settings.get('global_category_id'))
query = (Event.query
.filter(Event.category_chain_overlaps(global_cat.id),
~Event.is_deleted,
Event.acl_entries.any(),
Event.end_dt >= datetime(2025, 1, 18),
Event.vc_room_associations.any())
.options(subqueryload(Event.acl_entries)))
managers = defaultdict(set)
managers_by_event = defaultdict(set)
for event in query:
if not (evt_managers := {x.user for x in event.acl_entries if x.full_access and x.type == PrincipalType.user}):
continue
for user in evt_managers:
managers[user].add(event)
managers_by_event[event].add(user)

for user, events in managers.items():
tpl = get_plugin_template_module('emails/event_notification_zoom.txt', name=user.first_name, events=events)
send_email(make_email(to_list={user.email}, template=tpl,
sender_address=f'Indico Team <{config.NO_REPLY_EMAIL}>',
reply_address='indico-team@cern.ch'))

for event, users in managers_by_event.items():
event.log(EventLogRealm.event, LogKind.other, 'Indico Global', 'Sent migration notifications (Zoom)',
data={'Recipient IDs': ', '.join(map(str, sorted(u.id for u in users))),
'Recipients': ', '.join(sorted(u.full_name for u in users))})

GlobalRedirectPlugin.settings.set('allow_event_notifications_zoom', False)
db.session.commit()
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Add mapping table

Revision ID: 69b478f8e2ca
Revises:
Create Date: 2024-10-18 12:22:53.308233
"""

import sqlalchemy as sa
from alembic import op
from sqlalchemy.sql.ddl import CreateSchema, DropSchema


# revision identifiers, used by Alembic.
revision = '69b478f8e2ca'
down_revision = None
branch_labels = None
depends_on = None


def upgrade():
op.execute(CreateSchema('plugin_global_redirect'))
op.create_table(
'id_map',
sa.Column('col', sa.String(), primary_key=True),
sa.Column('local_id', sa.Integer(), primary_key=True),
sa.Column('global_id', sa.Integer(), nullable=False),
schema='plugin_global_redirect',
)


def downgrade():
op.drop_table('id_map', schema='plugin_global_redirect')
op.execute(DropSchema('plugin_global_redirect'))
Empty file.
34 changes: 34 additions & 0 deletions global_redirect/indico_global_redirect/models/id_map.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# This file is part of the CERN Indico plugins.
# Copyright (C) 2014 - 2025 CERN
#
# The CERN Indico plugins are free software; you can redistribute
# them and/or modify them under the terms of the MIT License; see
# the LICENSE file for more details.

import functools

from indico.core.db.sqlalchemy import db
from indico.util.string import format_repr


class GlobalIdMap(db.Model):
__tablename__ = 'id_map'
__table_args__ = {'schema': 'plugin_global_redirect'}

col = db.Column(db.String, primary_key=True)
local_id = db.Column(db.Integer, primary_key=True)
global_id = db.Column(db.Integer, nullable=False)

def __repr__(self):
return format_repr(self, 'local_id', 'col', _repr=self.global_id)

@classmethod
@functools.cache
def get_global_id(cls, col: str, local_id: int) -> int:
"""Get the Indico Global ID for a given col and id."""
return db.session.query(cls.global_id).filter_by(col=col, local_id=local_id).scalar()

@classmethod
def create(cls, col: str, local_id: int, global_id: int) -> None:
"""Create a new mapping."""
db.session.add(cls(col=col, local_id=local_id, global_id=global_id))
Loading
Loading