Initial commit — Moon Household Budget
This commit is contained in:
116
moon-household-budget-handoff/suggestions/gitea.py
Normal file
116
moon-household-budget-handoff/suggestions/gitea.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Gitea-backed suggestions module — framework-agnostic core.
|
||||
|
||||
Files user-submitted suggestions as issues in a Gitea repo, mirroring the
|
||||
pattern used by Tony's `dms` and `fullmoonbakehouse` apps. No database of its
|
||||
own — Gitea issues ARE the storage.
|
||||
|
||||
Configure via environment variables:
|
||||
GITEA_URL e.g. http://192.168.0.5:3022
|
||||
GITEA_TOKEN a Gitea access token with repo issue read/write scope
|
||||
GITEA_REPO "owner/repo", e.g. bonna61/moon-household-budget
|
||||
|
||||
If any are unset, gitea_config() returns None and callers should degrade
|
||||
gracefully (show "feedback isn't set up yet" instead of crashing).
|
||||
|
||||
Depends only on `requests` (add it to requirements.txt if not already there).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
ALLOWED_IMAGE_TYPES = {
|
||||
"image/jpeg", "image/png", "image/webp",
|
||||
"image/gif", "image/heic", "image/heif",
|
||||
}
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
@dataclass
|
||||
class GiteaConfig:
|
||||
base_url: str
|
||||
token: str
|
||||
repo: str # "owner/repo"
|
||||
|
||||
|
||||
def gitea_config() -> Optional[GiteaConfig]:
|
||||
base = os.environ.get("GITEA_URL")
|
||||
token = os.environ.get("GITEA_TOKEN")
|
||||
repo = os.environ.get("GITEA_REPO")
|
||||
if not (base and token and repo):
|
||||
return None
|
||||
return GiteaConfig(base.rstrip("/"), token, repo)
|
||||
|
||||
|
||||
class GiteaError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(self, cfg: GiteaConfig):
|
||||
self.cfg = cfg
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.cfg.base_url}/api/v1/repos/{self.cfg.repo}{path}"
|
||||
|
||||
def _req(self, method: str, path: str, **kw):
|
||||
headers = {
|
||||
"Authorization": f"token {self.cfg.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
r = requests.request(method, self._url(path), headers=headers, timeout=15, **kw)
|
||||
if not r.ok:
|
||||
raise GiteaError(f"Gitea {r.status_code}: {r.text[:300]}")
|
||||
return r.json()
|
||||
|
||||
def list_issues(self, state: str = "open"):
|
||||
return self._req("GET", f"/issues?type=issues&state={state}&limit=50")
|
||||
|
||||
def create_issue(self, title: str, body: str):
|
||||
return self._req("POST", "/issues", json={"title": title, "body": body})
|
||||
|
||||
def list_comments(self, number: int):
|
||||
return self._req("GET", f"/issues/{number}/comments")
|
||||
|
||||
def create_comment(self, number: int, body: str):
|
||||
return self._req("POST", f"/issues/{number}/comments", json={"body": body})
|
||||
|
||||
|
||||
def sniff_image(buf: bytes) -> Optional[str]:
|
||||
"""Return a MIME type by inspecting magic bytes, or None if unrecognized."""
|
||||
if buf[:3] == b"\xff\xd8\xff":
|
||||
return "image/jpeg"
|
||||
if buf[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return "image/png"
|
||||
if buf[:4] == b"GIF8":
|
||||
return "image/gif"
|
||||
if buf[:4] == b"RIFF" and buf[8:12] == b"WEBP":
|
||||
return "image/webp"
|
||||
if buf[4:8] == b"ftyp":
|
||||
brand = buf[8:12].decode("ascii", "ignore")
|
||||
if brand in ("heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs"):
|
||||
return "image/heic"
|
||||
if brand in ("mif1", "msf1"):
|
||||
return "image/heif"
|
||||
return None
|
||||
|
||||
|
||||
def save_upload(filename: str, data: bytes, upload_dir: Path, origin: str) -> str:
|
||||
"""Persist an upload and return an absolute URL for embedding in markdown."""
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = Path(filename).suffix.lower() or ".bin"
|
||||
safe = f"{int(time.time() * 1000)}-{secrets.token_urlsafe(8)}{ext}"
|
||||
(upload_dir / safe).write_bytes(data)
|
||||
return f"{origin.rstrip('/')}/uploads/suggestions/{safe}"
|
||||
|
||||
|
||||
def footer(name: str, email: str, kind: str = "user") -> str:
|
||||
"""Attribution footer appended to a filed issue body (dms/fmb style)."""
|
||||
return f"\n\n---\n_Filed by {name} ({email}) · {kind}_"
|
||||
121
moon-household-budget-handoff/suggestions/routes_fastapi.py
Normal file
121
moon-household-budget-handoff/suggestions/routes_fastapi.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
FastAPI adapter for the Gitea-backed suggestions module.
|
||||
|
||||
Use this INSTEAD of routes_flask.py if the app is FastAPI. Wire it in with:
|
||||
|
||||
from suggestions.routes_fastapi import router as suggestions_router
|
||||
app.include_router(suggestions_router)
|
||||
|
||||
AUTH: replace `current_user()` with the app's real dependency. It must return
|
||||
something exposing `.name` and `.email`, or None.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, File, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .gitea import (
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
MAX_UPLOAD_BYTES,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
footer,
|
||||
gitea_config,
|
||||
save_upload,
|
||||
sniff_image,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
UPLOAD_DIR = Path("/data/uploads/suggestions")
|
||||
|
||||
|
||||
def current_user():
|
||||
"""ADAPT THIS to the app's real auth. Return obj with .name/.email or None."""
|
||||
return type("U", (), {"name": "Household", "email": "household@local"})()
|
||||
|
||||
|
||||
def _client_or_error():
|
||||
cfg = gitea_config()
|
||||
if not cfg:
|
||||
return None, JSONResponse({"error": "not_configured"}, status_code=503)
|
||||
return GiteaClient(cfg), None
|
||||
|
||||
|
||||
@router.get("/api/suggestions")
|
||||
def list_suggestions(state: str = "open"):
|
||||
if current_user() is None:
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
client, err = _client_or_error()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
return {"issues": client.list_issues(state)}
|
||||
except GiteaError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
@router.post("/api/suggestions")
|
||||
async def create_suggestion(request: Request):
|
||||
user = current_user()
|
||||
if user is None:
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
client, err = _client_or_error()
|
||||
if err:
|
||||
return err
|
||||
data = await request.json()
|
||||
title = (data.get("title") or "").strip()
|
||||
if len(title) < 3:
|
||||
return JSONResponse({"error": "title_required"}, status_code=400)
|
||||
body = (data.get("body") or "").strip() + footer(user.name, user.email)
|
||||
try:
|
||||
return JSONResponse({"issue": client.create_issue(title, body)}, status_code=201)
|
||||
except GiteaError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
@router.get("/api/suggestions/{number}/comments")
|
||||
def list_comments(number: int):
|
||||
if current_user() is None:
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
client, err = _client_or_error()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
return {"comments": client.list_comments(number)}
|
||||
except GiteaError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
@router.post("/api/suggestions/{number}/comments")
|
||||
async def add_comment(number: int, request: Request):
|
||||
user = current_user()
|
||||
if user is None:
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
client, err = _client_or_error()
|
||||
if err:
|
||||
return err
|
||||
data = await request.json()
|
||||
text = (data.get("body") or "").strip()
|
||||
if not text:
|
||||
return JSONResponse({"error": "body_required"}, status_code=400)
|
||||
text += f"\n\n_— {user.name}_"
|
||||
try:
|
||||
return JSONResponse({"comment": client.create_comment(number, text)}, status_code=201)
|
||||
except GiteaError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
@router.post("/api/suggestions/upload")
|
||||
async def upload(request: Request, file: UploadFile = File(...)):
|
||||
if current_user() is None:
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
data = await file.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
return JSONResponse({"error": "too_large"}, status_code=413)
|
||||
if sniff_image(data) not in ALLOWED_IMAGE_TYPES:
|
||||
return JSONResponse({"error": "unsupported_type"}, status_code=415)
|
||||
origin = str(request.base_url)
|
||||
url = save_upload(file.filename or "image", data, UPLOAD_DIR, origin)
|
||||
return JSONResponse({"url": url}, status_code=201)
|
||||
146
moon-household-budget-handoff/suggestions/routes_flask.py
Normal file
146
moon-household-budget-handoff/suggestions/routes_flask.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Flask adapter for the Gitea-backed suggestions module.
|
||||
|
||||
Wire it into the app with:
|
||||
|
||||
from suggestions.routes_flask import suggestions_bp
|
||||
app.register_blueprint(suggestions_bp)
|
||||
|
||||
Then a logged-in user can open /suggestions to file feedback. The blueprint
|
||||
exposes both the HTML page and the JSON API the page's JS talks to.
|
||||
|
||||
AUTH: replace `current_user()` below with however this app identifies the
|
||||
logged-in person. It must return an object with `.name` and `.email`, or None.
|
||||
If the app has no auth at all, you can return a fixed identity (see the stub).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
from .gitea import (
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
MAX_UPLOAD_BYTES,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
footer,
|
||||
gitea_config,
|
||||
save_upload,
|
||||
sniff_image,
|
||||
)
|
||||
|
||||
suggestions_bp = Blueprint(
|
||||
"suggestions",
|
||||
__name__,
|
||||
template_folder="templates",
|
||||
static_folder="static",
|
||||
static_url_path="/suggestions-static",
|
||||
)
|
||||
|
||||
# Where uploaded images land. Lives under the mounted /data volume so backups
|
||||
# and restarts keep them. Served at /uploads/suggestions/<name> (see below).
|
||||
UPLOAD_DIR = Path("/data/uploads/suggestions")
|
||||
|
||||
|
||||
def current_user():
|
||||
"""Resolve the logged-in user. ADAPT THIS to the app's real auth.
|
||||
|
||||
Must return an object exposing `.name` and `.email`, or None if anonymous.
|
||||
"""
|
||||
# --- Example: Flask-Login ---
|
||||
# from flask_login import current_user as u
|
||||
# return u if getattr(u, "is_authenticated", False) else None
|
||||
#
|
||||
# --- Example: session dict ---
|
||||
# from flask import session
|
||||
# if "user_email" in session:
|
||||
# return type("U", (), {"name": session.get("user_name", "User"),
|
||||
# "email": session["user_email"]})()
|
||||
# return None
|
||||
#
|
||||
# --- Stub: single-household app with no real auth ---
|
||||
return type("U", (), {"name": "Household", "email": "household@local"})()
|
||||
|
||||
|
||||
def _guard():
|
||||
return current_user() is not None
|
||||
|
||||
|
||||
@suggestions_bp.route("/suggestions")
|
||||
def suggestions_page():
|
||||
return render_template("suggestions.html", configured=gitea_config() is not None)
|
||||
|
||||
|
||||
@suggestions_bp.route("/api/suggestions", methods=["GET", "POST"])
|
||||
def suggestions_api():
|
||||
if not _guard():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
cfg = gitea_config()
|
||||
if not cfg:
|
||||
return jsonify({"error": "not_configured"}), 503
|
||||
client = GiteaClient(cfg)
|
||||
|
||||
if request.method == "GET":
|
||||
state = request.args.get("state", "open")
|
||||
try:
|
||||
return jsonify({"issues": client.list_issues(state)})
|
||||
except GiteaError as e:
|
||||
return jsonify({"error": str(e)}), 502
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
title = (data.get("title") or "").strip()
|
||||
if len(title) < 3:
|
||||
return jsonify({"error": "title_required"}), 400
|
||||
body = (data.get("body") or "").strip()
|
||||
user = current_user()
|
||||
if user is not None:
|
||||
body += footer(user.name, user.email)
|
||||
try:
|
||||
return jsonify({"issue": client.create_issue(title, body)}), 201
|
||||
except GiteaError as e:
|
||||
return jsonify({"error": str(e)}), 502
|
||||
|
||||
|
||||
@suggestions_bp.route("/api/suggestions/<int:number>/comments", methods=["GET", "POST"])
|
||||
def suggestion_comments(number: int):
|
||||
if not _guard():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
cfg = gitea_config()
|
||||
if not cfg:
|
||||
return jsonify({"error": "not_configured"}), 503
|
||||
client = GiteaClient(cfg)
|
||||
|
||||
if request.method == "GET":
|
||||
try:
|
||||
return jsonify({"comments": client.list_comments(number)})
|
||||
except GiteaError as e:
|
||||
return jsonify({"error": str(e)}), 502
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = (data.get("body") or "").strip()
|
||||
if not text:
|
||||
return jsonify({"error": "body_required"}), 400
|
||||
user = current_user()
|
||||
if user is not None:
|
||||
text += f"\n\n_— {user.name}_"
|
||||
try:
|
||||
return jsonify({"comment": client.create_comment(number, text)}), 201
|
||||
except GiteaError as e:
|
||||
return jsonify({"error": str(e)}), 502
|
||||
|
||||
|
||||
@suggestions_bp.route("/api/suggestions/upload", methods=["POST"])
|
||||
def suggestions_upload():
|
||||
if not _guard():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "file_required"}), 400
|
||||
f = request.files["file"]
|
||||
data = f.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
return jsonify({"error": "too_large"}), 413
|
||||
if sniff_image(data) not in ALLOWED_IMAGE_TYPES:
|
||||
return jsonify({"error": "unsupported_type"}), 415
|
||||
url = save_upload(f.filename or "image", data, UPLOAD_DIR, request.host_url)
|
||||
return jsonify({"url": url}), 201
|
||||
@@ -0,0 +1,87 @@
|
||||
// Minimal vanilla-JS client for the suggestions page. Talks to the JSON API
|
||||
// exposed by routes_flask.py / routes_fastapi.py. No build step, no framework.
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
async function loadIssues() {
|
||||
const list = $("s-list");
|
||||
if (!list) return;
|
||||
try {
|
||||
const res = await fetch("/api/suggestions?state=open");
|
||||
const data = await res.json();
|
||||
const issues = data.issues || [];
|
||||
if (!issues.length) {
|
||||
list.innerHTML = '<p class="muted">No open suggestions yet.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = issues.map((i) => `
|
||||
<div class="card">
|
||||
<div class="issue-title">#${i.number} ${escapeHtml(i.title)}</div>
|
||||
<div class="issue-meta">${i.comments} comment(s) ·
|
||||
<a href="${i.html_url}" target="_blank" rel="noopener">view on Gitea</a></div>
|
||||
</div>`).join("");
|
||||
} catch (e) {
|
||||
list.innerHTML = '<p class="muted">Could not load suggestions.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return (s || "").replace(/[&<>"']/g, (c) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
||||
));
|
||||
}
|
||||
|
||||
async function uploadImage(file) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/suggestions/upload", { method: "POST", body: fd });
|
||||
if (!res.ok) throw new Error("upload failed");
|
||||
return (await res.json()).url;
|
||||
}
|
||||
|
||||
function wireComposer() {
|
||||
const send = $("s-send");
|
||||
const body = $("s-body");
|
||||
if (!send || !body) return;
|
||||
|
||||
// Paste-to-attach: drop an image embed into the body.
|
||||
body.addEventListener("paste", async (ev) => {
|
||||
const item = [...(ev.clipboardData?.items || [])].find((i) => i.type.startsWith("image/"));
|
||||
if (!item) return;
|
||||
ev.preventDefault();
|
||||
$("s-status").textContent = "Uploading image…";
|
||||
try {
|
||||
const url = await uploadImage(item.getAsFile());
|
||||
body.value += `\n\n`;
|
||||
$("s-status").textContent = "Image attached.";
|
||||
} catch {
|
||||
$("s-status").textContent = "Image upload failed.";
|
||||
}
|
||||
});
|
||||
|
||||
send.addEventListener("click", async () => {
|
||||
const title = $("s-title").value.trim();
|
||||
if (title.length < 3) { $("s-status").textContent = "Add a longer title."; return; }
|
||||
send.disabled = true;
|
||||
$("s-status").textContent = "Sending…";
|
||||
try {
|
||||
const res = await fetch("/api/suggestions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, body: body.value }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
$("s-title").value = "";
|
||||
body.value = "";
|
||||
$("s-status").textContent = "Thanks! Sent.";
|
||||
loadIssues();
|
||||
} catch {
|
||||
$("s-status").textContent = "Could not send — try again.";
|
||||
} finally {
|
||||
send.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wireComposer();
|
||||
loadIssues();
|
||||
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Suggestions · Moon Household Budget</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1c1c1e; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
.card { border: 1px solid #e3e3e6; border-radius: 12px; padding: 1rem; margin: .75rem 0; }
|
||||
input, textarea { width: 100%; box-sizing: border-box; padding: .6rem; border-radius: 8px;
|
||||
border: 1px solid #cfcfd4; font: inherit; margin-bottom: .5rem; }
|
||||
textarea { min-height: 100px; resize: vertical; }
|
||||
button { background: #4338ca; color: #fff; border: 0; border-radius: 8px;
|
||||
padding: .6rem 1rem; font: inherit; cursor: pointer; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
.muted { color: #6b6b70; font-size: .85rem; }
|
||||
.issue-title { font-weight: 600; }
|
||||
.issue-meta { color: #6b6b70; font-size: .8rem; }
|
||||
.notice { background: #fff8e1; border: 1px solid #f0e3a8; padding: .75rem; border-radius: 8px; }
|
||||
a { color: #4338ca; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>💡 Suggestions & Feedback</h1>
|
||||
<p class="muted">Ideas, bugs, “can it also do…?” — they go straight to Tony.</p>
|
||||
|
||||
{% if not configured %}
|
||||
<div class="notice">Feedback isn’t set up yet (Gitea env vars missing). Once configured, this page goes live.</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<input id="s-title" placeholder="Short title (required)" />
|
||||
<textarea id="s-body" placeholder="Details… paste a screenshot to attach it."></textarea>
|
||||
<div style="display:flex; gap:.5rem; align-items:center;">
|
||||
<button id="s-send">Send suggestion</button>
|
||||
<span id="s-status" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
<h2 style="font-size:1.1rem;">Open suggestions</h2>
|
||||
<div id="s-list"><p class="muted">Loading…</p></div>
|
||||
{% endif %}
|
||||
|
||||
<script src="{{ url_for('suggestions.static', filename='suggestions.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user