Skip to content

Namespace ticket CSV column families + shared value separator for export/import round-trip#569

Open
JpMaxMan wants to merge 5 commits into
mainfrom
feature/ticket-csv-column-prefixes
Open

Namespace ticket CSV column families + shared value separator for export/import round-trip#569
JpMaxMan wants to merge 5 commits into
mainfrom
feature/ticket-csv-column-prefixes

Conversation

@JpMaxMan

@JpMaxMan JpMaxMan commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

ref: https://app.clickup.com/t/86baraz3d

Summary

Follow-up promised on #567's column-naming thread: rather than defend the asymmetry, both additive per-name column families are now namespaced — badge_feature:{name} joins extra_question:{name} — uniformly across the import template, the import (bare feature names remain accepted, so existing CSVs keep working), and the ticket CSV export. Import additionally matches questions by label, since export emits sanitized labels as headers.

Multi-value answers round-trip too: export cells now use the import's | separator (shared ISummitOrderService::ExtraQuestionValueSeparator const), with each stored value id resolved to its label individually — post-splitting getNiceValue()'s comma-joined output would corrupt labels containing commas. A CSV exported from GET /summits/{id}/tickets/csv now re-imports without any column renaming or cell editing.

Note: export headers and multi-value cell separators change. We have no scripted consumers of the export, so no compat shim is included. Remaining edge: a value label that itself contains | won't round-trip — inherent to any textual separator.

Tests

testImportTicketDataAcceptsPrefixedBadgeFeatureColumns (canonical prefixed form; legacy bare form still covered by the existing badge-features test), testImportTicketDataMatchesExtraQuestionByLabel, and testTicketCSVExportMultiValueAnswerRoundTripsThroughImport — the last one exports a two-value answer and feeds the exported cell verbatim back through the import.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features / Improvements

    • Ticket CSV export/import now uses standardized, prefixed column headers for badge features and extra questions, with continued support for legacy unprefixed columns.
    • Multi-value extra-question answers now round-trip correctly and preserve selections.
  • Bug Fixes

    • Extra questions can be matched by both question label and internal name during CSV import.
    • If CSV columns are ambiguous or conflicting, the import safely skips unclear entries and uses the eligible match when available.

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8297dbb2-dc26-4ba7-9e4c-5371a5857ac5

📥 Commits

Reviewing files that changed from the base of the PR and between 2198074 and d49d29c.

📒 Files selected for processing (1)
  • app/Services/Model/Imp/SummitOrderService.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/Services/Model/Imp/SummitOrderService.php

📝 Walkthrough

Walkthrough

Updates ticket CSV export/import to use badge_feature:{name} and extra_question:{name} column keys, with legacy bare feature-name support on import. Adds constants, separator handling for multi-value answers, import matching by name or label, ambiguity filtering, and tests for round-trip behavior.

Changes

Ticket CSV Column Namespacing

Layer / File(s) Summary
Column naming constants
app/Services/Model/ISummitOrderService.php
Adds BadgeFeatureColumnPrefix and ExtraQuestionValueSeparator alongside the existing extra-question prefix.
CSV export serializer namespacing
app/ModelSerializers/Summit/Registration/SummitAttendeeTicketCSVSerializer.php
Exports badge features and extra questions under prefixed column keys and formats multi-value answers with the configured separator.
Controller column allowlist and docs
app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitTicketApiController.php
Updates export allowlists, import template headers, and API docs to describe prefixed badge feature and extra question columns.
Import column resolution and matching logic
app/Services/Model/Imp/SummitOrderService.php
Resolves prefixed badge feature columns with legacy fallback, matches extra questions by name or label, filters ambiguous candidates, and uses the configured value separator for list parsing.
Test coverage for column matching and round-trip
tests/SummitOrderServiceTest.php
Adds tests for prefixed badge feature import, label-based matching, ambiguity handling, and multi-value export/import round-trips.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

Suggested reviewers: romanetar, smarcet

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main CSV namespacing and shared separator changes for ticket export/import round-trips.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/ticket-csv-column-prefixes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@JpMaxMan JpMaxMan requested review from romanetar and smarcet July 3, 2026 18:42
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-569/

This page is automatically updated on each push to this PR.

@JpMaxMan JpMaxMan force-pushed the feature/ticket-csv-column-prefixes branch from a5ea1f9 to 21ecf73 Compare July 3, 2026 21:42
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-569/

This page is automatically updated on each push to this PR.

@JpMaxMan JpMaxMan force-pushed the feature/ticket-csv-column-prefixes branch from 21ecf73 to c3ac0d1 Compare July 3, 2026 23:10
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-569/

This page is automatically updated on each push to this PR.

@JpMaxMan JpMaxMan force-pushed the feature/ticket-csv-column-prefixes branch from c3ac0d1 to 7b22c5c Compare July 6, 2026 17:24
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-569/

This page is automatically updated on each push to this PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR aligns ticket CSV export/import semantics to support lossless round-trips by (1) namespacing additive “column families” and (2) sharing a consistent multi-value separator between export and import, while extending import to recognize extra questions by label (to match exported headers).

Changes:

  • Namespaced badge feature columns as badge_feature:{name} (while preserving legacy bare feature-name columns on import).
  • Exported extra-question headers as extra_question:{label} and updated import to match questions by name or label.
  • Export now formats multi-value answers using the shared | separator and resolves each stored value ID to its label.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/SummitOrderServiceTest.php Adds regression tests for namespaced badge-feature columns, label-based extra-question matching, and multi-value round-trip.
app/Services/Model/ISummitOrderService.php Introduces shared constants for column prefixes and multi-value separator.
app/Services/Model/Imp/SummitOrderService.php Updates import to accept badge_feature: columns, match extra questions by label, and use shared multi-value separator.
app/ModelSerializers/Summit/Registration/SummitAttendeeTicketCSVSerializer.php Updates export headers to use namespaced prefixes and formats list answers with `
app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitTicketApiController.php Updates template generation and OpenAPI descriptions to reflect namespaced badge-feature columns and label acceptance.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +4787 to +4790
$question = $summit->getOrderExtraQuestionByName($question_name);
// fall back to the label: the ticket csv export emits question labels as column names
if (is_null($question))
$question = $summit->getOrderExtraQuestionByLabel($question_name);

@romanetar romanetar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@JpMaxMan please review comments

];
},
function () use ($summit) {
$allowed_columns = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Export's columns= query-param allowlist wasn't updated to the new namespaced keys. $allowed_columns
still pushes bare $featuresType->getName() / $question->getLabel(), never the badge_feature:/extra_question: prefixed forms the serializer now emits. Confirmed via full call-trace: the
default (no columns=) export is unaffected since CSVExporter::export() ignores this list entirely and just uses the serialized row's actual keys — but a client explicitly requesting
?columns=badge_feature:VIP (the real, current column name) gets rejected with a ValidationException since that string isn't in the stale allowlist.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 970b38a — both allowlist loops now emit the namespaced forms, and the question entries use the sanitized label (html_entity_decode(strip_tags(...))) so they match what the serializer actually emits (the raw-label form in the old allowlist was already drifting from the emitted header for HTML-bearing labels). Confirmed your call-trace read: the default no-columns= path was unaffected either way.

@@ -4781,6 +4785,9 @@ private function resolveExtraQuestionColumns(Summit $summit, SummitAttendee $att
}

$question = $summit->getOrderExtraQuestionByName($question_name);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Extra-question name→label fallback can silently mis-file an answer to the wrong question. getOrderExtraQuestionByName() and the new getOrderExtraQuestionByLabel() fallback are independent lookups with no cross-check. If Question A's name equals Question B's label (both plausible, free-text fields with no cross-field uniqueness constraint), a CSV column extra_question:{that string} — including one produced by this PR's own export, which always emits labels as headers — resolves to Question A via the name-match and never reaches the label fallback intended for B. The answer is silently written to the wrong question; no warning is logged since a question was found. This directly undermines the PR's stated round-trip goal. (Independently confirmed by 3 of the 6 finder agents via the same mechanism.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 970b38a — agreed silent mis-file is the worst outcome for an importer, and reordering the lookups would just mirror the failure mode. The fix runs both lookups side by side: when name-match and label-match disagree, the column is logged as ambiguous (naming both question ids) and skipped entirely. Regression test added (testImportTicketDataSkipsAmbiguousExtraQuestionColumn): question A's name == question B's label → the ambiguous column touches neither question, row still processes.

@JpMaxMan JpMaxMan requested a review from romanetar July 6, 2026 20:02
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-569/

This page is automatically updated on each push to this PR.

@romanetar romanetar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@JpMaxMan found an edge-case regression introduced by the previous fix. Please review comment.

// column names ); when the two lookups disagree the column is ambiguous — one
// question's name is another question's label — skip it rather than silently
// filing the answer under the wrong question
$question = $summit->getOrderExtraQuestionByName($question_name);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@JpMaxMan The ambiguity check runs before the usage/eligibility filters, causing false-positive skips. The name-vs-label collision check
(lines 4791-4806) executes before the OrderQuestionUsage check (line 4822) and isAllowedQuestion() check (line 4834). So if a legitimately-matched Ticket-usage question's name collides
with the label of an unrelated Order-scoped (or attendee-disallowed) question, the column is now flagged "ambiguous" and dropped entirely — even though the label-matched question would
have been rejected by the usage/eligibility check moments later anyway and was never a real candidate. This is a regression in import completeness introduced by the fix: previously
(name-match-only, no fallback) this exact column would have imported correctly. No test exercises this cross-usage case — only same-usage true collisions are covered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed in 4a69e57. The name/label lookups now build a candidate list that's vetted through the existing order-scoped and isAllowedQuestion() rejections first; a column is only skipped as ambiguous when two eligible questions survive, so an ineligible collision partner can no longer shadow a legitimate match. Added testImportTicketDataIneligibleQuestionDoesNotMakeColumnAmbiguous covering the cross-usage case (ticket-usage question named X vs order-scoped question labeled X → resolves to the ticket-usage question).

Worth noting for severity: order-scoped questions aren't surfaced anywhere in the registration FE today, so that half of the path is defensive — the realistic leg was the isAllowedQuestion() one, which is now also decided per-candidate before ambiguity.

@JpMaxMan JpMaxMan requested a review from romanetar July 7, 2026 15:39
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-569/

This page is automatically updated on each push to this PR.

@smarcet smarcet changed the base branch from feature/ticket-import-extra-question-answers to main July 7, 2026 18:20
JpMaxMan and others added 3 commits July 7, 2026 15:37
…ator for export/import round-trip

Adopts badge_feature:{name} alongside extra_question:{name} uniformly
across the import template, the import (bare feature names still
accepted for legacy CSVs) and the ticket CSV export; import matches
questions by label since export emits labels; multi-value answer cells
now export with the import's '|' separator (per-id label resolution, so
comma-bearing labels stay intact). Closes the export->import round-trip
gap raised in #567 review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion-column skip

Per review: the columns= allowlist now emits the namespaced forms
(sanitized labels, matching the serializer exactly), and import matching
runs the name and label lookups side by side — a column that is one
question's name and another's label is logged as ambiguous and skipped
instead of silently filed under the name match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@smarcet smarcet force-pushed the feature/ticket-csv-column-prefixes branch from 4a69e57 to f9198e5 Compare July 7, 2026 18:53
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-569/

This page is automatically updated on each push to this PR.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/Services/Model/Imp/SummitOrderService.php (1)

4910-4910: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Round-trip is not faithful when a choice label itself contains the | separator.

Export joins multi-values with | and import splits on | here, so a value whose name/label contains | (e.g. A|B) becomes ambiguous tokens on re-import. The PR already handles labels containing commas via per-value resolution; labels containing the separator itself are the remaining gap. Likely rare, but worth confirming such labels can't be created (or documenting the constraint).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Services/Model/Imp/SummitOrderService.php` at line 4910, The round-trip
parsing in SummitOrderService::importExtraQuestionValue is ambiguous because it
splits exported multi-values on the same ExtraQuestionValueSeparator used for
joining, so labels containing “|” cannot be faithfully re-imported. Update the
import/export handling around the explode(self::ExtraQuestionValueSeparator,
$value) logic to either reject or escape separator characters in choice labels,
or resolve values without relying on raw separator splitting; make the
constraint explicit if such labels are not allowed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@app/ModelSerializers/Summit/Registration/SummitAttendeeTicketCSVSerializer.php`:
- Around line 159-171: In formatAnswerValue, the current empty($label) check
will incorrectly drop a legitimate label of "0" from exported CSV values.
Replace that loose emptiness check with a strict null/empty-string validation so
only missing labels are skipped while preserving valid "0" labels. Keep the rest
of the label collection logic in
SummitAttendeeTicketCSVSerializer::formatAnswerValue unchanged.

---

Nitpick comments:
In `@app/Services/Model/Imp/SummitOrderService.php`:
- Line 4910: The round-trip parsing in
SummitOrderService::importExtraQuestionValue is ambiguous because it splits
exported multi-values on the same ExtraQuestionValueSeparator used for joining,
so labels containing “|” cannot be faithfully re-imported. Update the
import/export handling around the explode(self::ExtraQuestionValueSeparator,
$value) logic to either reject or escape separator characters in choice labels,
or resolve values without relying on raw separator splitting; make the
constraint explicit if such labels are not allowed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 19113cca-2a9f-4085-8976-03418d9cc03b

📥 Commits

Reviewing files that changed from the base of the PR and between c1d5eef and f9198e5.

📒 Files selected for processing (5)
  • app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitTicketApiController.php
  • app/ModelSerializers/Summit/Registration/SummitAttendeeTicketCSVSerializer.php
  • app/Services/Model/ISummitOrderService.php
  • app/Services/Model/Imp/SummitOrderService.php
  • tests/SummitOrderServiceTest.php

Comment on lines +159 to +171
private static function formatAnswerValue(SummitOrderExtraQuestionType $question, string $value): string
{
if (!$question->allowsValues())
return strval($question->getNiceValue($value));

$labels = [];
foreach (explode(ExtraQuestionType::QuestionChoicesCharSeparator, $value) as $value_id) {
$label = $question->getNiceValue(trim($value_id));
if (empty($label)) continue;
$labels[] = $label;
}
return implode(ISummitOrderService::ExtraQuestionValueSeparator, $labels);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

empty($label) silently drops a legitimate label equal to "0".

PHP's empty() treats the string "0" as empty (empty("0") === true), so a checkbox/list value whose nice label is literally "0" would be silently excluded from the exported cell, causing data loss during export.

🐛 Proposed fix using a strict check
         $labels = [];
         foreach (explode(ExtraQuestionType::QuestionChoicesCharSeparator, $value) as $value_id) {
             $label = $question->getNiceValue(trim($value_id));
-            if (empty($label)) continue;
+            if ($label === null || $label === '') continue;
             $labels[] = $label;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private static function formatAnswerValue(SummitOrderExtraQuestionType $question, string $value): string
{
if (!$question->allowsValues())
return strval($question->getNiceValue($value));
$labels = [];
foreach (explode(ExtraQuestionType::QuestionChoicesCharSeparator, $value) as $value_id) {
$label = $question->getNiceValue(trim($value_id));
if (empty($label)) continue;
$labels[] = $label;
}
return implode(ISummitOrderService::ExtraQuestionValueSeparator, $labels);
}
private static function formatAnswerValue(SummitOrderExtraQuestionType $question, string $value): string
{
if (!$question->allowsValues())
return strval($question->getNiceValue($value));
$labels = [];
foreach (explode(ExtraQuestionType::QuestionChoicesCharSeparator, $value) as $value_id) {
$label = $question->getNiceValue(trim($value_id));
if ($label === null || $label === '') continue;
$labels[] = $label;
}
return implode(ISummitOrderService::ExtraQuestionValueSeparator, $labels);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/ModelSerializers/Summit/Registration/SummitAttendeeTicketCSVSerializer.php`
around lines 159 - 171, In formatAnswerValue, the current empty($label) check
will incorrectly drop a legitimate label of "0" from exported CSV values.
Replace that loose emptiness check with a strict null/empty-string validation so
only missing labels are skipped while preserving valid "0" labels. Keep the rest
of the label collection logic in
SummitAttendeeTicketCSVSerializer::formatAnswerValue unchanged.

