diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..3de4ce73c2e8a50c2b8857020ee78be215cd86dc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +venv/ +**/__pycache__/ +*.pyc +.pytest_cache/ +logs/ +*.log +seed_data/onet_dump/ +**/tests/ +.git/ +.env +staticfiles/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..d1bf1b17a84f3c7e253da4e53b5b099bcccd3944 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +SECRET_KEY=your-super-secret-key-change-in-production +DEBUG=True +DB_NAME=gapguide_db +DB_USER=postgres +DB_PASSWORD=postgres +DB_HOST=localhost +DB_PORT=5432 +ALLOWED_HOSTS=localhost,127.0.0.1 +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Optional. Enables the "Import modules from URL" admin action to read chapter +# markers from single YouTube videos via the Data API v3 (free, 10k units/day). +# Leave unset and YouTube imports fall back to manual checkpoint entry. +# YOUTUBE_API_KEY= + +# Prevent __pycache__/*.pyc files from shadowing edited source during dev. +PYTHONDONTWRITEBYTECODE=1 + +# --------------------------------------------------------------------------- +# Production / deployment only (Hugging Face Space secrets). Leave ALL of these +# UNSET locally — when unset the app uses the local DB_* + DEBUG=True defaults +# above. Do NOT put these in a local .env. +# --------------------------------------------------------------------------- +# DEBUG=False +# SECRET_KEY= +# ALLOWED_HOSTS=* +# DATABASE_URL=postgresql://USER:PASSWORD@HOST/DB?sslmode=require # Neon DIRECT (non-pooled) URL +# CORS_ALLOWED_ORIGINS=https://.vercel.app +# CSRF_TRUSTED_ORIGINS=https://.hf.space +# SECURE_SSL_REDIRECT=False # HF terminates HTTPS at its edge +# DEMO_ADMIN_PASSWORD= +# BUILD_EMBEDDINGS=1 # first boot only: build SBERT skill embeddings +# THROTTLE_RESUME=200/hour # optional: raise the resume-parse rate for a demo diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..dfdb8b771ce07609491fa2e83698969fd917a135 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,35 +1 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text +*.sh text eol=lf diff --git a/.ruff_cache/.gitignore b/.ruff_cache/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b100327770759f0efb7859fe78aca4801d69ed61 --- /dev/null +++ b/.ruff_cache/.gitignore @@ -0,0 +1,2 @@ +# Automatically created by ruff. +* diff --git a/.ruff_cache/0.15.9/14848081398162790867 b/.ruff_cache/0.15.9/14848081398162790867 new file mode 100644 index 0000000000000000000000000000000000000000..4b98bbb881130f088fd9c159fea2457b03d92bd6 Binary files /dev/null and b/.ruff_cache/0.15.9/14848081398162790867 differ diff --git a/.ruff_cache/0.15.9/397764829197027149 b/.ruff_cache/0.15.9/397764829197027149 new file mode 100644 index 0000000000000000000000000000000000000000..9eabbe63cb9af1f944ffe5adb3ce4bc97cf97743 Binary files /dev/null and b/.ruff_cache/0.15.9/397764829197027149 differ diff --git a/.ruff_cache/0.15.9/4097662610744648579 b/.ruff_cache/0.15.9/4097662610744648579 new file mode 100644 index 0000000000000000000000000000000000000000..18db333dc683602cefe8e7dffc33628b91ea56fe Binary files /dev/null and b/.ruff_cache/0.15.9/4097662610744648579 differ diff --git a/.ruff_cache/0.15.9/5949281871524638798 b/.ruff_cache/0.15.9/5949281871524638798 new file mode 100644 index 0000000000000000000000000000000000000000..65cba1fa1d57aa6d52d199116e524a6101b2559f Binary files /dev/null and b/.ruff_cache/0.15.9/5949281871524638798 differ diff --git a/.ruff_cache/0.15.9/6974492653149135859 b/.ruff_cache/0.15.9/6974492653149135859 new file mode 100644 index 0000000000000000000000000000000000000000..02c46dbce1dadbf29243183545bad512f6da7312 Binary files /dev/null and b/.ruff_cache/0.15.9/6974492653149135859 differ diff --git a/.ruff_cache/CACHEDIR.TAG b/.ruff_cache/CACHEDIR.TAG new file mode 100644 index 0000000000000000000000000000000000000000..bc1ecb967a482524e7736038de0df6e08f9ee452 --- /dev/null +++ b/.ruff_cache/CACHEDIR.TAG @@ -0,0 +1 @@ +Signature: 8a477f597d28d172789f06886806bc55 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..b4969c071e2fc97aa02bde2cc10c6b480166b77e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# GapGuide backend — Hugging Face Docker Space image. +# Django + DRF API with the in-process NER chain, served by gunicorn on :7860. +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + HF_HOME=/app/.cache/huggingface \ + NLTK_DATA=/app/.cache/nltk + +# HF Spaces run the container as UID 1000. +RUN useradd -m -u 1000 appuser +WORKDIR /app + +# Python deps first for better layer caching. requirements.txt carries the +# torch CPU --extra-index-url, so CPU wheels resolve here (verified: torch +# 2.11/2.12 +cpu cp313 manylinux exist). +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt \ + && python -m spacy download en_core_web_sm + +# App code, owned by the runtime user in a single COPY (no recursive-chown bloat). +COPY --chown=1000:1000 . . + +# Make the dirs the next steps write to owned by UID 1000 (the /app dir itself +# is root-owned from WORKDIR; chown only the specific dirs, not the whole tree). +RUN mkdir -p /app/.cache/huggingface /app/.cache/nltk /app/staticfiles \ + && chown 1000:1000 /app /app/.cache /app/.cache/huggingface /app/.cache/nltk /app/staticfiles + +USER 1000 + +# Bake everything the NER chain downloads → fast, offline-safe first parse in prod. +# Runs as UID 1000 so caches land under the owned /app/.cache. +RUN python -m nltk.downloader -d /app/.cache/nltk stopwords punkt punkt_tab \ + && python scripts/prefetch_models.py + +# Collect Django admin / DRF static for WhiteNoise. Inline build env sidesteps the +# DEBUG=False "insecure SECRET_KEY" guard; it does not persist to runtime. +RUN DEBUG=True SECRET_KEY=build-only python manage.py collectstatic --noinput + +EXPOSE 7860 +# /bin/sh form survives a Windows upload (no exec-bit / shebang dependency). +ENTRYPOINT ["/bin/sh", "entrypoint.sh"] diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000000000000000000000000000000000000..ee0ff8dec8a79936a3fa65a12622627c84e071f7 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,118 @@ +# Local Setup — GapGuide Backend + +Prerequisites: Windows 10/11, PostgreSQL 17 installed, Python 3.13 (already set up at `backend/venv/`), Node 20+ for the frontend. + +## 1. Install pgvector extension on PostgreSQL 17 + +Module 8 Layer 4 uses `pgvector` for SBERT cosine similarity. `CREATE EXTENSION vector` fails until the extension files are installed into the PostgreSQL server directory. Two options: + +### Option A — Precompiled binary (recommended, ~2 minutes) + +1. Go to https://github.com/andreiramani/pgvector_pgsql_windows/releases +2. Download the release tagged `0.8.2_17.6` (built against PostgreSQL 17.6; works on 17.3+ — older 17.0–17.2 hit a linker bug). +3. Extract. You should see `vector.control`, `vector--*.sql`, and `vector.dll`. +4. Copy files: + - `vector.control` and `vector--*.sql` → `C:\Program Files\PostgreSQL\17\share\extension\` + - `vector.dll` → `C:\Program Files\PostgreSQL\17\lib\` + - Administrator rights required (Program Files is protected). +5. Verify in psql: + ``` + psql -U postgres -d gapguide -c "CREATE EXTENSION IF NOT EXISTS vector; SELECT '[1,2,3]'::vector;" + ``` + Output should include `[1,2,3]`. + +### Option B — Build from source (~30 min, no trusted binary) + +1. Install Visual Studio with "Desktop development with C++". +2. Open "x64 Native Tools Command Prompt for VS" as **administrator**. +3. Run: + ``` + set "PGROOT=C:\Program Files\PostgreSQL\17" + cd %TEMP% + git clone --branch v0.8.2 https://github.com/pgvector/pgvector.git + cd pgvector + nmake /F Makefile.win + nmake /F Makefile.win install + ``` +4. Verify as in Option A step 5. + +## 2. Python dependencies + +```powershell +cd backend +.\venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python -m spacy download en_core_web_sm +``` + +First install is ~5 minutes (torch CPU wheel is the heaviest at ~200 MB). + +The NER layers will use `en_core_web_sm` (12 MB) by default. If you want +slightly better noun-phrase quality on paraphrased CVs, upgrade to +`en_core_web_lg` (~560 MB) — the layers auto-detect and prefer `_lg`: + +```powershell +python -m spacy download en_core_web_lg +``` + +HuggingFace models download lazily on first parse into `%USERPROFILE%\.cache\huggingface` (~1 GB total across Nucha BERT, JobBERT, SBERT). Subsequent parses are instant. + +## 3. Database setup + +```powershell +# create the DB (if fresh) +createdb -U postgres gapguide + +# migrate (requires pgvector from step 1) +python manage.py migrate + +# seed the catalog + 5 demo users +python manage.py seed_initial_skills +python manage.py seed_initial_roles +python manage.py seed_initial_resources +python manage.py seed_demo_users + +# build SBERT embeddings for the Skill catalog (Module 8 Layer 4) +python scripts/build_skill_embeddings.py +``` + +Demo user login: `demo.partial@gapguide.test` / `DemoPass123!`. + +## 4. Run locally + +```powershell +# terminal 1 — backend +cd backend +.\venv\Scripts\Activate.ps1 +python manage.py runserver + +# terminal 2 — frontend +cd frontend +npm install +npm run dev +``` + +Open http://localhost:8080. + +## 5. Running tests + +```powershell +cd backend + +# CI baseline (no ML, seconds) +$env:GAPGUIDE_PARSE_LAYERS = "lexical" +pytest -q + +# ML smoke test (downloads models on first run — ~15 min first time) +Remove-Item Env:\GAPGUIDE_PARSE_LAYERS +$env:GAPGUIDE_ML_SMOKE = "1" +pytest apps/accounts/tests/test_resume_parser_integration.py -q +``` + +## Troubleshooting + +- **`extension "vector" is not available`** — pgvector not installed on your Postgres. Redo step 1. The `vector.control` file must exist in PostgreSQL's `share\extension\` directory. +- **`ModuleNotFoundError: pgvector`** — `pip install -r requirements.txt` hasn't run (or ran in a different venv). Activate the venv first. +- **`spaCy model not found`** — run `python -m spacy download en_core_web_lg`. +- **Slow first resume parse** — normal. The NER chain downloads ~1 GB of models on first call. Subsequent parses use the cache. +- **`GAPGUIDE_PARSE_LAYERS=lexical`** — lets you run the backend without any ML deps loaded. Useful during dev when you don't want the first-call model downloads. diff --git a/README.md b/README.md index 5a89f48469334577c11a70dbec82dbb5a9b1b41a..448d31b10199563aa17d913b6798741874477473 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,18 @@ --- -title: Gapguide Api -emoji: 👀 -colorFrom: red -colorTo: yellow +title: GapGuide API +emoji: 🎯 +colorFrom: blue +colorTo: indigo sdk: docker +app_port: 7860 pinned: false --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# GapGuide API + +Django + DRF backend for **GapGuide** (AI Job Fit & Skill Gap Assistant), with the +in-process resume-parsing NER chain. Deployed as a Hugging Face Docker Space; the +React frontend is hosted separately on Vercel and talks to this API under `/api`. + +The container runs database migrations and idempotent catalog/demo seeds on start, +then serves the API with gunicorn on port 7860. See the repository for full docs. diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/accounts/__init__.py b/apps/accounts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..a35dd13314d88e88be14c22258bceef4cdec5e9c --- /dev/null +++ b/apps/accounts/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from .models import User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + list_display = ['email', 'name', 'role_type', 'is_active', 'created_at'] + list_filter = ['role_type', 'is_active', 'is_staff'] + search_fields = ['email', 'name'] + ordering = ['-created_at'] + readonly_fields = ['created_at'] + + fieldsets = ( + (None, {'fields': ('email', 'password')}), + ('Personal info', {'fields': ('name', 'role_type')}), + ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), + ('Important dates', {'fields': ('last_login', 'created_at')}), + ) + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2', 'name', 'role_type'), + }), + ) diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..bd9d60a14a916786ea5610bdeae50a223287ea24 --- /dev/null +++ b/apps/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.accounts' diff --git a/apps/accounts/management/__init__.py b/apps/accounts/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/accounts/management/commands/__init__.py b/apps/accounts/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/accounts/management/commands/seed_demo_users.py b/apps/accounts/management/commands/seed_demo_users.py new file mode 100644 index 0000000000000000000000000000000000000000..6a9c5842d4fad3c37364b393badef4076b823255 --- /dev/null +++ b/apps/accounts/management/commands/seed_demo_users.py @@ -0,0 +1,290 @@ +"""Seed 5 demo users spanning the WEAK, PARTIAL, and STRONG readiness bands plus the closed loop. + +Idempotent: re-running the command does not duplicate users or create +stacked UserProgress rows. Designed for local demo + manual QA. + +Personas: + 1. demo.weak — Data Scientist, Python=30 only. WEAK band. + 2. demo.partial — Data Scientist, Python=80 SQL=90 Scikit-learn=0. PARTIAL + cap. + 3. demo.ready — Frontend Developer, skills ≥ INTERMEDIATE. STRONG band. + 4. demo.closedloop — ML Engineer, one COMPLETED resource → upgrade pending. + 5. demo.admin — superuser, for Module 7 RoleAdmin demos. + +Password for every persona: DemoPass123! (printed in command output). +""" +from __future__ import annotations + +import os + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from apps.accounts.models import UserProfile +from apps.progress.models import UserProgress +from apps.resources.models import Resource, SkillResource +from apps.roles.models import Role, UserTargetRole +from apps.skills.models import Skill, UserSkill +from apps.skills.utils import proficiency_to_level + + +DEMO_PASSWORD = 'DemoPass123!' +# On a public deployment the superuser must NOT carry the well-known demo +# password. Override the admin password via the DEMO_ADMIN_PASSWORD env var; +# falls back to DEMO_PASSWORD locally where the env var is unset. +ADMIN_PASSWORD = os.environ.get('DEMO_ADMIN_PASSWORD', DEMO_PASSWORD) + + +# (email, name, target_role_name, {skill_name: proficiency}, degree, semester) +# target_role_name=None skips target setup (only persona 5/admin). +PERSONAS: list[dict] = [ + { + 'email': 'demo.weak@gapguide.test', + 'name': 'Demo Weak', + 'target': 'Data Scientist', + 'skills': {'Python': 30}, + 'degree': 'BSDS', + 'semester': 4, + }, + { + 'email': 'demo.partial@gapguide.test', + 'name': 'Demo Partial', + 'target': 'Data Scientist', + # Most Data Scientist skills at MET levels so raw readiness lands + # well above the 60 mandatory-cap. The single MISSING mandatory + # skill (Scikit-learn) is what pulls the score down to the cap — + # exercising the "Partial Match" band + mandatory_cap_applied path. + # Strong coverage across the Data Scientist skill set with ONE + # mandatory miss (Scikit-learn=0). Raw readiness lands comfortably + # above the 60 cap so the mandatory-cap logic actually fires and + # mandatory_cap_applied=True becomes visible in the demo. + 'skills': { + 'Python': 80, + 'SQL': 90, + 'Pandas': 75, + 'NumPy': 75, + 'PyTorch': 70, + 'Statistical Hypothesis Testing': 70, + 'Git': 85, + 'Scikit-learn': 0, # MISSING + mandatory -> triggers cap + 'Apache Spark': 60, + 'AWS': 60, + 'Docker': 55, + 'Feature Engineering': 70, + 'Matplotlib': 70, + 'Microsoft Power BI': 50, + 'Tableau': 65, + 'TensorFlow': 55, + }, + 'degree': 'BSDS', + 'semester': 6, + }, + { + 'email': 'demo.ready@gapguide.test', + 'name': 'Demo Ready', + 'target': 'Frontend Developer', + # Filled in dynamically below from Frontend Developer's RoleSkills. + 'skills': None, + 'degree': 'BSCS', + 'semester': 7, + }, + { + 'email': 'demo.closedloop@gapguide.test', + 'name': 'Demo ClosedLoop', + 'target': 'Machine Learning Engineer', + # Mid-stage ML learner: most core skills MET, a few shortfalls, and + # Python sits at 60 so the COMPLETED Python resource triggers a + # "bump 60 -> 85" upgrade suggestion on the Progress page. + 'skills': { + 'Python': 60, + 'SQL': 70, + 'NumPy': 65, + 'Pandas': 60, + 'Scikit-learn': 55, + 'PyTorch': 50, + 'TensorFlow': 45, + 'Git': 75, + 'GitHub': 70, + 'Docker': 50, + 'AWS': 40, + }, + 'degree': 'BSDS', + 'semester': 8, + }, + { + 'email': 'demo.admin@gapguide.test', + 'name': 'Demo Admin', + 'target': None, + 'skills': {}, + 'is_staff': True, + 'is_superuser': True, + 'role_type': 'ADMIN', + }, +] + + +class Command(BaseCommand): + help = ( + "Create or refresh 5 demo users (weak / partial / ready / closedloop / " + "admin) spanning the WEAK/PARTIAL/STRONG readiness bands and the " + "skill-upgrade loop. Idempotent; re-run after seed catalog changes." + ) + + def handle(self, *args, **options): + User = get_user_model() + + created_count = 0 + updated_count = 0 + warnings: list[str] = [] + + for spec in PERSONAS: + with transaction.atomic(): + user, created = User.objects.update_or_create( + email=spec['email'], + defaults={ + 'username': spec['email'], + 'name': spec['name'], + 'role_type': spec.get('role_type', 'STUDENT'), + 'is_staff': spec.get('is_staff', False), + 'is_superuser': spec.get('is_superuser', False), + }, + ) + pwd = ADMIN_PASSWORD if spec.get('is_superuser') else DEMO_PASSWORD + user.set_password(pwd) + user.save(update_fields=['password']) + if created: + created_count += 1 + else: + updated_count += 1 + + UserProfile.objects.update_or_create( + user=user, + defaults={ + 'degree': spec.get('degree', ''), + 'semester': spec.get('semester'), + 'interests': '', + 'bio': f"Demo persona: {spec['name']}.", + }, + ) + + target_name = spec.get('target') + if target_name: + role = Role.objects.filter(role_name=target_name).first() + if role is None: + warnings.append( + f"{spec['email']}: target role '{target_name}' not in catalog " + f"(did you run seed_initial_roles?). Skipping target set." + ) + else: + UserTargetRole.objects.filter(user=user).delete() + UserTargetRole.objects.create( + user=user, role=role, is_active=True, + ) + + skills_spec = spec['skills'] + if skills_spec is None: + # demo.ready persona: build from the target role's required skills + skills_spec = self._ready_persona_skills(target_name, warnings, spec['email']) + + for skill_name, proficiency in (skills_spec or {}).items(): + skill = Skill.objects.filter(skill_name=skill_name).first() + if skill is None: + warnings.append( + f"{spec['email']}: skill '{skill_name}' not in catalog. Skipping." + ) + continue + UserSkill.objects.update_or_create( + user=user, skill=skill, + defaults={ + 'proficiency': proficiency, + 'user_level': proficiency_to_level(proficiency), + 'dismissed_upgrade_at': None, + }, + ) + + # Persona 4: mark one Python-linked resource COMPLETED so the + # upgrade-suggestion engine has a reason to fire. + if spec['email'].startswith('demo.closedloop@'): + self._seed_closed_loop_completion(user, warnings) + + # Summary output. + self.stdout.write(self.style.SUCCESS( + f"Demo users: {created_count} created, {updated_count} refreshed " + f"(password for all: {DEMO_PASSWORD})" + )) + for line in warnings: + self.stdout.write(self.style.WARNING(' ! ' + line)) + self.stdout.write("") + for spec in PERSONAS: + self.stdout.write( + f" {spec['email']:<40} -> {spec.get('target') or '(admin)'}" + ) + + @staticmethod + def _ready_persona_skills( + target_name: str | None, warnings: list, email: str, + ) -> dict[str, int]: + """demo.ready has every required skill comfortably above its threshold. + + INTERMEDIATE threshold=60, ADVANCED threshold=100. We set proficiency + 80 for INTERMEDIATE skills (well above threshold → fully satisfied) and + 100 for ADVANCED skills, so every skill is MET → readiness 100 (STRONG). + This persona has no completed resource, so no upgrade suggestion fires. + """ + if not target_name: + return {} + role = Role.objects.filter(role_name=target_name).first() + if role is None: + warnings.append( + f"{email}: cannot populate skills — target role '{target_name}' missing." + ) + return {} + skills: dict[str, int] = {} + for rs in role.role_skills.select_related('skill'): + if rs.required_level == 'ADVANCED': + skills[rs.skill.skill_name] = 100 + elif rs.required_level == 'INTERMEDIATE': + skills[rs.skill.skill_name] = 80 + else: # BEGINNER + skills[rs.skill.skill_name] = 55 + return skills + + @staticmethod + def _seed_closed_loop_completion(user, warnings: list) -> None: + """Mark one Python-linked Resource as COMPLETED for the closedloop user. + + Given Python=60 on the user, the upgrade engine will suggest 85 when + it sees a completed Python resource. This makes the "Upgrade pending" + Alert card (Progress.jsx:146) appear in the demo without any clicks. + """ + python = Skill.objects.filter(skill_name='Python').first() + if python is None: + warnings.append( + "closedloop: Python skill missing from catalog — skipping closed-loop seed." + ) + return + + resource = ( + Resource.objects + .filter(skills=python) + .order_by('id') + .first() + ) + if resource is None: + warnings.append( + "closedloop: no Resource links to Python — skipping closed-loop seed " + "(did you run seed_initial_resources?)." + ) + return + + now = timezone.now() + UserProgress.objects.update_or_create( + user=user, resource=resource, + defaults={ + 'status': 'COMPLETED', + 'progress': 100, + 'started_at': now, + 'completed_at': now, + }, + ) diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..4e3cb15931a192ac78e9c4a433f4422f77d4a05e --- /dev/null +++ b/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 6.0.4 on 2026-04-07 18:50 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('name', models.CharField(blank=True, max_length=255)), + ('email', models.EmailField(max_length=254, unique=True)), + ('role_type', models.CharField(choices=[('STUDENT', 'Student'), ('ADMIN', 'Admin')], default='STUDENT', max_length=10)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/apps/accounts/migrations/0002_user_created_at_alter_user_role_type.py b/apps/accounts/migrations/0002_user_created_at_alter_user_role_type.py new file mode 100644 index 0000000000000000000000000000000000000000..81c4c7707b84dc50842ff793b019cf9ae520fd65 --- /dev/null +++ b/apps/accounts/migrations/0002_user_created_at_alter_user_role_type.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.4 on 2026-04-07 19:28 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AlterField( + model_name='user', + name='role_type', + field=models.CharField(choices=[('STUDENT', 'Student'), ('ADMIN', 'Admin')], default='STUDENT', max_length=20), + ), + ] diff --git a/apps/accounts/migrations/0003_userprofile.py b/apps/accounts/migrations/0003_userprofile.py new file mode 100644 index 0000000000000000000000000000000000000000..1eaa4f62fe75fe58c12366ea8839ce2a0e721e2a --- /dev/null +++ b/apps/accounts/migrations/0003_userprofile.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.4 on 2026-04-07 19:34 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_user_created_at_alter_user_role_type'), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('degree', models.CharField(blank=True, max_length=255)), + ('semester', models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(12)])), + ('interests', models.TextField(blank=True)), + ('bio', models.TextField(blank=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/apps/accounts/migrations/__init__.py b/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/accounts/models.py b/apps/accounts/models.py new file mode 100644 index 0000000000000000000000000000000000000000..4d4e1784f3c543e9c26f72f3543e0a7af8d6a09f --- /dev/null +++ b/apps/accounts/models.py @@ -0,0 +1,41 @@ +from django.conf import settings +from django.contrib.auth.models import AbstractUser +from django.core.validators import MinValueValidator, MaxValueValidator +from django.db import models + + +class User(AbstractUser): + ROLE_CHOICES = [ + ('STUDENT', 'Student'), + ('ADMIN', 'Admin'), + ] + + name = models.CharField(max_length=255, blank=True) + email = models.EmailField(unique=True) + role_type = models.CharField(max_length=20, choices=ROLE_CHOICES, default='STUDENT') + created_at = models.DateTimeField(auto_now_add=True) + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['username', 'name'] + + def __str__(self): + return self.email + + +class UserProfile(models.Model): + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='profile' + ) + degree = models.CharField(max_length=255, blank=True) + semester = models.IntegerField( + null=True, blank=True, + validators=[MinValueValidator(1), MaxValueValidator(12)] + ) + interests = models.TextField(blank=True) + bio = models.TextField(blank=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f'Profile of {self.user.email}' diff --git a/apps/accounts/ner/__init__.py b/apps/accounts/ner/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..52a3ad1d44474ff922fdaf7ec41f343a16ea0d5d --- /dev/null +++ b/apps/accounts/ner/__init__.py @@ -0,0 +1,57 @@ +"""NER layer package for Module 8 resume parsing. + +Layers (merge-all semantics — see research/06-dataset-sourcing.md §5 and +the plan at ~/.claude/plans/okay-plan-for-7a-fluffy-marshmallow.md): + + 1. nucha — Nucha/Nucha_ITSkillNER_BERT (MIT, F1 0.90) + 2. jobbert — jjzha/jobbert_skill_extraction (license unspecified, FYP-OK) + 3. skillner — rule-based, EMSI taxonomy (Nov 2021 release, still works) + 4. sbert — SentenceTransformer('all-MiniLM-L6-v2') + pgvector cosine + +Each concrete layer implements ``NerLayer`` and exposes a module-level +lazy singleton so the HF models only load on first call. The dispatcher in +``resume_parser._predict_skills_from_text`` invokes the layers in order, +catches any import / timeout / model-download error per layer, and unions +the results into one {alias: max_confidence} dict. + +The lexical floor (``ner.lexical``) is always run last even if the higher +layers succeed — it guarantees the endpoint returns at least the catalog +matches regardless of ML-layer availability. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class NerLayer(ABC): + """Contract every NER layer must satisfy. + + Implementations should: + * Keep the model as a module-level singleton, loaded on first call. + * Catch *internal* model errors and return an empty dict rather than + raising — the dispatcher already wraps calls in try/except, but + returning {} for recoverable failures keeps the log quieter. + * Never raise for an empty or non-English input — just return {}. + """ + + #: Short stable identifier used in the response's ``parser_version`` list. + #: Keep lowercase, no spaces. + name: str = "abstract" + + @abstractmethod + def predict(self, text: str) -> dict[str, float]: + """Return {skill_or_alias_name: confidence_0_to_1}. + + Keys are free-text — the dispatcher runs alias resolution against the + Skill catalog before they reach the user. Values are [0, 1] floats. + """ + raise NotImplementedError + + def available(self) -> bool: + """Cheap check the dispatcher calls before invoking predict(). + + Default: attempt a lazy-import of the layer's heavy deps. Override + for layers whose availability depends on external state (e.g. a + model being cached locally). + """ + return True diff --git a/apps/accounts/ner/jobbert.py b/apps/accounts/ner/jobbert.py new file mode 100644 index 0000000000000000000000000000000000000000..3abdab68755198bb0ea64765c791b1b185aa0f28 --- /dev/null +++ b/apps/accounts/ner/jobbert.py @@ -0,0 +1,117 @@ +"""Layer 2: jjzha/jobbert_skill_extraction — SkillSpan-trained BERT NER. + +Model: https://huggingface.co/jjzha/jobbert_skill_extraction + - 107.7M params, BERT, token-classification, B-Skill/I-Skill labels + - License is NOT stated on the model card — OK for academic FYP use only. + See research/06-dataset-sourcing.md §5. + - Downloads ~440 MB on first use. +""" +from __future__ import annotations + +import logging + +from . import NerLayer + + +logger = logging.getLogger(__name__) + +_MODEL_ID = "jjzha/jobbert_skill_extraction" +_pipeline = None +_warned_license = False + + +def _get_pipeline(): + global _pipeline, _warned_license + if _pipeline is None: + from transformers import pipeline + if not _warned_license: + logger.info( + "jobbert: %s has no stated license; use restricted to " + "academic/FYP scope.", _MODEL_ID, + ) + _warned_license = True + logger.info("jobbert: loading %s pipeline (first call may be slow)", _MODEL_ID) + # Do NOT pass truncation/max_length to the token-classification + # pipeline() constructor — its `_sanitize_parameters` rejects them with + # a TypeError, which made this whole layer fail to construct and get + # silently skipped by the dispatcher. HF auto-truncates in preprocess, + # and predict() already slices the input to 4000 chars. + _pipeline = pipeline( + task="ner", + model=_MODEL_ID, + aggregation_strategy="simple", + ) + return _pipeline + + +class JobBertLayer(NerLayer): + name = "jobbert" + + def predict(self, text: str) -> dict[str, float]: + if not text.strip(): + return {} + pipe = _get_pipeline() + src = text[:4000] + spans = pipe(src) + + out: dict[str, float] = {} + + def _emit(term: str, conf: float) -> None: + term = term.strip(" ,.;:-") + if len(term) >= 2 and conf > out.get(term, 0.0): + out[term] = conf + + # Accumulator for re-stitching a multi-token skill from its char offsets. + cur_start = None + cur_end = 0 + cur_conf = 0.0 + + def _flush() -> None: + nonlocal cur_start, cur_end, cur_conf + if cur_start is not None: + _emit(src[cur_start:cur_end], cur_conf) + cur_start, cur_end, cur_conf = None, 0, 0.0 + + for span in spans: + # jjzha/jobbert_skill_extraction has exactly one entity type + # (SKILL). Under aggregation_strategy="simple", HuggingFace emits + # entity_group as the bare BIO prefix ("B" / "I"), so any non-O span + # is a skill span. + label = (span.get("entity_group") or span.get("entity") or "").upper() + if label in ("", "O"): + _flush() + continue + conf = float(span.get("score") or 0.0) + start, end = span.get("start"), span.get("end") + if start is None or end is None: + # No character offsets (slow tokenizer / unit-test stub) — can't + # safely merge, so emit the raw word as a standalone term. + _flush() + _emit(span.get("word") or "", conf) + continue + # jjzha's labels carry no entity suffix, so aggregation_strategy= + # "simple" can't group a skill's B token with its following I tokens + # — multi-word skills ("machine learning") fragment. Re-stitch via + # offsets: a "B" begins a new skill; an "I" directly adjacent (<=1 + # char gap, i.e. a single separator) extends the current one. + is_continuation = ( + label != "B" and cur_start is not None and 0 <= start - cur_end <= 1 + ) + if is_continuation: + cur_end = max(cur_end, end) + cur_conf = min(cur_conf, conf) + else: + _flush() + cur_start, cur_end, cur_conf = start, end, conf + _flush() + return out + + def available(self) -> bool: + try: + import transformers # noqa: F401 + return True + except Exception: + return False + + +layer = JobBertLayer() diff --git a/apps/accounts/ner/lexical.py b/apps/accounts/ner/lexical.py new file mode 100644 index 0000000000000000000000000000000000000000..610dc851b76a087372efe21aec8ffb0ee7ad98be --- /dev/null +++ b/apps/accounts/ner/lexical.py @@ -0,0 +1,42 @@ +"""Layer 0/floor: lexical catalog matching. + +This wraps the existing pdfplumber-free matching logic from the MVP so the +dispatcher can treat it uniformly with the other layers. Unlike the four +ML layers, this one is always safe — it has no heavy deps and only touches +the DB's Skill catalog. + +Kept separate from ``resume_parser`` so the dispatcher can invoke it as +a layer alongside the others rather than having a special-case code path. +""" +from __future__ import annotations + +from . import NerLayer + + +class LexicalLayer(NerLayer): + name = "lexical" + + def predict(self, text: str) -> dict[str, float]: + if not text.strip(): + return {} + # Reuse the existing implementation so behaviour stays identical. + from apps.accounts.resume_parser import _candidate_matches + from apps.skills.models import Skill + + skills = list(Skill.objects.all().only("id", "skill_name")) + hits = _candidate_matches(text, skills) + # _candidate_matches returns {skill_id: {confidence, proficiency, matched_span}} + # — the dispatcher works with {name: confidence}, so remap. + by_id = {s.id: s.skill_name for s in skills} + out: dict[str, float] = {} + for skill_id, data in hits.items(): + name = by_id.get(skill_id) + if name: + out[name] = float(data["confidence"]) + return out + + def available(self) -> bool: + return True + + +layer = LexicalLayer() diff --git a/apps/accounts/ner/nucha.py b/apps/accounts/ner/nucha.py new file mode 100644 index 0000000000000000000000000000000000000000..81df4bf5860d414d1399f742eab32bc94e4a1dc3 --- /dev/null +++ b/apps/accounts/ner/nucha.py @@ -0,0 +1,76 @@ +"""Layer 1: Nucha/Nucha_ITSkillNER_BERT — BERT NER for IT skills. + +Model: https://huggingface.co/Nucha/Nucha_ITSkillNER_BERT + - MIT license, 108.9M params, BERT architecture + - Labels: HSKILL (hard skill) + SSKILL (soft skill), F1 ~0.90 + - Downloads ~440 MB into %USERPROFILE%\\.cache\\huggingface on first use. + +Contract: returns {skill_span_text: confidence}. The dispatcher's alias +resolver decides which canonical Skill catalog row that maps to. +""" +from __future__ import annotations + +import logging + +from . import NerLayer + + +logger = logging.getLogger(__name__) + +_MODEL_ID = "Nucha/Nucha_ITSkillNER_BERT" +_pipeline = None # set on first call, persists for the process lifetime + + +def _get_pipeline(): + """Lazy singleton — the first call does the ~440 MB download; later + calls reuse the cached model. Raises on network failure; the dispatcher + catches and moves to the next layer. + """ + global _pipeline + if _pipeline is None: + from transformers import pipeline # local import keeps lexical-only CI fast + logger.info("nucha: loading %s pipeline (first call may be slow)", _MODEL_ID) + _pipeline = pipeline( + task="ner", + model=_MODEL_ID, + aggregation_strategy="simple", + ) + return _pipeline + + +class NuchaLayer(NerLayer): + name = "nucha" + + def predict(self, text: str) -> dict[str, float]: + if not text.strip(): + return {} + pipe = _get_pipeline() + # The pipeline returns a list of span dicts with keys + # {entity_group, score, word, start, end}. aggregation_strategy="simple" + # merges adjacent B-/I- tokens into full spans. + spans = pipe(text[:4000]) # cap at ~4K chars to bound latency + out: dict[str, float] = {} + for span in spans: + label = span.get("entity_group") or span.get("entity") or "" + if not label.upper().endswith("SKILL"): + # Tokens labelled 'O' or padding — drop. + continue + term = (span.get("word") or "").strip(" ,.;:-") + if len(term) < 2: + continue + conf = float(span.get("score") or 0.0) + # Merge duplicates — keep the max confidence seen. + if conf > out.get(term, 0.0): + out[term] = conf + return out + + def available(self) -> bool: + try: + import transformers # noqa: F401 + return True + except Exception: + return False + + +# Module-level singleton for the dispatcher to import cheaply. +layer = NuchaLayer() diff --git a/apps/accounts/ner/sbert.py b/apps/accounts/ner/sbert.py new file mode 100644 index 0000000000000000000000000000000000000000..845adda4dc7394cccb7a6945ee6019df4a723827 --- /dev/null +++ b/apps/accounts/ner/sbert.py @@ -0,0 +1,131 @@ +"""Layer 4: SBERT + pgvector cosine — degraded-fallback canonical mapping. + +Uses sentence-transformers/all-MiniLM-L6-v2 (Apache 2.0, 22M params, 384 dims) +to embed candidate noun phrases from the resume text, then queries the +SkillEmbedding table via pgvector cosine distance to find the nearest +catalog skill. This is the always-available fallback for when higher BERT +layers can't load (memory-limited Render tier, offline, etc.) AND for +paraphrased skill language that lexical + BERT miss. + +Threshold: 0.65 similarity (equivalent to cosine_distance <= 0.35). The +dispatcher's alias resolver uses the canonical ``skill_name`` keyed output +directly — no further fuzzy-match hop needed. +""" +from __future__ import annotations + +import logging + +from . import NerLayer + + +logger = logging.getLogger(__name__) + +_MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2" +_SIM_THRESHOLD = 0.65 + +_model = None +_nlp = None + + +def _get_model(): + global _model + if _model is None: + from sentence_transformers import SentenceTransformer + logger.info("sbert: loading %s (first call may be slow)", _MODEL_ID) + _model = SentenceTransformer(_MODEL_ID) + return _model + + +def _get_nlp(): + """Reuse spaCy's en_core_web_lg for noun-phrase extraction. + + If skillNer already loaded it, great — it's a module-level singleton in + that layer too. Otherwise we load our own. + """ + global _nlp + if _nlp is None: + # Reuse skillner's loaded model if possible to save ~560 MB of RAM. + from . import skillner as _skillner_mod + cached = getattr(_skillner_mod, "_nlp", None) + if cached is not None: + _nlp = cached + else: + import spacy + # Prefer `_lg` for slightly better noun-chunk quality; fall back + # to `_sm` which is the minimum viable install documented in + # INSTALL.md. + try: + _nlp = spacy.load("en_core_web_lg") + except OSError: + _nlp = spacy.load("en_core_web_sm") + return _nlp + + +def _candidate_phrases(text: str) -> list[str]: + nlp = _get_nlp() + doc = nlp(text[:4000]) + seen: set[str] = set() + out: list[str] = [] + for chunk in doc.noun_chunks: + phrase = chunk.text.strip(" ,.;:-").lower() + # Skip very short fragments + stopwords-only spans — they lead to + # spurious cosine matches against broad skill names. + if len(phrase) < 3 or phrase in seen: + continue + seen.add(phrase) + out.append(phrase) + return out + + +class SBertLayer(NerLayer): + name = "sbert" + + def predict(self, text: str) -> dict[str, float]: + if not text.strip(): + return {} + # DB lookup: pgvector cosine against SkillEmbedding. Import Django + # models lazily so module import doesn't touch the DB. + from pgvector.django import CosineDistance + + from apps.skills.models import Skill, SkillEmbedding + + phrases = _candidate_phrases(text) + if not phrases: + return {} + + model = _get_model() + embeddings = model.encode(phrases, normalize_embeddings=True).tolist() + + out: dict[str, float] = {} + # One query per phrase — each hits the HNSW index. 20 phrases × ~2 ms + # per query = ~40 ms total, acceptable for on-demand parse. + for phrase, vec in zip(phrases, embeddings): + top = ( + SkillEmbedding.objects + .annotate(distance=CosineDistance("embedding", vec)) + .order_by("distance") + .select_related("skill") + .first() + ) + if top is None: + continue + # Cosine distance = 1 - cosine similarity for normalized vectors. + similarity = 1.0 - float(top.distance) + if similarity < _SIM_THRESHOLD: + continue + name = top.skill.skill_name + if similarity > out.get(name, 0.0): + out[name] = similarity + return out + + def available(self) -> bool: + try: + import sentence_transformers # noqa: F401 + import pgvector # noqa: F401 + from apps.skills.models import SkillEmbedding # noqa: F401 + return True + except Exception: + return False + + +layer = SBertLayer() diff --git a/apps/accounts/ner/skillner.py b/apps/accounts/ner/skillner.py new file mode 100644 index 0000000000000000000000000000000000000000..05e4b3a342ce43e5f1a6f90a3f762bf151c51925 --- /dev/null +++ b/apps/accounts/ner/skillner.py @@ -0,0 +1,95 @@ +"""Layer 3: skillNer — rule-based EMSI-taxonomy matcher. + +https://github.com/AnasAito/SkillNER — Nov 2021 last release, pure Python, +works on any spaCy `nlp` object (no version pin). Uses spaCy's PhraseMatcher +internally so CPU-only and fast. + +Gracefully unavailable if its transitive deps (nltk, jellyfish) fail to +import — the dispatcher's try/except continues with the next layer. +""" +from __future__ import annotations + +import logging + +from . import NerLayer + + +logger = logging.getLogger(__name__) + +_extractor = None +_nlp = None +_import_error = None + + +def _get_extractor(): + """Lazy singleton. Import both spaCy + skillNer on first call.""" + global _extractor, _nlp, _import_error + if _extractor is not None: + return _extractor + if _import_error is not None: + # Already attempted and failed — surface the original error so the + # dispatcher can log once instead of re-throwing noisily. + raise _import_error + + try: + import spacy + from skillNer.skill_extractor_class import SkillExtractor + from skillNer.general_params import SKILL_DB + from spacy.matcher import PhraseMatcher + except Exception as exc: # pragma: no cover — env-specific + _import_error = exc + logger.warning("skillner: unavailable (import failed): %s", exc) + raise + + # Prefer `_lg` (word vectors help edge cases) but fall back to `_sm` if + # only the smaller model is installed. skillner uses spaCy's PhraseMatcher + # which is string-based, so both work; `_lg` is strictly better-quality + # when RAM / disk allow. + try: + _nlp = spacy.load("en_core_web_lg") + except OSError: + _nlp = spacy.load("en_core_web_sm") + # Pass the PhraseMatcher *class*, not an instance: skillNer constructs its + # own matcher internally via `self.phraseMatcher(nlp.vocab, attr="LOWER")`, + # so handing it an instance raises at construction and disables the layer. + _extractor = SkillExtractor(_nlp, SKILL_DB, PhraseMatcher) + logger.info("skillner: loaded (spaCy=%s, SKILL_DB=%d entries)", + spacy.__version__, len(SKILL_DB)) + return _extractor + + +class SkillNerLayer(NerLayer): + name = "skillner" + + def predict(self, text: str) -> dict[str, float]: + if not text.strip(): + return {} + extractor = _get_extractor() + annotations = extractor.annotate(text[:4000]) + out: dict[str, float] = {} + results = (annotations or {}).get("results") or {} + # full_matches: perfect catalog hits — high confidence. + for match in results.get("full_matches") or []: + term = (match.get("doc_node_value") or "").strip() + if term: + out[term] = max(out.get(term, 0.0), 0.95) + # ngram_scored: fuzzy catalog hits — skillNer includes its own score. + for match in results.get("ngram_scored") or []: + term = (match.get("doc_node_value") or "").strip() + score = float(match.get("score") or 0.0) + if term: + out[term] = max(out.get(term, 0.0), score) + return out + + def available(self) -> bool: + if _import_error is not None: + return False + try: + import skillNer # noqa: F401 + import spacy # noqa: F401 + return True + except Exception: + return False + + +layer = SkillNerLayer() diff --git a/apps/accounts/resume_parser.py b/apps/accounts/resume_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..aa4fa064e8514dff0a65e3ebd24b114a9370bf31 --- /dev/null +++ b/apps/accounts/resume_parser.py @@ -0,0 +1,456 @@ +"""Resume parsing service — Module 8. + +Four-layer NER fallback chain merged on top of a lexical floor: + + Nucha BERT -> jobbert -> SkillNER -> SBERT+pgvector -> lexical (always) + +All layers that succeed contribute to the merged output — "merge semantics" +rather than "first-success". This makes the fallback chain visible in the +demo and maximises recall for downstream gap analysis: different layers +catch different phrasings ("ML" vs "machine learning" vs "scikit-learn"). + +Each layer runs synchronously inside try/except; a layer that raises is logged +at ERROR and skipped while the rest of the chain continues. The lexical floor +always fires so the endpoint never returns empty solely because HF models are +unavailable. + +Design constraints: + + * The endpoint contract of ``extract_skills_from_pdf`` is stable: a list + of ``SkillPrediction`` dicts. Higher layers enrich confidence for skills + the lexical layer already found, and add new skills via alias resolution. + * Matching is done against the existing Skill catalog only. We never create + new skills from free text — prevents catalog pollution and keeps the + recommendation engine grounded in the curated taxonomy. + * Proficiency is a heuristic, never an assertion. The user reviews and + edits on the Profile page before anything is saved. + +Layer selection via env var ``GAPGUIDE_PARSE_LAYERS`` (default is the full +chain; CI sets this to ``"lexical"`` via conftest.py so tests don't download +any models). See ~/.claude/plans/okay-plan-for-7a-fluffy-marshmallow.md. +""" +from __future__ import annotations + +import io +import logging +import os +import re +from dataclasses import asdict, dataclass + +import pdfplumber +from rapidfuzz import fuzz, process + +from apps.skills.models import Skill + + +logger = logging.getLogger(__name__) + +# Layer order — the dispatcher runs in this sequence. Merging is order-agnostic +# but the order here drives the "first to register a given skill wins the +# proficiency heuristic" rule. Lexical last (it already has the best local +# context), but we guarantee it runs last anyway in _predict_skills_from_text. +_DEFAULT_LAYERS = "nucha,jobbert,skillner,sbert,lexical" + + +MAX_PDF_BYTES = 5 * 1024 * 1024 # 5 MB — UCP students' CVs fit easily. +MAX_TEXT_CHARS = 60_000 # defends the downstream matcher. + +# Rough heuristic — the parser is an *augmentation* layer, so we cap signals +# below 70 so the user always has room to raise after review. +_DEFAULT_PROFICIENCY = 55 +_STRONG_SIGNAL_PROFICIENCY = 70 + +# Fuzzy layer is per-token (not partial_ratio against the full doc), so the +# threshold is genuinely "this word is essentially a typo of the skill name". +# Anything lower would flood the output with false positives like +# partial_ratio("python", "sourdough") > 0.5. +_FUZZY_TOKEN_SCORE = 88 +_FUZZY_MIN_SKILL_LEN = 5 # 'Go', 'R', 'SQL' never go through fuzzy path + +# Terms that indicate hands-on experience → pushes proficiency toward the top. +_EXPERIENCE_MARKERS = re.compile( + r"\b(?:expert|advanced|proficien[tc]y?|senior|lead|architect|years?|yrs?)\b", + re.IGNORECASE, +) + +# Terms that hint at entry level → pulls proficiency down. +_NOVICE_MARKERS = re.compile( + r"\b(?:beginner|junior|intro(?:duction)?|basic|elementary|familiar with)\b", + re.IGNORECASE, +) + +# Rank by *signal strength*, NOT numeric proficiency. NOVICE=40 is numerically +# less than DEFAULT=55 but carries a stronger claim ("beginner Python" beats +# silence), so it should win during window aggregation. +_NOVICE_PROFICIENCY = 40 +_PROFICIENCY_RANK = { + _STRONG_SIGNAL_PROFICIENCY: 2, + _NOVICE_PROFICIENCY: 1, + _DEFAULT_PROFICIENCY: 0, +} + +# Per-layer calibration coefficient applied before the max() merge in +# _predict_skills_from_text. Each layer emits [0,1] from a different +# distribution — softmax (Nucha/JobBERT), cosine similarity above 0.65 threshold +# (SBERT), hardcoded 0.95 / rapidfuzz ratio (lexical). Raw max lets lexical's +# hardcoded 0.95 outweigh a genuine model hit of 0.92. Values are heuristic — +# a Platt-scaling rebuild from labelled data is out of FYP scope, but these at +# least stop the hardcoded layers from dominating live model output. +_LAYER_CALIBRATION = { + 'nucha': 1.0, + 'jobbert': 1.0, + 'skillner': 0.90, + 'sbert': 0.85, + 'lexical': 0.95, +} + + +class ResumeParseError(ValueError): + """Raised for malformed or oversized inputs.""" + + +@dataclass +class SkillPrediction: + skill_id: int + skill_name: str + proficiency: int + confidence: float + matched_span: str + + def to_dict(self) -> dict: + return asdict(self) + + +def _extract_text(pdf_bytes: bytes) -> str: + if len(pdf_bytes) > MAX_PDF_BYTES: + raise ResumeParseError( + f"Resume exceeds the {MAX_PDF_BYTES // 1024 // 1024} MB upload limit." + ) + if not pdf_bytes: + raise ResumeParseError("Empty upload.") + + try: + with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf: + pages = [] + for page in pdf.pages: + text = page.extract_text() or "" + pages.append(text) + if sum(len(p) for p in pages) > MAX_TEXT_CHARS: + break + full_text = "\n".join(pages) + except Exception as exc: + # pdfplumber raises a grab-bag of exceptions for corrupt PDFs; treat + # them all as user input errors rather than 500s. + raise ResumeParseError(f"Could not read PDF: {exc}") from exc + + if not full_text.strip(): + raise ResumeParseError( + "No text could be extracted. If your CV is a scanned image, " + "re-export it as a text-based PDF." + ) + return full_text[:MAX_TEXT_CHARS] + + +def _classify_proficiency(context: str) -> tuple[int, float]: + """Return (proficiency, confidence_boost) from surrounding context. + + A short window around the matched span is all we get — this is purposely + coarse. Higher layers can refine with a real classifier later. + """ + if _EXPERIENCE_MARKERS.search(context): + return _STRONG_SIGNAL_PROFICIENCY, 0.10 + if _NOVICE_MARKERS.search(context): + return _NOVICE_PROFICIENCY, 0.05 + return _DEFAULT_PROFICIENCY, 0.0 + + +def _classify_aggregated(contexts: list[str]) -> tuple[int, float]: + """Pick the strongest signal across all window contexts for a single skill. + + Short-circuits on STRONG (the ceiling). Ranks by _PROFICIENCY_RANK, not by + raw proficiency value, so NOVICE ("beginner X") beats DEFAULT (silence) + even though NOVICE=40 < DEFAULT=55. + """ + best = (_DEFAULT_PROFICIENCY, 0.0) + best_rank = 0 + for ctx in contexts: + prof, boost = _classify_proficiency(ctx) + rank = _PROFICIENCY_RANK.get(prof, 0) + if rank > best_rank: + best, best_rank = (prof, boost), rank + if rank == 2: # STRONG — can't improve further. + break + return best + + +def _windows_for(text: str, pattern: str, context_chars: int = 60) -> list[str]: + """Yield ±context_chars windows around every match of `pattern` in text.""" + windows = [] + for match in re.finditer(pattern, text.lower()): + start = max(0, match.start() - context_chars) + end = min(len(text), match.end() + context_chars) + windows.append(text[start:end]) + return windows + + +_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9+#.\-]{2,}") + + +def _candidate_matches(text: str, skills: list[Skill]) -> dict[int, dict]: + """Return {skill_id: {'confidence', 'proficiency', 'matched_span'}} for + every catalog skill that appears in the text. + + Two passes: + 1. Exact (case-insensitive) boundary-aware substring hit → high + confidence (0.95). We do NOT include '.' in the boundary character + class because tech names like "Node.js" already round-trip through + ``re.escape`` and sentence-final punctuation like "... SQL." must + still match. + 2. Token-level fuzzy match — for each token in the document that's + long enough, rapidfuzz.fuzz.ratio against the catalog. ``partial_ + ratio`` was rejected because it false-positives aggressively + ("python" vs "sourdough" scored ~0.5). Per-token ``ratio`` with a + high threshold (88) only fires on near-typos. + """ + hits: dict[int, dict] = {} + + for skill in skills: + name = skill.skill_name.strip() + if not name: + continue + needle = name.lower() + # Word-boundary exact match — '\b' handles punctuation around + # identifier-like tech names. '.' is intentionally excluded from the + # boundary class so "SQL." at end-of-sentence still matches. + pattern = r"(?` — lives next to this module. + mod = __import__( + f"apps.accounts.ner.{layer_name}", + fromlist=["layer"], + ) + return getattr(mod, "layer", None) + except Exception as exc: + logger.warning("ner.%s: import failed, skipping (%s)", layer_name, exc) + return None + + +def _enabled_layers() -> list[str]: + raw = os.environ.get("GAPGUIDE_PARSE_LAYERS", _DEFAULT_LAYERS) + out = [s.strip() for s in raw.split(",") if s.strip()] + # Always guarantee lexical at the end — contract says the floor fires. + if "lexical" not in out: + out.append("lexical") + return out + + +def _find_span_for(text: str, skill_name: str) -> tuple[int, str]: + """Search the text for `skill_name` and derive (proficiency, matched_span). + + Used to enrich non-lexical layer hits with the same surrounding-context + proficiency heuristic the lexical layer uses. If the skill name isn't + literally in the text (the layer inferred it from a paraphrase), fall + back to the default proficiency + empty span. + + Multi-window aggregation: if the skill appears multiple times, the + strongest *signal* across windows wins (STRONG > NOVICE > DEFAULT). + """ + needle = re.escape(skill_name.lower()) + pattern = r"(? Skill | None: + """Map a free-text alias to a canonical Skill row. + + Exact match (case-insensitive) -> fuzzy match via rapidfuzz using the + same threshold as the lexical layer. Returns None if nothing confident + enough is found. Aliases too short to fuzzy-match just require an exact + hit; keeps 'Go' and 'R' from matching arbitrary noun phrases. + """ + if not alias: + return None + key = alias.strip().lower() + hit = skills_by_lower.get(key) + if hit is not None: + return hit + if len(key) < _FUZZY_MIN_SKILL_LEN: + return None + candidate = process.extractOne( + key, + list(skills_by_lower.keys()), + scorer=fuzz.ratio, + score_cutoff=_FUZZY_TOKEN_SCORE, + ) + if candidate is None: + return None + matched_name, _, _ = candidate + return skills_by_lower.get(matched_name) + + +def _predict_skills_from_text(text: str) -> tuple[dict[int, dict], list[str]]: + """Run enabled NER layers against `text` and merge their hits. + + Returns (hits_by_skill_id, fired_layers): + - hits_by_skill_id: {skill_id: {confidence, proficiency, matched_span}} + - fired_layers: layers that actually returned results (in order) + + Each layer runs synchronously. A layer that raises during predict() is + logged at ERROR (with traceback) and skipped — the rest of the chain + continues. (Import failures are caught earlier in _load_layer and logged + at WARNING.) If ALL layers fail, the endpoint still returns 200 with the + lexical-floor output (since lexical is always run). + """ + skills = list(Skill.objects.all().only('id', 'skill_name')) + skills_by_lower = {s.skill_name.lower(): s for s in skills} + + hits: dict[int, dict] = {} + fired: list[str] = [] + + for layer_name in _enabled_layers(): + layer = _load_layer(layer_name) + if layer is None or not layer.available(): + continue + try: + # Run synchronously. A previous version ran each layer in a + # ThreadPoolExecutor for timeout support, but Django's ORM uses + # thread-local connections — the new thread never saw the test + # transaction's uncommitted rows, so the lexical/sbert layers + # found nothing under pytest-django. HF client libraries already + # apply their own request timeouts, so dropping the local one + # costs us only a theoretical hang on a misbehaving custom layer. + layer_out = layer.predict(text) + except Exception as exc: + # Keep the catch broad (a flaky HF layer must never 500 the upload) + # but log at ERROR with the traceback — the always-on net that makes + # a silently-skipped layer (e.g. a wiring/construction bug) visible + # in the logs instead of vanishing. + logger.error( + "ner.%s: raised %s, skipping", layer_name, exc, exc_info=True, + ) + continue + + if not layer_out: + continue + fired.append(layer_name) + + calibration = _LAYER_CALIBRATION.get(layer_name, 1.0) + for alias, conf in layer_out.items(): + skill = _resolve_alias(alias, skills_by_lower, skills) + if skill is None: + continue + existing = hits.get(skill.id) + # Calibrate per-layer so a 0.92 softmax isn't dominated by a + # hardcoded lexical 0.95. Cap below 1.0 to keep downstream plots + # away from the edge of the [0,1] axis. + conf_f = round(min(float(conf) * calibration, 0.99), 3) + if existing is None: + prof, span = _find_span_for(text, skill.skill_name) + hits[skill.id] = { + 'confidence': conf_f, + 'proficiency': prof, + 'matched_span': span, + } + elif conf_f > existing['confidence']: + existing['confidence'] = conf_f + + return hits, fired + + +def extract_skills_from_pdf(pdf_bytes: bytes) -> list[dict]: + """Parse a resume PDF and return ordered, catalog-resolved skill predictions. + + Kept as a list-only return to preserve the MVP test contract. The view + uses :func:`parse_resume_envelope` instead, which also carries the list + of layers that fired (the dispatcher's `parser_version`). + """ + env = parse_resume_envelope(pdf_bytes) + return env['skills'] + + +def parse_resume_envelope(pdf_bytes: bytes) -> dict: + """Full response envelope: ``{'skills': [...], 'parser_version': [...]}``. + + `parser_version` is a list of layer names so the frontend can render a + real "which layers fired" chip sequence (`["nucha", "lexical"]`) rather + than the MVP's opaque `"mvp-lexical-v1"` string. + """ + text = _extract_text(pdf_bytes) + scored, fired = _predict_skills_from_text(text) + if not scored: + return {'skills': [], 'parser_version': fired} + + skill_lookup = { + s.id: s for s in Skill.objects.filter(id__in=scored.keys()) + } + predictions: list[SkillPrediction] = [] + for skill_id, data in scored.items(): + skill = skill_lookup.get(skill_id) + if skill is None: + continue + predictions.append(SkillPrediction( + skill_id=skill_id, + skill_name=skill.skill_name, + proficiency=int(data['proficiency']), + confidence=float(data['confidence']), + matched_span=str(data['matched_span'])[:200], + )) + + predictions.sort(key=lambda p: (-p.confidence, p.skill_name)) + return { + 'skills': [p.to_dict() for p in predictions], + 'parser_version': fired, + } diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..3c38f8e292a6a6fa6d2efd13c96b027a84fb6d53 --- /dev/null +++ b/apps/accounts/serializers.py @@ -0,0 +1,112 @@ +from django.contrib.auth import authenticate +from django.contrib.auth.password_validation import validate_password as django_validate_password +from django.core.exceptions import ValidationError as DjangoValidationError +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers +from .models import User, UserProfile +from apps.skills.models import UserSkill +from apps.skills.serializers import UserSkillSerializer + + +class RegisterSerializer(serializers.ModelSerializer): + password_confirm = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ['name', 'email', 'password', 'password_confirm'] + extra_kwargs = { + 'password': {'write_only': True}, + } + + def validate(self, attrs): + if attrs['password'] != attrs['password_confirm']: + raise serializers.ValidationError({"password": "Passwords do not match."}) + return attrs + + def validate_email(self, value): + # Case-insensitive uniqueness: store lowercase so `Foo@X.com` and + # `foo@x.com` cannot both register, and login is case-insensitive too. + normalized = value.strip().lower() + if User.objects.filter(email__iexact=normalized).exists(): + raise serializers.ValidationError("A user with this email already exists.") + return normalized + + def validate_password(self, value): + # Run Django's configured AUTH_PASSWORD_VALIDATORS (min-length, common, + # numeric, user-similarity). Without this call the settings list is dead + # config. + try: + django_validate_password(value) + except DjangoValidationError as exc: + raise serializers.ValidationError(list(exc.messages)) + return value + + def create(self, validated_data): + validated_data.pop('password_confirm') + email = validated_data['email'] + user = User.objects.create_user( + username=email, + email=email, + name=validated_data.get('name', ''), + password=validated_data['password'], + role_type='STUDENT' + ) + return user + + +class LoginSerializer(serializers.Serializer): + email = serializers.EmailField(write_only=True) + password = serializers.CharField(write_only=True) + + def validate(self, attrs): + # Normalize to match the lowercase username stored at registration. + email = (attrs.get('email') or '').strip().lower() + user = authenticate( + request=self.context.get('request'), + username=email, + password=attrs.get('password') + ) + + if not user: + raise serializers.ValidationError("Invalid credentials.") + + if not user.is_active: + raise serializers.ValidationError("User account is disabled.") + + return user + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'name', 'email', 'role_type', 'created_at'] + read_only_fields = ['id', 'name', 'email', 'role_type', 'created_at'] + + +class UserProfileSerializer(serializers.ModelSerializer): + class Meta: + model = UserProfile + fields = ['degree', 'semester', 'interests', 'bio', 'updated_at'] + read_only_fields = ['updated_at'] + + +class ProfileWithSkillsSerializer(serializers.Serializer): + user = serializers.SerializerMethodField() + profile = serializers.SerializerMethodField() + skills = serializers.SerializerMethodField() + + @extend_schema_field(UserSerializer) + def get_user(self, obj): + return UserSerializer(obj).data + + @extend_schema_field(UserProfileSerializer) + def get_profile(self, obj): + try: + return UserProfileSerializer(obj.profile).data + except UserProfile.DoesNotExist: + return {} + + @extend_schema_field(UserSkillSerializer(many=True)) + def get_skills(self, obj): + user_skills = UserSkill.objects.filter(user=obj).select_related('skill') + return UserSkillSerializer(user_skills, many=True).data diff --git a/apps/accounts/tests/__init__.py b/apps/accounts/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/accounts/tests/test_auth.py b/apps/accounts/tests/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..4c1ce2b9933f1e704fbed8be9dc77edc44497d1d --- /dev/null +++ b/apps/accounts/tests/test_auth.py @@ -0,0 +1,140 @@ +import pytest +from rest_framework.test import APIClient + +pytestmark = pytest.mark.django_db + +VALID_USER = { + 'name': 'Test User', + 'email': 'test@example.com', + 'password': 'TestPass123!', + 'password_confirm': 'TestPass123!' +} + +REGISTER_URL = '/api/auth/register/' +LOGIN_URL = '/api/auth/login/' +LOGOUT_URL = '/api/auth/logout/' +ME_URL = '/api/auth/me/' + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def user_data(): + return VALID_USER.copy() + + +@pytest.fixture +def registered_user(api_client, user_data): + """Register a user and return the response data.""" + response = api_client.post(REGISTER_URL, data=user_data, format='json') + return response.data + + +class TestRegistration: + def test_valid_registration(self, api_client, user_data): + """TC-01: Valid registration returns 201 with tokens.""" + response = api_client.post(REGISTER_URL, data=user_data, format='json') + assert response.status_code == 201 + assert 'access' in response.data + assert 'refresh' in response.data + assert 'user' in response.data + assert response.data['user']['email'] == user_data['email'] + + def test_duplicate_email(self, api_client, user_data): + """TC-02: Duplicate email returns 400.""" + api_client.post(REGISTER_URL, data=user_data, format='json') + response = api_client.post(REGISTER_URL, data=user_data, format='json') + assert response.status_code == 400 + + def test_password_mismatch(self, api_client, user_data): + """Password mismatch returns 400.""" + user_data['password_confirm'] = 'DifferentPassword!' + response = api_client.post(REGISTER_URL, data=user_data, format='json') + assert response.status_code == 400 + + def test_missing_fields(self, api_client): + """Missing required fields returns 400.""" + response = api_client.post(REGISTER_URL, data={'email': 'x@x.com'}, format='json') + assert response.status_code == 400 + + +class TestLogin: + def test_valid_login(self, api_client, user_data, registered_user): + """TC-03: Valid login returns 200 with tokens.""" + login_data = {'email': user_data['email'], 'password': user_data['password']} + response = api_client.post(LOGIN_URL, data=login_data, format='json') + assert response.status_code == 200 + assert 'access' in response.data + assert 'refresh' in response.data + + def test_invalid_credentials(self, api_client, user_data, registered_user): + """TC-04: Wrong password returns 400.""" + login_data = {'email': user_data['email'], 'password': 'WrongPassword!'} + response = api_client.post(LOGIN_URL, data=login_data, format='json') + assert response.status_code == 400 + + +class TestMe: + def test_me_without_token(self, api_client): + """GET /me/ without token returns 401.""" + response = api_client.get(ME_URL) + assert response.status_code == 401 + + def test_get_me(self, api_client, user_data, registered_user): + """Authenticated GET /me/ returns user data.""" + access_token = registered_user['access'] + api_client.credentials(HTTP_AUTHORIZATION='Bearer ' + access_token) + response = api_client.get(ME_URL) + assert response.status_code == 200 + assert response.data['email'] == user_data['email'] + + +class TestLoginThrottle: + def test_login_rate_limit_blocks_after_threshold( + self, api_client, user_data, registered_user, + ): + """Login endpoint is throttled at 10/minute — verify the 11th + attempt is rejected with 429, regardless of credentials.""" + bad = {'email': user_data['email'], 'password': 'nope'} + # Burn through the limit with invalid credentials (400 per attempt). + for _ in range(10): + r = api_client.post(LOGIN_URL, data=bad, format='json') + assert r.status_code in (400, 401) + # 11th must be throttled. + r = api_client.post(LOGIN_URL, data=bad, format='json') + assert r.status_code == 429 + + +class TestLogout: + def test_logout(self, api_client, user_data, registered_user): + """Logout blacklists refresh token, returns 204.""" + access_token = registered_user['access'] + refresh_token = registered_user['refresh'] + + api_client.credentials(HTTP_AUTHORIZATION='Bearer ' + access_token) + response = api_client.post( + LOGOUT_URL, + data={'refresh_token': refresh_token}, + format='json' + ) + assert response.status_code == 204 + + +class TestRegistrationRace: + def test_concurrent_email_race_returns_400(self, api_client, user_data): + """F2: a concurrent case-variant registration can bypass the + case-insensitive validate_email check; the DB unique index then raises + IntegrityError. The view must surface a clean 400 keyed on `email`, + not an unhandled 500.""" + from unittest.mock import patch + from django.db import IntegrityError + with patch( + 'apps.accounts.serializers.RegisterSerializer.save', + side_effect=IntegrityError, + ): + response = api_client.post(REGISTER_URL, data=user_data, format='json') + assert response.status_code == 400 + assert 'email' in response.data diff --git a/apps/accounts/tests/test_ner_dispatcher.py b/apps/accounts/tests/test_ner_dispatcher.py new file mode 100644 index 0000000000000000000000000000000000000000..a02fc2ce2f9cd06730a296448039c2ce6347912b --- /dev/null +++ b/apps/accounts/tests/test_ner_dispatcher.py @@ -0,0 +1,196 @@ +"""Dispatcher tests for the four-layer NER fallback chain. + +Covers: + * Merge semantics — outputs from multiple layers union into one alias dict. + * Per-layer failures are silently skipped; remaining layers still contribute. + * If every layer raises, the endpoint still returns 200 with the lexical + floor output (since lexical is always appended to the enabled list). +""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from apps.skills.models import Skill + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def catalog(): + py = Skill.objects.create(skill_name='Python', category='Programming', + difficulty_level='BEGINNER') + sql = Skill.objects.create(skill_name='SQL', category='Database', + difficulty_level='BEGINNER') + react = Skill.objects.create(skill_name='React', category='Frontend', + difficulty_level='BEGINNER') + return {'python': py, 'sql': sql, 'react': react} + + +def _make_layer(name, predictions): + """Build a lightweight object that quacks like NerLayer.""" + class _Fake: + pass + fake = _Fake() + fake.name = name + fake.predict = lambda _text: predictions + fake.available = lambda: True + return fake + + +def test_merge_semantics_union_hits_from_multiple_layers(catalog): + from apps.accounts.resume_parser import _predict_skills_from_text + + layers = { + 'nucha': _make_layer('nucha', {'Python': 0.92}), + 'jobbert': _make_layer('jobbert', {'SQL': 0.81}), + 'lexical': _make_layer('lexical', {'React': 0.95}), + } + + with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \ + patch('apps.accounts.resume_parser._enabled_layers', + return_value=['nucha', 'jobbert', 'lexical']): + hits, fired = _predict_skills_from_text("Python SQL React") + + assert fired == ['nucha', 'jobbert', 'lexical'] + skill_ids = {catalog['python'].id, catalog['sql'].id, catalog['react'].id} + assert set(hits) == skill_ids + + +def test_max_confidence_kept_when_layers_agree(catalog): + from apps.accounts.resume_parser import _predict_skills_from_text + + layers = { + 'nucha': _make_layer('nucha', {'Python': 0.70}), + 'jobbert': _make_layer('jobbert', {'Python': 0.92}), # higher + 'lexical': _make_layer('lexical', {'Python': 0.85}), + } + with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \ + patch('apps.accounts.resume_parser._enabled_layers', + return_value=['nucha', 'jobbert', 'lexical']): + hits, _ = _predict_skills_from_text("Python Python Python") + + py_id = catalog['python'].id + # round(min(0.92, 0.99), 3) == 0.92 + assert hits[py_id]['confidence'] == 0.92 + + +def test_failing_layer_is_skipped_silently(catalog): + """One layer raises — dispatcher logs an error and continues with others.""" + from apps.accounts.resume_parser import _predict_skills_from_text + + class _Exploding: + name = 'nucha' + def predict(self, _): raise RuntimeError("HF hub is down") + def available(self): return True + + layers = { + 'nucha': _Exploding(), + 'lexical': _make_layer('lexical', {'Python': 0.95}), + } + with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \ + patch('apps.accounts.resume_parser._enabled_layers', + return_value=['nucha', 'lexical']): + hits, fired = _predict_skills_from_text("Python rocks") + + assert fired == ['lexical'] # nucha dropped silently + assert catalog['python'].id in hits + + +def test_all_layers_fail_falls_back_to_empty_but_no_exception(catalog): + """If literally every layer raises, dispatcher returns (empty, empty) and + the endpoint still 200s — never propagates the layer failures.""" + from apps.accounts.resume_parser import _predict_skills_from_text + + class _Exploding: + def __init__(self, name): self.name = name + def predict(self, _): raise RuntimeError("boom") + def available(self): return True + + layers = { + 'nucha': _Exploding('nucha'), + 'lexical': _Exploding('lexical'), + } + with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \ + patch('apps.accounts.resume_parser._enabled_layers', + return_value=['nucha', 'lexical']): + hits, fired = _predict_skills_from_text("Python SQL") + + assert hits == {} + assert fired == [] + + +def test_alias_resolution_fuzzy_matches_typos(catalog): + """A non-lexical layer returning a near-miss alias still resolves to the + catalog row via rapidfuzz.""" + from apps.accounts.resume_parser import _predict_skills_from_text + + # "phython" → "Python" via rapidfuzz ratio ~92 (above 88 threshold). + # A two-letter transposition like "pyhton" only scores ~83 — on the + # wrong side of the cutoff — so pick a typo that lands decisively above it. + layers = { + 'nucha': _make_layer('nucha', {'phython': 0.90}), + 'lexical': _make_layer('lexical', {}), + } + with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \ + patch('apps.accounts.resume_parser._enabled_layers', + return_value=['nucha', 'lexical']): + hits, _ = _predict_skills_from_text("phython is my favourite") + + assert catalog['python'].id in hits + + +def test_alias_resolution_drops_unknown_skills(catalog): + """A layer claiming 'COBOL' when the catalog has no COBOL row → ignored.""" + from apps.accounts.resume_parser import _predict_skills_from_text + + layers = { + 'nucha': _make_layer('nucha', {'COBOL': 0.90, 'Brainfuck': 0.85}), + 'lexical': _make_layer('lexical', {}), + } + with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \ + patch('apps.accounts.resume_parser._enabled_layers', + return_value=['nucha', 'lexical']): + hits, _ = _predict_skills_from_text("COBOL Brainfuck") + + assert hits == {} + + +def test_layer_calibration_flips_max_when_lexical_outweighs_model(catalog): + """Per-layer calibration coefficients prevent lexical's hardcoded 0.95 from + dominating a genuine model hit of 0.92. With _LAYER_CALIBRATION: + lexical 0.95 × 0.95 = 0.9025 + nucha 0.92 × 1.00 = 0.92 + Nucha now wins. Without calibration lexical would win at 0.95. + """ + from apps.accounts.resume_parser import _predict_skills_from_text + + layers = { + 'nucha': _make_layer('nucha', {'Python': 0.92}), + 'lexical': _make_layer('lexical', {'Python': 0.95}), + } + with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \ + patch('apps.accounts.resume_parser._enabled_layers', + return_value=['nucha', 'lexical']): + hits, _ = _predict_skills_from_text("Python project") + + py_id = catalog['python'].id + # Nucha's 0.92 (uncalibrated ×1.0) beats lexical's 0.9025 (0.95 ×0.95). + assert hits[py_id]['confidence'] == 0.92 + + +def test_parser_version_only_lists_layers_that_returned_hits(catalog): + """A layer that runs but returns {} is NOT in parser_version.""" + from apps.accounts.resume_parser import _predict_skills_from_text + + layers = { + 'nucha': _make_layer('nucha', {}), + 'lexical': _make_layer('lexical', {'Python': 0.95}), + } + with patch('apps.accounts.resume_parser._load_layer', side_effect=layers.get), \ + patch('apps.accounts.resume_parser._enabled_layers', + return_value=['nucha', 'lexical']): + _, fired = _predict_skills_from_text("Python") + assert fired == ['lexical'] diff --git a/apps/accounts/tests/test_ner_layers.py b/apps/accounts/tests/test_ner_layers.py new file mode 100644 index 0000000000000000000000000000000000000000..a6301dec4c515956910853574cf7eddaa8506788 --- /dev/null +++ b/apps/accounts/tests/test_ner_layers.py @@ -0,0 +1,221 @@ +"""Per-layer unit tests for Module 8 NER layers. + +Each layer's heavy model is mocked so CI doesn't pull ~1 GB of BERT weights. +Runtime integration with real models is covered by +test_resume_parser_integration.py (gated on GAPGUIDE_ML_SMOKE=1). +""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Nucha layer +# --------------------------------------------------------------------------- +class TestNuchaLayer: + def test_predict_extracts_hard_and_soft_skills(self): + """Pipeline returns HSKILL/SSKILL spans → alias dict keyed on span.""" + from apps.accounts.ner import nucha + + fake_pipeline = MagicMock(return_value=[ + {'entity_group': 'HSKILL', 'score': 0.93, 'word': 'Python', + 'start': 0, 'end': 6}, + {'entity_group': 'HSKILL', 'score': 0.88, 'word': 'SQL', + 'start': 10, 'end': 13}, + {'entity_group': 'SSKILL', 'score': 0.71, 'word': 'teamwork', + 'start': 20, 'end': 28}, + # Non-skill tokens must be dropped. + {'entity_group': 'O', 'score': 0.99, 'word': 'and', + 'start': 14, 'end': 17}, + ]) + + with patch.object(nucha, '_pipeline', fake_pipeline): + out = nucha.layer.predict("Python SQL and teamwork") + + assert out == { + 'Python': pytest.approx(0.93), + 'SQL': pytest.approx(0.88), + 'teamwork': pytest.approx(0.71), + } + + def test_empty_text_returns_empty(self): + from apps.accounts.ner import nucha + with patch.object(nucha, '_pipeline', MagicMock()) as m: + assert nucha.layer.predict("") == {} + m.assert_not_called() # shortcut before the pipeline runs + + def test_duplicate_spans_keep_max_confidence(self): + from apps.accounts.ner import nucha + fake = MagicMock(return_value=[ + {'entity_group': 'HSKILL', 'score': 0.5, 'word': 'Python'}, + {'entity_group': 'HSKILL', 'score': 0.9, 'word': 'Python'}, + {'entity_group': 'HSKILL', 'score': 0.7, 'word': 'Python'}, + ]) + with patch.object(nucha, '_pipeline', fake): + out = nucha.layer.predict("Python Python Python") + assert out == {'Python': pytest.approx(0.9)} + + +# --------------------------------------------------------------------------- +# JobBERT layer +# --------------------------------------------------------------------------- +class TestJobBertLayer: + def test_predict_accepts_bio_labels(self): + from apps.accounts.ner import jobbert + + fake = MagicMock(return_value=[ + {'entity_group': 'Skill', 'score': 0.87, 'word': 'React'}, + {'entity_group': 'Skill', 'score': 0.81, 'word': 'Docker'}, + # Non-skill label dropped. + {'entity_group': 'O', 'score': 0.99, 'word': 'the'}, + ]) + with patch.object(jobbert, '_pipeline', fake): + out = jobbert.layer.predict("React Docker the project") + assert out == {'React': pytest.approx(0.87), 'Docker': pytest.approx(0.81)} + + def test_label_casing_is_ignored(self): + from apps.accounts.ner import jobbert + fake = MagicMock(return_value=[ + {'entity_group': 'SKILL', 'score': 0.9, 'word': 'AWS'}, + {'entity_group': 'skill', 'score': 0.8, 'word': 'GCP'}, + ]) + with patch.object(jobbert, '_pipeline', fake): + out = jobbert.layer.predict("AWS GCP") + assert 'AWS' in out and 'GCP' in out + + def test_multitoken_skill_is_merged_via_offsets(self): + """F-NER-FRAG: with character offsets, a skill's B token + adjacent I + tokens re-stitch into one term (jjzha's suffix-less BIO labels stop + aggregation_strategy='simple' from doing this). Two separate B-started + skills stay distinct even when adjacent.""" + from apps.accounts.ner import jobbert + + text = "machine learning and Python" + fake = MagicMock(return_value=[ + {'entity_group': 'B', 'score': 0.90, 'word': 'machine', + 'start': 0, 'end': 7}, + {'entity_group': 'I', 'score': 0.80, 'word': 'learning', + 'start': 8, 'end': 16}, # adjacent (gap == 1) → merges + {'entity_group': 'B', 'score': 0.95, 'word': 'Python', + 'start': 21, 'end': 27}, # new B → stays separate + ]) + with patch.object(jobbert, '_pipeline', fake): + out = jobbert.layer.predict(text) + assert out == { + 'machine learning': pytest.approx(0.80), # min score of the group + 'Python': pytest.approx(0.95), + } + + def test_adjacent_begin_spans_stay_separate(self): + """Two B-tagged skills with only a space between them are NOT merged — + a 'B' always starts a fresh term.""" + from apps.accounts.ner import jobbert + + fake = MagicMock(return_value=[ + {'entity_group': 'B', 'score': 0.9, 'word': 'Python', + 'start': 0, 'end': 6}, + {'entity_group': 'B', 'score': 0.85, 'word': 'SQL', + 'start': 7, 'end': 10}, + ]) + with patch.object(jobbert, '_pipeline', fake): + out = jobbert.layer.predict("Python SQL") + assert out == {'Python': pytest.approx(0.9), 'SQL': pytest.approx(0.85)} + + def test_gap_greater_than_one_keeps_tokens_separate(self): + """F-NER-FRAG boundary: a B token and a following I token separated by + more than one char (gap == 2) do NOT merge — pins the upper bound of + the continuation predicate (`0 <= start - cur_end <= 1`).""" + from apps.accounts.ner import jobbert + + text = "machine learning" # two spaces → 2-char gap between the spans + fake = MagicMock(return_value=[ + {'entity_group': 'B', 'score': 0.90, 'word': 'machine', + 'start': 0, 'end': 7}, + {'entity_group': 'I', 'score': 0.80, 'word': 'learning', + 'start': 9, 'end': 17}, # gap == 2 → stays separate + ]) + with patch.object(jobbert, '_pipeline', fake): + out = jobbert.layer.predict(text) + assert out == { + 'machine': pytest.approx(0.90), + 'learning': pytest.approx(0.80), + } + + def test_bare_bio_prefixes_are_kept(self): + """Regression guard for the label-filter bug in jobbert.py. + + The real jjzha/jobbert_skill_extraction model, under + aggregation_strategy="simple", emits entity_group as bare BIO + prefixes ("B" / "I") rather than the entity name. The old filter + (`"skill" not in label`) dropped every span. Since the model has + exactly one entity type (SKILL), every non-O span IS a skill. + """ + from apps.accounts.ner import jobbert + + fake = MagicMock(return_value=[ + {'entity_group': 'B', 'score': 0.91, 'word': 'Python'}, + {'entity_group': 'I', 'score': 0.76, 'word': 'Kubernetes'}, + # Outside-span must still be dropped. + {'entity_group': 'O', 'score': 0.99, 'word': 'the'}, + ]) + with patch.object(jobbert, '_pipeline', fake): + out = jobbert.layer.predict("Python and Kubernetes the prod cluster") + assert out == { + 'Python': pytest.approx(0.91), + 'Kubernetes': pytest.approx(0.76), + } + + +# --------------------------------------------------------------------------- +# SBERT layer — DB-backed via SkillEmbedding + pgvector CosineDistance +# --------------------------------------------------------------------------- +class TestSbertLayer: + """Unit-level: we don't want to hit pgvector here. Stub the ORM path and + verify the layer's own logic (threshold + aggregation).""" + + def test_below_threshold_is_dropped(self): + import numpy as np + + from apps.accounts.ner import sbert + + # Stub noun phrase extraction to avoid loading spacy. + with patch.object(sbert, '_candidate_phrases', return_value=['banana']): + fake_model = MagicMock() + # Real SentenceTransformer.encode returns a numpy ndarray; the + # layer calls .tolist() on it, so mirror the shape here. + fake_model.encode = MagicMock(return_value=np.array([[0.1] * 384])) + with patch.object(sbert, '_get_model', return_value=fake_model): + # pgvector query returns a match whose cosine distance is too + # high (similarity = 1 - 0.5 = 0.5 < 0.65 threshold). + fake_emb = MagicMock() + fake_emb.distance = 0.5 + fake_emb.skill.skill_name = 'Python' + fake_qs = MagicMock() + fake_qs.annotate.return_value.order_by.return_value.select_related.return_value.first.return_value = fake_emb + + with patch('apps.skills.models.SkillEmbedding.objects', fake_qs): + out = sbert.layer.predict("I like banana smoothies") + + assert out == {} # below threshold → dropped + + +# --------------------------------------------------------------------------- +# Lexical floor — already covered by test_resume_parser.py; just confirm the +# layer wrapper returns the expected shape. +# --------------------------------------------------------------------------- +@pytest.mark.django_db +class TestLexicalLayer: + def test_wraps_existing_candidate_matches(self): + from apps.accounts.ner import lexical + from apps.skills.models import Skill + + Skill.objects.create(skill_name='Python', category='Programming', + difficulty_level='BEGINNER') + Skill.objects.create(skill_name='SQL', category='Database', + difficulty_level='BEGINNER') + + out = lexical.layer.predict("Strong Python and SQL background.") + assert set(out) == {'Python', 'SQL'} + assert all(0 < v <= 1.0 for v in out.values()) diff --git a/apps/accounts/tests/test_profile.py b/apps/accounts/tests/test_profile.py new file mode 100644 index 0000000000000000000000000000000000000000..0e0090276265306c9303e8066f3330f994eee8db --- /dev/null +++ b/apps/accounts/tests/test_profile.py @@ -0,0 +1,152 @@ +import pytest +from rest_framework.test import APIClient +from apps.skills.models import Skill + +pytestmark = pytest.mark.django_db + +REGISTER_URL = '/api/auth/register/' +LOGIN_URL = '/api/auth/login/' +PROFILE_URL = '/api/auth/profile/' +PARSE_RESUME_URL = '/api/auth/profile/parse-resume/' +SKILLS_URL = '/api/skills/' +USER_SKILLS_URL = '/api/user-skills/' + +VALID_USER = { + 'name': 'Test User', + 'email': 'test2@example.com', + 'password': 'TestPass123!', + 'password_confirm': 'TestPass123!' +} + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def skill(db): + return Skill.objects.create( + skill_name='Python', + category='Programming', + difficulty_level='BEGINNER' + ) + + +@pytest.fixture +def auth_client(api_client): + api_client.post(REGISTER_URL, data=VALID_USER, format='json') + login_resp = api_client.post( + LOGIN_URL, + data={'email': VALID_USER['email'], 'password': VALID_USER['password']}, + format='json' + ) + token = login_resp.data['access'] + api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') + return api_client + + +class TestProfile: + def test_get_profile(self, auth_client): + """TC-05: GET /profile/ returns profile + skills.""" + response = auth_client.get(PROFILE_URL) + assert response.status_code == 200 + assert 'user' in response.data + assert 'profile' in response.data + assert 'skills' in response.data + + def test_update_profile(self, auth_client): + """TC-05: PUT /profile/ updates degree, semester, bio.""" + data = { + 'degree': 'BS Computer Science', + 'semester': 6, + 'bio': 'I love coding', + 'interests': 'AI, Web Dev' + } + response = auth_client.put(PROFILE_URL, data=data, format='json') + assert response.status_code == 200 + assert response.data['profile']['degree'] == 'BS Computer Science' + assert response.data['profile']['semester'] == 6 + + def test_update_profile_with_skills(self, auth_client, skill): + """TC-06: PUT /profile/ with skills upserts UserSkills.""" + data = { + 'degree': 'BS CS', + 'skills': [ + {'skill_id': skill.id, 'proficiency': 65} + ] + } + response = auth_client.put(PROFILE_URL, data=data, format='json') + assert response.status_code == 200 + skills = response.data['skills'] + assert len(skills) == 1 + assert skills[0]['proficiency'] == 65 + assert skills[0]['user_level'] == 'ADVANCED' # 65 > 60 + + def test_user_level_auto_computed(self, auth_client, skill): + """TC-06: user_level is auto-derived from proficiency.""" + data = {'skills': [{'skill_id': skill.id, 'proficiency': 30}]} + response = auth_client.put(PROFILE_URL, data=data, format='json') + assert response.status_code == 200 + assert response.data['skills'][0]['user_level'] == 'BEGINNER' # 30 <= 40 + + def test_profile_requires_auth(self, api_client): + """Profile endpoints require authentication.""" + response = api_client.get(PROFILE_URL) + assert response.status_code == 401 + + +class TestSkillCatalog: + def test_list_skills(self, auth_client, skill): + """GET /skills/ returns skill catalog (paginated).""" + response = auth_client.get(SKILLS_URL) + assert response.status_code == 200 + assert response.data['count'] >= 1 + + def test_filter_by_category(self, auth_client, skill): + """GET /skills/?category=Programming filters skills.""" + response = auth_client.get(f'{SKILLS_URL}?category=Programming') + assert response.status_code == 200 + assert all(s['category'] == 'Programming' for s in response.data['results']) + + +class TestUserSkills: + def test_add_user_skill(self, auth_client, skill): + """POST /user-skills/ adds a skill to user.""" + data = {'skill_id': skill.id, 'proficiency': 50} + response = auth_client.post(USER_SKILLS_URL, data=data, format='json') + assert response.status_code == 201 + assert response.data['proficiency'] == 50 + assert response.data['user_level'] == 'INTERMEDIATE' + + def test_update_user_skill(self, auth_client, skill): + """PUT /user-skills// updates proficiency.""" + # First add + add_resp = auth_client.post( + USER_SKILLS_URL, data={'skill_id': skill.id, 'proficiency': 30}, format='json' + ) + skill_id = add_resp.data['id'] + # Then update + update_resp = auth_client.put( + f'{USER_SKILLS_URL}{skill_id}/', + data={'skill_id': skill.id, 'proficiency': 70}, + format='json' + ) + assert update_resp.status_code == 200 + assert update_resp.data['user_level'] == 'ADVANCED' + + +class TestParseResumeStub: + """The Module 2 "stub" endpoint is now the real Module 8 MVP endpoint. + Kept under this class name for continuity — full happy-path + edge-case + coverage lives in apps/accounts/tests/test_resume_parser.py. + """ + + def test_requires_auth(self, api_client): + resp = api_client.post(PARSE_RESUME_URL, data={}, format='json') + assert resp.status_code == 401 + + def test_empty_body_returns_400(self, auth_client): + resp = auth_client.post(PARSE_RESUME_URL, data={}, format='multipart') + assert resp.status_code == 400 + assert 'detail' in resp.data diff --git a/apps/accounts/tests/test_resume_parser.py b/apps/accounts/tests/test_resume_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..b2cc930396766939a57b48cc760a9046925bf9a8 --- /dev/null +++ b/apps/accounts/tests/test_resume_parser.py @@ -0,0 +1,254 @@ +"""Module 8 MVP tests — lexical resume parser. + +Covers both the pure service function (extract_skills_from_pdf) and the HTTP +endpoint that wraps it. PDFs are built in-memory with pdfplumber's underlying +writer (reportlab isn't a dep, so we fake a minimal PDF byte stream via +pdfminer). For robustness we also lean on pdfplumber itself to round-trip a +known good fixture from pypdfium2's test payloads when possible — but the +happy path here avoids extra deps. +""" +from __future__ import annotations + +import io + +import pytest +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +from apps.accounts.resume_parser import ( + ResumeParseError, + extract_skills_from_pdf, +) +from apps.skills.models import Skill + + +User = get_user_model() +pytestmark = pytest.mark.django_db + + +# --------------------------------------------------------------------------- +# PDF building — use pdfplumber's underlying pdfminer / pypdfium2 stack. +# We render a minimal PDF with reportlab when available, else fall back to a +# raw PDF byte stream crafted by hand. Both paths yield a PDF that pdfplumber +# can extract_text() cleanly from. +# --------------------------------------------------------------------------- +def _build_pdf(text: str) -> bytes: + """Return a minimal valid single-page PDF containing `text`. + + Uses pypdfium2 (which pdfplumber already depends on) to render text. + If rendering isn't available in this env we hand-craft a PDF — slower + to maintain but avoids adding a new dependency just for tests. + """ + try: + # Preferred path: rely on a known-good PDF template from pypdfium2's + # bundled samples. If nothing ships, fall through to the hand-written + # variant. + import pypdfium2 as pdfium # noqa: F401 — proves the dep is live + except Exception: + pass + + # Hand-written minimal PDF — valid per the PDF 1.4 spec and parseable by + # pdfminer (pdfplumber's text extractor). + safe = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + pdf = ( + b"%PDF-1.4\n" + b"1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>>>>>endobj\n" + ) + stream = ( + "BT /F1 12 Tf 50 750 Td (" + safe + ") Tj ET" + ).encode("latin-1", errors="replace") + pdf += ( + b"4 0 obj<>stream\n" + + stream + b"\nendstream endobj\n" + ) + pdf += ( + b"5 0 obj<>endobj\n" + b"xref\n0 6\n" + b"0000000000 65535 f \n" + ) + # xref entries — rough, but sufficient for pdfminer tolerant parsing. + pdf += b"0000000010 00000 n \n" * 5 + pdf += ( + b"trailer<>\n" + b"startxref\n" + b"0\n" + b"%%EOF\n" + ) + return pdf + + +@pytest.fixture +def catalog(): + py = Skill.objects.create( + skill_name='Python', category='Programming', difficulty_level='BEGINNER') + sql = Skill.objects.create( + skill_name='SQL', category='Database', difficulty_level='BEGINNER') + ml = Skill.objects.create( + skill_name='Machine Learning', category='AI', + difficulty_level='INTERMEDIATE') + react = Skill.objects.create( + skill_name='React', category='Frontend', difficulty_level='INTERMEDIATE') + return {'python': py, 'sql': sql, 'ml': ml, 'react': react} + + +# --------------------------------------------------------------------------- +# Pure service tests +# --------------------------------------------------------------------------- +class TestExtractSkillsFromPdf: + def test_empty_bytes_raises(self, catalog): + with pytest.raises(ResumeParseError): + extract_skills_from_pdf(b"") + + def test_oversized_raises(self, catalog): + with pytest.raises(ResumeParseError): + extract_skills_from_pdf(b"x" * (6 * 1024 * 1024)) + + def test_corrupt_pdf_raises_user_error(self, catalog): + with pytest.raises(ResumeParseError): + extract_skills_from_pdf(b"not a pdf at all") + + def test_exact_skill_names_extracted(self, catalog): + pdf = _build_pdf( + "Experienced developer with Python, SQL and React expertise. " + "5 years of Machine Learning experience." + ) + predictions = extract_skills_from_pdf(pdf) + names = {p['skill_name'] for p in predictions} + # At least the catalog skills we planted should show up. + assert {'Python', 'SQL', 'Machine Learning', 'React'} <= names + + def test_experience_markers_boost_proficiency(self, catalog): + pdf = _build_pdf( + "Expert Python developer with 8 years of experience. Basic SQL." + ) + predictions = extract_skills_from_pdf(pdf) + by_name = {p['skill_name']: p for p in predictions} + # Python context has "Expert" + "years" → top bucket + # SQL context has "Basic" → mid-low bucket + assert by_name['Python']['proficiency'] >= by_name['SQL']['proficiency'] + + def test_results_sorted_by_confidence_desc(self, catalog): + pdf = _build_pdf("Python SQL React Machine Learning") + predictions = extract_skills_from_pdf(pdf) + confidences = [p['confidence'] for p in predictions] + assert confidences == sorted(confidences, reverse=True) + + def test_unknown_skills_not_created(self, catalog): + before = Skill.objects.count() + pdf = _build_pdf( + "Brainfuck and Whitespace and Malbolge are my specialties." + ) + _ = extract_skills_from_pdf(pdf) + assert Skill.objects.count() == before + + def test_no_matches_returns_empty_list(self, catalog): + pdf = _build_pdf("I love woodworking and baking sourdough bread.") + predictions = extract_skills_from_pdf(pdf) + # "bread" might false-positive via fuzzy on short names, so only + # assert there's no catalog name returned. + names = {p['skill_name'] for p in predictions} + assert names.isdisjoint({'Python', 'SQL', 'React', 'Machine Learning'}) + + def test_predictions_carry_matched_span_for_exact_hits(self, catalog): + pdf = _build_pdf( + "Built ML pipelines. Strong Python and React background." + ) + predictions = extract_skills_from_pdf(pdf) + py = next(p for p in predictions if p['skill_name'] == 'Python') + assert 'python' in py['matched_span'].lower() + + def test_proficiency_aggregates_across_windows(self, catalog): + """When a skill appears multiple times with conflicting signals, the + strongest claim wins — not the first occurrence. + + Here Python appears twice: first as "beginner Python" (NOVICE=40) then + as "lead Python team" (STRONG=70). Under the pre-#11 single-window + logic the first match won → proficiency 40. With multi-window + aggregation the STRONG signal should prevail → proficiency 70. + """ + pdf = _build_pdf( + "Learning path started as a beginner Python student. " + "Now lead Python team of five engineers building data platforms." + ) + predictions = extract_skills_from_pdf(pdf) + py = next(p for p in predictions if p['skill_name'] == 'Python') + assert py['proficiency'] == 70 + + +# --------------------------------------------------------------------------- +# HTTP endpoint tests +# --------------------------------------------------------------------------- +def _auth_client(): + user = User.objects.create_user( + username='parser@x.com', email='parser@x.com', + password='StrongPass123!', name='Parser', + ) + c = APIClient() + c.force_authenticate(user=user) + return c + + +class TestParseResumeEndpoint: + URL = '/api/auth/profile/parse-resume/' + + def test_requires_authentication(self): + c = APIClient() + r = c.post(self.URL, data={}, format='multipart') + assert r.status_code == 401 + + def test_empty_body_returns_400(self, catalog): + c = _auth_client() + r = c.post(self.URL, data={}, format='multipart') + assert r.status_code == 400 + assert 'resume' in r.data['detail'].lower() or \ + 'pdf' in r.data['detail'].lower() + + def test_empty_file_returns_400(self, catalog): + c = _auth_client() + upload = io.BytesIO(b"") + upload.name = 'empty.pdf' + r = c.post(self.URL, data={'resume': upload}, format='multipart') + assert r.status_code == 400 + + def test_corrupt_pdf_returns_400(self, catalog): + c = _auth_client() + upload = io.BytesIO(b"not a pdf") + upload.name = 'garbage.pdf' + r = c.post(self.URL, data={'resume': upload}, format='multipart') + assert r.status_code == 400 + + def test_happy_path_returns_skills(self, catalog): + c = _auth_client() + upload = io.BytesIO(_build_pdf( + "Senior Python developer. Deep Machine Learning background. " + "React on the frontend. 7 years experience." + )) + upload.name = 'resume.pdf' + r = c.post(self.URL, data={'resume': upload}, format='multipart') + assert r.status_code == 200 + body = r.data + assert 'skills' in body + # parser_version is now a list of layer names (e.g. ['lexical']). + # Under the CI pin GAPGUIDE_PARSE_LAYERS=lexical the floor is the + # only layer firing. + assert isinstance(body['parser_version'], list) + assert 'lexical' in body['parser_version'] + names = {s['skill_name'] for s in body['skills']} + # At least Python and React should land — those are unambiguous. + assert 'Python' in names + assert 'React' in names + + def test_no_state_mutation(self, catalog): + """Parsing is read-only — must not create UserSkill or Skill rows.""" + from apps.skills.models import UserSkill + c = _auth_client() + skill_count = Skill.objects.count() + us_count = UserSkill.objects.count() + upload = io.BytesIO(_build_pdf("Python Java React")) + upload.name = 'resume.pdf' + c.post(self.URL, data={'resume': upload}, format='multipart') + assert Skill.objects.count() == skill_count + assert UserSkill.objects.count() == us_count diff --git a/apps/accounts/tests/test_resume_parser_integration.py b/apps/accounts/tests/test_resume_parser_integration.py new file mode 100644 index 0000000000000000000000000000000000000000..136a94e78f39e40d405fae7f02e9113ba55bd001 --- /dev/null +++ b/apps/accounts/tests/test_resume_parser_integration.py @@ -0,0 +1,102 @@ +"""End-to-end NER chain integration test. + +Gated on ``GAPGUIDE_ML_SMOKE=1`` — runs the REAL four-layer chain +(Nucha + JobBERT + SkillNER + SBERT + lexical) against a fixture PDF. CI +sets GAPGUIDE_PARSE_LAYERS=lexical (see conftest.py), so this opt-in test +is the only place we verify the ML stack actually works end-to-end. + +Run manually: + $env:GAPGUIDE_ML_SMOKE="1" + pytest apps/accounts/tests/test_resume_parser_integration.py -q + +First run downloads ~1 GB of HF models + requires `en_core_web_lg` +installed and pgvector extension in Postgres with SkillEmbedding populated. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + + +pytestmark = [ + pytest.mark.django_db(transaction=True), + pytest.mark.skipif( + os.environ.get("GAPGUIDE_ML_SMOKE") != "1", + reason="ML smoke disabled (set GAPGUIDE_ML_SMOKE=1 to enable)", + ), +] + + +FIX = Path(__file__).resolve().parent.parent.parent.parent / 'tests' / 'fixtures' / 'resumes' + + +@pytest.fixture +def seeded_catalog(): + """Real catalog + SBERT embeddings. Requires: + * pgvector extension on the test DB + * en_core_web_lg installed + * seed_initial_skills has been run (we do it here) + """ + from django.core.management import call_command + call_command('seed_initial_skills') + + # Build embeddings for the seeded skills. + from apps.skills.models import Skill, SkillEmbedding + from sentence_transformers import SentenceTransformer + model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2') + skills = list(Skill.objects.all()) + vectors = model.encode( + [f"{s.skill_name} — {s.description or s.category}" for s in skills], + normalize_embeddings=True, + ) + for skill, vec in zip(skills, vectors): + SkillEmbedding.objects.update_or_create( + skill=skill, + defaults={ + 'embedding': vec.tolist(), + 'source_text': skill.skill_name, + }, + ) + return skills + + +def test_full_chain_extracts_core_skills_from_strong_fixture(seeded_catalog): + """resume_ds_strong.pdf must yield at least {Python, SQL} via the full chain. + + We deliberately don't assert exact counts / confidences — those depend + on the specific HF model versions and would flake across upgrades. + """ + # Force the full chain. + os.environ.pop('GAPGUIDE_PARSE_LAYERS', None) + from apps.accounts.resume_parser import parse_resume_envelope + + pdf = (FIX / 'resume_ds_strong.pdf').read_bytes() + env = parse_resume_envelope(pdf) + + names = {s['skill_name'] for s in env['skills']} + assert {'Python', 'SQL'}.issubset(names), (env['parser_version'], names) + + # At least one BERT/SBERT/SkillNER layer must have fired — proves the + # chain isn't silently falling back to lexical-only. + assert any( + layer in env['parser_version'] + for layer in ('nucha', 'jobbert', 'skillner', 'sbert') + ), env['parser_version'] + + +def test_minimal_fixture_exercises_sbert_fallback(seeded_catalog): + """resume_minimal.pdf uses paraphrased skills ("data visualisation", + "version control"). Lexical layer alone returns nothing; the SBERT + fallback should resolve at least one paraphrase to a catalog skill. + """ + os.environ.pop('GAPGUIDE_PARSE_LAYERS', None) + from apps.accounts.resume_parser import parse_resume_envelope + + pdf = (FIX / 'resume_minimal.pdf').read_bytes() + env = parse_resume_envelope(pdf) + + # Either SBERT or SkillNER should salvage at least one catalog hit here — + # otherwise the fallback chain isn't doing the job it was designed for. + assert len(env['skills']) >= 1, (env['parser_version'], env['skills']) diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..65d7ec7a8db5712fa37c60d9e3302dc8895a40dd --- /dev/null +++ b/apps/accounts/urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView +from .views import ( + RegisterView, LoginView, LogoutView, UserMeView, + ProfileView, ParseResumeStubView, +) + +urlpatterns = [ + path('register/', RegisterView.as_view(), name='register'), + path('login/', LoginView.as_view(), name='login'), + path('logout/', LogoutView.as_view(), name='logout'), + path('me/', UserMeView.as_view(), name='user-me'), + path('token/refresh/', TokenRefreshView.as_view(), name='token-refresh'), + path('profile/', ProfileView.as_view(), name='profile'), + path('profile/parse-resume/', ParseResumeStubView.as_view(), name='profile-parse-resume'), +] diff --git a/apps/accounts/views.py b/apps/accounts/views.py new file mode 100644 index 0000000000000000000000000000000000000000..69a53dfcd527beef0547535370c9ce61fbbeffcc --- /dev/null +++ b/apps/accounts/views.py @@ -0,0 +1,293 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer +from rest_framework import generics, permissions, serializers, status +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView +from rest_framework_simplejwt.exceptions import TokenError +from rest_framework_simplejwt.tokens import RefreshToken +from django.contrib.auth import get_user_model +from django.db import transaction, IntegrityError + +from apps.skills.models import Skill, UserSkill +from apps.skills.utils import proficiency_to_level + +from .models import UserProfile +from .serializers import ( + LoginSerializer, + ProfileWithSkillsSerializer, + RegisterSerializer, + UserProfileSerializer, + UserSerializer, +) + +User = get_user_model() + + +# Shape for endpoints that issue JWTs (register, login). +_AUTH_RESPONSE = inline_serializer( + name='AuthTokenResponse', + fields={ + 'access': serializers.CharField(), + 'refresh': serializers.CharField(), + 'user': UserSerializer(), + }, +) + + +@extend_schema( + responses={ + 201: _AUTH_RESPONSE, + 400: OpenApiResponse( + description='Validation error — duplicate email, weak/mismatched ' + 'password, or missing fields (body is {"": [...]}).', + ), + }, +) +class RegisterView(generics.CreateAPIView): + queryset = User.objects.all() + permission_classes = [permissions.AllowAny] + throttle_classes = [ScopedRateThrottle] + throttle_scope = 'register' + serializer_class = RegisterSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + user = serializer.save() + except IntegrityError: + # Concurrent-registration race: two simultaneous case-variant emails + # can both pass the case-insensitive validate_email check; both then + # insert the SAME app-lowercased value, and the plain `unique=True` + # constraint on email/username rejects the loser. Surface a clean 400 + # keyed on `email` (matching validate_email) instead of a 500. + return Response( + {'email': ['A user with this email already exists.']}, + status=status.HTTP_400_BAD_REQUEST, + ) + + refresh = RefreshToken.for_user(user) + return Response({ + 'access': str(refresh.access_token), + 'refresh': str(refresh), + 'user': UserSerializer(user).data + }, status=status.HTTP_201_CREATED) + + +@extend_schema( + request=LoginSerializer, + responses={200: _AUTH_RESPONSE}, +) +class LoginView(APIView): + serializer_class = LoginSerializer + permission_classes = [permissions.AllowAny] + throttle_classes = [ScopedRateThrottle] + throttle_scope = 'login' + + def post(self, request): + serializer = LoginSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data + + refresh = RefreshToken.for_user(user) + + return Response({ + 'access': str(refresh.access_token), + 'refresh': str(refresh), + 'user': UserSerializer(user).data + }, status=status.HTTP_200_OK) + + +@extend_schema( + request=inline_serializer( + name='LogoutRequest', + fields={'refresh_token': serializers.CharField()}, + ), + responses={ + 204: OpenApiResponse(description='Token blacklisted.'), + 400: OpenApiResponse(description='Missing or invalid refresh token.'), + }, +) +class LogoutView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + try: + refresh_token = request.data.get('refresh_token') + if not refresh_token: + return Response( + {'detail': 'refresh_token is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + token = RefreshToken(refresh_token) + token.blacklist() + return Response(status=status.HTTP_204_NO_CONTENT) + except TokenError: + return Response( + {'detail': 'Invalid or expired token'}, + status=status.HTTP_400_BAD_REQUEST + ) + + +class UserMeView(generics.RetrieveAPIView): + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + return self.request.user + + +@extend_schema( + request={ + 'multipart/form-data': inline_serializer( + name='ResumeUpload', + fields={'resume': serializers.FileField()}, + ), + }, + responses={ + 200: inline_serializer( + name='ParseResumeResponse', + fields={ + 'skills': serializers.ListField(child=serializers.DictField()), + 'parser_version': serializers.ListField(child=serializers.CharField()), + 'note': serializers.CharField(), + }, + ), + 400: OpenApiResponse(description='Empty / missing file or parse error.'), + }, +) +class ParseResumeStubView(APIView): + """Resume parsing — Module 8 MVP. + + Accepts a multipart PDF upload and returns pre-fill predictions scored + against the Skill catalog. This is the "degraded fallback" layer in the + research/06-dataset-sourcing.md §5 fallback chain — pdfplumber + lexical + match. Higher NER layers can be plugged in behind + `apps.accounts.resume_parser.extract_skills_from_pdf` when the ML + env-setup track (SpaCy/BERT wheels + pgvector) lands. + + Class name kept as-is (`ParseResumeStubView`) so URLConf + existing routes + don't churn — the view is no longer a stub but the URL name is stable. + """ + permission_classes = [permissions.IsAuthenticated] + throttle_classes = [ScopedRateThrottle] + throttle_scope = 'resume_parse' + + def post(self, request): + upload = ( + request.FILES.get('resume') + or request.FILES.get('file') + or next(iter(request.FILES.values()), None) + ) + if upload is None: + return Response( + {"detail": "Upload a PDF file under the 'resume' form field."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if upload.size == 0: + return Response( + {"detail": "Uploaded file is empty."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + from .resume_parser import ResumeParseError, parse_resume_envelope + + try: + envelope = parse_resume_envelope(upload.read()) + except ResumeParseError as exc: + return Response( + {"detail": str(exc)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + { + "skills": envelope["skills"], + "parser_version": envelope["parser_version"], + "note": ( + "Predictions are merged across the layers listed above. " + "Review the proficiency values — they are heuristics from " + "the surrounding context, not assertions." + ), + }, + status=status.HTTP_200_OK, + ) + + +class ProfileView(APIView): + serializer_class = ProfileWithSkillsSerializer + permission_classes = [permissions.IsAuthenticated] + + @extend_schema(responses=ProfileWithSkillsSerializer) + def get(self, request): + user = request.user + UserProfile.objects.get_or_create(user=user) + serializer = ProfileWithSkillsSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + request=inline_serializer( + name='ProfileUpdateRequest', + fields={ + 'degree': serializers.CharField(required=False, allow_blank=True), + 'semester': serializers.IntegerField(required=False, allow_null=True), + 'interests': serializers.CharField(required=False, allow_blank=True), + 'bio': serializers.CharField(required=False, allow_blank=True), + 'skills': serializers.ListField( + child=inline_serializer( + name='ProfileSkillItem', + fields={ + 'skill_id': serializers.IntegerField(), + 'proficiency': serializers.IntegerField(min_value=0, max_value=100), + }, + ), + required=False, + ), + }, + ), + responses=ProfileWithSkillsSerializer, + ) + @transaction.atomic + def put(self, request): + user = request.user + profile, _ = UserProfile.objects.get_or_create(user=user) + + profile_serializer = UserProfileSerializer( + profile, data=request.data, partial=True + ) + profile_serializer.is_valid(raise_exception=True) + profile_serializer.save() + + # Upsert skills (transactional — any error rolls back all changes) + skills_data = request.data.get('skills', []) or [] + requested_ids = { + int(item['skill_id']) for item in skills_data + if item.get('skill_id') is not None and item.get('proficiency') is not None + } + known_ids = set( + Skill.objects.filter(id__in=requested_ids).values_list('id', flat=True) + ) if requested_ids else set() + + for item in skills_data: + skill_id = item.get('skill_id') + proficiency = item.get('proficiency') + if skill_id is None or proficiency is None: + continue + skill_id = int(skill_id) + proficiency = int(proficiency) + if not (0 <= proficiency <= 100): + raise ValidationError(f"Proficiency must be 0-100 for skill_id {skill_id}") + if skill_id not in known_ids: + raise ValidationError(f"Skill with id={skill_id} does not exist") + UserSkill.objects.update_or_create( + user=user, skill_id=skill_id, + defaults={ + 'proficiency': proficiency, + 'user_level': proficiency_to_level(proficiency) + } + ) + + serializer = ProfileWithSkillsSerializer(user) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/analysis/__init__.py b/apps/analysis/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/analysis/admin.py b/apps/analysis/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..694323fa4c8382f9effc636c7062ef168bb62da2 --- /dev/null +++ b/apps/analysis/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/apps/analysis/apps.py b/apps/analysis/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..fbdc3dacc73d47b0159d18540e872389ab95b2a2 --- /dev/null +++ b/apps/analysis/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AnalysisConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.analysis' diff --git a/apps/analysis/models.py b/apps/analysis/models.py new file mode 100644 index 0000000000000000000000000000000000000000..137941ffaef5f37858adc11dbf608734c570dd38 --- /dev/null +++ b/apps/analysis/models.py @@ -0,0 +1 @@ +from django.db import models diff --git a/apps/analysis/services.py b/apps/analysis/services.py new file mode 100644 index 0000000000000000000000000000000000000000..14f07548a1cd7056da2e1a5eceb1fd237c41c9f9 --- /dev/null +++ b/apps/analysis/services.py @@ -0,0 +1,479 @@ +"""Gap Analysis + Recommendation services. + +Gap analysis — Weighted Competency Fulfillment Algorithm. +See research/design-decision-changes.md §Module 4 for derivation. Pure functions, +no DB writes: the report is computed on demand from UserSkill + RoleSkill state. + +Recommendations — composite ranking over the Resource catalog. +See research/08-next-modules-build-plan.md §Module A. Type weights from +research/03-gap-analysis-engine-research.md §2.1 are the single source of truth +for type preference. +""" +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Iterable + +from apps.resources.models import Resource, SkillResource +from apps.roles.models import Role, RoleSkill +from apps.skills.models import UserSkill +from apps.skills.utils import proficiency_to_level + + +LEVEL_THRESHOLDS: dict[str, int] = { + 'BEGINNER': 40, + 'INTERMEDIATE': 60, + 'ADVANCED': 100, +} + +MANDATORY_CAP = 60.0 + +BANDS: list[tuple[float, str]] = [ + (85.0, 'STRONG'), + (65.0, 'GOOD'), + (45.0, 'PARTIAL'), + (0.0, 'WEAK'), +] + + +@dataclass +class SkillGap: + skill_id: int + skill_name: str + category: str + required_level: str + threshold: int + user_proficiency: int + weight: float + is_mandatory: bool + gap: int + satisfaction: float + gap_type: str + severity: str | None + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass +class GapReport: + role_id: int + role_name: str + readiness: float + band: str + mandatory_cap_applied: bool + no_requirements: bool + gaps: list[SkillGap] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + 'role_id': self.role_id, + 'role_name': self.role_name, + 'readiness': round(self.readiness, 2), + 'band': self.band, + 'mandatory_cap_applied': self.mandatory_cap_applied, + 'mandatory_cap': MANDATORY_CAP, + 'no_requirements': self.no_requirements, + 'gaps': [g.to_dict() for g in self.gaps], + } + + +def _band_for(readiness: float) -> str: + for floor, name in BANDS: + if readiness >= floor: + return name + return 'WEAK' + + +def _classify( + user_proficiency: int, + threshold: int, + is_mandatory: bool, +) -> tuple[str, str | None]: + """Return (gap_type, severity) per §3 of design-decision-changes.md.""" + if user_proficiency >= threshold: + return 'MET', None + + if user_proficiency == 0: + return 'MISSING', 'CRITICAL' if is_mandatory else 'MEDIUM' + + ratio = (threshold - user_proficiency) / threshold + if is_mandatory or ratio >= 0.5: + severity = 'HIGH' + elif ratio >= 0.25: + severity = 'MEDIUM' + else: + severity = 'LOW' + return 'INSUFFICIENT', severity + + +def _user_proficiency_map(user_id: int, skill_ids: Iterable[int]) -> dict[int, int]: + qs = UserSkill.objects.filter(user_id=user_id, skill_id__in=list(skill_ids)) + return {us.skill_id: us.proficiency for us in qs} + + +def compute_gap_report(user, role: Role) -> GapReport: + role_skills = list( + RoleSkill.objects.filter(role=role).select_related('skill') + ) + + if not role_skills: + return GapReport( + role_id=role.id, + role_name=role.role_name, + readiness=100.0, + band=_band_for(100.0), + mandatory_cap_applied=False, + no_requirements=True, + ) + + prof_map = _user_proficiency_map(user.id, (rs.skill_id for rs in role_skills)) + + gaps: list[SkillGap] = [] + weighted_sat_sum = 0.0 + weight_sum = 0.0 + mandatory_shortfall = False + + for rs in role_skills: + threshold = LEVEL_THRESHOLDS[rs.required_level] + user_prof = prof_map.get(rs.skill_id, 0) + satisfaction = min(user_prof / threshold, 1.0) + gap_points = max(threshold - user_prof, 0) + gap_type, severity = _classify(user_prof, threshold, rs.is_mandatory) + + if rs.is_mandatory and satisfaction < 1.0: + mandatory_shortfall = True + + weight_sum += rs.weight + weighted_sat_sum += satisfaction * rs.weight + + gaps.append(SkillGap( + skill_id=rs.skill_id, + skill_name=rs.skill.skill_name, + category=rs.skill.category, + required_level=rs.required_level, + threshold=threshold, + user_proficiency=user_prof, + weight=rs.weight, + is_mandatory=rs.is_mandatory, + gap=gap_points, + satisfaction=round(satisfaction, 4), + gap_type=gap_type, + severity=severity, + )) + + if weight_sum <= 0: + # No positive weight to aggregate. With the RoleSkill DB CheckConstraints + # in force a mandatory skill always has weight>0, so weight_sum<=0 implies + # no mandatory shortfall and the documented zero-weight contract returns + # 100/no_requirements. The mandatory branch is defense-in-depth against a + # constraint-bypassing bulk write — it avoids a 0.0/0.0 ZeroDivisionError. + readiness = 0.0 if mandatory_shortfall else 100.0 + return GapReport( + role_id=role.id, + role_name=role.role_name, + readiness=readiness, + band=_band_for(readiness), + # readiness here is 0 (mandatory unmet) or 100 (no requirements); + # neither is a value "capped down to 60", so the cap is not applied. + mandatory_cap_applied=False, + no_requirements=not mandatory_shortfall, + gaps=gaps, + ) + + raw_readiness = (weighted_sat_sum / weight_sum) * 100.0 + cap_applied = mandatory_shortfall and raw_readiness > MANDATORY_CAP + readiness = MANDATORY_CAP if cap_applied else raw_readiness + readiness = max(0.0, min(readiness, 100.0)) + + gaps.sort( + key=lambda g: ( + -{'CRITICAL': 4, 'HIGH': 3, 'MEDIUM': 2, 'LOW': 1, None: 0}[g.severity], + -g.weight, + g.skill_name, + ) + ) + + return GapReport( + role_id=role.id, + role_name=role.role_name, + readiness=readiness, + band=_band_for(readiness), + mandatory_cap_applied=cap_applied, + no_requirements=False, + gaps=gaps, + ) + + +# --------------------------------------------------------------------------- +# Recommendation service +# --------------------------------------------------------------------------- +# +# Recommendations are ranked by a TWO-KEYED order so that material which is +# clearly the wrong level can never leapfrog appropriately-levelled material on +# quality (type/relevance/rating) alone: +# +# 1. LEVEL TIER (primary) — the distance between the user's CURRENT level for +# the skill (proficiency_to_level(), NOT the role's required level) and the +# resource's difficulty. distance 0-1 = "near" (tier 0); distance >=2 = +# "far" (tier 1). Every near resource ranks above every far one. So a +# beginner always sees BEGINNER/INTERMEDIATE content before ADVANCED — a +# hard guarantee, independent of how good the far resource looks otherwise. +# +# 2. COMPOSITE QUALITY SCORE (secondary) — orders resources WITHIN a tier: +# +# score = TYPE_WEIGHT[type] * w_type +# + DIFFICULTY_MATCH(user_current_level, difficulty) * w_diff +# + relevance_score * 10 * w_rel # normalise to 0..10 +# + (rating / 5.0) * 10 * w_rating # normalise to 0..10 +# +# Within the near tier the difficulty term still gives exact-level a gentle +# edge over one-level-off, but it stays SOFT: a much more relevant/stronger +# one-level-off resource can still outrank a weak exact-level one. The only +# HARD rule is the near-over-far split above. +# +# Coefficients (single source of truth — keep in sync with the docstring above): +# +# w_type = 1.0 (dominant — follows the type preference from research +# §2.1 "Video > Course > Article > Docs") +# w_diff = 1.0 +# w_rel = 1.5 (relevance is heavily weighted — a very-relevant DOCS +# can outrank a somewhat-relevant VIDEO at the same level) +# w_rating = 0.5 +# +# Notable consequences, verified by unit tests: +# * Equal rating/relevance/difficulty → VIDEO > COURSE > ARTICLE > DOCS. +# * A resource <=1 level from the user ALWAYS ranks above one >=2 levels away, +# regardless of type/relevance/rating (the level-tier guarantee). +# * Within a tier, exact-level beats one-level-off on equal quality, yet a +# relevance_score=1.0 DOCS can outrank a relevance_score=0.3 VIDEO at the +# same level (relevance is weighted 1.5x), both at rating 3.5/5: +# DOCS : 4*1.0 + 10*1.0 + (1.0*10)*1.5 + (3.5/5*10)*0.5 = 32.5 +# VIDEO: 10*1.0 + 10*1.0 + (0.3*10)*1.5 + (3.5/5*10)*0.5 = 28.0 +# * Stable tie-break by resource_id ASC so API snapshots don't flap. + +TYPE_WEIGHTS: dict[str, int] = { + 'VIDEO': 10, + 'COURSE': 8, + 'ARTICLE': 6, + 'DOCS': 4, +} + +DIFFICULTY_ORDER: dict[str, int] = { + 'BEGINNER': 0, + 'INTERMEDIATE': 1, + 'ADVANCED': 2, +} + +W_TYPE = 1.0 +W_DIFF = 1.0 +W_REL = 1.5 +W_RATING = 0.5 + +DIFFICULTY_EXACT = 10.0 +DIFFICULTY_ADJACENT = 5.0 +DIFFICULTY_MISMATCH = 0.0 + +# A resource within this many levels of the user's current level is "near" +# (tier 0); anything further is "far" (tier 1) and is ranked below every near +# resource. With the 3-level scale this means: appropriate-level + one step are +# near, a two-step mismatch (e.g. ADVANCED for a BEGINNER) is far. +NEAR_MAX_DISTANCE = 1 + +DEFAULT_LIMIT_PER_SKILL = 3 + +# Upper bound on resources returned per skill gap. Caps an adversarial +# ?limit=99999999 so a single request can't try to materialise the whole +# catalog per skill; the curated catalog is small, so 50 is generous. +MAX_LIMIT_PER_SKILL = 50 + + +@dataclass +class RecommendationItem: + resource_id: int + title: str + provider: str + url: str + type: str + difficulty_level: str + duration: int + rating: float + relevance_score: float + score: float + + def to_dict(self) -> dict: + return asdict(self) + + +def _difficulty_match_score(anchor_level: str, resource_level: str) -> float: + anchor = DIFFICULTY_ORDER[anchor_level] + res = DIFFICULTY_ORDER[resource_level] + dist = abs(anchor - res) + if dist == 0: + return DIFFICULTY_EXACT + if dist == 1: + return DIFFICULTY_ADJACENT + return DIFFICULTY_MISMATCH + + +def _difficulty_tier(anchor_level: str, resource_level: str) -> int: + """Primary ranking key: 0 if the resource is within NEAR_MAX_DISTANCE levels + of the user's current level ("near"), else 1 ("far"). + + Sorting on this BEFORE the composite score guarantees a clearly-mismatched + resource (>=2 levels off, e.g. ADVANCED for a beginner) can never outrank an + appropriately-levelled one on type/relevance/rating alone. + """ + dist = abs(DIFFICULTY_ORDER[anchor_level] - DIFFICULTY_ORDER[resource_level]) + return 0 if dist <= NEAR_MAX_DISTANCE else 1 + + +def _score( + type_: str, + anchor_level: str, + resource_level: str, + relevance_score: float, + rating: float, +) -> float: + return ( + TYPE_WEIGHTS.get(type_, 0) * W_TYPE + + _difficulty_match_score(anchor_level, resource_level) * W_DIFF + + (relevance_score * 10.0) * W_REL + + (rating / 5.0 * 10.0) * W_RATING + ) + + +def compute_recommendations( + user, + role: Role, + limit_per_skill: int = DEFAULT_LIMIT_PER_SKILL, +) -> dict: + """Return {"role_id", "role_name", "recommendations": {skill_id: [...]}}. + + Only returns entries for skills with an actual gap (MISSING or INSUFFICIENT); + MET skills are skipped. Stable tie-break by resource_id ASC. + """ + report = compute_gap_report(user, role) + + gap_skills = [g for g in report.gaps if g.gap_type != 'MET'] + + recommendations: dict[int, list[dict]] = {} + if not gap_skills: + return { + 'role_id': role.id, + 'role_name': role.role_name, + 'recommendations': recommendations, + } + + skill_ids = [g.skill_id for g in gap_skills] + links = ( + SkillResource.objects + .filter(skill_id__in=skill_ids) + .select_related('resource') + ) + + by_skill: dict[int, list[tuple[SkillResource, Resource]]] = { + sid: [] for sid in skill_ids + } + for link in links: + by_skill[link.skill_id].append((link, link.resource)) + + # Anchor difficulty matching to the user's CURRENT level for each gap skill + # (derived from proficiency), not the role's required level — so learners are + # steered to material at their level first. See the scoring block above. + anchor_by_skill = { + g.skill_id: proficiency_to_level(g.user_proficiency) for g in gap_skills + } + + for skill_id, pairs in by_skill.items(): + anchor_level = anchor_by_skill[skill_id] + scored: list[tuple[int, float, int, RecommendationItem]] = [] + for link, resource in pairs: + score = _score( + resource.type, + anchor_level, + resource.difficulty_level, + link.relevance_score, + resource.rating, + ) + tier = _difficulty_tier(anchor_level, resource.difficulty_level) + scored.append(( + tier, + score, + resource.id, + RecommendationItem( + resource_id=resource.id, + title=resource.title, + provider=resource.provider, + url=resource.url, + type=resource.type, + difficulty_level=resource.difficulty_level, + duration=resource.duration, + rating=resource.rating, + relevance_score=link.relevance_score, + score=round(score, 4), + ), + )) + + # Primary: level tier (near before far). Secondary: composite score desc. + # Tertiary: resource_id asc for stable, snapshot-friendly ordering. + scored.sort(key=lambda t: (t[0], -t[1], t[2])) + top = [item.to_dict() for _, _, _, item in scored[:limit_per_skill]] + recommendations[skill_id] = top + + return { + 'role_id': role.id, + 'role_name': role.role_name, + 'recommendations': recommendations, + } + + +# --------------------------------------------------------------------------- +# Role suggestion service +# --------------------------------------------------------------------------- +# +# Ranks every active role by how well the user's current skills fit it, so the +# UI can surface "Recommended for you" (top fit) above the rest of the catalog. +# The fit reflects an uploaded resume automatically: CV → UserSkill → gap +# analysis is already wired, so suggestions update once parsed skills are saved. +# +# Reuses compute_gap_report per role (no duplicated readiness math). For the +# small curated catalog (~10 roles, ceiling ~50) the ~2 queries/role cost is +# negligible; batch into a single RoleSkill + UserSkill pass if it ever grows. + +DEFAULT_TOP_MISSING = 3 + + +def suggest_roles(user, top_missing: int = DEFAULT_TOP_MISSING) -> dict: + """Return {"has_skills", "roles": [...]} ranked by skill fit. + + Each role row: role_id, role_name, industry, readiness, band, + matched_skills/total_skills, top_missing_skills (most-severe first), + no_requirements. Sorted by (no_requirements last, readiness desc, + role_name asc) — roles with no requirements report readiness=100 + spuriously, so they sink to the bottom rather than top the list. + """ + has_skills = UserSkill.objects.filter(user_id=user.id).exists() + + rows: list[dict] = [] + for role in Role.objects.filter(is_active=True): + report = compute_gap_report(user, role) + total = len(report.gaps) + matched = sum(1 for g in report.gaps if g.gap_type == 'MET') + # report.gaps is severity-sorted, so the first non-MET names are the + # most critical missing skills. + missing = [g.skill_name for g in report.gaps if g.gap_type != 'MET'] + rows.append({ + 'role_id': role.id, + 'role_name': role.role_name, + 'industry': role.industry, + 'readiness': round(report.readiness, 2), + 'band': report.band, + 'matched_skills': matched, + 'total_skills': total, + 'top_missing_skills': missing[:top_missing], + 'no_requirements': report.no_requirements, + }) + + rows.sort(key=lambda r: (r['no_requirements'], -r['readiness'], r['role_name'])) + return {'has_skills': has_skills, 'roles': rows} diff --git a/apps/analysis/tests/__init__.py b/apps/analysis/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/analysis/tests/test_gap_analysis.py b/apps/analysis/tests/test_gap_analysis.py new file mode 100644 index 0000000000000000000000000000000000000000..f80de1eae7a770fd29a0f9ceb9a93bd7bb3ff2c4 --- /dev/null +++ b/apps/analysis/tests/test_gap_analysis.py @@ -0,0 +1,280 @@ +import pytest +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APIClient + +from apps.analysis.services import ( + LEVEL_THRESHOLDS, + MANDATORY_CAP, + compute_gap_report, +) +from apps.roles.models import Role, RoleSkill, UserTargetRole +from apps.skills.models import Skill, UserSkill + +User = get_user_model() +pytestmark = pytest.mark.django_db + +ANALYSIS_URL = '/api/analysis/' + + +@pytest.fixture +def user(): + return User.objects.create_user( + username='analysis@example.com', + email='analysis@example.com', + password='password123', + name='Analysis Tester', + ) + + +@pytest.fixture +def auth_client(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +def _skill(name, category='Programming'): + return Skill.objects.create(skill_name=name, category=category, difficulty_level='BEGINNER') + + +def _role_with(skill_specs): + """skill_specs = [(skill_name, required_level, weight, is_mandatory), ...]""" + role = Role.objects.create(role_name=f'Role-{id(skill_specs)}', industry='Tech', is_active=True) + for name, level, weight, mandatory in skill_specs: + RoleSkill.objects.create( + role=role, + skill=_skill(name), + required_level=level, + weight=weight, + is_mandatory=mandatory, + ) + return role + + +def _set_prof(user, skill_name, proficiency, level='BEGINNER'): + skill = Skill.objects.get(skill_name=skill_name) + UserSkill.objects.update_or_create( + user=user, skill=skill, + defaults={'proficiency': proficiency, 'user_level': level}, + ) + + +class TestReportToDict: + def test_to_dict_exposes_mandatory_cap_value(self, user): + # F18: the numeric cap value is exposed so the UI need not hardcode "60". + role = _role_with([('Python', 'INTERMEDIATE', 1.0, False)]) + _set_prof(user, 'Python', 60) + d = compute_gap_report(user, role).to_dict() + assert d['mandatory_cap'] == MANDATORY_CAP + + +class TestClassification: + def test_met_when_proficiency_equals_threshold(self, user): + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + _set_prof(user, 'Python', 60) + report = compute_gap_report(user, role) + assert report.gaps[0].gap_type == 'MET' + assert report.gaps[0].severity is None + assert report.gaps[0].gap == 0 + assert report.gaps[0].satisfaction == 1.0 + + def test_missing_mandatory_is_critical(self, user): + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + report = compute_gap_report(user, role) + g = report.gaps[0] + assert g.gap_type == 'MISSING' + assert g.severity == 'CRITICAL' + assert g.user_proficiency == 0 + + def test_missing_optional_is_medium(self, user): + role = _role_with([('Python', 'INTERMEDIATE', 1.0, False)]) + report = compute_gap_report(user, role) + assert report.gaps[0].gap_type == 'MISSING' + assert report.gaps[0].severity == 'MEDIUM' + + def test_insufficient_mandatory_forces_high(self, user): + # Ratio 0.17 (low in absolute terms), but mandatory promotes to HIGH. + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + _set_prof(user, 'Python', 50) + g = compute_gap_report(user, role).gaps[0] + assert g.gap_type == 'INSUFFICIENT' + assert g.severity == 'HIGH' + + def test_insufficient_ratio_high(self, user): + # U=20, T=60 → ratio = 40/60 ≈ 0.67 ≥ 0.5 → HIGH + role = _role_with([('Python', 'INTERMEDIATE', 1.0, False)]) + _set_prof(user, 'Python', 20) + assert compute_gap_report(user, role).gaps[0].severity == 'HIGH' + + def test_insufficient_ratio_medium(self, user): + # U=60, T=100 → ratio = 40/100 = 0.4 → MEDIUM + role = _role_with([('Python', 'ADVANCED', 1.0, False)]) + _set_prof(user, 'Python', 60) + assert compute_gap_report(user, role).gaps[0].severity == 'MEDIUM' + + def test_insufficient_ratio_low(self, user): + # U=85, T=100 → ratio = 0.15 < 0.25 → LOW + role = _role_with([('Python', 'ADVANCED', 1.0, False)]) + _set_prof(user, 'Python', 85) + assert compute_gap_report(user, role).gaps[0].severity == 'LOW' + + def test_thresholds_match_spec(self): + assert LEVEL_THRESHOLDS == {'BEGINNER': 40, 'INTERMEDIATE': 60, 'ADVANCED': 100} + + +class TestReadiness: + def test_all_met_yields_100(self, user): + role = _role_with([ + ('Python', 'INTERMEDIATE', 1.0, True), + ('SQL', 'BEGINNER', 1.0, False), + ]) + _set_prof(user, 'Python', 60) + _set_prof(user, 'SQL', 40) + report = compute_gap_report(user, role) + assert report.readiness == 100.0 + assert report.band == 'STRONG' + assert report.mandatory_cap_applied is False + + def test_weighted_aggregation(self, user): + # Python weight 3, fully met (S=1). SQL weight 1, unmet (U=0, S=0). + # Raw = (1*3 + 0*1)/(3+1) * 100 = 75. SQL is optional, no cap. + role = _role_with([ + ('Python', 'INTERMEDIATE', 3.0, False), + ('SQL', 'INTERMEDIATE', 1.0, False), + ]) + _set_prof(user, 'Python', 60) + report = compute_gap_report(user, role) + assert report.readiness == 75.0 + assert report.band == 'GOOD' + + def test_mandatory_cap_applies(self, user): + # Three skills, two met heavily weighted, one mandatory at 0 → raw would + # be high but cap pins readiness at 60. + role = _role_with([ + ('Python', 'INTERMEDIATE', 5.0, False), + ('SQL', 'INTERMEDIATE', 5.0, False), + ('Docker', 'INTERMEDIATE', 1.0, True), # mandatory, unmet + ]) + _set_prof(user, 'Python', 60) + _set_prof(user, 'SQL', 60) + report = compute_gap_report(user, role) + assert report.mandatory_cap_applied is True + assert report.readiness == MANDATORY_CAP + assert report.band == 'PARTIAL' + + def test_mandatory_cap_not_applied_when_all_mandatory_met(self, user): + role = _role_with([ + ('Python', 'INTERMEDIATE', 1.0, True), + ('Docker', 'BEGINNER', 1.0, False), # optional, unmet + ]) + _set_prof(user, 'Python', 60) + report = compute_gap_report(user, role) + assert report.mandatory_cap_applied is False + # Raw = (1.0 + 0)/2 * 100 = 50 → PARTIAL (no cap needed) + assert report.readiness == 50.0 + assert report.band == 'PARTIAL' + + def test_cap_not_applied_when_raw_below_cap(self, user): + # Mandatory unmet AND raw already below 60 — cap flag stays False. + role = _role_with([ + ('Python', 'INTERMEDIATE', 1.0, True), + ('SQL', 'INTERMEDIATE', 1.0, True), + ]) + _set_prof(user, 'Python', 30) + # SQL unmet → 0 sat + report = compute_gap_report(user, role) + # Raw = (30/60 + 0)/2 * 100 = 25 → WEAK + assert report.readiness == 25.0 + assert report.mandatory_cap_applied is False + assert report.band == 'WEAK' + + def test_no_requirements_returns_100(self, user): + role = Role.objects.create(role_name='Empty', industry='Tech', is_active=True) + report = compute_gap_report(user, role) + assert report.readiness == 100.0 + assert report.no_requirements is True + assert report.gaps == [] + + def test_zero_weight_sum_returns_100(self, user): + role = _role_with([ + ('Python', 'INTERMEDIATE', 0.0, False), + ('SQL', 'BEGINNER', 0.0, False), + ]) + report = compute_gap_report(user, role) + assert report.readiness == 100.0 + assert report.no_requirements is True + assert len(report.gaps) == 2 + + +class TestBands: + # With a single ADVANCED non-mandatory skill (T=100, W=1), the readiness + # score equals the proficiency exactly — so proficiency is the knob that + # lands us on each side of every band boundary. + @pytest.mark.parametrize('prof, expected_readiness, expected_band', [ + (100, 100.0, 'STRONG'), + (85, 85.0, 'STRONG'), # lower edge of STRONG (≥85) + (84, 84.0, 'GOOD'), # just below STRONG + (65, 65.0, 'GOOD'), # lower edge of GOOD (≥65) + (64, 64.0, 'PARTIAL'), # just below GOOD + (45, 45.0, 'PARTIAL'), # lower edge of PARTIAL (≥45) + (44, 44.0, 'WEAK'), # just below PARTIAL + (0, 0.0, 'WEAK'), + ]) + def test_band_boundaries(self, user, prof, expected_readiness, expected_band): + role = _role_with([('Python', 'ADVANCED', 1.0, False)]) + _set_prof(user, 'Python', prof) + report = compute_gap_report(user, role) + assert report.readiness == pytest.approx(expected_readiness, abs=0.01) + assert report.band == expected_band + + +class TestGapSorting: + def test_gaps_sorted_by_severity_then_weight(self, user): + role = _role_with([ + ('Python', 'INTERMEDIATE', 1.0, True), # MISSING CRITICAL + ('SQL', 'INTERMEDIATE', 5.0, False), # MET + ('Docker', 'INTERMEDIATE', 2.0, False), # MISSING MEDIUM + ]) + _set_prof(user, 'SQL', 60) + report = compute_gap_report(user, role) + names = [g.skill_name for g in report.gaps] + # CRITICAL > MEDIUM > MET + assert names == ['Python', 'Docker', 'SQL'] + + +class TestAPI: + def test_requires_auth(self): + response = APIClient().get(ANALYSIS_URL) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_400_when_no_target_role(self, auth_client): + response = auth_client.get(ANALYSIS_URL) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_200_with_active_target_role(self, auth_client, user): + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + UserTargetRole.objects.create(user=user, role=role, is_active=True) + _set_prof(user, 'Python', 60) + response = auth_client.get(ANALYSIS_URL) + assert response.status_code == status.HTTP_200_OK + assert response.data['role_id'] == role.id + assert response.data['readiness'] == 100.0 + assert response.data['band'] == 'STRONG' + assert len(response.data['gaps']) == 1 + assert response.data['gaps'][0]['gap_type'] == 'MET' + + def test_role_id_override(self, auth_client, user): + role = _role_with([('Python', 'BEGINNER', 1.0, False)]) + response = auth_client.get(f'{ANALYSIS_URL}?role_id={role.id}') + assert response.status_code == status.HTTP_200_OK + assert response.data['role_id'] == role.id + + def test_role_id_not_found(self, auth_client): + response = auth_client.get(f'{ANALYSIS_URL}?role_id=99999') + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_inactive_role_id_rejected(self, auth_client): + role = Role.objects.create(role_name='Old', industry='X', is_active=False) + response = auth_client.get(f'{ANALYSIS_URL}?role_id={role.id}') + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/apps/analysis/tests/test_recommendations.py b/apps/analysis/tests/test_recommendations.py new file mode 100644 index 0000000000000000000000000000000000000000..75d54aedec2e090c4d2efe6ab8ce43c937c4aa7f --- /dev/null +++ b/apps/analysis/tests/test_recommendations.py @@ -0,0 +1,334 @@ +"""Unit tests for the recommendation ranking service + endpoint. + +See research/08-next-modules-build-plan.md §Module A. Logic under test lives +in apps.analysis.services.compute_recommendations. +""" +import pytest +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +from apps.analysis.services import compute_recommendations +from apps.resources.models import Resource, SkillResource +from apps.roles.models import Role, RoleSkill, UserTargetRole +from apps.skills.models import Skill, UserSkill + +User = get_user_model() +pytestmark = pytest.mark.django_db + +RECS_URL = '/api/recommendations/' + + +@pytest.fixture +def user(): + return User.objects.create_user( + username='recs@example.com', + email='recs@example.com', + password='password123', + name='Recs Tester', + ) + + +@pytest.fixture +def auth_client(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +def _skill(name, category='Programming'): + return Skill.objects.create( + skill_name=name, category=category, difficulty_level='BEGINNER', + ) + + +def _role_with(skill_specs, name_hint='Role'): + role = Role.objects.create( + role_name=f'{name_hint}-{id(skill_specs)}', + industry='Tech', is_active=True, + ) + for name, level, weight, mandatory in skill_specs: + RoleSkill.objects.create( + role=role, skill=_skill(name), + required_level=level, weight=weight, is_mandatory=mandatory, + ) + return role + + +def _set_prof(user, skill_name, proficiency, level='BEGINNER'): + skill = Skill.objects.get(skill_name=skill_name) + UserSkill.objects.update_or_create( + user=user, skill=skill, + defaults={'proficiency': proficiency, 'user_level': level}, + ) + + +def _resource(title, r_type, difficulty='INTERMEDIATE', rating=3.5, url=None, + duration=60): + return Resource.objects.create( + title=title, + provider='TestProvider', + url=url or f'https://example.com/{title.replace(" ", "-").lower()}', + difficulty_level=difficulty, + duration=duration, + type=r_type, + rating=rating, + ) + + +def _link(skill_name, resource, relevance=1.0): + SkillResource.objects.create( + skill=Skill.objects.get(skill_name=skill_name), + resource=resource, relevance_score=relevance, + ) + + +class TestRanking: + def test_type_preference_weight_order(self, user): + """Equal rating/relevance/difficulty → VIDEO > COURSE > ARTICLE > DOCS.""" + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + for ttype in ('VIDEO', 'COURSE', 'ARTICLE', 'DOCS'): + _link('Python', _resource( + f'{ttype} res', ttype, + difficulty='INTERMEDIATE', rating=3.5, + ), relevance=0.8) + + skill_id = Skill.objects.get(skill_name='Python').id + rec = compute_recommendations(user, role, limit_per_skill=10) + order = [r['type'] for r in rec['recommendations'][skill_id]] + assert order == ['VIDEO', 'COURSE', 'ARTICLE', 'DOCS'] + + def test_difficulty_matches_user_level_beginner(self, user): + """Difficulty is anchored to the USER's current level, not the role's + required level. A junior (proficiency 30 → BEGINNER) on an + INTERMEDIATE-required skill gets BEGINNER material first, ADVANCED last.""" + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + _set_prof(user, 'Python', 30) # BEGINNER level, still < 60 → INSUFFICIENT + for diff in ('BEGINNER', 'INTERMEDIATE', 'ADVANCED'): + _link('Python', _resource( + f'{diff} course', 'COURSE', difficulty=diff, rating=3.5, + ), relevance=0.8) + + skill_id = Skill.objects.get(skill_name='Python').id + rec = compute_recommendations(user, role, limit_per_skill=10) + order = [r['difficulty_level'] for r in rec['recommendations'][skill_id]] + assert order[0] == 'BEGINNER' + assert order[-1] == 'ADVANCED' + + def test_difficulty_anchor_follows_higher_user_level(self, user): + """The anchor moves with the user: an INTERMEDIATE learner (proficiency + 55) on an ADVANCED-required skill gets INTERMEDIATE material first — + proving the match follows the user, not the role's required level.""" + role = _role_with([('Python', 'ADVANCED', 1.0, True)]) + _set_prof(user, 'Python', 55) # INTERMEDIATE level, still < 100 → INSUFFICIENT + for diff in ('BEGINNER', 'INTERMEDIATE', 'ADVANCED'): + _link('Python', _resource( + f'{diff} course', 'COURSE', difficulty=diff, rating=3.5, + ), relevance=0.8) + + skill_id = Skill.objects.get(skill_name='Python').id + rec = compute_recommendations(user, role, limit_per_skill=10) + first = rec['recommendations'][skill_id][0] + assert first['difficulty_level'] == 'INTERMEDIATE' + + def test_relevance_score_tiebreaker(self, user): + """Two COURSE rows same rating/difficulty → higher relevance wins.""" + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + low = _resource('low-rel', 'COURSE', rating=3.5) + high = _resource('high-rel', 'COURSE', rating=3.5) + _link('Python', low, relevance=0.3) + _link('Python', high, relevance=0.9) + + skill_id = Skill.objects.get(skill_name='Python').id + rec = compute_recommendations(user, role, limit_per_skill=10) + titles = [r['title'] for r in rec['recommendations'][skill_id]] + assert titles[0] == 'high-rel' + + def test_rating_tiebreaker(self, user): + """Two COURSE rows same relevance → higher rating wins.""" + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + low = _resource('low-rating', 'COURSE', rating=2.0) + high = _resource('high-rating', 'COURSE', rating=5.0) + _link('Python', low, relevance=0.8) + _link('Python', high, relevance=0.8) + + skill_id = Skill.objects.get(skill_name='Python').id + rec = compute_recommendations(user, role, limit_per_skill=10) + titles = [r['title'] for r in rec['recommendations'][skill_id]] + assert titles[0] == 'high-rating' + + def test_deterministic_tie_break_by_id(self, user): + """Two identical rows → lower resource_id comes first so snapshots are stable.""" + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + first = _resource('first', 'COURSE', rating=3.5, url='https://e.com/a') + second = _resource('second', 'COURSE', rating=3.5, url='https://e.com/b') + _link('Python', first, relevance=0.8) + _link('Python', second, relevance=0.8) + assert first.id < second.id + + skill_id = Skill.objects.get(skill_name='Python').id + rec = compute_recommendations(user, role, limit_per_skill=10) + ids = [r['resource_id'] for r in rec['recommendations'][skill_id]] + assert ids == [first.id, second.id] + + def test_relevance_can_override_type_preference(self, user): + """Very-relevant DOCS (1.0) should beat low-relevance VIDEO (0.3) — + this is the documented behavior from the ranking design.""" + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + video = _resource('sparse video', 'VIDEO', rating=3.5) + docs = _resource('perfect docs', 'DOCS', rating=3.5) + _link('Python', video, relevance=0.3) + _link('Python', docs, relevance=1.0) + + skill_id = Skill.objects.get(skill_name='Python').id + rec = compute_recommendations(user, role, limit_per_skill=10) + titles = [r['title'] for r in rec['recommendations'][skill_id]] + assert titles[0] == 'perfect docs' + + def test_far_level_resource_never_outranks_nearer_one(self, user): + """Level-tier guarantee: a resource 2 levels off the user's current + level ("far") can never outrank one within 1 level ("near"), even when + the far resource wins on type, relevance AND rating. + + Regression for the seeded-SQL case where an ADVANCED course (higher + type/relevance) edged an INTERMEDIATE doc for a BEGINNER learner.""" + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + _set_prof(user, 'Python', 30) # BEGINNER anchor + # near (1 level off): deliberately the WEAKER resource on every other axis + near = _resource('intermediate docs', 'DOCS', + difficulty='INTERMEDIATE', rating=4.0) + # far (2 levels off): stronger type + relevance + rating + far = _resource('advanced course', 'COURSE', + difficulty='ADVANCED', rating=5.0) + _link('Python', near, relevance=0.7) + _link('Python', far, relevance=1.0) + + skill_id = Skill.objects.get(skill_name='Python').id + rec = compute_recommendations(user, role, limit_per_skill=10) + order = [r['difficulty_level'] for r in rec['recommendations'][skill_id]] + assert order == ['INTERMEDIATE', 'ADVANCED'] + + +class TestFilteringAndShape: + def test_met_skills_excluded(self, user): + role = _role_with([ + ('Python', 'INTERMEDIATE', 1.0, True), # we'll MET this + ('SQL', 'INTERMEDIATE', 1.0, True), # gap + ]) + _set_prof(user, 'Python', 60) # == threshold → MET + _link('Python', _resource('py docs', 'DOCS'), relevance=1.0) + _link('SQL', _resource('sql docs', 'DOCS'), relevance=1.0) + + python_id = Skill.objects.get(skill_name='Python').id + sql_id = Skill.objects.get(skill_name='SQL').id + rec = compute_recommendations(user, role) + assert python_id not in rec['recommendations'] + assert sql_id in rec['recommendations'] + + def test_top_n_per_skill(self, user): + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + for i in range(10): + _link('Python', _resource( + f'r{i}', 'COURSE', url=f'https://e.com/{i}', + ), relevance=0.8) + + skill_id = Skill.objects.get(skill_name='Python').id + rec = compute_recommendations(user, role, limit_per_skill=3) + assert len(rec['recommendations'][skill_id]) == 3 + + def test_empty_catalog_returns_empty_per_gap(self, user): + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + skill_id = Skill.objects.get(skill_name='Python').id + rec = compute_recommendations(user, role) + assert rec['recommendations'][skill_id] == [] + + def test_no_gaps_returns_empty_map(self, user): + """User with zero gaps (or role with no requirements) gets an empty + recommendations map, not an error.""" + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + _set_prof(user, 'Python', 100) + rec = compute_recommendations(user, role) + assert rec['recommendations'] == {} + + +class TestEndpoint: + def test_endpoint_uses_active_target_role(self, user, auth_client): + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + UserTargetRole.objects.create(user=user, role=role, is_active=True) + _link('Python', _resource('py course', 'COURSE'), relevance=0.8) + + r = auth_client.get(RECS_URL) + assert r.status_code == 200, r.data + assert r.data['role_id'] == role.id + skill_id = Skill.objects.get(skill_name='Python').id + assert skill_id in [int(k) for k in r.data['recommendations']] + + def test_endpoint_no_target_no_param_returns_400(self, auth_client): + r = auth_client.get(RECS_URL) + assert r.status_code == 400 + assert 'target role' in r.data['detail'].lower() + + def test_endpoint_role_param_overrides_target(self, user, auth_client): + a = _role_with([('Python', 'INTERMEDIATE', 1.0, True)], name_hint='A') + b = _role_with([('SQL', 'INTERMEDIATE', 1.0, True)], name_hint='B') + UserTargetRole.objects.create(user=user, role=a, is_active=True) + _link('Python', _resource('py c', 'COURSE'), relevance=0.8) + _link('SQL', _resource('sql c', 'COURSE'), relevance=0.8) + + r = auth_client.get(f'{RECS_URL}?role={b.id}') + assert r.status_code == 200 + assert r.data['role_id'] == b.id + + def test_endpoint_limit_query_param(self, user, auth_client): + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + UserTargetRole.objects.create(user=user, role=role, is_active=True) + for i in range(5): + _link('Python', _resource( + f'r{i}', 'COURSE', url=f'https://e.com/{i}', + ), relevance=0.8) + + r = auth_client.get(f'{RECS_URL}?limit=2') + assert r.status_code == 200 + skill_id = Skill.objects.get(skill_name='Python').id + assert len(r.data['recommendations'][skill_id]) == 2 + + def test_endpoint_rejects_invalid_limit(self, user, auth_client): + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + UserTargetRole.objects.create(user=user, role=role, is_active=True) + + r = auth_client.get(f'{RECS_URL}?limit=0') + assert r.status_code == 400 + + r = auth_client.get(f'{RECS_URL}?limit=abc') + assert r.status_code == 400 + + def test_endpoint_empty_for_fully_met_user(self, user, auth_client): + role = _role_with([('Python', 'BEGINNER', 1.0, True)]) + UserTargetRole.objects.create(user=user, role=role, is_active=True) + _set_prof(user, 'Python', 100) + + r = auth_client.get(RECS_URL) + assert r.status_code == 200 + assert r.data['recommendations'] == {} + + def test_endpoint_clamps_huge_limit(self, user, auth_client): + """An adversarial ?limit=100000 is clamped to MAX_LIMIT_PER_SKILL, not + passed through verbatim. With more links than the cap, the per-skill + list never exceeds MAX_LIMIT_PER_SKILL.""" + from apps.analysis.services import MAX_LIMIT_PER_SKILL + + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + UserTargetRole.objects.create(user=user, role=role, is_active=True) + for i in range(MAX_LIMIT_PER_SKILL + 10): + _link('Python', _resource( + f'r{i}', 'COURSE', url=f'https://e.com/{i}', + ), relevance=0.8) + + r = auth_client.get(f'{RECS_URL}?limit=100000') + assert r.status_code == 200, r.data + skill_id = Skill.objects.get(skill_name='Python').id + assert len(r.data['recommendations'][skill_id]) == MAX_LIMIT_PER_SKILL + + def test_endpoint_requires_authentication(self): + client = APIClient() + r = client.get(RECS_URL) + assert r.status_code == 401 diff --git a/apps/analysis/tests/test_recommendations_stress.py b/apps/analysis/tests/test_recommendations_stress.py new file mode 100644 index 0000000000000000000000000000000000000000..5e4e042f0939182cd184d6cd19e956276c57f547 --- /dev/null +++ b/apps/analysis/tests/test_recommendations_stress.py @@ -0,0 +1,285 @@ +"""Stress / adversarial regression tests for the recommendation ranker. + +Pins the level-tier guarantee (c808446) and the level-aware anchor (6ac0e63) +across the full proficiency range, at scale, and under hostile endpoint input. +These complement the behavioural tests in test_recommendations.py — here we +sweep boundaries and verify invariants hold rather than spot-check cases. + +hypothesis is not installed, so the sweeps are parametrized explicitly. +""" +import pytest +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +from apps.analysis.services import ( + DIFFICULTY_ORDER, + MAX_LIMIT_PER_SKILL, + NEAR_MAX_DISTANCE, + _score, + compute_recommendations, +) +from apps.resources.models import Resource, SkillResource +from apps.roles.models import Role, RoleSkill, UserTargetRole +from apps.skills.models import Skill, UserSkill +from apps.skills.utils import proficiency_to_level + +User = get_user_model() +pytestmark = pytest.mark.django_db + +RECS_URL = '/api/recommendations/' + + +@pytest.fixture +def user(): + return User.objects.create_user( + username='stress@example.com', email='stress@example.com', + password='password123', name='Stress Tester', + ) + + +@pytest.fixture +def auth_client(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +def _skill(name, category='Programming'): + return Skill.objects.create( + skill_name=name, category=category, difficulty_level='BEGINNER', + ) + + +def _role_with(skill_specs, name_hint='Role'): + role = Role.objects.create( + role_name=f'{name_hint}-{id(skill_specs)}', + industry='Tech', is_active=True, + ) + for name, level, weight, mandatory in skill_specs: + RoleSkill.objects.create( + role=role, skill=_skill(name), + required_level=level, weight=weight, is_mandatory=mandatory, + ) + return role + + +def _set_prof(user, skill_name, proficiency, level='BEGINNER'): + skill = Skill.objects.get(skill_name=skill_name) + UserSkill.objects.update_or_create( + user=user, skill=skill, + defaults={'proficiency': proficiency, 'user_level': level}, + ) + + +def _resource(title, r_type, difficulty='INTERMEDIATE', rating=3.5, url=None, + duration=60): + return Resource.objects.create( + title=title, provider='TestProvider', + url=url or f'https://example.com/{title.replace(" ", "-").lower()}', + difficulty_level=difficulty, duration=duration, type=r_type, rating=rating, + ) + + +def _link(skill_name, resource, relevance=1.0): + SkillResource.objects.create( + skill=Skill.objects.get(skill_name=skill_name), + resource=resource, relevance_score=relevance, + ) + + +def _recs_for(user, role, skill_name, **kwargs): + skill_id = Skill.objects.get(skill_name=skill_name).id + rec = compute_recommendations(user, role, **kwargs) + return rec['recommendations'].get(skill_id, []), skill_id + + +class TestTierMatrix: + """Equal-quality resources at every difficulty → ordering is driven purely + by level distance from the user's anchor, near strictly before far.""" + + @pytest.mark.parametrize('prof,anchor', [ + (30, 'BEGINNER'), + (50, 'INTERMEDIATE'), + (80, 'ADVANCED'), + ]) + def test_equal_quality_orders_by_distance_then_id(self, user, prof, anchor): + # ADVANCED-required so the skill stays a gap at every proficiency tested. + role = _role_with([('Python', 'ADVANCED', 1.0, True)]) + _set_prof(user, 'Python', prof) + resources = {} + for diff in ('BEGINNER', 'INTERMEDIATE', 'ADVANCED'): + res = _resource(f'{diff} course', 'COURSE', difficulty=diff, rating=3.5) + _link('Python', res, relevance=0.8) + resources[res.id] = diff + + items, _ = _recs_for(user, role, 'Python', limit_per_skill=10) + assert proficiency_to_level(prof) == anchor # sanity on the fixture + + anchor_idx = DIFFICULTY_ORDER[anchor] + + def dist(diff): + return abs(anchor_idx - DIFFICULTY_ORDER[diff]) + + # distances are non-decreasing down the list (exact-quality case) + dists = [dist(it['difficulty_level']) for it in items] + assert dists == sorted(dists) + # tier (near=0 ≤NEAR_MAX_DISTANCE, far=1) is non-decreasing → no far + # resource precedes any near one. + tiers = [0 if d <= NEAR_MAX_DISTANCE else 1 for d in dists] + assert tiers == sorted(tiers) + # the exact-level resource always ranks first. + assert items[0]['difficulty_level'] == anchor + + +class TestProficiencyBoundaries: + """The anchor level used for ranking tracks proficiency_to_level exactly at + every boundary (≤40 BEGINNER / ≤60 INTERMEDIATE / else ADVANCED).""" + + @pytest.mark.parametrize('prof', [0, 40, 41, 60, 61, 80]) + def test_anchor_matches_proficiency_to_level(self, user, prof): + role = _role_with([('Python', 'ADVANCED', 1.0, True)]) # gap up to 99 + _set_prof(user, 'Python', prof) + for diff in ('BEGINNER', 'INTERMEDIATE', 'ADVANCED'): + _link('Python', _resource(f'{diff} c', 'COURSE', difficulty=diff, + rating=3.5), relevance=0.8) + items, _ = _recs_for(user, role, 'Python', limit_per_skill=10) + # The resource whose difficulty == the anchor level is the exact match + # and therefore ranks first. + assert items[0]['difficulty_level'] == proficiency_to_level(prof) + + def test_fully_met_skill_yields_no_recommendation(self, user): + """prof 100 against the max threshold is MET → excluded entirely (a + boundary the anchor sweep can't reach, since MET skills have no recs).""" + role = _role_with([('Python', 'ADVANCED', 1.0, True)]) # threshold 100 + _set_prof(user, 'Python', 100) + _link('Python', _resource('adv c', 'COURSE', difficulty='ADVANCED'), + relevance=0.8) + items, skill_id = _recs_for(user, role, 'Python', limit_per_skill=10) + rec = compute_recommendations(user, role, limit_per_skill=10) + assert skill_id not in rec['recommendations'] + assert items == [] + + +class TestLimitTierInteraction: + def test_top_slots_fill_with_near_first(self, user): + """4 near + 2 far, limit 3 → every returned item is near.""" + role = _role_with([('Python', 'ADVANCED', 1.0, True)]) + _set_prof(user, 'Python', 30) # BEGINNER anchor + for i in range(2): + _link('Python', _resource(f'beg{i}', 'COURSE', difficulty='BEGINNER', + url=f'https://e.com/b{i}'), relevance=0.8) + _link('Python', _resource(f'int{i}', 'COURSE', difficulty='INTERMEDIATE', + url=f'https://e.com/i{i}'), relevance=0.8) + for i in range(2): # far + _link('Python', _resource(f'adv{i}', 'COURSE', difficulty='ADVANCED', + url=f'https://e.com/a{i}'), relevance=0.8) + + items, _ = _recs_for(user, role, 'Python', limit_per_skill=3) + assert len(items) == 3 + assert all(it['difficulty_level'] in ('BEGINNER', 'INTERMEDIATE') + for it in items) + + def test_far_kept_when_slots_remain(self, user): + """1 near + 3 far, limit 3 → near first, then 2 far (far not dropped + while there are open slots).""" + role = _role_with([('Python', 'ADVANCED', 1.0, True)]) + _set_prof(user, 'Python', 30) # BEGINNER anchor + _link('Python', _resource('beg', 'COURSE', difficulty='BEGINNER'), + relevance=0.8) + for i in range(3): # far (ADVANCED, dist 2) + _link('Python', _resource(f'adv{i}', 'COURSE', difficulty='ADVANCED', + url=f'https://e.com/a{i}'), relevance=0.8) + + items, _ = _recs_for(user, role, 'Python', limit_per_skill=3) + assert len(items) == 3 + assert items[0]['difficulty_level'] == 'BEGINNER' + assert [it['difficulty_level'] for it in items[1:]] == ['ADVANCED', 'ADVANCED'] + + +class TestFarOnly: + def test_far_only_catalog_still_returned(self, user): + """ADVANCED learner, only BEGINNER material → all of it is returned, not + filtered out. The tier is a sort key, never a filter.""" + role = _role_with([('Python', 'ADVANCED', 1.0, True)]) + _set_prof(user, 'Python', 80) # ADVANCED anchor; BEGINNER res = dist 2 + for i in range(3): + _link('Python', _resource(f'beg{i}', 'COURSE', difficulty='BEGINNER', + url=f'https://e.com/b{i}'), relevance=0.8) + items, _ = _recs_for(user, role, 'Python', limit_per_skill=10) + assert len(items) == 3 + assert all(it['difficulty_level'] == 'BEGINNER' for it in items) + + +class TestVolumeAndStability: + def test_large_catalog_limits_and_is_deterministic(self, user): + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + for i in range(50): + _link('Python', _resource(f'r{i}', 'COURSE', url=f'https://e.com/{i}'), + relevance=0.8) + a, _ = _recs_for(user, role, 'Python', limit_per_skill=10) + b, _ = _recs_for(user, role, 'Python', limit_per_skill=10) + assert len(a) == 10 + assert [it['resource_id'] for it in a] == [it['resource_id'] for it in b] + + +class TestScoreFieldNonMonotonic: + def test_far_resource_can_outscore_yet_rank_below(self, user): + """The score field is exposed and equals _score(...) rounded, but a far + resource may carry a HIGHER score than a near one and still rank below + it — documenting that the tier key intentionally breaks score-rank + monotonicity (a guarantee, not a bug).""" + role = _role_with([('Python', 'ADVANCED', 1.0, True)]) + _set_prof(user, 'Python', 30) # BEGINNER anchor + # near: weak INTERMEDIATE DOCS + _link('Python', _resource('int docs', 'DOCS', difficulty='INTERMEDIATE', + rating=4.0), relevance=0.7) + # far: strong ADVANCED VIDEO + _link('Python', _resource('adv video', 'VIDEO', difficulty='ADVANCED', + rating=5.0), relevance=1.0) + + items, _ = _recs_for(user, role, 'Python', limit_per_skill=10) + assert [it['difficulty_level'] for it in items] == ['INTERMEDIATE', 'ADVANCED'] + near, far = items[0], items[1] + # the near resource ranks first despite a lower score + assert near['score'] < far['score'] + # score equals the documented formula, rounded to 4dp + assert near['score'] == round( + _score('DOCS', 'BEGINNER', 'INTERMEDIATE', 0.7, 4.0), 4) + assert far['score'] == round( + _score('VIDEO', 'BEGINNER', 'ADVANCED', 1.0, 5.0), 4) + + +class TestEndpointAdversarial: + def test_huge_limit_clamped(self, user, auth_client): + role = _role_with([('Python', 'INTERMEDIATE', 1.0, True)]) + UserTargetRole.objects.create(user=user, role=role, is_active=True) + for i in range(MAX_LIMIT_PER_SKILL + 5): + _link('Python', _resource(f'r{i}', 'COURSE', url=f'https://e.com/{i}'), + relevance=0.8) + r = auth_client.get(f'{RECS_URL}?limit=99999999') + assert r.status_code == 200, r.data + sid = Skill.objects.get(skill_name='Python').id + assert len(r.data['recommendations'][sid]) == MAX_LIMIT_PER_SKILL + + @pytest.mark.parametrize('bad', ['-1', 'abc', '999999']) + def test_bad_role_param_never_500(self, user, auth_client, bad): + """A hostile ?role= value resolves to a clean 404, never a 500.""" + r = auth_client.get(f'{RECS_URL}?role={bad}') + assert r.status_code == 404, (bad, r.status_code) + + def test_xss_skill_and_title_round_trip_unescaped(self, user, auth_client): + """The API returns raw values (no HTML-escaping) — the React client is + responsible for escaping on render. Confirms the payload is not double- + encoded and that no injection is performed server-side.""" + xss = '' + role = _role_with([(xss, 'INTERMEDIATE', 1.0, True)]) + UserTargetRole.objects.create(user=user, role=role, is_active=True) + _link(xss, _resource(xss, 'COURSE'), relevance=0.9) + + r = auth_client.get(RECS_URL) + assert r.status_code == 200 + sid = Skill.objects.get(skill_name=xss).id + assert r.data['recommendations'][sid][0]['title'] == xss + # rendered JSON carries the raw string, not an HTML-escaped one. + assert b'' in r.content + assert b'<script>' not in r.content diff --git a/apps/analysis/tests/test_role_suggestions.py b/apps/analysis/tests/test_role_suggestions.py new file mode 100644 index 0000000000000000000000000000000000000000..569405c86517f00dfd7d1e3b522405e5ebcaae8b --- /dev/null +++ b/apps/analysis/tests/test_role_suggestions.py @@ -0,0 +1,167 @@ +"""Unit tests for the role-suggestion service + endpoint. + +suggest_roles ranks every active role by the user's skill fit (reusing +compute_gap_report) so the UI can show "Recommended for you" above the rest +of the catalog, while every role stays reachable (search / switch-track case). +""" +import pytest +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +from apps.analysis.services import suggest_roles +from apps.roles.models import Role, RoleSkill +from apps.skills.models import Skill, UserSkill + +User = get_user_model() +pytestmark = pytest.mark.django_db + +URL = '/api/role-suggestions/' + + +@pytest.fixture +def user(): + return User.objects.create_user( + username='sugg@example.com', email='sugg@example.com', + password='password123', name='Sugg Tester', + ) + + +@pytest.fixture +def auth_client(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +def _skill(name, category='Programming'): + return Skill.objects.create( + skill_name=name, category=category, difficulty_level='BEGINNER', + ) + + +def _role(name, skill_specs, industry='Tech', is_active=True): + """skill_specs: list of (Skill, level, weight, mandatory).""" + role = Role.objects.create( + role_name=name, industry=industry, is_active=is_active, + ) + for skill, level, weight, mandatory in skill_specs: + RoleSkill.objects.create( + role=role, skill=skill, + required_level=level, weight=weight, is_mandatory=mandatory, + ) + return role + + +def _set_prof(user, skill, proficiency, level='INTERMEDIATE'): + UserSkill.objects.update_or_create( + user=user, skill=skill, + defaults={'proficiency': proficiency, 'user_level': level}, + ) + + +def test_better_fit_role_ranks_higher(user): + python = _skill('Python') + rust = _skill('Rust') + _role('Backend (Python)', [(python, 'INTERMEDIATE', 1.0, True)]) + _role('Systems (Rust)', [(rust, 'INTERMEDIATE', 1.0, True)]) + _set_prof(user, python, 100) # fully meets the Python role; Rust unset → 0 + + result = suggest_roles(user) + names = [r['role_name'] for r in result['roles']] + assert names.index('Backend (Python)') < names.index('Systems (Rust)') + + good = next(r for r in result['roles'] if r['role_name'] == 'Backend (Python)') + assert good['readiness'] == 100.0 + assert good['matched_skills'] == 1 + assert good['total_skills'] == 1 + assert good['top_missing_skills'] == [] + + +def test_top_missing_skills_lists_unmet(user): + python = _skill('Python') + sql = _skill('SQL') + _role('Data Role', [ + (python, 'INTERMEDIATE', 1.0, True), + (sql, 'INTERMEDIATE', 1.0, True), + ]) + _set_prof(user, python, 100) # MET; SQL missing + + result = suggest_roles(user) + row = next(r for r in result['roles'] if r['role_name'] == 'Data Role') + assert row['matched_skills'] == 1 + assert row['total_skills'] == 2 + assert 'SQL' in row['top_missing_skills'] + assert 'Python' not in row['top_missing_skills'] + + +def test_no_requirements_role_sinks_to_bottom(user): + python = _skill('Python') + _role('Empty Role', []) # no RoleSkill → no_requirements, readiness=100 (spurious) + _role('Backend (Python)', [(python, 'INTERMEDIATE', 1.0, True)]) + _set_prof(user, python, 50) # partial fit on the real role + + result = suggest_roles(user) + names = [r['role_name'] for r in result['roles']] + assert names[-1] == 'Empty Role' + + empty = next(r for r in result['roles'] if r['role_name'] == 'Empty Role') + assert empty['no_requirements'] is True + + +def test_has_skills_false_when_user_has_no_skills(user): + python = _skill('Python') + _role('Backend (Python)', [(python, 'INTERMEDIATE', 1.0, True)]) + + result = suggest_roles(user) + assert result['has_skills'] is False + # Roles are still returned (all low fit) so the catalog stays browsable. + assert len(result['roles']) == 1 + + +def test_has_skills_true_after_setting_a_skill(user): + python = _skill('Python') + _role('Backend (Python)', [(python, 'INTERMEDIATE', 1.0, True)]) + _set_prof(user, python, 70) + + assert suggest_roles(user)['has_skills'] is True + + +def test_low_fit_role_still_listed_and_reachable(user): + """The switch-track case: a strong analyst can still see (and pick) a + poorly-fitting role — it's just ranked below the good matches.""" + python = _skill('Python') + k8s = _skill('Kubernetes') + _role('Data Analyst', [(python, 'INTERMEDIATE', 1.0, True)]) + _role('DevOps Engineer', [(k8s, 'ADVANCED', 1.0, True)]) + _set_prof(user, python, 80) # strong analyst, zero DevOps + + result = suggest_roles(user) + names = [r['role_name'] for r in result['roles']] + assert 'DevOps Engineer' in names # reachable despite poor fit + assert names.index('Data Analyst') < names.index('DevOps Engineer') + + +def test_inactive_roles_excluded(user): + python = _skill('Python') + _role('Active Role', [(python, 'INTERMEDIATE', 1.0, True)], is_active=True) + _role('Inactive Role', [(python, 'INTERMEDIATE', 1.0, True)], is_active=False) + + names = [r['role_name'] for r in suggest_roles(user)['roles']] + assert 'Active Role' in names + assert 'Inactive Role' not in names + + +def test_endpoint_returns_ranked_roles(user, auth_client): + python = _skill('Python') + _role('Backend (Python)', [(python, 'INTERMEDIATE', 1.0, True)]) + _set_prof(user, python, 100) + + r = auth_client.get(URL) + assert r.status_code == 200, r.data + assert r.data['has_skills'] is True + assert r.data['roles'][0]['role_name'] == 'Backend (Python)' + + +def test_endpoint_requires_authentication(): + r = APIClient().get(URL) + assert r.status_code == 401 diff --git a/apps/analysis/tests/test_role_suggestions_stress.py b/apps/analysis/tests/test_role_suggestions_stress.py new file mode 100644 index 0000000000000000000000000000000000000000..574f2e1b953173e9ae08a6b9ee2e5a65984a8f5f --- /dev/null +++ b/apps/analysis/tests/test_role_suggestions_stress.py @@ -0,0 +1,187 @@ +"""Stress / adversarial regression tests for suggest_roles (6ac0e63). + +Pins the ranking contract (no_requirements last, readiness desc, role_name asc), +the top_missing_skills cap + severity ordering, matched/total counts at the +threshold boundary, and behaviour at catalog scale and under hostile input. +Complements the behavioural cases in test_role_suggestions.py. +""" +import pytest +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +from apps.analysis.services import suggest_roles +from apps.roles.models import Role, RoleSkill +from apps.skills.models import Skill, UserSkill + +User = get_user_model() +pytestmark = pytest.mark.django_db + +URL = '/api/role-suggestions/' + + +@pytest.fixture +def user(): + return User.objects.create_user( + username='sgstress@example.com', email='sgstress@example.com', + password='password123', name='SuggStress', + ) + + +@pytest.fixture +def auth_client(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +def _skill(name, category='Programming'): + return Skill.objects.create( + skill_name=name, category=category, difficulty_level='BEGINNER', + ) + + +def _role(name, skill_specs, industry='Tech', is_active=True): + """skill_specs: list of (Skill, level, weight, mandatory).""" + role = Role.objects.create(role_name=name, industry=industry, is_active=is_active) + for skill, level, weight, mandatory in skill_specs: + RoleSkill.objects.create( + role=role, skill=skill, + required_level=level, weight=weight, is_mandatory=mandatory, + ) + return role + + +def _set_prof(user, skill, proficiency, level='INTERMEDIATE'): + UserSkill.objects.update_or_create( + user=user, skill=skill, + defaults={'proficiency': proficiency, 'user_level': level}, + ) + + +def _sort_key(row): + return (row['no_requirements'], -row['readiness'], row['role_name']) + + +class TestScale: + def test_fifty_roles_ranked_without_error(self, user): + """50 active roles: a mix of fully-met and unmet fit, returned in the + documented order, count exact, no error.""" + core = _skill('Core') + _set_prof(user, core, 100) + for i in range(25): # met → readiness 100 + _role(f'met-{i:02d}', [(core, 'INTERMEDIATE', 1.0, True)]) + for i in range(25): # unmet unique skill → readiness 0 + _role(f'unmet-{i:02d}', [(_skill(f'Niche{i}'), 'INTERMEDIATE', 1.0, True)]) + + result = suggest_roles(user) + rows = result['roles'] + assert len(rows) == 50 + # globally sorted by the documented key + assert [_sort_key(r) for r in rows] == sorted(_sort_key(r) for r in rows) + # the 25 fully-met roles sit at the top, the 25 unmet at the bottom + assert all(r['readiness'] == 100.0 for r in rows[:25]) + assert all(r['readiness'] == 0.0 for r in rows[25:]) + + +class TestTieBreaking: + def test_equal_readiness_breaks_alphabetically(self, user): + """All readiness tied at 0 → strict role_name ascending, regardless of + insertion order.""" + for name in ('Zebra', 'Alpha', 'Mango'): + _role(name, [(_skill(f'S-{name}'), 'INTERMEDIATE', 1.0, True)]) + names = [r['role_name'] for r in suggest_roles(user)['roles']] + assert names == ['Alpha', 'Mango', 'Zebra'] + + +class TestNoRequirementsMix: + def test_empty_role_sinks_below_fully_met_real_role(self, user): + py = _skill('Python') + _set_prof(user, py, 100) + _role('Empty', []) # no_requirements + _role('Real', [(py, 'INTERMEDIATE', 1.0, True)]) # fully met, real + + rows = suggest_roles(user)['roles'] + top, bottom = rows[0], rows[-1] + assert top['role_name'] == 'Real' + assert top['no_requirements'] is False + assert top['readiness'] == 100.0 + assert bottom['role_name'] == 'Empty' + assert bottom['no_requirements'] is True + + +class TestTopMissingSkills: + def test_capped_at_three_and_severity_ordered(self, user): + crit_a = _skill('CritA') + crit_b = _skill('CritB') + high_c = _skill('HighC') + med_d = _skill('MedD') + low_e = _skill('LowE') + role = _role('Mixed', [ + (crit_a, 'INTERMEDIATE', 1.0, True), # prof 0 → MISSING CRITICAL + (crit_b, 'INTERMEDIATE', 1.0, True), # prof 0 → MISSING CRITICAL + (high_c, 'INTERMEDIATE', 1.0, True), # prof 10 → INSUFFICIENT HIGH + (med_d, 'INTERMEDIATE', 1.0, False), # prof 0 → MISSING MEDIUM + (low_e, 'INTERMEDIATE', 1.0, False), # prof 55 → INSUFFICIENT LOW + ]) + _set_prof(user, high_c, 10) + _set_prof(user, low_e, 55) + + row = next(r for r in suggest_roles(user)['roles'] if r['role_name'] == 'Mixed') + assert row['matched_skills'] == 0 + assert row['total_skills'] == 5 + # capped at 3, most-critical first; the MEDIUM + LOW skills drop off. + assert row['top_missing_skills'] == ['CritA', 'CritB', 'HighC'] + + def test_empty_for_fully_met_role(self, user): + py = _skill('Python') + _set_prof(user, py, 100) + _role('Met', [(py, 'INTERMEDIATE', 1.0, True)]) + row = next(r for r in suggest_roles(user)['roles'] if r['role_name'] == 'Met') + assert row['top_missing_skills'] == [] + assert row['matched_skills'] == 1 + assert row['total_skills'] == 1 + + +class TestThresholdBoundary: + @pytest.mark.parametrize('prof,matched,missing', [ + (60, 1, False), # == threshold → MET + (59, 0, True), # one below → still a gap + ]) + def test_matched_count_at_threshold(self, user, prof, matched, missing): + py = _skill('Python') + _role('Boundary', [(py, 'INTERMEDIATE', 1.0, True)]) # threshold 60 + _set_prof(user, py, prof) + row = next(r for r in suggest_roles(user)['roles'] + if r['role_name'] == 'Boundary') + assert row['matched_skills'] == matched + assert row['total_skills'] == 1 + assert ('Python' in row['top_missing_skills']) is missing + + +class TestEndpointAdversarial: + def test_response_shape_stable(self, user, auth_client): + py = _skill('Python') + _role('Backend', [(py, 'INTERMEDIATE', 1.0, True)]) + _set_prof(user, py, 70) + + r = auth_client.get(URL) + assert r.status_code == 200, r.data + assert set(r.data.keys()) == {'has_skills', 'roles'} + row = r.data['roles'][0] + assert set(row.keys()) == { + 'role_id', 'role_name', 'industry', 'readiness', 'band', + 'matched_skills', 'total_skills', 'top_missing_skills', + 'no_requirements', + } + + def test_xss_skill_name_round_trips_unescaped(self, user, auth_client): + xss = '' + skill = _skill(xss) + _role('XSS Role', [(skill, 'INTERMEDIATE', 1.0, True)]) # prof 0 → missing + + r = auth_client.get(URL) + assert r.status_code == 200 + row = next(x for x in r.data['roles'] if x['role_name'] == 'XSS Role') + assert row['top_missing_skills'] == [xss] + assert b'' in r.content + assert b'<script>' not in r.content diff --git a/apps/analysis/urls.py b/apps/analysis/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..95b916ca08d0d264287a583f35666833bc03387d --- /dev/null +++ b/apps/analysis/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import GapAnalysisView + +# Recommendations are mounted at /api/recommendations/ via +# apps.analysis.urls_recommendations — do not re-add them here or reverse() +# will hit an ambiguous name. +urlpatterns = [ + path('', GapAnalysisView.as_view(), name='gap-analysis'), +] diff --git a/apps/analysis/urls_recommendations.py b/apps/analysis/urls_recommendations.py new file mode 100644 index 0000000000000000000000000000000000000000..f6387794d398fc2db486011899f29e6db9f6e0c6 --- /dev/null +++ b/apps/analysis/urls_recommendations.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import RecommendationsView + +urlpatterns = [ + path('', RecommendationsView.as_view(), name='recommendations'), +] diff --git a/apps/analysis/urls_role_suggestions.py b/apps/analysis/urls_role_suggestions.py new file mode 100644 index 0000000000000000000000000000000000000000..1ee48e354345786500cfc4a8acc9d22b9cbcb47b --- /dev/null +++ b/apps/analysis/urls_role_suggestions.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import RoleSuggestionsView + +urlpatterns = [ + path('', RoleSuggestionsView.as_view(), name='role-suggestions'), +] diff --git a/apps/analysis/views.py b/apps/analysis/views.py new file mode 100644 index 0000000000000000000000000000000000000000..2001020e8594c391efa5b420424266d4504cb65d --- /dev/null +++ b/apps/analysis/views.py @@ -0,0 +1,126 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.roles.models import Role, UserTargetRole + +from .services import ( + DEFAULT_LIMIT_PER_SKILL, + MAX_LIMIT_PER_SKILL, + compute_gap_report, + compute_recommendations, + suggest_roles, +) + +_ROLE_ID_PARAM = OpenApiParameter( + name='role_id', + type=int, + location=OpenApiParameter.QUERY, + required=False, + description='Role to analyze. Defaults to the user\'s active target role.', +) + + +def _resolve_role(request): + """Return (role, error_response). role_id query param overrides active target.""" + role_id = ( + request.query_params.get('role_id') + or request.query_params.get('role') + ) + + if role_id: + try: + role = Role.objects.get(id=role_id, is_active=True) + except (Role.DoesNotExist, ValueError): + return None, Response( + {'detail': 'Role not found or inactive.'}, + status=status.HTTP_404_NOT_FOUND, + ) + return role, None + + try: + target = UserTargetRole.objects.select_related('role').get( + user=request.user, is_active=True + ) + except UserTargetRole.DoesNotExist: + return None, Response( + {'detail': 'No active target role. Select a target role first.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + return target.role, None + + +@extend_schema( + parameters=[_ROLE_ID_PARAM], + responses=OpenApiTypes.OBJECT, + description='Weighted competency-fulfillment gap report for the user against a role.', +) +class GapAnalysisView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + role, err = _resolve_role(request) + if err is not None: + return err + + report = compute_gap_report(request.user, role) + return Response(report.to_dict(), status=status.HTTP_200_OK) + + +@extend_schema( + parameters=[ + _ROLE_ID_PARAM, + OpenApiParameter( + name='limit', + type=int, + location=OpenApiParameter.QUERY, + required=False, + description=f'Resources returned per skill gap (default {DEFAULT_LIMIT_PER_SKILL}).', + ), + ], + responses=OpenApiTypes.OBJECT, + description='Ranked learning-resource recommendations per skill gap.', +) +class RecommendationsView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + role, err = _resolve_role(request) + if err is not None: + return err + + limit_raw = request.query_params.get('limit') + limit = DEFAULT_LIMIT_PER_SKILL + if limit_raw is not None: + try: + limit = int(limit_raw) + except ValueError: + return Response( + {'detail': 'limit must be a positive integer.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if limit <= 0: + return Response( + {'detail': 'limit must be a positive integer.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + limit = min(limit, MAX_LIMIT_PER_SKILL) + + payload = compute_recommendations(request.user, role, limit_per_skill=limit) + return Response(payload, status=status.HTTP_200_OK) + + +@extend_schema( + responses=OpenApiTypes.OBJECT, + description=( + 'Active roles ranked by how well the user\'s current skills fit them. ' + 'Powers the "Recommended for you" section on the role-selection page.' + ), +) +class RoleSuggestionsView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + return Response(suggest_roles(request.user), status=status.HTTP_200_OK) diff --git a/apps/progress/__init__.py b/apps/progress/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/progress/admin.py b/apps/progress/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..3d564ce275d72ad4ddc4ce4fff9e1e58d5e1057a --- /dev/null +++ b/apps/progress/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from .models import UserCheckpointProgress, UserProgress + + +@admin.register(UserProgress) +class UserProgressAdmin(admin.ModelAdmin): + list_display = ('user', 'resource', 'status', 'progress', 'started_at', 'completed_at') + list_filter = ('status',) + search_fields = ('user__email', 'resource__title') + raw_id_fields = ('user', 'resource') + + +@admin.register(UserCheckpointProgress) +class UserCheckpointProgressAdmin(admin.ModelAdmin): + list_display = ('user', 'checkpoint', 'completed_at') + search_fields = ('user__email', 'checkpoint__title') + raw_id_fields = ('user', 'checkpoint') diff --git a/apps/progress/apps.py b/apps/progress/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..ce363a78155c46033223a40d56d5d1b8feb4492c --- /dev/null +++ b/apps/progress/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProgressConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.progress' diff --git a/apps/progress/migrations/0001_initial.py b/apps/progress/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..292fb622e679bc332ee5716d4c7c7a4b7626f3cd --- /dev/null +++ b/apps/progress/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.4 on 2026-04-20 17:36 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='UserCheckpointProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='UserProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('NOT_STARTED', 'Not Started'), ('IN_PROGRESS', 'In Progress'), ('COMPLETED', 'Completed')], default='NOT_STARTED', max_length=20)), + ('progress', models.IntegerField(default=0, help_text='0-100. Derived from checkpoint rollup when checkpoints exist, else manual slider.', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ], + ), + ] diff --git a/apps/progress/migrations/0002_initial.py b/apps/progress/migrations/0002_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..2a3072b14ff377cd24e0827cfb5f82434da2f5b3 --- /dev/null +++ b/apps/progress/migrations/0002_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 6.0.4 on 2026-04-20 17:36 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('progress', '0001_initial'), + ('resources', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='usercheckpointprogress', + name='checkpoint', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_progress', to='resources.resourcecheckpoint'), + ), + migrations.AddField( + model_name='usercheckpointprogress', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkpoint_progress', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='userprogress', + name='resource', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_progress', to='resources.resource'), + ), + migrations.AddField( + model_name='userprogress', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='resource_progress', to=settings.AUTH_USER_MODEL), + ), + migrations.AddIndex( + model_name='usercheckpointprogress', + index=models.Index(fields=['user', 'checkpoint'], name='progress_us_user_id_8e2d65_idx'), + ), + migrations.AlterUniqueTogether( + name='usercheckpointprogress', + unique_together={('user', 'checkpoint')}, + ), + migrations.AddIndex( + model_name='userprogress', + index=models.Index(fields=['user', 'status'], name='progress_us_user_id_7f94ca_idx'), + ), + migrations.AlterUniqueTogether( + name='userprogress', + unique_together={('user', 'resource')}, + ), + ] diff --git a/apps/progress/migrations/__init__.py b/apps/progress/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/progress/models.py b/apps/progress/models.py new file mode 100644 index 0000000000000000000000000000000000000000..234c6613b64ba023ac8b4b41ca57f6c7130e17d3 --- /dev/null +++ b/apps/progress/models.py @@ -0,0 +1,99 @@ +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.utils import timezone + + +class UserProgress(models.Model): + STATUS_CHOICES = [ + ('NOT_STARTED', 'Not Started'), + ('IN_PROGRESS', 'In Progress'), + ('COMPLETED', 'Completed'), + ] + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='resource_progress', + ) + resource = models.ForeignKey( + 'resources.Resource', + on_delete=models.CASCADE, + related_name='user_progress', + ) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='NOT_STARTED') + progress = models.IntegerField( + default=0, + validators=[MinValueValidator(0), MaxValueValidator(100)], + help_text='0-100. Derived from checkpoint rollup when checkpoints exist, else manual slider.', + ) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = [['user', 'resource']] + indexes = [models.Index(fields=['user', 'status'])] + + def __str__(self): + return f'{self.user.email} - {self.resource.title} ({self.status})' + + def recalculate_from_checkpoints(self, save=True): + """Recompute status + progress from UserCheckpointProgress rollup. + + Returns True when the resource has checkpoints (rollup applied); False + when the resource is manual-slider-only (caller owns status/progress). + """ + checkpoints = list(self.resource.checkpoints.all()) + if not checkpoints: + return False + + total = len(checkpoints) + done = UserCheckpointProgress.objects.filter( + user=self.user, + checkpoint__in=checkpoints, + completed_at__isnull=False, + ).count() + + self.progress = round(done / total * 100) + now = timezone.now() + + if done == 0: + self.status = 'NOT_STARTED' + self.completed_at = None + elif done == total: + self.status = 'COMPLETED' + if not self.started_at: + self.started_at = now + if not self.completed_at: + self.completed_at = now + else: + self.status = 'IN_PROGRESS' + if not self.started_at: + self.started_at = now + self.completed_at = None + + if save: + self.save() + return True + + +class UserCheckpointProgress(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='checkpoint_progress', + ) + checkpoint = models.ForeignKey( + 'resources.ResourceCheckpoint', + on_delete=models.CASCADE, + related_name='user_progress', + ) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = [['user', 'checkpoint']] + indexes = [models.Index(fields=['user', 'checkpoint'])] + + def __str__(self): + state = 'done' if self.completed_at else 'open' + return f'{self.user.email} - cp#{self.checkpoint_id} ({state})' diff --git a/apps/progress/serializers.py b/apps/progress/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..4d9617b94af7432ce029bfb45cc955abc97528c0 --- /dev/null +++ b/apps/progress/serializers.py @@ -0,0 +1,43 @@ +from rest_framework import serializers + +from .models import UserCheckpointProgress, UserProgress + + +class UserProgressSerializer(serializers.ModelSerializer): + resource_title = serializers.CharField(source='resource.title', read_only=True) + has_checkpoints = serializers.SerializerMethodField() + in_current_plan = serializers.SerializerMethodField() + + class Meta: + model = UserProgress + fields = ['id', 'resource', 'resource_title', 'status', 'progress', + 'started_at', 'completed_at', 'has_checkpoints', + 'in_current_plan'] + read_only_fields = ['status', 'started_at', 'completed_at', + 'resource_title', 'has_checkpoints', + 'in_current_plan'] + + def get_has_checkpoints(self, obj) -> bool: + # Prefer the annotated value from the list view's queryset; fall back + # to a direct count on single-object endpoints. + annotated = getattr(obj, 'resource_has_checkpoints', None) + if annotated is not None: + return annotated + # ResourceProgressView (detail) sets no prefetch_related, so this is + # one extra query on a single object — not an N+1, fine off the list. + return len(obj.resource.checkpoints.all()) > 0 + + def get_in_current_plan(self, obj) -> bool: + # Annotated by UserProgressListView.get_queryset. Single-object + # endpoints (ResourceProgressView) don't annotate it; default False — + # the frontend only consumes this flag on the list to disable + # out-of-plan rows, so a conservative False off the detail path is safe. + annotated = getattr(obj, 'in_current_plan_annot', None) + return bool(annotated) if annotated is not None else False + + +class UserCheckpointProgressSerializer(serializers.ModelSerializer): + class Meta: + model = UserCheckpointProgress + fields = ['id', 'checkpoint', 'completed_at'] + read_only_fields = ['completed_at'] diff --git a/apps/progress/services.py b/apps/progress/services.py new file mode 100644 index 0000000000000000000000000000000000000000..1d59d3ad0b703831346167810b22515aa858aec9 --- /dev/null +++ b/apps/progress/services.py @@ -0,0 +1,245 @@ +"""Skill-upgrade suggestion service — closed learning loop (Module B). + +See research/08-next-modules-build-plan.md §Module B. This is a stateless +derivation: suggestions are recomputed on demand from UserProgress + +SkillResource + UserSkill state. The only persisted bit is the dismissal +timestamp on UserSkill.dismissed_upgrade_at. +""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Iterable + +from django.db import transaction +from django.db.models import Max +from django.utils import timezone + +from apps.resources.models import SkillResource +from apps.skills.models import Skill, UserSkill +from apps.skills.utils import proficiency_to_level + +from .models import UserProgress + + +# Proficiency bump ladder — deterministic, documented. +# <40 → 60, 40–59 → 70, 60–79 → 85, 80–99 → 100, 100 → no suggestion. +_BUMPS: list[tuple[int, int]] = [ + (40, 60), + (60, 70), + (80, 85), + (100, 100), +] + + +def _suggested_proficiency(current: int) -> int | None: + if current >= 100: + return None + for upper, target in _BUMPS: + if current < upper: + return target + return None + + +@dataclass +class UpgradeSuggestion: + skill_id: int + skill_name: str + current_proficiency: int + suggested_proficiency: int + current_level: str + suggested_level: str + source_resource_ids: list[int] + + def to_dict(self) -> dict: + return asdict(self) + + +def _completed_progress_by_skill(user) -> dict[int, list[dict]]: + """Return {skill_id: [{resource_id, completed_at}, ...]} from COMPLETED + UserProgress rows whose resources link to skills. + """ + completed = ( + UserProgress.objects + .filter(user=user, status='COMPLETED') + .values_list('resource_id', 'completed_at') + ) + resource_rows = list(completed) + if not resource_rows: + return {} + + resource_ids = [rid for rid, _ in resource_rows] + completed_at_by_res = dict(resource_rows) + + links = SkillResource.objects.filter(resource_id__in=resource_ids) + + by_skill: dict[int, list[dict]] = {} + for link in links: + by_skill.setdefault(link.skill_id, []).append({ + 'resource_id': link.resource_id, + 'completed_at': completed_at_by_res[link.resource_id], + }) + return by_skill + + +def _user_skills_by_id(user, skill_ids: Iterable[int]) -> dict[int, UserSkill]: + return { + us.skill_id: us + for us in UserSkill.objects.filter( + user=user, skill_id__in=list(skill_ids), + ).select_related('skill') + } + + +def compute_upgrade_suggestions(user) -> list[dict]: + """Pending upgrade suggestions for a user. + + One suggestion per skill where: + * user has ≥1 COMPLETED resource linked to the skill + * bump map yields a proficiency > user's current proficiency + * the suggestion is not currently dismissed (or dismissal predates the + most recent completion — self-heal) + """ + completions_by_skill = _completed_progress_by_skill(user) + if not completions_by_skill: + return [] + + skill_ids = list(completions_by_skill.keys()) + user_skills = _user_skills_by_id(user, skill_ids) + skills_by_id = { + s.id: s for s in Skill.objects.filter(id__in=skill_ids) + } + + suggestions: list[UpgradeSuggestion] = [] + + for skill_id, completions in completions_by_skill.items(): + skill = skills_by_id.get(skill_id) + if skill is None: + continue + + us = user_skills.get(skill_id) + current_prof = us.proficiency if us else 0 + target = _suggested_proficiency(current_prof) + if target is None or target <= current_prof: + continue + + if us and us.dismissed_upgrade_at is not None: + most_recent = max( + (c['completed_at'] for c in completions + if c['completed_at'] is not None), + default=None, + ) + if most_recent is None or most_recent <= us.dismissed_upgrade_at: + continue + + source_ids = sorted( + {c['resource_id'] for c in completions}, + ) + suggestions.append(UpgradeSuggestion( + skill_id=skill_id, + skill_name=skill.skill_name, + current_proficiency=current_prof, + suggested_proficiency=target, + current_level=proficiency_to_level(current_prof) if us + else 'BEGINNER', + suggested_level=proficiency_to_level(target), + source_resource_ids=source_ids, + )) + + suggestions.sort(key=lambda s: s.skill_name) + return [s.to_dict() for s in suggestions] + + +@transaction.atomic +def apply_upgrade(user, skill_id: int) -> dict: + """Apply the pending upgrade for the given skill. + + Returns {'applied': bool, 'user_skill': {...}, 'already_applied': bool}. + Idempotent per completion: repeated calls without a new completion return + already_applied=True. After a successful apply, dismissed_upgrade_at is + stamped with the latest completion timestamp so `compute_upgrade_suggestions` + (which already uses this field as a suppression cursor) won't re-surface the + suggestion until a newer completion arrives. + """ + skill = Skill.objects.get(id=skill_id) + + completions = _completed_progress_by_skill(user).get(skill_id, []) + if not completions: + return { + 'applied': False, + 'already_applied': False, + 'reason': 'no_completed_resource', + } + + latest_completion = max( + (c['completed_at'] for c in completions if c['completed_at'] is not None), + default=None, + ) + + us = UserSkill.objects.filter(user=user, skill=skill).first() + current_prof = us.proficiency if us else 0 + + # Suppression gate: if the latest completion is not newer than the last + # apply/dismiss cursor, there's nothing new to act on. + if us and us.dismissed_upgrade_at is not None and latest_completion is not None: + if latest_completion <= us.dismissed_upgrade_at: + return { + 'applied': False, + 'already_applied': True, + 'user_skill': _serialize_user_skill(us), + } + + target = _suggested_proficiency(current_prof) + if target is None or target <= current_prof: + return { + 'applied': False, + 'already_applied': True, + 'user_skill': _serialize_user_skill(us) if us else None, + } + + new_level = proficiency_to_level(target) + cursor = latest_completion or timezone.now() + # update_or_create absorbs the first-apply race: two concurrent applies + # with no prior UserSkill would otherwise collide on unique(user, skill) + # → IntegrityError → 500. The earlier `us` read is kept for the suppression + # gate; this is now the single write path for both create and update. + us, _ = UserSkill.objects.update_or_create( + user=user, skill=skill, + defaults={ + 'proficiency': target, + 'user_level': new_level, + 'dismissed_upgrade_at': cursor, + }, + ) + + return { + 'applied': True, + 'already_applied': False, + 'user_skill': _serialize_user_skill(us), + } + + +@transaction.atomic +def dismiss_upgrade(user, skill_id: int) -> dict: + skill = Skill.objects.get(id=skill_id) + us, _ = UserSkill.objects.get_or_create( + user=user, skill=skill, + defaults={'proficiency': 0, 'user_level': 'BEGINNER'}, + ) + us.dismissed_upgrade_at = timezone.now() + us.save(update_fields=['dismissed_upgrade_at', 'updated_at']) + return { + 'dismissed': True, + 'user_skill': _serialize_user_skill(us), + } + + +def _serialize_user_skill(us: UserSkill | None) -> dict | None: + if us is None: + return None + return { + 'skill_id': us.skill_id, + 'skill_name': us.skill.skill_name, + 'proficiency': us.proficiency, + 'user_level': us.user_level, + 'dismissed_upgrade_at': us.dismissed_upgrade_at, + } diff --git a/apps/progress/tests/__init__.py b/apps/progress/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/progress/tests/test_progress.py b/apps/progress/tests/test_progress.py new file mode 100644 index 0000000000000000000000000000000000000000..627b72deed88e6bc509f878638e7ead1f8c73d93 --- /dev/null +++ b/apps/progress/tests/test_progress.py @@ -0,0 +1,318 @@ +import pytest +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APIClient + +from apps.progress.models import UserCheckpointProgress, UserProgress +from apps.resources.models import Resource, ResourceCheckpoint, SkillResource +from apps.roles.models import Role, RoleSkill, UserTargetRole +from apps.skills.models import Skill + +User = get_user_model() +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def user(): + return User.objects.create_user( + username='p@x.com', email='p@x.com', password='pw', name='P', + ) + + +@pytest.fixture +def auth_client(user): + c = APIClient() + c.force_authenticate(user=user) + return c + + +@pytest.fixture +def skill(): + return Skill.objects.create( + skill_name='Python', category='Programming', difficulty_level='BEGINNER', + ) + + +@pytest.fixture +def target_role(user, skill): + """Active target role + RoleSkill covering `skill`. Required by the + progress endpoints, which gate toggles on the user's current plan.""" + role = Role.objects.create( + role_name='Backend Dev', industry='Tech', is_active=True, + ) + RoleSkill.objects.create( + role=role, skill=skill, + required_level='INTERMEDIATE', weight=1.0, is_mandatory=True, + ) + return UserTargetRole.objects.create(user=user, role=role, is_active=True) + + +@pytest.fixture +def resource_no_cp(skill): + r = Resource.objects.create( + title='Docs', provider='Python.org', + url='https://docs.python.org/3/', + difficulty_level='INTERMEDIATE', duration=0, type='DOCS', + ) + SkillResource.objects.create(skill=skill, resource=r, relevance_score=1.0) + return r + + +@pytest.fixture +def resource_with_cp(skill): + r = Resource.objects.create( + title='ML Course', provider='Coursera', + url='https://coursera.org/ml', + difficulty_level='INTERMEDIATE', duration=3600, type='COURSE', + ) + SkillResource.objects.create(skill=skill, resource=r, relevance_score=1.0) + for i, title in enumerate(['Week 1', 'Week 2', 'Week 3', 'Week 4'], start=1): + ResourceCheckpoint.objects.create( + resource=r, order_index=i, title=title, source='manual', + ) + return r + + +class TestCheckpointRollup: + def test_no_checkpoints_returns_false(self, user, resource_no_cp): + up = UserProgress.objects.create(user=user, resource=resource_no_cp) + assert up.recalculate_from_checkpoints() is False + + def test_zero_completed(self, user, resource_with_cp): + up = UserProgress.objects.create(user=user, resource=resource_with_cp) + up.recalculate_from_checkpoints() + assert up.progress == 0 + assert up.status == 'NOT_STARTED' + assert up.started_at is None + assert up.completed_at is None + + def test_half_completed(self, user, resource_with_cp): + cps = list(resource_with_cp.checkpoints.all()[:2]) + from django.utils import timezone + for cp in cps: + UserCheckpointProgress.objects.create( + user=user, checkpoint=cp, completed_at=timezone.now(), + ) + up = UserProgress.objects.create(user=user, resource=resource_with_cp) + up.recalculate_from_checkpoints() + assert up.progress == 50 + assert up.status == 'IN_PROGRESS' + assert up.started_at is not None + assert up.completed_at is None + + def test_all_completed_sets_completed(self, user, resource_with_cp): + from django.utils import timezone + for cp in resource_with_cp.checkpoints.all(): + UserCheckpointProgress.objects.create( + user=user, checkpoint=cp, completed_at=timezone.now(), + ) + up = UserProgress.objects.create(user=user, resource=resource_with_cp) + up.recalculate_from_checkpoints() + assert up.progress == 100 + assert up.status == 'COMPLETED' + assert up.started_at is not None + assert up.completed_at is not None + + +class TestCheckpointToggleAPI: + def test_toggle_creates_and_flips(self, auth_client, target_role, resource_with_cp): + cp = resource_with_cp.checkpoints.first() + r1 = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/') + assert r1.status_code == status.HTTP_200_OK + assert r1.data['status'] == 'IN_PROGRESS' + assert r1.data['progress'] == 25 # 1/4 + + r2 = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/') + assert r2.data['status'] == 'NOT_STARTED' + assert r2.data['progress'] == 0 + + def test_toggle_explicit_completed_flag(self, auth_client, target_role, resource_with_cp): + cp = resource_with_cp.checkpoints.first() + r = auth_client.post( + f'/api/progress/checkpoint/{cp.id}/toggle/', + {'completed': True}, format='json', + ) + assert r.data['progress'] == 25 + + r2 = auth_client.post( + f'/api/progress/checkpoint/{cp.id}/toggle/', + {'completed': True}, format='json', + ) + # Idempotent when already completed. + assert r2.data['progress'] == 25 + + def test_all_toggles_complete_resource(self, auth_client, target_role, resource_with_cp): + for cp in resource_with_cp.checkpoints.all(): + r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/') + assert r.data['status'] == 'COMPLETED' + assert r.data['progress'] == 100 + assert r.data['completed_at'] is not None + + def test_toggle_reuses_preexisting_progress_row( + self, auth_client, user, target_role, resource_with_cp, + ): + """F47-real: a pre-existing UserProgress row is reused (get_or_create + graceful path), not collided with. Mirrors the post-race state where a + concurrent first-toggle already won the unique(user, resource) insert.""" + UserProgress.objects.create(user=user, resource=resource_with_cp) + cp = resource_with_cp.checkpoints.first() + r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/') + assert r.status_code == status.HTTP_200_OK + assert r.data['progress'] == 25 + assert UserProgress.objects.filter( + user=user, resource=resource_with_cp, + ).count() == 1 + + def test_toggle_blocked_without_active_target_role(self, auth_client, resource_with_cp): + """No active target role → 403. Users must commit to a plan before + tracking progress (prevents arbitrary progress on any resource).""" + cp = resource_with_cp.checkpoints.first() + r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/') + assert r.status_code == status.HTTP_403_FORBIDDEN + + def test_toggle_blocked_when_resource_outside_target_plan( + self, auth_client, user, resource_with_cp, + ): + """If the user has selected a target role, checkpoints on resources + unrelated to that role's skills must not be toggleable — prevents + fake progress on out-of-plan resources.""" + unrelated_skill = Skill.objects.create( + skill_name='Rust', category='Programming', difficulty_level='BEGINNER', + ) + role = Role.objects.create(role_name='Rust Dev', industry='Tech', is_active=True) + RoleSkill.objects.create( + role=role, skill=unrelated_skill, + required_level='INTERMEDIATE', weight=1.0, is_mandatory=True, + ) + UserTargetRole.objects.create(user=user, role=role, is_active=True) + + cp = resource_with_cp.checkpoints.first() + r = auth_client.post(f'/api/progress/checkpoint/{cp.id}/toggle/') + assert r.status_code == status.HTTP_403_FORBIDDEN + + +class TestManualSliderAPI: + def test_set_manual_progress(self, auth_client, target_role, resource_no_cp): + r = auth_client.post( + f'/api/progress/resource/{resource_no_cp.id}/', + {'progress': 50}, format='json', + ) + assert r.status_code == status.HTTP_200_OK + assert r.data['progress'] == 50 + assert r.data['status'] == 'IN_PROGRESS' + + def test_manual_100_marks_completed(self, auth_client, target_role, resource_no_cp): + r = auth_client.post( + f'/api/progress/resource/{resource_no_cp.id}/', + {'progress': 100}, format='json', + ) + assert r.data['status'] == 'COMPLETED' + assert r.data['completed_at'] is not None + + def test_manual_zero_resets(self, auth_client, target_role, resource_no_cp): + auth_client.post(f'/api/progress/resource/{resource_no_cp.id}/', + {'progress': 50}, format='json') + r = auth_client.post(f'/api/progress/resource/{resource_no_cp.id}/', + {'progress': 0}, format='json') + assert r.data['status'] == 'NOT_STARTED' + assert r.data['completed_at'] is None + + def test_manual_rejected_when_checkpoints_exist(self, auth_client, target_role, resource_with_cp): + r = auth_client.post( + f'/api/progress/resource/{resource_with_cp.id}/', + {'progress': 50}, format='json', + ) + assert r.status_code == status.HTTP_400_BAD_REQUEST + + def test_manual_out_of_range(self, auth_client, target_role, resource_no_cp): + r = auth_client.post( + f'/api/progress/resource/{resource_no_cp.id}/', + {'progress': 200}, format='json', + ) + assert r.status_code == status.HTTP_400_BAD_REQUEST + + def test_manual_missing_field(self, auth_client, target_role, resource_no_cp): + r = auth_client.post( + f'/api/progress/resource/{resource_no_cp.id}/', + {}, format='json', + ) + assert r.status_code == status.HTTP_400_BAD_REQUEST + + def test_manual_blocked_without_active_target_role(self, auth_client, resource_no_cp): + r = auth_client.post( + f'/api/progress/resource/{resource_no_cp.id}/', + {'progress': 50}, format='json', + ) + assert r.status_code == status.HTTP_403_FORBIDDEN + + def test_get_returns_zero_without_creating_row(self, auth_client, resource_no_cp): + # F20: GET is read-only — it must synthesize a zero-progress response + # WITHOUT writing a UserProgress row. + r = auth_client.get(f'/api/progress/resource/{resource_no_cp.id}/') + assert r.status_code == status.HTTP_200_OK + assert r.data['progress'] == 0 + assert r.data['status'] == 'NOT_STARTED' + assert r.data['id'] is None + assert UserProgress.objects.count() == 0 + + def test_get_returns_existing_progress(self, auth_client, user, resource_no_cp): + # F20: a saved value must survive a GET (regression guard for the + # read-then-synthesize change). + UserProgress.objects.create( + user=user, resource=resource_no_cp, progress=42, status='IN_PROGRESS', + ) + r = auth_client.get(f'/api/progress/resource/{resource_no_cp.id}/') + assert r.status_code == status.HTTP_200_OK + assert r.data['progress'] == 42 + assert r.data['status'] == 'IN_PROGRESS' + assert UserProgress.objects.count() == 1 + + +class TestProgressListAPI: + def test_list_filters_to_current_user(self, auth_client, user, resource_no_cp, resource_with_cp): + other = User.objects.create_user( + username='o@x.com', email='o@x.com', password='pw', name='O', + ) + UserProgress.objects.create(user=user, resource=resource_no_cp, progress=20, status='IN_PROGRESS') + UserProgress.objects.create(user=other, resource=resource_with_cp, progress=0) + + r = auth_client.get('/api/progress/') + assert r.status_code == status.HTTP_200_OK + assert len(r.data) == 1 + assert r.data[0]['resource'] == resource_no_cp.id + assert r.data[0]['has_checkpoints'] is False + + def test_in_current_plan_true_for_in_plan_resource( + self, auth_client, user, target_role, resource_no_cp, + ): + # F48: resource_no_cp is linked to `skill`, which target_role requires. + UserProgress.objects.create(user=user, resource=resource_no_cp, progress=10) + r = auth_client.get('/api/progress/') + assert len(r.data) == 1 + assert r.data[0]['in_current_plan'] is True + + def test_in_current_plan_false_without_active_target( + self, auth_client, user, resource_no_cp, + ): + # F48: no active target role → flagged out-of-plan, but still listed. + UserProgress.objects.create(user=user, resource=resource_no_cp, progress=10) + r = auth_client.get('/api/progress/') + assert len(r.data) == 1 + assert r.data[0]['in_current_plan'] is False + + def test_in_current_plan_annotates_not_filters( + self, auth_client, user, target_role, resource_no_cp, + ): + # F48: an out-of-plan tracked resource is flagged False but NOT removed + # from the list (annotate-don't-filter — the e2e flow relies on this). + other = Resource.objects.create( + title='Unrelated', provider='X', url='https://x.test/unrelated', + difficulty_level='BEGINNER', duration=10, type='ARTICLE', + ) + UserProgress.objects.create(user=user, resource=resource_no_cp, progress=10) + UserProgress.objects.create(user=user, resource=other, progress=5) + r = auth_client.get('/api/progress/') + assert len(r.data) == 2 + by_res = {row['resource']: row for row in r.data} + assert by_res[resource_no_cp.id]['in_current_plan'] is True + assert by_res[other.id]['in_current_plan'] is False diff --git a/apps/progress/tests/test_skill_upgrade.py b/apps/progress/tests/test_skill_upgrade.py new file mode 100644 index 0000000000000000000000000000000000000000..f4e249805b50a8f2dbf7c10229a7f74fd205ec33 --- /dev/null +++ b/apps/progress/tests/test_skill_upgrade.py @@ -0,0 +1,350 @@ +"""Unit tests for the skill-upgrade / closed-learning-loop service. + +See research/08-next-modules-build-plan.md §Module B. +""" +import pytest +from django.contrib.auth import get_user_model +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.progress.models import UserProgress +from apps.progress.services import ( + _suggested_proficiency, + apply_upgrade, + compute_upgrade_suggestions, + dismiss_upgrade, +) +from apps.resources.models import Resource, SkillResource +from apps.skills.models import Skill, UserSkill + +User = get_user_model() +pytestmark = pytest.mark.django_db + + +UPGRADE_LIST = '/api/progress/upgrade-suggestions/' + + +@pytest.fixture +def user(): + return User.objects.create_user( + username='upg@x.com', email='upg@x.com', password='pw', name='U', + ) + + +@pytest.fixture +def auth_client(user): + c = APIClient() + c.force_authenticate(user=user) + return c + + +def _skill(name='Python', category='Programming'): + return Skill.objects.create( + skill_name=name, category=category, difficulty_level='BEGINNER', + ) + + +def _resource(title='ML Course', r_type='COURSE', url=None): + return Resource.objects.create( + title=title, provider='X', url=url or f'https://e.com/{title}', + difficulty_level='INTERMEDIATE', duration=100, type=r_type, rating=4.0, + ) + + +def _complete(user, resource, when=None): + up = UserProgress.objects.create( + user=user, resource=resource, + status='COMPLETED', + progress=100, + completed_at=when or timezone.now(), + ) + return up + + +def _set_skill(user, skill, proficiency, level='BEGINNER', + dismissed_upgrade_at=None): + return UserSkill.objects.create( + user=user, skill=skill, proficiency=proficiency, user_level=level, + dismissed_upgrade_at=dismissed_upgrade_at, + ) + + +class TestBumpMap: + def test_zero_to_60(self): + assert _suggested_proficiency(0) == 60 + + def test_30_to_60(self): + assert _suggested_proficiency(30) == 60 + + def test_50_to_70(self): + assert _suggested_proficiency(50) == 70 + + def test_70_to_85(self): + assert _suggested_proficiency(70) == 85 + + def test_90_to_100(self): + assert _suggested_proficiency(90) == 100 + + def test_100_suppressed(self): + assert _suggested_proficiency(100) is None + + +class TestComputeSuggestions: + def test_completed_resource_creates_suggestion_when_user_skill_absent( + self, user): + ml = _skill('Machine Learning') + r = _resource() + SkillResource.objects.create(skill=ml, resource=r, relevance_score=1.0) + _complete(user, r) + + suggestions = compute_upgrade_suggestions(user) + assert len(suggestions) == 1 + s = suggestions[0] + assert s['skill_id'] == ml.id + assert s['current_proficiency'] == 0 + assert s['suggested_proficiency'] == 60 + assert s['suggested_level'] == 'INTERMEDIATE' + + def test_only_completed_status_triggers(self, user): + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + UserProgress.objects.create( + user=user, resource=r, status='IN_PROGRESS', progress=50, + ) + assert compute_upgrade_suggestions(user) == [] + + def test_suggestion_suppressed_at_100(self, user): + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + _set_skill(user, py, proficiency=100, level='ADVANCED') + _complete(user, r) + assert compute_upgrade_suggestions(user) == [] + + def test_never_downgrade(self, user): + """User at 85 → bump map suggests 100. But if current >= target, + suppress. (Table avoids downgrade by design; we also verify the + never-downgrade guard.)""" + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + _set_skill(user, py, proficiency=95, level='ADVANCED') + _complete(user, r) + suggestions = compute_upgrade_suggestions(user) + assert len(suggestions) == 1 + assert suggestions[0]['suggested_proficiency'] == 100 + + def test_multiple_skills_linked_to_one_resource(self, user): + py = _skill('Python') + ml = _skill('Machine Learning') + r = _resource() + SkillResource.objects.create(skill=py, resource=r, relevance_score=0.7) + SkillResource.objects.create(skill=ml, resource=r, relevance_score=1.0) + _complete(user, r) + suggestions = compute_upgrade_suggestions(user) + assert {s['skill_id'] for s in suggestions} == {py.id, ml.id} + + def test_dismissal_hides_suggestion(self, user): + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + # Completion first, then dismissal AFTER → suppresses. + _complete(user, r, when=timezone.now() - timezone.timedelta(days=2)) + _set_skill(user, py, proficiency=0, + dismissed_upgrade_at=timezone.now()) + assert compute_upgrade_suggestions(user) == [] + + def test_dismissal_self_heals_on_newer_completion(self, user): + """Dismissing a prior suggestion does not block a later completion + from resurfacing the suggestion.""" + py = _skill('Python') + r1 = _resource(title='First', url='https://e.com/first') + r2 = _resource(title='Second', url='https://e.com/second') + SkillResource.objects.create(skill=py, resource=r1) + SkillResource.objects.create(skill=py, resource=r2) + + earlier = timezone.now() - timezone.timedelta(days=5) + _complete(user, r1, when=earlier) + _set_skill(user, py, proficiency=0, + dismissed_upgrade_at=timezone.now() + - timezone.timedelta(days=2)) + + # A NEW completion post-dismissal re-surfaces the suggestion. + _complete(user, r2, when=timezone.now()) + + suggestions = compute_upgrade_suggestions(user) + assert len(suggestions) == 1 + assert suggestions[0]['skill_id'] == py.id + + +class TestApply: + def test_apply_creates_user_skill_if_absent(self, user): + ml = _skill('ML') + r = _resource() + SkillResource.objects.create(skill=ml, resource=r) + _complete(user, r) + + result = apply_upgrade(user, ml.id) + assert result['applied'] is True + us = UserSkill.objects.get(user=user, skill=ml) + assert us.proficiency == 60 + assert us.user_level == 'INTERMEDIATE' + + def test_apply_bumps_proficiency_and_recomputes_level(self, user): + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + _set_skill(user, py, proficiency=70, level='ADVANCED') + _complete(user, r) + + result = apply_upgrade(user, py.id) + assert result['applied'] is True + us = UserSkill.objects.get(user=user, skill=py) + assert us.proficiency == 85 + # proficiency_to_level(85) → ADVANCED (since > 60) + assert us.user_level == 'ADVANCED' + + def test_apply_is_idempotent(self, user): + """Repeated applies without a new completion must be no-ops. + One completion → one rung of the bump ladder, then already_applied. + """ + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + _complete(user, r) + + first = apply_upgrade(user, py.id) + assert first['applied'] is True + + second = apply_upgrade(user, py.id) + assert second['applied'] is False + assert second['already_applied'] is True + + us = UserSkill.objects.get(user=user, skill=py) + assert us.proficiency == 60 # did not climb past one rung + + def test_apply_resurfaces_on_new_completion(self, user): + """A completion after the last apply unlocks the next bump.""" + py = _skill('Python') + r1 = _resource(title='First', url='https://e.com/first') + r2 = _resource(title='Second', url='https://e.com/second') + SkillResource.objects.create(skill=py, resource=r1) + SkillResource.objects.create(skill=py, resource=r2) + + _complete(user, r1, when=timezone.now() - timezone.timedelta(days=2)) + first = apply_upgrade(user, py.id) + assert first['applied'] is True + assert UserSkill.objects.get(user=user, skill=py).proficiency == 60 + + _complete(user, r2, when=timezone.now()) # newer than the cursor + second = apply_upgrade(user, py.id) + assert second['applied'] is True + assert UserSkill.objects.get(user=user, skill=py).proficiency == 85 + + def test_apply_when_no_completion_is_a_noop(self, user): + py = _skill('Python') + result = apply_upgrade(user, py.id) + assert result['applied'] is False + assert result['already_applied'] is False + + def test_apply_when_already_at_100_is_already_applied(self, user): + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + _set_skill(user, py, proficiency=100, level='ADVANCED') + _complete(user, r) + + result = apply_upgrade(user, py.id) + assert result['applied'] is False + assert result['already_applied'] is True + + def test_apply_advances_dismissal_cursor(self, user): + """After an apply, dismissed_upgrade_at is advanced to the latest + completion timestamp. Semantically this means the suggestion is + suppressed until a newer completion arrives — matching the dismissed + flow. A prior user-triggered dismissal must not block an apply that is + acting on a newer completion.""" + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + earlier = timezone.now() - timezone.timedelta(days=2) + _set_skill(user, py, proficiency=0, dismissed_upgrade_at=earlier) + completion_at = timezone.now() + _complete(user, r, when=completion_at) + + result = apply_upgrade(user, py.id) + assert result['applied'] is True + us = UserSkill.objects.get(user=user, skill=py) + assert us.dismissed_upgrade_at == completion_at + + # Functional check: the applied suggestion doesn't resurface on a + # re-compute without a new completion. + assert compute_upgrade_suggestions(user) == [] + + +class TestDismiss: + def test_dismiss_sets_timestamp_and_hides(self, user): + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + _complete(user, r) + _set_skill(user, py, proficiency=0) + + result = dismiss_upgrade(user, py.id) + assert result['dismissed'] is True + us = UserSkill.objects.get(user=user, skill=py) + assert us.dismissed_upgrade_at is not None + assert compute_upgrade_suggestions(user) == [] + + def test_dismiss_creates_user_skill_if_absent(self, user): + py = _skill('Python') + dismiss_upgrade(user, py.id) + us = UserSkill.objects.get(user=user, skill=py) + assert us.proficiency == 0 + assert us.dismissed_upgrade_at is not None + + +class TestEndpoints: + def test_list_endpoint(self, user, auth_client): + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + _complete(user, r) + + r_resp = auth_client.get(UPGRADE_LIST) + assert r_resp.status_code == 200 + assert r_resp.data['suggestions'][0]['skill_id'] == py.id + + def test_apply_endpoint(self, user, auth_client): + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + _complete(user, r) + + r_resp = auth_client.post(f'{UPGRADE_LIST}{py.id}/apply/') + assert r_resp.status_code == 200 + assert r_resp.data['applied'] is True + assert UserSkill.objects.get(user=user, skill=py).proficiency == 60 + + def test_dismiss_endpoint(self, user, auth_client): + py = _skill('Python') + r = _resource() + SkillResource.objects.create(skill=py, resource=r) + _complete(user, r) + + r_resp = auth_client.post(f'{UPGRADE_LIST}{py.id}/dismiss/') + assert r_resp.status_code == 200 + assert r_resp.data['dismissed'] is True + + # List should now be empty. + r_resp = auth_client.get(UPGRADE_LIST) + assert r_resp.data['suggestions'] == [] + + def test_apply_unknown_skill_404(self, auth_client): + r_resp = auth_client.post(f'{UPGRADE_LIST}99999/apply/') + assert r_resp.status_code == 404 + + def test_requires_auth(self): + c = APIClient() + assert c.get(UPGRADE_LIST).status_code == 401 diff --git a/apps/progress/urls.py b/apps/progress/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..1da88ea6bc720e5e3523776a0d61899014d916f1 --- /dev/null +++ b/apps/progress/urls.py @@ -0,0 +1,26 @@ +from django.urls import path + +from .views import ( + CheckpointToggleView, + ResourceProgressView, + UpgradeSuggestionApplyView, + UpgradeSuggestionDismissView, + UpgradeSuggestionListView, + UserProgressListView, +) + +urlpatterns = [ + path('', UserProgressListView.as_view(), name='progress-list'), + path('resource//', ResourceProgressView.as_view(), + name='progress-resource'), + path('checkpoint//toggle/', CheckpointToggleView.as_view(), + name='progress-checkpoint-toggle'), + path('upgrade-suggestions/', UpgradeSuggestionListView.as_view(), + name='upgrade-suggestion-list'), + path('upgrade-suggestions//apply/', + UpgradeSuggestionApplyView.as_view(), + name='upgrade-suggestion-apply'), + path('upgrade-suggestions//dismiss/', + UpgradeSuggestionDismissView.as_view(), + name='upgrade-suggestion-dismiss'), +] diff --git a/apps/progress/views.py b/apps/progress/views.py new file mode 100644 index 0000000000000000000000000000000000000000..6326366231a3ee08a55c446813198fbbcfecd5c6 --- /dev/null +++ b/apps/progress/views.py @@ -0,0 +1,303 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer +from django.db import transaction +from django.db.models import Exists, OuterRef +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import generics, permissions, serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.resources.models import Resource, ResourceCheckpoint, SkillResource +from apps.roles.models import UserTargetRole +from apps.skills.models import Skill + +from .models import UserCheckpointProgress, UserProgress +from .serializers import UserProgressSerializer +from .services import ( + apply_upgrade, + compute_upgrade_suggestions, + dismiss_upgrade, +) + + +class UserProgressListView(generics.ListAPIView): + # Bounded per-user (≤ few dozen tracked resources). Intentionally + # unpaginated so the frontend can render the full learning plan in one + # pass. Revisit if a user ever tracks hundreds of resources. + serializer_class = UserProgressSerializer + permission_classes = [permissions.IsAuthenticated] + pagination_class = None + + def get_queryset(self): + # Annotate `resource_has_checkpoints` as a single Exists subquery so + # UserProgressSerializer can read it without per-row queries. + # + # Also annotate `in_current_plan_annot`: whether each tracked resource + # is linked to a skill required by the user's *active* target role. + # Computed against a Python list of the active role's skill ids to + # avoid a fragile nested OuterRef; no active target → empty list → + # __in=[] → all False (matches the progress-write 403 gate). Annotate, + # NEVER filter — the frontend renders out-of-plan rows as read-only and + # the e2e flow asserts the full tracked-resource count. + active_target = ( + UserTargetRole.objects + .filter(user=self.request.user, is_active=True) + .select_related('role') + .first() + ) + target_skill_ids = ( + list(active_target.role.role_skills.values_list('skill_id', flat=True)) + if active_target else [] + ) + return ( + UserProgress.objects + .filter(user=self.request.user) + .select_related('resource') + .annotate( + resource_has_checkpoints=Exists( + ResourceCheckpoint.objects.filter( + resource=OuterRef('resource_id'), + ) + ), + in_current_plan_annot=Exists( + SkillResource.objects.filter( + resource=OuterRef('resource_id'), + skill_id__in=target_skill_ids, + ) + ), + ) + ) + + +class ResourceProgressView(APIView): + """Per-resource progress. Supports manual-slider mode when the resource + has no checkpoints — otherwise the progress int is read-only and driven + by the checkpoint rollup. + """ + serializer_class = UserProgressSerializer + permission_classes = [permissions.IsAuthenticated] + + @extend_schema(responses=UserProgressSerializer) + def get(self, request, resource_id): + resource = get_object_or_404(Resource, id=resource_id) + # Read-only: never write on GET. A get_or_create here pollutes the + # progress table on mere reads and races with concurrent requests. + # Synthesize an unsaved zero-progress instance when none exists — its + # id serializes to None and has_checkpoints falls back to the resource. + progress = ( + UserProgress.objects + .filter(user=request.user, resource=resource) + .first() + or UserProgress(user=request.user, resource=resource) + ) + return Response(UserProgressSerializer(progress).data) + + @extend_schema( + request=inline_serializer( + name='ResourceProgressUpdate', + fields={ + 'progress': serializers.IntegerField(min_value=0, max_value=100), + }, + ), + responses=UserProgressSerializer, + ) + @transaction.atomic + def post(self, request, resource_id): + resource = get_object_or_404(Resource, id=resource_id) + + active_target = ( + UserTargetRole.objects + .filter(user=request.user, is_active=True) + .select_related('role') + .first() + ) + if active_target is None: + return Response( + {'detail': 'Select an active target role before tracking progress.'}, + status=status.HTTP_403_FORBIDDEN, + ) + target_skill_ids = active_target.role.role_skills.values_list('skill_id', flat=True) + if not resource.skillresource_set.filter(skill_id__in=target_skill_ids).exists(): + return Response( + {'detail': 'Resource is not part of your current learning plan.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + progress, _ = UserProgress.objects.get_or_create( + user=request.user, resource=resource, + ) + + if resource.checkpoints.exists(): + return Response( + {'detail': 'Resource has checkpoints; update per-checkpoint progress instead.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + new_progress = request.data.get('progress') + if new_progress is None: + return Response( + {'detail': 'progress (0-100) is required.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + new_progress = int(new_progress) + except (TypeError, ValueError): + return Response( + {'detail': 'progress must be an integer 0-100.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not 0 <= new_progress <= 100: + return Response( + {'detail': 'progress must be in range 0-100.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + now = timezone.now() + progress.progress = new_progress + if new_progress == 0: + progress.status = 'NOT_STARTED' + progress.completed_at = None + elif new_progress == 100: + progress.status = 'COMPLETED' + if not progress.started_at: + progress.started_at = now + if not progress.completed_at: + progress.completed_at = now + else: + progress.status = 'IN_PROGRESS' + if not progress.started_at: + progress.started_at = now + progress.completed_at = None + progress.save() + return Response(UserProgressSerializer(progress).data) + + +@extend_schema(responses=OpenApiTypes.OBJECT) +class UpgradeSuggestionListView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + suggestions = compute_upgrade_suggestions(request.user) + return Response({'suggestions': suggestions}) + + +@extend_schema( + request=None, + responses={ + 200: OpenApiTypes.OBJECT, + 404: OpenApiResponse(description='Skill not found.'), + }, +) +class UpgradeSuggestionApplyView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request, skill_id): + try: + Skill.objects.get(id=skill_id) + except Skill.DoesNotExist: + return Response( + {'detail': 'Skill not found.'}, + status=status.HTTP_404_NOT_FOUND, + ) + result = apply_upgrade(request.user, skill_id) + return Response(result) + + +@extend_schema( + request=None, + responses={ + 200: OpenApiTypes.OBJECT, + 404: OpenApiResponse(description='Skill not found.'), + }, +) +class UpgradeSuggestionDismissView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request, skill_id): + try: + Skill.objects.get(id=skill_id) + except Skill.DoesNotExist: + return Response( + {'detail': 'Skill not found.'}, + status=status.HTTP_404_NOT_FOUND, + ) + result = dismiss_upgrade(request.user, skill_id) + return Response(result) + + +@extend_schema( + request=inline_serializer( + name='CheckpointToggleRequest', + fields={ + 'completed': serializers.BooleanField( + required=False, + help_text='Explicit target state. Omit to toggle current state.', + ), + }, + ), + responses=UserProgressSerializer, +) +class CheckpointToggleView(APIView): + serializer_class = UserProgressSerializer + permission_classes = [permissions.IsAuthenticated] + + @transaction.atomic + def post(self, request, checkpoint_id): + checkpoint = get_object_or_404( + ResourceCheckpoint.objects.select_related('resource'), + id=checkpoint_id, + ) + + # Scope toggles to resources linked to the user's active target-role + # skills. Without this, any authenticated user could fake progress on + # arbitrary resources. An active target role is required — without one, + # there is no learning plan to attribute progress to. + active_target = ( + UserTargetRole.objects + .filter(user=request.user, is_active=True) + .select_related('role') + .first() + ) + if active_target is None: + return Response( + {'detail': 'Select an active target role before tracking progress.'}, + status=status.HTTP_403_FORBIDDEN, + ) + target_skill_ids = active_target.role.role_skills.values_list('skill_id', flat=True) + resource_in_plan = checkpoint.resource.skillresource_set.filter( + skill_id__in=target_skill_ids, + ).exists() + if not resource_in_plan: + return Response( + {'detail': 'Resource is not part of your current learning plan.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Materialize the UserProgress row race-safely, THEN lock it. A bare + # select_for_update().first() locks nothing when no row exists yet, so + # two concurrent first-toggles both create → IntegrityError on + # unique_together(user, resource) → 500. get_or_create (savepoint-safe + # inside this atomic) absorbs that race; the re-fetch with + # select_for_update then takes the row lock for the sibling rollup so + # concurrent toggles on sibling checkpoints see a consistent count. + UserProgress.objects.get_or_create( + user=request.user, resource=checkpoint.resource, + ) + progress = UserProgress.objects.select_for_update().get( + user=request.user, resource=checkpoint.resource, + ) + + cp_progress, _ = UserCheckpointProgress.objects.get_or_create( + user=request.user, checkpoint=checkpoint, + ) + desired = request.data.get('completed') + + if desired is None: + cp_progress.completed_at = None if cp_progress.completed_at else timezone.now() + else: + cp_progress.completed_at = timezone.now() if desired else None + cp_progress.save() + + progress.recalculate_from_checkpoints() + return Response(UserProgressSerializer(progress).data) diff --git a/apps/resources/__init__.py b/apps/resources/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/resources/admin.py b/apps/resources/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..7baf6f9bbcd50bfb6de145dc4f16cd58e6c6eaea --- /dev/null +++ b/apps/resources/admin.py @@ -0,0 +1,109 @@ +from django import forms +from django.contrib import admin, messages +from django.db import transaction +from django.db.models import Max + +from .models import Resource, ResourceCheckpoint, SkillResource + + +class SkillResourceInline(admin.TabularInline): + model = SkillResource + extra = 1 + autocomplete_fields = ['skill'] + + +class ResourceCheckpointInline(admin.TabularInline): + model = ResourceCheckpoint + extra = 0 + fields = ('order_index', 'title', 'url_fragment', 'estimated_minutes', 'source') + ordering = ('order_index',) + + +class ResourceAdminForm(forms.ModelForm): + bulk_checkpoints = forms.CharField( + required=False, + widget=forms.Textarea(attrs={'rows': 12, 'cols': 80}), + label='Bulk add checkpoints', + help_text=( + 'Paste module/lesson titles, one per line (source=manual). ' + 'Only applied when this resource has NO checkpoints yet; if it ' + 'already has any, the paste is ignored — edit the inline rows ' + 'below instead. Blank lines ignored.' + ), + ) + + class Meta: + model = Resource + exclude = ['skills'] + + +@admin.register(Resource) +class ResourceAdmin(admin.ModelAdmin): + form = ResourceAdminForm + list_display = ('title', 'provider', 'type', 'difficulty_level', 'duration', 'rating') + list_filter = ('provider', 'type', 'difficulty_level') + search_fields = ('title', 'provider', 'url') + inlines = [SkillResourceInline, ResourceCheckpointInline] + fieldsets = ( + (None, { + 'fields': ('title', 'provider', 'url', 'type', 'difficulty_level', + 'duration', 'rating'), + }), + ('Bulk checkpoint paste', { + 'classes': ('collapse',), + 'fields': ('bulk_checkpoints',), + 'description': 'Optional shortcut for adding multiple checkpoints at once.', + }), + ) + + def save_model(self, request, obj, form, change): + bulk = (form.cleaned_data.get('bulk_checkpoints') or '').strip() + lines = [line.strip() for line in bulk.splitlines() if line.strip()] if bulk else [] + + with transaction.atomic(): + super().save_model(request, obj, form, change) + if not lines: + return + # Refuse to append when checkpoints already exist — the admin + # should use the inline to edit existing rows. This prevents + # accidental duplication and silent orphaning of + # UserCheckpointProgress rows when the admin re-edits the list. + if obj.checkpoints.exists(): + self.message_user( + request, + "Bulk paste ignored: this resource already has checkpoints. " + "Edit them via the inline below instead.", + level=messages.WARNING, + ) + return + current_max = obj.checkpoints.aggregate(m=Max('order_index'))['m'] or 0 + ResourceCheckpoint.objects.bulk_create([ + ResourceCheckpoint( + resource=obj, + order_index=current_max + offset, + title=line, + source='manual', + ) + for offset, line in enumerate(lines, start=1) + ]) + self.message_user( + request, + f"Added {len(lines)} checkpoint(s) via bulk paste.", + level=messages.SUCCESS, + ) + + +@admin.register(ResourceCheckpoint) +class ResourceCheckpointAdmin(admin.ModelAdmin): + list_display = ('resource', 'order_index', 'title', 'source', 'estimated_minutes') + list_filter = ('source', 'resource__provider') + search_fields = ('title', 'resource__title') + ordering = ('resource', 'order_index') + + +@admin.register(SkillResource) +class SkillResourceAdmin(admin.ModelAdmin): + list_display = ('skill', 'resource', 'relevance_score') + list_filter = ('skill__category',) + search_fields = ('skill__skill_name', 'resource__title') + autocomplete_fields = ['skill', 'resource'] diff --git a/apps/resources/apps.py b/apps/resources/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..2306692761ec530e4f2c8b775fd4eabdf5f010ab --- /dev/null +++ b/apps/resources/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ResourcesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.resources' diff --git a/apps/resources/importers/__init__.py b/apps/resources/importers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ae5d59165c238cc776d1225fd305647903917970 --- /dev/null +++ b/apps/resources/importers/__init__.py @@ -0,0 +1,65 @@ +"""Course-outline extractor — admin-triggered, no headless browser. + +`extract_checkpoints(url)` dispatches by URL host and always returns an +`ExtractResult` (empty + note on any failure / unsupported provider — it never +raises). Coverage today: + - youtube.com / youtu.be -> Data API chapter markers (needs YOUTUBE_API_KEY) + - everything else -> fetch page, schema.org/Course JSON-LD, then a + heading fallback (Coursera + DataCamp verified) +SPA pages with neither (freecodecamp.org learn pages, edX marketing URLs, +Udemy) come back empty + note -> the admin enters checkpoints manually. +""" +from __future__ import annotations + +from . import jsonld as _jsonld +from . import youtube as _youtube +from .base import ExtractedCheckpoint, ExtractResult, TITLE_MAX, fetch, host_of + +__all__ = ["extract_checkpoints", "ExtractResult", "ExtractedCheckpoint"] + + +def _finalize(titles: list[str], *, source: str, provider: str, note: str = "") -> ExtractResult: + """Normalize raw titles -> ordered, de-duplicated, length-clamped checkpoints.""" + seen: set[str] = set() + checkpoints: list[ExtractedCheckpoint] = [] + for title in titles: + clean = " ".join((title or "").split())[:TITLE_MAX] + if not clean or clean in seen: + continue + seen.add(clean) + checkpoints.append( + ExtractedCheckpoint(order_index=len(checkpoints) + 1, title=clean) + ) + return ExtractResult( + checkpoints=checkpoints, source=source, provider=provider, note=note + ) + + +def extract_checkpoints(url: str, *, timeout: int = 15) -> ExtractResult: + if not url or not isinstance(url, str): + return ExtractResult(note="No URL provided.") + host = host_of(url) + if not host: + return ExtractResult(note="Unrecognized URL.") + + if "youtube.com" in host or "youtu.be" in host: + titles, note = _youtube.extract(url, timeout=timeout) + return _finalize(titles, source="youtube_api", provider="youtube", note=note) + + html = fetch(url, timeout=timeout) + if html is None: + return ExtractResult( + provider=host, + note="Could not fetch the page (blocked, offline, or timeout) — " + "enter checkpoints manually.", + ) + titles = _jsonld.parse_jsonld(html) + if titles: + return _finalize(titles, source="jsonld", provider=host) + titles = _jsonld.parse_html_headings(html) + if titles: + return _finalize(titles, source="html", provider=host) + return ExtractResult( + provider=host, + note="No course outline found on the page — enter checkpoints manually.", + ) diff --git a/apps/resources/importers/base.py b/apps/resources/importers/base.py new file mode 100644 index 0000000000000000000000000000000000000000..fb0911abcd934a96d9c09d488b924a98ec3a5c29 --- /dev/null +++ b/apps/resources/importers/base.py @@ -0,0 +1,88 @@ +"""Shared types + a safe HTTP fetcher for the checkpoint importers. + +The importer layer is an admin-triggered, one-shot "import assistant": it +fetches the same public course page Googlebot sees and returns a *draft* module +list for the admin to review. It is never a hard dependency — every entry point +degrades to manual entry. See research/07-playwright-auto-import-plan.md. +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from urllib.parse import urlparse + +import requests + +logger = logging.getLogger(__name__) + +# A real browser UA — some providers (Coursera) serve a bot challenge to the +# default python-requests UA. We send one fetch per admin action; no crawling. +_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36" +) +_MAX_BYTES = 5 * 1024 * 1024 # cap fetched bodies so a huge page can't OOM us +TITLE_MAX = 500 # matches ResourceCheckpoint.title max_length + + +@dataclass +class ExtractedCheckpoint: + order_index: int + title: str + url_fragment: str = "" + estimated_minutes: int | None = None + + +@dataclass +class ExtractResult: + """Outcome of an extraction attempt. `checkpoints` empty + a human `note` + means "nothing found, fall back to manual" — never an exception.""" + checkpoints: list[ExtractedCheckpoint] = field(default_factory=list) + source: str = "manual" + provider: str = "" + note: str = "" + + @property + def ok(self) -> bool: + return bool(self.checkpoints) + + +def host_of(url: str) -> str: + try: + return (urlparse(url).hostname or "").lower() + except (ValueError, TypeError): + return "" + + +def fetch(url: str, *, timeout: int = 15) -> str | None: + """GET a URL and return decoded text, or None on any failure. + + Hardened against the admin-triggered-SSRF surface: http/https only, a short + timeout, and a response-size cap. Never raises — callers treat None as + "couldn't fetch, go manual". + """ + scheme = (urlparse(url).scheme or "").lower() + if scheme not in ("http", "https"): + return None + try: + resp = requests.get( + url, + headers={"User-Agent": _UA, "Accept-Language": "en"}, + timeout=timeout, + stream=True, + ) + resp.raise_for_status() + chunks: list[bytes] = [] + total = 0 + for chunk in resp.iter_content(chunk_size=65536): + if not chunk: + continue + total += len(chunk) + if total > _MAX_BYTES: + break + chunks.append(chunk) + encoding = resp.encoding or "utf-8" + return b"".join(chunks).decode(encoding, errors="replace") + except requests.RequestException as exc: + logger.info("importers.fetch failed for %s: %s", url, exc) + return None diff --git a/apps/resources/importers/jsonld.py b/apps/resources/importers/jsonld.py new file mode 100644 index 0000000000000000000000000000000000000000..e756d50f47217190b56fd842117ed6b77a987db3 --- /dev/null +++ b/apps/resources/importers/jsonld.py @@ -0,0 +1,69 @@ +"""Parse course outlines from a fetched HTML page. + +Primary path: schema.org/Course JSON-LD (`hasPart` / `syllabusSections`), +which Coursera and DataCamp both emit (verified 2026-06-10 via raw requests). +Fallback: visible h2/h3 headings that look like week/module/chapter labels, +for course pages that have no usable JSON-LD. +""" +from __future__ import annotations + +import html +import json +import re + +from bs4 import BeautifulSoup + +_COURSE_TYPES = {"Course", "Syllabus", "CourseInstance"} +_SECTION_KEYS = ("hasPart", "syllabusSections", "hasCourseInstance") +_HEADING_RE = re.compile( + r"\b(week|module|chapter|lesson|unit|part|section)\b", re.IGNORECASE +) + + +def _collect_sections(node: object, out: list[str]) -> None: + """Walk the JSON-LD graph; from any Course/Syllabus node, pull the `name` + of each section in hasPart/syllabusSections/hasCourseInstance.""" + if isinstance(node, dict): + raw_type = node.get("@type") + types = set(raw_type if isinstance(raw_type, list) else [raw_type]) + if types & _COURSE_TYPES: + for key in _SECTION_KEYS: + part = node.get(key) + if isinstance(part, list): + for item in part: + if isinstance(item, dict): + name = item.get("name") + if isinstance(name, str) and name.strip(): + # Providers (e.g. Udemy) store names HTML-encoded + # inside the JSON-LD ("A & B") — decode them. + out.append(html.unescape(name.strip())) + for value in node.values(): + _collect_sections(value, out) + elif isinstance(node, list): + for value in node: + _collect_sections(value, out) + + +def parse_jsonld(html: str) -> list[str]: + soup = BeautifulSoup(html, "lxml") + titles: list[str] = [] + for tag in soup.find_all("script", attrs={"type": "application/ld+json"}): + text = tag.string or tag.get_text() or "" + try: + data = json.loads(text) + except (ValueError, TypeError): + continue + _collect_sections(data, titles) + return titles + + +def parse_html_headings(html: str) -> list[str]: + """Best-effort fallback for pages without JSON-LD: h2/h3 headings whose + text reads like a curriculum entry (contains week/module/chapter/…).""" + soup = BeautifulSoup(html, "lxml") + titles: list[str] = [] + for tag in soup.find_all(["h2", "h3"]): + text = " ".join(tag.get_text(" ", strip=True).split()) + if 3 <= len(text) <= 200 and _HEADING_RE.search(text): + titles.append(text) + return titles diff --git a/apps/resources/importers/youtube.py b/apps/resources/importers/youtube.py new file mode 100644 index 0000000000000000000000000000000000000000..3547329e6ceb4217ef93dee0846e21eeb76f6623 --- /dev/null +++ b/apps/resources/importers/youtube.py @@ -0,0 +1,80 @@ +"""Extract chapter markers from a single YouTube video via the Data API v3. + +All seed YouTube resources are single `watch?v=` videos (no playlists), so we +read the video description and parse `M:SS Title` chapter lines — the de-facto +chapter format creators use. Gated on YOUTUBE_API_KEY, read at call time so +tests can monkeypatch and a keyless deploy/CI degrades to manual. +""" +from __future__ import annotations + +import logging +import os +import re +from urllib.parse import parse_qs, urlparse + +import requests + +logger = logging.getLogger(__name__) + +_API = "https://www.googleapis.com/youtube/v3/videos" +# A chapter line: leading timestamp (m:ss or h:mm:ss), optional brackets/parens, +# then the title. Examples: "0:00 Intro", "(1:23:45) - Deep dive", "00:00 — Setup". +# A chapter line: optional leading junk (emoji like "⌨️", bullets, an opening +# bracket, whitespace), then a timestamp (m:ss or h:mm:ss), then separators, +# then the title. `^\W*?` skips the prefix but — since digits are word chars — +# stops at the timestamp, so it won't match a timestamp buried mid-sentence +# (that line would have word chars before the digits). Examples it must catch: +# "0:00 Intro", "(1:23) Setup", "⌨️ (6:40) Hello World", "1:02:30 - Advanced". +_CHAPTER_RE = re.compile( + r"^\W*?(\d{1,2}:\d{2}(?::\d{2})?)\W*(.+?)\s*$" +) + + +def video_id(url: str) -> str | None: + host = (urlparse(url).hostname or "").lower() + if "youtu.be" in host: + return (urlparse(url).path.lstrip("/") or None) + values = parse_qs(urlparse(url).query).get("v") + return values[0] if values else None + + +def parse_chapters(description: str) -> list[str]: + titles: list[str] = [] + for line in description.splitlines(): + match = _CHAPTER_RE.match(line) + if match: + # `\W*` already consumed separators before the title; only trim + # surrounding whitespace/leading dashes so titles that legitimately + # end in ")" or "." (e.g. "Query Translation (Multi-Query)") survive. + title = match.group(2).strip().lstrip("-–—:| ").strip() + if title: + titles.append(title) + return titles + + +def extract(url: str, *, timeout: int = 15) -> tuple[list[str], str]: + """Return (titles, note). Empty titles + a note => fall back to manual.""" + key = os.environ.get("YOUTUBE_API_KEY") + if not key: + return [], "YouTube import needs YOUTUBE_API_KEY set — enter checkpoints manually." + vid = video_id(url) + if not vid: + return [], "Could not parse a YouTube video id from the URL." + try: + resp = requests.get( + _API, + params={"part": "snippet", "id": vid, "key": key}, + timeout=timeout, + ) + resp.raise_for_status() + items = resp.json().get("items", []) + except (requests.RequestException, ValueError) as exc: + logger.info("youtube api failed for %s: %s", vid, exc) + return [], "YouTube API request failed — enter checkpoints manually." + if not items: + return [], "YouTube video not found." + description = items[0].get("snippet", {}).get("description", "") + chapters = parse_chapters(description) + if not chapters: + return [], "No chapter markers in the video description — enter manually." + return chapters, "" diff --git a/apps/resources/management/__init__.py b/apps/resources/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/resources/management/commands/__init__.py b/apps/resources/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/resources/management/commands/check_checkpoints.py b/apps/resources/management/commands/check_checkpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..1c88504ef1a1917e1dc85acadb42ad1bf9091ddb --- /dev/null +++ b/apps/resources/management/commands/check_checkpoints.py @@ -0,0 +1,52 @@ +"""Audit: every COURSE/VIDEO resource should carry >=2 checkpoints. + +Read-only. Exits nonzero (listing offenders) when any in-scope resource has +fewer, so it can gate CI / a release check. DOCS and ARTICLE are exempt — a +reference site's table of contents isn't a learning sequence, so those keep the +single manual-completion checkbox. +""" +import sys + +from django.core.management.base import BaseCommand +from django.db.models import Count + +from apps.resources.models import Resource + +SCOPED_TYPES = ("COURSE", "VIDEO") + + +class Command(BaseCommand): + help = ( + "Verify every COURSE/VIDEO resource has at least --min checkpoints " + "(default 2). Exits 1 and lists offenders if not. DOCS/ARTICLE exempt." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--min", type=int, default=2, + help="Minimum checkpoints required per COURSE/VIDEO resource.", + ) + + def handle(self, *args, **options): + minimum = options["min"] + scoped = Resource.objects.filter(type__in=SCOPED_TYPES) + total = scoped.count() + offenders = list( + scoped.annotate(n=Count("checkpoints")) + .filter(n__lt=minimum) + .order_by("type", "title") + ) + if not offenders: + self.stdout.write(self.style.SUCCESS( + f"OK: all {total} COURSE/VIDEO resources have >={minimum} checkpoints." + )) + return + self.stdout.write(self.style.ERROR( + f"{len(offenders)} of {total} COURSE/VIDEO resources have " + f"<{minimum} checkpoints:" + )) + for r in offenders: + self.stdout.write( + f" [{r.type}] {r.title} ({r.provider}) - {r.n} checkpoint(s)" + ) + sys.exit(1) diff --git a/apps/resources/management/commands/import_checkpoints.py b/apps/resources/management/commands/import_checkpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..bd70b9634e7f1acd7800ea7e663b3a5f473ef336 --- /dev/null +++ b/apps/resources/management/commands/import_checkpoints.py @@ -0,0 +1,163 @@ +"""Backfill course-outline checkpoints for COURSE/VIDEO resources that have none. + +Uses the lightweight importer (Coursera/DataCamp schema.org JSON-LD, YouTube +chapter markers). Dry-run by default. `--apply` writes rows (create-if-absent, +never clobbering existing checkpoints). `--emit-yaml` prints a `checkpoints:` +snippet per resource for pasting into seed_data/resources.yaml (we never rewrite +that curated file in place — PyYAML would strip its comments/ordering). + +Unextractable resources (Udemy, edX, SPA pages, single videos without chapter +markers) are printed as a manual-authoring worklist; pair this with +`manage.py check_checkpoints` to confirm the >=2 guarantee is met. +""" +import yaml +from django.core.management.base import BaseCommand +from django.db import transaction +from django.db.models import Count +from django.utils import timezone + +from apps.resources.importers import extract_checkpoints +from apps.resources.models import Resource, ResourceCheckpoint + +SCOPED_TYPES = ("COURSE", "VIDEO") + + +class Command(BaseCommand): + help = ( + "Draft checkpoints for COURSE/VIDEO resources with none via the course-" + "outline importer. Dry-run unless --apply. --emit-yaml prints snippets " + "for the seed file. Lists unextractable resources as a manual worklist." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--apply", action="store_true", + help="Create ResourceCheckpoint rows (create-if-absent). " + "Default: dry-run report only.", + ) + parser.add_argument( + "--emit-yaml", action="store_true", + help="Print a checkpoints: YAML snippet per resource (for manual " + "paste into seed_data/resources.yaml).", + ) + parser.add_argument( + "--type", choices=list(SCOPED_TYPES), default=None, + help="Restrict to one type (default: both COURSE and VIDEO).", + ) + parser.add_argument( + "--limit", type=int, default=None, + help="Process at most N resources (for spot checks).", + ) + parser.add_argument( + "--replace", action="store_true", + help="Refresh resources that ALREADY have checkpoints: when " + "extraction yields >=2, delete and recreate from the import " + "(e.g. real YouTube chapters once YOUTUBE_API_KEY is set). " + "Never blanks a resource (extraction <2 leaves rows intact). " + "Requires --apply. NOTE: deleting checkpoints cascades " + "UserCheckpointProgress.", + ) + + def handle(self, *args, **options): + replace = options["replace"] + if replace and not options["apply"]: + self.stderr.write(self.style.ERROR( + "--replace requires --apply (it deletes existing rows)." + )) + return + types = [options["type"]] if options["type"] else list(SCOPED_TYPES) + qs = Resource.objects.filter(type__in=types).annotate(n=Count("checkpoints")) + # Default backfill targets resources with NO checkpoints. --replace + # instead targets the ones that DO have them (to refresh in place). + qs = qs.filter(n__gte=1) if replace else qs.filter(n=0) + qs = qs.order_by("type", "title") + if options["limit"]: + qs = qs[: options["limit"]] + + extracted = created_rows = 0 + failed: list[tuple[Resource, str]] = [] + + for resource in qs: + result = extract_checkpoints(resource.url) + if not result.checkpoints: + failed.append((resource, result.note)) + self.stdout.write( + f" [--] {resource.title} ({resource.provider}) - {result.note}" + ) + continue + extracted += 1 + self.stdout.write(self.style.SUCCESS( + f" [ok] {resource.title} ({resource.provider}) - " + f"{len(result.checkpoints)} via {result.source}" + )) + for c in result.checkpoints: + self.stdout.write(f" {c.order_index}. {c.title}") + + if options["emit_yaml"]: + self._emit_yaml(resource, result) + + if not options["apply"]: + continue + + rows = [ + ResourceCheckpoint( + resource=resource, + order_index=c.order_index, + title=c.title[:500], + url_fragment=c.url_fragment, + estimated_minutes=c.estimated_minutes, + source=result.source, + extracted_at=timezone.now(), + ) + for c in result.checkpoints + ] + if replace: + # Refresh in place — but never blank a resource: only swap when + # the import gives a real list (>=2). Delete + recreate together + # so a failure can't leave the resource at 0 checkpoints. + if len(rows) < 2: + self.stdout.write( + f" (kept existing — extraction returned " + f"{len(rows)} < 2)" + ) + continue + with transaction.atomic(): + resource.checkpoints.all().delete() + ResourceCheckpoint.objects.bulk_create(rows) + created_rows += len(rows) + self.stdout.write(f" (replaced with {len(rows)} rows)") + elif not resource.checkpoints.exists(): + ResourceCheckpoint.objects.bulk_create(rows) + created_rows += len(rows) + + if not options["apply"]: + mode = "DRY-RUN (pass --apply to persist)" + elif replace: + mode = "REPLACED" + else: + mode = "APPLIED" + self.stdout.write("") + self.stdout.write(self.style.MIGRATE_HEADING(f"== {mode} ==")) + verb = "rows replaced" if replace else "rows created" + line = f"Extractable: {extracted} | needs manual authoring: {len(failed)}" + if options["apply"]: + line += f" | {verb}: {created_rows}" + self.stdout.write(line) + if failed: + self.stdout.write(self.style.WARNING( + "Manual-authoring worklist (COURSE/VIDEO with no auto-outline):" + )) + for resource, _ in failed: + self.stdout.write( + f" - [{resource.type}] {resource.title} ({resource.provider})" + ) + + def _emit_yaml(self, resource, result): + snippet = { + "checkpoints": [c.title for c in result.checkpoints], + "checkpoint_source": result.source, + } + self.stdout.write(f"# {resource.title} <{resource.url}>") + self.stdout.write(yaml.safe_dump( + snippet, allow_unicode=True, sort_keys=False, default_flow_style=False + )) diff --git a/apps/resources/management/commands/seed_initial_resources.py b/apps/resources/management/commands/seed_initial_resources.py new file mode 100644 index 0000000000000000000000000000000000000000..0ad8418a34cfe791c262a03464d3ee4e49d147b0 --- /dev/null +++ b/apps/resources/management/commands/seed_initial_resources.py @@ -0,0 +1,260 @@ +from pathlib import Path + +import yaml +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from apps.resources.models import Resource, ResourceCheckpoint, SkillResource +from apps.skills.models import Skill + +RESOURCES_YAML = Path(settings.BASE_DIR) / "seed_data" / "resources.yaml" +# Optional sidecar: checkpoint module lists keyed by resource URL. Kept separate +# so the curated resources.yaml (comments, ordering) stays pristine. Each entry: +# - url: "" +# source: jsonld # optional, default manual +# titles: ["Module 1", ...] +CHECKPOINTS_YAML = Path(settings.BASE_DIR) / "seed_data" / "checkpoints.yaml" + +REQUIRED_FIELDS = ("title", "provider", "url", "difficulty_level", + "duration", "type", "rating", "skills") + +VALID_DIFFICULTY = {"BEGINNER", "INTERMEDIATE", "ADVANCED"} +VALID_TYPE = {"VIDEO", "COURSE", "ARTICLE", "DOCS"} +VALID_SOURCES = {value for value, _ in ResourceCheckpoint.SOURCE_CHOICES} + + +class Command(BaseCommand): + help = ( + "Seed Resource + SkillResource rows from seed_data/resources.yaml. " + "Run seed_initial_skills first so all referenced skills exist. " + "Idempotent: resources are upserted by url; skill links upserted by " + "(skill, resource). --prune deletes rows not present in the YAML." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--path", + default=str(RESOURCES_YAML), + help="Path to resources YAML (default: backend/seed_data/resources.yaml)", + ) + parser.add_argument( + "--prune", + action="store_true", + help="Delete Resource rows whose url is not present in the YAML. " + "Cascades SkillResource rows via FK.", + ) + parser.add_argument( + "--checkpoints-path", + default=str(CHECKPOINTS_YAML), + help="Path to the checkpoints sidecar YAML (default: " + "backend/seed_data/checkpoints.yaml). Missing file = skipped.", + ) + + def handle(self, *args, **options): + yaml_path = Path(options["path"]) + if not yaml_path.exists(): + raise CommandError(f"Resources YAML not found: {yaml_path}") + + with yaml_path.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + entries = (data or {}).get("resources") or [] + if not entries: + raise CommandError("No resources found in YAML") + + self._validate_entries(entries) + + # Resolve skills up-front so a missing skill aborts before any writes. + skill_names = { + link["skill_name"] + for entry in entries + for link in entry["skills"] + } + skills_by_name = { + s.skill_name: s for s in Skill.objects.filter(skill_name__in=skill_names) + } + missing = sorted(skill_names - set(skills_by_name)) + if missing: + raise CommandError( + "Missing skills in DB (run `python manage.py seed_initial_skills` " + "first): " + ", ".join(missing) + ) + + created = updated = 0 + links_created = links_updated = links_deleted = 0 + resources_deleted = 0 + checkpoints_created = 0 + + with transaction.atomic(): + seen_urls: set[str] = set() + for entry in entries: + resource, was_created = Resource.objects.update_or_create( + url=entry["url"], + defaults={ + "title": entry["title"], + "provider": entry["provider"], + "difficulty_level": entry["difficulty_level"], + "duration": int(entry["duration"]), + "type": entry["type"], + "rating": float(entry["rating"]), + }, + ) + seen_urls.add(entry["url"]) + if was_created: + created += 1 + else: + updated += 1 + + seen_skill_ids: set[int] = set() + for link in entry["skills"]: + skill = skills_by_name[link["skill_name"]] + seen_skill_ids.add(skill.id) + _, lc = SkillResource.objects.update_or_create( + resource=resource, + skill=skill, + defaults={ + "relevance_score": float( + link.get("relevance_score", 1.0) + ), + }, + ) + if lc: + links_created += 1 + else: + links_updated += 1 + + stale_links = SkillResource.objects.filter( + resource=resource + ).exclude(skill_id__in=seen_skill_ids) + links_deleted += stale_links.count() + stale_links.delete() + + # Checkpoints: create-if-absent only. Never delete/reindex + # existing rows — UserCheckpointProgress FKs them with CASCADE, + # so a re-seed must never clobber a learner's history. Editing a + # populated list happens via the admin inline, not the seed. + checkpoint_titles = [ + str(t).strip() + for t in (entry.get("checkpoints") or []) + if str(t).strip() + ] + if checkpoint_titles and not resource.checkpoints.exists(): + source = entry.get("checkpoint_source", "manual") + ResourceCheckpoint.objects.bulk_create([ + ResourceCheckpoint( + resource=resource, + order_index=i, + title=title[:500], + source=source, + ) + for i, title in enumerate(checkpoint_titles, start=1) + ]) + checkpoints_created += len(checkpoint_titles) + + # Apply the optional checkpoints.yaml sidecar (URL-keyed). Same + # create-if-absent rule; resources are matched to already-upserted + # rows by URL. + checkpoints_created += self._apply_checkpoint_sidecar( + Path(options["checkpoints_path"]) + ) + + if options["prune"]: + stale_resources = Resource.objects.exclude(url__in=seen_urls) + resources_deleted = stale_resources.count() + stale_resources.delete() + + msg = ( + f"Resources: {created} created, {updated} updated" + + (f", {resources_deleted} deleted" if options["prune"] else "") + + f". SkillResource links: {links_created} created, " + f"{links_updated} updated, {links_deleted} deleted" + + f". Checkpoints: {checkpoints_created} created." + ) + self.stdout.write(self.style.SUCCESS(msg)) + + def _validate_entries(self, entries: list[dict]) -> None: + urls_seen: set[str] = set() + for i, entry in enumerate(entries, start=1): + missing = [f for f in REQUIRED_FIELDS if f not in entry] + if missing: + raise CommandError( + f"Resource #{i} ({entry.get('title', '?')!r}) missing " + f"fields: {missing}" + ) + if entry["difficulty_level"] not in VALID_DIFFICULTY: + raise CommandError( + f"Resource #{i} invalid difficulty_level " + f"{entry['difficulty_level']!r}" + ) + if entry["type"] not in VALID_TYPE: + raise CommandError( + f"Resource #{i} invalid type {entry['type']!r}" + ) + if entry["url"] in urls_seen: + raise CommandError( + f"Duplicate url in YAML: {entry['url']}" + ) + urls_seen.add(entry["url"]) + if not entry["skills"]: + raise CommandError( + f"Resource #{i} ({entry['title']!r}) has no skills — " + "every resource must link to ≥1 skill." + ) + # Optional `checkpoints` (list) + `checkpoint_source`. The create + # path uses bulk_create (no model validation), so validate source + # against the choices here, like difficulty/type above. + if "checkpoints" in entry and not isinstance(entry["checkpoints"], list): + raise CommandError( + f"Resource #{i} ({entry['title']!r}) `checkpoints` must be a list." + ) + src = entry.get("checkpoint_source") + if src is not None and src not in VALID_SOURCES: + raise CommandError( + f"Resource #{i} ({entry['title']!r}) invalid " + f"checkpoint_source {src!r} (allowed: {sorted(VALID_SOURCES)})" + ) + + def _apply_checkpoint_sidecar(self, path: Path) -> int: + """Create checkpoints from the optional checkpoints sidecar YAML. + + Keyed by resource URL; create-if-absent (skips resources that already + have checkpoints). A sidecar URL with no matching resource is skipped + with a warning (a stale entry shouldn't abort the whole seed). Returns + the number of checkpoint rows created. + """ + if not path.exists(): + return 0 + with path.open(encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + entries = data.get("checkpoints") or [] + created = 0 + for i, entry in enumerate(entries, start=1): + url = entry.get("url") + titles = [str(t).strip() for t in (entry.get("titles") or []) if str(t).strip()] + source = entry.get("source", "manual") + if not url or not titles: + raise CommandError( + f"checkpoints sidecar entry #{i} needs a url and a non-empty " + "titles list." + ) + if source not in VALID_SOURCES: + raise CommandError( + f"checkpoints sidecar entry #{i} ({url}) invalid source {source!r}." + ) + resource = Resource.objects.filter(url=url).first() + if resource is None: + self.stderr.write( + f" checkpoints sidecar: no resource for url {url} — skipped." + ) + continue + if resource.checkpoints.exists(): + continue # create-if-absent: never clobber existing rows + ResourceCheckpoint.objects.bulk_create([ + ResourceCheckpoint( + resource=resource, order_index=j, title=title[:500], source=source, + ) + for j, title in enumerate(titles, start=1) + ]) + created += len(titles) + return created diff --git a/apps/resources/migrations/0001_initial.py b/apps/resources/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..4fce620a5af1940f993b9aae090a23d0038586c8 --- /dev/null +++ b/apps/resources/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 6.0.4 on 2026-04-20 17:36 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('skills', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Resource', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=300)), + ('provider', models.CharField(max_length=100)), + ('url', models.URLField(max_length=500, unique=True)), + ('difficulty_level', models.CharField(choices=[('BEGINNER', 'Beginner'), ('INTERMEDIATE', 'Intermediate'), ('ADVANCED', 'Advanced')], max_length=20)), + ('duration', models.IntegerField(help_text='Total duration in minutes.', validators=[django.core.validators.MinValueValidator(0)])), + ('type', models.CharField(choices=[('VIDEO', 'Video'), ('COURSE', 'Course'), ('ARTICLE', 'Article'), ('DOCS', 'Docs')], max_length=20)), + ('rating', models.FloatField(default=3.5, help_text='Curator-assigned 0.0-5.0 rating used by recommendation ranking.', validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(5.0)])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['title'], + }, + ), + migrations.CreateModel( + name='SkillResource', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('relevance_score', models.FloatField(default=1.0, help_text='0.0-1.0 how relevant this resource is to the skill.', validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(1.0)])), + ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resources.resource')), + ('skill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='skills.skill')), + ], + options={ + 'unique_together': {('skill', 'resource')}, + }, + ), + migrations.AddField( + model_name='resource', + name='skills', + field=models.ManyToManyField(related_name='resources', through='resources.SkillResource', to='skills.skill'), + ), + migrations.CreateModel( + name='ResourceCheckpoint', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order_index', models.IntegerField(help_text='1-based position within the resource.', validators=[django.core.validators.MinValueValidator(1)])), + ('title', models.CharField(max_length=500)), + ('url_fragment', models.CharField(blank=True, default='', help_text='Optional anchor/path suffix linking to this module.', max_length=500)), + ('estimated_minutes', models.IntegerField(blank=True, null=True)), + ('source', models.CharField(choices=[('manual', 'Manual'), ('playwright', 'Playwright'), ('youtube_api', 'YouTube API'), ('fcc_github', 'freeCodeCamp GitHub')], default='manual', max_length=20)), + ('extracted_at', models.DateTimeField(blank=True, null=True)), + ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkpoints', to='resources.resource')), + ], + options={ + 'ordering': ['resource', 'order_index'], + 'unique_together': {('resource', 'order_index')}, + }, + ), + ] diff --git a/apps/resources/migrations/0002_alter_resourcecheckpoint_source.py b/apps/resources/migrations/0002_alter_resourcecheckpoint_source.py new file mode 100644 index 0000000000000000000000000000000000000000..7ecf9c7e446dcbb22207c9b0268da6e97128034d --- /dev/null +++ b/apps/resources/migrations/0002_alter_resourcecheckpoint_source.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-06-10 13:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='resourcecheckpoint', + name='source', + field=models.CharField(choices=[('manual', 'Manual'), ('jsonld', 'schema.org JSON-LD'), ('html', 'HTML parse'), ('youtube_api', 'YouTube API'), ('playwright', 'Playwright'), ('fcc_github', 'freeCodeCamp GitHub')], default='manual', max_length=20), + ), + ] diff --git a/apps/resources/migrations/__init__.py b/apps/resources/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/resources/models.py b/apps/resources/models.py new file mode 100644 index 0000000000000000000000000000000000000000..33e313c7e7548de9f1b9da7fc1ade37490554936 --- /dev/null +++ b/apps/resources/models.py @@ -0,0 +1,95 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + + +class Resource(models.Model): + DIFFICULTY_CHOICES = [ + ('BEGINNER', 'Beginner'), + ('INTERMEDIATE', 'Intermediate'), + ('ADVANCED', 'Advanced'), + ] + TYPE_CHOICES = [ + ('VIDEO', 'Video'), + ('COURSE', 'Course'), + ('ARTICLE', 'Article'), + ('DOCS', 'Docs'), + ] + + title = models.CharField(max_length=300) + provider = models.CharField(max_length=100) + url = models.URLField(max_length=500, unique=True) + difficulty_level = models.CharField(max_length=20, choices=DIFFICULTY_CHOICES) + duration = models.IntegerField( + help_text='Total duration in minutes.', + validators=[MinValueValidator(0)], + ) + type = models.CharField(max_length=20, choices=TYPE_CHOICES) + rating = models.FloatField( + default=3.5, + validators=[MinValueValidator(0.0), MaxValueValidator(5.0)], + help_text='Curator-assigned 0.0-5.0 rating used by recommendation ranking.', + ) + skills = models.ManyToManyField( + 'skills.Skill', + through='SkillResource', + related_name='resources', + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['title'] + + def __str__(self): + return f'{self.title} ({self.provider})' + + +class SkillResource(models.Model): + skill = models.ForeignKey('skills.Skill', on_delete=models.CASCADE) + resource = models.ForeignKey(Resource, on_delete=models.CASCADE) + relevance_score = models.FloatField( + default=1.0, + validators=[MinValueValidator(0.0), MaxValueValidator(1.0)], + help_text='0.0-1.0 how relevant this resource is to the skill.', + ) + + class Meta: + unique_together = [['skill', 'resource']] + + def __str__(self): + return f'{self.skill.skill_name} - {self.resource.title} ({self.relevance_score:.2f})' + + +class ResourceCheckpoint(models.Model): + SOURCE_CHOICES = [ + ('manual', 'Manual'), + ('jsonld', 'schema.org JSON-LD'), + ('html', 'HTML parse'), + ('youtube_api', 'YouTube API'), + ('playwright', 'Playwright'), + ('fcc_github', 'freeCodeCamp GitHub'), + ] + + resource = models.ForeignKey( + Resource, + on_delete=models.CASCADE, + related_name='checkpoints', + ) + order_index = models.IntegerField( + validators=[MinValueValidator(1)], + help_text='1-based position within the resource.', + ) + title = models.CharField(max_length=500) + url_fragment = models.CharField( + max_length=500, blank=True, default='', + help_text='Optional anchor/path suffix linking to this module.', + ) + estimated_minutes = models.IntegerField(null=True, blank=True) + source = models.CharField(max_length=20, choices=SOURCE_CHOICES, default='manual') + extracted_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = [['resource', 'order_index']] + ordering = ['resource', 'order_index'] + + def __str__(self): + return f'{self.resource.title} #{self.order_index}: {self.title[:60]}' diff --git a/apps/resources/serializers.py b/apps/resources/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..1607c9a60ee00699244c941d0e9f4aee18439770 --- /dev/null +++ b/apps/resources/serializers.py @@ -0,0 +1,89 @@ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from apps.skills.serializers import SkillSerializer + +from .models import Resource, ResourceCheckpoint, SkillResource + + +class ResourceCheckpointSerializer(serializers.ModelSerializer): + # Populated when the serializer context carries a `completed_checkpoint_ids` + # set — e.g. ResourceDetailView preloads the current user's completed + # checkpoint IDs for the fetched resource. Absent in contexts without a + # user (schema generation, seed scripts) — falls back to False. + is_completed = serializers.SerializerMethodField() + + class Meta: + model = ResourceCheckpoint + fields = ['id', 'order_index', 'title', 'url_fragment', + 'estimated_minutes', 'source', 'is_completed'] + + def get_is_completed(self, obj) -> bool: + completed = self.context.get('completed_checkpoint_ids') or set() + return obj.id in completed + + +class SkillResourceSerializer(serializers.ModelSerializer): + skill = SkillSerializer(read_only=True) + + class Meta: + model = SkillResource + fields = ['skill', 'relevance_score'] + + +class ResourceListSerializer(serializers.ModelSerializer): + # Populated by an Exists() annotation on the view's queryset — see + # ResourceListView. Keep as BooleanField so it fails loud if the view + # ever forgets the annotation. + has_checkpoints = serializers.BooleanField(read_only=True) + + class Meta: + model = Resource + fields = ['id', 'title', 'provider', 'url', 'difficulty_level', + 'duration', 'type', 'rating', 'has_checkpoints'] + + +class ResourceDetailSerializer(serializers.ModelSerializer): + checkpoints = ResourceCheckpointSerializer(many=True, read_only=True) + skill_mappings = serializers.SerializerMethodField() + + class Meta: + model = Resource + fields = ['id', 'title', 'provider', 'url', 'difficulty_level', + 'duration', 'type', 'rating', 'checkpoints', 'skill_mappings'] + + @extend_schema_field(SkillResourceSerializer(many=True)) + def get_skill_mappings(self, obj): + return SkillResourceSerializer( + SkillResource.objects.filter(resource=obj).select_related('skill'), + many=True, + ).data + + +class ResourceAdminSerializer(serializers.ModelSerializer): + """Full-field writable Resource serializer for admin CRUD. + + Linked skills and checkpoints are managed via separate endpoints + (SkillResourceAdminViewSet, ResourceCheckpointAdminViewSet) so that each + concern has its own CRUD surface — matches the existing Django admin UX. + """ + class Meta: + model = Resource + fields = ['id', 'title', 'provider', 'url', 'difficulty_level', + 'duration', 'type', 'rating', 'created_at'] + read_only_fields = ['created_at'] + + +class ResourceCheckpointAdminSerializer(serializers.ModelSerializer): + class Meta: + model = ResourceCheckpoint + fields = ['id', 'resource', 'order_index', 'title', 'url_fragment', + 'estimated_minutes', 'source', 'extracted_at'] + + +class SkillResourceAdminSerializer(serializers.ModelSerializer): + skill_detail = SkillSerializer(source='skill', read_only=True) + + class Meta: + model = SkillResource + fields = ['id', 'skill', 'skill_detail', 'resource', 'relevance_score'] diff --git a/apps/resources/tests/__init__.py b/apps/resources/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/resources/tests/test_admin_resources.py b/apps/resources/tests/test_admin_resources.py new file mode 100644 index 0000000000000000000000000000000000000000..2fe75d457f08a00cc5ced265a407f70afc6966e9 --- /dev/null +++ b/apps/resources/tests/test_admin_resources.py @@ -0,0 +1,318 @@ +"""Tests for admin-only Resource / Checkpoint / SkillResource CRUD +at /api/admin/. +""" +import pytest +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APIClient + +from apps.progress.models import UserProgress +from apps.resources.models import Resource, ResourceCheckpoint, SkillResource +from apps.skills.models import Skill + +User = get_user_model() +pytestmark = pytest.mark.django_db + +ADMIN_RESOURCES_URL = '/api/admin/resources/' +ADMIN_CHECKPOINTS_URL = '/api/admin/checkpoints/' +ADMIN_CHECKPOINTS_BULK_URL = '/api/admin/checkpoints/bulk/' +ADMIN_CHECKPOINTS_IMPORT_URL = '/api/admin/checkpoints/import/' +ADMIN_SKILL_RESOURCES_URL = '/api/admin/skill-resources/' + + +@pytest.fixture +def admin_client(): + user = User.objects.create_user( + username='admin@test.example', email='admin@test.example', + password='x', name='Admin', role_type='ADMIN', is_staff=True, + ) + c = APIClient() + c.force_authenticate(user=user) + return c + + +@pytest.fixture +def student_client(): + user = User.objects.create_user( + username='student@test.example', email='student@test.example', + password='x', name='Student', role_type='STUDENT', + ) + c = APIClient() + c.force_authenticate(user=user) + return c + + +@pytest.fixture +def resource(): + return Resource.objects.create( + title='Python for Everybody', + provider='Coursera', + url='https://example.com/py4e', + difficulty_level='BEGINNER', + duration=1200, + type='COURSE', + ) + + +@pytest.fixture +def skill(): + return Skill.objects.create(skill_name='Python', category='Programming', difficulty_level='BEGINNER') + + +class TestResourceAdminPermissions: + def test_student_cannot_create_resource(self, student_client): + resp = student_client.post(ADMIN_RESOURCES_URL, { + 'title': 'X', 'provider': 'Y', 'url': 'https://x.test', + 'difficulty_level': 'BEGINNER', 'duration': 30, 'type': 'VIDEO', + }, format='json') + assert resp.status_code == status.HTTP_403_FORBIDDEN + + +class TestResourceAdminCrud: + def test_admin_full_cycle(self, admin_client): + resp = admin_client.post(ADMIN_RESOURCES_URL, { + 'title': 'Django Tut', + 'provider': 'MDN', + 'url': 'https://example.com/django', + 'difficulty_level': 'INTERMEDIATE', + 'duration': 180, + 'type': 'ARTICLE', + }, format='json') + assert resp.status_code == status.HTTP_201_CREATED, resp.data + rid = resp.data['id'] + + resp = admin_client.patch( + f'{ADMIN_RESOURCES_URL}{rid}/', {'rating': 4.5}, format='json', + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.data['rating'] == 4.5 + + resp = admin_client.delete(f'{ADMIN_RESOURCES_URL}{rid}/') + assert resp.status_code == status.HTTP_204_NO_CONTENT + + +class TestCheckpointAdminCrud: + def test_per_row_create_filter_delete(self, admin_client, resource): + # Create checkpoint #1 + resp = admin_client.post(ADMIN_CHECKPOINTS_URL, { + 'resource': resource.id, 'order_index': 1, + 'title': 'Week 1: Intro', + }, format='json') + assert resp.status_code == status.HTTP_201_CREATED, resp.data + cp_id = resp.data['id'] + + # Filter by resource + resp = admin_client.get(f'{ADMIN_CHECKPOINTS_URL}?resource={resource.id}') + assert resp.status_code == status.HTTP_200_OK + rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data + assert len(rows) == 1 + + # Delete + resp = admin_client.delete(f'{ADMIN_CHECKPOINTS_URL}{cp_id}/') + assert resp.status_code == status.HTTP_204_NO_CONTENT + + def test_bulk_paste_creates_ordered_rows(self, admin_client, resource): + bulk_text = 'Module 1: Setup\nModule 2: Basics\n\nModule 3: Practice\n' + resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { + 'resource': resource.id, 'bulk': bulk_text, + }, format='json') + assert resp.status_code == status.HTTP_201_CREATED, resp.data + assert len(resp.data) == 3 + titles = [row['title'] for row in resp.data] + assert titles == ['Module 1: Setup', 'Module 2: Basics', 'Module 3: Practice'] + # order_index is 1-based and sequential. + order_indexes = [row['order_index'] for row in resp.data] + assert order_indexes == [1, 2, 3] + + # DB verification + db_rows = list( + ResourceCheckpoint.objects + .filter(resource=resource) + .order_by('order_index') + .values_list('title', flat=True) + ) + assert db_rows == titles + + def test_bulk_paste_refuses_when_existing_checkpoints(self, admin_client, resource): + ResourceCheckpoint.objects.create( + resource=resource, order_index=1, title='Existing', source='manual', + ) + resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { + 'resource': resource.id, 'bulk': 'New Row', + }, format='json') + assert resp.status_code == status.HTTP_409_CONFLICT + + def test_bulk_paste_rejects_empty_input(self, admin_client, resource): + resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { + 'resource': resource.id, 'bulk': ' \n\n ', + }, format='json') + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + def test_bulk_paste_accepts_valid_source_and_stamps_extracted_at(self, admin_client, resource): + """Imported lists carry their provenance; non-manual rows get a + timestamp. bulk_create bypasses model validation, so the view must + accept only valid choices.""" + resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { + 'resource': resource.id, 'bulk': 'Week 1\nWeek 2', 'source': 'jsonld', + }, format='json') + assert resp.status_code == status.HTTP_201_CREATED, resp.data + assert all(row['source'] == 'jsonld' for row in resp.data) + assert all(row['extracted_at'] is not None for row in resp.data) + + def test_bulk_paste_defaults_source_manual_no_timestamp(self, admin_client, resource): + resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { + 'resource': resource.id, 'bulk': 'Week 1\nWeek 2', + }, format='json') + assert resp.status_code == status.HTTP_201_CREATED, resp.data + assert all(row['source'] == 'manual' for row in resp.data) + assert all(row['extracted_at'] is None for row in resp.data) + + def test_bulk_paste_rejects_invalid_source(self, admin_client, resource): + resp = admin_client.post(ADMIN_CHECKPOINTS_BULK_URL, { + 'resource': resource.id, 'bulk': 'Week 1', 'source': 'evil', + }, format='json') + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert not ResourceCheckpoint.objects.filter(resource=resource).exists() + + +class TestCheckpointImportAction: + """POST /admin/checkpoints/import/ returns a draft preview and writes + nothing (the admin saves via the validated bulk endpoint).""" + + def test_import_returns_preview_without_writing(self, admin_client, resource, monkeypatch): + from apps.resources import views + from apps.resources.importers import ExtractedCheckpoint, ExtractResult + + def fake_extract(url, **kwargs): + assert url == resource.url # action reads Resource.url server-side + return ExtractResult( + checkpoints=[ + ExtractedCheckpoint(order_index=1, title='Wk 1'), + ExtractedCheckpoint(order_index=2, title='Wk 2'), + ], + source='jsonld', provider='coursera.org', + ) + monkeypatch.setattr(views, 'extract_checkpoints', fake_extract) + + resp = admin_client.post(ADMIN_CHECKPOINTS_IMPORT_URL, { + 'resource': resource.id, + }, format='json') + assert resp.status_code == status.HTTP_200_OK, resp.data + assert resp.data['source'] == 'jsonld' + assert [c['title'] for c in resp.data['checkpoints']] == ['Wk 1', 'Wk 2'] + # crucial: preview only — nothing persisted. + assert not ResourceCheckpoint.objects.filter(resource=resource).exists() + + def test_import_unsupported_returns_note_not_error(self, admin_client, resource, monkeypatch): + from apps.resources import views + from apps.resources.importers import ExtractResult + + monkeypatch.setattr( + views, 'extract_checkpoints', + lambda url, **kw: ExtractResult(note='enter manually', provider='x'), + ) + resp = admin_client.post(ADMIN_CHECKPOINTS_IMPORT_URL, { + 'resource': resource.id, + }, format='json') + assert resp.status_code == status.HTTP_200_OK + assert resp.data['checkpoints'] == [] + assert 'manually' in resp.data['note'] + + def test_import_missing_resource_400(self, admin_client): + resp = admin_client.post(ADMIN_CHECKPOINTS_IMPORT_URL, {}, format='json') + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + def test_import_student_forbidden(self, student_client, resource): + resp = student_client.post(ADMIN_CHECKPOINTS_IMPORT_URL, { + 'resource': resource.id, + }, format='json') + assert resp.status_code == status.HTTP_403_FORBIDDEN + + +class TestResourceAdminDelete: + def test_delete_blocked_when_student_progress_exists(self, admin_client, resource): + """F25: deleting a resource a student has tracked must 409, not silently + discard the learner's history.""" + student = User.objects.create_user( + username='s2@test.example', email='s2@test.example', + password='x', name='S2', role_type='STUDENT', + ) + UserProgress.objects.create(user=student, resource=resource) + resp = admin_client.delete(f'{ADMIN_RESOURCES_URL}{resource.id}/') + assert resp.status_code == status.HTTP_409_CONFLICT + assert Resource.objects.filter(id=resource.id).exists() + + def test_delete_succeeds_without_progress(self, admin_client, resource): + resp = admin_client.delete(f'{ADMIN_RESOURCES_URL}{resource.id}/') + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert not Resource.objects.filter(id=resource.id).exists() + + +class TestResourceAdminIntegrity: + def test_duplicate_url_returns_400_not_500(self, admin_client, resource): + """F24: a non-concurrent duplicate URL is caught by DRF's UniqueValidator + → 400 (never reaches the DB). The IntegrityConflictMixin's 409 only fires + on a true concurrent race that slips past validation.""" + resp = admin_client.post(ADMIN_RESOURCES_URL, { + 'title': 'Dup', 'provider': 'X', 'url': resource.url, + 'difficulty_level': 'BEGINNER', 'duration': 30, 'type': 'VIDEO', + }, format='json') + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + def test_duplicate_skill_resource_returns_400_not_500(self, admin_client, resource, skill): + """F10: a non-concurrent duplicate (skill, resource) link is caught by + the UniqueTogetherValidator → 400; the mixin's 409 is race-only.""" + first = admin_client.post(ADMIN_SKILL_RESOURCES_URL, { + 'skill': skill.id, 'resource': resource.id, 'relevance_score': 0.8, + }, format='json') + assert first.status_code == status.HTTP_201_CREATED, first.data + resp = admin_client.post(ADMIN_SKILL_RESOURCES_URL, { + 'skill': skill.id, 'resource': resource.id, 'relevance_score': 0.9, + }, format='json') + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + +class TestResourceQueryParamGuard: + def test_checkpoint_non_numeric_resource_param_returns_empty(self, admin_client, resource): + """F26: a non-numeric ?resource= must not 500 (ValueError at query prep); + return an empty set.""" + ResourceCheckpoint.objects.create( + resource=resource, order_index=1, title='X', source='manual', + ) + # 'abc' (non-numeric) and '²' (a Unicode digit isdigit() accepts but + # int() rejects) must both yield an empty 200, never a 500. + for bad in ('abc', '²'): + resp = admin_client.get(f'{ADMIN_CHECKPOINTS_URL}?resource={bad}') + assert resp.status_code == status.HTTP_200_OK + rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data + assert rows == [] + + def test_skill_resource_non_numeric_resource_param_returns_empty(self, admin_client, resource, skill): + SkillResource.objects.create(skill=skill, resource=resource, relevance_score=0.8) + resp = admin_client.get(f'{ADMIN_SKILL_RESOURCES_URL}?resource=abc') + assert resp.status_code == status.HTTP_200_OK + rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data + assert rows == [] + + +class TestSkillResourceAdminCrud: + def test_create_update_filter_delete(self, admin_client, resource, skill): + resp = admin_client.post(ADMIN_SKILL_RESOURCES_URL, { + 'skill': skill.id, 'resource': resource.id, 'relevance_score': 0.8, + }, format='json') + assert resp.status_code == status.HTTP_201_CREATED, resp.data + sr_id = resp.data['id'] + + resp = admin_client.get(f'{ADMIN_SKILL_RESOURCES_URL}?resource={resource.id}') + rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data + assert len(rows) == 1 + + resp = admin_client.patch( + f'{ADMIN_SKILL_RESOURCES_URL}{sr_id}/', + {'relevance_score': 0.95}, format='json', + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.data['relevance_score'] == 0.95 + + resp = admin_client.delete(f'{ADMIN_SKILL_RESOURCES_URL}{sr_id}/') + assert resp.status_code == status.HTTP_204_NO_CONTENT diff --git a/apps/resources/tests/test_check_checkpoints.py b/apps/resources/tests/test_check_checkpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..b7954566ff1b5e8dc741002f132dfd64014de8e4 --- /dev/null +++ b/apps/resources/tests/test_check_checkpoints.py @@ -0,0 +1,58 @@ +"""Tests for `manage.py check_checkpoints` — the >=2-checkpoints-per- +COURSE/VIDEO audit gate.""" +from io import StringIO + +import pytest +from django.core.management import call_command + +from apps.resources.models import Resource, ResourceCheckpoint + +pytestmark = pytest.mark.django_db + + +def _resource(title, type_, n_checkpoints): + r = Resource.objects.create( + title=title, provider='P', url=f'https://example.com/{title}', + difficulty_level='BEGINNER', duration=60, type=type_, + ) + for i in range(1, n_checkpoints + 1): + ResourceCheckpoint.objects.create( + resource=r, order_index=i, title=f'cp{i}', source='manual', + ) + return r + + +def _run(): + out = StringIO() + call_command('check_checkpoints', stdout=out) + return out.getvalue() + + +def test_passes_when_all_course_video_have_two_plus(): + _resource('a', 'COURSE', 3) + _resource('b', 'VIDEO', 2) + out = _run() + assert 'OK' in out + + +def test_exits_nonzero_when_a_course_has_fewer_than_two(): + _resource('ok', 'COURSE', 2) + _resource('thin', 'COURSE', 1) + with pytest.raises(SystemExit) as exc: + _run() + assert exc.value.code == 1 + + +def test_zero_checkpoint_video_flagged(): + _resource('novid', 'VIDEO', 0) + with pytest.raises(SystemExit): + _run() + + +def test_docs_and_articles_are_exempt(): + # DOCS/ARTICLE with 0 checkpoints must NOT trip the gate. + _resource('docs', 'DOCS', 0) + _resource('art', 'ARTICLE', 0) + _resource('course', 'COURSE', 2) + out = _run() + assert 'OK' in out diff --git a/apps/resources/tests/test_import_checkpoints_replace.py b/apps/resources/tests/test_import_checkpoints_replace.py new file mode 100644 index 0000000000000000000000000000000000000000..cce28a88d1364a1a40b036796f059b0d0a6c677b --- /dev/null +++ b/apps/resources/tests/test_import_checkpoints_replace.py @@ -0,0 +1,86 @@ +"""Tests for `import_checkpoints --replace` — refresh existing checkpoints in +place (e.g. swapping hand-authored YouTube segments for real chapter markers), +without ever blanking a resource.""" +from io import StringIO + +import pytest +from django.core.management import call_command + +from apps.resources import importers +from apps.resources.models import Resource, ResourceCheckpoint + +pytestmark = pytest.mark.django_db + + +def _video(n_existing): + r = Resource.objects.create( + title='Learn X', provider='YouTube', + url='https://www.youtube.com/watch?v=abc123', + difficulty_level='BEGINNER', duration=300, type='VIDEO', + ) + for i in range(1, n_existing + 1): + ResourceCheckpoint.objects.create( + resource=r, order_index=i, title=f'manual {i}', source='manual', + ) + return r + + +def _stub_extract(monkeypatch, titles, source='youtube_api'): + def fake(url, **kwargs): + return importers.ExtractResult( + checkpoints=[ + importers.ExtractedCheckpoint(order_index=i, title=t) + for i, t in enumerate(titles, start=1) + ], + source=source, provider='youtube', + ) + # patch where the command looked it up + from apps.resources.management.commands import import_checkpoints as cmd + monkeypatch.setattr(cmd, 'extract_checkpoints', fake) + + +def _run(*args): + out, err = StringIO(), StringIO() + call_command('import_checkpoints', *args, stdout=out, stderr=err) + return out.getvalue() + err.getvalue() + + +def test_replace_swaps_when_extraction_succeeds(monkeypatch): + r = _video(5) + _stub_extract(monkeypatch, ['Intro', 'Setup', 'Deep Dive']) + _run('--type', 'VIDEO', '--replace', '--apply') + rows = list(r.checkpoints.order_by('order_index')) + assert [c.title for c in rows] == ['Intro', 'Setup', 'Deep Dive'] + assert all(c.source == 'youtube_api' for c in rows) + # the 5 old manual rows are gone (replaced, not appended) + assert r.checkpoints.count() == 3 + + +def test_replace_keeps_existing_when_extraction_under_two(monkeypatch): + r = _video(5) + _stub_extract(monkeypatch, ['only one']) # <2 → must not blank + _run('--type', 'VIDEO', '--replace', '--apply') + rows = list(r.checkpoints.order_by('order_index')) + assert [c.title for c in rows] == ['manual 1', 'manual 2', 'manual 3', + 'manual 4', 'manual 5'] + assert all(c.source == 'manual' for c in rows) + + +def test_replace_requires_apply(monkeypatch): + r = _video(5) + _stub_extract(monkeypatch, ['Intro', 'Setup']) + out = _run('--type', 'VIDEO', '--replace') # no --apply + assert 'requires --apply' in out + # untouched + assert r.checkpoints.count() == 5 + assert r.checkpoints.first().source == 'manual' + + +def test_default_run_ignores_resources_that_have_checkpoints(monkeypatch): + """Without --replace, a resource that already has checkpoints is skipped + (the n=0 backfill gate), so existing rows are never touched.""" + r = _video(5) + _stub_extract(monkeypatch, ['Intro', 'Setup', 'Deep Dive']) + _run('--type', 'VIDEO', '--apply') # no --replace + assert r.checkpoints.count() == 5 + assert r.checkpoints.first().source == 'manual' diff --git a/apps/resources/tests/test_importers.py b/apps/resources/tests/test_importers.py new file mode 100644 index 0000000000000000000000000000000000000000..5d2ab5e0eb7d0e91b2e50f713944ffdf415e50f2 --- /dev/null +++ b/apps/resources/tests/test_importers.py @@ -0,0 +1,171 @@ +"""Unit tests for the course-outline importers. No network: requests.get is +mocked. Covers the JSON-LD/HTML parsers, YouTube chapter parsing, and the +dispatch's never-raises / graceful-fallback contract. +""" +import requests + +from apps.resources.importers import base, extract_checkpoints, jsonld, youtube + +JSONLD_HTML = """ + + + +""" + +HEADINGS_HTML = """ + +

Some Course

+

Week 1: Introduction

+

Week 2: Variables and Loops

+

About the instructor

+

Chapter 3 - Functions

+ +""" + + +class FakeResp: + def __init__(self, body, status_code=200): + self._body = body.encode() if isinstance(body, str) else body + self.status_code = status_code + self.encoding = "utf-8" + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(str(self.status_code)) + + def iter_content(self, chunk_size=65536): + yield self._body + + def json(self): + import json + return json.loads(self._body.decode()) + + +class TestJsonLdParser: + def test_extracts_haspart_titles_deduped(self): + titles = jsonld.parse_jsonld(JSONLD_HTML) + assert titles == ["Module A: Select", "Module B: Joins", "Module A: Select"] + # dedup happens in _finalize, not the raw parser; raw keeps order. + + def test_no_jsonld_returns_empty(self): + assert jsonld.parse_jsonld("nothing") == [] + + def test_html_entities_in_names_are_decoded(self): + html_doc = ( + '' + ) + assert jsonld.parse_jsonld(html_doc) == ["Variables & Functions"] + + def test_html_heading_fallback_filters_non_curriculum(self): + titles = jsonld.parse_html_headings(HEADINGS_HTML) + assert "Week 1: Introduction" in titles + assert "Week 2: Variables and Loops" in titles + assert "Chapter 3 - Functions" in titles + assert "About the instructor" not in titles # no week/module/... keyword + + +class TestYouTubeParser: + def test_video_id_from_watch_and_short(self): + assert youtube.video_id("https://www.youtube.com/watch?v=abc123") == "abc123" + assert youtube.video_id("https://youtu.be/xyz789") == "xyz789" + assert youtube.video_id("https://www.youtube.com/results?q=x") is None + + def test_parse_chapters(self): + desc = ( + "Intro text\n" + "0:00 Introduction\n" + "1:23 Variables\n" + "(10:00) - Functions\n" + "1:02:30 Advanced\n" + "see more at 9:99 in the docs\n" # mid-sentence timestamp: not a chapter + "not a chapter line\n" + ) + assert youtube.parse_chapters(desc) == [ + "Introduction", "Variables", "Functions", "Advanced", + ] + + def test_parse_chapters_emoji_prefixed(self): + # freeCodeCamp format: "⌨️ (timestamp) Title" — the keyboard emoji + # prefix must not stop the timestamp from being recognized. + desc = ( + "⌨️ (0:00) Introduction\n" + "⌨️ (1:45) Installing Python & PyCharm\n" + "⌨️ (6:40) Setup & Hello World\n" + ) + assert youtube.parse_chapters(desc) == [ + "Introduction", + "Installing Python & PyCharm", + "Setup & Hello World", + ] + + def test_no_key_returns_note(self, monkeypatch): + monkeypatch.delenv("YOUTUBE_API_KEY", raising=False) + titles, note = youtube.extract("https://www.youtube.com/watch?v=abc") + assert titles == [] + assert "YOUTUBE_API_KEY" in note + + +class TestDispatch: + def test_jsonld_page_dispatches_and_normalizes(self, monkeypatch): + monkeypatch.setattr(base.requests, "get", lambda *a, **k: FakeResp(JSONLD_HTML)) + result = extract_checkpoints("https://www.coursera.org/learn/sql") + assert result.source == "jsonld" + # de-duplicated + reindexed from 1 + assert [(c.order_index, c.title) for c in result.checkpoints] == [ + (1, "Module A: Select"), (2, "Module B: Joins"), + ] + + def test_html_fallback_when_no_jsonld(self, monkeypatch): + monkeypatch.setattr(base.requests, "get", lambda *a, **k: FakeResp(HEADINGS_HTML)) + result = extract_checkpoints("https://example.com/course") + assert result.source == "html" + assert len(result.checkpoints) == 3 + + def test_network_error_returns_empty_note_no_raise(self, monkeypatch): + def boom(*a, **k): + raise requests.ConnectionError("down") + monkeypatch.setattr(base.requests, "get", boom) + result = extract_checkpoints("https://example.com/x") + assert result.checkpoints == [] + assert result.note # human-readable fallback message + assert result.source == "manual" + + def test_non_http_scheme_rejected(self): + result = extract_checkpoints("ftp://example.com/x") + assert result.checkpoints == [] + + def test_empty_url(self): + assert extract_checkpoints("").checkpoints == [] + + def test_youtube_without_key_routes_to_youtube_note(self, monkeypatch): + monkeypatch.delenv("YOUTUBE_API_KEY", raising=False) + result = extract_checkpoints("https://www.youtube.com/watch?v=abc") + assert result.checkpoints == [] + assert "YOUTUBE_API_KEY" in result.note + + def test_youtube_with_key_parses_chapters(self, monkeypatch): + monkeypatch.setenv("YOUTUBE_API_KEY", "test-key") + payload = ( + '{"items":[{"snippet":{"description":"0:00 Intro\\n5:00 Core\\n"}}]}' + ) + monkeypatch.setattr(youtube.requests, "get", lambda *a, **k: FakeResp(payload)) + result = extract_checkpoints("https://www.youtube.com/watch?v=abc") + assert result.source == "youtube_api" + assert [c.title for c in result.checkpoints] == ["Intro", "Core"] + + def test_page_too_unstructured_returns_manual_note(self, monkeypatch): + monkeypatch.setattr( + base.requests, "get", + lambda *a, **k: FakeResp("

marketing

"), + ) + result = extract_checkpoints("https://www.udemy.com/course/x") + assert result.checkpoints == [] + assert "manually" in result.note diff --git a/apps/resources/tests/test_resources.py b/apps/resources/tests/test_resources.py new file mode 100644 index 0000000000000000000000000000000000000000..df2ea4be1302522120a4600d80df95a7d8546385 --- /dev/null +++ b/apps/resources/tests/test_resources.py @@ -0,0 +1,213 @@ +import pytest +from django.contrib.auth import get_user_model +from django.contrib.admin.sites import AdminSite +from django.contrib.messages.storage.fallback import FallbackStorage +from django.test import RequestFactory +from rest_framework import status +from rest_framework.test import APIClient + +from apps.resources.admin import ResourceAdmin, ResourceAdminForm +from apps.resources.models import Resource, ResourceCheckpoint, SkillResource +from apps.skills.models import Skill + + +def _admin_request(method='post', path='/admin/'): + """Build a RequestFactory request with messages storage wired up so + admin views that call self.message_user don't explode.""" + request = getattr(RequestFactory(), method)(path) + setattr(request, 'session', {}) + setattr(request, '_messages', FallbackStorage(request)) + return request + +User = get_user_model() +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def user(): + return User.objects.create_user( + username='r@x.com', email='r@x.com', password='pw', name='R', + ) + + +@pytest.fixture +def auth_client(user): + c = APIClient() + c.force_authenticate(user=user) + return c + + +@pytest.fixture +def python_skill(): + return Skill.objects.create(skill_name='Python', category='Programming', difficulty_level='BEGINNER') + + +@pytest.fixture +def sql_skill(): + return Skill.objects.create(skill_name='SQL', category='Database', difficulty_level='BEGINNER') + + +@pytest.fixture +def resource(python_skill): + r = Resource.objects.create( + title='Python for Everybody', + provider='Coursera', + url='https://coursera.org/python-for-everybody', + difficulty_level='BEGINNER', + duration=1200, + type='COURSE', + rating=4.7, + ) + SkillResource.objects.create(skill=python_skill, resource=r, relevance_score=0.95) + return r + + +class TestResourceAPI: + def test_list_requires_auth(self): + assert APIClient().get('/api/resources/').status_code == status.HTTP_401_UNAUTHORIZED + + def test_list_resources(self, auth_client, resource): + response = auth_client.get('/api/resources/') + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 1 + results = response.data['results'] + assert results[0]['title'] == 'Python for Everybody' + assert results[0]['has_checkpoints'] is False + + def test_filter_by_skill(self, auth_client, resource, python_skill, sql_skill): + # Another resource mapped to SQL only. + other = Resource.objects.create( + title='SQL Tutorial', provider='W3Schools', + url='https://w3schools.com/sql', + difficulty_level='BEGINNER', duration=60, type='DOCS', + ) + SkillResource.objects.create(skill=sql_skill, resource=other, relevance_score=0.8) + + response = auth_client.get(f'/api/resources/?skill={python_skill.id}') + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 1 + assert response.data['results'][0]['id'] == resource.id + + def test_filter_by_type_and_level(self, auth_client, resource): + Resource.objects.create( + title='Python Basics Video', provider='YouTube', + url='https://youtube.com/p1', + difficulty_level='ADVANCED', duration=30, type='VIDEO', + ) + r1 = auth_client.get('/api/resources/?type=COURSE') + assert r1.data['count'] == 1 and r1.data['results'][0]['id'] == resource.id + + r2 = auth_client.get('/api/resources/?difficulty_level=ADVANCED') + assert r2.data['count'] == 1 and r2.data['results'][0]['title'] == 'Python Basics Video' + + def test_detail_includes_checkpoints_and_skills(self, auth_client, resource, python_skill): + ResourceCheckpoint.objects.create(resource=resource, order_index=1, title='Intro') + ResourceCheckpoint.objects.create(resource=resource, order_index=2, title='Variables') + response = auth_client.get(f'/api/resources/{resource.id}/') + assert response.status_code == status.HTTP_200_OK + assert len(response.data['checkpoints']) == 2 + assert response.data['checkpoints'][0]['order_index'] == 1 + assert len(response.data['skill_mappings']) == 1 + assert response.data['skill_mappings'][0]['skill']['skill_name'] == 'Python' + assert response.data['skill_mappings'][0]['relevance_score'] == 0.95 + + +class TestModelConstraints: + def test_url_unique(self, resource): + with pytest.raises(Exception): + Resource.objects.create( + title='Dup', provider='X', url=resource.url, + difficulty_level='BEGINNER', duration=10, type='DOCS', + ) + + def test_checkpoint_order_unique_per_resource(self, resource): + ResourceCheckpoint.objects.create(resource=resource, order_index=1, title='A') + with pytest.raises(Exception): + ResourceCheckpoint.objects.create(resource=resource, order_index=1, title='B') + + def test_same_order_ok_across_resources(self, python_skill): + r1 = Resource.objects.create(title='R1', provider='P', url='https://a/x', + difficulty_level='BEGINNER', duration=10, type='DOCS') + r2 = Resource.objects.create(title='R2', provider='P', url='https://a/y', + difficulty_level='BEGINNER', duration=10, type='DOCS') + ResourceCheckpoint.objects.create(resource=r1, order_index=1, title='X') + ResourceCheckpoint.objects.create(resource=r2, order_index=1, title='Y') + assert ResourceCheckpoint.objects.count() == 2 + + +class TestResourceAdminBulkPaste: + def test_bulk_paste_appends_checkpoints(self, resource): + site = AdminSite() + admin = ResourceAdmin(Resource, site) + request = _admin_request() + request.user = User.objects.create_superuser( + username='admin@x.com', email='admin@x.com', password='pw', + ) + form = ResourceAdminForm( + instance=resource, + data={ + 'title': resource.title, 'provider': resource.provider, + 'url': resource.url, 'difficulty_level': resource.difficulty_level, + 'duration': resource.duration, 'type': resource.type, + 'rating': resource.rating, + 'bulk_checkpoints': 'Module 1: Intro\n\nModule 2: Variables\n \nModule 3: Loops', + }, + ) + assert form.is_valid(), form.errors + admin.save_model(request, resource, form, change=True) + + cps = list(resource.checkpoints.order_by('order_index')) + assert [c.title for c in cps] == [ + 'Module 1: Intro', 'Module 2: Variables', 'Module 3: Loops', + ] + assert [c.order_index for c in cps] == [1, 2, 3] + assert all(c.source == 'manual' for c in cps) + + def test_bulk_paste_refused_when_checkpoints_exist(self, resource): + """Re-pasting onto a resource that already has checkpoints is + rejected — admins must edit the inline rows instead. Prevents + silent duplication and orphaning of UserCheckpointProgress.""" + ResourceCheckpoint.objects.create(resource=resource, order_index=1, title='Existing') + site = AdminSite() + admin = ResourceAdmin(Resource, site) + request = _admin_request() + request.user = User.objects.create_superuser( + username='admin2@x.com', email='admin2@x.com', password='pw', + ) + form = ResourceAdminForm( + instance=resource, + data={ + 'title': resource.title, 'provider': resource.provider, + 'url': resource.url, 'difficulty_level': resource.difficulty_level, + 'duration': resource.duration, 'type': resource.type, + 'rating': resource.rating, + 'bulk_checkpoints': 'New A\nNew B', + }, + ) + assert form.is_valid(), form.errors + admin.save_model(request, resource, form, change=True) + + # Only the pre-existing checkpoint remains — the paste was rejected. + orders = list(resource.checkpoints.order_by('order_index').values_list('order_index', 'title')) + assert orders == [(1, 'Existing')] + + def test_bulk_paste_empty_noop(self, resource): + site = AdminSite() + admin = ResourceAdmin(Resource, site) + request = _admin_request() + request.user = User.objects.create_superuser( + username='admin3@x.com', email='admin3@x.com', password='pw', + ) + form = ResourceAdminForm( + instance=resource, + data={ + 'title': resource.title, 'provider': resource.provider, + 'url': resource.url, 'difficulty_level': resource.difficulty_level, + 'duration': resource.duration, 'type': resource.type, + 'rating': resource.rating, + 'bulk_checkpoints': ' \n\n ', + }, + ) + assert form.is_valid(), form.errors + admin.save_model(request, resource, form, change=True) + assert resource.checkpoints.count() == 0 diff --git a/apps/resources/tests/test_seed_resources.py b/apps/resources/tests/test_seed_resources.py new file mode 100644 index 0000000000000000000000000000000000000000..e25f966407e126de99db02d74d4a8e956c748076 --- /dev/null +++ b/apps/resources/tests/test_seed_resources.py @@ -0,0 +1,311 @@ +"""Tests for python manage.py seed_initial_resources. + +See research/08-next-modules-build-plan.md §Module A seed test list. +""" +from io import StringIO +from pathlib import Path + +import pytest +import yaml +from django.core.management import call_command +from django.core.management.base import CommandError + +from apps.resources.models import Resource, ResourceCheckpoint, SkillResource +from apps.skills.models import Skill + +pytestmark = pytest.mark.django_db + + +def _write_yaml(tmp_path: Path, resources: list[dict]) -> Path: + p = tmp_path / "resources.yaml" + p.write_text(yaml.safe_dump({"resources": resources}), encoding="utf-8") + return p + + +_MISSING = object() + + +def _entry(title='Python Intro', url='https://example.com/py', skills=_MISSING, + type_='COURSE', difficulty='BEGINNER', rating=4.0, duration=60): + if skills is _MISSING: + skills = [{'skill_name': 'Python', 'relevance_score': 0.9}] + return { + 'title': title, + 'provider': 'TestProvider', + 'url': url, + 'difficulty_level': difficulty, + 'duration': duration, + 'type': type_, + 'rating': rating, + 'skills': skills, + } + + +@pytest.fixture +def python_skill(db): + return Skill.objects.create( + skill_name='Python', category='Programming', difficulty_level='BEGINNER', + ) + + +@pytest.fixture +def sql_skill(db): + return Skill.objects.create( + skill_name='SQL', category='Database', difficulty_level='BEGINNER', + ) + + +def _run(path, prune=False): + out = StringIO() + args = ['seed_initial_resources', '--path', str(path)] + if prune: + args.append('--prune') + call_command(*args, stdout=out) + return out.getvalue() + + +def test_seed_is_idempotent(tmp_path, python_skill): + path = _write_yaml(tmp_path, [_entry()]) + _run(path) + first_count = Resource.objects.count() + first_links = SkillResource.objects.count() + + _run(path) + assert Resource.objects.count() == first_count + assert SkillResource.objects.count() == first_links + + +def test_seed_creates_skill_links(tmp_path, python_skill, sql_skill): + path = _write_yaml(tmp_path, [ + _entry( + title='Multi', url='https://example.com/multi', + skills=[ + {'skill_name': 'Python', 'relevance_score': 0.9}, + {'skill_name': 'SQL', 'relevance_score': 0.5}, + ], + ), + ]) + _run(path) + resource = Resource.objects.get(url='https://example.com/multi') + linked = { + link.skill.skill_name: link.relevance_score + for link in SkillResource.objects.filter(resource=resource) + } + assert linked == {'Python': 0.9, 'SQL': 0.5} + + +def test_seed_updates_existing_row(tmp_path, python_skill): + path = _write_yaml(tmp_path, [_entry(rating=3.0)]) + _run(path) + first = Resource.objects.get(url='https://example.com/py') + assert first.rating == 3.0 + + # Same URL, new rating → update, not duplicate. + path2 = _write_yaml(tmp_path, [_entry(rating=4.8)]) + _run(path2) + again = Resource.objects.get(url='https://example.com/py') + assert again.id == first.id + assert again.rating == 4.8 + + +def test_prune_removes_stale_resources_and_links(tmp_path, python_skill): + path = _write_yaml(tmp_path, [ + _entry(title='Keep', url='https://example.com/keep'), + _entry(title='Drop', url='https://example.com/drop'), + ]) + _run(path) + assert Resource.objects.count() == 2 + + path2 = _write_yaml(tmp_path, [ + _entry(title='Keep', url='https://example.com/keep'), + ]) + _run(path2, prune=True) + + assert set(Resource.objects.values_list('url', flat=True)) == { + 'https://example.com/keep', + } + # SkillResource rows for the deleted resource are cascaded out. + assert not SkillResource.objects.filter( + resource__url='https://example.com/drop' + ).exists() + + +def test_prune_removes_stale_skill_links_within_same_resource(tmp_path, + python_skill, + sql_skill): + """Running seed against a reduced skill list on an existing resource should + drop the orphan SkillResource rows — not leave them stranded.""" + path = _write_yaml(tmp_path, [ + _entry( + title='Multi', url='https://example.com/multi', + skills=[ + {'skill_name': 'Python', 'relevance_score': 0.9}, + {'skill_name': 'SQL', 'relevance_score': 0.5}, + ], + ), + ]) + _run(path) + assert SkillResource.objects.count() == 2 + + path2 = _write_yaml(tmp_path, [ + _entry( + title='Multi', url='https://example.com/multi', + skills=[{'skill_name': 'Python', 'relevance_score': 0.9}], + ), + ]) + _run(path2) + remaining = list(SkillResource.objects.values_list('skill__skill_name', flat=True)) + assert remaining == ['Python'] + + +def test_seed_is_transactional_on_bad_row(tmp_path, python_skill): + """Validation abort happens before any writes, so partial writes can't leak.""" + path = _write_yaml(tmp_path, [ + _entry(title='Good', url='https://example.com/good'), + { # missing most fields + 'title': 'Bad', + 'provider': 'X', + 'url': 'https://example.com/bad', + }, + ]) + with pytest.raises(CommandError): + _run(path) + assert Resource.objects.count() == 0 + + +def test_seed_rejects_unknown_skill(tmp_path, python_skill): + path = _write_yaml(tmp_path, [ + _entry( + title='Unknown skill', + url='https://example.com/unknown', + skills=[{'skill_name': 'NotAnOnetSkill', 'relevance_score': 0.9}], + ), + ]) + with pytest.raises(CommandError) as exc: + _run(path) + assert 'NotAnOnetSkill' in str(exc.value) + assert Resource.objects.count() == 0 + + +def test_seed_rejects_invalid_type(tmp_path, python_skill): + path = _write_yaml(tmp_path, [_entry(type_='PODCAST')]) + with pytest.raises(CommandError): + _run(path) + + +def test_seed_rejects_duplicate_url_in_yaml(tmp_path, python_skill): + path = _write_yaml(tmp_path, [ + _entry(title='A', url='https://example.com/dup'), + _entry(title='B', url='https://example.com/dup'), + ]) + with pytest.raises(CommandError) as exc: + _run(path) + assert 'Duplicate url' in str(exc.value) + + +def test_seed_rejects_empty_skills_list(tmp_path, python_skill): + path = _write_yaml(tmp_path, [_entry(skills=[])]) + with pytest.raises(CommandError) as exc: + _run(path) + assert 'skills' in str(exc.value).lower() + + +def test_seed_creates_checkpoints_from_yaml(tmp_path, python_skill): + entry = _entry(url='https://example.com/cp') + entry['checkpoints'] = ['Week 1: Intro', 'Week 2: Loops', 'Week 3: Done'] + entry['checkpoint_source'] = 'jsonld' + path = _write_yaml(tmp_path, [entry]) + _run(path) + resource = Resource.objects.get(url='https://example.com/cp') + rows = list(resource.checkpoints.order_by('order_index')) + assert [r.title for r in rows] == ['Week 1: Intro', 'Week 2: Loops', 'Week 3: Done'] + assert [r.order_index for r in rows] == [1, 2, 3] + assert all(r.source == 'jsonld' for r in rows) + + +def test_seed_checkpoints_create_if_absent_no_clobber(tmp_path, python_skill): + """Re-running seed must NOT duplicate or replace existing checkpoints — + UserCheckpointProgress FKs them and a re-seed can't drop a learner's rows.""" + entry = _entry(url='https://example.com/cp') + entry['checkpoints'] = ['A', 'B'] + path = _write_yaml(tmp_path, [entry]) + _run(path) + resource = Resource.objects.get(url='https://example.com/cp') + first_ids = set(resource.checkpoints.values_list('id', flat=True)) + assert len(first_ids) == 2 + + # Re-run with a DIFFERENT list: existing rows are untouched (create-if-absent). + entry2 = _entry(url='https://example.com/cp') + entry2['checkpoints'] = ['A', 'B', 'C', 'D'] + _run(_write_yaml(tmp_path, [entry2])) + again = set(resource.checkpoints.values_list('id', flat=True)) + assert again == first_ids # unchanged — same rows, no dup, no delete + + +def test_seed_applies_checkpoints_sidecar_by_url(tmp_path, python_skill): + rpath = _write_yaml(tmp_path, [_entry(url='https://example.com/side')]) + side = tmp_path / "checkpoints.yaml" + side.write_text(yaml.safe_dump({"checkpoints": [ + {"url": "https://example.com/side", "source": "jsonld", + "titles": ["Mod 1", "Mod 2", "Mod 3"]}, + {"url": "https://example.com/not-in-catalog", # stale entry: skipped + "titles": ["X"]}, + ]}), encoding="utf-8") + call_command('seed_initial_resources', '--path', str(rpath), + '--checkpoints-path', str(side), stdout=StringIO(), stderr=StringIO()) + resource = Resource.objects.get(url='https://example.com/side') + rows = list(resource.checkpoints.order_by('order_index')) + assert [r.title for r in rows] == ['Mod 1', 'Mod 2', 'Mod 3'] + assert all(r.source == 'jsonld' for r in rows) + + +def test_seed_sidecar_invalid_source_raises(tmp_path, python_skill): + rpath = _write_yaml(tmp_path, [_entry(url='https://example.com/side')]) + side = tmp_path / "checkpoints.yaml" + side.write_text(yaml.safe_dump({"checkpoints": [ + {"url": "https://example.com/side", "source": "nope", "titles": ["A"]}, + ]}), encoding="utf-8") + with pytest.raises(CommandError): + call_command('seed_initial_resources', '--path', str(rpath), + '--checkpoints-path', str(side), + stdout=StringIO(), stderr=StringIO()) + + +def test_seed_rejects_invalid_checkpoint_source(tmp_path, python_skill): + entry = _entry(url='https://example.com/cp') + entry['checkpoints'] = ['A', 'B'] + entry['checkpoint_source'] = 'bogus' + path = _write_yaml(tmp_path, [entry]) + with pytest.raises(CommandError) as exc: + _run(path) + assert 'checkpoint_source' in str(exc.value) + assert not ResourceCheckpoint.objects.exists() + + +def test_full_curated_catalog_loads_cleanly(): + """Sanity: the real seed_data/resources.yaml loads after seed_initial_skills, + and produces ≥60 resources with ≥1 skill link each.""" + # Seed skills from the curated role file so references resolve. + call_command('seed_initial_skills', stdout=StringIO()) + out = StringIO() + call_command('seed_initial_resources', stdout=out) + + count = Resource.objects.count() + assert count >= 60, f'expected ≥60 resources, got {count}' + # Every resource has ≥1 skill link. + orphans = [ + r.title for r in Resource.objects.all() + if not SkillResource.objects.filter(resource=r).exists() + ] + assert orphans == [] + + # The checkpoints.yaml sidecar populated the catalog: every COURSE/VIDEO + # resource ends with >=2 checkpoints (the guarantee check_checkpoints gates). + from django.db.models import Count + thin = list( + Resource.objects.filter(type__in=['COURSE', 'VIDEO']) + .annotate(n=Count('checkpoints')) + .filter(n__lt=2) + .values_list('title', flat=True) + ) + assert thin == [], f'COURSE/VIDEO resources with <2 checkpoints: {thin}' diff --git a/apps/resources/urls.py b/apps/resources/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..dfc3fb88cc6db1162b958336fda279466b84856d --- /dev/null +++ b/apps/resources/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import ResourceDetailView, ResourceListView + +urlpatterns = [ + path('', ResourceListView.as_view(), name='resource-list'), + path('/', ResourceDetailView.as_view(), name='resource-detail'), +] diff --git a/apps/resources/views.py b/apps/resources/views.py new file mode 100644 index 0000000000000000000000000000000000000000..023dcfdfdef8ae170a3023cc60531557e3af2cc6 --- /dev/null +++ b/apps/resources/views.py @@ -0,0 +1,277 @@ +from django.db import IntegrityError, transaction +from django.db.models import Exists, OuterRef +from django.utils import timezone +from django_filters import FilterSet, NumberFilter +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import generics, permissions, status, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from config.mixins import IntegrityConflictMixin +from config.pagination import StandardResultsSetPagination + +from .importers import extract_checkpoints +from .models import Resource, ResourceCheckpoint, SkillResource +from .serializers import ( + ResourceAdminSerializer, + ResourceCheckpointAdminSerializer, + ResourceDetailSerializer, + ResourceListSerializer, + SkillResourceAdminSerializer, +) + + +class ResourceFilterSet(FilterSet): + skill = NumberFilter(field_name='skillresource__skill_id', distinct=True) + + class Meta: + model = Resource + fields = { + 'provider': ['exact'], + 'type': ['exact'], + 'difficulty_level': ['exact'], + } + + +class ResourceListView(generics.ListAPIView): + # Annotate `has_checkpoints` via an Exists subquery so the serializer can + # read it as a plain field — avoids an N+1 from `obj.checkpoints.exists()` + # on every row (.exists() bypasses any prefetch cache). + queryset = Resource.objects.annotate( + has_checkpoints=Exists( + ResourceCheckpoint.objects.filter(resource=OuterRef('pk')) + ) + ) + serializer_class = ResourceListSerializer + permission_classes = [permissions.IsAuthenticated] + filter_backends = [DjangoFilterBackend] + filterset_class = ResourceFilterSet + pagination_class = StandardResultsSetPagination + + +class ResourceDetailView(generics.RetrieveAPIView): + queryset = Resource.objects.all().prefetch_related('checkpoints') + serializer_class = ResourceDetailSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_serializer_context(self): + """Preload the current user's completed-checkpoint IDs for this resource. + + Without this, the frontend checkpoint checkboxes render unchecked + on every page load even after the user has ticked them — the biggest + UX wart flagged in the 7b polish list. + """ + ctx = super().get_serializer_context() + resource_id = self.kwargs.get('pk') + if resource_id and self.request.user.is_authenticated: + # Import here to avoid a circular import from progress->resources. + from apps.progress.models import UserCheckpointProgress + ctx['completed_checkpoint_ids'] = set( + UserCheckpointProgress.objects + .filter( + user=self.request.user, + checkpoint__resource_id=resource_id, + completed_at__isnull=False, + ) + .values_list('checkpoint_id', flat=True) + ) + return ctx + + +class ResourceAdminViewSet(IntegrityConflictMixin, viewsets.ModelViewSet): + """Admin-only CRUD on Resource. Cascade (model-level) cleans up linked + SkillResource and ResourceCheckpoint rows on delete — but a delete is + refused (409) while any student has tracked progress on the resource, so + deleting never silently discards a learner's history. + """ + queryset = Resource.objects.all().order_by('title') + serializer_class = ResourceAdminSerializer + permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] + pagination_class = StandardResultsSetPagination + conflict_detail = 'A resource with this URL already exists.' + + def destroy(self, request, *args, **kwargs): + resource = self.get_object() + # Import locally (matches ResourceDetailView's pattern) and to keep the + # resources<->progress dependency one-directional. UserProgress is the + # sufficient signal: CheckpointToggleView always get_or_creates a + # UserProgress before any UserCheckpointProgress, so UCP ⟹ UP. + from apps.progress.models import UserProgress + if UserProgress.objects.filter(resource=resource).exists(): + return Response( + {'detail': ( + 'Cannot delete: one or more students have tracked progress ' + 'on this resource. Unlink it from roles instead.' + )}, + status=status.HTTP_409_CONFLICT, + ) + return super().destroy(request, *args, **kwargs) + + +class ResourceCheckpointAdminViewSet(viewsets.ModelViewSet): + """Admin-only CRUD on ResourceCheckpoint. + + Accepts `?resource=` for scoped fetches. The `bulk` action mirrors + the Django admin's textarea paste at apps/resources/admin.py:58-92 — + splits the input on newlines and creates N rows transactionally. + """ + queryset = ResourceCheckpoint.objects.all().order_by('resource_id', 'order_index') + serializer_class = ResourceCheckpointAdminSerializer + permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] + pagination_class = None + + def get_queryset(self): + qs = super().get_queryset() + resource_id = self.request.query_params.get('resource') + if resource_id: + # Guard non-numeric input: filtering on a non-int pk raises a + # ValueError at query prep (→ 500). isascii() also rejects Unicode + # digits (e.g. '²') that isdigit() accepts but int() can't parse. + qs = qs.filter(resource_id=resource_id) if ( + str(resource_id).isascii() and str(resource_id).isdigit() + ) else qs.none() + return qs + + @action(detail=False, methods=['post'], url_path='bulk') + def bulk(self, request): + resource_id = request.data.get('resource') + text = (request.data.get('bulk') or '').strip() + # `source` records provenance for imported lists (jsonld/html/youtube_api). + # bulk_create below bypasses model `choices` validation (Django choices + # are app-level, not a DB constraint), so validate it explicitly here. + source = request.data.get('source') or 'manual' + if source not in dict(ResourceCheckpoint.SOURCE_CHOICES): + return Response( + {'detail': f'Invalid source {source!r}.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not resource_id: + return Response( + {'detail': 'resource is required.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + resource = Resource.objects.get(pk=resource_id) + except Resource.DoesNotExist: + return Response( + {'detail': 'Resource not found.'}, + status=status.HTTP_404_NOT_FOUND, + ) + except (ValueError, TypeError): + # Non-numeric resource id in the body → ValueError at pk lookup. + return Response( + {'detail': 'resource must be a numeric id.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + lines = [line.strip() for line in text.splitlines() if line.strip()] + if not lines: + return Response( + {'detail': 'No non-empty lines provided.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Match Django admin behavior (apps/resources/admin.py:66-77): refuse + # bulk-append when checkpoints already exist. Edit existing rows via + # the per-row CRUD endpoint instead of bulk-adding. + if resource.checkpoints.exists(): + return Response( + {'detail': ( + 'Resource already has checkpoints. Edit individual rows ' + 'via the checkpoint endpoints instead of bulk-adding.' + )}, + status=status.HTTP_409_CONFLICT, + ) + # The exists() check above and the bulk_create below are a TOCTOU: two + # concurrent bulk pastes both see no checkpoints, then collide on + # unique_together(resource, order_index). Catch the loser's + # IntegrityError outside the atomic (clean rollback) → 409 not 500. + # Stamp extraction time for non-manual (imported) lists; manual stays null. + extracted_at = timezone.now() if source != 'manual' else None + try: + with transaction.atomic(): + created = ResourceCheckpoint.objects.bulk_create([ + ResourceCheckpoint( + resource=resource, + order_index=i, + title=line, + source=source, + extracted_at=extracted_at, + ) + for i, line in enumerate(lines, start=1) + ]) + except IntegrityError: + return Response( + {'detail': ( + 'Checkpoints were just added for this resource. Edit ' + 'individual rows via the checkpoint endpoints instead.' + )}, + status=status.HTTP_409_CONFLICT, + ) + serializer = self.get_serializer(created, many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=['post'], url_path='import') + def import_outline(self, request): + """Fetch a resource's course page and return a *draft* checkpoint list. + + Read-only: writes nothing. The admin reviews/edits the returned titles, + then persists them through the `bulk` action (which validates `source`). + `extract_checkpoints` never raises — a blocked/empty page comes back as + an empty list + a human `note` so the admin falls back to manual. + """ + resource_id = request.data.get('resource') + if not resource_id: + return Response( + {'detail': 'resource is required.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + resource = Resource.objects.get(pk=resource_id) + except Resource.DoesNotExist: + return Response( + {'detail': 'Resource not found.'}, + status=status.HTTP_404_NOT_FOUND, + ) + except (ValueError, TypeError): + return Response( + {'detail': 'resource must be a numeric id.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + result = extract_checkpoints(resource.url) + return Response( + { + 'source': result.source, + 'provider': result.provider, + 'note': result.note, + 'checkpoints': [ + { + 'order_index': c.order_index, + 'title': c.title, + 'url_fragment': c.url_fragment, + 'estimated_minutes': c.estimated_minutes, + } + for c in result.checkpoints + ], + }, + status=status.HTTP_200_OK, + ) + + +class SkillResourceAdminViewSet(IntegrityConflictMixin, viewsets.ModelViewSet): + """Admin-only CRUD on the Skill <-> Resource junction (with relevance).""" + queryset = SkillResource.objects.all().select_related('skill', 'resource') + serializer_class = SkillResourceAdminSerializer + permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] + pagination_class = None + conflict_detail = 'This skill is already linked to the resource.' + + def get_queryset(self): + qs = super().get_queryset() + resource_id = self.request.query_params.get('resource') + if resource_id: + # Guard non-numeric input: filtering on a non-int pk raises a + # ValueError at query prep (→ 500). isascii() also rejects Unicode + # digits (e.g. '²') that isdigit() accepts but int() can't parse. + qs = qs.filter(resource_id=resource_id) if ( + str(resource_id).isascii() and str(resource_id).isdigit() + ) else qs.none() + return qs diff --git a/apps/roles/__init__.py b/apps/roles/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/roles/admin.py b/apps/roles/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..1ceeea6d21fa5bff794cb67b13e71e1a7cf0d26a --- /dev/null +++ b/apps/roles/admin.py @@ -0,0 +1,248 @@ +from pathlib import Path + +import yaml +from django.conf import settings +from django.contrib import admin, messages +from django.contrib.admin.models import ADDITION, CHANGE, LogEntry +from django.db import transaction + +from apps.skills.models import Skill + +from .models import Role, RoleSkill, UserTargetRole, normalize_roleskill_fields + +CURATED_YAML = Path(settings.BASE_DIR) / "seed_data" / "onet_roles_curated.yaml" + + +class RoleSkillInline(admin.TabularInline): + model = RoleSkill + extra = 0 + fields = ('skill', 'required_level', 'weight', 'is_mandatory') + readonly_fields = () + autocomplete_fields = ['skill'] + + def has_add_permission(self, request, obj=None): + # Disallow adding RoleSkills while the parent Role is still being + # created — FK integrity forces the Role to be saved first. + return obj is not None + + +def _log_admin_action(request, objects, action_flag, message_per_object): + """Emit admin.LogEntry rows for a bulk admin action. + + Django's standard change_view auto-creates LogEntry, but bulk actions do + not — without this, Module 7 admin CRUD has a gap in its audit trail for + the O*NET import and role duplication flows. + """ + for obj in objects: + LogEntry.objects.log_actions( + user_id=request.user.pk, + queryset=[obj], + action_flag=action_flag, + change_message=message_per_object(obj), + single_object=True, + ) + + +def _load_curated_yaml(): + if not CURATED_YAML.exists(): + return None + with CURATED_YAML.open(encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def _match_curated_role(role, curated): + """Return the curated YAML entry that matches the given Role instance. + + Match precedence: (1) primary_soc == role.onet_soc_code, + (2) role.onet_soc_code in all_socs, (3) case-insensitive name match. + """ + if not curated: + return None + entries = curated.get("roles") or [] + soc = (role.onet_soc_code or "").strip() + if soc: + for entry in entries: + if (entry.get("primary_soc") or "").strip() == soc: + return entry + for entry in entries: + if soc in (entry.get("all_socs") or []): + return entry + name = (role.role_name or "").strip().lower() + for entry in entries: + if (entry.get("name") or "").strip().lower() == name: + return entry + return None + + +@admin.register(Role) +class RoleAdmin(admin.ModelAdmin): + list_display = ('role_name', 'industry', 'onet_soc_code', 'is_active') + list_filter = ('industry', 'is_active') + search_fields = ('role_name', 'description', 'onet_soc_code') + inlines = [RoleSkillInline] + actions = ['import_skills_from_onet', 'duplicate_role_with_skills'] + + @admin.action(description="Import skills from O*NET (pre-fill RoleSkills)") + def import_skills_from_onet(self, request, queryset): + curated = _load_curated_yaml() + if curated is None: + self.message_user( + request, + f"Curated O*NET YAML not found at {CURATED_YAML}. " + "Run scripts/parse_onet_dump.py + curate_roles.py first.", + level=messages.ERROR, + ) + return + + roles_matched = 0 + skills_created = 0 + skills_skipped_existing = 0 + skills_missing_catalog = [] + skills_invalid = [] + roles_unmatched = [] + per_role_added: dict[int, int] = {} + + with transaction.atomic(): + for role in queryset: + entry = _match_curated_role(role, curated) + if not entry: + roles_unmatched.append(role.role_name) + continue + roles_matched += 1 + added_this_role = 0 + for skill_entry in entry.get("skills") or []: + name = skill_entry.get("skill_name") + if not name: + continue + try: + skill = Skill.objects.get(skill_name=name) + except Skill.DoesNotExist: + skills_missing_catalog.append(f"{role.role_name}:{name}") + continue + is_mandatory = bool(skill_entry.get("is_mandatory", False)) + level, weight, error = normalize_roleskill_fields( + skill_entry.get("required_level"), + skill_entry.get("weight", 1.0), + is_mandatory, + ) + if error: + # Skip+report rather than full_clean inside the atomic + # block — one bad row must not abort the whole import. + skills_invalid.append(f"{role.role_name}:{name} ({error})") + continue + _, created = RoleSkill.objects.get_or_create( + role=role, + skill=skill, + defaults={ + "required_level": level, + "weight": weight, + "is_mandatory": is_mandatory, + }, + ) + if created: + skills_created += 1 + added_this_role += 1 + else: + skills_skipped_existing += 1 + if added_this_role: + per_role_added[role.pk] = added_this_role + + _log_admin_action( + request, queryset, + action_flag=CHANGE, + message_per_object=lambda r: ( + f"O*NET import: {per_role_added.get(r.pk, 0)} RoleSkill row(s) added." + if r.pk in per_role_added + else "O*NET import: no new RoleSkill rows (already present or unmatched)." + ), + ) + + self.message_user( + request, + f"O*NET import: {roles_matched} role(s) matched, " + f"{skills_created} RoleSkill row(s) created, " + f"{skills_skipped_existing} already present.", + level=messages.SUCCESS if roles_matched else messages.WARNING, + ) + if roles_unmatched: + self.message_user( + request, + "No curated entry for: " + ", ".join(roles_unmatched) + + " (check onet_soc_code or role_name).", + level=messages.WARNING, + ) + if skills_missing_catalog: + self.message_user( + request, + "Skills missing from catalog (run seed_initial_skills): " + + ", ".join(skills_missing_catalog[:20]) + + ("..." if len(skills_missing_catalog) > 20 else ""), + level=messages.WARNING, + ) + if skills_invalid: + self.message_user( + request, + "Skipped invalid RoleSkill rows: " + + ", ".join(skills_invalid[:20]) + + ("..." if len(skills_invalid) > 20 else ""), + level=messages.WARNING, + ) + + @admin.action(description="Duplicate role + skills") + def duplicate_role_with_skills(self, request, queryset): + created_names = [] + new_roles = [] + with transaction.atomic(): + for role in queryset: + source_skills = list(role.role_skills.all()) + base_name = f"{role.role_name} (copy)" + new_name = base_name + suffix = 2 + while Role.objects.filter(role_name=new_name).exists(): + new_name = f"{base_name} {suffix}" + suffix += 1 + new_role = Role.objects.create( + role_name=new_name, + description=role.description, + industry=role.industry, + is_active=role.is_active, + onet_soc_code=role.onet_soc_code, + ) + RoleSkill.objects.bulk_create([ + RoleSkill( + role=new_role, + skill=rs.skill, + required_level=rs.required_level, + weight=rs.weight, + is_mandatory=rs.is_mandatory, + ) + for rs in source_skills + ]) + created_names.append(new_name) + new_roles.append((new_role, role, len(source_skills))) + + _log_admin_action( + request, [nr for nr, _, _ in new_roles], + action_flag=ADDITION, + message_per_object=lambda nr: next( + f"Duplicated from '{src.role_name}' with {n} RoleSkill row(s)." + for new, src, n in new_roles if new.pk == nr.pk + ), + ) + + if created_names: + self.message_user( + request, + f"Duplicated {len(created_names)} role(s): " + ", ".join(created_names), + level=messages.SUCCESS, + ) + else: + self.message_user(request, "No roles selected.", level=messages.WARNING) + + +@admin.register(UserTargetRole) +class UserTargetRoleAdmin(admin.ModelAdmin): + list_display = ('user', 'role', 'selected_at', 'is_active') + list_filter = ('is_active', 'selected_at') + search_fields = ('user__email', 'role__role_name') + readonly_fields = ('selected_at',) diff --git a/apps/roles/apps.py b/apps/roles/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..61a8d251cadb2f752cd97d541d1dd5596955acc1 --- /dev/null +++ b/apps/roles/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RolesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.roles' diff --git a/apps/roles/management/__init__.py b/apps/roles/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/roles/management/commands/__init__.py b/apps/roles/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/roles/management/commands/seed_initial_roles.py b/apps/roles/management/commands/seed_initial_roles.py new file mode 100644 index 0000000000000000000000000000000000000000..88a30635fe1bad6c82327c14064993eec47e7d71 --- /dev/null +++ b/apps/roles/management/commands/seed_initial_roles.py @@ -0,0 +1,120 @@ +from pathlib import Path + +import yaml +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from apps.roles.models import Role, RoleSkill, normalize_roleskill_fields +from apps.skills.models import Skill + +CURATED_YAML = Path(settings.BASE_DIR) / "seed_data" / "onet_roles_curated.yaml" + + +class Command(BaseCommand): + help = ( + "Seed Role + RoleSkill rows from seed_data/onet_roles_curated.yaml. " + "Run seed_initial_skills first so all referenced skills exist." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--path", + default=str(CURATED_YAML), + help="Path to curated roles YAML (default: backend/seed_data/onet_roles_curated.yaml)", + ) + parser.add_argument( + "--prune", + action="store_true", + help="Delete RoleSkill rows for a role that are not in the YAML (keeps role rows).", + ) + + def handle(self, *args, **options): + yaml_path = Path(options["path"]) + if not yaml_path.exists(): + raise CommandError(f"Curated YAML not found: {yaml_path}") + + with yaml_path.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + roles_data = data.get("roles") or [] + if not roles_data: + raise CommandError("No roles found in curated YAML") + + missing_skills = set() + for role in roles_data: + for entry in role.get("skills") or []: + name = entry["skill_name"] + if not Skill.objects.filter(skill_name=name).exists(): + missing_skills.add(name) + if missing_skills: + raise CommandError( + "Missing skills in DB (run `python manage.py seed_initial_skills` first): " + + ", ".join(sorted(missing_skills)) + ) + + roles_created = roles_updated = 0 + roleskills_created = roleskills_updated = roleskills_deleted = 0 + roleskills_invalid = [] + + with transaction.atomic(): + for role in roles_data: + role_obj, was_created = Role.objects.update_or_create( + role_name=role["name"], + defaults={ + "description": role.get("description", "") or "", + "industry": role.get("industry", "") or "", + "onet_soc_code": role.get("primary_soc", "") or "", + "is_active": True, + }, + ) + if was_created: + roles_created += 1 + else: + roles_updated += 1 + + seen_skill_ids = set() + for entry in role.get("skills") or []: + skill = Skill.objects.get(skill_name=entry["skill_name"]) + seen_skill_ids.add(skill.id) + is_mandatory = bool(entry.get("is_mandatory", False)) + level, weight, error = normalize_roleskill_fields( + entry.get("required_level"), + entry.get("weight", 1.0), + is_mandatory, + ) + if error: + roleskills_invalid.append( + f"{role['name']}:{entry['skill_name']} ({error})" + ) + continue + _, rs_created = RoleSkill.objects.update_or_create( + role=role_obj, + skill=skill, + defaults={ + "required_level": level, + "weight": weight, + "is_mandatory": is_mandatory, + }, + ) + if rs_created: + roleskills_created += 1 + else: + roleskills_updated += 1 + + if options["prune"]: + stale = RoleSkill.objects.filter(role=role_obj).exclude(skill_id__in=seen_skill_ids) + roleskills_deleted += stale.count() + stale.delete() + + if roleskills_invalid: + self.stdout.write(self.style.WARNING( + f"Skipped {len(roleskills_invalid)} invalid RoleSkill row(s): " + + ", ".join(roleskills_invalid[:20]) + + ("..." if len(roleskills_invalid) > 20 else "") + )) + self.stdout.write(self.style.SUCCESS( + f"Roles: {roles_created} created, {roles_updated} updated. " + f"RoleSkills: {roleskills_created} created, {roleskills_updated} updated, " + f"{roleskills_deleted} deleted." + )) diff --git a/apps/roles/migrations/0001_initial.py b/apps/roles/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..7a5623941c799765ecdb9b55626d47f10c6f9558 --- /dev/null +++ b/apps/roles/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 6.0.4 on 2026-04-07 19:48 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('skills', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role_name', models.CharField(max_length=255, unique=True)), + ('description', models.TextField(blank=True)), + ('industry', models.CharField(max_length=100)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name_plural': 'Roles', + }, + ), + migrations.CreateModel( + name='RoleSkill', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('required_level', models.CharField(choices=[('BEGINNER', 'Beginner'), ('INTERMEDIATE', 'Intermediate'), ('ADVANCED', 'Advanced')], max_length=20)), + ('weight', models.FloatField(default=1.0)), + ('is_mandatory', models.BooleanField(default=False)), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_skills', to='roles.role')), + ('skill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='skills.skill')), + ], + options={ + 'unique_together': {('role', 'skill')}, + }, + ), + migrations.CreateModel( + name='UserTargetRole', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('selected_at', models.DateTimeField(auto_now_add=True)), + ('is_active', models.BooleanField(default=True)), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='roles.role')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='target_roles', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-selected_at'], + 'indexes': [models.Index(fields=['user', 'is_active'], name='roles_usert_user_id_5dcb3e_idx')], + }, + ), + ] diff --git a/apps/roles/migrations/0002_role_onet_soc_code.py b/apps/roles/migrations/0002_role_onet_soc_code.py new file mode 100644 index 0000000000000000000000000000000000000000..4ef78f8861db8dfadaeb8dc4aa70552a92711a41 --- /dev/null +++ b/apps/roles/migrations/0002_role_onet_soc_code.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('roles', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='role', + name='onet_soc_code', + field=models.CharField(blank=True, default='', max_length=20), + ), + ] diff --git a/apps/roles/migrations/0003_usertargetrole_one_active_constraint.py b/apps/roles/migrations/0003_usertargetrole_one_active_constraint.py new file mode 100644 index 0000000000000000000000000000000000000000..4695d5ac77c8f0ea05f4541d0969a266d6599540 --- /dev/null +++ b/apps/roles/migrations/0003_usertargetrole_one_active_constraint.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('roles', '0002_role_onet_soc_code'), + ] + + operations = [ + migrations.AddConstraint( + model_name='usertargetrole', + constraint=models.UniqueConstraint( + fields=('user',), + condition=models.Q(('is_active', True)), + name='one_active_target_role_per_user', + ), + ), + ] diff --git a/apps/roles/migrations/0004_roleskill_unique_constraint.py b/apps/roles/migrations/0004_roleskill_unique_constraint.py new file mode 100644 index 0000000000000000000000000000000000000000..0cb60ec5d345ab7649626f62298261a72a6fef5e --- /dev/null +++ b/apps/roles/migrations/0004_roleskill_unique_constraint.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('roles', '0003_usertargetrole_one_active_constraint'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='roleskill', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='roleskill', + constraint=models.UniqueConstraint( + fields=['role', 'skill'], + name='unique_role_skill', + ), + ), + ] diff --git a/apps/roles/migrations/0005_alter_roleskill_weight_and_more.py b/apps/roles/migrations/0005_alter_roleskill_weight_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..676014e34e6542b860e1082ebc443932d89c7ea8 --- /dev/null +++ b/apps/roles/migrations/0005_alter_roleskill_weight_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.4 on 2026-06-04 17:30 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('roles', '0004_roleskill_unique_constraint'), + ('skills', '0004_alter_userskill_dismissed_upgrade_at'), + ] + + operations = [ + migrations.AlterField( + model_name='roleskill', + name='weight', + field=models.FloatField(default=1.0, validators=[django.core.validators.MinValueValidator(0.0)]), + ), + migrations.AddConstraint( + model_name='roleskill', + constraint=models.CheckConstraint(condition=models.Q(('weight__gte', 0)), name='roleskill_weight_nonneg'), + ), + migrations.AddConstraint( + model_name='roleskill', + constraint=models.CheckConstraint(condition=models.Q(('is_mandatory', False), ('weight__gt', 0), _connector='OR'), name='roleskill_mandatory_has_weight'), + ), + ] diff --git a/apps/roles/migrations/__init__.py b/apps/roles/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/roles/models.py b/apps/roles/models.py new file mode 100644 index 0000000000000000000000000000000000000000..795cba74deaab030a832dd45bc9c65f90283fc9a --- /dev/null +++ b/apps/roles/models.py @@ -0,0 +1,112 @@ +import math + +from django.db import models +from django.core.validators import MinValueValidator +from django.db.models import Q +from django.conf import settings + +AUTH_USER_MODEL = settings.AUTH_USER_MODEL + + +class Role(models.Model): + role_name = models.CharField(max_length=255, unique=True) + description = models.TextField(blank=True) + industry = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + onet_soc_code = models.CharField(max_length=20, blank=True, default="") + + class Meta: + verbose_name_plural = "Roles" + + def __str__(self): + return self.role_name + + +class RoleSkill(models.Model): + LEVEL_CHOICES = [ + ('BEGINNER', 'Beginner'), + ('INTERMEDIATE', 'Intermediate'), + ('ADVANCED', 'Advanced'), + ] + + role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name='role_skills') + skill = models.ForeignKey('skills.Skill', on_delete=models.CASCADE) + required_level = models.CharField(max_length=20, choices=LEVEL_CHOICES) + weight = models.FloatField(default=1.0, validators=[MinValueValidator(0.0)]) + is_mandatory = models.BooleanField(default=False) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['role', 'skill'], name='unique_role_skill'), + # F16/F17: weight must be non-negative, and a mandatory skill must + # carry positive weight (otherwise it is invisible to the weighted + # readiness aggregate). Enforced at the DB so every write path is + # covered uniformly — the seeder/import/bulk_create/DRF paths all + # skip Model.full_clean(), so a model clean() alone is insufficient. + models.CheckConstraint( + condition=Q(weight__gte=0), + name='roleskill_weight_nonneg', + ), + models.CheckConstraint( + condition=Q(is_mandatory=False) | Q(weight__gt=0), + name='roleskill_mandatory_has_weight', + ), + ] + + def __str__(self): + return f"{self.role.role_name} - {self.skill.skill_name} ({self.required_level})" + + +def normalize_roleskill_fields(required_level, weight, is_mandatory): + """Normalize + validate RoleSkill field values for the bulk write paths + (the O*NET import admin action and the role seeder) that bypass the DRF + serializer / Model.full_clean and would otherwise hit the DB CheckConstraints + as an IntegrityError that rolls back the whole atomic batch. + + Returns ``(level, weight, error)`` — ``error`` is None when the row is valid; + callers should skip+report rows with a non-None error rather than writing + them. Also guards the gap-engine ``LEVEL_THRESHOLDS[required_level]`` lookup + (a bad level would otherwise KeyError → 500 at read time).""" + valid_levels = {choice[0] for choice in RoleSkill.LEVEL_CHOICES} + # Strip first, then default — so "" and whitespace-only both fall back to + # BEGINNER consistently (rather than "" -> BEGINNER but " " -> error). + level = (required_level or "").strip().upper() or "BEGINNER" + if level not in valid_levels: + return None, None, f"invalid required_level {required_level!r}" + try: + w = float(weight if weight is not None else 1.0) + except (TypeError, ValueError): + return None, None, f"invalid weight {weight!r}" + if not math.isfinite(w): + # NaN/Infinity pass float() and slip every < comparison; reject here so + # they can't abort the atomic batch (constraint) or poison readiness. + return None, None, f"non-finite weight {weight!r}" + if w < 0: + return None, None, f"negative weight {w}" + if is_mandatory and w <= 0: + return None, None, f"mandatory skill must have weight > 0 (got {w})" + return level, w, None + + +class UserTargetRole(models.Model): + user = models.ForeignKey(AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='target_roles') + role = models.ForeignKey(Role, on_delete=models.CASCADE) + selected_at = models.DateTimeField(auto_now_add=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['-selected_at'] + indexes = [ + models.Index(fields=['user', 'is_active']), + ] + constraints = [ + # Enforces spec: at most one active target role per user (CLAUDE.md §76). + models.UniqueConstraint( + fields=['user'], + condition=Q(is_active=True), + name='one_active_target_role_per_user', + ), + ] + + def __str__(self): + return f"{self.user.username} -> {self.role.role_name}" diff --git a/apps/roles/serializers.py b/apps/roles/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..356884243712bfd65e02cad37cea9d4c9f2c6075 --- /dev/null +++ b/apps/roles/serializers.py @@ -0,0 +1,105 @@ +from rest_framework import serializers +from apps.skills.models import Skill +from apps.skills.serializers import SkillSerializer +from .models import Role, RoleSkill, UserTargetRole + + +class RoleListSerializer(serializers.ModelSerializer): + class Meta: + model = Role + fields = ['id', 'role_name', 'industry'] + + +class RoleSkillSerializer(serializers.ModelSerializer): + skill = SkillSerializer(read_only=True) + + class Meta: + model = RoleSkill + fields = ['id', 'skill', 'required_level', 'weight', 'is_mandatory'] + + +class RoleDetailSerializer(serializers.ModelSerializer): + role_skills = RoleSkillSerializer(many=True, read_only=True) + + class Meta: + model = Role + fields = ['id', 'role_name', 'description', 'industry', 'onet_soc_code', 'role_skills'] + + +class UserTargetRoleSerializer(serializers.ModelSerializer): + role = RoleListSerializer(read_only=True) + role_id = serializers.IntegerField(write_only=True) + + class Meta: + model = UserTargetRole + fields = ['id', 'role', 'role_id', 'selected_at', 'is_active'] + + def validate_role_id(self, value): + if not Role.objects.filter(id=value, is_active=True).exists(): + raise serializers.ValidationError('Role not found or inactive.') + return value + + +class RoleAdminSerializer(serializers.ModelSerializer): + """Admin view of a Role — exposes is_active + onet_soc_code for write.""" + role_skills = RoleSkillSerializer(many=True, read_only=True) + + class Meta: + model = Role + fields = ['id', 'role_name', 'description', 'industry', 'is_active', + 'onet_soc_code', 'role_skills'] + + +class RoleSkillAdminSerializer(serializers.ModelSerializer): + """Admin CRUD on a single RoleSkill junction row.""" + skill = SkillSerializer(read_only=True) + skill_id = serializers.IntegerField(write_only=True) + role_id = serializers.IntegerField(write_only=True) + + class Meta: + model = RoleSkill + fields = ['id', 'role_id', 'skill', 'skill_id', + 'required_level', 'weight', 'is_mandatory'] + + def validate_skill_id(self, value): + if not Skill.objects.filter(id=value).exists(): + raise serializers.ValidationError('Skill not found.') + return value + + def validate_role_id(self, value): + if not Role.objects.filter(id=value).exists(): + raise serializers.ValidationError('Role not found.') + return value + + def validate(self, attrs): + # F16/F17 cross-field rule: a mandatory skill must carry positive weight. + # (The non-negative bound is enforced by the model's MinValueValidator, + # which DRF auto-propagates to the `weight` field as min_value=0.0.) + # On create, `weight` may be omitted (model default 1.0); on PATCH, fall + # back to the persisted value — never to None, which would falsely reject + # a mandatory create that doesn't resend weight. + instance = self.instance + is_mandatory = attrs.get( + 'is_mandatory', getattr(instance, 'is_mandatory', False) + ) + if 'weight' in attrs: + weight = attrs['weight'] + elif instance is not None: + weight = instance.weight + else: + weight = RoleSkill._meta.get_field('weight').default + if is_mandatory and (weight is None or weight <= 0): + raise serializers.ValidationError( + {'weight': 'A mandatory skill must have weight greater than 0.'} + ) + return attrs + + def create(self, validated_data): + return RoleSkill.objects.create(**validated_data) + + def update(self, instance, validated_data): + # Skill and role are fixed once the junction row exists — renaming + # either would effectively be a different row. + validated_data.pop('skill_id', None) + validated_data.pop('role_id', None) + return super().update(instance, validated_data) diff --git a/apps/roles/tests/__init__.py b/apps/roles/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/roles/tests/test_admin_roles.py b/apps/roles/tests/test_admin_roles.py new file mode 100644 index 0000000000000000000000000000000000000000..267777e3717c13f2c3a32d25af75a4fffaef0e22 --- /dev/null +++ b/apps/roles/tests/test_admin_roles.py @@ -0,0 +1,242 @@ +"""Tests for admin-only Role + RoleSkill CRUD at /api/admin/.""" +import pytest +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from rest_framework import status +from rest_framework.test import APIClient + +from apps.roles.models import Role, RoleSkill, normalize_roleskill_fields +from apps.skills.models import Skill + +User = get_user_model() +pytestmark = pytest.mark.django_db + +ADMIN_ROLES_URL = '/api/admin/roles/' +ADMIN_ROLE_SKILLS_URL = '/api/admin/role-skills/' + + +@pytest.fixture +def admin_client(): + user = User.objects.create_user( + username='admin@test.example', email='admin@test.example', + password='x', name='Admin', role_type='ADMIN', is_staff=True, + ) + c = APIClient() + c.force_authenticate(user=user) + return c + + +@pytest.fixture +def student_client(): + user = User.objects.create_user( + username='student@test.example', email='student@test.example', + password='x', name='Student', role_type='STUDENT', + ) + c = APIClient() + c.force_authenticate(user=user) + return c + + +@pytest.fixture +def skill(): + return Skill.objects.create(skill_name='Python', category='Programming', difficulty_level='BEGINNER') + + +class TestRoleAdminPermissions: + def test_student_cannot_create_role(self, student_client): + resp = student_client.post(ADMIN_ROLES_URL, { + 'role_name': 'Backend Dev', 'industry': 'Tech', + }, format='json') + assert resp.status_code == status.HTTP_403_FORBIDDEN + + +class TestRoleAdminCrud: + def test_admin_full_cycle_and_soft_delete(self, admin_client): + # Create + resp = admin_client.post(ADMIN_ROLES_URL, { + 'role_name': 'Backend Dev', + 'industry': 'Tech', + 'description': 'Backend engineering role.', + }, format='json') + assert resp.status_code == status.HTTP_201_CREATED, resp.data + role_id = resp.data['id'] + assert resp.data['is_active'] is True + + # Update + resp = admin_client.patch( + f'{ADMIN_ROLES_URL}{role_id}/', + {'description': 'Updated description.'}, format='json', + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.data['description'] == 'Updated description.' + + # Soft delete + resp = admin_client.delete(f'{ADMIN_ROLES_URL}{role_id}/') + assert resp.status_code == status.HTTP_204_NO_CONTENT + # Row still exists, just inactive. + role = Role.objects.get(pk=role_id) + assert role.is_active is False + + def test_admin_list_includes_inactive(self, admin_client): + active = Role.objects.create(role_name='Active', industry='Tech', is_active=True) + inactive = Role.objects.create(role_name='Legacy', industry='Tech', is_active=False) + resp = admin_client.get(ADMIN_ROLES_URL) + assert resp.status_code == status.HTTP_200_OK + rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data + ids = {r['id'] for r in rows} + assert active.id in ids + assert inactive.id in ids, 'Admin list must include inactive roles.' + + +class TestRoleSkillAdminCrud: + def test_create_update_delete_role_skill(self, admin_client, skill): + role = Role.objects.create(role_name='Dev', industry='Tech') + + # Create a junction row + resp = admin_client.post(ADMIN_ROLE_SKILLS_URL, { + 'role_id': role.id, + 'skill_id': skill.id, + 'required_level': 'INTERMEDIATE', + 'weight': 1.5, + 'is_mandatory': True, + }, format='json') + assert resp.status_code == status.HTTP_201_CREATED, resp.data + rs_id = resp.data['id'] + assert resp.data['skill']['id'] == skill.id + + # Filter by role + resp = admin_client.get(f'{ADMIN_ROLE_SKILLS_URL}?role={role.id}') + assert resp.status_code == status.HTTP_200_OK + rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data + assert len(rows) == 1 + + # Update weight + resp = admin_client.patch( + f'{ADMIN_ROLE_SKILLS_URL}{rs_id}/', + {'weight': 2.0}, format='json', + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.data['weight'] == 2.0 + + # Delete + resp = admin_client.delete(f'{ADMIN_ROLE_SKILLS_URL}{rs_id}/') + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert not RoleSkill.objects.filter(pk=rs_id).exists() + + def test_non_numeric_role_param_returns_empty(self, admin_client, skill): + """F26: a non-numeric ?role= must not 500 (ValueError at query prep); + return an empty set instead.""" + role = Role.objects.create(role_name='Dev', industry='Tech') + RoleSkill.objects.create( + role=role, skill=skill, required_level='INTERMEDIATE', + weight=1.0, is_mandatory=True, + ) + resp = admin_client.get(f'{ADMIN_ROLE_SKILLS_URL}?role=abc') + assert resp.status_code == status.HTTP_200_OK + rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data + assert rows == [] + + +class TestRoleSkillWeightIntegrity: + """F16/F17 — weight must be non-negative, and a mandatory skill must carry + positive weight. Enforced both at the API (serializer 400) and the DB + (CheckConstraint IntegrityError), since the seeder/import/bulk paths skip + serializer validation.""" + + def test_serializer_rejects_negative_weight(self, admin_client, skill): + role = Role.objects.create(role_name='Dev', industry='Tech') + resp = admin_client.post(ADMIN_ROLE_SKILLS_URL, { + 'role_id': role.id, 'skill_id': skill.id, + 'required_level': 'INTERMEDIATE', 'weight': -1.0, 'is_mandatory': False, + }, format='json') + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert 'weight' in resp.data + + def test_serializer_rejects_mandatory_zero_weight(self, admin_client, skill): + role = Role.objects.create(role_name='Dev', industry='Tech') + resp = admin_client.post(ADMIN_ROLE_SKILLS_URL, { + 'role_id': role.id, 'skill_id': skill.id, + 'required_level': 'INTERMEDIATE', 'weight': 0, 'is_mandatory': True, + }, format='json') + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert 'weight' in resp.data + + def test_mandatory_create_omitting_weight_defaults_to_1(self, admin_client, skill): + # Omitting weight on a mandatory create must NOT be falsely rejected — + # it falls back to the model default (1.0), which is > 0. + role = Role.objects.create(role_name='Dev', industry='Tech') + resp = admin_client.post(ADMIN_ROLE_SKILLS_URL, { + 'role_id': role.id, 'skill_id': skill.id, + 'required_level': 'INTERMEDIATE', 'is_mandatory': True, + }, format='json') + assert resp.status_code == status.HTTP_201_CREATED, resp.data + assert resp.data['weight'] == 1.0 + + def test_db_constraint_rejects_negative_weight(self, skill): + role = Role.objects.create(role_name='Dev', industry='Tech') + with pytest.raises(IntegrityError): + with transaction.atomic(): + RoleSkill.objects.create( + role=role, skill=skill, required_level='INTERMEDIATE', + weight=-1.0, is_mandatory=False, + ) + + def test_db_constraint_rejects_mandatory_zero_weight(self, skill): + role = Role.objects.create(role_name='Dev', industry='Tech') + with pytest.raises(IntegrityError): + with transaction.atomic(): + RoleSkill.objects.create( + role=role, skill=skill, required_level='INTERMEDIATE', + weight=0.0, is_mandatory=True, + ) + + def test_db_allows_optional_zero_weight(self, skill): + # The zero-weight contract: a NON-mandatory skill may carry weight 0. + role = Role.objects.create(role_name='Dev', industry='Tech') + rs = RoleSkill.objects.create( + role=role, skill=skill, required_level='INTERMEDIATE', + weight=0.0, is_mandatory=False, + ) + assert rs.pk is not None + + +class TestNormalizeRoleSkillFields: + """F12 + the bulk-write weight guard: normalize_roleskill_fields lets the + O*NET import action and the role seeder skip+report bad rows instead of + hitting the DB constraint and aborting the whole atomic batch.""" + + def test_accepts_and_normalizes_valid(self): + level, weight, error = normalize_roleskill_fields('intermediate', '1.5', True) + assert error is None + assert level == 'INTERMEDIATE' # normalized (upper-cased) + assert weight == 1.5 + + def test_defaults_omitted_weight_to_1(self): + level, weight, error = normalize_roleskill_fields('BEGINNER', None, False) + assert error is None and weight == 1.0 + + def test_rejects_bad_required_level(self): + _, _, error = normalize_roleskill_fields('EXPERT', 1.0, False) + assert error is not None + + def test_rejects_negative_weight(self): + _, _, error = normalize_roleskill_fields('BEGINNER', -1.0, False) + assert error is not None + + def test_rejects_mandatory_zero_weight(self): + _, _, error = normalize_roleskill_fields('INTERMEDIATE', 0, True) + assert error is not None + + def test_rejects_non_finite_weight(self): + # NaN/Infinity pass float() and slip every < comparison — must be caught. + _, _, e_nan = normalize_roleskill_fields('BEGINNER', float('nan'), False) + _, _, e_inf = normalize_roleskill_fields('BEGINNER', float('inf'), False) + assert e_nan is not None + assert e_inf is not None + + def test_empty_or_whitespace_level_defaults_to_beginner(self): + # "" and " " both fall back to BEGINNER consistently. + l1, _, e1 = normalize_roleskill_fields('', 1.0, False) + l2, _, e2 = normalize_roleskill_fields(' ', 1.0, False) + assert e1 is None and l1 == 'BEGINNER' + assert e2 is None and l2 == 'BEGINNER' diff --git a/apps/roles/tests/test_roles.py b/apps/roles/tests/test_roles.py new file mode 100644 index 0000000000000000000000000000000000000000..b25f7c39bacc69f1ad2fd289de265ec726ee1369 --- /dev/null +++ b/apps/roles/tests/test_roles.py @@ -0,0 +1,328 @@ +import pytest +from rest_framework import status +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model + +from apps.skills.models import Skill +from apps.roles.models import Role, RoleSkill, UserTargetRole + +User = get_user_model() +pytestmark = pytest.mark.django_db + +ROLES_URL = '/api/roles/' +TARGET_ROLE_URL = '/api/target-role/' + + +@pytest.fixture +def auth_client(): + user = User.objects.create_user( + username="testuser@example.com", + email="testuser@example.com", + password="password123", + name="Test User" + ) + client = APIClient() + # Force authenticate user bypasses the JWT requirement for fast testing + client.force_authenticate(user=user) + return client + + +@pytest.fixture +def user(auth_client): + return User.objects.first() + + +@pytest.fixture +def skill(): + return Skill.objects.create(skill_name="Python", category="Programming", difficulty_level="BEGINNER") + + +@pytest.fixture +def role(skill): + r = Role.objects.create(role_name="Backend Developer", industry="Tech", is_active=True) + RoleSkill.objects.create(role=r, skill=skill, required_level="INTERMEDIATE", weight=1.0, is_mandatory=True) + return r + + +@pytest.fixture +def inactive_role(skill): + r = Role.objects.create(role_name="Deprecated Dev", industry="Tech", is_active=False) + RoleSkill.objects.create(role=r, skill=skill, required_level="BEGINNER") + return r + + +class TestRoleCatalog: + def test_list_roles_returns_active_only(self, auth_client, role, inactive_role): + response = auth_client.get(ROLES_URL) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]['id'] == role.id + + def test_list_roles_filter_industry(self, auth_client, role): + # Role is created with industry "Tech" + response = auth_client.get(ROLES_URL + '?industry=Tech') + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + + response2 = auth_client.get(ROLES_URL + '?industry=Healthcare') + assert response2.status_code == status.HTTP_200_OK + assert len(response2.data) == 0 + + def test_role_detail_includes_skills(self, auth_client, role, skill): + response = auth_client.get(f"{ROLES_URL}{role.id}/") + assert response.status_code == status.HTTP_200_OK + assert response.data['id'] == role.id + assert len(response.data['role_skills']) == 1 + assert response.data['role_skills'][0]['skill']['id'] == skill.id + assert response.data['role_skills'][0]['is_mandatory'] is True + + +class TestUserTargetRole: + def test_set_target_role_success(self, auth_client, user, role): + response = auth_client.post(TARGET_ROLE_URL, {'role_id': role.id}, format='json') + assert response.status_code == status.HTTP_201_CREATED + assert UserTargetRole.objects.filter(user=user, is_active=True).exists() + + def test_set_inactive_role_fails(self, auth_client, inactive_role): + response = auth_client.post(TARGET_ROLE_URL, {'role_id': inactive_role.id}, format='json') + # Serializer rejects inactive or missing roles as bad input (400). + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'role_id' in response.data + + def test_switching_deactivates_previous(self, auth_client, user, role, skill): + role_b = Role.objects.create(role_name="Frontend Dev", industry="Tech", is_active=True) + RoleSkill.objects.create(role=role_b, skill=skill, required_level="BEGINNER") + + # Select role A + auth_client.post(TARGET_ROLE_URL, {'role_id': role.id}, format='json') + tr1 = UserTargetRole.objects.get(user=user) + assert tr1.is_active is True + + # Select role B + auth_client.post(TARGET_ROLE_URL, {'role_id': role_b.id}, format='json') + + # Check via API + response = auth_client.get(TARGET_ROLE_URL) + assert response.status_code == status.HTTP_200_OK + assert response.data['role']['id'] == role_b.id + + # Check DB + tr1.refresh_from_db() + assert tr1.is_active is False + assert UserTargetRole.objects.filter(user=user, is_active=True).count() == 1 + assert UserTargetRole.objects.get(user=user, is_active=True).role == role_b + + def test_delete_target_role(self, auth_client, user, role): + auth_client.post(TARGET_ROLE_URL, {'role_id': role.id}, format='json') + response = auth_client.delete(TARGET_ROLE_URL) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Should be inactive in DB + assert UserTargetRole.objects.filter(user=user, is_active=True).count() == 0 + + # GET should return 404 + get_response = auth_client.get(TARGET_ROLE_URL) + assert get_response.status_code == status.HTTP_404_NOT_FOUND + + def test_one_active_target_per_user_constraint(self, user, role): + """DB-level guard: the partial unique constraint must reject a + second active row. App-level upsert in the view avoids this path, + but direct ORM writes (admin, scripts) must be safe too.""" + from django.db import IntegrityError, transaction + UserTargetRole.objects.create(user=user, role=role, is_active=True) + with pytest.raises(IntegrityError): + with transaction.atomic(): + UserTargetRole.objects.create(user=user, role=role, is_active=True) + + def test_concurrent_deactivation_returns_400(self, auth_client, role): + """F14: if the role is deactivated AFTER validate_role_id passes but + before the view's Role.objects.get(), the DoesNotExist must surface as a + clean 400 keyed on role_id, not an unhandled 500.""" + from unittest.mock import patch + with patch.object(Role.objects, 'get', side_effect=Role.DoesNotExist): + response = auth_client.post( + TARGET_ROLE_URL, {'role_id': role.id}, format='json', + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'role_id' in response.data + + +class TestRoleAdminActions: + _counter = 0 + + def _admin_request(self): + from django.test import RequestFactory + from django.contrib.messages.storage.fallback import FallbackStorage + TestRoleAdminActions._counter += 1 + tag = f'admin-role-{TestRoleAdminActions._counter}@x.com' + req = RequestFactory().post('/admin/') + setattr(req, 'session', {}) + setattr(req, '_messages', FallbackStorage(req)) + req.user = User.objects.create_superuser( + username=tag, email=tag, password='pw', + ) + return req + + def test_duplicate_role_with_skills(self, role, skill): + from django.contrib.admin.sites import AdminSite + from apps.roles.admin import RoleAdmin + admin = RoleAdmin(Role, AdminSite()) + request = self._admin_request() + admin.duplicate_role_with_skills(request, Role.objects.filter(pk=role.pk)) + + copy = Role.objects.get(role_name='Backend Developer (copy)') + assert copy.pk != role.pk + assert copy.industry == role.industry + assert copy.onet_soc_code == role.onet_soc_code + src_pairs = {(rs.skill_id, rs.required_level, rs.weight, rs.is_mandatory) + for rs in role.role_skills.all()} + copy_pairs = {(rs.skill_id, rs.required_level, rs.weight, rs.is_mandatory) + for rs in copy.role_skills.all()} + assert src_pairs == copy_pairs + + def test_duplicate_role_handles_name_collisions(self, role): + from django.contrib.admin.sites import AdminSite + from apps.roles.admin import RoleAdmin + admin = RoleAdmin(Role, AdminSite()) + request = self._admin_request() + admin.duplicate_role_with_skills(request, Role.objects.filter(pk=role.pk)) + admin.duplicate_role_with_skills(request, Role.objects.filter(pk=role.pk)) + + names = set(Role.objects.values_list('role_name', flat=True)) + assert 'Backend Developer (copy)' in names + assert 'Backend Developer (copy) 2' in names + + def test_import_skills_from_onet_missing_yaml(self, tmp_path, monkeypatch, role): + """When the curated YAML is absent, the action emits an error + message and makes no changes.""" + from django.contrib.admin.sites import AdminSite + from apps.roles import admin as roles_admin + admin = roles_admin.RoleAdmin(Role, AdminSite()) + monkeypatch.setattr(roles_admin, 'CURATED_YAML', tmp_path / 'missing.yaml') + before = role.role_skills.count() + request = self._admin_request() + admin.import_skills_from_onet(request, Role.objects.filter(pk=role.pk)) + assert role.role_skills.count() == before + + def test_import_skills_from_onet_populates_from_yaml( + self, tmp_path, monkeypatch, skill, + ): + from django.contrib.admin.sites import AdminSite + from apps.roles import admin as roles_admin + + # Set up a Role and a second skill referenced by the curated YAML. + Skill.objects.create(skill_name='Django', category='Framework', difficulty_level='INTERMEDIATE') + target_role = Role.objects.create( + role_name='Backend Engineer', + industry='Tech', is_active=True, + onet_soc_code='15-1252.00', + ) + + yaml_path = tmp_path / 'curated.yaml' + yaml_path.write_text( + "roles:\n" + "- name: Backend Engineer\n" + " industry: Tech\n" + " primary_soc: 15-1252.00\n" + " all_socs: [15-1252.00]\n" + " skills:\n" + " - skill_name: Python\n" + " required_level: INTERMEDIATE\n" + " weight: 1.0\n" + " is_mandatory: true\n" + " - skill_name: Django\n" + " required_level: ADVANCED\n" + " weight: 2.0\n" + " is_mandatory: false\n", + encoding='utf-8', + ) + monkeypatch.setattr(roles_admin, 'CURATED_YAML', yaml_path) + + admin = roles_admin.RoleAdmin(Role, AdminSite()) + request = self._admin_request() + admin.import_skills_from_onet(request, Role.objects.filter(pk=target_role.pk)) + + rs_rows = {rs.skill.skill_name: rs for rs in target_role.role_skills.all()} + assert set(rs_rows) == {'Python', 'Django'} + assert rs_rows['Python'].is_mandatory is True + assert rs_rows['Django'].required_level == 'ADVANCED' + assert rs_rows['Django'].weight == 2.0 + + def test_duplicate_role_writes_audit_log(self, role, skill): + from django.contrib.admin.models import ADDITION, LogEntry + from django.contrib.admin.sites import AdminSite + from apps.roles.admin import RoleAdmin + + admin = RoleAdmin(Role, AdminSite()) + request = self._admin_request() + admin.duplicate_role_with_skills(request, Role.objects.filter(pk=role.pk)) + + copy = Role.objects.get(role_name='Backend Developer (copy)') + entries = LogEntry.objects.filter( + object_id=str(copy.pk), action_flag=ADDITION, + ) + assert entries.exists() + assert 'Duplicated from' in entries.first().change_message + + def test_import_skills_writes_audit_log(self, tmp_path, monkeypatch, skill): + from django.contrib.admin.models import CHANGE, LogEntry + from django.contrib.admin.sites import AdminSite + from apps.roles import admin as roles_admin + + target_role = Role.objects.create( + role_name='Audited Role', industry='Tech', + is_active=True, onet_soc_code='15-1252.00', + ) + yaml_path = tmp_path / 'curated.yaml' + yaml_path.write_text( + "roles:\n" + "- name: Audited Role\n" + " primary_soc: 15-1252.00\n" + " all_socs: [15-1252.00]\n" + " skills:\n" + " - skill_name: Python\n" + " required_level: INTERMEDIATE\n" + " weight: 1.0\n" + " is_mandatory: true\n", + encoding='utf-8', + ) + monkeypatch.setattr(roles_admin, 'CURATED_YAML', yaml_path) + + admin_cls = roles_admin.RoleAdmin(Role, AdminSite()) + admin_cls.import_skills_from_onet( + self._admin_request(), Role.objects.filter(pk=target_role.pk), + ) + + entries = LogEntry.objects.filter( + object_id=str(target_role.pk), action_flag=CHANGE, + ) + assert entries.exists() + assert 'O*NET import' in entries.first().change_message + + def test_import_skills_is_idempotent(self, tmp_path, monkeypatch, skill): + from django.contrib.admin.sites import AdminSite + from apps.roles import admin as roles_admin + + target_role = Role.objects.create( + role_name='Solo Role', industry='Tech', + is_active=True, onet_soc_code='99-9999.99', + ) + yaml_path = tmp_path / 'curated.yaml' + yaml_path.write_text( + "roles:\n" + "- name: Solo Role\n" + " primary_soc: 99-9999.99\n" + " all_socs: [99-9999.99]\n" + " skills:\n" + " - skill_name: Python\n" + " required_level: INTERMEDIATE\n" + " weight: 1.0\n" + " is_mandatory: true\n", + encoding='utf-8', + ) + monkeypatch.setattr(roles_admin, 'CURATED_YAML', yaml_path) + + admin = roles_admin.RoleAdmin(Role, AdminSite()) + admin.import_skills_from_onet(self._admin_request(), Role.objects.filter(pk=target_role.pk)) + admin.import_skills_from_onet(self._admin_request(), Role.objects.filter(pk=target_role.pk)) + assert target_role.role_skills.count() == 1 diff --git a/apps/roles/urls.py b/apps/roles/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..128348275ca622abe73012da3dc0c0db75646eb9 --- /dev/null +++ b/apps/roles/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import RoleListView, RoleDetailView, UserTargetRoleView + +urlpatterns = [ + path('roles/', RoleListView.as_view(), name='role-list'), + path('roles//', RoleDetailView.as_view(), name='role-detail'), + path('target-role/', UserTargetRoleView.as_view(), name='user-target-role'), +] diff --git a/apps/roles/views.py b/apps/roles/views.py new file mode 100644 index 0000000000000000000000000000000000000000..1393ec65d84c507353af04e816c2f99b648312db --- /dev/null +++ b/apps/roles/views.py @@ -0,0 +1,177 @@ +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import generics, permissions, status, viewsets +from rest_framework.response import Response +from rest_framework.views import APIView +from django.db import transaction +from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend +from django_filters import FilterSet + +from .models import Role, RoleSkill, UserTargetRole +from .serializers import ( + RoleAdminSerializer, + RoleDetailSerializer, + RoleListSerializer, + RoleSkillAdminSerializer, + UserTargetRoleSerializer, +) + + +class RoleFilterSet(FilterSet): + class Meta: + model = Role + fields = { + 'industry': ['exact'], + } + + +class RoleListView(generics.ListAPIView): + # Intentionally unpaginated: the role catalog is a small, curated set + # (~10 seeded, expected ceiling ~50). A plain list keeps the frontend + # dropdown trivial. Revisit if the catalog ever grows past a few hundred. + queryset = Role.objects.filter(is_active=True) + serializer_class = RoleListSerializer + permission_classes = [permissions.IsAuthenticated] + filter_backends = [DjangoFilterBackend] + filterset_class = RoleFilterSet + pagination_class = None + + +class RoleDetailView(generics.RetrieveAPIView): + queryset = Role.objects.filter(is_active=True) + serializer_class = RoleDetailSerializer + permission_classes = [permissions.IsAuthenticated] + + +class UserTargetRoleView(APIView): + serializer_class = UserTargetRoleSerializer + permission_classes = [permissions.IsAuthenticated] + + @extend_schema( + responses={ + 200: UserTargetRoleSerializer, + 404: OpenApiResponse(description='No active target role.'), + }, + ) + def get(self, request): + try: + target_role = UserTargetRole.objects.get(user=request.user, is_active=True) + serializer = UserTargetRoleSerializer(target_role, context={'request': request}) + return Response(serializer.data) + except UserTargetRole.DoesNotExist: + return Response( + {"detail": "No active target role."}, + status=status.HTTP_404_NOT_FOUND + ) + + @extend_schema( + request=UserTargetRoleSerializer, + responses=UserTargetRoleSerializer, + ) + @transaction.atomic + def post(self, request): + in_serializer = UserTargetRoleSerializer( + data=request.data, context={'request': request}, + ) + in_serializer.is_valid(raise_exception=True) + role_id = in_serializer.validated_data['role_id'] + try: + role = Role.objects.get(id=role_id, is_active=True) + except Role.DoesNotExist: + # The role was concurrently deactivated between validate_role_id and + # here. 400 (not 404): the request was well-formed but the resource + # state changed; key on role_id to match the validation-time shape. + return Response( + {'role_id': ['Role not found or inactive.']}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Lock any existing active rows for this user so concurrent POSTs + # serialize on the row and the partial unique constraint holds. + existing = list( + UserTargetRole.objects + .select_for_update() + .filter(user=request.user, is_active=True) + ) + for row in existing: + if row.role_id == role.id: + # Same role reselected — upsert semantics: refresh selected_at + # so the frontend sees the re-activation event. + row.selected_at = timezone.now() + row.save(update_fields=['selected_at']) + serializer = UserTargetRoleSerializer(row, context={'request': request}) + return Response(serializer.data, status=status.HTTP_200_OK) + UserTargetRole.objects.filter(pk__in=[r.pk for r in existing]).update(is_active=False) + + target_role = UserTargetRole.objects.create( + user=request.user, + role=role, + is_active=True, + ) + + serializer = UserTargetRoleSerializer(target_role, context={'request': request}) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @extend_schema( + request=None, + responses={ + 204: OpenApiResponse(description='Active target role deactivated.'), + 404: OpenApiResponse(description='No active target role to deactivate.'), + }, + ) + @transaction.atomic + def delete(self, request): + try: + target_role = UserTargetRole.objects.get(user=request.user, is_active=True) + target_role.is_active = False + target_role.save() + return Response(status=status.HTTP_204_NO_CONTENT) + except UserTargetRole.DoesNotExist: + return Response( + {"detail": "No active target role to deactivate."}, + status=status.HTTP_404_NOT_FOUND + ) + + +class RoleAdminViewSet(viewsets.ModelViewSet): + """Admin-only CRUD on Role. + + List includes inactive roles (admins need to see them). DELETE is a soft + delete — sets is_active=False — per the spec in CLAUDE.md (roles are + never hard-deleted because historical UserTargetRole rows still reference + them). + """ + queryset = Role.objects.all().order_by('role_name') + serializer_class = RoleAdminSerializer + permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] + pagination_class = None + + def destroy(self, request, *args, **kwargs): + role = self.get_object() + role.is_active = False + role.save(update_fields=['is_active']) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class RoleSkillAdminViewSet(viewsets.ModelViewSet): + """Admin-only CRUD on a Role's required-skill rows. + + Accepts a `?role=` query param so the frontend role edit dialog can + fetch only the RoleSkills for the role it is editing. + """ + queryset = RoleSkill.objects.all().select_related('skill', 'role') + serializer_class = RoleSkillAdminSerializer + permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] + pagination_class = None + + def get_queryset(self): + qs = super().get_queryset() + role_id = self.request.query_params.get('role') + if role_id: + # Guard non-numeric input: filtering on a non-int pk raises a + # ValueError at query prep (→ 500). isascii() also rejects Unicode + # digits (e.g. '²') that isdigit() accepts but int() can't parse. + qs = qs.filter(role_id=role_id) if ( + str(role_id).isascii() and str(role_id).isdigit() + ) else qs.none() + return qs diff --git a/apps/skills/__init__.py b/apps/skills/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/skills/admin.py b/apps/skills/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..f8c3a47ea2cb0c4d178290bce3494d402b746be9 --- /dev/null +++ b/apps/skills/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from .models import Skill, UserSkill + + +@admin.register(Skill) +class SkillAdmin(admin.ModelAdmin): + list_display = ['skill_name', 'category', 'difficulty_level'] + search_fields = ['skill_name', 'category'] + list_filter = ['category', 'difficulty_level'] + + +@admin.register(UserSkill) +class UserSkillAdmin(admin.ModelAdmin): + list_display = ['user', 'skill', 'proficiency', 'user_level', 'updated_at'] + list_filter = ['user_level'] + search_fields = ['user__email', 'skill__skill_name'] diff --git a/apps/skills/apps.py b/apps/skills/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..7efc71f51880c4ac54dbe919c125b9db7f9c687d --- /dev/null +++ b/apps/skills/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SkillsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.skills' diff --git a/apps/skills/management/__init__.py b/apps/skills/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/skills/management/commands/__init__.py b/apps/skills/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/skills/management/commands/seed_initial_skills.py b/apps/skills/management/commands/seed_initial_skills.py new file mode 100644 index 0000000000000000000000000000000000000000..b7624d3592b3d004566267ff733e8fc21c45076e --- /dev/null +++ b/apps/skills/management/commands/seed_initial_skills.py @@ -0,0 +1,70 @@ +from pathlib import Path + +import yaml +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from apps.skills.models import Skill + +CURATED_YAML = Path(settings.BASE_DIR) / "seed_data" / "onet_roles_curated.yaml" + + +class Command(BaseCommand): + help = "Seed the Skill catalog from seed_data/onet_roles_curated.yaml (idempotent)." + + def add_arguments(self, parser): + parser.add_argument( + "--path", + default=str(CURATED_YAML), + help="Path to curated roles YAML (default: backend/seed_data/onet_roles_curated.yaml)", + ) + + def handle(self, *args, **options): + yaml_path = Path(options["path"]) + if not yaml_path.exists(): + raise CommandError(f"Curated YAML not found: {yaml_path}") + + with yaml_path.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + + roles = data.get("roles") or [] + if not roles: + raise CommandError("No roles found in curated YAML") + + # A skill can appear under several roles at different required levels. + # difficulty_level: strictest-wins via a rank map (NOT max() — + # lexicographically 'ADVANCED' < 'BEGINNER', so a naive max() would + # never pick ADVANCED). category has no natural order → first-seen. + LEVEL_RANK = {"BEGINNER": 0, "INTERMEDIATE": 1, "ADVANCED": 2} + merged: dict[str, dict[str, str]] = {} + for role in roles: + for entry in role.get("skills") or []: + name = entry["skill_name"] + level = entry.get("required_level") or "BEGINNER" + if name not in merged: + merged[name] = { + "category": entry.get("category") or "General", + "difficulty_level": level, + } + continue + cur = merged[name]["difficulty_level"] + if LEVEL_RANK.get(level, 0) > LEVEL_RANK.get(cur, 0): + merged[name]["difficulty_level"] = level + + created = 0 + updated = 0 + with transaction.atomic(): + for skill_name, defaults in merged.items(): + _, was_created = Skill.objects.update_or_create( + skill_name=skill_name, + defaults=defaults, + ) + if was_created: + created += 1 + else: + updated += 1 + + self.stdout.write(self.style.SUCCESS( + f"Skills seeded: {created} created, {updated} updated, {len(merged)} total" + )) diff --git a/apps/skills/migrations/0001_initial.py b/apps/skills/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..36ee291130c3c82087b858149aed61306f1026d5 --- /dev/null +++ b/apps/skills/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 6.0.4 on 2026-04-07 19:34 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Skill', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('skill_name', models.CharField(max_length=255, unique=True)), + ('category', models.CharField(max_length=100)), + ('description', models.TextField(blank=True)), + ('difficulty_level', models.CharField(choices=[('BEGINNER', 'Beginner'), ('INTERMEDIATE', 'Intermediate'), ('ADVANCED', 'Advanced')], default='BEGINNER', max_length=20)), + ], + ), + migrations.CreateModel( + name='UserSkill', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('proficiency', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])), + ('user_level', models.CharField(choices=[('BEGINNER', 'Beginner'), ('INTERMEDIATE', 'Intermediate'), ('ADVANCED', 'Advanced')], max_length=20)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('skill', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_skills', to='skills.skill')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_skills', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'skill')}, + }, + ), + ] diff --git a/apps/skills/migrations/0002_userskill_dismissed_upgrade_at.py b/apps/skills/migrations/0002_userskill_dismissed_upgrade_at.py new file mode 100644 index 0000000000000000000000000000000000000000..f83ddb266304b934bdfc04cf8e63565f55b6425d --- /dev/null +++ b/apps/skills/migrations/0002_userskill_dismissed_upgrade_at.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-04-20 20:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('skills', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='userskill', + name='dismissed_upgrade_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/apps/skills/migrations/0003_skillembedding.py b/apps/skills/migrations/0003_skillembedding.py new file mode 100644 index 0000000000000000000000000000000000000000..98a733c45768788ba6edcac9a3b97480a60a83b6 --- /dev/null +++ b/apps/skills/migrations/0003_skillembedding.py @@ -0,0 +1,44 @@ +from django.db import migrations, models +import django.db.models.deletion + +import pgvector.django + + +class Migration(migrations.Migration): + + dependencies = [ + ('skills', '0002_userskill_dismissed_upgrade_at'), + ] + + operations = [ + migrations.RunSQL( + sql='CREATE EXTENSION IF NOT EXISTS vector;', + reverse_sql=migrations.RunSQL.noop, + ), + migrations.CreateModel( + name='SkillEmbedding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('embedding', pgvector.django.VectorField(dimensions=384)), + ('source_text', models.TextField()), + ('model_name', models.CharField(default='all-MiniLM-L6-v2', max_length=100)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('skill', models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='embedding', + to='skills.skill', + )), + ], + options={ + 'indexes': [ + pgvector.django.HnswIndex( + ef_construction=64, + fields=['embedding'], + m=16, + name='skillemb_hnsw_idx', + opclasses=['vector_cosine_ops'], + ), + ], + }, + ), + ] diff --git a/apps/skills/migrations/0004_alter_userskill_dismissed_upgrade_at.py b/apps/skills/migrations/0004_alter_userskill_dismissed_upgrade_at.py new file mode 100644 index 0000000000000000000000000000000000000000..06e1ccfbc5aa2ec57e2198a0a8ea569d31425277 --- /dev/null +++ b/apps/skills/migrations/0004_alter_userskill_dismissed_upgrade_at.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-04-23 13:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('skills', '0003_skillembedding'), + ] + + operations = [ + migrations.AlterField( + model_name='userskill', + name='dismissed_upgrade_at', + field=models.DateTimeField(blank=True, help_text='Last-action cursor for the upgrade-suggestion flow. Set on both dismiss and apply (see apps.progress.services.apply_upgrade and dismiss_upgrade). A new upgrade is eligible when any related resource completion is newer than this timestamp.', null=True), + ), + ] diff --git a/apps/skills/migrations/__init__.py b/apps/skills/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/skills/models.py b/apps/skills/models.py new file mode 100644 index 0000000000000000000000000000000000000000..9f354f35633e7b413289a8737bd1e8fb4a9a13ea --- /dev/null +++ b/apps/skills/models.py @@ -0,0 +1,101 @@ +from django.conf import settings +from django.core.validators import MinValueValidator, MaxValueValidator +from django.db import models + +from pgvector.django import HnswIndex, VectorField + + +class Skill(models.Model): + DIFFICULTY_CHOICES = [ + ('BEGINNER', 'Beginner'), + ('INTERMEDIATE', 'Intermediate'), + ('ADVANCED', 'Advanced'), + ] + + skill_name = models.CharField(max_length=255, unique=True) + category = models.CharField(max_length=100) + description = models.TextField(blank=True) + difficulty_level = models.CharField( + max_length=20, + choices=DIFFICULTY_CHOICES, + default='BEGINNER' + ) + + def __str__(self): + return self.skill_name + + +class UserSkill(models.Model): + DIFFICULTY_CHOICES = [ + ('BEGINNER', 'Beginner'), + ('INTERMEDIATE', 'Intermediate'), + ('ADVANCED', 'Advanced'), + ] + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='user_skills' + ) + skill = models.ForeignKey( + Skill, + on_delete=models.CASCADE, + related_name='user_skills' + ) + proficiency = models.IntegerField( + validators=[MinValueValidator(0), MaxValueValidator(100)] + ) + user_level = models.CharField(max_length=20, choices=DIFFICULTY_CHOICES) + updated_at = models.DateTimeField(auto_now=True) + # Cursor used by the upgrade-suggestion flow. Advanced on BOTH dismiss AND + # apply (see apps.progress.services). A suggestion is eligible when the + # latest related checkpoint completion is newer than this timestamp. + # Name is historical — kept to avoid a field-rename migration. + dismissed_upgrade_at = models.DateTimeField( + null=True, + blank=True, + help_text=( + "Last-action cursor for the upgrade-suggestion flow. Set on both " + "dismiss and apply (see apps.progress.services.apply_upgrade and " + "dismiss_upgrade). A new upgrade is eligible when any related " + "resource completion is newer than this timestamp." + ), + ) + + class Meta: + unique_together = [['user', 'skill']] + + def __str__(self): + return f'{self.user.email} - {self.skill.skill_name}' + + +class SkillEmbedding(models.Model): + """SBERT embedding of a Skill, queried by Module 8 Layer 4 (SBERT canonical + mapping) when higher-layer NER models fail to resolve a candidate span. + + 384 dims matches all-MiniLM-L6-v2; swapping models means re-running + scripts/build_skill_embeddings.py against a fresh catalog. + """ + skill = models.OneToOneField( + Skill, + on_delete=models.CASCADE, + related_name='embedding', + ) + embedding = VectorField(dimensions=384) + source_text = models.TextField() + model_name = models.CharField(max_length=100, default='all-MiniLM-L6-v2') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + indexes = [ + HnswIndex( + name='skillemb_hnsw_idx', + fields=['embedding'], + m=16, + ef_construction=64, + opclasses=['vector_cosine_ops'], + ), + ] + + def __str__(self): + return f'Embedding({self.skill.skill_name})' diff --git a/apps/skills/serializers.py b/apps/skills/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..7ab5667469b39bd7c2e379f36b72dcdc304d2b3c --- /dev/null +++ b/apps/skills/serializers.py @@ -0,0 +1,54 @@ +from rest_framework import serializers +from .models import Skill, UserSkill +from .utils import proficiency_to_level + + +class SkillSerializer(serializers.ModelSerializer): + class Meta: + model = Skill + fields = ['id', 'skill_name', 'category', 'description', 'difficulty_level'] + + +class UserSkillSerializer(serializers.ModelSerializer): + skill = SkillSerializer(read_only=True) + + class Meta: + model = UserSkill + fields = ['id', 'skill', 'proficiency', 'user_level', 'updated_at'] + + +class UserSkillCreateUpdateSerializer(serializers.Serializer): + skill_id = serializers.IntegerField() + proficiency = serializers.IntegerField() + + def validate_proficiency(self, value): + if not 0 <= value <= 100: + raise serializers.ValidationError("Proficiency must be between 0 and 100.") + return value + + def validate_skill_id(self, value): + if not Skill.objects.filter(id=value).exists(): + raise serializers.ValidationError("Skill with this ID does not exist.") + return value + + def create(self, validated_data): + skill_id = validated_data['skill_id'] + proficiency = validated_data['proficiency'] + user = self.context['request'].user + user_level = proficiency_to_level(proficiency) + + user_skill, _ = UserSkill.objects.update_or_create( + user=user, + skill_id=skill_id, + defaults={'proficiency': proficiency, 'user_level': user_level} + ) + return user_skill + + def update(self, instance, validated_data): + # skill_id is immutable on update — a UserSkill binds a user to a + # specific skill; reassigning it would corrupt that identity. + proficiency = validated_data.get('proficiency', instance.proficiency) + instance.proficiency = proficiency + instance.user_level = proficiency_to_level(proficiency) + instance.save() + return instance diff --git a/apps/skills/tests/__init__.py b/apps/skills/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/skills/tests/test_admin_skills.py b/apps/skills/tests/test_admin_skills.py new file mode 100644 index 0000000000000000000000000000000000000000..63ee11b162ac90417327d229b1a7083c6d770588 --- /dev/null +++ b/apps/skills/tests/test_admin_skills.py @@ -0,0 +1,118 @@ +"""Tests for admin-only skill CRUD at /api/admin/skills/. + +Matches the existing test_roles.py style (pytest + force_authenticate). +""" +import pytest +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APIClient + +from apps.roles.models import Role, RoleSkill +from apps.skills.models import Skill, UserSkill + +User = get_user_model() +pytestmark = pytest.mark.django_db + +ADMIN_SKILLS_URL = '/api/admin/skills/' + + +@pytest.fixture +def admin_client(): + user = User.objects.create_user( + username='admin@test.example', email='admin@test.example', + password='x', name='Admin', role_type='ADMIN', + is_staff=True, + ) + c = APIClient() + c.force_authenticate(user=user) + return c + + +@pytest.fixture +def student_client(): + user = User.objects.create_user( + username='student@test.example', email='student@test.example', + password='x', name='Student', role_type='STUDENT', + ) + c = APIClient() + c.force_authenticate(user=user) + return c + + +@pytest.fixture +def skill(): + return Skill.objects.create( + skill_name='Python', category='Programming', difficulty_level='BEGINNER', + ) + + +class TestSkillAdminPermissions: + def test_student_forbidden_on_create(self, student_client): + resp = student_client.post(ADMIN_SKILLS_URL, { + 'skill_name': 'Rust', 'category': 'Programming', + 'difficulty_level': 'INTERMEDIATE', + }, format='json') + assert resp.status_code == status.HTTP_403_FORBIDDEN + + def test_anonymous_forbidden(self, skill): + resp = APIClient().get(ADMIN_SKILLS_URL) + # Default DRF returns 401 for unauthenticated, 403 if auth succeeds + # but permission fails. Either way, access is denied. + assert resp.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) + + +class TestSkillAdminCrud: + def test_admin_full_cycle(self, admin_client): + # Create + resp = admin_client.post(ADMIN_SKILLS_URL, { + 'skill_name': 'Go', + 'category': 'Programming', + 'description': 'Google Go', + 'difficulty_level': 'INTERMEDIATE', + }, format='json') + assert resp.status_code == status.HTTP_201_CREATED, resp.data + skill_id = resp.data['id'] + + # List + resp = admin_client.get(ADMIN_SKILLS_URL) + assert resp.status_code == status.HTTP_200_OK + # Paginated response wraps rows in `results`. + rows = resp.data['results'] if isinstance(resp.data, dict) else resp.data + assert any(r['id'] == skill_id for r in rows) + + # Update + resp = admin_client.patch( + f'{ADMIN_SKILLS_URL}{skill_id}/', + {'description': 'Updated'}, format='json', + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.data['description'] == 'Updated' + + # Delete (no references) succeeds + resp = admin_client.delete(f'{ADMIN_SKILLS_URL}{skill_id}/') + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert not Skill.objects.filter(pk=skill_id).exists() + + def test_delete_blocked_when_user_skill_references_it(self, admin_client, skill): + # Reference the skill from a UserSkill row. + user = User.objects.create_user( + username='ref@test.example', email='ref@test.example', + password='x', name='Ref', + ) + UserSkill.objects.create( + user=user, skill=skill, proficiency=50, user_level='BEGINNER', + ) + resp = admin_client.delete(f'{ADMIN_SKILLS_URL}{skill.id}/') + assert resp.status_code == status.HTTP_409_CONFLICT + assert 'student skill profiles' in resp.data['detail'] + # Skill still exists. + assert Skill.objects.filter(pk=skill.id).exists() + + def test_delete_blocked_when_role_skill_references_it(self, admin_client, skill): + role = Role.objects.create(role_name='Dev', industry='Tech') + RoleSkill.objects.create( + role=role, skill=skill, required_level='BEGINNER', + ) + resp = admin_client.delete(f'{ADMIN_SKILLS_URL}{skill.id}/') + assert resp.status_code == status.HTTP_409_CONFLICT + assert 'role requirements' in resp.data['detail'] diff --git a/apps/skills/tests/test_skills.py b/apps/skills/tests/test_skills.py new file mode 100644 index 0000000000000000000000000000000000000000..deaeec45017ff418f4292eca724cac397e6f3c02 --- /dev/null +++ b/apps/skills/tests/test_skills.py @@ -0,0 +1,23 @@ +import pytest +from rest_framework.test import APIClient +from apps.skills.models import Skill +from apps.skills.utils import proficiency_to_level + +pytestmark = pytest.mark.django_db + + +class TestProficiencyMapping: + def test_beginner_range(self): + assert proficiency_to_level(0) == 'BEGINNER' + assert proficiency_to_level(20) == 'BEGINNER' + assert proficiency_to_level(40) == 'BEGINNER' + + def test_intermediate_range(self): + assert proficiency_to_level(41) == 'INTERMEDIATE' + assert proficiency_to_level(50) == 'INTERMEDIATE' + assert proficiency_to_level(60) == 'INTERMEDIATE' + + def test_advanced_range(self): + assert proficiency_to_level(61) == 'ADVANCED' + assert proficiency_to_level(80) == 'ADVANCED' + assert proficiency_to_level(100) == 'ADVANCED' diff --git a/apps/skills/urls.py b/apps/skills/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..5b240d0dca10719ebc4fead632e58801973b5200 --- /dev/null +++ b/apps/skills/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import SkillListView, UserSkillViewSet + +router = DefaultRouter() +router.register(r'', UserSkillViewSet, basename='user-skill') + +urlpatterns = [ + path('skills/', SkillListView.as_view(), name='skill-list'), + path('user-skills/', include(router.urls)), +] diff --git a/apps/skills/utils.py b/apps/skills/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..b8c1b03419f58fb16cba194ddb7bf16fe808c11d --- /dev/null +++ b/apps/skills/utils.py @@ -0,0 +1,19 @@ +def proficiency_to_level(proficiency: int) -> str: + """Map proficiency integer (0-100) to skill level string. + + Boundaries (inclusive on the upper edge): + 0-40 → BEGINNER + 41-60 → INTERMEDIATE + 61-100 → ADVANCED + + Note: these boundaries are the *display* mapping for the UI. The gap + analysis engine uses separate `LEVEL_THRESHOLDS` (BEGINNER=40, + INTERMEDIATE=60, ADVANCED=100) against raw proficiency — see + `apps.analysis.services`. + """ + if proficiency <= 40: + return 'BEGINNER' + elif proficiency <= 60: + return 'INTERMEDIATE' + else: + return 'ADVANCED' diff --git a/apps/skills/views.py b/apps/skills/views.py new file mode 100644 index 0000000000000000000000000000000000000000..3194aaae0eccfb8bdb3574097317d5473222cdfd --- /dev/null +++ b/apps/skills/views.py @@ -0,0 +1,101 @@ +from rest_framework import generics, viewsets, status +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.response import Response + +from config.pagination import StandardResultsSetPagination + +from .models import Skill, UserSkill +from .serializers import SkillSerializer, UserSkillSerializer, UserSkillCreateUpdateSerializer +from .utils import proficiency_to_level + + +class SkillListView(generics.ListAPIView): + serializer_class = SkillSerializer + permission_classes = [IsAuthenticated] + pagination_class = StandardResultsSetPagination + + def get_queryset(self): + queryset = Skill.objects.all().order_by('skill_name') + category = self.request.query_params.get('category', None) + if category: + queryset = queryset.filter(category__iexact=category) + return queryset + + +class UserSkillViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + # `queryset` is required so drf-spectacular can resolve the PK type during + # schema generation (the real filter lives in get_queryset). None() keeps + # the runtime filter the sole source of truth — nothing leaks. + queryset = UserSkill.objects.none() + + def get_queryset(self): + return self.request.user.user_skills.select_related('skill') + + def get_serializer_class(self): + if self.action in ['create', 'update', 'partial_update']: + return UserSkillCreateUpdateSerializer + return UserSkillSerializer + + def create(self, request, *args, **kwargs): + serializer = UserSkillCreateUpdateSerializer( + data=request.data, + context={'request': request} + ) + serializer.is_valid(raise_exception=True) + user_skill = serializer.save() + return Response( + UserSkillSerializer(user_skill).data, + status=status.HTTP_201_CREATED + ) + + def update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = UserSkillCreateUpdateSerializer( + instance, + data=request.data, + partial=kwargs.pop('partial', False), + context={'request': request} + ) + serializer.is_valid(raise_exception=True) + user_skill = serializer.save() + return Response(UserSkillSerializer(user_skill).data) + + def partial_update(self, request, *args, **kwargs): + kwargs['partial'] = True + return self.update(request, *args, **kwargs) + + +class SkillAdminViewSet(viewsets.ModelViewSet): + """Admin-only CRUD on the Skill catalog. + + Delete is blocked with 409 when UserSkill / RoleSkill / SkillResource rows + still reference the skill — avoids orphaning user progress or role + requirements. + """ + queryset = Skill.objects.all().order_by('skill_name') + serializer_class = SkillSerializer + permission_classes = [IsAuthenticated, IsAdminUser] + pagination_class = StandardResultsSetPagination + + def destroy(self, request, *args, **kwargs): + skill = self.get_object() + blockers = [] + if UserSkill.objects.filter(skill=skill).exists(): + blockers.append('student skill profiles') + # Reverse lookups on Skill for RoleSkill and SkillResource (neither + # declares a related_name, so they land on the default *_set name). + if skill.roleskill_set.exists(): + blockers.append('role requirements') + from apps.resources.models import SkillResource + if SkillResource.objects.filter(skill=skill).exists(): + blockers.append('learning resources') + if blockers: + return Response( + {'detail': ( + "Cannot delete this skill: still referenced by " + + ', '.join(blockers) + '.' + )}, + status=status.HTTP_409_CONFLICT, + ) + return super().destroy(request, *args, **kwargs) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/config/admin_urls.py b/config/admin_urls.py new file mode 100644 index 0000000000000000000000000000000000000000..de679469b11a2da16c5d3c2d7ac2751146d72d5e --- /dev/null +++ b/config/admin_urls.py @@ -0,0 +1,40 @@ +"""Admin-only DRF write endpoints. + +Each viewset is guarded by IsAdminUser (see individual ModelViewSet +`permission_classes`). Student JWTs hit 403 here. + +Mount: + config/urls.py → path('api/admin/', include('config.admin_urls')) + +URL map (under /api/admin/): + skills/ CRUD on Skill catalog (409 on referenced delete) + roles/ CRUD on Role (soft delete) + role-skills/ CRUD on RoleSkill junction (?role=) + resources/ CRUD on Resource (cascades to SkillResource + checkpoints) + checkpoints/ CRUD on ResourceCheckpoint (?resource=) + checkpoints/bulk/ POST newline-separated titles to create N rows + skill-resources/ CRUD on SkillResource junction (?resource=) +""" +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.resources.views import ( + ResourceAdminViewSet, + ResourceCheckpointAdminViewSet, + SkillResourceAdminViewSet, +) +from apps.roles.views import RoleAdminViewSet, RoleSkillAdminViewSet +from apps.skills.views import SkillAdminViewSet + + +router = DefaultRouter() +router.register(r'skills', SkillAdminViewSet, basename='admin-skill') +router.register(r'roles', RoleAdminViewSet, basename='admin-role') +router.register(r'role-skills', RoleSkillAdminViewSet, basename='admin-role-skill') +router.register(r'resources', ResourceAdminViewSet, basename='admin-resource') +router.register(r'checkpoints', ResourceCheckpointAdminViewSet, basename='admin-checkpoint') +router.register(r'skill-resources', SkillResourceAdminViewSet, basename='admin-skill-resource') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000000000000000000000000000000000000..e298bca7aaf88b120dd4ef28089bd06516fb36a4 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,6 @@ +import os +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/mixins.py b/config/mixins.py new file mode 100644 index 0000000000000000000000000000000000000000..3d237e241c4d72448841b80b4c60ae28f4df929e --- /dev/null +++ b/config/mixins.py @@ -0,0 +1,37 @@ +"""Reusable DRF viewset mixins. + +`IntegrityConflictMixin` converts a DB-level uniqueness *race* into a clean +409. It is not the first line of defense: DRF's auto-generated +`UniqueValidator` / `UniqueTogetherValidator` already reject the common +(non-concurrent) duplicate with a 400 during `is_valid()`, before the DB is +touched. This mixin only catches the genuine concurrent race that slips past +validation (two requests both pass `is_valid`, both reach the INSERT) — which +would otherwise surface as an uncaught 500. +""" +from django.db import IntegrityError, transaction +from rest_framework import status +from rest_framework.response import Response + + +class IntegrityConflictMixin: + """Mix into a ModelViewSet to turn an INSERT-time IntegrityError into 409. + + The create is wrapped in its own `atomic()` block so the catch is + savepoint-safe regardless of `ATOMIC_REQUESTS`: the failed INSERT rolls + back cleanly and the `except` runs outside the (now-closed) block, so no + later query hits a poisoned transaction. + + Set `conflict_detail` per viewset for a user-facing message. + """ + + conflict_detail = 'This record already exists.' + + def create(self, request, *args, **kwargs): + try: + with transaction.atomic(): + return super().create(request, *args, **kwargs) + except IntegrityError: + return Response( + {'detail': self.conflict_detail}, + status=status.HTTP_409_CONFLICT, + ) diff --git a/config/pagination.py b/config/pagination.py new file mode 100644 index 0000000000000000000000000000000000000000..e357d786fe2a85aba22afced40e6a3feb5c0901f --- /dev/null +++ b/config/pagination.py @@ -0,0 +1,7 @@ +from rest_framework.pagination import PageNumberPagination + + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 50 + page_size_query_param = 'page_size' + max_page_size = 200 diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..1011d9d5d9e0073c936c39c9c518518b17d9ec5a --- /dev/null +++ b/config/settings.py @@ -0,0 +1,237 @@ +import os +from pathlib import Path +from datetime import timedelta +from dotenv import load_dotenv +from django.core.exceptions import ImproperlyConfigured + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent + +_INSECURE_DEFAULT_SECRET = 'django-insecure-change-me' +SECRET_KEY = os.getenv('SECRET_KEY', _INSECURE_DEFAULT_SECRET) + +DEBUG = os.getenv('DEBUG', 'False').lower() in ('true', '1', 'yes') + +if not DEBUG and SECRET_KEY == _INSECURE_DEFAULT_SECRET: + raise ImproperlyConfigured( + "SECRET_KEY must be set via environment when DEBUG is False. " + "Set a strong random value in the SECRET_KEY env var." + ) + +ALLOWED_HOSTS = [h.strip() for h in os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if h.strip()] + +# Deployment (HF Spaces + Neon): a single DATABASE_URL takes precedence when +# set. Locally it is unset, so the explicit DB_* settings below are used +# unchanged. dj_database_url is imported lazily so local dev needn't install it. +if os.getenv('DATABASE_URL'): + import dj_database_url + DATABASES = { + 'default': dj_database_url.parse( + os.getenv('DATABASE_URL'), conn_max_age=0, ssl_require=True, + ) + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('DB_NAME', 'gapguide_db'), + 'USER': os.getenv('DB_USER', 'postgres'), + 'PASSWORD': os.getenv('DB_PASSWORD', 'postgres'), + 'HOST': os.getenv('DB_HOST', 'localhost'), + 'PORT': os.getenv('DB_PORT', '5432'), + } + } + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.postgres', + + 'rest_framework', + 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', + 'corsheaders', + 'drf_spectacular', + 'django_filters', + + 'apps.accounts', + 'apps.skills', + 'apps.roles', + 'apps.analysis', + 'apps.resources', + 'apps.progress', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + # WhiteNoise serves static files (Django admin / DRF) in the container with + # no nginx. Must sit immediately after SecurityMiddleware. No-op locally + # under runserver (DEBUG=True serves static via staticfiles finders). + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +STATIC_URL = 'static/' +# collectstatic target (used in the container build). Non-manifest WhiteNoise +# storage → no collectstatic required for local `runserver`. +STATIC_ROOT = BASE_DIR / 'staticfiles' +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage"}, +} +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +AUTH_USER_MODEL = 'accounts.User' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_THROTTLE_CLASSES': ( + 'rest_framework.throttling.ScopedRateThrottle', + ), + 'DEFAULT_THROTTLE_RATES': { + 'login': '10/minute', + 'register': '20/hour', + # F28 — the resume-parse endpoint runs the full NER chain (expensive). + # ScopedRateThrottle is a no-op until BOTH this rate key and the view's + # throttle_scope='resume_parse' exist; a missing/typo'd key here raises + # ImproperlyConfigured (500) on every parse, so keep them in lockstep. + 'resume_parse': os.getenv('THROTTLE_RESUME', '30/hour'), + }, +} + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': True, + 'ALGORITHM': 'HS256', + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +CORS_ALLOWED_ORIGINS = [o.strip() for o in os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:5173').split(',') if o.strip()] + +# F27 — production hardening. Applied only when DEBUG is off (dev runs plain +# http on localhost). `python manage.py check --deploy` (with a prod-like env: +# DEBUG=False + a real SECRET_KEY + ALLOWED_HOSTS) verifies these resolve the +# W004/W008/W012/W016 warnings. SECURE_PROXY_SSL_HEADER requires the upstream +# proxy to set X-Forwarded-Proto (else SECURE_SSL_REDIRECT can loop). +if not DEBUG: + # Env-toggle: behind HF Spaces' HTTPS edge we start this False to avoid a + # redirect loop if the proxy doesn't forward X-Forwarded-Proto. SECURE_PROXY_ + # SSL_HEADER stays always-on (harmless if absent, correct if present) so + # request.is_secure() — and thus secure cookies / admin login — works. + SECURE_SSL_REDIRECT = os.getenv('SECURE_SSL_REDIRECT', 'True').lower() in ('true', '1', 'yes') + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + SECURE_HSTS_SECONDS = 31536000 # 1 year + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_PRELOAD = True + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + CSRF_TRUSTED_ORIGINS = [ + o.strip() for o in os.getenv('CSRF_TRUSTED_ORIGINS', '').split(',') + if o.strip() + ] + +# Resume parser caps at 5 MB (see apps.accounts.resume_parser.MAX_PDF_BYTES). +# Raise Django's defaults (both are 2.5 MB) so 3–5 MB PDFs stay in memory +# instead of silently spooling to /tmp during parse. +FILE_UPLOAD_MAX_MEMORY_SIZE = 6 * 1024 * 1024 +DATA_UPLOAD_MAX_MEMORY_SIZE = 6 * 1024 * 1024 + +SPECTACULAR_SETTINGS = { + 'TITLE': 'GapGuide API', + 'DESCRIPTION': 'API documentation for GapGuide - Skill Gap Analysis Platform', + 'VERSION': '1.0.0', + # Four fields expose the same (BEGINNER, INTERMEDIATE, ADVANCED) choice set: + # Skill.difficulty_level, UserSkill.user_level, RoleSkill.required_level, and + # Resource.difficulty_level. Without explicit overrides drf-spectacular emits + # a "multiple names for same choice set" warning on schema build. + 'ENUM_NAME_OVERRIDES': { + 'DifficultyLevelEnum': 'apps.skills.models.Skill.DIFFICULTY_CHOICES', + }, +} + +# F30 — logging. Console always + a rotating file. The NER layers log under +# apps.accounts.* so handlers attach to the 'apps' logger (a django-only config +# would miss them). RotatingFileHandler opens its file at construction, so the +# log dir must exist first (module-level mkdir guard). +_LOG_DIR = BASE_DIR / 'logs' +try: + _LOG_DIR.mkdir(parents=True, exist_ok=True) +except OSError: + # Read-only / restricted FS: don't crash settings import. The file handler + # opens lazily (delay=True); the console handler is always available. + pass +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': {'format': '{asctime} {levelname} {name}: {message}', 'style': '{'}, + }, + 'handlers': { + 'console': {'class': 'logging.StreamHandler', 'formatter': 'verbose'}, + 'file': { + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': str(_LOG_DIR / 'gapguide.log'), + 'maxBytes': 5 * 1024 * 1024, + 'backupCount': 3, + 'formatter': 'verbose', + 'delay': True, # open the file on first emit, not at import time + }, + }, + 'root': {'handlers': ['console', 'file'], 'level': 'INFO'}, + 'loggers': { + 'apps': {'handlers': ['console', 'file'], 'level': 'INFO', 'propagate': False}, + 'django': {'handlers': ['console', 'file'], 'level': 'WARNING', 'propagate': False}, + }, +} diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..53c76e0a1768200b62f3df41a0f33ca36a9b3396 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,33 @@ +from django.contrib import admin +from django.urls import path, include +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +urlpatterns = [ + path('admin/', admin.site.urls), + + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + + # Accounts: auth + profile (all under /api/auth/) + path('api/auth/', include('apps.accounts.urls')), + + # Skills: /api/skills/ list + /api/user-skills/ CRUD + path('api/', include('apps.skills.urls')), + + # Roles: handled inside roles.urls + path('api/', include('apps.roles.urls')), + + # Analysis + path('api/analysis/', include('apps.analysis.urls')), + path('api/recommendations/', include('apps.analysis.urls_recommendations')), + path('api/role-suggestions/', include('apps.analysis.urls_role_suggestions')), + + # Resources + path('api/resources/', include('apps.resources.urls')), + + # Progress + path('api/progress/', include('apps.progress.urls')), + + # Admin-only write endpoints (IsAdminUser-guarded) + path('api/admin/', include('config.admin_urls')), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000000000000000000000000000000000000..9b8e4be7baecd02ab589db78c7e71ccef32bf37d --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,6 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..ac67e3b15be2684f3fc73fcbbca126ba824d0a82 --- /dev/null +++ b/conftest.py @@ -0,0 +1,21 @@ +import os + +import pytest +from django.core.cache import cache + + +# Pin tests to the lexical-only parser layer by default — running the full +# NER chain in CI would trigger ~1 GB of HF model downloads and hang the run. +# Set GAPGUIDE_ML_SMOKE=1 to opt into the real ML chain for the integration +# test at apps/accounts/tests/test_resume_parser_integration.py. +if not os.environ.get("GAPGUIDE_ML_SMOKE"): + os.environ.setdefault("GAPGUIDE_PARSE_LAYERS", "lexical") + + +@pytest.fixture(autouse=True) +def _clear_throttle_cache(): + """Reset DRF throttle counters between tests so rate limits are + per-test, not per-session.""" + cache.clear() + yield + cache.clear() diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..0aab435cbca6d078d8b926968378d72ce1b557f5 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# HF Space container entrypoint. Runs sequentially BEFORE gunicorn binds :7860, +# so no request can race the migrate/seed. All steps are idempotent. +set -e + +python manage.py migrate --noinput # CREATE EXTENSION vector is a no-op (pre-created in Neon) + +python manage.py seed_initial_skills # order matters: skills -> roles/resources -> demo users +python manage.py seed_initial_roles +python manage.py seed_initial_resources +python manage.py seed_demo_users # admin password from DEMO_ADMIN_PASSWORD + +# First boot only (set BUILD_EMBEDDINGS=1): build SBERT skill embeddings so the +# sbert NER layer has data. Idempotent; ~15s once models are baked. +if [ "$BUILD_EMBEDDINGS" = "1" ]; then + python scripts/build_skill_embeddings.py +fi + +exec gunicorn config.wsgi:application \ + --bind 0.0.0.0:7860 \ + --workers 1 --threads 4 --timeout 300 \ + --access-logfile - --error-logfile - diff --git a/manage.py b/manage.py new file mode 100644 index 0000000000000000000000000000000000000000..8e7ac79b9576cb38f460147ec7f3ade64a100c62 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000000000000000000000000000000000..885ee825fac5003d29024c24e5e2d060417400bd --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +DJANGO_SETTINGS_MODULE=config.settings +python_files = tests.py test_*.py *_tests.py +addopts = --ds=config.settings diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000000000000000000000000000000000000..8587c1f3ca868ed4e06eb33eb33747af4895fa67 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +# Dev-only dependencies. Install with: pip install -r requirements-dev.txt +# Kept separate so runtime images stay slim. + +# Fixture PDF generation for Module 8 resume-parser tests. +reportlab>=4.0,<5.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..0d0d068caecfda30dabc636b8d16d4214c4762dc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,47 @@ +Django>=5.1,<7.0 +djangorestframework==3.17.1 +djangorestframework-simplejwt[crypto]==5.5.1 +psycopg[binary]==3.3.3 +django-cors-headers==4.9.0 +drf-spectacular==0.29.0 +ruff==0.15.9 +pytest-django==4.12.0 +django-filter==25.2 +python-dotenv==1.2.2 +PyYAML>=6.0,<7.0 + +# Module 5 checkpoint auto-import — fetch + parse course-outline pages +# (schema.org/Course JSON-LD via Coursera/DataCamp, generic HTML fallback, +# YouTube Data API, freeCodeCamp GitHub JSON). No headless browser. `requests` +# is already present transitively (transformers/huggingface); pinned direct +# since the importers import it explicitly. `lxml` is the bs4 parser backend. +requests>=2.31 +beautifulsoup4>=4.12,<5.0 + +# Module 8 (resume parsing) — lexical layer. +pdfplumber>=0.11,<1.0 +rapidfuzz>=3.0,<4.0 + +# Module 8 full NER fallback chain (Nucha BERT -> JobBERT -> SkillNER -> SBERT+pgvector). +# CPU-only torch wheel to avoid the ~2 GB CUDA payload. Verified 2026-04-22 as +# installable end-to-end on Python 3.13.11 on Windows. +--extra-index-url https://download.pytorch.org/whl/cpu +torch>=2.11,<3.0 +transformers>=4.44,<5.0 +sentence-transformers>=3.0,<4.0 +skillNer>=1.0.3 +# skillNer transitively imports IPython (notebook display helpers) at extractor +# construction time — without this pin every resume upload logs +# "ner.skillner: raised No module named 'IPython', skipping" and layer 3 goes +# dark. See research/06-dataset-sourcing.md §5 NER fallback chain. +ipython>=8.0,<9.0 +spacy>=3.8,<4.0 +pgvector>=0.3.6,<1.0 + +# Deployment (HF Spaces Docker + Neon Postgres). Additive only; unused locally +# (runserver/local Postgres keep working). gunicorn = WSGI server, whitenoise = +# static file serving (Django admin/DRF) without nginx, dj-database-url = parse +# Neon's single DATABASE_URL. +gunicorn>=23.0 +whitenoise>=6.8 +dj-database-url>=2.3 diff --git a/scripts/build_skill_embeddings.py b/scripts/build_skill_embeddings.py new file mode 100644 index 0000000000000000000000000000000000000000..20f05287028d37922d00b3fd870ac4d8c2aef261 --- /dev/null +++ b/scripts/build_skill_embeddings.py @@ -0,0 +1,95 @@ +"""Populate the SkillEmbedding table with SBERT vectors for every catalog Skill. + +Idempotent. Re-run after any `seed_initial_skills` change to refresh the +embeddings. One-shot script (not a management command) mirroring +`parse_onet_dump.py` style. + +Usage: + python backend/scripts/build_skill_embeddings.py + +Takes ~15 s for ~70 skills (model load dominates — the encode step itself +is <1 s). Writes via update_or_create keyed on Skill so existing rows get +overwritten with the new vector. + +Requires: + * pgvector extension installed on the DB + migration 0003 applied. + * sentence-transformers + torch in the venv (requirements.txt). + * all-MiniLM-L6-v2 on disk or network accessible for first-call download. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# Let this script run from anywhere — set up the backend module path first. +BACKEND_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BACKEND_DIR)) + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +import django # noqa: E402 +django.setup() + +from django.db import transaction # noqa: E402 + +from apps.skills.models import Skill, SkillEmbedding # noqa: E402 + + +MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" + + +def _encoding_text(skill: Skill) -> str: + """What we embed for each skill. + + Use ``skill_name + " — " + (description or category)`` so the embedding + carries both the exact name AND a short semantic bridge. "Python" + embedded alone sits near "python snake" and "Monty Python" in SBERT + space; "Python — Programming" stays inside the tech cluster. + """ + context = (skill.description or "").strip() or skill.category.strip() + if context: + return f"{skill.skill_name} — {context}" + return skill.skill_name + + +def main() -> int: + # Lazy import so a failure here is visible, not an ImportError at the top. + from sentence_transformers import SentenceTransformer + + skills = list(Skill.objects.all().order_by("id")) + if not skills: + print("No skills in the catalog — run seed_initial_skills first.") + return 1 + + print(f"Loading {MODEL_NAME}…") + model = SentenceTransformer(MODEL_NAME) + + texts = [_encoding_text(s) for s in skills] + print(f"Encoding {len(texts)} skills…") + vectors = model.encode(texts, normalize_embeddings=True, show_progress_bar=False) + + created = 0 + updated = 0 + with transaction.atomic(): + for skill, text, vec in zip(skills, texts, vectors): + _, was_created = SkillEmbedding.objects.update_or_create( + skill=skill, + defaults={ + "embedding": vec.tolist(), + "source_text": text, + "model_name": MODEL_NAME.rsplit("/", 1)[-1], + }, + ) + if was_created: + created += 1 + else: + updated += 1 + + print(f"Done: {created} created, {updated} updated, " + f"{len(skills)} total.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/curate_roles.py b/scripts/curate_roles.py new file mode 100644 index 0000000000000000000000000000000000000000..76729e052ca0e51b7c038cff1ee1057f5e0072aa --- /dev/null +++ b/scripts/curate_roles.py @@ -0,0 +1,175 @@ +"""Apply role_curation.yaml to onet_roles_raw.yaml. + +Reads: + backend/seed_data/onet_roles_raw.yaml + backend/seed_data/role_curation.yaml + backend/seed_data/manual_skill_augmentation.yaml (for skills mentioned in keep but absent from raw) + +Writes: + backend/seed_data/onet_roles_curated.yaml (final seed-ready file) + backend/seed_data/onet_roles_curation_log.md (per-role audit log of keeps + drops + reasons) + +Logic: + For each role: + 1. Take its keep list from role_curation.yaml. + 2. For each keep entry, find it in raw skills OR in augmentation; if found nowhere, error. + 3. Apply optional weight / is_mandatory / required_level overrides. + 4. Anything in raw NOT in keep is DROPPED — record reason from drop_reasons map (or "no reason given"). + 5. Write curated YAML with same shape as raw, plus a curated_at timestamp at the top. +""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +SEED_DIR = REPO_ROOT / "seed_data" + +RAW_FILE = SEED_DIR / "onet_roles_raw.yaml" +CURATION_FILE = SEED_DIR / "role_curation.yaml" +AUGMENTATION_FILE = SEED_DIR / "manual_skill_augmentation.yaml" +OUTPUT_FILE = SEED_DIR / "onet_roles_curated.yaml" +LOG_FILE = SEED_DIR / "onet_roles_curation_log.md" + + +def find_skill(skills: list[dict], name: str) -> dict | None: + for s in skills: + if s["skill_name"] == name: + return s + return None + + +def synth_from_augmentation(role_name: str, skill_name: str, aug_doc: dict) -> dict | None: + """If a keep entry isn't in raw, see if it lives in the augmentation YAML.""" + role_aug = aug_doc.get("augmentation", {}).get(role_name, []) + for entry in role_aug: + if entry["skill"] == skill_name: + return { + "skill_name": skill_name, + "category": entry.get("category", "Tools"), + "source": "manual", + "source_soc": None, + "is_mandatory": entry["is_mandatory"], + "weight": entry["weight"], + "required_level": entry["required_level"], + } + return None + + +def main() -> int: + raw = yaml.safe_load(RAW_FILE.read_text(encoding="utf-8")) + curation = yaml.safe_load(CURATION_FILE.read_text(encoding="utf-8")) + augmentation = yaml.safe_load(AUGMENTATION_FILE.read_text(encoding="utf-8")) + + out_roles: list[dict] = [] + log_sections: list[str] = [] + log_sections.append( + f"# Role Curation Log\n\n" + f"Generated {datetime.now(timezone.utc).isoformat()}Z by `scripts/curate_roles.py`\n\n" + f"This log shows, per role, which skills were kept and which were dropped (with reason).\n" + f"Source of truth for keep decisions: `seed_data/role_curation.yaml`. Edit there + re-run.\n\n" + ) + + fatal = False + + for role in raw["roles"]: + name = role["name"] + rule = curation.get("curation", {}).get(name) + if not rule: + print(f"WARN: no curation rule for '{name}', keeping all skills as-is", + file=sys.stderr) + out_roles.append(role) + continue + + keep_entries = rule.get("keep", []) + drop_reasons = rule.get("drop_reasons", {}) + + # Build the curated skill list, in the order specified by `keep` + kept: list[dict] = [] + kept_names: set[str] = set() + for entry in keep_entries: + skill_name = entry["name"] + if skill_name in kept_names: + continue + base = find_skill(role["skills"], skill_name) + if base is None: + # Try augmentation fallback + base = synth_from_augmentation(name, skill_name, augmentation) + if base is None: + print(f"FATAL: keep '{skill_name}' for '{name}' not in raw nor augmentation", + file=sys.stderr) + fatal = True + continue + entry_dict = dict(base) # shallow copy + # Apply overrides if present + for k in ("is_mandatory", "weight", "required_level", "category"): + if k in entry: + entry_dict[k] = entry[k] + kept.append(entry_dict) + kept_names.add(skill_name) + + # Anything in raw not kept is dropped — log reason + dropped: list[tuple[str, str]] = [] + raw_names = {s["skill_name"] for s in role["skills"]} + for s in role["skills"]: + if s["skill_name"] not in kept_names: + reason = drop_reasons.get(s["skill_name"], "no reason given (please add to role_curation.yaml drop_reasons)") + dropped.append((s["skill_name"], reason)) + + # Sanity: any keep referenced a skill that is NOT in raw and NOT in augmentation? Already errored above. + # Also: drop_reasons referencing a skill that wasn't in raw — warn. + for orphan in set(drop_reasons.keys()) - raw_names: + print(f" WARN [{name}]: drop_reasons mentions '{orphan}' but it wasn't in raw", + file=sys.stderr) + + out_roles.append({ + "name": name, + "industry": role["industry"], + "description": role["description"], + "primary_soc": role["primary_soc"], + "all_socs": role["all_socs"], + "skills": kept, + }) + + log_sections.append( + f"## {name}\n\n" + f"**Kept ({len(kept)} skills):**\n\n" + + "\n".join( + f"- `{s['skill_name']}` " + f"({'mandatory' if s['is_mandatory'] else 'optional'}, " + f"weight={s['weight']}, level={s['required_level']}, source={s['source']})" + for s in kept + ) + + f"\n\n**Dropped ({len(dropped)} skills):**\n\n" + + ("\n".join(f"- `{name}` — {reason}" for name, reason in dropped) if dropped else "_none_") + + "\n\n" + ) + + print(f" {name}: {len(kept)} kept, {len(dropped)} dropped") + + if fatal: + print("\nABORT: see FATAL messages above. Fix role_curation.yaml and retry.", + file=sys.stderr) + return 1 + + OUTPUT_FILE.write_text( + f"# Curated role+skill seed for GapGuide\n" + f"# Generated {datetime.now(timezone.utc).isoformat()}Z by scripts/curate_roles.py\n" + f"# Edit seed_data/role_curation.yaml then re-run; do NOT hand-edit this file.\n\n" + + yaml.safe_dump({"roles": out_roles}, sort_keys=False, allow_unicode=True, width=200), + encoding="utf-8", + ) + LOG_FILE.write_text("\n".join(log_sections), encoding="utf-8") + + print(f"\nWrote {OUTPUT_FILE}") + print(f"Wrote {LOG_FILE}") + print(f"Total: {len(out_roles)} roles, {sum(len(r['skills']) for r in out_roles)} kept skills") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/evaluate_resume_parser.py b/scripts/evaluate_resume_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..ff5b790ae290cadebebba52e9a19e009681e361b --- /dev/null +++ b/scripts/evaluate_resume_parser.py @@ -0,0 +1,232 @@ +"""NER chain evaluation — Module 8 metrics for Phase 3 S5.2. + +Runs `parse_resume_envelope` against the 10 annotated fixture resumes, +compares output against ground-truth canonical skill names, and reports +precision / recall / F1 / canonical-mapping accuracy + per-fixture latency. + +Modes: + - full : run the complete 5-layer chain (nucha,jobbert,skillner,sbert,lexical) + - lexical: run the always-on floor only (GAPGUIDE_PARSE_LAYERS=lexical) + - both : run both and compare + +Usage: + python backend/scripts/evaluate_resume_parser.py --mode both + +Output is printed to stdout (redirect into phase3/metrics/ner_eval_raw.txt). +""" +from __future__ import annotations + +import argparse +import json +import os +import statistics +import sys +import time +from pathlib import Path + +import django +import yaml + + +BACKEND_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BACKEND_DIR)) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +django.setup() + +from django.core.management import call_command # noqa: E402 + +FIX_DIR = BACKEND_DIR / "tests" / "fixtures" / "resumes" +GT_PATH = BACKEND_DIR.parent / "phase3" / "metrics" / "resume_eval_groundtruth.yaml" + + +def seed_catalog_and_embeddings() -> None: + """Ensure skills are seeded and SBERT embeddings exist. + + Skipped silently if the catalog already has ≥50 skills and embeddings + cover them (idempotent re-runs shouldn't re-download models). + """ + from apps.skills.models import Skill, SkillEmbedding + + skill_count = Skill.objects.count() + if skill_count < 50: + print(f"[seed] skill count = {skill_count}; calling seed_initial_skills…") + call_command("seed_initial_skills") + else: + print(f"[seed] {skill_count} skills already present — skipping reseed.") + + missing = Skill.objects.count() - SkillEmbedding.objects.count() + if missing > 0: + print(f"[seed] {missing} skills missing embeddings — building now…") + from sentence_transformers import SentenceTransformer + model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2") + skills = list(Skill.objects.all()) + vecs = model.encode( + [f"{s.skill_name} — {s.description or s.category}" for s in skills], + normalize_embeddings=True, + ) + for skill, vec in zip(skills, vecs): + SkillEmbedding.objects.update_or_create( + skill=skill, + defaults={ + "embedding": vec.tolist(), + "source_text": skill.skill_name, + }, + ) + print("[seed] embeddings built.") + else: + print("[seed] SBERT embeddings already present — skipping rebuild.") + + +def load_groundtruth() -> dict[str, set[str]]: + with open(GT_PATH, encoding="utf-8") as f: + raw = yaml.safe_load(f) + return {k: set(v) for k, v in raw.items()} + + +def run_mode(mode: str, groundtruth: dict[str, set[str]]) -> dict: + """Run parse_resume_envelope against each fixture under the given mode. + + Returns per-fixture and aggregate metrics. + """ + # Configure chain. + if mode == "lexical": + os.environ["GAPGUIDE_PARSE_LAYERS"] = "lexical" + else: # full + os.environ.pop("GAPGUIDE_PARSE_LAYERS", None) + + # Reload the module so the env-var change takes effect. + import importlib + from apps.accounts import resume_parser as rp + importlib.reload(rp) + + per_fixture = [] + catalog_names = set() + from apps.skills.models import Skill + catalog_names = set(Skill.objects.values_list("skill_name", flat=True)) + + total_tp = total_fp = total_fn = 0 + total_predicted = 0 + total_in_catalog = 0 + latencies = [] + + for fname, gt in sorted(groundtruth.items()): + pdf_path = FIX_DIR / fname + if not pdf_path.exists(): + print(f"[warn] missing fixture: {fname}") + continue + pdf_bytes = pdf_path.read_bytes() + + t0 = time.perf_counter() + env = rp.parse_resume_envelope(pdf_bytes) + dt = time.perf_counter() - t0 + latencies.append(dt) + + predicted = {s["skill_name"] for s in env["skills"]} + fired = env.get("parser_version", []) + + tp = predicted & gt + fp = predicted - gt + fn = gt - predicted + in_catalog = predicted & catalog_names + + total_tp += len(tp) + total_fp += len(fp) + total_fn += len(fn) + total_predicted += len(predicted) + total_in_catalog += len(in_catalog) + + precision = len(tp) / len(predicted) if predicted else 0.0 + recall = len(tp) / len(gt) if gt else 0.0 + f1 = ( + 2 * precision * recall / (precision + recall) + if (precision + recall) > 0 else 0.0 + ) + mapping_acc = len(in_catalog) / len(predicted) if predicted else 1.0 + + per_fixture.append({ + "fixture": fname, + "latency_ms": round(dt * 1000, 1), + "layers_fired": fired, + "gt_count": len(gt), + "predicted_count": len(predicted), + "tp": sorted(tp), + "fp": sorted(fp), + "fn": sorted(fn), + "precision": round(precision, 3), + "recall": round(recall, 3), + "f1": round(f1, 3), + "mapping_accuracy": round(mapping_acc, 3), + }) + + # Micro-averaged (pooled) metrics. + mp = total_tp / (total_tp + total_fp) if (total_tp + total_fp) > 0 else 0.0 + mr = total_tp / (total_tp + total_fn) if (total_tp + total_fn) > 0 else 0.0 + mf = 2 * mp * mr / (mp + mr) if (mp + mr) > 0 else 0.0 + m_map = total_in_catalog / total_predicted if total_predicted > 0 else 0.0 + + # Macro-averaged (per-fixture, then mean). + macro_p = statistics.mean(pf["precision"] for pf in per_fixture) + macro_r = statistics.mean(pf["recall"] for pf in per_fixture) + macro_f = statistics.mean(pf["f1"] for pf in per_fixture) + macro_map = statistics.mean(pf["mapping_accuracy"] for pf in per_fixture) + + return { + "mode": mode, + "n_fixtures": len(per_fixture), + "per_fixture": per_fixture, + "micro": { + "precision": round(mp, 3), + "recall": round(mr, 3), + "f1": round(mf, 3), + "mapping_accuracy": round(m_map, 3), + }, + "macro": { + "precision": round(macro_p, 3), + "recall": round(macro_r, 3), + "f1": round(macro_f, 3), + "mapping_accuracy": round(macro_map, 3), + }, + "latency": { + "mean_ms": round(statistics.mean(latencies) * 1000, 1), + "p50_ms": round(statistics.median(latencies) * 1000, 1), + "max_ms": round(max(latencies) * 1000, 1), + }, + } + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("--mode", choices=["full", "lexical", "both"], default="both") + p.add_argument( + "--out-json", + default=str(BACKEND_DIR.parent / "phase3" / "metrics" / "ner_eval_results.json"), + ) + args = p.parse_args() + + seed_catalog_and_embeddings() + gt = load_groundtruth() + print(f"[eval] {len(gt)} fixtures in ground truth.") + + results = {} + if args.mode in ("full", "both"): + print("\n=== Running FULL chain (nucha,jobbert,skillner,sbert,lexical) ===") + results["full"] = run_mode("full", gt) + print(json.dumps(results["full"]["micro"], indent=2)) + print(json.dumps(results["full"]["macro"], indent=2)) + print(json.dumps(results["full"]["latency"], indent=2)) + + if args.mode in ("lexical", "both"): + print("\n=== Running LEXICAL only (baseline floor) ===") + results["lexical"] = run_mode("lexical", gt) + print(json.dumps(results["lexical"]["micro"], indent=2)) + print(json.dumps(results["lexical"]["macro"], indent=2)) + print(json.dumps(results["lexical"]["latency"], indent=2)) + + out_path = Path(args.out_json) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(results, indent=2), encoding="utf-8") + print(f"\n[eval] wrote detailed results to {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_resume_fixtures.py b/scripts/generate_resume_fixtures.py new file mode 100644 index 0000000000000000000000000000000000000000..d333a7f96725d49e60f383a3c799048fa2f6fad6 --- /dev/null +++ b/scripts/generate_resume_fixtures.py @@ -0,0 +1,149 @@ +"""Generate synthetic UCP-student-shaped resume PDFs for Module 8 tests + demo. + +Idempotent — re-running overwrites the same filenames. Output goes to +backend/tests/fixtures/resumes/. + +What each fixture exercises (see fixtures/resumes/README.md for details): + resume_ds_strong.pdf — dense DS skills (Python/pandas/sklearn/...) + resume_fe_junior.pdf — junior FE profile (React/JS/HTML/CSS/Git) + resume_fullstack_mixed.pdf — composite Django+React+Postgres+Docker + resume_minimal.pdf — few literal skill names — forces SBERT/fuzzy + resume_scanned.pdf — image-only PDF → triggers ResumeParseError + +Dev-only deps (requirements-dev.txt): reportlab. +""" +from __future__ import annotations + +from pathlib import Path + +from reportlab.lib.pagesizes import LETTER +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer +from reportlab.pdfgen import canvas + + +BASE_DIR = Path(__file__).resolve().parent.parent +FIXTURES_DIR = BASE_DIR / "tests" / "fixtures" / "resumes" + + +def _write_paragraphs(path: Path, paragraphs: list[str]) -> None: + """Emit a single-column text-only PDF from the given paragraphs.""" + styles = getSampleStyleSheet() + doc = SimpleDocTemplate( + str(path), pagesize=LETTER, + leftMargin=54, rightMargin=54, topMargin=54, bottomMargin=54, + ) + story = [] + for i, text in enumerate(paragraphs): + style = styles["Heading2"] if i == 0 else styles["BodyText"] + story.append(Paragraph(text, style)) + story.append(Spacer(1, 8)) + doc.build(story) + + +def resume_ds_strong() -> list[str]: + return [ + "Ayesha Khan — Data Science BS (UCP, Semester 7)", + "Summary: Final-year Data Science student with 2 internships in " + "machine learning and dashboarding. Experienced in Python, SQL, Pandas, " + "NumPy, Scikit-learn, TensorFlow, and PyTorch.", + "Skills: Python (advanced, 3 years), SQL (advanced), Pandas, " + "NumPy, Matplotlib, Scikit-learn, TensorFlow, PyTorch, Tableau, " + "Statistical Hypothesis Testing, Git, Jupyter.", + "Projects: Built an end-to-end churn-prediction pipeline using " + "scikit-learn and XGBoost; deployed a neural-network image classifier " + "with PyTorch and served it via FastAPI.", + "Internship — Data Analyst @ TechCorp (2024): Designed SQL " + "reports and Tableau dashboards for marketing funnel analysis.", + "Education: UCP Lahore, BS Data Science, CGPA 3.8/4.0. " + "Relevant coursework: Machine Learning, Deep Learning, Statistics.", + ] + + +def resume_fe_junior() -> list[str]: + return [ + "Bilal Ahmed — Computer Science BS (UCP, Semester 5)", + "Summary: Junior frontend developer with a focus on React, " + "HTML, and CSS. Familiar with basic Git workflows and modern " + "JavaScript (ES2020+).", + "Skills: React, JavaScript, HTML, CSS, Git, Figma (beginner), " + "Bootstrap, TailwindCSS, Node.js (basic).", + "Projects: Personal portfolio site (React, Vite, Tailwind). " + "Classroom attendance dashboard UI with React and Ant Design.", + "Education: UCP Lahore, BS Computer Science, CGPA 3.2/4.0.", + ] + + +def resume_fullstack_mixed() -> list[str]: + return [ + "Zainab Ali — Software Engineering BS (UCP, Semester 7)", + "Summary: Full-stack developer with production experience in " + "Django and React. Built and deployed containerised web apps using " + "Docker Compose and PostgreSQL.", + "Skills: Python, Django, Django REST Framework, JavaScript, " + "React, PostgreSQL, Redis, Docker, Git, GitHub, Linux, Nginx, " + "RESTful APIs, HTML, CSS.", + "Projects: UCP club management portal (Django + React + " + "Postgres, deployed via Docker on a DigitalOcean droplet). " + "CI/CD with GitHub Actions.", + "Education: UCP Lahore, BS Software Engineering, CGPA 3.7/4.0.", + ] + + +def resume_minimal() -> list[str]: + # Uses paraphrased / near-names (data visualisation, cloud computing, + # databases, version control) so the lexical layer largely misses and + # SBERT/pgvector has to map the aliases to canonical catalog entries. + return [ + "Usman Shah — BS Applied Mathematics (UCP, Semester 4)", + "Summary: Math undergraduate interested in data-driven work. " + "Some exposure to programming and statistical analysis.", + "Skills: programming in scripting languages, data visualisation, " + "spreadsheet modelling, basic cloud computing exposure, version " + "control, databases, statistical analysis.", + "Coursework: Probability, Linear Algebra, Numerical Methods.", + ] + + +def generate_text_pdfs() -> None: + FIXTURES_DIR.mkdir(parents=True, exist_ok=True) + jobs = { + "resume_ds_strong.pdf": resume_ds_strong(), + "resume_fe_junior.pdf": resume_fe_junior(), + "resume_fullstack_mixed.pdf": resume_fullstack_mixed(), + "resume_minimal.pdf": resume_minimal(), + } + for filename, paragraphs in jobs.items(): + out = FIXTURES_DIR / filename + _write_paragraphs(out, paragraphs) + print(f" wrote {filename} ({out.stat().st_size // 1024} KB)") + + +def generate_scanned_pdf() -> None: + """Emit a PDF with *no* extractable text — just vector shapes. + + Exercises the ResumeParseError('No text could be extracted') branch in + resume_parser._extract_text. A real scanned CV would be a rasterised + image; reportlab can't easily make one without Pillow, but an empty-text + PDF lands in the same code path because extract_text() returns ''. + """ + out = FIXTURES_DIR / "resume_scanned.pdf" + c = canvas.Canvas(str(out), pagesize=LETTER) + # Draw a gray rectangle where "text" would be — no drawString calls. + c.setFillColorRGB(0.85, 0.85, 0.85) + c.rect(54, 600, 500, 120, fill=1, stroke=0) + c.rect(54, 420, 500, 150, fill=1, stroke=0) + c.showPage() + c.save() + print(f" wrote resume_scanned.pdf ({out.stat().st_size // 1024} KB)") + + +def main() -> None: + print(f"Generating resume fixtures into {FIXTURES_DIR}") + generate_text_pdfs() + generate_scanned_pdf() + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_resume_fixtures_eval.py b/scripts/generate_resume_fixtures_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..d5b28aaca2106847be77778bee5b8fdd6abc7b59 --- /dev/null +++ b/scripts/generate_resume_fixtures_eval.py @@ -0,0 +1,174 @@ +"""Additional resume fixtures for NER-chain evaluation. + +Extends the original 4 fixtures in generate_resume_fixtures.py with 6 more +personas covering Backend, Data Engineer, AI Engineer (GenAI), DevOps/SRE, +BI Analyst, and ML Engineer — mapped against the 71-skill seeded catalog so +ground-truth canonical names line up 1:1 with Skill.skill_name. + +Run: python backend/scripts/generate_resume_fixtures_eval.py +""" +from __future__ import annotations + +from pathlib import Path + +from reportlab.lib.pagesizes import LETTER +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer + + +BASE_DIR = Path(__file__).resolve().parent.parent +FIXTURES_DIR = BASE_DIR / "tests" / "fixtures" / "resumes" + + +def _write_paragraphs(path: Path, paragraphs: list[str]) -> None: + styles = getSampleStyleSheet() + doc = SimpleDocTemplate( + str(path), pagesize=LETTER, + leftMargin=54, rightMargin=54, topMargin=54, bottomMargin=54, + ) + story = [] + for i, text in enumerate(paragraphs): + style = styles["Heading2"] if i == 0 else styles["BodyText"] + story.append(Paragraph(text, style)) + story.append(Spacer(1, 8)) + doc.build(story) + + +def resume_backend_dev() -> list[str]: + return [ + "Hassan Raza - Software Engineering BS (UCP, Semester 7)", + "Summary: Backend developer focused on Python and Django. " + "Built production REST APIs for a logistics dashboard and a " + "student course-registration portal. Comfortable with PostgreSQL " + "schema design, Docker-based deployments, and Linux administration.", + "Skills: Python (advanced), Django, Django REST Framework, " + "PostgreSQL, Docker, Git, GitHub, Linux, REST API Design, JWT / OAuth, " + "Bash Scripting.", + "Projects: Internal billing API (Django + PostgreSQL, " + "deployed with Docker Compose). Multi-tenant SaaS auth service " + "using JWT token rotation. CI pipeline with GitHub Actions.", + "Internship - Backend Engineer Intern @ Systems Ltd (2025): " + "Optimised slow Django ORM queries, reduced p99 latency by 40%.", + "Education: UCP Lahore, BS Software Engineering, CGPA 3.6/4.0.", + ] + + +def resume_data_engineer() -> list[str]: + return [ + "Sara Malik - BS Data Science (UCP, Semester 8)", + "Summary: Data engineer with experience building batch and " + "near-real-time pipelines. Lead author of an Airflow-orchestrated " + "ETL stack that backs weekly marketing attribution reports.", + "Skills: Python, SQL, Apache Spark, Apache Kafka, " + "Apache Airflow, Snowflake, dbt, ETL / ELT, Docker, Git, " + "Google BigQuery, PostgreSQL.", + "Projects: Ingestion pipeline pulling click-stream events " + "from Kafka into Snowflake via Spark Structured Streaming. " + "dbt-based warehouse modelling with star-schema marts. Data " + "quality tests with Great Expectations.", + "Internship - Data Engineer @ Careem (2025): Authored 12 " + "dbt models serving the rider-acquisition funnel.", + "Education: UCP Lahore, BS Data Science, CGPA 3.85/4.0.", + ] + + +def resume_ai_engineer_genai() -> list[str]: + return [ + "Ahmad Tariq - BS Artificial Intelligence (UCP, Semester 7)", + "Summary: AI engineer focused on LLM applications. " + "Built retrieval-augmented chatbots using OpenAI and Anthropic " + "models on internal corporate knowledge bases. Strong Python " + "background, familiar with vector-DB retrieval patterns.", + "Skills: Python, LangChain, LangGraph, OpenAI API, " + "Anthropic Claude API, RAG, HuggingFace Transformers, " + "Prompt Engineering, Sentence Transformers, pgvector, " + "FastAPI, Git.", + "Projects: Legal-document Q&A assistant using LangChain, " + "OpenAI embeddings, and pgvector retrieval. Agentic research " + "tool with LangGraph routing between Claude and GPT-4 per task.", + "Research: Undergraduate thesis on prompt-ensembling for " + "structured extraction tasks. Submitted workshop paper to a " + "local NLP meetup.", + "Education: UCP Lahore, BS Artificial Intelligence, " + "CGPA 3.92/4.0.", + ] + + +def resume_devops_sre() -> list[str]: + return [ + "Umer Farooq - BS Computer Science (UCP, Semester 8)", + "Summary: DevOps/SRE-track engineer. Manages production " + "Kubernetes clusters for a fintech platform. Deep experience in " + "infrastructure-as-code, container orchestration, and observability.", + "Skills: Docker, Kubernetes, Ansible, Terraform, Prometheus, " + "Grafana, CI/CD Pipelines, Jenkins, Linux, Bash Scripting, " + "AWS, Git, GitHub Actions.", + "Projects: Migrated a 30-service monolith from plain EC2 " + "to EKS with Helm charts. Set up Prometheus + Grafana dashboards " + "covering request-rate, error-rate, and saturation across services. " + "Ansible-driven bootstrap for new clusters.", + "Internship - DevOps Engineer @ Tier5 (2025): On-call rotation, " + "authored 14 runbooks, reduced MTTR by 2x.", + "Education: UCP Lahore, BS Computer Science, CGPA 3.7/4.0.", + ] + + +def resume_bi_analyst() -> list[str]: + return [ + "Mehwish Anwar - BS Data Science (UCP, Semester 6)", + "Summary: BI analyst intern with experience in Power BI " + "dashboards and SQL-based reporting. Comfortable with star-schema " + "dimensional modelling and DAX measures.", + "Skills: SQL, Microsoft Excel, Microsoft Power BI, Tableau, " + "DAX, Power Query / M Language, Data Modeling, Data Storytelling, " + "Star Schema Modeling, PostgreSQL.", + "Projects: Executive-level sales dashboard on Power BI " + "backed by a PostgreSQL star schema. Monthly churn-cohort Tableau " + "workbook for a telecom case competition.", + "Internship - BI Analyst @ Telenor (2025): Wrote 30+ SQL " + "reports for the customer-experience team; rewrote 4 DAX measures " + "that halved dashboard load time.", + "Education: UCP Lahore, BS Data Science, CGPA 3.6/4.0.", + ] + + +def resume_ml_engineer() -> list[str]: + return [ + "Taimoor Abbas - BS Data Science (UCP, Semester 8)", + "Summary: Machine learning engineer with strong deep-learning " + "foundations. Shipped a computer-vision defect-detection model to " + "production on a factory-floor edge device. Experienced in " + "experiment-tracking and model-serving pipelines.", + "Skills: Python, PyTorch, TensorFlow, Scikit-learn, MLflow, " + "Docker, Feature Engineering, HuggingFace Transformers, NumPy, " + "Pandas, Matplotlib, Git, Statistical Hypothesis Testing.", + "Projects: PyTorch-based ResNet defect-classifier with " + "MLflow run tracking. Dockerised model server with FastAPI. " + "Sklearn baseline pipelines for a churn-prediction kaggle comp.", + "Internship - ML Engineer Intern @ Arbisoft (2025): Built " + "a feature store prototype; documented 18 features with lineage " + "and freshness SLAs.", + "Education: UCP Lahore, BS Data Science, CGPA 3.8/4.0.", + ] + + +def main() -> None: + FIXTURES_DIR.mkdir(parents=True, exist_ok=True) + jobs = { + "resume_backend_dev.pdf": resume_backend_dev(), + "resume_data_engineer.pdf": resume_data_engineer(), + "resume_ai_engineer_genai.pdf": resume_ai_engineer_genai(), + "resume_devops_sre.pdf": resume_devops_sre(), + "resume_bi_analyst.pdf": resume_bi_analyst(), + "resume_ml_engineer.pdf": resume_ml_engineer(), + } + print(f"Generating eval fixtures into {FIXTURES_DIR}") + for filename, paragraphs in jobs.items(): + out = FIXTURES_DIR / filename + _write_paragraphs(out, paragraphs) + print(f" wrote {filename} ({out.stat().st_size // 1024} KB)") + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/scripts/parse_onet_dump.py b/scripts/parse_onet_dump.py new file mode 100644 index 0000000000000000000000000000000000000000..75e3e0a39638f81067f303c6b529079f2214ec5f --- /dev/null +++ b/scripts/parse_onet_dump.py @@ -0,0 +1,311 @@ +"""Parse the O*NET v30.2 bulk database into a normalized seed YAML. + +Reads: + backend/seed_data/onet_dump/db_30_2_text/Technology Skills.txt + backend/seed_data/onet_dump/db_30_2_text/Occupation Data.txt + backend/seed_data/role_soc_mapping.yaml + backend/seed_data/manual_skill_augmentation.yaml + +Writes: + backend/seed_data/onet_roles_raw.yaml + +Filter rule: keep technology_skills where Hot Technology=Y AND In Demand=Y. +That intersection produces 11-35 skills per role (verified) — the right granularity +for a usable RoleSkill list. Manual augmentation adds modern frameworks O*NET misses. + +Run from repo root: + cd backend && python scripts/parse_onet_dump.py +""" + +from __future__ import annotations + +import csv +import sys +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +SEED_DIR = REPO_ROOT / "seed_data" +ONET_DIR = SEED_DIR / "onet_dump" / "db_30_2_text" + +ROLE_MAPPING_FILE = SEED_DIR / "role_soc_mapping.yaml" +AUGMENTATION_FILE = SEED_DIR / "manual_skill_augmentation.yaml" +OUTPUT_FILE = SEED_DIR / "onet_roles_raw.yaml" + + +def load_occupation_data() -> dict[str, dict[str, str]]: + """SOC code -> {title, description}.""" + path = ONET_DIR / "Occupation Data.txt" + out: dict[str, dict[str, str]] = {} + with path.open(encoding="utf-8") as f: + reader = csv.DictReader(f, delimiter="\t") + for row in reader: + out[row["O*NET-SOC Code"]] = { + "title": row["Title"], + "description": row["Description"], + } + return out + + +def load_tech_skills_for_socs(socs: set[str]) -> dict[str, list[dict]]: + """SOC code -> [{example, commodity_title, hot, in_demand}]. + + Only returns rows where Hot Technology=Y AND In Demand=Y. + """ + path = ONET_DIR / "Technology Skills.txt" + out: dict[str, list[dict]] = {soc: [] for soc in socs} + with path.open(encoding="utf-8") as f: + reader = csv.DictReader(f, delimiter="\t") + for row in reader: + soc = row["O*NET-SOC Code"] + if soc not in socs: + continue + if row["Hot Technology"] != "Y" or row["In Demand"] != "Y": + continue + out[soc].append({ + "example": row["Example"], + "commodity_title": row["Commodity Title"], + "hot": True, + "in_demand": True, + }) + return out + + +def normalize_skill_name(raw: str) -> str: + """Map O*NET's verbose skill names to cleaner display names. + + Examples: + "Structured query language SQL" -> "SQL" + "Amazon Web Services AWS software" -> "AWS" + "Hypertext markup language HTML" -> "HTML" + "Cascading style sheets CSS" -> "CSS" + "Microsoft Azure software" -> "Microsoft Azure" + "Oracle Java" -> "Java" + "JavaScript Object Notation JSON" -> "JSON" + "Extensible markup language XML" -> "XML" + "Microsoft .NET Framework" -> ".NET" + "Spring Framework" / "Spring Boot" -> kept as-is + "The MathWorks MATLAB" -> "MATLAB" + "Google Angular" -> "Angular" + "IBM Terraform" -> "Terraform" + Cleaner names = better matches against manual augmentation entries and + against terms a student would actually type. + """ + s = raw.strip() + # Strip trailing " software" + if s.endswith(" software"): + s = s[: -len(" software")] + # Common acronym extractions: take the trailing all-caps token if the rest + # is a verbose expansion of it. + aliases = { + "Structured query language SQL": "SQL", + "Amazon Web Services AWS": "AWS", + "Hypertext markup language HTML": "HTML", + "Cascading style sheets CSS": "CSS", + "JavaScript Object Notation JSON": "JSON", + "Extensible markup language XML": "XML", + "The MathWorks MATLAB": "MATLAB", + "Google Angular": "Angular", + "IBM Terraform": "Terraform", + "Oracle Java": "Java", + "Microsoft .NET Framework": ".NET", + "Apache Hive": "Apache Hive", + "Apache Hadoop": "Apache Hadoop", + "Apache Spark": "Apache Spark", + "Apache Kafka": "Apache Kafka", + "Apache Airflow": "Apache Airflow", + "Apache Cassandra": "Apache Cassandra", + "Atlassian JIRA": "Jira", + "Atlassian Confluence": "Confluence", + "Microsoft Azure": "Microsoft Azure", + "Microsoft Excel": "Microsoft Excel", + "Microsoft Power BI": "Microsoft Power BI", + "Microsoft PowerPoint": "Microsoft PowerPoint", + "Microsoft Access": "Microsoft Access", + "Jenkins CI": "Jenkins", + "Node.js": "Node.js", + } + return aliases.get(s, s) + + +def categorize_skill(name: str, commodity_title: str) -> str: + """Best-effort skill category from O*NET commodity title or exact name match. + + Order matters: most specific buckets first (BI / Cloud / ML / Infra / Framework + / Database / Language) before generic Tools fallback. Match on EXACT skill + names where possible to avoid substring false positives (e.g., the single + letter "r" matching "PowerPoint"). + """ + n = name.strip() + n_lower = n.lower() + c_lower = commodity_title.lower() if commodity_title else "" + + # Data/Analytics tools — check before BI because commodity_title for many + # data tools (Spark, Hadoop) is "Business intelligence and data analysis software" + data_names = {"Apache Spark", "Apache Hadoop", "Apache Hive", "Apache Kafka", + "Apache Airflow", "Apache Cassandra", "dbt", "Pandas", "NumPy", + "Matplotlib", "Seaborn", "Alteryx software", "Alteryx"} + if n in data_names: + return "Data" + + # Most specific first + bi_names = {"Microsoft Power BI", "Tableau", "Looker", "Microsoft Excel"} + if n in bi_names or "business intelligence" in c_lower: + return "BI" + + cloud_names = {"AWS", "Microsoft Azure", "Google Cloud Platform", "Snowflake", + "Google BigQuery", "Amazon Redshift", "Amazon Elastic Compute Cloud EC2", + "Amazon Simple Storage Service S3"} + if n in cloud_names or "cloud-based" in c_lower: + return "Cloud" + + ml_names = {"PyTorch", "TensorFlow", "Scikit-learn", "HuggingFace Transformers", + "Apache MXNet", "MLflow", "Sentence Transformers", "ONNX"} + if n in ml_names: + return "ML" + + infra_names = {"Docker", "Kubernetes", "Terraform", "Ansible", "Linux", + "Linux Administration", "Bash Scripting", "Docker Compose"} + if n in infra_names: + return "Infra" + + framework_names = {"React", "Angular", "Vue.js", "Django", "Flask", "FastAPI", + "Spring Boot", "Spring Framework", "Next.js", "Node.js", + "Tailwind CSS", "TanStack Query", "Express", "LangChain", + "LlamaIndex"} + if n in framework_names: + return "Framework" + + cicd_names = {"Jenkins", "GitHub Actions", "Atlassian JIRA", "Jira", + "Atlassian Confluence", "Confluence"} + if n in cicd_names: + return "CI/CD" + + db_names = {"PostgreSQL", "MySQL", "MongoDB", "Redis", "Elasticsearch", + "NoSQL", "pgvector", "Pinecone"} + if n in db_names or ("data base" in c_lower and n not in cloud_names): + return "Database" + + # Languages — match on EXACT name only to avoid "r" matching "PowerPoint" + language_names = {"Python", "Java", "C", "C#", "C++", "JavaScript", "TypeScript", + "Go", "Ruby", "Swift", "Kotlin", "Bash", "MATLAB", "R", "SAS", + "PHP", "Rust", "Scala", "HTML", "CSS", "XML", "JSON", ".NET"} + if n in language_names: + return "Language" + + if n in ("Git", "GitHub", "Vite", "Jupyter Notebook"): + return "Tools" + + if "version" in c_lower or "monitoring" in c_lower: + return "Tools" + + return "Tools" + + +def main() -> int: + if not ONET_DIR.exists(): + print(f"ERROR: O*NET dump not found at {ONET_DIR}", file=sys.stderr) + print("Download db_30_2_text.zip from " + "https://www.onetcenter.org/dl_files/database/db_30_2_text.zip " + "and extract under backend/seed_data/onet_dump/", file=sys.stderr) + return 1 + + role_mapping = yaml.safe_load(ROLE_MAPPING_FILE.read_text(encoding="utf-8")) + augmentation = yaml.safe_load(AUGMENTATION_FILE.read_text(encoding="utf-8")) + + # Collect all SOCs we need to query (across composite roles) + all_socs: set[str] = set() + for role in role_mapping["roles"]: + all_socs.update(role["socs"]) + + occupation_data = load_occupation_data() + tech_skills = load_tech_skills_for_socs(all_socs) + + # Verify every SOC was found + missing = [soc for soc in all_socs if soc not in occupation_data] + if missing: + print(f"ERROR: SOC codes not found in Occupation Data.txt: {missing}", + file=sys.stderr) + return 1 + + output = {"roles": []} + + for role in role_mapping["roles"]: + name = role["name"] + socs = role["socs"] + + # Pick the title/description from the first SOC, unless overridden + primary_soc = socs[0] + title_source = occupation_data[primary_soc] + description = role.get("description_override") or title_source["description"] + + # Union tech skills across composite SOCs, dedupe by normalized name + seen_names: set[str] = set() + skills: list[dict] = [] + for soc in socs: + for entry in tech_skills.get(soc, []): + normalized = normalize_skill_name(entry["example"]) + if normalized in seen_names: + continue + seen_names.add(normalized) + skills.append({ + "skill_name": normalized, + "category": categorize_skill(normalized, entry["commodity_title"]), + "source": "onet", + "source_soc": soc, + "is_mandatory": True, # Hot+InDemand baseline + "weight": 1.0, # Admin can override + "required_level": "INTERMEDIATE", # Admin can override + }) + + # Apply manual augmentation + for aug in augmentation.get("augmentation", {}).get(name, []): + normalized = aug["skill"].strip() + if normalized in seen_names: + # Augmentation upgrades the existing entry's flags/weight + for s in skills: + if s["skill_name"] == normalized: + s["category"] = aug.get("category", s["category"]) + s["is_mandatory"] = aug["is_mandatory"] + s["weight"] = aug["weight"] + s["required_level"] = aug["required_level"] + s["source"] = "onet+manual" + break + else: + seen_names.add(normalized) + skills.append({ + "skill_name": normalized, + "category": aug.get("category", "Tools"), + "source": "manual", + "source_soc": None, + "is_mandatory": aug["is_mandatory"], + "weight": aug["weight"], + "required_level": aug["required_level"], + }) + + output["roles"].append({ + "name": name, + "industry": role["industry"], + "description": description, + "primary_soc": primary_soc, + "all_socs": socs, + "skills": skills, + }) + + print(f" {name}: {len(skills)} skills " + f"({sum(1 for s in skills if s['source'].startswith('onet'))} from O*NET, " + f"{sum(1 for s in skills if s['source'] == 'manual')} manual)") + + OUTPUT_FILE.write_text( + yaml.safe_dump(output, sort_keys=False, allow_unicode=True, width=200), + encoding="utf-8", + ) + print(f"\nWrote {OUTPUT_FILE}") + print(f" {len(output['roles'])} roles, " + f"{sum(len(r['skills']) for r in output['roles'])} total RoleSkill entries") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/prefetch_models.py b/scripts/prefetch_models.py new file mode 100644 index 0000000000000000000000000000000000000000..d661a89174102d0421020107e2039048ce3d74a4 --- /dev/null +++ b/scripts/prefetch_models.py @@ -0,0 +1,62 @@ +"""Bake the NER-chain models into the Docker image at build time. + +Run as a Dockerfile RUN step so the ~1 GB of HF weights + spaCy + skillNer's +nltk/EMSI assets are present in the image layers. The first resume parse in +production is then fast and offline-safe instead of triggering a ~1 GB download +on an ephemeral disk. + +Each load is wrapped so one flaky download cannot fail the whole image build — +a skipped model simply falls back to a lazy download at runtime (slower first +parse, but the deploy still succeeds). Model IDs mirror +apps/accounts/ner/{nucha,jobbert,sbert,skillner}.py exactly. No Django/DB needed. +""" +from __future__ import annotations + + +def _try(label, fn): + try: + fn() + print(f"prefetch ok: {label}") + except Exception as e: # noqa: BLE001 - intentional: never fail the build + print(f"prefetch SKIPPED {label}: {e}") + + +def main() -> None: + from transformers import pipeline + + _try( + "nucha", + lambda: pipeline( + task="ner", + model="Nucha/Nucha_ITSkillNER_BERT", + aggregation_strategy="simple", + ), + ) + _try( + "jobbert", + lambda: pipeline( + task="ner", + model="jjzha/jobbert_skill_extraction", + aggregation_strategy="simple", + ), + ) + + from sentence_transformers import SentenceTransformer + + _try("sbert", lambda: SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")) + + import spacy + from skillNer.general_params import SKILL_DB + from skillNer.skill_extractor_class import SkillExtractor + from spacy.matcher import PhraseMatcher + + _try( + "skillner", + lambda: SkillExtractor(spacy.load("en_core_web_sm"), SKILL_DB, PhraseMatcher), + ) + + print("prefetch complete") + + +if __name__ == "__main__": + main() diff --git a/scripts/qa_verify_personas.py b/scripts/qa_verify_personas.py new file mode 100644 index 0000000000000000000000000000000000000000..037fe25f6ea69e3903f986ae44bf22eddae3e7e2 --- /dev/null +++ b/scripts/qa_verify_personas.py @@ -0,0 +1,111 @@ +"""Tier 3 QA verification: hand-compute readiness per demo persona and compare +to `compute_gap_report`. + +Run via: ./venv/Scripts/python.exe manage.py shell < scripts/qa_verify_personas.py +""" +import os, sys, django # noqa +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +from apps.accounts.models import User +from apps.roles.models import UserTargetRole, RoleSkill +from apps.skills.models import UserSkill +from apps.analysis.services import compute_gap_report, LEVEL_THRESHOLDS, MANDATORY_CAP + + +def hand_compute(user, role): + """Independent re-derivation from the design doc §Module 4.""" + role_skills = list(RoleSkill.objects.filter(role=role).select_related('skill')) + if not role_skills: + return {'readiness': 100.0, 'no_requirements': True, 'cap': False} + + user_prof = { + us.skill_id: us.proficiency + for us in UserSkill.objects.filter(user=user) + } + + sat_sum = 0.0 + weight_sum = 0.0 + mandatory_short = False + gap_rows = [] + for rs in role_skills: + T = LEVEL_THRESHOLDS[rs.required_level] + U = user_prof.get(rs.skill_id, 0) + S = min(U / T, 1.0) + sat_sum += S * rs.weight + weight_sum += rs.weight + if rs.is_mandatory and S < 1.0: + mandatory_short = True + # severity + gap = max(T - U, 0) + if U >= T: + gt, sev = 'MET', None + elif U == 0: + gt, sev = 'MISSING', ('CRITICAL' if rs.is_mandatory else 'MEDIUM') + else: + ratio = gap / T + if rs.is_mandatory or ratio >= 0.5: + sev = 'HIGH' + elif ratio >= 0.25: + sev = 'MEDIUM' + else: + sev = 'LOW' + gt = 'INSUFFICIENT' + gap_rows.append((rs.skill.skill_name, rs.required_level, U, S, rs.weight, + rs.is_mandatory, gt, sev)) + + if weight_sum == 0: + return {'readiness': 100.0, 'no_requirements': True, 'cap': False, + 'raw': None, 'gaps': gap_rows} + + raw = sat_sum / weight_sum * 100.0 + cap = mandatory_short and raw > MANDATORY_CAP + final = MANDATORY_CAP if cap else raw + return {'readiness': final, 'no_requirements': False, 'cap': cap, + 'raw': raw, 'gaps': gap_rows} + + +DEMO_EMAILS = [ + 'demo.weak@gapguide.test', + 'demo.partial@gapguide.test', + 'demo.ready@gapguide.test', + 'demo.closedloop@gapguide.test', +] + +mismatches = [] +for email in DEMO_EMAILS: + try: + u = User.objects.get(email=email) + except User.DoesNotExist: + print(f'[MISS] {email} not in DB') + continue + target = UserTargetRole.objects.filter(user=u, is_active=True).select_related('role').first() + if not target: + print(f'[MISS] {email} has no active target') + continue + role = target.role + expected = hand_compute(u, role) + actual = compute_gap_report(u, role) + + exp_r = round(expected['readiness'], 2) + act_r = round(actual.readiness, 2) + ok = abs(exp_r - act_r) <= 0.1 and expected['cap'] == actual.mandatory_cap_applied + + print(f'\n=== {email} -> {role.role_name} ===') + print(f' expected readiness: {exp_r} (raw={expected["raw"]}, cap={expected["cap"]})') + print(f' actual readiness: {act_r} (band={actual.band}, cap={actual.mandatory_cap_applied})') + print(f' match: {ok}') + if not ok: + mismatches.append((email, expected, actual)) + # Also spot-check one severity vs hand-computed + actual_severities = {(g.skill_name, g.severity) for g in actual.gaps} + expected_severities = {(row[0], row[7]) for row in expected['gaps']} + diff = actual_severities ^ expected_severities + if diff: + print(f' severity diff: {diff}') + +if mismatches: + print(f'\n*** {len(mismatches)} persona readiness mismatches ***') + sys.exit(1) +else: + print('\n*** All persona readiness values match hand computation ***') diff --git a/seed_data/checkpoints.yaml b/seed_data/checkpoints.yaml new file mode 100644 index 0000000000000000000000000000000000000000..241e22710b8f1bc5f5839ddcdc24cf20aa6f3c60 --- /dev/null +++ b/seed_data/checkpoints.yaml @@ -0,0 +1,507 @@ +# Resource checkpoint module lists, keyed by resource URL. +# +# Loaded by `python manage.py seed_initial_resources` (create-if-absent — it +# never overwrites checkpoints a resource already has, so re-seeding can't drop +# a learner's UserCheckpointProgress). Kept separate from resources.yaml so that +# curated file's comments/ordering stay intact. +# +# `source: jsonld` lists were auto-extracted from the provider's public +# schema.org/Course data via `manage.py import_checkpoints` and reviewed. +# `source: manual` lists were hand-authored from the course's published outline +# (the importer couldn't reach an outline: SPA page, bot wall, or — for the +# YouTube videos — no YOUTUBE_API_KEY at seed time). +# +# Scope: COURSE and VIDEO resources only. DOCS/ARTICLE keep the single +# completion checkbox (a reference ToC isn't a learning sequence). Enforced by +# `manage.py check_checkpoints`. + +checkpoints: + # ---- Auto-extracted (schema.org/Course JSON-LD) ---- + - url: "https://www.coursera.org/specializations/python" + source: jsonld + titles: + - Programming for Everybody (Getting Started with Python) + - Python Data Structures + - Using Python to Access Web Data + - Using Databases with Python + - "Capstone: Retrieving, Processing, and Visualizing Data with Python" + + - url: "https://www.coursera.org/specializations/machine-learning-introduction" + source: jsonld + titles: + - "Supervised Machine Learning: Regression and Classification" + - Advanced Learning Algorithms + - Unsupervised Learning, Recommenders, Reinforcement Learning + + - url: "https://www.coursera.org/specializations/deep-learning" + source: jsonld + titles: + - Neural Networks and Deep Learning + - "Improving Deep Neural Networks: Hyperparameter Tuning, Regularization and Optimization" + - Structuring Machine Learning Projects + - Convolutional Neural Networks + - Sequence Models + + - url: "https://www.coursera.org/specializations/django" + source: jsonld + titles: + - Web Application Technologies and Django + - Building Web Applications in Django + - Django Features and Libraries + - Developing and Deploying a Complete Django Web Application + + - url: "https://www.coursera.org/specializations/excel-data-analytics-visualization" + source: jsonld + titles: + - Excel Fundamentals for Data Analysis + - Data Visualization in Excel + - Excel Power Tools for Data Analysis + + - url: "https://www.coursera.org/learn/sql-for-data-science" + source: jsonld + titles: + - Getting Started and Selecting & Retrieving Data with SQL + - Filtering, Sorting, and Calculating Data with SQL + - Subqueries and Joins in SQL + - Modifying and Analyzing Data with SQL + + - url: "https://www.coursera.org/professional-certificates/tensorflow-in-practice" + source: jsonld + titles: + - "Introduction to TensorFlow for AI, Machine Learning, and Deep Learning" + - Convolutional Neural Networks in TensorFlow + - Natural Language Processing in TensorFlow + - Sequences, Time Series and Prediction + + - url: "https://www.datacamp.com/courses/statistical-thinking-in-python-part-1" + source: jsonld + titles: + - Graphical Exploratory Data Analysis + - Quantitative Exploratory Data Analysis + - Thinking Probabilistically — Discrete Variables + - Thinking Probabilistically — Continuous Variables + + - url: "https://www.datacamp.com/tracks/data-engineer-in-python" + source: jsonld + titles: + - Introduction to Python for Developers + - Introduction to Importing Data in Python + - Cleaning Data in Python + - Writing Efficient Python Code + - Streamlined Data Ingestion with pandas + - Introduction to Git + - Software Engineering Principles in Python + - ETL and ELT in Python + - Introduction to Apache Airflow in Python + + - url: "https://developer.hashicorp.com/terraform/tutorials/certification-003" + source: jsonld + titles: + - Associate learning path (004) + - Associate exam content list (004) + - Associate sample questions (004) + + - url: "https://www.udemy.com/course/docker-mastery/" + source: jsonld + titles: + - The Best Way to Set Up Docker for Your OS + - Creating and Using Containers + - Container Images — Finding and Building Them + - "Persistent Data: Volumes" + - "Docker Compose: The Multi-Container Tool" + - Swarm Intro and Creating a Swarm Cluster + - "Container Registries: Image Storage and Distribution" + - The What and Why of Kubernetes + - Kubernetes Architecture and Install + - Docker Security Good Defaults and Tools + + - url: "https://www.udemy.com/course/feature-engineering-for-machine-learning/" + source: jsonld + titles: + - Variable Types and Characteristics + - Missing Data Imputation + - Categorical Encoding + - Variable Transformation + - Discretization + - Outlier Handling + - Engineering Datetime Variables + - Feature Scaling + - Assembling a Feature Engineering Pipeline + + - url: "https://www.udemy.com/course/javascript-the-complete-guide-2020-beginner-advanced/" + source: jsonld + titles: + - "Basics: Variables, Data Types, Operators & Functions" + - Efficient Development & Debugging + - Working with Control Structures + - More on Functions + - Working with the DOM + - More on Arrays & Iterables + - Classes & Object-oriented Programming + - "Async JavaScript: Promises & Callbacks" + - Working with HTTP Requests + - Modular JavaScript + + - url: "https://www.udemy.com/course/microsoft-power-bi-up-running-with-power-bi-desktop/" + source: jsonld + titles: + - Introducing Microsoft Power BI Desktop + - Connecting & Shaping Data + - Creating a Data Model + - Calculated Fields with DAX + - Visualizing Data with Reports + - Artificial Intelligence (AI) Visuals + - Power BI Optimization Tools + + - url: "https://www.udemy.com/course/react-the-complete-guide-incl-redux/" + source: jsonld + titles: + - JavaScript Refresher + - "React Essentials: Components, JSX, Props, State" + - Styling React Components + - Working with Refs & Portals + - React's Context API & useReducer + - Handling Side Effects with useEffect() + - Sending HTTP Requests + - Building Custom React Hooks + - Working with Forms & User Input + - Diving into Redux + - Building a Multi-Page SPA with React Router + - Adding Authentication to React Apps + + # ---- Hand-authored from the published outline (importer couldn't reach it) ---- + - url: "https://www.edx.org/learn/linux/the-linux-foundation-introduction-to-linux" + source: manual + titles: + - Introduction to Linux and Graphical Interfaces + - The Linux Filesystem + - Command Line Operations + - Finding Linux Documentation + - Text Editors and Manipulating Files + - Bash Shell Scripting Basics + - Users, Groups, and Permissions + + - url: "https://developer.confluent.io/learn-kafka/apache-kafka/events/" + source: manual + titles: + - Events and Streams + - Topics and Partitions + - Producers and Consumers + - Brokers and Replication + - Kafka Connect and Kafka Streams + + - url: "https://www.databricks.com/spark/getting-started-with-apache-spark" + source: manual + titles: + - Spark Architecture and Execution Model + - DataFrames and Spark SQL + - Transformations and Actions + - Running Spark on Databricks + - Performance Tuning and Optimization + + - url: "https://skillbuilder.aws/learn/aws-cloud-practitioner-essentials" + source: manual + titles: + - Cloud Concepts + - Security and Compliance + - Core AWS Services and Global Infrastructure + - Architecture and the Well-Architected Framework + - Pricing, Billing, and Support + + - url: "https://learn.microsoft.com/en-us/training/paths/microsoft-azure-fundamentals-describe-cloud-concepts/" + source: manual + titles: + - Describe Cloud Concepts + - Describe Azure Architecture and Services + - Describe Azure Management and Governance + - Describe Security and Identity + - Pricing, SLAs, and Service Lifecycle + + - url: "https://courses.getdbt.com/courses/fundamentals" + source: manual + titles: + - Setting Up dbt and Connecting a Warehouse + - Building Models + - Sources and Seeds + - Tests and Documentation + - Deployment and Jobs + + - url: "https://grafana.com/tutorials/grafana-fundamentals/" + source: manual + titles: + - Set Up Grafana + - Add Data Sources + - Build Your First Dashboard + - Panels and Visualizations + - Alerting Basics + + - url: "https://huggingface.co/learn/nlp-course" + source: manual + titles: + - Transformer Models + - Using Transformers + - Fine-Tuning a Pretrained Model + - The Datasets Library + - The Tokenizers Library + - Main NLP Tasks + + - url: "https://www.deeplearning.ai/short-courses/langchain-for-llm-application-development/" + source: manual + titles: + - Models, Prompts, and Output Parsers + - Memory + - Chains + - Question Answering over Documents + - Agents + + - url: "https://laracasts.com/series/laravel-from-scratch-2026" + source: manual + titles: + - Setup and Routing + - Blade Templates and Layouts + - Eloquent and Migrations + - Controllers and CRUD + - Authentication and Authorization + - Deployment + + - url: "https://www.udemy.com/course/sql-and-postgresql/" + source: manual + titles: + - SQL Fundamentals + - Data Types and Constraints + - Joins and Subqueries + - Indexes and Query Performance + - Functions, Triggers, and Views + - Transactions and Concurrency + + - url: "https://learn.mongodb.com/courses/m001-mongodb-basics" + source: manual + titles: + - Introduction and the Mongo Shell + - CRUD Operations + - Indexing and Aggregation + - Data Modeling + - Atlas and Compass Tools + + - url: "https://nextjs.org/learn" + source: manual + titles: + - Getting Started + - Routing and Navigation + - Data Fetching + - Rendering and Caching + - Styling + - Deployment + + - url: "https://www.deeplearning.ai/short-courses/chatgpt-prompt-engineering-for-developers/" + source: manual + titles: + - Guidelines for Prompting + - Iterative Prompt Development + - Summarizing and Inferring + - Transforming and Expanding + - Building a Chatbot + + - url: "https://www.freecodecamp.org/learn/2022/responsive-web-design/" + source: manual + titles: + - HTML Fundamentals + - CSS Basics + - Flexbox + - CSS Grid + - Responsive Design Principles + - Building Responsive Projects + + - url: "https://learn.snowflake.com/" + source: manual + titles: + - Snowflake Architecture + - Loading and Unloading Data + - Querying and Virtual Warehouses + - Data Sharing + - Security and Administration + + - url: "https://www.tableau.com/learn/training" + source: manual + titles: + - Connecting to Data + - Building Visualizations + - Dashboards and Stories + - Calculations and Table Calculations + - Sharing and Publishing + + # ---- YouTube videos (source youtube_api = real chapter markers via the + # Data API; manual = no chapter timestamps in description, hand-authored) ---- + - url: https://www.youtube.com/watch?v=sVcwVQRHIc8 + source: youtube_api + titles: + - Overview + - Indexing + - Retrieval + - Generation + - Query Translation (Multi-Query) + - Query Translation (RAG Fusion) + - Query Translation (Decomposition) + - Query Translation (Step Back) + - Query Translation (HyDE) + - Routing + - Query Construction + - Indexing (Multi Representation) + - Indexing (RAPTOR) + - Indexing (ColBERT) + - CRAG + - Adaptive RAG + - The future of RAG + - url: https://www.youtube.com/watch?v=r-uOLxNrNk8 + source: manual + titles: + - NumPy Basics + - Pandas DataFrames + - Data Cleaning + - Data Visualization + - Analysis Project + - url: https://www.youtube.com/watch?v=RGOj5yH7evk + source: youtube_api + titles: + - Introduction + - What is git? + - What is version control? + - Terms to be learn in video + - Git commands + - sign up in GitHub + - using git in local machine + - git install + - getting code editor + - inside VS Code + - cloning through VS Code + - git commit command + - git add command + - committing + - git push command + - SSH Keys + - git push + - Review workflow so far + - Compare between GitHub workflow and local git workflow + - git branching + - Undoing in git + - Forking in git + - Ending + - url: https://www.youtube.com/watch?v=hdI2bqOjy3c + source: manual + titles: + - Variables and Data Types + - Functions and Scope + - DOM Manipulation + - Events + - Arrays and Objects + - url: https://www.youtube.com/watch?v=X48VuDVv0do + source: youtube_api + titles: + - Course Overview + - What is K8s + - Main K8s Components + - K8s Architecture + - Minikube and kubectl - Local Setup + - Main Kubectl Commands - K8s CLI + - K8s YAML Configuration File + - 'Demo Project: MongoDB and MongoExpress' + - Organizing your components with K8s Namespaces + - K8s Ingress explained + - Helm - Package Manager + - Persisting Data in K8s with Volumes + - Deploying Stateful Apps with StatefulSet + - K8s Services explained + - url: https://www.youtube.com/watch?v=rfscVS0vtbw + source: youtube_api + titles: + - Introduction + - Installing Python & PyCharm + - Setup & Hello World + - Drawing a Shape + - Variables & Data Types + - Working With Strings + - Working With Numbers + - Getting Input From Users + - Building a Basic Calculator + - Mad Libs Game + - Lists + - List Functions + - Tuples + - Functions + - Return Statement + - If Statements + - If Statements & Comparisons + - Building a better Calculator + - Dictionaries + - While Loop + - Building a Guessing Game + - For Loops + - Exponent Function + - 2D Lists & Nested Loops + - Building a Translator + - Comments + - Try / Except + - Reading Files + - Writing to Files + - Modules & Pip + - Classes & Objects + - Building a Multiple Choice Quiz + - Object Functions + - Inheritance + - Python Interpreter + - url: https://www.youtube.com/watch?v=fBNz5xF-Kx4 + source: manual + titles: + - Node.js Fundamentals + - Modules and npm + - Express Basics + - Routing and Middleware + - Building a REST API + - url: https://www.youtube.com/watch?v=c36lUUr864M + source: youtube_api + titles: + - Intro + - 1 Installation + - 2 Tensor Basics + - 3 Autograd + - 4 Backpropagation + - 5 Gradient Descent + - 6 Training Pipeline + - 7 Linear Regression + - 8 Logistic Regression + - 9 Dataset and Dataloader + - 10 Dataset Transforms + - 11 Softmax and Crossentropy + - 12 Activation Functions + - 13 Feed Forward Net + - 14 CNN + - 15 Transfer Learning + - 16 Tensorboard + - 17 Save & Load Models + - url: https://www.youtube.com/watch?v=HXV3zeQKqGY + source: youtube_api + titles: + - Introduction + - What is a Database? + - Tables & Keys + - SQL Basics + - MySQL Windows Installation + - MySQL Mac Installation + - Creating Tables + - Inserting Data + - Constraints + - Update & Delete + - Basic Queries + - Company Database Intro + - Creating Company Database + - More Basic Queries + - Functions + - Wildcards + - Union + - Joins + - Nested Queries + - On Delete + - Triggers + - ER Diagrams Intro + - Designing an ER Diagram + - Converting ER Diagrams to Schemas diff --git a/seed_data/manual_skill_augmentation.yaml b/seed_data/manual_skill_augmentation.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c5a0ed9c08832ddffee1b3b3c680ac6dea9e62c0 --- /dev/null +++ b/seed_data/manual_skill_augmentation.yaml @@ -0,0 +1,212 @@ +# Per-role framework-level skill augmentation. +# Sourced from research/06-dataset-sourcing.md §3 — based on a scan of current LinkedIn / Rozee.pk +# job postings on 2026-04-19. These are skills O*NET v30.2 doesn't list at this granularity but +# every modern job posting requires. +# +# Each entry adds a new RoleSkill. If the named skill_name already exists in the Skill table +# (added via O*NET pull), parse_onet_dump.py upgrades the existing RoleSkill rather than +# creating a duplicate. is_mandatory and required_level here override O*NET defaults. +# +# Keep this list TIGHT — only add skills O*NET genuinely missed. Do not duplicate Hot+InDemand +# entries that the O*NET pull already produces (Python, SQL, Docker, etc. all come from O*NET). + +augmentation: + "Data Scientist": + - { skill: "Pandas", category: "Data", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "NumPy", category: "Data", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Scikit-learn", category: "ML", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Jupyter Notebook", category: "Tools", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Matplotlib", category: "Data", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + - { skill: "Seaborn", category: "Data", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + # Added 2026-04-19 after reviewer audit (concept-level skills missing — frameworks alone don't measure understanding) + - { skill: "Statistical Hypothesis Testing", category: "Stats", required_level: "INTERMEDIATE", is_mandatory: true, weight: 0.9 } + - { skill: "Feature Engineering", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.7 } + # Round 3 (reviewer 2): DSA optional for DS — interviews increasingly include LeetCode-style rounds even for DS + - { skill: "Data Structures & Algorithms", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + # Round 4: Docker for MLOps overlap (real PK DS postings) + - { skill: "Docker", category: "Infra", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.6 } + + "Data Analyst": + # Round 3: dropped "Microsoft Excel Advanced" — reviewer 2 flagged it as double-count with Microsoft Excel. + # The gap-analysis engine's required_level field already handles this (set Excel level=ADVANCED in curation override). + - { skill: "Tableau", category: "BI", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.9 } + - { skill: "Microsoft Power BI", category: "BI", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.9 } + - { skill: "Pandas", category: "Data", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Statistical Hypothesis Testing", category: "Stats", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.7 } + # Round 3 additions (reviewer 2): Data Visualization concept missing + - { skill: "Data Visualization", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + # Round 4: DAX + Power Query for DA users on Power BI (mirrored from BI Analyst) + - { skill: "DAX", category: "Language", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Power Query / M Language", category: "Tools", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + + "Machine Learning Engineer": + - { skill: "PyTorch", category: "ML", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "TensorFlow", category: "ML", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.9 } + - { skill: "Scikit-learn", category: "ML", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "MLflow", category: "MLOps", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.8 } + - { skill: "Docker", category: "Infra", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Kubernetes", category: "Infra", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.8 } + - { skill: "ONNX", category: "MLOps", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + - { skill: "FastAPI", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + # Added 2026-04-19 during curation pass — needed for ML Engineer baseline + - { skill: "Pandas", category: "Data", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "NumPy", category: "Data", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + # Round 3 additions (reviewer 2): DSA mandatory for ML Eng (interview-critical), OOP, Agile + - { skill: "Data Structures & Algorithms", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Object-Oriented Programming", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.6 } + - { skill: "Agile / Scrum", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + + "Backend Developer": + - { skill: "Django", category: "Framework", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "FastAPI", category: "Framework", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.8 } + - { skill: "Flask", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.3 } # demoted by reviewer (in decline) + - { skill: "PostgreSQL", category: "Database", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Redis", category: "Database", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "REST API Design", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "JWT / OAuth", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + # Added 2026-04-19 after reviewer audit (testing was missing entirely; Pakistani Java enterprise market is real) + - { skill: "pytest", category: "Tools", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Celery", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + - { skill: "nginx", category: "Infra", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + - { skill: "Pydantic", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + # Java kept as low-priority optional for Pakistani enterprise (Systems Limited, NetSol) + - { skill: "Java", category: "Language", required_level: "BEGINNER", is_mandatory: false, weight: 0.4 } + # Round 3 additions (reviewer 2): DSA, OOP, Agile, CI/CD, .NET for Pakistani enterprise, Unit Testing concept + - { skill: "Data Structures & Algorithms", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Object-Oriented Programming", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.6 } + - { skill: "Agile / Scrum", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + - { skill: "CI/CD Pipelines", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + - { skill: "GitHub Actions", category: "CI/CD", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + - { skill: ".NET / C#", category: "Language", required_level: "BEGINNER", is_mandatory: false, weight: 0.4 } + - { skill: "Unit Testing", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + # Round 4: Laravel/PHP for Pakistani SME backend market (Glassdoor PK Backend listings) + - { skill: "Laravel", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.4 } + - { skill: "PHP", category: "Language", required_level: "BEGINNER", is_mandatory: false, weight: 0.4 } + + "Frontend Developer": + - { skill: "React", category: "Framework", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Vue.js", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + - { skill: "TypeScript", category: "Language", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Tailwind CSS", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Vite", category: "Tools", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + - { skill: "TanStack Query", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.4 } + - { skill: "Next.js", category: "Framework", required_level: "INTERMEDIATE", is_mandatory: true, weight: 0.9 } + # Added 2026-04-19 during curation — frontend devs consume APIs daily + - { skill: "REST API Design", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + # Round 4 (job-board evidence): Redux explicitly cited in MERN postings + - { skill: "Redux", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + + # Added 2026-04-19 after reviewer audit (testing entirely missing; Docker for local dev; a11y now baseline) + - { skill: "Vitest", category: "Tools", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } # State of JS 2024: top satisfaction + - { skill: "Playwright", category: "Tools", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } # E2E baseline + - { skill: "Docker", category: "Infra", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } # for docker-compose backend deps in local dev + - { skill: "Web Accessibility (a11y)", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + - { skill: "Responsive Design", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + # Round 3 additions (reviewer 2): DSA (lower weight for FE than BE), Agile + - { skill: "Data Structures & Algorithms", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Agile / Scrum", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + + "Full Stack Developer": + # Inherits union of Backend + Frontend via SOC composite. Add extras that are full-stack-specific. + # Note: reviewer correctly flagged Docker Compose as redundant with Docker — folded into Docker (single mandatory). + - { skill: "REST API Design", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "TypeScript", category: "Language", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Next.js", category: "Framework", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.8 } + # Added 2026-04-19 during curation — frontend essentials missing from composite + - { skill: "Tailwind CSS", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Vite", category: "Tools", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + # Added 2026-04-19 after reviewer audit + - { skill: "Vitest", category: "Tools", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + - { skill: "JWT / OAuth", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Linux", category: "Infra", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.6 } # aligned to ML Eng per reviewer 2 + # Round 3: Prisma dropped — Python client archived 2025-04, community-only, not appropriate for Python/Django stack + # Round 3 additions (reviewer 2): DSA, OOP, Agile, CI/CD, GitHub Actions, Laravel/PHP + - { skill: "Data Structures & Algorithms", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Object-Oriented Programming", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.6 } + - { skill: "Agile / Scrum", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + - { skill: "CI/CD Pipelines", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + - { skill: "GitHub Actions", category: "CI/CD", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + - { skill: "Laravel", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.3 } + # Round 4: Django for Python+React Full Stack stack + - { skill: "Django", category: "Framework", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.8 } + - { skill: "PHP", category: "Language", required_level: "BEGINNER", is_mandatory: false, weight: 0.3 } + + "Data Engineer": + - { skill: "Apache Airflow", category: "Data", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "dbt", category: "Data", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Apache Spark", category: "Data", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Apache Kafka", category: "Data", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Snowflake", category: "Cloud", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.9 } + - { skill: "Google BigQuery", category: "Cloud", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.9 } + - { skill: "Docker", category: "Infra", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + # Added 2026-04-19 after reviewer audit (lakehouse formats baseline; Databricks platform skill; data modeling concept) + - { skill: "Delta Lake", category: "Data", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + # Round 3: Apache Iceberg demoted (picking both lakehouse formats overkill for entry-level; Delta Lake primary) + - { skill: "Apache Iceberg", category: "Data", required_level: "BEGINNER", is_mandatory: false, weight: 0.3 } + - { skill: "Databricks", category: "Cloud", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + - { skill: "Data Modeling", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.7 } # renamed — dropped Kimball parenthetical + - { skill: "Parquet", category: "Data", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + # Round 3 additions (reviewer 2): ETL/ELT concept, DSA for technical pipeline work + - { skill: "ETL / ELT", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Data Structures & Algorithms", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + # Round 4: Git for DE (not in primary SOC 15-1243.01 Hot+InDemand list) + - { skill: "Git", category: "Tools", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + + "DevOps Engineer": + - { skill: "Docker", category: "Infra", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Kubernetes", category: "Infra", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Terraform", category: "IaC", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Ansible", category: "IaC", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Jenkins", category: "CI/CD", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.8 } + - { skill: "GitHub Actions", category: "CI/CD", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.9 } + - { skill: "AWS", category: "Cloud", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Microsoft Azure", category: "Cloud", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Google Cloud Platform", category: "Cloud", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Linux Administration", category: "Infra", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Bash Scripting", category: "Language", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Prometheus", category: "Monitoring", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + - { skill: "Grafana", category: "Monitoring", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + # Added 2026-04-19 after reviewer audit (GitOps + Helm now baseline per KodeKloud 2025) + - { skill: "ArgoCD", category: "CI/CD", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + - { skill: "Helm", category: "Infra", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + # Round 3 additions (reviewer 2): CI/CD concept distinct from tools; networking fundamentals + - { skill: "CI/CD Pipelines", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Computer Networking Fundamentals", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.7 } + + "AI Engineer (GenAI / LLM)": + - { skill: "HuggingFace Transformers", category: "ML", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "LangChain", category: "Framework", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "LlamaIndex", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.4 } # demoted by reviewer (overlaps LangChain) + - { skill: "OpenAI API", category: "API", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Anthropic Claude API", category: "API", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "pgvector", category: "Database", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "Pinecone", category: "Database", required_level: "BEGINNER", is_mandatory: false, weight: 0.4 } # demoted by reviewer (open-source alternatives winning) + # Round 4: renamed to canonical "RAG" (job-posting term) + - { skill: "RAG", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Prompt Engineering", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Sentence Transformers", category: "ML", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "FastAPI", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + # Added 2026-04-19 after independent reviewer + verification (LangGraph v1.1.8 released 2 days ago, 29.6k stars) + # Round 3: LangGraph demoted to optional 0.7 — Indeed search shows ~1 posting mentioning it, only at senior level as "preferred" + - { skill: "LangGraph", category: "Framework", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + - { skill: "LLM Evaluations", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.7 } + - { skill: "LLM Observability", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + # Round 3: renamed from "Fine-tuning (LoRA/PEFT)" — cleaner canonical name for resume parser + - { skill: "LLM Fine-tuning", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + # Round 3 additions (reviewer 2): OOP, DSA (AI eng also hits Leetcode interviews) + - { skill: "Object-Oriented Programming", category: "Concept", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.6 } + - { skill: "Data Structures & Algorithms", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + + "Business Intelligence Analyst": + - { skill: "Microsoft Power BI", category: "BI", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Tableau", category: "BI", required_level: "INTERMEDIATE", is_mandatory: false, weight: 0.9 } + - { skill: "Looker", category: "BI", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + - { skill: "dbt", category: "Data", required_level: "BEGINNER", is_mandatory: false, weight: 0.7 } + # Round 3: dropped "Microsoft Excel Advanced" — double-count with Microsoft Excel; use required_level=ADVANCED via curation override instead + - { skill: "Star Schema Modeling", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + # Added 2026-04-19 after reviewer audit + verification (DataCamp/Coursera/iMocha all cite DAX as core) + - { skill: "DAX", category: "Language", required_level: "INTERMEDIATE", is_mandatory: true, weight: 1.0 } + - { skill: "Power Query / M Language", category: "Tools", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } + - { skill: "Data Storytelling", category: "Concept", required_level: "BEGINNER", is_mandatory: false, weight: 0.5 } + # Round 3 additions (reviewer 2): Snowflake as DWH to query (not operate); BI consumes DWHs + - { skill: "Snowflake", category: "Cloud", required_level: "BEGINNER", is_mandatory: false, weight: 0.6 } diff --git a/seed_data/onet_roles_curated.yaml b/seed_data/onet_roles_curated.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e325b4acd7fe492cec49d5bca5d23f52df50223b --- /dev/null +++ b/seed_data/onet_roles_curated.yaml @@ -0,0 +1,1117 @@ +# Curated role+skill seed for GapGuide +# Generated 2026-04-19T09:25:05.375469+00:00Z by scripts/curate_roles.py +# Edit seed_data/role_curation.yaml then re-run; do NOT hand-edit this file. + +roles: +- name: Data Scientist + industry: Data Science + description: Develop and implement a set of techniques or analytics applications to transform raw data into meaningful information using data-oriented programming languages and visualization software. + Apply data mining, data modeling, natural language processing, and machine learning to extract and analyze information from large structured and unstructured datasets. Visualize, interpret, and report + data findings. May create dynamic data reports. + primary_soc: 15-2051.00 + all_socs: + - 15-2051.00 + skills: + - skill_name: Python + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Pandas + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: NumPy + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Scikit-learn + category: ML + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PyTorch + category: ML + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Statistical Hypothesis Testing + category: Stats + source: manual + source_soc: null + is_mandatory: true + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: TensorFlow + category: ML + source: onet + source_soc: 15-2051.00 + is_mandatory: false + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-2051.00 + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Tableau + category: BI + source: onet + source_soc: 15-2051.00 + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Apache Spark + category: Data + source: onet + source_soc: 15-2051.00 + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: Feature Engineering + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: Microsoft Power BI + category: BI + source: onet + source_soc: 15-2051.00 + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE + - skill_name: Matplotlib + category: Data + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Docker + category: Infra + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE +- name: Data Analyst + industry: Data Science + description: Analyze data to identify patterns, generate insights, and produce reports for business decisions. Pakistan/global market role; O*NET groups under Statisticians (15-2041.00) which lists Data + Analyst as a sample title. + primary_soc: 15-2041.00 + all_socs: + - 15-2041.00 + skills: + - skill_name: SQL + category: Database + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Excel + category: BI + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: ADVANCED + - skill_name: Microsoft Power BI + category: BI + source: manual + source_soc: null + is_mandatory: false + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Tableau + category: BI + source: onet+manual + source_soc: 15-2041.00 + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Pandas + category: Data + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: DAX + category: Language + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Power Query / M Language + category: Tools + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Data Visualization + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Statistical Hypothesis Testing + category: Stats + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: INTERMEDIATE +- name: Machine Learning Engineer + industry: AI / ML + description: Build, train, and deploy machine learning models in production. Combine data science (Data Scientists 15-2051.00) with computer science research (Computer and Information Research Scientists + 15-1221.00). No dedicated O*NET SOC. + primary_soc: 15-2051.00 + all_socs: + - 15-2051.00 + - 15-1221.00 + skills: + - skill_name: Python + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PyTorch + category: ML + source: onet+manual + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Scikit-learn + category: ML + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: onet+manual + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Pandas + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: NumPy + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: TensorFlow + category: ML + source: onet+manual + source_soc: 15-2051.00 + is_mandatory: false + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: MLflow + category: MLOps + source: manual + source_soc: null + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Kubernetes + category: Infra + source: onet+manual + source_soc: 15-1221.00 + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: FastAPI + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Apache Spark + category: Data + source: onet + source_soc: 15-2051.00 + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: Linux + category: Infra + source: onet + source_soc: 15-1221.00 + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE +- name: Backend Developer + industry: Software Engineering + description: Research, design, and develop computer and network software or specialized utility programs. Analyze user needs and develop software solutions, applying principles and techniques of computer + science, engineering, and mathematical analysis. Update software or enhance existing software capabilities. May work with computer hardware engineers to integrate hardware and software systems, and + develop specifications and performance requirements. May maintain databases within an application area, working individually or coordinating database development as part of a team. + primary_soc: 15-1252.00 + all_socs: + - 15-1252.00 + skills: + - skill_name: Python + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Django + category: Framework + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Node.js + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PostgreSQL + category: Database + source: onet+manual + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: REST API Design + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JWT / OAuth + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 0.8 + required_level: BEGINNER + - skill_name: FastAPI + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: TypeScript + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-1252.00 + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: NoSQL + category: Database + source: onet + source_soc: 15-1252.00 + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE + - skill_name: Laravel + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.4 + required_level: BEGINNER + - skill_name: PHP + category: Language + source: manual + source_soc: null + is_mandatory: false + weight: 0.4 + required_level: BEGINNER +- name: Frontend Developer + industry: Software Engineering + description: Develop and implement websites, web applications, application databases, and interactive web interfaces. Evaluate code to ensure that it is properly structured, meets industry standards, + and is compatible with browsers and devices. Optimize website performance, scalability, and server-side code and processes. May develop website infrastructure and integrate websites with other computer + applications. + primary_soc: 15-1254.00 + all_socs: + - 15-1254.00 + skills: + - skill_name: React + category: Framework + source: onet+manual + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JavaScript + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: TypeScript + category: Language + source: onet+manual + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: HTML + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: CSS + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: REST API Design + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Next.js + category: Framework + source: manual + source_soc: null + is_mandatory: true + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Tailwind CSS + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.8 + required_level: BEGINNER + - skill_name: Redux + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Node.js + category: Framework + source: onet + source_soc: 15-1254.00 + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: Vite + category: Tools + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: TanStack Query + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.4 + required_level: BEGINNER +- name: Full Stack Developer + industry: Software Engineering + description: Build both server-side (Software Developers 15-1252.00) and client-side (Web Developers 15-1254.00) of web applications. Composite role — O*NET has no single SOC for full-stack. + primary_soc: 15-1252.00 + all_socs: + - 15-1252.00 + - 15-1254.00 + skills: + - skill_name: JavaScript + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: TypeScript + category: Language + source: onet+manual + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: React + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Node.js + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PostgreSQL + category: Database + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: REST API Design + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: HTML + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: CSS + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Next.js + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Django + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: MongoDB + category: Database + source: onet + source_soc: 15-1254.00 + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Tailwind CSS + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-1252.00 + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE +- name: Data Engineer + industry: Data Science + description: Design, build, and maintain data pipelines, warehouses, and ETL systems. Composite of Data Warehousing Specialists (15-1243.01) and Database Architects (15-1243.00). No dedicated O*NET SOC. + primary_soc: 15-1243.01 + all_socs: + - 15-1243.01 + - 15-1243.00 + skills: + - skill_name: Python + category: Language + source: onet + source_soc: 15-1243.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-1243.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Spark + category: Data + source: onet+manual + source_soc: 15-1243.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Airflow + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: ETL / ELT + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-1243.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Snowflake + category: Cloud + source: onet+manual + source_soc: 15-1243.00 + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: dbt + category: Data + source: manual + source_soc: null + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Apache Kafka + category: Data + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Google BigQuery + category: Cloud + source: manual + source_soc: null + is_mandatory: false + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet + source_soc: 15-1243.00 + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE + - skill_name: Data Modeling + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE +- name: DevOps Engineer + industry: Infrastructure + description: Bridge development and operations through CI/CD, infrastructure as code, container orchestration, and cloud platform management. O*NET maps loosely to Computer Systems Engineers/Architects + (15-1299.08); heavy manual augmentation needed for modern DevOps tooling. + primary_soc: 15-1299.08 + all_socs: + - 15-1299.08 + skills: + - skill_name: Docker + category: Infra + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Kubernetes + category: Infra + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Terraform + category: IaC + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: AWS + category: Cloud + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Linux + category: Infra + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Bash Scripting + category: Language + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: CI/CD Pipelines + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: GitHub Actions + category: CI/CD + source: manual + source_soc: null + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Ansible + category: IaC + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Prometheus + category: Monitoring + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Grafana + category: Monitoring + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Jenkins + category: CI/CD + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: false + weight: 0.5 + required_level: INTERMEDIATE +- name: AI Engineer (GenAI / LLM) + industry: AI / ML + description: Build applications powered by large language models, retrieval-augmented generation (RAG), and vector search. O*NET predates GenAI tooling — heavy manual augmentation required for LangChain, + LlamaIndex, OpenAI/Anthropic API, prompt engineering, vector databases. + primary_soc: 15-1221.00 + all_socs: + - 15-1221.00 + skills: + - skill_name: Python + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: HuggingFace Transformers + category: ML + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: LangChain + category: Framework + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: OpenAI API + category: API + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: RAG + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Prompt Engineering + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PyTorch + category: ML + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-1221.00 + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet + source_soc: 15-1221.00 + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: Anthropic Claude API + category: API + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: pgvector + category: Database + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: FastAPI + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: LangGraph + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Sentence Transformers + category: ML + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER +- name: Business Intelligence Analyst + industry: Data Science + description: Produce financial and market intelligence by querying data repositories and generating periodic reports. Devise methods for identifying data patterns and trends in available information sources. + primary_soc: 15-2051.01 + all_socs: + - 15-2051.01 + skills: + - skill_name: Microsoft Power BI + category: BI + source: onet+manual + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: DAX + category: Language + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Excel + category: BI + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: ADVANCED + - skill_name: Power Query / M Language + category: Tools + source: manual + source_soc: null + is_mandatory: true + weight: 0.9 + required_level: BEGINNER + - skill_name: Tableau + category: BI + source: onet+manual + source_soc: 15-2051.01 + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: Snowflake + category: Cloud + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Python + category: Language + source: onet + source_soc: 15-2051.01 + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE + - skill_name: Star Schema Modeling + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Data Storytelling + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER diff --git a/seed_data/onet_roles_curation_log.md b/seed_data/onet_roles_curation_log.md new file mode 100644 index 0000000000000000000000000000000000000000..aba7ea1e786c367e8790f44996a76bb95e239596 --- /dev/null +++ b/seed_data/onet_roles_curation_log.md @@ -0,0 +1,457 @@ +# Role Curation Log + +Generated 2026-04-19T09:25:05.374883+00:00Z by `scripts/curate_roles.py` + +This log shows, per role, which skills were kept and which were dropped (with reason). +Source of truth for keep decisions: `seed_data/role_curation.yaml`. Edit there + re-run. + + +## Data Scientist + +**Kept (16 skills):** + +- `Python` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `SQL` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Pandas` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `NumPy` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `Scikit-learn` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `PyTorch` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Statistical Hypothesis Testing` (mandatory, weight=0.9, level=INTERMEDIATE, source=manual) +- `Git` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `TensorFlow` (optional, weight=0.9, level=INTERMEDIATE, source=onet) +- `AWS` (optional, weight=0.8, level=INTERMEDIATE, source=onet) +- `Tableau` (optional, weight=0.8, level=INTERMEDIATE, source=onet) +- `Apache Spark` (optional, weight=0.7, level=INTERMEDIATE, source=onet) +- `Feature Engineering` (optional, weight=0.7, level=INTERMEDIATE, source=manual) +- `Microsoft Power BI` (optional, weight=0.6, level=INTERMEDIATE, source=onet) +- `Matplotlib` (optional, weight=0.6, level=BEGINNER, source=manual) +- `Docker` (optional, weight=0.6, level=INTERMEDIATE, source=manual) + +**Dropped (13 skills):** + +- `Apache Hadoop` — Spark replaced it +- `C++` — Low-level numerics only; not in PK DS postings +- `Microsoft Azure` — Pick one cloud — AWS retained as primary +- `Microsoft Excel` — R4: not core for DS (Excel is for DA/BI roles) +- `Microsoft PowerPoint` — Presentation tool, not measurable +- `Java` — Rare in DS workflow +- `R` — R4: PK DS market is Python+SQL; R rarely required (Glassdoor PK Data Scientist evidence) +- `SAS` — Enterprise legacy +- `Snowflake` — R4: warehouse skill — Data Engineer territory +- `MATLAB` — Academic only +- `Jupyter Notebook` — R4: assumed knowledge, not learnable on slider +- `Seaborn` — R4: covered by Matplotlib retention +- `Data Structures & Algorithms` — R4: interview prep, not posting requirement (per job-board evidence) + + +## Data Analyst + +**Kept (10 skills):** + +- `SQL` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Microsoft Excel` (mandatory, weight=1.0, level=ADVANCED, source=onet) +- `Microsoft Power BI` (optional, weight=0.9, level=INTERMEDIATE, source=manual) +- `Python` (mandatory, weight=0.9, level=INTERMEDIATE, source=onet) +- `Tableau` (optional, weight=0.8, level=INTERMEDIATE, source=onet+manual) +- `Pandas` (optional, weight=0.7, level=INTERMEDIATE, source=manual) +- `DAX` (optional, weight=0.7, level=BEGINNER, source=manual) +- `Power Query / M Language` (optional, weight=0.6, level=BEGINNER, source=manual) +- `Data Visualization` (optional, weight=0.6, level=BEGINNER, source=manual) +- `Statistical Hypothesis Testing` (optional, weight=0.5, level=INTERMEDIATE, source=manual) + +**Dropped (7 skills):** + +- `AWS` — Junior DA in PK rarely owns cloud infra +- `IBM SPSS Statistics` — Enterprise legacy +- `Microsoft Access` — Legacy desktop DB +- `Microsoft Office` — Too generic +- `Microsoft PowerPoint` — Presentation tool +- `R` — R4: PK DA postings rarely list R; demoted then dropped +- `SAS` — Enterprise legacy; PK market is Python/SQL + + +## Machine Learning Engineer + +**Kept (16 skills):** + +- `Python` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `PyTorch` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `Scikit-learn` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `SQL` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Docker` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `AWS` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Pandas` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `NumPy` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `Git` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `GitHub` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `TensorFlow` (optional, weight=0.9, level=INTERMEDIATE, source=onet+manual) +- `MLflow` (optional, weight=0.8, level=INTERMEDIATE, source=manual) +- `Kubernetes` (optional, weight=0.7, level=INTERMEDIATE, source=onet+manual) +- `FastAPI` (optional, weight=0.7, level=BEGINNER, source=manual) +- `Apache Spark` (optional, weight=0.7, level=INTERMEDIATE, source=onet) +- `Linux` (optional, weight=0.6, level=INTERMEDIATE, source=onet) + +**Dropped (26 skills):** + +- `Apache Hadoop` — Spark replaced it +- `C++` — Niche optimization only +- `Microsoft Azure` — Pick one cloud — AWS retained +- `Microsoft Excel` — Not core to ML eng +- `Microsoft Power BI` — BI tool +- `Microsoft PowerPoint` — Presentation tool +- `Java` — Rare in ML pipelines +- `R` — Statistics-heavy — DS territory +- `SAS` — Enterprise legacy +- `Snowflake` — Data Engineer territory +- `Tableau` — BI tool +- `MATLAB` — Academic only +- `Ansible` — DevOps territory +- `Bash` — R4: assumed background skill +- `C` — Legacy systems language +- `Go` — Rare in ML pipelines +- `Terraform` — DevOps territory +- `JavaScript` — Frontend, not ML +- `Jenkins` — Replaced by GitHub Actions; not ML-eng-specific +- `NoSQL` — Generic +- `Scala` — Spark+Scala niche, declining +- `Splunk Enterprise` — Observability niche +- `ONNX` — R4: niche optimization, not in PK ML postings +- `Data Structures & Algorithms` — R4: not in postings (interview prep) +- `Object-Oriented Programming` — R4: not in postings (interview prep) +- `Agile / Scrum` — R4: rarely in ML eng postings + + +## Backend Developer + +**Kept (16 skills):** + +- `Python` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Django` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `Node.js` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `PostgreSQL` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `SQL` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `REST API Design` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `Docker` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Git` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `GitHub` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `JWT / OAuth` (mandatory, weight=0.8, level=BEGINNER, source=manual) +- `FastAPI` (optional, weight=0.8, level=INTERMEDIATE, source=manual) +- `TypeScript` (optional, weight=0.7, level=INTERMEDIATE, source=onet) +- `AWS` (optional, weight=0.7, level=INTERMEDIATE, source=onet) +- `NoSQL` (optional, weight=0.6, level=INTERMEDIATE, source=onet) +- `Laravel` (optional, weight=0.4, level=BEGINNER, source=manual) +- `PHP` (optional, weight=0.4, level=BEGINNER, source=manual) + +**Dropped (34 skills):** + +- `Apache Kafka` — Data Engineer territory +- `Jira` — PM tool +- `C` — Not Python/Node-stack backend +- `C#` — Microsoft .NET stack — separate market +- `C++` — Game/systems, not web backend +- `CSS` — Frontend +- `XML` — Legacy +- `Angular` — Frontend +- `HTML` — Frontend +- `Terraform` — DevOps territory +- `JavaScript` — no reason given (please add to role_curation.yaml drop_reasons) +- `JSON` — Assumed knowledge +- `Jenkins` — DevOps territory +- `Kubernetes` — R4: senior-only in PK; Docker covers entry-level +- `Linux` — R4: assumed background +- `.NET` — Microsoft enterprise stack — separate market +- `Microsoft Azure` — Pick one cloud — AWS retained +- `Java` — Spring stack — separate market +- `React` — Frontend +- `Spring Boot` — Java stack +- `Spring Framework` — Java stack +- `Flask` — R4: declining; FastAPI/Django dominate PK postings +- `Redis` — R4: rarely listed by name in PK postings (assumed within Postgres/MongoDB choice) +- `pytest` — R4: rarely listed by name in postings +- `Celery` — R4: rarely listed by name +- `nginx` — R4: rarely listed by name +- `Pydantic` — R4: rarely listed by name (implicit with FastAPI) +- `Data Structures & Algorithms` — R4: interview prep, not posting requirement +- `Object-Oriented Programming` — R4: assumed for interviews, not posting requirement +- `Agile / Scrum` — R4: not in posting requirement lists +- `CI/CD Pipelines` — R4: not as concept skill in postings +- `GitHub Actions` — R4: rare standalone in PK BE postings +- `.NET / C#` — R4: separate Pakistani market, deserves its own role tier +- `Unit Testing` — R4: not in posting requirement lists + + +## Frontend Developer + +**Kept (14 skills):** + +- `React` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `JavaScript` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `TypeScript` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `HTML` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `CSS` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `REST API Design` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `Git` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `GitHub` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Next.js` (mandatory, weight=0.9, level=INTERMEDIATE, source=manual) +- `Tailwind CSS` (optional, weight=0.8, level=BEGINNER, source=manual) +- `Redux` (optional, weight=0.7, level=BEGINNER, source=manual) +- `Node.js` (optional, weight=0.7, level=INTERMEDIATE, source=onet) +- `Vite` (optional, weight=0.5, level=BEGINNER, source=manual) +- `TanStack Query` (optional, weight=0.4, level=BEGINNER, source=manual) + +**Dropped (33 skills):** + +- `AWS` — DevOps territory +- `Apache Kafka` — Data Eng +- `Jira` — PM tool +- `C#` — Backend +- `C++` — Not FE +- `Docker` — R4: rarely listed for FE-only roles +- `Go` — Not FE +- `Angular` — R4: separate role market in PK +- `GraphQL` — R4: rare in PK FE postings +- `JSON` — Assumed +- `Jenkins` — DevOps +- `jQuery` — Legacy +- `Kubernetes` — DevOps territory +- `Linux` — no reason given (please add to role_curation.yaml drop_reasons) +- `Microsoft Azure` — DevOps territory +- `MongoDB` — Backend/data +- `MySQL` — Backend/data +- `NoSQL` — Backend/data +- `Java` — Backend +- `PHP` — Backend (separate Full Stack stack) +- `PostgreSQL` — Backend/data +- `Python` — Backend +- `Spring Boot` — Java backend +- `Spring Framework` — Java backend +- `SQL` — Backend/data +- `Vue.js` — R4: separate role market in PK; React dominates +- `WordPress` — CMS — separate small-agency market +- `Vitest` — R4: testing rarely in PK FE postings +- `Playwright` — R4: testing rarely in PK FE postings +- `Web Accessibility (a11y)` — R4: rarely a posted requirement in PK +- `Responsive Design` — R4: assumed within React+Tailwind work +- `Data Structures & Algorithms` — R4: interview prep, not posting requirement +- `Agile / Scrum` — R4: not in FE posting requirement lists + + +## Full Stack Developer + +**Kept (18 skills):** + +- `JavaScript` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `TypeScript` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `React` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Node.js` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Python` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `PostgreSQL` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `REST API Design` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `SQL` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Docker` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Git` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `GitHub` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `HTML` (mandatory, weight=0.9, level=INTERMEDIATE, source=onet) +- `CSS` (mandatory, weight=0.9, level=INTERMEDIATE, source=onet) +- `Next.js` (optional, weight=0.8, level=INTERMEDIATE, source=manual) +- `Django` (optional, weight=0.8, level=INTERMEDIATE, source=manual) +- `MongoDB` (optional, weight=0.8, level=INTERMEDIATE, source=onet) +- `Tailwind CSS` (optional, weight=0.7, level=BEGINNER, source=manual) +- `AWS` (optional, weight=0.7, level=INTERMEDIATE, source=onet) + +**Dropped (34 skills):** + +- `Apache Kafka` — Data Eng +- `Jira` — PM tool +- `C` — Not web full-stack +- `C#` — .NET niche — separate stack +- `C++` — Not web full-stack +- `XML` — Legacy +- `Angular` — Pick one FE framework — React retained +- `Terraform` — DevOps +- `JSON` — Assumed knowledge +- `Jenkins` — DevOps +- `Kubernetes` — no reason given (please add to role_curation.yaml drop_reasons) +- `Linux` — R4: assumed background +- `.NET` — .NET niche — separate stack +- `Microsoft Azure` — Pick one cloud — AWS retained +- `NoSQL` — Generic — MongoDB covers it +- `Java` — Spring niche — separate stack +- `Spring Boot` — Java stack +- `Spring Framework` — Java stack +- `Go` — Niche for full-stack web in PK +- `GraphQL` — R4: rare in PK FS postings +- `jQuery` — Legacy +- `MySQL` — PostgreSQL covers SQL needs +- `PHP` — R4: separate Laravel/PHP stack market — Backend covers +- `Vue.js` — Pick one FE framework — React retained +- `WordPress` — CMS — separate market +- `Vite` — R4: assumed within Next.js/React tooling +- `Vitest` — R4: testing rarely in PK postings +- `JWT / OAuth` — R4: covered by Backend role; FS focuses on stack +- `Data Structures & Algorithms` — R4: interview prep, not posting requirement +- `Object-Oriented Programming` — R4: not in posting requirement lists +- `Agile / Scrum` — R4: not in posting requirement lists +- `CI/CD Pipelines` — R4: not as concept skill in PK FS postings +- `GitHub Actions` — R4: rare standalone for FS +- `Laravel` — R4: separate Laravel/PHP stack market — Backend covers + + +## Data Engineer + +**Kept (14 skills):** + +- `Python` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `SQL` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Apache Spark` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `Apache Airflow` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `ETL / ELT` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `AWS` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Git` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `Snowflake` (optional, weight=0.8, level=INTERMEDIATE, source=onet+manual) +- `dbt` (optional, weight=0.8, level=INTERMEDIATE, source=manual) +- `Apache Kafka` (optional, weight=0.7, level=BEGINNER, source=manual) +- `Google BigQuery` (optional, weight=0.9, level=INTERMEDIATE, source=manual) +- `Docker` (optional, weight=0.7, level=INTERMEDIATE, source=manual) +- `Microsoft Azure` (optional, weight=0.6, level=INTERMEDIATE, source=onet) +- `Data Modeling` (optional, weight=0.6, level=INTERMEDIATE, source=manual) + +**Dropped (14 skills):** + +- `Microsoft Access` — Legacy desktop DB +- `Microsoft Excel` — R4: rare for DE postings +- `Microsoft Office` — Too generic +- `Microsoft Outlook` — Email client +- `Microsoft Power BI` — BI Analyst territory +- `Microsoft PowerPoint` — Presentation tool +- `Tableau` — BI Analyst territory +- `Java` — R4: declining for DE; Python+Scala dominate +- `R` — BI/stats territory; Python+SQL covers DE +- `Delta Lake` — R4: rare in PK entry-level DE postings +- `Apache Iceberg` — R4: rare in PK entry-level DE postings; Delta Lake (also dropped) was the safer pick but neither needed +- `Databricks` — R4: senior-only in PK postings +- `Parquet` — R4: rarely listed by name in postings (implicit with Spark/Airflow) +- `Data Structures & Algorithms` — R4: interview prep, not posting requirement + + +## DevOps Engineer + +**Kept (16 skills):** + +- `Docker` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `Kubernetes` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `Terraform` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `AWS` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `Linux` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Bash Scripting` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `CI/CD Pipelines` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `Git` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `GitHub` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Python` (mandatory, weight=0.8, level=INTERMEDIATE, source=onet) +- `GitHub Actions` (optional, weight=0.8, level=INTERMEDIATE, source=manual) +- `Microsoft Azure` (optional, weight=0.7, level=BEGINNER, source=onet+manual) +- `Ansible` (optional, weight=0.7, level=BEGINNER, source=onet+manual) +- `Prometheus` (optional, weight=0.6, level=BEGINNER, source=manual) +- `Grafana` (optional, weight=0.6, level=BEGINNER, source=manual) +- `Jenkins` (optional, weight=0.5, level=INTERMEDIATE, source=onet+manual) + +**Dropped (16 skills):** + +- `Amazon Web Services AWS CloudFormation` — Terraform won +- `Jira` — R2: PM tool consistency +- `Bash` — Duplicate of Bash Scripting +- `C` — Not DevOps day-to-day +- `C++` — Not DevOps day-to-day +- `Go` — K8s/Terraform internals only +- `JavaScript` — Not DevOps +- `Microsoft Active Directory` — Windows enterprise niche +- `Microsoft PowerShell` — Windows admin only +- `Java` — Rare in modern DevOps +- `SQL` — R4: debugging-only, not core skill +- `Google Cloud Platform` — R4: rare in PK DevOps postings (AWS/Azure dominate) +- `Linux Administration` — R2: duplicate of Linux +- `ArgoCD` — R4: senior-only in PK postings +- `Helm` — R4: senior-only in PK postings +- `Computer Networking Fundamentals` — R4: assumed background, not in posting requirement lists + + +## AI Engineer (GenAI / LLM) + +**Kept (17 skills):** + +- `Python` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `HuggingFace Transformers` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `LangChain` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `OpenAI API` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `RAG` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `Prompt Engineering` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `PyTorch` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Docker` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Git` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `GitHub` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `AWS` (optional, weight=0.8, level=INTERMEDIATE, source=onet) +- `Microsoft Azure` (optional, weight=0.7, level=INTERMEDIATE, source=onet) +- `Anthropic Claude API` (optional, weight=0.7, level=BEGINNER, source=manual) +- `pgvector` (optional, weight=0.7, level=BEGINNER, source=manual) +- `FastAPI` (optional, weight=0.7, level=BEGINNER, source=manual) +- `LangGraph` (optional, weight=0.6, level=BEGINNER, source=manual) +- `Sentence Transformers` (optional, weight=0.6, level=BEGINNER, source=manual) + +**Dropped (26 skills):** + +- `Ansible` — DevOps +- `Apache Hadoop` — Big data — Data Eng +- `Apache Spark` — Big data — Data Eng +- `Bash` — Background +- `C` — Legacy +- `C++` — Niche +- `Go` — Rare in AI eng +- `Terraform` — DevOps +- `JavaScript` — Not AI eng +- `Jenkins` — DevOps +- `Kubernetes` — R4: senior-only in PK AI eng postings +- `Linux` — Background +- `NoSQL` — Generic — vector DBs are the relevant DB type +- `Java` — Rare in AI eng +- `R` — Statistics, not LLM eng +- `Scala` — Big data, not LLM eng +- `Splunk Enterprise` — Observability niche +- `SQL` — R4: rare for AI Eng day-to-day +- `TensorFlow` — PyTorch dominates research/LLM +- `LlamaIndex` — R4: overlaps LangChain; rarely both required in PK postings +- `Pinecone` — R4: open-source vector DBs (pgvector, Chroma) winning +- `LLM Evaluations` — R4: concept rarely listed in postings +- `LLM Observability` — R4: concept rarely listed in postings +- `LLM Fine-tuning` — R4: rare in entry-level PK postings +- `Object-Oriented Programming` — R4: interview prep +- `Data Structures & Algorithms` — R4: interview prep + + +## Business Intelligence Analyst + +**Kept (10 skills):** + +- `Microsoft Power BI` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet+manual) +- `DAX` (mandatory, weight=1.0, level=INTERMEDIATE, source=manual) +- `SQL` (mandatory, weight=1.0, level=INTERMEDIATE, source=onet) +- `Microsoft Excel` (mandatory, weight=1.0, level=ADVANCED, source=onet) +- `Power Query / M Language` (mandatory, weight=0.9, level=BEGINNER, source=manual) +- `Tableau` (optional, weight=0.7, level=INTERMEDIATE, source=onet+manual) +- `Snowflake` (optional, weight=0.7, level=BEGINNER, source=manual) +- `Python` (optional, weight=0.6, level=INTERMEDIATE, source=onet) +- `Star Schema Modeling` (optional, weight=0.6, level=BEGINNER, source=manual) +- `Data Storytelling` (optional, weight=0.5, level=BEGINNER, source=manual) + +**Dropped (10 skills):** + +- `AWS` — BI Analyst consumes from cloud DWH, doesn't own infra +- `Microsoft Azure` — Same as AWS — BI Analyst doesn't own cloud infra +- `Microsoft Office` — Too generic — Microsoft Excel covers it +- `Microsoft PowerPoint` — Presentation, not BI engineering +- `Oracle Cloud` — Enterprise niche +- `R` — R4: PK BI market is Power BI+SQL+Excel; R rarely required +- `SAP` — ERP — niche +- `SAS` — Enterprise legacy +- `Looker` — R4: rare in PK BI postings (Power BI/Tableau dominate) +- `dbt` — R4: more for Data Eng; rarely in PK BI postings + diff --git a/seed_data/onet_roles_raw.yaml b/seed_data/onet_roles_raw.yaml new file mode 100644 index 0000000000000000000000000000000000000000..26db8ddb1f47bc31075739e1d2df93897e78be47 --- /dev/null +++ b/seed_data/onet_roles_raw.yaml @@ -0,0 +1,2604 @@ +roles: +- name: Data Scientist + industry: Data Science + description: Develop and implement a set of techniques or analytics applications to transform raw data into meaningful information using data-oriented programming languages and visualization software. + Apply data mining, data modeling, natural language processing, and machine learning to extract and analyze information from large structured and unstructured datasets. Visualize, interpret, and report + data findings. May create dynamic data reports. + primary_soc: 15-2051.00 + all_socs: + - 15-2051.00 + skills: + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Hadoop + category: Data + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Spark + category: Data + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C++ + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Excel + category: BI + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Power BI + category: BI + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft PowerPoint + category: Tools + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Java + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PyTorch + category: ML + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: R + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SAS + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Snowflake + category: Cloud + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Tableau + category: BI + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: TensorFlow + category: ML + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: MATLAB + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Pandas + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: NumPy + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Scikit-learn + category: ML + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Jupyter Notebook + category: Tools + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Matplotlib + category: Data + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Seaborn + category: Data + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: Statistical Hypothesis Testing + category: Stats + source: manual + source_soc: null + is_mandatory: true + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Feature Engineering + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: Data Structures & Algorithms + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: Docker + category: Infra + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE +- name: Data Analyst + industry: Data Science + description: Analyze data to identify patterns, generate insights, and produce reports for business decisions. Pakistan/global market role; O*NET groups under Statisticians (15-2041.00) which lists Data + Analyst as a sample title. + primary_soc: 15-2041.00 + all_socs: + - 15-2041.00 + skills: + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: IBM SPSS Statistics + category: Tools + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Access + category: Database + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Excel + category: BI + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Office + category: Tools + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft PowerPoint + category: Tools + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: R + category: Language + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SAS + category: Language + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-2041.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Tableau + category: BI + source: onet+manual + source_soc: 15-2041.00 + is_mandatory: false + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Microsoft Power BI + category: BI + source: manual + source_soc: null + is_mandatory: false + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Pandas + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Statistical Hypothesis Testing + category: Stats + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: Data Visualization + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: DAX + category: Language + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Power Query / M Language + category: Tools + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER +- name: Machine Learning Engineer + industry: AI / ML + description: Build, train, and deploy machine learning models in production. Combine data science (Data Scientists 15-2051.00) with computer science research (Computer and Information Research Scientists + 15-1221.00). No dedicated O*NET SOC. + primary_soc: 15-2051.00 + all_socs: + - 15-2051.00 + - 15-1221.00 + skills: + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Hadoop + category: Data + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Spark + category: Data + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C++ + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Excel + category: BI + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Power BI + category: BI + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft PowerPoint + category: Tools + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Java + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PyTorch + category: ML + source: onet+manual + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: R + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SAS + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Snowflake + category: Cloud + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Tableau + category: BI + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: TensorFlow + category: ML + source: onet+manual + source_soc: 15-2051.00 + is_mandatory: false + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: MATLAB + category: Language + source: onet + source_soc: 15-2051.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Ansible + category: Infra + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Bash + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: onet+manual + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Go + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Terraform + category: Infra + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JavaScript + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Jenkins + category: CI/CD + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Kubernetes + category: Infra + source: onet+manual + source_soc: 15-1221.00 + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Linux + category: Infra + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: NoSQL + category: Database + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Scala + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Splunk Enterprise + category: Cloud + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Scikit-learn + category: ML + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: MLflow + category: MLOps + source: manual + source_soc: null + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: ONNX + category: MLOps + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: FastAPI + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Pandas + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: NumPy + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Data Structures & Algorithms + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Object-Oriented Programming + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE + - skill_name: Agile / Scrum + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER +- name: Backend Developer + industry: Software Engineering + description: Research, design, and develop computer and network software or specialized utility programs. Analyze user needs and develop software solutions, applying principles and techniques of computer + science, engineering, and mathematical analysis. Update software or enhance existing software capabilities. May work with computer hardware engineers to integrate hardware and software systems, and + develop specifications and performance requirements. May maintain databases within an application area, working individually or coordinating database development as part of a team. + primary_soc: 15-1252.00 + all_socs: + - 15-1252.00 + skills: + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Kafka + category: Data + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Jira + category: CI/CD + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C# + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C++ + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: CSS + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: XML + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Angular + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: HTML + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Terraform + category: Infra + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JavaScript + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JSON + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Jenkins + category: CI/CD + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Kubernetes + category: Infra + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Linux + category: Infra + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: .NET + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Node.js + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: NoSQL + category: Database + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Java + category: Language + source: onet+manual + source_soc: 15-1252.00 + is_mandatory: false + weight: 0.4 + required_level: BEGINNER + - skill_name: PostgreSQL + category: Database + source: onet+manual + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: React + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Spring Boot + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Spring Framework + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: TypeScript + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Django + category: Framework + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: FastAPI + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Flask + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.3 + required_level: BEGINNER + - skill_name: Redis + category: Database + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: REST API Design + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JWT / OAuth + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: pytest + category: Tools + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Celery + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: nginx + category: Infra + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: Pydantic + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: Data Structures & Algorithms + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Object-Oriented Programming + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE + - skill_name: Agile / Scrum + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: CI/CD Pipelines + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: GitHub Actions + category: CI/CD + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: .NET / C# + category: Language + source: manual + source_soc: null + is_mandatory: false + weight: 0.4 + required_level: BEGINNER + - skill_name: Unit Testing + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Laravel + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.4 + required_level: BEGINNER + - skill_name: PHP + category: Language + source: manual + source_soc: null + is_mandatory: false + weight: 0.4 + required_level: BEGINNER +- name: Frontend Developer + industry: Software Engineering + description: Develop and implement websites, web applications, application databases, and interactive web interfaces. Evaluate code to ensure that it is properly structured, meets industry standards, + and is compatible with browsers and devices. Optimize website performance, scalability, and server-side code and processes. May develop website infrastructure and integrate websites with other computer + applications. + primary_soc: 15-1254.00 + all_socs: + - 15-1254.00 + skills: + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Kafka + category: Data + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Jira + category: CI/CD + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C# + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C++ + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: CSS + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: onet+manual + source_soc: 15-1254.00 + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: Git + category: Tools + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Go + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Angular + category: Framework + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GraphQL + category: Database + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: HTML + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JavaScript + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JSON + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Jenkins + category: CI/CD + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: jQuery + category: Tools + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Kubernetes + category: Infra + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Linux + category: Infra + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: MongoDB + category: Database + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: MySQL + category: Database + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Node.js + category: Framework + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: NoSQL + category: Database + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Java + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PHP + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PostgreSQL + category: Database + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: React + category: Framework + source: onet+manual + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Spring Boot + category: Framework + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Spring Framework + category: Framework + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: TypeScript + category: Language + source: onet+manual + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Vue.js + category: Framework + source: onet+manual + source_soc: 15-1254.00 + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: WordPress + category: Tools + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Tailwind CSS + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Vite + category: Tools + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: TanStack Query + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.4 + required_level: BEGINNER + - skill_name: Next.js + category: Framework + source: manual + source_soc: null + is_mandatory: true + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: REST API Design + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Redux + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Vitest + category: Tools + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Playwright + category: Tools + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Web Accessibility (a11y) + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: Responsive Design + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Data Structures & Algorithms + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Agile / Scrum + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER +- name: Full Stack Developer + industry: Software Engineering + description: Build both server-side (Software Developers 15-1252.00) and client-side (Web Developers 15-1254.00) of web applications. Composite role — O*NET has no single SOC for full-stack. + primary_soc: 15-1252.00 + all_socs: + - 15-1252.00 + - 15-1254.00 + skills: + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Kafka + category: Data + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Jira + category: CI/CD + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C# + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C++ + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: CSS + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: XML + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Angular + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: HTML + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Terraform + category: Infra + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JavaScript + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JSON + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Jenkins + category: CI/CD + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Kubernetes + category: Infra + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Linux + category: Infra + source: onet+manual + source_soc: 15-1252.00 + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE + - skill_name: .NET + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Node.js + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: NoSQL + category: Database + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Java + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PostgreSQL + category: Database + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: React + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Spring Boot + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Spring Framework + category: Framework + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: TypeScript + category: Language + source: onet+manual + source_soc: 15-1252.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Go + category: Language + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GraphQL + category: Database + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: jQuery + category: Tools + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: MongoDB + category: Database + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: MySQL + category: Database + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PHP + category: Language + source: onet+manual + source_soc: 15-1254.00 + is_mandatory: false + weight: 0.3 + required_level: BEGINNER + - skill_name: Vue.js + category: Framework + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: WordPress + category: Tools + source: onet + source_soc: 15-1254.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: REST API Design + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Next.js + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Tailwind CSS + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Vite + category: Tools + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Vitest + category: Tools + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: JWT / OAuth + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Data Structures & Algorithms + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Object-Oriented Programming + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE + - skill_name: Agile / Scrum + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: CI/CD Pipelines + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: GitHub Actions + category: CI/CD + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: Laravel + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.3 + required_level: BEGINNER + - skill_name: Django + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE +- name: Data Engineer + industry: Data Science + description: Design, build, and maintain data pipelines, warehouses, and ETL systems. Composite of Data Warehousing Specialists (15-1243.01) and Database Architects (15-1243.00). No dedicated O*NET SOC. + primary_soc: 15-1243.01 + all_socs: + - 15-1243.01 + - 15-1243.00 + skills: + - skill_name: Microsoft Access + category: Database + source: onet + source_soc: 15-1243.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Excel + category: BI + source: onet + source_soc: 15-1243.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Office + category: Tools + source: onet + source_soc: 15-1243.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Outlook + category: Tools + source: onet + source_soc: 15-1243.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Power BI + category: BI + source: onet + source_soc: 15-1243.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft PowerPoint + category: Tools + source: onet + source_soc: 15-1243.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-1243.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-1243.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Tableau + category: BI + source: onet + source_soc: 15-1243.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-1243.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Spark + category: Data + source: onet+manual + source_soc: 15-1243.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet + source_soc: 15-1243.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Java + category: Language + source: onet + source_soc: 15-1243.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: R + category: Language + source: onet + source_soc: 15-1243.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Snowflake + category: Cloud + source: onet+manual + source_soc: 15-1243.00 + is_mandatory: false + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Apache Airflow + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: dbt + category: Data + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Kafka + category: Data + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Google BigQuery + category: Cloud + source: manual + source_soc: null + is_mandatory: false + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Delta Lake + category: Data + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Apache Iceberg + category: Data + source: manual + source_soc: null + is_mandatory: false + weight: 0.3 + required_level: BEGINNER + - skill_name: Databricks + category: Cloud + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Data Modeling + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: Parquet + category: Data + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: ETL / ELT + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Data Structures & Algorithms + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Git + category: Tools + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE +- name: DevOps Engineer + industry: Infrastructure + description: Bridge development and operations through CI/CD, infrastructure as code, container orchestration, and cloud platform management. O*NET maps loosely to Computer Systems Engineers/Architects + (15-1299.08); heavy manual augmentation needed for modern DevOps tooling. + primary_soc: 15-1299.08 + all_socs: + - 15-1299.08 + skills: + - skill_name: Amazon Web Services AWS CloudFormation + category: Cloud + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: AWS + category: Cloud + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Ansible + category: IaC + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Jira + category: CI/CD + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Bash + category: Language + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C + category: Language + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C++ + category: Language + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Go + category: Language + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Terraform + category: IaC + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JavaScript + category: Language + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Jenkins + category: CI/CD + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: false + weight: 0.8 + required_level: INTERMEDIATE + - skill_name: Kubernetes + category: Infra + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Linux + category: Infra + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Active Directory + category: Tools + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet+manual + source_soc: 15-1299.08 + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Microsoft PowerShell + category: Tools + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Java + category: Language + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-1299.08 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub Actions + category: CI/CD + source: manual + source_soc: null + is_mandatory: false + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Google Cloud Platform + category: Cloud + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Linux Administration + category: Infra + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Bash Scripting + category: Language + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Prometheus + category: Monitoring + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Grafana + category: Monitoring + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: ArgoCD + category: CI/CD + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Helm + category: Infra + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: CI/CD Pipelines + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Computer Networking Fundamentals + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE +- name: AI Engineer (GenAI / LLM) + industry: AI / ML + description: Build applications powered by large language models, retrieval-augmented generation (RAG), and vector search. O*NET predates GenAI tooling — heavy manual augmentation required for LangChain, + LlamaIndex, OpenAI/Anthropic API, prompt engineering, vector databases. + primary_soc: 15-1221.00 + all_socs: + - 15-1221.00 + skills: + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Ansible + category: Infra + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Hadoop + category: Data + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Apache Spark + category: Data + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Bash + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: C++ + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Docker + category: Infra + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Git + category: Tools + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: GitHub + category: Tools + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Go + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Terraform + category: Infra + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: JavaScript + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Jenkins + category: CI/CD + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Kubernetes + category: Infra + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Linux + category: Infra + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: NoSQL + category: Database + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Java + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: PyTorch + category: ML + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: R + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Scala + category: Language + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Splunk Enterprise + category: Cloud + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: TensorFlow + category: ML + source: onet + source_soc: 15-1221.00 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: HuggingFace Transformers + category: ML + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: LangChain + category: Framework + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: LlamaIndex + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.4 + required_level: BEGINNER + - skill_name: OpenAI API + category: API + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Anthropic Claude API + category: API + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: pgvector + category: Database + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Pinecone + category: Database + source: manual + source_soc: null + is_mandatory: false + weight: 0.4 + required_level: BEGINNER + - skill_name: RAG + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Prompt Engineering + category: Concept + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Sentence Transformers + category: ML + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: FastAPI + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: LangGraph + category: Framework + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: LLM Evaluations + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: INTERMEDIATE + - skill_name: LLM Observability + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: LLM Fine-tuning + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: Object-Oriented Programming + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: INTERMEDIATE + - skill_name: Data Structures & Algorithms + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER +- name: Business Intelligence Analyst + industry: Data Science + description: Produce financial and market intelligence by querying data repositories and generating periodic reports. Devise methods for identifying data patterns and trends in available information sources. + primary_soc: 15-2051.01 + all_socs: + - 15-2051.01 + skills: + - skill_name: AWS + category: Cloud + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Azure + category: Cloud + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Excel + category: BI + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Office + category: Tools + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft Power BI + category: BI + source: onet+manual + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Microsoft PowerPoint + category: Tools + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Oracle Cloud + category: Cloud + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Python + category: Language + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: R + category: Language + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SAP + category: Tools + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SAS + category: Language + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: SQL + category: Database + source: onet + source_soc: 15-2051.01 + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Tableau + category: BI + source: onet+manual + source_soc: 15-2051.01 + is_mandatory: false + weight: 0.9 + required_level: INTERMEDIATE + - skill_name: Looker + category: BI + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: dbt + category: Data + source: manual + source_soc: null + is_mandatory: false + weight: 0.7 + required_level: BEGINNER + - skill_name: Star Schema Modeling + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: DAX + category: Language + source: manual + source_soc: null + is_mandatory: true + weight: 1.0 + required_level: INTERMEDIATE + - skill_name: Power Query / M Language + category: Tools + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER + - skill_name: Data Storytelling + category: Concept + source: manual + source_soc: null + is_mandatory: false + weight: 0.5 + required_level: BEGINNER + - skill_name: Snowflake + category: Cloud + source: manual + source_soc: null + is_mandatory: false + weight: 0.6 + required_level: BEGINNER diff --git a/seed_data/resources.yaml b/seed_data/resources.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e550ef9d216aaeee454bb613e199917d8beaa2c6 --- /dev/null +++ b/seed_data/resources.yaml @@ -0,0 +1,963 @@ +# Curated learning-resource catalog for GapGuide +# Loaded by: python manage.py seed_initial_resources +# Sources (as of April 2026): Coursera, edX, freeCodeCamp, YouTube, DataCamp, Udemy, +# official docs, open textbooks. +# Schema per entry: +# title, provider, url, difficulty_level (BEGINNER|INTERMEDIATE|ADVANCED), +# duration (minutes, 0 for open-ended docs), type (VIDEO|COURSE|ARTICLE|DOCS), +# rating (0.0-5.0 curator-assigned), +# skills: [ { skill_name, relevance_score 0.0-1.0 }, ... ] +# +# Naming rule: every skill_name here MUST exist in seed_data/onet_roles_curated.yaml +# and therefore be present after `seed_initial_skills`. + +resources: + + # ---------------- Python ------------------------------------------------ + - title: "Python for Everybody Specialization" + provider: "Coursera" + url: "https://www.coursera.org/specializations/python" + difficulty_level: BEGINNER + duration: 1800 + type: COURSE + rating: 4.8 + skills: + - skill_name: Python + relevance_score: 1.0 + + - title: "Python Official Documentation" + provider: "python.org" + url: "https://docs.python.org/3/" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.9 + skills: + - skill_name: Python + relevance_score: 0.9 + + - title: "Learn Python - Full Course for Beginners" + provider: "freeCodeCamp (YouTube)" + url: "https://www.youtube.com/watch?v=rfscVS0vtbw" + difficulty_level: BEGINNER + duration: 270 + type: VIDEO + rating: 4.9 + skills: + - skill_name: Python + relevance_score: 0.95 + + - title: "Intermediate Python: Decorators, Generators, Context Managers" + provider: "Real Python" + url: "https://realpython.com/tutorials/intermediate/" + difficulty_level: INTERMEDIATE + duration: 240 + type: ARTICLE + rating: 4.7 + skills: + - skill_name: Python + relevance_score: 0.85 + + # ---------------- SQL / databases -------------------------------------- + - title: "SQL for Data Science" + provider: "Coursera (UC Davis)" + url: "https://www.coursera.org/learn/sql-for-data-science" + difficulty_level: BEGINNER + duration: 840 + type: COURSE + rating: 4.6 + skills: + - skill_name: SQL + relevance_score: 1.0 + + - title: "PostgreSQL Official Documentation" + provider: "postgresql.org" + url: "https://www.postgresql.org/docs/" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.8 + skills: + - skill_name: PostgreSQL + relevance_score: 1.0 + - skill_name: SQL + relevance_score: 0.7 + + - title: "Relational Databases and SQL - Full Course" + provider: "freeCodeCamp (YouTube)" + url: "https://www.youtube.com/watch?v=HXV3zeQKqGY" + difficulty_level: BEGINNER + duration: 240 + type: VIDEO + rating: 4.8 + skills: + - skill_name: SQL + relevance_score: 0.95 + + - title: "Mastering PostgreSQL in Application Development" + provider: "Udemy" + url: "https://www.udemy.com/course/sql-and-postgresql/" + difficulty_level: ADVANCED + duration: 900 + type: COURSE + rating: 4.5 + skills: + - skill_name: PostgreSQL + relevance_score: 0.95 + - skill_name: SQL + relevance_score: 0.8 + + - title: "MongoDB University M001" + provider: "MongoDB" + url: "https://learn.mongodb.com/courses/m001-mongodb-basics" + difficulty_level: BEGINNER + duration: 600 + type: COURSE + rating: 4.7 + skills: + - skill_name: MongoDB + relevance_score: 1.0 + - skill_name: NoSQL + relevance_score: 0.9 + + # ---------------- Data science stack ----------------------------------- + - title: "Pandas Official User Guide" + provider: "pandas.pydata.org" + url: "https://pandas.pydata.org/docs/user_guide/" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.7 + skills: + - skill_name: Pandas + relevance_score: 1.0 + + - title: "Data Analysis with Python" + provider: "freeCodeCamp (YouTube)" + url: "https://www.youtube.com/watch?v=r-uOLxNrNk8" + difficulty_level: BEGINNER + duration: 600 + type: VIDEO + rating: 4.7 + skills: + - skill_name: Pandas + relevance_score: 0.9 + - skill_name: NumPy + relevance_score: 0.8 + + - title: "NumPy Quickstart Tutorial" + provider: "numpy.org" + url: "https://numpy.org/doc/stable/user/quickstart.html" + difficulty_level: BEGINNER + duration: 0 + type: DOCS + rating: 4.6 + skills: + - skill_name: NumPy + relevance_score: 1.0 + + - title: "Matplotlib: Visualization with Python" + provider: "matplotlib.org" + url: "https://matplotlib.org/stable/tutorials/index.html" + difficulty_level: BEGINNER + duration: 0 + type: DOCS + rating: 4.4 + skills: + - skill_name: Matplotlib + relevance_score: 1.0 + - skill_name: Data Visualization + relevance_score: 0.7 + + - title: "Statistical Thinking for Data Science" + provider: "DataCamp" + url: "https://www.datacamp.com/courses/statistical-thinking-in-python-part-1" + difficulty_level: INTERMEDIATE + duration: 240 + type: COURSE + rating: 4.5 + skills: + - skill_name: Statistical Hypothesis Testing + relevance_score: 0.95 + + # ---------------- Machine learning ------------------------------------- + - title: "Machine Learning Specialization" + provider: "Coursera (Andrew Ng, DeepLearning.AI)" + url: "https://www.coursera.org/specializations/machine-learning-introduction" + difficulty_level: INTERMEDIATE + duration: 2400 + type: COURSE + rating: 4.9 + skills: + - skill_name: Scikit-learn + relevance_score: 0.95 + - skill_name: Python + relevance_score: 0.6 + - skill_name: Feature Engineering + relevance_score: 0.8 + + - title: "Scikit-learn User Guide" + provider: "scikit-learn.org" + url: "https://scikit-learn.org/stable/user_guide.html" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.8 + skills: + - skill_name: Scikit-learn + relevance_score: 1.0 + + - title: "Deep Learning Specialization" + provider: "Coursera (DeepLearning.AI)" + url: "https://www.coursera.org/specializations/deep-learning" + difficulty_level: ADVANCED + duration: 3600 + type: COURSE + rating: 4.8 + skills: + - skill_name: TensorFlow + relevance_score: 0.85 + - skill_name: PyTorch + relevance_score: 0.5 + + - title: "PyTorch Official Tutorials" + provider: "pytorch.org" + url: "https://pytorch.org/tutorials/" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.7 + skills: + - skill_name: PyTorch + relevance_score: 1.0 + + - title: "PyTorch Tutorial - Full Course for Beginners" + provider: "freeCodeCamp (YouTube)" + url: "https://www.youtube.com/watch?v=c36lUUr864M" + difficulty_level: BEGINNER + duration: 300 + type: VIDEO + rating: 4.6 + skills: + - skill_name: PyTorch + relevance_score: 0.95 + + - title: "TensorFlow Developer Professional Certificate" + provider: "Coursera (DeepLearning.AI)" + url: "https://www.coursera.org/professional-certificates/tensorflow-in-practice" + difficulty_level: INTERMEDIATE + duration: 1800 + type: COURSE + rating: 4.7 + skills: + - skill_name: TensorFlow + relevance_score: 1.0 + + - title: "Feature Engineering for Machine Learning" + provider: "Udemy" + url: "https://www.udemy.com/course/feature-engineering-for-machine-learning/" + difficulty_level: INTERMEDIATE + duration: 720 + type: COURSE + rating: 4.6 + skills: + - skill_name: Feature Engineering + relevance_score: 1.0 + + - title: "MLflow Official Documentation" + provider: "mlflow.org" + url: "https://mlflow.org/docs/latest/index.html" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.4 + skills: + - skill_name: MLflow + relevance_score: 1.0 + + # ---------------- GenAI / LLM ----------------------------------------- + - title: "LangChain for LLM Application Development" + provider: "DeepLearning.AI" + url: "https://www.deeplearning.ai/short-courses/langchain-for-llm-application-development/" + difficulty_level: INTERMEDIATE + duration: 90 + type: COURSE + rating: 4.7 + skills: + - skill_name: LangChain + relevance_score: 1.0 + - skill_name: OpenAI API + relevance_score: 0.7 + + - title: "LangChain Documentation" + provider: "langchain.com" + url: "https://python.langchain.com/docs/get_started/introduction" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.5 + skills: + - skill_name: LangChain + relevance_score: 1.0 + - skill_name: LangGraph + relevance_score: 0.6 + + - title: "OpenAI API Documentation" + provider: "platform.openai.com" + url: "https://platform.openai.com/docs" + difficulty_level: BEGINNER + duration: 0 + type: DOCS + rating: 4.8 + skills: + - skill_name: OpenAI API + relevance_score: 1.0 + + - title: "Anthropic Claude API Docs" + provider: "docs.anthropic.com" + url: "https://docs.anthropic.com/" + difficulty_level: BEGINNER + duration: 0 + type: DOCS + rating: 4.8 + skills: + - skill_name: Anthropic Claude API + relevance_score: 1.0 + + - title: "Building RAG Applications with LangChain" + provider: "YouTube (LangChain channel)" + url: "https://www.youtube.com/watch?v=sVcwVQRHIc8" + difficulty_level: INTERMEDIATE + duration: 90 + type: VIDEO + rating: 4.6 + skills: + - skill_name: RAG + relevance_score: 1.0 + - skill_name: LangChain + relevance_score: 0.9 + - skill_name: pgvector + relevance_score: 0.5 + + - title: "pgvector README" + provider: "GitHub (pgvector/pgvector)" + url: "https://github.com/pgvector/pgvector" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.7 + skills: + - skill_name: pgvector + relevance_score: 1.0 + + - title: "Prompt Engineering for Developers" + provider: "DeepLearning.AI" + url: "https://www.deeplearning.ai/short-courses/chatgpt-prompt-engineering-for-developers/" + difficulty_level: BEGINNER + duration: 90 + type: COURSE + rating: 4.8 + skills: + - skill_name: Prompt Engineering + relevance_score: 1.0 + + - title: "HuggingFace NLP Course" + provider: "HuggingFace" + url: "https://huggingface.co/learn/nlp-course" + difficulty_level: INTERMEDIATE + duration: 1200 + type: COURSE + rating: 4.8 + skills: + - skill_name: HuggingFace Transformers + relevance_score: 1.0 + - skill_name: Sentence Transformers + relevance_score: 0.6 + + - title: "Sentence-Transformers Documentation" + provider: "sbert.net" + url: "https://www.sbert.net/" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.6 + skills: + - skill_name: Sentence Transformers + relevance_score: 1.0 + + # ---------------- Frontend web ---------------------------------------- + - title: "Responsive Web Design Certification" + provider: "freeCodeCamp" + url: "https://www.freecodecamp.org/learn/2022/responsive-web-design/" + difficulty_level: BEGINNER + duration: 1800 + type: COURSE + rating: 4.8 + skills: + - skill_name: HTML + relevance_score: 1.0 + - skill_name: CSS + relevance_score: 1.0 + + - title: "HTML Living Standard" + provider: "html.spec.whatwg.org" + url: "https://html.spec.whatwg.org/" + difficulty_level: ADVANCED + duration: 0 + type: DOCS + rating: 4.3 + skills: + - skill_name: HTML + relevance_score: 1.0 + + - title: "CSS Reference - MDN" + provider: "MDN Web Docs" + url: "https://developer.mozilla.org/en-US/docs/Web/CSS" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.9 + skills: + - skill_name: CSS + relevance_score: 1.0 + + - title: "JavaScript - The Complete Guide" + provider: "Udemy" + url: "https://www.udemy.com/course/javascript-the-complete-guide-2020-beginner-advanced/" + difficulty_level: INTERMEDIATE + duration: 3000 + type: COURSE + rating: 4.7 + skills: + - skill_name: JavaScript + relevance_score: 1.0 + + - title: "JavaScript Crash Course for Beginners" + provider: "Traversy Media (YouTube)" + url: "https://www.youtube.com/watch?v=hdI2bqOjy3c" + difficulty_level: BEGINNER + duration: 100 + type: VIDEO + rating: 4.8 + skills: + - skill_name: JavaScript + relevance_score: 0.95 + + - title: "TypeScript Handbook" + provider: "typescriptlang.org" + url: "https://www.typescriptlang.org/docs/handbook/intro.html" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.7 + skills: + - skill_name: TypeScript + relevance_score: 1.0 + + - title: "React Documentation" + provider: "react.dev" + url: "https://react.dev/learn" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.8 + skills: + - skill_name: React + relevance_score: 1.0 + + - title: "React - The Complete Guide (incl. Redux)" + provider: "Udemy" + url: "https://www.udemy.com/course/react-the-complete-guide-incl-redux/" + difficulty_level: INTERMEDIATE + duration: 3000 + type: COURSE + rating: 4.7 + skills: + - skill_name: React + relevance_score: 1.0 + - skill_name: Redux + relevance_score: 0.8 + + - title: "Next.js Learn" + provider: "nextjs.org" + url: "https://nextjs.org/learn" + difficulty_level: INTERMEDIATE + duration: 420 + type: COURSE + rating: 4.8 + skills: + - skill_name: Next.js + relevance_score: 1.0 + - skill_name: React + relevance_score: 0.5 + + - title: "Tailwind CSS Documentation" + provider: "tailwindcss.com" + url: "https://tailwindcss.com/docs" + difficulty_level: BEGINNER + duration: 0 + type: DOCS + rating: 4.8 + skills: + - skill_name: Tailwind CSS + relevance_score: 1.0 + + - title: "TanStack Query Documentation" + provider: "tanstack.com" + url: "https://tanstack.com/query/latest/docs/framework/react/overview" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.7 + skills: + - skill_name: TanStack Query + relevance_score: 1.0 + + - title: "Vite Guide" + provider: "vitejs.dev" + url: "https://vitejs.dev/guide/" + difficulty_level: BEGINNER + duration: 0 + type: DOCS + rating: 4.6 + skills: + - skill_name: Vite + relevance_score: 1.0 + + # ---------------- Backend + APIs -------------------------------------- + - title: "Django for Everybody Specialization" + provider: "Coursera" + url: "https://www.coursera.org/specializations/django" + difficulty_level: INTERMEDIATE + duration: 1500 + type: COURSE + rating: 4.7 + skills: + - skill_name: Django + relevance_score: 1.0 + - skill_name: Python + relevance_score: 0.5 + + - title: "Django Official Documentation" + provider: "djangoproject.com" + url: "https://docs.djangoproject.com/" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.8 + skills: + - skill_name: Django + relevance_score: 1.0 + + - title: "FastAPI Documentation" + provider: "fastapi.tiangolo.com" + url: "https://fastapi.tiangolo.com/" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.9 + skills: + - skill_name: FastAPI + relevance_score: 1.0 + + - title: "Node.js Crash Course" + provider: "Traversy Media (YouTube)" + url: "https://www.youtube.com/watch?v=fBNz5xF-Kx4" + difficulty_level: BEGINNER + duration: 90 + type: VIDEO + rating: 4.7 + skills: + - skill_name: Node.js + relevance_score: 0.95 + + - title: "REST API Design Best Practices" + provider: "Microsoft Learn" + url: "https://learn.microsoft.com/en-us/azure/architecture/best-practices/api-design" + difficulty_level: INTERMEDIATE + duration: 60 + type: ARTICLE + rating: 4.6 + skills: + - skill_name: REST API Design + relevance_score: 1.0 + + - title: "JWT.io Introduction" + provider: "jwt.io" + url: "https://jwt.io/introduction" + difficulty_level: BEGINNER + duration: 30 + type: ARTICLE + rating: 4.5 + skills: + - skill_name: JWT / OAuth + relevance_score: 1.0 + + - title: "Laravel From Scratch" + provider: "Laracasts" + url: "https://laracasts.com/series/laravel-from-scratch-2026" + difficulty_level: BEGINNER + duration: 900 + type: COURSE + rating: 4.8 + skills: + - skill_name: Laravel + relevance_score: 1.0 + - skill_name: PHP + relevance_score: 0.8 + + - title: "PHP - The Right Way" + provider: "phptherightway.com" + url: "https://phptherightway.com/" + difficulty_level: INTERMEDIATE + duration: 0 + type: ARTICLE + rating: 4.5 + skills: + - skill_name: PHP + relevance_score: 1.0 + + # ---------------- Version control + Linux ----------------------------- + - title: "Learn Git Branching" + provider: "learngitbranching.js.org" + url: "https://learngitbranching.js.org/" + difficulty_level: BEGINNER + duration: 120 + type: ARTICLE + rating: 4.9 + skills: + - skill_name: Git + relevance_score: 1.0 + + - title: "Git & GitHub Crash Course" + provider: "freeCodeCamp (YouTube)" + url: "https://www.youtube.com/watch?v=RGOj5yH7evk" + difficulty_level: BEGINNER + duration: 65 + type: VIDEO + rating: 4.8 + skills: + - skill_name: Git + relevance_score: 0.95 + - skill_name: GitHub + relevance_score: 0.95 + + - title: "Linux Command Line Basics" + provider: "Linux Foundation edX" + url: "https://www.edx.org/learn/linux/the-linux-foundation-introduction-to-linux" + difficulty_level: BEGINNER + duration: 1400 + type: COURSE + rating: 4.6 + skills: + - skill_name: Linux + relevance_score: 1.0 + - skill_name: Bash Scripting + relevance_score: 0.7 + + - title: "Bash Scripting Tutorial" + provider: "ryanstutorials.net" + url: "https://ryanstutorials.net/bash-scripting-tutorial/" + difficulty_level: BEGINNER + duration: 120 + type: ARTICLE + rating: 4.4 + skills: + - skill_name: Bash Scripting + relevance_score: 1.0 + + # ---------------- DevOps / cloud -------------------------------------- + - title: "Docker Mastery" + provider: "Udemy" + url: "https://www.udemy.com/course/docker-mastery/" + difficulty_level: INTERMEDIATE + duration: 1500 + type: COURSE + rating: 4.7 + skills: + - skill_name: Docker + relevance_score: 1.0 + + - title: "Kubernetes Documentation" + provider: "kubernetes.io" + url: "https://kubernetes.io/docs/home/" + difficulty_level: ADVANCED + duration: 0 + type: DOCS + rating: 4.7 + skills: + - skill_name: Kubernetes + relevance_score: 1.0 + + - title: "Kubernetes Crash Course" + provider: "TechWorld with Nana (YouTube)" + url: "https://www.youtube.com/watch?v=X48VuDVv0do" + difficulty_level: INTERMEDIATE + duration: 240 + type: VIDEO + rating: 4.8 + skills: + - skill_name: Kubernetes + relevance_score: 0.95 + - skill_name: Docker + relevance_score: 0.6 + + - title: "Terraform Associate Cert Prep" + provider: "HashiCorp Learn" + url: "https://developer.hashicorp.com/terraform/tutorials/certification-003" + difficulty_level: INTERMEDIATE + duration: 900 + type: COURSE + rating: 4.6 + skills: + - skill_name: Terraform + relevance_score: 1.0 + + - title: "Ansible Getting Started" + provider: "docs.ansible.com" + url: "https://docs.ansible.com/ansible/latest/getting_started/" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.4 + skills: + - skill_name: Ansible + relevance_score: 1.0 + + - title: "GitHub Actions Documentation" + provider: "docs.github.com" + url: "https://docs.github.com/en/actions" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.6 + skills: + - skill_name: GitHub Actions + relevance_score: 1.0 + - skill_name: CI/CD Pipelines + relevance_score: 0.8 + + - title: "Jenkins User Documentation" + provider: "jenkins.io" + url: "https://www.jenkins.io/doc/" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.3 + skills: + - skill_name: Jenkins + relevance_score: 1.0 + - skill_name: CI/CD Pipelines + relevance_score: 0.8 + + - title: "Prometheus Overview" + provider: "prometheus.io" + url: "https://prometheus.io/docs/introduction/overview/" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.5 + skills: + - skill_name: Prometheus + relevance_score: 1.0 + + - title: "Grafana Fundamentals" + provider: "grafana.com" + url: "https://grafana.com/tutorials/grafana-fundamentals/" + difficulty_level: BEGINNER + duration: 120 + type: COURSE + rating: 4.5 + skills: + - skill_name: Grafana + relevance_score: 1.0 + + - title: "AWS Cloud Practitioner Essentials" + provider: "AWS Skill Builder" + url: "https://skillbuilder.aws/learn/aws-cloud-practitioner-essentials" + difficulty_level: BEGINNER + duration: 360 + type: COURSE + rating: 4.7 + skills: + - skill_name: AWS + relevance_score: 1.0 + + - title: "Azure Fundamentals AZ-900" + provider: "Microsoft Learn" + url: "https://learn.microsoft.com/en-us/training/paths/microsoft-azure-fundamentals-describe-cloud-concepts/" + difficulty_level: BEGINNER + duration: 600 + type: COURSE + rating: 4.7 + skills: + - skill_name: Microsoft Azure + relevance_score: 1.0 + + # ---------------- Data engineering ------------------------------------ + - title: "Apache Airflow Documentation" + provider: "airflow.apache.org" + url: "https://airflow.apache.org/docs/apache-airflow/stable/index.html" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.5 + skills: + - skill_name: Apache Airflow + relevance_score: 1.0 + + - title: "Apache Kafka Fundamentals" + provider: "Confluent Developer" + url: "https://developer.confluent.io/learn-kafka/apache-kafka/events/" + difficulty_level: INTERMEDIATE + duration: 300 + type: COURSE + rating: 4.6 + skills: + - skill_name: Apache Kafka + relevance_score: 1.0 + + - title: "Apache Spark - Databricks Academy" + provider: "Databricks" + url: "https://www.databricks.com/spark/getting-started-with-apache-spark" + difficulty_level: INTERMEDIATE + duration: 480 + type: COURSE + rating: 4.5 + skills: + - skill_name: Apache Spark + relevance_score: 1.0 + + - title: "dbt Fundamentals" + provider: "dbt Labs" + url: "https://courses.getdbt.com/courses/fundamentals" + difficulty_level: BEGINNER + duration: 300 + type: COURSE + rating: 4.8 + skills: + - skill_name: dbt + relevance_score: 1.0 + + - title: "Google BigQuery Documentation" + provider: "cloud.google.com" + url: "https://cloud.google.com/bigquery/docs" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.6 + skills: + - skill_name: Google BigQuery + relevance_score: 1.0 + + - title: "Snowflake Essentials" + provider: "Snowflake University" + url: "https://learn.snowflake.com/" + difficulty_level: BEGINNER + duration: 300 + type: COURSE + rating: 4.6 + skills: + - skill_name: Snowflake + relevance_score: 1.0 + + - title: "Building Modern Data Pipelines - ETL/ELT" + provider: "DataCamp" + url: "https://www.datacamp.com/tracks/data-engineer-in-python" + difficulty_level: INTERMEDIATE + duration: 2400 + type: COURSE + rating: 4.7 + skills: + - skill_name: ETL / ELT + relevance_score: 1.0 + - skill_name: Data Modeling + relevance_score: 0.6 + + # ---------------- BI / analytics -------------------------------------- + - title: "Excel Skills for Data Analytics and Visualization" + provider: "Coursera (Macquarie)" + url: "https://www.coursera.org/specializations/excel-data-analytics-visualization" + difficulty_level: INTERMEDIATE + duration: 1800 + type: COURSE + rating: 4.8 + skills: + - skill_name: Microsoft Excel + relevance_score: 1.0 + + - title: "Microsoft Power BI Desktop for Business Intelligence" + provider: "Udemy" + url: "https://www.udemy.com/course/microsoft-power-bi-up-running-with-power-bi-desktop/" + difficulty_level: INTERMEDIATE + duration: 1200 + type: COURSE + rating: 4.7 + skills: + - skill_name: Microsoft Power BI + relevance_score: 1.0 + - skill_name: DAX + relevance_score: 0.7 + - skill_name: Power Query / M Language + relevance_score: 0.6 + + - title: "Learn DAX - SQLBI" + provider: "SQLBI" + url: "https://www.sqlbi.com/guides/dax/" + difficulty_level: ADVANCED + duration: 0 + type: ARTICLE + rating: 4.8 + skills: + - skill_name: DAX + relevance_score: 1.0 + + - title: "Power Query M Reference" + provider: "Microsoft Learn" + url: "https://learn.microsoft.com/en-us/powerquery-m/" + difficulty_level: INTERMEDIATE + duration: 0 + type: DOCS + rating: 4.4 + skills: + - skill_name: Power Query / M Language + relevance_score: 1.0 + + - title: "Tableau Training" + provider: "tableau.com" + url: "https://www.tableau.com/learn/training" + difficulty_level: BEGINNER + duration: 600 + type: COURSE + rating: 4.6 + skills: + - skill_name: Tableau + relevance_score: 1.0 + - skill_name: Data Visualization + relevance_score: 0.8 + + - title: "Storytelling with Data" + provider: "storytellingwithdata.com" + url: "https://www.storytellingwithdata.com/books" + difficulty_level: INTERMEDIATE + duration: 0 + type: ARTICLE + rating: 4.8 + skills: + - skill_name: Data Storytelling + relevance_score: 1.0 + - skill_name: Data Visualization + relevance_score: 0.7 + + - title: "The Kimball Group - Dimensional Modeling Techniques" + provider: "Kimball Group" + url: "https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/" + difficulty_level: ADVANCED + duration: 0 + type: ARTICLE + rating: 4.5 + skills: + - skill_name: Star Schema Modeling + relevance_score: 1.0 + - skill_name: Data Modeling + relevance_score: 0.9 diff --git a/seed_data/role_curation.yaml b/seed_data/role_curation.yaml new file mode 100644 index 0000000000000000000000000000000000000000..dcb8134493b76de457ea9db0562f947e4c17ca11 --- /dev/null +++ b/seed_data/role_curation.yaml @@ -0,0 +1,422 @@ +# Role curation — Round 4 (2026-04-19) +# +# Driven by REAL Pakistani job-board evidence (Glassdoor PK / Indeed PK / LinkedIn PK postings). +# Previous rounds R1-R3 included roadmap/curriculum-level fundamentals (DSA, OOP, Agile, etc.) +# that — while real interview prep concerns — are NOT what real job postings list as requirements. +# R4 removes them to match what employers actually ask for. +# +# Target: 10-18 skills per role. Mandatory tier capped at ~10. +# Total: ~146 entries across 10 roles. +# +# RULE: A skill in this list IS kept. Anything in onet_roles_raw.yaml NOT listed here is dropped +# (with reason logged to onet_roles_curation_log.md). Each entry can override is_mandatory / +# required_level / weight. Omit to keep raw values. +# +# Sources cited in research/06-dataset-sourcing.md §10 (R4 sources subsection). + +curation: + + "Data Scientist": + keep: + - { name: "Python" } + - { name: "SQL" } + - { name: "Pandas" } + - { name: "NumPy" } + - { name: "Scikit-learn" } + - { name: "PyTorch" } + - { name: "Statistical Hypothesis Testing" } + - { name: "Git" } + - { name: "TensorFlow", is_mandatory: false, weight: 0.9 } + - { name: "AWS", is_mandatory: false, weight: 0.8 } + - { name: "Tableau", is_mandatory: false, weight: 0.8 } + - { name: "Apache Spark", is_mandatory: false, weight: 0.7 } + - { name: "Feature Engineering" } + - { name: "Microsoft Power BI", is_mandatory: false, weight: 0.6 } + - { name: "Matplotlib" } + - { name: "Docker", is_mandatory: false, weight: 0.6 } # MLOps overlap + drop_reasons: + "C++": "Low-level numerics only; not in PK DS postings" + "SAS": "Enterprise legacy" + "MATLAB": "Academic only" + "Java": "Rare in DS workflow" + "Microsoft Azure": "Pick one cloud — AWS retained as primary" + "Apache Hadoop": "Spark replaced it" + "Microsoft PowerPoint": "Presentation tool, not measurable" + "R": "R4: PK DS market is Python+SQL; R rarely required (Glassdoor PK Data Scientist evidence)" + "Microsoft Excel": "R4: not core for DS (Excel is for DA/BI roles)" + "Snowflake": "R4: warehouse skill — Data Engineer territory" + "Jupyter Notebook": "R4: assumed knowledge, not learnable on slider" + "Seaborn": "R4: covered by Matplotlib retention" + "Data Structures & Algorithms": "R4: interview prep, not posting requirement (per job-board evidence)" + + "Data Analyst": + keep: + - { name: "SQL" } + - { name: "Microsoft Excel", required_level: "ADVANCED" } # R3: collapsed Excel + Excel Advanced + - { name: "Microsoft Power BI" } + - { name: "Python", is_mandatory: true, weight: 0.9 } + - { name: "Tableau", is_mandatory: false, weight: 0.8 } + - { name: "Pandas", is_mandatory: false, weight: 0.7 } + - { name: "DAX", is_mandatory: false, weight: 0.7 } # for Power BI users + - { name: "Power Query / M Language", is_mandatory: false, weight: 0.6 } + - { name: "Data Visualization" } + - { name: "Statistical Hypothesis Testing", is_mandatory: false, weight: 0.5 } + drop_reasons: + "IBM SPSS Statistics": "Enterprise legacy" + "Microsoft Access": "Legacy desktop DB" + "Microsoft Office": "Too generic" + "Microsoft PowerPoint": "Presentation tool" + "SAS": "Enterprise legacy; PK market is Python/SQL" + "AWS": "Junior DA in PK rarely owns cloud infra" + "Microsoft Excel Advanced": "R3: collapsed into Microsoft Excel with required_level=ADVANCED" + "R": "R4: PK DA postings rarely list R; demoted then dropped" + + "Machine Learning Engineer": + keep: + - { name: "Python" } + - { name: "PyTorch" } + - { name: "Scikit-learn" } + - { name: "SQL" } + - { name: "Docker" } + - { name: "AWS" } + - { name: "Pandas" } + - { name: "NumPy" } + - { name: "Git" } + - { name: "GitHub" } + - { name: "TensorFlow", is_mandatory: false, weight: 0.9 } + - { name: "MLflow" } + - { name: "Kubernetes", is_mandatory: false, weight: 0.7 } + - { name: "FastAPI" } + - { name: "Apache Spark", is_mandatory: false, weight: 0.7 } + - { name: "Linux", is_mandatory: false, weight: 0.6 } + drop_reasons: + "C++": "Niche optimization only" + "R": "Statistics-heavy — DS territory" + "SAS": "Enterprise legacy" + "MATLAB": "Academic only" + "Microsoft Excel": "Not core to ML eng" + "Microsoft PowerPoint": "Presentation tool" + "Microsoft Power BI": "BI tool" + "Tableau": "BI tool" + "Snowflake": "Data Engineer territory" + "Apache Hadoop": "Spark replaced it" + "Java": "Rare in ML pipelines" + "Microsoft Azure": "Pick one cloud — AWS retained" + "Ansible": "DevOps territory" + "C": "Legacy systems language" + "Go": "Rare in ML pipelines" + "Terraform": "DevOps territory" + "JavaScript": "Frontend, not ML" + "Jenkins": "Replaced by GitHub Actions; not ML-eng-specific" + "NoSQL": "Generic" + "Scala": "Spark+Scala niche, declining" + "Splunk Enterprise": "Observability niche" + "ONNX": "R4: niche optimization, not in PK ML postings" + "Bash": "R4: assumed background skill" + "Object-Oriented Programming": "R4: not in postings (interview prep)" + "Data Structures & Algorithms": "R4: not in postings (interview prep)" + "Agile / Scrum": "R4: rarely in ML eng postings" + + "Backend Developer": + keep: + - { name: "Python" } + - { name: "Django" } + - { name: "Node.js" } + - { name: "PostgreSQL" } + - { name: "SQL" } + - { name: "REST API Design" } + - { name: "Docker" } + - { name: "Git" } + - { name: "GitHub" } + - { name: "JWT / OAuth", is_mandatory: true, weight: 0.8 } + - { name: "FastAPI", is_mandatory: false, weight: 0.8 } + - { name: "TypeScript", is_mandatory: false, weight: 0.7 } + - { name: "AWS", is_mandatory: false, weight: 0.7 } + - { name: "NoSQL", is_mandatory: false, weight: 0.6 } # MongoDB-equivalent + - { name: "Laravel" } + - { name: "PHP" } + drop_reasons: + "C": "Not Python/Node-stack backend" + "C#": "Microsoft .NET stack — separate market" + "C++": "Game/systems, not web backend" + "CSS": "Frontend" + "HTML": "Frontend" + "XML": "Legacy" + "JSON": "Assumed knowledge" + ".NET": "Microsoft enterprise stack — separate market" + "Java": "Spring stack — separate market" + "Spring Boot": "Java stack" + "Spring Framework": "Java stack" + "Angular": "Frontend" + "React": "Frontend" + "Apache Kafka": "Data Engineer territory" + "Terraform": "DevOps territory" + "Microsoft Azure": "Pick one cloud — AWS retained" + "Jenkins": "DevOps territory" + "Apache Spark": "Data Eng territory" + "Jira": "PM tool" + "Flask": "R4: declining; FastAPI/Django dominate PK postings" + "Redis": "R4: rarely listed by name in PK postings (assumed within Postgres/MongoDB choice)" + "Linux": "R4: assumed background" + "Kubernetes": "R4: senior-only in PK; Docker covers entry-level" + "pytest": "R4: rarely listed by name in postings" + "Celery": "R4: rarely listed by name" + "nginx": "R4: rarely listed by name" + "Pydantic": "R4: rarely listed by name (implicit with FastAPI)" + "Object-Oriented Programming": "R4: assumed for interviews, not posting requirement" + "Data Structures & Algorithms": "R4: interview prep, not posting requirement" + "Agile / Scrum": "R4: not in posting requirement lists" + "CI/CD Pipelines": "R4: not as concept skill in postings" + "GitHub Actions": "R4: rare standalone in PK BE postings" + ".NET / C#": "R4: separate Pakistani market, deserves its own role tier" + "Unit Testing": "R4: not in posting requirement lists" + + "Frontend Developer": + keep: + - { name: "React" } + - { name: "JavaScript" } + - { name: "TypeScript" } + - { name: "HTML" } + - { name: "CSS" } + - { name: "REST API Design" } + - { name: "Git" } + - { name: "GitHub" } + - { name: "Next.js" } + - { name: "Tailwind CSS", is_mandatory: false, weight: 0.8 } + - { name: "Redux" } # R4: explicitly cited in PK MERN postings + - { name: "Node.js", is_mandatory: false, weight: 0.7 } + - { name: "Vite", is_mandatory: false, weight: 0.5 } + - { name: "TanStack Query", is_mandatory: false, weight: 0.4 } + drop_reasons: + "C#": "Backend" + "C++": "Not FE" + "Java": "Backend" + "PHP": "Backend (separate Full Stack stack)" + "Python": "Backend" + "Go": "Not FE" + "SQL": "Backend/data" + "NoSQL": "Backend/data" + "MongoDB": "Backend/data" + "MySQL": "Backend/data" + "PostgreSQL": "Backend/data" + "Apache Kafka": "Data Eng" + "Kubernetes": "DevOps territory" + "Microsoft Azure": "DevOps territory" + "AWS": "DevOps territory" + "Spring Boot": "Java backend" + "Spring Framework": "Java backend" + "Jenkins": "DevOps" + "jQuery": "Legacy" + "WordPress": "CMS — separate small-agency market" + "JSON": "Assumed" + "Jira": "PM tool" + "Vue.js": "R4: separate role market in PK; React dominates" + "Angular": "R4: separate role market in PK" + "Vitest": "R4: testing rarely in PK FE postings" + "Playwright": "R4: testing rarely in PK FE postings" + "Docker": "R4: rarely listed for FE-only roles" + "Web Accessibility (a11y)": "R4: rarely a posted requirement in PK" + "Responsive Design": "R4: assumed within React+Tailwind work" + "GraphQL": "R4: rare in PK FE postings" + "Data Structures & Algorithms": "R4: interview prep, not posting requirement" + "Agile / Scrum": "R4: not in FE posting requirement lists" + + "Full Stack Developer": + keep: + - { name: "JavaScript" } + - { name: "TypeScript" } + - { name: "React" } + - { name: "Node.js" } + - { name: "Python" } + - { name: "PostgreSQL" } + - { name: "REST API Design" } + - { name: "SQL" } + - { name: "Docker" } + - { name: "Git" } + - { name: "GitHub" } + - { name: "HTML", is_mandatory: true, weight: 0.9 } + - { name: "CSS", is_mandatory: true, weight: 0.9 } + - { name: "Next.js" } + - { name: "Django", is_mandatory: false, weight: 0.8 } + - { name: "MongoDB", is_mandatory: false, weight: 0.8 } + - { name: "Tailwind CSS", is_mandatory: false, weight: 0.7 } + - { name: "AWS", is_mandatory: false, weight: 0.7 } + drop_reasons: + "C": "Not web full-stack" + "C#": ".NET niche — separate stack" + "C++": "Not web full-stack" + "XML": "Legacy" + "JSON": "Assumed knowledge" + ".NET": ".NET niche — separate stack" + "Java": "Spring niche — separate stack" + "Spring Boot": "Java stack" + "Spring Framework": "Java stack" + "Apache Kafka": "Data Eng" + "Terraform": "DevOps" + "Microsoft Azure": "Pick one cloud — AWS retained" + "MySQL": "PostgreSQL covers SQL needs" + "Angular": "Pick one FE framework — React retained" + "Vue.js": "Pick one FE framework — React retained" + "Prisma": "R3: Python client archived 2025-04, community-only" + "Go": "Niche for full-stack web in PK" + "PHP": "R4: separate Laravel/PHP stack market — Backend covers" + "Laravel": "R4: separate Laravel/PHP stack market — Backend covers" + "Jenkins": "DevOps" + "jQuery": "Legacy" + "WordPress": "CMS — separate market" + "Jira": "PM tool" + "GraphQL": "R4: rare in PK FS postings" + "Linux": "R4: assumed background" + "Vite": "R4: assumed within Next.js/React tooling" + "Vitest": "R4: testing rarely in PK postings" + "JWT / OAuth": "R4: covered by Backend role; FS focuses on stack" + "NoSQL": "Generic — MongoDB covers it" + "Docker Compose": "Reviewer R2: redundant with Docker" + "Object-Oriented Programming": "R4: not in posting requirement lists" + "Data Structures & Algorithms": "R4: interview prep, not posting requirement" + "Agile / Scrum": "R4: not in posting requirement lists" + "CI/CD Pipelines": "R4: not as concept skill in PK FS postings" + "GitHub Actions": "R4: rare standalone for FS" + + "Data Engineer": + keep: + - { name: "Python" } + - { name: "SQL" } + - { name: "Apache Spark" } + - { name: "Apache Airflow" } + - { name: "ETL / ELT" } + - { name: "AWS" } + - { name: "Git" } + - { name: "Snowflake", is_mandatory: false, weight: 0.8 } + - { name: "dbt", is_mandatory: false, weight: 0.8 } + - { name: "Apache Kafka" } + - { name: "Google BigQuery" } + - { name: "Docker", is_mandatory: false, weight: 0.7 } + - { name: "Microsoft Azure", is_mandatory: false, weight: 0.6 } + - { name: "Data Modeling", is_mandatory: false, weight: 0.6 } + drop_reasons: + "Microsoft Access": "Legacy desktop DB" + "Microsoft Office": "Too generic" + "Microsoft Outlook": "Email client" + "Microsoft PowerPoint": "Presentation tool" + "Microsoft Power BI": "BI Analyst territory" + "Tableau": "BI Analyst territory" + "R": "BI/stats territory; Python+SQL covers DE" + "Data Modeling (Kimball)": "R3: renamed to plain 'Data Modeling'" + "Apache Iceberg": "R4: rare in PK entry-level DE postings; Delta Lake (also dropped) was the safer pick but neither needed" + "Delta Lake": "R4: rare in PK entry-level DE postings" + "Databricks": "R4: senior-only in PK postings" + "Parquet": "R4: rarely listed by name in postings (implicit with Spark/Airflow)" + "Java": "R4: declining for DE; Python+Scala dominate" + "Microsoft Excel": "R4: rare for DE postings" + "Data Structures & Algorithms": "R4: interview prep, not posting requirement" + + "DevOps Engineer": + keep: + - { name: "Docker" } + - { name: "Kubernetes" } + - { name: "Terraform" } + - { name: "AWS" } + - { name: "Linux" } + - { name: "Bash Scripting" } + - { name: "CI/CD Pipelines" } + - { name: "Git" } + - { name: "GitHub" } + - { name: "Python", is_mandatory: true, weight: 0.8 } + - { name: "GitHub Actions", is_mandatory: false, weight: 0.8 } + - { name: "Microsoft Azure", is_mandatory: false, weight: 0.7 } + - { name: "Ansible", is_mandatory: false, weight: 0.7 } + - { name: "Prometheus" } + - { name: "Grafana" } + - { name: "Jenkins", is_mandatory: false, weight: 0.5 } + drop_reasons: + "Bash": "Duplicate of Bash Scripting" + "Amazon Web Services AWS CloudFormation": "Terraform won" + "C": "Not DevOps day-to-day" + "C++": "Not DevOps day-to-day" + "Java": "Rare in modern DevOps" + "JavaScript": "Not DevOps" + "Go": "K8s/Terraform internals only" + "Microsoft Active Directory": "Windows enterprise niche" + "Microsoft PowerShell": "Windows admin only" + "Linux Administration": "R2: duplicate of Linux" + "Jira": "R2: PM tool consistency" + "ArgoCD": "R4: senior-only in PK postings" + "Helm": "R4: senior-only in PK postings" + "Computer Networking Fundamentals": "R4: assumed background, not in posting requirement lists" + "Google Cloud Platform": "R4: rare in PK DevOps postings (AWS/Azure dominate)" + "SQL": "R4: debugging-only, not core skill" + + "AI Engineer (GenAI / LLM)": + keep: + - { name: "Python" } + - { name: "HuggingFace Transformers" } + - { name: "LangChain" } + - { name: "OpenAI API" } + - { name: "RAG" } # R4: renamed from "Retrieval-Augmented Generation (RAG)" to canonical job-posting term + - { name: "Prompt Engineering" } + - { name: "PyTorch" } + - { name: "Docker" } + - { name: "Git" } + - { name: "GitHub" } + - { name: "AWS", is_mandatory: false, weight: 0.8 } + - { name: "Microsoft Azure", is_mandatory: false, weight: 0.7 } + - { name: "Anthropic Claude API" } + - { name: "pgvector" } + - { name: "FastAPI" } + - { name: "LangGraph", is_mandatory: false, weight: 0.6 } + - { name: "Sentence Transformers", is_mandatory: false, weight: 0.6 } + drop_reasons: + "C": "Legacy" + "C++": "Niche" + "Bash": "Background" + "Go": "Rare in AI eng" + "Scala": "Big data, not LLM eng" + "Java": "Rare in AI eng" + "R": "Statistics, not LLM eng" + "JavaScript": "Not AI eng" + "Apache Hadoop": "Big data — Data Eng" + "Apache Spark": "Big data — Data Eng" + "Ansible": "DevOps" + "Terraform": "DevOps" + "Jenkins": "DevOps" + "Linux": "Background" + "NoSQL": "Generic — vector DBs are the relevant DB type" + "Splunk Enterprise": "Observability niche" + "TensorFlow": "PyTorch dominates research/LLM" + "Retrieval-Augmented Generation (RAG)": "R4: renamed to canonical 'RAG'" + "Fine-tuning (LoRA/PEFT)": "R3: renamed to 'LLM Fine-tuning'; R4: dropped entirely (rare in postings)" + "LlamaIndex": "R4: overlaps LangChain; rarely both required in PK postings" + "Pinecone": "R4: open-source vector DBs (pgvector, Chroma) winning" + "LLM Evaluations": "R4: concept rarely listed in postings" + "LLM Observability": "R4: concept rarely listed in postings" + "LLM Fine-tuning": "R4: rare in entry-level PK postings" + "Kubernetes": "R4: senior-only in PK AI eng postings" + "SQL": "R4: rare for AI Eng day-to-day" + "Object-Oriented Programming": "R4: interview prep" + "Data Structures & Algorithms": "R4: interview prep" + + "Business Intelligence Analyst": + keep: + - { name: "Microsoft Power BI" } + - { name: "DAX" } + - { name: "SQL" } + - { name: "Microsoft Excel", required_level: "ADVANCED" } + - { name: "Power Query / M Language", is_mandatory: true, weight: 0.9 } + - { name: "Tableau", is_mandatory: false, weight: 0.7 } + - { name: "Snowflake", is_mandatory: false, weight: 0.7 } + - { name: "Python", is_mandatory: false, weight: 0.6 } + - { name: "Star Schema Modeling" } + - { name: "Data Storytelling" } + drop_reasons: + "Microsoft Office": "Too generic — Microsoft Excel covers it" + "Microsoft PowerPoint": "Presentation, not BI engineering" + "Oracle Cloud": "Enterprise niche" + "SAP": "ERP — niche" + "SAS": "Enterprise legacy" + "AWS": "BI Analyst consumes from cloud DWH, doesn't own infra" + "Microsoft Azure": "Same as AWS — BI Analyst doesn't own cloud infra" + "Microsoft Excel Advanced": "R3: collapsed into Microsoft Excel with required_level=ADVANCED" + "Looker": "R4: rare in PK BI postings (Power BI/Tableau dominate)" + "dbt": "R4: more for Data Eng; rarely in PK BI postings" + "R": "R4: PK BI market is Power BI+SQL+Excel; R rarely required" diff --git a/seed_data/role_soc_mapping.yaml b/seed_data/role_soc_mapping.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9d9473a703b489c20b51e3748491b84f978a3f32 --- /dev/null +++ b/seed_data/role_soc_mapping.yaml @@ -0,0 +1,57 @@ +# GapGuide role → O*NET-SOC code mapping +# Verified against onetonline.org on 2026-04-19. See research/06-dataset-sourcing.md §1. +# Composite roles list multiple SOCs; the parser unions their tech skills and dedupes. +# +# `industry` is GapGuide's own field (not from O*NET) — used for filtering on the Target Role page. +# `description_override` is optional; if blank the parser uses Occupation Data.txt's description. + +roles: + - name: "Data Scientist" + industry: "Data Science" + socs: ["15-2051.00"] + description_override: "" + + - name: "Data Analyst" + industry: "Data Science" + socs: ["15-2041.00"] + description_override: "Analyze data to identify patterns, generate insights, and produce reports for business decisions. Pakistan/global market role; O*NET groups under Statisticians (15-2041.00) which lists Data Analyst as a sample title." + + - name: "Machine Learning Engineer" + industry: "AI / ML" + socs: ["15-2051.00", "15-1221.00"] + description_override: "Build, train, and deploy machine learning models in production. Combine data science (Data Scientists 15-2051.00) with computer science research (Computer and Information Research Scientists 15-1221.00). No dedicated O*NET SOC." + + - name: "Backend Developer" + industry: "Software Engineering" + socs: ["15-1252.00"] + description_override: "" + + - name: "Frontend Developer" + industry: "Software Engineering" + socs: ["15-1254.00"] + description_override: "" + + - name: "Full Stack Developer" + industry: "Software Engineering" + socs: ["15-1252.00", "15-1254.00"] + description_override: "Build both server-side (Software Developers 15-1252.00) and client-side (Web Developers 15-1254.00) of web applications. Composite role — O*NET has no single SOC for full-stack." + + - name: "Data Engineer" + industry: "Data Science" + socs: ["15-1243.01", "15-1243.00"] + description_override: "Design, build, and maintain data pipelines, warehouses, and ETL systems. Composite of Data Warehousing Specialists (15-1243.01) and Database Architects (15-1243.00). No dedicated O*NET SOC." + + - name: "DevOps Engineer" + industry: "Infrastructure" + socs: ["15-1299.08"] + description_override: "Bridge development and operations through CI/CD, infrastructure as code, container orchestration, and cloud platform management. O*NET maps loosely to Computer Systems Engineers/Architects (15-1299.08); heavy manual augmentation needed for modern DevOps tooling." + + - name: "AI Engineer (GenAI / LLM)" + industry: "AI / ML" + socs: ["15-1221.00"] + description_override: "Build applications powered by large language models, retrieval-augmented generation (RAG), and vector search. O*NET predates GenAI tooling — heavy manual augmentation required for LangChain, LlamaIndex, OpenAI/Anthropic API, prompt engineering, vector databases." + + - name: "Business Intelligence Analyst" + industry: "Data Science" + socs: ["15-2051.01"] + description_override: "" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/fixtures/resumes/README.md b/tests/fixtures/resumes/README.md new file mode 100644 index 0000000000000000000000000000000000000000..9505165b4201b63d9238d4a7fd086fcc4fa5d912 --- /dev/null +++ b/tests/fixtures/resumes/README.md @@ -0,0 +1,32 @@ +# Resume PDF Fixtures + +Synthetic UCP-student-shaped CVs used by `backend/apps/accounts/tests/test_resume_parser*.py` and the Module 8 demo. Regenerate with: + +``` +python backend/scripts/generate_resume_fixtures.py +``` + +Dev dep: `reportlab` (see `requirements-dev.txt`). The PDFs themselves are committed so tests don't require reportlab to run. + +## What each fixture exercises + +| File | Intended path | Verified output (lexical layer, 2026-04-22) | +|---|---|---| +| `resume_ds_strong.pdf` | Dense DS / ML profile — lexical layer alone finds 12 catalog skills. Used as the happy-path fixture by `test_resume_parser_integration.py` and the `test_resume_parse_accept_changes_readiness` e2e test. | 12 skills: FastAPI, Git, Matplotlib, NumPy, Pandas, PyTorch, Python, SQL, Scikit-learn, Statistical Hypothesis Testing, Tableau, TensorFlow | +| `resume_fe_junior.pdf` | Junior frontend profile — exercises typical React/JS/CSS extraction. | React, JavaScript, HTML, CSS, Git, Node.js (subject to catalog) | +| `resume_fullstack_mixed.pdf` | Composite Django + React + Postgres + Docker CV. Good for the Full Stack Developer role's gap-analysis path. | Django, React, PostgreSQL, Docker, Git, Linux, ... | +| `resume_minimal.pdf` | Paraphrased skill language ("data visualisation", "version control", "cloud computing", "databases"). Lexical layer finds 0 — **Module 8 Layer 4 (SBERT+pgvector) is how these get resolved**. Used to verify the degraded-fallback path really fires. | 0 (lexical) → ≥1 (with SBERT layer) | +| `resume_scanned.pdf` | Image-only (no extractable text). Triggers `ResumeParseError("No text could be extracted…")` at `resume_parser.py:101`. | Raises ResumeParseError | + +## Persona / fixture pairing + +For demo click-throughs (per `seed_demo_users`): + +- `demo.weak` → `resume_fe_junior.pdf` (adds basic React/JS skills to a nearly-empty profile) +- `demo.partial` → `resume_ds_strong.pdf` (fills the Scikit-learn hole that's capping their readiness) +- `demo.ready` → any (already at 100% readiness — the upload is illustrative only) +- `demo.closedloop` → `resume_fullstack_mixed.pdf` (adds Docker/Linux to round out their ML Engineer target) + +## Regenerating + +The generator is idempotent — rerun any time to sync content with catalog changes. Keep PDFs under 100 KB each to keep the repo small. If you need to grow past 100 KB, flag it in a commit — at that point we should reconsider storing them in Git LFS. diff --git a/tests/fixtures/resumes/resume_ai_engineer_genai.pdf b/tests/fixtures/resumes/resume_ai_engineer_genai.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c7cfa051b32d989ba5d9deaf1255906d2fe09215 --- /dev/null +++ b/tests/fixtures/resumes/resume_ai_engineer_genai.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260424193202+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260424193202+05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 880 +>> +stream +Gat=hd;R_m&B7ohn>LK*\=68@nFT6rQ,Aa%hO566>lSi>0Oc2J%I`_?*k5O.3";<1hU^_$62$V3u?7Le7FL5q.nH_iQuE)6?iqb1EhVJXAi(:8jQ%9c[Q!s#`!,0+3'PX\0>pRLq*u&GKF3Xghj4Ci_eF`+5EQ<[n-s$2[r=>Qi;oT,:3K>_$E78XeILROQ&'QUk.[QBsr#Zm=%.6j(7(<2Z9:ef"Qo,aJS`F1D.b[G\Y6Ht+f!S\Eh%7]aS[dC[...78n`X(3l_EB-kJ8<\61=no[p-E3_;34tFJ^dph)sA\R$J]"WX2+TUgZB>F2K.$@s+?p>4W0O+0.,9UY]*mZWAeQ@GQendstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<8552bb2c1a28da83df9926b45ae8987d><8552bb2c1a28da83df9926b45ae8987d>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1891 +%%EOF diff --git a/tests/fixtures/resumes/resume_backend_dev.pdf b/tests/fixtures/resumes/resume_backend_dev.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9212d86e908f4a0837920ee96aa19057d9922157 --- /dev/null +++ b/tests/fixtures/resumes/resume_backend_dev.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260424193202+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260424193202+05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 892 +>> +stream +Gat=h9lJ`N&;KZOMYC!T>3"8R&COL.OH58I,c9j\b@3,L'AMj>Y!_HWn#Qts.?mGi-/5_LB&7gq_hUaK?eqIc(s[)DmDJC#[Y,a4!G3"(B8tMt^HL9AE8_%DR(djr2mmC^RD8Ja2Apf)OF?5eDgaH\RFWq0'Donu7OXU8bnS2X?\t:o-1Ve*+m.$fUT&m-B3ZCYE%u:=0RLh+UK-hTf6\g8Q2Tmt?FO;-HRGlCZassbP?OMiea67"1G#kkWQjTU?3R#`d);#700G:-o/E8`JlF=5<,*IAk*U$[64ZQcYF\n2OtMKc8.>nPj"`BAIQib/)$\PO9niu`WE]aH7&5%+2.NMIKk)[9*&2,*`]Q29A>qD(.rGe1X1T0F)4%iNa_YZu(FdC.Qip?ebt@NqE<7.,4Cb0hMcK33_d#dU7(!iTMN<%Fb"Y?6Zr;[LLqNWd(2"iK7rqJRmra]>ms%@dqnYM\^PF_sL[_d#k=l`,Yrq%9]]Es$n")DoCW/'CQKlHb%0R]N;Kd"(9Qds!(J^O#;q^Qk:jm:_o>9,J1^81APU:CKr&R+\.8^psdaLH0^I'."IciqG9U1"fOZA$S/uQh4=c*A+ZZBWQc^S,UTAHDO(R_:/F[utZG"s2R]A@ZEKo1sYuls\=b)[4D@_]o:l'^eNboT`N%q87?_>9e]a0YW$A-%PC4!,8VtG+kg%ki`RZL946*"TV^:R+GjbGJp2md'MNJWLK?cVJSkH.,,%oi9sfSS"T>jm",iTVDe-1*9Ze"\m^3feKqH<+=X\@b5P#]6rb@Y;JnE#Q`AmhcK5kUJ[M3b)&dQ00h=g9-gI6n7WWFaIp63uHdI#<6pSB7]B>f'I;k!#iTPG6kc'~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1903 +%%EOF diff --git a/tests/fixtures/resumes/resume_bi_analyst.pdf b/tests/fixtures/resumes/resume_bi_analyst.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6cc2adb20ec34c8be3d8282d624ff5a9ad8b3202 --- /dev/null +++ b/tests/fixtures/resumes/resume_bi_analyst.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260424193202+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260424193202+05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 797 +>> +stream +Gat=h:N+rP&B4*cMK^>J+gs*8D(j-U>"&LL#ZpDEbHd^I#MVmqf-,P=N,9[_5obt4b?F;W^A%5gp#,E$eSgiX2\@ZlH162LO77a=J7sUEeGA(n[Q?hm='i\3H-NJCoV0<9hS'ZTjP7`;qRXkClHpYOZ;+,GlXDG&3k1U;:0<(H9W=,,=o6P4`5.[Y16sc6fu4,h-U_=Ao,maQYJI=DIpQ3(+,G"Tjtu?0r>JVc("P78e<=YQNo.Wr.b&WUr:,onS_&#+$0:,`5#>(?@<'(fuaNYd4SajMWO]Ymh)$$01%/e@>2UgtC>6-5LbCNZT6le,L2R!GA.W'1mNRcgZX!C"$_[AFrP5N6')QK`pZ`3ccI2*0g7'ikRr&$8eI?ZH^65fk+#K-D`=U;C.j2*FZ%7?I"g>JlB\&Qkr>Z6ZN[Q+"OXrj^=XtdiX7`nFXUSnD)W*A!1q6NVkq8Iksha&[A]p7F'W$lh>$.T)nrAaqB$gf.(Lh+YpG[S$8C1=I#*<*)*+ROoO@1l`+t9.X3m1>V%E=OZbMi\%e%S&]=1tSOO8$NhP\X]64&#A;5(Q7J/;t)s<-@LP#OVO2Qfhd_Y>>aYeD#n1s4=VmZBU.fc\Qjh@5H~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<4d390e7afb6b636e2b69aeec00b7e30d><4d390e7afb6b636e2b69aeec00b7e30d>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1808 +%%EOF diff --git a/tests/fixtures/resumes/resume_data_engineer.pdf b/tests/fixtures/resumes/resume_data_engineer.pdf new file mode 100644 index 0000000000000000000000000000000000000000..cebc4e252224c57e2c5101eee3f381f940029f3c --- /dev/null +++ b/tests/fixtures/resumes/resume_data_engineer.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260424193202+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260424193202+05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 846 +>> +stream +Gat=h6#WMP&;BTK'm!&M5_0K8h:&6<2QAYcV$_S8`A%I7!Lqf?g&1^Wd%kE7Z;QC0"L;[sqrZQ4q"+^FRo]UA$!dH:+:'.4+GdZ9OqfO2dF0Lg>oENY'h#ErrqYA7SW@GgE2l4nWfN;9,(<>AFZq:$-BS@j-o9n;.5TV3\np]]9]Ok[]2Dn&iIJmb\_+n@@p1].YqlF<33fn6]dil5S`@>lc4kKH+8JK*EOR-[*>t+&:[/f#71DHWJe$r>96)[b?]mq`VPV2>ARR"jG6rA"$\3Dim5j\S'aZ'1H-.TE-T]C1;>#0&co@qPXPc(j4LiPTsTH'PYHme/BOCfkT\i)U/MrTrt#rgoU/%Rl15FaoBHlg5=4**fA)A&2UK)pW$\Rf1uJm+tK?,iKT@73&\XH=.=ZS@og6Pn*+)ELOk0t9anhJrLqT'f!KZ.=Hsn]#\/B"=MN`1'IS,L8S-G4M)I(qfo21n\I~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<19cfb8e19446c10e8b77db4cd94cffd2><19cfb8e19446c10e8b77db4cd94cffd2>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1857 +%%EOF diff --git a/tests/fixtures/resumes/resume_devops_sre.pdf b/tests/fixtures/resumes/resume_devops_sre.pdf new file mode 100644 index 0000000000000000000000000000000000000000..63e81915a48ddec3b86c2b61da016bb560e63851 --- /dev/null +++ b/tests/fixtures/resumes/resume_devops_sre.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260424193202+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260424193202+05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 865 +>> +stream +Gat=h9iKe#&;KZL'mg@kS_24(do.ECNtR23R-B<_XDD>X>;BtS^q]?\#q6Qa3n(@Sj&nsSGFs;[&-@*Fue:N3>.hi1?Jmj31[t>j@*W?30su[7!ND&O(TNX0uq_]NusCB+F]"=Mc9,/T8tH'-i*5pE"m,rI?5/oRTrkAXg5_kH]o!-H'RQE?GG%feH"k@D)fGXD,GK>G8&ksOD@+p;AtZYE$M_l@0m+*74#&k)Qp+D8YbCGbUQ[^#tmW.@&^M%R6m#BbE9HTRB^ueYDk1=d[#L5r%:R*Q8iYKk_mlUAq;!oUTNJ1#n4q?+d$`uH6Dh]9N!'O7%uGJ?TX%Y%>Rdo\6eeli79C$ap)`QY*$A@6p*HVV+NMG,=Wm9.!REjPG:jWipdBd1/e_'=5EBabZ'-2=3DgQ:p6/HT;g8hM[-/S9_[o9P#PkT?L\N?k+"ooqK#dcE)QG,4H(NM2rGMm2rTRILN.8nO!niIO;kUeDW-2\cjP6Q.`KZXNAfRq'UHtZR=i+9)+S'D9WmRJm"R,Ib<`"WCt[db@A,1-@=tb1&3G?F?oj,VZsX]=%Jrc$JFKOI_tZuZ#L))Pmb`;f'Pt/19->LJZ@nc6hJq.)ImIh%)A6P]1Ys5B3eM3qhE1]Zm(oV4p0cMUq_7=d5#J-!<#'eQCsOP(G,H"f6`c@RI&B@u1jD"VglPb-99EdN`kJ"F>5k.?>%dTGCm_YUYTJ]Xksi7X-s~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<082215f43a3729907c869b657120d5aa><082215f43a3729907c869b657120d5aa>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1876 +%%EOF diff --git a/tests/fixtures/resumes/resume_ds_strong.pdf b/tests/fixtures/resumes/resume_ds_strong.pdf new file mode 100644 index 0000000000000000000000000000000000000000..94e8af459924b0796d8f95bcf37d430589c4ad8e --- /dev/null +++ b/tests/fixtures/resumes/resume_ds_strong.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260422194739+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260422194739+05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 846 +>> +stream +Gat=hhf%4&&BE](/++"H5a+#.f"DLGdWK%u3\kC[]XZ'L>S9-)*P21C3u^e+MjT/A6$2Xfn,'X;!Vl9imPc]u`X//0!XM+`((`mI#a-Xl,cs;5@!f3J_]Xd!0\Lhd;p,re"hR=d;c8jAr*>P\NR8e)W,i1#E[G08L'+qtL;>G&-rP`g%-Hqa=0Vud/5[?c0UR&FO?>l#_'X+scg)j(S+0]uJ)G6CH^.Id;R3$oS&B]DfQ6c5T%.Do<-.NA'"NFn.;nsn.]C`?536!Z&Jf7-Hjuab4=I1/3lAe3*gm'XQ>G_n'"ab1^/@g,'ZUr/"/Z-#*Sk\E1__YBAAl_2bi/UB@SM5"6p(MNrS-e/Y,=Y'FHSIXiX.)VLrO8Fp#ADP]&IWSte$iY<6;T5&548ag)-V>ks*Q;X2JOkoOMG$s#7E@a;QYJ9Cl+5h9P_B#1hendstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1857 +%%EOF diff --git a/tests/fixtures/resumes/resume_fe_junior.pdf b/tests/fixtures/resumes/resume_fe_junior.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c66ca7567e539b95952e9707aba25424bc0cf775 --- /dev/null +++ b/tests/fixtures/resumes/resume_fe_junior.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260422194739+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260422194739+05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 624 +>> +stream +Gat=g6#YIB&;BTNMKaa)Ebrc5g)EjZ)psPo:/>+1H\!L:0&9fbqX6L]9VNdJ8C^=u4J!G*n',[)J5032o*Q=I\=&$V,@kSCYob3[A"%Q>FM]@\#quWp8u>]ea7Q5rA`7\XWi;pi7.k\hU$L)HMis>BD(Aq#)+=X$<4+qmY*%jubFE(4@bH0s/3K`@il6E307,im4(KG'LnAcq$ON?g\'\^tSE.@]("TZ1f_d!IE8o.Gkba-8@f#QVqL>mXF[?o=#:YnW=t=J/BrMl)Sd//B356.*0c5HfMmedYP:RXan;t&@B>\Nde_8)W,D[`YTp#1$jTp4.b1B`!+a^8_V\-dWV`bTjWQ8Up5LN9lD[)U`#AZ)5EIs^oZ9%k*EPcB"Lr7(O5^uRXAE0f&Cu3,AH\%)mPMA_>BjZ/`?S9gQl1am'Jb5bR,ZRK@@fWS8FN<3kC/dD^Wg)DDB#1+5OkaE*;Y&=[[B2!?Ht?Wr~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1635 +%%EOF diff --git a/tests/fixtures/resumes/resume_fullstack_mixed.pdf b/tests/fixtures/resumes/resume_fullstack_mixed.pdf new file mode 100644 index 0000000000000000000000000000000000000000..39b416c8678da4a5e591ee082c43508237b709dd --- /dev/null +++ b/tests/fixtures/resumes/resume_fullstack_mixed.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260422194739+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260422194739+05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 675 +>> +stream +Gat=g9lldX&;KZQ'mm;lMLc?*p?'[H*Od>G?95gA+Cj(kU-dN&^[EANVeI"j17F>m*UW:,&$K5WE4KRVEe%j4_kdaD#OrR7htA4"_'8=Gm^_`0^k%X6j(400-Dn'iVRb;NR$2"p&+`M\EF#@>M_IFT0]beBlD:RikC42ElTH@0Z:=T)tl";];$[l,XS:<"G8Igj1,NZ3r@4.pr:ah`4M.TWcIDmV0,,)dTbkFM9&r9*;Kp"jY0Wc2D-BQZ-b;2jGF7g1q1uc@DP,<2m)^%RH6<2T1jt\r)]:$kN=]/0@PblY>NLj5F/N,oQFg8LKhS\Kk3%t;%';"kl$EK`lEl-!DMIEJT:/=b./GMht,YAb$[+4ZMrYhL@V6%&X;([$#g2)i#`k"K?0i>Vfn/^>:8)7QWEL;DTR.qMJ_^_u@]$8.a4*]P1p><\2<,tbJh*D22$OO!pO:so8['@tS8>QV_DmssT2A#@o.GtUN=29r\b>1W4iA\psB)bCM&Q]mMOp.b04g8Ag1n;f04#Y;j@N8P-KQ03d\mP[$%N+&>((5KkV!ckE@mtQ=;6,0$?&p',gI!rO4*rgVDendstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<862861c1ae44e2fd0b59eadfb5f13a71><862861c1ae44e2fd0b59eadfb5f13a71>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1686 +%%EOF diff --git a/tests/fixtures/resumes/resume_minimal.pdf b/tests/fixtures/resumes/resume_minimal.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3116c3ab8d0de6c323a4c74e6d6f1877d047f6af --- /dev/null +++ b/tests/fixtures/resumes/resume_minimal.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260422194739+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260422194739+05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 532 +>> +stream +Gat=ec#1uq'SZ:,MR.De$+;Q`@FnPa8l:`,>rFX5M`l02bfi&.[qG%aW6p%,tD:7q2W*mQ>"kEgamn,o#Jm&_^[Q-H.p?4K4M#Hq0b@sFdmkl!44n[GA`d(F1dY7O-Q.Nl4J>+\dWZ#Xs,pTKmFLP<1\tA_k6:[FG]b4]jp07^*`"NQkX"dt*I.&(0!!APu%5KFFinQ[X5*\>kEb`p\ic0n,3Vq:EL0@ABE4SK,N=Tu_=#FmRT1)*@(Q;US&OCJsrD0aZ_n#A>ViWS!GE94#&?PoU$3X+t\`ojiJE.E.=B=kNU%$Np=pC/T%>I@@q!\%?n\pl?MVC`RdtnRuqVLoN%J0rp]IK5`EZ("![XD~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<6e098e5867e12decf6768666d7642c7a><6e098e5867e12decf6768666d7642c7a>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1543 +%%EOF diff --git a/tests/fixtures/resumes/resume_ml_engineer.pdf b/tests/fixtures/resumes/resume_ml_engineer.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e319aa8ec7f4dc797ac371e95d4b2e59ca622fd5 --- /dev/null +++ b/tests/fixtures/resumes/resume_ml_engineer.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260424193202+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260424193202+05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 890 +>> +stream +Gat=hhf%4&&BE](/+/Np#u,*+BR:V6TreG4)qeG&9QE^0&j*FjG5V(r4+X@70r[d0aQFXhp[Ru1KRfZ@I?P#r-AK]h#iQ?S%)QZh6+h_Nh##n8]9VCVY/?&2P:^`3Q1nrrGW:QR<-69Ep:F6Mp5FIEGU922'948&GjbUC?$WZo$FN/'PNRi3LgM;(@f?>"kC!bi%JZ]@4IWX>]aJL5[Lq2_(2[<-NGBUuo[cf;0t$oDr42IUJj(_g;K.1`,#a)O*)P]QVWcmH.6nguad=:'raAAF)j)nnV9f[_+:Mi9UjU&08#307]N#OPnkUA3KiQ,T&9T\Iqhpm@gZH@\m^<0pdVN!CVsU*4ns+OI#[FAcIBMHP-WMgF1fPgPIu[e$/T+_+5U4.QMMHmC1dl&4Sm)r-%uRY$8'f@P]*/Dad/E^ZNnZBk,`b^fqS%FNm'TIib5W=2BJTJgYa(A),`e/1*%IRqMXLHkl6eF8BdtR"ei_u>\[K^I2$3KW(j#bgh;'R,b+dX1J2V&o9NR:NkH3bTet(@85Ojj'Q'HXB.,ibb;r1,jbOW;6-i^N_7b?$:>Z(,">`XID9k^Oil8-W&5nq[@^iu0G%G*?ke9-M^E:eNt9_P@BV6OU`.8*V0Te/k]c*#')ONOB(2+HZ*`HBArJXJlCd4d%LdMHFN>7`4mlc&LDZ&Pp+VdP0*S(i3(1[6-d)]`Q^r>$0MTX;@mh:RA7qpcC`#*[5D&=ee=ll#uurW3\9`e+~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<98b749963fbbcaaede2a22fa0e745511><98b749963fbbcaaede2a22fa0e745511>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1901 +%%EOF diff --git a/tests/fixtures/resumes/resume_scanned.pdf b/tests/fixtures/resumes/resume_scanned.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b357915d535696f914177e83cddc5043f47289d9 --- /dev/null +++ b/tests/fixtures/resumes/resume_scanned.pdf @@ -0,0 +1,68 @@ +%PDF-1.3 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/Contents 7 0 R /MediaBox [ 0 0 612 792 ] /Parent 6 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +4 0 obj +<< +/PageMode /UseNone /Pages 6 0 R /Type /Catalog +>> +endobj +5 0 obj +<< +/Author (anonymous) /CreationDate (D:20260422194739+05'00') /Creator (anonymous) /Keywords () /ModDate (D:20260422194739+05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +6 0 obj +<< +/Count 1 /Kids [ 3 0 R ] /Type /Pages +>> +endobj +7 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 105 +>> +stream +GapQh0E=F,0U\H3T\pNYT^QKk?tc>IP,;W#U1^23ihPEM_PLmRWW9q*l!/K@VBuX^"pPF35\t+ETMUlQOJW0n2DKdjS&0gn\,\SN']T~>endstream +endobj +xref +0 8 +0000000000 65535 f +0000000061 00000 n +0000000092 00000 n +0000000199 00000 n +0000000392 00000 n +0000000460 00000 n +0000000721 00000 n +0000000780 00000 n +trailer +<< +/ID +[<23efc3148d635f0d76a29c9bf17b9f0a><23efc3148d635f0d76a29c9bf17b9f0a>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 5 0 R +/Root 4 0 R +/Size 8 +>> +startxref +975 +%%EOF diff --git a/tests/manual_e2e.sh b/tests/manual_e2e.sh new file mode 100644 index 0000000000000000000000000000000000000000..f8a08acb89b671167f51d59539ffee983572ddf7 --- /dev/null +++ b/tests/manual_e2e.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Manual E2E run against a live server on :8765. +# Exercises the entire student happy path: register → skills → target role → +# analyze → recommendations → checkpoint toggle → upgrade suggestions → apply +# → re-analyze, plus the claim checks that live HTTP can see. + +set -u +API="http://127.0.0.1:8765/api" + +say() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } +ok() { printf '\033[32m[ok]\033[0m %s\n' "$*"; } +fail(){ printf '\033[31m[FAIL]\033[0m %s\n' "$*"; } + +# Unique email so re-runs don't collide with the previous state. +TAG="e2e$RANDOM" + +# --------------------------------------------------------------------------- +# CLAIM #2 (weak password accepted) — live HTTP verification +# --------------------------------------------------------------------------- +say "CLAIM #2: weak password accepted at /register/" +R=$(curl -s -o /tmp/r.json -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"w\",\"email\":\"weak_${TAG}@x.com\",\"password\":\"abc\",\"password_confirm\":\"abc\"}" \ + "$API/auth/register/") +[ "$R" = "201" ] && ok "weak password 'abc' was accepted (HTTP 201) — CLAIM CONFIRMED" \ + || fail "unexpected HTTP $R" + +# --------------------------------------------------------------------------- +# CLAIM #3 (email case-sensitive uniqueness) — live HTTP +# --------------------------------------------------------------------------- +say "CLAIM #3: email uniqueness is case-sensitive" +UPPER="Case_${TAG}@x.com" +LOWER="case_${TAG}@x.com" +R1=$(curl -s -o /dev/null -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"u\",\"email\":\"$UPPER\",\"password\":\"StrongPass123!\",\"password_confirm\":\"StrongPass123!\"}" \ + "$API/auth/register/") +R2=$(curl -s -o /dev/null -w '%{http_code}' \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"l\",\"email\":\"$LOWER\",\"password\":\"StrongPass123!\",\"password_confirm\":\"StrongPass123!\"}" \ + "$API/auth/register/") +[ "$R1" = "201" ] && [ "$R2" = "201" ] && ok "Both $UPPER and $LOWER registered — CLAIM CONFIRMED" \ + || fail "upper=$R1 lower=$R2" + +# --------------------------------------------------------------------------- +# Full student happy path +# --------------------------------------------------------------------------- +say "REGISTER student" +REG=$(curl -s -H 'Content-Type: application/json' \ + -d "{\"name\":\"Student\",\"email\":\"student_${TAG}@x.com\",\"password\":\"StrongPass123!\",\"password_confirm\":\"StrongPass123!\"}" \ + "$API/auth/register/") +ACCESS=$(echo "$REG" | python -c "import json,sys;print(json.load(sys.stdin)['access'])") +[ -n "$ACCESS" ] && ok "got JWT access token" || { fail "no access token"; exit 1; } +H=(-H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json') + +say "LIST skills catalog" +N_SKILLS=$(curl -s "${H[@]}" "$API/skills/?page_size=200" \ + | python -c "import json,sys;d=json.load(sys.stdin);print(d['count'])") +ok "skills count = $N_SKILLS" + +say "LIST roles" +N_ROLES=$(curl -s "${H[@]}" "$API/roles/" \ + | python -c "import json,sys;print(len(json.load(sys.stdin)))") +ok "roles count = $N_ROLES" + +say "Pick Data Scientist role" +DS_ID=$(curl -s "${H[@]}" "$API/roles/" \ + | python -c "import json,sys;print(next(r['id'] for r in json.load(sys.stdin) if r['role_name']=='Data Scientist'))") +ok "DS role_id = $DS_ID" + +say "SET TARGET ROLE -> Data Scientist" +curl -s "${H[@]}" -d "{\"role_id\":$DS_ID}" "$API/target-role/" > /dev/null +ok "target role set" + +say "SET USER SKILLS (Python=70, SQL=90)" +PY_ID=$(curl -s "${H[@]}" "$API/skills/?page_size=200" \ + | python -c "import json,sys;print(next(s['id'] for s in json.load(sys.stdin)['results'] if s['skill_name']=='Python'))") +SQL_ID=$(curl -s "${H[@]}" "$API/skills/?page_size=200" \ + | python -c "import json,sys;print(next(s['id'] for s in json.load(sys.stdin)['results'] if s['skill_name']=='SQL'))") +curl -s "${H[@]}" -d "{\"skill_id\":$PY_ID,\"proficiency\":70}" "$API/user-skills/" > /dev/null +curl -s "${H[@]}" -d "{\"skill_id\":$SQL_ID,\"proficiency\":90}" "$API/user-skills/" > /dev/null +ok "Python=70, SQL=90 set" + +say "GAP ANALYSIS" +GAP=$(curl -s "${H[@]}" "$API/analysis/") +R=$(echo "$GAP" | python -c "import json,sys;d=json.load(sys.stdin);print(d['readiness'],d['band'],d['mandatory_cap_applied'])") +ok "readiness/band/cap = $R" + +say "RECOMMENDATIONS" +REC=$(curl -s "${H[@]}" "$API/recommendations/") +N_GAPS=$(echo "$REC" | python -c "import json,sys;d=json.load(sys.stdin);print(len(d['recommendations']))") +ok "gap-skill keys in recs = $N_GAPS" + +# Pick any skill that has recommendations for the checkpoint-toggle test. +RES_TITLE=$(echo "$REC" | python -c " +import json,sys +d = json.load(sys.stdin) +for sid, items in d['recommendations'].items(): + if items: + print(items[0]['resource_id'], items[0]['title']); break +") +RES_ID=$(echo "$RES_TITLE" | awk '{print $1}') +ok "top rec resource_id = $RES_ID" + +say "GET resource detail (checkpoints?)" +DET=$(curl -s "${H[@]}" "$API/resources/$RES_ID/") +N_CP=$(echo "$DET" | python -c "import json,sys;print(len(json.load(sys.stdin)['checkpoints']))") +ok "checkpoints = $N_CP" + +if [ "$N_CP" -gt 0 ]; then + say "TOGGLE all checkpoints" + for CP in $(echo "$DET" | python -c "import json,sys;print(*(c['id'] for c in json.load(sys.stdin)['checkpoints']))"); do + curl -s "${H[@]}" -X POST "$API/progress/checkpoint/$CP/toggle/" -d '{"completed":true}' > /dev/null + done + PROG=$(curl -s "${H[@]}" "$API/progress/resource/$RES_ID/" \ + | python -c "import json,sys;d=json.load(sys.stdin);print(d['status'], d['progress'])") + ok "resource status/progress after all toggles = $PROG" +else + say "MANUAL slider to 100 (no checkpoints on this resource)" + curl -s "${H[@]}" -X POST "$API/progress/resource/$RES_ID/" -d '{"progress":100}' > /dev/null + ok "marked COMPLETED via slider" +fi + +say "UPGRADE SUGGESTIONS" +SUG=$(curl -s "${H[@]}" "$API/progress/upgrade-suggestions/") +N_SUG=$(echo "$SUG" | python -c "import json,sys;print(len(json.load(sys.stdin)['suggestions']))") +ok "pending suggestions = $N_SUG" + +if [ "$N_SUG" -gt 0 ]; then + FIRST_SKILL=$(echo "$SUG" | python -c "import json,sys;print(json.load(sys.stdin)['suggestions'][0]['skill_id'])") + say "CLAIM #1: apply the upgrade THREE TIMES in a row on the same completion" + for i in 1 2 3 4; do + R=$(curl -s "${H[@]}" -X POST "$API/progress/upgrade-suggestions/$FIRST_SKILL/apply/") + APPLIED=$(echo "$R" | python -c "import json,sys;d=json.load(sys.stdin);print(d.get('applied'), d.get('already_applied'), d.get('user_skill',{}).get('proficiency'))") + ok "call #$i applied/already/prof = $APPLIED" + done + echo "→ If the spec held, only call #1 would show 'applied=True'." +fi + +say "POST-UPGRADE GAP ANALYSIS" +GAP2=$(curl -s "${H[@]}" "$API/analysis/") +R2=$(echo "$GAP2" | python -c "import json,sys;d=json.load(sys.stdin);print(d['readiness'],d['band'],d['mandatory_cap_applied'])") +ok "readiness/band/cap = $R2" diff --git a/tests/test_e2e_student_flow.py b/tests/test_e2e_student_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..6ceae02233bac00496bf3cf4ed7e55326715af45 --- /dev/null +++ b/tests/test_e2e_student_flow.py @@ -0,0 +1,583 @@ +"""End-to-end cross-module smoke tests. + +Exercises the full Student happy path through the HTTP layer only: + register -> skills catalog -> set user skills -> roles catalog -> set target + role -> gap analysis -> resources browse -> checkpoint toggle -> progress rollup. + +If any seam between apps breaks (model FK drift, URL routing, serializer +shape), these fail. Unit tests in each app won't catch those regressions. +""" +from pathlib import Path + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from rest_framework.test import APIClient + +from apps.resources.models import Resource, ResourceCheckpoint, SkillResource +from apps.roles.models import Role, RoleSkill +from apps.skills.models import Skill + +pytestmark = pytest.mark.django_db + + +REGISTER = '/api/auth/register/' +SKILLS = '/api/skills/' +USER_SKILLS = '/api/user-skills/' +ROLES = '/api/roles/' +TARGET_ROLE = '/api/target-role/' +GAP_ANALYSIS = '/api/analysis/' +RECOMMENDATIONS = '/api/recommendations/' +RESOURCES = '/api/resources/' +PROGRESS_LIST = '/api/progress/' +PARSE_RESUME = '/api/auth/profile/parse-resume/' +UPGRADE_SUGGESTIONS = '/api/progress/upgrade-suggestions/' + +FIXTURES_DIR = Path(__file__).resolve().parent / 'fixtures' / 'resumes' + + +def _register(client: APIClient, email: str, name: str = 'Test') -> str: + r = client.post(REGISTER, data={ + 'name': name, 'email': email, + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + assert r.status_code == 201, r.data + return r.data['access'] + + +def auth_client(access_token): + c = APIClient() + c.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}') + return c + + +@pytest.fixture +def catalog(): + """Minimal catalog wired across roles/skills/resources.""" + python = Skill.objects.create( + skill_name='Python', category='Programming', difficulty_level='BEGINNER', + ) + sql = Skill.objects.create( + skill_name='SQL', category='Database', difficulty_level='BEGINNER', + ) + ml = Skill.objects.create( + skill_name='Machine Learning', category='AI', difficulty_level='INTERMEDIATE', + ) + + role = Role.objects.create( + role_name='Data Scientist', description='DS role', + industry='Tech', is_active=True, + ) + RoleSkill.objects.create( + role=role, skill=python, required_level='ADVANCED', + weight=1.0, is_mandatory=True, + ) + RoleSkill.objects.create( + role=role, skill=sql, required_level='INTERMEDIATE', + weight=0.8, is_mandatory=True, + ) + RoleSkill.objects.create( + role=role, skill=ml, required_level='INTERMEDIATE', + weight=0.6, is_mandatory=False, + ) + + ml_course = Resource.objects.create( + title='Intro to ML', provider='Coursera', + url='https://coursera.org/ml', + difficulty_level='INTERMEDIATE', duration=3600, type='COURSE', + rating=4.8, + ) + SkillResource.objects.create(skill=ml, resource=ml_course, relevance_score=0.95) + for i, t in enumerate(['Week 1', 'Week 2', 'Week 3', 'Week 4'], start=1): + ResourceCheckpoint.objects.create( + resource=ml_course, order_index=i, title=t, source='manual', + ) + + sql_docs = Resource.objects.create( + title='SQL Docs', provider='PostgreSQL.org', + url='https://postgresql.org/docs/', + difficulty_level='INTERMEDIATE', duration=0, type='DOCS', + rating=4.5, + ) + SkillResource.objects.create(skill=sql, resource=sql_docs, relevance_score=0.9) + + return { + 'skills': {'python': python, 'sql': sql, 'ml': ml}, + 'role': role, + 'resources': {'ml_course': ml_course, 'sql_docs': sql_docs}, + } + + +def test_student_flow_end_to_end(catalog): + """Full happy-path flow — if ANY cross-module seam is broken, this fails.""" + # 1. Register --------------------------------------------------------- + client = APIClient() + r = client.post(REGISTER, data={ + 'name': 'Student A', 'email': 's@x.com', + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + assert r.status_code == 201, r.data + access = r.data['access'] + c = auth_client(access) + + # 2. Browse skills (paginated) ---------------------------------------- + r = c.get(SKILLS) + assert r.status_code == 200 + names = {s['skill_name'] for s in r.data['results']} + assert {'Python', 'SQL', 'Machine Learning'} <= names + + # 3. Set user skills (partial coverage — ML is not set) --------------- + # Numbers chosen so raw readiness > 60 AND mandatory (Python) is short: + # weighted_sat = 0.7*1.0 + 1.0*0.8 + 0*0.6 = 1.5; / 2.4 * 100 = 62.5 + # => mandatory cap pins readiness to 60.0 (PARTIAL band). + for skill_id, prof in [ + (catalog['skills']['python'].id, 70), # below ADVANCED (T=100), mandatory + (catalog['skills']['sql'].id, 90), # above INTERMEDIATE (T=60) => MET + ]: + r = c.post(USER_SKILLS, data={'skill_id': skill_id, 'proficiency': prof}, + format='json') + assert r.status_code == 201, r.data + + r = c.get(USER_SKILLS) + assert r.status_code == 200 + assert len(r.data) == 2 + + # 4. Browse roles ----------------------------------------------------- + r = c.get(ROLES) + assert r.status_code == 200 + assert any(role['role_name'] == 'Data Scientist' for role in r.data) + + # 5. Set target role -------------------------------------------------- + r = c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json') + assert r.status_code == 201, r.data + + # 6. Gap analysis — verifies skills + roles + analysis seam ---------- + r = c.get(GAP_ANALYSIS) + assert r.status_code == 200, r.data + report = r.data + assert report['role_name'] == 'Data Scientist' + assert report['no_requirements'] is False + # Mandatory Python is unmet (70 < 100) -> raw 62.5 capped to 60. + assert report['mandatory_cap_applied'] is True + assert report['readiness'] == 60.0 + assert report['band'] == 'PARTIAL' + + gaps_by_name = {g['skill_name']: g for g in report['gaps']} + assert gaps_by_name['Python']['gap_type'] == 'INSUFFICIENT' + assert gaps_by_name['Python']['severity'] == 'HIGH' # mandatory shortfall + assert gaps_by_name['SQL']['gap_type'] == 'MET' + assert gaps_by_name['Machine Learning']['gap_type'] == 'MISSING' + assert gaps_by_name['Machine Learning']['severity'] == 'MEDIUM' # not mandatory + + # 7. Browse resources filtered by the ML gap (paginated) ------------- + ml_id = catalog['skills']['ml'].id + r = c.get(f'{RESOURCES}?skill={ml_id}') + assert r.status_code == 200 + assert r.data['count'] == 1 + results = r.data['results'] + assert results[0]['title'] == 'Intro to ML' + assert results[0]['has_checkpoints'] is True + + ml_course_id = results[0]['id'] + r = c.get(f'{RESOURCES}{ml_course_id}/') + assert r.status_code == 200 + checkpoints = r.data['checkpoints'] + assert len(checkpoints) == 4 + assert [cp['order_index'] for cp in checkpoints] == [1, 2, 3, 4] + + # 8. Toggle checkpoints — verifies resources + progress seam --------- + cp1, cp2 = checkpoints[0]['id'], checkpoints[1]['id'] + r = c.post(f'/api/progress/checkpoint/{cp1}/toggle/') + assert r.status_code == 200 + assert r.data['progress'] == 25 + assert r.data['status'] == 'IN_PROGRESS' + + r = c.post(f'/api/progress/checkpoint/{cp2}/toggle/') + assert r.data['progress'] == 50 + + # 9. Manual slider rejected because checkpoints exist ---------------- + r = c.post(f'/api/progress/resource/{ml_course_id}/', + data={'progress': 90}, format='json') + assert r.status_code == 400 + + # 10. Manual slider allowed for docs resource (no checkpoints) ------- + docs_id = catalog['resources']['sql_docs'].id + r = c.post(f'/api/progress/resource/{docs_id}/', + data={'progress': 100}, format='json') + assert r.status_code == 200 + assert r.data['status'] == 'COMPLETED' + assert r.data['completed_at'] is not None + + # 11. Progress list shows BOTH resources, scoped to this user -------- + r = c.get(PROGRESS_LIST) + assert r.status_code == 200 + assert len(r.data) == 2 + by_resource = {row['resource']: row for row in r.data} + assert by_resource[ml_course_id]['progress'] == 50 + assert by_resource[ml_course_id]['has_checkpoints'] is True + assert by_resource[docs_id]['progress'] == 100 + assert by_resource[docs_id]['has_checkpoints'] is False + + +def test_gap_analysis_requires_target_role(catalog): + client = APIClient() + r = client.post(REGISTER, data={ + 'name': 'Student B', 'email': 'b@x.com', + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + c = auth_client(r.data['access']) + + r = c.get(GAP_ANALYSIS) + assert r.status_code == 400 + assert 'target role' in r.data['detail'].lower() + + +def test_gap_analysis_role_id_override_bypasses_target(catalog): + client = APIClient() + r = client.post(REGISTER, data={ + 'name': 'Student C', 'email': 'c@x.com', + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + c = auth_client(r.data['access']) + + # No target role set — but ?role_id=X should still work for "what-if". + r = c.get(f"{GAP_ANALYSIS}?role_id={catalog['role'].id}") + assert r.status_code == 200 + assert r.data['role_name'] == 'Data Scientist' + + +def test_recommendations_flow(catalog): + """Full recommendations happy path — every gap skill gets ≥1 rec, the top + rec resolves via /api/resources//, and the top ML pick matches the + single ML row filtered via ?skill=.""" + client = APIClient() + r = client.post(REGISTER, data={ + 'name': 'Rec Student', 'email': 'rec@x.com', + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + c = auth_client(r.data['access']) + + # Python=70 (mandatory, below ADVANCED=100) → INSUFFICIENT + # SQL=90 (above INTERMEDIATE=60) → MET (excluded from recs) + # ML=0 → MISSING + for skill_id, prof in [ + (catalog['skills']['python'].id, 70), + (catalog['skills']['sql'].id, 90), + ]: + c.post(USER_SKILLS, data={'skill_id': skill_id, 'proficiency': prof}, + format='json') + + c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json') + + gap = c.get(GAP_ANALYSIS).data + rec = c.get(RECOMMENDATIONS).data + assert rec['role_id'] == catalog['role'].id + + gap_skill_ids = {g['skill_id'] for g in gap['gaps'] if g['gap_type'] != 'MET'} + rec_skill_ids = {int(k) for k in rec['recommendations']} + assert gap_skill_ids == rec_skill_ids + # SQL is MET → must not appear in recommendations. + assert catalog['skills']['sql'].id not in rec_skill_ids + + ml_id = catalog['skills']['ml'].id + ml_recs = rec['recommendations'].get(str(ml_id)) \ + or rec['recommendations'].get(ml_id) + assert ml_recs, 'ML gap should surface ≥1 recommendation' + top_ml = ml_recs[0] + assert top_ml['resource_id'] == catalog['resources']['ml_course'].id + + # Top rec must be fetchable via /api/resources// + r = c.get(f'{RESOURCES}{top_ml["resource_id"]}/') + assert r.status_code == 200 + assert r.data['url'] == top_ml['url'] + + # And cross-check: filtering resources by the ML skill returns the same row. + r = c.get(f'{RESOURCES}?skill={ml_id}') + assert r.status_code == 200 + assert r.data['results'][0]['id'] == top_ml['resource_id'] + + +def test_full_closed_loop(catalog): + """Full gap → recommend → complete → upgrade → re-analyze cycle. + + Start: Python=70 (mandatory short), SQL=90 (MET), ML=0. + → gap shows readiness capped at 60 (raw 62.5). + → recommendations suggest the ML course. + → complete ML course via checkpoints → COMPLETED. + → upgrade-suggestions lists ML@60. + → apply → user_skill.ML = 60, user_level INTERMEDIATE. + → re-analyze → ML MET, readiness still capped by Python shortfall. + → complete the SQL DOCS resource (slider path) → apply its upgrade → (no-op + since SQL already at 90, bump map goes to 100 which is new). + """ + client = APIClient() + r = client.post(REGISTER, data={ + 'name': 'Closed Loop Student', 'email': 'closed@x.com', + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + c = auth_client(r.data['access']) + + for skill_id, prof in [ + (catalog['skills']['python'].id, 70), + (catalog['skills']['sql'].id, 90), + ]: + c.post(USER_SKILLS, data={'skill_id': skill_id, 'proficiency': prof}, + format='json') + c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json') + + pre = c.get(GAP_ANALYSIS).data + assert pre['readiness'] == 60.0 + assert pre['mandatory_cap_applied'] is True + + rec = c.get(RECOMMENDATIONS).data + ml_id = catalog['skills']['ml'].id + ml_recs = rec['recommendations'].get(str(ml_id)) \ + or rec['recommendations'].get(ml_id) + top = ml_recs[0] + assert top['resource_id'] == catalog['resources']['ml_course'].id + + # Complete ML via checkpoints. + detail = c.get(f'{RESOURCES}{top["resource_id"]}/').data + for cp in detail['checkpoints']: + c.post(f'/api/progress/checkpoint/{cp["id"]}/toggle/') + + suggestions = c.get('/api/progress/upgrade-suggestions/').data['suggestions'] + ml_sug = next(s for s in suggestions if s['skill_id'] == ml_id) + assert ml_sug['suggested_proficiency'] == 60 + + r = c.post(f'/api/progress/upgrade-suggestions/{ml_id}/apply/') + assert r.status_code == 200 + assert r.data['applied'] is True + + mid = c.get(GAP_ANALYSIS).data + ml_gap = next(g for g in mid['gaps'] if g['skill_id'] == ml_id) + assert ml_gap['gap_type'] == 'MET' + # Python still mandatory short → cap still applied. + assert mid['mandatory_cap_applied'] is True + + # Now drive SQL upgrade too via the DOCS slider path (complete → bump). + sql_doc = catalog['resources']['sql_docs'] + r = c.post(f'/api/progress/resource/{sql_doc.id}/', + data={'progress': 100}, format='json') + assert r.data['status'] == 'COMPLETED' + + sql_id = catalog['skills']['sql'].id + r = c.post(f'/api/progress/upgrade-suggestions/{sql_id}/apply/') + # Bump map: user at 90 → target 100. So this applies. + assert r.status_code == 200 + assert r.data['applied'] is True + + +def test_progress_isolated_per_user(catalog): + """User A's checkpoint toggles must not leak into User B's progress.""" + a_client = APIClient() + b_client = APIClient() + ra = a_client.post(REGISTER, data={ + 'name': 'A', 'email': 'isoa@x.com', + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + rb = b_client.post(REGISTER, data={ + 'name': 'B', 'email': 'isob@x.com', + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + a = auth_client(ra.data['access']) + b = auth_client(rb.data['access']) + + # Both users must select the target role so progress toggles are in-plan. + a.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json') + b.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json') + + ml_course = catalog['resources']['ml_course'] + cp = ml_course.checkpoints.first() + a.post(f'/api/progress/checkpoint/{cp.id}/toggle/') + + ra_list = a.get(PROGRESS_LIST).data + rb_list = b.get(PROGRESS_LIST).data + assert len(ra_list) == 1 and ra_list[0]['progress'] == 25 + assert len(rb_list) == 0 + + +# -------------------- 7b: added e2e scenarios -------------------- + +def test_register_duplicate_email_rejected(): + """Second register with the same email (case-insensitive) must 400.""" + client = APIClient() + r1 = client.post(REGISTER, data={ + 'name': 'First', 'email': 'dup@x.com', + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + assert r1.status_code == 201 + + # Same email, different casing — must still collide. + r2 = client.post(REGISTER, data={ + 'name': 'Second', 'email': 'DUP@x.com', + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + assert r2.status_code == 400 + # Serializer puts the clash on the email field. + body = r2.data + error_text = str(body).lower() + assert 'email' in error_text and 'already' in error_text + + +def test_dismiss_then_next_suggestion_excludes(catalog): + """After dismiss, the same (user, skill) suggestion must not reappear until + a newer resource completion arrives.""" + client = APIClient() + access = _register(client, 'dismiss@x.com') + c = auth_client(access) + + for skill_id, prof in [ + (catalog['skills']['python'].id, 70), + (catalog['skills']['sql'].id, 90), + ]: + c.post(USER_SKILLS, data={'skill_id': skill_id, 'proficiency': prof}, + format='json') + c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json') + + # Complete the ML course → upgrade suggestion for ML appears. + ml_course = catalog['resources']['ml_course'] + for cp in ml_course.checkpoints.all(): + c.post(f'/api/progress/checkpoint/{cp.id}/toggle/') + + ml_id = catalog['skills']['ml'].id + initial = c.get(UPGRADE_SUGGESTIONS).data['suggestions'] + assert any(s['skill_id'] == ml_id for s in initial) + + # Dismiss. + r = c.post(f'{UPGRADE_SUGGESTIONS}{ml_id}/dismiss/') + assert r.status_code == 200 + assert r.data['dismissed'] is True + + # Fetch again — ML suggestion must be gone. + after = c.get(UPGRADE_SUGGESTIONS).data['suggestions'] + assert not any(s['skill_id'] == ml_id for s in after) + + +def test_resume_parse_accept_changes_readiness(catalog): + """Upload a fixture resume → accept predictions → readiness must move. + + Bridges Module 8: the parse-resume endpoint returns skill predictions + that, when accepted and upserted into UserSkill, move the gap report. + """ + client = APIClient() + access = _register(client, 'resume@x.com') + c = auth_client(access) + + c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json') + + # Before: no UserSkill rows → readiness is low. + before = c.get(GAP_ANALYSIS).data + assert before['readiness'] < 40 + + # Upload the DS-strong fixture. The lexical-layer MVP extracts Python + SQL + # (both in the catalog fixture) — assert they appear in predictions. + pdf_path = FIXTURES_DIR / 'resume_ds_strong.pdf' + assert pdf_path.exists(), 'fixture missing — run scripts/generate_resume_fixtures.py' + upload = SimpleUploadedFile( + pdf_path.name, pdf_path.read_bytes(), content_type='application/pdf', + ) + r = c.post(PARSE_RESUME, data={'resume': upload}, format='multipart') + assert r.status_code == 200, r.data + predicted_names = {p['skill_name'] for p in r.data['skills']} + assert {'Python', 'SQL'}.issubset(predicted_names), predicted_names + + # Accept the Python + SQL predictions. + predictions_by_name = {p['skill_name']: p for p in r.data['skills']} + for name in ('Python', 'SQL'): + p = predictions_by_name[name] + r = c.post(USER_SKILLS, data={ + 'skill_id': p['skill_id'], + 'proficiency': p['proficiency'], + }, format='json') + assert r.status_code == 201, r.data + + after = c.get(GAP_ANALYSIS).data + assert after['readiness'] > before['readiness'] + + +def test_target_role_switch_recomputes_gap(catalog): + """Switching target role must invalidate stale gap output.""" + # Add a second role inline. + other_role = Role.objects.create( + role_name='Frontend Developer', description='FE', + industry='Tech', is_active=True, + ) + # No RoleSkills on the second role — gap becomes a "no requirements" report. + + client = APIClient() + access = _register(client, 'switch@x.com') + c = auth_client(access) + + c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json') + first = c.get(GAP_ANALYSIS).data + assert first['role_name'] == 'Data Scientist' + + c.post(TARGET_ROLE, data={'role_id': other_role.id}, format='json') + second = c.get(GAP_ANALYSIS).data + assert second['role_name'] == 'Frontend Developer' + assert second['no_requirements'] is True + assert second['readiness'] == 100.0 + + +def test_checkpoint_untoggle_decreases_progress(catalog): + """Toggle endpoint is bidirectional — untoggle must reduce progress.""" + client = APIClient() + access = _register(client, 'untoggle@x.com') + c = auth_client(access) + c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json') + + ml_course = catalog['resources']['ml_course'] + cps = list(ml_course.checkpoints.order_by('order_index')) + + r = c.post(f'/api/progress/checkpoint/{cps[0].id}/toggle/') + assert r.data['progress'] == 25 + + r = c.post(f'/api/progress/checkpoint/{cps[1].id}/toggle/') + assert r.data['progress'] == 50 + + # Untoggle the first checkpoint — progress must drop back to 25. + r = c.post(f'/api/progress/checkpoint/{cps[0].id}/toggle/') + assert r.data['progress'] == 25 + assert r.data['status'] == 'IN_PROGRESS' + + +def test_manual_slider_rejection_includes_checkpoint_word(catalog): + """The FE at Learning.jsx:55 substring-matches 'checkpoint' in the error + detail to decide whether to hide the slider. Contract lives here.""" + client = APIClient() + access = _register(client, 'slider@x.com') + c = auth_client(access) + c.post(TARGET_ROLE, data={'role_id': catalog['role'].id}, format='json') + + ml_course = catalog['resources']['ml_course'] # has checkpoints + r = c.post(f'/api/progress/resource/{ml_course.id}/', + data={'progress': 50}, format='json') + assert r.status_code == 400 + detail = str(r.data).lower() + assert 'checkpoint' in detail, r.data + + +def test_resources_pagination_contract(catalog): + """/api/resources/?skill=X must return {count, results} regardless of + row count. Contract the FE pagination code relies on.""" + # Add 12 extra resources linked to ML so count > default page sample. + ml = catalog['skills']['ml'] + for i in range(12): + r = Resource.objects.create( + title=f'ML extra {i}', provider='Test', + url=f'https://example.com/ml/{i}', + difficulty_level='INTERMEDIATE', duration=60, type='ARTICLE', + rating=3.5, + ) + SkillResource.objects.create(skill=ml, resource=r, relevance_score=0.5) + + client = APIClient() + access = _register(client, 'page@x.com') + c = auth_client(access) + + r = c.get(f'{RESOURCES}?skill={ml.id}') + assert r.status_code == 200 + # catalog fixture already adds 1 ML resource; + 12 extras = 13 total. + assert r.data['count'] == 13 + assert 'results' in r.data + assert len(r.data['results']) == 13 # under default page_size=50 diff --git a/tests/test_qa_flow.py b/tests/test_qa_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..f48c6b14af5e786a0c9e2c66c2dbee0be80232e2 --- /dev/null +++ b/tests/test_qa_flow.py @@ -0,0 +1,168 @@ +"""Flow-level QA checks — converted from scripts/qa_flow_checks.py (F33). + +The original script drove APIClient against the *configured* DB with no +isolation (manual `.delete()` cleanup + `cache.clear()` on the real cache), +so a crashed run could leave junk users/skills behind and wipe live throttle +state. These now run against the pytest test DB (`@pytest.mark.django_db` → +per-test rollback), and conftest's autouse `_clear_throttle_cache` gives each +throttle test a clean rate-limit bucket. Behavioural coverage is unchanged. +""" +from io import StringIO + +import pytest +from django.contrib.auth import get_user_model +from django.core.management import call_command +from rest_framework.test import APIClient + +from apps.progress.models import UserProgress +from apps.resources.models import Resource +from apps.roles.models import Role, UserTargetRole +from apps.skills.models import Skill, UserSkill + +User = get_user_model() + + +@pytest.fixture +def seeded(db): + """Full curated seed — skills, roles, resources.""" + call_command('seed_initial_skills', stdout=StringIO()) + call_command('seed_initial_roles', stdout=StringIO()) + call_command('seed_initial_resources', stdout=StringIO()) + return True + + +@pytest.mark.django_db +def test_register_email_casing_collision_and_login(): + """Lowercase register → 201; an upper-case variant of the same address → + 400 (case-insensitive uniqueness); login accepts any casing.""" + c = APIClient() + r1 = c.post('/api/auth/register/', { + 'name': 'QA Casing', 'email': 'qa.casing@test.xx', + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + assert r1.status_code == 201, r1.data + + r2 = c.post('/api/auth/register/', { + 'name': 'QA Casing', 'email': 'QA.Casing@Test.XX', + 'password': 'StrongPass123!', 'password_confirm': 'StrongPass123!', + }, format='json') + assert r2.status_code == 400, r2.data + + r3 = c.post('/api/auth/login/', { + 'email': 'QA.CASING@TEST.XX', 'password': 'StrongPass123!', + }, format='json') + assert r3.status_code == 200, r3.data + + +@pytest.mark.django_db +def test_cross_user_data_isolation(seeded): + """User B never sees User A's UserSkill or UserProgress rows.""" + ua = User.objects.create_user(username='qa.a@test.xx', email='qa.a@test.xx', + password='StrongPass123!', name='Alice') + ub = User.objects.create_user(username='qa.b@test.xx', email='qa.b@test.xx', + password='StrongPass123!', name='Bob') + role = Role.objects.filter(is_active=True).first() + UserTargetRole.objects.update_or_create(user=ua, role=role, defaults={'is_active': True}) + UserTargetRole.objects.update_or_create(user=ub, role=role, defaults={'is_active': True}) + + skill = Skill.objects.first() + UserSkill.objects.update_or_create( + user=ua, skill=skill, + defaults={'proficiency': 88, 'user_level': 'ADVANCED'}, + ) + + ca = APIClient(); ca.force_authenticate(user=ua) + cb = APIClient(); cb.force_authenticate(user=ub) + + a_skills = ca.get('/api/user-skills/').json() + b_skills = cb.get('/api/user-skills/').json() + assert isinstance(a_skills, list) and len(a_skills) >= 1 + assert not any(s['skill']['id'] == skill.id for s in b_skills) + + res = Resource.objects.first() + UserProgress.objects.update_or_create( + user=ua, resource=res, + defaults={'status': 'IN_PROGRESS', 'progress': 55}, + ) + a_prog = ca.get('/api/progress/').json() + b_prog = cb.get('/api/progress/').json() + assert any(p.get('id') for p in a_prog) + assert not any(p.get('resource') == res.id for p in b_prog) + + +@pytest.mark.django_db +def test_target_role_switch_reflected_in_analysis(seeded): + """Switching the active target role changes the role the gap report is + computed against.""" + ua = User.objects.create_user(username='qa.sw@test.xx', email='qa.sw@test.xx', + password='StrongPass123!', name='Switch') + r_old = Role.objects.filter(is_active=True).order_by('id').first() + r_new = (Role.objects.filter(is_active=True) + .exclude(id=r_old.id).order_by('id').first()) + UserTargetRole.objects.update_or_create( + user=ua, role=r_old, defaults={'is_active': True}) + + ca = APIClient(); ca.force_authenticate(user=ua) + gap_old = ca.get('/api/analysis/').json() + ca.post('/api/target-role/', {'role_id': r_new.id}, format='json') + gap_new = ca.get('/api/analysis/').json() + assert gap_old.get('role_id') == r_old.id + assert gap_new.get('role_id') == r_new.id + + +@pytest.mark.django_db +def test_login_throttle_kicks_in_by_11th_attempt(): + """login scope = 10/minute → the 11th attempt in the window returns 429.""" + c = APIClient(REMOTE_ADDR='10.9.9.9') + first_429 = None + for i in range(1, 13): + r = c.post('/api/auth/login/', { + 'email': 'doesnotexist@test.xx', 'password': 'wrong', + }, format='json') + if r.status_code == 429: + first_429 = i + break + assert first_429 is not None, 'login never throttled' + assert first_429 <= 11, f'throttled too late (attempt {first_429})' + + +@pytest.mark.django_db +def test_register_throttle_kicks_in_by_21st_attempt(): + """register scope = 20/hour → the 21st attempt in the window returns 429.""" + c = APIClient(REMOTE_ADDR='10.9.9.10') + first_429 = None + for i in range(1, 23): + r = c.post('/api/auth/register/', { + 'name': 'x', 'email': f'throttle{i}@t.xx', + 'password': 'ShortButOk!9', 'password_confirm': 'ShortButOk!9', + }, format='json') + if r.status_code == 429: + first_429 = i + break + assert first_429 is not None, 'register never throttled' + assert first_429 <= 21, f'throttled too late (attempt {first_429})' + + +@pytest.mark.django_db +def test_skill_with_script_name_round_trips_as_raw_text(): + """A Skill whose name contains ', category='X', + difficulty_level='BEGINNER') + c = APIClient(); c.force_authenticate(user=user) + body = c.get('/api/skills/').json() + rows = body['results'] if isinstance(body, dict) and 'results' in body else body + assert any('