From 74055ce141e6fa22c7606a8226d856c5b15598f2 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Sat, 18 Jul 2026 08:51:40 -0700 Subject: [PATCH] Add public read-only REST API at /api/v1/ (#1268) Adds a versioned, read-only DRF API over already-public content so external sites can treat this website as the source of truth instead of duplicating it. Two consumers drove the design: Project Sidewalk (grants/people/leadership/ publications for a project) and Jon's academic page (a recent-pubs widget). Endpoints (all GET-only, paginated with a tunable ?page_size, max 100): - /publications/ filterable by ?project, ?author, ?year, ?type, ?ordering (default newest-first) -- serves both ?author=jonfroehlich&page_size=5 and ?project=. - /projects/ (visible only), /grants/, /people/ (members only), plus project sub-resources: /projects//{publications,grants,people,leadership}. - leadership returns ALL lead roles for all time (current + past), grouped by type; it queries ProjectRole directly rather than reusing get_project_leadership() (which drops a person's past lead roles once they hold any active role). Design decisions (from #1268 scoping): DRF was already a bundled-but-unused dependency; public data so no auth and no throttle; cross-origin enabled on /api/ only via a tiny in-repo ApiCorsMiddleware (no django-cors-headers); absolute media/page URLs in every payload; Person.email deliberately not exposed. Projects gated to is_visible=True; people scoped to actual members (those with a Position). Code in website/api/ (serializers/views/urls/middleware), mounted by the root URLconf, configured by the REST_FRAMEWORK block in settings. Full reference in docs/API.md; pointer in CLAUDE.md. 23 integration tests in website/tests/test_api.py (full suite: 607 pass). Bumps version to 2.27.0. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Y9JS9XZfD86FdDLJiJFSBk --- CLAUDE.md | 17 +++ docs/API.md | 121 ++++++++++++++++ makeabilitylab/settings.py | 24 +++- makeabilitylab/urls.py | 4 + website/api/__init__.py | 17 +++ website/api/middleware.py | 43 ++++++ website/api/serializers.py | 283 +++++++++++++++++++++++++++++++++++++ website/api/urls.py | 30 ++++ website/api/views.py | 234 ++++++++++++++++++++++++++++++ website/tests/test_api.py | 241 +++++++++++++++++++++++++++++++ 10 files changed, 1012 insertions(+), 2 deletions(-) create mode 100644 docs/API.md create mode 100644 website/api/__init__.py create mode 100644 website/api/middleware.py create mode 100644 website/api/serializers.py create mode 100644 website/api/urls.py create mode 100644 website/api/views.py create mode 100644 website/tests/test_api.py diff --git a/CLAUDE.md b/CLAUDE.md index cfa79e7b..1b40f1cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,23 @@ Custom admin organization lives in `website/admin/admin_site.py` (`MakeabilityLa - `/media/publications/` is served by the custom `serve_pdf` view (not Django's static serve), which does **fuzzy filename matching** so stale external links to renamed PDFs still resolve. Don't replace it with a plain static route. - In `DEBUG=True`, `/media/...` is also served by Django's `serve()`. In production, the web server handles `/media/` directly. +### Public REST API (`website/api/`, #1268) + +A public, **read-only** DRF API at `/api/v1/` over already-public content +(publications, projects, grants, people, project leadership). Built on the +already-bundled `djangorestframework` (previously an unused dependency). Code +lives in the `website/api/` package (`serializers.py`, `views.py`, `urls.py`, +`middleware.py`), mounted by the **root** URLconf (`makeabilitylab/urls.py`), +configured by the `REST_FRAMEWORK` block in `settings.py`. GET-only, no auth, no +throttle (data is already public); paginated (`?page_size=`, max 100); every +payload uses absolute URLs. Cross-origin requests are allowed on `/api/` only +via the in-repo `ApiCorsMiddleware` (no `django-cors-headers` dependency). +`Person.email` is intentionally not serialized. Projects are gated to +`is_visible=True`; the people list is scoped to actual members (those with a +Position). When adding a resource, follow the existing viewset/serializer +pattern and keep `v1` fields additive-only (breaking changes → `v2`). Full +reference: `docs/API.md`. Tests: `website/tests/test_api.py`. + ### Settings, config, and environment - **Compose files per environment:** the servers run `docker-compose.yml` (test *and* prod — `makeabilitylabwebsite/rebuildanddeploy.sh` runs `docker compose up` with no `-f`, so it always picks the default `docker-compose.yml`; it only varies per-host env vars). Local dev runs `docker-compose-local-dev.yml` (passed explicitly with `-f`). `docker-compose-local-dev.yml` is **never** used on the servers. diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 00000000..20cd9c79 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,121 @@ +# Makeability Lab public REST API + +A **public, read-only** JSON API over the lab's already-public content +(publications, projects, grants, people, and project leadership). It lets +external sites treat this website as the source of truth instead of duplicating +content. Introduced in #1268. + +- **Base URL:** `https://makeabilitylab.cs.washington.edu/api/v1/` + (test server: `https://makeabilitylab-test.cs.washington.edu/api/v1/`) +- **Format:** JSON. Read-only — only `GET`/`HEAD`/`OPTIONS`. +- **Auth:** none. All data is already public on the site. +- **Cross-origin:** enabled (`Access-Control-Allow-Origin: *`) on `/api/` only, + so browser-side JavaScript can fetch it directly. +- **Versioned:** everything lives under `/api/v1/`. See *Stability contract*. + +Built on Django REST Framework. In local dev (`DEBUG=True`) the endpoints also +render a **browsable HTML API** — just open them in a browser. + +## Pagination + +List endpoints are paginated (page-number style): + +```json +{ "count": 157, "next": "...?page=2", "previous": null, "results": [ ... ] } +``` + +- `?page=` — page number. +- `?page_size=` — items per page (default **25**, max **100**). + +A "top 5 most recent" list is just `?page_size=5` on an endpoint whose default +order is newest-first. + +## Endpoints + +### Publications — `GET /api/v1/publications/` + +Default order: **newest first** (`-date`). Optional, combinable filters: + +| Param | Example | Meaning | +|-------------|------------------------|---------------------------------------| +| `project` | `?project=sidewalk` | Publications attached to a project (by `short_name`). | +| `author` | `?author=jonfroehlich` | Publications by a person (by `url_name`). | +| `year` | `?year=2024` | Publications in a calendar year. | +| `type` | `?type=Conference` | By venue type (`Conference`, `Journal`, `Poster`, …). | +| `ordering` | `?ordering=title` | One of `date`, `-date`, `title`, `-title`. | + +`GET /api/v1/publications//` adds a formatted `citation_html` and raw +`bibtex`, plus `book_title`, `publisher`, `isbn`, `num_pages`, `peer_reviewed`. + +**Example — a "Recent Publications" widget** (client-side, e.g. on an academic +page): + +```js +const r = await fetch( + "https://makeabilitylab.cs.washington.edu/api/v1/publications/" + + "?author=jonfroehlich&page_size=5" +); +const { results } = await r.json(); +results.forEach(p => { + // p.title, p.year, p.forum_name, p.authors[].name, p.pdf_url, p.official_url +}); +``` + +### Projects — `GET /api/v1/projects/` + +Only **publicly visible** projects (`is_visible=True`). Detail and +sub-resources are keyed by `short_name`: + +- `GET /api/v1/projects//` — summary, about, website, dates, + keywords, umbrellas, thumbnail. +- `GET /api/v1/projects//publications/` — the project's pubs. +- `GET /api/v1/projects//grants/` — grants funding the project. +- `GET /api/v1/projects//people/` — everyone with a role on the + project, each as a `{ person, role, lead_project_role, start_date, end_date, + is_active }` record (a person may appear more than once for multiple roles). +- `GET /api/v1/projects//leadership/` — **all** leadership across + all time (current *and* past), grouped: + `{ pis, co_pis, student_leads, postdoc_leads, research_scientist_leads }`, + each a list of role records ordered newest-start first. A person appears once + per lead role they've held (so a past student lead who later became PI shows + up in both). Each record's `is_active` flag lets you separate current from + past leadership. + +### Grants — `GET /api/v1/grants/` + +Filters: `?project=`, `?sponsor=`. Each grant +includes its `sponsor`, `funding_amount`, `grant_id`, `grant_url`, and the +`projects` it funds. + +### People — `GET /api/v1/people/` + +Actual lab members (people with at least one Position); external co-authors are +not listed here even though they appear as publication `authors`. Detail by +`url_name`: `GET /api/v1/people//` — name, current title, bio, +thumbnail, and public social/web links (ORCID, Google Scholar, GitHub, etc.). + +> **Note:** `email` is intentionally **not** exposed by the API to avoid making +> it an email-harvesting surface, even where it appears on a member page. + +## Stability contract + +- **`v1` fields are additive-only.** New fields may be added; existing field + names and meanings will not change or be removed within `v1`. Breaking changes + ship as `/api/v2/`. +- Don't hardcode pagination page sizes as a proxy for "all" — page through + `next`, or set `page_size` explicitly (≤100). +- URLs in responses (PDFs, thumbnails, page links) are absolute and safe to use + directly. + +## Implementation notes (for maintainers) + +Code lives in `website/api/` (`serializers.py`, `views.py`, `urls.py`, +`middleware.py`), mounted at `/api/` by the root URLconf +(`makeabilitylab/urls.py`). Config is the `REST_FRAMEWORK` block in +`settings.py`. CORS is a tiny in-repo middleware +(`website.api.middleware.ApiCorsMiddleware`), scoped to `/api/`, rather than a +third-party package. Tests: `website/tests/test_api.py`. + +**Deliberately deferred** (add on the same pattern when needed): write +endpoints, auth / API keys, request throttling, and Talks/Posters/Videos +resources. diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py index b82d0ec8..c57556d1 100644 --- a/makeabilitylab/settings.py +++ b/makeabilitylab/settings.py @@ -86,8 +86,8 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # Makeability Lab Global Variables, including Makeability Lab version -ML_WEBSITE_VERSION = "2.26.0" # Keep this updated with each release and also change the short description below -ML_WEBSITE_VERSION_DESCRIPTION = "Improves image handling on the Awards admin badge plus a Data Health polish. The badge field now has an instant client-side preview and square (1:1) cropping via Cropper.js (#1408), and a new 'Pad badge to a square (don't crop)' option (#1410) that pads a non-square upload to a centered square -- white margins for JPEG, transparent for PNG/WebP -- instead of cropping off content, so editors no longer need to pad logos in an external tool before uploading. Padding is done server-side with Pillow: it re-encodes JPEG at quality 92, saves WebP lossless so a lossless source isn't degraded, and leaves already-square uploads untouched; a full-image crop box is stored so the public render isn't cropped. Also standardizes the per-row action links across the Data Health checks (#1405)." +ML_WEBSITE_VERSION = "2.27.0" # Keep this updated with each release and also change the short description below +ML_WEBSITE_VERSION_DESCRIPTION = "Adds a public, read-only REST API (#1268) at /api/v1/ so external sites can treat the Makeability Lab website as the source of truth for already-public content instead of duplicating it. Endpoints cover publications (filterable by project, author, year, and venue type -- e.g. ?author=jonfroehlich&page_size=5 for a 'recent publications' widget), publicly-visible projects, grants, and people, plus project sub-resources for a project's publications, grants, people, and leadership (PIs/Co-PIs/leads) -- the exact data Project Sidewalk needs to render its funding, team, and papers from one place. Built on the already-bundled Django REST Framework: read-only (GET only), no auth and no throttle since the data is already public, paginated with a tunable page_size (max 100), absolute media/page URLs in every payload, and cross-origin requests enabled on /api/ only (via a tiny in-repo CORS middleware) so a browser-side widget can fetch it directly. Personal email is deliberately not exposed. Full reference: docs/API.md." DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed MAX_BANNERS = 7 # Maximum number of banners on a page @@ -259,8 +259,28 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + + # Adds permissive CORS headers to /api/ responses only (#1268). Read-only, + # already-public data -- see website/api/middleware.py. + 'website.api.middleware.ApiCorsMiddleware', ] +# Django REST Framework config for the public read-only API (#1268). +# Public data, so no auth and no throttle (per the #1268 scoping decision); the +# browsable HTML API is enabled only in DEBUG (JSON-only in prod). +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [], + 'DEFAULT_PERMISSION_CLASSES': ['rest_framework.permissions.AllowAny'], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 25, + 'DEFAULT_RENDERER_CLASSES': ( + ['rest_framework.renderers.JSONRenderer', + 'rest_framework.renderers.BrowsableAPIRenderer'] + if DEBUG else + ['rest_framework.renderers.JSONRenderer'] + ), +} + # A string representing the full Python import path to your root URLconf. # See: https://docs.djangoproject.com/en/4.2/ref/settings/#root-urlconf ROOT_URLCONF = 'makeabilitylab.urls' diff --git a/makeabilitylab/urls.py b/makeabilitylab/urls.py index 71234640..620cd74a 100644 --- a/makeabilitylab/urls.py +++ b/makeabilitylab/urls.py @@ -50,6 +50,10 @@ # the top-level ./robots.txt to change crawler rules or the Sitemap line. path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'), + # Public read-only REST API (#1268). Declared before the website.urls + # include so the app's catch-all patterns can't shadow /api/. + path('api/', include('website.api.urls')), + #Info on how to route root to website was found here http://stackoverflow.com/questions/7580220/django-urls-howto-map-root-to-app re_path(r'', include('website.urls')), # re_path(r'^admin/', admin.site.urls), diff --git a/website/api/__init__.py b/website/api/__init__.py new file mode 100644 index 00000000..2473d08d --- /dev/null +++ b/website/api/__init__.py @@ -0,0 +1,17 @@ +""" +Public, read-only REST API for the Makeability Lab website (#1268). + +Exposes already-public data (publications, projects, grants, people, and +project leadership) in a machine-readable, versioned form so external consumers +can treat this site as the source of truth instead of duplicating content. Two +concrete consumers drove the design: Project Sidewalk (grants / people / +leadership / publications for a project) and Jon's academic page (a "recent +publications" list). + +Design summary (see docs/API.md for the full contract): + * Django REST Framework, mounted at ``/api/v1/``. + * Read-only (GET/HEAD/OPTIONS), no auth, no throttle -- the data is already + public on the site, so nothing new is disclosed. + * Cross-origin browser requests are allowed via ``ApiCorsMiddleware`` (scoped + to ``/api/``) so a client-side widget can fetch it directly. +""" diff --git a/website/api/middleware.py b/website/api/middleware.py new file mode 100644 index 00000000..33d1a592 --- /dev/null +++ b/website/api/middleware.py @@ -0,0 +1,43 @@ +""" +Minimal CORS support for the public API (#1268), scoped to ``/api/`` only. + +The API is read-only and serves data that's already public, so a permissive +``Access-Control-Allow-Origin: *`` is safe and lets a client-side widget (e.g. a +"recent publications" list on an external academic page) fetch it directly from +the browser. We deliberately don't pull in ``django-cors-headers`` for this -- +the surface is one GET-only path prefix. + +Only the ``/api/`` prefix gets these headers; the rest of the site is untouched +(no cross-origin exposure of admin, forms, etc.). +""" + +API_PREFIX = "/api/" + + +class ApiCorsMiddleware: + """Add permissive CORS headers to ``/api/`` responses and answer preflight. + + A browser preflights a cross-origin request with ``OPTIONS``; we short- + circuit that with a 200 + the CORS headers so the real GET is allowed. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + is_api = request.path.startswith(API_PREFIX) + + if is_api and request.method == "OPTIONS": + from django.http import HttpResponse + + response = HttpResponse(status=200) + else: + response = self.get_response(request) + + if is_api: + response["Access-Control-Allow-Origin"] = "*" + response["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS" + response["Access-Control-Allow-Headers"] = "Accept, Content-Type" + response["Access-Control-Max-Age"] = "86400" + + return response diff --git a/website/api/serializers.py b/website/api/serializers.py new file mode 100644 index 00000000..a95ecf70 --- /dev/null +++ b/website/api/serializers.py @@ -0,0 +1,283 @@ +""" +DRF serializers for the public read-only API (#1268). + +These deliberately expose only fields that are already public on the website and +build **absolute** URLs for media (PDFs, thumbnails) and human-facing pages, so a +consumer gets click-through links rather than bare relative paths. Personal +contact details (e.g. ``Person.email``) are intentionally *not* serialized to +avoid turning the API into an email-harvesting surface, even where they appear on +a member page. + +Existing model helpers are reused rather than re-deriving formatting: +``Person.get_full_name`` / ``get_current_title``, ``Publication`` citation +helpers, ``Project.get_display_short_name``, ``Grant.start_date`` / ``grant_url``. +""" + +from django.urls import NoReverseMatch, reverse +from rest_framework import serializers + +from website.models import Grant, Person, Project, ProjectRole, Publication + + +def abs_media_url(request, filefield): + """Return an absolute URL for a File/ImageField, or ``None`` if unset. + + ``filefield.url`` raises ``ValueError`` when the field has no file, so we + guard that and fall back to a relative URL when there's no request in + context (e.g. serializing outside a request cycle). + """ + if not filefield: + return None + try: + url = filefield.url + except ValueError: + return None + return request.build_absolute_uri(url) if request is not None else url + + +def abs_page_url(request, url_name, *args): + """Absolute URL for a named route, tolerant of reverse failures. + + Returns ``None`` rather than raising if the route can't be reversed (e.g. a + slug containing characters the URL pattern doesn't accept), so one odd row + never 500s a whole list response. + """ + try: + path = reverse(url_name, args=args) + except NoReverseMatch: + return None + return request.build_absolute_uri(path) if request is not None else path + + +class PersonSummarySerializer(serializers.ModelSerializer): + """Compact person representation, used when nested in publications/roles.""" + + name = serializers.SerializerMethodField() + url = serializers.SerializerMethodField() + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = Person + fields = ["id", "url_name", "name", "url", "thumbnail"] + + def get_name(self, obj): + return obj.get_full_name() + + def get_url(self, obj): + return abs_page_url( + self.context.get("request"), "website:member_by_name", obj.url_name + ) + + def get_thumbnail(self, obj): + return abs_media_url(self.context.get("request"), obj.image) + + +class PersonSerializer(PersonSummarySerializer): + """Full person representation for the people detail/list endpoints. + + Extends the summary with bio, current title, and the public social/web + links. ``email`` is intentionally omitted (see module docstring). + """ + + current_title = serializers.SerializerMethodField() + + class Meta(PersonSummarySerializer.Meta): + fields = PersonSummarySerializer.Meta.fields + [ + "first_name", + "middle_name", + "last_name", + "current_title", + "bio", + "personal_website", + "github", + "twitter", + "bluesky", + "threads", + "mastodon", + "linkedin", + "orcid", + "google_scholar", + ] + + def get_current_title(self, obj): + # Person.get_current_title is a cached_property, not a method. + return obj.get_current_title + + +class ProjectSummarySerializer(serializers.ModelSerializer): + """Compact project representation, used when nested in publications/grants.""" + + name = serializers.CharField(read_only=True) + display_short_name = serializers.SerializerMethodField() + url = serializers.SerializerMethodField() + + class Meta: + model = Project + fields = ["id", "short_name", "name", "display_short_name", "url"] + + def get_display_short_name(self, obj): + return obj.get_display_short_name() + + def get_url(self, obj): + return abs_page_url( + self.context.get("request"), "website:project", obj.short_name + ) + + +class ProjectSerializer(ProjectSummarySerializer): + """Full project representation for the projects detail/list endpoints.""" + + thumbnail = serializers.SerializerMethodField() + keywords = serializers.SerializerMethodField() + project_umbrellas = serializers.SerializerMethodField() + + class Meta(ProjectSummarySerializer.Meta): + fields = ProjectSummarySerializer.Meta.fields + [ + "summary", + "about", + "start_date", + "end_date", + "website", + "data_url", + "featured_code_repo_url", + "thumbnail", + "keywords", + "project_umbrellas", + ] + + def get_thumbnail(self, obj): + return abs_media_url(self.context.get("request"), obj.gallery_image) + + def get_keywords(self, obj): + return [kw.keyword for kw in obj.keywords.all()] + + def get_project_umbrellas(self, obj): + return [umb.name for umb in obj.project_umbrellas.all()] + + +class SponsorSummarySerializer(serializers.Serializer): + """Minimal sponsor info nested inside a grant.""" + + name = serializers.CharField() + short_name = serializers.CharField() + + +class GrantSerializer(serializers.ModelSerializer): + """A funding grant. ``start_date`` and ``grant_url`` are model properties + aliasing the shared Artifact ``date`` / ``forum_url`` fields.""" + + sponsor = SponsorSummarySerializer(read_only=True) + grant_url = serializers.URLField(read_only=True) + start_date = serializers.DateField(read_only=True) + projects = ProjectSummarySerializer(many=True, read_only=True) + + class Meta: + model = Grant + fields = [ + "id", + "title", + "sponsor", + "grant_id", + "funding_amount", + "grant_url", + "start_date", + "end_date", + "projects", + ] + + +class PublicationListSerializer(serializers.ModelSerializer): + """List representation of a publication. + + ``authors`` preserves the editor-defined order (SortedManyToManyField). + ``forum_name`` is the formatted "Proceedings of …" string, not the raw + field. Media links are absolute. + """ + + authors = PersonSummarySerializer(many=True, read_only=True) + projects = ProjectSummarySerializer(many=True, read_only=True) + year = serializers.SerializerMethodField() + venue_type = serializers.CharField(source="pub_venue_type", read_only=True) + forum_name = serializers.SerializerMethodField() + pdf_url = serializers.SerializerMethodField() + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = Publication + fields = [ + "id", + "title", + "authors", + "date", + "year", + "venue_type", + "forum_name", + "forum_url", + "doi", + "official_url", + "arxiv_url", + "code_repo_url", + "award", + "pdf_url", + "thumbnail", + "projects", + ] + + def get_year(self, obj): + return obj.date.year if obj.date else None + + def get_forum_name(self, obj): + return obj.get_formatted_forum_name() + + def get_pdf_url(self, obj): + return abs_media_url(self.context.get("request"), obj.pdf_file) + + def get_thumbnail(self, obj): + return abs_media_url(self.context.get("request"), obj.thumbnail) + + +class PublicationDetailSerializer(PublicationListSerializer): + """Detail representation: adds a formatted citation and BibTeX.""" + + citation_html = serializers.SerializerMethodField() + bibtex = serializers.SerializerMethodField() + + class Meta(PublicationListSerializer.Meta): + fields = PublicationListSerializer.Meta.fields + [ + "book_title", + "publisher", + "isbn", + "num_pages", + "peer_reviewed", + "citation_html", + "bibtex", + ] + + def get_citation_html(self, obj): + return obj.get_citation_as_html() + + def get_bibtex(self, obj): + # Plain newlines + no HTML hyperlinks: consumers want raw BibTeX, not + # the HTML-decorated variant used on the site. + return obj.get_citation_as_bibtex(newline="\n", use_hyperlinks=False) + + +class ProjectRoleSerializer(serializers.ModelSerializer): + """A person's role on a project (start/end, lead type, active flag).""" + + person = PersonSummarySerializer(read_only=True) + is_active = serializers.SerializerMethodField() + + class Meta: + model = ProjectRole + fields = [ + "person", + "role", + "lead_project_role", + "start_date", + "end_date", + "is_active", + ] + + def get_is_active(self, obj): + return obj.is_active() diff --git a/website/api/urls.py b/website/api/urls.py new file mode 100644 index 00000000..0920c07b --- /dev/null +++ b/website/api/urls.py @@ -0,0 +1,30 @@ +""" +URL routing for the public API (#1268), mounted at ``/api/`` by the root +URLconf. Versioned under ``v1/`` so the contract can evolve without breaking +consumers. + +The DRF ``DefaultRouter`` also serves a self-documenting API root at +``/api/v1/`` listing every endpoint, and (in DEBUG) a browsable HTML UI. +""" + +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ( + GrantViewSet, + PersonViewSet, + ProjectViewSet, + PublicationViewSet, +) + +app_name = "api" + +router = DefaultRouter() +router.register(r"publications", PublicationViewSet, basename="publication") +router.register(r"people", PersonViewSet, basename="person") +router.register(r"projects", ProjectViewSet, basename="project") +router.register(r"grants", GrantViewSet, basename="grant") + +urlpatterns = [ + path("v1/", include(router.urls)), +] diff --git a/website/api/views.py b/website/api/views.py new file mode 100644 index 00000000..39cd2ab4 --- /dev/null +++ b/website/api/views.py @@ -0,0 +1,234 @@ +""" +Read-only DRF viewsets for the public API (#1268). + +All viewsets are ``ReadOnlyModelViewSet`` (list + retrieve only). Filtering is +done with explicit query params in ``get_queryset`` rather than a filter library +to avoid a new dependency. Querysets ``prefetch_related`` / ``select_related`` +the relations the serializers touch, per the repo's N+1 notes. + +Visibility: projects are gated to ``is_visible=True`` (so unpublished projects +404), and the people list is scoped to actual lab members (people with at least +one Position), not every co-author in the database. +""" + +from rest_framework.decorators import action +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response +from rest_framework.viewsets import ReadOnlyModelViewSet + +from website.models import Grant, Person, Project, ProjectRole, Publication +from website.models.project_role import LeadProjectRoleTypes + +from .serializers import ( + GrantSerializer, + ProjectRoleSerializer, + ProjectSerializer, + PersonSerializer, + PublicationDetailSerializer, + PublicationListSerializer, +) + +# Ordering values we accept on ?ordering= for publications. Whitelisted so a +# consumer can't order by (and thereby probe) arbitrary columns. +_PUB_ORDERING = {"date", "-date", "title", "-title"} + +# Maps each lead-role type to its response key in the leadership endpoint. +# Ordered so the response keys are stable/predictable. +_LEAD_BUCKETS = { + LeadProjectRoleTypes.PI: "pis", + LeadProjectRoleTypes.CO_PI: "co_pis", + LeadProjectRoleTypes.STUDENT_LEAD: "student_leads", + LeadProjectRoleTypes.POSTDOC_LEAD: "postdoc_leads", + LeadProjectRoleTypes.RESEARCH_SCIENTIST_LEAD: "research_scientist_leads", +} + + +class ApiPagination(PageNumberPagination): + """Page-number pagination with a caller-tunable, capped page size. + + ``?page_size=5`` is what powers a "top 5 recent" list; ``max_page_size`` + stops a caller from requesting an unbounded page. Default page size comes + from ``settings.REST_FRAMEWORK['PAGE_SIZE']``. + """ + + page_size_query_param = "page_size" + max_page_size = 100 + + +class _PaginatedActionMixin: + """Helper so ``@action`` sub-resources paginate like top-level lists.""" + + def _paginated(self, queryset, serializer_cls): + page = self.paginate_queryset(queryset) + context = self.get_serializer_context() + if page is not None: + data = serializer_cls(page, many=True, context=context).data + return self.get_paginated_response(data) + data = serializer_cls(queryset, many=True, context=context).data + return Response(data) + + +class PublicationViewSet(ReadOnlyModelViewSet): + """Publications, newest first. + + Filters (all optional, combinable): + * ``?project=`` -- pubs attached to a project. + * ``?author=`` -- pubs by a person. + * ``?year=`` -- pubs in a calendar year. + * ``?type=`` -- by ``pub_venue_type`` (e.g. Conference). + * ``?ordering=`` one of ``date``/``-date``/``title``/``-title`` (default ``-date``). + + Powers both driving use cases: ``?author=jonfroehlich&page_size=5`` (recent + pubs widget) and ``?project=projectsidewalk`` (a project's publications). + """ + + pagination_class = ApiPagination + + def get_serializer_class(self): + if self.action == "retrieve": + return PublicationDetailSerializer + return PublicationListSerializer + + def get_queryset(self): + qs = Publication.objects.prefetch_related("authors", "projects") + params = self.request.query_params + + project = params.get("project") + if project: + qs = qs.filter(projects__short_name__iexact=project) + + author = params.get("author") + if author: + qs = qs.filter(authors__url_name__iexact=author) + + year = params.get("year") + if year and year.isdigit(): + qs = qs.filter(date__year=int(year)) + + venue = params.get("type") + if venue: + qs = qs.filter(pub_venue_type__iexact=venue) + + ordering = params.get("ordering") + ordering = ordering if ordering in _PUB_ORDERING else "-date" + return qs.order_by(ordering).distinct() + + +class PersonViewSet(ReadOnlyModelViewSet): + """Lab members (people with at least one Position), looked up by ``url_name``.""" + + serializer_class = PersonSerializer + pagination_class = ApiPagination + lookup_field = "url_name" + + def get_queryset(self): + return ( + Person.objects.filter(position__isnull=False) + .distinct() + .order_by("last_name", "first_name") + ) + + +class GrantViewSet(ReadOnlyModelViewSet): + """Funding grants, newest first. + + Filters: ``?project=``, ``?sponsor=``. + """ + + serializer_class = GrantSerializer + pagination_class = ApiPagination + + def get_queryset(self): + qs = Grant.objects.select_related("sponsor").prefetch_related("projects") + project = self.request.query_params.get("project") + if project: + qs = qs.filter(projects__short_name__iexact=project) + sponsor = self.request.query_params.get("sponsor") + if sponsor: + qs = qs.filter(sponsor__short_name__iexact=sponsor) + return qs.order_by("-date").distinct() + + +class ProjectViewSet(_PaginatedActionMixin, ReadOnlyModelViewSet): + """Publicly visible projects, looked up by ``short_name``. + + Sub-resources (Project Sidewalk's needs): + * ``/{short_name}/publications/`` -- the project's publications. + * ``/{short_name}/grants/`` -- grants funding the project. + * ``/{short_name}/people/`` -- everyone with a role, plus their role. + * ``/{short_name}/leadership/`` -- all-time PIs / Co-PIs / leads (current + past). + """ + + serializer_class = ProjectSerializer + pagination_class = ApiPagination + lookup_field = "short_name" + + def get_queryset(self): + return ( + Project.objects.filter(is_visible=True) + .prefetch_related("keywords", "project_umbrellas") + .order_by("name") + ) + + @action(detail=True, methods=["get"]) + def publications(self, request, short_name=None): + project = self.get_object() + qs = ( + Publication.objects.filter(projects=project) + .prefetch_related("authors", "projects") + .order_by("-date") + .distinct() + ) + return self._paginated(qs, PublicationListSerializer) + + @action(detail=True, methods=["get"]) + def grants(self, request, short_name=None): + project = self.get_object() + qs = ( + project.grant_set.select_related("sponsor") + .prefetch_related("projects") + .order_by("-date") + ) + return self._paginated(qs, GrantSerializer) + + @action(detail=True, methods=["get"]) + def people(self, request, short_name=None): + # Returns ProjectRoles (a person can hold more than one role, each with + # its own date range), not distinct people. + project = self.get_object() + qs = ( + ProjectRole.objects.filter(project=project) + .select_related("person") + .order_by("person__last_name", "person__first_name", "start_date") + ) + return self._paginated(qs, ProjectRoleSerializer) + + @action(detail=True, methods=["get"]) + def leadership(self, request, short_name=None): + # Returns *all* leadership roles for all time -- current and past -- + # grouped by lead type. We query ProjectRole directly rather than reuse + # Project.get_project_leadership(): that helper is built for the public + # page's current-vs-past display and computes "inactive" per *person*, + # so it drops a person's past lead roles once they hold any active role. + # Each returned role carries its own is_active flag, so a consumer can + # still separate current from past leadership client-side. + project = self.get_object() + roles = ( + ProjectRole.objects.filter( + project=project, lead_project_role__in=list(_LEAD_BUCKETS) + ) + .select_related("person") + .order_by("-start_date") + ) + context = self.get_serializer_context() + + grouped = {key: [] for key in _LEAD_BUCKETS.values()} + for role in roles: + grouped[_LEAD_BUCKETS[role.lead_project_role]].append(role) + + return Response( + { + key: ProjectRoleSerializer(items, many=True, context=context).data + for key, items in grouped.items() + } + ) diff --git a/website/tests/test_api.py b/website/tests/test_api.py new file mode 100644 index 00000000..83c58e71 --- /dev/null +++ b/website/tests/test_api.py @@ -0,0 +1,241 @@ +""" +Integration tests for the public read-only REST API (#1268). + +Exercises the real URL/view/serializer stack through Django's test client so the +API contract (endpoints, filters, pagination, visibility gating, absolute URLs, +CORS) is pinned against regressions. Uses the shared DatabaseTestCase fixtures +plus a few direct model creates for the relationships the factories don't cover +(Position, Sponsor/Grant, ProjectRole leadership). +""" + +from datetime import date + +from website.models import Grant, Position, ProjectRole, Sponsor +from website.models.position import Title +from website.models.project_role import LeadProjectRoleTypes +from website.models.publication import PubType +from website.tests.base import DatabaseTestCase + + +class ApiTestCase(DatabaseTestCase): + def setUp(self): + # A visible project (Project Sidewalk) and a hidden one. + self.project = self.make_project( + name="Project Sidewalk", short_name="projectsidewalk", is_visible=True + ) + self.hidden_project = self.make_project( + name="Secret Project", short_name="secretproj", is_visible=False + ) + + # Jon is a lab member (has a Position) and PI on the project. + self.jon = self.make_person(first_name="Jon", last_name="Froehlich") + Position.objects.create( + person=self.jon, start_date=date(2012, 1, 1), title=Title.FULL_PROF + ) + ProjectRole.objects.create( + person=self.jon, + project=self.project, + start_date=date(2012, 1, 1), + lead_project_role=LeadProjectRoleTypes.PI, + ) + # Jon also held a *past* (ended) Co-PI role. Even though he's currently + # an active PI, this past lead role must still surface in leadership -- + # the case Project.get_project_leadership() drops (per-person "inactive"). + ProjectRole.objects.create( + person=self.jon, + project=self.project, + start_date=date(2010, 1, 1), + end_date=date(2011, 12, 31), + lead_project_role=LeadProjectRoleTypes.CO_PI, + ) + # A person who was only ever a past student lead (role ended). + self.past_lead = self.make_person(first_name="Past", last_name="Lead") + ProjectRole.objects.create( + person=self.past_lead, + project=self.project, + start_date=date(2013, 1, 1), + end_date=date(2016, 1, 1), + lead_project_role=LeadProjectRoleTypes.STUDENT_LEAD, + ) + + # An external co-author with NO Position -> should not appear in /people/. + self.coauthor = self.make_person(first_name="Ext", last_name="Author") + + # Six conference pubs authored by Jon and attached to the project + # (years 2018..2023), plus one unrelated journal pub by the co-author. + self.project_pubs = [] + for year in range(2018, 2024): + pub = self.make_publication( + title=f"Sidewalk Paper {year}", year=year, authors=[self.jon] + ) + pub.projects.add(self.project) + self.project_pubs.append(pub) + + self.other_pub = self.make_publication( + title="Unrelated Journal Paper", + year=2024, + authors=[self.coauthor], + pub_venue_type=PubType.JOURNAL, + ) + + # A grant funding the project. + self.sponsor = Sponsor.objects.create(name="National Science Foundation", + short_name="NSF") + self.grant = Grant.objects.create( + title="NSF Award for Sidewalk", + sponsor=self.sponsor, + date=date(2015, 1, 1), + funding_amount=500000, + grant_id="1302338", + ) + self.grant.projects.add(self.project) + + # ---- publications list: filtering, ordering, pagination ----------------- + + def test_publications_list_returns_all(self): + resp = self.client.get("/api/v1/publications/") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["count"], 7) + + def test_publications_pagination_page_size(self): + resp = self.client.get("/api/v1/publications/?page_size=5") + body = resp.json() + self.assertEqual(body["count"], 7) + self.assertEqual(len(body["results"]), 5) + + def test_publications_default_ordering_newest_first(self): + results = self.client.get("/api/v1/publications/").json()["results"] + self.assertEqual(results[0]["year"], 2024) # the 2024 journal paper + + def test_publications_filter_by_author(self): + resp = self.client.get(f"/api/v1/publications/?author={self.jon.url_name}") + body = resp.json() + self.assertEqual(body["count"], 6) + titles = {r["title"] for r in body["results"]} + self.assertNotIn("Unrelated Journal Paper", titles) + + def test_publications_filter_by_project(self): + resp = self.client.get("/api/v1/publications/?project=projectsidewalk") + self.assertEqual(resp.json()["count"], 6) + + def test_publications_filter_by_year(self): + resp = self.client.get("/api/v1/publications/?year=2023") + self.assertEqual(resp.json()["count"], 1) + + def test_publications_filter_by_type(self): + self.assertEqual( + self.client.get("/api/v1/publications/?type=Journal").json()["count"], 1 + ) + self.assertEqual( + self.client.get("/api/v1/publications/?type=Conference").json()["count"], 6 + ) + + def test_publication_detail_has_bibtex(self): + pub = self.project_pubs[0] + resp = self.client.get(f"/api/v1/publications/{pub.id}/") + self.assertEqual(resp.status_code, 200) + self.assertIn("bibtex", resp.json()) + + def test_publication_urls_are_absolute(self): + results = self.client.get("/api/v1/publications/").json()["results"] + pub = results[0] + self.assertTrue(pub["pdf_url"].startswith("http://")) + # nested author page URL is absolute and points at /member/ + author_url = pub["authors"][0]["url"] + self.assertTrue(author_url.startswith("http://")) + self.assertIn("/member/", author_url) + + # ---- projects list + visibility gating ---------------------------------- + + def test_projects_list_excludes_hidden(self): + results = self.client.get("/api/v1/projects/").json()["results"] + short_names = {p["short_name"] for p in results} + self.assertIn("projectsidewalk", short_names) + self.assertNotIn("secretproj", short_names) + + def test_hidden_project_detail_404(self): + self.assertEqual(self.client.get("/api/v1/projects/secretproj/").status_code, 404) + + def test_unknown_project_detail_404(self): + self.assertEqual(self.client.get("/api/v1/projects/nope/").status_code, 404) + + # ---- project sub-resources ---------------------------------------------- + + def test_project_publications_subresource(self): + resp = self.client.get("/api/v1/projects/projectsidewalk/publications/") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["count"], 6) + + def test_project_grants_subresource(self): + resp = self.client.get("/api/v1/projects/projectsidewalk/grants/") + body = resp.json() + self.assertEqual(body["count"], 1) + grant = body["results"][0] + self.assertEqual(grant["sponsor"]["short_name"], "NSF") + self.assertEqual(grant["funding_amount"], 500000) + + def test_project_people_subresource(self): + resp = self.client.get("/api/v1/projects/projectsidewalk/people/") + body = resp.json() + # 3 roles: Jon's PI + Jon's past Co-PI + Past Lead's student-lead role. + self.assertEqual(body["count"], 3) + names = {r["person"]["name"] for r in body["results"]} + self.assertEqual(names, {"Jon Froehlich", "Past Lead"}) + lead_roles = {r["lead_project_role"] for r in body["results"]} + self.assertEqual(lead_roles, {"PI", "Co-PI", "Student Lead"}) + + def test_project_leadership_subresource(self): + resp = self.client.get("/api/v1/projects/projectsidewalk/leadership/") + body = resp.json() + pi_names = {r["person"]["name"] for r in body["pis"]} + self.assertIn("Jon Froehlich", pi_names) + + def test_project_leadership_includes_all_time(self): + """Leadership spans current AND past roles, including past roles held by + someone who is currently active in another capacity.""" + body = self.client.get( + "/api/v1/projects/projectsidewalk/leadership/" + ).json() + + # Jon's past Co-PI role appears even though he's a current PI. + copi = body["co_pis"] + self.assertEqual(len(copi), 1) + self.assertEqual(copi[0]["person"]["name"], "Jon Froehlich") + self.assertFalse(copi[0]["is_active"]) + + # A person whose only role was a past student lead still appears. + leads = body["student_leads"] + self.assertEqual({r["person"]["name"] for r in leads}, {"Past Lead"}) + self.assertFalse(leads[0]["is_active"]) + + # ---- people ------------------------------------------------------------- + + def test_people_list_scoped_to_members(self): + results = self.client.get("/api/v1/people/").json()["results"] + names = {p["name"] for p in results} + self.assertIn("Jon Froehlich", names) # has a Position + self.assertNotIn("Ext Author", names) # co-author only, no Position + + def test_person_detail_by_url_name(self): + resp = self.client.get(f"/api/v1/people/{self.jon.url_name}/") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["name"], "Jon Froehlich") + + def test_person_email_not_exposed(self): + resp = self.client.get(f"/api/v1/people/{self.jon.url_name}/") + self.assertNotIn("email", resp.json()) + + # ---- CORS --------------------------------------------------------------- + + def test_cors_header_present_on_api(self): + resp = self.client.get("/api/v1/publications/") + self.assertEqual(resp["Access-Control-Allow-Origin"], "*") + + def test_cors_header_absent_off_api(self): + resp = self.client.get("/version.json") + self.assertNotIn("Access-Control-Allow-Origin", resp) + + def test_cors_options_preflight(self): + resp = self.client.options("/api/v1/publications/") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp["Access-Control-Allow-Origin"], "*")