@smarcet

smarcet commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

@JpMaxMan Deep review pass on this PR, adversarially verified (two independent checks per finding, actively trying to disprove each one before posting).

Label sanitization mismatch breaks the round-trip for HTML/entity-bearing labels

Export builds the header as extra_question: + html_entity_decode(strip_tags($question->getLabel())) (SummitAttendeeTicketCSVSerializer.php:91-94). Import resolves that header via Summit::getOrderExtraQuestionByLabel(), which does an exact match against the raw stored label (Summit.php:4539-4545, Criteria::expr()->eq('label', trim($label))) — no strip_tags/html_entity_decode anywhere on the import side.

So any question whose label contains HTML tags or entities exports a sanitized header that can never equal the raw stored label. If the question's name doesn't happen to match either, the column is treated as "unknown question" and the answer is silently dropped (just a debug/warning log, no error surfaced).

This isn't a hypothetical edge case:

  • SummitOrderExtraQuestionTypeService.php:327 sets setLabel(trim($question['question']['html'])) for Eventbrite-seeded questions (and :343 for waiver questions), both with usage = TicketQuestionUsage — exactly the questions this export/import round-trip targets. That job is live and wired (IngestSummitExternalRegistrationData, dispatched from SummitOrderService.php:5146).
  • Even outside Eventbrite, the normal admin path runs labels through HTMLCleaner::cleanData() (HTMLPurifier), which by default permits common tags (b, i, em, strong, a, p, ...) rather than stripping everything — so an admin bolding part of a question label through the normal UI hits the same mismatch.
  • ExtraQuestionType::setLabel() only trims whitespace — nothing sanitizes the label at save time to make this a non-issue.

