Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/sponsors/models/benefits.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Benefit feature and configuration models for the sponsors app."""

from django import forms
from django.conf import settings
from django.db import IntegrityError, models, transaction
from django.db.models import UniqueConstraint
from django.urls import reverse
Expand All @@ -16,6 +17,7 @@
########################################
# Benefit features abstract classes
from apps.sponsors.models.managers import BenefitFeatureQuerySet
from apps.sponsors.structured_job_postings import StructuredJobPostingsField


########################################
Expand Down Expand Up @@ -711,6 +713,11 @@ def as_form_field(self, **kwargs):
help_text = kwargs.pop("help_text", self.help_text)
label = kwargs.pop("label", self.label)
required = kwargs.pop("required", False)

structured_substrings = getattr(settings, "STRUCTURED_JOB_POSTINGS_INTERNAL_NAMES", ())
if structured_substrings and any(s in self.internal_name for s in structured_substrings):
return StructuredJobPostingsField(required=required, help_text=help_text, label=label, **kwargs)

max_length = self.max_length
widget = forms.TextInput
if max_length is None or max_length > self.TEXTAREA_MIN_LENGTH:
Expand Down
109 changes: 109 additions & 0 deletions apps/sponsors/structured_job_postings.py
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>
Comment on lines +9 to +11
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.
</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
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
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.
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# Hide the original field input — the rows above replace it. #}
<input type="hidden" name="{{ widget.name }}" value="">
</div>
Comment on lines +2 to +32
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.
{% endspaceless %}
144 changes: 144 additions & 0 deletions apps/sponsors/tests/test_structured_job_postings.py
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)
4 changes: 4 additions & 0 deletions pydotorg/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@
SPONSORSHIP_NOTIFICATION_FROM_EMAIL = config("SPONSORSHIP_NOTIFICATION_FROM_EMAIL", default="sponsors@python.org")
SPONSORSHIP_NOTIFICATION_TO_EMAIL = config("SPONSORSHIP_NOTIFICATION_TO_EMAIL", default="psf-sponsors@python.org")
PYPI_SPONSORS_CSV = str(Path(BASE) / "data" / "pypi-sponsors.csv")
# Required-text-asset benefits whose internal_name contains any of these
# substrings get the split-field job postings widget instead of a plain
# textarea. The composed pipe-delimited text is stored in the same field.
STRUCTURED_JOB_POSTINGS_INTERNAL_NAMES = ("job_listings", "job_postings")

# Mail
DEFAULT_FROM_EMAIL = "noreply@python.org"
Expand Down
Loading