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