Compare commits
4 Commits
5c889f108e
...
754e98c6a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754e98c6a5 | ||
|
|
1aa4b16eef | ||
|
|
21fc2b958d | ||
|
|
8e705b8865 |
@@ -34,13 +34,21 @@ automation/bin/karakeep_api.py /api/health --raw-path --pretty
|
|||||||
- `bin/pd_backup_sync_to_serenity.sh` — `rsync`s configured backup/config roots to Serenity
|
- `bin/pd_backup_sync_to_serenity.sh` — `rsync`s configured backup/config roots to Serenity
|
||||||
- `bin/run_pd_backups.sh` — wrapper that runs both steps in order
|
- `bin/run_pd_backups.sh` — wrapper that runs both steps in order
|
||||||
|
|
||||||
These scripts are staged repo-side only. They still need:
|
These scripts are the live PD backup runner model.
|
||||||
|
|
||||||
|
Repo-side deployment prerequisites remain:
|
||||||
|
|
||||||
1. real values in `.env`
|
1. real values in `.env`
|
||||||
2. a writable dump destination on PD
|
2. a writable dump destination on PD
|
||||||
3. SSH trust / key path for Serenity (`root@10.5.1.5` by default)
|
3. SSH trust / key path for Serenity (`root@10.5.1.5` by default)
|
||||||
- recommended known_hosts path on PD: `/home/truenas_admin/.ssh/known_hosts`
|
- recommended known_hosts path on PD: `/home/truenas_admin/.ssh/known_hosts`
|
||||||
4. a real scheduler (cron/systemd timer/n8n) on the live host
|
4. a real scheduler on the live host
|
||||||
|
|
||||||
|
Current documented live state:
|
||||||
|
|
||||||
|
- the PD → Serenity backup flow is the intended deployed model
|
||||||
|
- root cron on PD is the preferred scheduler
|
||||||
|
- first-run verification and the deployed status are tracked in `docs/operations/PD_BACKUP_DEPLOYMENT.md` and `docs/planning/TODO.md`
|
||||||
|
|
||||||
## PD / TrueNAS deployment recommendation
|
## PD / TrueNAS deployment recommendation
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Current State (Known Gaps)
|
## Current State (Known Gaps)
|
||||||
- **No off-site backup** — this is a known gap, improvement pending
|
- **No off-site backup** — this is a known gap, improvement pending
|
||||||
- Serenity has local ZFS snapshots and replication
|
- Serenity has local ZFS snapshots and replication
|
||||||
- PlausibleDeniability has no automated backup of appdata or databases
|
- PlausibleDeniability now has repo-documented automated backup scaffolding deployed to PD for Postgres dumps plus rsync replication to Serenity
|
||||||
|
|
||||||
## Serenity Backup Strategy
|
## Serenity Backup Strategy
|
||||||
- Sanoid snapshots cache/appdata: 7 daily, 4 weekly, 6 monthly
|
- Sanoid snapshots cache/appdata: 7 daily, 4 weekly, 6 monthly
|
||||||
@@ -32,7 +32,7 @@ Repo-side scaffolding now lives under `automation/`:
|
|||||||
- `automation/bin/pd_backup_sync_to_serenity.sh`
|
- `automation/bin/pd_backup_sync_to_serenity.sh`
|
||||||
- `automation/bin/run_pd_backups.sh`
|
- `automation/bin/run_pd_backups.sh`
|
||||||
|
|
||||||
These are staged helper scripts only until they are deployed with a real `.env`, SSH trust to Serenity, and a live scheduler on PD.
|
These helper scripts are now the live PD backup runner model, with deployment details tracked in `docs/operations/PD_BACKUP_DEPLOYMENT.md`.
|
||||||
|
|
||||||
Recommended live deployment model on PD: a plain **root cron** job invoking `/usr/bin/bash bin/run_pd_backups.sh` from `/mnt/docker-ssd/docker/compose/automation`.
|
Recommended live deployment model on PD: a plain **root cron** job invoking `/usr/bin/bash bin/run_pd_backups.sh` from `/mnt/docker-ssd/docker/compose/automation`.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Current workflow versions:
|
|||||||
- `n8n-workflows/18-school-paperless-intake-v1.2.json`
|
- `n8n-workflows/18-school-paperless-intake-v1.2.json`
|
||||||
- `n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json`
|
- `n8n-workflows/19-paperless-school-metadata-enrichment-v1.1.json`
|
||||||
|
|
||||||
This workflow set is meant for a Telegram chat-driven upload pipeline that sends schoolwork into Paperless-NGX while recording intake metadata in shared Postgres.
|
This workflow set is the Telegram chat-driven upload pipeline that sends schoolwork into Paperless-NGX with deterministic intake IDs and follow-up metadata enrichment.
|
||||||
|
|
||||||
## Versioning rule
|
## Versioning rule
|
||||||
|
|
||||||
@@ -31,16 +31,16 @@ When these workflow artifacts change, the version must change too:
|
|||||||
- `telegram_chat_id`
|
- `telegram_chat_id`
|
||||||
- `telegram_message_id`
|
- `telegram_message_id`
|
||||||
4. Generates a deterministic `intake_id` from date + class + assignment + file checksum
|
4. Generates a deterministic `intake_id` from date + class + assignment + file checksum
|
||||||
5. Stores/updates the intake row in Postgres first
|
5. Builds a deterministic intake envelope from the request metadata and upload details
|
||||||
6. Uploads the file to Paperless using the deterministic intake ID in the filename
|
6. Uploads the file to Paperless using the deterministic intake ID in the filename
|
||||||
7. Marks the row as `uploaded` after Paperless accepts the upload
|
7. Marks the row as `uploaded` after Paperless accepts the upload
|
||||||
|
|
||||||
## Shared Postgres expectations
|
## Current correlation model
|
||||||
|
|
||||||
- Use the shared Postgres network/credentials pattern already used by the automation stack.
|
- Earlier drafts stored intake rows in shared Postgres.
|
||||||
- Earlier drafts used shared Postgres tracking, but the current live-tested v1.2/v1.1 path works without Postgres dependency in the webhook chain.
|
- The current live-tested `v1.2` + `v1.1` chain does **not** require Postgres in the webhook path.
|
||||||
- Deterministic filename/title are now the source of correlation between upload and enrichment.
|
- Deterministic filename/title are the current source of correlation between upload and enrichment.
|
||||||
- Tracked schema: `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql` (kept for future DB-backed intake tracking if reinstated)
|
- `docs/reference/SCHOOL_INTAKE_POSTGRES_SCHEMA.sql` is retained for a future DB-backed intake ledger if that gets reinstated.
|
||||||
|
|
||||||
## Required environment variables
|
## Required environment variables
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ The deterministic filename/title preserve the `intake_id` for later correlation.
|
|||||||
Workflow 19 handles the follow-up metadata pass after Paperless finishes processing:
|
Workflow 19 handles the follow-up metadata pass after Paperless finishes processing:
|
||||||
- fetch Paperless document by webhook document id
|
- fetch Paperless document by webhook document id
|
||||||
- resolve `intake_id` from the stored filename
|
- resolve `intake_id` from the stored filename
|
||||||
- look up the intake record in shared Postgres
|
- recover class/assignment/submission metadata from the deterministic filename and title
|
||||||
- re-apply deterministic title
|
- re-apply deterministic title
|
||||||
- merge generic school tags plus optional class/submission-kind mappings
|
- merge generic school tags plus optional class/submission-kind mappings
|
||||||
- optionally set document type / correspondent from JSON env mappings
|
- optionally set document type / correspondent from JSON env mappings
|
||||||
@@ -116,7 +116,7 @@ Use the example curl request in:
|
|||||||
|
|
||||||
Minimum test checklist:
|
Minimum test checklist:
|
||||||
1. Import the workflow into n8n.
|
1. Import the workflow into n8n.
|
||||||
2. Attach the `Shared Postgres` credential.
|
2. Import workflow 19 alongside workflow 18.
|
||||||
3. Send the sample multipart request.
|
3. Send the sample multipart request.
|
||||||
4. Verify the uploaded Paperless document title and stored filename both include the deterministic intake ID.
|
4. Verify the uploaded Paperless document title and stored filename both include the deterministic intake ID.
|
||||||
5. Trigger workflow 19 with the Paperless document id and verify title/tags enrichment succeeds.
|
5. Trigger workflow 19 with the Paperless document id and verify title/tags enrichment succeeds.
|
||||||
@@ -124,7 +124,7 @@ Minimum test checklist:
|
|||||||
|
|
||||||
## Known assumptions / caveats
|
## Known assumptions / caveats
|
||||||
|
|
||||||
- The workflow is repo-only and was not live-tested against the running n8n/Paperless stack.
|
- Workflow 18 `v1.2` assignment intake was live smoke-tested successfully on 2026-05-15.
|
||||||
- The Postgres node uses SQL expressions inline rather than parameter binding because exported n8n node JSON can differ by version.
|
- Workflow 19 `v1.1` is designed around the deterministic filename/title model rather than a Postgres lookup.
|
||||||
- Workflow 18 v1.0 needed `require('crypto')`; workflow 18 v1.1 removed that dependency for easier live rollout.
|
- Workflow 18 v1.0 needed `require('crypto')`; workflow 18 v1.1 removed that dependency for easier live rollout; workflow 18 v1.2 replaced it with an internal stable hash.
|
||||||
- If Paperless needs tags as repeated `tags[]` fields instead of a comma-separated `tags` field, adjust the HTTP Request node after import.
|
- If Paperless needs tags as repeated `tags[]` fields instead of a comma-separated `tags` field, adjust the HTTP Request node after import.
|
||||||
|
|||||||
@@ -245,12 +245,55 @@ def select_items(items:list[dict[str,Any]],prefs:dict[str,Any],max_items:int=14)
|
|||||||
for i in items:
|
for i in items:
|
||||||
if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600: recent.append(i)
|
if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600: recent.append(i)
|
||||||
recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True)
|
recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True)
|
||||||
out=[]; seen=set(); sources=Counter()
|
|
||||||
|
out=[]; seen=set(); sources=Counter(); categories=Counter()
|
||||||
|
|
||||||
|
def consider(limit_per_category:int|None)->None:
|
||||||
for i in recent:
|
for i in recent:
|
||||||
url=i.get('url',''); src=i.get('source','')
|
|
||||||
if url in seen or sources[src]>=2: continue
|
|
||||||
out.append(i); seen.add(url); sources[src]+=1
|
|
||||||
if len(out)>=max_items: break
|
if len(out)>=max_items: break
|
||||||
|
url=i.get('url',''); src=i.get('source',''); cat=(i.get('category') or 'other').strip() or 'other'
|
||||||
|
if url in seen or sources[src]>=2: continue
|
||||||
|
if limit_per_category is not None and categories[cat] >= limit_per_category: continue
|
||||||
|
out.append(i); seen.add(url); sources[src]+=1; categories[cat]+=1
|
||||||
|
|
||||||
|
# First fill favors breadth across categories, then allows a second item per
|
||||||
|
# category, and only then permits a small spillover from the strongest buckets.
|
||||||
|
consider(1)
|
||||||
|
consider(2)
|
||||||
|
consider(3)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def select_exploration_items(items:list[dict[str,Any]],prefs:dict[str,Any],exclude_urls:set[str]|None=None,max_items:int=4)->list[dict[str,Any]]:
|
||||||
|
now=datetime.now(timezone.utc)
|
||||||
|
exclude_urls=exclude_urls or set()
|
||||||
|
boosted={str(x).strip() for x in prefs.get('boost_categories',[])}
|
||||||
|
quiet={str(x).strip() for x in prefs.get('quiet_categories',[])}
|
||||||
|
preferred=['tech','smart_home','gaming','cooking','security']
|
||||||
|
recent=[i for i in items if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600]
|
||||||
|
recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True)
|
||||||
|
|
||||||
|
out=[]; seen=set(exclude_urls); categories=Counter(); sources=Counter()
|
||||||
|
|
||||||
|
def consider(allowed_boosted:bool, only_categories:set[str]|None=None, per_category_limit:int=1)->None:
|
||||||
|
for i in recent:
|
||||||
|
if len(out)>=max_items: break
|
||||||
|
url=i.get('url',''); src=i.get('source',''); cat=(i.get('category') or 'other').strip() or 'other'
|
||||||
|
if not url or url in seen or sources[src]>=1 or categories[cat]>=per_category_limit:
|
||||||
|
continue
|
||||||
|
if cat in quiet:
|
||||||
|
continue
|
||||||
|
if only_categories is not None and cat not in only_categories:
|
||||||
|
continue
|
||||||
|
if not allowed_boosted and cat in boosted:
|
||||||
|
continue
|
||||||
|
out.append(i); seen.add(url); categories[cat]+=1; sources[src]+=1
|
||||||
|
|
||||||
|
# Keep this lane genuinely exploratory: preferred off-profile categories first,
|
||||||
|
# then broader off-profile picks, allowing a little depth if the pool is narrow.
|
||||||
|
consider(False, set(preferred), 1)
|
||||||
|
consider(False, set(preferred), 3)
|
||||||
|
consider(False, None, 1)
|
||||||
|
consider(False, None, 3)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def label(cat:str)->str:
|
def label(cat:str)->str:
|
||||||
@@ -545,6 +588,7 @@ def render()->str:
|
|||||||
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
|
candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{})
|
||||||
donetick_todos=fetch_donetick_todos(); todos=inject_dashboard_todos(donetick_todos or load_json(DATA/'todos.json',[])); calendar=load_json(DATA/'calendar.json',[]); notes=load_json(DATA/'notes.json',[])
|
donetick_todos=fetch_donetick_todos(); todos=inject_dashboard_todos(donetick_todos or load_json(DATA/'todos.json',[])); calendar=load_json(DATA/'calendar.json',[]); notes=load_json(DATA/'notes.json',[])
|
||||||
selected=select_items(candidates,prefs); top_briefing=selected[:6]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]}
|
selected=select_items(candidates,prefs); top_briefing=selected[:6]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]}
|
||||||
|
exploration=select_exploration_items(candidates,prefs,{i.get('url','') for i in selected if i.get('url')},4)
|
||||||
for i in selected:
|
for i in selected:
|
||||||
if i.get('url','') in top_urls: continue
|
if i.get('url','') in top_urls: continue
|
||||||
cat=i.get('category')
|
cat=i.get('category')
|
||||||
@@ -580,6 +624,7 @@ def render()->str:
|
|||||||
{section('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:5],'No sky signal right now.',compact=True)}
|
{section('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:5],'No sky signal right now.',compact=True)}
|
||||||
{section('Homelab / Smart Home / Radio','Infrastructure, self-hosting, smart home, and ham radio.',groups['homelab'][:4],'No homelab signal right now.',compact=True)}
|
{section('Homelab / Smart Home / Radio','Infrastructure, self-hosting, smart home, and ham radio.',groups['homelab'][:4],'No homelab signal right now.',compact=True)}
|
||||||
{section('Local','Clarksville / Middle Tennessee items that seem useful.',groups['local'][:3],'No local signal right now.',compact=True)}
|
{section('Local','Clarksville / Middle Tennessee items that seem useful.',groups['local'][:3],'No local signal right now.',compact=True)}
|
||||||
|
{section('New to You','Intentional oddballs outside your usual top lanes, so you can see if any new categories deserve a slot.',exploration[:4],'No worthwhile experimental picks right now.',compact=True)}
|
||||||
{section('Other signals','Everything else that survived filtering.',groups['signals'][:3],'No extra signals.',compact=True)}
|
{section('Other signals','Everything else that survived filtering.',groups['signals'][:3],'No extra signals.',compact=True)}
|
||||||
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally.</p></footer></main>{feedback_bootstrap()}</body></html>"""
|
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally.</p></footer></main>{feedback_bootstrap()}</body></html>"""
|
||||||
|
|
||||||
|
|||||||
@@ -66,3 +66,26 @@ Then verify:
|
|||||||
- assignment intake confirms into n8n/Paperless
|
- assignment intake confirms into n8n/Paperless
|
||||||
- recording conversion works inside container
|
- recording conversion works inside container
|
||||||
- recording downstream workflow returns success
|
- recording downstream workflow returns success
|
||||||
|
- browser uploads return to `/assignments/upload` with a success notice instead of rendering raw JSON
|
||||||
|
- programming-class uploads create one `source_bundle` submission and immediately queue the Paperless receipt upload
|
||||||
|
|
||||||
|
## Serenity backup on NOMAD
|
||||||
|
|
||||||
|
If Schoolhouse submission bundles should survive a NOMAD disk problem, back up `/opt/doris-schoolhouse/data` to Serenity.
|
||||||
|
|
||||||
|
Current live helper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/doris-schoolhouse/bin/backup-to-serenity.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- mounts Serenity NFS export `10.5.1.5:/mnt/user/data` at `/mnt/serenity-data` when needed
|
||||||
|
- mirrors `/opt/doris-schoolhouse/data/` into `/mnt/serenity-data/backups/nomad/doris-schoolhouse/data/`
|
||||||
|
- writes `last_success_utc.txt` and `source_host.txt` markers beside the mirrored data
|
||||||
|
|
||||||
|
Suggested cron on NOMAD:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
40 2 * * * /opt/doris-schoolhouse/bin/backup-to-serenity.sh >> /opt/doris-schoolhouse/logs/backup-to-serenity.log 2>&1
|
||||||
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Desktop-first local web app for school intake, recordings, D2L sync, and Paperle
|
|||||||
|
|
||||||
This app gives John a fast local UI to:
|
This app gives John a fast local UI to:
|
||||||
|
|
||||||
- upload assignment turn-ins
|
- upload assignment turn-ins, with programming-class source files bundled into one archived submission artifact
|
||||||
- upload class recordings
|
- upload class recordings
|
||||||
- confirm suggested metadata
|
- confirm suggested metadata
|
||||||
- track assignment status through grading
|
- track assignment status through grading
|
||||||
@@ -31,11 +31,9 @@ Do not run production from an agent workspace.
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
This is an implementation scaffold with:
|
The app is live on NOMAD at `/opt/doris-schoolhouse` and uses shared Postgres in production.
|
||||||
|
|
||||||
## Current local persistence
|
For local iteration, the source tree still supports a JSON-backed mode:
|
||||||
|
|
||||||
Until the full shared-Postgres integration is wired, the scaffold uses JSON state files for fast local iteration:
|
|
||||||
|
|
||||||
- default state dir: `/data/state`
|
- default state dir: `/data/state`
|
||||||
- default upload temp dir: `/data/uploads`
|
- default upload temp dir: `/data/uploads`
|
||||||
@@ -47,18 +45,15 @@ export SCHOOLHOUSE_STATE_DIR=/tmp/doris-schoolhouse-state
|
|||||||
export UPLOAD_TMP_DIR=/tmp/doris-schoolhouse-uploads
|
export UPLOAD_TMP_DIR=/tmp/doris-schoolhouse-uploads
|
||||||
```
|
```
|
||||||
|
|
||||||
Production target remains shared Postgres plus Paperless/n8n integration.
|
Production runtime uses shared Postgres plus Paperless/n8n integration.
|
||||||
|
|
||||||
`SCHOOLHOUSE_STORAGE_BACKEND=auto` prefers Postgres when psycopg and a PostgreSQL DSN are available, and falls back to JSON state for local iteration.
|
`SCHOOLHOUSE_STORAGE_BACKEND=auto` prefers Postgres when psycopg and a PostgreSQL DSN are available, and falls back to JSON state for local iteration.
|
||||||
|
|
||||||
|
Repo contents still include:
|
||||||
- route stubs
|
- route handlers and templates
|
||||||
- templates
|
- schema/bootstrap helpers
|
||||||
- schema draft
|
|
||||||
- compose/env examples
|
- compose/env examples
|
||||||
- D2L / Paperless integration placeholders
|
- D2L / Paperless / n8n integration code
|
||||||
|
|
||||||
It is not deployed yet.
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -88,11 +83,28 @@ Useful helpers now included:
|
|||||||
```bash
|
```bash
|
||||||
bin/check-env.sh
|
bin/check-env.sh
|
||||||
bin/apply-postgres-schema.sh
|
bin/apply-postgres-schema.sh
|
||||||
bin/test-assignment-handoff.sh <file> <class_name> [assignment_name]
|
bin/test-assignment-handoff.sh <file> [more_files ...] <class_name> [assignment_name]
|
||||||
bin/test-recording-handoff.sh <file> <class_name>
|
bin/test-recording-handoff.sh <file> <class_name>
|
||||||
```
|
```
|
||||||
|
|
||||||
These are safe prep helpers. The handoff scripts are dry-run previews; they print the exact curl commands rather than firing them automatically.
|
These are safe prep helpers. The handoff scripts are dry-run previews; they print the exact curl commands rather than firing them automatically.
|
||||||
|
Assignment intake accepts either a legacy single `file` field or the newer multi-file `files` field. Programming-class uploads are normalized into one source-bundle zip per submission version, with a manifest and source-listing sidecar kept alongside the bundle locally. For Paperless archival, Schoolhouse uploads a generated PDF submission receipt/listing while keeping the real source bundle zip as the local canonical artifact.
|
||||||
|
|
||||||
|
Browser submits now auto-confirm into Paperless before redirecting back to `/assignments/upload`. The success notice reports the resulting Paperless queue state, so a normal web upload should no longer stop at `uploaded-local` unless archival actually failed.
|
||||||
|
|
||||||
|
## Serenity backup
|
||||||
|
|
||||||
|
On NOMAD, the canonical local Schoolhouse artifacts live under `/opt/doris-schoolhouse/data`. The helper `bin/backup-to-serenity.sh` mounts Serenity's NFS export at `/mnt/serenity-data` if needed and mirrors that data tree into:
|
||||||
|
|
||||||
|
`/mnt/serenity-data/backups/nomad/doris-schoolhouse/data`
|
||||||
|
|
||||||
|
Recommended daily cron on NOMAD:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
40 2 * * * /opt/doris-schoolhouse/bin/backup-to-serenity.sh >> /opt/doris-schoolhouse/logs/backup-to-serenity.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the real source bundles, receipt PDFs, manifests, and other Schoolhouse runtime data copied onto Serenity without depending on broken SSH trust from NOMAD.
|
||||||
|
|
||||||
## Helper scripts
|
## Helper scripts
|
||||||
|
|
||||||
@@ -159,7 +171,7 @@ That image includes:
|
|||||||
|
|
||||||
So even if the host is missing `ffmpeg`, the containerized runtime can still do WAV → MP3 conversion.
|
So even if the host is missing `ffmpeg`, the containerized runtime can still do WAV → MP3 conversion.
|
||||||
|
|
||||||
## Future deploy shape
|
## Deploy shape
|
||||||
|
|
||||||
Expected live deploy flow on NOMAD:
|
Expected live deploy flow on NOMAD:
|
||||||
|
|
||||||
@@ -172,7 +184,7 @@ docker compose --env-file .env config
|
|||||||
docker compose --env-file .env up -d --build
|
docker compose --env-file .env up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Not doing that yet. No live deployment should happen without explicit rollout work.
|
That flow is the reference for refreshing the live runtime from repo source.
|
||||||
|
|
||||||
|
|
||||||
## Live integration notes
|
## Live integration notes
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.services.bundles import SubmissionBundleService
|
||||||
from app.services.d2l import D2LService
|
from app.services.d2l import D2LService
|
||||||
from app.services.paperless import PaperlessService
|
from app.services.paperless import PaperlessService
|
||||||
from app.services.repository import get_repository
|
from app.services.repository import get_repository
|
||||||
@@ -17,6 +20,74 @@ repo = get_repository()
|
|||||||
d2l = D2LService()
|
d2l = D2LService()
|
||||||
paperless = PaperlessService()
|
paperless = PaperlessService()
|
||||||
webhooks = WebhookService()
|
webhooks = WebhookService()
|
||||||
|
bundles = SubmissionBundleService()
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_submission_to_paperless(current: dict) -> dict:
|
||||||
|
handoff = paperless.assignment_intake_payload(current)
|
||||||
|
file_path = current.get("stored_path") or current.get("local_file")
|
||||||
|
if not file_path or not Path(file_path).exists():
|
||||||
|
raise HTTPException(status_code=400, detail="Stored upload file is missing.")
|
||||||
|
fields = {
|
||||||
|
"class_name": handoff["class_name"],
|
||||||
|
"assignment_name": handoff["assignment_name"],
|
||||||
|
"submission_kind": handoff["submission_kind"],
|
||||||
|
"notes": handoff.get("notes", ""),
|
||||||
|
"source": handoff.get("source", "doris-schoolhouse"),
|
||||||
|
}
|
||||||
|
for optional_key in ("semester", "course_code", "paper_kind", "telegram_chat_id", "telegram_message_id"):
|
||||||
|
value = handoff.get(optional_key)
|
||||||
|
if value:
|
||||||
|
fields[optional_key] = value
|
||||||
|
artifact_kind = handoff.get("artifact_kind") or "document"
|
||||||
|
if artifact_kind == "source_bundle":
|
||||||
|
paperless_response = paperless.upload_source_bundle_receipt(current)
|
||||||
|
enrichment = paperless.enrich_direct_submission(current, paperless_response)
|
||||||
|
response = {
|
||||||
|
"ok": True,
|
||||||
|
"status_code": 200,
|
||||||
|
"body": paperless_response,
|
||||||
|
"mode": "direct-paperless",
|
||||||
|
"enrichment": enrichment,
|
||||||
|
}
|
||||||
|
task_id = paperless_response.get("task_id") or paperless_response.get("id")
|
||||||
|
body_obj = {
|
||||||
|
"paperless_document_id": enrichment.get("document_id"),
|
||||||
|
"paperless_title": enrichment.get("title") or paperless.build_submission_title(current),
|
||||||
|
"paperless_task_id": task_id,
|
||||||
|
"paperless_metadata": enrichment.get("metadata_payload"),
|
||||||
|
}
|
||||||
|
ok = True
|
||||||
|
else:
|
||||||
|
if handoff.get("artifact_kind"):
|
||||||
|
fields["artifact_kind"] = str(handoff["artifact_kind"])
|
||||||
|
if handoff.get("file_count"):
|
||||||
|
fields["file_count"] = str(handoff["file_count"])
|
||||||
|
response = webhooks.post_multipart(handoff["target_url"], fields, "document", file_path)
|
||||||
|
body = response.get("body", {}) if isinstance(response, dict) else {}
|
||||||
|
body_obj = body[0] if isinstance(body, list) and body and isinstance(body[0], dict) else (body if isinstance(body, dict) else {})
|
||||||
|
ok = bool(response.get("ok", False)) if isinstance(response, dict) else False
|
||||||
|
updates = {
|
||||||
|
"status": "paperless-queued" if ok else "paperless-error",
|
||||||
|
"confirmed_at": store.now(),
|
||||||
|
"webhook_response": response,
|
||||||
|
"paperless_document_id": body_obj.get("paperless_document_id") or body_obj.get("document_id"),
|
||||||
|
"paperless_title": body_obj.get("paperless_title") or body_obj.get("title") or handoff.get("assignment_name"),
|
||||||
|
"paperless_task_id": body_obj.get("paperless_task_id"),
|
||||||
|
}
|
||||||
|
updated = repo.update_submission(str(current.get("id")), updates) or current
|
||||||
|
grade_note = None
|
||||||
|
if ok and updated.get("assignment_id"):
|
||||||
|
grade_note = paperless.append_grade_note_if_available(str(updated.get("assignment_id")))
|
||||||
|
return {
|
||||||
|
"status": "ok" if ok else "error",
|
||||||
|
"submission": updated,
|
||||||
|
"submissions": [updated],
|
||||||
|
"handoff": handoff,
|
||||||
|
"webhook": [response],
|
||||||
|
"grade_note": grade_note,
|
||||||
|
"file_count": handoff.get("file_count") or 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -41,7 +112,9 @@ async def list_submissions():
|
|||||||
|
|
||||||
@router.post("/intake")
|
@router.post("/intake")
|
||||||
async def intake_assignment(
|
async def intake_assignment(
|
||||||
file: UploadFile,
|
request: Request,
|
||||||
|
files: list[UploadFile] | None = File(None),
|
||||||
|
file: UploadFile | None = File(None),
|
||||||
class_id: str = Form(...),
|
class_id: str = Form(...),
|
||||||
class_name: str = Form(...),
|
class_name: str = Form(...),
|
||||||
assignment_name: str = Form(...),
|
assignment_name: str = Form(...),
|
||||||
@@ -59,16 +132,43 @@ async def intake_assignment(
|
|||||||
matching = next((item for item in assignments if item.get("title") == assignment_name), None)
|
matching = next((item for item in assignments if item.get("title") == assignment_name), None)
|
||||||
if not matching:
|
if not matching:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found in cached D2L data for selected class.")
|
raise HTTPException(status_code=404, detail="Assignment not found in cached D2L data for selected class.")
|
||||||
|
uploads = list(files or [])
|
||||||
|
if file is not None:
|
||||||
|
uploads.append(file)
|
||||||
|
if not uploads:
|
||||||
|
raise HTTPException(status_code=400, detail="At least one upload is required.")
|
||||||
|
|
||||||
upload_root = Path(settings.upload_tmp_dir)
|
upload_root = Path(settings.upload_tmp_dir)
|
||||||
upload_root.mkdir(parents=True, exist_ok=True)
|
upload_root.mkdir(parents=True, exist_ok=True)
|
||||||
safe_name = Path(file.filename or "upload.bin").name
|
course = next((item for item in d2l.get_courses() if str(item.get("d2l_course_id") or item.get("id")) == str(class_id)), None)
|
||||||
submission_id = store.new_id("submission")
|
duplicate_names: dict[str, int] = {}
|
||||||
stored_path = upload_root / f"{submission_id}-{safe_name}"
|
prepared_uploads: list[dict[str, Any]] = []
|
||||||
payload = await file.read()
|
|
||||||
stored_path.write_bytes(payload)
|
|
||||||
checksum = hashlib.sha256(payload).hexdigest()
|
|
||||||
|
|
||||||
|
for index, upload in enumerate(uploads, start=1):
|
||||||
|
safe_name = Path(upload.filename or f"upload-{index}.bin").name
|
||||||
|
duplicate_count = duplicate_names.get(safe_name, 0)
|
||||||
|
duplicate_names[safe_name] = duplicate_count + 1
|
||||||
|
if duplicate_count:
|
||||||
|
stem = Path(safe_name).stem
|
||||||
|
suffix = Path(safe_name).suffix
|
||||||
|
safe_name = f"{stem}-{duplicate_count + 1}{suffix}"
|
||||||
|
payload = await upload.read()
|
||||||
|
prepared_uploads.append({
|
||||||
|
"filename": safe_name,
|
||||||
|
"payload": payload,
|
||||||
|
"mime_type": upload.content_type or "application/octet-stream",
|
||||||
|
})
|
||||||
|
|
||||||
|
submission_id = store.new_id("submission")
|
||||||
|
bundle = bundles.build_bundle(
|
||||||
|
upload_root=upload_root,
|
||||||
|
submission_id=submission_id,
|
||||||
|
class_name=class_name,
|
||||||
|
assignment_name=assignment_name,
|
||||||
|
version_label=version,
|
||||||
|
uploads=prepared_uploads,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
record = {
|
record = {
|
||||||
"id": submission_id,
|
"id": submission_id,
|
||||||
"assignment_id": matching["id"],
|
"assignment_id": matching["id"],
|
||||||
@@ -77,6 +177,7 @@ async def intake_assignment(
|
|||||||
"assignment_name": assignment_name,
|
"assignment_name": assignment_name,
|
||||||
"version_label": version,
|
"version_label": version,
|
||||||
"submission_kind": submission_kind,
|
"submission_kind": submission_kind,
|
||||||
|
"artifact_kind": bundle["artifact_kind"],
|
||||||
"semester": semester or None,
|
"semester": semester or None,
|
||||||
"course_code": course_code or (course.get("code") if course else None),
|
"course_code": course_code or (course.get("code") if course else None),
|
||||||
"paper_kind": paper_kind or None,
|
"paper_kind": paper_kind or None,
|
||||||
@@ -84,17 +185,44 @@ async def intake_assignment(
|
|||||||
"telegram_chat_id": telegram_chat_id or None,
|
"telegram_chat_id": telegram_chat_id or None,
|
||||||
"telegram_message_id": telegram_message_id or None,
|
"telegram_message_id": telegram_message_id or None,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
"original_filename": safe_name,
|
"original_filename": bundle["bundle_filename"],
|
||||||
"stored_path": str(stored_path),
|
"stored_path": bundle["bundle_path"],
|
||||||
"mime_type": file.content_type or "application/octet-stream",
|
"stored_paths": [item["stored_path"] for item in bundle["file_manifest_json"]],
|
||||||
"file_size_bytes": len(payload),
|
"bundle_path": bundle["bundle_path"],
|
||||||
"checksum_sha256": checksum,
|
"bundle_filename": bundle["bundle_filename"],
|
||||||
|
"source_listing_path": bundle["source_listing_path"],
|
||||||
|
"receipt_pdf_path": bundle["receipt_pdf_path"],
|
||||||
|
"file_manifest_json": bundle["file_manifest_json"],
|
||||||
|
"mime_type": bundle["bundle_mime_type"],
|
||||||
|
"file_size_bytes": bundle["bundle_size_bytes"],
|
||||||
|
"checksum_sha256": bundle["bundle_checksum_sha256"],
|
||||||
"status": "uploaded-local",
|
"status": "uploaded-local",
|
||||||
"received_at": store.now(),
|
"received_at": store.now(),
|
||||||
|
"batch_id": None,
|
||||||
|
"file_count": bundle["file_count"],
|
||||||
|
"file_index": None,
|
||||||
|
"combined_source_checksum_sha256": bundle["combined_source_checksum_sha256"],
|
||||||
}
|
}
|
||||||
record["paperless_handoff"] = paperless.assignment_intake_payload(record)
|
record["paperless_handoff"] = paperless.assignment_intake_payload(record)
|
||||||
created = repo.create_submission(record)
|
created = repo.create_submission(record)
|
||||||
return {"status": "ok", "submission": created, "backend": repo.backend}
|
accept = request.headers.get("accept", "")
|
||||||
|
auto_confirmed = None
|
||||||
|
if "text/html" in accept:
|
||||||
|
auto_confirmed = _confirm_submission_to_paperless(created)
|
||||||
|
created = auto_confirmed["submission"]
|
||||||
|
response_payload = {
|
||||||
|
"status": "ok",
|
||||||
|
"submission": created,
|
||||||
|
"submissions": [created],
|
||||||
|
"batch_id": None,
|
||||||
|
"file_count": bundle["file_count"],
|
||||||
|
"paperless": auto_confirmed,
|
||||||
|
"backend": repo.backend,
|
||||||
|
}
|
||||||
|
if "text/html" in accept:
|
||||||
|
redirect_url = "/assignments/upload?submitted=" + str(created["id"]) + "&file_count=" + str(bundle["file_count"]) + "&artifact_kind=" + str(created.get("artifact_kind", "document")) + "&paperless_status=" + str(created.get("status", "uploaded-local"))
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=303)
|
||||||
|
return response_payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{submission_id}/confirm-paperless")
|
@router.post("/{submission_id}/confirm-paperless")
|
||||||
@@ -103,33 +231,7 @@ async def confirm_assignment_paperless(submission_id: str):
|
|||||||
current = next((item for item in submissions if str(item.get("id")) == str(submission_id)), None)
|
current = next((item for item in submissions if str(item.get("id")) == str(submission_id)), None)
|
||||||
if not current:
|
if not current:
|
||||||
raise HTTPException(status_code=404, detail="Submission not found.")
|
raise HTTPException(status_code=404, detail="Submission not found.")
|
||||||
handoff = paperless.assignment_intake_payload(current)
|
return _confirm_submission_to_paperless(current)
|
||||||
file_path = current.get("stored_path") or current.get("local_file")
|
|
||||||
if not file_path or not Path(file_path).exists():
|
|
||||||
raise HTTPException(status_code=400, detail="Stored upload file is missing.")
|
|
||||||
fields = {
|
|
||||||
"class_name": handoff["class_name"],
|
|
||||||
"assignment_name": handoff["assignment_name"],
|
|
||||||
"submission_kind": handoff["submission_kind"],
|
|
||||||
"notes": handoff.get("notes", ""),
|
|
||||||
"source": handoff.get("source", "doris-schoolhouse"),
|
|
||||||
}
|
|
||||||
for optional_key in ("semester", "course_code", "paper_kind", "telegram_chat_id", "telegram_message_id"):
|
|
||||||
value = handoff.get(optional_key)
|
|
||||||
if value:
|
|
||||||
fields[optional_key] = value
|
|
||||||
response = webhooks.post_multipart(handoff["target_url"], fields, "document", file_path)
|
|
||||||
body = response.get("body", {}) if isinstance(response, dict) else {}
|
|
||||||
body_obj = body[0] if isinstance(body, list) and body and isinstance(body[0], dict) else (body if isinstance(body, dict) else {})
|
|
||||||
ok = bool(response.get("ok", False)) if isinstance(response, dict) else False
|
|
||||||
updated = repo.update_submission(submission_id, {
|
|
||||||
"status": "paperless-queued" if ok else "paperless-error",
|
|
||||||
"confirmed_at": store.now(),
|
|
||||||
"webhook_response": response,
|
|
||||||
"paperless_document_id": body_obj.get("paperless_document_id") or body_obj.get("document_id"),
|
|
||||||
"paperless_title": body_obj.get("paperless_title") or body_obj.get("title") or handoff.get("assignment_name"),
|
|
||||||
})
|
|
||||||
return {"status": "ok" if ok else "error", "submission": updated or current, "handoff": handoff, "webhook": response}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{assignment_id}/status")
|
@router.post("/{assignment_id}/status")
|
||||||
|
|||||||
172
home/doris-schoolhouse/app/services/bundles.py
Normal file
172
home/doris-schoolhouse/app/services/bundles.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZIP_DEFLATED, ZipFile
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(value: str, fallback: str = "item") -> str:
|
||||||
|
cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in (value or fallback))
|
||||||
|
parts = [part for part in cleaned.split("-") if part]
|
||||||
|
return "-".join(parts)[:80] or fallback
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionBundleService:
|
||||||
|
def _wrap_pdf_text(self, text: str, width: int = 96) -> list[str]:
|
||||||
|
wrapped: list[str] = []
|
||||||
|
for raw_line in text.splitlines():
|
||||||
|
line = raw_line.rstrip()
|
||||||
|
if not line:
|
||||||
|
wrapped.append("")
|
||||||
|
continue
|
||||||
|
while len(line) > width:
|
||||||
|
wrapped.append(line[:width])
|
||||||
|
line = line[width:]
|
||||||
|
wrapped.append(line)
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
def _escape_pdf_text(self, value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
||||||
|
|
||||||
|
def _write_text_pdf(self, path: Path, title: str, body: str) -> None:
|
||||||
|
lines = self._wrap_pdf_text(f"{title}\n\n{body}")
|
||||||
|
page_height = 792
|
||||||
|
top = 756
|
||||||
|
line_height = 12
|
||||||
|
lines_per_page = 58
|
||||||
|
pages = [lines[i:i + lines_per_page] for i in range(0, len(lines), lines_per_page)] or [[""]]
|
||||||
|
|
||||||
|
objects: list[bytes] = []
|
||||||
|
page_object_numbers: list[int] = []
|
||||||
|
object_number = 4
|
||||||
|
for page_lines in pages:
|
||||||
|
content_lines = ["BT", "/F1 10 Tf", f"72 {top} Td"]
|
||||||
|
first = True
|
||||||
|
for line in page_lines:
|
||||||
|
escaped = self._escape_pdf_text(line)
|
||||||
|
if first:
|
||||||
|
content_lines.append(f"({escaped}) Tj")
|
||||||
|
first = False
|
||||||
|
else:
|
||||||
|
content_lines.append(f"0 -{line_height} Td ({escaped}) Tj")
|
||||||
|
content_lines.append("ET")
|
||||||
|
content_bytes = "\n".join(content_lines).encode("utf-8")
|
||||||
|
page_object_numbers.append(object_number)
|
||||||
|
objects.append(
|
||||||
|
f"{object_number} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 {page_height}] /Contents {object_number + 1} 0 R /Resources << /Font << /F1 3 0 R >> >> >>\nendobj\n".encode("utf-8")
|
||||||
|
)
|
||||||
|
objects.append(
|
||||||
|
f"{object_number + 1} 0 obj\n<< /Length {len(content_bytes)} >>\nstream\n".encode("utf-8") + content_bytes + b"\nendstream\nendobj\n"
|
||||||
|
)
|
||||||
|
object_number += 2
|
||||||
|
|
||||||
|
pages_kids = " ".join(f"{num} 0 R" for num in page_object_numbers)
|
||||||
|
header = b"%PDF-1.4\n"
|
||||||
|
root_objects = [
|
||||||
|
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
||||||
|
f"2 0 obj\n<< /Type /Pages /Kids [{pages_kids}] /Count {len(page_object_numbers)} >>\nendobj\n".encode("utf-8"),
|
||||||
|
b"3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n",
|
||||||
|
]
|
||||||
|
all_objects = root_objects + objects
|
||||||
|
offsets = []
|
||||||
|
current = len(header)
|
||||||
|
for obj in all_objects:
|
||||||
|
offsets.append(current)
|
||||||
|
current += len(obj)
|
||||||
|
xref_start = current
|
||||||
|
xref_lines = [b"xref\n", f"0 {len(all_objects) + 1}\n".encode("utf-8"), b"0000000000 65535 f \n"]
|
||||||
|
for offset in offsets:
|
||||||
|
xref_lines.append(f"{offset:010d} 00000 n \n".encode("utf-8"))
|
||||||
|
trailer = f"trailer\n<< /Size {len(all_objects) + 1} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF\n".encode("utf-8")
|
||||||
|
pdf_bytes = header + b"".join(all_objects) + b"".join(xref_lines) + trailer
|
||||||
|
path.write_bytes(pdf_bytes)
|
||||||
|
|
||||||
|
def build_bundle(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
upload_root: Path,
|
||||||
|
submission_id: str,
|
||||||
|
class_name: str,
|
||||||
|
assignment_name: str,
|
||||||
|
version_label: str,
|
||||||
|
uploads: list[dict],
|
||||||
|
notes: str = "",
|
||||||
|
) -> dict:
|
||||||
|
bundle_root = upload_root / "submission-bundles" / submission_id
|
||||||
|
source_root = bundle_root / "source"
|
||||||
|
source_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
manifest_files = []
|
||||||
|
combined_hash = hashlib.sha256()
|
||||||
|
for item in uploads:
|
||||||
|
source_path = source_root / item["filename"]
|
||||||
|
source_path.write_bytes(item["payload"])
|
||||||
|
combined_hash.update(item["payload"])
|
||||||
|
manifest_files.append(
|
||||||
|
{
|
||||||
|
"filename": item["filename"],
|
||||||
|
"stored_path": str(source_path),
|
||||||
|
"mime_type": item["mime_type"],
|
||||||
|
"size_bytes": len(item["payload"]),
|
||||||
|
"checksum_sha256": hashlib.sha256(item["payload"]).hexdigest(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
source_listing_path = bundle_root / f"{slugify(assignment_name, 'assignment')}-{slugify(version_label, 'v1')}-source-listing.txt"
|
||||||
|
listing_parts = []
|
||||||
|
for item in manifest_files:
|
||||||
|
listing_parts.append(f"===== {item['filename']} =====\n")
|
||||||
|
listing_parts.append(Path(item["stored_path"]).read_text(encoding="utf-8", errors="replace"))
|
||||||
|
listing_parts.append("\n\n")
|
||||||
|
source_listing_path.write_text("".join(listing_parts), encoding="utf-8")
|
||||||
|
|
||||||
|
manifest_path = bundle_root / "manifest.json"
|
||||||
|
manifest = {
|
||||||
|
"class_name": class_name,
|
||||||
|
"assignment_name": assignment_name,
|
||||||
|
"version_label": version_label,
|
||||||
|
"notes": notes,
|
||||||
|
"file_count": len(manifest_files),
|
||||||
|
"files": manifest_files,
|
||||||
|
"source_listing_path": str(source_listing_path),
|
||||||
|
}
|
||||||
|
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
|
||||||
|
|
||||||
|
bundle_name = f"{slugify(class_name, 'class')}-{slugify(assignment_name, 'assignment')}-{slugify(version_label, 'v1')}.zip"
|
||||||
|
bundle_path = bundle_root / bundle_name
|
||||||
|
with ZipFile(bundle_path, "w", compression=ZIP_DEFLATED) as archive:
|
||||||
|
for item in manifest_files:
|
||||||
|
archive.write(item["stored_path"], arcname=item["filename"])
|
||||||
|
archive.write(source_listing_path, arcname=source_listing_path.name)
|
||||||
|
archive.write(manifest_path, arcname="manifest.json")
|
||||||
|
|
||||||
|
receipt_pdf_path = bundle_root / f"{slugify(assignment_name, 'assignment')}-{slugify(version_label, 'v1')}-submission-receipt.pdf"
|
||||||
|
receipt_body = "\n".join([
|
||||||
|
f"Class: {class_name}",
|
||||||
|
f"Assignment: {assignment_name}",
|
||||||
|
f"Version: {version_label}",
|
||||||
|
f"Bundle file: {bundle_name}",
|
||||||
|
f"Source file count: {len(manifest_files)}",
|
||||||
|
"",
|
||||||
|
"Included files:",
|
||||||
|
*[f"- {item['filename']} ({item['size_bytes']} bytes)" for item in manifest_files],
|
||||||
|
"",
|
||||||
|
"Source listing:",
|
||||||
|
source_listing_path.read_text(encoding="utf-8", errors="replace"),
|
||||||
|
])
|
||||||
|
self._write_text_pdf(receipt_pdf_path, f"{class_name} — {assignment_name} — {version_label}", receipt_body)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"artifact_kind": "source_bundle",
|
||||||
|
"bundle_path": str(bundle_path),
|
||||||
|
"bundle_filename": bundle_name,
|
||||||
|
"bundle_mime_type": "application/zip",
|
||||||
|
"bundle_size_bytes": bundle_path.stat().st_size,
|
||||||
|
"bundle_checksum_sha256": hashlib.sha256(bundle_path.read_bytes()).hexdigest(),
|
||||||
|
"file_manifest_json": manifest_files,
|
||||||
|
"file_count": len(manifest_files),
|
||||||
|
"source_root": str(source_root),
|
||||||
|
"source_listing_path": str(source_listing_path),
|
||||||
|
"receipt_pdf_path": str(receipt_pdf_path),
|
||||||
|
"manifest_path": str(manifest_path),
|
||||||
|
"combined_source_checksum_sha256": combined_hash.hexdigest(),
|
||||||
|
}
|
||||||
@@ -91,12 +91,23 @@ class D2LService:
|
|||||||
"status": "completed",
|
"status": "completed",
|
||||||
})
|
})
|
||||||
self.repo.save_sync_runs(sync_runs)
|
self.repo.save_sync_runs(sync_runs)
|
||||||
|
grade_note_results = []
|
||||||
|
try:
|
||||||
|
from app.services.paperless import PaperlessService
|
||||||
|
paperless = PaperlessService()
|
||||||
|
for item in assignments:
|
||||||
|
result = paperless.append_grade_note_if_available(str(item.get("id")))
|
||||||
|
if result.get("status") != "skipped":
|
||||||
|
grade_note_results.append(result)
|
||||||
|
except Exception as exc:
|
||||||
|
grade_note_results.append({"status": "error", "reason": str(exc)})
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"snapshot_path": snapshot.get("source_path"),
|
"snapshot_path": snapshot.get("source_path"),
|
||||||
"course_count": len(courses),
|
"course_count": len(courses),
|
||||||
"assignment_count": len(assignments),
|
"assignment_count": len(assignments),
|
||||||
"backend": self.repo.backend,
|
"backend": self.repo.backend,
|
||||||
|
"grade_note_results": grade_note_results,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_courses(self) -> list[dict[str, Any]]:
|
def get_courses(self) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import re
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -14,6 +15,10 @@ class PaperlessService:
|
|||||||
self.client = PaperlessClient()
|
self.client = PaperlessClient()
|
||||||
|
|
||||||
def assignment_intake_payload(self, submission: dict) -> dict:
|
def assignment_intake_payload(self, submission: dict) -> dict:
|
||||||
|
local_files = submission.get("stored_paths") or []
|
||||||
|
if not local_files and submission.get("stored_path"):
|
||||||
|
local_files = [submission.get("stored_path")]
|
||||||
|
local_file = submission.get("receipt_pdf_path") or submission.get("bundle_path") or submission.get("stored_path")
|
||||||
return {
|
return {
|
||||||
"target_url": urljoin(settings.n8n_base_url, settings.n8n_assignment_webhook),
|
"target_url": urljoin(settings.n8n_base_url, settings.n8n_assignment_webhook),
|
||||||
"class_name": submission.get("class_name"),
|
"class_name": submission.get("class_name"),
|
||||||
@@ -26,8 +31,14 @@ class PaperlessService:
|
|||||||
"source": submission.get("source") or "doris-schoolhouse",
|
"source": submission.get("source") or "doris-schoolhouse",
|
||||||
"telegram_chat_id": submission.get("telegram_chat_id"),
|
"telegram_chat_id": submission.get("telegram_chat_id"),
|
||||||
"telegram_message_id": submission.get("telegram_message_id"),
|
"telegram_message_id": submission.get("telegram_message_id"),
|
||||||
"local_file": submission.get("stored_path"),
|
"local_file": local_file,
|
||||||
|
"local_files": local_files,
|
||||||
"version": submission.get("version_label"),
|
"version": submission.get("version_label"),
|
||||||
|
"batch_id": submission.get("batch_id"),
|
||||||
|
"file_count": submission.get("file_count"),
|
||||||
|
"artifact_kind": submission.get("artifact_kind") or "document",
|
||||||
|
"source_listing_path": submission.get("source_listing_path"),
|
||||||
|
"receipt_pdf_path": submission.get("receipt_pdf_path"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def recording_intake_payload(self, recording: dict) -> dict:
|
def recording_intake_payload(self, recording: dict) -> dict:
|
||||||
@@ -40,21 +51,122 @@ class PaperlessService:
|
|||||||
"source": "doris-schoolhouse",
|
"source": "doris-schoolhouse",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def upload_source_bundle_receipt(self, submission: dict) -> dict:
|
||||||
|
file_path = submission.get("receipt_pdf_path") or submission.get("bundle_path") or submission.get("stored_path")
|
||||||
|
title = self.build_submission_title(submission)
|
||||||
|
return self.client.upload_document(file_path, title)
|
||||||
|
|
||||||
|
def _course_short_label(self, submission: dict) -> str:
|
||||||
|
course_code = str(submission.get("course_code") or "").strip()
|
||||||
|
match = re.search(r"([A-Z]{3,4})_([A-Z]{3,4})_(\d{4})", course_code)
|
||||||
|
if match:
|
||||||
|
return f"{match.group(2)}-{match.group(3)}"
|
||||||
|
class_name = str(submission.get("class_name") or "").strip()
|
||||||
|
match = re.search(r"([A-Za-z]+(?:\s+[A-Za-z]+)*)\s+(\d{4})", class_name)
|
||||||
|
if not match:
|
||||||
|
return class_name
|
||||||
|
words = match.group(1).split()
|
||||||
|
prefix = ''.join(word[0] for word in words[:4]).upper()
|
||||||
|
return f"{prefix}-{match.group(2)}"
|
||||||
|
|
||||||
|
def _course_tag_name(self, submission: dict) -> str | None:
|
||||||
|
label = self._course_short_label(submission)
|
||||||
|
if not label or "-" not in label:
|
||||||
|
return None
|
||||||
|
return label.replace("-", " ").lower()
|
||||||
|
|
||||||
|
def build_submission_title(self, submission: dict) -> str:
|
||||||
|
return " — ".join(
|
||||||
|
part for part in [
|
||||||
|
self._course_short_label(submission) or submission.get("class_name"),
|
||||||
|
submission.get("assignment_name"),
|
||||||
|
submission.get("version_label"),
|
||||||
|
"source bundle" if submission.get("artifact_kind") == "source_bundle" else submission.get("submission_kind"),
|
||||||
|
] if part
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_direct_metadata_payload(self, submission: dict, current_document: dict | None = None) -> dict:
|
||||||
|
current_document = current_document or {}
|
||||||
|
known_tags = {
|
||||||
|
str(tag.get("name", "")).strip().casefold(): tag
|
||||||
|
for tag in self.client.list_tags()
|
||||||
|
}
|
||||||
|
merged_tags = {
|
||||||
|
int(tag_id)
|
||||||
|
for tag_id in (current_document.get("tags") or [])
|
||||||
|
if str(tag_id).isdigit()
|
||||||
|
}
|
||||||
|
for tag_name in ("school", "assignments", "spring-2026"):
|
||||||
|
tag = known_tags.get(tag_name)
|
||||||
|
if tag and tag.get("id"):
|
||||||
|
merged_tags.add(int(tag["id"]))
|
||||||
|
course_tag_name = self._course_tag_name(submission)
|
||||||
|
if course_tag_name:
|
||||||
|
tag = known_tags.get(course_tag_name)
|
||||||
|
if tag and tag.get("id"):
|
||||||
|
merged_tags.add(int(tag["id"]))
|
||||||
|
if submission.get("artifact_kind") == "source_bundle":
|
||||||
|
for tag_name in ("programming", "java"):
|
||||||
|
tag = known_tags.get(tag_name)
|
||||||
|
if tag and tag.get("id"):
|
||||||
|
merged_tags.add(int(tag["id"]))
|
||||||
|
payload = {
|
||||||
|
"title": self.build_submission_title(submission),
|
||||||
|
"tags": sorted(merged_tags),
|
||||||
|
}
|
||||||
|
for doc_type in self.client.list_document_types():
|
||||||
|
if str(doc_type.get("name", "")).strip().casefold() == "school" and doc_type.get("id"):
|
||||||
|
payload["document_type"] = int(doc_type["id"])
|
||||||
|
break
|
||||||
|
for correspondent in self.client.list_correspondents():
|
||||||
|
if str(correspondent.get("name", "")).strip().casefold() == "austin peay state university" and correspondent.get("id"):
|
||||||
|
payload["correspondent"] = int(correspondent["id"])
|
||||||
|
break
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def enrich_direct_submission(self, submission: dict, upload_response: dict | None = None) -> dict:
|
||||||
|
upload_response = upload_response or {}
|
||||||
|
task_id = upload_response.get("task_id") or upload_response.get("id") or submission.get("paperless_task_id")
|
||||||
|
if not task_id:
|
||||||
|
raise ValueError("Paperless upload did not return a task id")
|
||||||
|
task = self.client.wait_for_task(str(task_id))
|
||||||
|
status = str(task.get("status") or "").upper()
|
||||||
|
document_id = task.get("related_document")
|
||||||
|
if status != "SUCCESS":
|
||||||
|
duplicate_match = re.search(r"#(\d+)\)", str(task.get("result") or ""))
|
||||||
|
if duplicate_match:
|
||||||
|
document_id = duplicate_match.group(1)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Paperless task did not complete successfully: {status or 'unknown'}")
|
||||||
|
if not document_id:
|
||||||
|
raise ValueError("Paperless task completed without a related document id")
|
||||||
|
current = self.client.get_document(document_id)
|
||||||
|
payload = self.build_direct_metadata_payload(submission, current)
|
||||||
|
updated = self.client.update_document(document_id, payload)
|
||||||
|
return {
|
||||||
|
"task": task,
|
||||||
|
"document": updated,
|
||||||
|
"document_id": updated.get("id") or current.get("id") or int(document_id),
|
||||||
|
"title": updated.get("title") or payload.get("title"),
|
||||||
|
"metadata_payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
def build_grade_note(self, assignment_id: str) -> str:
|
def build_grade_note(self, assignment_id: str) -> str:
|
||||||
submissions = self.repo.list_submissions()
|
submissions = self.repo.list_submissions()
|
||||||
assignments = {item.get("id"): item for item in self.repo.list_assignments()}
|
assignments = {item.get("id"): item for item in self.repo.list_assignments()}
|
||||||
assignment = assignments.get(str(assignment_id), {})
|
assignment = assignments.get(str(assignment_id), {})
|
||||||
linked = [s for s in submissions if str(s.get("assignment_id")) == str(assignment_id)]
|
linked = [s for s in submissions if str(s.get("assignment_id")) == str(assignment_id)]
|
||||||
note_lines = [
|
note_lines = [
|
||||||
"Grade sync from Doris Schoolhouse",
|
"D2L grade note imported from Doris Schoolhouse.",
|
||||||
f"Assignment: {assignment.get('title', 'unknown')}",
|
f"Assignment: {assignment.get('title', 'unknown')}",
|
||||||
]
|
]
|
||||||
if assignment.get("grade"):
|
if assignment.get("grade"):
|
||||||
note_lines.append(f"Grade: {assignment['grade']}")
|
note_lines.append(f"Grade: {assignment['grade']}")
|
||||||
if assignment.get("points"):
|
comments = str(assignment.get("comments") or "").strip()
|
||||||
note_lines.append(f"Points: {assignment['points']}")
|
if comments:
|
||||||
if assignment.get("comments"):
|
note_lines.append(f"Comments: {comments}")
|
||||||
note_lines.append(f"Professor comments: {assignment['comments']}")
|
else:
|
||||||
|
note_lines.append("Comments: No professor comment captured in the D2L scrape.")
|
||||||
if linked:
|
if linked:
|
||||||
note_lines.append(f"Linked submissions: {len(linked)}")
|
note_lines.append(f"Linked submissions: {len(linked)}")
|
||||||
return "\n".join(note_lines)
|
return "\n".join(note_lines)
|
||||||
@@ -107,3 +219,17 @@ class PaperlessService:
|
|||||||
"note_hash": note_hash,
|
"note_hash": note_hash,
|
||||||
"paperless_response": response,
|
"paperless_response": response,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def append_grade_note_if_available(self, assignment_id: str) -> dict:
|
||||||
|
assignments = {item.get("id"): item for item in self.repo.list_assignments()}
|
||||||
|
assignment = assignments.get(str(assignment_id), {})
|
||||||
|
if not assignment:
|
||||||
|
return {"status": "skipped", "reason": "assignment-not-found", "assignment_id": assignment_id}
|
||||||
|
if not (assignment.get("grade") or str(assignment.get("comments") or "").strip()):
|
||||||
|
return {"status": "skipped", "reason": "no-grade-or-comments", "assignment_id": assignment_id}
|
||||||
|
linked = [s for s in self.repo.list_submissions() if str(s.get("assignment_id")) == str(assignment_id)]
|
||||||
|
if not linked:
|
||||||
|
return {"status": "skipped", "reason": "no-linked-submissions", "assignment_id": assignment_id}
|
||||||
|
if not any(item.get("paperless_document_id") for item in linked):
|
||||||
|
return {"status": "skipped", "reason": "no-paperless-document-id", "assignment_id": assignment_id}
|
||||||
|
return self.append_grade_note(assignment_id)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from urllib import request
|
from urllib import request
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
@@ -27,5 +30,96 @@ class PaperlessClient:
|
|||||||
def get_document(self, document_id: int | str) -> dict:
|
def get_document(self, document_id: int | str) -> dict:
|
||||||
return self._req('GET', f'/api/documents/{document_id}/')
|
return self._req('GET', f'/api/documents/{document_id}/')
|
||||||
|
|
||||||
|
def update_document(self, document_id: int | str, payload: dict) -> dict:
|
||||||
|
return self._req('PATCH', f'/api/documents/{document_id}/', payload)
|
||||||
|
|
||||||
def add_note(self, document_id: int | str, note: str) -> dict:
|
def add_note(self, document_id: int | str, note: str) -> dict:
|
||||||
return self._req('POST', f'/api/documents/{document_id}/notes/', {'note': note})
|
return self._req('POST', f'/api/documents/{document_id}/notes/', {'note': note})
|
||||||
|
|
||||||
|
def _list_endpoint(self, path: str) -> list[dict]:
|
||||||
|
data = self._req('GET', path)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
if isinstance(data, dict):
|
||||||
|
results = data.get('results')
|
||||||
|
if isinstance(results, list):
|
||||||
|
return results
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_tags(self) -> list[dict]:
|
||||||
|
return self._list_endpoint('/api/tags/?page_size=200')
|
||||||
|
|
||||||
|
def create_tag(self, name: str) -> dict:
|
||||||
|
return self._req('POST', '/api/tags/', {'name': name})
|
||||||
|
|
||||||
|
def get_or_create_tag(self, name: str) -> dict:
|
||||||
|
needle = name.strip().casefold()
|
||||||
|
for tag in self.list_tags():
|
||||||
|
if str(tag.get('name', '')).strip().casefold() == needle:
|
||||||
|
return tag
|
||||||
|
return self.create_tag(name)
|
||||||
|
|
||||||
|
def list_document_types(self) -> list[dict]:
|
||||||
|
return self._list_endpoint('/api/document_types/?page_size=200')
|
||||||
|
|
||||||
|
def list_correspondents(self) -> list[dict]:
|
||||||
|
return self._list_endpoint('/api/correspondents/?page_size=200')
|
||||||
|
|
||||||
|
def list_tasks(self, task_id: str | None = None) -> list[dict]:
|
||||||
|
path = '/api/tasks/?page_size=100'
|
||||||
|
if task_id:
|
||||||
|
path += f'&task_id={task_id}'
|
||||||
|
return self._list_endpoint(path)
|
||||||
|
|
||||||
|
def wait_for_task(self, task_id: str, timeout_seconds: int = 90, poll_seconds: float = 1.0) -> dict:
|
||||||
|
deadline = time.time() + timeout_seconds
|
||||||
|
last = {}
|
||||||
|
while time.time() < deadline:
|
||||||
|
tasks = self.list_tasks(task_id=task_id)
|
||||||
|
if tasks:
|
||||||
|
last = tasks[0]
|
||||||
|
status = str(last.get('status') or '').upper()
|
||||||
|
if status in {'SUCCESS', 'FAILURE', 'CANCELLED'}:
|
||||||
|
return last
|
||||||
|
time.sleep(poll_seconds)
|
||||||
|
return last
|
||||||
|
|
||||||
|
def upload_document(self, file_path: str, title: str) -> dict:
|
||||||
|
path = Path(file_path)
|
||||||
|
boundary = "----DorisSchoolhousePaperlessUpload"
|
||||||
|
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
|
parts: list[bytes] = []
|
||||||
|
for key, value in {"title": title}.items():
|
||||||
|
parts.extend([
|
||||||
|
f"--{boundary}\r\n".encode(),
|
||||||
|
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode(),
|
||||||
|
str(value).encode(),
|
||||||
|
b"\r\n",
|
||||||
|
])
|
||||||
|
parts.extend([
|
||||||
|
f"--{boundary}\r\n".encode(),
|
||||||
|
f'Content-Disposition: form-data; name="document"; filename="{path.name}"\r\n'.encode(),
|
||||||
|
f"Content-Type: {mime}\r\n\r\n".encode(),
|
||||||
|
path.read_bytes(),
|
||||||
|
b"\r\n",
|
||||||
|
f"--{boundary}--\r\n".encode(),
|
||||||
|
])
|
||||||
|
body = b"".join(parts)
|
||||||
|
req = request.Request(
|
||||||
|
urljoin(self.base_url, "/api/documents/post_document/"),
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Token {self.token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with request.urlopen(req, timeout=120) as resp:
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
if isinstance(parsed, str):
|
||||||
|
return {"task_id": parsed}
|
||||||
|
return parsed
|
||||||
|
|||||||
@@ -227,6 +227,23 @@ class PostgresRepository:
|
|||||||
s.paperless_note_last_hash,
|
s.paperless_note_last_hash,
|
||||||
s.submitted_at,
|
s.submitted_at,
|
||||||
s.confirmed_metadata_json,
|
s.confirmed_metadata_json,
|
||||||
|
s.confirmed_metadata_json->>'batch_id' AS batch_id,
|
||||||
|
s.confirmed_metadata_json->>'file_count' AS file_count,
|
||||||
|
s.confirmed_metadata_json->>'file_index' AS file_index,
|
||||||
|
s.confirmed_metadata_json->>'submission_kind' AS submission_kind,
|
||||||
|
s.confirmed_metadata_json->>'artifact_kind' AS artifact_kind,
|
||||||
|
s.confirmed_metadata_json->>'course_code' AS course_code,
|
||||||
|
s.confirmed_metadata_json->>'paper_kind' AS paper_kind,
|
||||||
|
s.confirmed_metadata_json->>'source' AS source,
|
||||||
|
s.confirmed_metadata_json->>'notes' AS notes,
|
||||||
|
s.confirmed_metadata_json->>'received_at' AS received_at,
|
||||||
|
s.confirmed_metadata_json->>'bundle_path' AS bundle_path,
|
||||||
|
s.confirmed_metadata_json->>'bundle_filename' AS bundle_filename,
|
||||||
|
s.confirmed_metadata_json->>'source_listing_path' AS source_listing_path,
|
||||||
|
s.confirmed_metadata_json->>'receipt_pdf_path' AS receipt_pdf_path,
|
||||||
|
s.confirmed_metadata_json->>'paperless_task_id' AS paperless_task_id,
|
||||||
|
s.confirmed_metadata_json->'file_manifest_json' AS file_manifest_json,
|
||||||
|
s.confirmed_metadata_json->'stored_paths' AS stored_paths,
|
||||||
s.confirmed_metadata_json->>'stored_path' AS stored_path,
|
s.confirmed_metadata_json->>'stored_path' AS stored_path,
|
||||||
s.confirmed_metadata_json->>'local_file' AS local_file
|
s.confirmed_metadata_json->>'local_file' AS local_file
|
||||||
FROM schoolhouse_submission s
|
FROM schoolhouse_submission s
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ from urllib import error, request
|
|||||||
|
|
||||||
|
|
||||||
class WebhookService:
|
class WebhookService:
|
||||||
def _multipart_body(self, fields: dict[str, str], file_field: str, file_path: str) -> tuple[bytes, str]:
|
def _multipart_parts_for_fields(self, boundary: str, fields: dict[str, str]) -> list[bytes]:
|
||||||
boundary = f"----DorisSchoolhouse{uuid.uuid4().hex}"
|
|
||||||
parts: list[bytes] = []
|
parts: list[bytes] = []
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
parts.extend([
|
parts.extend([
|
||||||
@@ -16,16 +15,32 @@ class WebhookService:
|
|||||||
str(value).encode(),
|
str(value).encode(),
|
||||||
b"\r\n",
|
b"\r\n",
|
||||||
])
|
])
|
||||||
|
return parts
|
||||||
|
|
||||||
|
def _multipart_parts_for_file(self, boundary: str, file_field: str, file_path: str) -> list[bytes]:
|
||||||
path = Path(file_path)
|
path = Path(file_path)
|
||||||
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
parts.extend([
|
return [
|
||||||
f"--{boundary}\r\n".encode(),
|
f"--{boundary}\r\n".encode(),
|
||||||
f'Content-Disposition: form-data; name="{file_field}"; filename="{path.name}"\r\n'.encode(),
|
f'Content-Disposition: form-data; name="{file_field}"; filename="{path.name}"\r\n'.encode(),
|
||||||
f"Content-Type: {mime}\r\n\r\n".encode(),
|
f"Content-Type: {mime}\r\n\r\n".encode(),
|
||||||
path.read_bytes(),
|
path.read_bytes(),
|
||||||
b"\r\n",
|
b"\r\n",
|
||||||
f"--{boundary}--\r\n".encode(),
|
]
|
||||||
])
|
|
||||||
|
def _multipart_body(self, fields: dict[str, str], file_field: str, file_path: str) -> tuple[bytes, str]:
|
||||||
|
boundary = f"----DorisSchoolhouse{uuid.uuid4().hex}"
|
||||||
|
parts = self._multipart_parts_for_fields(boundary, fields)
|
||||||
|
parts.extend(self._multipart_parts_for_file(boundary, file_field, file_path))
|
||||||
|
parts.append(f"--{boundary}--\r\n".encode())
|
||||||
|
return b"".join(parts), boundary
|
||||||
|
|
||||||
|
def _multipart_body_many(self, fields: dict[str, str], file_field: str, file_paths: list[str]) -> tuple[bytes, str]:
|
||||||
|
boundary = f"----DorisSchoolhouse{uuid.uuid4().hex}"
|
||||||
|
parts = self._multipart_parts_for_fields(boundary, fields)
|
||||||
|
for file_path in file_paths:
|
||||||
|
parts.extend(self._multipart_parts_for_file(boundary, file_field, file_path))
|
||||||
|
parts.append(f"--{boundary}--\r\n".encode())
|
||||||
return b"".join(parts), boundary
|
return b"".join(parts), boundary
|
||||||
|
|
||||||
def post_multipart(self, url: str, fields: dict[str, str], file_field: str, file_path: str) -> dict:
|
def post_multipart(self, url: str, fields: dict[str, str], file_field: str, file_path: str) -> dict:
|
||||||
@@ -50,3 +65,26 @@ class WebhookService:
|
|||||||
except error.HTTPError as exc:
|
except error.HTTPError as exc:
|
||||||
raw = exc.read().decode("utf-8", errors="replace")
|
raw = exc.read().decode("utf-8", errors="replace")
|
||||||
return {"ok": False, "status_code": exc.code, "body": parse_raw(raw), "error": str(exc)}
|
return {"ok": False, "status_code": exc.code, "body": parse_raw(raw), "error": str(exc)}
|
||||||
|
|
||||||
|
def post_multipart_many(self, url: str, fields: dict[str, str], file_field: str, file_paths: list[str]) -> dict:
|
||||||
|
body, boundary = self._multipart_body_many(fields, file_field, file_paths)
|
||||||
|
req = request.Request(
|
||||||
|
url,
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_raw(raw: str):
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return {"raw": raw}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with request.urlopen(req, timeout=120) as response:
|
||||||
|
raw = response.read().decode("utf-8", errors="replace")
|
||||||
|
return {"ok": True, "status_code": response.status, "body": parse_raw(raw)}
|
||||||
|
except error.HTTPError as exc:
|
||||||
|
raw = exc.read().decode("utf-8", errors="replace")
|
||||||
|
return {"ok": False, "status_code": exc.code, "body": parse_raw(raw), "error": str(exc)}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ nav a { color: var(--accent); text-decoration: none; }
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notice-card { margin-bottom: 20px; }
|
||||||
|
.success-card {
|
||||||
|
border-color: #14532d;
|
||||||
|
background: linear-gradient(180deg, rgba(20, 83, 45, 0.28), rgba(17, 24, 39, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
.form-card { max-width: 760px; }
|
.form-card { max-width: 760px; }
|
||||||
.stack { display: grid; gap: 14px; }
|
.stack { display: grid; gap: 14px; }
|
||||||
label { display: grid; gap: 8px; color: var(--muted); }
|
label { display: grid; gap: 8px; color: var(--muted); }
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ async function loadDashboard() {
|
|||||||
setText('sync-status', `Last sync: ${data.latest_sync.completed_at || 'unknown'} · ${data.latest_sync.assignment_count || 0} assignment rows`);
|
setText('sync-status', `Last sync: ${data.latest_sync.completed_at || 'unknown'} · ${data.latest_sync.assignment_count || 0} assignment rows`);
|
||||||
}
|
}
|
||||||
renderList('recent-assignments', data.recent_assignments || [], (item) => `<strong>${item.title || 'Untitled'}</strong><small>${item.course_name || ''}</small><small>Status: ${item.status || 'assigned'} · Grade: ${item.grade || '—'}</small>`);
|
renderList('recent-assignments', data.recent_assignments || [], (item) => `<strong>${item.title || 'Untitled'}</strong><small>${item.course_name || ''}</small><small>Status: ${item.status || 'assigned'} · Grade: ${item.grade || '—'}</small>`);
|
||||||
renderList('recent-submissions', data.recent_submissions || [], (item) => `<strong>${item.assignment_name || item.original_filename || 'Submission'}</strong><small>${item.class_name || ''}</small><small>Status: ${item.status || 'unknown'} · File: ${item.original_filename || '—'}</small>`);
|
renderList('recent-submissions', data.recent_submissions || [], (item) => {
|
||||||
|
const count = Number(item.file_count || (item.file_manifest_json || []).length || 1);
|
||||||
|
const artifact = item.artifact_kind === 'source_bundle' ? 'Source bundle' : 'File';
|
||||||
|
return `<strong>${item.assignment_name || item.original_filename || 'Submission'}</strong><small>${item.class_name || ''}</small><small>Status: ${item.status || 'unknown'} · ${artifact}: ${item.original_filename || '—'} · ${count} file${count === 1 ? '' : 's'}</small>`;
|
||||||
|
});
|
||||||
renderList('recent-recordings', data.recent_recordings || [], (item) => `<strong>${item.original_filename || 'Recording'}</strong><small>${item.session_date || ''}</small><small>Transcription: ${item.transcription_status || '—'} · Summary: ${item.summary_status || '—'}</small>`);
|
renderList('recent-recordings', data.recent_recordings || [], (item) => `<strong>${item.original_filename || 'Recording'}</strong><small>${item.session_date || ''}</small><small>Transcription: ${item.transcription_status || '—'} · Summary: ${item.summary_status || '—'}</small>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% if request.query_params.get('submitted') %}
|
||||||
|
<section class="card form-card notice-card success-card">
|
||||||
|
<h2>Submission Created</h2>
|
||||||
|
<p>Submission {{ request.query_params.get('submitted') }} was created successfully.</p>
|
||||||
|
<p class="muted">Artifact: {{ request.query_params.get('artifact_kind', 'document') }} · Files: {{ request.query_params.get('file_count', '1') }}</p>
|
||||||
|
<p class="muted">Paperless status: {{ request.query_params.get('paperless_status', 'uploaded-local') }}</p>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
<section class="card form-card">
|
<section class="card form-card">
|
||||||
<h2>Assignment Upload</h2>
|
<h2>Assignment Upload</h2>
|
||||||
<form class="stack" method="post" enctype="multipart/form-data" action="/api/assignments/intake" id="assignment-upload-form">
|
<form class="stack" method="post" enctype="multipart/form-data" action="/api/assignments/intake" id="assignment-upload-form">
|
||||||
@@ -15,9 +23,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Version <input name="version" value="v1"></label>
|
<label>Version <input name="version" value="v1"></label>
|
||||||
|
<p class="muted">Programming-class uploads are bundled into one zip artifact for archival. The source files stay grouped together as one submission.</p>
|
||||||
<label>Notes <textarea name="notes" rows="4" placeholder="Anything useful about this turn-in."></textarea></label>
|
<label>Notes <textarea name="notes" rows="4" placeholder="Anything useful about this turn-in."></textarea></label>
|
||||||
<label>File <input type="file" name="file" required></label>
|
<label>Files
|
||||||
<button type="submit">Upload for metadata confirmation</button>
|
<input type="file" name="files" multiple required>
|
||||||
|
<small class="muted">Pick one or many files for the same assignment version. Schoolhouse will build one source bundle.</small>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Build submission bundle</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
34
home/doris-schoolhouse/bin/backup-to-serenity.sh
Executable file
34
home/doris-schoolhouse/bin/backup-to-serenity.sh
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERENITY_NFS_EXPORT="${SERENITY_NFS_EXPORT:-10.5.1.5:/mnt/user/data}"
|
||||||
|
SERENITY_MOUNT_POINT="${SERENITY_MOUNT_POINT:-/mnt/serenity-data}"
|
||||||
|
SCHOOLHOUSE_DATA_ROOT="${SCHOOLHOUSE_DATA_ROOT:-/opt/doris-schoolhouse/data}"
|
||||||
|
SERENITY_BACKUP_SUBDIR="${SERENITY_BACKUP_SUBDIR:-backups/nomad/doris-schoolhouse}"
|
||||||
|
RSYNC_BIN="${RSYNC_BIN:-/usr/bin/rsync}"
|
||||||
|
SUDO_BIN="${SUDO_BIN:-/usr/bin/sudo}"
|
||||||
|
MOUNT_BIN="${MOUNT_BIN:-/usr/bin/mount}"
|
||||||
|
DATE_BIN="${DATE_BIN:-/usr/bin/date}"
|
||||||
|
HOSTNAME_BIN="${HOSTNAME_BIN:-/usr/bin/hostname}"
|
||||||
|
|
||||||
|
if [[ ! -d "$SCHOOLHOUSE_DATA_ROOT" ]]; then
|
||||||
|
echo "Schoolhouse data root missing: $SCHOOLHOUSE_DATA_ROOT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$SUDO_BIN" mkdir -p "$SERENITY_MOUNT_POINT"
|
||||||
|
if ! mountpoint -q "$SERENITY_MOUNT_POINT"; then
|
||||||
|
"$SUDO_BIN" "$MOUNT_BIN" -t nfs -o rw,nfsvers=4 "$SERENITY_NFS_EXPORT" "$SERENITY_MOUNT_POINT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
dest_root="$SERENITY_MOUNT_POINT/$SERENITY_BACKUP_SUBDIR"
|
||||||
|
mkdir -p "$dest_root/data"
|
||||||
|
|
||||||
|
"$RSYNC_BIN" -rlt --delete --omit-dir-times --no-perms --no-owner --no-group "$SCHOOLHOUSE_DATA_ROOT/" "$dest_root/data/"
|
||||||
|
|
||||||
|
stamp="$("$DATE_BIN" -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
host_name="$("$HOSTNAME_BIN")"
|
||||||
|
printf '%s\n' "$stamp" > "$dest_root/last_success_utc.txt"
|
||||||
|
printf '%s\n' "$host_name" > "$dest_root/source_host.txt"
|
||||||
|
|
||||||
|
echo "Schoolhouse backup synced to $dest_root at $stamp"
|
||||||
@@ -1,21 +1,36 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ "$#" -lt 2 ]; then
|
if [ "$#" -lt 2 ]; then
|
||||||
echo "usage: $0 <file> <class_name> [assignment_name]" >&2
|
echo "usage: $0 <file> [more_files ...] <class_name> [assignment_name]" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
FILE="$1"
|
|
||||||
CLASS_NAME="$2"
|
ARGS=("$@")
|
||||||
ASSIGNMENT_NAME="${3:-Sample Assignment}"
|
COUNT=$#
|
||||||
|
if [ "$COUNT" -eq 2 ]; then
|
||||||
|
FILES=("${ARGS[0]}")
|
||||||
|
CLASS_NAME="${ARGS[1]}"
|
||||||
|
ASSIGNMENT_NAME="Sample Assignment"
|
||||||
|
elif [ "$COUNT" -eq 3 ]; then
|
||||||
|
FILES=("${ARGS[0]}")
|
||||||
|
CLASS_NAME="${ARGS[1]}"
|
||||||
|
ASSIGNMENT_NAME="${ARGS[2]}"
|
||||||
|
else
|
||||||
|
FILES=("${ARGS[@]:0:$COUNT-2}")
|
||||||
|
CLASS_NAME="${ARGS[$COUNT-2]}"
|
||||||
|
ASSIGNMENT_NAME="${ARGS[$COUNT-1]}"
|
||||||
|
fi
|
||||||
|
|
||||||
TARGET="${N8N_BASE_URL:-https://n8n.example.com}${SCHOOLHOUSE_N8N_ASSIGNMENT_WEBHOOK:-/webhook/school/intake/upload}"
|
TARGET="${N8N_BASE_URL:-https://n8n.example.com}${SCHOOLHOUSE_N8N_ASSIGNMENT_WEBHOOK:-/webhook/school/intake/upload}"
|
||||||
cat <<MSG
|
|
||||||
Dry-run assignment handoff preview
|
echo "Dry-run assignment handoff preview"
|
||||||
Target: $TARGET
|
echo "Target: $TARGET"
|
||||||
Command:
|
echo "Command:"
|
||||||
curl -X POST "$TARGET" \\
|
echo "curl -X POST \"$TARGET\" \\"
|
||||||
-F "document=@$FILE" \\
|
for file in "${FILES[@]}"; do
|
||||||
-F "class_name=$CLASS_NAME" \\
|
echo " -F \"document=@$file\" \\"
|
||||||
-F "assignment_name=$ASSIGNMENT_NAME" \\
|
done
|
||||||
-F "submission_kind=assignment_turn_in" \\
|
echo " -F \"class_name=$CLASS_NAME\" \\"
|
||||||
-F "source=doris-schoolhouse"
|
echo " -F \"assignment_name=$ASSIGNMENT_NAME\" \\"
|
||||||
MSG
|
echo " -F \"submission_kind=assignment_turn_in\" \\"
|
||||||
|
echo " -F \"source=doris-schoolhouse\""
|
||||||
|
|||||||
Reference in New Issue
Block a user