Skip to content

Commit 45882e8

Browse files
duartegalvaotomako
authored andcommitted
PROVISIONAL Affiliation-extras plugin: add Affiliation Catalogs (indico#6)
7232300 Add missing i18n e8df8e6 Fix visual bug where group/tag dropdowns get clipped by modal fa10014 Refactor scoped RHs to match with Indico patterns e12fbe7 Fix country API being admin-only b5bf3bd Bump plugin version 6791494 Handle tagged groups in affiliation search 91db545 Refactor affiliation search into FinalForm 7e59835 Code improvements 1e0deb2 Apply review feedback to the Affiliation Catalogs plugin (indico#3) d851273 Remove unnecessary i18n 4ba229d Fix param name mismatch 7707ed5 Review fixes for the Affiliation Catalogs plugin (indico#2) 13131b5 Improve log format 6b1af16 Run ruff format ea9b01b Add reglist columns for representation affiliation and type 18fecc7 Allow inviting affiliations (indico#1) 73173db Cleanup code 7104de0 Add representation field 7f65c91 Rename category controllers to catalogs f94ff54 Implement catalog editor for events a8ac9c9 Rename presets to catalogs fcd718b Allow setting a default preset e659518 Allow cloning presets 5c962aa Show origin group and tags on affiliations list 4671831 Add affiliation presets 886f9e9 Use UploadFileMixin for picture attachments
1 parent 32f3b52 commit 45882e8

58 files changed

Lines changed: 5721 additions & 137 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

affiliation_extras/conftest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# This file is part of the third-party Indico plugins.
2+
# Copyright (C) 2026 CERN
3+
#
4+
# The third-party Indico plugins are free software; you can
5+
# redistribute them and/or modify them under the terms of the;
6+
# MIT License see the LICENSE file for more details.
7+
8+
import pytest
9+
10+
from indico.core.plugins import IndicoPlugin
11+
12+
13+
@pytest.fixture(autouse=True)
14+
def _plugin_manifest(mocker):
15+
"""Mock the plugin's webpack manifest.
16+
17+
Plugin assets are not built when running tests, so ``IndicoPlugin.manifest``
18+
returns ``None`` and any page that injects a plugin bundle raises a
19+
``RuntimeError``. Indico core mocks its own manifest the same way in
20+
``make_test_client``; we do the same for plugin bundles here.
21+
"""
22+
mocker.patch.object(IndicoPlugin, 'manifest')

affiliation_extras/indico_affiliation_extras/blueprint.py

Lines changed: 207 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
# MIT License see the LICENSE file for more details.
77

88
from indico.core.plugins import IndicoPluginBlueprint
9+
from indico.util.caching import memoize
10+
from indico.web.flask.util import make_view_func
911

10-
from indico_affiliation_extras.controllers import (
12+
from indico_affiliation_extras.controllers.admin import (
1113
RHAffiliationGroup,
1214
RHAffiliationGroups,
1315
RHAffiliationTag,
@@ -18,35 +20,229 @@
1820
RHEmailRepresentativesPreview,
1921
RHEmailRepresentativesSend,
2022
)
23+
from indico_affiliation_extras.controllers.catalogs import (
24+
RHAffiliationCatalogCountries,
25+
RHAffiliationCatalogGroups,
26+
RHAffiliationCatalogSearch,
27+
RHAffiliationCatalogTags,
28+
RHCloneAffiliationCatalog,
29+
RHCreateAffiliationCatalog,
30+
RHDeleteAffiliationCatalog,
31+
RHEditAffiliationCatalog,
32+
RHManageCategoryAffiliations,
33+
RHManageEventAffiliations,
34+
RHResolveAffiliations,
35+
RHToggleDefaultCatalog,
36+
)
37+
from indico_affiliation_extras.controllers.regform import (
38+
RHAffiliationUserCount,
39+
RHAffiliationUserCountByIds,
40+
RHInviteByAffiliation,
41+
RHManageSearchRepresentationAffiliation,
42+
RHRegFormAffiliationCountries,
43+
RHRegFormAffiliationGroups,
44+
RHRegFormAffiliations,
45+
RHRegFormAffiliationTags,
46+
RHRegFormSearchAffiliationsExtended,
47+
RHSearchRepresentationAffiliation,
48+
)
49+
50+
51+
blueprint = IndicoPluginBlueprint('affiliation_extras', __name__)
52+
53+
_admin_prefix = '/admin/plugins/affiliation_extras'
54+
2155

56+
@memoize
57+
def _dispatch(event_rh, category_rh):
58+
event_view = make_view_func(event_rh)
59+
category_view = make_view_func(category_rh)
60+
61+
def view_func(**kwargs):
62+
return category_view(**kwargs) if kwargs['object_type'] == 'category' else event_view(**kwargs)
63+
64+
return view_func
2265

23-
blueprint = IndicoPluginBlueprint('affiliation_extras', __name__, url_prefix='/api/admin/plugins/affiliation_extras')
2466

2567
blueprint.add_url_rule(
26-
'/representatives/email/metadata',
68+
f'{_admin_prefix}/representatives/email/metadata',
2769
'email_representatives_metadata',
2870
RHEmailRepresentativesMetadata,
2971
methods=('POST',),
3072
)
3173
blueprint.add_url_rule(
32-
'/representatives/email/preview', 'email_representatives_preview', RHEmailRepresentativesPreview, methods=('POST',)
74+
f'{_admin_prefix}/representatives/email/preview',
75+
'email_representatives_preview',
76+
RHEmailRepresentativesPreview,
77+
methods=('POST',),
3378
)
3479
blueprint.add_url_rule(
35-
'/representatives/email/send', 'email_representatives_send', RHEmailRepresentativesSend, methods=('POST',)
80+
f'{_admin_prefix}/representatives/email/send',
81+
'email_representatives_send',
82+
RHEmailRepresentativesSend,
83+
methods=('POST',),
3684
)
3785
blueprint.add_url_rule(
38-
'/representatives/email/image',
86+
f'{_admin_prefix}/representatives/email/image',
3987
'email_representatives_image_upload',
4088
RHEmailRepresentativesImageUpload,
4189
methods=('POST',),
4290
)
4391

44-
blueprint.add_url_rule('/groups', 'api_affiliation_groups', RHAffiliationGroups, methods=('GET', 'POST'))
4592
blueprint.add_url_rule(
46-
'/groups/<int:group_id>', 'api_affiliation_group', RHAffiliationGroup, methods=('GET', 'PATCH', 'DELETE')
93+
f'{_admin_prefix}/groups', 'api_affiliation_groups', RHAffiliationGroups, methods=('GET', 'POST')
94+
)
95+
blueprint.add_url_rule(
96+
f'{_admin_prefix}/groups/<int:group_id>',
97+
'api_affiliation_group',
98+
RHAffiliationGroup,
99+
methods=('GET', 'PATCH', 'DELETE'),
100+
)
101+
blueprint.add_url_rule(f'{_admin_prefix}/tags', 'api_affiliation_tags', RHAffiliationTags, methods=('GET', 'POST'))
102+
blueprint.add_url_rule(
103+
f'{_admin_prefix}/tags/<int:tag_id>', 'api_affiliation_tag', RHAffiliationTag, methods=('GET', 'PATCH', 'DELETE')
104+
)
105+
blueprint.add_url_rule(f'{_admin_prefix}/contact-lists/names', 'api_contact_list_names', RHContactListNames)
106+
_regform_prefix = f'{_admin_prefix}/events/<int:event_id>/regforms/<int:reg_form_id>'
107+
108+
blueprint.add_url_rule(
109+
f'{_regform_prefix}/affiliations',
110+
'api_reg_form_affiliations',
111+
RHRegFormAffiliations,
112+
)
113+
blueprint.add_url_rule(
114+
f'{_regform_prefix}/affiliation-groups',
115+
'api_reg_form_affiliation_groups',
116+
RHRegFormAffiliationGroups,
47117
)
48-
blueprint.add_url_rule('/tags', 'api_affiliation_tags', RHAffiliationTags, methods=('GET', 'POST'))
49118
blueprint.add_url_rule(
50-
'/tags/<int:tag_id>', 'api_affiliation_tag', RHAffiliationTag, methods=('GET', 'PATCH', 'DELETE')
119+
f'{_regform_prefix}/affiliation-tags',
120+
'api_reg_form_affiliation_tags',
121+
RHRegFormAffiliationTags,
51122
)
52-
blueprint.add_url_rule('/contact-lists/names', 'api_contact_list_names', RHContactListNames)
123+
blueprint.add_url_rule(
124+
f'{_regform_prefix}/countries',
125+
'api_reg_form_countries',
126+
RHRegFormAffiliationCountries,
127+
)
128+
blueprint.add_url_rule(
129+
f'{_regform_prefix}/affiliations/search',
130+
'api_reg_form_search_affiliations',
131+
RHRegFormSearchAffiliationsExtended,
132+
)
133+
blueprint.add_url_rule(
134+
f'{_regform_prefix}/affiliations/user-count',
135+
'api_affiliation_user_count_by_ids',
136+
RHAffiliationUserCountByIds,
137+
methods=('POST',),
138+
)
139+
blueprint.add_url_rule(
140+
f'{_regform_prefix}/affiliation-user-count',
141+
'api_affiliation_user_count',
142+
RHAffiliationUserCount,
143+
methods=('POST',),
144+
)
145+
blueprint.add_url_rule(
146+
f'{_regform_prefix}/invite',
147+
'api_invite_by_affiliation',
148+
RHInviteByAffiliation,
149+
methods=('POST',),
150+
)
151+
blueprint.add_url_rule(
152+
'/event/<int:event_id>/affiliation-extras/registration/<int:reg_form_id>/affiliations/'
153+
'<int:field_id>/list/<int:affiliation_list_id>',
154+
'search_registration_affiliation',
155+
RHSearchRepresentationAffiliation,
156+
)
157+
blueprint.add_url_rule(
158+
'/event/<int:event_id>/manage/affiliation-extras/registration/<int:reg_form_id>/affiliations/'
159+
'<int:field_id>/list/<int:affiliation_list_id>',
160+
'search_registration_affiliation_management',
161+
RHManageSearchRepresentationAffiliation,
162+
)
163+
164+
# SPA page routes (React Router handles display)
165+
_management_page = _dispatch(RHManageEventAffiliations, RHManageCategoryAffiliations)
166+
167+
for object_type in ('event', 'category'):
168+
if object_type == 'category':
169+
prefix = '/category/<int:category_id>'
170+
else:
171+
prefix = '/event/<int:event_id>'
172+
prefix += '/manage/affiliations'
173+
defaults = {'object_type': object_type}
174+
175+
# SPA page routes (React Router handles display)
176+
blueprint.add_url_rule(f'{prefix}/', 'manage_affiliations', _management_page, defaults=defaults)
177+
blueprint.add_url_rule(f'{prefix}/new/', 'create_catalog', _management_page, defaults=defaults)
178+
blueprint.add_url_rule(f'{prefix}/<int:catalog_id>/', 'catalog_detail', _management_page, defaults=defaults)
179+
180+
# Catalog API
181+
blueprint.add_url_rule(
182+
f'{prefix}/api/affiliations/catalogs',
183+
'api_create_catalog',
184+
RHCreateAffiliationCatalog,
185+
defaults=defaults,
186+
methods=('POST',),
187+
)
188+
blueprint.add_url_rule(
189+
f'{prefix}/api/affiliations/catalogs/<int:catalog_id>',
190+
'api_edit_catalog',
191+
RHEditAffiliationCatalog,
192+
defaults=defaults,
193+
methods=('PATCH',),
194+
)
195+
blueprint.add_url_rule(
196+
f'{prefix}/api/affiliations/catalogs/<int:catalog_id>',
197+
'api_delete_catalog',
198+
RHDeleteAffiliationCatalog,
199+
defaults=defaults,
200+
methods=('DELETE',),
201+
)
202+
blueprint.add_url_rule(
203+
f'{prefix}/api/affiliations/catalogs/<int:catalog_id>/clone',
204+
'api_clone_catalog',
205+
RHCloneAffiliationCatalog,
206+
defaults=defaults,
207+
methods=('POST',),
208+
)
209+
blueprint.add_url_rule(
210+
f'{prefix}/api/affiliations/catalogs/<int:catalog_id>/toggle-default',
211+
'api_toggle_default_catalog',
212+
RHToggleDefaultCatalog,
213+
defaults=defaults,
214+
methods=('POST',),
215+
)
216+
blueprint.add_url_rule(
217+
f'{prefix}/api/affiliations/resolve',
218+
'api_resolve_affiliations',
219+
RHResolveAffiliations,
220+
defaults=defaults,
221+
methods=('POST',),
222+
)
223+
224+
# Scoped reference-data reads for the catalog editor and invite dialog pickers
225+
blueprint.add_url_rule(
226+
f'{prefix}/api/affiliations/groups',
227+
'api_affiliation_catalog_groups',
228+
RHAffiliationCatalogGroups,
229+
defaults=defaults,
230+
)
231+
blueprint.add_url_rule(
232+
f'{prefix}/api/affiliations/tags',
233+
'api_affiliation_catalog_tags',
234+
RHAffiliationCatalogTags,
235+
defaults=defaults,
236+
)
237+
blueprint.add_url_rule(
238+
f'{prefix}/api/affiliations/countries',
239+
'api_affiliation_catalog_countries',
240+
RHAffiliationCatalogCountries,
241+
defaults=defaults,
242+
)
243+
blueprint.add_url_rule(
244+
f'{prefix}/api/affiliations/search',
245+
'api_affiliation_catalog_search',
246+
RHAffiliationCatalogSearch,
247+
defaults=defaults,
248+
)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// This file is part of the third-party Indico plugins.
2+
// Copyright (C) 2026 CERN
3+
//
4+
// The third-party Indico plugins are free software; you can
5+
// redistribute them and/or modify them under the terms of the;
6+
// MIT License see the LICENSE file for more details.
7+
8+
import _ from 'lodash';
9+
import PropTypes from 'prop-types';
10+
import React from 'react';
11+
import {DndProvider} from 'react-dnd';
12+
import {Form as FinalForm} from 'react-final-form';
13+
import {HTML5Backend} from 'react-dnd-html5-backend';
14+
import {Form, Segment} from 'semantic-ui-react';
15+
16+
import {ManagementPageSubTitle} from 'indico/react/components';
17+
import {FinalInput, FinalSubmitButton} from 'indico/react/forms';
18+
import {Translate} from 'indico/react/i18n';
19+
20+
import FinalCatalogList from '../components/CatalogListField';
21+
22+
import './CatalogDetailPane.module.scss';
23+
24+
export default function CatalogDetailPane({catalog, targetLocator, isNew, onSubmit}) {
25+
const isCreate = isNew === true;
26+
const initialValues = {
27+
name: catalog?.name || '',
28+
lists: _.sortBy(catalog?.lists || [], 'position').map(list => ({
29+
id: list.id,
30+
name: list.name,
31+
position: list.position,
32+
is_enabled: list.is_enabled,
33+
groups: list.groups,
34+
tags: list.tags,
35+
affiliations: list.affiliations,
36+
})),
37+
};
38+
39+
if (!catalog && !isCreate) {
40+
return (
41+
<Segment placeholder>
42+
<Translate>Catalog not found</Translate>
43+
</Segment>
44+
);
45+
}
46+
47+
const handleSubmit = async formData =>
48+
onSubmit({
49+
name: formData.name.trim(),
50+
lists: formData.lists.map(list => ({
51+
id: list.id,
52+
name: list.name.trim(),
53+
position: list.position,
54+
is_enabled: list.is_enabled,
55+
groups: list.groups.map(group => group.id),
56+
tags: list.tags.map(tag => tag.id),
57+
affiliations: list.affiliations.map(affiliation => affiliation.id),
58+
})),
59+
});
60+
61+
return (
62+
<div styleName="catalog-detail">
63+
<ManagementPageSubTitle title={isCreate ? Translate.string('New catalog') : catalog.name} />
64+
<DndProvider backend={HTML5Backend}>
65+
<FinalForm
66+
onSubmit={handleSubmit}
67+
initialValues={initialValues}
68+
initialValuesEqual={_.isEqual}
69+
subscription={{}}
70+
>
71+
{fprops => (
72+
<Form onSubmit={fprops.handleSubmit}>
73+
<section>
74+
<h3>
75+
<Translate>Name</Translate>
76+
</h3>
77+
<FinalInput
78+
name="name"
79+
required
80+
placeholder={Translate.string('Enter a name for the catalog')}
81+
validate={value =>
82+
value && value.trim()
83+
? undefined
84+
: Translate.string('Catalog name is required.')
85+
}
86+
/>
87+
</section>
88+
<section>
89+
<h3>
90+
<Translate>Lists</Translate>
91+
</h3>
92+
<FinalCatalogList name="lists" targetLocator={targetLocator} required />
93+
</section>
94+
<div styleName="form-actions">
95+
<FinalSubmitButton label={Translate.string('Save changes')} disabledUntilChange />
96+
</div>
97+
</Form>
98+
)}
99+
</FinalForm>
100+
</DndProvider>
101+
</div>
102+
);
103+
}
104+
105+
CatalogDetailPane.propTypes = {
106+
catalog: PropTypes.object,
107+
targetLocator: PropTypes.object.isRequired,
108+
isNew: PropTypes.bool,
109+
onSubmit: PropTypes.func.isRequired,
110+
};
111+
112+
CatalogDetailPane.defaultProps = {
113+
catalog: null,
114+
isNew: false,
115+
};

0 commit comments

Comments
 (0)