Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f4574ee
Add a simple view for mailing list preferences
micsucmed Feb 19, 2025
e33c321
Update mailing lists statesafter subscribing and unsubscribing
micsucmed Jun 30, 2025
bea215c
Improve button alignment
micsucmed Jul 2, 2025
4ebc5f1
Fix submitting bugs
micsucmed Jul 2, 2025
8b88342
Disable buttons after submission
micsucmed Jul 2, 2025
1cf991b
remove unnecessary import
micsucmed Jul 2, 2025
bb33c00
Subscribe or unsubscribe on toggle
micsucmed Jul 2, 2025
50aa120
Sent requests to brevo for single lists
micsucmed Jul 2, 2025
42ce5c8
Improve appearance of list name and toggle
micsucmed Jul 2, 2025
878817c
Solve small bug when retriving lists of a non contact
micsucmed Aug 4, 2025
8fb8b82
Handle errors not expected to fail
micsucmed Sep 24, 2025
44683f6
Remove unused error modal
micsucmed Sep 24, 2025
0041961
Remove unnecessary try catch block
micsucmed Sep 24, 2025
ee1370b
Raise IndicoError on failed API requests to Brevo
micsucmed Sep 24, 2025
a3fd292
Add user loggers
micsucmed Sep 25, 2025
dd6dd15
Change className to styleName
micsucmed Sep 25, 2025
e8b5046
Solve linting
micsucmed Sep 25, 2025
fcbedc8
Add brevo python to dependencies
micsucmed Sep 25, 2025
4b7d377
Fix formating and remove empty return statements
micsucmed Sep 25, 2025
c44745d
Rearrange imports
micsucmed Oct 15, 2025
297e8ca
Remove plugin component registration
micsucmed Oct 16, 2025
ed071cd
Rename list title
micsucmed Oct 16, 2025
e2d15f7
Fix format
micsucmed Oct 16, 2025
07b57e4
Update brevo_python dependency version
micsucmed Oct 16, 2025
d1fd29c
Move get_all_lists to the appropriate RH
micsucmed Oct 16, 2025
a8240e5
Get list name from brevo
micsucmed Oct 16, 2025
628ce65
Disable toggle from frantic clicking
micsucmed Oct 16, 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
11 changes: 10 additions & 1 deletion indico_jacow/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

from indico_jacow.controllers import (RHAbstractsExportCSV, RHAbstractsExportExcel, RHAbstractsStats,
RHContributionsExportCSV, RHContributionsExportExcel, RHCountries,
RHCreateAffiliation, RHDisplayAbstractsStatistics, RHPeerReviewCSVImport)
RHCreateAffiliation, RHDisplayAbstractsStatistics, RHMailingLists,
RHMailingListSubscribe, RHMailingListUnsubscribe, RHPeerReviewCSVImport)


blueprint = IndicoPluginBlueprint('jacow', __name__, url_prefix='/event/<int:event_id>')
Expand All @@ -35,3 +36,11 @@

blueprint.add_url_rule('!/api/jacow/countries', 'countries', RHCountries)
blueprint.add_url_rule('!/api/jacow/affiliation', 'create_affiliation', RHCreateAffiliation, methods=('POST',))


# Mailing preferences
blueprint.add_url_rule('!/users/emails/mailing-lists', 'mailing_lists', RHMailingLists)
blueprint.add_url_rule('!/users/emails/mailing-lists/subscribe', 'mailing_lists_subscribe',
RHMailingListSubscribe, methods=('POST',))
blueprint.add_url_rule('!/users/emails/mailing-lists/unsubscribe', 'mailing_lists_unsubscribe',
RHMailingListUnsubscribe, methods=('POST',))
117 changes: 117 additions & 0 deletions indico_jacow/client/MailingList.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// This file is part of the JACoW plugin.
// Copyright (C) 2021 - 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 getMailingListsURL from 'indico-url:plugin_jacow.mailing_lists';
import mailingListSubscribeURL from 'indico-url:plugin_jacow.mailing_lists_subscribe';
import mailingListUnsubscribeURL from 'indico-url:plugin_jacow.mailing_lists_unsubscribe';

import PropTypes from 'prop-types';
import {useState} from 'react';
import ReactDOM from 'react-dom';
import {ListItem, ListContent, List, Checkbox} from 'semantic-ui-react';

