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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,3 @@ learning_observer/learning_observer/static_data/admins.yaml
.ipynb_checkpoints/
.eggs/
.next/
modules/wo_portfolio_diff/wo_portfolio_diff/portfolio_diff/*
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0+2026.03.06T16.07.48.997Z.0bfff3a0.berickson.20260303.copy.paste.reducer
0.1.0+2026.03.13T15.49.25.710Z.fa70160c.berickson.20260312.small.fixes
2 changes: 1 addition & 1 deletion learning_observer/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0+2026.02.27T21.25.37.849Z.3207a114.berickson.20260220.dami.portfolio.pr
0.1.0+2026.03.13T15.15.47.294Z.a9a3177c.berickson.20260312.small.fixes
191 changes: 191 additions & 0 deletions learning_observer/learning_observer/remote_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""
learning_observer.remote_assets
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Utilities for downloading and installing prebuilt frontend asset bundles
(e.g. exported Next.js apps) that are hosted in a GitHub repository.

Intended use: LO module ``__init__.py`` files call :func:`fetch_module_assets`
from their startup checks to ensure their bundled frontend is present.

Typical repository layout assumed by the pointer-file convention::

lo_assets/
my_module/
my_module-current.tar.gz ← plain-text file containing the tarball name
my_module-v1.2.3.tar.gz ← the actual tarball
"""

import os
import shutil
import sys
import tarfile
import tempfile
import urllib.request

# ---------------------------------------------------------------------------
# Default repository coordinates
# ---------------------------------------------------------------------------

_LO_ASSETS_REPO = 'ETS-Next-Gen/lo_assets'
_LO_ASSETS_REF = 'main'


# ---------------------------------------------------------------------------
# Interactive / non-interactive prompts
# ---------------------------------------------------------------------------

def confirm(prompt, default_noninteractive):
"""
Ask a yes/no question and return a boolean.

Resolution order:
1. *default_noninteractive* when stdin is not a TTY.
2. An interactive prompt otherwise.

Example usage in a module startup check::

should_fetch = confirm(
prompt='Download missing assets? (y/n) ',
default_noninteractive=False,
)
"""
if not sys.stdin.isatty():
return default_noninteractive

return input(prompt).strip().lower() in {'y', 'yes'}


# ---------------------------------------------------------------------------
# Low-level network helpers
# ---------------------------------------------------------------------------

def read_url(url):
"""
Fetch *url* and return its contents as a stripped string.
"""
with urllib.request.urlopen(url) as response:
return response.read().decode('utf-8').strip()


def download_file(url, destination):
"""
Stream *url* to *destination*, avoiding loading the full response into
memory.
"""
with urllib.request.urlopen(url) as response, open(destination, 'wb') as output_file:
shutil.copyfileobj(response, output_file)


# ---------------------------------------------------------------------------
# Archive helpers
# ---------------------------------------------------------------------------

def find_nextjs_root(extracted_dir):
"""
Walk *extracted_dir* and return the shallowest path that looks like an
exported Next.js app (contains both ``index.html`` and a ``_next/``
directory).

Raises ``ValueError`` if no such directory is found.
"""
candidates = []
for root, _, files in os.walk(extracted_dir):
if 'index.html' in files and os.path.isdir(os.path.join(root, '_next')):
candidates.append(root)

if not candidates:
raise ValueError(
"Could not find an exported Next.js app inside the archive "
"(expected index.html and _next/ in the same directory)."
)

return min(candidates, key=len)


def extract_assets_tarball(tar_path, target_dir):
"""
Extract the Next.js app bundle at *tar_path* into *target_dir*.

The archive may contain arbitrary wrapper directories; only the shallowest
directory that looks like an exported Next.js app is copied to
*target_dir*. Any pre-existing *target_dir* is removed first.

Raises ``ValueError`` (from :func:`find_nextjs_root`) if the archive does
not contain a recognisable Next.js export.
"""
with tempfile.TemporaryDirectory() as temp_extract_dir:
with tarfile.open(tar_path, mode='r:gz') as archive:
archive.extractall(temp_extract_dir)

source_root = find_nextjs_root(temp_extract_dir)

if os.path.isdir(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(source_root, target_dir)


# ---------------------------------------------------------------------------
# High-level fetch interface
# ---------------------------------------------------------------------------

def fetch_module_assets(
target_dir,
pointer_file,
assets_url=None,
assets_repo=_LO_ASSETS_REPO,
assets_ref=_LO_ASSETS_REF,
):
"""
Download and install a prebuilt Next.js asset bundle for an LO module.

Parameters
----------
target_dir:
Local directory where the extracted app should be placed.
pointer_file:
Path within *assets_repo* to a plain-text file whose content is the
filename of the current tarball, e.g.
``'wo_portfolio_diff/wo_portfolio_diff-current.tar.gz'``.
The tarball is assumed to live in the same directory as this file.
assets_url:
If supplied, skip pointer-file resolution and download this URL
directly. Useful for pinning a specific release or local testing.
assets_repo:
``owner/repo`` slug on GitHub. Defaults to the canonical LO assets
repository.
assets_ref:
Branch, tag, or commit SHA to resolve *pointer_file* against.
Defaults to ``'main'``.

Raises
------
urllib.error.URLError
Network error while resolving the pointer file or downloading.
ValueError
The downloaded archive does not contain a recognisable Next.js export.
OSError
File-system error while writing or extracting the archive.
tarfile.TarError
The downloaded file is not a valid gzipped tar archive.
"""
if assets_url is None:
pointer_url = (
f'https://raw.githubusercontent.com/{assets_repo}/{assets_ref}'
f'/{pointer_file}'
)
resolved_name = read_url(pointer_url)
pointer_dir = pointer_file.rsplit('/', 1)[0]
assets_url = (
f'https://raw.githubusercontent.com/{assets_repo}/{assets_ref}'
f'/{pointer_dir}/{resolved_name}'
)

with tempfile.NamedTemporaryFile(suffix='.tar.gz', delete=False) as tmp:
archive_path = tmp.name

try:
download_file(assets_url, archive_path)
extract_assets_tarball(archive_path, target_dir)
finally:
os.unlink(archive_path)
23 changes: 21 additions & 2 deletions learning_observer/learning_observer/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,8 +480,22 @@ async def _nextjs_handler(request):
sub_url = request.match_info.get('tail')
if sub_url is None:
return aiohttp.web.FileResponse(os.path.join(path, 'index.html'))
# TODO will this handle multi-layered sub-urls? /foo/bar
return aiohttp.web.FileResponse(os.path.join(path, f'{sub_url}.html'))
# Normalize the subpath and ensure it stays in the exported Next.js folder.
# This allows exported assets like `runtime-config.js` to be served directly.
relative_path = sub_url.lstrip('/')
file_path = os.path.normpath(os.path.join(path, relative_path))
if os.path.commonpath([os.path.abspath(file_path), os.path.abspath(path)]) != os.path.abspath(path):
raise aiohttp.web.HTTPNotFound()

if os.path.isfile(file_path):
return aiohttp.web.FileResponse(file_path)

# Handle extension-less routes exported as html files.
html_path = os.path.normpath(os.path.join(path, f'{relative_path}.html'))
if os.path.commonpath([os.path.abspath(html_path), os.path.abspath(path)]) != os.path.abspath(path):
raise aiohttp.web.HTTPNotFound()

return aiohttp.web.FileResponse(html_path)
return _nextjs_handler


Expand All @@ -501,6 +515,11 @@ def register_nextjs_routes(app):
tail_path = page_path + '{tail:.*}'
app.router.add_get(tail_path, create_nextjs_handler(full_path))

# Some exported apps resolve local assets under an `/_next<page_path>` prefix.
# Mirror those requests back to the same exported directory.
next_prefixed_tail_path = f'/_next{page_path}' + '{tail:.*}'
app.router.add_get(next_prefixed_tail_path, create_nextjs_handler(full_path))


def register_wsgi_routes(app):
'''
Expand Down
52 changes: 31 additions & 21 deletions modules/portfolio_diff/README.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,46 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
# portfolio_diff frontend

## Getting Started
This directory contains the Next.js frontend for portfolio diff visualizations. The app is configured with `output: "export"`, so a production build generates static files in `out/` that can be copied into Python modules or archived for an external assets repository.

First, run the development server:
## Prerequisites

- Node.js + npm available locally.
- Install dependencies from this directory:

```bash
npm install
```

## Build the static site

From `modules/portfolio_diff`:

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
rm -rf out/
npm run build
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
After build completion, the static export is written to `out/`.

You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
## Bundle build output into a `.tar.gz`

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
If you need an artifact for an assets repository, archive the exported files from `out/`:

## Learn More
```bash
VERSION_TAG=$(date +%Y%m%d-%H%M%S)
tar -C out -czf "portfolio_diff_${VERSION_TAG}.tar.gz" .
```

To learn more about Next.js, take a look at the following resources:
That creates a tarball in `modules/portfolio_diff` (for example `portfolio_diff-20260710-153010.tar.gz`) containing the static site root.

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You should include copy this to the [`lo_assets` repository](github.com/ETS-Next-Gen/lo_assets) and update the `portfolio_diff-current.tar.gz` link to point to the new version.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Existing helper script

## Deploy on Vercel
This repo also includes `build_and_add_to_module.sh`, which builds and copies `out/` into `../wo_portfolio_diff/wo_portfolio_diff/portfolio_diff/` for local module packaging:

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
```bash
./build_and_add_to_module.sh
```

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Use that helper when updating the in-repo Python package. Use the tarball workflow above when publishing to a separate assets repository.
7 changes: 6 additions & 1 deletion modules/portfolio_diff/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
/** @type {import('next').NextConfig} */
const basePath = "/_next/wo_portfolio_diff/portfolio_diff";

const nextConfig = {
basePath: "/_next/wo_portfolio_diff/portfolio_diff",
basePath,
env: {
NEXT_PUBLIC_BASE_PATH: basePath,
},
eslint: {
ignoreDuringBuilds: true,
},
Expand Down
7 changes: 7 additions & 0 deletions modules/portfolio_diff/src/app/layout.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Navbar from "./components/Navbar";
import { CourseIdProvider } from "@/app/providers/CourseIdProvider";
import Script from "next/script";
import "./globals.css";

export const metadata = {
Expand All @@ -11,6 +12,12 @@ export default function RootLayout({ children }) {
return (
<CourseIdProvider>
<html lang="en">
<head>
<Script
src={`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/runtime-config.js`}
strategy="beforeInteractive"
/>
</head>
<body className="antialiased">
<Navbar />
{children}
Expand Down
7 changes: 2 additions & 5 deletions modules/portfolio_diff/src/app/students/compare/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import dynamic from "next/dynamic";

import { MetricsPanel } from "@/app/components/MetricsPanel";
import { useCourseIdContext } from "@/app/providers/CourseIdProvider";
import { getWsOriginFromWindow } from "@/app/utils/ws";
import { getConfiguredWsOrigin } from "@/app/utils/ws";

/* ---------------------- deterministic helpers ---------------------- */
const seedFrom = (s) => {
Expand Down Expand Up @@ -1110,10 +1110,7 @@ export default function EssayComparison() {
]);

/* ---------------------- comparison data fetch ---------------------- */
const origin =
process.env.NEXT_PUBLIC_LO_WS_ORIGIN?.replace(/\/+$/, "") ||
getWsOriginFromWindow() ||
"ws://localhost:8888";
const origin = getConfiguredWsOrigin();

const dataScope = useMemo(() => {
if (!urlReady || !studentID) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import { MetricsPanel } from "@/app/components/MetricsPanel";
import { useLOConnectionDataManager } from "lo_event/lo_event/lo_assess/components/components.jsx";
import { useCourseIdContext } from "@/app/providers/CourseIdProvider";
import { getWsOriginFromWindow } from "@/app/utils/ws";
import { getConfiguredWsOrigin } from "@/app/utils/ws";

/* =========================================================
Helpers
Expand Down Expand Up @@ -678,10 +678,7 @@ function SingleEssayInnerModal({ studentKey, docId, docIds }) {
}, [exportEnabled, courseId, docIds, selectedMetrics, studentKey]);

// Connect to LO websocket
const origin =
process.env.NEXT_PUBLIC_LO_WS_ORIGIN?.replace(/\/+$/, "") ||
getWsOriginFromWindow() ||
"ws://localhost:8888";
const origin = getConfiguredWsOrigin();

const url = `${origin}/wsapi/communication_protocol`;
const { connection, data: loData, errors: loErrors } = useLOConnectionDataManager({ url, dataScope });
Expand Down
Loading
Loading