diff --git a/.bumpversion.toml b/.bumpversion.toml index 6406440..0bb2ff8 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "1.18.2+260805" +current_version = "1.19.0+260729" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)\\+(?P\\d+)" serialize = ["{major}.{minor}.{patch}+{build}"] search = "{current_version}" diff --git a/pyproject.toml b/pyproject.toml index f80ec72..0372bba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rasenmaeher_api" -version = "1.18.2+260805" +version = "1.19.0+260729" description = "python-rasenmaeher-api" authors = [ { name = "Aciid", email = "703382+Aciid@users.noreply.github.com" }, diff --git a/src/rasenmaeher_api/__init__.py b/src/rasenmaeher_api/__init__.py index 707f847..4708f21 100644 --- a/src/rasenmaeher_api/__init__.py +++ b/src/rasenmaeher_api/__init__.py @@ -1,3 +1,3 @@ """python-rasenmaeher-api""" -__version__ = "1.18.2+260805" # NOTE Use `bump-my-version` to bump versions correctly +__version__ = "1.19.0+260729" # NOTE Use `bump-my-version` to bump versions correctly diff --git a/src/rasenmaeher_api/rmsettings.py b/src/rasenmaeher_api/rmsettings.py index dcf973b..feeb13f 100644 --- a/src/rasenmaeher_api/rmsettings.py +++ b/src/rasenmaeher_api/rmsettings.py @@ -142,6 +142,11 @@ def log_level_int(self) -> int: kc_realm: str = "RASENMAEHER" # In which realm the real users are kc_enabled: bool = True # Whether to use KC or not (mainly so that unit tests have less dependencies for now) + # JOKE feedback ingest integration + joke_ingest_url: str | None = None + joke_ingest_key: str | None = None + joke_timeout: float = 5.0 + # Enrollment code generation related code_size: int = 8 code_avoid_confusion: bool = True # Replace 1 and 0 with O and I to avoid confusion diff --git a/src/rasenmaeher_api/web/api/feedback/__init__.py b/src/rasenmaeher_api/web/api/feedback/__init__.py new file mode 100644 index 0000000..5d53ba6 --- /dev/null +++ b/src/rasenmaeher_api/web/api/feedback/__init__.py @@ -0,0 +1,5 @@ +"""Feedback API.""" + +from rasenmaeher_api.web.api.feedback.views import router + +__all__ = ["router"] diff --git a/src/rasenmaeher_api/web/api/feedback/schema.py b/src/rasenmaeher_api/web/api/feedback/schema.py new file mode 100644 index 0000000..a0af856 --- /dev/null +++ b/src/rasenmaeher_api/web/api/feedback/schema.py @@ -0,0 +1,39 @@ +"""Schema for feedback.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class FeedbackIn(BaseModel): + """Feedback submitted by a user from the app's feedback dialog.""" + + model_config = ConfigDict( + extra="forbid", + json_schema_extra={ + "examples": [ + { + "role": "admin", + "os": "macos", + "rating": "good", + "comments": "Works great, minor UI nit on mobile.", + "version": "1.0.0", + } + ] + }, + ) + + role: str | None = Field(default=None, description="User type/role reported by the client") + os: str | None = Field(default=None, description="Client OS") + rating: str = Field(description="User's rating selection") + comments: str = Field(description="Free-form feedback text") + version: str | None = Field(default=None, description="Frontend app version") + + +class FeedbackOut(BaseModel): + """Result of forwarding feedback to JOKE.""" + + model_config = ConfigDict( + extra="forbid", + json_schema_extra={"examples": [{"ok": True}]}, + ) + + ok: bool = Field(description="Whether the feedback was accepted and forwarded") diff --git a/src/rasenmaeher_api/web/api/feedback/views.py b/src/rasenmaeher_api/web/api/feedback/views.py new file mode 100644 index 0000000..fe7c125 --- /dev/null +++ b/src/rasenmaeher_api/web/api/feedback/views.py @@ -0,0 +1,95 @@ +"""Feedback API views. + +Proxies the app's feedback dialog to JOKE so the ingest URL and secret stay +server-side and are never shipped in the frontend bundle. +""" + +import logging +from uuid import uuid4 + +import aiohttp +from fastapi import APIRouter, Depends, HTTPException, Request + +from ....db.people import Person +from ....rmsettings import RMSettings +from ..middleware.user import ValidUser +from ..utils.auditcontext import build_audit_extra, get_audit_request_context +from .schema import FeedbackIn, FeedbackOut + +router = APIRouter() +LOGGER = logging.getLogger(__name__) + + +@router.post("") +async def submit_feedback( + feedback: FeedbackIn, + request: Request, + person: Person | None = Depends(ValidUser(auto_error=False)), +) -> FeedbackOut: + """Forward a feedback submission to JOKE.""" + conf = RMSettings.singleton() + if not conf.joke_ingest_url or not conf.joke_ingest_key: + LOGGER.error("JOKE ingest is not configured (RM_JOKE_INGEST_URL / RM_JOKE_INGEST_KEY missing)") + raise HTTPException(status_code=503, detail="Feedback submission is not configured") + + source_ip = get_audit_request_context(request).get("source.ip", "unknown") + callsign = person.callsign if person else None + + # JOKE's embed-form ingest only reliably renders `message` as visible content, so the + # metadata that matters for triage (version, platform) is folded into the + # body text itself rather than relying on it being surfaced from the extra JSON keys. + metadata_lines = [ + f"{label}: {value}" + for label, value in ( + ("role", feedback.role), + ("os", feedback.os), + ("version", feedback.version), + ) + if value + ] + message = feedback.comments + if metadata_lines: + message = f"{feedback.comments}\n\n" + "\n".join(metadata_lines) + + payload = { + "_id": str(uuid4()), + "subject": f"Deploy App version {feedback.version} feedback: {feedback.rating}", + "message": message, + "name": callsign, + "rating": feedback.rating, + "role": feedback.role, + "os": feedback.os, + "version": feedback.version, + } + + try: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=conf.joke_timeout)) as session: + headers = { + "content-type": "application/json", + "x-joke-key": conf.joke_ingest_key, + "x-forwarded-for": source_ip, + } + async with session.post(conf.joke_ingest_url, json=payload, headers=headers) as response: + if response.status == 429: + LOGGER.warning("JOKE rate-limited feedback submission") + raise HTTPException(status_code=429, detail="Too many feedback submissions, try again shortly") + response.raise_for_status() + except aiohttp.ClientError as exc: + LOGGER.error( + "Failed to forward feedback to JOKE: %s", + exc, + extra=build_audit_extra(action="feedback.submit", outcome="failure", actor=callsign, request=request), + ) + raise HTTPException(status_code=502, detail="Could not submit feedback, please try again") from exc + except TimeoutError as exc: + LOGGER.error( + "Timed out forwarding feedback to JOKE", + extra=build_audit_extra(action="feedback.submit", outcome="failure", actor=callsign, request=request), + ) + raise HTTPException(status_code=502, detail="Could not submit feedback, please try again") from exc + + LOGGER.audit( # type: ignore[attr-defined] + "Feedback submitted", + extra=build_audit_extra(action="feedback.submit", outcome="success", actor=callsign, request=request), + ) + return FeedbackOut(ok=True) diff --git a/src/rasenmaeher_api/web/api/router.py b/src/rasenmaeher_api/web/api/router.py index 6a02ac0..f1014df 100644 --- a/src/rasenmaeher_api/web/api/router.py +++ b/src/rasenmaeher_api/web/api/router.py @@ -8,6 +8,7 @@ descriptions, enduserpfx, enrollment, + feedback, firstuser, healthcheck, instructions, @@ -32,6 +33,7 @@ api_router.include_router(people.router, prefix="/people", tags=["people"]) api_router.include_router(descriptions.router, prefix="/descriptions", tags=["descriptions"]) api_router.include_router(internal.router, prefix="/internal", tags=["internal"]) +api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) api_router_v2 = APIRouter() api_router_v2.include_router(descriptions.router_v2, prefix="/descriptions", tags=["descriptions"]) diff --git a/tests/test_rasenmaeher_api.py b/tests/test_rasenmaeher_api.py index a3b2c0b..9ca03b1 100644 --- a/tests/test_rasenmaeher_api.py +++ b/tests/test_rasenmaeher_api.py @@ -15,7 +15,7 @@ def test_version() -> None: """Make sure version matches expected""" - assert __version__ == "1.18.2+260805" + assert __version__ == "1.19.0+260729" @pytest.mark.asyncio(loop_scope="session") diff --git a/uv.lock b/uv.lock index 40d1d61..9952a5b 100644 --- a/uv.lock +++ b/uv.lock @@ -1965,7 +1965,7 @@ wheels = [ [[package]] name = "rasenmaeher-api" -version = "1.18.2+260805" +version = "1.19.0+260729" source = { editable = "." } dependencies = [ { name = "aiodns" },