Skip to main content

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_threshold
  • Region — 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/SUMMERTerm.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_active
  • RegionalAdmin — 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 score
  • StorageConfig — per-org S3 / MinIO config (encrypted credentials)
  • StorageMigration — tracks progress of a one-time file migration between two StorageConfigs
  • 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 encrypted StorageConfig. Exposed to platform admins via POST /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 the AWS_PROVISIONING_PERMISSIONS_BOUNDARY_ARN permissions boundary — the provisioning role's iam:CreateUser grant is conditioned on it, so an unset boundary fails closed instead of creating an uncapped user. Policy documents + rationale live in terraform/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) — a post_save on Organization dispatches provision_org_storage_task (via transaction.on_commit), and a pre_delete dispatches deprovision_org_storage_task. pre_delete, not post_delete, because the StorageConfig holding 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_config writes is_verified=False rather than raising, an unretried propagation delay would silently fall the org back to the shared platform bucket. Retries call reverify_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 match org_bucket_name(org). Both hooks are gated on AWS_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 own AWS_PROVISIONING_BUCKET_PREFIX, and buckets are tagged greekmanage:instance from AWS_PROVISIONING_INSTANCE_ID so a re-run refuses to adopt another instance's bucket — necessary because bucket names derive from the org UUID and restore_service only restores into an org with the same UUID.
  • Per-org storage construction (storage.py) — build_s3_storage(config) passes every StorageConfig-owned field to S3Boto3Storage explicitly, including empty ones as None. Omitting a kwarg is not "unset": django-storages then falls back to the process-wide AWS_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_URL is normalised "" → None at its definition for the same reason — boto3 rejects an empty endpoint with Invalid 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 own Membership.status, with document upload + officer review
  • MemberAddRequest — 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 a User + Membership (created_user/created_membership), or links the requested email to an already-existing user as a verified secondary apps.accounts.UserEmail (linked_user) — for the "this person already has an account under an org email and doesn't know it" case, with no duplicate User created
  • BulkImport — CSV/XLSX chapter roster import job (upload → mapping → preview → processing)
  • BulkImportRow — per-row status/result tracking for a BulkImport

Bulletins

  • Bulletin — org/region/chapter-targeted announcement (TipTap HTML), pinnable, publishable, expirable
  • BulletinDismissal — per-user dismissal record for a Bulletin (member-facing "hide" only; admin views still show every bulletin)

Social

  • SocialAccount — connected Instagram/X account for an org, with OAuth token storage
  • SocialPost — synced post from a SocialAccount (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 requirement
  • PNMProfile — PNM-specific application data + intake lifecycle, one-to-one with Membership (keys on the org's Term)
  • Recruitment periods are Term-native (Phase 8c): a "PNM period" is a Term plus its TermRecruitmentConfig, whose recruitment-lifecycle status (draft/active/closed) drives the org-wide application cycle. The standalone PNMPeriod model 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 PNM
  • PNMNameAssignment — 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.

PNMProfileDestination
birth_dateMemberProfile.date_of_birth
expected_graduation_semester + expected_graduation_yearMembership.graduation_season + graduation_year
phone_numberUser.phone_number
permanent_addressMemberProfile.permanent_address_line1 (whole blob)
crossed_atMembership.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.
  • Idempotenthandle_pnm_crossing runs 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 MemberDemographicsnot onto MemberProfile, and that distinction is the entire decision:

  • MemberProfileView._check_access lets any member of the same org read any other member's profile. A demographic field there is org-wide readable unless every future edit to MemberProfileSerializer remembers a per-field exception.
  • MemberDemographics has 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), singleton
  • AccountProvisioningRecord — tracks the provisioned/linked account for a Membership (one row per membership)

Key endpoints

URLPurpose
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 CRUD
  • IsRegionalAdmin — region-scoped reads + chapter management within region
  • IsChapterOfficer — chapter-scoped membership management
  • IsChapterMember — read-only on own chapter

Background tasks

  • generate_org_snapshots — periodic stats snapshot for retention analytics
  • recalculate_membership_stats — fires on bulk membership changes

Signals

  • post_save on Membership — creates the related MemberProfile and 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