Split-field widget for PyCon job listings benefit#2985
Split-field widget for PyCon job listings benefit#2985JacobCoffee wants to merge 1 commit intomainfrom
Conversation
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>
There was a problem hiding this comment.
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.
| <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%;"> |
There was a problem hiding this comment.
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.
| <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%;"> |
| <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%;"> |
There was a problem hiding this comment.
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.
| <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%;"> |
| <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> |
There was a problem hiding this comment.
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.
| <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> |
| <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> |
There was a problem hiding this comment.
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.
| <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> |
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
What it does NOT do
Test plan
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