Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bumpversion.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "1.18.2+260805"
current_version = "1.19.0+260729"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)\\+(?P<build>\\d+)"
serialize = ["{major}.{minor}.{patch}+{build}"]
search = "{current_version}"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down
2 changes: 1 addition & 1 deletion src/rasenmaeher_api/__init__.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions src/rasenmaeher_api/rmsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/rasenmaeher_api/web/api/feedback/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Feedback API."""

from rasenmaeher_api.web.api.feedback.views import router

__all__ = ["router"]
39 changes: 39 additions & 0 deletions src/rasenmaeher_api/web/api/feedback/schema.py
Original file line number Diff line number Diff line change
@@ -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")
95 changes: 95 additions & 0 deletions src/rasenmaeher_api/web/api/feedback/views.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions src/rasenmaeher_api/web/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
descriptions,
enduserpfx,
enrollment,
feedback,
firstuser,
healthcheck,
instructions,
Expand All @@ -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"])
Expand Down
2 changes: 1 addition & 1 deletion tests/test_rasenmaeher_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading