Skip to content

Commit d9811cc

Browse files
JacobCoffeeclaude
andcommitted
Split-field widget for the PyCon job listings benefit
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>
1 parent 5d5338f commit d9811cc

File tree

5 files changed

+297
-0
lines changed

5 files changed

+297
-0
lines changed

apps/sponsors/models/benefits.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Benefit feature and configuration models for the sponsors app."""
22

33
from django import forms
4+
from django.conf import settings
45
from django.db import IntegrityError, models, transaction
56
from django.db.models import UniqueConstraint
67
from django.urls import reverse
@@ -16,6 +17,7 @@
1617
########################################
1718
# Benefit features abstract classes
1819
from apps.sponsors.models.managers import BenefitFeatureQuerySet
20+
from apps.sponsors.structured_job_postings import StructuredJobPostingsField
1921

2022

2123
########################################
@@ -711,6 +713,11 @@ def as_form_field(self, **kwargs):
711713
help_text = kwargs.pop("help_text", self.help_text)
712714
label = kwargs.pop("label", self.label)
713715
required = kwargs.pop("required", False)
716+
717+
structured_substrings = getattr(settings, "STRUCTURED_JOB_POSTINGS_INTERNAL_NAMES", ())
718+
if structured_substrings and any(s in self.internal_name for s in structured_substrings):
719+
return StructuredJobPostingsField(required=required, help_text=help_text, label=label, **kwargs)
720+
714721
max_length = self.max_length
715722
widget = forms.TextInput
716723
if max_length is None or max_length > self.TEXTAREA_MIN_LENGTH:
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Split-field widget + helpers for the sponsor job postings benefit.
2+
3+
Stores composed rows as pipe-delimited lines in the existing TextAsset
4+
value. No new model or benefit type — this is purely a form-layer concern.
5+
6+
Lives in its own module so models/benefits.py can import the field at the
7+
top without pulling in apps.sponsors.forms (circular).
8+
"""
9+
10+
from django import forms
11+
12+
STRUCTURED_JOB_POSTINGS_BLANK_ROW_COUNT = 3
13+
STRUCTURED_JOB_POSTINGS_MIN_VISIBLE_ROWS = 15
14+
_TITLE_URL_PARTS = 2
15+
_TITLE_LOCATION_URL_PARTS = 3
16+
17+
18+
def parse_structured_job_postings(text):
19+
"""Parse pipe-delimited job listing text into a list of row dicts.
20+
21+
Expected format, one job per line:
22+
Title | Location | URL
23+
24+
Location is optional (2 fields also accepted). Lines that don't match
25+
that shape are preserved as title-only rows so the sponsor can see
26+
(and fix) unrecognized content rather than silently losing it.
27+
"""
28+
if not text:
29+
return []
30+
rows = []
31+
for raw_line in text.replace("\ufeff", "").splitlines():
32+
line = raw_line.strip()
33+
if not line:
34+
continue
35+
parts = [p.strip() for p in line.split("|")]
36+
if len(parts) == _TITLE_URL_PARTS:
37+
title, url = parts
38+
location = ""
39+
elif len(parts) == _TITLE_LOCATION_URL_PARTS:
40+
title, location, url = parts
41+
else:
42+
rows.append({"title": line, "location": "", "url": ""})
43+
continue
44+
rows.append({"title": title, "location": location, "url": url})
45+
return rows
46+
47+
48+
def serialize_structured_job_postings(rows):
49+
"""Compose a list of row dicts back into pipe-delimited text."""
50+
lines = []
51+
for row in rows:
52+
title = (row.get("title") or "").strip()
53+
location = (row.get("location") or "").strip()
54+
url = (row.get("url") or "").strip()
55+
if not title and not location and not url:
56+
continue
57+
parts = [title, location, url] if location else [title, url]
58+
lines.append(" | ".join(parts))
59+
return "\n".join(lines)
60+
61+
62+
class StructuredJobPostingsWidget(forms.Widget):
63+
"""Renders N rows of (title, location, url) inputs.
64+
65+
Composes the rows into pipe-delimited text on submission, parses stored
66+
text back into rows on form init. Underlying TextAsset storage stays a
67+
single text field.
68+
"""
69+
70+
template_name = "sponsors/widgets/structured_job_postings.html"
71+
72+
def format_value(self, value):
73+
"""Parse the stored text into rows and pad with blanks for the form."""
74+
rows = parse_structured_job_postings(value)
75+
filled = len(rows)
76+
visible = max(
77+
STRUCTURED_JOB_POSTINGS_MIN_VISIBLE_ROWS,
78+
filled + STRUCTURED_JOB_POSTINGS_BLANK_ROW_COUNT,
79+
)
80+
rows.extend({"title": "", "location": "", "url": ""} for _ in range(visible - filled))
81+
return rows
82+
83+
def get_context(self, name, value, attrs):
84+
"""Expose the parsed rows to the widget template."""
85+
context = super().get_context(name, value, attrs)
86+
context["widget"]["rows"] = self.format_value(value)
87+
return context
88+
89+
def value_from_datadict(self, data, files, name):
90+
"""Read the per-row POST fields and compose them into pipe-delimited text."""
91+
titles = data.getlist(f"{name}__title")
92+
locations = data.getlist(f"{name}__location")
93+
urls = data.getlist(f"{name}__url")
94+
total = max(len(titles), len(locations), len(urls))
95+
rows = [
96+
{
97+
"title": titles[idx] if idx < len(titles) else "",
98+
"location": locations[idx] if idx < len(locations) else "",
99+
"url": urls[idx] if idx < len(urls) else "",
100+
}
101+
for idx in range(total)
102+
]
103+
return serialize_structured_job_postings(rows)
104+
105+
106+
class StructuredJobPostingsField(forms.CharField):
107+
"""CharField backed by the structured job postings widget."""
108+
109+
widget = StructuredJobPostingsWidget
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{% spaceless %}
2+
<div class="structured-job-postings">
3+
<p class="help">
4+
Enter each job posting on its own row. All fields optional per row — leave any row blank to skip.
5+
</p>
6+
<table class="structured-job-postings__table" style="width:100%;border-collapse:collapse;">
7+
<thead>
8+
<tr>
9+
<th style="text-align:left;padding:0.25rem 0.5rem;">Job title</th>
10+
<th style="text-align:left;padding:0.25rem 0.5rem;">Location</th>
11+
<th style="text-align:left;padding:0.25rem 0.5rem;">Link to job</th>
12+
</tr>
13+
</thead>
14+
<tbody>
15+
{% for row in widget.rows %}
16+
<tr>
17+
<td style="padding:0.25rem 0.5rem;">
18+
<input type="text" name="{{ widget.name }}__title" value="{{ row.title }}" maxlength="255" style="width:100%;">
19+
</td>
20+
<td style="padding:0.25rem 0.5rem;">
21+
<input type="text" name="{{ widget.name }}__location" value="{{ row.location }}" maxlength="255" style="width:100%;">
22+
</td>
23+
<td style="padding:0.25rem 0.5rem;">
24+
<input type="url" name="{{ widget.name }}__url" value="{{ row.url }}" maxlength="500" style="width:100%;">
25+
</td>
26+
</tr>
27+
{% endfor %}
28+
</tbody>
29+
</table>
30+
{# Hide the original field input — the rows above replace it. #}
31+
<input type="hidden" name="{{ widget.name }}" value="">
32+
</div>
33+
{% endspaceless %}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Tests for the structured job postings widget + field."""
2+
3+
from django.http import QueryDict
4+
from django.test import TestCase, override_settings
5+
from model_bakery import baker
6+
7+
from apps.sponsors.models import RequiredTextAsset
8+
from apps.sponsors.structured_job_postings import (
9+
StructuredJobPostingsField,
10+
StructuredJobPostingsWidget,
11+
parse_structured_job_postings,
12+
serialize_structured_job_postings,
13+
)
14+
15+
16+
class ParseStructuredJobPostingsTests(TestCase):
17+
def test_empty_returns_empty_list(self):
18+
self.assertEqual(parse_structured_job_postings(""), [])
19+
self.assertEqual(parse_structured_job_postings(None), [])
20+
21+
def test_two_field_no_location(self):
22+
result = parse_structured_job_postings("Engineer | https://example.com/1")
23+
self.assertEqual(
24+
result,
25+
[{"title": "Engineer", "location": "", "url": "https://example.com/1"}],
26+
)
27+
28+
def test_three_field_with_location(self):
29+
result = parse_structured_job_postings("Engineer | Remote | https://example.com/1")
30+
self.assertEqual(
31+
result,
32+
[{"title": "Engineer", "location": "Remote", "url": "https://example.com/1"}],
33+
)
34+
35+
def test_multiple_rows(self):
36+
text = "A | https://a.example.com\nB | NYC | https://b.example.com"
37+
self.assertEqual(
38+
parse_structured_job_postings(text),
39+
[
40+
{"title": "A", "location": "", "url": "https://a.example.com"},
41+
{"title": "B", "location": "NYC", "url": "https://b.example.com"},
42+
],
43+
)
44+
45+
def test_legacy_markdown_preserved_as_title_only_row(self):
46+
text = "Please highlight these roles below\n- see careers page"
47+
result = parse_structured_job_postings(text)
48+
self.assertEqual(len(result), 2)
49+
self.assertEqual(result[0]["title"], "Please highlight these roles below")
50+
self.assertEqual(result[0]["url"], "")
51+
52+
def test_crlf_and_bom_handled(self):
53+
text = "\ufeffA | https://a.example.com\r\nB | https://b.example.com\r\n"
54+
result = parse_structured_job_postings(text)
55+
self.assertEqual([r["title"] for r in result], ["A", "B"])
56+
57+
58+
class SerializeStructuredJobPostingsTests(TestCase):
59+
def test_drops_empty_rows(self):
60+
rows = [
61+
{"title": "", "location": "", "url": ""},
62+
{"title": "Engineer", "location": "", "url": "https://example.com"},
63+
]
64+
self.assertEqual(
65+
serialize_structured_job_postings(rows),
66+
"Engineer | https://example.com",
67+
)
68+
69+
def test_omits_location_when_blank(self):
70+
rows = [{"title": "A", "location": "", "url": "https://a.example.com"}]
71+
self.assertEqual(
72+
serialize_structured_job_postings(rows),
73+
"A | https://a.example.com",
74+
)
75+
76+
def test_roundtrip(self):
77+
text = "Engineer | Remote | https://example.com/1\nAdvocate | Pittsburgh | https://example.com/2"
78+
self.assertEqual(
79+
serialize_structured_job_postings(parse_structured_job_postings(text)),
80+
text,
81+
)
82+
83+
84+
class StructuredJobPostingsWidgetTests(TestCase):
85+
def test_format_value_pads_blank_rows(self):
86+
widget = StructuredJobPostingsWidget()
87+
rows = widget.format_value("")
88+
self.assertGreaterEqual(len(rows), 15)
89+
self.assertTrue(all(r["title"] == "" for r in rows))
90+
91+
def test_format_value_preserves_filled_rows_then_pads(self):
92+
widget = StructuredJobPostingsWidget()
93+
rows = widget.format_value("Engineer | Remote | https://example.com/1")
94+
self.assertEqual(rows[0], {"title": "Engineer", "location": "Remote", "url": "https://example.com/1"})
95+
self.assertGreaterEqual(len(rows), 15)
96+
97+
def test_value_from_datadict_composes_rows(self):
98+
widget = StructuredJobPostingsWidget()
99+
data = QueryDict(mutable=True)
100+
data.setlist("jobs__title", ["Engineer", "Advocate", ""])
101+
data.setlist("jobs__location", ["Remote", "Pittsburgh, PA", ""])
102+
data.setlist("jobs__url", ["https://example.com/1", "https://example.com/2", ""])
103+
value = widget.value_from_datadict(data, {}, "jobs")
104+
self.assertEqual(
105+
value,
106+
"Engineer | Remote | https://example.com/1\nAdvocate | Pittsburgh, PA | https://example.com/2",
107+
)
108+
109+
110+
@override_settings(STRUCTURED_JOB_POSTINGS_INTERNAL_NAMES=("job_listings",))
111+
class RequiredTextAssetAsFormFieldTests(TestCase):
112+
def test_job_listings_internal_name_uses_structured_field(self):
113+
asset = baker.prepare(
114+
RequiredTextAsset,
115+
internal_name="job_listings_for_us_pycon_org_2026",
116+
label="Job listings",
117+
help_text="",
118+
max_length=None,
119+
)
120+
field = asset.as_form_field()
121+
self.assertIsInstance(field, StructuredJobPostingsField)
122+
123+
def test_unrelated_internal_name_uses_default_textarea(self):
124+
asset = baker.prepare(
125+
RequiredTextAsset,
126+
internal_name="general_text",
127+
label="Some text",
128+
help_text="",
129+
max_length=None,
130+
)
131+
field = asset.as_form_field()
132+
self.assertNotIsInstance(field, StructuredJobPostingsField)
133+
134+
@override_settings(STRUCTURED_JOB_POSTINGS_INTERNAL_NAMES=())
135+
def test_unconfigured_setting_disables_structured(self):
136+
asset = baker.prepare(
137+
RequiredTextAsset,
138+
internal_name="job_listings_for_us_pycon_org_2026",
139+
label="Job listings",
140+
help_text="",
141+
max_length=None,
142+
)
143+
field = asset.as_form_field()
144+
self.assertNotIsInstance(field, StructuredJobPostingsField)

pydotorg/settings/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,10 @@
279279
SPONSORSHIP_NOTIFICATION_FROM_EMAIL = config("SPONSORSHIP_NOTIFICATION_FROM_EMAIL", default="sponsors@python.org")
280280
SPONSORSHIP_NOTIFICATION_TO_EMAIL = config("SPONSORSHIP_NOTIFICATION_TO_EMAIL", default="psf-sponsors@python.org")
281281
PYPI_SPONSORS_CSV = str(Path(BASE) / "data" / "pypi-sponsors.csv")
282+
# Required-text-asset benefits whose internal_name contains any of these
283+
# substrings get the split-field job postings widget instead of a plain
284+
# textarea. The composed pipe-delimited text is stored in the same field.
285+
STRUCTURED_JOB_POSTINGS_INTERNAL_NAMES = ("job_listings", "job_postings")
282286

283287
# Mail
284288
DEFAULT_FROM_EMAIL = "noreply@python.org"

0 commit comments

Comments
 (0)