-
-
Notifications
You must be signed in to change notification settings - Fork 672
Split-field widget for PyCon job listings benefit #2985
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| """Split-field widget + helpers for the sponsor job postings benefit. | ||
|
|
||
| Stores composed rows as pipe-delimited lines in the existing TextAsset | ||
| value. No new model or benefit type — this is purely a form-layer concern. | ||
|
|
||
| Lives in its own module so models/benefits.py can import the field at the | ||
| top without pulling in apps.sponsors.forms (circular). | ||
| """ | ||
|
|
||
| from django import forms | ||
|
|
||
| STRUCTURED_JOB_POSTINGS_BLANK_ROW_COUNT = 3 | ||
| STRUCTURED_JOB_POSTINGS_MIN_VISIBLE_ROWS = 15 | ||
| _TITLE_URL_PARTS = 2 | ||
| _TITLE_LOCATION_URL_PARTS = 3 | ||
|
|
||
|
|
||
| def parse_structured_job_postings(text): | ||
| """Parse pipe-delimited job listing text into a list of row dicts. | ||
|
|
||
| Expected format, one job per line: | ||
| Title | Location | URL | ||
|
|
||
| Location is optional (2 fields also accepted). Lines that don't match | ||
| that shape are preserved as title-only rows so the sponsor can see | ||
| (and fix) unrecognized content rather than silently losing it. | ||
| """ | ||
| if not text: | ||
| return [] | ||
| rows = [] | ||
| for raw_line in text.replace("\ufeff", "").splitlines(): | ||
| line = raw_line.strip() | ||
| if not line: | ||
| continue | ||
| parts = [p.strip() for p in line.split("|")] | ||
| if len(parts) == _TITLE_URL_PARTS: | ||
| title, url = parts | ||
| location = "" | ||
| elif len(parts) == _TITLE_LOCATION_URL_PARTS: | ||
| title, location, url = parts | ||
| else: | ||
| rows.append({"title": line, "location": "", "url": ""}) | ||
| continue | ||
| rows.append({"title": title, "location": location, "url": url}) | ||
| return rows | ||
|
|
||
|
|
||
| def serialize_structured_job_postings(rows): | ||
| """Compose a list of row dicts back into pipe-delimited text.""" | ||
| lines = [] | ||
| for row in rows: | ||
| title = (row.get("title") or "").strip() | ||
| location = (row.get("location") or "").strip() | ||
| url = (row.get("url") or "").strip() | ||
| if not title and not location and not url: | ||
| continue | ||
| parts = [title, location, url] if location else [title, url] | ||
| lines.append(" | ".join(parts)) | ||
| return "\n".join(lines) | ||
|
|
||
|
|
||
| class StructuredJobPostingsWidget(forms.Widget): | ||
| """Renders N rows of (title, location, url) inputs. | ||
|
|
||
| Composes the rows into pipe-delimited text on submission, parses stored | ||
| text back into rows on form init. Underlying TextAsset storage stays a | ||
| single text field. | ||
| """ | ||
|
|
||
| template_name = "sponsors/widgets/structured_job_postings.html" | ||
|
|
||
| def format_value(self, value): | ||
| """Parse the stored text into rows and pad with blanks for the form.""" | ||
| rows = parse_structured_job_postings(value) | ||
| filled = len(rows) | ||
| visible = max( | ||
| STRUCTURED_JOB_POSTINGS_MIN_VISIBLE_ROWS, | ||
| filled + STRUCTURED_JOB_POSTINGS_BLANK_ROW_COUNT, | ||
| ) | ||
| rows.extend({"title": "", "location": "", "url": ""} for _ in range(visible - filled)) | ||
| return rows | ||
|
|
||
| def get_context(self, name, value, attrs): | ||
| """Expose the parsed rows to the widget template.""" | ||
| context = super().get_context(name, value, attrs) | ||
| context["widget"]["rows"] = self.format_value(value) | ||
| return context | ||
|
|
||
| def value_from_datadict(self, data, files, name): | ||
| """Read the per-row POST fields and compose them into pipe-delimited text.""" | ||
| titles = data.getlist(f"{name}__title") | ||
| locations = data.getlist(f"{name}__location") | ||
| urls = data.getlist(f"{name}__url") | ||
| total = max(len(titles), len(locations), len(urls)) | ||
| rows = [ | ||
| { | ||
| "title": titles[idx] if idx < len(titles) else "", | ||
| "location": locations[idx] if idx < len(locations) else "", | ||
| "url": urls[idx] if idx < len(urls) else "", | ||
| } | ||
| for idx in range(total) | ||
| ] | ||
| return serialize_structured_job_postings(rows) | ||
|
|
||
|
|
||
| class StructuredJobPostingsField(forms.CharField): | ||
| """CharField backed by the structured job postings widget.""" | ||
|
|
||
| widget = StructuredJobPostingsWidget |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,33 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {% spaceless %} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <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%;"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
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%;"> | |
| <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
AI
Apr 18, 2026
There was a problem hiding this comment.
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.
| <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
AI
Apr 18, 2026
There was a problem hiding this comment.
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.
| <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> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| """Tests for the structured job postings widget + field.""" | ||
|
|
||
| from django.http import QueryDict | ||
| from django.test import TestCase, override_settings | ||
| from model_bakery import baker | ||
|
|
||
| from apps.sponsors.models import RequiredTextAsset | ||
| from apps.sponsors.structured_job_postings import ( | ||
| StructuredJobPostingsField, | ||
| StructuredJobPostingsWidget, | ||
| parse_structured_job_postings, | ||
| serialize_structured_job_postings, | ||
| ) | ||
|
|
||
|
|
||
| class ParseStructuredJobPostingsTests(TestCase): | ||
| def test_empty_returns_empty_list(self): | ||
| self.assertEqual(parse_structured_job_postings(""), []) | ||
| self.assertEqual(parse_structured_job_postings(None), []) | ||
|
|
||
| def test_two_field_no_location(self): | ||
| result = parse_structured_job_postings("Engineer | https://example.com/1") | ||
| self.assertEqual( | ||
| result, | ||
| [{"title": "Engineer", "location": "", "url": "https://example.com/1"}], | ||
| ) | ||
|
|
||
| def test_three_field_with_location(self): | ||
| result = parse_structured_job_postings("Engineer | Remote | https://example.com/1") | ||
| self.assertEqual( | ||
| result, | ||
| [{"title": "Engineer", "location": "Remote", "url": "https://example.com/1"}], | ||
| ) | ||
|
|
||
| def test_multiple_rows(self): | ||
| text = "A | https://a.example.com\nB | NYC | https://b.example.com" | ||
| self.assertEqual( | ||
| parse_structured_job_postings(text), | ||
| [ | ||
| {"title": "A", "location": "", "url": "https://a.example.com"}, | ||
| {"title": "B", "location": "NYC", "url": "https://b.example.com"}, | ||
| ], | ||
| ) | ||
|
|
||
| def test_legacy_markdown_preserved_as_title_only_row(self): | ||
| text = "Please highlight these roles below\n- see careers page" | ||
| result = parse_structured_job_postings(text) | ||
| self.assertEqual(len(result), 2) | ||
| self.assertEqual(result[0]["title"], "Please highlight these roles below") | ||
| self.assertEqual(result[0]["url"], "") | ||
|
|
||
| def test_crlf_and_bom_handled(self): | ||
| text = "\ufeffA | https://a.example.com\r\nB | https://b.example.com\r\n" | ||
| result = parse_structured_job_postings(text) | ||
| self.assertEqual([r["title"] for r in result], ["A", "B"]) | ||
|
|
||
|
|
||
| class SerializeStructuredJobPostingsTests(TestCase): | ||
| def test_drops_empty_rows(self): | ||
| rows = [ | ||
| {"title": "", "location": "", "url": ""}, | ||
| {"title": "Engineer", "location": "", "url": "https://example.com"}, | ||
| ] | ||
| self.assertEqual( | ||
| serialize_structured_job_postings(rows), | ||
| "Engineer | https://example.com", | ||
| ) | ||
|
|
||
| def test_omits_location_when_blank(self): | ||
| rows = [{"title": "A", "location": "", "url": "https://a.example.com"}] | ||
| self.assertEqual( | ||
| serialize_structured_job_postings(rows), | ||
| "A | https://a.example.com", | ||
| ) | ||
|
|
||
| def test_roundtrip(self): | ||
| text = "Engineer | Remote | https://example.com/1\nAdvocate | Pittsburgh | https://example.com/2" | ||
| self.assertEqual( | ||
| serialize_structured_job_postings(parse_structured_job_postings(text)), | ||
| text, | ||
| ) | ||
|
|
||
|
|
||
| class StructuredJobPostingsWidgetTests(TestCase): | ||
| def test_format_value_pads_blank_rows(self): | ||
| widget = StructuredJobPostingsWidget() | ||
| rows = widget.format_value("") | ||
| self.assertGreaterEqual(len(rows), 15) | ||
| self.assertTrue(all(r["title"] == "" for r in rows)) | ||
|
|
||
| def test_format_value_preserves_filled_rows_then_pads(self): | ||
| widget = StructuredJobPostingsWidget() | ||
| rows = widget.format_value("Engineer | Remote | https://example.com/1") | ||
| self.assertEqual(rows[0], {"title": "Engineer", "location": "Remote", "url": "https://example.com/1"}) | ||
| self.assertGreaterEqual(len(rows), 15) | ||
|
|
||
| def test_value_from_datadict_composes_rows(self): | ||
| widget = StructuredJobPostingsWidget() | ||
| data = QueryDict(mutable=True) | ||
| data.setlist("jobs__title", ["Engineer", "Advocate", ""]) | ||
| data.setlist("jobs__location", ["Remote", "Pittsburgh, PA", ""]) | ||
| data.setlist("jobs__url", ["https://example.com/1", "https://example.com/2", ""]) | ||
| value = widget.value_from_datadict(data, {}, "jobs") | ||
| self.assertEqual( | ||
| value, | ||
| "Engineer | Remote | https://example.com/1\nAdvocate | Pittsburgh, PA | https://example.com/2", | ||
| ) | ||
|
|
||
|
|
||
| @override_settings(STRUCTURED_JOB_POSTINGS_INTERNAL_NAMES=("job_listings",)) | ||
| class RequiredTextAssetAsFormFieldTests(TestCase): | ||
| def test_job_listings_internal_name_uses_structured_field(self): | ||
| asset = baker.prepare( | ||
| RequiredTextAsset, | ||
| internal_name="job_listings_for_us_pycon_org_2026", | ||
| label="Job listings", | ||
| help_text="", | ||
| max_length=None, | ||
| ) | ||
| field = asset.as_form_field() | ||
| self.assertIsInstance(field, StructuredJobPostingsField) | ||
|
|
||
| def test_unrelated_internal_name_uses_default_textarea(self): | ||
| asset = baker.prepare( | ||
| RequiredTextAsset, | ||
| internal_name="general_text", | ||
| label="Some text", | ||
| help_text="", | ||
| max_length=None, | ||
| ) | ||
| field = asset.as_form_field() | ||
| self.assertNotIsInstance(field, StructuredJobPostingsField) | ||
|
|
||
| @override_settings(STRUCTURED_JOB_POSTINGS_INTERNAL_NAMES=()) | ||
| def test_unconfigured_setting_disables_structured(self): | ||
| asset = baker.prepare( | ||
| RequiredTextAsset, | ||
| internal_name="job_listings_for_us_pycon_org_2026", | ||
| label="Job listings", | ||
| help_text="", | ||
| max_length=None, | ||
| ) | ||
| field = asset.as_form_field() | ||
| self.assertNotIsInstance(field, StructuredJobPostingsField) |
There was a problem hiding this comment.
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.