diff --git a/Makefile b/Makefile index 3ea2f945..f842650c 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ local-test local-test-backend local-test-frontend \ local-clean local-kill-ports \ docker-up db-up docker-down docker-logs docker-watch \ - db-migrate db-makemigrations db-reset db-reset-hard db-grant-test-db-perms \ + db-migrate db-makemigrations db-seed db-reset db-reset-hard db-grant-test-db-perms \ stage-smoke stage-up stage-down \ docker-shell-backend docker-shell-frontend docker-shell-db \ lint install-hooks @@ -57,6 +57,7 @@ help: @echo " make docker-watch Run compose watch (live host->container sync)" @echo " make db-migrate Apply Django migrations against dev DB" @echo " make db-makemigrations Create Django migration files (in container)" + @echo " make db-seed Seed dev DB with a PM user + a few open opportunities" @echo " make db-reset Truncate all app data (keeps schema + migrations)" @echo " make db-reset-hard Drop dev DB volume and recreate DB container" @echo " make db-grant-test-db-perms Grant test-DB CREATEDB perms (for Django tests)" @@ -205,6 +206,9 @@ db-migrate: db-makemigrations: $(DEV_COMPOSE) exec $(BACKEND_SERVICE) python manage.py makemigrations +db-seed: + $(DEV_COMPOSE) exec $(BACKEND_SERVICE) python manage.py seed_dev + db-reset: @echo "This will truncate all app data (schema + migrations preserved). Type 'yes' to confirm:" @read ans && [ "$$ans" = "yes" ] || (echo "Aborted."; exit 1) diff --git a/backend/accounts/apps.py b/backend/accounts/apps.py index c441066c..400fe5f3 100644 --- a/backend/accounts/apps.py +++ b/backend/accounts/apps.py @@ -7,7 +7,7 @@ class AccountsConfig(AppConfig): Owns the `CustomUser` model (the project's `AUTH_USER_MODEL`), the per-user detail endpoint, the auth flow endpoints (signup, login, logout, me, csrf), and their permission classes / - serializers. Domain models (Opportunity, Project, etc.) stay in + serializers. Domain models (Opportunity, Role, etc.) stay in `ctj_api`; cross-app FKs to user use `settings.AUTH_USER_MODEL` so the boundary stays explicit. diff --git a/backend/accounts/migrations/0003_alter_customuser_meeting_availability.py b/backend/accounts/migrations/0003_alter_customuser_meeting_availability.py new file mode 100644 index 00000000..d91cc67b --- /dev/null +++ b/backend/accounts/migrations/0003_alter_customuser_meeting_availability.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-06-18 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='meeting_availability', + field=models.JSONField(blank=True, help_text='Availability windows the user can attend. JSON shape: [{"day": "Wed", "start": "17:00", "end": "21:00"}, ...]', null=True), + ), + ] diff --git a/backend/accounts/models.py b/backend/accounts/models.py index 8859ba35..a3824a6d 100644 --- a/backend/accounts/models.py +++ b/backend/accounts/models.py @@ -84,7 +84,22 @@ class CustomUser(AbstractUser): max_available_hours = models.IntegerField( null=True, blank=True, help_text="User's available hours per week." ) - meeting_availability = models.JSONField(null=True, blank=True) + # JSON shape: list of objects with keys `day` (str), `start` + # ("HH:MM"), `end` ("HH:MM"). Example entry: + # `{"day": "Wed", "start": "17:00", "end": "21:00"}`. + # Parallels `Opportunity.meeting_times` shape but omits the + # `team` key (opportunity-side metadata, not user-side). The + # Availability filter on the browse surface keeps an opportunity + # if any of its `meeting_times` slots overlaps any of these + # windows. Not enforced by a JSONSchema validator yet. + meeting_availability = models.JSONField( + null=True, + blank=True, + help_text=( + "Availability windows the user can attend. JSON shape: " + '[{"day": "Wed", "start": "17:00", "end": "21:00"}, ...]' + ), + ) isProjectManager = models.BooleanField( default=False, help_text="A user that is a PM can create and edit opportunities in the CMS.", diff --git a/backend/accounts/serializers.py b/backend/accounts/serializers.py index b7553487..efa7b0ef 100644 --- a/backend/accounts/serializers.py +++ b/backend/accounts/serializers.py @@ -17,17 +17,28 @@ from rest_framework import serializers from accounts.models import CustomUser +from ctj_api.serializers import _resolve_skill_names class CustomUserReadSerializer(serializers.ModelSerializer): """Read serializer for `CustomUser` records. + `skill_names` is a derived display-only field that resolves the + `skills_learned_matrix` JSON to an alphabetically-sorted list of + skill names. The frontend warm-referral filter compares this + list against the opportunity's `skill_names`; surfacing the + resolved names here saves the consumer a SkillMatrix fetch + N + `Skill` lookups. The matrix UUID stays on the wire for clients + that need the underlying ratings (matching algorithm). + Used by: - `user_detail` FBV (`GET /api/users//`). - `auth_me` / `auth_signup` / `auth_login` for response bodies where the canonical "current user" shape is needed. """ + skill_names = serializers.SerializerMethodField() + class Meta: model = CustomUser fields = [ @@ -37,6 +48,7 @@ class Meta: "email", "community_of_practice", "skills_learned_matrix", + "skill_names", "max_available_hours", "meeting_availability", "isProjectManager", @@ -44,6 +56,9 @@ class Meta: "updated_at", ] + def get_skill_names(self, obj): + return _resolve_skill_names(obj.skills_learned_matrix) + class RegisterSerializer(serializers.Serializer): """Request serializer for `POST /api/auth/signup/`. diff --git a/backend/accounts/tests/common.py b/backend/accounts/tests/common.py index d3a506fd..82fb900f 100644 --- a/backend/accounts/tests/common.py +++ b/backend/accounts/tests/common.py @@ -3,9 +3,8 @@ Each helper saves a `CustomUser` instance with sensible defaults that callers can override via keyword arguments. Per-test-file `setUp` methods import only the helpers they need. Domain factories -(CoP, Role, Skill, Project, Opportunity) live in -`ctj_api.tests.common` and are imported separately when a test -spans both apps. +(CoP, Role, Skill, Opportunity) live in `ctj_api.tests.common` and +are imported separately when a test spans both apps. """ from accounts.models import CustomUser diff --git a/backend/accounts/tests/test_auth.py b/backend/accounts/tests/test_auth.py index 64891ff4..363ec8a5 100644 --- a/backend/accounts/tests/test_auth.py +++ b/backend/accounts/tests/test_auth.py @@ -6,6 +6,7 @@ from accounts.models import CustomUser from accounts.tests.common import make_regular_user +from ctj_api.tests.common import make_skill, make_skill_matrix class AuthCsrfTests(APITestCase): @@ -251,3 +252,31 @@ def test_me_returns_403_for_anonymous(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) body = response.json() self.assertEqual(body["error"]["code"], "not_authenticated") + + def test_me_includes_skill_names_resolved_from_matrix(self): + """`skill_names` resolves the user's `skills_learned_matrix` to + alphabetically-sorted skill names. The browse surface's Skills + filter (warm-referral partial match) reads off this field rather + than fetching the matrix + N `Skill` lookups itself.""" + matrix = make_skill_matrix( + make_skill(name="TypeScript"), + make_skill(name="React"), + make_skill(name="PostgreSQL"), + ) + self.user.skills_learned_matrix = matrix + self.user.save() + self.client.force_authenticate(user=self.user) + response = self.client.get("/api/auth/me/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + list(response.json()["skill_names"]), + ["PostgreSQL", "React", "TypeScript"], + ) + + def test_me_returns_empty_skill_names_when_no_matrix(self): + """A user with no `skills_learned_matrix` returns + `skill_names: []`, not null.""" + self.client.force_authenticate(user=self.user) + response = self.client.get("/api/auth/me/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["skill_names"], []) diff --git a/backend/ctj_api/admin.py b/backend/ctj_api/admin.py index f6671a03..f4f2a413 100644 --- a/backend/ctj_api/admin.py +++ b/backend/ctj_api/admin.py @@ -1,12 +1,11 @@ """Django admin registrations for CTJ's domain models. -All six domain models are registered with the default `ModelAdmin` +All five domain models are registered with the default `ModelAdmin` (no customization). Effect: admins see every field on every row, with no list filters, search fields, or read-only protections. Enough for Stage 1 curation of the admin-managed reference tables -(`Skill`, `Role`, `Project`, `CommunityOfPractice`) plus emergency -edits to opportunities. The `CustomUser` admin lives in -`accounts.admin`. +(`Skill`, `Role`, `CommunityOfPractice`) plus emergency edits to +opportunities. The `CustomUser` admin lives in `accounts.admin`. If a model's admin needs filters, list display, or search later, register it with a dedicated `ModelAdmin` subclass instead of the @@ -23,7 +22,6 @@ from .models import ( CommunityOfPractice, Opportunity, - Project, Role, Skill, SkillMatrix, @@ -32,6 +30,5 @@ admin.site.register(CommunityOfPractice) admin.site.register(Role) admin.site.register(Skill) -admin.site.register(Project) admin.site.register(SkillMatrix) admin.site.register(Opportunity) diff --git a/backend/ctj_api/apps.py b/backend/ctj_api/apps.py index a2ff8f5c..1cbea116 100644 --- a/backend/ctj_api/apps.py +++ b/backend/ctj_api/apps.py @@ -5,9 +5,9 @@ class CtjApiConfig(AppConfig): """Django app config for `ctj_api`, the project's domain app. Owns the recruitment-catalog and taxonomy models (Opportunity, - Project, Role, Skill, SkillMatrix, CommunityOfPractice) and - their views/serializers/permissions/urls. The `CustomUser` - identity model and the auth flow live in the `accounts` app. + Role, Skill, SkillMatrix, CommunityOfPractice) and their + views/serializers/permissions/urls. The `CustomUser` identity + model and the auth flow live in the `accounts` app. `default_auto_field` is set to `BigAutoField` to silence Django's startup warning about implicit auto field selection, but in diff --git a/backend/ctj_api/management/__init__.py b/backend/ctj_api/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ctj_api/management/commands/__init__.py b/backend/ctj_api/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ctj_api/management/commands/seed_dev.py b/backend/ctj_api/management/commands/seed_dev.py new file mode 100644 index 00000000..9da49afb --- /dev/null +++ b/backend/ctj_api/management/commands/seed_dev.py @@ -0,0 +1,141 @@ +"""Idempotent dev-only seed: one PM user, one CoP, one Role, three Opportunities. + +Invoke via `make db-seed` (in-container) or +`python manage.py seed_dev` (host, after `make db-up`). Safe to re-run: +all rows are looked up by stable natural keys and updated in place. + +This command exists to make local UI work usable without clicking +through admin to hand-build the dependency graph. It is NOT used by +production code paths, NOT imported by views, and NOT loaded in +tests. The committed-data prohibition (memory: feedback_no_hardcoded_data) +explicitly carves out dev-only seed scripts. +""" + +from django.core.management.base import BaseCommand +from django.db import transaction + +from accounts.models import CustomUser +from ctj_api.models import CommunityOfPractice, Opportunity, Role + +DEV_USER_EMAIL = "dev@example.com" +DEV_USER_PASSWORD = "password123!" + + +class Command(BaseCommand): + help = "Seed the dev database with a PM user and a few open Opportunities." + + @transaction.atomic + def handle(self, *args, **options): + cop, _ = CommunityOfPractice.objects.get_or_create( + practice_area=CommunityOfPractice.PracticeAreas.ENGINEERING, + defaults={"description": "Engineering CoP"}, + ) + + role_backend, _ = Role.objects.get_or_create( + title="Backend Engineer", community_of_practice=cop + ) + role_frontend, _ = Role.objects.get_or_create( + title="Frontend Engineer", community_of_practice=cop + ) + role_sre, _ = Role.objects.get_or_create( + title="Site Reliability Engineer", community_of_practice=cop + ) + + user, created = CustomUser.objects.get_or_create( + email=DEV_USER_EMAIL, + defaults={ + "username": DEV_USER_EMAIL, + "name": "Dev User", + "people_depot_user_id": f"local:{DEV_USER_EMAIL}", + "isProjectManager": True, + "is_staff": True, + "is_superuser": True, + }, + ) + if created: + user.set_password(DEV_USER_PASSWORD) + user.save() + else: + updated_flags = False + for flag in ("isProjectManager", "is_staff", "is_superuser"): + if not getattr(user, flag): + setattr(user, flag, True) + updated_flags = True + if updated_flags: + user.save() + + opportunities = [ + { + "project_name": "Civic Tech Jobs", + "role": role_backend, + "overview": "Build the Django + DRF backend for the CTJ matcher.", + "body": "Wire the opportunity catalog, matching pipeline, and DRF.", + "responsibilities": "Ship endpoints, review PRs, pair with frontend.", + "min_experience_required": "mid-level", + "min_hours_required": 8, + "work_environment": "remote", + "meeting_times": [ + { + "team": "Developer Team", + "day": "Wed", + "start": "12:30", + "end": "13:30", + } + ], + }, + { + "project_name": "Civic Tech Jobs", + "role": role_frontend, + "overview": "Build the Next.js surfaces for browsing opportunities.", + "body": "Own the /opportunities listing, filter UI, and cards.", + "responsibilities": "Implement Figma flows, write Vitest, keep a11y.", + "min_experience_required": "junior", + "min_hours_required": 6, + "work_environment": "remote", + "meeting_times": [ + { + "team": "Developer Team", + "day": "Wed", + "start": "12:30", + "end": "13:30", + } + ], + }, + { + "project_name": "HfLA Tools", + "role": role_sre, + "overview": "Keep HfLA's shared dev infrastructure healthy.", + "body": "Tune CI, run migrations, and own the staging environment.", + "responsibilities": "Monitor deploys, triage stage, write runbooks.", + "min_experience_required": "senior", + "min_hours_required": 10, + "work_environment": "hybrid", + "meeting_times": [ + {"team": "Ops", "day": "Tue", "start": "18:00", "end": "19:00"} + ], + }, + ] + + for spec in opportunities: + Opportunity.objects.update_or_create( + project_name=spec["project_name"], + role=spec["role"], + defaults={ + "overview": spec["overview"], + "body": spec["body"], + "responsibilities": spec["responsibilities"], + "min_experience_required": spec["min_experience_required"], + "min_hours_required": spec["min_hours_required"], + "work_environment": spec["work_environment"], + "meeting_times": spec["meeting_times"], + "status": "open", + "created_by": user, + }, + ) + + self.stdout.write( + self.style.SUCCESS( + f"Seeded {Opportunity.objects.count()} opportunities. " + f"Login: {DEV_USER_EMAIL} / {DEV_USER_PASSWORD}" + ) + ) diff --git a/backend/ctj_api/migrations/0002_dissolve_project_reshape_opportunity.py b/backend/ctj_api/migrations/0002_dissolve_project_reshape_opportunity.py new file mode 100644 index 00000000..558c94ca --- /dev/null +++ b/backend/ctj_api/migrations/0002_dissolve_project_reshape_opportunity.py @@ -0,0 +1,118 @@ +# Generated by Django 6.0.4 on 2026-06-02, then hand-edited: +# - file renamed for legibility, +# - added a RunPython data step at the front for the `"on hold"` -> +# `"on_hold"` status rename and the `min_experience_required` +# NULL -> "" backfill. The RunPython has to come *before* the +# `AlterField` on `status` (so the new choices validate) and +# *before* the `AlterField` on `min_experience_required` (the new +# shape isn't `null=True` anymore; backfilling avoids the +# NOT-NULL transition tripping on existing NULL rows). +# +# Schema-wise this migration dissolves the `Project` model: the +# `Opportunity.project` FK is removed, the project's `meeting_times` +# JSON moves onto `Opportunity` as a column, and the project name +# becomes the free-text `Opportunity.project_name`. Existing project +# associations are dropped (pre-launch reshape; PeopleDepot owns +# projects upstream). The `overview` / `responsibilities` card text +# also moves onto `Opportunity` (decoupled from `Role`). + +from django.db import migrations, models + + +def _fix_opportunity_data(apps, schema_editor): + """Forward data migration: rename `"on hold"` status and backfill NULL experience.""" + Opportunity = apps.get_model("ctj_api", "Opportunity") + Opportunity.objects.filter(status="on hold").update(status="on_hold") + Opportunity.objects.filter(min_experience_required__isnull=True).update( + min_experience_required="" + ) + + +def _revert_opportunity_data(apps, schema_editor): + """Reverse: put `"on hold"` back. The NULL backfill isn't + reversible (we can't tell which `""` came from a NULL row), so + leave those as empty strings on revert. + """ + Opportunity = apps.get_model("ctj_api", "Opportunity") + Opportunity.objects.filter(status="on_hold").update(status="on hold") + + +class Migration(migrations.Migration): + + dependencies = [ + ("ctj_api", "0001_initial"), + ] + + operations = [ + migrations.RunPython(_fix_opportunity_data, _revert_opportunity_data), + migrations.RemoveField( + model_name="opportunity", + name="project", + ), + migrations.AddField( + model_name="opportunity", + name="project_name", + field=models.CharField( + blank=True, + default="", + help_text=( + "Free-text project name. Not a FK - PeopleDepot owns " + "projects upstream. Doubles as the (deliberately weak) " + "project filter key." + ), + max_length=255, + ), + ), + migrations.AddField( + model_name="opportunity", + name="overview", + field=models.TextField( + blank=True, + default="", + help_text='Card "Role Overview" section: what this position is and does.', + ), + ), + migrations.AddField( + model_name="opportunity", + name="responsibilities", + field=models.TextField( + blank=True, + default="", + help_text='Card "Responsibilities" section: what is expected of the volunteer.', + ), + ), + migrations.AddField( + model_name="opportunity", + name="meeting_times", + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name="opportunity", + name="min_experience_required", + field=models.CharField( + blank=True, + default="", + help_text="min_experience_required: junior, senior, mid-level, etc.", + max_length=50, + ), + ), + migrations.AlterField( + model_name="opportunity", + name="status", + field=models.CharField( + choices=[ + ("open", "Open"), + ("closed", "Closed"), + ("on_hold", "On hold"), + ("filled", "Filled"), + ("draft", "Draft"), + ], + default="draft", + help_text="Status will determine how the opportunity is shown.", + max_length=20, + ), + ), + migrations.DeleteModel( + name="Project", + ), + ] diff --git a/backend/ctj_api/models.py b/backend/ctj_api/models.py index 204bb49e..29ee089b 100644 --- a/backend/ctj_api/models.py +++ b/backend/ctj_api/models.py @@ -1,12 +1,11 @@ """Domain models for the CTJ platform. -This module defines the six Django models that back CTJ's core +This module defines the five Django models that back CTJ's core flows: the practice-area taxonomy (`CommunityOfPractice`, `Role`), the skill system (`Skill`, `SkillMatrix`), and the recruitment -catalog (`Project`, `Opportunity`). The user / identity model -(`CustomUser`) lives in the `accounts` app; FKs to it use -`settings.AUTH_USER_MODEL` so this module avoids importing the -user model directly. +catalog (`Opportunity`). The user / identity model (`CustomUser`) +lives in the `accounts` app; FKs to it use `settings.AUTH_USER_MODEL` +so this module avoids importing the user model directly. Stage 1 / Stage 2 boundary: several of these tables are locally curated in Stage 1 and migrate to PeopleDepot-backed shapes in @@ -150,45 +149,6 @@ def __str__(self): return self.name -class Project(models.Model): - """ - Summary: - - A real-world HfLA project that opportunities are posted under. - - Business workflow: - - HfLA runs many concurrent civic-tech projects (Tabler, Food - Oasis, CivicTechJobs itself, etc.); each owns its own - opportunities and meeting cadence. - - `people_depot_project_id` is the link to the upstream PeopleDepot - project record (PD owns project metadata; CTJ stores only what - it needs locally for the qualifier flow). - - Current policy: - - Stage 1: local table populated alongside PD. - - Stage 2: `Opportunity.project` swaps to a PeopleDepot UUID - reference and this table goes away. - - Lifecycle control: - - `admin-managed` (Stage 1). - - Visibility: - - `public-read` via `/api/projects/`. - """ - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - people_depot_project_id = models.CharField(max_length=255, unique=True) - name = models.CharField(max_length=50) - meeting_times = models.JSONField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = "projects" - - def __str__(self): - return self.name - - class SkillMatrix(models.Model): """ Summary: @@ -241,43 +201,54 @@ def __str__(self): class Opportunity(models.Model): """ Summary: - - An open volunteer position posted under a Project. + - An open volunteer position - the unit volunteers browse and match + against. Sign-in required to view; there are no public listings. Business workflow: - The recruitment-catalog row that the matching algorithm ranks against a user's `SkillMatrix`. - - Belongs to one `Project` (the team it's part of), takes the - title of one `Role` (the kind of position), declares its - required skills via a `SkillMatrix` reference, and tracks - status through a small enum (open / closed / on hold / filled / - draft). - - Posted by a project manager; viewable publicly in the catalog. + - Names its project as free-text (`project_name`), takes the title + of one `Role` (the kind of position), carries its own card + content (`overview`, `body`, `responsibilities`), declares its + required skills via a `SkillMatrix` reference, and tracks status + through a small enum (open / closed / on_hold / filled / draft). + - Posted by a project manager; visible to authenticated users only. Current policy: + - `project_name` is plain free-text, not a foreign key: project + metadata is not modeled locally. PeopleDepot owns Projects/Roles + upstream; an external reference may replace this string later. + Doubles as the (deliberately weak) project filter key. + - `meeting_times` (JSON) lives here too: an opportunity is shown to + a volunteer if they can make at least one of its meeting slots. + - `overview` / `responsibilities` are the opportunity's own card + content (the "Role Overview" and "Responsibilities" sections), + authored per-opportunity rather than inherited from `Role`. - `created_by` uses `on_delete=SET_NULL`: deleting a user does not - cascade-delete their posted opportunities. Loss of authorship - is preferable to losing the public-facing listings. - - `min_experience_required` is currently a nullable `CharField`, - which violates Django's "use empty string for missing - `CharField`" convention (DJ001). The TODO inline is to drop - `null=True` in a follow-up migration; the `noqa` keeps current - state lint-clean until then. - - Stage 2: `project` and `role` swap to PeopleDepot UUID references; - local `Project` and `Role` tables go away. + cascade-delete their posted opportunities. Loss of authorship is + preferable to losing the listings. + - `status` defaults to `"draft"`: PMs work in draft, transition to + `"open"` to publish. Conservative default. Lifecycle control: - `creator-managed` (only the creator can update; any PM can delete) - see `OpportunityPermission` in `ctj_api.permissions`. Visibility: - - `public-read` for browsing. + - `auth-read` for browsing (sign-in required; no public listings). - `pm-write` for creation; `creator-write` for updates; `pm-delete` for deletion. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - project = models.ForeignKey( - Project, on_delete=models.CASCADE, related_name="opportunities" + project_name = models.CharField( + max_length=255, + default="", + blank=True, + help_text=( + "Free-text project name. Not a FK - PeopleDepot owns projects " + "upstream. Doubles as the (deliberately weak) project filter key." + ), ) role = models.ForeignKey( Role, @@ -285,12 +256,20 @@ class Opportunity(models.Model): related_name="opportunities", help_text="Role.title will be the title of the opportunity.", ) + overview = models.TextField( + default="", + blank=True, + help_text='Card "Role Overview" section: what this position is and does.', + ) body = models.TextField(help_text="A description of the opportunity.") - # TODO: drop null=True (Django convention is "" for missing CharField); - # requires a migration, deferred to a follow-up PR. - min_experience_required = models.CharField( # noqa: DJ001 + responsibilities = models.TextField( + default="", + blank=True, + help_text='Card "Responsibilities" section: what is expected of the volunteer.', + ) + min_experience_required = models.CharField( max_length=50, - null=True, + default="", blank=True, help_text="min_experience_required: junior, senior, mid-level, etc.", ) @@ -305,6 +284,13 @@ class Opportunity(models.Model): ("in_person", "In Person"), ], ) + # JSON shape: list of objects with keys `team` (str), `day` (str), + # `start` ("HH:MM"), `end` ("HH:MM"). Example entry: + # `{"team": "Developer Team", "day": "Wed", "start": "12:30", "end": "13:30"}`. + # Not enforced by a JSONSchema validator yet -- the card renders + # whatever's there. A volunteer is shown the opportunity if they can + # make at least one slot. + meeting_times = models.JSONField(null=True, blank=True) skills_required_matrix = models.OneToOneField( SkillMatrix, on_delete=models.SET_NULL, @@ -318,11 +304,12 @@ class Opportunity(models.Model): choices=[ ("open", "Open"), ("closed", "Closed"), - ("on hold", "On hold"), + ("on_hold", "On hold"), ("filled", "Filled"), ("draft", "Draft"), ], - help_text="Status will determine how the opportunity will be shown publicly.", + default="draft", + help_text="Status will determine how the opportunity is shown.", ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -341,4 +328,4 @@ class Meta: verbose_name_plural = "Opportunities" def __str__(self): - return f"{self.role.title} @ {self.project.name}" + return f"{self.role.title} @ {self.project_name}" diff --git a/backend/ctj_api/permissions.py b/backend/ctj_api/permissions.py index f25f3840..94f1bd3a 100644 --- a/backend/ctj_api/permissions.py +++ b/backend/ctj_api/permissions.py @@ -7,19 +7,18 @@ class OpportunityPermission(permissions.BasePermission): """Permission policy for the Opportunity recruitment-catalog endpoints. Policy: - - Safe methods (GET/HEAD/OPTIONS): allowed for everyone (public read). + - Safe methods (GET/HEAD/OPTIONS): allowed for any *authenticated* + user. There are no public listings - browsing requires sign-in. - POST (create): allowed only for users with `isProjectManager=True`. - - PUT (full update): allowed only for the user who created the - opportunity (`obj.created_by == request.user`). + - PUT and PATCH (full / partial update): allowed only for the user + who created the opportunity (`obj.created_by == request.user`). + PATCH mirrors PUT - same authorization semantics, smaller + payload. - DELETE: allowed for any project manager - (`isProjectManager=True`), not only the creator. - - PATCH: not branched on; falls through to `return False` in both - `has_permission` and `has_object_permission`. Practical effect: - PATCH on `/api/opportunities//` is always 403'd, even though - `OpportunityViewSet` is a `ModelViewSet` that nominally exposes - it. Flagging as a bug - if partial updates should be allowed - (likely scoped to the creator like PUT), this class needs PATCH - branches in both methods. Deferred out of this docs-only PR. + (`isProjectManager=True`), not only the creator. Deletion is + intentionally broader than update so PMs can co-moderate + abandoned listings; update stays creator-scoped so PMs don't + step on each other's wording. Used by: - `OpportunityViewSet` (`/api/opportunities/`). @@ -33,29 +32,29 @@ def has_permission(self, request, view): method and properties of `request.user`. Branches: - - Safe methods (GET/HEAD/OPTIONS): always allowed (public - read; the catalog should be browsable without auth). + - Safe methods (GET/HEAD/OPTIONS): allowed only for + authenticated users (no public listings - sign-in required + to browse). - POST: requires `request.user.isProjectManager == True`. Uses `getattr(..., False)` so anonymous users (whose `request.user` is an `AnonymousUser` with no `isProjectManager` attribute) are rejected without raising `AttributeError`. - - PUT and DELETE: passes through if the user is authenticated. - The per-row creator/PM check happens in + - PUT, PATCH, DELETE: passes through if the user is + authenticated. The per-row creator/PM check happens in `has_object_permission` once DRF has loaded the target row. - - PATCH and anything else: falls through to `return False`. - See the class docstring for the PATCH bug flag. + - Anything else: falls through to `return False`. """ - # Allow safe methods for all users + # Reads require sign-in (no public listings) if request.method in permissions.SAFE_METHODS: - return True + return request.user.is_authenticated # Only PM's can create opportunities if request.method == "POST": return getattr(request.user, "isProjectManager", False) - # For PUT and DELETE, defer to object-level permissions - if request.method in ["PUT", "DELETE"]: + # For PUT, PATCH and DELETE, defer to object-level permissions + if request.method in ["PUT", "PATCH", "DELETE"]: return request.user.is_authenticated return False @@ -68,24 +67,24 @@ def has_object_permission(self, request, view, obj): instance via `obj`. Branches: - - Safe methods (GET/HEAD/OPTIONS): always allowed (mirrors - the same allowance in `has_permission`). - - PUT: only the user who created the opportunity can update - it (`obj.created_by == request.user`). + - Safe methods (GET/HEAD/OPTIONS): allowed for authenticated + users (mirrors the same allowance in `has_permission`). + - PUT and PATCH: only the user who created the opportunity + can update it (`obj.created_by == request.user`). PATCH + shares PUT's authorization semantics. - DELETE: any project manager can delete (the request-level check confirmed authentication; this confirms PM status). - Note this is broader than PUT - any PM can delete anyone's - opportunity, but only the creator can update their own. - - PATCH and anything else: falls through to `return False`. - See the class docstring for the PATCH bug flag. + Broader than update - any PM can delete anyone's + opportunity, but only the creator can edit their own. + - Anything else: falls through to `return False`. """ - # Read permissions are allowed to any request, - # so we'll always allow GET, HEAD or OPTIONS requests. + # Reads require sign-in (no public listings); has_permission + # already gated anonymous users, this mirrors it at object level. if request.method in permissions.SAFE_METHODS: - return True + return request.user.is_authenticated - # PUT permissions are only allowed to the PM that created the opportunity. - if request.method == "PUT": + # PUT/PATCH only by the user that created the opportunity. + if request.method in ["PUT", "PATCH"]: return obj.created_by == request.user # Any PM can delete any opportunity diff --git a/backend/ctj_api/serializers.py b/backend/ctj_api/serializers.py index 37d8cc13..f2c508b4 100644 --- a/backend/ctj_api/serializers.py +++ b/backend/ctj_api/serializers.py @@ -19,13 +19,35 @@ from ctj_api.models import ( CommunityOfPractice, Opportunity, - Project, Role, Skill, - SkillMatrix, ) +def _resolve_skill_names(matrix): + """Resolve a `SkillMatrix` to its sorted list of skill names. + + Skill names are sorted alphabetically so consumer rendering is + stable. Returns `[]` for a `None` matrix or an empty + `skill_matrix` dict (the underlying field defaults to `{}`). + + Used by both `OpportunityReadSerializer.get_skill_names` + (matrix is `skills_required_matrix`) and + `CustomUserReadSerializer.get_skill_names` (matrix is + `skills_learned_matrix`). + """ + if matrix is None: + return [] + skill_ids = list(matrix.skill_matrix.keys()) + if not skill_ids: + return [] + return list( + Skill.objects.filter(id__in=skill_ids) + .order_by("name") + .values_list("name", flat=True) + ) + + class OpportunityReadSerializer(serializers.ModelSerializer): """Read serializer for `Opportunity` records. @@ -34,30 +56,49 @@ class OpportunityReadSerializer(serializers.ModelSerializer): so list/retrieve responses surface a human-readable identity rather than an internal ID. + `role_title` and `skill_names` are derived display-only fields. + `role_title` saves the client an extra `/api/roles//` fetch + just to render the opportunity's role title; `skill_names` + resolves the `skills_required_matrix` to alphabetically-sorted + skill names so the card can render them without a SkillMatrix + fetch + N `Skill` lookups. The underlying `role` (UUID) and + `skills_required_matrix` (UUID) stay on the wire for clients + that need the references (matching algorithm, future ratings UI). + Used by: - `OpportunityViewSet.list` (`GET /api/opportunities/`). - `OpportunityViewSet.retrieve` (`GET /api/opportunities//`). """ created_by = serializers.ReadOnlyField(source="created_by.email") + role_title = serializers.ReadOnlyField(source="role.title") + skill_names = serializers.SerializerMethodField() class Meta: model = Opportunity fields = [ "id", - "project", + "project_name", "role", + "role_title", + "overview", "body", + "responsibilities", "min_experience_required", "min_hours_required", "work_environment", + "meeting_times", "skills_required_matrix", + "skill_names", "status", "created_by", "created_at", "updated_at", ] + def get_skill_names(self, obj): + return _resolve_skill_names(obj.skills_required_matrix) + class OpportunityWriteSerializer(serializers.ModelSerializer): """Write serializer for `Opportunity` records. @@ -71,49 +112,26 @@ class OpportunityWriteSerializer(serializers.ModelSerializer): Used by: - `OpportunityViewSet.create` (`POST /api/opportunities/`). - `OpportunityViewSet.update` / `partial_update` - (`PUT/PATCH /api/opportunities//`). Note: PATCH is currently - 403'd by `OpportunityPermission` (no PATCH branch); flagged for - fix in `ctj_api.permissions`. + (`PUT/PATCH /api/opportunities//`). """ class Meta: model = Opportunity fields = [ - "project", + "project_name", "role", + "overview", "body", + "responsibilities", "min_experience_required", "min_hours_required", "work_environment", + "meeting_times", "skills_required_matrix", "status", ] -class SkillMatrixSerializer(serializers.ModelSerializer): - """Read/write serializer for `SkillMatrix` records. - - Note: defined but never imported. Not split into Read/Write - because the class is currently dead code. - The cleanup PR drops it. If `SkillMatrix` becomes API-exposed - later, replace this with `SkillMatrixReadSerializer` (and - `SkillMatrixWriteSerializer` if a write endpoint is added). - Deferred out of this shape-only PR. - - Used by: - - (none currently). - """ - - class Meta: - model = SkillMatrix - fields = [ - "id", - "skill_matrix", - "created_at", - "updated_at", - ] - - class CommunityOfPracticeReadSerializer(serializers.ModelSerializer): """Read serializer for `CommunityOfPractice` records. @@ -144,7 +162,13 @@ class RoleReadSerializer(serializers.ModelSerializer): class Meta: model = Role - fields = ["id", "title", "community_of_practice", "created_at", "updated_at"] + fields = [ + "id", + "title", + "community_of_practice", + "created_at", + "updated_at", + ] class SkillReadSerializer(serializers.ModelSerializer): @@ -158,23 +182,3 @@ class SkillReadSerializer(serializers.ModelSerializer): class Meta: model = Skill fields = ["id", "name", "communities_of_practice", "created_at", "updated_at"] - - -class ProjectReadSerializer(serializers.ModelSerializer): - """Read serializer for `Project` records. - - Used by: - - `project_list` FBV (`GET /api/projects/`). - - `project_detail` FBV (`GET /api/projects//`). - """ - - class Meta: - model = Project - fields = [ - "id", - "people_depot_project_id", - "name", - "meeting_times", - "created_at", - "updated_at", - ] diff --git a/backend/ctj_api/tests/common.py b/backend/ctj_api/tests/common.py index 270dda3f..d7ad278b 100644 --- a/backend/ctj_api/tests/common.py +++ b/backend/ctj_api/tests/common.py @@ -4,8 +4,8 @@ callers can override via keyword arguments. The helpers do not return shared state - each call creates a new row. Callers wire factories together explicitly when they need relationships (e.g. an `Opportunity` -needs a `Project`, a `Role`, and a `created_by` user; the test's -`setUp` creates the dependencies and passes them in). +needs a `Role` and a `created_by` user; the test's `setUp` creates the +dependencies and passes them in - the project is now plain free-text). User factories (`make_pm_user`, `make_regular_user`) live in `accounts.tests.common` since the `CustomUser` model lives in the @@ -18,9 +18,9 @@ from ctj_api.models import ( CommunityOfPractice, Opportunity, - Project, Role, Skill, + SkillMatrix, ) @@ -36,9 +36,16 @@ def make_cop( ) -def make_role(*, title: str = "Developer", cop: CommunityOfPractice) -> Role: +def make_role( + *, + title: str = "Developer", + cop: CommunityOfPractice, +) -> Role: """Create a Role row anchored to the given CoP.""" - return Role.objects.create(title=title, community_of_practice=cop) + return Role.objects.create( + title=title, + community_of_practice=cop, + ) def make_skill(*, name: str = "Python") -> Skill: @@ -46,43 +53,54 @@ def make_skill(*, name: str = "Python") -> Skill: return Skill.objects.create(name=name) -def make_project( - *, - name: str = "Civic Tech Jobs", - people_depot_project_id: str = "1234-abcd", -) -> Project: - """Create a Project row.""" - return Project.objects.create( - name=name, - people_depot_project_id=people_depot_project_id, +def make_skill_matrix(*skills: Skill, default_rating: int = 3) -> SkillMatrix: + """Create a SkillMatrix populated with the given skills. + + Builds the matrix shape (`{skill_id: rating}`) from the provided + Skill rows; all entries get `default_rating` (3 by default) unless + a caller overrides. Tests that need varied ratings should populate + `matrix.skill_matrix` directly after this returns. + """ + matrix = SkillMatrix.objects.create( + skill_matrix={str(skill.id): default_rating for skill in skills}, ) + return matrix def make_opportunity( *, - project: Project, role: Role, created_by: CustomUser, + project_name: str = "Civic Tech Jobs", + overview: str = "", body: str = "Test opportunity", + responsibilities: str = "", min_experience_required: str = "junior", min_hours_required: int = 10, work_environment: str = "remote", + meeting_times: list | None = None, + skills_required_matrix: SkillMatrix | None = None, status: str = "open", ) -> Opportunity: """Create an Opportunity row. - Caller supplies the FK rows (project, role, created_by) explicitly - because tests typically want to assert against a specific PM user - or project; default-constructing them inside the helper would - obscure those references. + Caller supplies the FK rows (role, created_by) explicitly because + tests typically want to assert against a specific PM user; the + project is now plain free-text (`project_name`). Tests that exercise + card rendering should pass explicit `overview` / `responsibilities` + / `meeting_times` / `skills_required_matrix`. """ return Opportunity.objects.create( - project=project, role=role, + project_name=project_name, + overview=overview, body=body, + responsibilities=responsibilities, min_experience_required=min_experience_required, min_hours_required=min_hours_required, work_environment=work_environment, + meeting_times=meeting_times, + skills_required_matrix=skills_required_matrix, status=status, created_by=created_by, ) diff --git a/backend/ctj_api/tests/test_opportunities.py b/backend/ctj_api/tests/test_opportunities.py index bd37c6f2..35e386d7 100644 --- a/backend/ctj_api/tests/test_opportunities.py +++ b/backend/ctj_api/tests/test_opportunities.py @@ -7,44 +7,209 @@ from ctj_api.tests.common import ( make_cop, make_opportunity, - make_project, make_role, + make_skill, + make_skill_matrix, ) class OpportunityTests(APITestCase): """`OpportunityViewSet` behavior at `/api/opportunities/`. - Reads are public; mutations are gated by `OpportunityPermission`. - Only PMs can create; only the creator can update; any PM can - delete. PATCH is currently always 403'd (flagged for fix in - `ctj_api.permissions`). + Sign-in is required for everything (no public listings). Reads + need authentication; mutations are further gated by + `OpportunityPermission`: only PMs can create; only the creator can + update (PUT/PATCH); any PM can delete (broader than update so PMs + can co-moderate abandoned listings). """ def setUp(self): self.client = APIClient() self.pm_user = make_pm_user() + self.other_pm_user = make_pm_user( + username="other_pm", + email="other_pm@example.com", + people_depot_user_id="other_pm_pd_id", + ) self.regular_user = make_regular_user() self.cop = make_cop() self.role = make_role(cop=self.cop) - self.project = make_project() self.opportunity = make_opportunity( - project=self.project, role=self.role, created_by=self.pm_user, ) - def test_anonymous_can_list_opportunities(self): - """Opportunity list is publicly readable (no auth required).""" + # -- Read -------------------------------------------------------- + + def test_anonymous_cannot_list_opportunities(self): + """Anonymous users are rejected: there are no public listings.""" + response = self.client.get("/api/opportunities/") + self.assertIn( + response.status_code, + (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN), + ) + + def test_authenticated_user_can_list_opportunities(self): + """Any signed-in user (PM or not) can read the listing.""" + self.client.force_authenticate(user=self.regular_user) response = self.client.get("/api/opportunities/") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertGreater(len(response.data), 0) + def test_list_filters_to_status_open(self): + """Only `status="open"` rows appear in the list (browse surface + rule); drafts, on-hold, filled, and closed are filtered out.""" + # Mark the setUp opportunity (default `open`) and add one of each + # non-open status. + for non_open in ("draft", "on_hold", "filled", "closed"): + make_opportunity( + role=self.role, + created_by=self.pm_user, + status=non_open, + body=f"Should not appear ({non_open}).", + ) + self.client.force_authenticate(user=self.regular_user) + response = self.client.get("/api/opportunities/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + statuses = {row["status"] for row in response.data} + self.assertEqual(statuses, {"open"}) + + def test_retrieve_returns_non_open_opportunity(self): + """Retrieve isn't filtered by status - only list is. PMs reading + their own draft / on-hold rows by UUID still get them.""" + draft_opp = make_opportunity( + role=self.role, + created_by=self.pm_user, + status="draft", + body="Drafted, not yet published.", + ) + self.client.force_authenticate(user=self.pm_user) + response = self.client.get(f"/api/opportunities/{draft_opp.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], "draft") + + def test_list_exposes_role_title_and_skill_names(self): + """`role_title` (from `role.title`) and `skill_names` (resolved + from `skills_required_matrix`) appear on the read shape so the + card can render without extra `/api/roles/` + `/api/skills/` + lookups. `skill_names` is sorted alphabetically.""" + matrix = make_skill_matrix( + make_skill(name="React"), + make_skill(name="Django"), + make_skill(name="PostgreSQL"), + ) + opp = make_opportunity( + role=make_role(title="Backend Engineer", cop=self.cop), + created_by=self.pm_user, + skills_required_matrix=matrix, + ) + self.client.force_authenticate(user=self.regular_user) + response = self.client.get(f"/api/opportunities/{opp.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["role_title"], "Backend Engineer") + self.assertEqual( + list(response.data["skill_names"]), ["Django", "PostgreSQL", "React"] + ) + + def test_list_skill_names_empty_when_no_matrix(self): + """An opportunity with no `skills_required_matrix` returns + `skill_names: []`, not null - the card renders an empty list + rather than crashing on a missing field.""" + # Use the setUp opportunity (no matrix attached). + self.client.force_authenticate(user=self.regular_user) + response = self.client.get(f"/api/opportunities/{self.opportunity.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["skill_names"], []) + + def test_list_exposes_card_fields(self): + """The reshaped card content (`project_name`, `overview`, + `responsibilities`, `meeting_times`) is part of the read shape.""" + self.client.force_authenticate(user=self.pm_user) + opp = make_opportunity( + role=self.role, + created_by=self.pm_user, + project_name="Tabler", + overview="Builds the platform.", + responsibilities="Code, review, deploy.", + meeting_times=[ + {"team": "Dev", "day": "Wed", "start": "12:30", "end": "13:30"} + ], + ) + response = self.client.get(f"/api/opportunities/{opp.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["project_name"], "Tabler") + self.assertEqual(response.data["overview"], "Builds the platform.") + self.assertEqual(response.data["responsibilities"], "Code, review, deploy.") + self.assertEqual(response.data["meeting_times"][0]["day"], "Wed") + + def test_detail_returns_404_for_missing_id(self): + """GET on an unknown opportunity UUID returns 404 (when signed in).""" + self.client.force_authenticate(user=self.regular_user) + response = self.client.get( + "/api/opportunities/00000000-0000-0000-0000-000000000000/" + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # -- Create ------------------------------------------------------ + + def test_pm_can_create_opportunity(self): + """PMs can POST a new opportunity; the response carries the new row.""" + self.client.force_authenticate(user=self.pm_user) + payload = { + "project_name": "Tabler", + "role": str(self.role.id), + "body": "Backend engineer for the Tabler project.", + "min_experience_required": "mid-level", + "min_hours_required": 8, + "work_environment": "remote", + "status": "open", + } + response = self.client.post("/api/opportunities/", payload) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["body"], payload["body"]) + self.assertEqual(response.data["project_name"], "Tabler") + + def test_create_without_status_defaults_to_draft(self): + """Omitting `status` on POST yields `status="draft"` (the model default).""" + self.client.force_authenticate(user=self.pm_user) + payload = { + "project_name": "Food Oasis", + "role": str(self.role.id), + "body": "Draft-by-default check.", + "min_hours_required": 5, + "work_environment": "hybrid", + } + response = self.client.post("/api/opportunities/", payload) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["status"], "draft") + + def test_create_on_hold_status_accepted(self): + """`status="on_hold"` (underscored) is accepted; the literal-space + legacy value was renamed in migration 0002.""" + self.client.force_authenticate(user=self.pm_user) + payload = { + "project_name": "Food Oasis", + "role": str(self.role.id), + "body": "On-hold posting.", + "min_hours_required": 5, + "work_environment": "in_person", + "status": "on_hold", + } + response = self.client.post("/api/opportunities/", payload) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["status"], "on_hold") + + def test_create_validates_required_fields(self): + """Missing required fields (role, body, etc.) return 400.""" + self.client.force_authenticate(user=self.pm_user) + response = self.client.post("/api/opportunities/", {}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_regular_user_cannot_create_opportunity(self): """Non-PM users get 403 on POST /api/opportunities/.""" self.client.force_authenticate(user=self.regular_user) payload = { - "project": str(self.project.id), + "project_name": "Tabler", "role": str(self.role.id), "body": "Unauthorized create attempt", "min_experience_required": "senior", @@ -54,3 +219,77 @@ def test_regular_user_cannot_create_opportunity(self): } response = self.client.post("/api/opportunities/", payload) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # -- Update (PUT / PATCH) ---------------------------------------- + + def test_creator_can_put_update(self): + """The creating PM can PUT a full update to their own opportunity.""" + self.client.force_authenticate(user=self.pm_user) + payload = { + "project_name": "Tabler", + "role": str(self.role.id), + "body": "Updated description.", + "min_experience_required": "senior", + "min_hours_required": 12, + "work_environment": "hybrid", + "status": "open", + } + response = self.client.put( + f"/api/opportunities/{self.opportunity.id}/", payload + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["body"], "Updated description.") + + def test_creator_can_patch_update(self): + """The creating PM can PATCH a partial update (regression for the + previous always-403 PATCH gap in `OpportunityPermission`).""" + self.client.force_authenticate(user=self.pm_user) + response = self.client.patch( + f"/api/opportunities/{self.opportunity.id}/", + {"body": "Patched body only."}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.opportunity.refresh_from_db() + self.assertEqual(self.opportunity.body, "Patched body only.") + + def test_non_creator_pm_cannot_put_update(self): + """A PM who didn't create the opportunity cannot PUT it.""" + self.client.force_authenticate(user=self.other_pm_user) + payload = { + "project_name": "Tabler", + "role": str(self.role.id), + "body": "Hostile takeover.", + "min_experience_required": "senior", + "min_hours_required": 5, + "work_environment": "remote", + "status": "open", + } + response = self.client.put( + f"/api/opportunities/{self.opportunity.id}/", payload + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_non_creator_pm_cannot_patch_update(self): + """A PM who didn't create the opportunity cannot PATCH it either.""" + self.client.force_authenticate(user=self.other_pm_user) + response = self.client.patch( + f"/api/opportunities/{self.opportunity.id}/", + {"body": "Hostile partial takeover."}, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # -- Delete ------------------------------------------------------ + + def test_any_pm_can_delete_others_opportunity(self): + """A PM who didn't create the opportunity can still DELETE it + (deletion is intentionally broader than update so PMs can + co-moderate abandoned listings).""" + self.client.force_authenticate(user=self.other_pm_user) + response = self.client.delete(f"/api/opportunities/{self.opportunity.id}/") + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_regular_user_cannot_delete_opportunity(self): + """Non-PM users get 403 on DELETE.""" + self.client.force_authenticate(user=self.regular_user) + response = self.client.delete(f"/api/opportunities/{self.opportunity.id}/") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/ctj_api/tests/test_projects.py b/backend/ctj_api/tests/test_projects.py deleted file mode 100644 index 6de0c9f3..00000000 --- a/backend/ctj_api/tests/test_projects.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Tests for `/api/projects/` (read-only catalog).""" - -from rest_framework import status -from rest_framework.test import APITestCase - -from ctj_api.tests.common import make_project - - -class ProjectReadTests(APITestCase): - """Read-only catalog endpoints for `Project`.""" - - def setUp(self): - self.project = make_project() - - def test_list_returns_all_projects(self): - """GET on the Project list returns 200 with all rows serialized.""" - response = self.client.get("/api/projects/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]["name"], "Civic Tech Jobs") diff --git a/backend/ctj_api/urls.py b/backend/ctj_api/urls.py index d6fbd818..f5b84e83 100644 --- a/backend/ctj_api/urls.py +++ b/backend/ctj_api/urls.py @@ -6,7 +6,7 @@ function-based view (see `ctj_api.views` for the shape rule): - `healthcheck`: the liveness endpoint. -- `communities-of-practice/`, `roles/`, `skills/`, `projects/`: +- `communities-of-practice/`, `roles/`, `skills/`: list + detail FBV pairs for read-only catalog resources. The user detail endpoint (`users//`) lives in `accounts.urls` @@ -41,8 +41,6 @@ path("roles//", views.role_detail), path("skills/", views.skill_list), path("skills//", views.skill_detail), - path("projects/", views.project_list), - path("projects//", views.project_detail), re_path(r"^", include(router.urls)), # Catch-all for incorrect API routes re_path(r"^.*$", views.api_not_found), diff --git a/backend/ctj_api/views.py b/backend/ctj_api/views.py index 4d0d1216..c7ef4f69 100644 --- a/backend/ctj_api/views.py +++ b/backend/ctj_api/views.py @@ -1,16 +1,17 @@ """DRF views for CTJ's domain endpoints, mounted under `/api/`. This module hosts the read/write endpoints for the recruitment -catalog: opportunities, projects, roles, the skill catalog, and -the community-of-practice taxonomy. It also provides the -unauthenticated `healthcheck` endpoint and a JSON-shaped catch-all -404 handler for unknown `/api/*` paths. The per-user detail -endpoint lives in `accounts.views`. +catalog: opportunities, roles, the skill catalog, and the +community-of-practice taxonomy. It also provides the unauthenticated +`healthcheck` endpoint and a JSON-shaped catch-all 404 handler for +unknown `/api/*` paths. The per-user detail endpoint lives in +`accounts.views`. Routing for these views lives in `ctj_api.urls` (mounted at `/api/` from `backend.urls`); permission classes live in -`ctj_api.permissions`. Most read endpoints are public; mutations -are gated per-view by DRF permission classes. +`ctj_api.permissions`. The opportunities endpoint requires sign-in +for all methods (no public listings); the read-only catalog FBVs +(roles, skills, communities-of-practice) remain public. View shape convention: - Full-CRUD resources (>=4 actions of list/create/retrieve/update/ @@ -34,7 +35,6 @@ from ctj_api.models import ( CommunityOfPractice, Opportunity, - Project, Role, Skill, ) @@ -43,7 +43,6 @@ CommunityOfPracticeReadSerializer, OpportunityReadSerializer, OpportunityWriteSerializer, - ProjectReadSerializer, RoleReadSerializer, SkillReadSerializer, ) @@ -133,9 +132,11 @@ class OpportunityViewSet(viewsets.ModelViewSet): """ Summary: - Full-CRUD endpoint for the `Opportunity` recruitment catalog. - - Reads are public; mutations are gated by `OpportunityPermission` - (in `ctj_api.permissions`): only project managers can create, - only the creator can update, and any PM can delete. + - Sign-in required for everything (no public listings). Reads need + authentication; mutations are further gated by + `OpportunityPermission` (in `ctj_api.permissions`): only project + managers can create, only the creator can update, and any PM can + delete. - Kept as a `ModelViewSet` because the view exposes the full CRUD surface; narrower views use FBVs. @@ -157,26 +158,37 @@ class OpportunityViewSet(viewsets.ModelViewSet): - DELETE / delete Auth: - - IsAuthenticatedOrReadOnly + OpportunityPermission + - IsAuthenticated + OpportunityPermission Errors: - 400: Validation error on create/update (`OpportunityWriteSerializer` rejected the payload). - - 401: Unauthenticated mutation. + - 401: Unauthenticated request (reads and writes both require + sign-in). - 403: `OpportunityPermission` denied (e.g. non-PM trying to - create, non-creator trying to update). Note: PATCH is always - 403'd due to a gap in `OpportunityPermission` (no PATCH branch); - flagged for fix in `ctj_api.permissions`. + create, non-creator trying to update or partial-update). - 404: No opportunity exists with the given ID. """ queryset = Opportunity.objects.all() serializer_class = OpportunityReadSerializer permission_classes = ( - permissions.IsAuthenticatedOrReadOnly, + permissions.IsAuthenticated, OpportunityPermission, ) + def get_queryset(self): + # The browse surface (list) shows only open opportunities by + # design - drafts, on-hold, filled, and closed records aren't + # part of the volunteer-facing catalog. Retrieve / update / + # destroy still operate against the full set so PMs can edit + # their own non-open records (the upcoming PM CMS surface + # reads from those). + queryset = Opportunity.objects.all() + if self.action == "list": + queryset = queryset.filter(status="open") + return queryset + def get_serializer_class(self): # Dispatch by action: list/retrieve return the Read shape; # create/update/destroy accept the Write shape. The class-level @@ -351,56 +363,3 @@ def skill_detail(request, pk): skill = get_object_or_404(Skill, pk=pk) serializer = SkillReadSerializer(skill) return Response(serializer.data) - - -@api_view(["GET"]) -@permission_classes([permissions.AllowAny]) -def project_list(request): - """ - Summary: - - Public list of the `Project` table. - - Stage 1: admins curate the project list through Django admin - (`/admin/`). - - Stage 2: the project source moves to PeopleDepot. - - Flow: - 1. Fetch all `Project` rows. - 2. Serialize via `ProjectReadSerializer` and return 200. - - URL: - - GET /api/projects/ - - Auth: - - Public (`AllowAny`). - - Errors: - - (none) - """ - projects = Project.objects.all() - serializer = ProjectReadSerializer(projects, many=True) - return Response(serializer.data) - - -@api_view(["GET"]) -@permission_classes([permissions.AllowAny]) -def project_detail(request, pk): - """ - Summary: - - Public retrieval of a single `Project` row by UUID. - - Flow: - 1. Look up the row by primary-key UUID. - 2. Serialize via `ProjectReadSerializer` and return 200. - - URL: - - GET /api/projects// - - Auth: - - Public (`AllowAny`). - - Errors: - - 404: No project exists with the given UUID. - """ - project = get_object_or_404(Project, pk=pk) - serializer = ProjectReadSerializer(project) - return Response(serializer.data) diff --git a/docs/developer/installation.md b/docs/developer/installation.md index f1dc8c92..0b8666a7 100644 --- a/docs/developer/installation.md +++ b/docs/developer/installation.md @@ -89,6 +89,19 @@ docker compose run django python manage.py createsuperuser That account can sign into Django admin at http://localhost:8000/admin/ and is what you'll use to exercise PM-gated flows. Subsequent admin and PM elevations happen through Django admin's UI (toggle the `isProjectManager` flag on the user record). +### Seed user (browse-surface fixtures) + +`make db-seed` creates a deterministic PM user and three open opportunities so the `/opportunities` surface has something to render without building the dependency graph by hand. The command is idempotent (re-running is a no-op except for refreshing the opportunity fields), and the data is non-production - the seed lives in [`backend/ctj_api/management/commands/seed_dev.py`](../../backend/ctj_api/management/commands/seed_dev.py) and is never invoked by views or tests. + +| Field | Value | +|-------|-------| +| Email / username | `dev@example.com` | +| Password | `password123!` | +| `isProjectManager` | `true` | +| `is_staff` / `is_superuser` | `true` (signs into Django admin at `/admin/`) | + +Sign in at http://localhost:3000/login. The seeded opportunities cover a mix of work environments (remote, hybrid) and experience levels (junior, mid-level, senior) so the filter UI on `/opportunities` is exerciseable on a fresh DB. + Stage 2 will introduce Cognito JWT verification and PeopleDepot client mocks; that work lands when the upstream PeopleDepot deployment posture stabilizes. See [backend.md](backend.md#auth) for the Stage 2 design. ## Additional Resources diff --git a/docs/developer/quickstart-guide.md b/docs/developer/quickstart-guide.md index fb6cce40..b4534179 100644 --- a/docs/developer/quickstart-guide.md +++ b/docs/developer/quickstart-guide.md @@ -68,6 +68,7 @@ Common DB targets (against the running dev stack): ```sh make db-migrate # apply Django migrations make db-makemigrations # create migration files +make db-seed # seed a PM user + a few open opportunities (idempotent) make db-reset # truncate all app data (keeps schema + migrations); prompts for 'yes' make db-reset-hard # drop the volume and recreate the DB container; prompts for 'yes' make db-grant-test-db-perms # grant the test runner CREATEDB perms diff --git a/frontend/src/app/(with-nav)/opportunities/page.tsx b/frontend/src/app/(with-nav)/opportunities/page.tsx new file mode 100644 index 00000000..3a14856f --- /dev/null +++ b/frontend/src/app/(with-nav)/opportunities/page.tsx @@ -0,0 +1,15 @@ +/** + * Opportunity listing route at `/opportunities`. + * + * Renders inside the `(with-nav)` route group's layout. Defers to the + * `OpportunityListPage` feature component, which renders the + * Opportunity listing card. Live data fetching will replace the + * sample source once the auth-gated `/api/opportunities/` client + * lands. + */ + +import { OpportunityListPage } from "@/features/opportunities/components/OpportunityListPage"; + +export default function Page() { + return ; +} diff --git a/frontend/src/features/opportunities/components/OpportunityCard.module.css b/frontend/src/features/opportunities/components/OpportunityCard.module.css new file mode 100644 index 00000000..958c51c9 --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityCard.module.css @@ -0,0 +1,123 @@ +.card { + display: flex; + gap: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-grey); + border-radius: 16px; + background-color: var(--color-white); + box-shadow: 0 10px 30px -15px rgb(0 0 0 / 25%); +} + +/* Left: prose column */ +.main { + flex: 1 1 0; + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 0; +} + +.titleBlock { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.titleRow { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px var(--space-2); +} + +.role { + color: var(--color-charcoal); +} + +.project { + color: var(--color-grey-dark); +} + +.experience { + font-weight: var(--weight-bold); + color: var(--color-charcoal); + text-transform: capitalize; +} + +.metaRow { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-top: 4px; +} + +.metaChip { + padding: 4px 12px; + border-radius: var(--radius-sm); + background-color: var(--color-tan-light); + color: var(--color-charcoal); + font-size: 0.85rem; +} + +.section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sectionLabel { + font-weight: var(--weight-bold); + color: var(--color-charcoal); +} + +/* Right: metadata sidebar */ +.sidebar { + flex: 0 0 240px; + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-left: var(--space-4); + border-left: 1px solid var(--color-grey-light); +} + +.sidebarSection { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.sidebarLabel { + font-weight: var(--weight-bold); + color: var(--color-charcoal); +} + +.meetingList { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.meeting { + display: flex; + flex-direction: column; +} + +.meetingTeam { + font-weight: var(--weight-bold); + color: var(--color-grey-dark); +} + +/* Stack the sidebar under the prose on narrow viewports (md: 769px). */ +@media (width <= 768px) { + .card { + flex-direction: column; + } + + .sidebar { + flex-basis: auto; + padding-left: 0; + padding-top: var(--space-3); + border-left: none; + border-top: 1px solid var(--color-grey-light); + } +} diff --git a/frontend/src/features/opportunities/components/OpportunityCard.tsx b/frontend/src/features/opportunities/components/OpportunityCard.tsx new file mode 100644 index 00000000..58477055 --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityCard.tsx @@ -0,0 +1,149 @@ +/** + * Presentational card for a single Opportunity, used by the + * `/opportunities` listing page to preview the reshaped Opportunity + * shape. + * + * Two-column layout: a prose column on the left (role title + project + * name, "About the Project" / "Role Overview" / "Responsibilities & + * Requirements") and a metadata sidebar on the right (Meeting Times, + * Skills). This surface is the replacement for the GitHub-listed + * project-role catalog HfLA volunteers currently browse - read as + * "join a project role", not "apply to a job posting". + * + * Departures from the legacy Figma, per Ryan's clarification (`blah.`) + * and subsequent review: + * - No "Program Area" chip (concept deleted). + * - Skills are one flat list with no Tech/Languages split and no + * ratings (ratings are intentionally never surfaced). + * - No project logo (the project is now a free-text name, no image). + * - No login banner / "create account" CTA (sign-in is required). + * - No status badge - the listing only ever shows open opportunities + * by design. + * - No posted date - the surface is a role catalog, not a feed of + * recent postings. + */ + +import Typography from "@/shared/components/Typography"; + +import styles from "./OpportunityCard.module.css"; + +import type { + Opportunity, + WorkEnvironment, +} from "@/shared/lib/api/opportunities"; + +const WORK_ENVIRONMENT_LABELS: Record = { + remote: "Remote", + hybrid: "Hybrid", + in_person: "In Person", +}; + +function OpportunityCard({ opportunity }: { opportunity: Opportunity }) { + const { + project_name, + role_title, + overview, + body, + responsibilities, + min_experience_required, + min_hours_required, + work_environment, + meeting_times, + skill_names, + } = opportunity; + + return ( +
+ {/* Left: prose column */} +
+
+
+ + {role_title} + + + {project_name} + +
+ {min_experience_required ? ( + + {min_experience_required} + + ) : null} +
    +
  • {min_hours_required} hrs/week
  • +
  • + {WORK_ENVIRONMENT_LABELS[work_environment]} +
  • +
+
+ + {body ? ( +
+ + About the Project + + {body} +
+ ) : null} + + {overview ? ( +
+ + Role Overview + + {overview} +
+ ) : null} + + {responsibilities ? ( +
+ + Responsibilities & Requirements + + {responsibilities} +
+ ) : null} +
+ + {/* Right: metadata sidebar */} + +
+ ); +} + +export { OpportunityCard }; diff --git a/frontend/src/features/opportunities/components/OpportunityFilters.module.css b/frontend/src/features/opportunities/components/OpportunityFilters.module.css new file mode 100644 index 00000000..67fda5be --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityFilters.module.css @@ -0,0 +1,162 @@ +.sidebar { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); + background-color: var(--color-white); + border-radius: var(--radius-sm); +} + +.head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); +} + +.title { + margin: 0; +} + +.clearAll { + background: none; + border: 0; + padding: 0; + color: var(--color-charcoal); + text-decoration: underline; + cursor: pointer; + font: inherit; +} + +.clearAll:disabled { + color: var(--color-grey); + cursor: default; + text-decoration: none; +} + +.chipList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} + +.chip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 4px var(--space-1) 4px var(--space-2); + background-color: var(--color-blue); + color: var(--color-white); + border-radius: 999px; + font-size: 0.875rem; + line-height: 1.2; +} + +.chipLabel { + white-space: nowrap; +} + +.chipRemove { + background: none; + border: 0; + color: var(--color-white); + font-size: 1rem; + line-height: 1; + padding: 0 4px; + cursor: pointer; + border-radius: 50%; +} + +.chipRemove:hover { + background-color: rgb(255 255 255 / 20%); +} + +.divider { + border: 0; + border-top: 1px solid var(--color-grey-light); + margin: var(--space-1) 0; + width: 100%; +} + +.section { + border-bottom: 1px solid var(--color-grey-light); +} + +.section:last-of-type { + border-bottom: 0; +} + +.sectionHead { + list-style: none; + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) 0; + cursor: pointer; + font-weight: 600; + color: var(--color-charcoal); +} + +.sectionHead::-webkit-details-marker { + display: none; +} + +.sectionHead::after { + content: ""; + width: 8px; + height: 8px; + border-right: 2px solid var(--color-grey-dark); + border-bottom: 2px solid var(--color-grey-dark); + transform: rotate(45deg) translate(-2px, -2px); + transition: transform 150ms ease; +} + +.section[open] .sectionHead::after { + transform: rotate(-135deg) translate(-2px, -2px); +} + +.sectionBody { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: 0 0 var(--space-2); +} + +.toggleLabel, +.radioLabel { + display: flex; + align-items: center; + gap: var(--space-1); + color: var(--color-charcoal); + cursor: pointer; +} + +.textInput { + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-grey); + border-radius: var(--radius-sm); + background-color: var(--color-white); + font: inherit; + color: var(--color-charcoal); + width: 100%; +} + +.textInput:focus { + outline: 2px solid var(--color-blue); + outline-offset: 1px; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/frontend/src/features/opportunities/components/OpportunityFilters.tsx b/frontend/src/features/opportunities/components/OpportunityFilters.tsx new file mode 100644 index 00000000..4a933f5f --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityFilters.tsx @@ -0,0 +1,178 @@ +/** + * Filter sidebar for the `/opportunities` listing page. + * + * Layout mirrors the Figma: a sticky left rail with the "Filters (N)" + * count + "Clear all" link, a row of active-filter chips, a divider, + * then one collapsible section per filter. Sections use `
` + * for native a11y / keyboard support. + * + * Content per the spec (`blah.`) - the legacy Figma's Roles / + * Experience / Program Area / Tech / Languages sections are gone; + * Tech + Languages condensed into Skills: + * - Availability: ON/OFF checkbox; ON keeps opportunities the user + * can attend at least one meeting of. + * - Project: free-text substring filter against `project_name`. + * - Skills: two-state radio (OFF / Partial Match, ≥30% overlap). + * + * Controlled component - the parent owns the filter state and the + * filtering itself. + */ + +"use client"; + +import { useMemo } from "react"; + +import Typography from "@/shared/components/Typography"; + +import styles from "./OpportunityFilters.module.css"; + +import type { + OpportunityFilterState, + SkillsFilterMode, +} from "@/features/opportunities/lib/filters"; + +interface OpportunityFiltersProps { + filters: OpportunityFilterState; + onAvailabilityChange: (value: boolean) => void; + onProjectQueryChange: (value: string) => void; + onSkillsModeChange: (value: SkillsFilterMode) => void; + onClearAll: () => void; +} + +interface ActiveChip { + key: string; + label: string; + clear: () => void; +} + +function OpportunityFilters({ + filters, + onAvailabilityChange, + onProjectQueryChange, + onSkillsModeChange, + onClearAll, +}: OpportunityFiltersProps) { + const activeChips = useMemo(() => { + const chips: ActiveChip[] = []; + if (filters.availability) { + chips.push({ + key: "availability", + label: "Available to me", + clear: () => onAvailabilityChange(false), + }); + } + const trimmedQuery = filters.projectQuery.trim(); + if (trimmedQuery) { + chips.push({ + key: "project", + label: `Project: ${trimmedQuery}`, + clear: () => onProjectQueryChange(""), + }); + } + if (filters.skillsMode === "partial_match") { + chips.push({ + key: "skills", + label: "Skills: Partial match (30%)", + clear: () => onSkillsModeChange("off"), + }); + } + return chips; + }, [filters, onAvailabilityChange, onProjectQueryChange, onSkillsModeChange]); + + return ( + + ); +} + +export { OpportunityFilters }; diff --git a/frontend/src/features/opportunities/components/OpportunityListPage.module.css b/frontend/src/features/opportunities/components/OpportunityListPage.module.css new file mode 100644 index 00000000..1de3e9ba --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityListPage.module.css @@ -0,0 +1,74 @@ +.page { + max-width: 1200px; + margin: 0 auto; + padding: var(--space-6) var(--space-3) var(--space-10); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.intro { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.subtitle { + color: var(--color-grey-dark); +} + +.layout { + display: grid; + grid-template-columns: 280px 1fr; + gap: var(--space-5); + align-items: start; +} + +.filterRail { + position: sticky; + top: var(--space-3); + align-self: start; +} + +.results { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.resultsCount { + color: var(--color-grey-dark); + text-align: right; +} + +.list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.empty { + color: var(--color-grey-dark); + text-align: center; + padding: var(--space-4) 0; +} + +.notice { + color: var(--color-grey-dark); + padding: var(--space-4) 0; + text-align: center; +} + +@media (width <= 768px) { + .layout { + grid-template-columns: 1fr; + } + + .filterRail { + position: static; + } + + .resultsCount { + text-align: left; + } +} diff --git a/frontend/src/features/opportunities/components/OpportunityListPage.tsx b/frontend/src/features/opportunities/components/OpportunityListPage.tsx new file mode 100644 index 00000000..04819933 --- /dev/null +++ b/frontend/src/features/opportunities/components/OpportunityListPage.tsx @@ -0,0 +1,159 @@ +/** + * Top-level component for the `/opportunities` listing page. + * + * Two-column layout matching the Figma: a sticky filter sidebar on + * the left and a results column on the right (results count + card + * list). Reads live data: + * - `useAuth()` for the signed-in user (filter user-side input). + * - `opportunitiesApi.list()` for the listing (server-filtered to + * `status="open"` opportunities). + * + * State branches handled explicitly: + * - auth loading -> "Loading..." stub + * - anonymous (auth resolved, no user) -> sign-in prompt with link + * to `/login` + * - opportunities loading -> "Loading..." stub + * - opportunities fetch error -> error stub with message + * - signed in, opportunities fetched, zero rows after filtering -> + * existing "No opportunities match" message + * + * Mounted by `/opportunities` in the `(with-nav)` route group. + */ + +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; + +import { applyOpportunityFilters } from "@/features/opportunities/lib/filters"; +import Typography from "@/shared/components/Typography"; +import { useAuth } from "@/shared/contexts/AuthContext"; +import { ApiError } from "@/shared/lib/api/client"; +import { opportunitiesApi } from "@/shared/lib/api/opportunities"; + +import { OpportunityCard } from "./OpportunityCard"; +import { OpportunityFilters } from "./OpportunityFilters"; +import styles from "./OpportunityListPage.module.css"; + +import type { + OpportunityFilterState, + SkillsFilterMode, +} from "@/features/opportunities/lib/filters"; +import type { Opportunity } from "@/shared/lib/api/opportunities"; + +const INITIAL_FILTERS: OpportunityFilterState = { + availability: false, + projectQuery: "", + skillsMode: "off", +}; + +function OpportunityListPage() { + const { user, loading: authLoading } = useAuth(); + const [filters, setFilters] = + useState(INITIAL_FILTERS); + const [opportunities, setOpportunities] = useState( + null, + ); + const [fetchError, setFetchError] = useState(null); + + useEffect(() => { + if (!user) return; + let cancelled = false; + opportunitiesApi + .list() + .then((rows) => { + if (!cancelled) setOpportunities(rows); + }) + .catch((err: unknown) => { + if (cancelled) return; + const message = + err instanceof ApiError + ? err.message + : "Failed to load opportunities."; + setFetchError(message); + }); + return () => { + cancelled = true; + }; + }, [user]); + + const visibleOpportunities = useMemo(() => { + if (!user || !opportunities) return []; + return applyOpportunityFilters(opportunities, user, filters); + }, [opportunities, user, filters]); + + return ( +
+
+ Opportunities + + Open project roles across Hack for LA. Sign-in required. + +
+ + {authLoading ? ( + + Loading... + + ) : !user ? ( + + Sign in to view open opportunities. + + ) : ( +
+
+ + setFilters((prev) => ({ ...prev, availability })) + } + onProjectQueryChange={(projectQuery) => + setFilters((prev) => ({ ...prev, projectQuery })) + } + onSkillsModeChange={(skillsMode: SkillsFilterMode) => + setFilters((prev) => ({ ...prev, skillsMode })) + } + onClearAll={() => setFilters(INITIAL_FILTERS)} + /> +
+ +
+ {fetchError ? ( + + {fetchError} + + ) : opportunities === null ? ( + + Loading opportunities... + + ) : ( + <> + + {visibleOpportunities.length}{" "} + {visibleOpportunities.length === 1 ? "result" : "results"} + + +
+ {visibleOpportunities.length === 0 ? ( + + No opportunities match the current filters. + + ) : ( + visibleOpportunities.map((opportunity) => ( + + )) + )} +
+ + )} +
+
+ )} +
+ ); +} + +export { OpportunityListPage }; diff --git a/frontend/src/features/opportunities/lib/filters.ts b/frontend/src/features/opportunities/lib/filters.ts new file mode 100644 index 00000000..f8e37779 --- /dev/null +++ b/frontend/src/features/opportunities/lib/filters.ts @@ -0,0 +1,103 @@ +/** + * Filter predicates for the `/opportunities` listing surface. + * + * Encodes the filter contract from the spec (`blah.`): + * - Availability ON/OFF. ON keeps an opportunity if the user can + * attend at least one of its meeting times. Opportunities with + * no `meeting_times` set (null) never pass when ON, since we + * have no slot to match against. + * - Project: free-text substring match against `project_name` + * (case-insensitive, trimmed). Empty query is a no-op. + * - Skills OFF / Partial Match (30% threshold). Partial match + * keeps an opportunity if at least 30% of its `skill_names` + * also appear in the user's `skill_names` (case-insensitive set + * overlap); opportunities with no skills always pass to avoid + * hiding listings that haven't tagged any. + * + * Pure functions over the live wire shapes (`Opportunity` from + * `opportunitiesApi` and the current `User` off the auth context). + */ + +import type { User } from "@/shared/lib/api/auth"; +import type { Opportunity } from "@/shared/lib/api/opportunities"; + +export type SkillsFilterMode = "off" | "partial_match"; + +export interface OpportunityFilterState { + availability: boolean; + projectQuery: string; + skillsMode: SkillsFilterMode; +} + +const SKILL_PARTIAL_MATCH_THRESHOLD = 0.3; + +function timeToMinutes(time: string): number { + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; +} + +function intervalsOverlap( + aStart: string, + aEnd: string, + bStart: string, + bEnd: string, +): boolean { + return ( + Math.max(timeToMinutes(aStart), timeToMinutes(bStart)) < + Math.min(timeToMinutes(aEnd), timeToMinutes(bEnd)) + ); +} + +function matchesAvailability(opportunity: Opportunity, user: User): boolean { + if (!opportunity.meeting_times || !user.meeting_availability) return false; + return opportunity.meeting_times.some((slot) => + user.meeting_availability!.some( + (window) => + window.day.toLowerCase() === slot.day.toLowerCase() && + intervalsOverlap(slot.start, slot.end, window.start, window.end), + ), + ); +} + +function matchesProject(opportunity: Opportunity, query: string): boolean { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return true; + return opportunity.project_name.toLowerCase().includes(trimmed); +} + +function matchesSkills( + opportunity: Opportunity, + user: User, + mode: SkillsFilterMode, +): boolean { + if (mode === "off") return true; + // Don't hide opportunities that haven't tagged any skills - we have + // no signal to score them against, and excluding them would surprise. + if (opportunity.skill_names.length === 0) return true; + const userSkills = new Set(user.skill_names.map((s) => s.toLowerCase())); + const overlap = opportunity.skill_names.filter((skill) => + userSkills.has(skill.toLowerCase()), + ).length; + return ( + overlap / opportunity.skill_names.length >= SKILL_PARTIAL_MATCH_THRESHOLD + ); +} + +export function applyOpportunityFilters( + opportunities: Opportunity[], + user: User, + filters: OpportunityFilterState, +): Opportunity[] { + return opportunities.filter((opportunity) => { + if (filters.availability && !matchesAvailability(opportunity, user)) { + return false; + } + if (!matchesProject(opportunity, filters.projectQuery)) { + return false; + } + if (!matchesSkills(opportunity, user, filters.skillsMode)) { + return false; + } + return true; + }); +} diff --git a/frontend/src/shared/lib/api/auth.ts b/frontend/src/shared/lib/api/auth.ts index 53d34e8c..81e41a9e 100644 --- a/frontend/src/shared/lib/api/auth.ts +++ b/frontend/src/shared/lib/api/auth.ts @@ -11,6 +11,18 @@ import { apiFetch } from "./client"; +/** + * One availability window the user can attend. Parallels the + * `meeting_availability` JSON shape on `CustomUser`; same fields as + * `MeetingSlot` minus `team`. Used by the Availability filter on the + * `/opportunities` browse surface. + */ +interface AvailabilitySlot { + day: string; + start: string; // "HH:MM" + end: string; // "HH:MM" +} + /** * Response shape of `GET /api/auth/me/`, `POST /api/auth/login/`, * `POST /api/auth/signup/`, and `GET /api/users//`. @@ -18,7 +30,10 @@ import { apiFetch } from "./client"; * Mirrors `CustomUserReadSerializer.Meta.fields` on the backend. * `community_of_practice` and `skills_learned_matrix` are foreign * key UUIDs (not nested objects); resolve them via separate - * endpoints if the UI needs full records. + * endpoints if the UI needs full records. `skill_names` is the + * serializer-resolved alphabetical list of the user's skill names + * (sourced off `skills_learned_matrix`); the browse surface's Skills + * filter reads this directly. */ export type User = { id: string; @@ -27,8 +42,9 @@ export type User = { email: string; community_of_practice: string | null; skills_learned_matrix: string | null; + skill_names: string[]; max_available_hours: number | null; - meeting_availability: unknown; + meeting_availability: AvailabilitySlot[] | null; isProjectManager: boolean; created_at: string; updated_at: string; diff --git a/frontend/src/shared/lib/api/opportunities.ts b/frontend/src/shared/lib/api/opportunities.ts new file mode 100644 index 00000000..61ea7a8d --- /dev/null +++ b/frontend/src/shared/lib/api/opportunities.ts @@ -0,0 +1,69 @@ +/** + * Typed wrappers for the `/api/opportunities/` endpoint. + * + * Mirrors `ctj_api.serializers.OpportunityReadSerializer`. The + * derived display fields `role_title` (from `role.title`) and + * `skill_names` (alphabetically-sorted skill names resolved from + * `skills_required_matrix`) save the listing card from N+1 + * lookups against `/api/roles/` and `/api/skills/`; the underlying + * `role` and `skills_required_matrix` UUIDs stay on the wire for + * matching-algorithm consumers. + * + * The list action filters to `status="open"` server-side + * (`OpportunityViewSet.get_queryset`) - the listing surface is the + * volunteer-facing catalog, drafts / on-hold / filled / closed + * aren't part of it. Retrieve still returns any single opportunity + * (the PM CMS will read off retrieve later). + * + * All wrappers use the shared `apiFetch` client. + */ + +import { apiFetch } from "./client"; + +type OpportunityStatus = "open" | "closed" | "on_hold" | "filled" | "draft"; + +export type WorkEnvironment = "remote" | "hybrid" | "in_person"; + +/** + * One meeting slot on an opportunity. `Opportunity.meeting_times` + * is a list of these. Parallels `User.meeting_availability` shape + * minus the `team` key (opportunity-side metadata). + */ +interface MeetingSlot { + team: string; + day: string; + start: string; // "HH:MM" + end: string; // "HH:MM" +} + +/** + * Read shape of an opportunity. Mirrors + * `OpportunityReadSerializer.Meta.fields`. + */ +export type Opportunity = { + id: string; + project_name: string; + role: string; + role_title: string; + overview: string; + body: string; + responsibilities: string; + min_experience_required: string; + min_hours_required: number; + work_environment: WorkEnvironment; + meeting_times: MeetingSlot[] | null; + skills_required_matrix: string | null; + skill_names: string[]; + status: OpportunityStatus; + created_by: string | null; + created_at: string; + updated_at: string; +}; + +export const opportunitiesApi = { + /** List opportunities (server-filtered to `status="open"`). */ + list: () => apiFetch("/api/opportunities/"), + + /** Retrieve a single opportunity by UUID (any status). */ + retrieve: (id: string) => apiFetch(`/api/opportunities/${id}/`), +}; diff --git a/frontend/tests/contexts/AuthContext.test.tsx b/frontend/tests/contexts/AuthContext.test.tsx index 8a414495..f36fa4d0 100644 --- a/frontend/tests/contexts/AuthContext.test.tsx +++ b/frontend/tests/contexts/AuthContext.test.tsx @@ -23,6 +23,7 @@ const baseUser: User = { email: "test@example.com", community_of_practice: null, skills_learned_matrix: null, + skill_names: [], max_available_hours: null, meeting_availability: null, isProjectManager: false,