Indico STSA plugin
Customizations for the Singapore Taiwanese Student Association.
Seven things, each independently switchable:
- E-mail subject prefixes. Every mail Indico sends goes out with STSA's own
prefix instead of
[Indico]. - A member discount, per registration form. Money off for anyone who registers while signed in with their STSA membership — with the answers they have already typed kept intact across the trip to the login page.
- Members-only group registration. Where the group registration plugin is also installed, creating or joining a group can be restricted to signed-in members.
- The standard wallet buttons. Indico's "Add to Wallet" dropdown is replaced with Apple's and Google's own badges, on the registration page and in the e-mail the ticket arrives with — and they work without signing in.
- An STSA ticket, in the association's own colours and marks, with a font that can actually draw Chinese.
- STSA colours on the Apple Wallet pass, so the saved ticket and the printed one belong to the same association.
- Payment reminders. One button in the registrant list writes to everybody whose registration fee is still outstanding, each mail naming what that person owes.
- The membership's e-mail address. A member who is signed in registers under the address on their membership, so their registrations can always be matched back to them.
A member is anyone signed in. An STSA membership is an account on the site: there is no membership table and no separate sign-up, so "becoming a member" is exactly "signing in". Every participant-facing string says "STSA membership" rather than naming Indico.
Installing
pip install indico-plugin-stsa
Then add it to PLUGINS in indico.conf:
PLUGINS = {'stsa'}
and create its tables:
indico db --plugin stsa upgrade
Restart Indico (and the Celery worker, if you run one).
Missing that last step does not take anything down. Without its tables the plugin reads as nothing configured: the member discount and the group gate are off, registration forms and e-mails behave exactly as they would with the plugin uninstalled, and the STSA pages in the management area say what is missing and print the command above instead of failing. The same line goes to the server log once per process.
Released wheels carry the compiled webpack bundle. Installing straight from a git checkout does not — see Building the assets.
1. E-mail subject prefixes
Configured once, for the whole site, at Administration → Plugins → STSA:
| Setting | Default | |
|---|---|---|
| Rewrite e-mail subject prefixes | on | The master switch. |
| Subject prefix | [STSA 活動] |
What [Indico] becomes. Empty strips it. |
| Use the Apple and Google wallet badges | on | See the wallet buttons. |
Every outgoing mail is covered: registration confirmations, password resets,
event reminders, abstract notifications, and anything a plugin sends. Mails
that deliberately carry no prefix — the room booking ones set the prefix block
to empty — are left exactly as they are, because there is nothing there to
replace. The error-report mail's [Indico@yourserver] is replaced whole; the
server name it carries is in the body anyway.
How
Indico builds every mail through indico.core.notifications.make_email, which
is decorated with @make_interceptable precisely so that a plugin can step in.
The plugin intercepts it, lets Indico build the mail as usual, and rewrites the
subject afterwards.
The obvious alternative — overriding emails/base.txt and emails/base.html
through get_template_customization_paths — was rejected. It only reaches the
mails that go through those templates and do not override the subject_prefix
block themselves, it misses every mail built from a plain subject= string, and
the value would be fixed at deploy time rather than configurable.
If the setting cannot be read, or the rewrite raises for any reason, the original subject is used and the mail still goes out. A misconfigured prefix must never cost somebody their registration confirmation.
2. The member discount
Per registration form, at Event → Registration → STSA, or from the STSA box on the registration form's own settings page.
| Setting | |
|---|---|
| Enable the member discount | Off by default. |
| Discount | A percentage, or a fixed amount in the form's currency. |
| Discount applies to | The registration fee only, or the whole price including paid options. |
| Sign-in notice | Optional. Replaces the built-in wording of the notice. |
What the participant sees
Somebody who is not signed in gets a notice above the form saying what the discount is worth and a prominent Sign in with your STSA membership button. Following it takes them through the login page and straight back to the form, with everything they had already typed still in place.
Somebody who is signed in sees nothing extra. The discount appears as a named Member discount line on their invoice, their registration summary and their confirmation e-mail, with the rate next to it.
The invoice line, and why organizers never see it
The discount is stored as a value on a billable registration field the plugin
provisions for itself — ext__stsa_member_discount, in a manager-only section
called Member discount (internal). That is what makes it a named Member
discount line with a rate next to it rather than Indico's unlabelled "Price
adjustment", and the plugin is its only writer: the field is locked, so core
skips it when a registration is created or modified.
None of it is an organizer's business, so the field and its section are hidden
wherever a manager sees the form — the form editor and the management
registration form alike. The field is registered with core's React field
registry precisely so that it can be hidden: an input type no plugin has
registered renders as Unknown input type: ext__stsa_member_discount, which is
how organizers used to meet it. It is kept out of the Add field dropdown for
the same reason — the plugin provisions the field itself, and a second copy
would be a second invoice line that nothing ever writes to.
The registration list's Customize list dialog offers every field on the form as a column, this one included, so it is filtered out of that dialog too. If somebody had already switched the column on, opening the dialog once and applying it puts that right.
Keeping the answers
The answers go into sessionStorage when the page is navigated away from
(pagehide, which also covers signing in through the header menu rather than
the plugin's own link) and are read back — once, then deleted — when the form
next loads. A draft older than six hours is ignored: that is an abandoned tab,
not a login round trip.
Two things deliberately do not survive: file uploads, because the browser
will not let a File be put back, and the captcha answer, because the
challenge it belongs to is gone. Both fields are simply left untouched.
Who counts as a member
A registration earns the discount when it is the account holder's own. Both halves of that matter:
registration.useralone is not enough. Core sets it from the e-mail address, withget_user_by_email, so on its own it would hand the discount to anybody who types a member's address into an anonymous registration.session.useralone is not enough either. It would hand the discount to a signed-in organizer registering somebody else.
So the test is registration.user is not None and session.user == registration.user.
Registrations created from the management area are trusted instead: an organizer
adding a participant has made that decision deliberately.
The link a registration has to a membership account is the whole test, so it is not left to whatever somebody types: while a member is signed in, the e-mail field is held to the address on their membership — see The membership's e-mail address.
Once earned, the discount sticks. Modifying a registration only ever adds the discount, never removes it — a participant may well be editing from the link in their confirmation e-mail with nobody signed in, and losing a discount over a changed phone number would be indefensible. Switching the discount off on the form does remove it, on the next change to each registration.
Applying it to registrations that already exist
Saving the settings only affects registrations made from then on. The Apply to existing registrations button on the settings page reprices everything on the form, and is deliberately a button rather than a side effect of saving: it treats every registration linked to a membership account as a member's, including ones made before the discount existed. Somebody whose price goes up after they have paid will owe the difference, and Indico has no partial-payment concept to collect it with.
3. Members-only group registration
Needs the group registration plugin. The switch is on the same STSA settings page, and is hidden when that plugin is not installed — a switch that currently gates nothing should not be offered.
With it on, a participant who is not signed in finds the group plan picker
inert, with a note underneath saying groups are for STSA members and offering
the same sign-in button. A code arriving from a shared join link
(?group_code=…) is cleared rather than acted on.
The server refuses the same choice again when the registration is submitted, and that is the half that enforces the rule. The check reads the submitted answer rather than the stored one, which is what makes it independent of whether the group plugin's own signal handler has already run: reading the stored answer would either block every later edit of a registration that is already in a group, or let the very thing this is meant to stop straight through, depending on which plugin's handler happened to run first.
Organizers are never gated. Adding a participant to a group from the management area is a deliberate act by somebody who is already signed in. The carve-out is that act, not the registration it produced: a registration an organizer made is still editable from the link in its confirmation e-mail, by somebody who need not be signed in at all, so a later edit of it is gated like any other.
4. The wallet buttons
Where an organizer has configured the passes, Indico offers them as an Add to Wallet dropdown — or, if only one wallet is set up, as an ordinary Indico button. Neither is what people look for, and both vendors' guidelines ask for their own badge instead. So the plugin swaps them for the real thing, in two places: on the registration page, and in the e-mail the ticket arrives with.
The artwork
Both badges are the vendors' official artwork; neither is drawn by hand, because both vendors forbid recreating theirs. But only one of them can ship here:
| Ships with the plugin? | Source | Languages | |
|---|---|---|---|
| yes | the asset packs published with its brand guidelines | 27 | |
| Apple | no — you install it | the pack behind Download badge files on its badge guidelines | 24 |
Apple's Wallet Marketing Artwork License Agreement grants a non-transferable licence to use the badge only in connection with your own passes, so this package has no right to hand it on to whoever installs it next. Each deployment downloads its own copy — a formality for anyone already issuing Apple passes, since that needs an Apple Developer account regardless — and installs it with:
python scripts/install-apple-badges.py ~/Downloads/Add-to-Apple-Wallet.zip
Until then the Apple link keeps the button Indico shipped. Nothing is
substituted for it. indico_stsa/static/wallet/apple/README.md has the details,
and a test plus a release gate make sure the artwork never ends up in a package
by accident.
Apple ships SVG and EPS but no PNG, and e-mail needs one — Gmail and Outlook do not render SVG at all — so the installer rasterizes Apple's own SVGs at 3× the rendered height, leaving proportions and colours untouched.
The badge matching the reader's Indico language is used, falling back to English. Both are rendered at 48px, Google's stated minimum, with clear space around them and no hover or press effect — both vendors forbid altering their artwork, and Apple names dimming and animation specifically.
On the page
A small script rewrites the finished page: it finds the links pointing at Indico's two wallet endpoints, puts a badge for each in a row below the action box, and removes the dropdown once nothing is left in it.
The badge keeps the href core gave the link and adds the registration token the page was opened with. That is a fix, not a liberty: core links these two endpoints inconsistently. Where only one wallet is configured it uses the registrant locator, which carries the token — but in the dropdown, the branch you get when both are configured, it links the registration form with no token at all. Anyone who registered without an account was bounced to a login page they have no account for and could not add their pass. The badge grants exactly the access the page it sits on already grants, and nothing more.
The alternative was forking registration_summary.html, a 300-line core
template that changes between Indico releases and would then have to be reviewed
on every upgrade for the sake of two buttons. Working on the DOM also fails
safely: if core moves these endpoints, the links are simply not found and the
participant keeps the buttons Indico shipped.
The badges go in a row of their own rather than into the toolbar. That toolbar is a single non-wrapping flex row sized for Indico's own 30px buttons, and two 48px badges pushed into it slide out past the edge of the box.
In the e-mail
The same badges are added to the mail the ticket is attached to — which is where
somebody who has just been sent a ticket is actually looking. They appear on
exactly the mails that carry the ticket and no others: the condition mirrors the
one in Indico's _notify_registration, so an organizer who has switched ticket
e-mails off does not start getting pass links instead.
The images are embedded rather than linked, because most mail clients block remote images until the reader asks for them and a blocked button is a button nobody presses. Indico already sends inline images this way for registration pictures. The links carry the registration's token, so they work without signing in.
The mail is already rendered by the time the plugin sees it, so the block is spliced in just above the card's grey footer. If a future Indico changes that footer the badges land after the card instead — untidy, but still a working button.
5. The ticket
indico stsa install-ticket installs 「門票」, a tear-off stub, and makes it the
default ticket for the root category — so every event uses it unless an
organizer picks something else.
┌──────────────────────────────────────────┐
│ ▓▓ STSA wordmark ADMIT ONE │ navy field, #2F5478
├──────────────────────────────────────────┤
│ 2026 STSA 秋季迎新晚會 Welcome Night │ Noto Serif CJK
│ │
│ 持票人 ATTENDEE │
│ 昱辰 林 │ the holder is the hero
│ 日期時間 WHEN 地點 WHERE │
│ - - - - - - - - - - - - - - - - - - - - -│ the tear line
│ ███████ 入場憑證 ENTRY PASS │
│ ███████ 昱辰 林 NO. 4 │ the stub names them again
│ (emblem) Admits the named holder │
└══════════════════════════════════════════┘ oxblood rule, #8A2424
Re-running is how an upgrade is applied: the template is found by title and
updated in place, so events already pointing at it keep pointing at it. It never
runs by itself — a plugin that rewrote a template on every start would silently
undo an organizer's edits. --no-default installs it without taking over, and
--dry-run says what would change and rolls back.
Once installed it is an ordinary designer template: organizers can open it and change it, and their edits survive until somebody re-runs the command.
Why the design is two layers
Indico's designer canvas positions text and images and nothing else — there is
no line or rectangle primitive. So every band, rule and the perforation arrives
as one background PNG, built by scripts/build-ticket-artwork.py from the marks
in indico_stsa/static/brand/ (taken from
stsa-tw/Assets). The text and the QR sit on
top. The two halves share a coordinate system: move a rule in the script, move
the text in indico_stsa/ticket.py.
The palette is sampled from the emblem itself — #2F5478 from the merlion half,
#8A2424 from the bear — rather than picked to look nice next to it.
Chinese
Indico offers badge fonts that cannot draw Chinese. The Liberation faces have no CJK glyphs and fall back to empty boxes; the two Kochi faces are Japanese; UMing is dated. Meanwhile Indico ships Noto Sans CJK and Noto Serif CJK and registers neither.
So the plugin backs Indico's serif, sans-serif and courier families with
those faces. Every template in the instance gets Chinese, not only this one, and
a badge already set in sans-serif keeps working — it just stops producing tofu
the moment a name is in Chinese. It costs nothing to ship, since the fonts are
already there. Switch it off at Administration → Plugins → STSA if you would
rather keep Liberation's metrics.
Nothing on the ticket asks for bold. These are variable fonts and ReportLab renders their default instance, so a bold request would come out at regular weight; hierarchy is built from size, colour and the serif/sans contrast, which do arrive.
Emoji
An event called 秋季迎新晚會 🎉 printed a crossed box where the emoji should be:
no CJK font has emoji glyphs, and ReportLab draws .notdef for a codepoint the
font is missing. There is no fixing that within the text path — a Paragraph is
drawn in exactly one face, and Indico strips inline markup out of the text before
it gets there, so a second font cannot be asked for mid-line.
So a line that needs two fonts is not drawn as text. It is composed run by run, each run in the font that has the glyphs, and handed back as an image — which the badge renderer already knows how to place. Everything else stays on the vector text path; only the lines that would have been boxes change. The image is composed at 4× and the box is sized to what came out, so it stays crisp and is never stretched.
The emoji are monochrome (Noto Emoji, OFL, shipped with the plugin). That is deliberate as well as convenient: tickets are usually printed, often in black and white, and colour emoji are bitmap fonts that ReportLab cannot embed at all.
If the font is ever missing, or composing fails, the characters are dropped instead. A title reading 秋季迎新晚會 is a small loss; one reading 秋季迎新晚會 ⊠ looks broken.
6. The Apple Wallet pass
Indico builds and signs the .pkpass itself — that is core, not a plugin — and
paints it #007cac, its own blue, hardcoded in
AppleWalletManager.build_pass_object. Its five fields are hardcoded too. This
replaces the whole of it with a design you edit in Xcode's Pass Designer.
The design lives in indico_stsa/EventTicket.pkpasstemplate/, a Pass Designer
bundle you can open directly: pass.json, the artwork, and the .lproj strings. wallet_pass.py
starts from that file and substitutes only what belongs to one registration —
the field values, the barcode, the dates, and the identity keys that have to
match the certificate. Labels, colours, the style key and the static copy are
quoted verbatim, so changing how the pass looks is a Pass Designer job and not
a commit.
The layout is posterGeneric
A poster pass: artwork full-bleed behind the fields, rather than the classic strip-and-fields card. Generic rather than event ticket, and that distinction is the load-bearing one — Apple limits the poster event ticket scheme to NFC-enabled passes, which wants an entitlement issued case by case and unlikely to be granted to a student association. A poster generic pass carries no such requirement.
So wallet_pass.style_keys() reads whichever keys the template holds rather than
naming one: switching the style in Pass Designer renames it, and a hardcoded
name would silently revert the design to core's eventTicket. For the same
reason preferredStyleSchemes is stripped on the way out — Pass Designer writes
posterEventTicket back into the template on every save, and shipping it would
ask for the entitlement-gated scheme again. A test asserts both halves of that:
that the template still carries the key, and that the pass never does.
posterGeneric needs iOS 27, so a pass carries two layouts
An iPhone on iOS 26 cannot draw a poster pass, and Apple's answer is not a
second pass but a second style dictionary in the same pass.json — the
classic generic beside the poster one, each with its own fields, and Wallet
draws the newest layout the OS understands. Everything the two share — the
barcode, the colours, semantics, the serial — is top-level and written once.
pass_json therefore fills in every style it finds. That is not tidiness:
a style left alone still ships, and it would hand the template's 王小明 and
#42 to whoever an iPhone on 26 drew it for — not a blank ticket but a wrong
one, signed under a real member's name. Two tests hold that line, both across
every style rather than the first: the registration is substituted, and Pass
Designer's _id bookkeeping is gone.
The bucket rule above is the poster layout's and the fallback does not share
it — generic draws secondaryFields and auxiliaryFields and has no
footerFields at all, so 持票人 cannot sit where it sits today. Laying that out
is a Pass Designer job like the rest of the design; nothing in Python needs to
know which style is the fallback.
The template still ships one style, so every pass issued today is
poster-only and Apple's line on those is that iOS 26 and earlier "cannot
display the new design". What that looks like from MemberApp has not been
confirmed: TicketStore.loadWalletPass decides the button with
PKPass(data:), so if that parse is what fails, the Wallet button is simply
absent on 26 rather than the pass adding and drawing wrong. The debug line
[Indico] wallet pass for event … — no pass on an iOS 26 simulator would
settle it.
test_the_template_ships_one_style_for_now is the tripwire: it passes today
and fails the moment the fallback is drawn, which is when it should be deleted.
Why the whole of pass.json is replaced
Indico builds passes with wallet-py3k, whose Pass.json_dict() is a fixed
whitelist — it emits only the keys it was written to know about. Setting an
attribute it does not know is silently dropped, which rules out semantics,
sharingProhibited, useAutomaticColors, the plural barcodes, and the style
key itself, which is always eventTicket because that is the class Indico
instantiates.
Rather than fight that key by key, the pass object is handed a json_dict of
our own. Pass._createPassJson calls it through PassHandler, so what it
returns is pass.json. Field.json_dict() returns self.__dict__, so fields
were never the constraint — only the pass level was.
What each registration contributes
plugin.py is the only place that knows what a registration looks like; it
flattens one into a PassTicket and wallet_pass.py draws from that, which is
what keeps the module free of Indico and testable without an instance.
| Pass field | From |
|---|---|
header time |
event.start_dt_local — the event's timezone, so the pass shows the hour the organiser announced. Date and time on one field, because the header draws only one |
primary event |
the title. The caption is the template's — one word for every category, rather than the event's kind |
footer holder |
registration.full_name |
back registration |
#{friendly_id} |
back venue |
venue_name · room_name, dropped entirely when the event has neither |
back organiser, notice, 說明 |
static copy, quoted from the template |
barcodes[0] |
get_ticket_qr_code_data, so the QR matches the printed ticket byte for byte |
altText |
the ticket number — the one text slot Wallet gives you under the code, for a door that has to look somebody up when the scanner will not read |
The face has exactly three lines, and that is not a style choice.
posterGeneric draws the first entry of headerFields, primaryFields and
footerFields and nothing after it; secondaryFields and auxiliaryFields it
ignores completely. None of that is documented — it was settled by signing real
passes and looking, after the holder sat in secondaryFields and never once
appeared on a ticket. backFields draws all of its entries, so everything that
does not fit the three face slots lives there.
Three things are deliberately absent. No expirationDate and no voided:
RHTicketDownload's four access checks say nothing about the date, so a ticket
outlives its event and one already attended is a record worth keeping.
sharingProhibited is on, because a ticket QR is the credential — whoever
holds it can be checked in as that member. And locations is dropped: the
template carries its sample event's coordinates, Indico holds none for a real
one, and a pass claiming every event happens at one park would wake on the Lock
Screen in the wrong place.
The images ship with the template
Core takes logo.png and icon.png from WALLET_LOGO_URL — one URL for the
whole instance — and fetches them over HTTP, a round trip from Indico to itself
per image, substituting Indico's logo when a fetch fails. These are read off
disk instead and attached through Pass._files, which is a private attribute of
a third-party library and so is touched defensively: no dict, no images, and a
pass that still carries its design.
.lproj entries keep their path inside the bundle, which is how Wallet finds
the labels for a member whose phone is not in English.
Seeing it
Nothing drawn in Python stands in for this: iOS refuses an unsigned pass, in the Simulator as much as on a phone. Build a real one —
pip install wallet-py3k cryptography
python scripts/sign-preview-pass.py \
--certificate ~/pass-cert.pem --key ~/pass-key.pem
— then AirDrop it to yourself, or drag it onto a booted simulator. Deploying the
plugin is not required; the certificate is, and it is the same PEM pair
configured on the Indico category under Apple Wallet. The key is read from the
path given and never stored, and a passphrase on it is prompted for rather than
passed on the command line, where every account on the machine can read it out of
ps and the shell keeps it in history — set STSA_PASS_KEY_PASSWORD for a run
that has to be unattended.
The whole design can be switched off with wallet_pass_design, and both the
handler and every failure inside it are caught: this runs while a participant is
downloading their ticket, so an exception would turn a working pass into an
error page. Indico's blue pass is a perfectly good ticket, and that is what a
failure leaves behind.
7. Payment reminders
The registrant list gets a Remind unpaid (3) button, next to Moderation and Check-in control. It writes to everybody on that registration form whose fee is still outstanding, in one click, without anybody having to filter the list and tick four hundred boxes first.
The count is in the label rather than only in the dialog, so the button says what it is about to do before it is pressed — and it is not drawn at all when nobody owes anything, when the event takes no payments, or when the viewer only has moderation or check-in rights.
Who counts as unpaid
Three things have to be true, and each one keeps somebody out of the list who would otherwise be chased for nothing:
- the registration is in Indico's Awaiting payment state. That is the only state that means a fee is outstanding: complete has either paid or was never asked to, and pending, rejected and withdrawn are not waiting on money.
- no payment has been made. This is not the same as the state. A
transaction that is still
pending— a bank transfer nobody has confirmed yet — counts as paid while deliberately leaving the registration Awaiting payment. Somebody who has already sent the money must not be chased for it. - the fee is above zero. Removing the fee from registrations that had not paid it (Update Registration Fee → Remove fee) stops Indico asking for the money without moving the state, so the price is the only thing left that says there is nothing to pay.
The first is a filter on the query; the other two are decided in Python, because Indico computes both — the price from the registration's billable answers, the payment from its latest transaction — so neither can be asked of the database. Both are eager-loaded, or reading them would be two more queries per registrant.
The dialog
The button opens core's own e-mail dialog, prefilled: a subject and a body an organizer can rewrite, the recipient list, the sender addresses they are allowed to send as, and a preview. It is a subclass of core's E-mail action rather than a new mail sender, so placeholders, the event locale and the event log entry all behave exactly as they do everywhere else.
The one addition is {amount}, which renders what that registrant still
owes. The body is one string sent to everybody, so the figure can only reach
the mail as a placeholder — core replaces it per recipient. It is offered to
every registration e-mail, core's own E-mail dialog included, because
placeholders are registered per context for the whole instance; that is also
why the feature has an admin switch, since two plugins claiming amount would
break every registration e-mail rather than only the ones using it.
Attach ticket is removed from the dialog. Nobody on this list has paid, so there is no ticket to attach, and the switch would offer a choice whose only outcomes are "nothing" and "a warning explaining why it was nothing".
What is not trusted
The recipients are found, never read off the request — on the send as well as on the open. So the mail goes to whoever still owes money at the moment Send is pressed, somebody who paid while the dialog sat open is dropped, and the endpoint cannot be talked into mailing somebody who was never on the list. Both endpoints check the admin switch and the event's payment feature again for the same reason: switched off is off, not merely hidden.
Why not the Actions menu
That is where the rest of the bulk actions live, and core even has a signal for
adding to it (registrant_list_action_menu). It does not work for this. Both
the Actions dropdown itself and every entry inside it are rendered with the
disabled class, which only js-requires-selected-row ever takes off again —
so an action that deliberately ignores the selection cannot be reached there at
all. The button goes through registration-status-action-button instead, which
is core's own hook for an extra button in that toolbar and which nothing in
core uses, so there is no ordering to negotiate with anybody.
7. The membership's e-mail address
On the plugin's admin page, on by default.
Somebody who is signed in finds the registration form's E-mail field filled in from their account, as Indico has always filled it in — and now with a padlock next to it, saying Your STSA membership is registered under this address. They register under the address on their membership, and the association can match every registration back to the member who made it. It is also what keeps the member discount honest: the discount is decided from the link between a registration and an account, and that link is made from this field.
Any address on the account is accepted, not only the first one. Indico looks
a registration's user up with get_user_by_email, which matches secondary
addresses too, so a member who registered under one of theirs is already linked
to the right membership; refusing it would leave them with a registration they
could no longer edit, because the address it already holds is one the form would
no longer take.
Three cases are deliberately left alone:
- Organizers. Adding a registration from the management area means typing that participant's address, so nothing there is locked or checked.
- Somebody else's registration. A registration is editable from the link in its confirmation e-mail, so the person in front of it need not be the person it belongs to. The lock applies only when the registration is the signed-in member's own — a member who opens somebody else's link must not stamp their own address onto it.
- Invitations. The address on an invitation is the organizer's choice, and core writes that address into the registration whatever was submitted. A lock there could only refuse a registration Indico was about to make correctly.
The padlock is not is_field_data_locked
That signal is the obvious way to lock a field, and it is what the member
discount field uses — but it means far more than "the participant may not edit
this". Core skips a locked field when a registration is created or
modified, leaving its data empty; that is exactly why the discount field can
have this plugin as its only writer. Doing the same to the e-mail field would be
fatal: Registration.email is not nullable and is written from that same loop,
so every registration would end in an integrity error long before any handler of
ours could put the address back.
So the padlock is drawn by writing lockedReason into the flat submission data
the form is rendered from — the same key core's own get_locked_reason fills in
there, so the participant gets core's disabled input and core's padlock with our
wording under it, and none of core's write-side behaviour changes. Core's own
data-lock-email, which is how an invitation locks the field, is not available:
the template always renders it, and a second copy of an attribute is not read.
What actually enforces it
A disabled input is a courtesy, not a rule. The rule is
before_check_registration_email, which core asks of every address before it is
used — both when the form checks one as it is typed and when a registration is
submitted, on a new registration and on a modification alike. So a hand-built
POST meets it too, and the answer is core's own email-other-user conflict,
which the registration form already renders as This email address is not
associated with your Indico account. Answering with a name of our own would
leave the form falling through to a sentence about the account the registration
will not be linked to, which is both alarming and wrong.
The draft that carries answers across the trip to the login page leaves the e-mail field out once the lock applies. Restoring the address somebody typed while signed out would put a value the server refuses into the one field they can no longer correct — and core has already filled it in from the account they have just signed in with, which is the address they are registering under anyway.
Working with the group registration plugin
Both plugins put a negative line item on the registration, and both compute it from "everything billable except my own line". Left alone, each would be calculated from the other and every recomputation would move both.
So the member discount is calculated from the registration fee and the paid options only — both discount lines are excluded from its basis. The group discount is then free to stack on top of it, and whenever the member discount changes, the plugin asks the group plugin to recompute the group's line so it is never one registration out of date. The dependency runs one way, so it cannot loop.
Neither plugin can push a registration below zero: each clamps its own line to the amount it applies to, and Indico clamps the total at zero regardless.
The price the plan picker quotes
The group plugin's plan picker puts a price against every plan — Pair, 2 members, 90.00 SGD each — and shows the same kind of number again when somebody pastes a code to preview the group they are joining. Both are worked out in the browser from one number the server hands it: the form's standard registration fee. The member discount was not in that number, so a signed-in member was quoted the full price and then charged less than they had been shown.
So the fee itself is re-quoted. get_flat_section_submission_data builds the
field data the whole registration form is rendered from and is decorated with
@make_interceptable, which is Indico's own invitation for a plugin to step in;
the plugin calls it, and where the discount is going to land it writes the fee
that member actually pays. Changing the fee rather than the plan list is what
fixes both quotes at once — the join preview looks the group's plan up over
AJAX, so a rewritten plan list would never reach it — and it keeps this side
from having an opinion about what a group plan is worth, which is the group
plugin's business.
The picker carries two fees for exactly this, and only one of them is ours:
payerBasePrice, what the person in front of it pays before a group plan, is
written; basePrice, the standard fee, is left alone. That distinction is the
whole of the arithmetic. The group plugin works a plan's own rate out from
whichever of the two its Discount applies to setting names, exactly as it does
on the server — so a percentage plan set against the fee does not start
compounding with the member discount just because the picker is quoting a
discounted price. Group registration 0.2.4 is where payerBasePrice starts
to exist; against an older one the standard fee is written instead, and the
quote carries the discount but comes out high by the plan's percentage of it.
Whether the discount is going to land is the same decision apply_member_discount
makes, read forwards: a registration being edited answers for itself, and a new
one answers for whoever is filling it in, where being signed in is exactly what
the notice above the form already promises is enough. In the management area the
fee belongs to somebody who is not in the room, so the standard fee is quoted.
What no quote can include is the paid options nobody has chosen yet, so a member who then adds one pays a little more than the number the picker showed. That is the picker's own long-standing approximation rather than anything to do with the discount, and the option's price is in front of whoever is choosing it.
The plugin works perfectly well without the group registration plugin; the group switch simply disappears.
Building the assets
Released wheels already carry the compiled bundle. From a git checkout you need an Indico source tree at the version the plugin runs against:
git clone --branch v3.3.13 https://github.com/indico/indico ~/dev/indico
(cd ~/dev/indico && npm ci)
/opt/indico/.venv/bin/python build-assets.py --indico-source ~/dev/indico
Run it with the Python that Indico and this plugin are installed into: the build
resolves the plugin's own URL rules by importing it. --dev and --watch are
passed through.
Tests
pip install -e '.[dev]'
pytest
The unit tests cover the parts that hold the arithmetic and the string handling and need neither Indico nor a database. Everything that talks to the database is exercised against a real Indico instance.
Licence
MIT.
Metadata
Release files for indico-plugin-stsa 0.3.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| indico_plugin_stsa-0.3.2.tar.gz | 7.3 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| indico_plugin_stsa-0.3.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 14.7 MB
Release files / indico_plugin_stsa-0.3.2.tar.gz
| Download URL | indico_plugin_stsa-0.3.2.tar.gz |
|---|---|
| Size | 7.3 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
63a0f651a9592e8ccbfa2331c64180c4a654570dec162ba3fa7386dff679075c
|
|
BLAKE2b-256 checksum How to use checksums |
351dc84541caac6083746e4fdfbf8463d71e364b81a62cff7d77343a43a26ed3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency logRelease files / indico_plugin_stsa-0.3.2-py3-none-any.whl
| Download URL | indico_plugin_stsa-0.3.2-py3-none-any.whl |
|---|---|
| Size | 7.4 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f4a4671da3fbd85d69e1f92cd74313cd48ee34240680b3aa230692f93844e01a
|
|
BLAKE2b-256 checksum How to use checksums |
9d1ebf8931b62d11071942f1e10a22087db261182402a1460c093ea140f7fefd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency log