diff --git a/affiliation_extras/conftest.py b/affiliation_extras/conftest.py
new file mode 100644
index 0000000..9121d33
--- /dev/null
+++ b/affiliation_extras/conftest.py
@@ -0,0 +1,22 @@
+# This file is part of the third-party Indico plugins.
+# Copyright (C) 2026 CERN
+#
+# The third-party 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 pytest
+
+from indico.core.plugins import IndicoPlugin
+
+
+@pytest.fixture(autouse=True)
+def _plugin_manifest(mocker):
+ """Mock the plugin's webpack manifest.
+
+ Plugin assets are not built when running tests, so ``IndicoPlugin.manifest``
+ returns ``None`` and any page that injects a plugin bundle raises a
+ ``RuntimeError``. Indico core mocks its own manifest the same way in
+ ``make_test_client``; we do the same for plugin bundles here.
+ """
+ mocker.patch.object(IndicoPlugin, 'manifest')
diff --git a/affiliation_extras/indico_affiliation_extras/client/components/AffiliationList.tsx b/affiliation_extras/indico_affiliation_extras/client/components/AffiliationList.tsx
index f6b048f..db9dd00 100644
--- a/affiliation_extras/indico_affiliation_extras/client/components/AffiliationList.tsx
+++ b/affiliation_extras/indico_affiliation_extras/client/components/AffiliationList.tsx
@@ -47,7 +47,7 @@ export function AffiliationList({
tagIds: number[];
affiliationIds: number[];
}) {
- const {data, loading} = useIndicoAxios({
+ const {data, loading, error} = useIndicoAxios({
url: resolveAffiliationsURL,
method: 'POST',
data: {groups, tags, affiliations},
@@ -59,13 +59,18 @@ export function AffiliationList({
groups: a.groups.filter(g => groups.includes(g.id)),
tags: a.tags.filter(t => tags.includes(t.id)),
group_tags: a.group_tags.filter(t => tags.includes(t.id)),
- })),
+ })) ?? [],
[data]
);
if (loading) {
return ;
}
+ if (error) {
+ return (
+
+ );
+ }
if (!resolvedAffiliations.length) {
return ;
}
diff --git a/affiliation_extras/indico_affiliation_extras/client/components/CatalogListField.tsx b/affiliation_extras/indico_affiliation_extras/client/components/CatalogListField.tsx
index a6341a0..8d7dd16 100644
--- a/affiliation_extras/indico_affiliation_extras/client/components/CatalogListField.tsx
+++ b/affiliation_extras/indico_affiliation_extras/client/components/CatalogListField.tsx
@@ -8,7 +8,7 @@
import resolveAffiliationsURL from 'indico-url:plugin_affiliation_extras.api_resolve_affiliations';
import _ from 'lodash';
-import React, {useState} from 'react';
+import React, {useMemo, useState} from 'react';
import {Button, Confirm, Icon, Input, Loader, Modal, Popup} from 'semantic-ui-react';
import {FinalField} from 'indico/react/forms';
@@ -26,19 +26,12 @@ import {AffiliationList} from './AffiliationList';
import './CatalogListField.module.scss';
-const DEFAULT_LIST_VALUE = {
- id: null,
- name: '',
- position: null,
- is_enabled: true,
- groups: [],
- tags: [],
- affiliations: [],
-};
const DRAG_TYPE = 'affiliations-catalog-list';
export interface CatalogItem {
id?: number | null;
+ // client-only stable id for unsaved rows (used as React key / drag id; dropped server-side)
+ _frontendId?: string;
name: string;
position?: number | null;
is_enabled?: boolean;
@@ -47,6 +40,17 @@ export interface CatalogItem {
affiliations: Affiliation[];
}
+const makeDefaultList = (): CatalogItem => ({
+ id: null,
+ _frontendId: _.uniqueId('list-'),
+ name: '',
+ position: null,
+ is_enabled: true,
+ groups: [],
+ tags: [],
+ affiliations: [],
+});
+
interface CatalogListRowProps {
value: CatalogItem;
index: number;
@@ -71,7 +75,7 @@ function CatalogListRow({
const closeModal = () => setModalOpen(null);
const [handleRef, itemRef, style] = useSortableItem({
type: DRAG_TYPE,
- id: value.id ?? `new-${index}`,
+ id: value.id ?? value._frontendId ?? `new-${index}`,
index,
active: true,
separateHandle: true,
@@ -164,7 +168,7 @@ function CatalogListRow({
/>
{modalOpen === 'edit' && (
{
onChange({...value, ...members});
@@ -227,7 +231,8 @@ function CatalogListField({
onBlur: () => void;
targetLocator: Record;
}) {
- const values = _value?.length ? _value : [DEFAULT_LIST_VALUE];
+ const emptyDefault = useMemo(makeDefaultList, []);
+ const values = _value?.length ? _value : [emptyDefault];
const normalizePositions = (items: CatalogItem[]) =>
items.map((item, idx) => ({
...item,
@@ -276,7 +281,7 @@ function CatalogListField({
{normalizedValues.map((value, idx) => (
handleChange([...normalizedValues, DEFAULT_LIST_VALUE], false)}
+ onClick={() => handleChange([...normalizedValues, makeDefaultList()], false)}
disabled={normalizedValues.some(v => !v.name.trim())}
style={{marginTop: '0.5em'}}
compact
@@ -314,6 +319,10 @@ const validateCatalogLists = (value: CatalogItem[]) => {
if (value.some(({name}) => !name.trim())) {
return Translate.string('List names must not be empty.');
}
+ const names = value.map(({name}) => name.trim().toLowerCase());
+ if (new Set(names).size !== names.length) {
+ return Translate.string('List names must be unique.');
+ }
if (
value.some(
({groups, tags, affiliations}) => !groups.length && !tags.length && !affiliations.length
diff --git a/affiliation_extras/indico_affiliation_extras/client/dashboard/EmailAffiliations.tsx b/affiliation_extras/indico_affiliation_extras/client/dashboard/EmailAffiliations.tsx
index efd86a2..cd00cf9 100644
--- a/affiliation_extras/indico_affiliation_extras/client/dashboard/EmailAffiliations.tsx
+++ b/affiliation_extras/indico_affiliation_extras/client/dashboard/EmailAffiliations.tsx
@@ -137,7 +137,7 @@ function RecipientsWarning({
/>
)}
{affiliations.some(a =>
- getAffiliationEmails(a, contactLists, includeUnnamedLists).filter(e => invalidEmails.has(e))
+ getAffiliationEmails(a, contactLists, includeUnnamedLists).some(e => invalidEmails.has(e))
) && (
tu
else:
contact_id = contact.id
if contact_id in used_ids:
- raise UserValueError('Contact list IDs must be unique')
+ raise UserValueError(_('Contact list IDs must be unique'))
if contact_id not in existing_by_id:
- raise UserValueError('Contact list does not belong to this affiliation')
+ raise UserValueError(_('Contact list does not belong to this affiliation'))
touched_ids.add(contact_id)
used_ids.add(contact_id)
contact.name = contact_data['name']
@@ -275,7 +276,7 @@ def _apply_catalog_lists(catalog: AffiliationCatalog, catalog_lists: list[dict])
db.session.add(list_obj)
else:
if list_obj.id not in existing_by_id:
- raise UserValueError('List does not belong to this catalog')
+ raise UserValueError(_('List does not belong to this catalog'))
touched_ids.add(list_obj.id)
list_obj.name = list_data['name'].strip()
list_obj.is_enabled = list_data['is_enabled']
@@ -376,14 +377,24 @@ def _get_catalog_setting(target: Category | Event):
def _get_default_catalog_on_category(category: Category, *, only_inherited: bool = False):
- if not only_inherited:
- catalog = _get_catalog_setting(category)
- if catalog:
- return catalog
- parent_chain = category.parent_chain_query.all()
- for parent in reversed(parent_chain):
- catalog = _get_catalog_setting(parent)
- if catalog:
+ # Fetch every catalog usable anywhere in this category's chain in a single query, then
+ # resolve the default per category without re-querying get_all_catalogs for each ancestor.
+ chain_catalogs = get_all_catalogs(category)
+
+ def _resolve(target: Category):
+ catalog_id = category_settings.get(target, 'default_catalog_id')
+ if not catalog_id:
+ return None
+ target_chain_ids = {categ['id'] for categ in target.chain}
+ return next(
+ (c for c in chain_catalogs if c.id == catalog_id and c.category_id in target_chain_ids),
+ None,
+ )
+
+ if not only_inherited and (catalog := _resolve(category)):
+ return catalog
+ for parent in reversed(category.parent_chain_query.all()):
+ if catalog := _resolve(parent):
return catalog
return None