Skip to content
Merged
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 backendServer/backend/settings/prod.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
# sets instead (proxy_set_header X-Real-IP $remote_addr;).
RATELIMIT_IP_META_KEY = "HTTP_X_REAL_IP"

# nginx terminates TLS and proxies to gunicorn over a Unix socket, so Django never
# sees an HTTPS connection directly — request.is_secure() (and anything built on it,
# like build_absolute_uri()) would compute False for every request, producing
# http:// URLs on an https:// site. Trust the header nginx always sets instead
# (proxy_set_header X-Forwarded-Proto $scheme; on every server block).
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # crash at startup if missing

ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
Expand Down
35 changes: 35 additions & 0 deletions backendServer/broadcast/adapters/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,41 @@ def full_address(ev) -> str:
return f"{ev.address_line1}, {mid}{ev.state} {ev.zip}"


_FREE_TEXT_RE = re.compile(r"\bfree\b", re.I)
_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?")


def format_cost(is_free: bool, price: str) -> str:
"""Normalize a free-text cost into the plain number destination forms expect.

Destination sites (Triangle Weekender, ABC11) want a bare number in their
Cost field, not the free-text the submitter typed on The Commons — a range
like "5-10" gets rejected or mis-rendered. This normalizes at the adapter
boundary only; the raw string on CanonicalEvent is never touched.

Rules (decided by the ticket owner, 46.5):
- `is_free` flag, or the word "free" anywhere in the text -> "0"
- a range ("5-10", "$5 - $10", "5–10" en dash) -> the low value, e.g. "5"
- a single value ("$12", "12") -> the bare number, "12"
- empty input -> "" (nothing to normalize; caller's required/optional
handling decides what happens next)
- non-numeric junk with no digits at all (e.g. "TBD", "call for pricing")
-> passed through unchanged. We never invent a number, and forwarding
the original text is safer than silently dropping it.
"""
if is_free:
return "0"
text = (price or "").strip()
if not text:
return ""
if _FREE_TEXT_RE.search(text):
return "0"
match = _NUMBER_RE.search(text)
if not match:
return text
return match.group(0)


def apply_specs(page, specs, ev, timeout_ms) -> list[str]:
"""Fill the Playwright-fillable specs; return descriptors of missing required.

Expand Down
88 changes: 66 additions & 22 deletions backendServer/broadcast/adapters/abc11_community.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
Field ids (cfN) and the submit button (value="save") are verified against the
captured dump. Category (react-select) and image (custom uploader) are
best-effort; everything else maps from canonical fields.

Image: the form has exactly one `input[type=file][accept="image/*"]`, behind a
drag-and-drop skin at `.image-field-esf .image-dropzone` — a real file input,
not a JS-only dropzone, so a programmatic file set works through the normal
Playwright/extension path. Emitted only when `ev.image_url` is set (mirrors
triangle_weekender's image spec). Uploading reveals an alt-text field on
ABC11's form; `CanonicalEvent` has no alt-text field upstream, and per the
"adapters never invent content" rule we do not synthesize one — that field is
left for manual follow-up (see docs/broadcast.md's adapter rules).
"""

from broadcast.adapters import _helpers as h
Expand Down Expand Up @@ -48,24 +57,27 @@ def _duration(ev):


# Map our canonical category slugs to a search term to type into ABC11's
# react-select Category box; the extension types it and picks the first option.
# react-select Category box; the extension types it and picks whichever
# rendered option is an exact (else prefix) match, per term. ABC11's
# vocabulary is a fixed 17-term list (verified live 2026-08-03 — see suite
# 46.1) with no "Music"/"Family"/etc. terms, so most of our slugs have no
# defensible match — those are left unmapped rather than guessing (a wrong
# category is worse than none; adapters never invent content).
_CAT_MAP = {
"music": "Music",
"arts": "Arts",
"family-kids": "Family",
"food-drink": "Food",
"festival": "Festival",
"market": "Market",
"literary": "Literature",
"community": "Community",
"nightlife": "Nightlife",
"wellness": "Health",
"education": "Education",
"sports": "Sports",
"film": "Film",
"dance": "Dance",
"comedy": "Comedy",
"theatre": "Theater",
"music": "Theater / Concerts",
"dance": "Theater / Concerts",
"comedy": "Theater / Concerts",
"theatre": "Theater / Concerts",
"arts": "Art / Photography / Crafts",
"food-drink": "Food and Beverage / Farmer's Market",
"market": "Food and Beverage / Farmer's Market",
"festival": "Festivals / Parades",
"community": "Community Events / Volunteerism",
"education": "School / Education",
"sports": "Sports and Recreation",
# Unmapped — no defensible match in ABC11's vocabulary; left out so no
# category is sent rather than stretching to a nearest neighbour:
# family-kids, literary, nightlife, wellness, film.
}


Expand All @@ -80,14 +92,20 @@ def _cat_terms(ev) -> str:

# Declared once; consumed by the server-side fill loop (h.apply_specs) and the
# manual-review recipe export. Date/time are best-effort (optional) as before.
# NOTE: #eventStartDate-label is the field's <label>, not the input — the
# content-script date handler must fall back to the label's associated input.
# NOTE: #eventStartDate-label is NOT a <label> — the id just happens to end in
# "-label". It's the Fluent UI (Office UI Fabric) datepicker's own text input
# (role="combobox"), the only date input on the form; the selector is correct
# as-is and needs no fallback. What it does need is the retry handled by the
# content-script date handler (see fillInputVerified) — Trumba's widget
# self-populates the field with a near-current default shortly after load
# (see the ready_selector comment below), so a value written before that
# default fires gets silently overwritten.
_RECIPE_FIELDS = [
RecipeField("#cf3", "text", lambda ev: ev.title, required=True, label="Event title"),
RecipeField("#cf4", "textarea", lambda ev: ev.description, label="Event Details"),
RecipeField("#cf5", "text", _location, label="Location"),
RecipeField("#cf6", "text", lambda ev: ev.event_url, label="Web link"),
RecipeField("#cf33293", "text", lambda ev: "0" if ev.is_free else ev.price, label="Cost"),
RecipeField("#cf33293", "text", lambda ev: h.format_cost(ev.is_free, ev.price), label="Cost"),
RecipeField("#cf33704", "text", lambda ev: ev.organizer_name, label="Contact Name"),
RecipeField("#cf34384", "text", lambda ev: ev.contact_phone, label="Contact Phone"),
RecipeField("#cf34385", "text", lambda ev: ev.contact_email, label="Contact Email"),
Expand All @@ -105,7 +123,8 @@ def _cat_terms(ev) -> str:
"date",
lambda ev: _date(ev.start_datetime),
label="Start date",
hint="targets a label — fall back to its input",
hint="Trumba's own widget self-populates a default shortly after load; "
"the fill must re-read and re-apply if that default overwrites us",
),
RecipeField(
"#eventStartTime",
Expand Down Expand Up @@ -166,7 +185,20 @@ def recipe_field_specs(self, ev):
lambda ev, c=cats: c,
recipe_only=True,
label="Category",
hint="type each term and pick the first option",
hint="react-select — extension matches each term exactly (else by "
"prefix) and reports any it can't find",
)
)
if ev.image_url:
specs.append(
RecipeField(
".image-field-esf input[type=file]",
"file",
lambda ev: ev.image_url,
recipe_only=True,
label="Event image",
hint="auto-uploaded by the extension; reveals an alt-text field "
"on ABC11's form that must be filled in manually",
)
)
return specs
Expand Down Expand Up @@ -198,6 +230,11 @@ def fill_and_submit(self, page, ev, ctx):
screenshot_path=h.take_screenshot(page, ctx, self.key),
)

if ev.image_url:
local = h.download_image(ev.image_url, ctx.download_dir)
if local:
_try_fill_file(page, ".image-field-esf input[type=file]", local)

if h.has_captcha(page):
return TargetResult(
status="needs_manual",
Expand Down Expand Up @@ -226,3 +263,10 @@ def _login_wall(page) -> bool:
return page.locator("input[type='password']").first.is_visible(timeout=500)
except Exception:
return False


def _try_fill_file(page, selector: str, path: str) -> None:
try:
page.locator(selector).first.set_input_files(path, timeout=5000)
except Exception:
pass
40 changes: 22 additions & 18 deletions backendServer/broadcast/adapters/triangle_weekender.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
- Venue and Organizer are select2 "Create or Find" widgets: we type the name,
reuse an existing entry on a close string match, otherwise pick "Create".
Detail inputs (address/email/…) only populate when we create a new entry.
- Categories are a select2 AJAX dropdown (remote term search); the extension
only selects a term on an exact label match, so unrelated site categories
are never picked. The custom "County" checkboxes map from our locality
tags, so we set those. Tags are a free-form site vocabulary (not a
controlled list like categories) and can't be matched reliably, so we don't
attempt them.
- Categories are a select2 "AJAX" dropdown, but the typed search term never
actually filters it — it always renders the full ~62-term vocabulary. The
extension matches against that full rendered list (exact, else prefix), so
unrelated site categories are still never picked. The custom "County"
checkboxes map from our locality tags, so we set those. Tags are a
free-form site vocabulary (not a controlled list like categories) and
can't be matched reliably, so we don't attempt them.
- A newsletter popup is scheduled to appear after a delay and can cover the
submit button; we dismiss it before submitting.
- No captcha on this form.
Expand All @@ -29,20 +30,22 @@
# Locality slugs that are county/region-level only — not useful as a city name.
_REGION_ONLY_SLUGS = frozenset({"wake", "chatham", "triangle"})

# Mapping from our canonical category slugs (routing.CATEGORIES) to the search
# terms the Triangle Weekender's AJAX category dropdown understands. The
# extension searches each term; unmatched terms are skipped silently.
# Mapping from our canonical category slugs (routing.CATEGORIES) to search
# terms drawn from Triangle Weekender's real ~62-term vocabulary (verified
# live 2026-08-03 — see suite 46.1). A slug may map to multiple labels
# (comma-joined by _wk_category_terms, split again by the extension); the
# extension reports any term it can't match rather than skipping it silently.
_WK_CATEGORY_MAP: dict[str, str] = {
"music": "Music",
"music": "Music & Concerts,Live Music",
"arts": "Arts",
"family-kids": "Family",
"family-kids": "Kids & Family,Family Fun",
"wellness": "Wellness",
"food-drink": "Food",
"festival": "Festival",
"food-drink": "Food & Drink",
"festival": "Festivals",
"market": "Market",
"literary": "Literary",
"community": "Community",
"nightlife": "Nightlife",
"literary": "Books & Readings,Writing",
"community": "Gather,Volunteerism",
"nightlife": "Comedy,Trivia",
"education": "Education",
}

Expand Down Expand Up @@ -103,7 +106,7 @@ def _end(ev):
),
RecipeField("#EventEndDate", "date", lambda ev: _wk_date(_end(ev)), label="End date"),
RecipeField("#EventURL", "text", lambda ev: ev.event_url, required=True, label="Event URL"),
RecipeField("#EventCost", "text", lambda ev: "0" if ev.is_free else ev.price, label="Cost"),
RecipeField("#EventCost", "text", lambda ev: h.format_cost(ev.is_free, ev.price), label="Cost"),
]


Expand Down Expand Up @@ -243,7 +246,8 @@ def recipe_field_specs(self, ev):
_wk_category_terms,
recipe_only=True,
label="Event categories",
hint="AJAX dropdown — extension searches each term; unmatched terms are skipped",
hint="select2 dropdown — extension matches each term against the full "
"rendered list and reports any it can't find",
)
)
if ev.image_url:
Expand Down
50 changes: 47 additions & 3 deletions backendServer/broadcast/tests/test_adapter_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
ABC11's Duration, derived from start/end) are silently dropped on the headless
path. No DB, no real browser — a stub Playwright page records fill() calls."""

import dataclasses
import tempfile
from datetime import UTC, datetime
from unittest.mock import patch

from django.test import SimpleTestCase, tag

Expand All @@ -14,9 +16,9 @@


class _StubLocator:
"""Records fill() calls; everything else is inert so has_captcha()/
dismiss_consent()/_login_wall() (all wrapped in try/except in the
adapter/helpers) treat this as "nothing there" and move on."""
"""Records fill()/set_input_files() calls; everything else is inert so
has_captcha()/dismiss_consent()/_login_wall() (all wrapped in try/except
in the adapter/helpers) treat this as "nothing there" and move on."""

def __init__(self, page, selector):
self._page = page
Expand All @@ -32,6 +34,9 @@ def is_visible(self, timeout=None):
def fill(self, value, timeout=None):
self._page.filled[self._selector] = value

def set_input_files(self, path, timeout=None):
self._page.files[self._selector] = path

def click(self, timeout=None):
self._page.clicked.append(self._selector)

Expand All @@ -42,6 +47,7 @@ class _StubPage:

def __init__(self):
self.filled: dict[str, str] = {}
self.files: dict[str, str] = {}
self.clicked: list[str] = []
self.url = "https://example.test/stub"

Expand Down Expand Up @@ -106,3 +112,41 @@ def test_duration_fields_are_filled_when_event_has_an_end_time(self):
self.assertIn("#eventDurationMinutes", page.filled)
self.assertEqual(page.filled["#eventDurationHours"], "2")
self.assertEqual(page.filled["#eventDurationMinutes"], "30")

def test_image_is_downloaded_and_set_on_the_file_input(self):
"""46.8: an event with an image_url must reach the file input via
set_input_files — the adapter used to emit no image field at all."""
adapter = Abc11CommunityAdapter()
ev = dataclasses.replace(_make_event(), image_url="https://example.com/photo.jpg")
page = _StubPage()

with tempfile.TemporaryDirectory() as shots, tempfile.TemporaryDirectory() as dl:
ctx = RunContext(
dry_run=True, screenshot_dir=shots, download_dir=dl, submission_id="test"
)
with patch(
"broadcast.adapters.abc11_community.h.download_image",
return_value="/tmp/fake-photo.jpg",
) as mock_download:
result = adapter.fill_and_submit(page, ev, ctx)

self.assertEqual(result.status, "succeeded")
mock_download.assert_called_once_with("https://example.com/photo.jpg", dl)
self.assertEqual(page.files.get(".image-field-esf input[type=file]"), "/tmp/fake-photo.jpg")

def test_no_image_field_touched_when_event_has_no_image(self):
adapter = Abc11CommunityAdapter()
ev = _make_event()
self.assertEqual(ev.image_url, "")
page = _StubPage()

with tempfile.TemporaryDirectory() as shots, tempfile.TemporaryDirectory() as dl:
ctx = RunContext(
dry_run=True, screenshot_dir=shots, download_dir=dl, submission_id="test"
)
with patch("broadcast.adapters.abc11_community.h.download_image") as mock_download:
result = adapter.fill_and_submit(page, ev, ctx)

self.assertEqual(result.status, "succeeded")
mock_download.assert_not_called()
self.assertNotIn(".image-field-esf input[type=file]", page.files)
Loading
Loading