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
31 changes: 31 additions & 0 deletions .github/workflows/flutter-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
paths:
- ".github/workflows/flutter-ci.yml"
- "README.md"
- "feedback_service/**"
- "flutter_app/**"
push:
branches:
Expand All @@ -14,6 +15,7 @@ on:
paths:
- ".github/workflows/flutter-ci.yml"
- "README.md"
- "feedback_service/**"
- "flutter_app/**"
workflow_dispatch:
inputs:
Expand All @@ -35,6 +37,35 @@ env:
FLUTTER_APP_DIR: flutter_app

jobs:
validate-feedback-service:
name: Validate feedback service
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: |
feedback_service/requirements.txt
feedback_service/requirements-dev.txt

- name: Install feedback dependencies
run: python -m pip install --requirement feedback_service/requirements-dev.txt

- name: Lint feedback service
run: python -m ruff check feedback_service

- name: Check feedback formatting
run: python -m ruff format --check feedback_service

- name: Test feedback service
run: python -m pytest -q feedback_service/tests

validate:
name: Validate Flutter app
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
/build
/captures
.externalNativeBuild
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,23 @@ flutter run -d chrome \
--dart-define=STUDYOS_DEMO_OPENROUTER_API_KEY="$OPENROUTER_API_KEY"
```

## Course Ratings Deployment

The **Course ratings** view connects to the containerized SQLite service under
`feedback_service/`. That service proxies the shared StudyPlanner catalog and
stores pseudonymous star ratings plus moderated comments. Deploy it behind
HTTPS, then provide its public base URL to Flutter builds:

```sh
flutter build web --release \
--dart-define=STUDYOS_FEEDBACK_API_URL=https://feedback.example.edu
```

The URL isn't a secret. Moderator credentials remain server-side environment
variables; never embed them—or a GitHub write token—in a Flutter build. See
`feedback_service/README.md` for deployment, moderation, backup, retention, and
restore guidance.

## Migration Notes

- Flutter owns the main chat UI, input bar, status display, navigation, and
Expand Down
9 changes: 4 additions & 5 deletions docs/university_feature_port.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,10 @@
setup in Settings with secure local storage and expose only a compact Home
shortcut once configured, so the card is useful without taking over the main
app navigation.
11. Add feedback without a database or GitHub account requirement for students.
The app creates GitHub issues directly with a developer-provided build
credential for `Tue-StudyOS/StudyOS_Agent` with `Issues: write`, for example
through `--dart-define=STUDYOS_FEEDBACK_TOKEN=...`. Do not expose this token
in the user-facing app.
11. Route ratings and optional comments through the dedicated feedback API.
The API issues a pseudonymous installation token that the app keeps in
secure local storage; release builds must never contain a GitHub write token
or moderator credential. Comments require human approval before publication.

## UX Shape

Expand Down
17 changes: 17 additions & 0 deletions feedback_service/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM python:3.12-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1

RUN addgroup --system --gid 10001 feedback \
&& adduser --system --uid 10001 --ingroup feedback --home /app feedback
WORKDIR /app
COPY feedback_service/requirements.txt ./feedback_service/requirements.txt
RUN pip install --requirement feedback_service/requirements.txt
COPY feedback_service ./feedback_service
RUN mkdir /data /backups && chown feedback:feedback /data /backups

USER feedback
EXPOSE 8080
CMD ["uvicorn", "feedback_service.main:app", "--host", "0.0.0.0", "--port", "8080", "--no-access-log", "--proxy-headers", "--forwarded-allow-ips", "127.0.0.1"]
117 changes: 117 additions & 0 deletions feedback_service/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# StudyOS course ratings service

A small FastAPI service backed by SQLite. It proxies public, read-only course
lookup to StudyPlanner and stores ratings by stable course number. Ratings are
public immediately while optional comments enter a moderation queue. Anonymous
installation credentials are random 256-bit bearer tokens; only their SHA-256
digests are stored.
Moderation is manual by default, so there is no third-party API, data transfer,
or usage-based moderation bill.

## Run

Copy `compose.example.yml`, generate a long random `FEEDBACK_ADMIN_TOKEN`, set
the exact frontend origins in `FEEDBACK_CORS_ORIGINS`, and run
`docker compose up --build`. The SQLite database is kept in the named volume.
The API schema is available at `/docs`; health checks use `/healthz`.

Never embed the admin token in a client. Put this service behind HTTPS and a
reverse proxy. The in-process limits (10 installation issuances per peer and 30
writes per author per minute by default) are only a deterministic safety net;
the proxy must enforce distributed IP and global limits, body-size limits, and
request timeouts. Configure trusted proxy IPs in the container command before
relying on forwarded client addresses. Token issuance is intentionally
anonymous and therefore Sybil-prone; comments default to `pending` for this
reason.

## Configuration

- `FEEDBACK_DATABASE_PATH` (default `/data/feedback.sqlite3`)
- `FEEDBACK_ADMIN_TOKEN` (admin endpoints remain inaccessible when empty)
- `FEEDBACK_MODERATOR_ID` (audit identity bound to the admin token)
- `FEEDBACK_CATALOG_API_URL` (default: the public StudyPlanner API)
- `FEEDBACK_CATALOG_RATE_LIMIT` (per peer/minute; default `30`)
- `FEEDBACK_CORS_ORIGINS` (comma-separated exact origins; empty disables CORS)
- `FEEDBACK_INSTALLATION_RATE_LIMIT` and `FEEDBACK_AUTHOR_RATE_LIMIT`

## Moderation operations

List pending or reported comments from a trusted admin machine, then publish,
reject, or delete the text. Admin deletion leaves the numeric rating active;
owner deletion removes both rating and comment from public results.

```sh
curl -H "X-Admin-Token: $FEEDBACK_ADMIN_TOKEN" \
-H "X-Moderator-Id: $FEEDBACK_MODERATOR_ID" \
"https://feedback.example.edu/v1/admin/moderation?state=pending&limit=50"

