Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion backend/accounts/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
17 changes: 16 additions & 1 deletion backend/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
15 changes: 15 additions & 0 deletions backend/accounts/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uuid>/`).
- `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 = [
Expand All @@ -37,13 +48,17 @@ class Meta:
"email",
"community_of_practice",
"skills_learned_matrix",
"skill_names",
"max_available_hours",
"meeting_availability",
"isProjectManager",
"created_at",
"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/`.
Expand Down
5 changes: 2 additions & 3 deletions backend/accounts/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions backend/accounts/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"], [])
9 changes: 3 additions & 6 deletions backend/ctj_api/admin.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,7 +22,6 @@
from .models import (
CommunityOfPractice,
Opportunity,
Project,
Role,
Skill,
SkillMatrix,
Expand All @@ -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)
6 changes: 3 additions & 3 deletions backend/ctj_api/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file.
Empty file.
141 changes: 141 additions & 0 deletions backend/ctj_api/management/commands/seed_dev.py
Original file line number Diff line number Diff line change
@@ -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}"
)
)
Loading