Skip to content

Split-field widget for PyCon job listings benefit#2985

Draft
JacobCoffee wants to merge 1 commit intomainfrom
feat/structured-job-postings-form
Draft

Split-field widget for PyCon job listings benefit#2985
JacobCoffee wants to merge 1 commit intomainfrom
feat/structured-job-postings-form

Conversation

@JacobCoffee
Copy link
Copy Markdown
Member

Summary

Sponsors currently fill in a single large markdown textarea for the "Job listings for us.pycon.org" benefit. PyCon-site regex-parses that free-form text into job cards, which is fragile. This PR replaces that textarea with a split-field widget ({title, location, url} × N rows) for that specific benefit only, keeping the underlying TextAsset storage as a single text field.

Paired with PyCon/pycon-site#669 which parses the composed text on sync.

How it works

  • New `apps/sponsors/structured_job_postings.py` exports a `StructuredJobPostingsField` + widget + parse/serialize helpers. (In its own module so `models/benefits.py` can import at the top without a circular.)
  • `RequiredTextAsset.as_form_field()` returns the structured field when `self.internal_name` contains any substring from `settings.STRUCTURED_JOB_POSTINGS_INTERNAL_NAMES` (default: `("job_listings", "job_postings")`). All other text assets fall through to the existing textarea.
  • Widget renders 15 rows by default (or `filled + 3`, whichever is larger). Unfilled rows are dropped on submit.
  • Rows compose to `Title | Location | URL\n...` in the stored TextAsset. 2-field rows (no location) also accepted.
  • Stored text parses back into rows on form init. Legacy markdown content is preserved as title-only rows so sponsors can see (and fix) unrecognized content instead of silently losing it.

What it does NOT do

  • No new benefit type, no new BenefitFeatureConfiguration subclass, no schema migration.
  • Does not touch other text-asset benefits — they still render as textarea.

Test plan

  • `./manage.py test apps.sponsors` — 353 passed
  • `./manage.py test apps.sponsors.tests.test_structured_job_postings` — 15 passed
  • `ruff check` + `ruff format --check` clean
  • Manually verify in `/manage/your-sponsorship-application-view/…` that the job listings benefit renders rows, that submitting composes text, and that loading re-populates rows
  • Manually verify an unrelated text-asset benefit still renders as textarea

Downstream

Once this deploys and sponsors re-save via the new widget, PyCon-site's `sync_sponsors` will parse the composed text into structured `SponsorJobPosting` rows (see PyCon/pycon-site#669). Sponsors who haven't re-saved keep rendering via the legacy markdown path on pycon-site — no regression.

🤖 Generated with Claude Code

Sponsors currently fill in a single large markdown textarea for the
"Job listings for us.pycon.org" benefit. PyCon-site then regex-parses
that free-form text into job cards, which is fragile.

This change replaces that textarea with a split-field widget ({title,
location, URL} x N rows) ONLY for benefits whose internal_name matches
a configured substring (default: "job_listings" or "job_postings"). The
widget composes the rows into pipe-delimited text on submission and
parses the stored text back into rows on form init, so the underlying
TextAsset storage remains a single text field. No model changes, no new
benefit type, no migration.

- New apps/sponsors/structured_job_postings.py with the field, widget,
  and parse/serialize helpers (lives outside forms.py to keep the
  benefits.py import top-level without circular issues).
- RequiredTextAsset.as_form_field() returns the structured field when
  the internal_name matches STRUCTURED_JOB_POSTINGS_INTERNAL_NAMES.
  All other text assets fall through to the default textarea.
- Template renders 15 rows (or filled+3, whichever is larger); unfilled
  rows are dropped on submit.
- Tests for parse/serialize/widget/field-selection paths.

Downstream: pycon-site parses the same pipe-delimited format on sync
into a structured SponsorJobPosting model, but also keeps the legacy
markdown rendering path for sponsors who haven't migrated yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Replaces the free-form markdown textarea used for the PyCon “job listings” required text asset with a structured multi-row widget (title/location/url) while keeping the underlying storage as the existing single TextAsset.text field.

