diff --git a/apps/commons/admin.py b/apps/commons/admin.py index 33a7f6c..7594c2a 100644 --- a/apps/commons/admin.py +++ b/apps/commons/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Idea, IdeaInterest, VentureInterest +from .models import Idea, IdeaInterest, SponsorPledge, VentureInterest class IdeaInterestInline(admin.TabularInline): @@ -25,3 +25,12 @@ class VentureInterestAdmin(admin.ModelAdmin): list_filter = ("org",) search_fields = ("user__email", "org__slug", "org__display_name", "note") readonly_fields = ("created_at",) + + +@admin.register(SponsorPledge) +class SponsorPledgeAdmin(admin.ModelAdmin): + list_display = ("name", "org_name", "email", "kind", "tier", "amount", "org", "created_at", + "responded_at") + list_filter = ("kind", "tier", "org", "list_publicly") + search_fields = ("name", "email", "org_name", "offer", "note") + readonly_fields = ("created_at",) diff --git a/apps/commons/api.py b/apps/commons/api.py index 7fdba75..d6306ef 100644 --- a/apps/commons/api.py +++ b/apps/commons/api.py @@ -14,20 +14,27 @@ toggle — see apps.orgs.embed_auth). """ +import json +from decimal import Decimal, InvalidOperation + from django.conf import settings -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, ValidationError +from django.core.validators import validate_email from django.db.models import Count +from django.http import JsonResponse from django.urls import path +from django.views.decorators.csrf import csrf_exempt from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from apps.orgs.embed_auth import EmbedSessionAuthentication -from apps.orgs.models import Membership, Org +from apps.orgs.models import Membership, MembershipRole, Org +from apps.orgs.s2s import authorized as s2s_authorized -from .mail import notify_venture_interest -from .models import VentureInterest +from .mail import confirm_sponsor_pledge, notify_sponsor_pledge, notify_venture_interest +from .models import SponsorPledge, VentureInterest def _ventures_qs(): @@ -39,6 +46,17 @@ def _ventures_qs(): return Org.objects.annotate(member_count=Count("memberships")).order_by("display_name") +def _is_org_admin(user, org): + """Runs this org: its admin, an accelerator admin, or a superuser.""" + from apps.orgs.views import _is_accelerator_admin + + if not user.is_authenticated: + return False + if _is_accelerator_admin(user): + return True + return Membership.objects.filter(org=org, user=user, role=MembershipRole.ADMIN).exists() + + def _interest_payload(i, include_person=False): row = { "id": i.id, @@ -184,6 +202,11 @@ def get(self, request, org_slug): + attention.doorway_items() + attention.invite_accepted_items() ) + # Sponsorship offered to this org, for the people who can answer it. A + # pledge names a person and a sum they have not given yet, so it is the + # admins' to see and not the whole team's. + if _is_org_admin(request.user, request.org): + items = items + attention.sponsor_pledge_items(request.org) # Unanswered first, oldest first — one rule for every kind. items.sort(key=lambda i: (i["done"], i["since"])) return Response({"org_slug": request.org.slug, "items": items}) @@ -222,8 +245,107 @@ def get(self, request): return Response({"interests": [_interest_payload(i, include_person=True) for i in rows]}) +# --- Sponsor pledges --------------------------------------------------------------------- +# +# Creation is S2S (plain Django view, shared bearer): the person filling the form +# is on workers.vc and has no account here, so there is no session to ride. The +# doorway renders the form and posts it; this side owns the row. + + +@csrf_exempt +def sponsor_pledge_create(request, org_slug): + """Record one offer of sponsorship. Called by the workers.vc doorway. + + Only `name` and `email` are required — someone who wants to give money must + never be turned away over a field. Everything else is what they chose to say. + """ + if request.method != "POST": + return JsonResponse({"error": "method_not_allowed"}, status=405) + if not s2s_authorized(request): + return JsonResponse({"error": "unauthorized"}, status=401) + org = Org.objects.filter(slug=org_slug).first() + if org is None: + return JsonResponse({"error": "not_found"}, status=404) + try: + data = json.loads(request.body or b"{}") + except ValueError: + return JsonResponse({"error": "bad_json"}, status=400) + + name = (data.get("name") or "").strip() + email = (data.get("email") or "").strip() + if not name or not email: + return JsonResponse({"error": "name_and_email_required"}, status=400) + try: + validate_email(email) + except ValidationError: + return JsonResponse({"error": "bad_email"}, status=400) + + kind = SponsorPledge.Kind.IN_KIND if data.get("kind") == "in_kind" else SponsorPledge.Kind.CASH + amount = None + raw_amount = data.get("amount") + if kind == SponsorPledge.Kind.CASH and raw_amount not in (None, ""): + try: + amount = Decimal(str(raw_amount).replace(",", "").replace("$", "").strip()) + except (InvalidOperation, ValueError): + return JsonResponse({"error": "bad_amount"}, status=400) + if amount <= 0 or amount >= Decimal("100000000"): + return JsonResponse({"error": "bad_amount"}, status=400) + + pledge = SponsorPledge.objects.create( + org=org, + name=name[:200], + email=email, + org_name=(data.get("org_name") or "").strip()[:200], + kind=kind, + tier=(data.get("tier") or "").strip()[:40], + amount=amount, + offer=(data.get("offer") or "").strip(), + note=(data.get("note") or "").strip(), + list_publicly=bool(data.get("list_publicly", True)), + listed_as=(data.get("listed_as") or "").strip()[:200], + ) + notify_sponsor_pledge(pledge) + confirm_sponsor_pledge(pledge) + return JsonResponse({"id": pledge.id, "summary": pledge.summary}, status=201) + + +# Bearer auth, no session: the org gate must not run (see orgs/middleware.py). +sponsor_pledge_create.org_context_exempt = True + + +class SponsorPledgeRespondView(APIView): + """Mark a pledge answered from the rail. The org's admins, or accelerator admins.""" + + authentication_classes = [EmbedSessionAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + from apps.orgs.views import _is_accelerator_admin + + pledge = SponsorPledge.objects.filter(pk=pk).select_related("org").first() + if pledge is None: + return Response({"error": "not found"}, status=status.HTTP_404_NOT_FOUND) + is_admin = Membership.objects.filter( + org=pledge.org, user=request.user, role=MembershipRole.ADMIN + ).exists() + if not (is_admin or _is_accelerator_admin(request.user)): + raise PermissionDenied("This pledge is for that team's admins.") + pledge.mark_responded(request.user) + return Response({"id": pledge.id, "responded_at": pledge.responded_at.isoformat()}) + + urlpatterns = [ path("ventures/", VenturesView.as_view(), name="commons-ventures"), + path( + "orgs//sponsor-pledges/", + sponsor_pledge_create, + name="commons-sponsor-pledge-create", + ), + path( + "sponsor-pledges//respond/", + SponsorPledgeRespondView.as_view(), + name="commons-sponsor-pledge-respond", + ), path( "ventures//interest/", VentureInterestView.as_view(), diff --git a/apps/commons/attention.py b/apps/commons/attention.py index 04e690e..c70a87e 100644 --- a/apps/commons/attention.py +++ b/apps/commons/attention.py @@ -3,14 +3,17 @@ One generic item shape so new kinds slot in without touching the embed contract: - {kind, id, title, detail, email, since, done, url, org_slug} + {kind, id, title, detail, email, since, done, url, org_slug, respond_url} kind "venture_interest" today; "pool_pending" on the accelerator's rail; future kinds (drops to approve, votes to cast, ...) reuse the shape. done answered/handled — the client renders it dimmed, at the bottom. url a link out when the action lives elsewhere (e.g. the doorway's - approval queue); venture_interest instead carries org_slug + id so - the client can POST the mark-answered endpoint. + approval queue). + respond_url the mark-answered endpoint for this row, relative to the GovKit + root. Present means the client draws the button; that is the whole + contract, so a new kind gets one by filling the field, not by + teaching the embed its name. Facts stay in their homes: interest rows here in commons, pending walk-ups in the workersvc doorway's ledger (read over its loopback S2S API, never copied). @@ -23,7 +26,7 @@ from django.conf import settings from django.core.cache import cache -from .models import VentureInterest +from .models import SponsorPledge, VentureInterest logger = logging.getLogger(__name__) @@ -44,6 +47,7 @@ def _interest_item(i, with_org_name): "done": i.responded_at is not None, "url": "", "org_slug": i.org.slug, + "respond_url": f"/api/v1/commons/orgs/{i.org.slug}/interest/{i.id}/respond/", } @@ -59,6 +63,31 @@ def all_open_interest_items(): return [_interest_item(i, with_org_name=True) for i in rows] +def sponsor_pledge_items(org): + """Sponsorship offered to this org, unanswered first (model ordering). + + Answered ones stay on the rail (dimmed) rather than disappearing: there is + no other place in the product yet where the team can see who offered, and a + pledge that vanishes on first reply is a pledge nobody follows up. + """ + rows = SponsorPledge.objects.filter(org=org).select_related("org") + return [ + { + "kind": "sponsor_pledge", + "id": p.id, + "title": f"{p.who} offered to sponsor — {p.summary}", + "detail": " ".join(x for x in (p.offer, p.note) if x), + "email": p.email, + "since": p.created_at.isoformat(), + "done": p.responded_at is not None, + "url": "", + "org_slug": p.org.slug, + "respond_url": f"/api/v1/commons/sponsor-pledges/{p.id}/respond/", + } + for p in rows + ] + + def invite_accepted_items(): """Recent invite accepts — awareness for the accelerator rail. Direct invites (no commit ceremony, no wall card) would otherwise be invisible diff --git a/apps/commons/mail.py b/apps/commons/mail.py index 9d54e8b..e8cf168 100644 --- a/apps/commons/mail.py +++ b/apps/commons/mail.py @@ -50,3 +50,74 @@ def notify_venture_interest(interest) -> None: ) except Exception: logger.exception("venture-interest mail failed (org=%s)", interest.org.slug) + + +def notify_sponsor_pledge(pledge) -> None: + """Tell the org's admins someone offered to sponsor. No-op without SMTP. + + Money offered and not answered is the one thing on the rail that costs + real money to miss, so it is mailed as well as railed. + """ + if not mail_configured(): + return + admins = Membership.objects.filter(org=pledge.org, role=MembershipRole.ADMIN).select_related( + "user" + ) + recipients = [m.user.email for m in admins if m.user.email] + if not recipients: + return + lines = [f"{pledge.who} offered to sponsor {pledge.org.display_name}: {pledge.summary}."] + if pledge.tier: + lines.append(f"Tier: {pledge.tier}") + if pledge.offer: + lines.append(f"Offering: {pledge.offer}") + if pledge.note: + lines.append(f"In their words:\n{pledge.note}") + lines.append( + "Listing: " + (f"as “{pledge.public_name}”" if pledge.public_name else "asked not to be listed") + ) + lines.append( + f"Write back to {pledge.email}, then mark it answered on the dashboard so it" + " leaves the rail." + ) + try: + send_mail( + subject=f"{pledge.who} offered to sponsor {pledge.org.display_name} ({pledge.summary})", + message="\n\n".join(lines), + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=recipients, + fail_silently=True, + ) + except Exception: + logger.exception("sponsor-pledge mail failed (org=%s)", pledge.org.slug) + + +def confirm_sponsor_pledge(pledge) -> None: + """Tell the sponsor we have them. No-op without SMTP. + + Without this the person's whole journey ends on a web page they will close. + A pledge is a promise made to a stranger; the least we owe them is a record + in their own inbox of what they said and that a human is coming. + """ + if not mail_configured(): + return + body = [ + f"Thank you — we have your offer to sponsor {pledge.org.display_name}: {pledge.summary}.", + ] + if pledge.offer: + body.append(f"You offered: {pledge.offer}") + body.append( + "Nothing is charged and nothing is owed. Someone from the team will write" + " back to you to settle how to send it." + ) + body.append("If any of this is wrong, just reply to this message.") + try: + send_mail( + subject=f"Your sponsorship of {pledge.org.display_name}", + message="\n\n".join(body), + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[pledge.email], + fail_silently=True, + ) + except Exception: + logger.exception("sponsor-pledge confirmation failed (pledge=%s)", pledge.pk) diff --git a/apps/commons/migrations/0003_sponsorpledge.py b/apps/commons/migrations/0003_sponsorpledge.py new file mode 100644 index 0000000..f02fa03 --- /dev/null +++ b/apps/commons/migrations/0003_sponsorpledge.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1.15 on 2026-08-10 16:33 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('commons', '0002_ventureinterest'), + ('orgs', '0025_invitelink_invite_from_link'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SponsorPledge', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('email', models.EmailField(max_length=254)), + ('org_name', models.CharField(blank=True, max_length=200)), + ('kind', models.CharField(choices=[('cash', 'Cash'), ('in_kind', 'In kind')], default='cash', max_length=10)), + ('tier', models.SlugField(blank=True, max_length=40)), + ('amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('offer', models.TextField(blank=True)), + ('note', models.TextField(blank=True, help_text='Their own words. Never generated.')), + ('list_publicly', models.BooleanField(default=True)), + ('listed_as', models.CharField(blank=True, max_length=200)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('responded_at', models.DateTimeField(blank=True, null=True)), + ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sponsor_pledges', to='orgs.org')), + ('responded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sponsor_pledges_answered', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': [models.OrderBy(models.F('responded_at'), nulls_first=True), 'created_at'], + }, + ), + ] diff --git a/apps/commons/models.py b/apps/commons/models.py index 36facca..7b651d9 100644 --- a/apps/commons/models.py +++ b/apps/commons/models.py @@ -119,3 +119,93 @@ class Meta: def __str__(self): return f"{self.user.email}: {self.kind} {self.idea.title}" + + +class SponsorPledge(models.Model): + """Someone offering to sponsor an org, in their own words. + + The public-side twin of VentureInterest for a person with no account: a + sponsor is a stranger here until they have given something. workers.vc + renders the form on /sponsor/ and posts it over S2S; this row is the ONE + home of the fact, and the attention rail is a view over it. + + A pledge is an intention, never equity. When one is honoured the sponsor + becomes an ExternalHolder with an OrgStake through the normal deliberate + path (apps/orgs) — nothing here grants a share. + + `responded_at` is the same supervision hook VentureInterest uses: money + offered and never answered is the worst thing this table could do, so an + unanswered pledge floats to the top of the rail until a human replies. + """ + + class Kind(models.TextChoices): + CASH = "cash", "Cash" + IN_KIND = "in_kind", "In kind" + + org = models.ForeignKey("orgs.Org", on_delete=models.CASCADE, related_name="sponsor_pledges") + + name = models.CharField(max_length=200) + email = models.EmailField() + # Who they are sponsoring as, when that is not themselves. + org_name = models.CharField(max_length=200, blank=True) + + kind = models.CharField(max_length=10, choices=Kind.choices, default=Kind.CASH) + # The tier they clicked, as offered on the page ("silver"), or blank for an + # amount they typed themselves. Kept as the label they chose rather than + # derived from the amount: the tier is what was promised to them. + tier = models.SlugField(max_length=40, blank=True) + # Null for in-kind, and for a cash pledge with no figure yet. Money, so a + # decimal — never a float. + amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + # What they are offering instead of cash, in their words (credits, hosting, + # legal hours). Free text: a list of ours would only be wrong. + offer = models.TextField(blank=True) + note = models.TextField(blank=True, help_text="Their own words. Never generated.") + + # The sponsor page promises a name on it, so consent is asked for, not + # assumed. Blank `listed_as` means list them by name (or org_name). + list_publicly = models.BooleanField(default=True) + listed_as = models.CharField(max_length=200, blank=True) + + created_at = models.DateTimeField(default=timezone.now) + responded_at = models.DateTimeField(null=True, blank=True) + responded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="sponsor_pledges_answered", + ) + + class Meta: + # Unanswered first, oldest first — the rail's order, same as interests. + ordering = [models.F("responded_at").asc(nulls_first=True), "created_at"] + + def __str__(self): + state = "answered" if self.responded_at else "open" + return f"{self.who} → {self.org.slug} ({self.summary}, {state})" + + @property + def who(self): + return f"{self.name} ({self.org_name})" if self.org_name else self.name + + @property + def public_name(self): + """How they asked to be listed, or nothing if they asked not to be.""" + if not self.list_publicly: + return "" + return self.listed_as or self.org_name or self.name + + @property + def summary(self): + """The pledge in a few words, for a rail row or a subject line.""" + if self.kind == self.Kind.IN_KIND: + return "in kind" + return f"${self.amount:,.0f}" if self.amount else "an amount to discuss" + + def mark_responded(self, by_user): + """Idempotent: the first reply wins; a second click changes nothing.""" + if self.responded_at is None: + self.responded_at = timezone.now() + self.responded_by = by_user + self.save(update_fields=["responded_at", "responded_by"]) diff --git a/docs/BOUNDARIES.md b/docs/BOUNDARIES.md index 9b65c8a..fd8d9f2 100644 --- a/docs/BOUNDARIES.md +++ b/docs/BOUNDARIES.md @@ -15,6 +15,7 @@ | Per-org task-source config and the valuation mirror (`apps/tasksources`) | Trust claims, attestations → **LinkedTrust** | | The genesis checklist as *events* — what a team did, appended (`apps/orgs/genesis.py`) | The curriculum text itself → code (`genesis.py` MODULES) + the reading page in **workers.vc** | | Exports (`apps/exports`) | Chrome, landing, the dash page → **workers.vc** | +| Interest and sponsorship offered from outside (`apps/commons`: `VentureInterest`, `SponsorPledge`) | The pages that collect them → **workers.vc** (`/sponsor/`, the venture join pages) | ## The two rules that keep this untangled diff --git a/static/embed/govkit.js b/static/embed/govkit.js index a4f0544..d923adc 100644 --- a/static/embed/govkit.js +++ b/static/embed/govkit.js @@ -780,8 +780,8 @@ // --- --------------------------------------------------- // The attention rail: one typed list of everything on this org's plate // (commons/orgs//attention/). Kinds render by shape, not by name, so - // new kinds work unseen: `done` dims a row; `org_slug` + venture_interest - // gets the mark-answered POST; a `url` gets a link out. + // new kinds work unseen: `done` dims a row; `respond_url` gets the + // mark-answered POST; a `url` gets a link out. function fmtDay(iso) { var d = new Date(iso); @@ -818,12 +818,11 @@ link.appendChild(la); row.appendChild(link); } - if (r.kind === 'venture_interest' && !r.done && r.org_slug) { + if (r.respond_url && !r.done) { var btn = el('button', null, 'Mark answered'); btn.addEventListener('click', function () { btn.disabled = true; - fetch(c.up + '/api/v1/commons/orgs/' + encodeURIComponent(r.org_slug) + - '/interest/' + r.id + '/respond/', { + fetch(c.up + r.respond_url, { method: 'POST', credentials: 'include', headers: { 'X-Govkit-Embed': '1' }, diff --git a/tests/test_sponsor_pledge.py b/tests/test_sponsor_pledge.py new file mode 100644 index 0000000..c950b64 --- /dev/null +++ b/tests/test_sponsor_pledge.py @@ -0,0 +1,228 @@ +"""Sponsorship offered from the workers.vc page: it lands here, and a human sees it. + +The whole point of the table is that nobody who offers money is lost. So the +tests follow that journey: the doorway posts a pledge (S2S, no session), the +org's admins find it on their rail, and marking it answered takes it off. +""" + +import json + +import pytest +from django.core import mail + +from apps.commons.models import SponsorPledge +from apps.orgs.models import MembershipRole + +# The embed's preflight gate stands in for a CSRF token cross-origin. +EMBED = {"HTTP_X_GOVKIT_EMBED": "1"} +TOKEN = "s2s-test-token" +ACCEL = "accel" + + +@pytest.fixture +def accel_org(org_factory, settings): + settings.ACCELERATOR_ORG_SLUG = ACCEL + settings.DOORWAY_API_URL = "" # no doorway in tests + settings.GOVKIT_S2S_TOKEN = TOKEN + return org_factory(slug=ACCEL, display_name="Workers VC") + + +def _post(client, org_slug, payload, token=TOKEN): + headers = {"HTTP_AUTHORIZATION": f"Bearer {token}"} if token else {} + return client.post( + f"/api/v1/commons/orgs/{org_slug}/sponsor-pledges/", + data=json.dumps(payload), + content_type="application/json", + **headers, + ) + + +def _rail(client, org): + return client.get(f"/api/v1/commons/orgs/{org.slug}/attention/").json()["items"] + + +def _sign_in(client, org, user_factory, membership_factory, role=MembershipRole.ADMIN): + user = user_factory(email=f"{role}@example.com") + membership_factory(org=org, user=user, role=role) + client.force_login(user) + return user + + +class TestIntake: + def test_a_cash_pledge_is_recorded(self, client, accel_org): + resp = _post( + client, + ACCEL, + { + "name": "Dana Sponsor", + "email": "dana@example.com", + "org_name": "Dana Capital", + "tier": "silver", + "amount": "500", + "note": "Happy to help with the stipends.", + }, + ) + + assert resp.status_code == 201 + p = SponsorPledge.objects.get(pk=resp.json()["id"]) + assert (p.name, p.org_name, p.tier) == ("Dana Sponsor", "Dana Capital", "silver") + assert str(p.amount) == "500.00" + assert p.summary == "$500" + assert p.responded_at is None + + def test_an_in_kind_offer_needs_no_amount(self, client, accel_org): + resp = _post( + client, + ACCEL, + { + "name": "Sam Giver", + "email": "sam@example.com", + "kind": "in_kind", + "offer": "Six months of hosting.", + }, + ) + + assert resp.status_code == 201 + p = SponsorPledge.objects.get(pk=resp.json()["id"]) + assert p.amount is None + assert p.summary == "in kind" + assert p.offer == "Six months of hosting." + + def test_a_dollar_sign_and_commas_are_not_a_rejection(self, client, accel_org): + resp = _post( + client, ACCEL, {"name": "Ren", "email": "ren@example.com", "amount": "$1,000"} + ) + + assert resp.status_code == 201 + assert str(SponsorPledge.objects.get(pk=resp.json()["id"]).amount) == "1000.00" + + def test_listing_consent_is_asked_not_assumed(self, client, accel_org): + _post( + client, + ACCEL, + { + "name": "Quiet Backer", + "email": "quiet@example.com", + "list_publicly": False, + "amount": "200", + }, + ) + _post( + client, + ACCEL, + { + "name": "Loud Backer", + "email": "loud@example.com", + "listed_as": "The Loud Fund", + "amount": "200", + }, + ) + + quiet = SponsorPledge.objects.get(name="Quiet Backer") + loud = SponsorPledge.objects.get(name="Loud Backer") + assert quiet.public_name == "" + assert loud.public_name == "The Loud Fund" + + @pytest.mark.parametrize( + "payload,error", + [ + ({"email": "a@example.com"}, "name_and_email_required"), + ({"name": "No Mail"}, "name_and_email_required"), + ({"name": "Bad Mail", "email": "not-an-email"}, "bad_email"), + ({"name": "Odd", "email": "a@example.com", "amount": "lots"}, "bad_amount"), + ({"name": "Zero", "email": "a@example.com", "amount": "0"}, "bad_amount"), + ], + ) + def test_bad_input_is_refused(self, client, accel_org, payload, error): + resp = _post(client, ACCEL, payload) + assert resp.status_code == 400 + assert resp.json()["error"] == error + assert not SponsorPledge.objects.exists() + + def test_without_the_shared_secret_nothing_is_written(self, client, accel_org): + assert _post(client, ACCEL, {"name": "X", "email": "x@example.com"}, token="wrong").status_code == 401 + assert _post(client, ACCEL, {"name": "X", "email": "x@example.com"}, token=None).status_code == 401 + assert not SponsorPledge.objects.exists() + + def test_an_unknown_org_is_a_404_not_a_stray_row(self, client, accel_org): + assert _post(client, "nobody", {"name": "X", "email": "x@example.com"}).status_code == 404 + assert not SponsorPledge.objects.exists() + + def test_the_sponsor_and_the_team_both_get_told( + self, client, accel_org, user_factory, membership_factory, settings + ): + settings.EMAIL_HOST = "localhost" + settings.DEFAULT_FROM_EMAIL = "cohort@example.com" + admin = user_factory(email="admin@example.com") + membership_factory(org=accel_org, user=admin, role=MembershipRole.ADMIN) + + _post(client, ACCEL, {"name": "Dana", "email": "dana@example.com", "amount": "500"}) + + recipients = sorted(sum((m.to for m in mail.outbox), [])) + assert recipients == ["admin@example.com", "dana@example.com"] + + +class TestTheRail: + @pytest.fixture + def pledge(self, client, accel_org): + _post( + client, + ACCEL, + {"name": "Dana", "email": "dana@example.com", "amount": "500", "note": "for stipends"}, + ) + return SponsorPledge.objects.get() + + def test_an_admin_sees_it_and_can_answer_it( + self, client, accel_org, pledge, user_factory, membership_factory + ): + _sign_in(client, accel_org, user_factory, membership_factory) + + item = next(i for i in _rail(client, accel_org) if i["kind"] == "sponsor_pledge") + assert "$500" in item["title"] and "Dana" in item["title"] + assert item["email"] == "dana@example.com" + assert item["done"] is False + + assert client.post(item["respond_url"], **EMBED).status_code == 200 + pledge.refresh_from_db() + assert pledge.responded_at is not None + + answered = next(i for i in _rail(client, accel_org) if i["kind"] == "sponsor_pledge") + assert answered["done"] is True + + def test_an_ordinary_member_does_not_see_who_offered_money( + self, client, accel_org, pledge, user_factory, membership_factory + ): + _sign_in(client, accel_org, user_factory, membership_factory, role=MembershipRole.MEMBER) + + assert [i for i in _rail(client, accel_org) if i["kind"] == "sponsor_pledge"] == [] + + def test_a_member_cannot_mark_it_answered( + self, client, accel_org, pledge, user_factory, membership_factory + ): + _sign_in(client, accel_org, user_factory, membership_factory, role=MembershipRole.MEMBER) + + assert client.post(f"/api/v1/commons/sponsor-pledges/{pledge.id}/respond/", **EMBED).status_code == 403 + pledge.refresh_from_db() + assert pledge.responded_at is None + + def test_a_stranger_cannot_mark_it_answered(self, client, accel_org, pledge): + assert client.post(f"/api/v1/commons/sponsor-pledges/{pledge.id}/respond/", **EMBED).status_code in ( + 401, + 403, + ) + pledge.refresh_from_db() + assert pledge.responded_at is None + + def test_the_first_reply_wins( + self, client, accel_org, pledge, user_factory, membership_factory + ): + _sign_in(client, accel_org, user_factory, membership_factory) + url = f"/api/v1/commons/sponsor-pledges/{pledge.id}/respond/" + + client.post(url, **EMBED) + pledge.refresh_from_db() + first = pledge.responded_at + + client.post(url, **EMBED) + pledge.refresh_from_db() + assert pledge.responded_at == first