Proof — this test reproduces the drop (label Caf&eacute; Preference → exported header Café Preference → re-import of that exact exported cell loses the answer):

public function testTicketCSVExportHtmlEntityLabelDoesNotRoundTripThroughImport()
{
    Queue::fake();

    // label as stored e.g. by the Eventbrite ingestion path (SummitOrderExtraQuestionTypeService
    // ::seedSummitOrderExtraQuestionTypesFromEventBrite) or by an admin entering basic markup
    // through the normal API (HTMLPurifier permits <b>/<i>/<a>/... by default)
    $question = $this->insertOrderExtraQuestion
    (
        'DIET_REQ',
        ExtraQuestionTypeConstants::TextQuestionType,
        [],
        SummitOrderExtraQuestionTypeConstants::TicketQuestionUsage,
        'Caf&eacute; Preference'
    );

    $attendee = $this->getDefaultAttendee();
    $ticket = $attendee->getTickets()->first();

    // export: header is the sanitized label ( html_entity_decode(strip_tags(...)) ), not the raw one
    $values = SerializerRegistry::getInstance()
        ->getSerializer($ticket, SerializerRegistry::SerializerType_CSV)
        ->serialize(null, [], [], [
            'ticket_questions' => [$question],
            'answers_by_owner' => [$attendee->getId() => [$question->getId() => 'Vegan']],
        ]);

    $question_column = 'extra_question:Café Preference';
    $this->assertArrayHasKey($question_column, $values);
    $this->assertEquals('Vegan', $values[$question_column]);

    // re-import the exact exported header + cell, verbatim, for a new attendee
    $unassigned = $this->getUnassignedTicket();
    $csv_content = <<<CSV
number,attendee_email,attendee_first_name,attendee_last_name,{$question_column}
{$unassigned->getNumber()},new.attendee@nowhere.com,New,Attendee,Vegan
CSV;

    $service = $this->buildTicketDataImportService($csv_content);
    $service->processTicketData(self::$summit->getId(), 'tickets.csv');

    $new_attendee = App::make(ISummitAttendeeRepository::class)
        ->getBySummitAndEmail(self::$summit, 'new.attendee@nowhere.com');
    $this->assertNotNull($new_attendee);

    // getOrderExtraQuestionByLabel() matches the RAW stored label ('Caf&eacute; Preference'),
    // which never equals the sanitized exported header ('Café Preference'), so the column is
    // treated as an unknown question and the answer is silently dropped
    $answer = $new_attendee->getExtraQuestionAnswerByQuestion($question);
    $this->assertNotNull($answer, 'expected the CSV export/import round trip to preserve the answer');
    $this->assertEquals('Vegan', $answer->getValue());
}

Ran against the current branch head (f9198e54d):

