Skip to content

Commit 00c7fc1

Browse files
JacobCoffeeclaude
andauthored
Sponsor job listing form pycon (#3202)
* enforce text asset max_length and add placeholder * document text asset length limits and placeholder * add SPONSOR_JOB_POSTINGS_ASSET_MARKERS setting * add one-job-per-row job postings form field * add row add/remove script for job postings * use job postings field for job listing assets * render assets form as divs and load form media * style job postings table and asset field spacing * doc * allow unchanged over-limit text asset values * document grandfathered text asset limits * keep job postings on one line with any url case * hide job posting controls without js * test unchanged over-limit text asset values Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * test job posting url case and line breaks Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 85dc012 commit 00c7fc1

12 files changed

Lines changed: 649 additions & 16 deletions

File tree

‎apps/sponsors/job_postings.py‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""One-job-per-row form input for the sponsor job listings benefit.
2+
3+
Sponsors enter each job (title, optional location, link) as its own row. The
4+
rows are stored in the benefit's existing ``TextAsset`` value, one job per line::
5+
6+
Senior Python Engineer | Remote | https://example.com/jobs/1
7+
Developer Advocate | https://example.com/jobs/2
8+
9+
PyCon-site's sponsor sync parses that format into structured job postings.
10+
11+
Kept out of ``apps.sponsors.forms`` so ``models/benefits.py`` can import it
12+
without a circular import.
13+
"""
14+
15+
import re
16+
17+
from django import forms
18+
from django.conf import settings
19+
from django.core.exceptions import ValidationError
20+
from django.core.validators import URLValidator
21+
22+
SEPARATOR = "|"
23+
ROW_PARTS = ("title", "location", "url")
24+
HELP_TEXT = (
25+
"Enter one job per row: the job title, its location (optional), and a link to the job posting. "
26+
"Use “Add another job” for more rows. Clear a row to remove that job."
27+
)
28+
29+
_MARKDOWN_LINK_RE = re.compile(r"\[(?P<title>[^\]]+)\]\((?P<url>[^)\s]+)\)")
30+
_URL_RE = re.compile(r"https?://\S+", re.IGNORECASE)
31+
_LEGACY_TITLE_STRIP = " \t-*:|,–—" # noqa: RUF001 - en/em dashes used as markdown separators
32+
_url_validator = URLValidator(schemes=["http", "https"])
33+
34+
35+
def is_job_postings_asset(internal_name):
36+
"""Return True if a required text asset collects sponsor job postings.
37+
38+
Matches ``settings.SPONSOR_JOB_POSTINGS_ASSET_MARKERS`` against the internal
39+
name, ignoring case and ``_``/``-`` differences, so yearly clones
40+
(``pycon-2026-job-listing``) keep matching.
41+
"""
42+
normalized = internal_name.lower().replace("_", "-")
43+
return any(marker.lower().replace("_", "-") in normalized for marker in settings.SPONSOR_JOB_POSTINGS_ASSET_MARKERS)
44+
45+
46+
def _row(title="", location="", url=""):
47+
return {"title": title, "location": location, "url": url}
48+
49+
50+
def _single_line(value):
51+
"""Collapse whitespace, including every ``str.splitlines()`` boundary, to single spaces.
52+
53+
Each job must serialize to exactly one stored line.
54+
"""
55+
return " ".join(value.split())
56+
57+
58+
def _lowercase_scheme(url):
59+
scheme, sep, rest = url.partition("://")
60+
return f"{scheme.lower()}{sep}{rest}"
61+
62+
63+
def _parse_line(line):
64+
parts = [part.strip() for part in line.split(SEPARATOR)]
65+
if len(parts) in (2, 3) and _URL_RE.fullmatch(parts[-1]):
66+
return _row(parts[0], parts[1] if len(parts) == 3 else "", parts[-1]) # noqa: PLR2004
67+
68+
# Legacy free-form markdown: recover what we can so the sponsor only has to tidy up.
69+
if match := _MARKDOWN_LINK_RE.search(line):
70+
return _row(match["title"].strip(), url=match["url"])
71+
if match := _URL_RE.search(line):
72+
title = (line[: match.start()] + line[match.end() :]).strip(_LEGACY_TITLE_STRIP)
73+
return _row(title, url=match.group().rstrip(".,;)"))
74+
return _row(line.strip(_LEGACY_TITLE_STRIP))
75+
76+
77+
def parse_job_postings(text):
78+
"""Split stored job postings text into row dicts, one per non-blank line.
79+
80+
Lines in the ``Title | Location | URL`` (or ``Title | URL``) format parse
81+
exactly. Legacy markdown lines keep their link and text so nothing a
82+
sponsor already submitted disappears from the form.
83+
"""
84+
if not text:
85+
return []
86+
rows = (_parse_line(line.strip()) for line in text.replace("\ufeff", "").splitlines())
87+
return [row for row in rows if any(row.values())]
88+
89+
90+
def serialize_job_postings(rows):
91+
"""Join row dicts into the stored one-job-per-line text."""
92+
lines = []
93+
for row in rows:
94+
parts = [row["title"], row["location"], row["url"]] if row["location"] else [row["title"], row["url"]]
95+
lines.append(f" {SEPARATOR} ".join(parts))
96+
return "\n".join(lines)
97+
98+
99+
class JobPostingsWidget(forms.Widget):
100+
"""Render one (title, location, link) row per job plus a blank row to add another."""
101+
102+
template_name = "sponsors/widgets/job_postings.html"
103+
104+
class Media:
105+
"""Script that adds and removes rows in the browser."""
106+
107+
js = ("js/sponsors/jobPostings.js",)
108+
109+
def format_value(self, value):
110+
"""Return the rows to render: stored or submitted jobs, then one blank row."""
111+
rows = parse_job_postings(value) if value is None or isinstance(value, str) else list(value)
112+
return [*rows, _row()]
113+
114+
def value_from_datadict(self, data, files, name):
115+
"""Collect the submitted rows, dropping rows left completely blank."""
116+
getter = getattr(data, "getlist", data.get)
117+
columns = []
118+
for part in ROW_PARTS:
119+
values = getter(f"{name}__{part}") or []
120+
columns.append([values] if isinstance(values, str) else values)
121+
rows = []
122+
for index in range(max(len(column) for column in columns)):
123+
row = _row(*(_single_line(column[index] if index < len(column) else "") for column in columns))
124+
if any(row.values()):
125+
rows.append(row)
126+
return rows
127+
128+
def id_for_label(self, id_):
129+
"""Point the field label at the first row's title input."""
130+
return f"{id_}_0_title" if id_ else id_
131+
132+
133+
class JobPostingsField(forms.Field):
134+
"""Validate job rows and return them as the stored one-job-per-line text."""
135+
136+
widget = JobPostingsWidget
137+
138+
def to_python(self, value):
139+
"""Accept stored text (initial value) or the widget's list of rows."""
140+
if not value:
141+
return []
142+
if isinstance(value, str):
143+
return parse_job_postings(value)
144+
return list(value)
145+
146+
def validate(self, value):
147+
"""Require a title and a valid http(s) link for every job."""
148+
super().validate(value)
149+
errors = []
150+
for number, row in enumerate(value, start=1):
151+
if not row["title"]:
152+
errors.append(ValidationError("Job %(number)s: enter the job title.", params={"number": number}))
153+
if not row["url"]:
154+
errors.append(
155+
ValidationError("Job %(number)s: enter a link to the job posting.", params={"number": number})
156+
)
157+
else:
158+
try:
159+
_url_validator(row["url"])
160+
except ValidationError:
161+
errors.append(
162+
ValidationError(
163+
"Job %(number)s: enter a full link starting with http:// or https://.",
164+
params={"number": number},
165+
)
166+
)
167+
if any(SEPARATOR in row[part] for part in ROW_PARTS):
168+
errors.append(
169+
ValidationError(
170+
"Job %(number)s: remove the “%(separator)s” character.",
171+
params={"number": number, "separator": SEPARATOR},
172+
)
173+
)
174+
if errors:
175+
raise ValidationError(errors)
176+
177+
def clean(self, value):
178+
"""Return the validated rows as stored text, with lowercase link schemes."""
179+
rows = super().clean(value)
180+
return serialize_job_postings([{**row, "url": _lowercase_scheme(row["url"])} for row in rows])
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Generated by Django 5.2.17 on 2026-10-27 22:35
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("sponsors", "0105_update_polymorphic_options"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="requiredtextasset",
14+
name="placeholder",
15+
field=models.CharField(
16+
blank=True,
17+
default="",
18+
help_text="Example text shown in the empty input, e.g. 'Acme Corp is a…'",
19+
max_length=256,
20+
),
21+
),
22+
migrations.AddField(
23+
model_name="requiredtextassetconfiguration",
24+
name="placeholder",
25+
field=models.CharField(
26+
blank=True,
27+
default="",
28+
help_text="Example text shown in the empty input, e.g. 'Acme Corp is a…'",
29+
max_length=256,
30+
),
31+
),
32+
migrations.AlterField(
33+
model_name="requiredtextasset",
34+
name="max_length",
35+
field=models.IntegerField(
36+
blank=True,
37+
default=None,
38+
help_text="Maximum number of characters sponsors may enter, empty means unlimited",
39+
null=True,
40+
),
41+
),
42+
migrations.AlterField(
43+
model_name="requiredtextassetconfiguration",
44+
name="max_length",
45+
field=models.IntegerField(
46+
blank=True,
47+
default=None,
48+
help_text="Maximum number of characters sponsors may enter, empty means unlimited",
49+
null=True,
50+
),
51+
),
52+
]

