Story + calls-to-action app for volunteer orgs. Campaign pages where money is ONE way to help: give (wrapped Givebutter widget, or SimpleTip), mentor, hire, host an event, AMA, subscribe. Testimonials from named-or-anonymous vouchers (LinkedClaim URI per testimony, publish-gated), per-person share links with counted attribution, Instagram story/post card downloads.
First deployment target: VM 513 (voluntask), domain act.raisethevoices.org, for the
Raise the Voices JREAS Hub campaign. Design + phased plan: projects repo
Active/raise-the-voices/act-design.md; VM handoff: dashboard-vm513-handoff.md.
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python manage.py migrate
.venv/bin/python manage.py seed_jreas # RTV org + JREAS campaign (draft)
.venv/bin/python manage.py createsuperuser
.venv/bin/python manage.py runserverPublish the campaign in /admin (Campaigns → jreas → status: published).
Live: cooperation.org/letters/ on the civic-actions VM (10.0.0.154) since 2026-09-07; only
/letters/, /admin/, /static/, /media/ are routed there. Later: act.raisethevoices.org
(VM 513) for the campaign pages.
.envfrom.env.example(mode 600). Postgres: databaseacton VM 100 (rolesact_ownerfor migrations,act_userfor the running app).ACT_BASE_PATH/SCRIPT_NAMEempty at a domain root.- Python 3.12 venv,
pip install -r requirements.txt. WeasyPrint needslibpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0. manage.py migrate --check(runmigratewith theact_ownercredentials if pending),collectstatic.media/must be writable by the service user (act).deploy/act-retry-mail.service+.timer: confirmation-mail retries (systemctl enable --now act-retry-mail.timer).deploy/act.service(gunicorn 127.0.0.1:8050) anddeploy/nginx-cooperation.org.conf(locations added inside the existing cooperation.org vhost) are what is live.deploy/nginx-act.confis the own-domain variant for act.raisethevoices.org.- Deploy a change:
git pull,.venv/bin/pip install -r requirements.txt,collectstatic --clear(hashed filenames; nginx caches/static/for 7 days), tests withACT_DEBUG=1 manage.py test,migrate --check,sudo systemctl restart act. - DNS + Caddy route are host-side actions. Register in cobox
app-registry.md.
One repo and one database schema, several deployments. ACT_APPS in .env picks the apps a
deployment routes and shows in admin: campaigns (story + calls to action, owns /),
letters (open letters and petitions at /letters/). Org and volunteer accounts are shared
and always visible.
| Site | ACT_APPS | ACT_SITE_ORG | Public paths |
|---|---|---|---|
| cooperation.org | letters |
, | /letters/, /admin/ (root forwards to /letters/) |
| raisethevoices.org | campaigns,blog,people |
, (platform: every org's published campaigns) | /, /c/<slug>/, /updates/, /blog/, /people/, /admin/ |
| Jreas Coop (domain TBD) | campaigns,blog,people |
jreas-coop |
/ (org home), /c/jreas-coop/, /updates/, /blog/, /people/, /governance/, /admin/ |
New capabilities (content management, content planning, email campaigns) arrive as further apps in this list.
Set ACT_SITE_ORG=<org slug> and the deployment is that org: the wordmark and footer are
the org's name, home is campaigns/org_home.html (tagline, about in Markdown, its campaigns,
the give rail of its first published campaign with the locked Civic Works disclosure, Latest,
People, Governance), and campaigns, posts and people of other orgs 404. Every text field is the
org's own words, edited in /admin → Orgs (superuser). A slug with no Org row raises
ImproperlyConfigured on / rather than silently falling back.
/updates/and/updates/feed/(RSS): published campaign updates (receipts, counts) and blog posts as one stream, newest first. Home shows the latest six./blog/,/blog/<slug>/,/blog/feed/,blogapp. Post: Markdown body, optional author (a Person), summary for lists and feed, publish gate (published_atset on publish)./people/,/people/<slug>/,peopleapp. Person: name, role, photo, Markdown bio, links, optional login. Publishes only withconsent_on_recordticked and an approver's publish , a name tied to funding can endanger someone; the admin action and the save hook both refuse otherwise. A post's author link appears only while the author's profile is published./governance/, shown (and linked in nav) only when the Org hasgovernance_text(Markdown, the members' own words) orgovernance_url. Governance itself is not modelled here: earned governance (reviewed work → voting weight, pie, votes, sortition) is GovKit (Cooperation-org/govkit, dash.workers.vc);governance_urlpoints at the org's page there.
seed_jreas creates org jreas-coop ("Jreas Coop") and the JREAS Hub campaign under it, as draft.
letters/ app, pages under /letters/<slug>/. A Letter belongs to an Org, is written in
Markdown (bold, italics, paragraphs, bullets), and is draft until published in /admin.
kind is open letter (counter only) or petition (addressee + goal). Theme: parchment,
EB Garamond (bundled, OFL).
Signing: first name, last name, city, country, plus the letter's own fields
(Letters → Signature fields: label, kind short / paragraph / checkbox, required, public).
A signature counts when the signer has either confirmed their email or drawn a
signature (signature_pad, stored as PNG). Email is required to get updates
(keep_updated). Same email twice: an unconfirmed one is replaced, a confirmed one is
refused.
Confirmation mail goes out over SMTP (EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD,
DEFAULT_FROM_EMAIL). With EMAIL_HOST unset nothing is sent; every attempt, sent or failed,
is an EmailLog row. A failed send is retried automatically 1 h, 6 h and 24 h later
(letters.models.RETRY_DELAYS; manage.py retry_confirmations on deploy/act-retry-mail.timer,
every 15 min), then it gives up. The schedule and last error live on the Signature. Admin →
Signatures → filter "confirmation": confirmed / drawn / approved / sent, waiting / failed, retry
scheduled / failed, gave up / no email sent; action "Resend confirmation email" starts over.
Approved (checkbox in the list) makes a signature count and show even if the email never
confirmed, for the friend who signed but never got the mail.
PDF: /letters/<slug>.pdf is the letter with every counted signature that has on_pdf
ticked, ordered by pdf_sort then date. In admin, filter (city, country, …), tick rows,
action "PDF of the letter with the selected signatures". WeasyPrint renders the same
templates; static and media files are read from disk.
Same mechanism as workers.vc (doorway/claims.py) and LinkedClaims/docs/embedding.md:
/c/<slug>/testimony/embeds<linked-video-recorder>fromLT_EMBED; the browser uploads the video to LinkedTrust storage and the returnedvideoUrllands in the form's hidden field.- The testimonial sits pending. In admin an approver runs "Sign as LinkedClaim and publish":
campaigns/linkedtrust.pyPOSTs{subject: campaign page, claim: ENDORSES, statement, videoUrl, name only if opted in}toLT_API/api/claimswithx-lt-client-id/secret, storesclaim_id,linkedclaim_uri,signed_at, orsign_error. - The campaign page renders each signed testimonial as
<linked-badge claim-id=…>(badge.js fromLT_EMBED); unsigned ones render as plain cards marked "not yet signed".
LT_CLIENT_ID/LT_CLIENT_SECRET are issued per site by whoever runs live.linkedtrust.us; without
them signing is refused (an issuer-less claim is permanent damage), publishing still works.
act never touches funds. The give rail embeds the Givebutter widget for the org's
Givebutter account (charity of record: Civic Works, receipts and discretion-and-control
disclosure are locked text in campaigns/donation_text.py, deliberately not DB-editable;
see projects repo Active/raise-the-voices/legal/fundraising-compliance.md) or a
<simple-tip> element. Set Org.givebutter_account_id + Campaign.givebutter_campaign_id
in admin when the Givebutter campaign exists.
Volunteer logins get is_staff + one VolunteerProfile(org=...) per org they work with (the
same people volunteer on letters and on fundraisers); admin queries are scoped to those orgs,
Org pickers offer only them, delete is superuser-only, and publishing (campaigns, testimonials,
updates, posts, people) is an approver action per org, an approver for org A cannot publish
org B's rows even when they can see them. LinkedTrust SSO activates when LINKEDTRUST_CLIENT_ID/SECRET are set
(client registered at live.linkedtrust.us, see django-linkedtrust-auth README); Django
session login always works as the fallback.