Build Forgejo-backed community prototype
This commit is contained in:
parent
797ae5ea35
commit
6671a01d26
16 changed files with 2485 additions and 293 deletions
|
|
@ -1,7 +1,11 @@
|
||||||
|
APP_BASE_URL=http://kacper-dev-pod:8800
|
||||||
FORGEJO_BASE_URL=https://aksal.cloud
|
FORGEJO_BASE_URL=https://aksal.cloud
|
||||||
FORGEJO_TOKEN=
|
FORGEJO_TOKEN=
|
||||||
|
FORGEJO_OAUTH_CLIENT_ID=
|
||||||
|
FORGEJO_OAUTH_CLIENT_SECRET=
|
||||||
|
FORGEJO_OAUTH_SCOPES=openid profile
|
||||||
FORGEJO_REPO_SCAN_LIMIT=30
|
FORGEJO_REPO_SCAN_LIMIT=30
|
||||||
FORGEJO_RECENT_ISSUE_LIMIT=6
|
FORGEJO_RECENT_ISSUE_LIMIT=6
|
||||||
FORGEJO_REQUEST_TIMEOUT_SECONDS=10.0
|
FORGEJO_REQUEST_TIMEOUT_SECONDS=10.0
|
||||||
CALENDAR_FEED_URLS=
|
CALENDAR_FEED_URLS=
|
||||||
CALENDAR_EVENT_LIMIT=6
|
CALENDAR_EVENT_LIMIT=3
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,10 @@ cp .env.example .env
|
||||||
Useful variables:
|
Useful variables:
|
||||||
|
|
||||||
- `FORGEJO_BASE_URL=https://aksal.cloud`
|
- `FORGEJO_BASE_URL=https://aksal.cloud`
|
||||||
|
- `APP_BASE_URL=http://kacper-dev-pod:8800`
|
||||||
|
- `FORGEJO_OAUTH_CLIENT_ID=...`
|
||||||
|
- `FORGEJO_OAUTH_CLIENT_SECRET=...`
|
||||||
|
- `FORGEJO_OAUTH_SCOPES=openid profile`
|
||||||
- `FORGEJO_TOKEN=...`
|
- `FORGEJO_TOKEN=...`
|
||||||
- `CALENDAR_FEED_URLS=webcal://...`
|
- `CALENDAR_FEED_URLS=webcal://...`
|
||||||
- `HOST=0.0.0.0`
|
- `HOST=0.0.0.0`
|
||||||
|
|
@ -69,7 +73,10 @@ Useful variables:
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `FORGEJO_TOKEN` is required for live repo and discussion reads on `aksal.cloud`.
|
- Browser sign-in uses Forgejo OAuth/OIDC. `APP_BASE_URL` must match the URL opened in the browser, and the Forgejo OAuth app must include `/api/auth/forgejo/callback` under that base URL.
|
||||||
|
- Browser OAuth requests only identity scopes. The backend stores the resulting Forgejo token server-side and may use it only after enforcing public-repository checks.
|
||||||
|
- `FORGEJO_TOKEN` is optional and should be treated as a read-only local fallback. Browser sessions and API token calls may write comments only after verifying the target repo is public.
|
||||||
|
- API clients can query with `Authorization: token ...` or `Authorization: Bearer ...`.
|
||||||
- `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds.
|
- `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds.
|
||||||
- Do not commit `.env` or `.env.local`.
|
- Do not commit `.env` or `.env.local`.
|
||||||
|
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -38,17 +38,34 @@ HOST=0.0.0.0 PORT=8800 ./scripts/start.sh
|
||||||
Optional live Forgejo configuration:
|
Optional live Forgejo configuration:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
export APP_BASE_URL="http://kacper-dev-pod:8800"
|
||||||
export FORGEJO_BASE_URL="https://aksal.cloud"
|
export FORGEJO_BASE_URL="https://aksal.cloud"
|
||||||
export FORGEJO_TOKEN="your-forgejo-api-token"
|
export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id"
|
||||||
|
export FORGEJO_OAUTH_CLIENT_SECRET="your-forgejo-oauth-client-secret"
|
||||||
|
export FORGEJO_OAUTH_SCOPES="openid profile"
|
||||||
export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com/other.ics"
|
export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com/other.ics"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`APP_BASE_URL` must match the URL you use in the browser. Create the OAuth app in Forgejo with this redirect URI:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://kacper-dev-pod:8800/api/auth/forgejo/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
`FORGEJO_TOKEN` is optional. When set, it is a read fallback for local development. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity for public repo reads and public issue replies. The backend must verify repositories are public before reading discussion data or writing comments.
|
||||||
|
|
||||||
Or put those values in `.env`:
|
Or put those values in `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Sign in through `/signin` using Forgejo OAuth, or query the API directly with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: token your-forgejo-api-token" http://127.0.0.1:8800/api/prototype
|
||||||
|
```
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
269
app.py
269
app.py
|
|
@ -1,14 +1,26 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import Body, FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from auth import (
|
||||||
|
OAuthStateRecord,
|
||||||
|
clear_login_session,
|
||||||
|
consume_oauth_state,
|
||||||
|
create_login_session,
|
||||||
|
create_oauth_state,
|
||||||
|
current_session_user,
|
||||||
|
resolve_forgejo_token,
|
||||||
|
)
|
||||||
|
from forgejo_client import ForgejoClient, ForgejoClientError
|
||||||
from live_prototype import build_live_prototype_payload
|
from live_prototype import build_live_prototype_payload
|
||||||
from settings import get_settings
|
from settings import Settings, get_settings
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
DIST_DIR = BASE_DIR / "frontend" / "dist"
|
DIST_DIR = BASE_DIR / "frontend" / "dist"
|
||||||
|
|
@ -29,8 +41,135 @@ def create_app() -> FastAPI:
|
||||||
return JSONResponse({"status": "ok"})
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
@app.get("/api/prototype")
|
@app.get("/api/prototype")
|
||||||
async def prototype() -> JSONResponse:
|
async def prototype(request: Request) -> JSONResponse:
|
||||||
return JSONResponse(await build_live_prototype_payload(get_settings()))
|
settings = get_settings()
|
||||||
|
session_user = current_session_user(request)
|
||||||
|
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
|
||||||
|
return JSONResponse(
|
||||||
|
await build_live_prototype_payload(
|
||||||
|
settings,
|
||||||
|
forgejo_token=forgejo_token,
|
||||||
|
auth_source=auth_source,
|
||||||
|
session_user=session_user,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/auth/session")
|
||||||
|
async def auth_session(request: Request) -> JSONResponse:
|
||||||
|
session_user = current_session_user(request)
|
||||||
|
if session_user:
|
||||||
|
return JSONResponse(_auth_payload(session_user, "session"))
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
|
||||||
|
if not forgejo_token or auth_source == "server":
|
||||||
|
return JSONResponse(_auth_payload(None, "none"))
|
||||||
|
|
||||||
|
async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
|
||||||
|
try:
|
||||||
|
user = await client.fetch_current_user()
|
||||||
|
except ForgejoClientError as error:
|
||||||
|
raise HTTPException(status_code=401, detail=str(error)) from error
|
||||||
|
|
||||||
|
return JSONResponse(_auth_payload(user, auth_source))
|
||||||
|
|
||||||
|
@app.get("/api/auth/forgejo/start")
|
||||||
|
async def forgejo_auth_start(request: Request, return_to: str = "/") -> RedirectResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
if not _oauth_configured(settings):
|
||||||
|
return _signin_error_redirect("Forgejo OAuth is not configured on the site yet.")
|
||||||
|
|
||||||
|
redirect_uri = _oauth_redirect_uri(request, settings)
|
||||||
|
state, code_challenge = create_oauth_state(redirect_uri, return_to)
|
||||||
|
|
||||||
|
async with ForgejoClient(settings) as client:
|
||||||
|
try:
|
||||||
|
oidc = await client.fetch_openid_configuration()
|
||||||
|
except ForgejoClientError as error:
|
||||||
|
return _signin_error_redirect(str(error))
|
||||||
|
|
||||||
|
authorization_endpoint = str(oidc.get("authorization_endpoint") or "")
|
||||||
|
if not authorization_endpoint:
|
||||||
|
return _signin_error_redirect("Forgejo did not return an OAuth authorization endpoint.")
|
||||||
|
|
||||||
|
query = urlencode(
|
||||||
|
{
|
||||||
|
"client_id": settings.forgejo_oauth_client_id or "",
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": " ".join(settings.forgejo_oauth_scopes),
|
||||||
|
"state": state,
|
||||||
|
"code_challenge": code_challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return RedirectResponse(f"{authorization_endpoint}?{query}", status_code=303)
|
||||||
|
|
||||||
|
@app.get("/api/auth/forgejo/callback")
|
||||||
|
async def forgejo_auth_callback(
|
||||||
|
code: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> RedirectResponse:
|
||||||
|
if error:
|
||||||
|
return _signin_error_redirect(f"Forgejo sign-in failed: {error}")
|
||||||
|
if not code or not state:
|
||||||
|
return _signin_error_redirect("Forgejo did not return the expected sign-in data.")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if not _oauth_configured(settings):
|
||||||
|
return _signin_error_redirect("Forgejo OAuth is not configured on the site yet.")
|
||||||
|
|
||||||
|
oauth_state = consume_oauth_state(state)
|
||||||
|
if oauth_state is None:
|
||||||
|
return _signin_error_redirect("The Forgejo sign-in request expired. Try again.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
access_token = await _exchange_forgejo_code(settings, code, oauth_state)
|
||||||
|
user = await _fetch_forgejo_oidc_user(settings, access_token)
|
||||||
|
except ForgejoClientError as exchange_error:
|
||||||
|
return _signin_error_redirect(str(exchange_error))
|
||||||
|
|
||||||
|
response = RedirectResponse(oauth_state.return_to, status_code=303)
|
||||||
|
create_login_session(response, access_token, user)
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.delete("/api/auth/session")
|
||||||
|
async def delete_auth_session(request: Request) -> JSONResponse:
|
||||||
|
response = JSONResponse(_auth_payload(None, "none"))
|
||||||
|
clear_login_session(request, response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.post("/api/discussions/replies")
|
||||||
|
async def create_discussion_reply(
|
||||||
|
request: Request,
|
||||||
|
payload: dict[str, object] = Body(...),
|
||||||
|
) -> JSONResponse:
|
||||||
|
owner = _required_string(payload, "owner")
|
||||||
|
repo = _required_string(payload, "repo")
|
||||||
|
body = _required_string(payload, "body")
|
||||||
|
issue_number = _required_positive_int(payload, "number")
|
||||||
|
settings = get_settings()
|
||||||
|
forgejo_token, auth_source = resolve_forgejo_token(request, settings)
|
||||||
|
if not forgejo_token or auth_source == "server":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Sign in or send an Authorization token before replying.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with ForgejoClient(settings, forgejo_token=forgejo_token) as client:
|
||||||
|
try:
|
||||||
|
repo_payload = await client.fetch_repository(owner, repo)
|
||||||
|
if repo_payload.get("private"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="This site only writes to public Forgejo repositories.",
|
||||||
|
)
|
||||||
|
comment = await client.create_issue_comment(owner, repo, issue_number, body)
|
||||||
|
except ForgejoClientError as error:
|
||||||
|
raise HTTPException(status_code=502, detail=str(error)) from error
|
||||||
|
|
||||||
|
return JSONResponse(_discussion_reply(comment))
|
||||||
|
|
||||||
if DIST_DIR.exists():
|
if DIST_DIR.exists():
|
||||||
assets_dir = DIST_DIR / "assets"
|
assets_dir = DIST_DIR / "assets"
|
||||||
|
|
@ -53,3 +192,123 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def _required_string(payload: dict[str, object], key: str) -> str:
|
||||||
|
value = payload.get(key)
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise HTTPException(status_code=400, detail=f"{key} is required.")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _required_positive_int(payload: dict[str, object], key: str) -> int:
|
||||||
|
value = payload.get(key)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
|
||||||
|
|
||||||
|
if isinstance(value, int):
|
||||||
|
parsed = value
|
||||||
|
elif isinstance(value, str) and value.isdigit():
|
||||||
|
parsed = int(value)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
|
||||||
|
|
||||||
|
if parsed < 1:
|
||||||
|
raise HTTPException(status_code=400, detail=f"{key} must be a positive integer.")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
|
||||||
|
author = comment.get("user") or {}
|
||||||
|
body = str(comment.get("body", "") or "").strip()
|
||||||
|
if not body:
|
||||||
|
body = "No comment body provided."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": int(comment.get("id", 0)),
|
||||||
|
"author": author.get("login", "Unknown author"),
|
||||||
|
"avatar_url": author.get("avatar_url", ""),
|
||||||
|
"body": body,
|
||||||
|
"created_at": comment.get("created_at", ""),
|
||||||
|
"html_url": comment.get("html_url", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_payload(user: dict[str, Any] | None, source: str) -> dict[str, object]:
|
||||||
|
oauth_configured = _oauth_configured(get_settings())
|
||||||
|
if not user:
|
||||||
|
return {
|
||||||
|
"authenticated": False,
|
||||||
|
"login": None,
|
||||||
|
"source": source,
|
||||||
|
"can_reply": source in {"authorization", "session"},
|
||||||
|
"oauth_configured": oauth_configured,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"authenticated": True,
|
||||||
|
"login": user.get("login", "Unknown user"),
|
||||||
|
"source": source,
|
||||||
|
"can_reply": source in {"authorization", "session"},
|
||||||
|
"oauth_configured": oauth_configured,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_configured(settings: Settings) -> bool:
|
||||||
|
return bool(settings.forgejo_oauth_client_id and settings.forgejo_oauth_client_secret)
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_redirect_uri(request: Request, settings: Settings) -> str:
|
||||||
|
if settings.app_base_url:
|
||||||
|
return f"{settings.app_base_url}/api/auth/forgejo/callback"
|
||||||
|
return str(request.url_for("forgejo_auth_callback"))
|
||||||
|
|
||||||
|
|
||||||
|
def _signin_error_redirect(message: str) -> RedirectResponse:
|
||||||
|
return RedirectResponse(f"/signin?{urlencode({'error': message})}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
async def _exchange_forgejo_code(
|
||||||
|
settings: Settings,
|
||||||
|
code: str,
|
||||||
|
oauth_state: OAuthStateRecord,
|
||||||
|
) -> str:
|
||||||
|
async with ForgejoClient(settings) as client:
|
||||||
|
oidc = await client.fetch_openid_configuration()
|
||||||
|
token_endpoint = str(oidc.get("token_endpoint") or "")
|
||||||
|
if not token_endpoint:
|
||||||
|
raise ForgejoClientError("Forgejo did not return an OAuth token endpoint.")
|
||||||
|
|
||||||
|
token_payload = await client.exchange_oauth_code(
|
||||||
|
token_endpoint=token_endpoint,
|
||||||
|
client_id=settings.forgejo_oauth_client_id or "",
|
||||||
|
client_secret=settings.forgejo_oauth_client_secret or "",
|
||||||
|
code=code,
|
||||||
|
redirect_uri=oauth_state.redirect_uri,
|
||||||
|
code_verifier=oauth_state.code_verifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = token_payload.get("access_token")
|
||||||
|
if not isinstance(access_token, str) or not access_token:
|
||||||
|
raise ForgejoClientError("Forgejo did not return an access token.")
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_forgejo_oidc_user(settings: Settings, access_token: str) -> dict[str, Any]:
|
||||||
|
async with ForgejoClient(settings) as client:
|
||||||
|
oidc = await client.fetch_openid_configuration()
|
||||||
|
userinfo_endpoint = str(oidc.get("userinfo_endpoint") or "")
|
||||||
|
if not userinfo_endpoint:
|
||||||
|
raise ForgejoClientError("Forgejo did not return an OIDC UserInfo endpoint.")
|
||||||
|
|
||||||
|
userinfo = await client.fetch_userinfo(userinfo_endpoint, access_token)
|
||||||
|
|
||||||
|
login = userinfo.get("preferred_username") or userinfo.get("name") or userinfo.get("sub")
|
||||||
|
if not isinstance(login, str) or not login:
|
||||||
|
raise ForgejoClientError("Forgejo did not return a usable user identity.")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"login": login,
|
||||||
|
"avatar_url": userinfo.get("picture", ""),
|
||||||
|
"email": userinfo.get("email", ""),
|
||||||
|
}
|
||||||
|
|
|
||||||
142
auth.py
Normal file
142
auth.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from base64 import urlsafe_b64encode
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
|
||||||
|
from settings import Settings
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME = "robot_u_session"
|
||||||
|
SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 14
|
||||||
|
OAUTH_STATE_MAX_AGE_SECONDS = 60 * 10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionRecord:
|
||||||
|
forgejo_token: str | None
|
||||||
|
user: dict[str, Any]
|
||||||
|
created_at: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OAuthStateRecord:
|
||||||
|
redirect_uri: str
|
||||||
|
return_to: str
|
||||||
|
code_verifier: str
|
||||||
|
created_at: float
|
||||||
|
|
||||||
|
|
||||||
|
_SESSIONS: dict[str, SessionRecord] = {}
|
||||||
|
_OAUTH_STATES: dict[str, OAuthStateRecord] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_forgejo_token(request: Request, settings: Settings) -> tuple[str | None, str]:
|
||||||
|
header_token = _authorization_token(request.headers.get("authorization"))
|
||||||
|
if header_token:
|
||||||
|
return header_token, "authorization"
|
||||||
|
|
||||||
|
session = _session_from_request(request)
|
||||||
|
if session and session.forgejo_token:
|
||||||
|
return session.forgejo_token, "session"
|
||||||
|
|
||||||
|
if settings.forgejo_token:
|
||||||
|
return settings.forgejo_token, "server"
|
||||||
|
|
||||||
|
return None, "none"
|
||||||
|
|
||||||
|
|
||||||
|
def current_session_user(request: Request) -> dict[str, Any] | None:
|
||||||
|
session = _session_from_request(request)
|
||||||
|
return session.user if session else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_login_session(
|
||||||
|
response: Response,
|
||||||
|
forgejo_token: str | None,
|
||||||
|
user: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
session_id = secrets.token_urlsafe(32)
|
||||||
|
_SESSIONS[session_id] = SessionRecord(
|
||||||
|
forgejo_token=forgejo_token,
|
||||||
|
user=user,
|
||||||
|
created_at=time.time(),
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE_NAME,
|
||||||
|
session_id,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=SESSION_MAX_AGE_SECONDS,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_login_session(request: Request, response: Response) -> None:
|
||||||
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if session_id:
|
||||||
|
_SESSIONS.pop(session_id, None)
|
||||||
|
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||||
|
|
||||||
|
|
||||||
|
def create_oauth_state(redirect_uri: str, return_to: str) -> tuple[str, str]:
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
code_verifier = secrets.token_urlsafe(64)
|
||||||
|
_OAUTH_STATES[state] = OAuthStateRecord(
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
return_to=_safe_return_path(return_to),
|
||||||
|
code_verifier=code_verifier,
|
||||||
|
created_at=time.time(),
|
||||||
|
)
|
||||||
|
return state, code_challenge(code_verifier)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_oauth_state(state: str) -> OAuthStateRecord | None:
|
||||||
|
record = _OAUTH_STATES.pop(state, None)
|
||||||
|
if not record:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if time.time() - record.created_at > OAUTH_STATE_MAX_AGE_SECONDS:
|
||||||
|
return None
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def code_challenge(code_verifier: str) -> str:
|
||||||
|
digest = sha256(code_verifier.encode("ascii")).digest()
|
||||||
|
return urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _authorization_token(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts = value.strip().split(None, 1)
|
||||||
|
if len(parts) == 2 and parts[0].lower() in {"bearer", "token"}:
|
||||||
|
return parts[1].strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_return_path(value: str) -> str:
|
||||||
|
if not value.startswith("/") or value.startswith("//"):
|
||||||
|
return "/"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _session_from_request(request: Request) -> SessionRecord | None:
|
||||||
|
session_id = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session = _SESSIONS.get(session_id)
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if time.time() - session.created_at > SESSION_MAX_AGE_SECONDS:
|
||||||
|
_SESSIONS.pop(session_id, None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return session
|
||||||
70
blogs/building-robot-u-site/index.md
Normal file
70
blogs/building-robot-u-site/index.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
---
|
||||||
|
title: Building Robot U
|
||||||
|
summary: How the Robot U community site became a thin, friendly layer over Forgejo.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Building Robot U
|
||||||
|
|
||||||
|
Robot U started with a simple goal: give a small robotics community one place to learn, share projects, and ask for help without inventing a second source of truth.
|
||||||
|
|
||||||
|
The site is intentionally built as a thin web layer over Forgejo. Forgejo stores the real community content. Repositories hold courses and posts. Issues hold discussions. OAuth lets members sign in with the same identity they already use for code and collaboration.
|
||||||
|
|
||||||
|
## What The Site Does
|
||||||
|
|
||||||
|
The first prototype focuses on the core community loop:
|
||||||
|
|
||||||
|
- Visitors can read public courses, blog posts, discussions, replies, and upcoming events without signing in.
|
||||||
|
- Members can sign in with Forgejo OAuth.
|
||||||
|
- Signed-in members can reply to discussions from the site while still posting as their real Forgejo account.
|
||||||
|
- Courses are discovered from public repositories that contain a `lessons/` folder.
|
||||||
|
- Blog content is discovered from public repositories that contain a `blogs/` folder.
|
||||||
|
- Discussions are backed by Forgejo issues, so the Forgejo API remains the durable storage layer.
|
||||||
|
- Events are loaded from an ICS calendar feed.
|
||||||
|
|
||||||
|
That means the website is not competing with Forgejo. It is presenting Forgejo content in a way that makes sense for learners.
|
||||||
|
|
||||||
|
## Why Courses Are Repositories
|
||||||
|
|
||||||
|
Courses need more than one page of text. A robotics lesson might include diagrams, code samples, CAD files, firmware, or wiring references. Keeping a lesson in a repository makes those materials easy to version, review, and download.
|
||||||
|
|
||||||
|
The current course convention is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lessons/
|
||||||
|
01-getting-started/
|
||||||
|
01-blink-an-led/
|
||||||
|
index.md
|
||||||
|
example-code/
|
||||||
|
wiring.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Each lesson is just a folder with a Markdown file and any supporting assets it needs. Adding a lesson is a normal Git change. Editing a lesson is a normal Git change. Creating a new course is creating a new public repository with a `lessons/` folder.
|
||||||
|
|
||||||
|
## Why Discussions Are Issues
|
||||||
|
|
||||||
|
Forum posts and blockers should not disappear into a custom database if the website changes. Backing discussions with Forgejo issues gives the community a durable model that already supports authorship, replies, labels, links, and permissions.
|
||||||
|
|
||||||
|
The site reads public issues for general discussions and shows replies under each discussion. When a signed-in user replies from the website, the backend uses that user's Forgejo session token so the comment belongs to the real person, not a bot account.
|
||||||
|
|
||||||
|
## Anonymous Reading, Signed-In Writing
|
||||||
|
|
||||||
|
A key design rule is that reading should be easy. Public learning content should be visible without an account.
|
||||||
|
|
||||||
|
Writing is different. Replies and future content actions should require a real Forgejo identity. This keeps the contribution model clear:
|
||||||
|
|
||||||
|
- Public visitors can read.
|
||||||
|
- Members sign in with Forgejo to participate.
|
||||||
|
- Forgejo remains the place that enforces repository and issue permissions.
|
||||||
|
|
||||||
|
## What Is Next
|
||||||
|
|
||||||
|
This prototype is enough to prove the shape of the system, but there is plenty left to improve:
|
||||||
|
|
||||||
|
- Render individual blog posts as first-class pages.
|
||||||
|
- Add user profiles and progress tracking.
|
||||||
|
- Improve course navigation for longer curricula.
|
||||||
|
- Add search across courses, posts, and discussions.
|
||||||
|
- Use Forgejo webhooks and server-sent events so site activity updates quickly.
|
||||||
|
- Add better issue-to-lesson linking so discussions can appear directly under the related lesson.
|
||||||
|
|
||||||
|
The important part is that the foundation is simple. Robot U can grow as a community learning site without hiding the tools and workflows that make technical collaboration work.
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, date, datetime, time
|
from datetime import UTC, datetime, time, timedelta
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
MAX_RECURRENCE_OCCURRENCES = 120
|
||||||
|
RECURRENCE_LOOKAHEAD_DAYS = 365
|
||||||
|
|
||||||
|
|
||||||
class CalendarFeedError(RuntimeError):
|
class CalendarFeedError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
@ -29,7 +33,10 @@ class CalendarFeed:
|
||||||
async def fetch_calendar_feed(url: str, timeout_seconds: float) -> CalendarFeed:
|
async def fetch_calendar_feed(url: str, timeout_seconds: float) -> CalendarFeed:
|
||||||
normalized_url = _normalize_calendar_url(url)
|
normalized_url = _normalize_calendar_url(url)
|
||||||
async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client:
|
||||||
response = await client.get(normalized_url)
|
response = await client.get(
|
||||||
|
normalized_url,
|
||||||
|
headers={"User-Agent": "RobotUCalendar/0.1 (+https://aksal.cloud)"},
|
||||||
|
)
|
||||||
|
|
||||||
if not response.is_success:
|
if not response.is_success:
|
||||||
raise CalendarFeedError(f"{response.status_code} from calendar feed {normalized_url}")
|
raise CalendarFeedError(f"{response.status_code} from calendar feed {normalized_url}")
|
||||||
|
|
@ -55,6 +62,7 @@ def _parse_ics(raw_text: str, feed_url: str) -> tuple[str, list[CalendarEvent]]:
|
||||||
lines = _unfold_ics_lines(raw_text)
|
lines = _unfold_ics_lines(raw_text)
|
||||||
calendar_name = _calendar_name(lines, feed_url)
|
calendar_name = _calendar_name(lines, feed_url)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
recurrence_until = now + timedelta(days=RECURRENCE_LOOKAHEAD_DAYS)
|
||||||
events: list[CalendarEvent] = []
|
events: list[CalendarEvent] = []
|
||||||
|
|
||||||
current_event: dict[str, str] | None = None
|
current_event: dict[str, str] | None = None
|
||||||
|
|
@ -63,9 +71,14 @@ def _parse_ics(raw_text: str, feed_url: str) -> tuple[str, list[CalendarEvent]]:
|
||||||
current_event = {}
|
current_event = {}
|
||||||
continue
|
continue
|
||||||
if line == "END:VEVENT":
|
if line == "END:VEVENT":
|
||||||
parsed_event = _event_from_properties(current_event or {}, calendar_name)
|
events.extend(
|
||||||
if parsed_event and parsed_event.starts_at >= now:
|
_events_from_properties(
|
||||||
events.append(parsed_event)
|
current_event or {},
|
||||||
|
calendar_name,
|
||||||
|
now=now,
|
||||||
|
recurrence_until=recurrence_until,
|
||||||
|
),
|
||||||
|
)
|
||||||
current_event = None
|
current_event = None
|
||||||
continue
|
continue
|
||||||
if current_event is None or ":" not in line:
|
if current_event is None or ":" not in line:
|
||||||
|
|
@ -105,23 +118,139 @@ def _calendar_host(feed_url: str) -> str:
|
||||||
return parsed.hostname or "Calendar"
|
return parsed.hostname or "Calendar"
|
||||||
|
|
||||||
|
|
||||||
def _event_from_properties(properties: dict[str, str], calendar_name: str) -> CalendarEvent | None:
|
def _events_from_properties(
|
||||||
|
properties: dict[str, str],
|
||||||
|
calendar_name: str,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
recurrence_until: datetime,
|
||||||
|
) -> list[CalendarEvent]:
|
||||||
title = _decode_ics_text(properties.get("SUMMARY", "").strip())
|
title = _decode_ics_text(properties.get("SUMMARY", "").strip())
|
||||||
start_key = next((key for key in properties if key.startswith("DTSTART")), None)
|
start_key = next((key for key in properties if key.startswith("DTSTART")), None)
|
||||||
if not title or not start_key:
|
if not title or not start_key:
|
||||||
return None
|
return []
|
||||||
|
|
||||||
starts_at = _parse_ics_datetime(start_key, properties[start_key])
|
starts_at = _parse_ics_datetime(start_key, properties[start_key])
|
||||||
if starts_at is None:
|
if starts_at is None:
|
||||||
return None
|
return []
|
||||||
|
|
||||||
location = _decode_ics_text(properties.get("LOCATION", "").strip())
|
location = _decode_ics_text(properties.get("LOCATION", "").strip())
|
||||||
return CalendarEvent(
|
event_template = {
|
||||||
title=title,
|
"title": title,
|
||||||
starts_at=starts_at,
|
"source": calendar_name,
|
||||||
source=calendar_name,
|
"mode": location or "Calendar",
|
||||||
mode=location or "Calendar",
|
}
|
||||||
|
recurrence_rule = properties.get("RRULE")
|
||||||
|
if not recurrence_rule:
|
||||||
|
if starts_at >= now:
|
||||||
|
return [CalendarEvent(starts_at=starts_at, **event_template)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
CalendarEvent(starts_at=occurrence, **event_template)
|
||||||
|
for occurrence in _recurrence_occurrences(
|
||||||
|
starts_at,
|
||||||
|
recurrence_rule,
|
||||||
|
now=now,
|
||||||
|
recurrence_until=recurrence_until,
|
||||||
|
start_key=start_key,
|
||||||
)
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _recurrence_occurrences(
|
||||||
|
starts_at: datetime,
|
||||||
|
raw_rule: str,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
recurrence_until: datetime,
|
||||||
|
start_key: str,
|
||||||
|
) -> list[datetime]:
|
||||||
|
rule = _parse_recurrence_rule(raw_rule)
|
||||||
|
frequency = rule.get("FREQ", "").upper()
|
||||||
|
interval = _positive_int(rule.get("INTERVAL"), default=1)
|
||||||
|
count = _positive_int(rule.get("COUNT"), default=MAX_RECURRENCE_OCCURRENCES)
|
||||||
|
until = _parse_recurrence_until(rule.get("UNTIL"), start_key)
|
||||||
|
effective_until = min(
|
||||||
|
[candidate for candidate in (until, recurrence_until) if candidate is not None],
|
||||||
|
default=recurrence_until,
|
||||||
|
)
|
||||||
|
occurrences: list[datetime] = []
|
||||||
|
occurrence = starts_at
|
||||||
|
|
||||||
|
for index in range(count):
|
||||||
|
if occurrence > effective_until:
|
||||||
|
break
|
||||||
|
if occurrence >= now:
|
||||||
|
occurrences.append(occurrence)
|
||||||
|
if len(occurrences) >= MAX_RECURRENCE_OCCURRENCES:
|
||||||
|
break
|
||||||
|
|
||||||
|
next_occurrence = _next_recurrence_occurrence(occurrence, frequency, interval)
|
||||||
|
if next_occurrence is None or next_occurrence <= occurrence:
|
||||||
|
break
|
||||||
|
occurrence = next_occurrence
|
||||||
|
|
||||||
|
return occurrences
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_recurrence_rule(raw_rule: str) -> dict[str, str]:
|
||||||
|
rule: dict[str, str] = {}
|
||||||
|
for segment in raw_rule.split(";"):
|
||||||
|
if "=" not in segment:
|
||||||
|
continue
|
||||||
|
key, value = segment.split("=", 1)
|
||||||
|
rule[key.strip().upper()] = value.strip()
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_int(value: str | None, *, default: int) -> int:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return parsed if parsed > 0 else default
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_recurrence_until(value: str | None, start_key: str) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return _parse_ics_datetime(start_key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_recurrence_occurrence(
|
||||||
|
occurrence: datetime,
|
||||||
|
frequency: str,
|
||||||
|
interval: int,
|
||||||
|
) -> datetime | None:
|
||||||
|
if frequency == "DAILY":
|
||||||
|
return occurrence + timedelta(days=interval)
|
||||||
|
if frequency == "WEEKLY":
|
||||||
|
return occurrence + timedelta(weeks=interval)
|
||||||
|
if frequency == "MONTHLY":
|
||||||
|
return _add_months(occurrence, interval)
|
||||||
|
if frequency == "YEARLY":
|
||||||
|
return _add_months(occurrence, interval * 12)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _add_months(value: datetime, months: int) -> datetime:
|
||||||
|
month_index = value.month - 1 + months
|
||||||
|
year = value.year + month_index // 12
|
||||||
|
month = month_index % 12 + 1
|
||||||
|
day = min(value.day, _days_in_month(year, month))
|
||||||
|
return value.replace(year=year, month=month, day=day)
|
||||||
|
|
||||||
|
|
||||||
|
def _days_in_month(year: int, month: int) -> int:
|
||||||
|
if month == 12:
|
||||||
|
next_month = datetime(year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
next_month = datetime(year, month + 1, 1)
|
||||||
|
this_month = datetime(year, month, 1)
|
||||||
|
return (next_month - this_month).days
|
||||||
|
|
||||||
|
|
||||||
def _parse_ics_datetime(key: str, value: str) -> datetime | None:
|
def _parse_ics_datetime(key: str, value: str) -> datetime | None:
|
||||||
|
|
@ -132,13 +261,31 @@ def _parse_ics_datetime(key: str, value: str) -> datetime | None:
|
||||||
if value.endswith("Z"):
|
if value.endswith("Z"):
|
||||||
return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
|
return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
|
||||||
if "T" in value:
|
if "T" in value:
|
||||||
return datetime.strptime(value, "%Y%m%dT%H%M%S").replace(tzinfo=UTC)
|
return _with_ics_timezone(datetime.strptime(value, "%Y%m%dT%H%M%S"), key)
|
||||||
parsed_date = datetime.strptime(value, "%Y%m%d").date()
|
parsed_date = datetime.strptime(value, "%Y%m%d").date()
|
||||||
return datetime.combine(parsed_date, time.min, tzinfo=UTC)
|
return datetime.combine(parsed_date, time.min, tzinfo=UTC)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _with_ics_timezone(value: datetime, key: str) -> datetime:
|
||||||
|
timezone_name = _ics_timezone_name(key)
|
||||||
|
if not timezone_name:
|
||||||
|
return value.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return value.replace(tzinfo=ZoneInfo(timezone_name))
|
||||||
|
except ZoneInfoNotFoundError:
|
||||||
|
return value.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _ics_timezone_name(key: str) -> str | None:
|
||||||
|
for segment in key.split(";")[1:]:
|
||||||
|
if segment.startswith("TZID="):
|
||||||
|
return segment.split("=", 1)[1].strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _decode_ics_text(value: str) -> str:
|
def _decode_ics_text(value: str) -> str:
|
||||||
return (
|
return (
|
||||||
value.replace("\\n", "\n")
|
value.replace("\\n", "\n")
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@ class ForgejoClientError(RuntimeError):
|
||||||
|
|
||||||
|
|
||||||
class ForgejoClient:
|
class ForgejoClient:
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings, forgejo_token: str | None = None) -> None:
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
|
self._forgejo_token = forgejo_token or settings.forgejo_token
|
||||||
self._client = httpx.AsyncClient(timeout=settings.forgejo_request_timeout_seconds)
|
self._client = httpx.AsyncClient(timeout=settings.forgejo_request_timeout_seconds)
|
||||||
|
|
||||||
async def __aenter__(self) -> ForgejoClient:
|
async def __aenter__(self) -> ForgejoClient:
|
||||||
|
|
@ -30,6 +31,54 @@ class ForgejoClient:
|
||||||
absolute_url=True,
|
absolute_url=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def exchange_oauth_code(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
token_endpoint: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
code: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
code_verifier: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
response = await self._client.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"code_verifier": code_verifier,
|
||||||
|
},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
if response.is_success:
|
||||||
|
payload = response.json()
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
raise ForgejoClientError("Unexpected Forgejo OAuth token payload.")
|
||||||
|
|
||||||
|
message = self._extract_error_message(response)
|
||||||
|
raise ForgejoClientError(f"{response.status_code} from Forgejo OAuth: {message}")
|
||||||
|
|
||||||
|
async def fetch_userinfo(self, userinfo_endpoint: str, access_token: str) -> dict[str, Any]:
|
||||||
|
response = await self._client.get(
|
||||||
|
userinfo_endpoint,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response.is_success:
|
||||||
|
payload = response.json()
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
raise ForgejoClientError("Unexpected Forgejo UserInfo payload.")
|
||||||
|
|
||||||
|
message = self._extract_error_message(response)
|
||||||
|
raise ForgejoClientError(f"{response.status_code} from Forgejo UserInfo: {message}")
|
||||||
|
|
||||||
async def fetch_current_user(self) -> dict[str, Any]:
|
async def fetch_current_user(self) -> dict[str, Any]:
|
||||||
return await self._get_json("/api/v1/user", auth_required=True)
|
return await self._get_json("/api/v1/user", auth_required=True)
|
||||||
|
|
||||||
|
|
@ -39,22 +88,37 @@ class ForgejoClient:
|
||||||
params={
|
params={
|
||||||
"limit": self._settings.forgejo_repo_scan_limit,
|
"limit": self._settings.forgejo_repo_scan_limit,
|
||||||
"page": 1,
|
"page": 1,
|
||||||
|
"private": "false",
|
||||||
|
"is_private": "false",
|
||||||
},
|
},
|
||||||
auth_required=True,
|
|
||||||
)
|
)
|
||||||
data = payload.get("data", [])
|
data = payload.get("data", [])
|
||||||
return [repo for repo in data if isinstance(repo, dict)]
|
return [repo for repo in data if isinstance(repo, dict)]
|
||||||
|
|
||||||
async def search_recent_issues(self) -> list[dict[str, Any]]:
|
async def fetch_repository(self, owner: str, repo: str) -> dict[str, Any]:
|
||||||
payload = await self._get_json(
|
payload = await self._get_json(
|
||||||
"/api/v1/repos/issues/search",
|
f"/api/v1/repos/{owner}/{repo}",
|
||||||
|
)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
raise ForgejoClientError(f"Unexpected repository payload for {owner}/{repo}")
|
||||||
|
|
||||||
|
async def list_repo_issues(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
payload = await self._get_json(
|
||||||
|
f"/api/v1/repos/{owner}/{repo}/issues",
|
||||||
params={
|
params={
|
||||||
"state": "open",
|
"state": "open",
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"limit": self._settings.forgejo_recent_issue_limit,
|
"limit": limit,
|
||||||
"type": "issues",
|
"type": "issues",
|
||||||
|
"sort": "recentupdate",
|
||||||
},
|
},
|
||||||
auth_required=True,
|
|
||||||
)
|
)
|
||||||
if isinstance(payload, list):
|
if isinstance(payload, list):
|
||||||
return [issue for issue in payload if isinstance(issue, dict)]
|
return [issue for issue in payload if isinstance(issue, dict)]
|
||||||
|
|
@ -65,7 +129,7 @@ class ForgejoClient:
|
||||||
if path:
|
if path:
|
||||||
endpoint = f"{endpoint}/{path.strip('/')}"
|
endpoint = f"{endpoint}/{path.strip('/')}"
|
||||||
|
|
||||||
payload = await self._get_json(endpoint, auth_required=True)
|
payload = await self._get_json(endpoint)
|
||||||
if isinstance(payload, list):
|
if isinstance(payload, list):
|
||||||
return [entry for entry in payload if isinstance(entry, dict)]
|
return [entry for entry in payload if isinstance(entry, dict)]
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
|
|
@ -80,16 +144,31 @@ class ForgejoClient:
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
payload = await self._get_json(
|
payload = await self._get_json(
|
||||||
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments",
|
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments",
|
||||||
auth_required=True,
|
|
||||||
)
|
)
|
||||||
if isinstance(payload, list):
|
if isinstance(payload, list):
|
||||||
return [comment for comment in payload if isinstance(comment, dict)]
|
return [comment for comment in payload if isinstance(comment, dict)]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def create_issue_comment(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
issue_number: int,
|
||||||
|
body: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = await self._request_json(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments",
|
||||||
|
json_payload={"body": body},
|
||||||
|
auth_required=True,
|
||||||
|
)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
raise ForgejoClientError(f"Unexpected comment payload for {owner}/{repo}#{issue_number}")
|
||||||
|
|
||||||
async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]:
|
async def get_file_content(self, owner: str, repo: str, path: str) -> dict[str, str]:
|
||||||
payload = await self._get_json(
|
payload = await self._get_json(
|
||||||
f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}",
|
f"/api/v1/repos/{owner}/{repo}/contents/{path.strip('/')}",
|
||||||
auth_required=True,
|
|
||||||
)
|
)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise ForgejoClientError(f"Unexpected file payload for {owner}/{repo}:{path}")
|
raise ForgejoClientError(f"Unexpected file payload for {owner}/{repo}:{path}")
|
||||||
|
|
@ -118,17 +197,41 @@ class ForgejoClient:
|
||||||
params: Mapping[str, str | int] | None = None,
|
params: Mapping[str, str | int] | None = None,
|
||||||
auth_required: bool = False,
|
auth_required: bool = False,
|
||||||
) -> dict[str, Any] | list[Any]:
|
) -> dict[str, Any] | list[Any]:
|
||||||
if auth_required and not self._settings.forgejo_token:
|
return await self._request_json(
|
||||||
|
"GET",
|
||||||
|
path,
|
||||||
|
absolute_url=absolute_url,
|
||||||
|
params=params,
|
||||||
|
auth_required=auth_required,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _request_json(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
absolute_url: bool = False,
|
||||||
|
params: Mapping[str, str | int] | None = None,
|
||||||
|
json_payload: Mapping[str, object] | None = None,
|
||||||
|
auth_required: bool = False,
|
||||||
|
) -> dict[str, Any] | list[Any]:
|
||||||
|
if auth_required and not self._forgejo_token:
|
||||||
raise ForgejoClientError(
|
raise ForgejoClientError(
|
||||||
"This Forgejo instance requires an authenticated API token for repo and issue access.",
|
"This Forgejo instance requires an authenticated API token for repo and issue access.",
|
||||||
)
|
)
|
||||||
|
|
||||||
url = path if absolute_url else f"{self._settings.forgejo_base_url}{path}"
|
url = path if absolute_url else f"{self._settings.forgejo_base_url}{path}"
|
||||||
headers = {}
|
headers = {}
|
||||||
if self._settings.forgejo_token:
|
if self._forgejo_token:
|
||||||
headers["Authorization"] = f"token {self._settings.forgejo_token}"
|
headers["Authorization"] = f"token {self._forgejo_token}"
|
||||||
|
|
||||||
response = await self._client.get(url, params=params, headers=headers)
|
response = await self._client.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
json=json_payload,
|
||||||
|
)
|
||||||
if response.is_success:
|
if response.is_success:
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
|
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
|
||||||
import type {
|
import type {
|
||||||
|
AuthState,
|
||||||
CourseCard,
|
CourseCard,
|
||||||
CourseChapter,
|
CourseChapter,
|
||||||
CourseLesson,
|
CourseLesson,
|
||||||
|
|
@ -48,6 +49,22 @@ function parseDiscussionRoute(pathname: string): number | null {
|
||||||
return Number.isFinite(issueId) ? issueId : null;
|
return Number.isFinite(issueId) ? issueId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSignInRoute(pathname: string): boolean {
|
||||||
|
return normalizePathname(pathname) === "/signin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCoursesIndexRoute(pathname: string): boolean {
|
||||||
|
return normalizePathname(pathname) === "/courses";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDiscussionsIndexRoute(pathname: string): boolean {
|
||||||
|
return normalizePathname(pathname) === "/discussions";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActivityRoute(pathname: string): boolean {
|
||||||
|
return normalizePathname(pathname) === "/activity";
|
||||||
|
}
|
||||||
|
|
||||||
function parseCourseRoute(pathname: string): { owner: string; repo: string } | null {
|
function parseCourseRoute(pathname: string): { owner: string; repo: string } | null {
|
||||||
const match = normalizePathname(pathname).match(/^\/courses\/([^/]+)\/([^/]+)$/);
|
const match = normalizePathname(pathname).match(/^\/courses\/([^/]+)\/([^/]+)$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
|
@ -143,12 +160,17 @@ function usePathname() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function navigate(nextPath: string) {
|
function navigate(nextPath: string) {
|
||||||
const normalized = normalizePathname(nextPath);
|
const nextUrl = new URL(nextPath, window.location.origin);
|
||||||
if (normalized === pathname) {
|
const normalized = normalizePathname(nextUrl.pathname);
|
||||||
|
const renderedPath = `${normalized}${nextUrl.search}`;
|
||||||
|
if (
|
||||||
|
normalized === pathname &&
|
||||||
|
renderedPath === `${window.location.pathname}${window.location.search}`
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.history.pushState({}, "", normalized);
|
window.history.pushState({}, "", renderedPath);
|
||||||
window.scrollTo({ top: 0, behavior: "auto" });
|
window.scrollTo({ top: 0, behavior: "auto" });
|
||||||
setPathname(normalized);
|
setPathname(normalized);
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +190,152 @@ function EmptyState(props: { copy: string }) {
|
||||||
return <p className="empty-state">{props.copy}</p>;
|
return <p className="empty-state">{props.copy}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canUseInteractiveAuth(auth: AuthState): boolean {
|
||||||
|
return auth.authenticated && auth.can_reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgejoSignInUrl(returnTo: string): string {
|
||||||
|
const target = new URL("/api/auth/forgejo/start", window.location.origin);
|
||||||
|
target.searchParams.set("return_to", returnTo);
|
||||||
|
return `${target.pathname}${target.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopBar(props: {
|
||||||
|
auth: AuthState;
|
||||||
|
pathname: string;
|
||||||
|
onGoHome: () => void;
|
||||||
|
onGoCourses: () => void;
|
||||||
|
onGoDiscussions: () => void;
|
||||||
|
onGoActivity: () => void;
|
||||||
|
onGoSignIn: () => void;
|
||||||
|
onSignOut: () => void;
|
||||||
|
}) {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const {
|
||||||
|
auth,
|
||||||
|
pathname,
|
||||||
|
onGoHome,
|
||||||
|
onGoCourses,
|
||||||
|
onGoDiscussions,
|
||||||
|
onGoActivity,
|
||||||
|
onGoSignIn,
|
||||||
|
onSignOut,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
function navClass(targetPath: string) {
|
||||||
|
const normalized = normalizePathname(pathname);
|
||||||
|
const isActive =
|
||||||
|
targetPath === "/"
|
||||||
|
? normalized === "/"
|
||||||
|
: normalized === targetPath || normalized.startsWith(`${targetPath}/`);
|
||||||
|
return isActive ? "topbar-link active" : "topbar-link";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPathname = normalizePathname(pathname);
|
||||||
|
const brandClass = normalizedPathname === "/" ? "topbar-brand active" : "topbar-brand";
|
||||||
|
const menuClass = isMenuOpen ? "topbar-menu open" : "topbar-menu";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
function handleNavigation(callback: () => void) {
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={brandClass}
|
||||||
|
aria-label="Go to Robot U home"
|
||||||
|
onClick={() => {
|
||||||
|
handleNavigation(onGoHome);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Robot U
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topbar-menu-button"
|
||||||
|
aria-controls="topbar-menu"
|
||||||
|
aria-expanded={isMenuOpen}
|
||||||
|
aria-label="Toggle navigation menu"
|
||||||
|
onClick={() => {
|
||||||
|
setIsMenuOpen((currentValue) => !currentValue);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="topbar-menu" className={menuClass}>
|
||||||
|
<nav className="topbar-nav" aria-label="Primary navigation">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={navClass("/courses")}
|
||||||
|
onClick={() => {
|
||||||
|
handleNavigation(onGoCourses);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Courses
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={navClass("/discussions")}
|
||||||
|
onClick={() => {
|
||||||
|
handleNavigation(onGoDiscussions);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Discussions
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={navClass("/activity")}
|
||||||
|
onClick={() => {
|
||||||
|
handleNavigation(onGoActivity);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="topbar-auth">
|
||||||
|
{auth.authenticated ? (
|
||||||
|
<p>{auth.login}</p>
|
||||||
|
) : (
|
||||||
|
<p className="topbar-auth-muted">Not signed in</p>
|
||||||
|
)}
|
||||||
|
{auth.authenticated ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => {
|
||||||
|
handleNavigation(onSignOut);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => {
|
||||||
|
handleNavigation(onGoSignIn);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCard) => void }) {
|
function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCard) => void }) {
|
||||||
const { course, onOpenCourse } = props;
|
const { course, onOpenCourse } = props;
|
||||||
|
|
||||||
|
|
@ -247,20 +415,123 @@ function DiscussionReplyCard(props: { reply: DiscussionReply }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComposeBox() {
|
function repoParts(fullName: string): { owner: string; repo: string } | null {
|
||||||
return (
|
const [owner, repo] = fullName.split("/", 2);
|
||||||
<form
|
if (!owner || !repo) {
|
||||||
className="compose-box"
|
return null;
|
||||||
onSubmit={(event) => {
|
}
|
||||||
|
|
||||||
|
return { owner, repo };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseError(response: Response, fallback: string): Promise<Error> {
|
||||||
|
const payload = (await response.json().catch(() => null)) as { detail?: string } | null;
|
||||||
|
return new Error(payload?.detail || fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postDiscussionReply(
|
||||||
|
discussion: DiscussionCard,
|
||||||
|
body: string,
|
||||||
|
): Promise<DiscussionReply> {
|
||||||
|
const repo = repoParts(discussion.repo);
|
||||||
|
if (!repo) {
|
||||||
|
throw new Error("This discussion is missing repository information.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/discussions/replies", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.repo,
|
||||||
|
number: discussion.number,
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await responseError(response, `Reply failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as DiscussionReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposeBox(props: {
|
||||||
|
discussion: DiscussionCard;
|
||||||
|
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
|
||||||
|
auth: AuthState;
|
||||||
|
onGoSignIn: () => void;
|
||||||
|
}) {
|
||||||
|
const { discussion, onReplyCreated, auth, onGoSignIn } = props;
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const trimmedBody = body.trim();
|
||||||
|
const canReply = canUseInteractiveAuth(auth);
|
||||||
|
|
||||||
|
async function submitReply(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!canReply) {
|
||||||
|
setError("Sign in before replying.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedBody || isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const reply = await postDiscussionReply(discussion, trimmedBody);
|
||||||
|
onReplyCreated(discussion.id, reply);
|
||||||
|
setBody("");
|
||||||
|
} catch (replyError) {
|
||||||
|
const message =
|
||||||
|
replyError instanceof Error ? replyError.message : "Reply could not be posted.";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="compose-box" onSubmit={submitReply}>
|
||||||
|
{!canReply ? (
|
||||||
|
<div className="signin-callout">
|
||||||
|
<p>
|
||||||
|
{auth.authenticated
|
||||||
|
? "Reply posting is unavailable for this session."
|
||||||
|
: "Sign in before replying."}
|
||||||
|
</p>
|
||||||
|
{!auth.authenticated ? (
|
||||||
|
<button type="button" className="secondary-button" onClick={onGoSignIn}>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<textarea
|
||||||
|
className="compose-input"
|
||||||
|
placeholder="Write a reply"
|
||||||
|
value={body}
|
||||||
|
disabled={!canReply}
|
||||||
|
onInput={(event) => {
|
||||||
|
setBody(event.currentTarget.value);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<textarea className="compose-input" placeholder="Write a reply" />
|
|
||||||
<div className="compose-actions">
|
<div className="compose-actions">
|
||||||
<button type="submit" className="compose-button" disabled>
|
<button
|
||||||
Post reply
|
type="submit"
|
||||||
|
className="compose-button"
|
||||||
|
disabled={!canReply || !trimmedBody || isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Posting..." : "Post reply"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{error ? <p className="compose-error">{error}</p> : null}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -366,8 +637,14 @@ function LessonPage(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiscussionPage(props: { discussion: DiscussionCard; onGoHome: () => void }) {
|
function DiscussionPage(props: {
|
||||||
const { discussion, onGoHome } = props;
|
discussion: DiscussionCard;
|
||||||
|
auth: AuthState;
|
||||||
|
onGoHome: () => void;
|
||||||
|
onGoSignIn: () => void;
|
||||||
|
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
|
||||||
|
}) {
|
||||||
|
const { discussion, auth, onGoHome, onGoSignIn, onReplyCreated } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="thread-view">
|
<section className="thread-view">
|
||||||
|
|
@ -406,12 +683,59 @@ function DiscussionPage(props: { discussion: DiscussionCard; onGoHome: () => voi
|
||||||
<header className="subsection-header">
|
<header className="subsection-header">
|
||||||
<h2>Reply</h2>
|
<h2>Reply</h2>
|
||||||
</header>
|
</header>
|
||||||
<ComposeBox />
|
<ComposeBox
|
||||||
|
discussion={discussion}
|
||||||
|
auth={auth}
|
||||||
|
onGoSignIn={onGoSignIn}
|
||||||
|
onReplyCreated={onReplyCreated}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CoursesView(props: { data: PrototypeData; onOpenCourse: (course: CourseCard) => void }) {
|
||||||
|
const { data, onOpenCourse } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page-section">
|
||||||
|
<SectionHeader title="Courses" />
|
||||||
|
{data.featured_courses.length > 0 ? (
|
||||||
|
<div className="card-grid">
|
||||||
|
{data.featured_courses.map((course) => (
|
||||||
|
<CourseItem key={course.repo} course={course} onOpenCourse={onOpenCourse} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState copy="No public repos with `/lessons/` were found." />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: number) => void }) {
|
||||||
|
const { data, onOpenDiscussion } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page-section">
|
||||||
|
<SectionHeader title="Discussions" />
|
||||||
|
{data.recent_discussions.length > 0 ? (
|
||||||
|
<div className="stack">
|
||||||
|
{data.recent_discussions.map((discussion) => (
|
||||||
|
<DiscussionPreviewItem
|
||||||
|
key={discussion.id}
|
||||||
|
discussion={discussion}
|
||||||
|
onOpenDiscussion={onOpenDiscussion}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState copy="No visible Forgejo issues were returned for this account." />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HomeView(props: {
|
function HomeView(props: {
|
||||||
data: PrototypeData;
|
data: PrototypeData;
|
||||||
onOpenCourse: (course: CourseCard) => void;
|
onOpenCourse: (course: CourseCard) => void;
|
||||||
|
|
@ -429,18 +753,7 @@ function HomeView(props: {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="page-section">
|
<CoursesView data={data} onOpenCourse={onOpenCourse} />
|
||||||
<SectionHeader title="Courses" />
|
|
||||||
{data.featured_courses.length > 0 ? (
|
|
||||||
<div className="card-grid">
|
|
||||||
{data.featured_courses.map((course) => (
|
|
||||||
<CourseItem key={course.repo} course={course} onOpenCourse={onOpenCourse} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState copy="No public repos with `/lessons/` were found." />
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="two-column-grid">
|
<section className="two-column-grid">
|
||||||
<div className="page-section">
|
<div className="page-section">
|
||||||
|
|
@ -470,49 +783,242 @@ function HomeView(props: {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="page-section">
|
<DiscussionsView data={data} onOpenDiscussion={onOpenDiscussion} />
|
||||||
<SectionHeader title="Discussions" />
|
|
||||||
{data.recent_discussions.length > 0 ? (
|
|
||||||
<div className="stack">
|
|
||||||
{data.recent_discussions.map((discussion) => (
|
|
||||||
<DiscussionPreviewItem
|
|
||||||
key={discussion.id}
|
|
||||||
discussion={discussion}
|
|
||||||
onOpenDiscussion={onOpenDiscussion}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState copy="No visible Forgejo issues were returned for this account." />
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppContent(props: {
|
function SignInPage(props: { auth: AuthState; onGoHome: () => void }) {
|
||||||
|
const { auth, onGoHome } = props;
|
||||||
|
const query = new URLSearchParams(window.location.search);
|
||||||
|
const error = query.get("error");
|
||||||
|
const returnTo = query.get("return_to") || "/";
|
||||||
|
|
||||||
|
function startForgejoSignIn() {
|
||||||
|
window.location.assign(forgejoSignInUrl(returnTo));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="signin-page">
|
||||||
|
<button type="button" className="back-link" onClick={onGoHome}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<article className="panel signin-panel">
|
||||||
|
<header className="thread-header">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p className="muted-copy">
|
||||||
|
Forgejo sign-in needs attention. You can retry the OAuth flow or return to the site.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="signin-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compose-button"
|
||||||
|
disabled={!auth.oauth_configured}
|
||||||
|
onClick={startForgejoSignIn}
|
||||||
|
>
|
||||||
|
Continue with Forgejo
|
||||||
|
</button>
|
||||||
|
{!auth.oauth_configured ? (
|
||||||
|
<p className="compose-error">Forgejo OAuth is not configured on this backend yet.</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="compose-error">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPrototypeData(signal?: AbortSignal): Promise<PrototypeData> {
|
||||||
|
const response = await fetch("/api/prototype", {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Prototype request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as PrototypeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDiscussionReply(
|
||||||
|
currentData: PrototypeData | null,
|
||||||
|
discussionId: number,
|
||||||
|
reply: DiscussionReply,
|
||||||
|
): PrototypeData | null {
|
||||||
|
if (!currentData) {
|
||||||
|
return currentData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentData,
|
||||||
|
recent_discussions: currentData.recent_discussions.map((discussion) => {
|
||||||
|
if (discussion.id !== discussionId) {
|
||||||
|
return discussion;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...discussion,
|
||||||
|
replies: discussion.replies + 1,
|
||||||
|
updated_at: reply.created_at || discussion.updated_at,
|
||||||
|
comments: [...discussion.comments, reply],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
timestamp: string;
|
||||||
|
route: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampMillis(value: string): number {
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coursePath(course: CourseCard): string {
|
||||||
|
return `/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
|
||||||
|
const activities: ActivityEntry[] = [];
|
||||||
|
|
||||||
|
for (const course of data.featured_courses) {
|
||||||
|
if (course.updated_at) {
|
||||||
|
activities.push({
|
||||||
|
id: `course:${course.repo}`,
|
||||||
|
title: `Course updated: ${course.title}`,
|
||||||
|
detail: `${course.repo} · ${course.lessons} lessons`,
|
||||||
|
timestamp: course.updated_at,
|
||||||
|
route: coursePath(course),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const post of data.recent_posts) {
|
||||||
|
if (post.updated_at) {
|
||||||
|
activities.push({
|
||||||
|
id: `post:${post.repo}`,
|
||||||
|
title: `Post repo updated: ${post.title}`,
|
||||||
|
detail: post.repo,
|
||||||
|
timestamp: post.updated_at,
|
||||||
|
route: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const discussion of data.recent_discussions) {
|
||||||
|
activities.push({
|
||||||
|
id: `discussion:${discussion.id}`,
|
||||||
|
title: `Discussion updated: ${discussion.title}`,
|
||||||
|
detail: `${discussion.repo} · ${discussion.replies} replies`,
|
||||||
|
timestamp: discussion.updated_at,
|
||||||
|
route: `/discussions/${discussion.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const reply of discussion.comments) {
|
||||||
|
activities.push({
|
||||||
|
id: `reply:${discussion.id}:${reply.id}`,
|
||||||
|
title: `${reply.author} replied`,
|
||||||
|
detail: discussion.title,
|
||||||
|
timestamp: reply.created_at,
|
||||||
|
route: `/discussions/${discussion.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return activities.sort(
|
||||||
|
(left, right) => timestampMillis(right.timestamp) - timestampMillis(left.timestamp),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityItem(props: { entry: ActivityEntry; onOpenRoute: (route: string) => void }) {
|
||||||
|
const { entry, onOpenRoute } = props;
|
||||||
|
|
||||||
|
if (entry.route) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="activity-card activity-card-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (entry.route) {
|
||||||
|
onOpenRoute(entry.route);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>{entry.title}</h3>
|
||||||
|
<p className="muted-copy">{entry.detail}</p>
|
||||||
|
<p className="meta-line">{formatTimestamp(entry.timestamp)}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="activity-card">
|
||||||
|
<h3>{entry.title}</h3>
|
||||||
|
<p className="muted-copy">{entry.detail}</p>
|
||||||
|
<p className="meta-line">{formatTimestamp(entry.timestamp)}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityView(props: { data: PrototypeData; onOpenRoute: (route: string) => void }) {
|
||||||
|
const activities = buildActivityFeed(props.data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page-section">
|
||||||
|
<SectionHeader title="Activity" />
|
||||||
|
{activities.length > 0 ? (
|
||||||
|
<div className="activity-list">
|
||||||
|
{activities.map((entry) => (
|
||||||
|
<ActivityItem key={entry.id} entry={entry} onOpenRoute={props.onOpenRoute} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState copy="No public site activity has been loaded yet." />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppContentProps {
|
||||||
data: PrototypeData;
|
data: PrototypeData;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
onOpenCourse: (course: CourseCard) => void;
|
onOpenCourse: (course: CourseCard) => void;
|
||||||
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
|
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
|
||||||
onOpenDiscussion: (id: number) => void;
|
onOpenDiscussion: (id: number) => void;
|
||||||
|
onOpenRoute: (route: string) => void;
|
||||||
|
onGoSignIn: () => void;
|
||||||
|
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
|
||||||
onGoHome: () => void;
|
onGoHome: () => void;
|
||||||
}) {
|
onGoCourses: () => void;
|
||||||
const lessonRoute = parseLessonRoute(props.pathname);
|
onGoDiscussions: () => void;
|
||||||
if (lessonRoute !== null) {
|
}
|
||||||
const selectedCourse = findCourseByRoute(props.data.featured_courses, lessonRoute);
|
|
||||||
|
function LessonRouteView(
|
||||||
|
props: AppContentProps & {
|
||||||
|
route: { owner: string; repo: string; chapter: string; lesson: string };
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const selectedCourse = findCourseByRoute(props.data.featured_courses, props.route);
|
||||||
if (!selectedCourse) {
|
if (!selectedCourse) {
|
||||||
return (
|
return (
|
||||||
<section className="page-message">
|
<section className="page-message">
|
||||||
<h1>Course not found.</h1>
|
<h1>Course not found.</h1>
|
||||||
<button type="button" className="back-link" onClick={props.onGoHome}>
|
<button type="button" className="back-link" onClick={props.onGoCourses}>
|
||||||
Back to courses
|
Back to courses
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedLesson = findLessonByRoute(selectedCourse, lessonRoute);
|
const selectedLesson = findLessonByRoute(selectedCourse, props.route);
|
||||||
if (!selectedLesson) {
|
if (!selectedLesson) {
|
||||||
return (
|
return (
|
||||||
<section className="page-message">
|
<section className="page-message">
|
||||||
|
|
@ -542,14 +1048,13 @@ function AppContent(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const courseRoute = parseCourseRoute(props.pathname);
|
function CourseRouteView(props: AppContentProps & { route: { owner: string; repo: string } }) {
|
||||||
if (courseRoute !== null) {
|
const selectedCourse = findCourseByRoute(props.data.featured_courses, props.route);
|
||||||
const selectedCourse = findCourseByRoute(props.data.featured_courses, courseRoute);
|
|
||||||
if (!selectedCourse) {
|
if (!selectedCourse) {
|
||||||
return (
|
return (
|
||||||
<section className="page-message">
|
<section className="page-message">
|
||||||
<h1>Course not found.</h1>
|
<h1>Course not found.</h1>
|
||||||
<button type="button" className="back-link" onClick={props.onGoHome}>
|
<button type="button" className="back-link" onClick={props.onGoCourses}>
|
||||||
Back to courses
|
Back to courses
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -559,12 +1064,65 @@ function AppContent(props: {
|
||||||
return (
|
return (
|
||||||
<CoursePage
|
<CoursePage
|
||||||
course={selectedCourse}
|
course={selectedCourse}
|
||||||
onGoHome={props.onGoHome}
|
onGoHome={props.onGoCourses}
|
||||||
onOpenLesson={props.onOpenLesson}
|
onOpenLesson={props.onOpenLesson}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DiscussionRouteView(props: AppContentProps & { discussionId: number }) {
|
||||||
|
const selectedDiscussion = props.data.recent_discussions.find(
|
||||||
|
(discussion) => discussion.id === props.discussionId,
|
||||||
|
);
|
||||||
|
if (!selectedDiscussion) {
|
||||||
|
return (
|
||||||
|
<section className="page-message">
|
||||||
|
<h1>Discussion not found.</h1>
|
||||||
|
<button type="button" className="back-link" onClick={props.onGoDiscussions}>
|
||||||
|
Back to discussions
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DiscussionPage
|
||||||
|
discussion={selectedDiscussion}
|
||||||
|
auth={props.data.auth}
|
||||||
|
onGoHome={props.onGoDiscussions}
|
||||||
|
onGoSignIn={props.onGoSignIn}
|
||||||
|
onReplyCreated={props.onReplyCreated}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppContent(props: AppContentProps) {
|
||||||
|
if (isSignInRoute(props.pathname)) {
|
||||||
|
return <SignInPage auth={props.data.auth} onGoHome={props.onGoHome} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActivityRoute(props.pathname)) {
|
||||||
|
return <ActivityView data={props.data} onOpenRoute={props.onOpenRoute} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCoursesIndexRoute(props.pathname)) {
|
||||||
|
return <CoursesView data={props.data} onOpenCourse={props.onOpenCourse} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDiscussionsIndexRoute(props.pathname)) {
|
||||||
|
return <DiscussionsView data={props.data} onOpenDiscussion={props.onOpenDiscussion} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lessonRoute = parseLessonRoute(props.pathname);
|
||||||
|
if (lessonRoute !== null) {
|
||||||
|
return <LessonRouteView {...props} route={lessonRoute} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const courseRoute = parseCourseRoute(props.pathname);
|
||||||
|
if (courseRoute !== null) {
|
||||||
|
return <CourseRouteView {...props} route={courseRoute} />;
|
||||||
|
}
|
||||||
|
|
||||||
const discussionId = parseDiscussionRoute(props.pathname);
|
const discussionId = parseDiscussionRoute(props.pathname);
|
||||||
if (discussionId === null) {
|
if (discussionId === null) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -576,21 +1134,55 @@ function AppContent(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedDiscussion = props.data.recent_discussions.find(
|
return <DiscussionRouteView {...props} discussionId={discussionId} />;
|
||||||
(discussion) => discussion.id === discussionId,
|
}
|
||||||
);
|
|
||||||
if (!selectedDiscussion) {
|
function LoadedApp(
|
||||||
|
props: AppContentProps & {
|
||||||
|
onGoActivity: () => void;
|
||||||
|
onSignOut: () => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<section className="page-message">
|
<>
|
||||||
<h1>Discussion not found.</h1>
|
<TopBar
|
||||||
<button type="button" className="back-link" onClick={props.onGoHome}>
|
auth={props.data.auth}
|
||||||
Back to discussions
|
pathname={props.pathname}
|
||||||
</button>
|
onGoHome={props.onGoHome}
|
||||||
</section>
|
onGoCourses={props.onGoCourses}
|
||||||
|
onGoDiscussions={props.onGoDiscussions}
|
||||||
|
onGoActivity={props.onGoActivity}
|
||||||
|
onGoSignIn={props.onGoSignIn}
|
||||||
|
onSignOut={props.onSignOut}
|
||||||
|
/>
|
||||||
|
<main className="app-shell">
|
||||||
|
<AppContent
|
||||||
|
data={props.data}
|
||||||
|
pathname={props.pathname}
|
||||||
|
onOpenCourse={props.onOpenCourse}
|
||||||
|
onOpenLesson={props.onOpenLesson}
|
||||||
|
onOpenDiscussion={props.onOpenDiscussion}
|
||||||
|
onOpenRoute={props.onOpenRoute}
|
||||||
|
onGoSignIn={props.onGoSignIn}
|
||||||
|
onReplyCreated={props.onReplyCreated}
|
||||||
|
onGoHome={props.onGoHome}
|
||||||
|
onGoCourses={props.onGoCourses}
|
||||||
|
onGoDiscussions={props.onGoDiscussions}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <DiscussionPage discussion={selectedDiscussion} onGoHome={props.onGoHome} />;
|
function AppStatusPage(props: { title: string; copy?: string }) {
|
||||||
|
return (
|
||||||
|
<main className="app-shell">
|
||||||
|
<section className="page-message">
|
||||||
|
<h1>{props.title}</h1>
|
||||||
|
{props.copy ? <p className="muted-copy">{props.copy}</p> : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
@ -598,23 +1190,13 @@ export default function App() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { pathname, navigate } = usePathname();
|
const { pathname, navigate } = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
async function loadPrototype(signal?: AbortSignal) {
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
async function loadPrototype() {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/prototype", {
|
const payload = await fetchPrototypeData(signal);
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Prototype request failed with ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = (await response.json()) as PrototypeData;
|
|
||||||
setData(payload);
|
setData(payload);
|
||||||
|
setError(null);
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (controller.signal.aborted) {
|
if (signal?.aborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -624,7 +1206,9 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPrototype();
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
loadPrototype(controller.signal);
|
||||||
return () => {
|
return () => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
|
|
@ -634,6 +1218,22 @@ export default function App() {
|
||||||
navigate(`/discussions/${id}`);
|
navigate(`/discussions/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goSignIn() {
|
||||||
|
window.location.assign(forgejoSignInUrl(pathname));
|
||||||
|
}
|
||||||
|
|
||||||
|
function goCourses() {
|
||||||
|
navigate("/courses");
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDiscussions() {
|
||||||
|
navigate("/discussions");
|
||||||
|
}
|
||||||
|
|
||||||
|
function goActivity() {
|
||||||
|
navigate("/activity");
|
||||||
|
}
|
||||||
|
|
||||||
function openCourse(course: CourseCard) {
|
function openCourse(course: CourseCard) {
|
||||||
navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`);
|
navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`);
|
||||||
}
|
}
|
||||||
|
|
@ -644,31 +1244,45 @@ export default function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addReplyToDiscussion(discussionId: number, reply: DiscussionReply) {
|
||||||
|
setData((currentData) => appendDiscussionReply(currentData, discussionId, reply));
|
||||||
|
}
|
||||||
|
|
||||||
function goHome() {
|
function goHome() {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await fetch("/api/auth/session", {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
await loadPrototype();
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <AppStatusPage title="Backend data did not load." copy={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <AppStatusPage title="Loading content." />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<LoadedApp
|
||||||
{error ? (
|
|
||||||
<section className="page-message">
|
|
||||||
<h1>Backend data did not load.</h1>
|
|
||||||
<p className="muted-copy">{error}</p>
|
|
||||||
</section>
|
|
||||||
) : data ? (
|
|
||||||
<AppContent
|
|
||||||
data={data}
|
data={data}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
onOpenCourse={openCourse}
|
onOpenCourse={openCourse}
|
||||||
onOpenLesson={openLesson}
|
onOpenLesson={openLesson}
|
||||||
onOpenDiscussion={openDiscussion}
|
onOpenDiscussion={openDiscussion}
|
||||||
|
onOpenRoute={navigate}
|
||||||
|
onGoSignIn={goSignIn}
|
||||||
|
onReplyCreated={addReplyToDiscussion}
|
||||||
onGoHome={goHome}
|
onGoHome={goHome}
|
||||||
|
onGoCourses={goCourses}
|
||||||
|
onGoDiscussions={goDiscussions}
|
||||||
|
onGoActivity={goActivity}
|
||||||
|
onSignOut={signOut}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<section className="page-message">
|
|
||||||
<h1>Loading content.</h1>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,29 @@
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: light;
|
||||||
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
|
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
--bg: #0d1117;
|
--bg: linear-gradient(180deg, #fffaf0 0%, #f3ede1 100%);
|
||||||
--panel: #151b23;
|
--panel: #fffdf8;
|
||||||
--panel-hover: #1a2230;
|
--panel-hover: #f1eadf;
|
||||||
--border: #2b3442;
|
--card: #ffffff;
|
||||||
--text: #edf2f7;
|
--border: #ded5c7;
|
||||||
--muted: #9aa6b2;
|
--text: #1f2933;
|
||||||
--accent: #84d7ff;
|
--muted: #667085;
|
||||||
|
--accent: #0f6f8f;
|
||||||
|
--accent-strong: #0b5d78;
|
||||||
|
--accent-soft: rgba(15, 111, 143, 0.12);
|
||||||
|
--accent-border: rgba(15, 111, 143, 0.28);
|
||||||
|
--topbar-bg: rgba(255, 253, 248, 0.92);
|
||||||
|
--code-bg: #edf3f5;
|
||||||
|
--warning-bg: rgba(191, 125, 22, 0.12);
|
||||||
|
--warning-border: rgba(191, 125, 22, 0.32);
|
||||||
|
--warning-text: #7a4700;
|
||||||
|
--button-text: #ffffff;
|
||||||
|
--disabled-bg: #ece6dc;
|
||||||
|
--disabled-text: #8a8174;
|
||||||
|
--error: #a64234;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
@ -52,6 +65,125 @@ textarea {
|
||||||
padding: 2rem 1rem 3rem;
|
padding: 2rem 1rem 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 0.0625rem solid var(--accent-border);
|
||||||
|
padding: 0.75rem clamp(1rem, 4vw, 2rem);
|
||||||
|
background: var(--topbar-bg);
|
||||||
|
backdrop-filter: blur(1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-menu {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-brand,
|
||||||
|
.topbar-link,
|
||||||
|
.topbar-menu-button {
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-brand {
|
||||||
|
border-radius: 999rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-brand:hover,
|
||||||
|
.topbar-brand:focus-visible,
|
||||||
|
.topbar-brand.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-nav,
|
||||||
|
.topbar-auth {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-nav {
|
||||||
|
flex: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-link {
|
||||||
|
border-radius: 999rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-link:hover,
|
||||||
|
.topbar-link:focus-visible,
|
||||||
|
.topbar-link.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel-hover);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-auth {
|
||||||
|
margin-left: auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-auth p {
|
||||||
|
max-width: 12rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-auth-muted {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-menu-button {
|
||||||
|
display: none;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999rem;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-menu-button span {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 0.125rem;
|
||||||
|
border-radius: 999rem;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-menu-button:hover,
|
||||||
|
.topbar-menu-button:focus-visible {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel-hover);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.page-header,
|
.page-header,
|
||||||
.page-section,
|
.page-section,
|
||||||
.panel,
|
.panel,
|
||||||
|
|
@ -123,13 +255,15 @@ textarea {
|
||||||
|
|
||||||
.card,
|
.card,
|
||||||
.discussion-preview-card,
|
.discussion-preview-card,
|
||||||
|
.activity-card,
|
||||||
.reply-card {
|
.reply-card {
|
||||||
border: 0.0625rem solid var(--border);
|
border: 0.0625rem solid var(--border);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
background: #111722;
|
background: var(--card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card,
|
.card,
|
||||||
|
.activity-card,
|
||||||
.reply-card {
|
.reply-card {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -148,11 +282,17 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h3,
|
.card h3,
|
||||||
.discussion-preview-card h3 {
|
.discussion-preview-card h3,
|
||||||
|
.activity-card h3 {
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.discussion-preview-card {
|
.discussion-preview-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|
@ -161,8 +301,17 @@ textarea {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-card-button {
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.discussion-preview-card:hover,
|
.discussion-preview-card:hover,
|
||||||
.discussion-preview-card:focus-visible {
|
.discussion-preview-card:focus-visible,
|
||||||
|
.activity-card-button:hover,
|
||||||
|
.activity-card-button:focus-visible {
|
||||||
background: var(--panel-hover);
|
background: var(--panel-hover);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
@ -188,6 +337,7 @@ textarea {
|
||||||
|
|
||||||
.back-link,
|
.back-link,
|
||||||
.secondary-link,
|
.secondary-link,
|
||||||
|
.secondary-button,
|
||||||
.compose-button {
|
.compose-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -205,6 +355,34 @@ textarea {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 0.0625rem solid var(--accent-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--panel);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button:hover,
|
||||||
|
.secondary-button:focus-visible,
|
||||||
|
.back-link:hover,
|
||||||
|
.back-link:focus-visible {
|
||||||
|
background: var(--panel-hover);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.thread-view {
|
.thread-view {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
@ -336,7 +514,7 @@ textarea {
|
||||||
.markdown-content code {
|
.markdown-content code {
|
||||||
border-radius: 0.35rem;
|
border-radius: 0.35rem;
|
||||||
padding: 0.12rem 0.35rem;
|
padding: 0.12rem 0.35rem;
|
||||||
background: #0b1017;
|
background: var(--code-bg);
|
||||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
font-size: 0.92em;
|
font-size: 0.92em;
|
||||||
}
|
}
|
||||||
|
|
@ -347,7 +525,7 @@ textarea {
|
||||||
border: 0.0625rem solid var(--border);
|
border: 0.0625rem solid var(--border);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
padding: 0.85rem 0.95rem;
|
padding: 0.85rem 0.95rem;
|
||||||
background: #0b1017;
|
background: var(--code-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content pre code {
|
.markdown-content pre code {
|
||||||
|
|
@ -378,15 +556,57 @@ textarea {
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-input {
|
.signin-page {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-panel {
|
||||||
|
max-width: 42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-callout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 0.0625rem solid var(--warning-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: var(--warning-text);
|
||||||
|
background: var(--warning-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-input,
|
||||||
|
.token-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 9rem;
|
|
||||||
resize: vertical;
|
|
||||||
border: 0.0625rem solid var(--border);
|
border: 0.0625rem solid var(--border);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
padding: 0.85rem 0.95rem;
|
padding: 0.85rem 0.95rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: #0f141d;
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-input {
|
||||||
|
min-height: 9rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-input:focus,
|
||||||
|
.token-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-actions {
|
.compose-actions {
|
||||||
|
|
@ -397,9 +617,35 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-button {
|
.compose-button {
|
||||||
color: #7a8696;
|
border-color: var(--accent);
|
||||||
background: #101722;
|
color: var(--button-text);
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
transition:
|
||||||
|
background 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-button:hover:not(:disabled),
|
||||||
|
.compose-button:focus-visible:not(:disabled) {
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
background: var(--accent-strong);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-button:disabled {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--disabled-text);
|
||||||
|
background: var(--disabled-bg);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-error {
|
||||||
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-link {
|
.secondary-link {
|
||||||
|
|
@ -407,12 +653,57 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 48rem) {
|
@media (max-width: 48rem) {
|
||||||
|
.topbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-menu-button {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-menu {
|
||||||
|
display: none;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: 100%;
|
||||||
|
border-top: 0.0625rem solid var(--border);
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-menu.open {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-nav,
|
||||||
|
.topbar-auth {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-nav {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-link {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-auth {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.card-grid,
|
.card-grid,
|
||||||
.two-column-grid {
|
.two-column-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-actions,
|
.compose-actions,
|
||||||
|
.auth-bar,
|
||||||
|
.signin-callout,
|
||||||
.subsection-header {
|
.subsection-header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ export interface SourceOfTruthCard {
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
authenticated: boolean;
|
||||||
|
login: string | null;
|
||||||
|
source: string;
|
||||||
|
can_reply: boolean;
|
||||||
|
oauth_configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CourseCard {
|
export interface CourseCard {
|
||||||
title: string;
|
title: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
|
|
@ -21,6 +29,7 @@ export interface CourseCard {
|
||||||
summary: string;
|
summary: string;
|
||||||
status: string;
|
status: string;
|
||||||
outline: CourseChapter[];
|
outline: CourseChapter[];
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CourseChapter {
|
export interface CourseChapter {
|
||||||
|
|
@ -44,6 +53,7 @@ export interface PostCard {
|
||||||
repo: string;
|
repo: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventCard {
|
export interface EventCard {
|
||||||
|
|
@ -81,6 +91,7 @@ export interface DiscussionReply {
|
||||||
|
|
||||||
export interface PrototypeData {
|
export interface PrototypeData {
|
||||||
hero: HeroData;
|
hero: HeroData;
|
||||||
|
auth: AuthState;
|
||||||
source_of_truth: SourceOfTruthCard[];
|
source_of_truth: SourceOfTruthCard[];
|
||||||
featured_courses: CourseCard[];
|
featured_courses: CourseCard[];
|
||||||
recent_posts: PostCard[];
|
recent_posts: PostCard[];
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,16 @@ from forgejo_client import ForgejoClient, ForgejoClientError
|
||||||
from settings import Settings
|
from settings import Settings
|
||||||
|
|
||||||
|
|
||||||
async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
|
async def build_live_prototype_payload(
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
forgejo_token: str | None = None,
|
||||||
|
auth_source: str = "none",
|
||||||
|
session_user: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
access_token = forgejo_token or settings.forgejo_token
|
||||||
|
has_user_token = bool(access_token) and auth_source in {"authorization", "session"}
|
||||||
source_cards = [
|
source_cards = [
|
||||||
{
|
{
|
||||||
"title": "Forgejo base URL",
|
"title": "Forgejo base URL",
|
||||||
|
|
@ -17,11 +25,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Access mode",
|
"title": "Access mode",
|
||||||
"description": (
|
"description": _access_mode_description(access_token, auth_source),
|
||||||
"Server token configured for live API reads."
|
|
||||||
if settings.forgejo_token
|
|
||||||
else "Instance API requires auth. Set FORGEJO_TOKEN for live repo discovery."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -34,7 +38,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async with ForgejoClient(settings) as client:
|
async with ForgejoClient(settings, forgejo_token=access_token) as client:
|
||||||
try:
|
try:
|
||||||
oidc = await client.fetch_openid_configuration()
|
oidc = await client.fetch_openid_configuration()
|
||||||
except ForgejoClientError as error:
|
except ForgejoClientError as error:
|
||||||
|
|
@ -49,32 +53,8 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not settings.forgejo_token:
|
|
||||||
warnings.append(
|
|
||||||
"aksal.cloud blocks anonymous API calls, so the prototype needs FORGEJO_TOKEN "
|
|
||||||
"before it can load repos or issues.",
|
|
||||||
)
|
|
||||||
source_cards.append(
|
|
||||||
{
|
|
||||||
"title": "Discovery state",
|
|
||||||
"description": "Waiting for FORGEJO_TOKEN to enable live repo and issue reads.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return _empty_payload(
|
|
||||||
source_cards=source_cards,
|
|
||||||
warnings=warnings,
|
|
||||||
hero_summary=(
|
|
||||||
"Connected to aksal.cloud for identity and OIDC discovery, but live repo content "
|
|
||||||
"is gated until a Forgejo API token is configured on the backend."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_user, repos, issues = await asyncio.gather(
|
repos = await client.search_repositories()
|
||||||
client.fetch_current_user(),
|
|
||||||
client.search_repositories(),
|
|
||||||
client.search_recent_issues(),
|
|
||||||
)
|
|
||||||
except ForgejoClientError as error:
|
except ForgejoClientError as error:
|
||||||
warnings.append(str(error))
|
warnings.append(str(error))
|
||||||
source_cards.append(
|
source_cards.append(
|
||||||
|
|
@ -86,23 +66,30 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
|
||||||
return _empty_payload(
|
return _empty_payload(
|
||||||
source_cards=source_cards,
|
source_cards=source_cards,
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
|
auth=_auth_payload(
|
||||||
|
session_user, _display_auth_source(auth_source, session_user), settings
|
||||||
|
),
|
||||||
hero_summary=(
|
hero_summary=(
|
||||||
"The backend reached aksal.cloud, but the configured token could not complete "
|
"The backend reached aksal.cloud, but the configured token could not complete "
|
||||||
"the repo discovery flow."
|
"the public repo discovery flow."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
current_user = await _current_user_for_auth_source(client, has_user_token, warnings)
|
||||||
|
public_repos = [repo for repo in repos if not repo.get("fork") and not repo.get("private")]
|
||||||
repo_summaries = await asyncio.gather(
|
repo_summaries = await asyncio.gather(
|
||||||
*[
|
*[_summarize_repo(client, repo) for repo in public_repos],
|
||||||
_summarize_repo(client, repo)
|
|
||||||
for repo in repos
|
|
||||||
if not repo.get("fork") and not repo.get("private")
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
content_repos = [summary for summary in repo_summaries if summary is not None]
|
content_repos = [summary for summary in repo_summaries if summary is not None]
|
||||||
course_repos = [summary for summary in content_repos if summary["lesson_count"] > 0]
|
course_repos = [summary for summary in content_repos if summary["lesson_count"] > 0]
|
||||||
post_repos = [summary for summary in content_repos if summary["blog_count"] > 0]
|
post_repos = [summary for summary in content_repos if summary["blog_count"] > 0]
|
||||||
|
public_issues = await _recent_public_issues(
|
||||||
|
client,
|
||||||
|
public_repos,
|
||||||
|
settings.forgejo_recent_issue_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_user is not None:
|
||||||
source_cards.append(
|
source_cards.append(
|
||||||
{
|
{
|
||||||
"title": "Signed-in API identity",
|
"title": "Signed-in API identity",
|
||||||
|
|
@ -114,10 +101,11 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
|
||||||
"title": "Discovery state",
|
"title": "Discovery state",
|
||||||
"description": (
|
"description": (
|
||||||
f"Detected {len(course_repos)} course repos, {len(post_repos)} post repos, "
|
f"Detected {len(course_repos)} course repos, {len(post_repos)} post repos, "
|
||||||
f"and {len(issues)} recent issues."
|
f"and {len(public_issues)} recent public issues."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
auth_user = session_user or current_user
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"hero": {
|
"hero": {
|
||||||
|
|
@ -125,7 +113,7 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
|
||||||
"title": "Robot U is reading from your aksal.cloud Forgejo instance.",
|
"title": "Robot U is reading from your aksal.cloud Forgejo instance.",
|
||||||
"summary": (
|
"summary": (
|
||||||
"This prototype now uses the real Forgejo base URL, OIDC metadata, visible repos, "
|
"This prototype now uses the real Forgejo base URL, OIDC metadata, visible repos, "
|
||||||
"and recent issues available to the configured backend token."
|
"and recent issues available to the active token."
|
||||||
),
|
),
|
||||||
"highlights": [
|
"highlights": [
|
||||||
"Repo discovery filters to public, non-fork repositories only",
|
"Repo discovery filters to public, non-fork repositories only",
|
||||||
|
|
@ -133,27 +121,54 @@ async def build_live_prototype_payload(settings: Settings) -> dict[str, object]:
|
||||||
"Recent discussions are loaded from live Forgejo issues",
|
"Recent discussions are loaded from live Forgejo issues",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"auth": _auth_payload(
|
||||||
|
auth_user if has_user_token else session_user,
|
||||||
|
_display_auth_source(auth_source, session_user),
|
||||||
|
settings,
|
||||||
|
),
|
||||||
"source_of_truth": source_cards,
|
"source_of_truth": source_cards,
|
||||||
"featured_courses": [_course_card(summary) for summary in course_repos[:6]],
|
"featured_courses": [_course_card(summary) for summary in course_repos[:6]],
|
||||||
"recent_posts": [_post_card(summary) for summary in post_repos[:6]],
|
"recent_posts": [_post_card(summary) for summary in post_repos[:6]],
|
||||||
"upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit),
|
"upcoming_events": _event_cards(calendar_feeds, settings.calendar_event_limit),
|
||||||
"recent_discussions": await asyncio.gather(
|
"recent_discussions": await asyncio.gather(
|
||||||
*[_discussion_card(client, issue) for issue in issues],
|
*[_discussion_card(client, issue) for issue in public_issues],
|
||||||
),
|
),
|
||||||
"implementation_notes": [
|
"implementation_notes": [
|
||||||
"Live repo discovery is now driven by the Forgejo API instead of mock content.",
|
"Live repo discovery is now driven by the Forgejo API instead of mock content.",
|
||||||
"Issues shown here are real Forgejo issues visible to the configured token.",
|
"Issues shown here are loaded only from public Forgejo repositories.",
|
||||||
*warnings,
|
*warnings,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _access_mode_description(access_token: str | None, auth_source: str) -> str:
|
||||||
|
if auth_source in {"authorization", "session"} and access_token:
|
||||||
|
return f"Authenticated through {auth_source} token."
|
||||||
|
if auth_source == "server" or access_token:
|
||||||
|
return "Reading public content through the server-side Forgejo token."
|
||||||
|
return "Reading public content anonymously."
|
||||||
|
|
||||||
|
|
||||||
|
async def _current_user_for_auth_source(
|
||||||
|
client: ForgejoClient,
|
||||||
|
has_user_token: bool,
|
||||||
|
warnings: list[str],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not has_user_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await client.fetch_current_user()
|
||||||
|
except ForgejoClientError as error:
|
||||||
|
warnings.append(str(error))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _summarize_repo(
|
async def _summarize_repo(
|
||||||
client: ForgejoClient,
|
client: ForgejoClient,
|
||||||
repo: dict[str, Any],
|
repo: dict[str, Any],
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
owner = repo.get("owner", {})
|
owner_login = _repo_owner_login(repo)
|
||||||
owner_login = owner.get("login")
|
|
||||||
repo_name = repo.get("name")
|
repo_name = repo.get("name")
|
||||||
if not isinstance(owner_login, str) or not isinstance(repo_name, str):
|
if not isinstance(owner_login, str) or not isinstance(repo_name, str):
|
||||||
return None
|
return None
|
||||||
|
|
@ -247,6 +262,7 @@ def _course_card(summary: dict[str, Any]) -> dict[str, object]:
|
||||||
"summary": summary["description"],
|
"summary": summary["description"],
|
||||||
"status": "Live course repo",
|
"status": "Live course repo",
|
||||||
"outline": summary["course_outline"],
|
"outline": summary["course_outline"],
|
||||||
|
"updated_at": summary["updated_at"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -258,9 +274,65 @@ def _post_card(summary: dict[str, Any]) -> dict[str, object]:
|
||||||
"repo": summary["full_name"],
|
"repo": summary["full_name"],
|
||||||
"kind": "Repo with /blogs/",
|
"kind": "Repo with /blogs/",
|
||||||
"summary": f"{label}. {summary['description']}",
|
"summary": f"{label}. {summary['description']}",
|
||||||
|
"updated_at": summary["updated_at"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _recent_public_issues(
|
||||||
|
client: ForgejoClient,
|
||||||
|
repos: list[dict[str, Any]],
|
||||||
|
limit: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
issue_lists = await asyncio.gather(
|
||||||
|
*[_repo_issues(client, repo, limit) for repo in repos],
|
||||||
|
)
|
||||||
|
issues = [issue for issue_list in issue_lists for issue in issue_list]
|
||||||
|
return sorted(issues, key=lambda issue: str(issue.get("updated_at", "")), reverse=True)[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
async def _repo_issues(
|
||||||
|
client: ForgejoClient,
|
||||||
|
repo: dict[str, Any],
|
||||||
|
limit: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
owner_login = _repo_owner_login(repo)
|
||||||
|
repo_name = repo.get("name")
|
||||||
|
if not isinstance(owner_login, str) or not isinstance(repo_name, str):
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
issues = await client.list_repo_issues(owner_login, repo_name, limit=limit)
|
||||||
|
except ForgejoClientError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [_with_repository(issue, repo, owner_login, repo_name) for issue in issues]
|
||||||
|
|
||||||
|
|
||||||
|
def _with_repository(
|
||||||
|
issue: dict[str, Any],
|
||||||
|
repo: dict[str, Any],
|
||||||
|
owner_login: str,
|
||||||
|
repo_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
issue_with_repo = dict(issue)
|
||||||
|
issue_with_repo["repository"] = {
|
||||||
|
"owner": owner_login,
|
||||||
|
"name": repo_name,
|
||||||
|
"full_name": repo.get("full_name", f"{owner_login}/{repo_name}"),
|
||||||
|
"private": False,
|
||||||
|
}
|
||||||
|
return issue_with_repo
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_owner_login(repo: dict[str, Any]) -> str | None:
|
||||||
|
owner = repo.get("owner", {})
|
||||||
|
if isinstance(owner, dict) and isinstance(owner.get("login"), str):
|
||||||
|
return owner["login"]
|
||||||
|
if isinstance(owner, str):
|
||||||
|
return owner
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[str, object]]:
|
def _event_cards(calendar_feeds: list[CalendarFeed], limit: int) -> list[dict[str, object]]:
|
||||||
upcoming_events = sorted(
|
upcoming_events = sorted(
|
||||||
[event for feed in calendar_feeds for event in feed.events],
|
[event for feed in calendar_feeds for event in feed.events],
|
||||||
|
|
@ -341,10 +413,17 @@ def _discussion_reply(comment: dict[str, Any]) -> dict[str, object]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _display_auth_source(auth_source: str, session_user: dict[str, Any] | None) -> str:
|
||||||
|
if session_user:
|
||||||
|
return "session"
|
||||||
|
return auth_source
|
||||||
|
|
||||||
|
|
||||||
def _empty_payload(
|
def _empty_payload(
|
||||||
*,
|
*,
|
||||||
source_cards: list[dict[str, str]],
|
source_cards: list[dict[str, str]],
|
||||||
warnings: list[str],
|
warnings: list[str],
|
||||||
|
auth: dict[str, object],
|
||||||
hero_summary: str,
|
hero_summary: str,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
|
|
@ -355,16 +434,43 @@ def _empty_payload(
|
||||||
"highlights": [
|
"highlights": [
|
||||||
"Forgejo remains the source of truth for content and discussions",
|
"Forgejo remains the source of truth for content and discussions",
|
||||||
"The prototype now targets aksal.cloud by default",
|
"The prototype now targets aksal.cloud by default",
|
||||||
"Live repo discovery unlocks as soon as a backend token is configured",
|
"Public repo discovery works without signing in when Forgejo allows anonymous reads",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"auth": auth,
|
||||||
"source_of_truth": source_cards,
|
"source_of_truth": source_cards,
|
||||||
"featured_courses": [],
|
"featured_courses": [],
|
||||||
"recent_posts": [],
|
"recent_posts": [],
|
||||||
"upcoming_events": [],
|
"upcoming_events": [],
|
||||||
"recent_discussions": [],
|
"recent_discussions": [],
|
||||||
"implementation_notes": warnings
|
"implementation_notes": warnings
|
||||||
or ["Live repo discovery is ready, but no Forgejo token has been configured yet."],
|
or ["Live repo discovery is ready, but Forgejo did not return public content."],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_payload(
|
||||||
|
user: dict[str, Any] | None,
|
||||||
|
source: str,
|
||||||
|
settings: Settings,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
oauth_configured = bool(
|
||||||
|
settings.forgejo_oauth_client_id and settings.forgejo_oauth_client_secret
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
return {
|
||||||
|
"authenticated": False,
|
||||||
|
"login": None,
|
||||||
|
"source": source,
|
||||||
|
"can_reply": source in {"authorization", "session"},
|
||||||
|
"oauth_configured": oauth_configured,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"authenticated": True,
|
||||||
|
"login": user.get("login", "Unknown user"),
|
||||||
|
"source": source,
|
||||||
|
"can_reply": source in {"authorization", "session"},
|
||||||
|
"oauth_configured": oauth_configured,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -468,7 +574,9 @@ async def _load_calendar_feeds(settings: Settings, warnings: list[str]) -> list[
|
||||||
|
|
||||||
|
|
||||||
def _format_event_datetime(value: Any) -> str:
|
def _format_event_datetime(value: Any) -> str:
|
||||||
return value.strftime("%b %-d, %-I:%M %p UTC")
|
timezone_name = value.strftime("%Z") if hasattr(value, "strftime") else ""
|
||||||
|
suffix = f" {timezone_name}" if timezone_name else ""
|
||||||
|
return f"{value.strftime('%b %-d, %-I:%M %p')}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
def _empty_lesson(
|
def _empty_lesson(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ status=0
|
||||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
python_files=(
|
python_files=(
|
||||||
"app.py"
|
"app.py"
|
||||||
|
"auth.py"
|
||||||
|
"calendar_feeds.py"
|
||||||
"forgejo_client.py"
|
"forgejo_client.py"
|
||||||
"live_prototype.py"
|
"live_prototype.py"
|
||||||
"settings.py"
|
"settings.py"
|
||||||
|
|
@ -43,7 +45,7 @@ run_check \
|
||||||
uv run --with "deptry>=0.24.0,<1.0.0" \
|
uv run --with "deptry>=0.24.0,<1.0.0" \
|
||||||
deptry . \
|
deptry . \
|
||||||
--requirements-files requirements.txt \
|
--requirements-files requirements.txt \
|
||||||
--known-first-party app,forgejo_client,live_prototype,settings \
|
--known-first-party app,auth,calendar_feeds,forgejo_client,live_prototype,settings \
|
||||||
--per-rule-ignores DEP002=uvicorn \
|
--per-rule-ignores DEP002=uvicorn \
|
||||||
--extend-exclude ".*/frontend/.*" \
|
--extend-exclude ".*/frontend/.*" \
|
||||||
--extend-exclude ".*/\\.venv/.*" \
|
--extend-exclude ".*/\\.venv/.*" \
|
||||||
|
|
@ -51,7 +53,7 @@ run_check \
|
||||||
run_check \
|
run_check \
|
||||||
"Vulture" \
|
"Vulture" \
|
||||||
uv run --with "vulture>=2.15,<3.0.0" \
|
uv run --with "vulture>=2.15,<3.0.0" \
|
||||||
vulture app.py forgejo_client.py live_prototype.py settings.py tests --min-confidence 80
|
vulture app.py auth.py calendar_feeds.py forgejo_client.py live_prototype.py settings.py tests --min-confidence 80
|
||||||
run_check \
|
run_check \
|
||||||
"Backend Tests" \
|
"Backend Tests" \
|
||||||
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"
|
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"
|
||||||
|
|
|
||||||
19
settings.py
19
settings.py
|
|
@ -7,8 +7,12 @@ from functools import lru_cache
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
|
app_base_url: str | None
|
||||||
forgejo_base_url: str
|
forgejo_base_url: str
|
||||||
forgejo_token: str | None
|
forgejo_token: str | None
|
||||||
|
forgejo_oauth_client_id: str | None
|
||||||
|
forgejo_oauth_client_secret: str | None
|
||||||
|
forgejo_oauth_scopes: tuple[str, ...]
|
||||||
forgejo_repo_scan_limit: int
|
forgejo_repo_scan_limit: int
|
||||||
forgejo_recent_issue_limit: int
|
forgejo_recent_issue_limit: int
|
||||||
forgejo_request_timeout_seconds: float
|
forgejo_request_timeout_seconds: float
|
||||||
|
|
@ -33,16 +37,29 @@ def _parse_calendar_feed_urls(raw_value: str | None) -> tuple[str, ...]:
|
||||||
return tuple(entry.strip() for entry in value.replace("\n", ",").split(",") if entry.strip())
|
return tuple(entry.strip() for entry in value.replace("\n", ",").split(",") if entry.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_scopes(raw_value: str | None) -> tuple[str, ...]:
|
||||||
|
value = (raw_value or "").strip()
|
||||||
|
if not value:
|
||||||
|
return ("openid", "profile")
|
||||||
|
return tuple(scope for scope in value.replace(",", " ").split() if scope)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
return Settings(
|
return Settings(
|
||||||
|
app_base_url=_normalize_base_url(os.getenv("APP_BASE_URL"))
|
||||||
|
if os.getenv("APP_BASE_URL")
|
||||||
|
else None,
|
||||||
forgejo_base_url=_normalize_base_url(os.getenv("FORGEJO_BASE_URL")),
|
forgejo_base_url=_normalize_base_url(os.getenv("FORGEJO_BASE_URL")),
|
||||||
forgejo_token=os.getenv("FORGEJO_TOKEN") or None,
|
forgejo_token=os.getenv("FORGEJO_TOKEN") or None,
|
||||||
|
forgejo_oauth_client_id=os.getenv("FORGEJO_OAUTH_CLIENT_ID") or None,
|
||||||
|
forgejo_oauth_client_secret=os.getenv("FORGEJO_OAUTH_CLIENT_SECRET") or None,
|
||||||
|
forgejo_oauth_scopes=_parse_scopes(os.getenv("FORGEJO_OAUTH_SCOPES")),
|
||||||
forgejo_repo_scan_limit=int(os.getenv("FORGEJO_REPO_SCAN_LIMIT", "30")),
|
forgejo_repo_scan_limit=int(os.getenv("FORGEJO_REPO_SCAN_LIMIT", "30")),
|
||||||
forgejo_recent_issue_limit=int(os.getenv("FORGEJO_RECENT_ISSUE_LIMIT", "6")),
|
forgejo_recent_issue_limit=int(os.getenv("FORGEJO_RECENT_ISSUE_LIMIT", "6")),
|
||||||
forgejo_request_timeout_seconds=float(
|
forgejo_request_timeout_seconds=float(
|
||||||
os.getenv("FORGEJO_REQUEST_TIMEOUT_SECONDS", "10.0"),
|
os.getenv("FORGEJO_REQUEST_TIMEOUT_SECONDS", "10.0"),
|
||||||
),
|
),
|
||||||
calendar_feed_urls=_parse_calendar_feed_urls(os.getenv("CALENDAR_FEED_URLS")),
|
calendar_feed_urls=_parse_calendar_feed_urls(os.getenv("CALENDAR_FEED_URLS")),
|
||||||
calendar_event_limit=int(os.getenv("CALENDAR_EVENT_LIMIT", "6")),
|
calendar_event_limit=int(os.getenv("CALENDAR_EVENT_LIMIT", "3")),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import os
|
||||||
import json
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
from settings import get_settings
|
from settings import get_settings
|
||||||
|
|
@ -13,42 +13,400 @@ from settings import get_settings
|
||||||
|
|
||||||
class AppTestCase(unittest.TestCase):
|
class AppTestCase(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
self.env_patcher = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"APP_BASE_URL": "http://testserver",
|
||||||
|
"FORGEJO_TOKEN": "",
|
||||||
|
"FORGEJO_OAUTH_CLIENT_ID": "client-id",
|
||||||
|
"FORGEJO_OAUTH_CLIENT_SECRET": "client-secret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.env_patcher.start()
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
self.app = create_app()
|
self.app = create_app()
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
self.env_patcher.stop()
|
||||||
def _get_route_response(self, path: str) -> JSONResponse:
|
|
||||||
route = next(route for route in self.app.routes if getattr(route, "path", None) == path)
|
|
||||||
return asyncio.run(route.endpoint())
|
|
||||||
|
|
||||||
def test_health(self) -> None:
|
def test_health(self) -> None:
|
||||||
response = self._get_route_response("/health")
|
response = self.client.get("/health")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(json.loads(response.body), {"status": "ok"})
|
self.assertEqual(response.json(), {"status": "ok"})
|
||||||
|
|
||||||
def test_prototype_payload_shape(self) -> None:
|
def test_prototype_payload_shape(self) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"hero": {"title": "Robot U"},
|
"hero": {"title": "Robot U"},
|
||||||
|
"auth": {
|
||||||
|
"authenticated": False,
|
||||||
|
"login": None,
|
||||||
|
"source": "none",
|
||||||
|
"can_reply": False,
|
||||||
|
"oauth_configured": True,
|
||||||
|
},
|
||||||
"featured_courses": [],
|
"featured_courses": [],
|
||||||
"recent_posts": [],
|
"recent_posts": [],
|
||||||
"recent_discussions": [],
|
"recent_discussions": [],
|
||||||
"upcoming_events": [],
|
"upcoming_events": [],
|
||||||
"source_of_truth": [],
|
"source_of_truth": [],
|
||||||
}
|
}
|
||||||
with patch("app.build_live_prototype_payload", new=AsyncMock(return_value=payload)):
|
builder = AsyncMock(return_value=payload)
|
||||||
response = self._get_route_response("/api/prototype")
|
with patch("app.build_live_prototype_payload", new=builder):
|
||||||
payload = json.loads(response.body)
|
response = self.client.get("/api/prototype")
|
||||||
|
response_payload = response.json()
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn("hero", payload)
|
self.assertIn("hero", response_payload)
|
||||||
self.assertIn("featured_courses", payload)
|
self.assertIn("auth", response_payload)
|
||||||
self.assertIn("recent_posts", payload)
|
self.assertIn("featured_courses", response_payload)
|
||||||
self.assertIn("recent_discussions", payload)
|
self.assertIn("recent_posts", response_payload)
|
||||||
self.assertIn("upcoming_events", payload)
|
self.assertIn("recent_discussions", response_payload)
|
||||||
self.assertIn("source_of_truth", payload)
|
self.assertIn("upcoming_events", response_payload)
|
||||||
|
self.assertIn("source_of_truth", response_payload)
|
||||||
|
self.assertEqual(builder.call_args.kwargs["forgejo_token"], None)
|
||||||
|
self.assertEqual(builder.call_args.kwargs["auth_source"], "none")
|
||||||
|
|
||||||
|
def test_prototype_accepts_authorization_token(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"hero": {"title": "Robot U"},
|
||||||
|
"auth": {
|
||||||
|
"authenticated": True,
|
||||||
|
"login": "kacper",
|
||||||
|
"source": "authorization",
|
||||||
|
"can_reply": True,
|
||||||
|
"oauth_configured": True,
|
||||||
|
},
|
||||||
|
"featured_courses": [],
|
||||||
|
"recent_posts": [],
|
||||||
|
"recent_discussions": [],
|
||||||
|
"upcoming_events": [],
|
||||||
|
"source_of_truth": [],
|
||||||
|
}
|
||||||
|
builder = AsyncMock(return_value=payload)
|
||||||
|
with patch("app.build_live_prototype_payload", new=builder):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/prototype",
|
||||||
|
headers={"Authorization": "token test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "test-token")
|
||||||
|
self.assertEqual(builder.call_args.kwargs["auth_source"], "authorization")
|
||||||
|
|
||||||
|
def test_prototype_can_use_server_token_without_user_session(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"hero": {"title": "Robot U"},
|
||||||
|
"auth": {
|
||||||
|
"authenticated": False,
|
||||||
|
"login": None,
|
||||||
|
"source": "server",
|
||||||
|
"can_reply": False,
|
||||||
|
"oauth_configured": True,
|
||||||
|
},
|
||||||
|
"featured_courses": [],
|
||||||
|
"recent_posts": [],
|
||||||
|
"recent_discussions": [],
|
||||||
|
"upcoming_events": [],
|
||||||
|
"source_of_truth": [],
|
||||||
|
}
|
||||||
|
builder = AsyncMock(return_value=payload)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}),
|
||||||
|
patch("app.build_live_prototype_payload", new=builder),
|
||||||
|
):
|
||||||
|
response = self.client.get("/api/prototype")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "server-token")
|
||||||
|
self.assertEqual(builder.call_args.kwargs["auth_source"], "server")
|
||||||
|
|
||||||
|
def test_auth_session_ignores_server_token_fallback(self) -> None:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
with patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}):
|
||||||
|
response = self.client.get("/api/auth/session")
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(payload["authenticated"], False)
|
||||||
|
self.assertEqual(payload["login"], None)
|
||||||
|
self.assertEqual(payload["source"], "none")
|
||||||
|
self.assertEqual(payload["can_reply"], False)
|
||||||
|
|
||||||
|
def test_forgejo_auth_start_redirects_to_authorization_endpoint(self) -> None:
|
||||||
|
fake_client = _FakeForgejoClient()
|
||||||
|
with patch("app.ForgejoClient", return_value=fake_client):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/auth/forgejo/start?return_to=/discussions/7",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
location = response.headers["location"]
|
||||||
|
query = parse_qs(urlparse(location).query)
|
||||||
|
self.assertEqual(response.status_code, 303)
|
||||||
|
self.assertTrue(location.startswith("https://aksal.cloud/login/oauth/authorize?"))
|
||||||
|
self.assertEqual(query["client_id"], ["client-id"])
|
||||||
|
self.assertEqual(query["redirect_uri"], ["http://testserver/api/auth/forgejo/callback"])
|
||||||
|
self.assertEqual(query["response_type"], ["code"])
|
||||||
|
self.assertEqual(query["scope"], ["openid profile"])
|
||||||
|
self.assertEqual(query["code_challenge_method"], ["S256"])
|
||||||
|
self.assertIn("state", query)
|
||||||
|
self.assertIn("code_challenge", query)
|
||||||
|
|
||||||
|
def test_forgejo_auth_callback_sets_session_cookie(self) -> None:
|
||||||
|
fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
|
||||||
|
with patch("app.ForgejoClient", return_value=fake_client):
|
||||||
|
start_response = self.client.get(
|
||||||
|
"/api/auth/forgejo/start?return_to=/discussions/7",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0]
|
||||||
|
callback_response = self.client.get(
|
||||||
|
f"/api/auth/forgejo/callback?code=auth-code&state={state}",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(callback_response.status_code, 303)
|
||||||
|
self.assertEqual(callback_response.headers["location"], "/discussions/7")
|
||||||
|
self.assertIn("robot_u_session", callback_response.cookies)
|
||||||
|
self.assertEqual(fake_client.exchanged_code, "auth-code")
|
||||||
|
|
||||||
|
session_response = self.client.get("/api/auth/session")
|
||||||
|
session_payload = session_response.json()
|
||||||
|
self.assertEqual(session_response.status_code, 200)
|
||||||
|
self.assertEqual(session_payload["authenticated"], True)
|
||||||
|
self.assertEqual(session_payload["login"], "kacper")
|
||||||
|
self.assertEqual(session_payload["source"], "session")
|
||||||
|
self.assertEqual(session_payload["can_reply"], True)
|
||||||
|
|
||||||
|
def test_prototype_uses_signed_in_forgejo_identity_token(self) -> None:
|
||||||
|
fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
|
||||||
|
with patch("app.ForgejoClient", return_value=fake_client):
|
||||||
|
start_response = self.client.get(
|
||||||
|
"/api/auth/forgejo/start",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0]
|
||||||
|
self.client.get(
|
||||||
|
f"/api/auth/forgejo/callback?code=auth-code&state={state}",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"hero": {"title": "Robot U"},
|
||||||
|
"auth": {
|
||||||
|
"authenticated": True,
|
||||||
|
"login": "kacper",
|
||||||
|
"source": "session",
|
||||||
|
"can_reply": True,
|
||||||
|
"oauth_configured": True,
|
||||||
|
},
|
||||||
|
"featured_courses": [],
|
||||||
|
"recent_posts": [],
|
||||||
|
"recent_discussions": [],
|
||||||
|
"upcoming_events": [],
|
||||||
|
"source_of_truth": [],
|
||||||
|
}
|
||||||
|
builder = AsyncMock(return_value=payload)
|
||||||
|
with patch("app.build_live_prototype_payload", new=builder):
|
||||||
|
response = self.client.get("/api/prototype")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(builder.call_args.kwargs["forgejo_token"], "oauth-token")
|
||||||
|
self.assertEqual(builder.call_args.kwargs["auth_source"], "session")
|
||||||
|
self.assertEqual(builder.call_args.kwargs["session_user"]["login"], "kacper")
|
||||||
|
|
||||||
|
def test_create_discussion_reply(self) -> None:
|
||||||
|
fake_client = _FakeForgejoClient(
|
||||||
|
comment={
|
||||||
|
"id": 123,
|
||||||
|
"body": "Thanks, this helped.",
|
||||||
|
"created_at": "2026-04-11T12:00:00Z",
|
||||||
|
"html_url": "https://aksal.cloud/Robot-U/RobotClass/issues/2#issuecomment-123",
|
||||||
|
"user": {
|
||||||
|
"login": "Kacper",
|
||||||
|
"avatar_url": "https://aksal.cloud/avatar.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with patch("app.ForgejoClient", return_value=fake_client) as client_factory:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/discussions/replies",
|
||||||
|
json={
|
||||||
|
"owner": "Robot-U",
|
||||||
|
"repo": "RobotClass",
|
||||||
|
"number": 2,
|
||||||
|
"body": "Thanks, this helped.",
|
||||||
|
},
|
||||||
|
headers={"Authorization": "token test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "test-token")
|
||||||
|
self.assertEqual(
|
||||||
|
fake_client.created_comment, ("Robot-U", "RobotClass", 2, "Thanks, this helped.")
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["id"], 123)
|
||||||
|
self.assertEqual(payload["author"], "Kacper")
|
||||||
|
self.assertEqual(payload["body"], "Thanks, this helped.")
|
||||||
|
|
||||||
|
def test_create_discussion_reply_uses_signed_in_identity(self) -> None:
|
||||||
|
sign_in_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
|
||||||
|
with patch("app.ForgejoClient", return_value=sign_in_client):
|
||||||
|
start_response = self.client.get(
|
||||||
|
"/api/auth/forgejo/start",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0]
|
||||||
|
self.client.get(
|
||||||
|
f"/api/auth/forgejo/callback?code=auth-code&state={state}",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
reply_client = _FakeForgejoClient(
|
||||||
|
comment={
|
||||||
|
"id": 123,
|
||||||
|
"body": "Thanks, this helped.",
|
||||||
|
"created_at": "2026-04-11T12:00:00Z",
|
||||||
|
"html_url": "https://aksal.cloud/Robot-U/RobotClass/issues/2#issuecomment-123",
|
||||||
|
"user": {"login": "Kacper", "avatar_url": ""},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with patch("app.ForgejoClient", return_value=reply_client) as client_factory:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/discussions/replies",
|
||||||
|
json={
|
||||||
|
"owner": "Robot-U",
|
||||||
|
"repo": "RobotClass",
|
||||||
|
"number": 2,
|
||||||
|
"body": "Thanks, this helped.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(client_factory.call_args.kwargs["forgejo_token"], "oauth-token")
|
||||||
|
self.assertEqual(
|
||||||
|
reply_client.created_comment, ("Robot-U", "RobotClass", 2, "Thanks, this helped.")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_discussion_reply_rejects_private_repo(self) -> None:
|
||||||
|
fake_client = _FakeForgejoClient(repo_private=True)
|
||||||
|
with patch("app.ForgejoClient", return_value=fake_client):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/discussions/replies",
|
||||||
|
json={
|
||||||
|
"owner": "Robot-U",
|
||||||
|
"repo": "PrivateRobotClass",
|
||||||
|
"number": 2,
|
||||||
|
"body": "Thanks, this helped.",
|
||||||
|
},
|
||||||
|
headers={"Authorization": "token test-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertIsNone(fake_client.created_comment)
|
||||||
|
|
||||||
|
def test_create_discussion_reply_rejects_server_token_fallback(self) -> None:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
with patch.dict(os.environ, {"FORGEJO_TOKEN": "server-token"}):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/discussions/replies",
|
||||||
|
json={
|
||||||
|
"owner": "Robot-U",
|
||||||
|
"repo": "RobotClass",
|
||||||
|
"number": 2,
|
||||||
|
"body": "Thanks, this helped.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeForgejoClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
comment: dict[str, object] | None = None,
|
||||||
|
user: dict[str, object] | None = None,
|
||||||
|
access_token: str = "test-oauth-token",
|
||||||
|
repo_private: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._comment = comment
|
||||||
|
self._user = user or {"login": "test-user"}
|
||||||
|
self._access_token = access_token
|
||||||
|
self._repo_private = repo_private
|
||||||
|
self.created_comment: tuple[str, str, int, str] | None = None
|
||||||
|
self.exchanged_code: str | None = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _FakeForgejoClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def create_issue_comment(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
issue_number: int,
|
||||||
|
body: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
self.created_comment = (owner, repo, issue_number, body)
|
||||||
|
if self._comment is None:
|
||||||
|
raise AssertionError("Fake comment was not configured.")
|
||||||
|
return self._comment
|
||||||
|
|
||||||
|
async def fetch_current_user(self) -> dict[str, object]:
|
||||||
|
return self._user
|
||||||
|
|
||||||
|
async def fetch_repository(self, owner: str, repo: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"owner": {"login": owner},
|
||||||
|
"name": repo,
|
||||||
|
"full_name": f"{owner}/{repo}",
|
||||||
|
"private": self._repo_private,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fetch_openid_configuration(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"authorization_endpoint": "https://aksal.cloud/login/oauth/authorize",
|
||||||
|
"token_endpoint": "https://aksal.cloud/login/oauth/access_token",
|
||||||
|
"userinfo_endpoint": "https://aksal.cloud/login/oauth/userinfo",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def exchange_oauth_code(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
token_endpoint: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
code: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
code_verifier: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
self.exchanged_code = code
|
||||||
|
self.exchanged_token_request = {
|
||||||
|
"token_endpoint": token_endpoint,
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"code_verifier": code_verifier,
|
||||||
|
}
|
||||||
|
return {"access_token": self._access_token}
|
||||||
|
|
||||||
|
async def fetch_userinfo(self, userinfo_endpoint: str, access_token: str) -> dict[str, object]:
|
||||||
|
self.fetched_userinfo = {
|
||||||
|
"userinfo_endpoint": userinfo_endpoint,
|
||||||
|
"access_token": access_token,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"preferred_username": self._user["login"],
|
||||||
|
"picture": self._user.get("avatar_url", ""),
|
||||||
|
"email": self._user.get("email", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
42
tests/test_calendar_feeds.py
Normal file
42
tests/test_calendar_feeds.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from calendar_feeds import _parse_ics
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarFeedTestCase(unittest.TestCase):
|
||||||
|
def test_weekly_recurrence_returns_future_occurrences(self) -> None:
|
||||||
|
timezone = ZoneInfo("America/New_York")
|
||||||
|
starts_at = datetime.now(timezone).replace(
|
||||||
|
hour=13,
|
||||||
|
minute=0,
|
||||||
|
second=0,
|
||||||
|
microsecond=0,
|
||||||
|
) - timedelta(days=7)
|
||||||
|
ends_at = starts_at.replace(hour=16)
|
||||||
|
raw_calendar = f"""BEGIN:VCALENDAR
|
||||||
|
X-WR-CALNAME:Robot-U
|
||||||
|
BEGIN:VEVENT
|
||||||
|
SUMMARY:Robotics Club
|
||||||
|
DTSTART;TZID=America/New_York:{starts_at.strftime("%Y%m%dT%H%M%S")}
|
||||||
|
DTEND;TZID=America/New_York:{ends_at.strftime("%Y%m%dT%H%M%S")}
|
||||||
|
RRULE:FREQ=WEEKLY
|
||||||
|
LOCATION:44 Portland Street
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
"""
|
||||||
|
|
||||||
|
_calendar_name, events = _parse_ics(raw_calendar, "https://example.com/calendar.ics")
|
||||||
|
|
||||||
|
self.assertGreaterEqual(len(events), 1)
|
||||||
|
self.assertEqual(events[0].title, "Robotics Club")
|
||||||
|
self.assertGreaterEqual(events[0].starts_at, datetime.now(ZoneInfo("UTC")))
|
||||||
|
self.assertEqual(events[0].starts_at.tzinfo, timezone)
|
||||||
|
self.assertEqual(events[0].mode, "44 Portland Street")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue