|
| 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]) |
0 commit comments