From 81a5a62c2467ea262e07ac8569782f0bdc159b68 Mon Sep 17 00:00:00 2001 From: mazkdevf <79049205+mazkdevf@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:32:56 +0300 Subject: [PATCH 1/3] feat(api): replace Formspark frontend calls with JOKE feedback proxy --- src/rasenmaeher_api/rmsettings.py | 5 + .../web/api/feedback/__init__.py | 5 + .../web/api/feedback/schema.py | 41 ++++++++ src/rasenmaeher_api/web/api/feedback/views.py | 96 +++++++++++++++++++ src/rasenmaeher_api/web/api/router.py | 2 + 5 files changed, 149 insertions(+) create mode 100644 src/rasenmaeher_api/web/api/feedback/__init__.py create mode 100644 src/rasenmaeher_api/web/api/feedback/schema.py create mode 100644 src/rasenmaeher_api/web/api/feedback/views.py diff --git a/src/rasenmaeher_api/rmsettings.py b/src/rasenmaeher_api/rmsettings.py index f300f93..7ff31af 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: Optional[str] = None + joke_ingest_key: Optional[str] = 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..95b4583 --- /dev/null +++ b/src/rasenmaeher_api/web/api/feedback/schema.py @@ -0,0 +1,41 @@ +"""Schema for feedback.""" + +from typing import Optional + +from pydantic import BaseModel, Field, ConfigDict + + +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: Optional[str] = Field(default=None, description="User type/role reported by the client") + os: Optional[str] = Field(default=None, description="Client OS") + rating: str = Field(description="User's rating selection") + comments: str = Field(description="Free-form feedback text") + version: Optional[str] = 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..da76a47 --- /dev/null +++ b/src/rasenmaeher_api/web/api/feedback/views.py @@ -0,0 +1,96 @@ +"""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. +""" + +from typing import Optional +from uuid import uuid4 +import logging + +import aiohttp +from fastapi import APIRouter, Depends, HTTPException, Request + +from .schema import FeedbackIn, FeedbackOut +from ..middleware.user import ValidUser +from ..utils.auditcontext import build_audit_extra, get_audit_request_context +from ....db.people import Person +from ....rmsettings import RMSettings + +router = APIRouter() +LOGGER = logging.getLogger(__name__) + + +@router.post("") +async def submit_feedback( + feedback: FeedbackIn, + request: Request, + person: Optional[Person] = 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 ae30e09..60900e5 100644 --- a/src/rasenmaeher_api/web/api/router.py +++ b/src/rasenmaeher_api/web/api/router.py @@ -16,6 +16,7 @@ people, descriptions, internal, + feedback, ) api_router = APIRouter() @@ -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"]) From 4e778e552a66815938a1eef271994249fda5206b Mon Sep 17 00:00:00 2001 From: mazkdevf <79049205+mazkdevf@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:39:49 +0300 Subject: [PATCH 2/3] chore: bump version to 1.19.0+260729 --- .bumpversion.toml | 2 +- pyproject.toml | 2 +- src/rasenmaeher_api/__init__.py | 2 +- tests/test_rasenmaeher_api.py | 2 +- uv.lock | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 91704d4..0bb2ff8 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "1.18.1+260728" +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 5013a78..0372bba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rasenmaeher_api" -version = "1.18.1+260728" +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 8cbb35e..4708f21 100644 --- a/src/rasenmaeher_api/__init__.py +++ b/src/rasenmaeher_api/__init__.py @@ -1,3 +1,3 @@ """python-rasenmaeher-api""" -__version__ = "1.18.1+260728" # 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/tests/test_rasenmaeher_api.py b/tests/test_rasenmaeher_api.py index 19c9d2b..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.1+260728" + assert __version__ == "1.19.0+260729" @pytest.mark.asyncio(loop_scope="session") diff --git a/uv.lock b/uv.lock index eee20a4..9952a5b 100644 --- a/uv.lock +++ b/uv.lock @@ -1965,7 +1965,7 @@ wheels = [ [[package]] name = "rasenmaeher-api" -version = "1.18.1+260728" +version = "1.19.0+260729" source = { editable = "." } dependencies = [ { name = "aiodns" }, From 213f633966e65f34554822f5b3e4dbd5e5c60edb Mon Sep 17 00:00:00 2001 From: mazkdevf <79049205+mazkdevf@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:44:21 +0300 Subject: [PATCH 3/3] fix: apply ruff auto-fixes for feedback endpoint --- src/rasenmaeher_api/rmsettings.py | 4 ++-- src/rasenmaeher_api/web/api/feedback/schema.py | 12 +++++------- src/rasenmaeher_api/web/api/feedback/views.py | 11 +++++------ src/rasenmaeher_api/web/api/router.py | 2 +- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/rasenmaeher_api/rmsettings.py b/src/rasenmaeher_api/rmsettings.py index 3fd5d1e..feeb13f 100644 --- a/src/rasenmaeher_api/rmsettings.py +++ b/src/rasenmaeher_api/rmsettings.py @@ -143,8 +143,8 @@ def log_level_int(self) -> int: 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: Optional[str] = None - joke_ingest_key: Optional[str] = None + joke_ingest_url: str | None = None + joke_ingest_key: str | None = None joke_timeout: float = 5.0 # Enrollment code generation related diff --git a/src/rasenmaeher_api/web/api/feedback/schema.py b/src/rasenmaeher_api/web/api/feedback/schema.py index 95b4583..a0af856 100644 --- a/src/rasenmaeher_api/web/api/feedback/schema.py +++ b/src/rasenmaeher_api/web/api/feedback/schema.py @@ -1,8 +1,6 @@ """Schema for feedback.""" -from typing import Optional - -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class FeedbackIn(BaseModel): @@ -17,17 +15,17 @@ class FeedbackIn(BaseModel): "os": "macos", "rating": "good", "comments": "Works great, minor UI nit on mobile.", - "version": "1.0.0" + "version": "1.0.0", } ] }, ) - role: Optional[str] = Field(default=None, description="User type/role reported by the client") - os: Optional[str] = Field(default=None, description="Client OS") + 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: Optional[str] = Field(default=None, description="Frontend app version") + version: str | None = Field(default=None, description="Frontend app version") class FeedbackOut(BaseModel): diff --git a/src/rasenmaeher_api/web/api/feedback/views.py b/src/rasenmaeher_api/web/api/feedback/views.py index da76a47..fe7c125 100644 --- a/src/rasenmaeher_api/web/api/feedback/views.py +++ b/src/rasenmaeher_api/web/api/feedback/views.py @@ -4,18 +4,17 @@ server-side and are never shipped in the frontend bundle. """ -from typing import Optional -from uuid import uuid4 import logging +from uuid import uuid4 import aiohttp from fastapi import APIRouter, Depends, HTTPException, Request -from .schema import FeedbackIn, FeedbackOut -from ..middleware.user import ValidUser -from ..utils.auditcontext import build_audit_extra, get_audit_request_context 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__) @@ -25,7 +24,7 @@ async def submit_feedback( feedback: FeedbackIn, request: Request, - person: Optional[Person] = Depends(ValidUser(auto_error=False)), + person: Person | None = Depends(ValidUser(auto_error=False)), ) -> FeedbackOut: """Forward a feedback submission to JOKE.""" conf = RMSettings.singleton() diff --git a/src/rasenmaeher_api/web/api/router.py b/src/rasenmaeher_api/web/api/router.py index 262dd10..f1014df 100644 --- a/src/rasenmaeher_api/web/api/router.py +++ b/src/rasenmaeher_api/web/api/router.py @@ -8,10 +8,10 @@ descriptions, enduserpfx, enrollment, + feedback, firstuser, healthcheck, instructions, - feedback, internal, people, product,