feat: feedback button, cf-core env-var LLM config, mobile polish #14
4 changed files with 133 additions and 12 deletions
|
|
@ -12,11 +12,10 @@ from pathlib import Path
|
|||
from typing import Literal
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.store import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -118,7 +117,13 @@ class FeedbackResponse(BaseModel):
|
|||
issue_url: str
|
||||
|
||||
|
||||
# ── Route ──────────────────────────────────────────────────────────────────────
|
||||
# ── Routes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/status")
|
||||
def feedback_status() -> dict:
|
||||
"""Return whether feedback submission is configured on this instance."""
|
||||
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not settings.DEMO_MODE}
|
||||
|
||||
|
||||
@router.post("", response_model=FeedbackResponse)
|
||||
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
|
||||
|
|
|
|||
|
|
@ -136,12 +136,21 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ currentTab?: string }>()
|
||||
|
||||
// Check if feedback is enabled (token configured) — we try once and cache
|
||||
const enabled = ref(true) // optimistic; 503 from API will hide on next attempt
|
||||
// Probe once on mount — hidden until confirmed enabled so button never flashes
|
||||
const enabled = ref(false)
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/feedback/status')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
enabled.value = data.enabled === true
|
||||
}
|
||||
} catch { /* network error — stay hidden */ }
|
||||
})
|
||||
|
||||
const open = ref(false)
|
||||
const step = ref(1)
|
||||
|
|
@ -208,11 +217,6 @@ async function submit() {
|
|||
submitter: form.value.submitter.trim(),
|
||||
}),
|
||||
})
|
||||
if (res.status === 503) {
|
||||
enabled.value = false // token not configured — hide the button
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
submitError.value = err.detail ?? 'Submission failed.'
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ dependencies = [
|
|||
"numpy>=1.25",
|
||||
"pyzbar>=0.1.9",
|
||||
"Pillow>=10.0",
|
||||
# HTTP client
|
||||
# HTTP clients
|
||||
"httpx>=0.27",
|
||||
"requests>=2.31",
|
||||
# CircuitForge shared scaffold
|
||||
"circuitforge-core>=0.6.0",
|
||||
]
|
||||
|
|
|
|||
111
tests/api/test_feedback.py
Normal file
111
tests/api/test_feedback.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""Tests for the /feedback endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
# ── /feedback/status ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_status_disabled_when_no_token(monkeypatch):
|
||||
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||
res = client.get("/api/v1/feedback/status")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"enabled": False}
|
||||
|
||||
|
||||
def test_status_enabled_when_token_set(monkeypatch):
|
||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||
res = client.get("/api/v1/feedback/status")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"enabled": True}
|
||||
|
||||
|
||||
def test_status_disabled_in_demo_mode(monkeypatch):
|
||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
|
||||
res = client.get("/api/v1/feedback/status")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"enabled": False}
|
||||
|
||||
|
||||
# ── POST /feedback ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_submit_returns_503_when_no_token(monkeypatch):
|
||||
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
|
||||
res = client.post("/api/v1/feedback", json={
|
||||
"title": "Test", "description": "desc", "type": "bug",
|
||||
})
|
||||
assert res.status_code == 503
|
||||
|
||||
|
||||
def test_submit_returns_403_in_demo_mode(monkeypatch):
|
||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
|
||||
res = client.post("/api/v1/feedback", json={
|
||||
"title": "Test", "description": "desc", "type": "bug",
|
||||
})
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
def test_submit_creates_issue(monkeypatch):
|
||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||
monkeypatch.setenv("FORGEJO_REPO", "Circuit-Forge/kiwi")
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||
|
||||
# Mock the two Forgejo HTTP calls: label fetch + issue create
|
||||
label_response = MagicMock()
|
||||
label_response.ok = True
|
||||
label_response.json.return_value = [
|
||||
{"id": 1, "name": "beta-feedback"},
|
||||
{"id": 2, "name": "needs-triage"},
|
||||
{"id": 3, "name": "bug"},
|
||||
]
|
||||
|
||||
issue_response = MagicMock()
|
||||
issue_response.ok = True
|
||||
issue_response.json.return_value = {"number": 42, "html_url": "https://example.com/issues/42"}
|
||||
|
||||
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
|
||||
patch("app.api.endpoints.feedback.requests.post", return_value=issue_response):
|
||||
res = client.post("/api/v1/feedback", json={
|
||||
"title": "Something broke",
|
||||
"description": "It broke when I tapped X",
|
||||
"type": "bug",
|
||||
"repro": "1. Open app\n2. Tap X",
|
||||
"tab": "pantry",
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["issue_number"] == 42
|
||||
assert data["issue_url"] == "https://example.com/issues/42"
|
||||
|
||||
|
||||
def test_submit_returns_502_on_forgejo_error(monkeypatch):
|
||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||
|
||||
label_response = MagicMock()
|
||||
label_response.ok = True
|
||||
label_response.json.return_value = []
|
||||
|
||||
bad_response = MagicMock()
|
||||
bad_response.ok = False
|
||||
bad_response.text = "forbidden"
|
||||
|
||||
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
|
||||
patch("app.api.endpoints.feedback.requests.post", return_value=bad_response):
|
||||
res = client.post("/api/v1/feedback", json={
|
||||
"title": "Oops", "description": "desc", "type": "other",
|
||||
})
|
||||
|
||||
assert res.status_code == 502
|
||||
Loading…
Reference in a new issue