Skip to content
Merged
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
322 changes: 291 additions & 31 deletions application/single_app/admin_settings_fields.py

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions application/single_app/admin_settings_nav.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,38 @@
yield group, tab


# Tabs that existed before the information architecture rework, mapped to where
# their content now lives. ``LEGACY_TAB_REDIRECTS`` in
# ``static/js/admin/admin_sidebar_nav.js`` resolves the same ids for the
# server-rendered page; this copy lets the server resolve a Latest Features
# shortcut for the V2 surface, and a functional test keeps the two identical.
LEGACY_TAB_REDIRECTS = {
"governance": "feature-governance",
"scale": "redis-caching",
"general": "branding",
"safety": "access-roles",

Check warning on line 650 in application/single_app/admin_settings_nav.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
"security": "secrets",
"workspaces": "workspace-types",
"search-extract": "web-research",
"ai-models": "model-endpoints",
"data-management": "backup",
}


def resolve_admin_tab_id(tab_id):
"""Return the live tab id for a current or pre-rework tab id.

A leading ``#`` is accepted because Latest Features shortcuts store their
target as a fragment. Returns None when the id names no tab, directly or
through ``LEGACY_TAB_REDIRECTS``.
"""
candidate = str(tab_id or "").strip().lstrip("#")
if not candidate:
return None
candidate = LEGACY_TAB_REDIRECTS.get(candidate, candidate)
return candidate if candidate in get_tab_ids() else None


def get_tab_ids():
"""Return every tab id in navigation order."""
return [tab["id"] for _, tab in iter_tabs()]
Expand Down
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.275"
VERSION = "0.261.276"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
235 changes: 235 additions & 0 deletions application/single_app/functions_support_latest_features.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
# functions_support_latest_features.py
"""The Latest Features catalogues, shaped for the V2 admin surface.

``support_menu_config`` holds two release catalogues: one written for end users
and shown from the Support menu, and one written for administrators and shown on
the Admin Latest Features tab. The server-rendered page turns both into markup
with Jinja, resolving each shortcut with ``url_for`` and each screenshot with
``url_for('static', ...)``. The V2 surface can do neither, so this module does
that resolution once, on the server, and returns plain data.

It imports no Flask and no Azure client, so it can be exercised directly: the
route passes in the two resolvers it needs.

``admin``
Administrator release groups. Each shortcut that targets an admin tab is
resolved to a live tab id, following ``LEGACY_TAB_REDIRECTS``, so the V2 page
can jump to the matching card, or fall back to the server-rendered tab when V2
does not draw it.

``user``
End-user release groups as users read them, with each announcement's default
visibility. Shortcuts keep their ``requires_settings`` so the V2 preview can
show or hide them as the draft changes; the documentation guide buttons
depend on a switch in the same section.
"""

import re

from admin_settings_nav import resolve_admin_tab_id
from support_menu_config import (
get_admin_latest_feature_release_groups_for_settings,
get_default_support_latest_features_visibility,
get_support_latest_feature_release_groups_for_preview,
)


ACTION_KIND_ADMIN = "admin"
ACTION_KIND_PAGE = "page"
ACTION_KIND_EXTERNAL = "external"

# Section ids are element ids on both admin surfaces. Anything else is dropped
# rather than passed to the browser as a jump target.
SECTION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,120}$")

# Settings keys an action may depend on. They are compared against the draft in
# the browser, so only plain keys are passed through.
SETTING_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_]{1,120}$")


def _text(value):
"""Return a trimmed string for any catalogue value."""
return str(value if value is not None else "").strip()


def _is_same_origin_path(href):
"""Whether a destination is a path on this application."""
return href.startswith("/") and not href.startswith("//") and "\\" not in href


def _is_http_url(href):
"""Whether a destination is an absolute http(s) address."""
return href.lower().startswith(("https://", "http://"))


def _serialize_image(image, resolve_static_url):
"""Return one screenshot with a browser-ready URL, or None when it has no path."""
path = _text(image.get("path"))
if not path:
return None
url = _text(resolve_static_url(path))
if not _is_same_origin_path(url):
return None
return {
"url": url,
"alt": _text(image.get("alt")),
"title": _text(image.get("title")),
"caption": _text(image.get("caption")),
"label": _text(image.get("label")),
}


def _resolve_page_href(action, resolve_endpoint_url):
"""Return the destination of a non-admin shortcut, or '' when it has none."""
href = _text(action.get("href"))
if href:
return href

endpoint = _text(action.get("endpoint"))
if not endpoint:
return ""
href = _text(resolve_endpoint_url(endpoint))
fragment = _text(action.get("fragment"))
if href and fragment:
href = f"{href}#{fragment}"
return href


def _serialize_action(action, resolve_endpoint_url):
"""Return one shortcut as the V2 page needs it, or None when it cannot be followed.

``kind`` says how the browser follows it:

``admin``
A tab on the Admin Settings page, optionally narrowed to a section. ``href``
is the server-rendered tab, used when V2 does not draw the target itself.
``page``
Another page of this application.
``external``
An absolute http(s) address, opened in a new tab.
"""
label = _text(action.get("label"))
if not label:
return None

serialized = {
"label": label,
"description": _text(action.get("description")),
"icon": _text(action.get("icon")),
"requires_settings": [
key
for key in (_text(item) for item in action.get("requires_settings") or [])
if SETTING_KEY_PATTERN.match(key)
],
}

admin_tab = resolve_admin_tab_id(action.get("admin_tab"))
if admin_tab:
section = _text(action.get("admin_section"))
serialized.update(
{
"kind": ACTION_KIND_ADMIN,
"admin_tab": admin_tab,
"admin_section": section if SECTION_ID_PATTERN.match(section) else None,
"href": f"/admin/settings#{admin_tab}",
}
)
return serialized