import {Translate} from 'indico/react/i18n';
import {indicoAxios, handleAxiosError} from 'indico/utils/axios';

import './MailingList.module.scss';

export function MailingList({mailingLists}) {
const [lists, setLists] = useState(mailingLists.lists);
const [listsLoadingRequests, setListsLoadingRequests] = useState(new Set());

const subscribeList = async list => {
await indicoAxios.post(mailingListSubscribeURL(), list);
};

const unsubscribeList = async list => {
await indicoAxios.post(mailingListUnsubscribeURL(), list);
};

const handleToggle = async (ev, {value}) => {
if (listsLoadingRequests.has(value)) return;

setListsLoadingRequests(prev => new Set(prev).add(value));

const targetList = lists.find(list => list.id === value);
const newSubscriptionStatus = !targetList.subscribed;

setLists(prevLists =>
prevLists.map(list =>
list.id === value ? { ...list, subscribed: newSubscriptionStatus } : list
)
);

try {
if (newSubscriptionStatus) {
await subscribeList({list_id: value});
} else {
await unsubscribeList({list_id: value});
}
} catch (e) {
handleAxiosError(e);
setLists(prevLists =>
prevLists.map(list =>
list.id === value ? { ...list, subscribed: !newSubscriptionStatus } : list
)
);
} finally {
setListsLoadingRequests(prev => {
const newSet = new Set(prev);
newSet.delete(value);
return newSet;
});
}
};

return (
<div style={{marginTop: '15px'}}>
{/* Subscribed Lists Section */}
<div className="i-box">
<div className="i-box-header">
<div className="i-box-title">
<Translate>Mailing Lists</Translate>
</div>
</div>
<div className="i-box-content">
<List divided relaxed size="big">
{lists.map(list => (
<ListItem styleName="mailing" key={list.id}>
<ListContent>{list.name}</ListContent>
<ListContent>
<Checkbox
toggle
value={list.id}
onChange={handleToggle}
disabled={listsLoadingRequests.has(list.id)}
checked={list.subscribed}
/>
</ListContent>
</ListItem>
))}
</List>
</div>
</div>
</div>
);
}

MailingList.propTypes = {
mailingLists: PropTypes.shape({
lists: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
subscribed: PropTypes.bool.isRequired,
})
).isRequired,
}).isRequired,
};

window.setupMailingList = (elem, subMailingLists) => {
subMailingLists = JSON.parse(subMailingLists);
ReactDOM.render(<MailingList mailingLists={subMailingLists} />, elem);
};
17 changes: 17 additions & 0 deletions indico_jacow/client/MailingList.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// This file is part of the JACoW plugin.
// Copyright (C) 2021 - 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.

:global(.ui.list .item).mailing {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;

&::after {
content: none;
}
}
1 change: 1 addition & 0 deletions indico_jacow/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import {registerPluginComponent, registerPluginObject} from 'indico/utils/plugins';

