Skip to main content

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 — extends User (not Membership) 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/country plus an independent permanent_* set), big/new_member_educator FKs (both point at User), willing_to_mentor, open_to_connect
  • MemberDegree — FK to MemberProfile; degree, major, minor, school, graduation_year
  • MemberDemographics — 1:1 with Membership (not MemberProfile); free-text ethnicity, source (recruitment/self_reported/import), collected_at. RLS-scoped via membership → 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 to User; company, title, industry, department, start/end dates, source (manual / linkedin / resume)
  • Certification — FK to User; name, issuing organization, year obtained, expiration
  • Affiliation — FK to User; non-Greek org name, role, type (nonprofit / professional / civic / other)
  • MemberSkill — FK to User; a single lowercased skill name, unique per user
  • CustomFieldDefinition — org-defined custom field (member- or chapter-scoped); type (text/number/date/boolean/select/multiselect), visibility (private/public), required, display order
  • MemberCustomData — one JSONField of custom field values per (user, organization)
  • ChapterCustomData — one JSONField of custom field values per chapter
  • MentorMatch — 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 order
  • Recognition — a shout-out from a user to a Membership recipient, optionally tagged with a RecognitionCategory; is_public, soft-deletable
  • BigLittleRequest — 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 (filters JSONField)

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>/....

URLPurpose
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/respond
  • IsChapterMember — read directory enrichment data for other members in the same chapter
  • IsNationalAdmin — custom field definitions, exports, completeness/nudge, recognition categories, admin mentor pairing
  • IsNationalAdminOrChapterOfficer — some recognition-category and reorder actions
  • Demographics use three local helpers in views.py rather than a shared class — no existing TierPermission subclass 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 recipient
  • send_profile_nudge(org_id, actor_id) — bulk-sends the incomplete-profile nudge dispatched by MemberNudgeView

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 (LinkedInAccount model, apps/authentication/linkedin_urls.py). The members app only reads LinkedInAccount.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_brotherbig (v0.73.54, #986)

Renamed because "big brother" is fraternity-specific and GreekManage serves sororities. related_name went little_brotherslittles. 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:

SurfaceNewDeprecated alias
MemberProfileSerializer (read)bigbig_brother — still emitted
PNMProfileTransitionSerializer (write)big_idbig_brother_id — still accepted
Bulk-import target fieldbigbig_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