href = _resolve_page_href(action, resolve_endpoint_url)
if _is_http_url(href):
serialized.update({"kind": ACTION_KIND_EXTERNAL, "href": href})
return serialized
if _is_same_origin_path(href):
serialized.update({"kind": ACTION_KIND_PAGE, "href": href})
return serialized
return None


def _serialize_feature(feature, resolve_endpoint_url, resolve_static_url, default_visible=None):
"""Return one announcement with its screenshots and shortcuts resolved."""
images = [
image
for image in (
_serialize_image(item, resolve_static_url)
for item in feature.get("images") or []
if isinstance(item, dict)
)
if image
]
actions = [
action
for action in (
_serialize_action(item, resolve_endpoint_url)
for item in feature.get("actions") or []
if isinstance(item, dict)
)
if action
]
serialized = {
"id": _text(feature.get("id")),
"title": _text(feature.get("title")),
"icon": _text(feature.get("icon")),
"summary": _text(feature.get("summary")),
"details": _text(feature.get("details")),
"why": _text(feature.get("why")),
"guidance": [_text(item) for item in feature.get("guidance") or [] if _text(item)],
"images": images,
"actions": actions,
}
if default_visible is not None:
serialized["default_visible"] = bool(default_visible)
return serialized


def _serialize_group(group, resolve_endpoint_url, resolve_static_url, visibility_defaults=None):
"""Return one release group and its announcements."""
features = []
for feature in group.get("features") or []:
if not isinstance(feature, dict) or not _text(feature.get("id")):
continue
default_visible = None
if visibility_defaults is not None:
default_visible = visibility_defaults.get(feature["id"], True)
features.append(
_serialize_feature(
feature,
resolve_endpoint_url,
resolve_static_url,
default_visible=default_visible,
)
)
return {
"id": _text(group.get("id")),
"label": _text(group.get("label")),
"description": _text(group.get("description")),
"release_version": _text(group.get("release_version")),
"default_expanded": bool(group.get("default_expanded")),
"features": features,
}


def build_latest_features_payload(settings, *, resolve_endpoint_url, resolve_static_url, version):
"""Return both Latest Features catalogues for the V2 admin surface.

``resolve_endpoint_url`` turns a Flask endpoint name into a path and returns
'' when the endpoint does not exist; ``resolve_static_url`` turns a static file
path into a URL. Both are supplied by the route so this stays free of Flask.
"""
visibility_defaults = get_default_support_latest_features_visibility()
return {
"version": _text(version),
"admin": [
_serialize_group(group, resolve_endpoint_url, resolve_static_url)
for group in get_admin_latest_feature_release_groups_for_settings(settings)
],
"user": [
_serialize_group(
group,
resolve_endpoint_url,
resolve_static_url,
visibility_defaults=visibility_defaults,
)
for group in get_support_latest_feature_release_groups_for_preview(settings)
],
}
49 changes: 48 additions & 1 deletion application/single_app/route_backend_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@
import logging
import uuid

from flask import current_app, jsonify, request, session
from flask import current_app, jsonify, request, session, url_for
from werkzeug.exceptions import BadRequest, RequestEntityTooLarge
from werkzeug.routing import BuildError

from admin_app_roles import get_app_role_requirements
from admin_settings_fields import (
Expand Down Expand Up @@ -172,6 +173,7 @@
is_url_access_enabled_for_user,
)
from functions_workspace_sections import build_workspace_section_availability
from functions_support_latest_features import build_latest_features_payload
from functions_workspace_context import (
WorkspaceContextError,
build_group_workspace_context,
Expand Down Expand Up @@ -1669,6 +1671,51 @@ def v2_admin_get_update_status():
)
return jsonify({"error": "Unable to check for application updates."}), 500

def _latest_feature_endpoint_url(endpoint):
"""Resolve a catalogue shortcut's Flask endpoint, or '' when it is not registered."""
try:
return url_for(endpoint)
except BuildError:
log_event(
"[V2_ADMIN_SETTINGS] A Latest Features shortcut names an unknown endpoint.",
extra={"endpoint": endpoint},
level=logging.WARNING,
)
return ""

@bp.route("/api/v2/admin/latest-features", methods=["GET"])
@swagger_route(security=get_auth_security())
@login_required
@admin_required
def v2_admin_get_latest_features():
"""Return the admin and user-facing Latest Features catalogues.

The server-rendered Help tabs build these cards with Jinja, resolving each
shortcut through ``url_for`` and each screenshot through the static route.
The V2 surface cannot, so the resolved catalogues are served here.

Kept apart from the settings GET because the catalogues are long and only the
Help group draws them: the SPA requests this beside the settings when its
navigation includes those cards, so it never holds up the rest of the page.
The payload holds release copy and links only, no settings values.
"""
try:
payload = build_latest_features_payload(
get_settings(),
resolve_endpoint_url=_latest_feature_endpoint_url,
resolve_static_url=lambda path: url_for("static", filename=path),
version=VERSION,
)
return jsonify(payload), 200
except Exception as exc:
log_event(
"[V2_ADMIN_SETTINGS] Failed to load the Latest Features catalogues.",
extra={"error_type": type(exc).__name__},
level=logging.ERROR,
exceptionTraceback=True,
)
return jsonify({"error": "Failed to load Latest Features."}), 500

@bp.route("/api/v2/admin/settings", methods=["PATCH"])
@swagger_route(security=get_auth_security())
@login_required
Expand Down
Loading
Loading