1) Tests\SummitOrderServiceTest::testTicketCSVExportHtmlEntityLabelDoesNotRoundTripThroughImport
expected the CSV export/import round trip to preserve the answer
Failed asserting that null is not null.

Suggested fix: apply the same html_entity_decode(strip_tags(...)) transform to the DB comparison (compare in PHP, not in the Criteria query), or add a dedicated sanitized-label lookup that mirrors the export transform exactly.

Extra-question column resolution issues two DB queries per column per row

resolveExtraQuestionColumns (SummitOrderService.php:4795-4796) unconditionally calls both getOrderExtraQuestionByName() and getOrderExtraQuestionByLabel() for every extra_question:* column on every CSV row, with no memoization. Summit::$order_extra_questions is fetch: 'EXTRA_LAZY' (Summit.php:589), so each matching() call is a fresh SQL query rather than an in-memory filter — confirmed by tracing into LazyCriteriaCollection::doInitialize(). Before this PR it was one query per column per row (name-match only); now it's two, unconditionally.

I found this is a deliberate trade-off (an earlier commit on this branch short-circuited the label lookup; a later one removed the short-circuit specifically to fix the ambiguous name/label collision case), and with realistic CSVs having ~1-3 extra-question columns against a small per-summit question table in a background queued job, it's minor, not a hot-path regression. Flagging mainly as a follow-up: consider pre-loading $summit's extra questions once per import job and building a local name/label index instead of two DB round-trips per column per row.

@smarcet smarcet left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@JpMaxMan please review

…sanitized label, not just the raw stored one

Also folds the by-name/by-label resolution into a single per-summit
index built once per import job instead of two DB round-trips per
extra_question column per row (order_extra_questions is EXTRA_LAZY).
@JpMaxMan

JpMaxMan commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@smarcet Both landed in 2198074 — thanks for the adversarially-verified pass, this is exactly the kind of review that catches what a quick look wouldn't.

Finding 1 (HTML/entity label mismatch): fixed by folding the by-name and by-label resolution into one per-summit lookup index (getOrderExtraQuestionsIndex()) keyed by name, raw label, and the export-sanitized label (html_entity_decode(strip_tags(...)) — same transform the serializer applies). Added testTicketCSVExportHtmlEntityLabelRoundTripsThroughImport, adapted from your repro (Caf&eacute; Preference → exported Café Preference → re-import now resolves instead of silently dropping the answer).

Finding 2 (2x EXTRA_LAZY queries per column per row): resolved as a side effect of the same change — the index is built once per summit (cached on the service instance) instead of two matching() round-trips per column per row.

Re-requesting your review on this one — CI is running now.

@JpMaxMan JpMaxMan requested a review from smarcet July 8, 2026 02:30
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-569/

This page is automatically updated on each push to this PR.

…rt of every import job

ISummitOrderService is bound as an application singleton
(ModelServicesProvider.php:356) -- on a warm queue worker, the
per-summit index cache introduced in the previous commit would
survive across import jobs, so a question added/edited between two
runs for the same summit would resolve against stale in-memory data.
Force a rebuild once at the top of processTicketData instead of
relying on lazy caching alone.
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-569/

This page is automatically updated on each push to this PR.

@smarcet

smarcet commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

@JpMaxMan please review unit tests ( CI is red )
https://github.com/OpenStackweb/summit-api/actions/runs/28919500453/job/85793392737?pr=569#step:10:816

  1. Tests\SummitOrderServiceTest::testTicketCSVExportHtmlEntityLabelRoundTripsThroughImport
    expected the CSV export/import round trip to preserve the answer
    Failed asserting that null is not null.

/home/runner/work/summit-api/summit-api/tests/SummitOrderServiceTest.php:1088

@smarcet smarcet left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@JpMaxMan please review unit tests
CI is red
https://github.com/OpenStackweb/summit-api/actions/runs/28919500453/job/85793392737?pr=569#step:10:816

  1. Tests\SummitOrderServiceTest::testTicketCSVExportHtmlEntityLabelRoundTripsThroughImport
    expected the CSV export/import round trip to preserve the answer
    Failed asserting that null is not null.

/home/runner/work/summit-api/summit-api/tests/SummitOrderServiceTest.php:1088

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.

4 participants