curl -X POST \
-H "X-Admin-Token: $FEEDBACK_ADMIN_TOKEN" \
-H "X-Moderator-Id: $FEEDBACK_MODERATOR_ID" \
-H "Content-Type: application/json" \
-d '{"action":"publish","reason":"manual review"}' \
"https://feedback.example.edu/v1/admin/feedback/FEEDBACK_ID/moderation"
```

Every moderation action records the supplied moderator identifier in
`moderation_audit`; reject/delete actions require a reason. The configured ID is
bound to the admin secret instead of trusting an arbitrary request label. Use
separate service deployments/credentials if independent moderator attribution is
required. Reasons and reports are never returned by
public endpoints. Rotate the admin token after suspected exposure. This version
intentionally has no automated third-party moderation:
provider privacy, retention, failure behavior, and billing must be reviewed
before such an integration is enabled, and failures must fall back to pending
human review.

## Course identity and privacy boundary

`POST /v1/courses/search` forwards only the public search text to the shared
StudyPlanner catalog and caches up to 64 bounded results in memory for five
minutes. POST keeps search terms out of URL history and ordinary access logs;
operators must still avoid request-body logging. ALMA
credentials, cookies, enrollment lists, profiles, and timetables never reach
this service. Course feedback is grouped longitudinally by course number, so
different semesters contribute to one course's aggregate.

The “My courses” shortcuts are computed in the app and are not uploaded as a
list; selecting one sends its title as a catalog search. Installation tokens do
not prove a unique person, university membership,
or enrollment. These are community ratings, not verified-student reviews.

Schema v1 belonged to the abandoned app/service-rating prototype. The service
refuses to relabel that data as course feedback; back it up and recreate the
volume before starting this schema-v2 build.

## Launch and retention checklist

Before public deployment, the team must name a moderation owner, response
expectation, and escalation/contact path. Publish a short notice covering the
pseudonymous installation identifier, public ratings/comments, report and
moderation processing, deletion behavior, backup location/expiry, and operator
contact. Choose and automate retention for rejected/deleted text, audit events,
reports, inactive installations, and backups; this repository does not invent a
course-wide retention period. Do not collect university credentials, email
addresses, profile data, or raw device fingerprints in this service.

## Backup and restore

Create an online, transactionally consistent backup inside the running
container and verify it before copying it to separate storage:

```sh
docker compose exec feedback python -m feedback_service.backup create \
/data/feedback.sqlite3 /backups/feedback-$(date +%F).sqlite3
docker compose exec feedback python -m feedback_service.backup verify \
/backups/feedback-$(date +%F).sqlite3
```

Backups still contain comments and pseudonymous installation hashes; restrict
their access and define an expiry matching the project's retention notice. To
restore, stop the service, verify the selected backup, replace
`/data/feedback.sqlite3`, remove stale `-wal`/`-shm` sidecars, then restart and
check `/healthz` plus a public read. Practice this against a disposable volume
before launch.

Run one application process per SQLite volume; scale at the reverse proxy only
after moving to a multi-writer database.
1 change: 1 addition & 0 deletions feedback_service/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Small, self-hosted feedback service for StudyOS."""
55 changes: 55 additions & 0 deletions feedback_service/backup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from __future__ import annotations

import argparse
import os
import sqlite3
from pathlib import Path


def create_backup(source: Path, destination: Path) -> None:
if source.resolve() == destination.resolve():
raise ValueError("source and destination must differ")
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f".{destination.name}.tmp")
temporary.unlink(missing_ok=True)
try:
with (
sqlite3.connect(source) as source_db,
sqlite3.connect(temporary) as backup_db,
):
source_db.backup(backup_db)
result = backup_db.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
raise RuntimeError(f"backup integrity check failed: {result}")
os.replace(temporary, destination)
finally:
temporary.unlink(missing_ok=True)


def verify_backup(path: Path) -> None:
uri = f"file:{path.resolve()}?mode=ro"
with sqlite3.connect(uri, uri=True) as database:
result = database.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
raise RuntimeError(f"backup integrity check failed: {result}")


def main() -> None:
parser = argparse.ArgumentParser(
description="Create or verify a StudyOS feedback backup"
)
subcommands = parser.add_subparsers(dest="command", required=True)
create = subcommands.add_parser("create")
create.add_argument("source", type=Path)
create.add_argument("destination", type=Path)
verify = subcommands.add_parser("verify")
verify.add_argument("path", type=Path)
arguments = parser.parse_args()
if arguments.command == "create":
create_backup(arguments.source, arguments.destination)
else:
verify_backup(arguments.path)


if __name__ == "__main__":
main()
Loading