‎apps/sponsors/models/benefits.py‎

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

33
from django import forms
4+
from django.core.exceptions import ValidationError
45
from django.db import IntegrityError, models, transaction
56
from django.db.models import UniqueConstraint
67
from django.urls import reverse
78
from polymorphic.models import PolymorphicModel
89

10+
from apps.sponsors.job_postings import HELP_TEXT as JOB_POSTINGS_HELP_TEXT
11+
from apps.sponsors.job_postings import JobPostingsField, is_job_postings_asset
912
from apps.sponsors.models.assets import FileAsset, ImgAsset, Response, ResponseAsset, TextAsset
1013
from apps.sponsors.models.enums import (
1114
AssetsRelatedTo,
@@ -215,10 +218,16 @@ class BaseRequiredTextAsset(BaseRequiredAsset):
215218
)
216219
max_length = models.IntegerField(
217220
default=None,
218-
help_text="Limit to length of the input, empty means unlimited",
221+
help_text="Maximum number of characters sponsors may enter, empty means unlimited",
219222
null=True,
220223
blank=True,
221224
)
225+
placeholder = models.CharField(
226+
max_length=256,
227+
default="",
228+
blank=True,
229+
help_text="Example text shown in the empty input, e.g. 'Acme Corp is a…'",
230+
)
222231

223232
class Meta(BaseRequiredAsset.Meta):
224233
"""Meta configuration for BaseRequiredTextAsset."""
@@ -685,10 +694,35 @@ def as_form_field(self, **kwargs):
685694
)
686695

687696

697+
def _normalize_text(value):
698+
return (value or "").replace("\r\n", "\n").strip()
699+
700+
701+
class _TextAssetField(forms.CharField):
702+
"""CharField that keeps accepting an unchanged initial value longer than ``max_length``.
703+
704+
Admins can add or lower a limit after a sponsor already submitted. Without this, the
705+
stored value would fail validation on every later save of the whole assets form.
706+
New or edited input is still held to the limit.
707+
"""
708+
709+
def run_validators(self, value):
710+
"""Run validators, ignoring the length limit when the value is unchanged."""
711+
try:
712+
super().run_validators(value)
713+
except ValidationError as exc:
714+
if _normalize_text(value) != _normalize_text(self.initial):
715+
raise
716+
errors = [error for error in exc.error_list if error.code != "max_length"]
717+
if errors:
718+
raise ValidationError(errors) from exc
719+
720+
688721
class RequiredTextAsset(RequiredAssetMixin, BaseRequiredTextAsset, BenefitFeature):
689722
"""Required text asset feature that sponsors must provide."""
690723

691-
TEXTAREA_MIN_LENGTH = 256
724+
SINGLE_LINE_MAX_LENGTH = 100
725+
LIMITED_TEXTAREA_ROWS = 4
692726

693727
class Meta(BaseRequiredTextAsset.Meta, BenefitFeature.Meta):
694728
"""Meta configuration for RequiredTextAsset."""
@@ -701,15 +735,31 @@ def __str__(self):
701735
return "Require text"
702736

