Add member profile questions, admin tooling, legal pages, and fast tests

- Add configurable profile questions with conditional visibility, admin-only fields, user answers, and seeded onboarding/volunteer questions
  - Add admin UI for managing profile questions and member profile answers
  - Add volunteer level/profile data support across backend schemas, models, API, and migration
  - Update dashboard/profile UI, super admin menu, membership service types, and related styling
  - Add privacy policy, terms of service, cookie notice, and footer links
  - Add frontend Vitest coverage for profile question logic
  - Add backend pytest coverage for profile answer normalization and validation
  - Update restart.sh to build, run frontend/backend unit tests, and restart only after tests pass
  - Refresh README, quickstart, project structure, instructions, and Square docs to match current app features
    - Protect feature flag reload behind super-admin access
    - Restrict admin-triggered password resets so admins can only reset member accounts
    - Replace email template HTML preview rendering with escaped text preview
    - Update docs for feature flag reload access, password reset scope, and email template preview safety

    -- test user questions are also made by AI and not very useful. but i didn't know what to put there so its good enough for a test
This commit is contained in:
2026-05-04 22:05:58 +01:00
parent 74a4e3ede8
commit 632e66e21d
34 changed files with 3932 additions and 749 deletions
+7
View File
@@ -0,0 +1,7 @@
import sys
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[2]
if str(APP_ROOT) not in sys.path:
sys.path.insert(0, str(APP_ROOT))
@@ -0,0 +1,113 @@
from datetime import date, datetime
import pytest
from fastapi import HTTPException
from app.api.v1.users import (
_deserialize_answer_value,
_normalize_answer_value,
_normalize_volunteer_level,
_parse_options,
_serialize_options,
)
from app.models.models import ProfileQuestion
from app.schemas import QuestionOption
def make_question(input_type: str, options_json: str | None = None) -> ProfileQuestion:
return ProfileQuestion(
key=f"{input_type}_question",
label=f"{input_type.title()} Question",
input_type=input_type,
options_json=options_json,
)
def test_option_parsing_and_serialization_filters_invalid_items() -> None:
assert _parse_options('[{"label":" Yes ","value":" yes "}, {"label":"","value":"no"}, "bad"]') == [
{"label": "Yes", "value": "yes"}
]
assert _parse_options("not-json") == []
serialized = _serialize_options([QuestionOption(label="Private Pilot", value="ppl")])
assert _parse_options(serialized) == [{"label": "Private Pilot", "value": "ppl"}]
@pytest.mark.parametrize(
("value", "expected"),
[
(True, "true"),
("yes", "true"),
("0", "false"),
(False, "false"),
],
)
def test_boolean_answers_are_normalized(value: object, expected: str) -> None:
assert _normalize_answer_value(make_question("boolean"), value) == expected
def test_invalid_boolean_answer_raises_400() -> None:
with pytest.raises(HTTPException) as exc:
_normalize_answer_value(make_question("boolean"), "maybe")
assert exc.value.status_code == 400
@pytest.mark.parametrize(
("value", "expected"),
[
(3, "3"),
("3.50", "3.5"),
(date(2026, 5, 4), "2026-05-04"),
(datetime(2026, 5, 4, 12, 30), "2026-05-04"),
],
)
def test_number_and_date_answers_are_normalized(value: object, expected: str) -> None:
input_type = "date" if isinstance(value, (date, datetime)) else "number"
assert _normalize_answer_value(make_question(input_type), value) == expected
def test_select_answers_must_match_configured_options() -> None:
question = make_question("select", '[{"label":"Private Pilot","value":"ppl"}]')
assert _normalize_answer_value(question, "ppl") == "ppl"
with pytest.raises(HTTPException) as exc:
_normalize_answer_value(question, "cpl")
assert exc.value.status_code == 400
def test_empty_answers_clear_existing_values() -> None:
assert _normalize_answer_value(make_question("text"), "") is None
assert _normalize_answer_value(make_question("text"), None) is None
def test_answer_deserialization_restores_frontend_types() -> None:
assert _deserialize_answer_value(make_question("boolean"), "true") is True
assert _deserialize_answer_value(make_question("boolean"), "false") is False
assert _deserialize_answer_value(make_question("number"), "10") == 10
assert _deserialize_answer_value(make_question("number"), "10.5") == 10.5
assert _deserialize_answer_value(make_question("text"), "SASA") == "SASA"
@pytest.mark.parametrize(
("value", "expected"),
[
("yes", "yes"),
("true", "yes"),
("0", "no"),
("", None),
(None, None),
],
)
def test_volunteer_level_accepts_boolean_like_values(value: str | None, expected: str | None) -> None:
assert _normalize_volunteer_level(value) == expected
def test_invalid_volunteer_level_raises_400() -> None:
with pytest.raises(HTTPException) as exc:
_normalize_volunteer_level("sometimes")
assert exc.value.status_code == 400