Changes:

  • Added StructuredJobPostingsField/widget plus parse/serialize helpers to compose/decompose pipe-delimited storage text.
  • Updated RequiredTextAsset.as_form_field() to select the structured field based on configurable internal-name substrings.
  • Added a dedicated widget template and a new test module covering parsing, serialization, widget behavior, and field selection.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pydotorg/settings/base.py Adds configurable substrings for enabling the structured job postings widget on specific internal names.
apps/sponsors/models/benefits.py Selects the structured form field for matching RequiredTextAsset internal names.
apps/sponsors/structured_job_postings.py Implements the structured widget/field plus parse/serialize helpers.
apps/sponsors/templates/sponsors/widgets/structured_job_postings.html Provides the HTML rendering for the multi-row job postings widget.
apps/sponsors/tests/test_structured_job_postings.py Adds unit tests for parsing/serialization, widget composition, and as_form_field() selection.

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

Comment on lines +18 to +24
<input type="text" name="{{ widget.name }}__title" value="{{ row.title }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="text" name="{{ widget.name }}__location" value="{{ row.location }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="url" name="{{ widget.name }}__url" value="{{ row.url }}" maxlength="500" style="width:100%;">
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

None of the per-row inputs render id attributes or incorporate widget.attrs (e.g., the auto-generated id used by label for=..., aria-invalid, CSS classes). This breaks label focusing (the field label won’t point to any real control) and drops standard accessibility/error attributes Django would normally apply. Consider applying widget.attrs.id to the first row’s title input (or a dedicated focusable element) and propagating relevant attrs/ARIA to the inputs.

Suggested change
<input type="text" name="{{ widget.name }}__title" value="{{ row.title }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="text" name="{{ widget.name }}__location" value="{{ row.location }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="url" name="{{ widget.name }}__url" value="{{ row.url }}" maxlength="500" style="width:100%;">
<input type="text" name="{{ widget.name }}__title" value="{{ row.title }}" maxlength="255"{% if forloop.first and widget.attrs.id %} id="{{ widget.attrs.id }}"{% endif %}{% if widget.attrs.class %} class="{{ widget.attrs.class }}"{% endif %} style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="text" name="{{ widget.name }}__location" value="{{ row.location }}" maxlength="255"{% if widget.attrs.class %} class="{{ widget.attrs.class }}"{% endif %} style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="url" name="{{ widget.name }}__url" value="{{ row.url }}" maxlength="500"{% if widget.attrs.class %} class="{{ widget.attrs.class }}"{% endif %} style="width:100%;">

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +24
<input type="text" name="{{ widget.name }}__title" value="{{ row.title }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="text" name="{{ widget.name }}__location" value="{{ row.location }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="url" name="{{ widget.name }}__url" value="{{ row.url }}" maxlength="500" style="width:100%;">
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

The maxlength values (255/500) are hard-coded in the template and aren’t tied to any server-side validation or model constraints (TextAsset is a TextField). If these limits are intentional, consider centralizing them as constants (and validating in StructuredJobPostingsField) so the UI limits and backend behavior can’t drift; otherwise, consider removing maxlength to avoid unexpectedly preventing legitimate longer titles/URLs.

Suggested change
<input type="text" name="{{ widget.name }}__title" value="{{ row.title }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="text" name="{{ widget.name }}__location" value="{{ row.location }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="url" name="{{ widget.name }}__url" value="{{ row.url }}" maxlength="500" style="width:100%;">
<input type="text" name="{{ widget.name }}__title" value="{{ row.title }}" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="text" name="{{ widget.name }}__location" value="{{ row.location }}" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="url" name="{{ widget.name }}__url" value="{{ row.url }}" style="width:100%;">

Copilot uses AI. Check for mistakes.
Comment on lines +2 to +32
<div class="structured-job-postings">
<p class="help">
Enter each job posting on its own row. All fields optional per row — leave any row blank to skip.
</p>
<table class="structured-job-postings__table" style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:0.25rem 0.5rem;">Job title</th>
<th style="text-align:left;padding:0.25rem 0.5rem;">Location</th>
<th style="text-align:left;padding:0.25rem 0.5rem;">Link to job</th>
</tr>
</thead>
<tbody>
{% for row in widget.rows %}
<tr>
<td style="padding:0.25rem 0.5rem;">
<input type="text" name="{{ widget.name }}__title" value="{{ row.title }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="text" name="{{ widget.name }}__location" value="{{ row.location }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="url" name="{{ widget.name }}__url" value="{{ row.url }}" maxlength="500" style="width:100%;">
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# Hide the original field input — the rows above replace it. #}
<input type="hidden" name="{{ widget.name }}" value="">
</div>
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

This widget renders block-level markup (<div>, <p>, <table>). In the sponsorship assets update page the form is rendered with {{ form.as_p }}, which wraps each field in a <p>; that produces invalid HTML (nested <p> / block elements inside <p>) and can lead to broken layout in browsers. Consider updating the form template to render fields without as_p (loop fields with <div> rows), or adjust the widget template to avoid emitting <p>/outer block wrappers when used in as_p contexts.

Suggested change
<div class="structured-job-postings">
<p class="help">
Enter each job posting on its own row. All fields optional per row — leave any row blank to skip.
</p>
<table class="structured-job-postings__table" style="width:100%;border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left;padding:0.25rem 0.5rem;">Job title</th>
<th style="text-align:left;padding:0.25rem 0.5rem;">Location</th>
<th style="text-align:left;padding:0.25rem 0.5rem;">Link to job</th>
</tr>
</thead>
<tbody>
{% for row in widget.rows %}
<tr>
<td style="padding:0.25rem 0.5rem;">
<input type="text" name="{{ widget.name }}__title" value="{{ row.title }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="text" name="{{ widget.name }}__location" value="{{ row.location }}" maxlength="255" style="width:100%;">
</td>
<td style="padding:0.25rem 0.5rem;">
<input type="url" name="{{ widget.name }}__url" value="{{ row.url }}" maxlength="500" style="width:100%;">
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# Hide the original field input — the rows above replace it. #}
<input type="hidden" name="{{ widget.name }}" value="">
</div>
<span class="structured-job-postings">
<span class="help">
Enter each job posting on its own row. All fields optional per row — leave any row blank to skip.
</span><br>
{% for row in widget.rows %}
<span class="structured-job-postings__row">
<span class="structured-job-postings__label">Job title</span>
<input type="text" name="{{ widget.name }}__title" value="{{ row.title }}" maxlength="255" style="width:30%;">
<span class="structured-job-postings__label">Location</span>
<input type="text" name="{{ widget.name }}__location" value="{{ row.location }}" maxlength="255" style="width:30%;">
<span class="structured-job-postings__label">Link to job</span>
<input type="url" name="{{ widget.name }}__url" value="{{ row.url }}" maxlength="500" style="width:30%;">
</span>{% if not forloop.last %}<br>{% endif %}
{% endfor %}
{# Hide the original field input — the rows above replace it. #}
<input type="hidden" name="{{ widget.name }}" value="">
</span>

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +11
<th style="text-align:left;padding:0.25rem 0.5rem;">Job title</th>
<th style="text-align:left;padding:0.25rem 0.5rem;">Location</th>
<th style="text-align:left;padding:0.25rem 0.5rem;">Link to job</th>
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

The table headers should include scope="col" (and/or IDs) so assistive tech can correctly associate them with the corresponding cell controls. As-is, the inputs rely on visual placement only, which reduces accessibility for screen readers.

Suggested change
<th style="text-align:left;padding:0.25rem 0.5rem;">Job title</th>
<th style="text-align:left;padding:0.25rem 0.5rem;">Location</th>
<th style="text-align:left;padding:0.25rem 0.5rem;">Link to job</th>
<th scope="col" style="text-align:left;padding:0.25rem 0.5rem;">Job title</th>
<th scope="col" style="text-align:left;padding:0.25rem 0.5rem;">Location</th>
<th scope="col" style="text-align:left;padding:0.25rem 0.5rem;">Link to job</th>

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants