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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,23 @@ Custom admin organization lives in `website/admin/admin_site.py` (`MakeabilityLa
- `/media/publications/<filename>` 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.
Expand Down
121 changes: 121 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -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=<n>` — page number.
- `?page_size=<n>` — 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/<id>/` 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/<short_name>/` — summary, about, website, dates,
keywords, umbrellas, thumbnail.
- `GET /api/v1/projects/<short_name>/publications/` — the project's pubs.
- `GET /api/v1/projects/<short_name>/grants/` — grants funding the project.
- `GET /api/v1/projects/<short_name>/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/<short_name>/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=<short_name>`, `?sponsor=<sponsor short_name>`. 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/<url_name>/` — 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.
24 changes: 22 additions & 2 deletions makeabilitylab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions makeabilitylab/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
17 changes: 17 additions & 0 deletions website/api/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
43 changes: 43 additions & 0 deletions website/api/middleware.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading