diff --git a/backend/content/management/commands/seed_lookups.py b/backend/content/management/commands/seed_lookups.py index 7897a4c..290a1f8 100644 --- a/backend/content/management/commands/seed_lookups.py +++ b/backend/content/management/commands/seed_lookups.py @@ -252,6 +252,15 @@ def handle(self, *args, **options): }, ) + # Link org-specific sources to their organizations so Teacher users can see them + for source_key, org_token in [("unibz", "unibz"), ("mdu", "mdu")]: + org = organization_map.get(org_token) + if org: + ScrapingSource.objects.filter(key=source_key).update( + organization=org, + organization_token=str(org.id), + ) + # Single shared password for all loginable seeded users SEED_PASSWORD = "passw0rd" diff --git a/backend/content/scrapers/service.py b/backend/content/scrapers/service.py index f6088df..524a5ed 100644 --- a/backend/content/scrapers/service.py +++ b/backend/content/scrapers/service.py @@ -555,7 +555,11 @@ def _upsert_offer( if offer_type is None: LOGGER.warning("[%s] offer_type %r not found in DB — discarding", source.key, resolved_offer_type_name) return "skipped", None, None - target_profile = TargetProfile.objects.get(name=source.target_profile) + try: + target_profile = TargetProfile.objects.get(name=source.target_profile) + except TargetProfile.DoesNotExist: + LOGGER.error("[%s] target_profile '%s' not in DB — skipping offer", source.key, source.target_profile) + return "skipped", None, None natural_key = (source.url, str(organization.id), str(offer_type.id)) diff --git a/backend/content/test_sources.py b/backend/content/test_sources.py index f1da70c..4bedf9e 100644 --- a/backend/content/test_sources.py +++ b/backend/content/test_sources.py @@ -2,7 +2,7 @@ import json import uuid from django.test import TestCase, Client -from content.models import Organization, ScrapingSource, User +from content.models import Organization, ScrapingSource, User, UserOrganization, UserRole from content.auth import hash_password _TEST_ORG_ID = str(uuid.UUID("00000000-0000-0000-0000-000000000001")) @@ -188,6 +188,35 @@ def test_create_missing_required_fields(self): ) self.assertEqual(resp.status_code, 400) + def test_create_auto_generates_key_when_absent(self): + payload = { + "name": "Auto Key Source", + "url": "https://autokey.example.com", + "organization_id": _TEST_ORG_ID, + } + resp = self.client.post( + self.url, + data=json.dumps(payload), + content_type="application/json", + **self._auth(), + ) + self.assertEqual(resp.status_code, 201) + data = json.loads(resp.content) + key = data["key"] + self.assertTrue(len(key) > 0) + import uuid as _uuid + _uuid.UUID(key) # raises ValueError if not a valid UUID + + def test_create_invalid_target_profile(self): + payload = _source_payload(target_profile="invalid_profile", key="bad_tp_src") + resp = self.client.post( + self.url, + data=json.dumps(payload), + content_type="application/json", + **self._auth(), + ) + self.assertEqual(resp.status_code, 400) + def test_create_duplicate_key(self): ScrapingSource.objects.create( key="test_source", name="Existing", url="https://x.com", organization_token="o", @@ -459,3 +488,201 @@ def test_get_sources_empty_db(self): from content.scrapers.source_registry import get_sources results = get_sources() self.assertEqual(results, []) + + +_OTHER_ORG_ID = str(uuid.UUID("00000000-0000-0000-0000-000000000002")) + + +def _make_teacher(username="teacher_test", password="Teacher123!"): + user = User.objects.create( + username=username, + email=f"{username}@example.com", + password_hash=hash_password(password), + profile=User.ProfileType.TEACHER, + email_verified=True, + approval_status="approved", + is_active=True, + ) + return user, password + + +def _make_company(username="company_test", password="Company123!"): + user = User.objects.create( + username=username, + email=f"{username}@example.com", + password_hash=hash_password(password), + profile=User.ProfileType.COMPANY, + email_verified=True, + approval_status="approved", + is_active=True, + ) + return user, password + + +def _make_other_org(): + org, _ = Organization.objects.get_or_create( + id=_OTHER_ORG_ID, + defaults={ + "name": "Other Org", + "type": Organization.OrganizationType.UNIVERSITY, + "country": "SE", + "website": "https://other.example.com", + }, + ) + return org + + +class SourcesOrgScopeTestCase(TestCase): + """Teachers can only access sources belonging to their own organization.""" + + def setUp(self): + self.client = Client() + ScrapingSource.objects.all().delete() + self.own_org = _make_org() + self.other_org = _make_other_org() + self.role, _ = UserRole.objects.get_or_create( + name="researcher", defaults={"description": "Researcher role"} + ) + self.teacher, self.teacher_pw = _make_teacher() + UserOrganization.objects.create( + user=self.teacher, organization=self.own_org, role=self.role + ) + self.teacher_token = _token(self.client, self.teacher.username, self.teacher_pw) + self.own_source = ScrapingSource.objects.create( + key="own_src", + name="Own Source", + url="https://own.example.com", + organization=self.own_org, + organization_token=str(self.own_org.id), + llm_fallback_enabled=True, + enabled=True, + ) + self.other_source = ScrapingSource.objects.create( + key="other_src", + name="Other Source", + url="https://other.example.com", + organization=self.other_org, + organization_token=str(self.other_org.id), + llm_fallback_enabled=True, + enabled=True, + ) + + def _auth(self, token=None): + return {"HTTP_AUTHORIZATION": f"Bearer {token or self.teacher_token}"} + + def test_teacher_can_list_own_org_sources(self): + resp = self.client.get("/api/admin/sources", **self._auth()) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.content) + keys = {s["key"] for s in data["results"]} + self.assertIn("own_src", keys) + + def test_teacher_cannot_see_other_org_sources(self): + resp = self.client.get("/api/admin/sources", **self._auth()) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.content) + keys = {s["key"] for s in data["results"]} + self.assertNotIn("other_src", keys) + self.assertEqual(data["count"], 1) + + def test_teacher_can_create_source_org_auto_assigned(self): + payload = { + "name": "New Teacher Source", + "url": "https://new.example.com", + } + resp = self.client.post( + "/api/admin/sources", + data=json.dumps(payload), + content_type="application/json", + **self._auth(), + ) + self.assertEqual(resp.status_code, 201) + data = json.loads(resp.content) + self.assertEqual(data["organization_id"], str(self.own_org.id)) + import uuid as _uuid + _uuid.UUID(data["key"]) # auto-generated key must be a valid UUID + src = ScrapingSource.objects.get(key=data["key"]) + self.assertEqual(str(src.organization_id), str(self.own_org.id)) + + def test_teacher_can_toggle_llm_fallback_own_source(self): + resp = self.client.patch( + f"/api/admin/sources/{self.own_source.key}", + data=json.dumps({"llm_fallback_enabled": False}), + content_type="application/json", + **self._auth(), + ) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.content) + self.assertFalse(data["llm_fallback_enabled"]) + self.own_source.refresh_from_db() + self.assertFalse(self.own_source.llm_fallback_enabled) + + def test_teacher_cannot_change_source_organization(self): + resp = self.client.patch( + f"/api/admin/sources/{self.own_source.key}", + data=json.dumps({"organization_id": str(self.other_org.id)}), + content_type="application/json", + **self._auth(), + ) + self.assertEqual(resp.status_code, 200) + self.own_source.refresh_from_db() + self.assertEqual(str(self.own_source.organization_id), str(self.own_org.id)) + + def test_teacher_cannot_access_other_org_source_detail(self): + resp = self.client.get( + f"/api/admin/sources/{self.other_source.key}", **self._auth() + ) + self.assertEqual(resp.status_code, 404) + + def test_teacher_cannot_patch_other_org_source(self): + resp = self.client.patch( + f"/api/admin/sources/{self.other_source.key}", + data=json.dumps({"name": "Hacked"}), + content_type="application/json", + **self._auth(), + ) + self.assertEqual(resp.status_code, 404) + + def test_teacher_cannot_delete_other_org_source(self): + resp = self.client.delete( + f"/api/admin/sources/{self.other_source.key}", **self._auth() + ) + self.assertEqual(resp.status_code, 404) + self.assertTrue(ScrapingSource.objects.filter(key="other_src").exists()) + + def test_teacher_can_delete_own_org_source(self): + resp = self.client.delete( + f"/api/admin/sources/{self.own_source.key}", **self._auth() + ) + self.assertEqual(resp.status_code, 204) + self.assertFalse(ScrapingSource.objects.filter(key="own_src").exists()) + + def test_teacher_with_no_org_cannot_create_source(self): + teacher_no_org, pw = _make_teacher(username="teacher_no_org", password="Teacher123!") + token = _token(self.client, "teacher_no_org", pw) + resp = self.client.post( + "/api/admin/sources", + data=json.dumps({"name": "New Source", "url": "https://new.example.com"}), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + self.assertEqual(resp.status_code, 403) + data = json.loads(resp.content) + self.assertIn("No organization linked", data["detail"]) + + def test_patch_invalid_target_profile_returns_400(self): + resp = self.client.patch( + f"/api/admin/sources/{self.own_source.key}", + data=json.dumps({"target_profile": "invalid_profile"}), + content_type="application/json", + **self._auth(), + ) + self.assertEqual(resp.status_code, 400) + data = json.loads(resp.content) + self.assertIn("target_profile", data["detail"]) + + def test_company_cannot_access_sources(self): + company, company_pw = _make_company() + company_token = _token(self.client, company.username, company_pw) + resp = self.client.get("/api/admin/sources", HTTP_AUTHORIZATION=f"Bearer {company_token}") + self.assertEqual(resp.status_code, 403) diff --git a/backend/content/views/sources.py b/backend/content/views/sources.py index d323e26..7e6bf74 100644 --- a/backend/content/views/sources.py +++ b/backend/content/views/sources.py @@ -1,18 +1,21 @@ import json +import uuid from django.http import HttpResponse, JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from content.auth import require_auth -from content.models import Organization, ScrapingSource, User +from content.models import Organization, ScrapingSource, User, UserOrganization ADMIN_PROFILE = User.ProfileType.ADMIN +OFFER_MANAGER_PROFILES = [User.ProfileType.TEACHER] +VALID_TARGET_PROFILES = {'student', 'company', 'researcher'} ALLOWED_FIELDS = { "name", "url", "target_profile", "country", "domain_names", "interval_minutes", "llm_fallback_enabled", - "enabled", "quality", "crawl_depth", "crawl_max_pages", + "enabled", "crawl_depth", "crawl_max_pages", "crawl_match_patterns", "crawl_exclude_patterns", "auto_publish_enabled", } @@ -50,11 +53,16 @@ def _serialize(s: ScrapingSource) -> dict: @csrf_exempt -@require_auth(roles=[ADMIN_PROFILE]) +@require_auth(roles=[ADMIN_PROFILE, *OFFER_MANAGER_PROFILES]) @require_http_methods(["GET", "POST"]) def admin_sources_collection(request): if request.method == "GET": sources = ScrapingSource.objects.select_related("organization").all().order_by("key") + if request.auth_user.profile in OFFER_MANAGER_PROFILES: + org_ids = UserOrganization.objects.filter( + user=request.auth_user + ).values_list('organization_id', flat=True) + sources = sources.filter(organization_id__in=org_ids) return JsonResponse({"count": sources.count(), "results": [_serialize(s) for s in sources[:500]]}) # POST — create @@ -62,18 +70,30 @@ def admin_sources_collection(request): if body is None: return JsonResponse({"detail": "Invalid JSON body."}, status=400) - key = str(body.get("key") or "").strip() + key = str(body.get("key") or "").strip() or str(uuid.uuid4()) name = str(body.get("name") or "").strip() url = str(body.get("url") or "").strip() - org_id_str = str(body.get("organization_id") or "").strip() - - if not all([key, name, url, org_id_str]): - return JsonResponse({"detail": "key, name, url, and organization_id are required."}, status=400) - - try: - org = Organization.objects.get(id=org_id_str) - except (Organization.DoesNotExist, ValueError): - return JsonResponse({"detail": "Organization not found."}, status=404) + tp = str(body.get("target_profile") or "student") + if tp not in VALID_TARGET_PROFILES: + return JsonResponse({"detail": f"target_profile must be one of: {', '.join(sorted(VALID_TARGET_PROFILES))}."}, status=400) + + if request.auth_user.profile in OFFER_MANAGER_PROFILES: + if not all([name, url]): + return JsonResponse({"detail": "name and url are required."}, status=400) + link = UserOrganization.objects.filter( + user=request.auth_user + ).select_related('organization').first() + if not link: + return JsonResponse({"detail": "No organization linked to account."}, status=403) + org = link.organization + else: + org_id_str = str(body.get("organization_id") or "").strip() + if not all([name, url, org_id_str]): + return JsonResponse({"detail": "name, url, and organization_id are required."}, status=400) + try: + org = Organization.objects.get(id=org_id_str) + except (Organization.DoesNotExist, ValueError): + return JsonResponse({"detail": "Organization not found."}, status=404) if ScrapingSource.objects.filter(key=key).exists(): return JsonResponse({"detail": f"Source with key '{key}' already exists."}, status=409) @@ -83,14 +103,14 @@ def admin_sources_collection(request): name=name, url=url, organization=org, - organization_token=org_id_str, - target_profile=str(body.get("target_profile") or "student"), + organization_token=str(org.id), + target_profile=tp, country=str(body.get("country") or "").strip().upper(), domain_names=body.get("domain_names") or [], interval_minutes=int(body.get("interval_minutes") or 360), llm_fallback_enabled=bool(body.get("llm_fallback_enabled", True)), enabled=bool(body.get("enabled", True)), - quality=str(body.get("quality") or "real"), + quality="real", crawl_depth=int(body.get("crawl_depth") or 1), crawl_max_pages=int(body.get("crawl_max_pages") or 25), crawl_match_patterns=body.get("crawl_match_patterns") or [], @@ -102,7 +122,7 @@ def admin_sources_collection(request): @csrf_exempt -@require_auth(roles=[ADMIN_PROFILE]) +@require_auth(roles=[ADMIN_PROFILE, *OFFER_MANAGER_PROFILES]) @require_http_methods(["GET", "PATCH", "DELETE"]) def admin_source_detail(request, key: str): try: @@ -110,6 +130,13 @@ def admin_source_detail(request, key: str): except ScrapingSource.DoesNotExist: return JsonResponse({"detail": "Source not found."}, status=404) + if request.auth_user.profile in OFFER_MANAGER_PROFILES: + user_org_ids = list(UserOrganization.objects.filter( + user=request.auth_user + ).values_list('organization_id', flat=True)) + if source.organization_id not in user_org_ids: + return JsonResponse({"detail": "Not found."}, status=404) + if request.method == "GET": return JsonResponse(_serialize(source)) @@ -122,14 +149,26 @@ def admin_source_detail(request, key: str): if body is None: return JsonResponse({"detail": "Invalid JSON body."}, status=400) + # Offer managers cannot re-assign organization + if request.auth_user.profile in OFFER_MANAGER_PROFILES: + body.pop('organization_id', None) + + if "target_profile" in body: + tp = str(body["target_profile"]) + if tp not in VALID_TARGET_PROFILES: + return JsonResponse({"detail": f"target_profile must be one of: {', '.join(sorted(VALID_TARGET_PROFILES))}."}, status=400) + # Handle organization FK separately — not in the generic loop if "organization_id" in body: oid = body["organization_id"] if oid is None: source.organization = None + source.organization_token = "" else: try: - source.organization = Organization.objects.get(id=oid) + org = Organization.objects.get(id=oid) + source.organization = org + source.organization_token = str(org.id) except (Organization.DoesNotExist, ValueError): return JsonResponse({"detail": "Organization not found."}, status=404) diff --git a/backend/ui/src/app/app.component.html b/backend/ui/src/app/app.component.html index c80fc21..32aa813 100644 --- a/backend/ui/src/app/app.component.html +++ b/backend/ui/src/app/app.component.html @@ -20,6 +20,9 @@ Scrapper Tracking Bulk Import } + @if (auth.isTeacher) { + Sources + }
← Public Site diff --git a/backend/ui/src/app/app.component.spec.ts b/backend/ui/src/app/app.component.spec.ts index a8c8852..a92ce2b 100644 --- a/backend/ui/src/app/app.component.spec.ts +++ b/backend/ui/src/app/app.component.spec.ts @@ -30,6 +30,12 @@ describe('AppComponent', () => { auth.currentUser = { username: 'tester', email: 'tester@example.com', profile: 'Admin' }; }; + const enableTeacher = (): void => { + const auth = TestBed.inject(AuthService); + auth.loggedIn = true; + auth.currentUser = { username: 'teacher', email: 'teacher@example.com', profile: 'Teacher' }; + }; + it('should create the app', () => { const fixture = TestBed.createComponent(AppComponent); expect(fixture.componentInstance).toBeTruthy(); @@ -52,6 +58,27 @@ describe('AppComponent', () => { expect(hrefs).toContain('/admin/users'); }); + it('Teacher user sees Sources nav link', () => { + enableTeacher(); + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + http.match(() => true).forEach(r => r.flush({ count: 0, results: [] })); + const el = fixture.nativeElement as HTMLElement; + const hrefs = Array.from(el.querySelectorAll('a')).map(a => a.getAttribute('href')); + expect(hrefs).toContain('/admin/sources'); + }); + + it('Teacher user does not see admin-only nav links', () => { + enableTeacher(); + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + http.match(() => true).forEach(r => r.flush({ count: 0, results: [] })); + const el = fixture.nativeElement as HTMLElement; + const hrefs = Array.from(el.querySelectorAll('a')).map(a => a.getAttribute('href')); + expect(hrefs).not.toContain('/admin/users'); + expect(hrefs).not.toContain('/admin/organizations'); + }); + // ── Change password modal ────────────────────────────────────────────── describe('change password', () => { diff --git a/backend/ui/src/app/app.routes.ts b/backend/ui/src/app/app.routes.ts index aafe00a..3aef7a7 100644 --- a/backend/ui/src/app/app.routes.ts +++ b/backend/ui/src/app/app.routes.ts @@ -13,6 +13,7 @@ import { DomainsPageComponent } from './pages/domains-page.component'; import { authGuard } from './shared/auth.guard'; import { adminGuard } from './shared/admin.guard'; import { guestGuard } from './shared/guest.guard'; +import { offerManagerGuard } from './shared/offer-manager.guard'; export const routes: Routes = [ { @@ -78,7 +79,7 @@ export const routes: Routes = [ { path: 'admin/sources', component: SourcesAdminPageComponent, - canActivate: [adminGuard], + canActivate: [offerManagerGuard], }, { path: '**', diff --git a/backend/ui/src/app/pages/sources-admin-page.component.html b/backend/ui/src/app/pages/sources-admin-page.component.html index ab2137e..1f46af6 100644 --- a/backend/ui/src/app/pages/sources-admin-page.component.html +++ b/backend/ui/src/app/pages/sources-admin-page.component.html @@ -23,7 +23,6 @@

Scraping Sources

- @@ -35,7 +34,6 @@

Scraping Sources

- @@ -51,14 +49,14 @@

Scraping Sources

- +
Key Name Organization Country
{{ s.key }} {{ s.name }} {{ s.organization_name || '—' }} {{ s.country || '—' }} -
No sources configured.No sources configured.
@@ -73,10 +71,6 @@

{{ sourceModalTarget ? 'Edit Source' : 'Add Source' }}

{{ errorMessage }}
- -