import {MailingList} from './MailingList';
import MultipleAffiliationsSelector, {
MultipleAffiliationsButton,
customFields,
Expand Down
118 changes: 115 additions & 3 deletions indico_jacow/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@

import csv
import io
import json
from collections import defaultdict
from statistics import mean, pstdev

from flask import jsonify, session
import brevo_python
from brevo_python.rest import ApiException
from flask import jsonify, request, session
from flask_pluginengine import current_plugin
from marshmallow import fields
from sqlalchemy.orm import load_only
from werkzeug.exceptions import Forbidden
from werkzeug.utils import cached_property

from indico.core.db import db
from indico.core.errors import UserValueError
from indico.core.errors import IndicoError, UserValueError
from indico.modules.events.abstracts.controllers.abstract_list import RHManageAbstractsExportActionsBase
from indico.modules.events.abstracts.controllers.base import RHAbstractsBase
from indico.modules.events.abstracts.models.review_ratings import AbstractReviewRating
Expand All @@ -28,7 +32,9 @@
from indico.modules.events.management.controllers import RHManageEventBase
from indico.modules.events.papers.controllers.base import RHManagePapersBase
from indico.modules.events.tracks.models.tracks import Track
from indico.modules.logs.models.entries import LogKind, UserLogRealm
from indico.modules.users import User
from indico.modules.users.controllers import RHUserBase
from indico.modules.users.models.affiliations import Affiliation
from indico.modules.users.schemas import AffiliationSchema
from indico.modules.users.util import search_affiliations
Expand All @@ -42,7 +48,7 @@
from indico.web.flask.util import url_for
from indico.web.rh import RH, RHProtected

from indico_jacow.views import WPAbstractsStats, WPDisplayAbstractsStatistics
from indico_jacow.views import WPAbstractsStats, WPDisplayAbstractsStatistics, WPUserMailingLists


def _get_boolean_questions(event):
Expand Down Expand Up @@ -309,3 +315,109 @@ def _process(self, data):
current_plugin.logger.info('Affiliation %r created by %r', aff, session.user)
search_affiliations.bump_version()
return AffiliationSchema().jsonify(aff)


class BrevoAPIMixin:
@cached_property
def api_instance(self):
if not hasattr(self, '_api_instance'):
from indico_jacow.plugin import JACOWPlugin
configuration_brevo = brevo_python.Configuration()
configuration_brevo.api_key['api-key'] = JACOWPlugin.settings.get('brevo_api_key')
self._api_instance = brevo_python.ContactsApi(brevo_python.ApiClient(configuration_brevo))
return self._api_instance

def get_contact_info(self, email):
try:
return self.api_instance.get_contact_info(email).to_dict()
except ApiException as e:
if e.status == 404:
return None
raise

def create_contact(self, email, first_name, last_name, list_ids):
contact = brevo_python.CreateContact(
email=email,
attributes={'FIRSTNAME': first_name, 'LASTNAME': last_name},
list_ids=list_ids
)
return self.api_instance.create_contact(contact).to_dict()

def get_list(self, list_id):
try:
return self.api_instance.get_list(list_id).to_dict()
except ApiException as e:
raise IndicoError(f'Exception when retrieving Mailing List from Brevo: {e.reason}')


class RHMailingLists(BrevoAPIMixin, RHUserBase):
def _process(self):
valid_contact_ids = set()
emails = self.user.all_emails
lists = self.get_all_lists()

for email in emails:
if (contact_info := self.get_contact_info(email)):
if 'list_ids' in contact_info:
valid_contact_ids.update(contact_info['list_ids'])

for lst in lists.get('lists', []):
lst['subscribed'] = lst['id'] in valid_contact_ids

mailing_lists = json.dumps(lists)
return WPUserMailingLists.render_template('mailing_lists.html', 'mailing_lists', user=self.user,
mailing_lists=mailing_lists)

def get_all_lists(self):
try:
return self.api_instance.get_lists().to_dict()
except ApiException as e:
raise IndicoError(f'Exception when retrieving Mailing Lists from Brevo: {e.reason}')


class RHMailingListSubscribe(BrevoAPIMixin, RHUserBase):
@use_kwargs({
'list_id': fields.Int(required=True, validate=not_empty),
})
def _process(self, list_id):
email = self.user.email
try:
if self.get_contact_info(email):
response = self.add_contact_to_lists(list_id, email)
else:
response = self.create_contact(
email=email,
first_name=self.user.first_name,
last_name=self.user.last_name,
list_ids=[list_id],
)
self.user.log(UserLogRealm.user, LogKind.positive, 'Mailing Lists',
f'Subscribed to list: {self.get_list(list_id)['name']}',
session.user, data={'IP': request.remote_addr},
meta={'list_id': list_id})
return response
except ApiException as e:
raise IndicoError(f'Failed to subscribe to the list and/or create contact: {e.reason}')

def add_contact_to_lists(self, list_id, contact_email):
contact_email = brevo_python.AddContactToList(emails=[contact_email])
response = self.api_instance.add_contact_to_list(list_id, contact_email)
return response.to_dict()


class RHMailingListUnsubscribe(BrevoAPIMixin, RHUserBase):
@use_kwargs({
'list_id': fields.Int(required=True, validate=not_empty),
})
def _process(self, list_id):
contact_emails = brevo_python.RemoveContactFromList(emails=list(self.user.all_emails))

try:
response = self.api_instance.remove_contact_from_list(list_id, contact_emails)
self.user.log(UserLogRealm.user, LogKind.positive, 'Mailing Lists',
f'Unsubscribed from list: {self.get_list(list_id)['name']}',
session.user, data={'IP': request.remote_addr},
meta={'list_id': list_id})
return response.to_dict()
except Exception as e:
raise IndicoError(f'Could not unsubscribe from the list: {e.reason}')
11 changes: 10 additions & 1 deletion indico_jacow/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
from indico.modules.events.persons.forms import ManagePersonListsForm
from indico.modules.events.persons.schemas import PersonLinkSchema
from indico.modules.events.registration.schemas import CheckinRegistrationSchema
from indico.modules.users.views import WPUser
from indico.util.i18n import _
from indico.web.forms.base import IndicoForm
from indico.web.forms.fields import IndicoPasswordField
from indico.web.forms.widgets import SwitchWidget
from indico.web.menu import SideMenuItem

Expand All @@ -38,6 +40,7 @@
class SettingsForm(IndicoForm):
sync_enabled = BooleanField(_('Sync profiles'), widget=SwitchWidget(),
description=_('Periodically sync user details with the central database'))
brevo_api_key = IndicoPasswordField(_('Brevo API key'), toggle=True)


class JACOWPlugin(IndicoPlugin):
Expand All @@ -50,6 +53,7 @@ class JACOWPlugin(IndicoPlugin):
settings_form = SettingsForm
default_settings = {
'sync_enabled': False,
'brevo_api_key': '',
}
default_event_settings = {
'multiple_affiliations': False,
Expand All @@ -73,10 +77,11 @@ def init(self):
self.connect(signals.menu.items, self._add_sidemenu_item, sender='event-management-sidemenu')
self.connect(signals.plugin.schema_pre_load, self._person_link_schema_pre_load, sender=PersonLinkSchema)
self.connect(signals.plugin.schema_post_dump, self._person_link_schema_post_dump, sender=PersonLinkSchema)
self.connect(signals.menu.items, self._extend_user_profile_menu, sender='user-profile-sidemenu')
self.connect(signals.plugin.schema_post_dump, self._checkin_registration_schema_post_dump,
sender=CheckinRegistrationSchema)
wps = (WPContributions, WPDisplayAbstracts, WPManageAbstracts, WPManageContributions,
WPMyContributions, WPManagePapers)
WPMyContributions, WPManagePapers, WPUser)
self.inject_bundle('main.js', wps)
self.inject_bundle('main.css', wps)

Expand Down Expand Up @@ -221,6 +226,10 @@ def _add_sidemenu_item(self, sender, event, **kwargs):
return SideMenuItem('abstracts_stats', _('CfA Statistics'),
url_for_plugin('jacow.abstracts_stats', event), section='reports')

def _extend_user_profile_menu(self, sender, user, **kwargs):
return SideMenuItem('mailing_lists', _('Mailing Lists'),
url_for_plugin('jacow.mailing_lists', user), 65, disabled=user.is_system)

def _person_link_schema_pre_load(self, sender, data, **kwargs):
jacow_affiliations_ids = g.setdefault('jacow_affiliations_ids', {})
jacow_affiliations_ids[data['email'].lower()] = data.get('jacow_affiliations_ids', [])
Expand Down
8 changes: 8 additions & 0 deletions indico_jacow/templates/mailing_lists.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% extends 'users/base.html' %}

{% block user_content %}
<div id="mailing-lists"></div>
<script>
setupMailingList(document.querySelector('#mailing-lists'), {{ mailing_lists | tojson }});
</script>
{% endblock %}
5 changes: 5 additions & 0 deletions indico_jacow/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from indico.core.plugins import WPJinjaMixinPlugin
from indico.modules.events.abstracts.views import WPDisplayAbstracts
from indico.modules.events.management.views import WPEventManagement
from indico.modules.users.views import WPUser


class WPDisplayAbstractsStatistics(WPJinjaMixinPlugin, WPDisplayAbstracts):
Expand All @@ -17,3 +18,7 @@ class WPDisplayAbstractsStatistics(WPJinjaMixinPlugin, WPDisplayAbstracts):

class WPAbstractsStats(WPJinjaMixinPlugin, WPEventManagement):
sidemenu_option = 'abstracts_stats'


class WPUserMailingLists(WPJinjaMixinPlugin, WPUser):
sidemenu_option = 'mailing_lists'
Loading