members app
Rich profile data attached to each User (not Membership directly — a user can hold memberships in multiple chapters over time, but has a single MemberProfile).
Models (15)
MemberProfile— extendsUser(notMembership) 1:1;school_attended,major,minor,date_of_birth,profession,company,linkedin_url, a full postal address (address_line1/address_line2/city/state/postal_code/countryplus an independentpermanent_*set),big/new_member_educatorFKs (both point atUser),willing_to_mentor,open_to_connectMemberDegree— FK toMemberProfile; degree, major, minor, school, graduation_yearMemberDemographics— 1:1 withMembership(notMemberProfile); free-textethnicity,source(recruitment/self_reported/import),collected_at. RLS-scoped viamembership → chapter → organization
:::warning Why demographics are not on MemberProfile
MemberProfileView._check_access lets any member of the same org read any
other member's profile. Putting a demographic field there would make it
org-wide readable unless every future edit to MemberProfileSerializer
remembered a per-field exception. MemberDemographics gets its own endpoint,
so its default audience is nobody and access is granted deliberately (#1025).
ethnicity is deliberately free text with no choices. DECLINED_DEMOGRAPHIC_VALUES
recognises refusals ("Prefer not to say", "Decline to state", …) case- and
whitespace-insensitively, so a refusal stays distinguishable from a blank
nobody ever answered — three states, not two. The disclosed and declined
model properties derive from the string rather than being stored, so the two
can never drift apart.
There is no retention rule and none should be added without reopening #1025. Values are kept indefinitely by decision.
Because the field is free text, the aggregate's buckets list has one row per
distinct answer and is therefore unbounded by nature — the case
gm-unbounded-embedded-list exists for. It is declared with BoundedListField
(ceiling 50) and the view folds the tail into other_count/other_distinct
rather than truncating, so answered == sum(bucket.count) + other_count holds
whether or not the cap was reached.
:::
:::note Profile vs degree academic fields
MemberProfile.major/.minor are current state — what the member studies
now — and are what the search and AI layers read. MemberDegree.major/.minor
are the historical record of what a completed credential was in. They are
not expected to agree and neither is derived from the other. MemberDegree.degree
is required on create, so a current undergrad belongs in the profile fields
rather than a fabricated degree row.
:::
:::warning date_of_birth is unencrypted
Deliberately a plain DateField, matching PNMProfile.birth_date, so it stays
queryable (an encrypted DOB forecloses any "birthdays this month" feature). If
the privacy posture changes, the decision must cover PNMProfile.birth_date
too or the data is exposed on the other side anyway.
:::
WorkHistory— FK toUser; company, title, industry, department, start/end dates,source(manual / linkedin / resume)Certification— FK toUser; name, issuing organization, year obtained, expirationAffiliation— FK toUser; non-Greek org name, role, type (nonprofit / professional / civic / other)MemberSkill— FK toUser; a single lowercased skill name, unique per userCustomFieldDefinition— org-defined custom field (member- or chapter-scoped); type (text/number/date/boolean/select/multiselect), visibility (private/public), required, display orderMemberCustomData— one JSONField of custom field values per (user, organization)ChapterCustomData— one JSONField of custom field values per chapterMentorMatch— mentor↔mentee pairing;status(suggested → requested → accepted, or declined/terminated),match_score,match_reasons,is_manual(admin-paired vs. AI-suggested)RecognitionCategory— org-configurable recognition categories (e.g. "Member of the Week"); icon, display orderRecognition— a shout-out from a user to aMembershiprecipient, optionally tagged with aRecognitionCategory;is_public, soft-deletableBigLittleRequest— multi-tier approval workflow for assigning a big/little pair; entry tier depends on the requester's role (member → chapter review; officer → regional; regional admin → org; national admin → auto-approved)SavedMemberView— a named, optionally org-shared saved directory filter (filtersJSONField)
There is no SkillEndorsement model — it doesn't appear anywhere in this app's (or the codebase's) git history. MemberSkill is a flat per-user skill list with no peer-endorsement concept.
Key endpoints
Mounted at /api/ directly (see backend/greekmanage/urls.py); most routes are nested under organizations/<org_pk>/... rather than members/<id>/....
| URL | Purpose |
|---|---|
GET/PATCH /api/organizations/<org_pk>/members/<user_pk>/profile/ | Full profile (own or, read-only, another member's) |
GET/PATCH /api/organizations/<org_pk>/members/<user_pk>/demographics/ | Self-identified demographics. Read: the member, officers of their chapter, the regional admin of their region, org admins. Write: the member only. source/collected_at are server-owned |
GET /api/organizations/<org_pk>/demographics/summary/?scope= | Aggregate counts; scope is members (default), applicants or all. Org admins only. buckets is capped at DEMOGRAPHICS_BUCKET_CEILING (50) with the tail folded into other_count/other_distinct |
GET /api/organizations/<org_pk>/members/<user_pk>/work-history/ .../certifications/ .../affiliations/ .../skills/ | Read-only enrichment data for viewing another member's profile |
GET/POST /api/members/me/skills/, .../work-history/, .../certifications/, .../affiliations/ | CRUD on the caller's own enrichment data (DefaultRouter-registered viewsets) |
GET /api/organizations/<org_pk>/skills/ | Distinct skill names org-wide (autocomplete) |
GET /api/organizations/<org_pk>/directory/insights/ | Directory-level aggregate stats |
GET /api/organizations/<org_pk>/members/completeness/ | Per-chapter profile-completeness breakdown |
POST /api/organizations/<org_pk>/members/nudge/ | Bulk-nudge members with incomplete profiles (7-day cooldown, configurable) |
POST /api/organizations/<org_pk>/recognitions/ | Send a recognition |
GET /api/organizations/<org_pk>/mentorship/suggestions/ | AI-scored mentor suggestions |
POST /api/mentorship/request/, POST /api/mentorship/<id>/respond/ | Mentee-initiated match request / mentor response |
POST /api/organizations/<org_pk>/big-little-requests/ | Submit a big/little request (enters at the tier matching the requester's role) |
GET /api/organizations/<org_pk>/family-tree/ | Big-little lineage data for the tree visualization |
GET/POST /api/organizations/<org_pk>/saved-views/ | Saved directory filter views |
Permissions
Uses the shared classes from apps.common.permissions (this app has no permissions.py of its own):
IsAuthenticated— own enrichment data (work history, certs, affiliations, skills), mentorship request/respondIsChapterMember— read directory enrichment data for other members in the same chapterIsNationalAdmin— custom field definitions, exports, completeness/nudge, recognition categories, admin mentor pairingIsNationalAdminOrChapterOfficer— some recognition-category and reorder actions- Demographics use three local helpers in
views.pyrather than a shared class — no existingTierPermissionsubclass expresses the split:_demographics_org_privileged(user, org_id)— a coarse gate on URL inputs alone, so an outsider is refused before the subject is looked up. Without it, 403-vs-404 turns the endpoint into a membership oracle for any org._demographics_can_read_member(user, membership)— scoped to the subject: an officer must share their chapter, a regional admin their region. An org-wide officer check would expose every member's ethnicity to every officer of a multi-chapter national._demographics_report_reader(user, org_id)— org admins only. Deliberately narrower than per-member read: a chapter officer's remit is one chapter, so an org-wide roll-up is outside it, and no screen offers it to them. A region-scoped report is tracked separately (#1047).
Background tasks
Celery tasks in backend/apps/members/tasks.py (only two — smaller surface than the old doc implied):
send_recognition_notification(recognition_id)— notifies the recognition recipientsend_profile_nudge(org_id, actor_id)— bulk-sends the incomplete-profile nudge dispatched byMemberNudgeView
There is no dedicated LinkedIn-sync or completeness-scoring Celery task. Profile completeness (services/completeness.py) and mentor matching (services/mentorship.py) are computed synchronously on request, not via background tasks.
External integrations
- LinkedIn OAuth account linking lives in
apps.authentication(LinkedInAccountmodel,apps/authentication/linkedin_urls.py). Themembersapp only readsLinkedInAccount.objects.filter(user=...).exists()as one factor in the completeness score — it does not itself perform any LinkedIn data pull.
Notable patterns
Custom fields
Custom field definitions (CustomFieldDefinition) are org-scoped and can target either members or chapters. The actual values live in a separate per-(user, org) or per-chapter JSONField row (MemberCustomData / ChapterCustomData), each GIN-indexed for filtering — not inline on MemberProfile.
Profile completeness
services/completeness.py computes a weighted 0–100 score per user from a fixed breakdown (profile photo, current/past work history, education, location, ≥3 skills, LinkedIn linked, affiliations), using per-org weights from ProfileCompletenessConfig (falls back to ProfileCompletenessConfig.DEFAULT_WEIGHTS). compute_org_completeness aggregates this per chapter for MemberCompletenessView.
:::danger The location bucket reads only city + state
DEFAULT_WEIGHTS is an explicit eight-key allowlist, so new columns do not
auto-enroll — and the postal-address, date_of_birth, major and minor
fields are all deliberately excluded. Do not "complete" the location check by
also requiring address_line1 or postal_code: every member without a street
address would lose 10 points and begin receiving profile-completeness nudge
emails. Regression tests in
apps/members/tests/test_completeness_address_isolation.py pin this.
:::
Big-little family tree
MemberProfile.big is a direct FK to User (not to another MemberProfile), set once a BigLittleRequest is approved (or reversed via BigLittleReverseView). FamilyTreeView/FamilyTreeRootsView walk this relation to build the tree. The frontend (frontend/src/components/family-tree/family-tree-view.tsx) renders it with @xyflow/react (React Flow), not Reaflow/D3.
:::danger The column name lives in raw SQL strings
The tree is built from hand-written recursive CTEs, not the ORM. big_id appears as a SQL string literal in four functions in big_little_views.py — _is_descendant, _get_root_user_id, _get_descendant_tree, _count_descendants — plus a raw-cursor row key (row["big_id"]) and an ORM .only("big_id") in _would_create_cycle.
None of those are attribute access. Renaming the field again would rename the column and nothing would fail at import time or type-check — the suite would stay green while the family tree died at runtime with column ... does not exist. apps/members/tests/test_family_tree_sql.py drives each query through a real HTTP request for exactly this reason; e2e/members/family-tree.member.spec.ts is the through-the-UI half. Both were mutation-tested (each site broken deliberately, each confirmed to turn a test red).
:::
big_brother → big (v0.73.54, #986)
Renamed because "big brother" is fraternity-specific and GreekManage serves sororities. related_name went little_brothers → littles. Migration members.0022 is a RenameField only — no rows change and no lineage is touched.
The API keeps the old key for one deprecation window, because the mobile app bundles its JS (webDir: 'dist', no server.url), so installed clients stay pinned to the frontend build they shipped with:
| Surface | New | Deprecated alias |
|---|---|---|
MemberProfileSerializer (read) | big | big_brother — still emitted |
PNMProfileTransitionSerializer (write) | big_id | big_brother_id — still accepted |
| Bulk-import target field | big | big_brother — normalised via LEGACY_TARGET_ALIASES |
Sending both big_id and big_brother_id with different values is a 400, not a precedence rule — a client that sends two different bigs does not know which it wants. The dedicated big/little importer already used neutral big_email/little_email and was unchanged.
Lineage detach is audited
big is on_delete=SET_NULL, so deleting a big's User silently NULLs every little's big_id and detaches the branch. An intentional unpair through BigLittleReverseView has always written an AuditLog; the destructive path had not. apps/members/signals.py now records a lineage_detached_by_user_delete entry on pre_delete — by post_delete the FKs are already nulled and the lineage is unrecoverable. The write never raises: an audit failure must not be why a delete fails.
Mentorship matching
services/mentorship.py computes a 0–1 match_score between a mentee and candidate mentors from skill overlap (weight 0.4), industry overlap (0.3), and shared certifications, persisted on MentorMatch.match_reasons/match_score. Matches can also be paired directly by an admin (is_manual=True), bypassing the suggest → request → accept flow.
Code paths
- Models:
backend/apps/members/models.py - Views:
backend/apps/members/views.py - Big/Little + family tree views:
backend/apps/members/big_little_views.py - Services (completeness, mentorship, export, name check):
backend/apps/members/services/ - Tasks:
backend/apps/members/tasks.py
Related
- organizations app —
User(viaMembership) owns the profile - Custom fields (org admin)