feat: Opportunities - reshape model + ship the listing surface#754
Open
Nickatak wants to merge 8 commits into
Open
feat: Opportunities - reshape model + ship the listing surface#754Nickatak wants to merge 8 commits into
Nickatak wants to merge 8 commits into
Conversation
Per Ryan's clarification (the spec captured locally as `blah.`), Opportunity listings are no longer public-readable and the Project model collapses into Opportunity: - Dissolve `Project` entirely. `project_name` moves onto Opportunity as a free-text `CharField`; `meeting_times` JSON moves on; the Opportunity.project FK, ProjectSerializer, ProjectViewSet, /api/projects/ URL, admin registration, and projects test suite are all removed. - Add `Opportunity.overview` (Role Overview, now owned by Opportunity) and back out `Role.overview` / `Role.responsibilities`. Experience level stays on Opportunity as `min_experience_required` but is dropped as a filter dimension (still exposed for display). - Drop public-read across the board. `OpportunityPermission` no longer green-lights anonymous SAFE_METHODS; browse and detail are authenticated-only. Closes the SAFE_METHODS PATCH gap (BUG-003) at the same time. - Status enum: "on hold" -> "on_hold" (machine-friendly identifier), default flips to "draft" so newly-created opportunities aren't publishable by accident. - `Opportunity.min_experience_required`: drop `null=True` / `default=""` (closes DJ001 - was on hackforla#737's deferred items list). - Remove the dead `SkillMatrixSerializer` (BUG-002). - Migration 0002 dissolves Project, reshapes Opportunity, and backfills existing "on hold" rows to "on_hold" via RunPython. Frontend: new `/opportunities` listing page wired through a feature directory at `features/opportunities/`. Two-column card matching the Figma target minus the deletions blah. calls out (no Program Area chip, no Tech/Languages split, no skill ratings, no project logo, no create-account CTA). Renders against static sample data for now - the source will swap to a live fetch of `/api/opportunities/` once the API client lands. Subtitle on the page calls out the sign-in-required posture so the dev preview is honest about the mock-data state. Backend: 39/39 tests pass; ruff / mypy (36 files) / bandit clean on project code. Frontend: lint:types / lint:css / lint:dead clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the filter contract from blah.:
- Availability ON/OFF: keeps opportunities the user can attend at
least one meeting of (day match + time-window overlap).
- Project: free-text substring filter against project_name,
case-insensitive and trimmed; empty query is a no-op.
- Skills OFF / Partial Match (30%): partial match keeps an
opportunity if at least 30% of its required skills appear in the
user's skill list (case-insensitive set overlap). Opportunities
with no tagged skills always pass to avoid hiding untagged
listings.
Filtering needs a "current user" (their meeting availability + their
skill list) to compare against. The page is still pre-auth mock data,
so this lands a `sampleCurrentUser` alongside `sampleOpportunities`.
Both go away in the same swap once the API client and signed-in
user wire-up land - the filter helpers read off the same shape, so
the swap is just changing the import sources.
OpportunityListPage becomes a client component (filter state +
useMemo over the filtered list). Pure predicate code sits in
`features/opportunities/lib/filters.ts` so the wiring is testable
in isolation when tests come.
Frontend: lint:types / lint:css / lint:dead / eslint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier filter bar was a horizontal strip above the listing - adequate for wiring the filter state but a poor match for the Figma target. This restructures it into the Figma's left-sidebar shape: - Page becomes a two-column grid (`280px 1fr`) with the filter sidebar sticky to the viewport top. Mobile breakpoint at 768px stacks the layout and unsticks the sidebar. - Sidebar header: `Filters (N)` count + a `Clear all` button that disables when no filter is active. - Active-filter chips render between the header and the section list (one chip per non-default filter), each with an inline `×` to clear that single filter. - Three collapsible filter sections (Availability / Project / Skills) built on native `<details>` / `<summary>`; the marker is replaced with a CSS chevron that rotates on open. Default closed. - Results column gains an `N results` count above the card list. Legacy Figma filter sections (Roles / Experience Level / Program Area / Tech / Languages) are intentionally absent per `blah.`. Behavior unchanged - same filter contract, same predicate code in `lib/filters.ts`. This is presentation only plus the new chip / clear-all controls. Frontend: lint:types / lint:css / lint:dead / eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…adge Two card-layout changes per Nick's review: - Project name moves from the right rail into the main column title row, sitting next to the role title (baseline-aligned, secondary color). The right rail keeps only `Posted: ...` + the Meeting Times / Skills sections. - The status badge is gone entirely - the listing surface only ever shows open opportunities by design, so the "Open" label was redundant and the other states wouldn't render here anyway. The `status` field stays on `Opportunity` (matches the API shape and the underlying admin) but doesn't render. Drops the now-dead `STATUS_LABELS` / `STATUS_CLASSES` maps and the five `.status*` CSS classes; `OpportunityStatus` is no longer exported from the sample-data module (still used internally as the `status` field's type). Frontend: lint:types / lint:css / lint:dead / eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…alog The browse surface is the replacement for HfLA's current GitHub-listed project-role catalog - volunteers join an open project role, not apply to a recent job posting. A "Posted: <date>" line frames it as a feed of recent listings, which is the wrong mental model. Removes the posted-date line from `OpportunityCard`, drops the `posted` field from the `Opportunity` type + sample data, and updates the card docstring to spell the framing out so future edits hold the line. Normalizes the one remaining `status: "on_hold"` sample to `"open"` to match the design rule that the listing only ever shows open opportunities. Frontend: lint:types / lint:css / lint:dead / eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…to open
Frontend listing card needs `role_title` (Role.title) and a flat
`skill_names: list[str]` on each opportunity to render without N+1
`/api/roles/<id>/` and `/api/skills/<id>/` lookups. Same shape need on
the current user (`/api/auth/me/`) so the Skills warm-referral filter
can read the user's known skill names directly. Surfacing both as
serializer-level derived fields keeps the underlying FK UUIDs on the
wire for matching-algorithm consumers.
Backend changes:
- `OpportunityReadSerializer` adds `role_title = ReadOnlyField(source=
"role.title")` and `skill_names = SerializerMethodField()` resolving
`skills_required_matrix` -> alphabetically sorted skill names. Empty
list (not null) when no matrix is attached.
- `CustomUserReadSerializer` adds the same `skill_names` field against
`skills_learned_matrix`. Both serializers share a
`_resolve_skill_names` helper in `ctj_api.serializers`.
- `OpportunityViewSet.get_queryset()` filters the list action to
`status="open"` only - the browse surface is the volunteer-facing
catalog, drafts / on-hold / filled / closed aren't part of it.
Retrieve / update / destroy still operate against the full set so
PMs (and the upcoming CMS surface) can edit non-open records.
- `CustomUser.meeting_availability` JSON shape now documented:
`[{"day": "Wed", "start": "17:00", "end": "21:00"}, ...]`, mirroring
`Opportunity.meeting_times` shape minus the `team` key. Migration
`accounts.0003` carries the field's new `help_text`.
Tests:
- `test_list_filters_to_status_open` covers the queryset filter
(all four non-open statuses excluded).
- `test_retrieve_returns_non_open_opportunity` confirms retrieve
isn't filtered (PM-CMS path stays intact).
- `test_list_exposes_role_title_and_skill_names` covers the new
derived fields and the alphabetical sort.
- `test_list_skill_names_empty_when_no_matrix` covers the empty
case (no matrix attached -> `[]`, not null).
- `test_me_includes_skill_names_resolved_from_matrix` +
`test_me_returns_empty_skill_names_when_no_matrix` mirror the same
coverage on the user-side serializer.
Backend: 45/45 tests pass; ruff / mypy (36 files) / bandit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The browse surface now reads off the real endpoints. No more mock data in the repo. Frontend: - New `opportunitiesApi` client at `src/shared/lib/api/opportunities.ts`, mirroring `OpportunityReadSerializer` (including the new derived `role_title` and `skill_names` fields). Exposes `.list()` (server-filtered to `status="open"`) and `.retrieve(id)`. - `User` type in `src/shared/lib/api/auth.ts` gains `skill_names: string[]` (mirrors the serializer addition) and `meeting_availability: AvailabilitySlot[] | null` (replacing the prior `unknown`, now that the JSON shape is documented on the backend model). - `OpportunityListPage` becomes a real data-fetching component: reads `useAuth()` for the current user, fetches the list in a `useEffect`, and branches on (a) auth loading, (b) anonymous user (renders a sign-in prompt linking `/login`), (c) fetch in flight, (d) fetch error, (e) zero results after filtering. - `OpportunityCard` and `lib/filters.ts` swap their type imports from the sample-data files to the API client + auth types. `meeting_times` is nullable now (matches the model's `null=True`), so the card's render conditional + the Availability predicate both handle the null case. - Deletes `data/sampleOpportunities.ts` + `data/sampleCurrentUser.ts`. - `AuthContext.test.tsx`'s `baseUser` fixture updated for the new `skill_names` field. Frontend: vitest 37/37 pass + 3 pre-existing skips; lint:types / lint:css / lint:dead / eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Idempotent dev-only seed: one PM/admin user (dev@example.com / password123!, isProjectManager + is_staff + is_superuser) and three open opportunities so /opportunities renders without hand-building the dependency graph through admin. Dev-only, never imported by views or tests; exempt from the no- hardcoded-data rule (test fixtures / dev-only seed scripts are explicitly carved out). Both seeded data and admin flags carry forward to the upcoming PM CMS work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
feat/opportunities: dissolve Project, reshape Opportunity, ship the listing surface
Off
develop. #752 (feat/auth) and #753(
feat/navbar-trim-org-links) merged 2026-06-01; this PR is eightcommits directly on top of the current
developtip, no stacked-PRcoupling.
Summary
Two related shifts in one PR plus a small dev-tooling rider: the
backend reshape and the volunteer-facing
/opportunitiesbrowsesurface wired to the result.
Backend — applied on top of the inherited Opportunity / Project /
Role layer:
Projectdissolves entirely.project_namemoves ontoOpportunityas free-text (deliberately weak as a filter key);meeting_timesJSON moves ontoOpportunity; theOpportunity.projectFK,ProjectSerializer,ProjectViewSet,/api/projects/URL, admin registration, and projects test suiteare all removed. CommunityOfPractice / role-taxonomy is
untouched here — that's the next PR.
Opportunity.overview(Role Overview, now owned by Opportunity)is added;
Role.overview/Role.responsibilitiesare backedout. Experience level stays on
Opportunityasmin_experience_requiredbut per the filter spec is no longer afilter dimension (still displayed).
OpportunityPermissionno longer green-lights anonymousSAFE_METHODS; this closes the SAFE_METHODS PATCH gap (BUG-003)
in the same change.
"on hold"→"on_hold"; default flips to"draft".OpportunityViewSet.get_queryset()filters the listaction to
status="open"so the volunteer-facing catalog onlyever surfaces open roles; retrieve / update / destroy still
operate against the full set (PM CMS reads off retrieve).
OpportunityReadSerializeradds two derived display-only fields:role_title(fromrole.title) andskill_names: list[str](alphabetically resolved from
skills_required_matrix).CustomUserReadSerializeradds the parallelskill_namesfor thecurrent user. Both save the listing card from N+1 lookups; the
underlying FK UUIDs stay on the wire for matching consumers.
CustomUser.meeting_availabilityJSON shape now documented:[{"day": "Wed", "start": "17:00", "end": "21:00"}, ...],parallel to
Opportunity.meeting_timesminus theteamkey.0002_dissolve_project_reshape_opportunityperformsthe reshape and backfills
"on hold"rows viaRunPython;migration
accounts.0003carries the newhelp_textonmeeting_availability.Opportunity.min_experience_requireddropsnull=True/default=""(closes DJ001 from Bump dependencies + replace lint stack #737's deferreditems); dead
SkillMatrixSerializerremoved (BUG-002).Frontend — the
/opportunitiesbrowse surface, sign-in-gated,zero hardcoded data:
Filters (N)count,Clear all, active-filter chips,collapsible
<details>sections per filter), results column onthe right with an
N resultscount.the user's
meeting_availability), Project free-text substring,Skills OFF / Partial Match (≥30% overlap on
skill_names). Purepredicate code in
features/opportunities/lib/filters.ts.(
Role Title · Project), experience + chips below, prosesections, right rail with Meeting Times and Skills. No status
badge, no posted date, no logo, no "create account" CTA —
see Decisions baked in.
opportunitiesApi.list()fetches theopen opportunities, the auth context (
useAuth()) provides thecurrent user. State branches: auth loading, anonymous (sign-in
prompt linking
/login), fetch in flight, fetch error, zeroresults. No hardcoded sample data anywhere in the repo.
Dev tooling —
make db-seed(new) runs aseed_devmanagement command that idempotently creates one PM/admin user
(
dev@example.com/password123!;isProjectManager+is_staffis_superuserset so the same account also drives Django admin)and three open opportunities. Dev-only; never imported by views or
tests. Exempt from the no-hardcoded-data rule (test fixtures and
dev-only seed scripts are carved out). See
docs/developer/installation.mdfor credentials anddocs/developer/quickstart-guide.mdfor the make-target entry.Commits
Eight commits.
feat: Dissolve Project, reshape Opportunity for sign-in-only listings(19 files; +972 / −296; backend reshape + migration +initial frontend listing scaffold).
feat: Add filter bar to /opportunities (Availability, Project, Skills)(6 files; +343 / −6; client-side filter state, purepredicate code in
lib/filters.ts).feat: Restructure /opportunities filter UI to match Figma layout(4 files; +335 / −94; two-column grid with stickysidebar,
Filters (N)header + Clear all, active-filter chips,collapsible
<details>sections,N resultscount).refactor: Promote project name into the card title row, drop status badge(3 files; +23 / −81; project name moves to themain title row, status badge removed - the listing surface only
ever shows open opportunities by design).
refactor: Drop posted-date from the card; reframe as project-role catalog(3 files; +13 / −21; this surfacereplaces HfLA's current GitHub-listed project-role catalog,
so the "Posted: ..." framing is wrong - volunteers join an
open role, they don't apply to a posting).
feat: Resolve display-only fields on Opportunity + User; filter list to open(8 files; +215 / −2;role_title+skill_namesderived fields on both Read serializers,queryset filter on the list action,
meeting_availabilityshape documented, tests).
feat: Wire /opportunities to live API; delete sample-data scaffolding(9 files; +283 / −260;opportunitiesApiclient,Usertype updates, listing page becomes a real fetchingcomponent with loading / error / unauthenticated states,
sample data files deleted).
feat: Add seed_dev management command + make db-seed for dev fixtures(6 files; +160 / −1; idempotent seed for onePM/admin user + three open opportunities so
/opportunitiesrenders on a fresh DB without hand-building the dependency
graph through admin).
Decisions baked in
The page replaces HfLA's current GitHub-listed project-role
catalog - volunteers join an open project role; they don't apply
to a recent posting. That framing drives: sign-in required
(you're joining, not browsing), only open roles ever appear (no
status badge needed), no posted-date metadata, no
create-account CTA. Worth holding the line on: small UI choices
("Apply" buttons, "New this week" sort, etc.) follow from this
framing and re-introduce job-board patterns we deliberately cut.
/api/opportunities/; the current user comes off the authcontext. No
sampleX.tsfiles; the only "data" in the repo aretest fixtures (in
tests/paths) and the dev-only seed script(not imported by views or tests).
belongs to the Opportunity — no Project data at all. Project
identity is a string on Opportunity, a deliberately weak filter
key. PeopleDepot will be the eventual source of truth for
project metadata; no
people_depot_project_idon Opportunityyet (out of scope, comes when PeopleDepot integration lands).
Rolekeeps its model + FK. The role-taxonomy drop is thenext PR (CommunityOfPractice /
Role.community_of_practice/Skill.communities_of_practice/CustomUser.community_of_practice/ frontend CoP picker).Leaving Role intact here keeps the PR scoped.
anonymous access. Model docstrings + any "anonymous-visible"
framing in the README will need a follow-up sweep — caught a
few; flagged the rest as a follow-up.
status="open". Drafts,on-hold, filled, and closed opportunities never appear in the
browse list — the listing surface is the volunteer-facing
catalog. Retrieve / update / destroy still hit the full set
so PMs can edit non-open records via the upcoming CMS.
role_titleand
skill_namesare derived on the wire so the card canrender with one request, not one + N. The underlying
role: UUID/skills_required_matrix: UUIDstay on the wirefor matching-algorithm consumers.
overviewbelongs to Opportunity, not Role. The Figma'sRole Overview prose travels with the opportunity, not the role.
Backing this off
Rolelets the role dataset stay generic.tagged skills always pass — we have no signal to score them and
excluding them would hide listings just because their PM hasn't
tagged skills yet.
Questions for review
project_namerequired on publish? Currentlyblank=True(draft-friendly). Figma always shows a project name; we may
want it required once
statusflips to"open". Proposing:keep
blank=Trueat the model layer, enforce on the publishtransition in serializer-level validation — but happy to make
it column-level required if you'd rather.
/api/opportunities/requires sign-in. The catalog endpoints (
/api/roles/,/api/skills/,/api/communities-of-practice/) are stillpublic (
AllowAny). The "require sign-ins" direction here wasabout listings specifically; scope is undecided for catalog.
Easy to flip if you want a flat policy.
bodylabel. WithProjectdissolved, the Figma's "Aboutthe Project" prose now lives in
Opportunity.body. The listingcard labels that section "About the Project" — reads slightly
oddly (it's opportunity-authored now). Keep, or rename to
"About this opportunity"?
Test plan
make local-test-backend→ 45/45 pass (39 prior +6 new for
role_title/skill_names/ list-status filter //api/auth/meskill_names).ruff checkclean;mypyclean (36 sourcefiles);
bandit -c pyproject.toml -r ctj_api/ accounts/ backend/→ 0 findings.makemigrations --checkreports no drift afterboth new migrations (
ctj_api.0002+accounts.0003).npx vitest run→ 37/37 pass (3 pre-existingskips);
lint:types/lint:css/lint:dead(knip) /eslintclean./opportunitiesreturns 200; anonymous sees the sign-inprompt; authenticated user sees the filter sidebar + result
list (or empty state).
GET /api/opportunities/returns 403 with thenot_authenticatedenvelope code.apply each filter and confirm results adjust; clear all;
sign out and confirm the sign-in prompt returns; PM admin
still CRUDs opportunities via Django admin end-to-end.
Follow-ups (out of scope for this PR)
CommunityOfPractice,PracticeAreas,/api/communities-of-practice/,Role.community_of_practice,Skill.communities_of_practice,CustomUser.community_of_practice, frontendcopData.ts+qualifier CoP picker. Will be its own PR stacked on this one.
/opportunities/<id>,/opportunities/new,/opportunities/<id>/edit). PM-only,reads off the retrieve action (which intentionally isn't
filtered by status).
lib/filters.tsare pureand unit-testable; tests come with the broader opportunities
test suite (queued behind detail + CMS).
skill_names. The current implementation issues oneextra
Skill.objects.filterper opportunity in the listresponse. Acceptable at current scale (open listings are
small); revisit with a prefetch or denormalization when the
catalog grows.
number of model + README hints still describe browse as
public-readable; one more pass after the auth-gating posture
settles.
still shows the old labels in some places; tidy after the
"on_hold"rename settles in.Role taxonomy that's being removed in the follow-up PR; it
rewrites alongside the deletion. Carried in this PR so the
surface it tests is usable on a fresh DB today.