organizations app
The structural backbone. Every other app's data is scoped through this app's models.
Models (29)
All 29 models below live in backend/apps/organizations/models.py — there is no separate pnm or accounts app; PNM, account-request, and bulletin models are all defined in this one file. (The platform-wide User model lives in apps/accounts/models.py, a different app, and is not counted here.)
Core hierarchy
Organization— tenant root; fields: name, slug, org_type, is_active, primary_color, accepts_account_requests, name_check_thresholdRegion— optional grouping of chapters within an org;states(list of state codes)Chapter— concrete chapter; FK to org + nullable region; fields: name, designation, university, city, state, founded_date, status (active / inactive / suspended)Membership— joins User to Chapter; fields: role (member / officer / president / advisor / alumni), status (pnm / undergrad / associate / alumni / active_alumni / lifetime_alumni / disaffiliated / inactive), joined_date, graduation_year, graduation_season, pledge_class, line_number, crossing_semester, crossing_date
:::danger graduation_year is the ENDING ACADEMIC year
2027 means the 2026–2027 academic year, so a member graduating in calendar
Fall 2026 (December) stores 2027. Pair it with graduation_season
(FALL/SPRING/SUMMER — Term.Season minus FULL_YEAR) for semester
precision.
Convert between calendar and academic years with
term_mapping.calendar_year_for() / academic_year_for() — never by hand.
Getting this backwards is #836, which named a fall term a year later than the
semester it described and caused dues to be configured against the wrong record.
Do not reuse parse_billing_label / billing_label_for for this: they are
a frozen BillingPeriod storage format parsed by backfill migrations, and
parse_billing_label has no SUMMER regex, so summer does not round-trip.
graduation_year is range-validated (1900–2100 at the model layer, plus a
"no more than ten years out" rule in the serializer). The bounds are constants,
not a callable limit_value — drf-spectacular reads limit_value straight into
the OpenAPI maximum without calling it, and schema generation dies on a
function object.
Membership.graduation_year is canonical: compute_rollover and the KPI
services read it. MemberDegree.graduation_year is a separate historical
credential record and may disagree without either being wrong.
:::
Admin tiers
OrganizationAdmin— promotes a User to org admin; fields: title, is_activeRegionalAdmin— promotes a User to regional admin; fields: role (regional_director / coordinator / advisor), title, is_active
Config & singletons
ProfileCompletenessConfig— per-org rules for what counts toward member profile completeness scoreStorageConfig— per-org S3 / MinIO config (encrypted credentials)StorageMigration— tracks progress of a one-time file migration between twoStorageConfigs- AWS bucket provisioning (
aws_provisioning.py) —provision_org_bucket(org, dry_run=)creates an instance-agnostic per-org bucket (greekmanage-org-<uuid>, public access blocked + default SSE) and a least-privilege IAM user scoped to it, then writes the encryptedStorageConfig. Exposed to platform admins viaPOST /api/platform/organizations/<id>/storage-config/provision/({dry_run:true}returns the plan without touching AWS). Credential is env-driven (AWS_PROVISIONING_*, off by default; falls back to the boto3 provider chain / task role). All boto3/IAM access is wrapped in this module. Provisioned users are created under theAWS_PROVISIONING_PERMISSIONS_BOUNDARY_ARNpermissions boundary — the provisioning role'siam:CreateUsergrant is conditioned on it, so an unset boundary fails closed instead of creating an uncapped user. Policy documents + rationale live interraform/policies/. Re-provisioning is repeatable: the superseded access key is revoked before a new one is issued, because AWS caps an IAM user at 2 keys. - Storage lifecycle (
signals.py+tasks.py) — apost_saveonOrganizationdispatchesprovision_org_storage_task(viatransaction.on_commit), and apre_deletedispatchesdeprovision_org_storage_task.pre_delete, notpost_delete, because theStorageConfigholding the bucket name cascades away with the org. Provisioning is async so the verify probe can retry — IAM is eventually consistent and a new access key isn't immediately usable, and since_verify_configwritesis_verified=Falserather than raising, an unretried propagation delay would silently fall the org back to the shared platform bucket. Retries callreverify_org_storage(probe only) so they never rotate the org's key. Deprovisioning removes the access keys, inline policy and IAM user but retains the bucket, and no-ops when the recorded bucket name doesn't matchorg_bucket_name(org). Both hooks are gated onAWS_PROVISIONING_ENABLED, which is what stops staging minting a bucket per CI deploy. When several instances share one AWS account (prod on ECS + dev on the minipc) each uses its ownAWS_PROVISIONING_BUCKET_PREFIX, and buckets are taggedgreekmanage:instancefromAWS_PROVISIONING_INSTANCE_IDso a re-run refuses to adopt another instance's bucket — necessary because bucket names derive from the org UUID andrestore_serviceonly restores into an org with the same UUID. - Per-org storage construction (
storage.py) —build_s3_storage(config)passes everyStorageConfig-owned field toS3Boto3Storageexplicitly, including empty ones asNone. Omitting a kwarg is not "unset": django-storages then falls back to the process-wideAWS_S3_*settings, which describe a different bucket entirely. On a deployment that still has a global S3-compatible endpoint (MinIO) while orgs are cut over to native AWS S3, that fallback would silently route the org's files to MinIO and hand out URLs on the wrong host.settings.AWS_S3_ENDPOINT_URLis normalised"" → Noneat its definition for the same reason — boto3 rejects an empty endpoint withInvalid endpoint:. OrganizationEmailConfig— per-org email sending identity/SMTP override (from_name/from_email/reply_to, optional dedicated SMTP credentials)OrganizationConsentTemplate— org-authored consent template (anti-hazing, code-of-conduct, etc.), versioned
Workflow / requests
StatusChangeRequest— member-submitted request to change their ownMembership.status, with document upload + officer reviewMemberAddRequest— officer-submitted request to add a new member, routed through regional then org review; fields: email, first_name, last_name, role, joined_date, graduation_year, graduation_season, pledge_class, crossing_semester, justification
:::warning MemberAddRequest graduation fields must not drift from Membership
Approval copies graduation_year and graduation_season verbatim onto the
new Membership, so both columns restate the same convention (ENDING ACADEMIC
year) and the same 1900–2100 model validators. They are restated rather than
inherited because Django does not run validators on save() — without them any
writer that is not the create serializer (admin, shell, an importer) could put
an out-of-range year into the canonical column (#987).
graduation_season did not exist here until #987, which also made
GraduationTermValidationMixin's season-requires-year check meaningful on this
serializer: it had been looking for a key that could never be present, so it
passed vacuously.
:::
AccountRequest— unauthenticated account request submitted from the login page; approval either creates aUser+Membership(created_user/created_membership), or links the requested email to an already-existing user as a verified secondaryapps.accounts.UserEmail(linked_user) — for the "this person already has an account under an org email and doesn't know it" case, with no duplicateUsercreatedBulkImport— CSV/XLSX chapter roster import job (upload → mapping → preview → processing)BulkImportRow— per-row status/result tracking for aBulkImport
Bulletins
Bulletin— org/region/chapter-targeted announcement (TipTap HTML), pinnable, publishable, expirableBulletinDismissal— per-user dismissal record for aBulletin(member-facing "hide" only; admin views still show every bulletin)
Social
SocialAccount— connected Instagram/X account for an org, with OAuth token storageSocialPost— synced post from aSocialAccount(Instagram Graph API)
Chapter contacts
ChapterAdvisor— contact record for a faculty/staff advisor assigned to a chapter (contact only, not linked to a User account)
PNM (Potential New Member / rush)
PNMApplicationConfig— per-org PNM intake configuration (singleton): application questions, required fields, notification routing, min-GPA gate, reference-letter requirementPNMProfile— PNM-specific application data + intake lifecycle, one-to-one withMembership(keys on the org'sTerm)- Recruitment periods are Term-native (Phase 8c): a "PNM period" is a
Termplus itsTermRecruitmentConfig, whose recruitment-lifecyclestatus(draft/active/closed) drives the org-wide application cycle. The standalonePNMPeriodmodel was retired. PNMAttachment— file uploaded by/for a PNM applicant (resume, headshot, transcript, cover letter)PNMReferenceLetter— one-time-token reference letter request/submission for a PNMPNMNameAssignment— single-tier approval flow (org-admin only) for a PNM's proposed fraternal name
Crossing carries the application forward
services/pnm_lifecycle.handle_pnm_crossing copies the intake answers that have
a member-side home (#987). PNM_STANDARD_FIELDS defines 16 collectable fields;
before #987 exactly three were carried, so everything else became unreachable
the moment a PNM crossed.
PNMProfile | Destination |
|---|---|
birth_date | MemberProfile.date_of_birth |
expected_graduation_semester + expected_graduation_year | Membership.graduation_season + graduation_year |
phone_number | User.phone_number |
permanent_address | MemberProfile.permanent_address_line1 (whole blob) |
crossed_at | Membership.crossing_date, and a derived crossing_semester |
:::danger expected_graduation_year is a CALENDAR year
The public apply page renders it as a bare number beside a free-text
"Expected graduation semester", with no academic-year framing, so an applicant
enters the term the way they say it out loud. Membership.graduation_year is
the ENDING ACADEMIC year. The copy converts through
term_mapping.academic_year_for(); copying it straight through would put every
fall graduate a full academic year early (#836).
When the free-text semester does not normalize to a season, the year is not carried at all. Without a season the conversion is undecidable, and guessing is how #836 shipped — skipping is never worse than the pre-#987 behaviour of carrying nothing, whereas a guess writes a wrong year that then reads as authoritative to rollover and the KPI services. :::
Two invariants hold for every copy above:
- Never clobber — a field is written only when the source is present and the destination is still empty, so a member-edited value always wins.
- Idempotent —
handle_pnm_crossingruns from both the manual transition endpoint and the compliance name-assignment approval hook.
crossing_semester is derived from the effective crossing_date (which may
be a pre-existing one), not from crossed_at, so the date and its display
string cannot disagree. permanent_address is a single TextField and is moved
whole into permanent_address_line1 — never regex-parsed — and is skipped
rather than truncated when it exceeds the 255-char column.
Fields with no member-side home are deliberately left on the PNMProfile:
place_of_birth, gpa, year_in_college,
organizations_memberships, honors_achievements, other_activities,
community_involvement, emergency_contact_name, emergency_contact_phone,
admin_notes. degree_type is also left behind: MemberDegree records an
EARNED credential, and a member who just crossed holds none — the same
reasoning bulk_import.py already applies to major/minor.
ethnicity was on that list until #1025 and no longer is. It is now carried by
_carry_demographics into MemberDemographics — not onto MemberProfile,
and that distinction is the entire decision:
MemberProfileView._check_accesslets any member of the same org read any other member's profile. A demographic field there is org-wide readable unless every future edit toMemberProfileSerializerremembers a per-field exception.MemberDemographicshas its own endpoint whose default audience is nobody. Read is granted explicitly to the member, chapter officers, org admins and regional admins; write is the member's alone — an officer overwriting self-identified data is a substitution, not a correction.
NoMemberSideHomeTest still pins that ethnicity is absent from
MemberProfile, so re-adding the column there fails the suite.
Retention: none, on purpose. No rule in apps.common.retention.RETENTION_RULES
touches MemberDemographics, and none should be added without reopening #1025 —
"keep indefinitely" is the recorded product decision, not an oversight. Note the
word is overloaded in this codebase: apps/retention/ is member retention
(chapter health and churn), and the Backups "Retention Policy" governs snapshot
counts. Neither has anything to do with this.
Account provisioning
AccountProvisioningConfig— per-org auto-provisioning config for crossing PNMs (Google Workspace or Microsoft 365), singletonAccountProvisioningRecord— tracks the provisioned/linked account for aMembership(one row per membership)
Key endpoints
| URL | Purpose |
|---|---|
GET /api/organizations/ | List orgs (platform admin sees all; others see own) |
GET /api/organizations/<id>/ | Org detail |
PATCH /api/organizations/<id>/ | Update org (name, branding, etc.) |
POST /api/organizations/<id>/admins/ | Invite an org admin |
GET /api/organizations/<id>/regions/ | List regions |
POST /api/organizations/<id>/regions/ | Create a region |
GET /api/organizations/<id>/chapters/ | List chapters |
POST /api/organizations/<id>/chapters/ | Create a chapter |
GET /api/chapters/<id>/members/ | Roster |
POST /api/chapters/<id>/members/ | Add a member |
PATCH /api/memberships/<id>/ | Update role / status |
Permissions
IsNationalAdmin— org-wide CRUDIsRegionalAdmin— region-scoped reads + chapter management within regionIsChapterOfficer— chapter-scoped membership managementIsChapterMember— read-only on own chapter
Background tasks
generate_org_snapshots— periodic stats snapshot for retention analyticsrecalculate_membership_stats— fires on bulk membership changes
Signals
post_saveonMembership— creates the relatedMemberProfileand onboarding records
Notable patterns
Membership statuses control platform access
Membership.PLATFORM_ACCESS_STATUSES is the gate — only users with at least one membership in an "access" status can sign in. Statuses excluded from access:
pnm(Prospective New Member — pre-bid)disaffiliated(terminated)inactive(paused)
One Membership per (User, Chapter)
DB constraint: unique_together = ("user", "chapter"). Transferring a member to another chapter creates a new Membership with the prior one moved to inactive.
Sole-admin protection
_validate_org_admin_deactivation blocks deactivating the last active org admin. Returns 409 Conflict with code: "last_admin".
Org-level branding
Organization.primary_color (HSL string, nullable). When set, the frontend resolves it via frontend/src/lib/brand-theme.ts and sets --primary, --primary-foreground, --cta, --cta-foreground, and --ring on document.documentElement (not on the layout subtree — Radix portals its overlays into document.body), overriding the platform default #334155. In dark mode the colour is lifted into a legible lightness/saturation band rather than used as authored, and the foreground flips between near-white and near-black based on the resulting lightness.
Per-org module licensing
Lives on the OrganizationModule model in apps/platform/models.py (a different app from the rest of this page's models). Foreign-key on Organization, one row per module.
Code paths
- Models:
backend/apps/organizations/models.py - Views:
backend/apps/organizations/views.py - Permissions referenced:
backend/apps/common/permissions.py - Middleware that scopes queries:
backend/apps/common/middleware.py