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
11 changes: 10 additions & 1 deletion apps/commons/admin.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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",)
130 changes: 126 additions & 4 deletions apps/commons/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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,
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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/<slug:org_slug>/sponsor-pledges/",
sponsor_pledge_create,
name="commons-sponsor-pledge-create",
),
path(
"sponsor-pledges/<int:pk>/respond/",
SponsorPledgeRespondView.as_view(),
name="commons-sponsor-pledge-respond",
),
path(
"ventures/<slug:venture_slug>/interest/",
VentureInterestView.as_view(),
Expand Down
37 changes: 33 additions & 4 deletions apps/commons/attention.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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__)

Expand All @@ -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/",
}


Expand All @@ -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
Expand Down
71 changes: 71 additions & 0 deletions apps/commons/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
41 changes: 41 additions & 0 deletions apps/commons/migrations/0003_sponsorpledge.py
Original file line number Diff line number Diff line change
@@ -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'],
},
),
]
Loading
Loading