703737
def as_form_field(self, **kwargs):
704-
"""Return a CharField configured for this required text asset."""
738+
"""Return a CharField configured for this required text asset.
739+
740+
``max_length`` is enforced on new input (an unchanged stored value that is
741+
already longer still saves) and announced in the help text. Short limits get a
742+
single-line input, other limits a small textarea. Job listing assets get a
743+
one-job-per-row field instead of a free-form textarea.
744+
"""
705745
help_text = kwargs.pop("help_text", self.help_text)
706746
label = kwargs.pop("label", self.label)
707747
required = kwargs.pop("required", False)
748+
if is_job_postings_asset(self.internal_name):
749+
return JobPostingsField(required=required, help_text=JOB_POSTINGS_HELP_TEXT, label=label, **kwargs)
708750
max_length = self.max_length
709-
widget = forms.TextInput
710-
if max_length is None or max_length > self.TEXTAREA_MIN_LENGTH:
711-
widget = forms.Textarea
712-
return forms.CharField(required=required, help_text=help_text, label=label, widget=widget, **kwargs)
751+
attrs = {"placeholder": self.placeholder} if self.placeholder else {}
752+
if max_length is None:
753+
widget = forms.Textarea(attrs=attrs)
754+
elif max_length <= self.SINGLE_LINE_MAX_LENGTH:
755+
widget = forms.TextInput(attrs=attrs)
756+
else:
757+
widget = forms.Textarea(attrs={**attrs, "rows": self.LIMITED_TEXTAREA_ROWS})
758+
if max_length is not None:
759+
help_text = " ".join(filter(None, [help_text, f"Up to {max_length} characters."]))
760+
return _TextAssetField(
761+
required=required, max_length=max_length, help_text=help_text, label=label, widget=widget, **kwargs
762+
)
713763

714764

715765
class RequiredResponseAsset(RequiredAssetMixin, BaseRequiredResponseAsset, BenefitFeature):
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<div class="job-postings" data-job-postings data-job-postings-id="{{ widget.attrs.id|default:'' }}">
2+
<table class="job-postings-table">
3+
<thead>
4+
<tr>
5+
<th scope="col"><span class="screen-reader-text">Job number</span></th>
6+
<th scope="col">Job title</th>
7+
<th scope="col">Location <small>(optional)</small></th>
8+
<th scope="col">Link to job posting</th>
9+
<th scope="col"><span class="screen-reader-text">Remove</span></th>
10+
</tr>
11+
</thead>
12+
<tbody data-job-postings-rows>
13+
{% for row in widget.value %}
14+
{% include "sponsors/widgets/job_postings_row.html" with index=forloop.counter0 number=forloop.counter %}
15+
{% endfor %}
16+
</tbody>
17+
</table>
18+
<template data-job-postings-template>
19+
{% include "sponsors/widgets/job_postings_row.html" with row=None index="" number="" %}
20+
</template>
21+
<button type="button" class="button" data-job-postings-add hidden>Add another job</button>
22+
</div>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<tr data-job-postings-row>
2+
<th scope="row" data-job-postings-number>{{ number }}</th>
3+
<td>
4+
<input type="text" name="{{ widget.name }}__title" value="{{ row.title|default:'' }}" placeholder="e.g. Senior Python Engineer"
5+
data-job-postings-input="title"{% if widget.attrs.id and number %} id="{{ widget.attrs.id }}_{{ index }}_title"{% endif %}
6+
aria-label="Job {{ number }} title">
7+
</td>
8+
<td>
9+
<input type="text" name="{{ widget.name }}__location" value="{{ row.location|default:'' }}" placeholder="e.g. Remote"
10+
data-job-postings-input="location"{% if widget.attrs.id and number %} id="{{ widget.attrs.id }}_{{ index }}_location"{% endif %}
11+
aria-label="Job {{ number }} location">
12+
</td>
13+
<td>
14+
<input type="url" name="{{ widget.name }}__url" value="{{ row.url|default:'' }}" placeholder="e.g. https://example.com/jobs/123"
15+
data-job-postings-input="url"{% if widget.attrs.id and number %} id="{{ widget.attrs.id }}_{{ index }}_url"{% endif %}
16+
aria-label="Job {{ number }} link">
17+
</td>
18+
<td>
19+
<button type="button" class="button" data-job-postings-remove hidden aria-label="Remove job {{ number }}">Remove</button>
20+
</td>
21+
</tr>

0 commit comments

Comments
 (0)