Skip to content

Affiliation-extras plugin: add Affiliation Catalogs#6

Draft
duartegalvao wants to merge 15 commits into
indico:masterfrom
duartegalvao:affiliation-schemas
Draft

Affiliation-extras plugin: add Affiliation Catalogs#6
duartegalvao wants to merge 15 commits into
indico:masterfrom
duartegalvao:affiliation-schemas

Conversation

@duartegalvao

Copy link
Copy Markdown
Member

This PR contains a second version of the affiliation-extras plugin.
Changelog:

  • Affiliation Catalogs: events can have set lists of affiliations categorized by type (e.g. Organizations, Institutes, etc.). Each list filters affiliations based on a list of groups, tags and ad-hoc affiliations. Catalogs can be set at category-level and inherited.
  • Representation Field: new field type for registration forms, which requests participants to select a representation type (linked to the event's Affiliation Catalog) before selecting an affiliation.

Depends on:

@duartegalvao duartegalvao force-pushed the affiliation-schemas branch 6 times, most recently from 3a9c0ea to d3959e6 Compare May 14, 2026 09:17
duartegalvao and others added 14 commits June 8, 2026 09:29
* Add invite affiliations tab

Move AffiliationInvitations definition to another file

Improve AddItemsModal UI

Migrate invite dialog to use AffiliationListField

Replaces the regform-endpoint-based AffiliationsTab with AffiliationInvitations,
which delegates affiliation selection to the existing AffiliationListField component.
AddItemsModal is refactored from a generic data-URL modal to an affiliation-specific
search modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Show user count per affiliation in AddItemsModal search results

Extends the affiliations search API to include user_count per affiliation.
AddItemsModal gains a showInviteCount prop that, when enabled, displays
the registration count per result item and a running total of registrations
for newly added affiliations in the footer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Fix send button to show correct invitation count

Adds a backend endpoint that counts unique users across the selected
affiliations, groups, and tags (deduplicating users who belong to
multiple entries). AffiliationListField fetches this count asynchronously
and stores it in _userCount so the synchronous getCount callback in
AffiliationInvitations can return the accurate value for the send
confirmation dialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Improve ui

Self review minor improvements

Turn the results column in a ternary expression

Decouple userCount from AffiliationListField and AddItemsModal

Disable search button if no search input

Change UserBasicSchema for BasicUserSchema

Remove comments

Use resolve_affiliations when necessary

Rename affiliationInvitations

Move AddItemsModal.jsx

Fix rebase problems

Convert AddItemsModal from JS to TS

Remove email attachments comments

Improve CSS for AddItemsModal.tsx

Small refactor

Self review fixes

* Small refactor to follow indico patterns

* Improve tags

* Refactor useEffect and variable names

* Add AddAffiliationsModal scss module

* Add AffiliationListField scss module

* Improve css styles

* Run biomejs

* Fix affiliation invitation schema import

* Fix conditional hook

* Add compatibility shim

* Apply code style improvements

---------

Co-authored-by: Duarte Galvão <duarte.galvao@unconventional.dev>
<TagsItems tags={tags} groupTags={groupTags} />
{affiliationCount > 0 && (
<Popup
content={Translate.string('Affiliations')}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be good to list the affiliation names in this popup rather than the static Affiliations label. value.affiliations already carries them (CatalogListField only passes .length), so it'd mirror the group/tag hover. Worth capping for long lists, since See affiliations already shows the full resolved set.

* test: mock plugin manifest so manage pages render in tests

* fix: keep unchanged representation value valid after catalog changes

* perf: resolve inherited default catalogs without per-ancestor queries

* fix: reject duplicate affiliation list names in a catalog

* refactor: register all models from the package init

* fix: handle failed affiliation resolve without crashing the list

* fix: use stable keys for unsaved catalog list rows

* fix: correct invalid contact emails warning condition

@moliholy moliholy left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things that don't map to a single line:

  • No controller tests. The access matrix (event/category × method × role) is the biggest gap; receipts/controllers/access_test.py is a good template.
  • schemas_test.py stubs sys.modules and skips the catalog schemas entirely, so the catalog args + the uniqueness validators (the headline feature) are untested.
  • State/props are snake_case while one endpoint opts into camelize. Core camelizes at the boundary; worth picking one.

return jsonify(count=count)


class RHInviteByAffiliation(RHAdminBase):

@moliholy moliholy Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be admin-only? A regform manager who is not also a site admin then cannot invite by affiliation.

invitation_list=RegistrationInvitationSchema(many=True).dump(invitations),
)

def _get_allowed_sender_emails(self, *, for_sending=False):

@moliholy moliholy Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this reuse the event-level sender validation instead of duplicating it?

model = AffiliationCatalog
fields = ('id', 'name', 'owner', 'lists')

owner = fields.Nested(OwnerDataSchema)

@moliholy moliholy Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loads users one affiliation at a time; a single batched query would help, though for the all-affiliations endpoint that would pull every user in the system.

return jsonify(url=file.signed_download_url)
@use_kwargs({'file': fields.Raw(required=True, data_key='upload')}, location='files')
def _process(self, file):
response, __ = UploadFileMixin._process.__wrapped__(self, file)

@moliholy moliholy Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reaches into a wrapped internal; could it call the public save helper directly? The refresh just below also looks unnecessary.

log_fields[key] = {'title': f'List: {name} - {title}', 'type': type_}
return changes, log_fields


@moliholy moliholy Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks almost identical to the contact-list change logging above; could the two share a helper?


blueprint = IndicoPluginBlueprint('affiliation_extras', __name__)

_admin_prefix = '/api/admin/plugins/affiliation_extras'

@moliholy moliholy Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the URL and endpoint naming here differs from core convention, so it might be worth aligning.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or is it because it's a plugin?

nullable=False,
index=True,
)
position = db.Column(db.Integer, nullable=False)

@moliholy moliholy Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this default instead of requiring every caller to set it, the way core positioned models do?

registerPluginObject(PLUGIN_NAME, 'invite-dialog-extra-modes', affiliationInvitations);

// Category management
import setupAffiliationCatalogs from './catalogs';

@moliholy moliholy Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this import could move up with the others rather than sitting below the code.

const countURL = userCountByIdsURL({event_id: eventId, reg_form_id: regformId});
const renderItemExtra = item =>
item.extraInfo !== undefined
? React.createElement(

@moliholy moliholy Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be plain JSX like the other components, rather than building the elements by hand?

value.name.trim() || value.id != null ? setModalOpen('delete') : onDelete();
const hasMembers =
value.groups.length > 0 || value.tags.length > 0 || value.affiliations.length > 0;
const isEnabled = value.is_enabled;

@moliholy moliholy Jun 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this inline width duplicates the stylesheet, so it could move into the SCSS module.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants